diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 63a4329b020..d2368562296 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -3,7 +3,7 @@ # This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read # the default host, through defaultCreationHost. Everything else routes by the owner. # Prune after removing reads: pnpm check:focus-read-ratchet --prune -1 src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx +3 src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx # existing watch selector now visible through useCallback; includes its import-path occurrence 2 src/renderer/src/app-shell/use-app-shell-services.ts 2 src/renderer/src/App.tsx 1 src/renderer/src/component-fixtures/component-fixture-explorer.tsx # dev-only fixture page seeds default settings @@ -181,7 +181,7 @@ 1 src/renderer/src/components/terminal-pane/controller/use-terminal-pane-lifecycle.ts 1 src/renderer/src/components/terminal-pane/mounting/use-terminal-pane-mount-lifecycle.ts 1 src/renderer/src/components/terminal/terminal-tab-actions.ts -4 src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts +2 src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts 2 src/renderer/src/components/terminal/terminal-tab-create.ts 2 src/renderer/src/components/use-new-linear-issue-projects.ts 4 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index 7e8c517c7af..680e9b80be2 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -50,27 +50,27 @@ 1 src/main/persistence/tracking-repos/project-group-operations.ts 4 src/main/persistence/tracking-repos/repo-worktree-pruning.ts 3 src/main/persistence/tracking-repos/worktree-identity-migration.ts +2 src/main/pty/scrollback/terminal-scrollback-snapshots.ts 1 src/main/runtime/browser/client-pages/client-hosted-browser-page-persistence.ts 1 src/main/runtime/client/runtime-client-state.ts 1 src/main/runtime/mobile-session-editor-projection.ts -3 src/main/runtime/terminal/surface/mobile-session-layout-projection.ts 4 src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts -2 src/main/runtime/terminal/launch/runtime-pane-admission.ts -7 src/main/runtime/terminal/adoption/runtime-terminal-orphan-session-adoption.ts -1 src/main/runtime/workspaces/session-hosts/runtime-workspace-session-controller.ts 2 src/main/runtime/session-graph/session-graph-promotion.ts 2 src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts 2 src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts 2 src/main/runtime/session-tabs/headless-tab-group-moves.ts 6 src/main/runtime/session-tabs/headless-tab-properties.ts +2 src/main/runtime/session-tabs/headless-terminal-tab-titles.ts 1 src/main/runtime/session-tabs/structured-session-tab-close.ts 1 src/main/runtime/session-tabs/terminal-surface-retirements.ts 2 src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts +7 src/main/runtime/terminal/adoption/runtime-terminal-orphan-session-adoption.ts 1 src/main/runtime/terminal/driver/terminal-driver-resources.ts -3 src/main/runtime/terminal/stop/worktree-terminal-stop.ts 4 src/main/runtime/terminal/identity/workspace-session-worktree-id.ts +2 src/main/runtime/terminal/launch/runtime-pane-admission.ts +3 src/main/runtime/terminal/stop/worktree-terminal-stop.ts +3 src/main/runtime/terminal/surface/mobile-session-layout-projection.ts 1 src/main/runtime/workspaces/session-hosts/get-runtime-id.ts -2 src/main/runtime/session-tabs/headless-terminal-tab-titles.ts +1 src/main/runtime/workspaces/session-hosts/runtime-workspace-session-controller.ts 1 src/main/ssh/orcad-migration-source-retention.ts 1 src/main/ssh/targets/ssh-target-id-migration.ts -2 src/main/pty/scrollback/terminal-scrollback-snapshots.ts diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index eaf923cb0fb..24c5fa88e6c 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -27,6 +27,7 @@ 7 src/renderer/src/lib/workspace/persistence/workspace-session-hydration-keys.ts 25 src/renderer/src/lib/workspace/persistence/workspace-session-patch.ts 3 src/renderer/src/lib/workspace/persistence/workspace-session-staged-browser-tabs.ts +1 src/renderer/src/lib/workspace/persistence/workspace-session-unified-tabs.ts 17 src/renderer/src/lib/workspace/persistence/workspace-session.ts 3 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts 3 src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -52,21 +53,21 @@ 5 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts 2 src/renderer/src/store/slices/browser/browser-page-state-actions.ts 1 src/renderer/src/store/slices/browser/browser-tab-actions.ts -5 src/renderer/src/store/slices/browser/browser-tab-open-view.ts # extracted from browser-tab-actions.ts 4 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts +5 src/renderer/src/store/slices/browser/browser-tab-open-view.ts # extracted from browser-tab-actions.ts 2 src/renderer/src/store/slices/browser/create-browser-slice.ts 9 src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts -7 src/renderer/src/store/slices/editor/actions/close-file-action.ts +6 src/renderer/src/store/slices/editor/actions/close-file-action.ts 1 src/renderer/src/store/slices/editor/actions/git-status-actions.ts 4 src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts 3 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts -27 src/renderer/src/store/slices/editor/actions/open-combined-diff.ts -13 src/renderer/src/store/slices/editor/actions/open-conflict-file.ts -14 src/renderer/src/store/slices/editor/actions/open-conflict-review.ts -4 src/renderer/src/store/slices/editor/actions/open-file-apply.ts +3 src/renderer/src/store/slices/editor/actions/open-combined-diff.ts +1 src/renderer/src/store/slices/editor/actions/open-conflict-file.ts +6 src/renderer/src/store/slices/editor/actions/open-conflict-review.ts +3 src/renderer/src/store/slices/editor/actions/open-file-apply.ts 3 src/renderer/src/store/slices/editor/actions/open-file-mutations.ts 7 src/renderer/src/store/slices/editor/actions/open-file-state.ts -26 src/renderer/src/store/slices/editor/actions/open-history-diff.ts +2 src/renderer/src/store/slices/editor/actions/open-history-diff.ts 1 src/renderer/src/store/slices/editor/actions/open-unstaged-diff.ts 10 src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts 2 src/renderer/src/store/slices/editor/actions/rekey-open-files-action.ts @@ -76,14 +77,17 @@ 2 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts 1 src/renderer/src/store/slices/editor/file-ids/open-file-path-rekey.ts 4 src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts +1 src/renderer/src/store/slices/editor/tabs/workspace-editor-files.ts 2 src/renderer/src/store/slices/tab-group-reference-repair.ts 4 src/renderer/src/store/slices/tab-group-state.ts 1 src/renderer/src/store/slices/tabs-hydration.ts 4 src/renderer/src/store/slices/tabs/create-tabs-slice.ts -17 src/renderer/src/store/slices/tabs/tabs-close-actions.ts +12 src/renderer/src/store/slices/tabs/rename-worktree-layout.ts +9 src/renderer/src/store/slices/tabs/tabs-close-actions.ts 14 src/renderer/src/store/slices/tabs/tabs-create-actions.ts 23 src/renderer/src/store/slices/tabs/tabs-drag-preview-actions.ts 11 src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +8 src/renderer/src/store/slices/tabs/tabs-empty-workspace.ts 4 src/renderer/src/store/slices/tabs/tabs-focus-actions.ts 12 src/renderer/src/store/slices/tabs/tabs-group-actions.ts 1 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts @@ -93,7 +97,7 @@ 4 src/renderer/src/store/slices/tabs/tabs-session-actions.ts 1 src/renderer/src/store/slices/tabs/tabs-split-ratio-actions.ts 5 src/renderer/src/store/slices/tabs/tabs-surface.ts -33 src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts +24 src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts 6 src/renderer/src/store/slices/terminal-orphan-helpers.ts 4 src/renderer/src/store/slices/terminal-tab-title-batch.ts 6 src/renderer/src/store/slices/terminals.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 9a7dac2c780..e55f2350490 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.web.json", "exclude": [ + "../src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts", "../src/renderer/src/components/native-chat/NativeChatAcpQuestionCard.test.tsx", "../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx", "../src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts", diff --git a/config/tsconfig.test.pane-launch.json b/config/tsconfig.test.pane-launch.json index dba6326f43f..a012d216ab0 100644 --- a/config/tsconfig.test.pane-launch.json +++ b/config/tsconfig.test.pane-launch.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.web.json", "include": [ + "../src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts", "../src/renderer/src/components/native-chat/NativeChatAcpQuestionCard.test.tsx", "../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts", "../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx", diff --git a/config/tsconfig.web.json b/config/tsconfig.web.json index da8a491aa88..91906715e63 100644 --- a/config/tsconfig.web.json +++ b/config/tsconfig.web.json @@ -11,6 +11,7 @@ "../src/shared/**/*" ], "exclude": [ + "../src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts", "../src/renderer/src/components/native-chat/NativeChatAcpQuestionCard.test.tsx", "../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx", "../src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts", diff --git a/skill-guides/orca-cli.md b/skill-guides/orca-cli.md index 0bed8c1d350..dd3015d297d 100644 --- a/skill-guides/orca-cli.md +++ b/skill-guides/orca-cli.md @@ -120,7 +120,7 @@ ORCA worktree set --worktree active --pr null --gitlab-mr null --json ORCA worktree rm --worktree id::: --force --json ``` -`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; folder workspaces have no Git worktrees or source control. With `--environment`, pass an absolute path on that server. +`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; worktrees attached to a folder workspace appear in its Source Control panel, one section each. With `--environment`, pass an absolute path on that server. Ports: `ORCA ports --json` lists the listening ports Orca tied to the current workspace (`--worktree ` for another one, `--all` for every listener on that workspace's host). Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, and `kind` (`workspace`, `container`, or `external`). Only `workspace` rows have `owner` (with `owner.confidence`: `cwd` or `command`) and, when the dev server printed one in an Orca terminal, `advertisedUrl`. Use this to find a dev server's port instead of running `lsof` yourself. If `unavailableReason` is set, the scan could not run on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. SSH workspaces opened directly from this app report `unavailableReason` for now; a workspace on a managed Orca server is scanned on that server. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 435664dcb3a..7861ce08fa9 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -348,7 +348,7 @@ const ORCA_CLI_MARKDOWN = [ "ORCA worktree rm --worktree id::: --force --json", "```", "", - "`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; folder workspaces have no Git worktrees or source control. With `--environment`, pass an absolute path on that server.", + "`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; worktrees attached to a folder workspace appear in its Source Control panel, one section each. With `--environment`, pass an absolute path on that server.", "", "Ports: `ORCA ports --json` lists the listening ports Orca tied to the current workspace (`--worktree ` for another one, `--all` for every listener on that workspace's host). Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, and `kind` (`workspace`, `container`, or `external`). Only `workspace` rows have `owner` (with `owner.confidence`: `cwd` or `command`) and, when the dev server printed one in an Orca terminal, `advertisedUrl`. Use this to find a dev server's port instead of running `lsof` yourself. If `unavailableReason` is set, the scan could not run on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. SSH workspaces opened directly from this app report `unavailableReason` for now; a workspace on a managed Orca server is scanned on that server.", "", diff --git a/src/cli/specs/repo.ts b/src/cli/specs/repo.ts index 70b3b64d0b8..6d974225dc3 100644 --- a/src/cli/specs/repo.ts +++ b/src/cli/specs/repo.ts @@ -14,7 +14,7 @@ export const REPO_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca repo add --path [--kind git|folder] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'path', 'kind'], notes: [ - 'Defaults to git. Use --kind folder for an ordinary directory, including a parent of separate Git repositories; folder workspaces have no Git worktrees or source control.', + 'Defaults to git. Use --kind folder for an ordinary directory, including a parent of separate Git repositories; worktrees attached to a folder workspace appear in its Source Control panel, one section each.', 'With --environment, --path must be an absolute path on that Orca server.' ], examples: ['orca repo add --path /abs/path/to/folder --kind folder --json'] diff --git a/src/main/agent-launch/trust/agent-workspace-trust-spawn.ts b/src/main/agent-launch/trust/agent-workspace-trust-spawn.ts index 1a214517faa..824620c7e98 100644 --- a/src/main/agent-launch/trust/agent-workspace-trust-spawn.ts +++ b/src/main/agent-launch/trust/agent-workspace-trust-spawn.ts @@ -12,6 +12,7 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { splitWorktreeId } from '../../../shared/worktree/id' import { shouldPreTrustAgentWorkspace, + resolveAgentWorkspaceTrustOwner, type AgentWorkspaceTrustDecision } from '../../../shared/agent-workspace-trust-decision' @@ -44,8 +45,10 @@ export function applyAgentWorkspaceTrustToSpawn( launchAgent: unknown /** The user chose "let agents ask" for this launch; it wins even if saving that answer failed. */ skipForThisLaunch?: boolean - /** Worktree or folder workspace id; its root is the folder the agent is trusted in. */ + /** Placement workspace id; its root may differ from the agent’s starting folder. */ worktreeId: string | undefined + /** Section root to pre-trust for this launch; never part of the terminal identity. */ + trustWorktreeId?: string /** The resolved folder the agent starts in; floating terminals have only this. */ cwd: string | undefined store: @@ -58,12 +61,13 @@ export function applyAgentWorkspaceTrustToSpawn( spawnOptions: AgentTrustSpawnFields } & AgentTrustLaunchContext ): Promise | null { + const trustWorkspaceId = resolveAgentWorkspaceTrustOwner(args) if ( !args.isFreshLaunch || args.skipForThisLaunch === true || !shouldPreTrustAgentWorkspace( args.settings?.agentWorkspaceTrustEnabled, - agentWorkspaceTrustDecisionForWorkspace(args.worktreeId, args.store) + agentWorkspaceTrustDecisionForWorkspace(trustWorkspaceId, args.store) ) ) { return null @@ -75,7 +79,7 @@ export function applyAgentWorkspaceTrustToSpawn( return null } const workspaceRoot = resolveTerminalWorkspacePath( - args.worktreeId, + trustWorkspaceId, (folderWorkspaceId) => args.store?.getFolderWorkspace(folderWorkspaceId)?.folderPath ) const workspacePath = AGENT_TRUST_KEYED_BY_START_FOLDER[preset] diff --git a/src/main/agent-launch/trust/agent-workspace-trust.test.ts b/src/main/agent-launch/trust/agent-workspace-trust.test.ts index a85c16e375c..b60ce51582d 100644 --- a/src/main/agent-launch/trust/agent-workspace-trust.test.ts +++ b/src/main/agent-launch/trust/agent-workspace-trust.test.ts @@ -1,6 +1,15 @@ -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type * as NodePath from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ClaudeFolderTrustFile from '../../claude/claude-folder-trust-file' @@ -17,6 +26,16 @@ const mocks = vi.hoisted(() => ({ claudeGrant: vi.fn() })) +// The host filesystem uses Windows path resolution for WSL UNC identities. +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolve: (...paths: string[]) => + paths[0]?.startsWith('\\\\wsl.') ? actual.win32.resolve(...paths) : actual.resolve(...paths) + } +}) + vi.mock('./agent-trust-presets', () => ({ markCodexProjectTrusted: mocks.codex, markCursorWorkspaceTrusted: mocks.cursor, @@ -35,6 +54,7 @@ vi.mock('../../claude/claude-folder-trust-file', async (importOriginal) => { }) import { applyAgentWorkspaceTrust, type AgentTrustLaunchContext } from './agent-workspace-trust' +import { applyAgentWorkspaceTrustToSpawn } from './agent-workspace-trust-spawn' import { clearWslHomeCache, rememberWslHome } from '../../wsl/discovery/wsl-home-cache' import { AGENT_TRUST_WRITE_DEADLINE_MS, @@ -124,6 +144,118 @@ describe('applyAgentWorkspaceTrust for Claude', () => { rmSync(root, { recursive: true, force: true }) }) + it('respects the section repo choice and preserves ordinary root trust for outside starts', async () => { + const workspaceRoot = join(root, 'folder') + const outsideCwd = join(root, 'attached-worktree') + mkdirSync(workspaceRoot) + mkdirSync(outsideCwd) + const config = join(root, '.claude.json') + writeFileSync(config, '{}') + const spawn = { + ...local, + env: { CLAUDE_CONFIG_DIR: root }, + launchAgent: 'claude', + worktreeId: 'folder:section-parent', + cwd: outsideCwd, + store: { getFolderWorkspace: () => ({ folderPath: workspaceRoot }) }, + isFreshLaunch: true, + settings: {}, + spawnOptions: {} + } + await applyAgentWorkspaceTrustToSpawn(spawn) + expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({ + projects: { [workspaceRoot]: { hasTrustDialogAccepted: true } } + }) + await applyAgentWorkspaceTrustToSpawn({ + ...spawn, + trustWorktreeId: `repo::${outsideCwd}`, + store: { + ...spawn.store, + getRepo: () => ({ agentWorkspaceTrust: 'agent-asks' }) + } + }) + expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({ + projects: { [workspaceRoot]: { hasTrustDialogAccepted: true } } + }) + await applyAgentWorkspaceTrustToSpawn({ + ...spawn, + trustWorktreeId: `repo::${outsideCwd}`, + store: { + ...spawn.store, + getRepo: () => ({ agentWorkspaceTrust: 'pre-trust' }) + } + }) + expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({ + projects: { + [workspaceRoot]: { hasTrustDialogAccepted: true }, + [outsideCwd]: { hasTrustDialogAccepted: true } + } + }) + }) + + it('trusts a section root through a contained junction without granting the placement root', async () => { + const workspaceRoot = join(root, 'folder') + const outside = join(root, 'attached-worktree') + mkdirSync(workspaceRoot) + mkdirSync(outside) + const linked = join(workspaceRoot, 'linked-child') + symlinkSync(outside, linked, 'junction') + const config = join(root, '.claude.json') + writeFileSync(config, '{}') + await applyAgentWorkspaceTrustToSpawn({ + ...local, + env: { CLAUDE_CONFIG_DIR: root }, + launchAgent: 'claude', + worktreeId: 'folder:section-parent', + trustWorktreeId: `repo::${linked}`, + cwd: linked, + store: { getFolderWorkspace: () => ({ folderPath: workspaceRoot }) }, + isFreshLaunch: true, + settings: {}, + spawnOptions: {} + }) + expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({ + projects: { + [linked]: { hasTrustDialogAccepted: true }, + [outside]: { hasTrustDialogAccepted: true } + } + }) + }) + + it('writes the mounted-drive section root in the prepared WSL Claude config', async () => { + const config = join(root, '.claude.json') + writeFileSync(config, '{}') + rememberWslHome('Ubuntu', '\\\\wsl.localhost\\Ubuntu\\home\\u') + try { + await applyAgentWorkspaceTrustToSpawn({ + ...local, + launchAgent: 'claude', + worktreeId: 'folder:section-parent', + trustWorktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\mnt\\c\\repos\\child', + cwd: '/mnt/c/repos/child', + store: { + getFolderWorkspace: () => ({ folderPath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\folder' }) + }, + claudeAuth: { + runtime: 'wsl', + configDir: root, + wslLinuxConfigDir: '/home/u/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/home/u/.claude' }, + provenance: 'test' + }, + wslDistro: 'Ubuntu', + isFreshLaunch: true, + settings: {}, + spawnOptions: {} + }) + expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({ + projects: { '/mnt/c/repos/child': { hasTrustDialogAccepted: true } } + }) + } finally { + clearWslHomeCache() + } + }) + it('grants in the config file the final spawn env names', async () => { writeFileSync(join(root, '.claude.json'), '{}') await applyAgentWorkspaceTrust('claude', root, { diff --git a/src/main/ipc/filesystem/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem/filesystem-git-status-staging.test.ts index ab0b2a25afe..eca4862f633 100644 --- a/src/main/ipc/filesystem/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem/filesystem-git-status-staging.test.ts @@ -6,6 +6,10 @@ import { store, WORKTREE_FEATURE_PATH, discardChangesMock, + getStatusMock, + readFileMock, + statMock, + openMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -110,6 +114,7 @@ vi.mock( ) import { registerFilesystemHandlers } from './filesystem' +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { invalidateAuthorizedRootsCache } from '../worktrees/removal/registered-worktree-roots-cache' describe('registerFilesystemHandlers', () => { @@ -131,4 +136,25 @@ describe('registerFilesystemHandlers', () => { expect(discardChangesMock).not.toHaveBeenCalled() }) + + it('rejects direct SSH status and reads before any local access', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the existing fixture supplies the store API exercised by these handlers. + registerFilesystemHandlers(store as never) + await expect( + handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + connectionId: 'ssh-unserved' + }) + ).rejects.toThrow(SshHostNotServedError) + await expect( + handlers.get('fs:readFile')!(null, { + filePath: `${WORKTREE_FEATURE_PATH}/a.ts`, + connectionId: 'ssh-unserved' + }) + ).rejects.toThrow(SshHostNotServedError) + expect(getStatusMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() + expect(openMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index d4a2fbad205..023e3c31a97 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -38,6 +38,7 @@ export type PtySpawnIpcArgs = { resumeOrigin?: unknown launchToken?: unknown launchAgent?: TuiAgent + trustWorktreeId?: string startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string diff --git a/src/main/ipc/pty/pane-agent-launch.test-fixture.ts b/src/main/ipc/pty/pane-agent-launch.test-fixture.ts index 470f871448f..edb5a837767 100644 --- a/src/main/ipc/pty/pane-agent-launch.test-fixture.ts +++ b/src/main/ipc/pty/pane-agent-launch.test-fixture.ts @@ -16,7 +16,10 @@ export async function planQueuedPaneAgentLaunch( tabId: 'tab-1', leafId: '11111111-1111-4111-8111-111111111111' }, - pane: Pick = {} + pane: Pick< + PtySpawnIpcArgs, + 'connectionId' | 'commandDelivery' | 'startupCommandDelivery' | 'trustWorktreeId' | 'cwd' + > = {} ): Promise { const runtime = new OrcaRuntimeService(partialRuntimeStore({ getSettings: () => settings })) const discovery = vi.spyOn(runtime, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 241e62623dd..713aa887bd2 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -118,6 +118,7 @@ export type RuntimePtySpawnArgs = { cwd?: string command?: string launchAgent?: TuiAgent + trustWorktreeId?: string launchConfig?: SleepingAgentLaunchConfig launchToken?: string commandDelivery?: 'renderer' | 'provider' diff --git a/src/main/ipc/pty/spawn-options-assembly.ts b/src/main/ipc/pty/spawn-options-assembly.ts index 1a2f9b7ca87..7ebebf80160 100644 --- a/src/main/ipc/pty/spawn-options-assembly.ts +++ b/src/main/ipc/pty/spawn-options-assembly.ts @@ -78,6 +78,7 @@ export type PtySpawnLaunchOptionsInput = { commandDelivery?: PtySpawnOptions['commandDelivery'] startupCommandDelivery?: PtySpawnOptions['startupCommandDelivery'] worktreeId?: string + trustWorktreeId?: string tabId?: unknown connectionId?: string | null shellOverride?: string @@ -198,6 +199,7 @@ export async function assemblePtySpawnLaunchOptions(input: PtySpawnLaunchOptions const trustWrite = applyAgentWorkspaceTrustToSpawn({ launchAgent: args.launchAgent, worktreeId: args.worktreeId, + trustWorktreeId: args.trustWorktreeId, skipForThisLaunch: args.skipAgentWorkspaceTrust === true, cwd: input.cwd, store: input.store, diff --git a/src/main/runtime/rpc/methods/session-tabs/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs/session-tabs.ts index 7a4bdb68b94..19bd962bec7 100644 --- a/src/main/runtime/rpc/methods/session-tabs/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs/session-tabs.ts @@ -88,6 +88,7 @@ export const SESSION_TAB_METHODS = [ ? { resumeProviderSession: params.resumeProviderSession } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.trustWorktreeId ? { trustWorktreeId: params.trustWorktreeId } : {}), ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, select: params.select, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 9053b2b8e85..c71fd211402 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -74,6 +74,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.trustWorktreeId ? { trustWorktreeId: params.trustWorktreeId } : {}), terminalImageViewerSupported: terminalImageViewerSupported(context) } ) @@ -101,6 +102,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ ...(params.resumeOrigin ? { resumeOrigin: params.resumeOrigin } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.trustWorktreeId ? { trustWorktreeId: params.trustWorktreeId } : {}), ...(params.terminalKittyKeyboardProtocol === true ? { terminalKittyKeyboardProtocol: true } : {}), diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 4e2e1bd3fa3..f63f522bba7 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -75,6 +75,7 @@ export type RuntimePtyController = { cwd?: string command?: string launchAgent?: TuiAgent + trustWorktreeId?: string launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string commandDelivery?: 'renderer' | 'provider' diff --git a/src/main/runtime/section-trust-ownership.test.ts b/src/main/runtime/section-trust-ownership.test.ts new file mode 100644 index 00000000000..089adfbccee --- /dev/null +++ b/src/main/runtime/section-trust-ownership.test.ts @@ -0,0 +1,52 @@ +import './orca-runtime-tests/runtime-test-module-registration.spec' +import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { OrcaRuntimeService } from './orca-runtime' +import { + TEST_FOLDER_WORKSPACE_KEY, + createFolderWorkspaceRuntimeStore, + makeFolderWorkspace +} from './orca-runtime-tests/orca-runtime-test-fixtures.spec' + +describe('section trust ownership', () => { + it.each([false, true])( + 'carries transient section trust ownership through host creation (override %s)', + async (section) => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-section-trust' }) + const folderPath = await mkdtemp(join(tmpdir(), 'orca-pr38831-trust-root-')) + const childPath = await mkdtemp(join(tmpdir(), 'orca-pr38831-trust-child-')) + const trustWorktreeId = `repo::${childPath}` + const folder = makeFolderWorkspace({ folderPath }) + const runtime = new OrcaRuntimeService(createFolderWorkspaceRuntimeStore(folder)) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + try { + await runtime.createAgentSession({ + clientOperationId: `${Date.now()}-${'ab'.repeat(16)}`, + worktree: `id:${TEST_FOLDER_WORKSPACE_KEY}`, + agent: 'claude', + startupCwd: childPath, + ...(section ? { trustWorktreeId } : {}) + }) + expect(spawn.mock.calls[0]?.[0]).toMatchObject({ + worktreeId: TEST_FOLDER_WORKSPACE_KEY, + cwd: childPath + }) + if (section) { + expect(spawn.mock.calls[0]?.[0]?.trustWorktreeId).toBe(trustWorktreeId) + } else { + expect(spawn.mock.calls[0]?.[0]).not.toHaveProperty('trustWorktreeId') + } + } finally { + await rm(folderPath, { recursive: true, force: true }) + await rm(childPath, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/runtime/session-construction/agent-session/orca-runtime-create-agent-session.ts b/src/main/runtime/session-construction/agent-session/orca-runtime-create-agent-session.ts index 0e31cf2f9b6..0fe58f00d5b 100644 --- a/src/main/runtime/session-construction/agent-session/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/session-construction/agent-session/orca-runtime-create-agent-session.ts @@ -78,6 +78,7 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG : Object.entries(request.sessionOptions).sort(), request.extraAgentArgs ?? null, request.startupCwd ?? null, + ...(request.trustWorktreeId ? [request.trustWorktreeId] : []), request.presentation ?? null, request.placement?.tabId ?? null, request.placement?.leafId ?? null, @@ -148,6 +149,7 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG : Object.entries(request.sessionOptions).sort(), request.extraAgentArgs ?? null, startupCwd ?? null, + ...(request.trustWorktreeId ? [request.trustWorktreeId] : []), request.presentation ?? null, request.placement?.tabId ?? null, request.placement?.leafId ?? null, @@ -203,6 +205,7 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG terminalImageViewerSupported: caller.terminalImageViewerSupported, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, cwd: startupCwd, + ...(request.trustWorktreeId ? { trustWorktreeId: request.trustWorktreeId } : {}), presentation: request.presentation ?? 'background', tabId: operationTabId, leafId: operationLeafId, diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index fed5c69d5e8..afb70bf1fe0 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -36,6 +36,7 @@ export type MobileTerminalCreateOptions = { agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + trustWorktreeId?: string viewMode?: 'terminal' | 'chat' activate?: boolean clientNavigationId?: string @@ -56,6 +57,7 @@ export type OwnedMobileTerminalCreateOptions = { identity?: { tabId: string; leafId: string; sessionId?: string } createMutation?: { clientIdentity: string; id: string } launchAgent?: TuiAgent + trustWorktreeId?: string viewMode?: 'terminal' | 'chat' targetGroupId?: string afterDesktopTabId?: string @@ -109,6 +111,7 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchAgent?: TuiAgent + trustWorktreeId?: string terminalImageViewerSupported?: boolean viewMode?: 'terminal' | 'chat' activate?: boolean diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index b46e5d85af1..55b98bf7d4c 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -119,6 +119,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ...(opts.wslUserAct ? { wslUserAct: true } : {}), ...(opts.resumeOrigin ? { resumeOrigin: opts.resumeOrigin } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}), ...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), ...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}), diff --git a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts index 290515b5304..39e0037229e 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts @@ -111,6 +111,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca // The paired client's own create: its user's act. wslUserAct: true, ...startupCommand, + ...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}), cwd, identity: { tabId: deterministicAgentSessionUuid(`mobile-qoder-tab\0${seed}`), @@ -139,6 +140,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, launchAgent: startupCommand.launchAgent, + ...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}), viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, ...mainPlacedAfter( @@ -204,6 +206,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca ...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, source: 'runtime-session', @@ -267,6 +270,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca startupCommandDelivery: startupCommand.startupCommandDelivery, identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, launchAgent: startupCommand.launchAgent, + ...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}), viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, diff --git a/src/main/runtime/terminal/launch/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/terminal/launch/orca-runtime-create-terminal-desktop.ts index 931c9f2dd4a..ef547d06322 100644 --- a/src/main/runtime/terminal/launch/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/terminal/launch/orca-runtime-create-terminal-desktop.ts @@ -65,6 +65,7 @@ export async function createDesktopTerminal( ...(opts.resumeProviderSession ? { resumeProviderSession: opts.resumeProviderSession } : {}), ...(opts.launchToken ? { launchToken: opts.launchToken } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: opts.startupCommandDelivery, title: opts.title, diff --git a/src/main/runtime/terminal/launch/terminal-create-options.ts b/src/main/runtime/terminal/launch/terminal-create-options.ts index d671698614b..c346ebb1c34 100644 --- a/src/main/runtime/terminal/launch/terminal-create-options.ts +++ b/src/main/runtime/terminal/launch/terminal-create-options.ts @@ -31,6 +31,7 @@ export type TerminalCreateOptions = { resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent + trustWorktreeId?: string startupAgent?: TuiAgent /** Offer initial text only to compatible startupAgent launch commands; otherwise refuse instead of silently dropping it. * Typed-line carry gates delivery; onStartupPromptCarry reports clean launches whose later delivery belongs to the live PTY owner. */ diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 35518d171d4..87ad129ad41 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -141,6 +141,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi ? launchOpts.command : (agentTeamsPlan?.command ?? launchOpts.command), launchAgent: launchOpts.launchAgent, + ...(launchOpts.trustWorktreeId ? { trustWorktreeId: launchOpts.trustWorktreeId } : {}), launchConfig: effectiveLaunchConfig, ...(launchToken ? { launchToken } : {}), commandDelivery: 'provider', diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 791601c86a3..a2f0817337c 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -55,6 +55,7 @@ export type PtyApi = { resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent + trustWorktreeId?: string startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index a26ea9617cd..99e12dff8c3 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -50,6 +50,7 @@ export const ptySessionControlApi = { resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent + trustWorktreeId?: string startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string diff --git a/src/renderer/src/app-shell/startup-window-parity.test.ts b/src/renderer/src/app-shell/startup-window-parity.test.ts index b74d5552b40..14733b9baeb 100644 --- a/src/renderer/src/app-shell/startup-window-parity.test.ts +++ b/src/renderer/src/app-shell/startup-window-parity.test.ts @@ -62,7 +62,7 @@ describe('the window shows the same session before and after the shared Loader r expect(view[`${GIT} active terminal`]).toBe('tab-a') }) - it('restores the terminals of a session from before the tab bar, dropping its file tabs', async () => { + it('restores terminals and saved edit tabs from a session before the tab bar', async () => { const profile = PROFILES.find( (entry) => entry.name === 'session from before the tab bar, with an open file' )! @@ -71,7 +71,7 @@ describe('the window shows the same session before and after the shared Loader r { hostId: profile.hostId, session: profile.build() } ]) ) - expect(view[`${GIT} group 0 tabs`]).toEqual(['tab-a', 'tab-b']) - expect(view).not.toHaveProperty([`${GIT} tab /Users/dev/orca/src/app.ts`]) + expect(view[`${GIT} group 0 tabs`]).toEqual(['/Users/dev/orca/src/app.ts', 'tab-a', 'tab-b']) + expect(view).toHaveProperty([`${GIT} tab /Users/dev/orca/src/app.ts`]) }) }) diff --git a/src/renderer/src/components/agent-workspace-trust/agent-workspace-trust-decision-prompt.test.ts b/src/renderer/src/components/agent-workspace-trust/agent-workspace-trust-decision-prompt.test.ts index 380df5934f6..d9bc80de66d 100644 --- a/src/renderer/src/components/agent-workspace-trust/agent-workspace-trust-decision-prompt.test.ts +++ b/src/renderer/src/components/agent-workspace-trust/agent-workspace-trust-decision-prompt.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' +import { + installIpcPtyWindow, + restorePtySpecWindow +} from '../terminal-pane/pty-transport-test-harness' type StoreState = { repos: Partial[] @@ -51,6 +55,71 @@ afterEach(async () => { }) describe('first agent launch in a repo', () => { + it.each([true, false])( + 'asks once for concurrent section launches through PTY connect (save succeeds: %s)', + async (saveSucceeds) => { + const originalWindow: typeof window | undefined = globalThis.window + installIpcPtyWindow(originalWindow, {}) + if (!saveSucceeds) { + state.updateRepo = vi.fn(async () => false) + } + vi.mocked(window.api.pty.spawn) + .mockResolvedValueOnce({ id: 'section-1' }) + .mockResolvedValueOnce({ id: 'section-2' }) + const { createIpcPtyTransport } = + await import('../terminal-pane/pty-transport/ipc/pty-transport') + const transports = ['folder:placement', 'folder:other'].map((worktreeId) => + createIpcPtyTransport({ + worktreeId, + trustWorktreeId: WORKTREE, + cwd: '/repos/app/feature', + command: 'claude', + launchAgent: 'claude' + }) + ) + const connecting = Promise.all( + transports.map((transport) => transport.connect({ url: '', callbacks: {} })) + ) + try { + expect(getAgentWorkspaceTrustDecisionRequest()).toMatchObject({ + repoId: 'repo-1', + repoName: 'app', + hostId: 'local' + }) + expect(window.api.pty.spawn).not.toHaveBeenCalled() + await answerAgentWorkspaceTrustDecision('agent-asks') + await connecting + expect(state.updateRepo).toHaveBeenCalledExactlyOnceWith( + 'repo-1', + { agentWorkspaceTrust: 'agent-asks' }, + { hostId: 'local' } + ) + expect(getAgentWorkspaceTrustDecisionRequest()).toBeNull() + expect(vi.mocked(window.api.pty.spawn).mock.calls.map(([args]) => args)).toMatchObject([ + { + worktreeId: 'folder:placement', + trustWorktreeId: WORKTREE, + cwd: '/repos/app/feature', + skipAgentWorkspaceTrust: true + }, + { + worktreeId: 'folder:other', + trustWorktreeId: WORKTREE, + cwd: '/repos/app/feature', + skipAgentWorkspaceTrust: true + } + ]) + } finally { + await answerAgentWorkspaceTrustDecision('agent-asks') + await connecting + for (const transport of transports) { + transport.destroy?.() + } + restorePtySpecWindow(originalWindow) + } + } + ) + it('holds the launch until answered, asking once for all its worktrees', async () => { const first = launch() const second = launch('repo-1::/repos/app/other') diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.tsx index 8b15c6c5ce7..4de6b7872b3 100644 --- a/src/renderer/src/components/agent/AgentSettingsDialog.tsx +++ b/src/renderer/src/components/agent/AgentSettingsDialog.tsx @@ -1,4 +1,6 @@ -import React from 'react' +import { useAppStateValue, useAppStateViewActive } from '@/store/app-state-view' +import React, { useCallback } from 'react' +import { useAppStore } from '@/store' import { getOverlayFocusOwners, overlayFocusContains } from '@/lib/overlay-focus-ownership' import { Dialog, @@ -8,7 +10,6 @@ import { DialogTitle } from '@/components/ui/dialog' import { AgentsPane } from '@/components/settings/agents/AgentsPane' -import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { isWindowsTerminalCapabilityHost, @@ -29,10 +30,17 @@ export default function AgentSettingsDialog({ onOpenChange, onCloseAutoFocus }: AgentSettingsDialogProps): React.JSX.Element | null { - const settings = useAppStore((s) => s.settings) - const updateSettings = useAppStore((s) => s.updateSettings) - const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) + const viewActive = useAppStateViewActive() + const settings = useAppStateValue((s) => s.settings) + const updateSettings = useCallback( + (...args: Parameters['updateSettings']>) => + useAppStore.getState().updateSettings(...args), + [] + ) + const runtimeEnvironments = useAppStateValue((s) => s.runtimeEnvironments) + const runtimeEnvironmentCatalogHydrated = useAppStateValue( + (s) => s.runtimeEnvironmentCatalogHydrated + ) const isWindowsRenderer = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') const isWebClient = isWebClientLocation() @@ -41,7 +49,7 @@ export default function AgentSettingsDialog({ ) const localCapabilityOwnerKey = isWebClient ? runtimeCapabilityOwnerKey : 'local' const localWindowsTerminalCapabilities = useLocalWindowsTerminalCapabilities( - open && (isWindowsRenderer || isWebClient), + viewActive && open && (isWindowsRenderer || isWebClient), false, localCapabilityOwnerKey ) diff --git a/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts index c42f29a96f9..f08b09bcc4d 100644 --- a/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts +++ b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react' +import { useAppViewExternalStore } from '@/store/app-state-view' import { shallow } from 'zustand/shallow' import type { AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker' import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' @@ -292,5 +292,5 @@ export function createAgentPickerInventorySource( export function useAgentPickerInventoryEntries( source: AgentPickerInventorySource ): AgentPickerEntry[] { - return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot) + return useAppViewExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot) } diff --git a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts index 70e2eeebae7..78cea8f2754 100644 --- a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts +++ b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts @@ -1,3 +1,4 @@ +import { useAppStateView, useAppStateViewActive } from '@/store/app-state-view' import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { parseExecutionHostId } from '../../../../shared/execution-host' import { @@ -8,7 +9,6 @@ import { import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { readHostAgentLaunchDefaults } from '../../runtime/agent-launch-settings/agent-launch-settings-transport' import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows' -import { useAppStore } from '@/store' import { buildAgentLaunchRouteInput } from '@/lib/agent-session/launch/routing/agent-launch-route-input' import { workspaceKindForWorktreeId } from '../../../../shared/workspace-launch-kind' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' @@ -56,15 +56,17 @@ export function useAgentLaunchPickerInventorySource( open: boolean, selected?: AgentPickerEntry['agent'] ): AgentPickerInventorySource { + const store = useAppStateView() + const viewActive = useAppStateViewActive() const selectedRef = useRef(selected) useLayoutEffect(() => { selectedRef.current = selected }, [selected]) const context = model.state === 'settled' ? model.pickerContext : undefined - const settings = context ? context.settings : useAppStore.getState().settings + const settings = context ? context.settings : store.getState().settings const executionHostId = context?.executionHostId ?? - buildAgentLaunchRouteInput(useAppStore.getState(), { + buildAgentLaunchRouteInput(store.getState(), { agent: 'claude', workspace: context?.workspace ?? { kind: worktreeId ? workspaceKindForWorktreeId(worktreeId) : 'floating', @@ -77,11 +79,11 @@ export function useAgentLaunchPickerInventorySource( target?.kind === 'local' ? null : context?.runtimeId ) const repo = context?.workspace.repoId - ? useAppStore.getState().repos.find((candidate) => candidate.id === context.workspace.repoId) + ? store.getState().repos.find((candidate) => candidate.id === context.workspace.repoId) : undefined const workspaceId = worktreeId ?? context?.workspace.worktreeId const selector = workspaceId ? `id:${workspaceId}` : repo ? `path:${repo.path}` : undefined - const state = useAppStore.getState() + const state = store.getState() const hostId = parseExecutionHostId(executionHostId)?.id const worktrees = selectWorktreesByRepo(state) const folders = selectFolderWorkspaces(state) @@ -194,7 +196,7 @@ export function useAgentLaunchPickerInventorySource( [key, open, fallback] ) useEffect(() => { - if (!open || !target || model.state !== 'settled') { + if (!viewActive || !open || !target || model.state !== 'settled') { return } const controller = new AbortController() @@ -264,6 +266,6 @@ export function useAgentLaunchPickerInventorySource( } ) // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only semantic inputs and visibility own observation; selected priority publishes independently after commit. - }, [key, open, source]) + }, [key, open, source, viewActive]) return source } diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts index e6ff2123876..4134d78a068 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -1,8 +1,8 @@ +import { useAppStateValue } from '@/store/app-state-view' import { getAgentRowConversationName } from '../../../../shared/agent-status/presentation/agent-row-conversation-name' import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' -import { useAppStore } from '@/store' import { structuredChatTabById } from '@/lib/structured-chat-tab-index' import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name' import type { AppState } from '@/store/types' @@ -34,15 +34,15 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | parentPaneKey !== undefined && parsePaneKey(parentPaneKey)?.tabId === agent.tab.id const cannotOwnTabName = agent.rowSource === 'subagent' || usesParentTab - const generatedTitlesEnabled = useAppStore( + const generatedTitlesEnabled = useAppStateValue( (s) => !cannotOwnTabName && s.settings?.tabAutoGenerateTitle === true ) - const liveTab = useAppStore((s) => + const liveTab = useAppStateValue((s) => cannotOwnTabName ? undefined : getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id) ) - const structuredTab = useAppStore((s) => + const structuredTab = useAppStateValue((s) => cannotOwnTabName ? undefined : structuredChatTabById(s.unifiedTabsByWorktree, agent.tab.worktreeId, agent.tab.id) @@ -51,7 +51,7 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | // Parse once per render; selectors run on every store write and must stay allocation-free. const ownLeafId = cannotOwnTabName ? null : parsePaneKey(agent.paneKey)?.leafId // Select this split pane’s primitive title so sibling focus/title changes cannot rename or re-render its row. - const paneLiveTitle = useAppStore((s) => + const paneLiveTitle = useAppStateValue((s) => cannotOwnTabName ? undefined : resolveAgentRowPaneLiveTitle( diff --git a/src/renderer/src/components/diff-comments/use-diff-comment-owner.ts b/src/renderer/src/components/diff-comments/use-diff-comment-owner.ts index 9c4638c770d..99a7e6c6170 100644 --- a/src/renderer/src/components/diff-comments/use-diff-comment-owner.ts +++ b/src/renderer/src/components/diff-comments/use-diff-comment-owner.ts @@ -1,3 +1,4 @@ +import { useAppStateValue } from '@/store/app-state-view' import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { useAppStore } from '@/store' @@ -23,7 +24,7 @@ export function useDiffCommentView( const select = useMemo(() => createDiffCommentViewSelector(), []) const [selection, runtimeEnvironmentId, externalSshTargetId, operationProvenance] = sourceParts(source) - return useAppStore((state) => + return useAppStateValue((state) => select( state, worktreeId, @@ -38,7 +39,7 @@ export function useDiffCommentView( /** The notes of a captured copy (an open confirmation), through the same cached read. */ export function useOwnedDiffComments(owner: DiffCommentOwner | null): DiffComment[] | undefined { const select = useMemo(() => createOwnedDiffCommentsSelector(), []) - return useAppStore((state) => select(state, owner)) + return useAppStateValue((state) => select(state, owner)) } /** A composer target whose owner is captured when it opens, so a later submit saves to that copy. */ diff --git a/src/renderer/src/components/diff-comments/use-visible-worktree-diff-comments.ts b/src/renderer/src/components/diff-comments/use-visible-worktree-diff-comments.ts index fe5b30f54f0..cf2f40dadd3 100644 --- a/src/renderer/src/components/diff-comments/use-visible-worktree-diff-comments.ts +++ b/src/renderer/src/components/diff-comments/use-visible-worktree-diff-comments.ts @@ -1,8 +1,8 @@ +import { useAppStateValue } from '@/store/app-state-view' import { useDiffCommentView } from './use-diff-comment-owner' import type { DiffCommentOwner } from '@/store/diff-comment-owner' import type { WorkspaceCopySource } from '@/lib/workspace-copy-owner' import { useMemo } from 'react' -import { useAppStore } from '@/store' import { isMarkdownReviewNotesEnabled, withoutMarkdownReviewNotes @@ -24,7 +24,9 @@ export function useVisibleWorktreeDiffComments( const { owner, comments: listed } = useDiffCommentView(worktreeId, source) // Unknown copies still need a stable empty list; inline arrays would churn memoized consumers. const allComments = listed ?? NO_COMMENTS - const markdownReviewNotesEnabled = useAppStore((s) => isMarkdownReviewNotesEnabled(s.settings)) + const markdownReviewNotesEnabled = useAppStateValue((s) => + isMarkdownReviewNotesEnabled(s.settings) + ) const comments = useMemo( () => (markdownReviewNotesEnabled ? allComments : withoutMarkdownReviewNotes(allComments)), [allComments, markdownReviewNotesEnabled] diff --git a/src/renderer/src/components/editor/EditorPanel.placement-actions.test.tsx b/src/renderer/src/components/editor/EditorPanel.placement-actions.test.tsx new file mode 100644 index 00000000000..d49336c1f1d --- /dev/null +++ b/src/renderer/src/components/editor/EditorPanel.placement-actions.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TabsDrawnFromMain } from '@/store/slices/tabs-drawn-from-main.test-fixture' +import { + EDITOR_OWNER, + EDITOR_PLACEMENT, + seedEditorPlacementConsumers, + drawEditorPlacementConsumers +} from '@/store/slices/editor-placement-consumers.test-fixture' +import type { EditorToggleValue } from './view-mode/EditorViewToggle' + +vi.mock('@/runtime/runtime-rpc-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await original()) +) +vi.mock('@/runtime/runtime-layout-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await original()) +) +vi.mock('./useEditorPanelContentState', () => ({ + useEditorPanelContentState: () => ({ + fileContents: {}, + diffContents: {}, + reloadContent: () => {} + }) +})) +vi.mock('./EditorPanelShell', () => ({ + EditorPanelShell: ({ + onOpenDiffTargetFile, + onEditorToggleChange, + onOpenMarkdownPreview, + onOpenPreviewToSide + }: { + onOpenDiffTargetFile: () => void + onEditorToggleChange: (mode: EditorToggleValue) => void + onOpenMarkdownPreview: () => void + onOpenPreviewToSide: () => void + }) => ( + <> + + + + + + ) +})) +import EditorPanel from './EditorPanel' + +let main: TabsDrawnFromMain | undefined +afterEach(() => { + cleanup() + main?.close() + main = undefined +}) + +it.each(['Open', 'Rich', 'Markdown preview'])( + '%s from a child diff opens the result in the invoking folder group', + async (action) => { + const file = seedEditorPlacementConsumers() + main = await drawEditorPlacementConsumers() + const screen = render() + fireEvent.click(screen.getByText(action)) + await main.settle() + const state = useAppStore.getState() + const result = state.openFiles.find( + (entry) => entry.mode === (action === 'Markdown preview' ? 'markdown-preview' : 'edit') + ) + expect(result).toMatchObject({ + worktreeId: EDITOR_OWNER, + filePath: '/repo/notes.md', + runtimeEnvironmentId: null + }) + expect( + state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === result?.id) + ).toMatchObject({ groupId: 'x-source' }) + expect(state.unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual(['y-tab']) + if (action === 'Rich') { + expect(state.markdownViewMode[result!.id]).toBe('rich') + } + } +) + +it('opens an HTML side preview beside its invoking folder group', async () => { + const file = seedEditorPlacementConsumers({ + filePath: '/repo/index.html', + relativePath: 'index.html', + language: 'html' + }) + main = await drawEditorPlacementConsumers() + const screen = render() + fireEvent.click(screen.getByText('Side preview')) + await main.settle() + const state = useAppStore.getState() + const browser = state.browserTabsByWorktree[EDITOR_PLACEMENT]?.[0] + expect(browser?.url).toBe('file:///repo/index.html') + expect( + state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === browser?.id) + ).toMatchObject({ groupId: 'x-other' }) + expect(state.browserTabsByWorktree[EDITOR_OWNER] ?? []).toEqual([]) +}) + +it('places an SSH document preview in the folder while its document location retains the child', async () => { + const file = seedEditorPlacementConsumers({ + filePath: '/repo/index.html', + relativePath: 'index.html', + language: 'html' + }) + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => ({ + ...repo, + connectionId: 'child-ssh', + executionHostId: 'ssh:child-ssh' + })), + worktreesByRepo: { + repo1: state.worktreesByRepo.repo1!.map((worktree) => ({ + ...worktree, + hostId: 'ssh:child-ssh' + })) + }, + sshConnectionStates: new Map([ + [ + 'child-ssh', + { + targetId: 'child-ssh', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + } + ] + ]) + })) + main = await drawEditorPlacementConsumers() + const screen = render() + fireEvent.click(screen.getByText('Side preview')) + await main.settle() + const state = useAppStore.getState() + const preview = state.browserTabsByWorktree[EDITOR_PLACEMENT]?.[0] + expect(preview?.docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId: EDITOR_OWNER, + filePath: '/repo/index.html' + }) + expect( + state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === preview?.id) + ?.groupId + ).toBe('x-other') + expect(state.browserTabsByWorktree[EDITOR_OWNER] ?? []).toEqual([]) +}) diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 5fa127f0612..f2bd5824e25 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -3,8 +3,7 @@ import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { formatClipboardFilePath } from '@/lib/clipboard-file-path' import { getOpenFileRevealOwner } from '@/lib/reveal-in-file-manager' -import { detectLanguage } from '@/lib/language-detect' -import { canShowWorkspaceFileBrowserAction, openFilePreviewToSide } from '@/lib/file-preview' +import { canShowWorkspaceFileBrowserAction } from '@/lib/file-preview' import { getEditorHeaderCopyState } from './editor-header' import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { exportActiveMarkdownToPdf } from './export-active-markdown' @@ -33,6 +32,7 @@ import { useEditorPanelSave } from './save/commands/useEditorPanelSave' import { isMarkdownReviewNotesEnabled } from '@/lib/markdown-review-notes-setting' import { useFileCustomEditors } from '@/store/plugin-custom-editors' import { applyCustomEditorToggle } from './view-mode/custom-editor-toggle' +import { editorPanelFileActions } from './editor-panel-file-actions' import { runAfterEditorFramesFlush } from './save/flush/editor-pending-flush' function EditorPanelInner({ @@ -76,7 +76,6 @@ function EditorPanelInner({ ) const editorViewMode = useAppStore((s) => s.editorViewMode) const setEditorViewMode = useAppStore((s) => s.setEditorViewMode) - const openFile = useAppStore((s) => s.openFile) const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const markdownFrontmatterVisible = useAppStore((s) => s.markdownFrontmatterVisible) const setMarkdownFrontmatterVisible = useAppStore((s) => s.setMarkdownFrontmatterVisible) @@ -161,7 +160,7 @@ function EditorPanelInner({ )?.kind !== 'local' ) - useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview }) + useMarkdownPreviewShortcut({ activeFile, activeViewStateId, panelRef, openMarkdownPreview }) const handleContentChangeForFile = useEditorContentChangeHandler({ fileContents, diffContents }) @@ -246,37 +245,8 @@ function EditorPanelInner({ ...customEditors }) - const handleOpenPreviewToSide = (): void => { - const state = useAppStore.getState() - const sourceGroupId = activeViewStateId - ? ((state.unifiedTabsByWorktree[activeFile.worktreeId] ?? []).find( - (t) => t.id === activeViewStateId - )?.groupId ?? null) - : null - openFilePreviewToSide({ - language: model.resolvedLanguage, - filePath: activeFile.filePath, - worktreeId: activeFile.worktreeId, - sourceGroupId - }) - } - const handleOpenDiffTargetFile = (preferredMarkdownViewMode?: 'rich'): void => { - if (!model.openFileState.canOpen) { - return - } - const openedFileId = openFile({ - filePath: activeFile.filePath, - relativePath: activeFile.relativePath, - worktreeId: activeFile.worktreeId, - runtimeEnvironmentId: activeFile.runtimeEnvironmentId, - language: detectLanguage(activeFile.relativePath), - mode: 'edit' - }) - if (preferredMarkdownViewMode) { - setEditorViewMode(openedFileId, 'edit') - setMarkdownViewMode(openedFileId, preferredMarkdownViewMode) - } - } + const { handleOpenPreviewToSide, handleOpenDiffTargetFile, handleOpenMarkdownPreview } = + editorPanelFileActions(activeFile, activeViewStateId, model) const handleEditorToggleChange = (next: EditorToggleValue): void => { // Why: leaving a custom editor unmounts its frame, which may still hold debounced edits. runAfterEditorFramesFlush(useAppStore.getState(), [activeFile.id], () => @@ -299,18 +269,6 @@ function EditorPanelInner({ setMarkdownViewMode(fileId, markdownMode) } } - const handleOpenMarkdownPreview = (): void => { - openMarkdownPreview( - { - filePath: activeFile.filePath, - relativePath: activeFile.relativePath, - worktreeId: activeFile.worktreeId, - runtimeEnvironmentId: activeFile.runtimeEnvironmentId, - language: model.resolvedLanguage - }, - { sourceFileId: activeFile.id } - ) - } const disableRenameBrowse = Boolean( renameDialogFileOffClient || (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) diff --git a/src/renderer/src/components/editor/autosave/editor-autosave-controller.ts b/src/renderer/src/components/editor/autosave/editor-autosave-controller.ts index 6f1db731f03..03d5d18c049 100644 --- a/src/renderer/src/components/editor/autosave/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/autosave/editor-autosave-controller.ts @@ -46,7 +46,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { if (!(event instanceof CustomEvent)) { return } - const { fileId, onNotClosed }: EditorSaveAndCloseDetail = event.detail + const { fileId, tabId, onNotClosed }: EditorSaveAndCloseDetail = event.detail const file = store.getState().openFiles.find((openFile) => openFile.id === fileId) if (!file) { return @@ -68,7 +68,11 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } } - store.getState().closeFile(fileId) + if (tabId) { + store.getState().closeUnifiedTab(tabId) + } else { + store.getState().closeFile(fileId) + } } const handleSaveFile = async (event: Event): Promise => { diff --git a/src/renderer/src/components/editor/autosave/editor-autosave.ts b/src/renderer/src/components/editor/autosave/editor-autosave.ts index d3d4ff3bc1a..e7219b0d8eb 100644 --- a/src/renderer/src/components/editor/autosave/editor-autosave.ts +++ b/src/renderer/src/components/editor/autosave/editor-autosave.ts @@ -64,6 +64,7 @@ export type EditorSaveFileTarget = { export type EditorSaveAndCloseDetail = { fileId: string + tabId?: string /** Called at once when the tab stays open, so a waiting dialog needn't time out. */ onNotClosed?: (reason: 'blocked-stale' | 'not-utf8' | 'not-saved') => void } @@ -83,6 +84,7 @@ export type EditorFileSavedDetail = { export type EditorRequestFileCloseDetail = { fileId: string + tabId?: string /** Runs once the file actually closes — after save or discard, never on cancel. */ onClosed?: () => void } @@ -227,7 +229,7 @@ export async function requestEditorFileSave(target: EditorSaveFileTarget): Promi export function requestEditorFileClose( fileId: string, - options?: Pick + options?: Pick ): void { window.dispatchEvent( new CustomEvent(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, { diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index 26c8ffdf06c..33244891ff5 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -1,3 +1,4 @@ +import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement' import React, { useCallback, useRef, useState } from 'react' import { useAppStore } from '@/store' import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' @@ -63,9 +64,7 @@ export default function CombinedDiffViewer({ (s) => s.gitBranchChangesByWorktree[file.worktreeId] ?? EMPTY_GIT_BRANCH_ENTRIES ) const branchSummary = useAppStore((s) => s.gitBranchCompareSummaryByWorktree[file.worktreeId]) - const openAllDiffs = useAppStore((s) => s.openAllDiffs) const openConflictReview = useAppStore((s) => s.openConflictReview) - const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) const updateSettings = useAppStore((s) => s.updateSettings) const clearDiffComments = useAppStore((s) => s.clearDiffComments) // The notes of the copy this diff was read from (the file's server when it names one). @@ -211,21 +210,28 @@ export default function CombinedDiffViewer({ shouldAutoReloadFromGitStatus: entrySet.shouldAutoReloadFromGitStatus, treeMode: entrySet.treeMode }) - const { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } = - useCombinedDiffSectionActions({ - activeGroupId, - branchCompare: entrySet.branchCompare, - canOpenWorkspaceFileBrowserForPath, - commitCompare: entrySet.commitCompare, - file, - isAllMode: entrySet.isAllMode, - isBranchMode: entrySet.isBranchMode, - isCommitMode: entrySet.isCommitMode, - sections, - sectionsRef: registry.sectionsRef, - setSectionHeights, - setSections - }) + const { + handleSectionSaveRef, + modifiedEditorsRef, + openSection, + openSectionPreview, + openAlternateDiff + } = useCombinedDiffSectionActions({ + activeGroupId, + viewStateScopeId, + branchSummary, + branchCompare: entrySet.branchCompare, + canOpenWorkspaceFileBrowserForPath, + commitCompare: entrySet.commitCompare, + file, + isAllMode: entrySet.isAllMode, + isBranchMode: entrySet.isBranchMode, + isCommitMode: entrySet.isCommitMode, + sections, + sectionsRef: registry.sectionsRef, + setSectionHeights, + setSections + }) useCombinedDiffViewPersist({ combinedGitStatusSignature, @@ -257,23 +263,6 @@ export default function CombinedDiffViewer({ viewStateKey }) - const openAlternateDiff = useCallback(() => { - if (!file.combinedAlternate) { - return - } - - if (file.combinedAlternate.source === 'combined-all') { - openAllDiffs(file.worktreeId, file.filePath) - return - } - - if (branchSummary && branchSummary.status === 'ready') { - openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, { - source: 'combined-all' - }) - } - }, [branchSummary, file, openAllDiffs, openBranchAllDiffs]) - const { setScrollSurfaceMounted } = notes const setScrollContainerRef = useCallback( (node: HTMLDivElement | null) => { @@ -297,9 +286,10 @@ export default function CombinedDiffViewer({ path: entry.path, conflictKind: entry.conflictKind })), - 'combined-diff-exclusion' + 'combined-diff-exclusion', + editorViewPlacement(useAppStore.getState(), file, viewStateScopeId) ) - }, [file.filePath, file.worktreeId, openConflictReview, skippedConflicts]) + }, [file, viewStateScopeId, openConflictReview, skippedConflicts]) const commitHeader = entrySet.isCommitMode && entrySet.commitCompare ? ( diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.placement.test.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.placement.test.tsx new file mode 100644 index 00000000000..32b4d634d54 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.placement.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TabsDrawnFromMain } from '@/store/slices/tabs-drawn-from-main.test-fixture' +import { + EDITOR_OWNER, + EDITOR_PLACEMENT, + seedEditorPlacementConsumers, + drawEditorPlacementConsumers +} from '@/store/slices/editor-placement-consumers.test-fixture' +import type { DiffSection } from '../../diff/sections/diff-section-types' +import { useCombinedDiffSectionActions } from './use-combined-diff-section-actions' + +vi.mock('@/runtime/runtime-rpc-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await original()) +) +vi.mock('@/runtime/runtime-layout-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await original()) +) +let main: TabsDrawnFromMain | undefined +afterEach(() => { + cleanup() + main?.close() + main = undefined +}) + +const branch = { + baseRef: 'main', + compareRef: 'HEAD', + baseOid: 'base', + headOid: 'head', + mergeBase: 'base', + compareVersion: 'branch-version' +} +const commit = { + commitOid: 'commit', + parentOid: 'parent', + compareRef: 'commit', + baseRef: 'parent', + compareVersion: 'commit-version' +} +const section: DiffSection = { + key: 'notes.md', + path: 'notes.md', + status: 'modified', + originalContent: '', + modifiedContent: '', + collapsed: false, + loading: false, + dirty: false, + diffResult: null, + largeDiffRenderLimit: null +} + +it.each(['edit', 'branch', 'commit'] as const)( + 'opens a combined %s section in its invoking group, preserving the child owner', + async (kind) => { + const file = seedEditorPlacementConsumers({ + filePath: '/repo', + relativePath: 'All Changes', + diffSource: 'combined-all' + }) + main = await drawEditorPlacementConsumers() + const hook = renderHook(() => + useCombinedDiffSectionActions({ + activeGroupId: 'y-group', + viewStateScopeId: 'x-source-tab', + branchCompare: branch, + commitCompare: commit, + canOpenWorkspaceFileBrowserForPath: () => false, + file, + isAllMode: false, + isBranchMode: kind === 'branch', + isCommitMode: kind === 'commit', + sections: [section], + sectionsRef: { current: [section] }, + setSectionHeights: vi.fn(), + setSections: vi.fn() + }) + ) + act(() => hook.result.current.openSection(0)) + await main.settle() + const state = useAppStore.getState() + const opened = state.openFiles.find((entry) => entry.id !== file.id) + expect(opened).toMatchObject({ + worktreeId: EDITOR_OWNER, + filePath: '/repo/notes.md', + runtimeEnvironmentId: null, + mode: kind === 'edit' ? 'edit' : 'diff' + }) + expect( + state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === opened?.id) + ?.groupId + ).toBe('x-source') + expect(state.unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual(['y-tab']) + } +) + +it.each(['combined-all', 'combined-branch'] as const)( + 'switches the alternate %s diff in the invoking folder group', + async (alternate) => { + const file = seedEditorPlacementConsumers({ + filePath: '/repo', + relativePath: 'All Branch Changes', + diffSource: 'combined-branch', + combinedAlternate: { source: alternate } + }) + main = await drawEditorPlacementConsumers() + const hook = renderHook(() => + useCombinedDiffSectionActions({ + activeGroupId: 'y-group', + viewStateScopeId: 'x-source-tab', + branchSummary: { ...branch, changedFiles: 1, status: 'ready' }, + branchCompare: branch, + commitCompare: null, + canOpenWorkspaceFileBrowserForPath: () => false, + file, + isAllMode: false, + isBranchMode: true, + isCommitMode: false, + sections: [], + sectionsRef: { current: [] }, + setSectionHeights: vi.fn(), + setSections: vi.fn() + }) + ) + act(() => hook.result.current.openAlternateDiff()) + await main.settle() + const state = useAppStore.getState() + const opened = state.openFiles.find( + (entry) => + entry.id !== file.id && + entry.diffSource === + (alternate === 'combined-all' ? 'combined-uncommitted' : 'combined-branch') + ) + expect(opened?.worktreeId).toBe(EDITOR_OWNER) + expect( + state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === opened?.id) + ?.groupId + ).toBe('x-source') + expect(state.unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual(['y-tab']) + } +) diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts index 8c605b80c17..ba3f2f95416 100644 --- a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts @@ -1,36 +1,30 @@ -import { useCallback, useRef } from 'react' +import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement' +import { useCallback } from 'react' +import { useCombinedDiffSectionSave } from './use-combined-diff-section-save' import type React from 'react' -import type { editor as monacoEditor } from 'monaco-editor' import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' import { openFilePreviewToSide } from '@/lib/file-preview' +import type { AppState } from '@/store/types' import type { OpenFile } from '@/store/slices/editor' import type { GitBranchChangeEntry } from '../../../../../../shared/git/diff/git-diff-compare-types' import { canOpenDiffSectionPreviewToSide } from '../../diff/sections/diff-section-preview' -import { getLargeDiffRenderLimit } from '../../../../../../shared/large-diff-render-limit' -import { - getStoredTextDiffContent, - getStoredTextDiffResult, - isPrunedTextDiffResult -} from '../../diff/large-files/large-diff-section-content' -import { removeDiffSectionMeasuredHeight } from '../../diff/sections/diff-section-height-cache' import type { DiffSection } from '../../diff/sections/diff-section-types' import type { DiffSectionItemProps } from '../../diff/sections/diff-section-item-props' -import { getDiskBaselineSignature } from '../../../../../../shared/disk-baseline-signature' -import { writeEditorSave } from '../../save/queue/editor-save-disk-precondition' -import { withEditorFileWriteLock } from '../../save/queue/editor-file-write-lock' -import { editorPathHoldKey } from '../../autosave/editor-autosave-holds' export type CombinedDiffSectionActions = { handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef'] modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef'] + openAlternateDiff: () => void openSection: (index: number) => void openSectionPreview: (section: DiffSection) => void } export function useCombinedDiffSectionActions({ activeGroupId, + viewStateScopeId, + branchSummary, branchCompare, canOpenWorkspaceFileBrowserForPath, commitCompare, @@ -43,6 +37,8 @@ export function useCombinedDiffSectionActions({ setSections }: { activeGroupId: string | undefined + viewStateScopeId?: string + branchSummary?: AppState['gitBranchCompareSummaryByWorktree'][string] branchCompare: NonNullable | null canOpenWorkspaceFileBrowserForPath: (path: string) => boolean commitCompare: NonNullable | null @@ -55,10 +51,17 @@ export function useCombinedDiffSectionActions({ setSectionHeights: React.Dispatch>> setSections: React.Dispatch> }): CombinedDiffSectionActions { + const openAllDiffs = useAppStore((s) => s.openAllDiffs) + const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) const openFile = useAppStore((s) => s.openFile) const openBranchDiff = useAppStore((s) => s.openBranchDiff) const openCommitDiff = useAppStore((s) => s.openCommitDiff) - const modifiedEditorsRef = useRef>(new Map()) + const { handleSectionSaveRef, modifiedEditorsRef } = useCombinedDiffSectionSave({ + file, + sectionsRef, + setSectionHeights, + setSections + }) const openSection = useCallback( (index: number) => { @@ -67,6 +70,7 @@ export function useCombinedDiffSectionActions({ return } + const placement = editorViewPlacement(useAppStore.getState(), file, viewStateScopeId) const language = detectLanguage(section.path) const entry: GitBranchChangeEntry = { path: section.path, @@ -79,30 +83,38 @@ export function useCombinedDiffSectionActions({ const isBranchEntry = section.area === undefined if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) { - openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language) + openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language, { + ...placement, + runtimeEnvironmentId: file.runtimeEnvironmentId + }) return } if (isCommitMode && commitCompare) { - openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language) + openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language, { + ...placement, + runtimeEnvironmentId: file.runtimeEnvironmentId + }) return } - openFile({ - filePath: joinPath(file.filePath, section.path), - relativePath: section.path, - worktreeId: file.worktreeId, - runtimeEnvironmentId: file.runtimeEnvironmentId, - language, - mode: 'edit' - }) + openFile( + { + filePath: joinPath(file.filePath, section.path), + relativePath: section.path, + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + language, + mode: 'edit' + }, + placement + ) }, [ branchCompare, commitCompare, - file.filePath, - file.runtimeEnvironmentId, - file.worktreeId, + file, + viewStateScopeId, isAllMode, isBranchMode, isCommitMode, @@ -130,176 +142,42 @@ export function useCombinedDiffSectionActions({ } // Use this tab’s split group; the workspace active group may belong to another pane. const state = useAppStore.getState() - const sourceGroupId = - (state.unifiedTabsByWorktree[file.worktreeId] ?? []).find( - (tab) => - tab.entityId === file.id && (tab.contentType === 'diff' || tab.contentType === 'editor') - )?.groupId ?? - activeGroupId ?? - null + const placement = editorViewPlacement(state, file, viewStateScopeId) openFilePreviewToSide({ language: detectLanguage(section.path), filePath: joinPath(file.filePath, section.path), worktreeId: file.worktreeId, - sourceGroupId + placementWorkspaceId: placement.placementWorkspaceId, + sourceGroupId: placement.targetGroupId ?? activeGroupId ?? null }) }, - [ - activeGroupId, - canOpenWorkspaceFileBrowserForPath, - file.filePath, - file.id, - file.worktreeId, - isCommitMode - ] + [activeGroupId, viewStateScopeId, file, canOpenWorkspaceFileBrowserForPath, isCommitMode] ) - const writtenBySectionRef = useRef( - new Map() - ) - const runSectionSave = useCallback( - async ( - sectionKey: string, - mode: 'overwrite' | undefined, - operation: NonNullable - ) => { - // Why by key: rows can be rebuilt while a save waits; a gone section is not saved. - const index = sectionsRef.current.findIndex((s) => s.key === sectionKey) - const section = sectionsRef.current[index] - if (!section || section.operation !== operation) { - return - } - const modifiedEditor = modifiedEditorsRef.current.get(index) - if (!modifiedEditor && !section.dirty) { - return - } - - const content = modifiedEditor?.getValue() ?? section.modifiedContent - const absolutePath = joinPath(file.filePath, section.path) - // Why: edits live in modifiedContent; the diff result keeps the text loaded from (or last - // written to) disk, unless a large diff pruned it, in which case no version is known. - // Why first: a save earlier in this section's chain wrote it, maybe before state caught up. - const previousWrite = writtenBySectionRef.current.get(section.key) - const loaded = - (previousWrite?.operation === operation ? previousWrite.content : undefined) ?? - (section.diffResult?.kind === 'text' && !isPrunedTextDiffResult(section.diffResult) - ? section.diffResult.modifiedContent - : undefined) - const markConflict = (): void => - setSections((prev) => - prev.map((s) => - s.key === sectionKey && s.operation === operation ? { ...s, saveConflict: true } : s - ) - ) - if (loaded === undefined && mode !== 'overwrite') { - markConflict() - return - } - try { - operation.assertCurrent() - const writeResult = await withEditorFileWriteLock( - editorPathHoldKey(useAppStore.getState(), operation.file, absolutePath), - () => { - operation.assertCurrent() - return writeEditorSave( - operation.mutationContext(), - absolutePath, - content, - undefined, - mode === 'overwrite' || loaded === undefined - ? { kind: 'explicit-overwrite' } - : { - kind: 'guarded', - signature: getDiskBaselineSignature(loaded), - allowMissing: false - } - ) - } - ) - if (!writeResult.ok) { - markConflict() - return - } - try { - operation.assertCurrent() - } catch { - return - } - // Why: the section list can be rebuilt while the write is pending, so re-resolve - // by key — the captured index may now point at a different file. - const savedIndex = sectionsRef.current.findIndex( - (s) => s.key === sectionKey && s.operation === operation - ) - if (savedIndex === -1) { - return - } - writtenBySectionRef.current.set(sectionKey, { operation, content }) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, savedIndex)) - setSections((prev) => - prev.map((s) => { - if (s.key !== sectionKey || s.operation !== operation) { - return s - } - const written = - s.diffResult?.kind === 'text' ? { ...s.diffResult, modifiedContent: content } : null - // Typing during the write is newer intent; advance only the baseline. - if (s.modifiedContent !== content) { - return written ? { ...s, diffResult: written, saveConflict: undefined } : s - } - if (!written) { - return { ...s, dirty: false, saveConflict: undefined } - } - const nextLargeDiffRenderLimit = getLargeDiffRenderLimit({ - originalContent: s.originalContent, - modifiedContent: content - }) - const storedContent = getStoredTextDiffContent(written, nextLargeDiffRenderLimit) - return { - ...s, - modifiedContent: storedContent.modifiedContent, - originalContent: storedContent.originalContent, - dirty: false, - saveConflict: undefined, - diffResult: getStoredTextDiffResult(written, nextLargeDiffRenderLimit), - largeDiffRenderLimit: nextLargeDiffRenderLimit - } - }) - ) - } catch (err) { - console.error('Save failed:', err) - } - }, - [file.filePath, sectionsRef, setSectionHeights, setSections] - ) - - // Build each precondition after that section’s prior save settles. - const sectionSaveChainsRef = useRef(new Map>()) - const handleSectionSave = useCallback( - (index: number, mode?: 'overwrite'): Promise => { - const section = sectionsRef.current[index] - const key = section?.key - const operation = section?.operation - if (key === undefined || !operation) { - return Promise.resolve() - } - const chains = sectionSaveChainsRef.current - const next = (chains.get(key) ?? Promise.resolve()).then(() => - runSectionSave(key, mode, operation) + const openAlternateDiff = useCallback(() => { + if (!file.combinedAlternate) { + return + } + const placement = editorViewPlacement(useAppStore.getState(), file, viewStateScopeId) + const options = { ...placement, runtimeEnvironmentId: file.runtimeEnvironmentId } + if (file.combinedAlternate.source === 'combined-all') { + openAllDiffs(file.worktreeId, file.filePath, undefined, undefined, undefined, options) + } else if (branchSummary?.status === 'ready') { + openBranchAllDiffs( + file.worktreeId, + file.filePath, + branchSummary, + { source: 'combined-all' }, + options ) - const tracked = next.finally(() => { - if (chains.get(key) === tracked) { - chains.delete(key) - writtenBySectionRef.current.delete(key) - } - }) - chains.set(key, tracked) - return tracked - }, - [runSectionSave, sectionsRef] - ) + } + }, [branchSummary, file, openAllDiffs, openBranchAllDiffs, viewStateScopeId]) - const handleSectionSaveRef = useRef(handleSectionSave) - handleSectionSaveRef.current = handleSectionSave - - return { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } + return { + handleSectionSaveRef, + modifiedEditorsRef, + openSection, + openSectionPreview, + openAlternateDiff + } } diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-save.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-save.ts new file mode 100644 index 00000000000..8f997075d96 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-save.ts @@ -0,0 +1,182 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type React from 'react' +import type { editor as monacoEditor } from 'monaco-editor' +import { useAppStore } from '@/store' +import { joinPath } from '@/lib/path' +import type { OpenFile } from '@/store/slices/editor' +import type { DiffSection } from '../../diff/sections/diff-section-types' +import { getLargeDiffRenderLimit } from '../../../../../../shared/large-diff-render-limit' +import { + getStoredTextDiffContent, + getStoredTextDiffResult, + isPrunedTextDiffResult +} from '../../diff/large-files/large-diff-section-content' +import { removeDiffSectionMeasuredHeight } from '../../diff/sections/diff-section-height-cache' +import { getDiskBaselineSignature } from '../../../../../../shared/disk-baseline-signature' +import { writeEditorSave } from '../../save/queue/editor-save-disk-precondition' +import { withEditorFileWriteLock } from '../../save/queue/editor-file-write-lock' +import { editorPathHoldKey } from '../../autosave/editor-autosave-holds' + +export function useCombinedDiffSectionSave({ + file, + sectionsRef, + setSectionHeights, + setSections +}: { + file: OpenFile + sectionsRef: React.RefObject + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> +}) { + const modifiedEditorsRef = useRef>(new Map()) + const writtenBySectionRef = useRef( + new Map() + ) + const runSectionSave = useCallback( + async ( + sectionKey: string, + mode: 'overwrite' | undefined, + operation: NonNullable + ) => { + // Why by key: rows can be rebuilt while a save waits; a gone section is not saved. + const index = sectionsRef.current.findIndex((s) => s.key === sectionKey) + const section = sectionsRef.current[index] + if (!section || section.operation !== operation) { + return + } + const modifiedEditor = modifiedEditorsRef.current.get(index) + if (!modifiedEditor && !section.dirty) { + return + } + + const content = modifiedEditor?.getValue() ?? section.modifiedContent + const absolutePath = joinPath(file.filePath, section.path) + // Why: edits live in modifiedContent; the diff result keeps the text loaded from (or last + // written to) disk, unless a large diff pruned it, in which case no version is known. + // Why first: a save earlier in this section's chain wrote it, maybe before state caught up. + const previousWrite = writtenBySectionRef.current.get(section.key) + const loaded = + (previousWrite?.operation === operation ? previousWrite.content : undefined) ?? + (section.diffResult?.kind === 'text' && !isPrunedTextDiffResult(section.diffResult) + ? section.diffResult.modifiedContent + : undefined) + const markConflict = (): void => + setSections((prev) => + prev.map((s) => + s.key === sectionKey && s.operation === operation ? { ...s, saveConflict: true } : s + ) + ) + if (loaded === undefined && mode !== 'overwrite') { + markConflict() + return + } + try { + operation.assertCurrent() + const writeResult = await withEditorFileWriteLock( + editorPathHoldKey(useAppStore.getState(), operation.file, absolutePath), + () => { + operation.assertCurrent() + return writeEditorSave( + operation.mutationContext(), + absolutePath, + content, + undefined, + mode === 'overwrite' || loaded === undefined + ? { kind: 'explicit-overwrite' } + : { + kind: 'guarded', + signature: getDiskBaselineSignature(loaded), + allowMissing: false + } + ) + } + ) + if (!writeResult.ok) { + markConflict() + return + } + try { + operation.assertCurrent() + } catch { + return + } + // Why: the section list can be rebuilt while the write is pending, so re-resolve + // by key — the captured index may now point at a different file. + const savedIndex = sectionsRef.current.findIndex( + (s) => s.key === sectionKey && s.operation === operation + ) + if (savedIndex === -1) { + return + } + writtenBySectionRef.current.set(sectionKey, { operation, content }) + setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, savedIndex)) + setSections((prev) => + prev.map((s) => { + if (s.key !== sectionKey || s.operation !== operation) { + return s + } + const written = + s.diffResult?.kind === 'text' ? { ...s.diffResult, modifiedContent: content } : null + // Typing during the write is newer intent; advance only the baseline. + if (s.modifiedContent !== content) { + return written ? { ...s, diffResult: written, saveConflict: undefined } : s + } + if (!written) { + return { ...s, dirty: false, saveConflict: undefined } + } + const nextLargeDiffRenderLimit = getLargeDiffRenderLimit({ + originalContent: s.originalContent, + modifiedContent: content + }) + const storedContent = getStoredTextDiffContent(written, nextLargeDiffRenderLimit) + return { + ...s, + modifiedContent: storedContent.modifiedContent, + originalContent: storedContent.originalContent, + dirty: false, + saveConflict: undefined, + diffResult: getStoredTextDiffResult(written, nextLargeDiffRenderLimit), + largeDiffRenderLimit: nextLargeDiffRenderLimit + } + }) + ) + } catch (err) { + console.error('Save failed:', err) + } + }, + [file.filePath, sectionsRef, setSectionHeights, setSections] + ) + + // Build each precondition after that section’s prior save settles. + const sectionSaveChainsRef = useRef(new Map>()) + const handleSectionSave = useCallback( + (index: number, mode?: 'overwrite'): Promise => { + const section = sectionsRef.current[index] + const key = section?.key + const operation = section?.operation + if (key === undefined || !operation) { + return Promise.resolve() + } + const chains = sectionSaveChainsRef.current + const next = (chains.get(key) ?? Promise.resolve()).then(() => + runSectionSave(key, mode, operation) + ) + const tracked = next.finally(() => { + if (chains.get(key) === tracked) { + chains.delete(key) + writtenBySectionRef.current.delete(key) + } + }) + chains.set(key, tracked) + return tracked + }, + [runSectionSave, sectionsRef] + ) + + const handleSectionSaveRef = useRef(handleSectionSave) + useLayoutEffect(() => { + handleSectionSaveRef.current = handleSectionSave + }, [handleSectionSave]) + + return { handleSectionSaveRef, modifiedEditorsRef } +} diff --git a/src/renderer/src/components/editor/diff/review/DiffNotesSendMenu.tsx b/src/renderer/src/components/editor/diff/review/DiffNotesSendMenu.tsx index d4b882f0923..b462786dc2d 100644 --- a/src/renderer/src/components/editor/diff/review/DiffNotesSendMenu.tsx +++ b/src/renderer/src/components/editor/diff/review/DiffNotesSendMenu.tsx @@ -1,7 +1,8 @@ +import { useAppStore } from '@/store' +import { useAppStateValue } from '@/store/app-state-view' import type { DiffCommentOwner } from '@/store/diff-comment-owner' import React, { useCallback, useMemo } from 'react' import type { DiffComment } from '../../../../../../shared/diff-comment-types' -import { useAppStore } from '@/store/index' import { formatDiffComments } from '../../../../../../shared/diff-comments-format' import { NotesSendMenu, type NotesSendMenuScope } from '../../review-note-delivery/NotesSendMenu' import { translate } from '@/i18n/i18n' @@ -42,9 +43,11 @@ export function DiffNotesSendMenu({ respondToOpenRequest?: boolean }): React.JSX.Element { const languageRevision = useUiLanguageRevision() - const clearDeliveredDiffComments = useAppStore((s) => s.clearDeliveredDiffComments) - const openRequest = useAppStore((s) => s.diffNotesSendMenuOpenRequest) - const consumeOpenRequest = useAppStore((s) => s.consumeDiffNotesSendMenuOpenRequest) + const openRequest = useAppStateValue((s) => + respondToOpenRequest && s.diffNotesSendMenuOpenRequest?.worktreeId === worktreeId + ? s.diffNotesSendMenuOpenRequest + : null + ) // Enforce TTL when opening commits in NotesSendMenu; render-time clocks are stale or require polling. const hasPendingOpenRequest = respondToOpenRequest && openRequest?.worktreeId === worktreeId const openRequestNonce = hasPendingOpenRequest ? (openRequest?.nonce ?? null) : null @@ -52,8 +55,8 @@ export function DiffNotesSendMenu({ ? (openRequest?.issuedAt ?? 0) + OPEN_REQUEST_TTL_MS : null const handleOpenRequestHandled = useCallback( - () => consumeOpenRequest(worktreeId), - [consumeOpenRequest, worktreeId] + () => useAppStore.getState().consumeDiffNotesSendMenuOpenRequest(worktreeId), + [worktreeId] ) const unsentNotes = useMemo(() => comments.filter((comment) => !comment.sentAt), [comments]) const fileNotes = useMemo( @@ -102,7 +105,9 @@ export function DiffNotesSendMenu({ openRequestNonce={openRequestNonce} openRequestExpiresAt={openRequestExpiresAt} onOpenRequestHandled={handleOpenRequestHandled} - onDelivered={(notes) => void (owner && clearDeliveredDiffComments(owner, notes))} + onDelivered={(notes) => + void (owner && useAppStore.getState().clearDeliveredDiffComments(owner, notes)) + } /> ) } diff --git a/src/renderer/src/components/editor/editor-panel-file-actions.ts b/src/renderer/src/components/editor/editor-panel-file-actions.ts new file mode 100644 index 00000000000..2d7f887605f --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-file-actions.ts @@ -0,0 +1,61 @@ +import { useAppStore } from '@/store' +import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement' +import { openFilePreviewToSide } from '@/lib/file-preview' +import { detectLanguage } from '@/lib/language-detect' +import type { OpenFile } from '@/store/slices/editor/types/open-file' +import type { getEditorPanelRenderModel } from './editor-panel-render-model' + +export function editorPanelFileActions( + file: OpenFile, + viewStateId: string | null, + model: Pick, 'openFileState' | 'resolvedLanguage'> +) { + const handleOpenPreviewToSide = (): void => { + const state = useAppStore.getState() + const placement = editorViewPlacement(state, file, viewStateId) + openFilePreviewToSide({ + language: model.resolvedLanguage, + filePath: file.filePath, + worktreeId: file.worktreeId, + placementWorkspaceId: placement.placementWorkspaceId, + sourceGroupId: placement.targetGroupId ?? null + }) + } + const handleOpenDiffTargetFile = (preferredMarkdownViewMode?: 'rich'): void => { + if (!model.openFileState.canOpen) { + return + } + const { openFile, setEditorViewMode, setMarkdownViewMode } = useAppStore.getState() + const openedFileId = openFile( + { + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + language: detectLanguage(file.relativePath), + mode: 'edit' + }, + editorViewPlacement(useAppStore.getState(), file, viewStateId) + ) + if (preferredMarkdownViewMode) { + setEditorViewMode(openedFileId, 'edit') + setMarkdownViewMode(openedFileId, preferredMarkdownViewMode) + } + } + const handleOpenMarkdownPreview = (): void => { + useAppStore.getState().openMarkdownPreview( + { + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + language: model.resolvedLanguage + }, + { + sourceFileId: file.id, + ...editorViewPlacement(useAppStore.getState(), file, viewStateId) + } + ) + } + return { handleOpenPreviewToSide, handleOpenDiffTargetFile, handleOpenMarkdownPreview } +} diff --git a/src/renderer/src/components/editor/model-lifetime/retention/editor-model-lifetime-fixture.ts b/src/renderer/src/components/editor/model-lifetime/retention/editor-model-lifetime-fixture.ts index 5176d7b3958..b9ad099cee0 100644 --- a/src/renderer/src/components/editor/model-lifetime/retention/editor-model-lifetime-fixture.ts +++ b/src/renderer/src/components/editor/model-lifetime/retention/editor-model-lifetime-fixture.ts @@ -55,6 +55,7 @@ export function createModelLifetimeFixture(register = true) { const file = modelLifetimeFile(id) const model = modelLifetimeEditorModel(file.filePath, `${id}\n${'x'.repeat(256 * 1024)}`) store.setState({ openFiles: [...store.getState().openFiles, file], activeFileId: id }) + store.getState().createUnifiedTab(workspace, 'editor', { entityId: id, label: id }) return { file, model } } return { store, bridge, attach, add } diff --git a/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx b/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx index de15846403f..3b07f38ee1f 100644 --- a/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx +++ b/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.tsx @@ -1,7 +1,8 @@ +import { useAppStore } from '@/store' +import { useAppStateValue } from '@/store/app-state-view' import type { DiffCommentOwner } from '@/store/diff-comment-owner' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Send, Sparkles } from 'lucide-react' -import { useAppStore } from '@/store/index' import type { AgentSendPopoverTargetMode } from '@/store/slices/ui/ui-slice-contract-core' import { DropdownMenu, @@ -94,11 +95,9 @@ export function NotesSendMenu({ onOpenRequestHandled, onDelivered }: NotesSendMenuProps): React.JSX.Element { - const openAgentSendPopoverTargetMode = useAppStore((s) => s.openAgentSendPopoverTargetMode) - const closeAgentSendPopoverTargetMode = useAppStore((s) => s.closeAgentSendPopoverTargetMode) - const activeTargetModeId = useAppStore((s) => s.agentSendPopoverTargetMode?.id ?? null) const [sendMenuOpen, setSendMenuOpen] = useState(false) const targetModeId = useMemo(() => buildNotesSendTargetModeId(modeIdParts), [modeIdParts]) + const ownsTargetMode = useAppStateValue((s) => s.agentSendPopoverTargetMode?.id === targetModeId) const inFlightVersion = useNotesInFlightVersion() const sendableScopes = useMemo[]>(() => { void inFlightVersion @@ -147,7 +146,7 @@ export function NotesSendMenu({ if (scope.notes.length === 0) { return } - openAgentSendPopoverTargetMode({ + useAppStore.getState().openAgentSendPopoverTargetMode({ id: targetModeId, worktreeId, source, @@ -158,15 +157,7 @@ export function NotesSendMenu({ onPromptHandedOff: holdInFlight(scope.notes) }) }, - [ - holdInFlight, - markDelivered, - openAgentSendPopoverTargetMode, - source, - targetModeId, - targetModeLabel, - worktreeId - ] + [holdInFlight, markDelivered, source, targetModeId, targetModeLabel, worktreeId] ) const handleOpenChange = useCallback( @@ -177,23 +168,23 @@ export function NotesSendMenu({ openTargetMode(defaultScope) } } else { - closeAgentSendPopoverTargetMode(targetModeId) + useAppStore.getState().closeAgentSendPopoverTargetMode(targetModeId) } }, - [closeAgentSendPopoverTargetMode, defaultScope, openTargetMode, targetModeId] + [defaultScope, openTargetMode, targetModeId] ) - const effectiveSendMenuOpen = sendMenuOpen && activeTargetModeId === targetModeId - if (sendMenuOpen && activeTargetModeId !== targetModeId) { + const effectiveSendMenuOpen = sendMenuOpen && ownsTargetMode + if (sendMenuOpen && !ownsTargetMode) { // Clear stale local open state before painting another target’s menu. setSendMenuOpen(false) } useEffect( () => () => { - closeAgentSendPopoverTargetMode(targetModeId) + useAppStore.getState().closeAgentSendPopoverTargetMode(targetModeId) }, - [closeAgentSendPopoverTargetMode, targetModeId] + [targetModeId] ) useEffect(() => { diff --git a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx index e78410024f9..b71762f87c7 100644 --- a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx @@ -1,8 +1,19 @@ +import { useAppStateValue, useAppStateView, useAppStateViewActive } from '@/store/app-state-view' import { toast } from 'sonner' +import { shallow } from 'zustand/shallow' import { useShallow } from 'zustand/react/shallow' -import { useAppStore } from '@/store/index' import { - useStructuredChatTabNames, + selectLiveAgentStatusEntriesForWorktree, + selectTerminalLayoutsForWorktree +} from '@/components/sidebar/workspace-card/activity/worktree-agent-row-selectors' +import { + selectRuntimePaneTitlesForWorktree, + selectLivePtyIdsForWorktree +} from '@/components/sidebar/workspace-card/activity/worktree-card-status-inputs' +import { useAppStore } from '@/store/index' +import { selectKnownWorktreeById } from '@/store/selectors' +import { + useWorktreeStructuredChatTabNames, getStructuredChatTabNames } from '@/runtime/structured-chat-name-observation' import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton' @@ -30,7 +41,6 @@ import { track } from '@/lib/telemetry' import { useNow } from '@/hooks/use-now' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' -import { selectLivePtyIdsForWorktree } from '@/components/sidebar/workspace-card/activity/worktree-card-status-inputs' import { useWorktreeAgentRows } from '@/components/sidebar/workspace-card/activity/useWorktreeAgentRows' import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name' import { getAgentRowPrimaryText } from '../../../../../shared/agent-status/presentation/agent-row-primary-text' @@ -69,43 +79,52 @@ export function ReviewNotesSendMenuContent({ }): React.JSX.Element { const hasPrompt = prompt.trim().length > 0 - // Include every workspace agent, not just the focused pane; memoize slice-derived targets to avoid fresh selector arrays. - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) - const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) - const ptyIdsByTabId = useAppStore(useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId))) - const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const viewActive = useAppStateViewActive() + const store = useAppStateView() const agentRows = useWorktreeAgentRows(worktreeId) - const now = useNow(30_000) - const chatNames = useStructuredChatTabNames() - const sendTargets = useMemo(() => { - void agentStatusEpoch - return deriveNotesSendAgentTargets( - { - agentStatusByPaneKey, - tabsByWorktree, - unifiedTabsByWorktree, - terminalLayoutsByTabId, - ptyIdsByTabId, - runtimePaneTitlesByTabId - }, - worktreeId, - chatNames - ) - }, [ - // Freshness timers can change eligibility through this epoch without replacing the status map. - agentStatusEpoch, - chatNames, - agentStatusByPaneKey, - tabsByWorktree, - unifiedTabsByWorktree, - terminalLayoutsByTabId, - runtimePaneTitlesByTabId, - ptyIdsByTabId, - worktreeId - ]) + const now = useNow(30_000, viewActive) + const worktree = useAppStateValue( + useShallow((state) => { + const row = selectKnownWorktreeById(state, worktreeId) + return row + ? { + id: row.id, + hostId: row.hostId, + runtimeOwnerEnvironmentId: row.runtimeOwnerEnvironmentId + } + : null + }) + ) + const tabsKey = useAppStateValue((state) => state.unifiedTabsByWorktree[worktreeId]) + const chatNames = useWorktreeStructuredChatTabNames(worktree, tabsKey, true) + const selectTargets = useMemo(() => { + let inputs: unknown[] = [] + let previous: NotesSendAgentTarget[] = [] + return (state: ReturnType) => { + const nextInputs = [ + state.tabsByWorktree[worktreeId], + state.unifiedTabsByWorktree[worktreeId], + selectLiveAgentStatusEntriesForWorktree(state, worktreeId), + selectTerminalLayoutsForWorktree(state, worktreeId), + selectLivePtyIdsForWorktree(state, worktreeId), + selectRuntimePaneTitlesForWorktree(state, worktreeId), + state.agentStatusEpoch + ] + if (shallow(inputs, nextInputs)) { + return previous + } + inputs = nextInputs + const next = deriveNotesSendAgentTargets(state, worktreeId, chatNames) + if ( + next.length !== previous.length || + next.some((target, i) => !shallow(target, previous[i])) + ) { + previous = next + } + return previous + } + }, [worktreeId, chatNames]) + const sendTargets = useAppStateValue(selectTargets) const orderedSendTargets = useMemo( () => orderSendTargetsByWorktreeAgentRows(sendTargets, agentRows), [agentRows, sendTargets] diff --git a/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts b/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts index 92660c64e79..01258099529 100644 --- a/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts +++ b/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts @@ -33,10 +33,17 @@ export async function discardEditorBuffer(fileId: string): Promise<() => void> { } /** "Don't Save": drop unsaved edits, let saves already writing finish, then close the tab. */ -export async function discardEditorFileChangesAndClose(fileId: string): Promise { +export async function discardEditorFileChangesAndClose( + fileId: string, + tabId?: string +): Promise { const release = await discardEditorBuffer(fileId) try { - useAppStore.getState().closeFile(fileId) + if (tabId) { + useAppStore.getState().closeUnifiedTab(tabId) + } else { + useAppStore.getState().closeFile(fileId) + } } finally { release() } diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 403c82e9c47..4dc0c2a1a98 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -172,6 +172,10 @@ describe('useEditorPanelContentState', () => { mocks.getState.mockReturnValue({ ...createTestStore().getState(), settings: null, + repos: [{ id: 'repo-1', path: '/repo', hostId: 'local' }], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }] + }, openFiles: [], setLastKnownDiskSignature: vi.fn() }) diff --git a/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts index 9e88641ece1..8e876b84625 100644 --- a/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts @@ -1,7 +1,6 @@ import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' -import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' +import { getEditorFileReadContext } from '@/lib/editor-file-operation-owner' import type { OpenFile } from '@/store/slices/editor' -import { getConnectionIdForFile } from '@/lib/connection-context' import { useAppStore } from '@/store' import { getRuntimeGitBranchDiff, @@ -71,11 +70,10 @@ export function useEditorPanelDiffContentLoader({ ? file.branchCompare : null const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null - const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined - const fileTarget = requireRuntimeTargetForFileOwner( + const { connectionId, target: fileTarget } = getEditorFileReadContext( useAppStore.getState(), - file.worktreeId, - file.runtimeEnvironmentId + file, + worktreePath ) const gitScope = getRuntimeGitScope(fileTarget, connectionId) const effectiveDiffSource: typeof file.diffSource = diff --git a/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx index 4da190237f5..8f32bca8676 100644 --- a/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx @@ -172,6 +172,10 @@ describe('useEditorPanelContentState visibility', () => { mocks.getState.mockReturnValue({ ...createTestStore().getState(), settings: null, + repos: [{ id: 'repo-1', path: '/repo', hostId: 'local' }], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }] + }, openFiles: [], setLastKnownDiskSignature: vi.fn() }) diff --git a/src/renderer/src/components/editor/view-mode/useMarkdownPreviewShortcut.ts b/src/renderer/src/components/editor/view-mode/useMarkdownPreviewShortcut.ts index e59acb095ba..05c867e633f 100644 --- a/src/renderer/src/components/editor/view-mode/useMarkdownPreviewShortcut.ts +++ b/src/renderer/src/components/editor/view-mode/useMarkdownPreviewShortcut.ts @@ -1,3 +1,4 @@ +import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement' import { useEffect, type RefObject } from 'react' import { detectLanguage } from '../../../../../shared/editor-language/editor-language-detect' import { getShortcutPlatform } from '@/lib/shortcut-platform' @@ -7,6 +8,7 @@ import { canOpenMarkdownPreview, isMarkdownPreviewShortcut } from './markdown-pr import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type UseMarkdownPreviewShortcutParams = { + activeViewStateId?: string | null activeFile: OpenFile | null panelRef: RefObject openMarkdownPreview: ( @@ -17,12 +19,13 @@ type UseMarkdownPreviewShortcutParams = { runtimeEnvironmentId?: string | null language: string }, - options?: { sourceFileId?: string } + options?: { sourceFileId?: string; placementWorkspaceId?: string; targetGroupId?: string } ) => void } export function useMarkdownPreviewShortcut({ activeFile, + activeViewStateId, panelRef, openMarkdownPreview }: UseMarkdownPreviewShortcutParams): void { @@ -73,12 +76,19 @@ export function useMarkdownPreviewShortcut({ runtimeEnvironmentId: activeFileRuntimeEnvironmentId, language: shortcutLanguage }, - { sourceFileId: activeFileId ?? undefined } + { + sourceFileId: activeFileId ?? undefined, + ...(activeFile + ? editorViewPlacement(useAppStore.getState(), activeFile, activeViewStateId) + : {}) + } ) } addAppKeyListener('keydown', handleKeyDown) return () => removeAppKeyListener('keydown', handleKeyDown) }, [ + activeFile, + activeViewStateId, activeFileDiffSource, activeFileMode, activeFilePath, diff --git a/src/renderer/src/components/file-actions/OpenInContextSubmenu.tsx b/src/renderer/src/components/file-actions/OpenInContextSubmenu.tsx index 40611a873df..aefd3ef328e 100644 --- a/src/renderer/src/components/file-actions/OpenInContextSubmenu.tsx +++ b/src/renderer/src/components/file-actions/OpenInContextSubmenu.tsx @@ -1,3 +1,4 @@ +import { useAppStateValue } from '@/store/app-state-view' import React, { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { ExternalLink, FolderOpen } from 'lucide-react' @@ -8,7 +9,6 @@ import { ContextMenuSubContent, ContextMenuSubTrigger } from '@/components/ui/context-menu' -import { useAppStore } from '@/store/index' import { getRouteForLocalPathOpenOwner } from '@/lib/local-path-open-guard' import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { getWorkspaceEditorSshHost } from '@/lib/workspace-editor-ssh-target' @@ -33,12 +33,14 @@ export function OpenInContextSubmenu({ workspaceId, owner }: OpenInContextSubmenuProps): React.JSX.Element { - const openInApplications = useAppStore( + const openInApplications = useAppStateValue( (s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS ) const openInRoute = useMemo(() => getRouteForLocalPathOpenOwner(owner), [owner]) const workspace = useMemo(() => (workspaceId ? { workspaceId } : null), [workspaceId]) - const runtimeSshHost = useAppStore(useShallow((s) => getWorkspaceEditorSshHost(s, workspace))) + const runtimeSshHost = useAppStateValue( + useShallow((s) => getWorkspaceEditorSshHost(s, workspace)) + ) const handleOpen = useCallback( (application: OpenInApplication) => { diff --git a/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx b/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx index 7f427bed39f..1a0857eaf81 100644 --- a/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx +++ b/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx @@ -1,6 +1,6 @@ +import { useAppStateValue } from '@/store/app-state-view' import { translate } from '@/i18n/i18n' import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' -import { useAppStore } from '@/store' import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { Button } from '../ui/button' @@ -21,7 +21,7 @@ export function ManagedRuntimeRemoveDialog(props: { onRemove: () => void }): React.JSX.Element { const { agentLabel, host, open, onOpenChange, onRemove } = props - const hostLabel = useAppStore((state) => + const hostLabel = useAppStateValue((state) => host.kind === 'environment' ? selectExecutionHostDisplayLabel(state, toRuntimeExecutionHostId(host.environmentId)) : translate('components.managed-runtime.thisComputer', 'this computer') diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.body-work.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.body-work.test.tsx new file mode 100644 index 00000000000..6cb8cba514f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.body-work.test.tsx @@ -0,0 +1,693 @@ +import { getDefaultSettings } from '../../../../shared/constants' +// @vitest-environment happy-dom +import { act, Profiler, type ReactNode } from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/worktree/types' +import type * as WorktreeGitCopy from '@/lib/worktree-git-copy' +import type * as GitStatusClient from '@/runtime/runtime-git-status-client' +import type * as GitDiffClient from '@/runtime/runtime-git-diff-client' +import type * as PanelModule from './source-control/panel/panel' +import type * as RepoClient from '@/runtime/runtime-repo-client' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { TooltipProvider } from '@/components/ui/tooltip' +import FolderWorkspaceSourceControlPanel from './FolderWorkspaceSourceControlPanel' +import { SourceControlPanel } from './source-control/panel/panel' +import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context' +import { createRunningCommitMessageGenerationRecord } from '@/store/slices/commit-message-generation' +import type { PullRequestGenerationRecord } from '@/store/slices/pull-request-generation-state' + +const probes = vi.hoisted(() => ({ + status: vi.fn(), + upstreamRef: vi.fn(), + compare: vi.fn(), + history: vi.fn(), + submodules: vi.fn(), + upstream: vi.fn(), + eligibility: vi.fn(), + visible: new Set(), + bodyRenders: new Map() +})) +const pending = (): Promise => new Promise(() => {}) + +vi.mock('./source-control/panel/panel', async (original) => { + const { SourceControlPanel: Panel } = await original() + const { useSourceControlScope } = await import('./source-control/panel/worktree-scope') + return { + SourceControlPanel: () => { + const id = useSourceControlScope()?.worktreeId ?? 'selected' + return ( + probes.bodyRenders.set(id, (probes.bodyRenders.get(id) ?? 0) + 1)} + > + + + ) + } + } +}) + +vi.mock('./source-control/sync/git-status-refresh', () => ({ + refreshGitStatusForWorktree: async (args: { + worktreeId: string + request?: { onStatusAccepted?: (status: unknown) => void } + }) => { + probes.status(args.worktreeId) + args.request?.onStatusAccepted?.({ + entries: [], + conflictOperation: 'unknown', + branch: 'refs/heads/feature', + upstreamStatus: { hasUpstream: true, upstreamName: 'origin/feature', ahead: 0, behind: 0 } + }) + } +})) +vi.mock('./source-control/sync/use-refresh-on-host-contact', () => ({ + useRefreshOnHostContact: () => {} +})) +vi.mock('@/lib/worktree-git-copy', async (original) => ({ + ...(await original()), + resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({ + worktreeId: id, + hostId, + transport: { kind: 'local' } + }) +})) +vi.mock('@/runtime/runtime-git-status-client', async (original) => ({ + ...(await original()), + setRuntimeGitStatusUpstreamRefWatch: async (context: { worktreeId: string }) => + probes.upstreamRef(context.worktreeId), + getRuntimeGitHistory: (context: { worktreeId: string }) => { + probes.history(context.worktreeId) + return pending() + }, + getRuntimeGitSubmoduleStatus: (context: { worktreeId: string }) => { + probes.submodules(context.worktreeId) + return pending() + } +})) +vi.mock('@/runtime/runtime-git-diff-client', async (original) => ({ + ...(await original()), + getRuntimeGitBranchCompare: async (context: { worktreeId: string }, baseRef: string) => { + probes.compare(context.worktreeId) + return { + summary: { + baseRef, + baseOid: 'base', + compareRef: 'feature', + headOid: 'head', + mergeBase: 'base', + changedFiles: 0, + status: 'ready' + }, + entries: [] + } + } +})) +vi.mock('@/runtime/runtime-repo-client', async (original) => ({ + ...(await original()), + watchRuntimeRepoBaseRefDefault: () => () => {}, + searchRuntimeRepoBaseRefs: async () => ['feature/live'] +})) + +// Report a section on screen only when the test made it visible, as a scrolled sidebar would. +const observed = new Map() +class ViewportIntersectionObserver implements IntersectionObserver { + readonly root = null + readonly rootMargin = '0px' + readonly scrollMargin = '0px' + readonly thresholds = [0] + constructor(private readonly callback: IntersectionObserverCallback) {} + report(target: Element): void { + const id = target.getAttribute('data-source-control-worktree') ?? '' + const bounds = target.getBoundingClientRect() + const entry: IntersectionObserverEntry = { + target, + isIntersecting: probes.visible.has(id), + boundingClientRect: bounds, + intersectionRect: bounds, + rootBounds: bounds, + intersectionRatio: probes.visible.has(id) ? 1 : 0, + time: performance.now() + } + this.callback([entry], this) + } + observe(target: Element): void { + observed.set(target, this) + this.report(target) + } + unobserve(target: Element): void { + observed.delete(target) + } + disconnect(): void { + for (const [target, observer] of observed) { + if (observer === this) { + observed.delete(target) + } + } + } + takeRecords(): IntersectionObserverEntry[] { + return [] + } +} +async function scrollTo(ids: readonly string[]): Promise { + probes.visible.clear() + for (const id of ids) { + probes.visible.add(id) + } + await act(async () => { + for (const [target, observer] of observed) { + if (observer instanceof ViewportIntersectionObserver) { + observer.report(target) + } + } + }) +} +function writeUnrelatedGeneration(): void { + const context = { + worktreeId: 'other::/elsewhere', + worktreePath: '/elsewhere', + requestId: 1, + runtimeTarget: { kind: 'local' as const } + } + const pr: PullRequestGenerationRecord = { + context: { ...context, repoId: 'other', branch: 'other' }, + seed: { base: 'main', title: '', body: '', draft: false }, + seedFieldRevisions: { base: 0, title: 0, body: 0, draft: 0 }, + requiresPushBeforeCreate: false, + status: 'running', + result: null, + error: null, + hydrated: false + } + useAppStore + .getState() + .setCommitMessageGenerationRecord( + context.worktreeId, + createRunningCommitMessageGenerationRecord(context) + ) + useAppStore.getState().setPullRequestGenerationRecord(context.worktreeId, pr) +} + +const BODY_OPERATIONS = ['compare', 'history', 'submodules', 'upstream', 'eligibility'] as const +let root: Root +let container: HTMLDivElement +let renders = 0 +const refuseConfirmation = async () => false + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('IntersectionObserver', ViewportIntersectionObserver) + const namespace = new Proxy( + {}, + { + get: (_target, method) => + typeof method === 'string' && method.startsWith('on') ? () => () => {} : pending + } + ) + vi.stubGlobal('api', new Proxy({}, { get: () => namespace })) + vi.useFakeTimers() + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + for (const probe of Object.values(probes)) { + if (typeof probe === 'function') { + probe.mockClear() + } + } + probes.visible.clear() + observed.clear() + renders = 0 +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function setup(count: number): Worktree[] { + const worktrees: Worktree[] = Array.from({ length: count }, (_, i) => ({ + id: `repo::/child-${i}`, + repoId: 'repo', + path: `/child-${i}`, + branch: `refs/heads/feature-${i}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + displayName: `Child ${i}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: i, + lastActivityAt: 0, + hostId: 'local' as const, + instanceId: `instance-${i}` + })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies the catalog, status and probed actions the panels read. + useAppStore.setState({ + settings: getDefaultSettings('/tmp'), + activeWorktreeId: 'folder:feature', + activeWorkspaceKey: 'folder:feature', + activeWorkspaceExecutionHostId: 'local', + rightSidebarOpen: true, + rightSidebarTab: 'source-control', + repos: [ + { + id: 'repo', + displayName: 'Child', + path: '/repo', + kind: 'git', + worktreeBaseRef: 'origin/main' + } + ], + worktreesByRepo: { repo: worktrees }, + folderWorkspaces: [ + { id: 'feature', name: 'Feature', folderPath: '/folder', projectGroupId: 'group' } + ], + workspaceLineageByChildKey: Object.fromEntries( + worktrees.map((row, i) => [ + row.id, + { + childWorkspaceKey: `worktree:${row.id}`, + childInstanceId: row.instanceId, + parentWorkspaceKey: 'folder:feature', + parentInstanceId: null, + createdAt: i + } + ]) + ), + // Changed sections start expanded, so every body would mount without the viewport gate. + gitStatusByWorktree: Object.fromEntries( + worktrees.map((row) => [row.id, [{ path: 'a.ts', status: 'modified', area: 'unstaged' }]]) + ), + fetchUpstreamStatus: async (worktreeId: string) => { + probes.upstream(worktreeId) + }, + getHostedReviewCreationEligibility: (args: { worktreePath?: string }) => { + probes.eligibility(args.worktreePath) + return pending() + }, + fetchHostedReviewForBranch: async () => null, + fetchPRForBranch: async () => null + } as unknown as Partial) + return worktrees +} + +async function render(node: ReactNode): Promise { + await act(async () => + root.render( + + + (renders += 1)}> + {node} + + + + ) + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) +} + +function bodyWork(worktrees: readonly Worktree[]) { + const idByPath = new Map(worktrees.map((row) => [row.path, row.id])) + return Object.fromEntries( + BODY_OPERATIONS.map((operation) => { + const ids = probes[operation].mock.calls.map( + ([key]: unknown[]) => idByPath.get(String(key)) ?? String(key) + ) + return [operation, { calls: ids.length, worktrees: [...new Set(ids)].sort() }] + }) + ) +} + +it('runs expanded body work only for on-screen sections, at mount and over the safety interval', async () => { + const counts: unknown[] = [] + for (const count of [3, 30]) { + useAppStore.setState(useAppStore.getInitialState(), true) + const worktrees = setup(count) + const visible = [worktrees[0]!.id, worktrees[1]!.id] + probes.visible.clear() + for (const id of visible) { + probes.visible.add(id) + } + for (const probe of [probes.upstreamRef, ...BODY_OPERATIONS.map((name) => probes[name])]) { + probe.mockClear() + } + await render() + const atMount = bodyWork(worktrees) + expect(atMount.upstream!.calls).toBeGreaterThan(0) + expect(atMount.compare!.worktrees).toEqual(visible.toSorted()) + expect(atMount.compare!.calls).toBeGreaterThanOrEqual(visible.length) + const intervals: ReturnType[] = [] + for (let tick = 0; tick < 2; tick++) { + for (const operation of BODY_OPERATIONS) { + probes[operation].mockClear() + } + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + intervals.push(bodyWork(worktrees)) + } + for (const snapshot of [atMount, ...intervals]) { + for (const work of Object.values(snapshot)) { + expect(work.worktrees.every((id) => visible.includes(id))).toBe(true) + expect(work.calls).toBeLessThanOrEqual(visible.length) + } + } + // Only the selected worktree binds an upstream ref; sections never add resolutions. + expect(probes.upstreamRef).not.toHaveBeenCalled() + counts.push({ atMount, intervals }) + await act(async () => root.render(null)) + } + // Ten times the attached worktrees costs the same when the same sections are on screen. + expect(counts[1]).toEqual(counts[0]) +}) + +it.each([3, 30])( + 'freezes collapsed and off-screen bodies among %i sections on unrelated app writes', + async (count) => { + const worktrees = setup(count) + const [active, collapsed, offScreen] = worktrees + for (const row of worktrees) { + probes.visible.add(row.id) + } + await render() + const collapsedSection = container.querySelector( + `[data-source-control-worktree="${collapsed!.id}"]` + )! + await act(async () => + collapsedSection.querySelector('button[aria-expanded]')!.click() + ) + await scrollTo(worktrees.filter((row) => row !== offScreen).map((row) => row.id)) + const writes = [ + () => + useAppStore.setState((state) => ({ + worktreesByRepo: { + repo: state.worktreesByRepo.repo!.map((row) => + row.id === collapsed!.id ? { ...row, lastActivityAt: 99 } : row + ) + } + })), + () => + useAppStore.setState((state) => ({ + settings: { + ...state.settings!, + theme: state.settings?.theme === 'dark' ? 'light' : 'dark' + } + })), + () => + useAppStore.setState((state) => ({ + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + 'folder:feature': 'other-group' + }, + activeTabTypeByWorktree: { + ...state.activeTabTypeByWorktree, + 'folder:feature': 'terminal' + } + })), + () => + useAppStore.setState((state) => ({ + groupsByWorktree: { + ...state.groupsByWorktree, + 'folder:feature': [ + { id: 'other-group', worktreeId: 'folder:feature', tabOrder: [], activeTabId: null } + ] + } + })), + () => + useAppStore.setState((state) => ({ + openFiles: [ + ...state.openFiles, + { + id: 'unrelated-file', + worktreeId: 'elsewhere', + filePath: '/elsewhere/other.ts', + relativePath: 'other.ts', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ] + })), + () => + useAppStore.setState((state) => ({ + gitStatusByWorktree: { + ...state.gitStatusByWorktree, + elsewhere: [{ path: 'other.ts', status: 'modified', area: 'unstaged' }] + } + })), + writeUnrelatedGeneration + ] + for (const [writeIndex, write] of writes.entries()) { + probes.bodyRenders.clear() + probes.eligibility.mockClear() + await act(async () => write()) + for (const row of worktrees) { + expect(probes.bodyRenders.get(row.id) ?? 0, `write ${writeIndex}, owner ${row.id}`).toBe(0) + } + expect(probes.eligibility).not.toHaveBeenCalled() + } + probes.bodyRenders.clear() + await act(async () => + useAppStore.setState((state) => ({ + openFiles: [ + ...state.openFiles, + { + id: 'active-file', + worktreeId: active!.id, + filePath: `${active!.path}/a.ts`, + relativePath: 'a.ts', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ], + activeFileIdByWorktree: { + ...state.activeFileIdByWorktree, + 'folder:feature': 'active-file' + }, + activeTabTypeByWorktree: { ...state.activeTabTypeByWorktree, 'folder:feature': 'editor' } + })) + ) + expect( + container + .querySelector( + `[data-source-control-worktree="${active!.id}"] [data-source-control-path="a.ts"]` + ) + ?.getAttribute('data-current') + ).toBe('true') + expect(probes.bodyRenders.get(active!.id)).toBeGreaterThan(0) + for (const row of worktrees.slice(1)) { + expect(probes.bodyRenders.get(row.id) ?? 0).toBe(0) + } + // The frozen views ignore their own status and owner changes, while a relevant live update is visible. + probes.bodyRenders.clear() + await act(async () => + useAppStore.setState((state) => ({ + gitStatusByWorktree: { + ...state.gitStatusByWorktree, + [active!.id]: [ + ...state.gitStatusByWorktree[active!.id]!, + { path: 'visible.ts', status: 'modified', area: 'unstaged' } + ], + [offScreen!.id]: [ + ...state.gitStatusByWorktree[offScreen!.id]!, + { path: 'hidden.ts', status: 'modified', area: 'unstaged' } + ] + } + })) + ) + expect(probes.bodyRenders.get(active!.id)).toBeGreaterThan(0) + expect(probes.bodyRenders.get(collapsed!.id) ?? 0).toBe(0) + expect(probes.bodyRenders.get(offScreen!.id) ?? 0).toBe(0) + expect( + container.querySelector(`[data-source-control-worktree="${active!.id}"]`)!.textContent + ).toContain('visible.ts') + await scrollTo(worktrees.map((row) => row.id)) + expect( + container.querySelector(`[data-source-control-worktree="${offScreen!.id}"]`)!.textContent + ).toContain('hidden.ts') + } +) + +it('keeps the selected workspace panel idle when another worktree writes generation records', async () => { + const [worktree] = setup(1) + useAppStore.setState({ + activeWorktreeId: worktree!.id, + activeWorkspaceKey: `worktree:${worktree!.id}` + }) + await render() + expect(probes.eligibility).toHaveBeenCalled() + probes.eligibility.mockClear() + renders = 0 + await act(async () => writeUnrelatedGeneration()) + expect(probes.eligibility).not.toHaveBeenCalled() + expect(renders).toBe(0) +}) + +it.each(['off-screen', 'collapsed'])( + 'keeps selection and draft while %s and resumes the current owner snapshot', + async (inactive) => { + const worktrees = setup(3) + const [first] = worktrees + await scrollTo([first!.id]) + await render() + const section = container.querySelector(`[data-source-control-worktree="${first!.id}"]`) + const row = section?.querySelector('[data-testid="source-control-entry"]') + expect(row).toBeTruthy() + // Both modifiers select on either platform without triggering split open. + await act(async () => + row!.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true, metaKey: true })) + ) + expect(section?.textContent).toContain('1 selected') + + const draft = section!.querySelector('textarea')! + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set! + await act(async () => { + setValue.call(draft, 'fix: keep my draft') + draft.dispatchEvent(new Event('input', { bubbles: true })) + draft.dispatchEvent(new Event('change', { bubbles: true })) + }) + expect(draft.value).toBe('fix: keep my draft') + await (inactive === 'off-screen' + ? scrollTo([]) + : act(async () => + section!.querySelector('button[aria-expanded]')!.click() + )) + await act(async () => + useAppStore.setState((state) => ({ + worktreesByRepo: { + repo: state.worktreesByRepo.repo!.map((row) => + row.id === first!.id ? { ...row, branch: 'refs/heads/current-branch' } : row + ) + }, + gitStatusByWorktree: { + ...state.gitStatusByWorktree, + [first!.id]: [ + ...state.gitStatusByWorktree[first!.id]!, + { path: 'latest.ts', status: 'modified', area: 'unstaged' } + ] + } + })) + ) + expect(section!.querySelector('[data-source-control-path="latest.ts"]')).toBeNull() + for (const operation of BODY_OPERATIONS) { + probes[operation].mockClear() + } + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + // A mounted but off-screen body keeps its view and runs nothing. + for (const operation of BODY_OPERATIONS) { + expect(probes[operation]).not.toHaveBeenCalled() + } + + await (inactive === 'off-screen' + ? scrollTo([first!.id]) + : act(async () => + section!.querySelector('button[aria-expanded]')!.click() + )) + expect(section?.textContent).toContain('1 selected') + expect(section!.querySelector('[data-source-control-path="latest.ts"]')).toBeTruthy() + expect(section!.querySelector('textarea')!.value).toBe( + 'fix: keep my draft' + ) + } +) + +it('opens a deleted section entry as its diff from Open in File Explorer', async () => { + const [owner] = setup(1) + useAppStore.setState({ + gitStatusByWorktree: { + [owner!.id]: [{ path: 'deleted.ts', status: 'deleted', area: 'unstaged' }] + } + }) + probes.visible.add(owner!.id) + const openDiff = vi.spyOn(useAppStore.getState(), 'openDiff').mockImplementation(() => {}) + const openFile = vi.spyOn(useAppStore.getState(), 'openFile').mockImplementation(() => 'unused') + await render() + const row = container.querySelector('[data-testid="source-control-entry"]')! + await act(async () => + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, button: 2 })) + ) + const item = [...document.querySelectorAll('[role="menuitem"]')].find((node) => + node.textContent?.includes('Open in File Explorer') + ) + expect(item).toBeTruthy() + await act(async () => item!.click()) + expect(openDiff).toHaveBeenCalledWith( + owner!.id, + '/child-0/deleted.ts', + 'deleted.ts', + 'typescript', + false, + expect.objectContaining({ placementWorkspaceId: 'folder:feature' }) + ) + expect(openFile).not.toHaveBeenCalled() +}) + +it('split-opens child content in the current placement group after a focus change', async () => { + const [owner] = setup(1) + probes.visible.add(owner!.id) + const createSplit = vi.fn(() => 'new-split') + const openDiff = vi.fn() + useAppStore.setState({ createEmptySplitGroup: createSplit, openDiff }) + await render() + await act(async () => + useAppStore.setState({ + activeGroupIdByWorktree: { 'folder:feature': 'current-group' }, + groupsByWorktree: { + 'folder:feature': [ + { id: 'current-group', worktreeId: 'folder:feature', tabOrder: [], activeTabId: null } + ] + } + }) + ) + const row = container.querySelector('[data-testid="source-control-entry"]')! + await act(async () => row.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true }))) + expect(createSplit).toHaveBeenCalledWith('folder:feature', 'current-group', 'right') + expect(openDiff).toHaveBeenCalledWith( + owner!.id, + '/child-0/a.ts', + 'a.ts', + 'typescript', + false, + expect.objectContaining({ + placementWorkspaceId: 'folder:feature', + targetGroupId: 'new-split', + preview: false + }) + ) +}) + +it('keeps an owned base-ref dialog usable after its section leaves the viewport and collapses', async () => { + const worktrees = setup(3) + probes.visible.add(worktrees[0]!.id) + await render() + const changeBase = screen.getByRole('button', { name: /Change base ref/ }) + await act(async () => fireEvent.click(changeBase)) + await scrollTo([]) + const section = container.querySelector('section')! + const collapse = section.querySelector('button[aria-expanded="true"]')! + await act(async () => fireEvent.click(collapse)) + await act(async () => { + fireEvent.change(screen.getByPlaceholderText('Search branches by name...'), { + target: { value: 'feature' } + }) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(screen.getByRole('button', { name: 'feature/live' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.test.tsx new file mode 100644 index 00000000000..5d135c6e51a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.test.tsx @@ -0,0 +1,380 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import type { Worktree } from '../../../../shared/worktree/types' +import type * as GitIdentityDisplay from '../../../../shared/worktree/git-identity-display' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { TooltipProvider } from '@/components/ui/tooltip' +import FolderWorkspaceSourceControlPanel from './FolderWorkspaceSourceControlPanel' +import { useSourceControlWatchConsumers } from '@/hooks/source-control-watch-consumers' +import { + useSourceControlScope, + type SourceControlScope +} from './source-control/panel/worktree-scope' +import { useWorktreeGitStatusObservation } from './source-control/sync/use-worktree-git-status-observation' +import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event' + +const probes = vi.hoisted(() => ({ + status: vi.fn(async (_request: { worktreeId: string }) => {}), + body: vi.fn(), + header: vi.fn(), + watches: new Array() +})) +vi.mock('./source-control/sync/git-status-refresh', () => ({ + refreshGitStatusForWorktree: probes.status +})) +vi.mock('./source-control/sync/use-git-status-upstream-ref-watch', () => ({ + useGitStatusUpstreamRefWatch: () => undefined +})) +vi.mock('./source-control/sync/use-refresh-on-host-contact', () => ({ + useRefreshOnHostContact: () => {} +})) +vi.mock('@/lib/worktree-git-copy', () => ({ + resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({ + worktreeId: id, + hostId, + transport: { kind: 'local' } + }) +})) +vi.mock('../../../../shared/worktree/git-identity-display', async (original) => { + const actual = await original() + return { + ...actual, + getWorktreeGitIdentityDisplay: ( + ...args: Parameters + ) => { + probes.header(args[0].branch) + return actual.getWorktreeGitIdentityDisplay(...args) + } + } +}) +vi.mock('./source-control/panel/panel', () => ({ + SourceControlPanel: () => { + const scope = useSourceControlScope() + probes.body(scope?.worktreeId) + return
+ } +})) + +// Unlike happy-dom, report every observed section as on screen. +class OnScreenIntersectionObserver { + private readonly callback: IntersectionObserverCallback + constructor(callback: IntersectionObserverCallback) { + this.callback = callback + } + observe(target: Element): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the section hook reads only target and isIntersecting. + this.callback([{ target, isIntersecting: true } as IntersectionObserverEntry], this as never) + } + unobserve(): void {} + disconnect(): void {} +} + +let root: Root +let container: HTMLDivElement +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('IntersectionObserver', OnScreenIntersectionObserver) + vi.useFakeTimers() + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + probes.status.mockClear() + probes.body.mockClear() + probes.header.mockClear() +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) +function Watches() { + probes.watches = useSourceControlWatchConsumers().map((target) => target.worktreeId) + return null +} +function setup(count: number) { + const worktrees: Worktree[] = Array.from({ length: count }, (_, i) => ({ + id: `repo::/child-${i}`, + repoId: 'repo', + path: `/child-${i}`, + branch: `feature-${i}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + displayName: `Child ${i}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: i, + lastActivityAt: 0, + hostId: 'local' as const, + instanceId: `instance-${i}` + })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies the catalog and status fields consumed by the folder panel. + useAppStore.setState({ + activeWorktreeId: 'folder:feature', + activeWorkspaceKey: 'folder:feature', + activeWorkspaceExecutionHostId: 'local', + rightSidebarOpen: true, + rightSidebarTab: 'source-control', + repos: [{ id: 'repo', displayName: 'Child', path: '/repo', kind: 'git' }], + worktreesByRepo: { repo: worktrees }, + folderWorkspaces: [ + { id: 'feature', name: 'Feature', folderPath: '/folder', projectGroupId: 'group' } + ], + workspaceLineageByChildKey: Object.fromEntries( + worktrees.map((row, i) => [ + row.id, + { + childWorkspaceKey: `worktree:${row.id}`, + childInstanceId: row.instanceId, + parentWorkspaceKey: 'folder:feature', + parentInstanceId: null, + createdAt: i + } + ]) + ), + gitStatusByWorktree: Object.fromEntries(worktrees.map((row) => [row.id, []])) + } as unknown as Partial) + return worktrees +} +async function render(visible = true) { + await act(async () => + root.render( + + + {visible && } + + ) + ) +} + +it.each([3, 30])( + 'bounds observation, watches and unrelated renders for %i attached worktrees', + async (count) => { + const worktrees = setup(count) + await render() + // One status per visible repository is necessary for its badge; collapsed bodies do no compare work. + expect(probes.status).toHaveBeenCalledTimes(count) + expect(probes.body).not.toHaveBeenCalled() + // Every mounted section watches its tree, collapsed ones included (R4). + expect(probes.watches).toEqual(worktrees.map((row) => row.id)) + probes.header.mockClear() + await act(async () => useAppStore.setState({ activeFileId: 'unrelated-file' })) + expect(probes.header).not.toHaveBeenCalled() + await act(async () => + useAppStore.setState({ + sshConnectionStates: new Map([ + [ + 'other-host', + { targetId: 'other-host', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]) + }) + ) + expect(probes.header).not.toHaveBeenCalled() + await act(async () => + useAppStore.setState((state) => ({ + gitStatusByWorktree: { + ...state.gitStatusByWorktree, + [worktrees[0]!.id]: [{ path: 'a.ts', status: 'modified', area: 'unstaged' }] + } + })) + ) + expect(probes.header.mock.calls).toEqual([['feature-0']]) + expect(probes.body).not.toHaveBeenCalled() + // Agent changes wake the matching collapsed badge, using the existing scheduler's coalescing. + probes.status.mockClear() + await act(async () => { + window.dispatchEvent( + new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, { + detail: { + runtimeEnvironmentId: null, + payload: { + worktreePath: worktrees[0]!.path, + events: [{ absolutePath: `${worktrees[0]!.path}/a.ts`, kind: 'change' }] + } + } + }) + ) + await vi.advanceTimersByTimeAsync(4000) + }) + expect(probes.status).toHaveBeenCalledTimes(1) + expect(probes.status.mock.calls[0]?.[0]?.worktreeId).toBe(worktrees[0]!.id) + const toggles = container.querySelectorAll('button[aria-expanded]') + for (const toggle of toggles) { + await act(async () => toggle.click()) + } + expect(probes.body).toHaveBeenCalledTimes(count) + expect(probes.watches).toEqual(worktrees.map((row) => row.id)) + await render(false) + probes.status.mockClear() + await act(async () => { + await vi.advanceTimersByTimeAsync(120000) + }) + expect(probes.status).not.toHaveBeenCalled() + expect(probes.watches).toEqual([]) + } +) + +function ActiveStatusObserver() { + useWorktreeGitStatusObservation(null) + return null +} + +function ScopedStatusObserver({ owner }: { owner: SourceControlScope }) { + useWorktreeGitStatusObservation(owner, { enabled: true }) + return null +} + +it('filters file events by the section runtime host when a same-id local twin exists', async () => { + const [local] = setup(1) + const remote = { ...local!, hostId: 'runtime:paired' as const, instanceId: 'remote-instance' } + const repo = useAppStore.getState().repos[0]! + const owner: SourceControlScope = { + worktreeId: remote.id, + repoId: remote.repoId, + executionHostId: 'runtime:paired', + worktree: remote, + repo, + attachedAt: 0, + placementWorkspaceId: 'folder:feature' + } + useAppStore.setState({ worktreesByRepo: { repo: [local!, remote] } }) + await act(async () => root.render()) + expect(probes.status).toHaveBeenCalledTimes(1) + probes.status.mockClear() + for (const runtimeEnvironmentId of [null, 'other-runtime', 'paired']) { + await act(async () => { + window.dispatchEvent( + new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, { + detail: { + runtimeEnvironmentId, + payload: { + worktreePath: remote.path, + events: [{ absolutePath: `${remote.path}/a.ts`, kind: 'change' }] + } + } + }) + ) + await vi.advanceTimersByTimeAsync(4000) + }) + expect(probes.status).toHaveBeenCalledTimes(runtimeEnvironmentId === 'paired' ? 1 : 0) + } +}) + +it('refreshes an open diff after a file event with the sidebar closed', async () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mac') + const [worktree] = setup(1) + const path = '/Users/test/Library/Containers/app/worktree' + useAppStore.setState({ + activeWorktreeId: worktree!.id, + activeWorkspaceKey: `worktree:${worktree!.id}`, + worktreesByRepo: { repo: [{ ...worktree!, path }] }, + rightSidebarOpen: false, + openFiles: [ + { + id: 'open-diff', + filePath: `${path}/a.ts`, + relativePath: 'a.ts', + worktreeId: worktree!.id, + language: 'typescript', + isDirty: false, + mode: 'diff' + } + ] + }) + await act(async () => root.render()) + expect(probes.status).toHaveBeenCalledTimes(1) + probes.status.mockClear() + await act(async () => { + window.dispatchEvent( + new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, { + detail: { + runtimeEnvironmentId: null, + payload: { + worktreePath: path, + events: [{ absolutePath: `${path}/a.ts`, kind: 'change' }] + } + } + }) + ) + await vi.advanceTimersByTimeAsync(4000) + }) + expect(probes.status).toHaveBeenCalledTimes(1) + expect(probes.status.mock.calls[0]?.[0]?.worktreeId).toBe(worktree!.id) +}) + +it('includes a paired-runtime descendant of a local attached worktree as a section', async () => { + const [parent] = setup(1) + const remote = { + ...parent!, + id: 'remote-repo::/remote-child', + repoId: 'remote-repo', + path: '/remote-child', + displayName: 'Remote child', + hostId: 'runtime:paired' as const, + runtimeOwnerEnvironmentId: 'paired', + instanceId: 'remote-instance' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared lineage selector reads only the status descriptor's installation identity. + useAppStore.setState({ + repos: [ + ...useAppStore.getState().repos, + { + id: 'remote-repo', + displayName: 'Remote', + path: '/remote', + kind: 'git', + executionHostId: 'runtime:paired' + } + ], + worktreesByRepo: { repo: [parent!], 'remote-repo': [remote] }, + ownLineageInstallationId: 'local-install', + runtimeStatusByEnvironmentId: new Map([ + [ + 'paired', + { + status: { hostDescriptor: { installationId: 'remote-install' } }, + checkedAt: 1 + } + ] + ]), + crossStoreLineageByStoreKey: { + paired: { + [`worktree:${remote.id}`]: { + childWorkspaceKey: `worktree:${remote.id}`, + childInstanceId: 'remote-instance', + parent: { + installationId: 'local-install', + workspaceKey: `worktree:${parent!.id}`, + instanceId: 'instance-0' + }, + origin: 'manual', + capture: { source: 'explicit-ui', confidence: 'explicit' }, + createdAt: 2 + } + } + }, + gitStatusByWorktree: { [parent!.id]: [], [remote.id]: [] } + } as unknown as Partial) + await render() + expect( + [...container.querySelectorAll('section')].map((section) => + section.getAttribute('data-source-control-worktree') + ) + ).toEqual([parent!.id, 'remote-repo::/remote-child']) + expect(probes.status.mock.calls.map(([request]) => request.worktreeId)).toEqual([ + parent!.id, + 'remote-repo::/remote-child' + ]) +}) diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.tsx new file mode 100644 index 00000000000..45672dee42f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.tsx @@ -0,0 +1,320 @@ +import { + selectCrossStoreFolderChildrenById, + selectSidebarLineageParentIdentities +} from '@/store/cross-store-lineage-parents' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { ArrowDown, ArrowUp, ArrowUpRight, ChevronRight, GitBranch, RefreshCw } from 'lucide-react' +import { useAppStore } from '@/store' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { + getAttachedWorktreesForFolderWorkspace, + attachedWorktreeOwnerKey +} from './folder-workspace-attached-worktrees' +import { resolveReviewRepository } from '@/lib/review-owner' +import { translate } from '@/i18n/i18n' +import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' +import { OverlayActivityOwner } from '@/components/ui/use-overlay-focus-scope' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { getWorktreeGitIdentityDisplay } from '../../../../shared/worktree/git-identity-display' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { SourceControlPanel } from './source-control/panel/panel' +import { + SourceControlScopeProvider, + type SourceControlScope +} from './source-control/panel/worktree-scope' +import { useWorktreeGitStatusObservation } from './source-control/sync/useGitStatusPolling' +import { useSourceControlWatchConsumer } from '@/hooks/source-control-watch-consumers' +import { useSectionOnScreen } from './source-control/panel/section-viewport-presence' +import { sourceControlFileWatchRuntimeEnvironmentId } from './source-control/sync/worktree-git-owner-target' +const EMPTY_ENTRIES: readonly unknown[] = [] + +type SectionProps = { + owner: SourceControlScope + scrollRoot: Element | null + refreshers: Set<() => void> +} + +const WorktreeSection = memo( + function WorktreeSection({ owner, scrollRoot, refreshers }: SectionProps) { + useUiLanguageRevision() + const id = owner.worktreeId + const entries = useAppStore((state) => state.gitStatusByWorktree[id] ?? EMPTY_ENTRIES) + const statusKnown = useAppStore((state) => Object.hasOwn(state.gitStatusByWorktree, id)) + const remote = useAppStore((state) => state.remoteStatusesByWorktree[id]) + const error = useAppStore((state) => state.gitStatusReadErrorByWorktree[id]) + const huge = useAppStore((state) => state.gitStatusHugeByWorktree[id]) + const connected = useAppStore( + (state) => + !owner.repo.connectionId || + state.sshConnectionStates.get(owner.repo.connectionId)?.status === 'connected' + ) + const [expanded, setExpanded] = useState() + const isExpanded = expanded ?? (statusKnown ? entries.length > 0 : true) + // Decide once at the first result; later agent edits update badges without undoing a user's collapse. + if (expanded === undefined && statusKnown) { + setExpanded(entries.length > 0) + } + const refreshNow = useWorktreeGitStatusObservation(owner, { enabled: true }) + useEffect(() => { + refreshers.add(refreshNow) + return () => { + refreshers.delete(refreshNow) + } + }, [refreshNow, refreshers]) + const [sectionRef, onScreen] = useSectionOnScreen(scrollRoot) + const bodyShown = + isExpanded && connected && (statusKnown || error !== undefined || isFolderRepo(owner.repo)) + // Open overlays keep their section live until they close, including while off screen. + const [openOverlays, setOpenOverlays] = useState(() => new Set()) + const onOverlayActivity = useCallback((overlay: HTMLElement, open: boolean) => { + setOpenOverlays((current) => { + if (current.has(overlay) === open) { + return current + } + const next = new Set(current) + if (open) { + next.add(overlay) + } else { + next.delete(overlay) + } + return next + }) + }, []) + const bodyActive = (bodyShown && onScreen) || openOverlays.size > 0 + // Mount on first sight, then keep the body so selection, history view and focus survive scrolling and collapse. + const [bodyMounted, setBodyMounted] = useState(false) + if (bodyActive && !bodyMounted) { + setBodyMounted(true) + } + const watchRuntimeEnvironmentId = useAppStore((state) => + sourceControlFileWatchRuntimeEnvironmentId(state, owner) + ) + // Only local or served-runtime roots have the existing watch transport; huge repos keep their pause. + useSourceControlWatchConsumer( + attachedWorktreeOwnerKey(owner), + connected && !huge && !isFolderRepo(owner.repo) && watchRuntimeEnvironmentId !== undefined + ? { + worktreeId: id, + worktreePath: owner.worktree.path, + connectionId: owner.repo.connectionId ?? undefined, + runtimeEnvironmentId: watchRuntimeEnvironmentId + } + : null + ) + const panel = useMemo(() => , []) + const identity = getWorktreeGitIdentityDisplay(owner.worktree) + return ( +
+
+ + + + + + + {translate( + 'auto.components.rightSidebar.FolderWorkspaceSourceControl.openWorktree', + 'Open this worktree' + )} + + +
+ {isExpanded && + (!connected ? ( +
+ {translate( + 'auto.components.rightSidebar.FolderWorkspaceSourceControl.reconnect', + 'Reconnect to see this worktree’s changes.' + )} +
+ ) : !bodyShown ? ( +
+
+
+
+ ) : ( + !bodyMounted &&
+ ) + }, + (left, right) => + left.scrollRoot === right.scrollRoot && + left.refreshers === right.refreshers && + left.owner.worktree === right.owner.worktree && + left.owner.repo === right.owner.repo && + left.owner.placementWorkspaceId === right.owner.placementWorkspaceId +) + +export default function FolderWorkspaceSourceControlPanel() { + const activeWorkspaceKey = useAppStore((state) => state.activeWorkspaceKey) + const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) + const executionHostId = useAppStore((state) => state.activeWorkspaceExecutionHostId) + const folderWorkspaces = useAppStore(selectFolderWorkspaces) + const worktreesByRepo = useAppStore(selectWorktreesByRepo) + const workspaceLineageByChildKey = useAppStore((state) => state.workspaceLineageByChildKey) + const worktreeLineageById = useAppStore((state) => state.worktreeLineageById) + const lineageParentIdentities = useAppStore(selectSidebarLineageParentIdentities) + const crossStoreChildrenByFolderId = useAppStore(selectCrossStoreFolderChildrenById) + const repos = useAppStore((state) => state.repos) + const resolution = useMemo( + () => + getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey, + activeWorktreeId, + executionHostId, + folderWorkspaces, + worktreesByRepo, + workspaceLineageByChildKey, + worktreeLineageById, + lineageParentIdentities, + crossStoreChildrenByFolderId + }), + [ + activeWorkspaceKey, + activeWorktreeId, + executionHostId, + folderWorkspaces, + worktreesByRepo, + workspaceLineageByChildKey, + worktreeLineageById, + lineageParentIdentities, + crossStoreChildrenByFolderId + ] + ) + const owners = useMemo( + () => + resolution.owners.flatMap((owner) => { + const repo = resolveReviewRepository(repos, owner.worktree) + return repo && resolution.folderWorkspace + ? [{ ...owner, repo, placementWorkspaceId: `folder:${resolution.folderWorkspace.id}` }] + : [] + }), + [resolution, repos] + ) + const [scrollRoot, setScrollRoot] = useState(null) + const [refreshers] = useState(() => new Set<() => void>()) + // Each section's scheduler applies the status tier, coalescing, abort and stale-result guards. + const refresh = () => { + for (const refreshNow of refreshers) { + refreshNow() + } + } + return ( +
+
+
+
{resolution.folderWorkspace?.name}
+
+ {translate( + 'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.attachedCount', + '{{count}} attached worktrees', + { count: owners.length, defaultValue_one: '{{count}} attached worktree' } + )} +
+
+ + + + + + {translate('auto.components.right.sidebar.SourceControl.refreshAll', 'Refresh all')} + + +
+ {owners.length === 0 ? ( +
+
+ {translate( + 'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.emptyTitle', + 'No attached worktrees yet' + )} +
+
+ {translate( + 'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.emptyCopy', + 'Worktrees created from this workspace will show up here.' + )} +
+
+ ) : ( +
+ {owners.map((owner) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.watch-coverage.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.watch-coverage.test.tsx new file mode 100644 index 00000000000..912671a0147 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceSourceControlPanel.watch-coverage.test.tsx @@ -0,0 +1,341 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/worktree/types' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' +import type * as WorktreeGitCopy from '@/lib/worktree-git-copy' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useEditorExternalWatch } from '@/hooks/useEditorExternalWatch' +import FolderWorkspaceSourceControlPanel from './FolderWorkspaceSourceControlPanel' + +const probes = vi.hoisted(() => ({ + status: vi.fn(async (_request: { worktreeId: string }) => {}) +})) +vi.mock('./source-control/sync/git-status-refresh', () => ({ + refreshGitStatusForWorktree: probes.status +})) +vi.mock('./source-control/sync/use-git-status-upstream-ref-watch', () => ({ + useGitStatusUpstreamRefWatch: () => undefined +})) +vi.mock('./source-control/sync/use-refresh-on-host-contact', () => ({ + useRefreshOnHostContact: () => {} +})) +vi.mock('@/lib/worktree-git-copy', async (original) => ({ + ...(await original()), + resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({ + worktreeId: id, + hostId, + transport: { kind: 'local' } + }) +})) +// Body work is covered elsewhere; this contract is about header-tier watch coverage. +vi.mock('./source-control/panel/panel', () => ({ SourceControlPanel: () => null })) + +type RuntimeStream = { + args: { selector: string; method: string; params: { worktree: string } } + callbacks: { onResponse: (response: unknown) => void } + unsubscribe: ReturnType +} +const fsListeners = new Set<(payload: FsChangedPayload) => void>() +let runtimeStreams: RuntimeStream[] = [] +let watchWorktree: ReturnType +let unwatchWorktree: ReturnType +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.useFakeTimers() + fsListeners.clear() + runtimeStreams = [] + watchWorktree = vi.fn(async () => {}) + unwatchWorktree = vi.fn(async () => {}) + vi.stubGlobal('api', { + fs: { + watchWorktree, + unwatchWorktree, + onFsChanged: (listener: (payload: FsChangedPayload) => void) => { + fsListeners.add(listener) + return () => fsListeners.delete(listener) + } + }, + runtimeEnvironments: { + subscribe: async ( + args: RuntimeStream['args'], + callbacks: RuntimeStream['callbacks'] + ): Promise<{ unsubscribe: () => void }> => { + const stream = { args, callbacks, unsubscribe: vi.fn() } + runtimeStreams.push(stream) + return { unsubscribe: stream.unsubscribe } + }, + call: async () => ({ ok: true, result: {} }) + }, + worktrees: { + onChanged: () => () => {}, + onGitStatusMetadataChanged: () => () => {} + } + }) + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + probes.status.mockClear() +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function worktreeRow(repoId: string, path: string, index: number, extra: Partial = {}) { + return { + id: `${repoId}::${path}`, + repoId, + path, + branch: `refs/heads/feature-${index}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + displayName: `Child ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: index, + lastActivityAt: 0, + hostId: 'local' as const, + instanceId: `instance-${repoId}-${index}`, + ...extra + } satisfies Worktree +} + +function setup(state: { + children: Worktree[] + others?: Worktree[] + openFiles?: AppState['openFiles'] + lastVisitedAtByWorktreeId?: Record + changed?: ReadonlySet +}) { + const byRepo: Record = {} + for (const row of [...state.children, ...(state.others ?? [])]) { + ;(byRepo[row.repoId] ??= []).push(row) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies the catalog, lineage and status fields the panel and watch owner read. + useAppStore.setState({ + activeWorktreeId: 'folder:feature', + activeWorkspaceKey: 'folder:feature', + activeWorkspaceExecutionHostId: 'local', + rightSidebarOpen: true, + rightSidebarTab: 'source-control', + repos: [ + { id: 'repo', displayName: 'Child', path: '/repo', kind: 'git' }, + { id: 'other', displayName: 'Other', path: '/other', kind: 'git' }, + { + id: 'rrepo', + displayName: 'Remote', + path: '/remote', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ], + sshConnectionStates: new Map([ + [ + 'nested-ssh', + { targetId: 'nested-ssh', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]), + worktreesByRepo: byRepo, + folderWorkspaces: [ + { id: 'feature', name: 'Feature', folderPath: '/folder', projectGroupId: 'group' } + ], + workspaceLineageByChildKey: Object.fromEntries( + state.children.map((row, i) => [ + row.id, + { + childWorkspaceKey: `worktree:${row.id}`, + childInstanceId: row.instanceId, + parentWorkspaceKey: 'folder:feature', + parentInstanceId: null, + createdAt: i + } + ]) + ), + // Unchanged sections start collapsed; changed ones expand. + gitStatusByWorktree: Object.fromEntries( + state.children.map((row) => [ + row.id, + state.changed?.has(row.id) ? [{ path: 'a.ts', status: 'modified', area: 'unstaged' }] : [] + ]) + ), + openFiles: state.openFiles ?? [], + lastVisitedAtByWorktreeId: state.lastVisitedAtByWorktreeId ?? {} + } as unknown as Partial) +} + +function WatchOwner() { + useEditorExternalWatch() + return null +} +async function render(panel = true) { + await act(async () => + root.render( + + + {panel && } + + ) + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) +} + +async function settleAfter(send: () => void) { + probes.status.mockClear() + await act(async () => { + send() + // Debounce plus the existing 3 s activity floor, well inside the 60 s safety poll. + await vi.advanceTimersByTimeAsync(4000) + }) + return probes.status.mock.calls.map(([request]) => request.worktreeId) +} + +const watchedPaths = (calls: { worktreePath: string }[][]) => + calls.map(([args]) => args!.worktreePath) + +it('wakes only the owning collapsed or expanded section from file events beyond the former three watches', async () => { + const children = Array.from({ length: 30 }, (_, i) => worktreeRow('repo', `/child-${i}`, i)) + setup({ children, changed: new Set([children[5]!.id]) }) + await render() + // Every mounted local section holds a recursive root, collapsed and off-screen ones included. + expect(watchedPaths(watchWorktree.mock.calls).sort()).toEqual( + children.map((row) => row.path).sort() + ) + const local = (path: string, kind: 'create' | 'update' | 'delete', file: string) => () => { + for (const listener of fsListeners) { + listener({ worktreePath: path, events: [{ kind, absolutePath: `${path}/${file}` }] }) + } + } + for (const target of [children[29]!, children[5]!]) { + expect(await settleAfter(local(target.path, 'update', 'src/deep/tracked.ts'))).toEqual([ + target.id + ]) + expect(await settleAfter(local(target.path, 'create', 'untracked.txt'))).toEqual([target.id]) + expect(await settleAfter(local(target.path, 'delete', 'untracked.txt'))).toEqual([target.id]) + } +}) + +it.each(['explicit', 'repository'] as const)( + 'watches an SSH section with %s runtime ownership through files.watch', + async (ownership) => { + const runtimeChild = worktreeRow( + 'rrepo', + '/remote/child', + 0, + ownership === 'explicit' + ? { hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } + : { hostId: undefined } + ) + setup({ children: [runtimeChild] }) + useAppStore.setState({ + settings: { + ...useAppStore.getState().settings!, + activeRuntimeEnvironmentId: 'selected-elsewhere' + } + }) + await render() + expect(watchWorktree).not.toHaveBeenCalled() + expect(runtimeStreams.map((stream) => [stream.args.selector, stream.args.method])).toEqual([ + ['env-1', 'files.watch'] + ]) + const [stream] = runtimeStreams + await act(async () => + stream!.callbacks.onResponse({ ok: true, result: { type: 'ready', subscriptionId: 'sub-1' } }) + ) + const recipients = await settleAfter(() => + stream!.callbacks.onResponse({ + ok: true, + result: { + type: 'changed', + worktree: runtimeChild.id, + events: [{ kind: 'create', absolutePath: '/remote/child/nested/new.txt' }] + } + }) + ) + expect(recipients).toEqual([runtimeChild.id]) + await render(false) + expect(stream!.unsubscribe).toHaveBeenCalled() + } +) + +it('keeps ordinary warm watches, shares roots and releases only section-owned ones', async () => { + const children = Array.from({ length: 30 }, (_, i) => worktreeRow('repo', `/child-${i}`, i)) + const others = [1, 2].map((i) => worktreeRow('other', `/other-${i}`, i)) + const openFile = (worktree: Worktree) => ({ + id: `${worktree.id}::file`, + filePath: `${worktree.path}/file.ts`, + relativePath: 'file.ts', + worktreeId: worktree.id, + language: 'typescript', + isDirty: false, + mode: 'edit' as const + }) + setup({ + children, + others, + openFiles: [...others, children[0]!].map(openFile), + // The three recently used workspaces: two ordinary ones and one that is also a section. + lastVisitedAtByWorktreeId: Object.fromEntries( + [...others, children[0]!].map((row, i) => [row.id, 100 + i]) + ) + }) + await render() + const watched = watchedPaths(watchWorktree.mock.calls) + // Thirty sections never take the recently used workspaces' slots. + for (const row of others) { + expect(watched).toContain(row.path) + } + // The section and an open file in the same worktree share one root. + expect(watched.filter((path) => path === children[0]!.path)).toHaveLength(1) + expect(unwatchWorktree).not.toHaveBeenCalled() + + await render(false) + const released = watchedPaths(unwatchWorktree.mock.calls).sort() + expect(released).toEqual( + children + .slice(1) + .map((row) => row.path) + .sort() + ) +}) + +it.each(['direct', 'nested'] as const)( + 'does not claim a watch for unserved %s SSH', + async (route) => { + const child = worktreeRow('rrepo', '/remote/child', 0, { hostId: 'ssh:nested-ssh' }) + setup({ children: [child] }) + useAppStore.setState({ + repos: useAppStore.getState().repos.map((repo) => + repo.id === 'rrepo' + ? { + ...repo, + connectionId: 'nested-ssh', + executionHostId: route === 'nested' ? 'runtime:env-1' : 'ssh:nested-ssh' + } + : repo + ) + }) + await render() + expect(watchWorktree).not.toHaveBeenCalled() + expect(runtimeStreams).toEqual([]) + // Scheduling is preserved; unsupported SSH status still reaches the original remote rejection boundary. + expect(probes.status.mock.calls.map(([request]) => request.worktreeId)).toEqual([child.id]) + } +) diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx index 901480c78c5..c02afafcc14 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx @@ -1,3 +1,7 @@ +import { + selectSidebarLineageParentIdentities, + selectCrossStoreFolderChildrenById +} from '@/store/cross-store-lineage-parents' import WorktreeCard from '@/components/sidebar/workspace-card/surface/WorktreeCard' import { stopNestedWorktreeCardBubble } from '@/components/sidebar/worktree-list/rows/header-event-guards' import { @@ -6,18 +10,21 @@ import { } from '@/components/sidebar/worktree-list/rows/indentation' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import { resolveReviewRepository } from '@/lib/review-owner' import type { Worktree } from '../../../../shared/worktree/types' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' import { useState } from 'react' import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' -import { selectCrossStoreFolderChildrenById } from '@/store/cross-store-lineage-parents' export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element { + const executionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) + const lineageParentIdentities = useAppStore(selectSidebarLineageParentIdentities) const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const crossStoreChildrenByFolderId = useAppStore(selectCrossStoreFolderChildrenById) const repos = useAppStore((s) => s.repos) @@ -25,13 +32,14 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element { ReadonlySet >(() => new Set()) - const repoById = new Map(repos.map((repo) => [repo.id, repo])) const { folderWorkspace, childWorktrees, lineageChildrenByParentId, rootChildWorktrees } = getAttachedWorktreesForFolderWorkspace({ + executionHostId, activeWorkspaceKey, activeWorktreeId, folderWorkspaces, workspaceLineageByChildKey, + lineageParentIdentities, worktreeLineageById, worktreesByRepo, crossStoreChildrenByFolderId @@ -53,16 +61,19 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element { worktree: Worktree, ancestorIds: ReadonlySet = new Set() ): React.JSX.Element => { - const lineageChildren = lineageChildrenByParentId.get(worktree.id) ?? [] - const lineageCollapsed = collapsedLineageWorktreeIds.has(worktree.id) - const nextAncestorIds = new Set([...ancestorIds, worktree.id]) - const safeLineageChildren = lineageChildren.filter((child) => !nextAncestorIds.has(child.id)) + const identity = getWorktreeHostIdentity(worktree) + const lineageChildren = lineageChildrenByParentId.get(identity) ?? [] + const lineageCollapsed = collapsedLineageWorktreeIds.has(identity) + const nextAncestorIds = new Set([...ancestorIds, identity]) + const safeLineageChildren = lineageChildren.filter( + (child) => !nextAncestorIds.has(getWorktreeHostIdentity(child)) + ) const hasSafeLineageChildren = safeLineageChildren.length > 0 return ( (
{ event.preventDefault() event.stopPropagation() - toggleLineage(worktree.id) + toggleLineage(identity) } : undefined } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/review-actions/use-checks-panel-create-review.tsx b/src/renderer/src/components/right-sidebar/checks-panel/review-actions/use-checks-panel-create-review.tsx index 4995829a8d7..8a766b355e6 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/review-actions/use-checks-panel-create-review.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/review-actions/use-checks-panel-create-review.tsx @@ -39,7 +39,6 @@ type ChecksPanelCreateReviewInput = ChecksPanelCreatedReviewInput & | 'createPrInFlightRef' | 'createStackedHostedReview' | 'mountedRef' - | 'ownerSettings' | 'panelContextKey' | 'panelContextKeyRef' | 'setCreatePrError' @@ -73,7 +72,6 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) hostedReviewCreateCopy, hostedReviewCreateProvider, hostedReviewCreation, - ownerSettings, panelContextKey, panelContextKeyRef, prAiGenerationEnabled, @@ -319,8 +317,7 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders, generatePullRequestFields: handleGeneratePullRequestFields, generationKey: activePullRequestGenerationKey, - repo, - settings: ownerSettings + repo }) return { handlePullRequestCreated, handleCreatePullRequest } } diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts index 14c5d96da74..904c12a5d16 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' +import { getFolderWorkspaceWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import { resolveSidebarLineageParentIdentities } from '../../../../shared/sidebar-list/worktree-lineage-projection' import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' -import type { WorkspaceLineage } from '../../../../shared/worktree/lineage-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' @@ -92,3 +95,166 @@ describe('getAttachedWorktreesForFolderWorkspace', () => { expect(result.childWorktrees.map((worktree) => worktree.id)).toEqual([visible.id]) }) }) + +function edge(child: Worktree, parent: Worktree, createdAt: number): WorktreeLineage { + return { + worktreeId: child.id, + worktreeInstanceId: child.instanceId!, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId!, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt + } +} + +it('keeps attachment order, descendants and Attached-tab parents on their resolved host and instance', () => { + const root = makeWorktree({ + id: 'same-root', + hostId: 'local', + instanceId: 'local-root', + lastActivityAt: 99 + }) + const twin = makeWorktree({ id: 'same-root', hostId: 'ssh:box', instanceId: 'remote-root' }) + const child = makeWorktree({ id: 'same-child', hostId: 'local', instanceId: 'local-child' }) + const remoteChild = makeWorktree({ + id: 'same-child', + hostId: 'ssh:box', + instanceId: 'remote-child' + }) + const remoteGrandchild = makeWorktree({ + id: 'grandchild', + hostId: 'ssh:box', + instanceId: 'remote-grandchild' + }) + const remoteOnly = makeWorktree({ + id: 'remote-only', + hostId: 'ssh:box', + instanceId: 'remote-only-instance' + }) + const stale = makeWorktree({ id: 'stale', hostId: 'local', instanceId: 'new-instance' }) + const remoteEdge = edge(remoteChild, twin, 40) + Object.assign(remoteChild, { lineage: remoteEdge }) + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: 'folder:folder-1', + activeWorktreeId: null, + executionHostId: 'local', + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { + remoteOnly: { ...makeWorkspaceLineage(remoteOnly), createdAt: 60 }, + first: { ...makeWorkspaceLineage(twin), createdAt: 10 }, + second: { ...makeWorkspaceLineage(root), createdAt: 20 }, + stale: { ...makeWorkspaceLineage(stale), childInstanceId: 'old-instance' } + }, + worktreeLineageById: { + [child.id]: edge(child, root, 30), + [remoteGrandchild.id]: edge(remoteGrandchild, remoteChild, 50) + }, + worktreesByRepo: { + remote: [twin, remoteChild, remoteGrandchild, remoteOnly], + local: [root, child, stale] + }, + crossStoreChildrenByFolderId: new Map() + }) + expect( + result.owners.map((owner) => [ + owner.executionHostId, + owner.worktree.instanceId, + owner.attachedAt + ]) + ).toEqual([ + ['local', 'local-root', 20], + ['local', 'local-child', 30], + ['ssh:box', 'remote-only-instance', 60] + ]) + expect(result.lineageChildrenByParentId.get('local|same-root')).toEqual([child]) + expect(result.lineageChildrenByParentId.has('ssh:box|same-root')).toBe(false) + expect(result.rootChildWorktrees.map((row) => row.instanceId)).toEqual([ + 'local-root', + 'remote-only-instance' + ]) +}) + +it('includes resolved cross-store roots and their descendants without attaching another host copy', () => { + const root = makeWorktree({ id: 'remote-root', hostId: 'ssh:box', instanceId: 'root' }) + const twin = makeWorktree({ id: 'remote-root', hostId: 'ssh:other', instanceId: 'twin' }) + const child = makeWorktree({ id: 'remote-child', hostId: 'ssh:box', instanceId: 'child' }) + const lineage = edge(child, root, 5) + Object.assign(child, { lineage }) + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: 'folder:folder-1', + activeWorktreeId: null, + executionHostId: 'local', + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: {}, + worktreeLineageById: {}, + worktreesByRepo: { remote: [root, child], other: [twin] }, + lineageParentIdentities: new Map([ + ['ssh:box|remote-root', 'local|folder:folder-1'], + ['ssh:box|remote-child', 'ssh:box|remote-root'] + ]), + crossStoreChildrenByFolderId: new Map([['folder-1', [root]]]) + }) + expect(result.owners.map((owner) => owner.worktree.instanceId)).toEqual(['root', 'child']) + expect(result.rootChildWorktrees).toEqual([root]) + expect(result.lineageChildrenByParentId.get('ssh:box|remote-root')).toEqual([child]) +}) + +it('attaches cross-store roots only to the resolved host when folder IDs collide', () => { + const localFolder = makeFolder() + const pairedFolder: FolderWorkspace = { ...makeFolder(), executionHostId: 'runtime:paired' } + const root = makeWorktree({ id: 'root', hostId: 'local', instanceId: 'root-instance' }) + const child = makeWorktree({ id: 'child', hostId: 'local', instanceId: 'child-instance' }) + const worktreeLineageById = { child: edge(child, root, 5) } + const rows = [ + root, + child, + ...[localFolder, pairedFolder].map((folder) => getFolderWorkspaceWorktree(folder)) + ] + const lineageParentIdentities = resolveSidebarLineageParentIdentities( + worktreeLineageById, + new Map(rows.map((row) => [getWorktreeHostIdentity(row), row])), + new Map([ + [ + '', + { + installationId: 'local-installation', + crossStoreLineageByChildKey: { + [worktreeWorkspaceKey(root.id)]: { + childWorkspaceKey: worktreeWorkspaceKey(root.id), + childInstanceId: 'root-instance', + parent: { + installationId: 'paired-installation', + workspaceKey: 'folder:folder-1', + instanceId: 'folder-1' + }, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + } + } + ], + ['paired', { installationId: 'paired-installation', crossStoreLineageByChildKey: {} }] + ]) + ) + const inputs = { + activeWorkspaceKey: 'folder:folder-1', + activeWorktreeId: null, + folderWorkspaces: [localFolder, pairedFolder], + workspaceLineageByChildKey: {}, + worktreeLineageById, + worktreesByRepo: { local: [root, child] }, + lineageParentIdentities, + crossStoreChildrenByFolderId: new Map([['folder-1', [root]]]) + } + + const local = getAttachedWorktreesForFolderWorkspace({ ...inputs, executionHostId: 'local' }) + const paired = getAttachedWorktreesForFolderWorkspace({ + ...inputs, + executionHostId: 'runtime:paired' + }) + + expect(local.childWorktrees).toEqual([]) + expect(paired.owners.map((owner) => owner.worktree.id)).toEqual(['root', 'child']) +}) diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts index fa3af5f9cc7..d3f4f7fd1d7 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts @@ -1,18 +1,41 @@ +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' +import { getFolderWorkspaceHostIdentity } from '../../../../shared/folder-workspaces/folder-workspace-worktree' import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' -import { getProjectedWorktreeLineageChildrenByParentId } from '../../../../shared/sidebar-list/worktree-lineage-projection' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import { getProjectedWorktreeLineage } from '../../../../shared/worktree/workspace-lineage-graph' +import { + getSidebarLineageParentsByIdentity, + type SidebarLineageParentIdentities, + getProjectedWorktreeLineageChildrenByParentId +} from '../../../../shared/sidebar-list/worktree-lineage-projection' export type AttachedWorktreeResolution = { folderWorkspace: FolderWorkspace | null childWorktrees: Worktree[] + owners: AttachedWorktreeOwner[] lineageChildrenByParentId: Map rootChildWorktrees: Worktree[] } +export type AttachedWorktreeOwner = { + worktreeId: string + repoId: string + executionHostId: ExecutionHostId + worktree: Worktree + attachedAt: number +} + +export function attachedWorktreeOwnerKey(owner: AttachedWorktreeOwner): string { + return `${owner.executionHostId}::${owner.worktreeId}::${owner.worktree.instanceId ?? ''}` +} + type AttachedWorktreeResolverArgs = { + executionHostId?: ExecutionHostId | null + lineageParentIdentities?: SidebarLineageParentIdentities activeWorkspaceKey: string | null activeWorktreeId: string | null folderWorkspaces: readonly FolderWorkspace[] @@ -28,6 +51,8 @@ export function getWorktreeActivityTime(worktree: Worktree): number { } export function getAttachedWorktreesForFolderWorkspace({ + executionHostId, + lineageParentIdentities, activeWorkspaceKey, activeWorktreeId, folderWorkspaces, @@ -39,49 +64,144 @@ export function getAttachedWorktreesForFolderWorkspace({ const activeScope = parseWorkspaceKey(activeWorkspaceKey ?? activeWorktreeId ?? '') const folderWorkspace = activeScope?.type === 'folder' - ? (folderWorkspaces.find((workspace) => workspace.id === activeScope.folderWorkspaceId) ?? - null) + ? (folderWorkspaces.find( + (workspace) => + workspace.id === activeScope.folderWorkspaceId && + (!executionHostId || getRepoExecutionHostId(workspace) === executionHostId) + ) ?? null) : null if (!folderWorkspace) { return { folderWorkspace: null, childWorktrees: [], + owners: [], lineageChildrenByParentId: new Map(), rootChildWorktrees: [] } } const folderKey = folderWorkspaceKey(folderWorkspace.id) - const worktreeById = getWorktreeById(worktreesByRepo) - const childWorktrees = Object.values(workspaceLineageByChildKey) - .filter((lineage) => lineage.parentWorkspaceKey === folderKey) - .map((lineage) => getLineageChildWorktree(lineage, worktreeById)) - .filter((worktree): worktree is Worktree => worktree !== null) - .concat(crossStoreChildrenByFolderId.get(folderWorkspace.id) ?? []) - .sort(sortWorktreesByRecentActivity) - - const childWorktreeIds = new Set(childWorktrees.map((worktree) => worktree.id)) - const lineageChildrenByParentId = getLineageChildrenByParentId( - worktreeLineageById, - worktreeById, - childWorktreeIds + const folderHost = getRepoExecutionHostId(folderWorkspace) + const rows = new Map( + Object.values(worktreesByRepo).flatMap((copies) => + copies.map((row) => [getWorktreeHostIdentity(row), row] as const) + ) ) - const nestedChildIds = new Set() - for (const children of lineageChildrenByParentId.values()) { - for (const child of children) { - nestedChildIds.add(child.id) + // Status stores are keyed by worktree ID; prefer the folder host, then the legacy selector's first copy. + const selectedById = new Map() + for (const row of rows.values()) { + const selected = selectedById.get(row.id) + if ( + !selected || + ((selected.hostId ?? 'local') !== folderHost && (row.hostId ?? 'local') === folderHost) + ) { + selectedById.set(row.id, row) } } - const topLevelChildWorktrees = childWorktrees.filter( - (worktree) => !nestedChildIds.has(worktree.id) + const parentByIdentity = getSidebarLineageParentsByIdentity( + worktreeLineageById, + rows, + lineageParentIdentities + ) + const childrenByParent = new Map() + for (const [identity, parent] of parentByIdentity) { + const child = rows.get(identity)! + if (child.isArchived) { + continue + } + const parentIdentity = getWorktreeHostIdentity(parent) + const children = childrenByParent.get(parentIdentity) ?? [] + children.push(child) + childrenByParent.set(parentIdentity, children) + } + const rootLineageByChildId = new Map() + for (const lineage of Object.values(workspaceLineageByChildKey)) { + const child = parseWorkspaceKey(lineage.childWorkspaceKey) + if (lineage.parentWorkspaceKey !== folderKey || child?.type !== 'worktree') { + continue + } + const entries = rootLineageByChildId.get(child.worktreeId) ?? [] + entries.push(lineage) + rootLineageByChildId.set(child.worktreeId, entries) + } + const members = new Map() + const queue: Worktree[] = [] + const add = (worktree: Worktree, attachedAt: number) => { + const identity = getWorktreeHostIdentity(worktree) + if ( + worktree.isArchived || + members.has(identity) || + selectedById.get(worktree.id) !== worktree + ) { + return + } + members.set(identity, { + worktreeId: worktree.id, + repoId: worktree.repoId, + executionHostId: worktree.hostId ?? folderHost, + worktree, + attachedAt + }) + queue.push(worktree) + } + for (const row of rows.values()) { + const lineage = rootLineageByChildId + .get(row.id) + ?.find((edge) => + edge.childInstanceId + ? edge.childInstanceId === row.instanceId + : (row.hostId ?? folderHost) === folderHost + ) + if (lineage) { + add(row, lineage.createdAt) + } + } + const folderIdentity = getFolderWorkspaceHostIdentity(folderWorkspace) + for (const child of crossStoreChildrenByFolderId.get(folderWorkspace.id) ?? []) { + if (lineageParentIdentities?.get(getWorktreeHostIdentity(child)) !== folderIdentity) { + continue + } + add( + child, + getProjectedWorktreeLineage(child, worktreeLineageById)?.createdAt ?? child.createdAt ?? 0 + ) + } + for (let i = 0; i < queue.length; i++) { + for (const child of childrenByParent.get(getWorktreeHostIdentity(queue[i])) ?? []) { + const lineage = getProjectedWorktreeLineage(child, worktreeLineageById) + add(child, lineage?.createdAt ?? child.createdAt ?? 0) + } + } + const lineageChildrenByParentId = new Map() + const nested = new Set() + for (const identity of members.keys()) { + const children = (childrenByParent.get(identity) ?? []).filter((child) => + members.has(getWorktreeHostIdentity(child)) + ) + if (children.length > 0) { + children.sort(sortWorktreesByRecentActivity) + lineageChildrenByParentId.set(identity, children) + for (const child of children) { + nested.add(getWorktreeHostIdentity(child)) + } + } + } + const childWorktrees = [...members.values()].map((owner) => owner.worktree) + childWorktrees.sort(sortWorktreesByRecentActivity) + const rootChildWorktrees = childWorktrees.filter( + (row) => !nested.has(getWorktreeHostIdentity(row)) + ) + const owners = [...members.values()].sort( + (left, right) => + left.attachedAt - right.attachedAt || + attachedWorktreeOwnerKey(left).localeCompare(attachedWorktreeOwnerKey(right)) ) - const rootChildWorktrees = - topLevelChildWorktrees.length > 0 ? topLevelChildWorktrees : childWorktrees return { folderWorkspace, childWorktrees, + owners, lineageChildrenByParentId, rootChildWorktrees } @@ -125,16 +245,6 @@ export function getLineageChildrenByParentId( return descendantsByParentId } -function getWorktreeById( - worktreesByRepo: Record -): Map { - return new Map( - Object.values(worktreesByRepo) - .flat() - .map((worktree) => [worktree.id, worktree]) - ) -} - /** Exported so the composer's parent picker offers exactly the roots this view attaches. */ export function getLineageChildWorktree( lineage: WorkspaceLineage, diff --git a/src/renderer/src/components/right-sidebar/hosted-review/creation/create-pull-request-dialog-field-model.ts b/src/renderer/src/components/right-sidebar/hosted-review/creation/create-pull-request-dialog-field-model.ts index 9a017de6431..56aa2dc83d2 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review/creation/create-pull-request-dialog-field-model.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review/creation/create-pull-request-dialog-field-model.ts @@ -1,5 +1,5 @@ import type { WorkspaceAttachmentMetadata } from '../../../../../../shared/workspace-attachments/workspace-attachment-legacy' -import type { AppState } from '@/store/index' +import type { SourceControlAiSettingsInput } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import type { RuntimeGeneratePullRequestFieldsOverrides, @@ -34,7 +34,7 @@ export type UseCreatePullRequestDialogFieldsOptions = { currentBaseRef?: string | null repo?: Pick | null /** Generation preferences; routing comes from `target`. */ - settings: AppState['settings'] + settings: SourceControlAiSettingsInput | null /** The worktree owner's transport. */ target: RuntimeClientTarget | null submitting: boolean diff --git a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-base-ref-discovery.ts b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-base-ref-discovery.ts index 1d15c7c9589..c3a7bda64fb 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-base-ref-discovery.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-base-ref-discovery.ts @@ -1,6 +1,7 @@ +import { useAppStateViewActive, useAppStateValue } from '@/store/app-state-view' +import { useSourceControlScope } from '../../source-control/panel/worktree-scope' import { translate } from '@/i18n/i18n' import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' -import { useAppStore } from '@/store/index' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' import { @@ -33,19 +34,24 @@ export function useCreatePullRequestBaseRefDiscovery({ setBaseSearchPending, setBaseSearchError }: CreatePullRequestBaseRefDiscoveryOptions): string | null { + const viewActive = useAppStateViewActive() + const scope = useSourceControlScope() // Stamp the repo so an old default cannot suppress stacked-PR lookup after navigation. const [repoDefault, setRepoDefault] = useState<{ repoId: string; baseRef: string } | null>(null) const repoDefaultBaseRef = repoDefault?.repoId === repoId ? repoDefault.baseRef : null // Refs belong to the repo owner, which may differ from the focused host. - const repoHostId = useAppStore((s) => { - const repo = findRepoForHost(s.repos, repoId, { settings: s.settings }) + const repoHostId = useAppStateValue((s) => { + const repo = findRepoForHost(s.repos, repoId, { + settings: s.settings, + hostId: scope?.executionHostId + }) return repo ? getRepoExecutionHostId(repo) : null }) // Resolve the repo default separately; eligibility’s defaultBaseRef can be the worktree’s own base. useEffect(() => { // Cache the stable repo default to avoid an RPC on every composer opening. - if (!open || repoDefaultBaseRef || !repoHostId) { + if (!viewActive || !open || repoDefaultBaseRef || !repoHostId) { return } let stale = false @@ -59,17 +65,17 @@ export function useCreatePullRequestBaseRefDiscovery({ return () => { stale = true } - }, [open, repoDefaultBaseRef, repoHostId, repoId]) + }, [open, repoDefaultBaseRef, repoHostId, repoId, viewActive]) useEffect(() => { - if (!open || base || !repoDefaultBaseRef) { + if (!viewActive || !open || base || !repoDefaultBaseRef) { return } setBase(repoDefaultBaseRef) - }, [base, open, repoDefaultBaseRef, setBase]) + }, [base, open, repoDefaultBaseRef, setBase, viewActive]) useEffect(() => { - if (!open || !repoHostId || baseQuery.trim().length < 2) { + if (!viewActive || !open || !repoHostId || baseQuery.trim().length < 2) { setBaseResults([]) setBaseSearchPending(false) setBaseSearchError(null) @@ -108,6 +114,7 @@ export function useCreatePullRequestBaseRefDiscovery({ } }, [ baseQuery, + viewActive, open, repoHostId, repoId, diff --git a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-field-generation.ts b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-field-generation.ts index 72a521695f2..e85801126c1 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-field-generation.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-create-pull-request-field-generation.ts @@ -3,7 +3,7 @@ import { translate } from '@/i18n/i18n' import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from 'react' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { getConnectionId } from '@/lib/connection-context' -import { useAppStore, type AppState } from '@/store/index' +import { useAppStore } from '@/store/index' import { cancelRuntimeGeneratePullRequestFields, generateRuntimePullRequestFields @@ -32,7 +32,6 @@ type CreatePullRequestFieldGenerationOptions = { draft: boolean worktreeId: string | null worktreePath: string - settings: AppState['settings'] target: RuntimeClientTarget | null eligibility: HostedReviewCreationEligibility | null resolvedPrDefaults: Required @@ -76,7 +75,6 @@ export function useCreatePullRequestFieldGeneration({ draft, worktreeId, worktreePath, - settings, target, eligibility, resolvedPrDefaults, @@ -149,7 +147,7 @@ export function useCreatePullRequestFieldGeneration({ const requestContext = { // Pin generation to the worktree owner so focused-host navigation cannot retarget it. target, - prefs: settings, + prefs: useAppStore.getState().settings, worktreeId, worktreePath, connectionId @@ -233,7 +231,6 @@ export function useCreatePullRequestFieldGeneration({ resolvedPrDefaults.useTemplate, setGenerateError, setGenerating, - settings, target, title, worktreeId, diff --git a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request-context.test.tsx b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request-context.test.tsx index 75f61bca024..d14f5b10755 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request-context.test.tsx +++ b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request-context.test.tsx @@ -67,8 +67,7 @@ function useComposer(key: string) { fieldsAreSeedPlaceholders: fields.fieldsAreSeedPlaceholders, generatePullRequestFields: fields.handleGenerate, generationKey: key, - repo: null, - settings: null + repo: null }) return fields } diff --git a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request.ts b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request.ts index 627391ef524..46952740d5b 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review/creation/use-generate-before-create-pull-request.ts @@ -1,5 +1,5 @@ import { useCallback, useLayoutEffect } from 'react' -import { useAppStore, type AppState } from '@/store/index' +import { useAppStore } from '@/store/index' import { recordPullRequestGenerationEdits, type PullRequestFieldRevisions, @@ -23,8 +23,7 @@ export function useGenerateBeforeCreatePullRequest({ fieldsAreSeedPlaceholders, generatePullRequestFields, generationKey, - repo, - settings + repo }: { aiGenerationEnabled: boolean canCreate: boolean @@ -39,7 +38,6 @@ export function useGenerateBeforeCreatePullRequest({ ) => Promise generationKey: string | null repo: Pick | null - settings: AppState['settings'] }) { useLayoutEffect(() => { if (generationKey && fieldsInitialized) { @@ -70,7 +68,7 @@ export function useGenerateBeforeCreatePullRequest({ status === 'canceled' || !hasConfiguredSourceControlTextGenerationDefaults({ actionId: 'pullRequest', - settings, + settings: useAppStore.getState().settings, repo }) ) { @@ -94,8 +92,7 @@ export function useGenerateBeforeCreatePullRequest({ fieldsAreSeedPlaceholders, generatePullRequestFields, generationKey, - repo, - settings + repo ] ) diff --git a/src/renderer/src/components/right-sidebar/hosted-review/creation/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/hosted-review/creation/useCreatePullRequestDialogFields.ts index ddd2fdc9151..4311e655741 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review/creation/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review/creation/useCreatePullRequestDialogFields.ts @@ -232,7 +232,6 @@ export function useCreatePullRequestDialogFields({ draft, worktreeId, worktreePath, - settings, target, eligibility, resolvedPrDefaults, diff --git a/src/renderer/src/components/right-sidebar/hosted-review/folder-checks/FolderWorkspacePrChecksPanel.tsx b/src/renderer/src/components/right-sidebar/hosted-review/folder-checks/FolderWorkspacePrChecksPanel.tsx index 077e9cda0a2..563a0206213 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review/folder-checks/FolderWorkspacePrChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/hosted-review/folder-checks/FolderWorkspacePrChecksPanel.tsx @@ -1,3 +1,7 @@ +import { + selectSidebarLineageParentIdentities, + selectCrossStoreFolderChildrenById +} from '@/store/cross-store-lineage-parents' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { RefreshCw } from 'lucide-react' import { useAppStore } from '@/store/index' @@ -15,7 +19,6 @@ import { } from './parent-pr-checks-refresh' import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector' import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' -import { selectCrossStoreFolderChildrenById } from '@/store/cross-store-lineage-parents' type FolderWorkspacePrChecksPanelProps = { isVisible?: boolean @@ -29,6 +32,7 @@ export default function FolderWorkspacePrChecksPanel({ const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) + const lineageParentIdentities = useAppStore(selectSidebarLineageParentIdentities) const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const crossStoreChildrenByFolderId = useAppStore(selectCrossStoreFolderChildrenById) const repos = useAppStore((s) => s.repos) @@ -50,6 +54,7 @@ export default function FolderWorkspacePrChecksPanel({ activeWorktreeId, folderWorkspaces, workspaceLineageByChildKey, + lineageParentIdentities, worktreeLineageById, worktreesByRepo, crossStoreChildrenByFolderId @@ -59,6 +64,7 @@ export default function FolderWorkspacePrChecksPanel({ activeWorktreeId, folderWorkspaces, workspaceLineageByChildKey, + lineageParentIdentities, worktreeLineageById, worktreesByRepo, crossStoreChildrenByFolderId diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts index 2f9fefc70e0..4eb07964969 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts +++ b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts @@ -13,7 +13,7 @@ export function getVisibleRightSidebarActivityItems( ): ActivityBarItem[] { return items.filter( (item) => - (!item.gitOnly || !isFolder) && + (!item.gitOnly || !isFolder || (isFolderWorkspace && item.id === 'source-control')) && (!item.folderOnly || isFolderWorkspace) && (!item.sshOnly || hasSshHost) ) diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx index e435a1dcdf2..36b081de354 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx @@ -1,9 +1,11 @@ +import { useAppStore } from '@/store' import { Suspense } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import type { ActiveRightSidebarTab } from '@/store/slices/editor' import { isPluginPanelTabKey } from '../../../../shared/plugins/manifest/plugin-manifest' const FileExplorer = lazy(() => import('./file-explorer/panel/FileExplorer')) +const FolderWorkspaceSourceControl = lazy(() => import('./FolderWorkspaceSourceControlPanel')) const SourceControl = lazy(() => import('./source-control/panel/SourceControl')) const ChecksPanel = lazy(() => import('./checks-panel/ChecksPanel')) const PortsPanel = lazy(() => import('./PortsPanel')) @@ -23,6 +25,9 @@ export function RightSidebarPanelContent({ effectiveTab, rightSidebarOpen }: RightSidebarPanelContentProps): React.JSX.Element { + const isFolderWorkspace = useAppStore( + (state) => state.activeWorkspaceKey?.startsWith('folder:') ?? false + ) return (
{effectiveTab === 'explorer' && } - {effectiveTab === 'source-control' && } + {effectiveTab === 'source-control' && + (isFolderWorkspace ? ( + rightSidebarOpen && + ) : ( + + ))} {effectiveTab === 'checks' && } {/* SSH forwarding needs raw ports.detect data absent from the workspace-scoped status popover. */} {effectiveTab === 'ports' && ( diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx index 1a10a677e0b..c08e987ad9f 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx @@ -215,7 +215,7 @@ describe('rendered right sidebar titlebar drag regions', () => { mockAppState.cachedWorktree = null }) - it('hides git-only activity buttons for folder workspace ids without a backing repo', () => { + it('offers Source Control but hides repository-only activity buttons in a folder workspace', () => { mockAppState.activeWorktreeId = 'folder:folder-1' mockAppState.activeRepo = null @@ -226,7 +226,7 @@ describe('rendered right sidebar titlebar drag regions', () => { expect(markup).not.toContain('aria-label="Search') expect(markup).toContain('aria-label="Attached worktrees') expect(markup).toContain('aria-label="PR Checks') - expect(markup).not.toContain('aria-label="Source Control') + expect(markup).toContain('aria-label="Source Control') expect(markup).not.toContain('aria-label="Checks') }) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/SourceControlAgentActionDialog.tsx index 5864d9bbcdc..d31ae7fc22d 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/ai/SourceControlAgentActionDialog.tsx @@ -33,6 +33,8 @@ export type SourceControlAgentActionDialogProps = { savedAgentArgs?: string | null worktreeId?: string | null groupId?: string | null + placementWorkspaceId?: string + initialCwd?: string connectionId?: string | null repoId?: string | null promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready' @@ -51,7 +53,7 @@ export type SourceControlAgentActionDialogProps = { onLaunchAccepted?: () => void /** Fires when an accepted launch later failed to deliver its prompt. */ onLaunchAborted?: () => void - onLaunched?: () => void + onLaunched?: RunSourceControlAgentActionStartArgs['onLaunched'] startLabel?: string onStart?: RunSourceControlAgentActionStartArgs['onStart'] } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts b/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts index 8de2e4c6c7d..991230d40b1 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts @@ -4,15 +4,15 @@ import type { GitConflictOperation, GitStatusEntry } from '../../../../../../shared/git/status/git-status-types' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' +import type { SourceControlRenderSettings } from '../listing/use-worktree-context' import type { Repo } from '../../../../../../shared/repo-types' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' export type SourceControlAiStoreSnapshot = Pick & - WorktreeAgentInventoryState + Omit export type SourceControlAiControllerParams = { - settings: GlobalSettings | null + settings: SourceControlRenderSettings | null /** The repo owner's transport; it scopes model discovery. */ target: RuntimeClientTarget | null activeRepo: Repo | null diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recipe-persistence.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recipe-persistence.ts index 2ee543c26ee..b272f7aa6c2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recipe-persistence.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recipe-persistence.ts @@ -1,3 +1,5 @@ +import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { AppState } from '@/store' import { isCustomAgentId } from '../../../../../../shared/source-control/ai/generation/commit-message-agent-spec' import type { ResolvedSourceControlAiGenerationParams } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution' @@ -15,6 +17,7 @@ import { generationParamsToActionRecipe } from './text-generation-defaults' type SourceControlAiRecipePersistenceStoreSnapshot = Pick export async function saveSourceControlAiActionRecipeForTarget({ + hostId, getStoreState, updateSettings, updateRepo, @@ -23,6 +26,7 @@ export async function saveSourceControlAiActionRecipeForTarget({ recipe, customAgentCommand }: { + hostId?: ExecutionHostId getStoreState: () => SourceControlAiRecipePersistenceStoreSnapshot updateSettings: AppState['updateSettings'] updateRepo: AppState['updateRepo'] @@ -38,7 +42,11 @@ export async function saveSourceControlAiActionRecipeForTarget({ } const latestRepo = target.type === 'repo' - ? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null) + ? (state.repos.find((candidate) => + hostId + ? repoMatchesHostIdentity(candidate, target.repoId, hostId) + : candidate.id === target.repoId + ) ?? null) : null const result = saveSourceControlActionRecipe({ target, diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 3b5eb058551..bd5b5f31a82 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -1,3 +1,8 @@ +import { + canLaunchSourceControlSectionAgentInPlace, + showSourceControlAgentStartedIn +} from './section-agent-placement' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection' import { ensureAgentLaunchPreferences } from '@/runtime/agent-launch-settings/use-host-agent-launch-preferences' import { @@ -89,6 +94,9 @@ export function getDefaultSourceControlRecoveryLaunchCopy( export async function launchSourceControlRecoveryAgentWithDefault({ activeWorktreeId, + placementWorkspaceId, + initialCwd, + executionHostId, activeGroupId, activeSourceControlLaunchPlatform, sourceRepoConnectionId, @@ -100,6 +108,9 @@ export async function launchSourceControlRecoveryAgentWithDefault({ copy }: { activeWorktreeId: string + placementWorkspaceId?: string + initialCwd?: string + executionHostId?: ExecutionHostId activeGroupId: string | null | undefined activeSourceControlLaunchPlatform: NodeJS.Platform | undefined sourceRepoConnectionId?: string | null @@ -157,8 +168,16 @@ export async function launchSourceControlRecoveryAgentWithDefault({ return false } + const liveStore = useAppStore.getState() + const inPlace = + placementWorkspaceId && + canLaunchSourceControlSectionAgentInPlace(liveStore, activeWorktreeId, placementWorkspaceId) + ? placementWorkspaceId + : undefined + // A saved or default agent may open as a chat without its CLI. const opensAsChat = (agent: TuiAgent): boolean => + !placementWorkspaceId && agentChoiceMayOpenAsChat( useAppStore.getState(), agent, @@ -196,11 +215,22 @@ export async function launchSourceControlRecoveryAgentWithDefault({ ) const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), + ...(placementWorkspaceId && !inPlace ? { activate: false } : {}), agent, - worktreeId: activeWorktreeId, - groupId: activeGroupId ?? activeWorktreeId, + worktreeId: inPlace ?? activeWorktreeId, + ...(inPlace && inPlace !== activeWorktreeId ? { trustWorktreeId: activeWorktreeId } : {}), + groupId: + placementWorkspaceId && !inPlace + ? (liveStore.activeGroupIdByWorktree[activeWorktreeId] ?? activeWorktreeId) + : ((inPlace ? liveStore.activeGroupIdByWorktree[inPlace] : undefined) ?? + activeGroupId ?? + placementWorkspaceId ?? + activeWorktreeId), + ...((!placementWorkspaceId || inPlace) && initialCwd ? { initialCwd } : {}), prompt: commandInput, ...projection, + ...(placementWorkspaceId ? { requestedSurface: 'terminal' as const } : {}), + ...(executionHostId ? { selectedExecutionHostId: executionHostId } : {}), promptDelivery: 'submit-after-ready', launchPlatform: activeSourceControlLaunchPlatform, launchSource: 'source_control_recovery' @@ -221,9 +251,13 @@ export async function launchSourceControlRecoveryAgentWithDefault({ return false } - if (result.surface.kind === 'local-terminal') { + if (result.surface.kind === 'local-terminal' && (!placementWorkspaceId || inPlace)) { focusTerminalTabSurface(result.surface.tabId) } - toast.success(copy.success) + if (placementWorkspaceId && !inPlace) { + showSourceControlAgentStartedIn(activeWorktreeId, executionHostId) + } else { + toast.success(copy.success) + } return true } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts index a2d5070da35..76cc97e519a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts @@ -1,7 +1,13 @@ import { sourceControlActionRecipeEntry } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection' import type { SourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions' import type * as RuntimeGitStatusClientModule from '@/runtime/runtime-git-status-client' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + makeFolderWorkspace, + makeWorktree, + TEST_REPO +} from '@/store/slices/worktrees-slice-test-fixtures' const mocks = vi.hoisted(() => ({ focusTerminalTabSurface: vi.fn(), @@ -11,6 +17,8 @@ const mocks = vi.hoisted(() => ({ onSaveAgentDefault: vi.fn(), onStart: vi.fn(), toastError: vi.fn(), + toastSuccess: vi.fn(), + navigate: vi.fn(), mutationInFlight: vi.fn() })) vi.mock('@/runtime/runtime-git-status-client', async (importOriginal) => ({ @@ -27,7 +35,20 @@ vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({ })) vi.mock('sonner', () => ({ - toast: { error: mocks.toastError } + toast: { error: mocks.toastError, success: mocks.toastSuccess } +})) + +vi.mock('@/lib/workspace-navigation-toast-action', () => ({ + workspaceNavigationToastAction: ({ + workspaceId, + executionHostId + }: { + workspaceId: string + executionHostId?: string + }) => ({ + label: 'Go to workspace', + onClick: () => mocks.navigate(workspaceId, executionHostId) + }) })) import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart' @@ -44,7 +65,6 @@ function buildArgs( saveTargetValue: 'none', actionId: 'resolveComments', repoId: null, - settings: null, repo: null, worktreeId: 'wt-1', groupId: 'group-1', @@ -66,12 +86,120 @@ function buildArgs( } } +const initialState = useAppStore.getState() +afterEach(() => useAppStore.setState(initialState, true)) + describe('runSourceControlAgentActionStart', () => { beforeEach(() => { vi.clearAllMocks() + useAppStore.setState({ + repos: [{ ...TEST_REPO, connectionId: 'box' }], + worktreesByRepo: { + [TEST_REPO.id]: [makeWorktree({ id: 'child', repoId: TEST_REPO.id, hostId: 'ssh:box' })] + }, + folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'ssh:box' })] + }) mocks.mutationInFlight.mockResolvedValue({ inFlight: false }) }) + it.each(['resolveConflicts', 'fixCommitFailure', 'fixPushFailure'] as const)( + 'keeps %s in the folder group with the child cwd and recovery scope', + async (actionId) => { + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-terminal', tabId: 'agent' } + }) + const recoveryContext = { + target: { kind: 'local' as const }, + worktreeId: 'child', + worktreePath: '/folder/repo', + connectionId: 'box' + } + await expect( + runSourceControlAgentActionStart( + buildArgs({ + actionId, + worktreeId: 'child', + groupId: 'folder-group', + placementWorkspaceId: 'folder:feature', + initialCwd: '/folder/repo', + recoveryContext, + recipe: { agentId: 'codex', surface: 'chat', agentArgs: '--model gpt-5' }, + launchChoice: { + agent: 'codex', + route: 'structured-native-chat', + requestedSurface: 'chat', + executionHostId: 'ssh:box' + } + }) + ) + ).resolves.toBe(true) + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'folder:feature', + groupId: 'folder-group', + initialCwd: '/folder/repo', + trustWorktreeId: 'child', + selectedExecutionHostId: 'ssh:box', + requestedSurface: 'terminal', + agentArgs: '--model gpt-5' + }) + ) + expect(mocks.mutationInFlight).toHaveBeenCalledWith(recoveryContext) + } + ) + + it('keeps a cross-host section launch child-owned instead of executing its path in the folder', async () => { + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'local' })], + activeGroupIdByWorktree: { child: 'child-group' }, + activeWorktreeId: 'folder:feature', + activeWorkspaceKey: 'folder:feature', + activeWorkspaceExecutionHostId: 'local', + worktreesByRepo: { + ...useAppStore.getState().worktreesByRepo, + local: [ + makeWorktree({ id: 'local::/tmp/wt', repoId: 'local', path: '/tmp/wt', hostId: 'local' }) + ] + } + }) + mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'host-published' } }) + await expect( + runSourceControlAgentActionStart( + buildArgs({ + worktreeId: 'child', + placementWorkspaceId: 'folder:feature', + groupId: 'folder-group', + initialCwd: '/tmp/wt', + recipe: { agentId: 'codex', surface: 'chat' }, + launchChoice: { + agent: 'codex', + route: 'structured-native-chat', + requestedSurface: 'chat', + executionHostId: 'ssh:box' + } + }) + ) + ).resolves.toBe(true) + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'child', + groupId: 'child-group', + activate: false, + requestedSurface: 'terminal', + selectedExecutionHostId: 'ssh:box' + }) + ) + expect(mocks.launchAgentInNewTab.mock.calls[0]?.[0]).not.toHaveProperty('initialCwd') + expect(mocks.launchAgentInNewTab.mock.calls[0]?.[0]).not.toHaveProperty('trustWorktreeId') + expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature') + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + expect(mocks.toastSuccess).toHaveBeenCalledOnce() + expect(mocks.onLaunched).toHaveBeenCalledWith('child') + expect(mocks.navigate).not.toHaveBeenCalled() + mocks.toastSuccess.mock.calls[0]?.[1].action.onClick() + expect(mocks.navigate).toHaveBeenCalledWith('child', 'ssh:box') + }) + it('notifies onLaunchAccepted as soon as the agent tab is created, before prompt delivery', async () => { let resolveDelivery: (value: { delivered: boolean @@ -122,8 +250,8 @@ describe('runSourceControlAgentActionStart', () => { surface: { kind: 'local-terminal', tabId: 'tab-1' }, promptDeliveryResult }) + useAppStore.setState({ settings: getDefaultSettings('/tmp') }) const args = buildArgs({ - settings: getDefaultSettings('/tmp'), saveTargetValue: 'global', onSaveAgentDefault, onLaunchAccepted, diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.ts index 45f1f2f2f7e..927e145b9cc 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.ts @@ -1,3 +1,10 @@ +import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity' +import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' +import { useAppStore } from '@/store' +import { + canLaunchSourceControlSectionAgentInPlace, + showSourceControlAgentStartedIn +} from './section-agent-placement' import type { DirectWorkItemLaunchResult } from '@/lib/workspace/creation/work-item/direct-work-item-setup' import { sourceControlActionRecipeEntry, @@ -8,7 +15,6 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-session/launch/routing/agent-launch-request-id' import type { RuntimeGitContext } from '@/runtime/runtime-git-client-context' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' import type { Repo } from '../../../../../../shared/repo-types' import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent' import type { AgentLaunchChoice } from '@/lib/agent-menu-selection' @@ -39,10 +45,11 @@ export type RunSourceControlAgentActionStartArgs = { actionId: SourceControlLaunchActionId repoId?: string | null agentLaunchPreferences?: AgentLaunchPreferences - settings: GlobalSettings | null - repo: Pick | null + repo: Pick | null worktreeId?: string | null groupId?: string | null + placementWorkspaceId?: string + initialCwd?: string promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform launchSource: LaunchSource @@ -74,7 +81,7 @@ export type RunSourceControlAgentActionStartArgs = { onLaunchAccepted?: () => void /** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */ onLaunchAborted?: () => void - onLaunched?: () => void + onLaunched?: (backgroundWorkspaceId?: string) => void onClose: () => void } @@ -86,11 +93,12 @@ export async function runSourceControlAgentActionStart({ saveTargetValue, actionId, repoId, - settings, agentLaunchPreferences, - repo, + repo: submittedRepo, worktreeId, groupId, + placementWorkspaceId, + initialCwd, promptDelivery, launchPlatform, launchSource, @@ -115,6 +123,13 @@ export async function runSourceControlAgentActionStart({ if (signal?.aborted) { return false } + const state = useAppStore.getState() + const settings = state.settings + const repo = submittedRepo + ? (state.repos.find((candidate) => + repoMatchesHostIdentity(candidate, submittedRepo.id, getRepoExecutionHostId(submittedRepo)) + ) ?? null) + : null let launched = false let launchFailureNotified = false let launchAcceptedNotified = false @@ -138,11 +153,18 @@ export async function runSourceControlAgentActionStart({ ) ?? recipe launchRecipe = sourceControlActionRecipeEntry(recipeToSave) } + const store = useAppStore.getState() + const inPlace = + placementWorkspaceId && + worktreeId && + canLaunchSourceControlSectionAgentInPlace(store, worktreeId, placementWorkspaceId) + ? placementWorkspaceId + : undefined const projection = projectSelectedSourceControlActionLaunch( launchRecipe, selectedAgent, { renderedPrompt: trimmedCommandInput, settings }, - launchChoice + placementWorkspaceId ? { ...launchChoice, requestedSurface: 'terminal' } : launchChoice ) const { commandInput: _prompt, @@ -211,9 +233,18 @@ export async function runSourceControlAgentActionStart({ } else if (worktreeId) { const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), + ...(placementWorkspaceId && !inPlace ? { activate: false } : {}), agent: selectedAgent, - worktreeId, - groupId: groupId ?? worktreeId, + worktreeId: inPlace ?? worktreeId, + ...(inPlace && inPlace !== worktreeId ? { trustWorktreeId: worktreeId } : {}), + groupId: + placementWorkspaceId && !inPlace + ? (store.activeGroupIdByWorktree[worktreeId] ?? worktreeId) + : ((inPlace ? store.activeGroupIdByWorktree[inPlace] : undefined) ?? + groupId ?? + placementWorkspaceId ?? + worktreeId), + ...((!placementWorkspaceId || inPlace) && initialCwd ? { initialCwd } : {}), prompt: trimmedCommandInput, agentArgs: launchAgentArgs, ...launchOptions, @@ -225,7 +256,7 @@ export async function runSourceControlAgentActionStart({ launchSource }) launched = Boolean(result) - if (result?.surface.kind === 'local-terminal') { + if (result?.surface.kind === 'local-terminal' && (!placementWorkspaceId || inPlace)) { focusTerminalTabSurface(result.surface.tabId) } // Park launch state before slow submit-after-ready completes; host writes still wait for delivery. @@ -258,7 +289,12 @@ export async function runSourceControlAgentActionStart({ return false } - onLaunched?.() + const backgroundWorkspaceId = + placementWorkspaceId && !inPlace && worktreeId ? worktreeId : undefined + if (backgroundWorkspaceId) { + showSourceControlAgentStartedIn(backgroundWorkspaceId, selectedExecutionHostId) + } + onLaunched?.(backgroundWorkspaceId) if (!signal?.aborted) { onClose() } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/section-agent-placement.ts b/src/renderer/src/components/right-sidebar/source-control/ai/section-agent-placement.ts new file mode 100644 index 00000000000..b5d2b755931 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/ai/section-agent-placement.ts @@ -0,0 +1,40 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' +import { workspaceNavigationToastAction } from '@/lib/workspace-navigation-toast-action' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { resolveOwner } from '@/lib/resolve-owner' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import { authorityKey } from '../../../../../../shared/host-authority' + +/** Terminal content still belongs to its placement workspace; a cwd cannot move execution to another host. */ +export function canLaunchSourceControlSectionAgentInPlace( + state: WorktreeOperationRouteState, + worktreeId: string, + placementWorkspaceId: string +): boolean { + const content = resolveOwner(state, { workspaceId: worktreeId }) + const placement = resolveOwner(state, { workspaceId: placementWorkspaceId }) + return ( + content.kind === 'resolved' && + placement.kind === 'resolved' && + authorityKey(content.owner) === authorityKey(placement.owner) + ) +} + +export function showSourceControlAgentStartedIn( + worktreeId: string, + executionHostId?: ExecutionHostId +): void { + const worktree = useAppStore.getState().getKnownWorktreeById(worktreeId, executionHostId) + const name = worktree + ? resolveWorktreeDisplayName(worktree) + : translate('auto.components.sidebar.WorktreeContextMenu.workspaceSection', 'Workspace') + toast.success( + translate('sourceControl.sectionAgentStarted', 'Started in {{value0}}', { value0: name }), + { + action: workspaceNavigationToastAction({ workspaceId: worktreeId, executionHostId }) + } + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog-form.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog-form.tsx index cfaf09b568d..478de814deb 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog-form.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog-form.tsx @@ -1,3 +1,4 @@ +import { useAppStore } from '@/store' import React, { useCallback, useMemo, useState } from 'react' import { RefreshCw, Save, Sparkles, Terminal, TriangleAlert } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -26,7 +27,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../../../../../../ import { formatLinkedIssueTemplateValue } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-action-variables' import type { SourceControlTextActionId } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' +import type { SourceControlRenderSettings } from '../listing/use-worktree-context' import type { Repo } from '../../../../../../shared/repo-types' import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent' import { toast } from 'sonner' @@ -47,7 +48,7 @@ export type SourceControlTextGenerationSaveTarget = { type SourceControlTextGenerationDialogFormProps = { actionId: SourceControlTextActionId generateLabel: string - settings: GlobalSettings | null + settings: SourceControlRenderSettings | null repo: Pick | null baseParams: ResolvedSourceControlAiGenerationParams | null basePromptPreview?: string @@ -129,14 +130,16 @@ export function SourceControlTextGenerationDialogForm({ return sourceControlTextGenerationSaveTargetKey(saveTarget.target) === saveTargetKey }) ?? saveTargets[0] - const params = buildCommitMessageGenerationParams({ - agentId, - commandTemplate, - agentArgs, - baseParams, - settings, - customAgentCommand: baseParams?.customAgentCommand - }) + const buildParams = (settings: SourceControlRenderSettings | null) => + buildCommitMessageGenerationParams({ + agentId, + commandTemplate, + agentArgs, + baseParams, + settings, + customAgentCommand: baseParams?.customAgentCommand + }) + const params = buildParams(settings) // Only chips use workspace previews; synthetic planning keeps empty linkedIssue from blocking repo/global recipe saves. const variablePreviews = useMemo(() => { const previews: Record = {} @@ -227,7 +230,7 @@ export function SourceControlTextGenerationDialogForm({ ) return } - onGenerate(params) + onGenerate(buildParams(useAppStore.getState().settings) ?? params) onOpenChange(false) } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog.tsx index bcb2847e4af..d5108f985c7 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/ai/text-generation-dialog.tsx @@ -13,7 +13,7 @@ import { type ResolvedSourceControlAiGenerationParams } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution' import type { SourceControlTextActionId } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' +import type { SourceControlRenderSettings } from '../listing/use-worktree-context' import type { Repo } from '../../../../../../shared/repo-types' import type { SourceControlAiWriteTarget } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save' import { buildBranchNamePrompt } from '../../../../../../shared/source-control/ai/naming/branch-name-from-work' @@ -31,7 +31,7 @@ import './text-generation-params' type SourceControlTextGenerationBaseDialogProps = { open: boolean onOpenChange: (open: boolean) => void - settings: GlobalSettings | null + settings: SourceControlRenderSettings | null repo?: Pick | null discoveryHostKey: string /** Omitted by workspace-less callers (Settings dry-run); `null` means "linked to nothing". */ diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx index 3e977b7ece4..dea699c3927 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx @@ -3,7 +3,11 @@ import { sourceControlActionRecipeEntry } from '../../../../../../shared/source- import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' -import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' +import { + makeFolderWorkspace, + makeWorktree, + TEST_REPO +} from '@/store/slices/worktrees-slice-test-fixtures' import { clearHostAgentMenuSettingsForTests } from '@/runtime/agent-launch-settings/host-agent-launch-preferences-resource' import { launchSourceControlRecoveryAgentWithDefault, @@ -35,11 +39,26 @@ vi.mock('@/runtime/runtime-git-status-client', async (importOriginal) => ({ getRuntimeGitMutationInFlight: mutationInFlight })) +const navigate = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/workspace-navigation-toast-action', () => ({ + workspaceNavigationToastAction: ({ + workspaceId, + executionHostId + }: { + workspaceId: string + executionHostId?: string + }) => ({ + label: 'Go to workspace', + onClick: () => navigate(workspaceId, executionHostId) + }) +})) const initialState = useAppStore.getState() describe('AI operation recovery composer', () => { beforeEach(() => { vi.mocked(toast.error).mockClear() + vi.mocked(toast.success).mockClear() + navigate.mockClear() hostRpc.mockReset() mutationInFlight.mockReset().mockResolvedValue({ inFlight: false }) detectAgents.mockReset().mockResolvedValue(['codex']) @@ -47,8 +66,13 @@ describe('AI operation recovery composer', () => { setLocalRuntimeCapabilitiesForTests([]) useAppStore.setState({ isRemoteOperationActive: false, + folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'local' })], repos: [TEST_REPO], - worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })] }, + worktreesByRepo: { + [TEST_REPO.id]: [ + makeWorktree({ id: 'repo1::/tmp/wt', repoId: TEST_REPO.id, hostId: 'local' }) + ] + }, ensureDetectedAgents: async () => { const ids: TuiAgent[] = await detectAgents() useAppStore.setState({ localDetectedAgentIdsByContext: { host: ids } }) @@ -71,6 +95,84 @@ describe('AI operation recovery composer', () => { useAppStore.setState(initialState, true) }) + it('launches a section recovery in its folder with the child working directory', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings(path.resolve('tmp')), defaultTuiAgent: 'codex' } + }) + await expect( + launchSourceControlRecoveryAgentWithDefault({ + activeWorktreeId: 'repo1::/tmp/wt', + activeGroupId: 'folder-group', + activeSourceControlLaunchPlatform: 'linux', + sourceRepoConnectionId: null, + placementWorkspaceId: 'folder:feature', + initialCwd: '/folder/repo', + executionHostId: 'local', + actionId: 'fixCommitFailure', + basePrompt: 'fix this commit', + getLaunchActionRecipe: () => + sourceControlActionRecipeEntry({ agentId: 'codex', surface: 'chat' }), + getStoreState: useAppStore.getState, + copy: getDefaultSourceControlRecoveryLaunchCopy('commit') + }) + ).resolves.toBe(true) + expect(launchAgent).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'folder:feature', + groupId: 'folder-group', + initialCwd: '/folder/repo', + trustWorktreeId: 'repo1::/tmp/wt', + selectedExecutionHostId: 'local', + requestedSurface: 'terminal' + }) + ) + expect(detectAgents).toHaveBeenCalledOnce() + }) + + it('uses the child workspace for cross-host recovery without applying its cwd to the folder', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings(path.resolve('tmp')), defaultTuiAgent: 'codex' }, + folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'ssh:other' })], + activeGroupIdByWorktree: { 'repo1::/tmp/wt': 'child-group' }, + activeWorktreeId: 'folder:feature', + activeWorkspaceKey: 'folder:feature', + activeWorkspaceExecutionHostId: 'ssh:other' + }) + await expect( + launchSourceControlRecoveryAgentWithDefault({ + activeWorktreeId: 'repo1::/tmp/wt', + activeGroupId: 'folder-group', + activeSourceControlLaunchPlatform: 'linux', + sourceRepoConnectionId: null, + placementWorkspaceId: 'folder:feature', + initialCwd: '/child', + executionHostId: 'local', + actionId: 'fixCommitFailure', + basePrompt: 'fix this commit', + getLaunchActionRecipe: () => + sourceControlActionRecipeEntry({ agentId: 'codex', surface: 'chat' }), + getStoreState: useAppStore.getState, + copy: getDefaultSourceControlRecoveryLaunchCopy('commit') + }) + ).resolves.toBe(true) + expect(launchAgent).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'repo1::/tmp/wt', + groupId: 'child-group', + activate: false, + requestedSurface: 'terminal', + selectedExecutionHostId: 'local' + }) + ) + expect(launchAgent.mock.calls[0]?.[0]).not.toHaveProperty('initialCwd') + expect(launchAgent.mock.calls[0]?.[0]).not.toHaveProperty('trustWorktreeId') + expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature') + expect(toast.success).toHaveBeenCalledOnce() + expect(navigate).not.toHaveBeenCalled() + const action = vi.mocked(toast.success).mock.calls[0]?.[1]?.action + expect(action).toEqual(expect.objectContaining({ onClick: expect.any(Function) })) + }) + it('claims recovery success only after the terminal and task handoff settle', async () => { vi.mocked(toast.success).mockClear() const completed = Promise.withResolvers<{ @@ -89,7 +191,7 @@ describe('AI operation recovery composer', () => { }) const run = () => launchSourceControlRecoveryAgentWithDefault({ - activeWorktreeId: 'wt-1', + activeWorktreeId: 'repo1::/tmp/wt', activeGroupId: null, activeSourceControlLaunchPlatform: 'linux', sourceRepoConnectionId: null, diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts index 39cc6ccce4f..90b20f49370 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts @@ -1,3 +1,6 @@ +import { useSourceControlScope } from '../panel/worktree-scope' +import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity' +import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' import { captureSourceControlActionSelection, resolveSourceControlActionRecipe, @@ -12,7 +15,6 @@ import { useAppStore } from '@/store' import { getRuntimeGitScope } from '@/runtime/runtime-git-client-context' import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../../../shared/source-control/ai/generation/commit-message-host-key' import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS } from '../../../../../../shared/source-control/ai/settings/source-control-ai-settings' - import { resolveSourceControlAiForOperation, type ResolvedSourceControlAiGenerationParams @@ -34,15 +36,6 @@ import { useConflictRecoveryComposer } from './use-conflict-recovery-composer' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -export function getSourceControlAiControllerDiscoveryHostKey( - target: SourceControlAiControllerParams['target'], - activeConnectionId: string | null | undefined -): string { - return getCommitMessageModelDiscoveryHostKeyForScope( - getRuntimeGitScope(target ?? { kind: 'local' }, activeConnectionId) - ) -} - export function useSourceControlAi({ settings, target, @@ -65,6 +58,8 @@ export function useSourceControlAi({ openSettingsPage, getStoreState = useAppStore.getState }: SourceControlAiControllerParams) { + const scope = useSourceControlScope() + const repoHostId = activeRepo ? getRepoExecutionHostId(activeRepo) : undefined const savedStart = useSavedSourceControlActionStart() const [resolveConflictsEditor, setResolveConflictsEditor] = useState< Partial @@ -72,9 +67,8 @@ export function useSourceControlAi({ const [commitGenerationDialogOpen, setCommitGenerationDialogOpen] = useState(false) const [pullRequestGenerationDialogOpen, setPullRequestGenerationDialogOpen] = useState(false) - const sourceControlAiDiscoveryHostKey = getSourceControlAiControllerDiscoveryHostKey( - target, - activeConnectionId + const sourceControlAiDiscoveryHostKey = getCommitMessageModelDiscoveryHostKeyForScope( + getRuntimeGitScope(target ?? { kind: 'local' }, activeConnectionId) ) const sourceControlAiActionsVisible = useMemo( () => (settings ? resolveSourceControlAiEnabled({ settings, repo: activeRepo }) : false), @@ -92,31 +86,31 @@ export function useSourceControlAi({ : null, [activeRepo, settings, sourceControlAiDiscoveryHostKey] ) - const resolvedPrCreationDefaults = useMemo(() => { - if (!settings) { - return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS - } - const resolved = resolveSourceControlAiForOperation({ - settings, - repo: activeRepo, - operation: 'pullRequest', - discoveryHostKey: sourceControlAiDiscoveryHostKey, - prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS - }) - return resolved.ok - ? resolved.value.prCreationDefaults - : resolveSourceControlAiPrCreationDefaults({ - settings, - repo: activeRepo, - prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS - }) - }, [activeRepo, settings, sourceControlAiDiscoveryHostKey]) + const resolvedPrCreationDefaults = useMemo( + () => + settings + ? resolveSourceControlAiPrCreationDefaults({ settings, repo: activeRepo }) + : DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + [activeRepo, settings] + ) const getLaunchActionRecipe = useCallback( (actionId: SourceControlLaunchActionId): SelectedSourceControlActionRecipe => resolveSourceControlActionRecipe({ settings, repo: activeRepo, actionId }), [activeRepo, settings] ) + const getCurrentLaunchActionRecipe = useCallback( + (actionId: SourceControlLaunchActionId) => { + const state = getStoreState() + const repo = activeRepo + ? (state.repos.find((candidate) => + repoMatchesHostIdentity(candidate, activeRepo.id, getRepoExecutionHostId(activeRepo)) + ) ?? null) + : null + return resolveSourceControlActionRecipe({ settings: state.settings, repo, actionId }) + }, + [activeRepo, getStoreState] + ) const saveActionRecipeForTarget = useCallback( ( target: SourceControlAiWriteTarget, @@ -125,6 +119,7 @@ export function useSourceControlAi({ customAgentCommand?: string ): Promise => saveSourceControlAiActionRecipeForTarget({ + hostId: repoHostId, getStoreState, updateSettings, updateRepo, @@ -133,7 +128,7 @@ export function useSourceControlAi({ recipe, customAgentCommand }), - [getStoreState, updateRepo, updateSettings] + [getStoreState, updateRepo, updateSettings, repoHostId] ) const saveLaunchActionDefault = useCallback( ( @@ -142,6 +137,7 @@ export function useSourceControlAi({ recipe: SourceControlActionRecipe ): Promise => saveSourceControlAiActionRecipeForTarget({ + hostId: repoHostId, getStoreState, updateSettings: updateSettingsOrThrow, updateRepo, @@ -149,7 +145,7 @@ export function useSourceControlAi({ actionId, recipe }), - [getStoreState, updateRepo, updateSettingsOrThrow] + [getStoreState, updateRepo, updateSettingsOrThrow, repoHostId] ) const openSourceControlAiSettings = useCallback((): void => { @@ -177,14 +173,19 @@ export function useSourceControlAi({ onAdmitted: async (recoveryContext, signal) => { const editor = { initialSelection: captureSourceControlActionSelection({ - settings, + settings: getStoreState().settings, repo: activeRepo, actionId: 'resolveConflicts' }), recoveryContext, baseCommandInput: resolveConflictsPrompt, worktreeId: activeWorktreeId, - groupId: activeGroupId, + placementWorkspaceId: scope?.placementWorkspaceId, + initialCwd: scope ? (worktreePath ?? undefined) : undefined, + groupId: + (scope + ? useAppStore.getState().activeGroupIdByWorktree[scope.placementWorkspaceId] + : undefined) ?? activeGroupId, connectionId: activeConnectionId, repoId: activeRepo?.id, launchPlatform: activeSourceControlLaunchPlatform @@ -194,13 +195,13 @@ export function useSourceControlAi({ basePrompt: resolveConflictsPrompt, ...editor, actionId: 'resolveConflicts', - settings, repo: activeRepo, promptDelivery: 'submit-after-ready', launchSource: 'conflict_resolution', signal, onClose: () => {}, - onLaunched: () => + onLaunched: (backgroundWorkspaceId) => + !backgroundWorkspaceId && toast.success( translate( 'auto.components.right.sidebar.SourceControl.e48caaf0dd', @@ -235,7 +236,7 @@ export function useSourceControlAi({ commitError, pushRecoveryPrompt, stagedEntries, - getLaunchActionRecipe, + getLaunchActionRecipe: getCurrentLaunchActionRecipe, getStoreState }) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts index ccf747b0fa3..02b53ec6e1d 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts @@ -1,3 +1,4 @@ +import { useSourceControlScope } from '../panel/worktree-scope' import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection' import { useCallback, useMemo, useState } from 'react' import type { SourceControlLaunchActionId } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions' @@ -42,6 +43,7 @@ export function useSourceControlRecoveryAi({ getLaunchActionRecipe, getStoreState }: SourceControlRecoveryAiParams) { + const scope = useSourceControlScope() const [isLaunchingCommitFailureAgent, setIsLaunchingCommitFailureAgent] = useState(false) const [isLaunchingPushFailureAgent, setIsLaunchingPushFailureAgent] = useState(false) @@ -68,6 +70,9 @@ export function useSourceControlRecoveryAi({ try { return await launchSourceControlRecoveryAgentWithDefault({ activeWorktreeId, + placementWorkspaceId: scope?.placementWorkspaceId, + initialCwd: scope ? (worktreePath ?? undefined) : undefined, + executionHostId: scope?.executionHostId, activeGroupId, activeSourceControlLaunchPlatform, sourceRepoConnectionId, @@ -83,6 +88,8 @@ export function useSourceControlRecoveryAi({ } }, [ + scope, + worktreePath, activeGroupId, activeWorktreeId, activeSourceControlLaunchPlatform, @@ -105,6 +112,9 @@ export function useSourceControlRecoveryAi({ try { return await launchSourceControlRecoveryAgentWithDefault({ activeWorktreeId, + placementWorkspaceId: scope?.placementWorkspaceId, + initialCwd: scope ? (worktreePath ?? undefined) : undefined, + executionHostId: scope?.executionHostId, activeGroupId, activeSourceControlLaunchPlatform, sourceRepoConnectionId, @@ -120,6 +130,8 @@ export function useSourceControlRecoveryAi({ } }, [ + scope, + worktreePath, activeGroupId, activeWorktreeId, activeSourceControlLaunchPlatform, diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.test.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.test.tsx new file mode 100644 index 00000000000..e95bdb05dd1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../../../shared/constants' +import { canonicalSourceControlActionSelection } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection' +import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' +import { useSavedSourceControlActionStart } from './use-saved-source-control-action-start' + +const { launch } = vi.hoisted(() => ({ launch: vi.fn() })) +vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: launch +})) +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/lib/host-chat-program-acquisition', () => ({ + AgentLaunchChoiceCancelled: class extends Error {}, + acquireAgentLaunchChoices: async () => ({ detected: ['claude'] }) +})) +vi.mock('@/runtime/agent-launch-settings/use-host-agent-launch-preferences', () => ({ + ensureAgentLaunchPreferences: async () => undefined +})) +const initial = useAppStore.getState() +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) +}) + +it('uses the current Claude permission default when a saved action launches after render', async () => { + const settings = { ...getDefaultSettings('/tmp'), agentPermissionDefault: 'full-access' as const } + useAppStore.setState({ + settings, + repos: [TEST_REPO], + worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'child', repoId: TEST_REPO.id })] } + }) + launch.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 'agent' } }) + const { result } = renderHook(() => { + const saved = useSavedSourceControlActionStart() + return () => + saved.start({ + selection: canonicalSourceControlActionSelection({ + agentId: 'claude', + surface: 'terminal', + agentArgs: '--model opus' + }), + basePrompt: 'Fix the failed commit', + actionId: 'fixCommitFailure', + repo: TEST_REPO, + repoId: TEST_REPO.id, + worktreeId: 'child', + connectionId: null, + promptDelivery: 'auto-submit', + launchPlatform: 'linux', + launchSource: 'source_control_recovery', + onClose: () => {}, + onFallback: () => { + throw new Error('Unexpected fallback') + } + }) + }) + useAppStore.setState({ settings: { ...settings, agentPermissionDefault: 'ask' } }) + await act(async () => { + await result.current() + }) + expect(launch).toHaveBeenCalledWith( + expect.objectContaining({ agentArgs: '--permission-mode default --model opus' }) + ) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.ts index f26b9fe39a1..5e12a4c828e 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.ts @@ -63,10 +63,6 @@ export function useSavedSourceControlActionStart() { onFallback() return } - const projection = projectSelectedSourceControlActionLaunch(recipe, agent, { - basePrompt: args.basePrompt, - settings: args.settings - }) const workspace = { worktreeId, kind: workspaceKindForWorktreeId(worktreeId) } const [choices, agentLaunchPreferences] = await Promise.all([ acquireAgentLaunchChoices( @@ -77,6 +73,11 @@ export function useSavedSourceControlActionStart() { ensureAgentLaunchPreferences(store, workspace, signal) ]) signal?.throwIfAborted() + const settings = useAppStore.getState().settings + const projection = projectSelectedSourceControlActionLaunch(recipe, agent, { + basePrompt: args.basePrompt, + settings: settings + }) const route = isTuiAgent(agent) ? buildAgentLaunchRouteInput(store, { agent, @@ -103,7 +104,7 @@ export function useSavedSourceControlActionStart() { projection.preferredSurface ? agent : undefined ) const plan = buildSourceControlAgentDeliveryPlan({ - settings: args.settings, + settings, agentLaunchPreferences, selectedAgent: agent, commandInput: projection.commandInput, diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-action-agent-choice.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-action-agent-choice.ts index 54d6094f5ad..43d093157b4 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-action-agent-choice.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-action-agent-choice.ts @@ -1,8 +1,8 @@ +import { useAppStateView } from '@/store/app-state-view' import { buildAgentLaunchRouteInput } from '@/lib/agent-session/launch/routing/agent-launch-route-input' import { workspaceKindForWorktreeId } from '../../../../../../shared/workspace-launch-kind' import { AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-launch/contracts/agent-launch-runtime-capability' import { useMemo } from 'react' -import { useAppStore } from '@/store/index' import { isCustomAgentId } from '../../../../../../shared/source-control/ai/generation/commit-message-agent-spec' import { pickSourceControlLaunchAgent } from '../../../../../../shared/source-control/ai/source-control-launch-agent-selection' import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection' @@ -39,7 +39,9 @@ export function useSourceControlActionAgentChoice(args: { recipe: SelectedSourceControlActionRecipe /** This open's deliberate pick; absent replays the saved recipe. */ livePick: AgentLaunchSurface | undefined + terminalOnly?: boolean }) { + const store = useAppStateView() const { recipe } = args const worktreeDetectionTarget = useAgentDetectionTargetForWorktree(args.worktreeId ?? null) const rows = useMemo( @@ -55,8 +57,9 @@ export function useSourceControlActionAgentChoice(args: { wakeOnOpen: true }) const { detectedIds, detectionFailed, needsServerUpdate } = observation.detection - const detectedAgents = - observation.model.state === 'settled' + const detectedAgents = args.terminalOnly + ? (detectedIds ?? NO_AGENTS) + : observation.model.state === 'settled' ? [ ...new Set( observation.model.rows @@ -76,15 +79,18 @@ export function useSourceControlActionAgentChoice(args: { detectedAgents: detectedIds ?? NO_AGENTS, disabledAgents, chosenOpensAsChat: (agent) => + !args.terminalOnly && observation.model.state === 'settled' && observation.model.rows.some((row) => row.kind === 'chat' && row.agent === agent) }) const savedSurface = recipe.agentId === selectedAgent ? recipe.surface : undefined - const liveSurface = args.livePick ?? savedSurface + const liveSurface = args.terminalOnly ? 'terminal' : (args.livePick ?? savedSurface) const selection = observation.selectionFor( selectedAgent, - args.livePick ?? (savedSurface === 'terminal' ? 'terminal' : undefined), - savedSurface === 'chat' ? 'chat' : undefined + args.terminalOnly + ? 'terminal' + : (args.livePick ?? (savedSurface === 'terminal' ? 'terminal' : undefined)), + !args.terminalOnly && savedSurface === 'chat' ? 'chat' : undefined ) const enabledDetectedAgents = detectedAgents.filter((agent) => isTuiAgentEnabled(agent, disabledAgents) @@ -95,7 +101,7 @@ export function useSourceControlActionAgentChoice(args: { selectedAgent && !isSourceControlAgentDetectedAndEnabled(selectedAgent, detectedAgents, disabledAgents) ) - const route = buildAgentLaunchRouteInput(useAppStore.getState(), { + const route = buildAgentLaunchRouteInput(store.getState(), { agent: selectedAgent ?? 'claude', workspace: args.worktreeId ? { worktreeId: args.worktreeId, kind: workspaceKindForWorktreeId(args.worktreeId) } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-launchable-agents.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-launchable-agents.ts index e6d3c92ff67..96b4a6fbaa8 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-launchable-agents.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-launchable-agents.ts @@ -1,3 +1,4 @@ +import { useSourceControlState } from '../panel/worktree-scope' import { useCallback, useState } from 'react' import { useAppStore } from '@/store/index' import { @@ -24,7 +25,7 @@ export function useSourceControlLaunchableAgents(args: { const workspace = sourceControlLaunchWorkspace(target) useChatProgramFacts(workspace, active && !connectionUnavailable) // Source Control renders with the dialog closed; only an open dialog lists agents. - const detected = useAppStore((state) => + const detected = useSourceControlState((state) => !active ? null : worktreeId diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.ts index 1b6cb0d90aa..34aea415f02 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.ts @@ -1,3 +1,4 @@ +import { useSourceControlState } from '../panel/worktree-scope' import type { AgentPickerDraft, AgentPickerEntry @@ -14,8 +15,7 @@ import { import { captureSourceControlActionSelection } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy' import { useCallback, useMemo, useState } from 'react' import { resolveSourceControlLaunchAgentScope } from '../../../../../../shared/source-control/ai/source-control-launch-agent-selection' -import { useAppStore } from '@/store/index' -import { useRepoById } from '@/store/selectors' +import { selectRepoByIdForActiveWorkspace } from '@/store/selectors' import { renderSourceControlActionCommandTemplate } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions' import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent' import type { AgentLaunchSurface } from '@/lib/agent-session/launch/routing/agent-session-launch-plan' @@ -41,6 +41,8 @@ export function useSourceControlAgentActionDialog({ baseCommandInput, worktreeId, groupId, + placementWorkspaceId, + initialCwd, connectionId, repoId, promptDelivery = 'submit-after-ready', @@ -53,8 +55,10 @@ export function useSourceControlAgentActionDialog({ onLaunched, onStart }: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult { - const settings = useAppStore((state) => state.settings) - const repo = useRepoById(repoId ?? null) + const settings = useSourceControlState((state) => state.settings) + const repo = useSourceControlState((state) => + selectRepoByIdForActiveWorkspace(state, repoId ?? null) + ) const [currentSelection, setCurrentSelection] = useState( () => initialSelection ?? @@ -83,7 +87,8 @@ export function useSourceControlAgentActionDialog({ repoId, repo, recipe, - livePick + livePick, + terminalOnly: Boolean(placementWorkspaceId) }) const launchAgentScope = useMemo( () => @@ -130,11 +135,12 @@ export function useSourceControlAgentActionDialog({ saveTargetValue, actionId, repoId, - settings, agentLaunchPreferences: choice.observation.preferences, repo, worktreeId, groupId, + placementWorkspaceId, + initialCwd, promptDelivery, launchPlatform, // Why: an SSH host runs the plain `orca` shim; keep the previewed command diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionStart.ts index f7f2dbf6619..4493b912b62 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionStart.ts @@ -1,3 +1,4 @@ +import { useAppStore } from '@/store' import { projectSelectedSourceControlActionLaunch } from '../../../../../../shared/source-control/ai/recipes/source-control-action-launch-preferences' import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection' import type { AgentLaunchPreferences } from '@/runtime/agent-launch-settings/host-agent-launch-preferences-resource' @@ -9,7 +10,6 @@ import type { SourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' import type { Repo } from '../../../../../../shared/repo-types' import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent' import type { AgentLaunchChoice } from '@/lib/agent-menu-selection' @@ -36,10 +36,11 @@ type UseSourceControlAgentActionStartArgs = { actionId: SourceControlLaunchActionId repoId?: string | null agentLaunchPreferences: AgentLaunchPreferences - settings: GlobalSettings | null repo: Pick | null worktreeId?: string | null groupId?: string | null + placementWorkspaceId?: string + initialCwd?: string promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform /** SSH uses plain orca; omit the local Linux orca-ide rename in its preview. */ @@ -59,7 +60,7 @@ type UseSourceControlAgentActionStartArgs = { ) => void | Promise onLaunchAccepted?: () => void onLaunchAborted?: () => void - onLaunched?: () => void + onLaunched?: RunSourceControlAgentActionStartArgs['onLaunched'] onClose: () => void } @@ -92,11 +93,12 @@ export function useSourceControlAgentActionStart({ saveTargetValue, actionId, repoId, - settings, agentLaunchPreferences, repo, worktreeId, groupId, + placementWorkspaceId, + initialCwd, promptDelivery, launchPlatform, isRemote, @@ -138,7 +140,7 @@ export function useSourceControlAgentActionStart({ submitted: SelectedSourceControlActionRecipe ): SourceControlAgentActionDeliveryPlanState => buildSourceControlAgentDeliveryPlan({ - settings, + settings: useAppStore.getState().settings, agentLaunchPreferences, selectedAgent, commandInput, @@ -147,7 +149,7 @@ export function useSourceControlAgentActionStart({ agentArgsApply && selectedAgent ? projectSelectedSourceControlActionLaunch(submitted, selectedAgent, { renderedPrompt: commandInput, - settings + settings: useAppStore.getState().settings }).agentArgs : undefined, detectedAgents: currentDetectedAgents, @@ -156,7 +158,6 @@ export function useSourceControlAgentActionStart({ isRemote }), [ - settings, agentLaunchPreferences, agentArgsApply, commandInput, @@ -197,11 +198,12 @@ export function useSourceControlAgentActionStart({ saveTargetValue: saveLaunchRecipe ? saveTargetValue : 'none', actionId, repoId, - settings, agentLaunchPreferences, repo, worktreeId, groupId, + placementWorkspaceId, + initialCwd, promptDelivery, launchPlatform, launchSource, @@ -241,6 +243,8 @@ export function useSourceControlAgentActionStart({ prepareRecipe, connectionUnavailable, groupId, + placementWorkspaceId, + initialCwd, launchSource, recoveryContext, launchPlatform, @@ -257,7 +261,6 @@ export function useSourceControlAgentActionStart({ repoId, saveLaunchRecipe, saveTargetValue, - settings, selectedAgent, trimmedCommandInput, worktreeId diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/commit-drafts.ts b/src/renderer/src/components/right-sidebar/source-control/commit/commit-drafts.ts index 01534f934c9..19d43360a30 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/commit-drafts.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/commit-drafts.ts @@ -1,22 +1,5 @@ import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-types' -export type CommitDraftsByWorktree = Record - export function normalizeSourceControlViewMode(value: unknown): SourceControlViewMode { return value === 'tree' || value === 'list' ? value : 'list' } - -export function readCommitDraftForWorktree( - drafts: CommitDraftsByWorktree, - worktreeId: string | null | undefined -): string { - return drafts[worktreeId ?? ''] ?? '' -} - -export function writeCommitDraftForWorktree( - drafts: CommitDraftsByWorktree, - worktreeId: string, - value: string -): CommitDraftsByWorktree { - return { ...drafts, [worktreeId]: value } -} diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts index ce4fcff6998..65959fd532c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts @@ -1,6 +1,10 @@ // @vitest-environment happy-dom +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' +import { SourceControlScopeProvider } from '../panel/worktree-scope' import type { SourceControlToastTestOptions } from './source-control-toast-test-options' const { toastError, toastDismiss } = vi.hoisted(() => ({ @@ -83,4 +87,29 @@ describe('showSourceControlEntryFailureToast', () => { dismissSourceControlEntryFailureToast('wt-2') expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation') }) + + it('keeps Retry while a folder section shows the worktree under the active folder workspace', () => { + storeState.activeWorktreeId = 'folder:feature' + const owner = { + worktreeId: 'wt-1', + repoId: 'repo-1', + executionHostId: 'local' as const, + worktree: makeWorktree('wt-1', 'feature-a'), + attachedAt: 1, + repo: makeRepo(), + placementWorkspaceId: 'folder:feature' + } + const root = createRoot(document.createElement('div')) + act(() => root.render(createElement(SourceControlScopeProvider, { owner }, null))) + const onRetry = vi.fn() + show({ onRetry }) + + clickRetry() + expect(onRetry).toHaveBeenCalledTimes(1) + + act(() => root.unmount()) + clickRetry() + expect(onRetry).toHaveBeenCalledTimes(1) + expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation') + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts index d3b06eb1b55..80cd2986e9c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts @@ -2,11 +2,20 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { readIpcErrorMessage } from '@/lib/ipc-error' import { useAppStore } from '@/store' +import { isSourceControlScopeMounted } from '../panel/worktree-scope' export type SourceControlEntryOperation = 'stage' | 'unstage' | 'discard' const ENTRY_FAILURE_TOAST_ID = 'source-control-entry-mutation' +// A mounted folder section shows the worktree even when the folder workspace is the active one. +function entryRowsVisible(worktreeId: string | null): boolean { + return ( + useAppStore.getState().activeWorktreeId === worktreeId || + isSourceControlScopeMounted(worktreeId) + ) +} + // Wrap nullable ownership to distinguish an empty slot from a null-owned one. let entryFailureSlotOwner: { worktreeId: string | null } | null = null @@ -75,7 +84,7 @@ export function showSourceControlEntryFailureToast({ }): void { const isActiveWorktree = useAppStore.getState().activeWorktreeId === worktreeId const title = entryFailureTitle(operation, filePath, deletesFile) - const offerRetry = Boolean(onRetry) && isActiveWorktree + const offerRetry = Boolean(onRetry) && entryRowsVisible(worktreeId) entryFailureSlotOwner = { worktreeId } toast.error( isActiveWorktree || !worktreeName @@ -97,7 +106,7 @@ export function showSourceControlEntryFailureToast({ onClick: (event) => { // Own dismissal instead of Sonner's delayed ID removal, which could erase a fast retry failure in the same slot. event.preventDefault() - if (useAppStore.getState().activeWorktreeId !== worktreeId) { + if (!entryRowsVisible(worktreeId)) { dismissSourceControlEntryFailureToast(worktreeId) return } diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts index 6121670b7cd..9132641e96b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { toast } from 'sonner' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { translate } from '@/i18n/i18n' import { readIpcErrorMessage } from '@/lib/ipc-error' import { @@ -56,6 +56,7 @@ export function useSourceControlBulkActions({ clearSelection: () => void refreshActiveGitStatusAfterMutation: () => Promise }) { + const sourceControlScope = useSourceControlScope() const [isExecutingBulk, setIsExecutingBulk] = useState(false) // Reset during render so switches never paint the previous worktree's busy state. const [bulkActionsWorktreeId, setBulkActionsWorktreeId] = useState(activeWorktreeId) @@ -92,7 +93,7 @@ export function useSourceControlBulkActions({ } setIsExecutingBulk(true) try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const connectionId = sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null) await bulkStageRuntimeGitPaths( { // Route staging through workspace ownership rather than focused runtime. @@ -117,7 +118,8 @@ export function useSourceControlBulkActions({ clearSelection, activeWorktreeId, isExecutingBulk, - refreshActiveGitStatusAfterMutation + refreshActiveGitStatusAfterMutation, + sourceControlScope ]) const handleBulkUnstage = useCallback(async () => { @@ -126,7 +128,7 @@ export function useSourceControlBulkActions({ } setIsExecutingBulk(true) try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const connectionId = sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null) await bulkUnstageRuntimeGitPaths( { // Route unstaging through workspace ownership rather than focused runtime. @@ -151,7 +153,8 @@ export function useSourceControlBulkActions({ clearSelection, activeWorktreeId, isExecutingBulk, - refreshActiveGitStatusAfterMutation + refreshActiveGitStatusAfterMutation, + sourceControlScope ]) // Scopes replace explicit paths; only the host can enumerate changes beyond the listing cap. @@ -167,7 +170,7 @@ export function useSourceControlBulkActions({ target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, - connectionId: getConnectionId(activeWorktreeId ?? null) ?? undefined + connectionId: sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null) } await (scope ? stageRuntimeGitWorktreeScope(context, scope) @@ -181,6 +184,7 @@ export function useSourceControlBulkActions({ } }, [ + sourceControlScope, activeRepoTarget, activeWorktreeId, clearSelection, @@ -209,7 +213,7 @@ export function useSourceControlBulkActions({ } setIsExecutingBulk(true) try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const connectionId = sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null) await bulkUnstageRuntimeGitPaths( { // Route unstaging through workspace ownership rather than focused runtime. @@ -229,6 +233,7 @@ export function useSourceControlBulkActions({ } }, [ + sourceControlScope, activeRepoTarget, activeWorktreeId, clearSelection, 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 82d63f19b2f..b59af4cc74d 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 @@ -2,14 +2,13 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target' import { translate } from '@/i18n/i18n' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { commitRuntimeGit, completeRuntimeGitMerge } from '@/runtime/runtime-git-sync-client' import type { SourceControlOperationTarget } from '../listing/operation-target' import type { SourceControlStoreActions } from '../listing/use-store-actions' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' import type { SourceControlStatusRefresh } from '../sync/use-status-refresh' -import { writeCommitDraftForWorktree } from './commit-drafts' /** Explicit targets keep Create PR commits on their starting worktree after navigation. */ export function useSourceControlCommitAction({ @@ -17,36 +16,33 @@ export function useSourceControlCommitAction({ activeWorktree, activeWorktreeId, beginGitBranchCompareRequest, - commitInFlightRef, commitMessage, compareBaseRef, + operationStore, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - setCommitErrorForWorktree, - setCommitInFlightByWorktree, stagedCount, unresolvedConflictCount, - updateCommitDrafts, + updateCommitDraft, worktreePath }: { activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktree: SourceControlWorktreeContext['activeWorktree'] activeWorktreeId: string | null beginGitBranchCompareRequest: SourceControlStoreActions['beginGitBranchCompareRequest'] - commitInFlightRef: SourceControlWorktreeOperationState['commitInFlightRef'] commitMessage: string compareBaseRef: string | null + operationStore: SourceControlWorktreeOperationState['operationStore'] refreshActiveGitStatusAfterMutation: SourceControlStatusRefresh['refreshActiveGitStatusAfterMutation'] refreshBranchCompareRef: React.RefObject<() => Promise> refreshGitHistoryRef: React.RefObject<() => Promise> - setCommitErrorForWorktree: SourceControlWorktreeOperationState['setCommitErrorForWorktree'] - setCommitInFlightByWorktree: SourceControlWorktreeOperationState['setCommitInFlightByWorktree'] stagedCount: number unresolvedConflictCount: number - updateCommitDrafts: SourceControlWorktreeOperationState['updateCommitDrafts'] + updateCommitDraft: SourceControlWorktreeOperationState['updateCommitDraft'] worktreePath: string | null }) { + const scope = useSourceControlScope() const handleCommit = useCallback( async ( messageOverride?: string, @@ -63,7 +59,7 @@ export function useSourceControlCommitAction({ target: activeRepoTarget, worktreeId: activeWorktreeId, worktreePath, - connectionId: getConnectionId(activeWorktreeId) ?? undefined, + connectionId: sourceControlConnectionId(scope, activeWorktreeId), pushTarget: activeWorktree?.pushTarget } : null) @@ -79,13 +75,11 @@ export function useSourceControlCommitAction({ return false } - if (commitInFlightRef.current[target.worktreeId]) { + // Admission reads the owner record synchronously so a double-click or remount cannot resubmit. + if (operationStore.getState().isCommitting) { return false } - commitInFlightRef.current[target.worktreeId] = true - - setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true })) - setCommitErrorForWorktree(target.worktreeId, null) + operationStore.setState({ isCommitting: true, commitError: null }) try { const commitResult = await commitRuntimeGit( { @@ -98,19 +92,13 @@ export function useSourceControlCommitAction({ message ) if (!commitResult.success) { - setCommitErrorForWorktree(target.worktreeId, commitResult.error ?? 'Commit failed') + operationStore.setState({ commitError: commitResult.error ?? 'Commit failed' }) return false } // Clear only an unchanged submitted draft so typing during commit cannot lose new edits. - updateCommitDrafts((prev) => { - const current = prev[target.worktreeId] - if (current !== undefined && current.trim() !== message) { - return prev - } - return writeCommitDraftForWorktree(prev, target.worktreeId, '') - }) - setCommitErrorForWorktree(target.worktreeId, null) + updateCommitDraft((current) => (current.trim() !== message ? current : '')) + operationStore.setState({ commitError: null }) if (!options?.target) { void refreshActiveGitStatusAfterMutation() } @@ -128,31 +116,28 @@ export function useSourceControlCommitAction({ } return true } catch (error) { - setCommitErrorForWorktree( - target.worktreeId, - error instanceof Error ? error.message : 'Commit failed' - ) + operationStore.setState({ + commitError: error instanceof Error ? error.message : 'Commit failed' + }) return false } finally { - setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false })) - commitInFlightRef.current[target.worktreeId] = false + operationStore.setState({ isCommitting: false }) } }, [ + scope, activeRepoTarget, activeWorktree?.pushTarget, activeWorktreeId, beginGitBranchCompareRequest, - commitInFlightRef, commitMessage, compareBaseRef, + operationStore, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - setCommitErrorForWorktree, - setCommitInFlightByWorktree, stagedCount, - updateCommitDrafts, + updateCommitDraft, unresolvedConflictCount, worktreePath ] @@ -162,24 +147,22 @@ export function useSourceControlCommitAction({ if (!activeWorktreeId || !worktreePath || unresolvedConflictCount > 0) { return } - if (commitInFlightRef.current[activeWorktreeId]) { + if (operationStore.getState().isCommitting) { return } - commitInFlightRef.current[activeWorktreeId] = true - setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) + operationStore.setState({ isCommitting: true }) let result: { success: boolean; error?: string } try { result = await completeRuntimeGitMerge({ target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, - connectionId: getConnectionId(activeWorktreeId) ?? undefined + connectionId: sourceControlConnectionId(scope, activeWorktreeId) }) } catch (error) { result = { success: false, error: error instanceof Error ? error.message : undefined } } finally { - setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) - commitInFlightRef.current[activeWorktreeId] = false + operationStore.setState({ isCommitting: false }) } void refreshActiveGitStatusAfterMutation() if (!result.success) { @@ -206,12 +189,12 @@ export function useSourceControlCommitAction({ activeRepoTarget, activeWorktreeId, beginGitBranchCompareRequest, - commitInFlightRef, compareBaseRef, + operationStore, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - setCommitInFlightByWorktree, + scope, unresolvedConflictCount, worktreePath ]) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts index 875c9a0d3a4..ade8d26e6f8 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts @@ -15,43 +15,28 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda allocateCommitMessageGenerationRequestId, beginGitBranchCompareRequest, branchName, - commitInFlightRef, commitMessage, - commitMessageGenerationRecords, compareBaseRef, conflictOperation, effectiveBaseRef, fastForwardBranch, fetchBranch, - generateErrors, - generateInFlightByWorktree, - generateInFlightRef, getCreatePrIntentOperationTarget, grouped, - isAbortingOperation, openCommitGenerationDialog, + operationStore, pullBranch, pushBranch, rebaseFromBase, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - remoteActionErrorSequenceByWorktreeRef, remoteStatus, remoteStatusForActions, - resolvedCommitMessageAi, - setAbortOperationInFlightByWorktree, - setCommitErrorForWorktree, - setCommitInFlightByWorktree, setCommitMessageGenerationRecord, - setGenerateErrors, - setGenerateInFlightByWorktree, - setRemoteActionErrors, - settings, - sourceControlAiActionsVisible, syncBranch, unresolvedConflicts, - updateCommitDrafts, + updateCommitDraft, updateCommitMessageGenerationRecord, worktreePath } = foundation @@ -61,17 +46,15 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda activeWorktree, activeWorktreeId, beginGitBranchCompareRequest, - commitInFlightRef, commitMessage, compareBaseRef, + operationStore, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - setCommitErrorForWorktree, - setCommitInFlightByWorktree, stagedCount: grouped.staged.length, unresolvedConflictCount: unresolvedConflicts.length, - updateCommitDrafts, + updateCommitDraft, worktreePath }) const commitMessageGeneration = useSourceControlCommitMessageGeneration({ @@ -79,18 +62,10 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda activeRepoTarget, activeWorktreeId, allocateCommitMessageGenerationRequestId, - commitMessageGenerationRecords, - generateErrors, - generateInFlightByWorktree, - generateInFlightRef, openCommitGenerationDialog, - resolvedCommitMessageAi, + operationStore, setCommitMessageGenerationRecord, - setGenerateErrors, - setGenerateInFlightByWorktree, - settings, - sourceControlAiActionsVisible, - updateCommitDrafts, + updateCommitDraft, updateCommitMessageGenerationRecord, worktreePath }) @@ -108,12 +83,8 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda ) const createPrIntentCommitMessage = useSourceControlCreatePrIntentCommitMessage({ activeRepo, - generateInFlightRef, getCreatePrIntentOperationTarget, - resolvedCommitMessageAi, - setGenerateErrors, - setGenerateInFlightByWorktree, - settings + operationStore }) const remoteActionRunner = useSourceControlRemoteActionRunner({ activeRepoTarget, @@ -125,16 +96,15 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda fetchBranch, grouped, handleCommit, + operationStore, pullBranch, pushBranch, rebaseFromBase, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - remoteActionErrorSequenceByWorktreeRef, remoteStatus, remoteStatusForActions, - setRemoteActionErrors, syncBranch, worktreePath }) @@ -142,12 +112,10 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda activeRepoTarget, activeWorktreeId, conflictOperation, - isAbortingOperation, + operationStore, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - setAbortOperationInFlightByWorktree, - setRemoteActionErrors, worktreePath }) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.cancel.test.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.cancel.test.ts index a84ba51bed6..cb458535b86 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.cancel.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.cancel.test.ts @@ -4,7 +4,10 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { useAppStore } from '@/store' -import type { CommitDraftsByWorktree } from './commit-drafts' +import { + getWorktreeOperationStore, + writeWorktreeCommitDraft +} from '../panel/worktree-operation-store' const host = vi.hoisted(() => { const held: { @@ -26,33 +29,21 @@ import { useSourceControlCommitMessageGeneration } from './use-commit-message-ge const WORKTREE_ID = 'repo-1::/repo' const initialAppState = useAppStore.getInitialState() -let drafts: CommitDraftsByWorktree = {} -const generateInFlightRef: { current: Record } = { current: {} } +const operationStore = getWorktreeOperationStore(WORKTREE_ID, null, undefined) let latest: ReturnType | null = null let root: Root | null = null function Probe(): null { - const records = useAppStore((s) => s.commitMessageGenerationRecords) const state = useAppStore.getState() latest = useSourceControlCommitMessageGeneration({ activeRepo: null, activeRepoTarget: { kind: 'local' }, activeWorktreeId: WORKTREE_ID, allocateCommitMessageGenerationRequestId: state.allocateCommitMessageGenerationRequestId, - commitMessageGenerationRecords: records, - generateErrors: {}, - generateInFlightByWorktree: {}, - generateInFlightRef, openCommitGenerationDialog: () => {}, - resolvedCommitMessageAi: null, + operationStore, setCommitMessageGenerationRecord: state.setCommitMessageGenerationRecord, - setGenerateErrors: () => {}, - setGenerateInFlightByWorktree: () => {}, - settings: null, - sourceControlAiActionsVisible: true, - updateCommitDrafts: (update) => { - drafts = typeof update === 'function' ? update(drafts) : update - }, + updateCommitDraft: (update) => writeWorktreeCommitDraft(operationStore, update), updateCommitMessageGenerationRecord: state.updateCommitMessageGenerationRecord, worktreePath: '/repo' }) @@ -75,8 +66,7 @@ async function startGenerating(): Promise<{ pending: Promise | undefined } beforeEach(() => { useAppStore.setState({ recordFeatureInteraction: async () => {} }) - drafts = {} - generateInFlightRef.current = {} + writeWorktreeCommitDraft(operationStore, '') }) afterEach(() => { @@ -97,6 +87,6 @@ describe('a commit message generation that was canceled', () => { host.succeed?.('feat: generated for the old diff') await pending }) - expect(drafts[WORKTREE_ID]).toBeUndefined() + expect(operationStore.getState().draft).toBe('') }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts index 058469f99de..c03ae1b2ee4 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts @@ -1,6 +1,16 @@ +import { + useSourceControlStoreView, + useSourceControlState, + sourceControlConnectionId, + useSourceControlScope +} from '../panel/worktree-scope' +import { resolveSourceControlAiEnabled } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy' +import { resolveSourceControlAiForOperation } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution' +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../../../shared/source-control/ai/generation/commit-message-host-key' +import { getRuntimeGitScope } from '@/runtime/runtime-git-client-context' import { textGenerationCopyText } from '@/lib/text-generation-error-copy' import { useCallback, useEffect } from 'react' -import { getConnectionId } from '@/lib/connection-context' +import { useStore } from 'zustand' import { cancelRuntimeGenerateCommitMessage, generateRuntimeCommitMessage @@ -22,7 +32,6 @@ import type { SourceControlAi } from '../ai/use-ai' import type { SourceControlStoreActions } from '../listing/use-store-actions' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' -import { writeCommitDraftForWorktree } from './commit-drafts' /** Persist generation records so results completed after unmount hydrate the textarea once on return. */ export function useSourceControlCommitMessageGeneration({ @@ -30,18 +39,10 @@ export function useSourceControlCommitMessageGeneration({ activeRepoTarget, activeWorktreeId, allocateCommitMessageGenerationRequestId, - commitMessageGenerationRecords, - generateErrors, - generateInFlightByWorktree, - generateInFlightRef, openCommitGenerationDialog, - resolvedCommitMessageAi, + operationStore, setCommitMessageGenerationRecord, - setGenerateErrors, - setGenerateInFlightByWorktree, - settings, - sourceControlAiActionsVisible, - updateCommitDrafts, + updateCommitDraft, updateCommitMessageGenerationRecord, worktreePath }: { @@ -49,34 +50,47 @@ export function useSourceControlCommitMessageGeneration({ activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null allocateCommitMessageGenerationRequestId: SourceControlStoreActions['allocateCommitMessageGenerationRequestId'] - commitMessageGenerationRecords: SourceControlStoreActions['commitMessageGenerationRecords'] - generateErrors: SourceControlWorktreeOperationState['generateErrors'] - generateInFlightByWorktree: SourceControlWorktreeOperationState['generateInFlightByWorktree'] - generateInFlightRef: SourceControlWorktreeOperationState['generateInFlightRef'] openCommitGenerationDialog: SourceControlAi['openCommitGenerationDialog'] - resolvedCommitMessageAi: SourceControlAi['resolvedCommitMessageAi'] + operationStore: SourceControlWorktreeOperationState['operationStore'] setCommitMessageGenerationRecord: SourceControlStoreActions['setCommitMessageGenerationRecord'] - setGenerateErrors: SourceControlWorktreeOperationState['setGenerateErrors'] - setGenerateInFlightByWorktree: SourceControlWorktreeOperationState['setGenerateInFlightByWorktree'] - settings: SourceControlWorktreeContext['settings'] - sourceControlAiActionsVisible: boolean - updateCommitDrafts: SourceControlWorktreeOperationState['updateCommitDrafts'] + updateCommitDraft: SourceControlWorktreeOperationState['updateCommitDraft'] updateCommitMessageGenerationRecord: SourceControlStoreActions['updateCommitMessageGenerationRecord'] worktreePath: string | null }) { + const scope = useSourceControlScope() const activeCommitMessageGenerationKey = getCommitMessageGenerationRecordKey( activeWorktreeId, worktreePath ) + // Select only this owner's record so another worktree's generation cannot re-render this panel. const activeCommitMessageGenerationRecord: CommitMessageGenerationRecord | null = - activeCommitMessageGenerationKey - ? (commitMessageGenerationRecords[activeCommitMessageGenerationKey] ?? null) + useSourceControlState((s) => + activeCommitMessageGenerationKey + ? (s.commitMessageGenerationRecords[activeCommitMessageGenerationKey] ?? null) + : null + ) + const operationView = useSourceControlStoreView(operationStore) + const generateInFlight = useStore(operationView, (s) => s.generateInFlight) + const ownerGenerateError = useStore(operationView, (s) => s.generateError) + const isGenerating = activeCommitMessageGenerationRecord?.status === 'running' || generateInFlight + const generateError = activeCommitMessageGenerationRecord?.error ?? ownerGenerateError + + const resolveCurrentAi = useCallback(() => { + const settings = useAppStore.getState().settings + return settings + ? resolveSourceControlAiForOperation({ + settings, + repo: activeRepo, + operation: 'commitMessage', + discoveryHostKey: getCommitMessageModelDiscoveryHostKeyForScope( + getRuntimeGitScope( + activeRepoTarget ?? { kind: 'local' }, + sourceControlConnectionId(scope, activeWorktreeId) + ) + ) + }) : null - const isGenerating = - activeCommitMessageGenerationRecord?.status === 'running' || - (generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false) - const generateError = - activeCommitMessageGenerationRecord?.error ?? generateErrors[activeWorktreeId ?? ''] ?? null + }, [activeRepo, activeRepoTarget, activeWorktreeId, scope]) const handleGenerate = useCallback( async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise => { @@ -88,9 +102,10 @@ export function useSourceControlCommitMessageGeneration({ ) { return } - if (generateInFlightRef.current[activeWorktreeId]) { + if (operationStore.getState().generateInFlight) { return } + const resolvedCommitMessageAi = resolveCurrentAi() if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) { return } @@ -103,14 +118,14 @@ export function useSourceControlCommitMessageGeneration({ const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? '' if (!command) { const message = textGenerationCopyText('sourceControlCustomCommandEmpty') - setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: message })) + operationStore.setState({ generateError: message }) return } } - generateInFlightRef.current[activeWorktreeId] = true + operationStore.setState({ generateInFlight: true, generateError: null }) const requestId = allocateCommitMessageGenerationRequestId() - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) setCommitMessageGenerationRecord( activeCommitMessageGenerationKey, createRunningCommitMessageGenerationRecord({ @@ -121,14 +136,12 @@ export function useSourceControlCommitMessageGeneration({ runtimeTarget: activeRepoTarget }) ) - setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) - setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { const result = await generateRuntimeCommitMessage( { // Route generation through workspace ownership rather than focused runtime. target: activeRepoTarget, - prefs: settings, + prefs: useAppStore.getState().settings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -139,7 +152,7 @@ export function useSourceControlCommitMessageGeneration({ if (!result.success) { // Cancellation is an intentional action and must not surface as failure. if (result.canceled) { - setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + operationStore.setState({ generateError: null }) updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) => resolveCommitMessageGenerationFailure({ record, @@ -150,10 +163,7 @@ export function useSourceControlCommitMessageGeneration({ ) return } - setGenerateErrors((prev) => ({ - ...prev, - [activeWorktreeId]: result.error - })) + operationStore.setState({ generateError: result.error }) updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) => resolveCommitMessageGenerationFailure({ record, @@ -174,20 +184,14 @@ export function useSourceControlCommitMessageGeneration({ setCommitMessageGenerationRecord(activeCommitMessageGenerationKey, succeeded) } // Preserve typed edits and drop cancelled runs even if the host succeeds; their message may describe obsolete changes. - updateCommitDrafts((prev) => { - const current = prev[activeWorktreeId] - return !succeeded || (current && current.length > 0) - ? prev - : writeCommitDraftForWorktree(prev, activeWorktreeId, result.message) - }) + updateCommitDraft((current) => + !succeeded || current.length > 0 ? current : result.message + ) useAppStore.getState().recordFeatureInteraction('ai-commit-generation') - setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + operationStore.setState({ generateError: null }) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to generate commit message' - setGenerateErrors((prev) => ({ - ...prev, - [activeWorktreeId]: message - })) + operationStore.setState({ generateError: message }) updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) => resolveCommitMessageGenerationFailure({ record, @@ -196,29 +200,28 @@ export function useSourceControlCommitMessageGeneration({ }) ) } finally { - setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) - generateInFlightRef.current[activeWorktreeId] = false + operationStore.setState({ generateInFlight: false }) } }, [ + scope, activeCommitMessageGenerationKey, activeRepoTarget, activeWorktreeId, allocateCommitMessageGenerationRequestId, - generateInFlightRef, - resolvedCommitMessageAi, + operationStore, + resolveCurrentAi, setCommitMessageGenerationRecord, - setGenerateErrors, - setGenerateInFlightByWorktree, - settings, - updateCommitDrafts, + updateCommitDraft, updateCommitMessageGenerationRecord, worktreePath ] ) const handleGenerateCommitMessageClick = useCallback((): void => { - if (!sourceControlAiActionsVisible) { + const settings = useAppStore.getState().settings + const resolvedCommitMessageAi = resolveCurrentAi() + if (!settings || !resolveSourceControlAiEnabled({ settings, repo: activeRepo })) { return } if ( @@ -229,26 +232,19 @@ export function useSourceControlCommitMessageGeneration({ return } openCommitGenerationDialog() - }, [ - activeRepo, - handleGenerate, - openCommitGenerationDialog, - resolvedCommitMessageAi, - settings, - sourceControlAiActionsVisible - ]) + }, [activeRepo, handleGenerate, openCommitGenerationDialog, resolveCurrentAi]) const handleCancelGenerate = useCallback((): void => { if (!activeWorktreeId || !worktreePath || !activeCommitMessageGenerationKey) { return } - if (!generateInFlightRef.current[activeWorktreeId]) { + if (!operationStore.getState().generateInFlight) { return } updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) => resolveCommitMessageGenerationCancel(record) ) - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) if (!activeRepoTarget) { return } @@ -264,7 +260,8 @@ export function useSourceControlCommitMessageGeneration({ activeCommitMessageGenerationKey, activeRepoTarget, activeWorktreeId, - generateInFlightRef, + operationStore, + scope, updateCommitMessageGenerationRecord, worktreePath ]) @@ -281,16 +278,9 @@ export function useSourceControlCommitMessageGeneration({ ) { return } - updateCommitDrafts((prev) => { - const current = prev[activeWorktreeId] - return current && current.length > 0 - ? prev - : writeCommitDraftForWorktree( - prev, - activeWorktreeId, - activeCommitMessageGenerationRecord.message ?? '' - ) - }) + updateCommitDraft((current) => + current.length > 0 ? current : (activeCommitMessageGenerationRecord.message ?? '') + ) updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) => markCommitMessageGenerationHydrated(record) ) @@ -298,7 +288,7 @@ export function useSourceControlCommitMessageGeneration({ activeCommitMessageGenerationKey, activeCommitMessageGenerationRecord, activeWorktreeId, - updateCommitDrafts, + updateCommitDraft, updateCommitMessageGenerationRecord ]) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts index c97795a958b..4ee3b2b50e2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { toast } from 'sonner' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { basename } from '@/lib/path' import { bulkUnstageRuntimeGitPaths } from '@/runtime/runtime-git-working-tree-client' import { translate } from '@/i18n/i18n' @@ -44,6 +44,7 @@ export function useSourceControlDiscardConfirmation({ discardSingle: (path: string) => Promise refreshActiveGitStatusAfterMutation: () => Promise }) { + const scope = useSourceControlScope() const [pendingDiscard, setPendingDiscard] = useState(null) // Reset during render so a worktree switch cannot paint its predecessor's confirmation. const [pendingDiscardWorktreeId, setPendingDiscardWorktreeId] = useState(activeWorktreeId) @@ -88,7 +89,7 @@ export function useSourceControlDiscardConfirmation({ } setIsExecutingBulk(true) try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) // Aggregate per-file errors into one toast rather than spamming partial failures. const errors: unknown[] = [] const result = await runDiscardAllForArea(area, paths, { @@ -161,6 +162,7 @@ export function useSourceControlDiscardConfirmation({ } }, [ + scope, activeRepoTarget, worktreePath, activeWorktreeId, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts index c9785a70640..54959aee0df 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts @@ -12,7 +12,7 @@ import { requestEditorFileSave, requestEditorSaveQuiesce } from '@/components/editor/autosave/editor-autosave' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { basename } from '@/lib/path' import { useAppStore } from '@/store' import { @@ -41,6 +41,7 @@ export function useSourceControlEntryMutations({ refreshActiveGitStatusAfterMutation: () => Promise }) { // Name the callback so Retry can reenter the same attempt. + const scope = useSourceControlScope() const runEntryMutation = useCallback( async function runEntryMutation( operation: 'stage' | 'unstage', @@ -51,7 +52,7 @@ export function useSourceControlEntryMutations({ return } try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId ?? null) await mutate( { // Route mutations through workspace ownership rather than focused runtime. @@ -81,7 +82,7 @@ export function useSourceControlEntryMutations({ // Refresh outside the mutation catch so its errors cannot be mislabeled as stage failures. await refreshActiveGitStatusAfterMutation() }, - [activeRepoTarget, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] + [activeRepoTarget, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation, scope] ) const handleStage = useCallback( @@ -170,7 +171,7 @@ export function useSourceControlEntryMutations({ }) // Release in finally so every workspace holds autosave until Git finishes. try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) await discardRuntimeGitPath( { // Route discard through workspace ownership rather than focused runtime. @@ -191,7 +192,7 @@ export function useSourceControlEntryMutations({ releaseAutoSave() } }, - [activeRepoTarget, activeWorktreeId, worktreePath] + [activeRepoTarget, activeWorktreeId, scope, worktreePath] ) const discardMany = useCallback( @@ -224,7 +225,7 @@ export function useSourceControlEntryMutations({ throw failed.reason } try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) await bulkDiscardRuntimeGitPaths( { // Route discard through workspace ownership rather than focused runtime. @@ -249,7 +250,7 @@ export function useSourceControlEntryMutations({ } } }, - [activeRepoTarget, activeWorktreeId, worktreePath] + [activeRepoTarget, activeWorktreeId, scope, worktreePath] ) return { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx index 77f1fd9eff4..a50f1ae4e8b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/content-status.tsx @@ -8,6 +8,7 @@ import { StatusRetryButton } from './status-retry-button' import { TooManyChangesBanner } from './too-many-changes-banner' import { useStoppedWslHost } from '../../../wsl-host-stopped/use-stopped-wsl-host' import { WslHostStoppedNotice } from '../../../wsl-host-stopped/WslHostStoppedNotice' +import { useSourceControlScope } from '../panel/worktree-scope' export function SourceControlContentStatus({ unresolvedConflictCount, @@ -60,6 +61,8 @@ export function SourceControlContentStatus({ }): React.JSX.Element { // Why: a stopped host can't answer git, so its read error or "clean" result is not the workspace's. const stoppedWslHost = useStoppedWslHost(worktreeId) + // Folder sections keep to the plain message and Retry; raw Git output stays in the worktree's own panel. + const showRawStatusError = useSourceControlScope() === null return ( <> {stoppedWslHost && ( @@ -121,9 +124,11 @@ export function SourceControlContentStatus({ )}
-
-              {statusReadError}
-            
+ {showRawStatusError && ( +
+                {statusReadError}
+              
+ )}
diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx index 3d8705ef8c3..f56ffa623d7 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx @@ -1,3 +1,4 @@ +import { useSourceControlScope, useSourceControlState } from '../panel/worktree-scope' import React, { useCallback } from 'react' import { Copy, ExternalLink, Eye, FolderOpen } from 'lucide-react' import { @@ -7,7 +8,6 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' -import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { LocalOnlyMenuHint } from '@/components/local-only-menu-hint' import { @@ -50,11 +50,12 @@ export function SourceControlEntryContextMenu({ onOpenChange, children }: SourceControlEntryContextMenuProps): React.JSX.Element { + const scope = useSourceControlScope() // Resolve the repo owner; OS reveal cannot distinguish another host's path from an identically named local path. - const runtimeEnvironmentId = useAppStore((s) => + const runtimeEnvironmentId = useSourceControlState((s) => runtimeEnvironmentIdOrLocalUnlessTwin(s, currentWorktreeId) ) - const revealOwner = useAppStore((s) => + const revealOwner = useSourceControlState((s) => getWorkspaceFileRevealOwner(s, currentWorktreeId, { connectionId, runtimeEnvironmentId }) ) const revealBlocked = isRevealOrEditorOpenBlocked(revealOwner) @@ -77,8 +78,12 @@ export function SourceControlEntryContextMenu({ if (!absolutePath) { return } + if (scope && !hasWorkingTreeFile) { + onView?.() + return + } onRevealInExplorer(currentWorktreeId, absolutePath) - }, [absolutePath, currentWorktreeId, onRevealInExplorer]) + }, [absolutePath, currentWorktreeId, hasWorkingTreeFile, onRevealInExplorer, onView, scope]) const handleRevealInFileManager = useCallback(() => { if (absolutePath) { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts b/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts index df4f9a486e1..5d322b58343 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts @@ -25,7 +25,14 @@ export function openUncommittedEntry( (entry.status === 'added' || entry.status === 'untracked') ) { state.openFile( - { filePath, relativePath: entry.path, worktreeId, language, mode: 'edit' }, + { + filePath, + relativePath: entry.path, + worktreeId, + language, + mode: 'edit', + runtimeEnvironmentId: options.runtimeEnvironmentId + }, options ) return diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts index cad72a776b5..c603539782b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts @@ -1,7 +1,13 @@ +import { + useSourceControlState, + sourceControlScopeRuntimeEnvironmentId, + useSourceControlScope +} from '../panel/worktree-scope' import { useCallback, useMemo } from 'react' import { detectLanguage } from '@/lib/language-detect' import { openUncommittedEntry } from './open-uncommitted-entry' import { useAppStore } from '@/store' +import { openFileIndex } from '@/store/slices/editor/tabs/workspace-editor-files' import type { GitBranchChangeEntry, GitBranchCompareSummary @@ -30,14 +36,11 @@ export function useSourceControlRowOpening({ visibleBranchRowKeys: readonly string[] branchSummary: GitBranchCompareSummary | null }) { - const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) - const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) - const createEmptySplitGroup = useAppStore((s) => s.createEmptySplitGroup) - const trackConflictPath = useAppStore((s) => s.trackConflictPath) - const openConflictFile = useAppStore((s) => s.openConflictFile) - const openDiff = useAppStore((s) => s.openDiff) - const openFile = useAppStore((s) => s.openFile) - const openBranchDiff = useAppStore((s) => s.openBranchDiff) + const scope = useSourceControlScope() + const executionHostId = scope?.executionHostId + const placementWorkspaceId = scope?.placementWorkspaceId + const placementId = placementWorkspaceId ?? activeWorktreeId + const runtimeEnvironmentId = sourceControlScopeRuntimeEnvironmentId(scope) // Modifier-click opens a right split to preserve the current pane. const resolveSplitTargetGroupId = useCallback( @@ -45,29 +48,35 @@ export function useSourceControlRowOpening({ if (!event || !activeWorktreeId || !isSourceControlSplitOpenModifier(event, isMac)) { return undefined } + const state = useAppStore.getState() const sourceGroupId = - activeGroupIdByWorktree[activeWorktreeId] ?? groupsByWorktree[activeWorktreeId]?.[0]?.id + state.activeGroupIdByWorktree[placementId ?? activeWorktreeId] ?? + state.groupsByWorktree[placementId ?? activeWorktreeId]?.[0]?.id if (!sourceGroupId) { return undefined } - return createEmptySplitGroup(activeWorktreeId, sourceGroupId, 'right') ?? undefined + return ( + state.createEmptySplitGroup(placementId ?? activeWorktreeId, sourceGroupId, 'right') ?? + undefined + ) }, - [activeGroupIdByWorktree, activeWorktreeId, createEmptySplitGroup, groupsByWorktree, isMac] + [placementId, activeWorktreeId, isMac] ) // Use a primitive signature so unrelated store writes do not rerender; return null for non-editor tabs. - const activeOpenFileSignature = useAppStore((s) => { - if (!activeWorktreeId || s.activeTabTypeByWorktree?.[activeWorktreeId] !== 'editor') { + const activeOpenFileSignature = useSourceControlState((s) => { + if ( + !activeWorktreeId || + s.activeTabTypeByWorktree?.[placementId ?? activeWorktreeId] !== 'editor' + ) { return null } - const activeFileId = s.activeFileIdByWorktree?.[activeWorktreeId] + const activeFileId = s.activeFileIdByWorktree?.[placementId ?? activeWorktreeId] if (!activeFileId) { return null } - const activeFile = s.openFiles?.find( - (file) => file.id === activeFileId && file.worktreeId === activeWorktreeId - ) - return activeFile + const activeFile = openFileIndex(s.openFiles).get(activeFileId) + return activeFile?.worktreeId === activeWorktreeId ? buildActiveOpenFileSignature( activeFile.diffSource, activeFile.relativePath, @@ -95,22 +104,21 @@ export function useSourceControlRowOpening({ } const targetGroupId = resolveSplitTargetGroupId(event) const openAsPreview = shouldOpenSourceControlRowAsPreview(event, targetGroupId) - openUncommittedEntry( - { trackConflictPath, openConflictFile, openFile, openDiff }, - activeWorktreeId, - worktreePath, - entry, - { targetGroupId, preview: openAsPreview } - ) + openUncommittedEntry(useAppStore.getState(), activeWorktreeId, worktreePath, entry, { + targetGroupId, + preview: openAsPreview, + placementWorkspaceId, + executionHostId, + runtimeEnvironmentId + }) }, [ + placementWorkspaceId, + executionHostId, + runtimeEnvironmentId, activeWorktreeId, worktreePath, - resolveSplitTargetGroupId, - trackConflictPath, - openConflictFile, - openDiff, - openFile + resolveSplitTargetGroupId ] ) @@ -125,16 +133,32 @@ export function useSourceControlRowOpening({ return } const targetGroupId = resolveSplitTargetGroupId(event) - openBranchDiff( - activeWorktreeId, - worktreePath, - entry, - branchSummary, - detectLanguage(entry.path), - { targetGroupId, preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) } - ) + useAppStore + .getState() + .openBranchDiff( + activeWorktreeId, + worktreePath, + entry, + branchSummary, + detectLanguage(entry.path), + { + targetGroupId, + preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId), + placementWorkspaceId, + executionHostId, + runtimeEnvironmentId + } + ) }, - [activeWorktreeId, branchSummary, openBranchDiff, resolveSplitTargetGroupId, worktreePath] + [ + placementWorkspaceId, + executionHostId, + runtimeEnvironmentId, + activeWorktreeId, + branchSummary, + resolveSplitTargetGroupId, + worktreePath + ] ) return { resolveSplitTargetGroupId, activeOpenRowKeys, handleOpenDiff, openCommittedDiff } diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx index f177171dc82..8557ba1c65b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx @@ -42,7 +42,7 @@ afterEach(() => { }) describe('useSourceControlStoreActions store subscriptions', () => { - it('keeps only the two generation-record maps subscribed', () => { + it('subscribes to no store slices', () => { const baseline = listenerCount() function Probe(): null { @@ -51,8 +51,8 @@ describe('useSourceControlStoreActions store subscriptions', () => { } mount() - // Subscribe only to the two generation maps; fixed-identity actions come from getState(). - expect(listenerCount() - baseline).toBe(2) + // Fixed-identity actions come from getState(); generation records are selected per owner by their consumers. + expect(listenerCount() - baseline).toBe(0) unmount() expect(listenerCount()).toBe(baseline) @@ -82,39 +82,4 @@ describe('useSourceControlStoreActions store subscriptions', () => { expect(latest).toBe(first) }) - - it('still tracks the generation-record maps it subscribes to', () => { - let latest: SourceControlStoreActions | null = null - function Probe(): null { - latest = useSourceControlStoreActions() - return null - } - function read(): SourceControlStoreActions { - if (!latest) { - throw new Error('probe did not render') - } - return latest - } - mount() - - const before = read() - const record = { status: 'pending' } as never - act(() => { - useAppStore.setState({ - pullRequestGenerationRecords: { 'wt-1': record } - }) - }) - - expect(read()).not.toBe(before) - expect(read().prGenerationRecords).toEqual({ 'wt-1': record }) - - const afterPr = read() - act(() => { - useAppStore.setState({ - commitMessageGenerationRecords: { 'wt-1': record } - }) - }) - expect(read()).not.toBe(afterPr) - expect(read().commitMessageGenerationRecords).toEqual({ 'wt-1': record }) - }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts index 0aface1a6cb..99d0d72d56d 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts @@ -1,58 +1,203 @@ +import { basename, getRelativePathInsideRoot } from '@/lib/path' +import { detectLanguage } from '@/lib/language-detect' +import type { AppState } from '@/store/types' +import { + sourceControlScopeRuntimeEnvironmentId, + useSourceControlScope +} from '../panel/worktree-scope' import { useMemo } from 'react' import { useAppStore } from '@/store' -/** Read fixed-identity actions via getState; subscribe only to generation maps, keeping the return object stable between map changes. */ +const commands = { + allocateCommitMessageGenerationRequestId: ( + ...args: Parameters + ) => useAppStore.getState().allocateCommitMessageGenerationRequestId(...args), + allocatePullRequestGenerationRequestId: ( + ...args: Parameters + ) => useAppStore.getState().allocatePullRequestGenerationRequestId(...args), + beginGitBranchCompareRequest: (...args: Parameters) => + useAppStore.getState().beginGitBranchCompareRequest(...args), + clearDiffComments: (...args: Parameters) => + useAppStore.getState().clearDiffComments(...args), + clearDiffCommentsForFile: (...args: Parameters) => + useAppStore.getState().clearDiffCommentsForFile(...args), + deleteDiffComment: (...args: Parameters) => + useAppStore.getState().deleteDiffComment(...args), + enqueueGitHubPRRefresh: (...args: Parameters) => + useAppStore.getState().enqueueGitHubPRRefresh(...args), + ensureHostedReviewPushTarget: (...args: Parameters) => + useAppStore.getState().ensureHostedReviewPushTarget(...args), + fastForwardBranch: (...args: Parameters) => + useAppStore.getState().fastForwardBranch(...args), + fetchBranch: (...args: Parameters) => + useAppStore.getState().fetchBranch(...args), + fetchUpstreamStatus: (...args: Parameters) => + useAppStore.getState().fetchUpstreamStatus(...args), + openModal: (...args: Parameters) => + useAppStore.getState().openModal(...args), + openSettingsPage: (...args: Parameters) => + useAppStore.getState().openSettingsPage(...args), + openSettingsTarget: (...args: Parameters) => + useAppStore.getState().openSettingsTarget(...args), + pullBranch: (...args: Parameters) => + useAppStore.getState().pullBranch(...args), + pushBranch: (...args: Parameters) => + useAppStore.getState().pushBranch(...args), + rebaseFromBase: (...args: Parameters) => + useAppStore.getState().rebaseFromBase(...args), + setCommitMessageGenerationRecord: ( + ...args: Parameters + ) => useAppStore.getState().setCommitMessageGenerationRecord(...args), + setGitBranchCompareResult: (...args: Parameters) => + useAppStore.getState().setGitBranchCompareResult(...args), + setGitStatus: (...args: Parameters) => + useAppStore.getState().setGitStatus(...args), + setPullRequestGenerationRecord: ( + ...args: Parameters + ) => useAppStore.getState().setPullRequestGenerationRecord(...args), + setRightSidebarOpen: (...args: Parameters) => + useAppStore.getState().setRightSidebarOpen(...args), + setRightSidebarTab: (...args: Parameters) => + useAppStore.getState().setRightSidebarTab(...args), + setUpstreamStatus: (...args: Parameters) => + useAppStore.getState().setUpstreamStatus(...args), + syncBranch: (...args: Parameters) => + useAppStore.getState().syncBranch(...args), + updateCommitMessageGenerationRecord: ( + ...args: Parameters + ) => useAppStore.getState().updateCommitMessageGenerationRecord(...args), + updatePullRequestGenerationRecord: ( + ...args: Parameters + ) => useAppStore.getState().updatePullRequestGenerationRecord(...args), + updateSettings: (...args: Parameters) => + useAppStore.getState().updateSettings(...args), + updateWorktreeGitIdentity: (...args: Parameters) => + useAppStore.getState().updateWorktreeGitIdentity(...args) +} + +/** Read fixed-identity actions via getState; key section wrappers on primitive routing so store writes never rebuild them. */ export function useSourceControlStoreActions() { - const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) - const commitMessageGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords) + const scope = useSourceControlScope() + const executionHostId = scope?.executionHostId + const placementWorkspaceId = scope?.placementWorkspaceId + const runtimeEnvironmentId = sourceControlScopeRuntimeEnvironmentId(scope) return useMemo(() => { - const state = useAppStore.getState() - return { - allocateCommitMessageGenerationRequestId: state.allocateCommitMessageGenerationRequestId, - allocatePullRequestGenerationRequestId: state.allocatePullRequestGenerationRequestId, - beginGitBranchCompareRequest: state.beginGitBranchCompareRequest, - clearDiffComments: state.clearDiffComments, - clearDiffCommentsForFile: state.clearDiffCommentsForFile, - commitMessageGenerationRecords, - createHostedReview: state.createHostedReview, - createStackedHostedReview: state.createStackedHostedReview, - deleteDiffComment: state.deleteDiffComment, - enqueueGitHubPRRefresh: state.enqueueGitHubPRRefresh, - ensureHostedReviewPushTarget: state.ensureHostedReviewPushTarget, - fastForwardBranch: state.fastForwardBranch, - fetchBranch: state.fetchBranch, - fetchHostedReviewForBranch: state.fetchHostedReviewForBranch, - fetchPRForBranch: state.fetchPRForBranch, - fetchUpstreamStatus: state.fetchUpstreamStatus, - getHostedReviewCreationEligibility: state.getHostedReviewCreationEligibility, - openAllDiffs: state.openAllDiffs, - openBranchAllDiffs: state.openBranchAllDiffs, - openConflictReview: state.openConflictReview, - openModal: state.openModal, - openSettingsPage: state.openSettingsPage, - openSettingsTarget: state.openSettingsTarget, - prGenerationRecords, - pullBranch: state.pullBranch, - pushBranch: state.pushBranch, - rebaseFromBase: state.rebaseFromBase, - revealInExplorer: state.revealInExplorer, - setCommitMessageGenerationRecord: state.setCommitMessageGenerationRecord, - setGitBranchCompareResult: state.setGitBranchCompareResult, - setGitStatus: state.setGitStatus, - setPullRequestGenerationRecord: state.setPullRequestGenerationRecord, - setRightSidebarOpen: state.setRightSidebarOpen, - setRightSidebarTab: state.setRightSidebarTab, - setUpstreamStatus: state.setUpstreamStatus, - syncBranch: state.syncBranch, - updateCommitMessageGenerationRecord: state.updateCommitMessageGenerationRecord, - updatePullRequestGenerationRecord: state.updatePullRequestGenerationRecord, - updateRepo: state.updateRepo, - updateSettings: state.updateSettings, - updateWorktreeGitIdentity: state.updateWorktreeGitIdentity, - updateWorktreeMeta: state.updateWorktreeMeta + const options = { placementWorkspaceId, executionHostId, runtimeEnvironmentId } + const createHostedReview: AppState['createHostedReview'] = (path, input) => + useAppStore.getState().createHostedReview(path, { + ...input, + ...(executionHostId ? { repoOwnerExecutionHostId: executionHostId } : {}) + }) + const createStackedHostedReview: AppState['createStackedHostedReview'] = (path, input) => + useAppStore.getState().createStackedHostedReview(path, { + ...input, + ...(executionHostId ? { repoOwnerExecutionHostId: executionHostId } : {}) + }) + const fetchHostedReviewForBranch: AppState['fetchHostedReviewForBranch'] = ( + path, + branch, + options + ) => + useAppStore.getState().fetchHostedReviewForBranch(path, branch, { + ...options, + ...(executionHostId ? { repoOwnerExecutionHostId: executionHostId } : {}) + }) + const fetchPRForBranch: AppState['fetchPRForBranch'] = (path, branch, options) => + useAppStore.getState().fetchPRForBranch(path, branch, { + ...options, + ...(executionHostId ? { repoOwnerExecutionHostId: executionHostId } : {}) + }) + const getHostedReviewCreationEligibility: AppState['getHostedReviewCreationEligibility'] = ( + args + ) => + useAppStore.getState().getHostedReviewCreationEligibility({ + ...args, + ...(executionHostId ? { repoOwnerExecutionHostId: executionHostId } : {}) + }) + const openAllDiffs: AppState['openAllDiffs'] = (id, path, alternate, filter, entries) => + useAppStore.getState().openAllDiffs(id, path, alternate, filter, entries, options) + const openBranchAllDiffs: AppState['openBranchAllDiffs'] = (id, path, compare, alternate) => + useAppStore.getState().openBranchAllDiffs(id, path, compare, alternate, options) + const openConflictReview: AppState['openConflictReview'] = (id, path, entries, source) => + useAppStore.getState().openConflictReview(id, path, entries, source, options) + const updateRepo: AppState['updateRepo'] = (id, updates, options) => + useAppStore.getState().updateRepo(id, updates, { + ...options, + ...(executionHostId ? { hostId: executionHostId } : {}) + }) + const updateWorktreeMeta: AppState['updateWorktreeMeta'] = (id, updates, options) => + useAppStore.getState().updateWorktreeMeta(id, updates, { + ...options, + ...(executionHostId ? { executionHostId } : {}) + }) + const revealInExplorer: AppState['revealInExplorer'] = (id, path) => { + if (!placementWorkspaceId) { + useAppStore.getState().revealInExplorer(id, path) + return + } + const latest = useAppStore.getState() + const groupId = latest.activeGroupIdByWorktree[placementWorkspaceId] + const root = latest.getKnownWorktreeById(id, executionHostId)?.path ?? null + useAppStore.getState().openFile( + { + filePath: path, + relativePath: getRelativePathInsideRoot(path, root) ?? basename(path), + worktreeId: id, + language: detectLanguage(path), + mode: 'edit', + runtimeEnvironmentId + }, + { + ...options, + targetGroupId: groupId, + preview: false + } + ) } - }, [commitMessageGenerationRecords, prGenerationRecords]) + return { + allocateCommitMessageGenerationRequestId: commands.allocateCommitMessageGenerationRequestId, + allocatePullRequestGenerationRequestId: commands.allocatePullRequestGenerationRequestId, + beginGitBranchCompareRequest: commands.beginGitBranchCompareRequest, + clearDiffComments: commands.clearDiffComments, + clearDiffCommentsForFile: commands.clearDiffCommentsForFile, + createHostedReview, + createStackedHostedReview, + deleteDiffComment: commands.deleteDiffComment, + enqueueGitHubPRRefresh: commands.enqueueGitHubPRRefresh, + ensureHostedReviewPushTarget: commands.ensureHostedReviewPushTarget, + fastForwardBranch: commands.fastForwardBranch, + fetchBranch: commands.fetchBranch, + fetchHostedReviewForBranch, + fetchPRForBranch, + fetchUpstreamStatus: commands.fetchUpstreamStatus, + getHostedReviewCreationEligibility, + openAllDiffs, + openBranchAllDiffs, + openConflictReview, + openModal: commands.openModal, + openSettingsPage: commands.openSettingsPage, + openSettingsTarget: commands.openSettingsTarget, + pullBranch: commands.pullBranch, + pushBranch: commands.pushBranch, + rebaseFromBase: commands.rebaseFromBase, + revealInExplorer, + setCommitMessageGenerationRecord: commands.setCommitMessageGenerationRecord, + setGitBranchCompareResult: commands.setGitBranchCompareResult, + setGitStatus: commands.setGitStatus, + setPullRequestGenerationRecord: commands.setPullRequestGenerationRecord, + setRightSidebarOpen: commands.setRightSidebarOpen, + setRightSidebarTab: commands.setRightSidebarTab, + setUpstreamStatus: commands.setUpstreamStatus, + syncBranch: commands.syncBranch, + updateCommitMessageGenerationRecord: commands.updateCommitMessageGenerationRecord, + updatePullRequestGenerationRecord: commands.updatePullRequestGenerationRecord, + updateRepo, + updateSettings: commands.updateSettings, + updateWorktreeGitIdentity: commands.updateWorktreeGitIdentity, + updateWorktreeMeta + } + }, [executionHostId, placementWorkspaceId, runtimeEnvironmentId]) } export type SourceControlStoreActions = ReturnType diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts index e2320047537..28918a8d2bc 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts @@ -3,7 +3,11 @@ import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import type { GitStatusEntry } from '../../../../../../shared/git/status/git-status-types' import { isWslDistroNotRunningRefusal } from '../../../../../../shared/wsl-distro-not-running-refusal' -import { getConnectionId } from '@/lib/connection-context' +import { + sourceControlConnectionId, + useSourceControlScope, + useSourceControlSectionActive +} from '../panel/worktree-scope' import { getRuntimeGitSubmoduleStatus } from '@/runtime/runtime-git-status-client' import { getSubmoduleExpansionKey, @@ -30,11 +34,13 @@ export type UseSourceControlSubmoduleStatusResult = { export function useSourceControlSubmoduleStatus( input: UseSourceControlSubmoduleStatusInput ): UseSourceControlSubmoduleStatusResult { + const scope = useSourceControlScope() + const sectionActive = useSourceControlSectionActive() const { activeWorktreeId, worktreePath, activeRepoTarget, entries } = input const [expandedSubmoduleKeys, setExpandedSubmoduleKeys] = useState>(() => new Set()) const activeRuntimeRouteKey = activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : '' - const activeConnectionRouteKey = getConnectionId(activeWorktreeId ?? null) ?? '' + const activeConnectionRouteKey = sourceControlConnectionId(scope, activeWorktreeId ?? null) ?? '' const submoduleStatusScopeKey = `${activeConnectionRouteKey}\0${activeRuntimeRouteKey}\0${activeWorktreeId ?? ''}\0${worktreePath ?? ''}` // Store scope with results so late previous-host/worktree updates can no-op without render-time refs. const [submoduleStatusStore, setSubmoduleStatusStore] = useState<{ @@ -69,7 +75,7 @@ export function useSourceControlSubmoduleStatus( } ) try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId ?? null) const result = await getRuntimeGitSubmoduleStatus( { // Use the repo owner like the rest of the panel. @@ -115,7 +121,7 @@ export function useSourceControlSubmoduleStatus( ) } }, - [activeRepoTarget, activeWorktreeId, submoduleStatusScopeKey, worktreePath] + [activeRepoTarget, activeWorktreeId, scope, submoduleStatusScopeKey, worktreePath] ) const toggleSubmodule = useCallback((entry: Pick) => { @@ -133,6 +139,9 @@ export function useSourceControlSubmoduleStatus( // Refresh expanded children when parent entries change; collapsed submodules incur no Git work. useEffect(() => { + if (!sectionActive) { + return + } const visibleExpandableKeys = new Set( entries.filter(isExpandableSubmoduleEntry).map(getSubmoduleExpansionKey) ) @@ -141,7 +150,7 @@ export function useSourceControlSubmoduleStatus( void fetchSubmoduleStatus(expansionKey) } } - }, [expandedSubmoduleKeys, entries, fetchSubmoduleStatus]) + }, [expandedSubmoduleKeys, entries, fetchSubmoduleStatus, sectionActive]) return { expandedSubmoduleKeys, submoduleStatusByKey, toggleSubmodule } } diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts index ce7c86c9e57..287db1f4770 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts @@ -1,10 +1,16 @@ +import { + useSourceControlScope, + useSourceControlScopeOwner, + useSourceControlState, + useSourceControlSectionActive +} from '../panel/worktree-scope' import { useMemo } from 'react' import { getConnectionId } from '@/lib/connection-context' import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target' import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts' import { getWorktreeGitIdentityDisplay } from '../../../../../../shared/worktree/git-identity-display' -import { useAppStore } from '@/store' -import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { getWorktreeMapFromState, selectRepoByIdForActiveWorkspace } from '@/store/selectors' import { getGitHubPRCacheKey } from '../../../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import type { GitBranchChangeEntry } from '../../../../../../shared/git/diff/git-diff-compare-types' @@ -16,48 +22,90 @@ import { selectReviewCacheEntry } from '../review/review-cache-entry-selection' +// Render inputs only; commands read current settings at their mutation boundary. +const SOURCE_CONTROL_SETTINGS = [ + 'sourceControlAi', + 'commitMessageAi', + 'defaultTuiAgent', + 'disabledTuiAgents', + 'agentCmdOverrides', + 'agentDefaultArgs', + 'terminalWindowsShell', + 'sourceControlViewMode', + 'sourceControlGroupOrder', + 'sourceControlCompareAgainstUpstream' +] as const satisfies readonly (keyof NonNullable)[] + +export type SourceControlRenderSettings = Readonly< + Pick, (typeof SOURCE_CONTROL_SETTINGS)[number]> +> + const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = [] const EMPTY_BRANCH_CHANGE_ENTRIES: GitBranchChangeEntry[] = [] export function useSourceControlWorktreeContext() { - const activeWorktree = useActiveWorktree() - const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const activeWorktreeInstanceId = activeWorktree?.instanceId - const activeGroupId = useAppStore((s) => - activeWorktreeId ? s.activeGroupIdByWorktree[activeWorktreeId] : undefined + const scope = useSourceControlScope() + const scopeOwner = useSourceControlScopeOwner() + const sectionActive = useSourceControlSectionActive() + const selectedWorktree = useSourceControlState((s) => + !scope && s.activeWorktreeId + ? (s.getKnownWorktreeById( + s.activeWorktreeId, + s.activeWorkspaceExecutionHostId ?? undefined + ) ?? null) + : null ) - const worktreeMap = useWorktreeMap() - const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) - const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const selectedWorktreeId = useSourceControlState((s) => (scope ? null : s.activeWorktreeId)) + const activeWorktree = scopeOwner?.worktree ?? selectedWorktree + const activeWorktreeId = scope?.worktreeId ?? selectedWorktreeId + const activeWorktreeInstanceId = activeWorktree?.instanceId + const placementWorkspaceId = scope?.placementWorkspaceId ?? activeWorktreeId + const activeGroupId = useSourceControlState((s) => + !scope && placementWorkspaceId ? s.activeGroupIdByWorktree[placementWorkspaceId] : undefined + ) + const scopeWorktreeId = scope?.worktreeId + const scopedWorktrees = useMemo( + () => new Map(scopeWorktreeId ? [[scopeWorktreeId, true]] : []), + [scopeWorktreeId] + ) + // A section's history ends with its owner; only the ordinary panel caches several worktrees. + const worktreeMap = useSourceControlState((s) => + scopeWorktreeId ? scopedWorktrees : getWorktreeMapFromState(s) + ) + const rightSidebarTab = useSourceControlState((s) => s.rightSidebarTab) + const selectedRepo = useSourceControlState((s) => + scope ? null : selectRepoByIdForActiveWorkspace(s, activeWorktree?.repoId ?? null) + ) + const activeRepo = scopeOwner?.repo ?? selectedRepo const activeRepoId = activeRepo?.id ?? null const activeRepoPath = activeRepo?.path ?? null const activeRepoConnectionId = activeRepo?.connectionId ?? null const activeRepoExecutionHostId = activeRepo?.executionHostId ?? null const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' - const entries = useAppStore((s) => + const entries = useSourceControlState((s) => activeWorktreeId ? (s.gitStatusByWorktree[activeWorktreeId] ?? EMPTY_GIT_STATUS_ENTRIES) : EMPTY_GIT_STATUS_ENTRIES ) - const activeGitStatusHead = useAppStore((s) => + const activeGitStatusHead = useSourceControlState((s) => activeWorktreeId ? (s.gitStatusHeadByWorktree?.[activeWorktreeId] ?? null) : null ) - const repositoryHuge = useAppStore((s) => + const repositoryHuge = useSourceControlState((s) => activeWorktreeId ? s.gitStatusHugeByWorktree?.[activeWorktreeId] : undefined ) - const statusReadError = useAppStore((s) => + const statusReadError = useSourceControlState((s) => activeWorktreeId ? s.gitStatusReadErrorByWorktree?.[activeWorktreeId] : undefined ) - const branchEntries = useAppStore((s) => + const branchEntries = useSourceControlState((s) => activeWorktreeId ? (s.gitBranchChangesByWorktree[activeWorktreeId] ?? EMPTY_BRANCH_CHANGE_ENTRIES) : EMPTY_BRANCH_CHANGE_ENTRIES ) - const branchSummary = useAppStore((s) => + const branchSummary = useSourceControlState((s) => activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null ) - const publishedBranchLineTotal = useAppStore((s) => + const publishedBranchLineTotal = useSourceControlState((s) => activeWorktreeId ? (s.gitBranchLineTotalByWorktree?.[activeWorktreeId] ?? null) : null ) // Status and comparison refresh independently; drop totals measured against a stale fork point. @@ -65,29 +113,53 @@ export function useSourceControlWorktreeContext() { publishedBranchLineTotal && publishedBranchLineTotal.mergeBase === branchSummary?.mergeBase ? publishedBranchLineTotal : null - const conflictOperation = useAppStore((s) => + const conflictOperation = useSourceControlState((s) => activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' ) - const conflictOperationsByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree) // Preserve unknown upstream while fetching so workspace switches cannot flash Publish Branch. - const remoteStatus = useAppStore((s) => + const remoteStatus = useSourceControlState((s) => activeWorktreeId ? s.remoteStatusesByWorktree[activeWorktreeId] : undefined ) - const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) - const inFlightRemoteOpKind = useAppStore( + const inFlightRemoteOpKind = useSourceControlState( (s) => s.remoteOperations.findLast((operation) => operation.worktreeId === activeWorktreeId)?.kind ?? null ) + const isRemoteOperationActive = useSourceControlState((s) => s.isRemoteOperationActive) const isRemoteOperationInAnotherWorktree = isRemoteOperationActive && inFlightRemoteOpKind === null - const settings = useAppStore((s) => s.settings) + const selectSettings = useMemo(() => { + let previous: SourceControlRenderSettings | null = null + return ({ settings }: AppState) => { + if ( + previous && + settings && + SOURCE_CONTROL_SETTINGS.every((key) => Object.is(previous?.[key], settings[key])) + ) { + return previous + } + previous = settings && { + sourceControlAi: settings.sourceControlAi, + commitMessageAi: settings.commitMessageAi, + defaultTuiAgent: settings.defaultTuiAgent, + disabledTuiAgents: settings.disabledTuiAgents, + agentCmdOverrides: settings.agentCmdOverrides, + agentDefaultArgs: settings.agentDefaultArgs, + terminalWindowsShell: settings.terminalWindowsShell, + sourceControlViewMode: settings.sourceControlViewMode, + sourceControlGroupOrder: settings.sourceControlGroupOrder, + sourceControlCompareAgainstUpstream: settings.sourceControlCompareAgainstUpstream + } + return previous + } + }, []) + const settings = useSourceControlState(selectSettings) const hostedReviewCacheKey = activeRepo && branchName ? getHostedReviewCacheKey( activeRepo.path, branchName, - settings, + null, activeRepo.id, activeRepo.connectionId, activeRepo.executionHostId, @@ -100,50 +172,57 @@ export function useSourceControlWorktreeContext() { activeRepo.path, activeRepo.id, branchName, - settings, + null, activeRepo.connectionId, activeRepo.executionHostId, true ) : null // Subscribe only to active repo/branch entries because background refresh replaces whole cache maps. - const hostedReviewEntry = useAppStore((s) => + const hostedReviewEntry = useSourceControlState((s) => selectReviewCacheEntry(s.hostedReviewCache, hostedReviewCacheKey) ) const hostedReviewEntryData = hostedReviewEntry?.data ?? null - const activePrFromQueue = useAppStore((s) => selectReviewCacheData(s.prCache, activePrCacheKey)) + const activePrFromQueue = useSourceControlState((s) => + selectReviewCacheData(s.prCache, activePrCacheKey) + ) // Route Git and metadata to the repo owner; unresolved ownership must run nothing. const activeRepoTarget = useMemo( () => activeRepoId ? ipcOrRuntimeTargetForHostId( - getRepoExecutionHostId({ - connectionId: activeRepoConnectionId, - executionHostId: activeRepoExecutionHostId - }) + scope?.executionHostId ?? + getRepoExecutionHostId({ + connectionId: activeRepoConnectionId, + executionHostId: activeRepoExecutionHostId + }) ) : null, - [activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId] + [activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, scope?.executionHostId] ) const activeRepoRuntimeEnvironmentId = activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : null - const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) + const rightSidebarOpen = useSourceControlState((s) => s.rightSidebarOpen) const isFolder = activeRepo ? isFolderRepo(activeRepo) : false const worktreePath = activeWorktree?.path ?? null const activeConnectionId = activeWorktreeId - ? (getConnectionId(activeWorktreeId) ?? activeRepoConnectionId) + ? scope + ? activeRepoConnectionId + : (getConnectionId(activeWorktreeId) ?? activeRepoConnectionId) : null // Quote for the workspace owner's OS, where agents run (#27971). - const activeSourceControlLaunchPlatform = + const activeSourceControlLaunchPlatform = useSourceControlState((state) => activeWorktree && activeRepo - ? resolveRepoWorktreeLaunchPlatform(useAppStore.getState(), activeRepo, activeWorktree) + ? resolveRepoWorktreeLaunchPlatform(state, activeRepo, activeWorktree) : undefined - // Mounted hidden sidebars need tab/open polling gates to avoid work without visible consumers. - const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen + ) + // Mounted hidden sidebars and collapsed/off-screen sections keep their view but poll nothing. + const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen && sectionActive const hasUncommittedEntries = entries.length > 0 return { + scope, activeConnectionId, activeGitStatusHead, activeGroupId, @@ -165,7 +244,6 @@ export function useSourceControlWorktreeContext() { branchName, branchSummary, conflictOperation, - conflictOperationsByWorktree, entries, gitIdentityDisplay, hasUncommittedEntries, diff --git a/src/renderer/src/components/right-sidebar/source-control/notes/notes-shelf.tsx b/src/renderer/src/components/right-sidebar/source-control/notes/notes-shelf.tsx index 4abdfa7e180..e50278a84c3 100644 --- a/src/renderer/src/components/right-sidebar/source-control/notes/notes-shelf.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/notes/notes-shelf.tsx @@ -1,3 +1,4 @@ +import { useSourceControlScope, useSourceControlState } from '../panel/worktree-scope' import { useDiffCommentView } from '@/components/diff-comments/use-diff-comment-owner' import type { AppState } from '@/store' import React from 'react' @@ -41,6 +42,10 @@ export function SourceControlNotesShelf({ deleteDiffComment: AppState['deleteDiffComment'] handleOpenComment: (comment: DiffComment) => void }): React.JSX.Element { + const scope = useSourceControlScope() + const placementGroupId = useSourceControlState((s) => + scope ? s.activeGroupIdByWorktree[scope.placementWorkspaceId] : undefined + ) const { owner } = useDiffCommentView(activeWorktreeId, 'selection') return (
@@ -79,7 +84,7 @@ export function SourceControlNotesShelf({ { + cleanup() + useAppStore.setState(initialAppState, true) +}) + +it('opens a folder section note in the folder group while the child keeps the content', () => { + const openFile = vi.fn(() => 'file') + useAppStore.setState({ + openFile, + activeWorktreeId: 'folder:feature', + activeGroupIdByWorktree: { 'folder:feature': 'folder-group' } + }) + const worktree = makeWorktree('wt-child', 'wt-child', { instanceId: 'instance-1' }) + const owner = { + worktreeId: 'wt-child', + repoId: 'repo-1', + executionHostId: 'ssh:box' as const, + worktree, + attachedAt: 1, + repo: { ...makeRepo(), connectionId: 'box' }, + placementWorkspaceId: 'folder:feature' + } + const { result } = renderHook( + () => + useSourceControlNoteOpening({ + activeWorktreeId: 'wt-child', + worktreePath: '/tmp/wt-child', + entries: [], + branchEntries: [], + branchSummary: null, + handleOpenDiff: vi.fn(), + openCommittedDiff: vi.fn(), + sourceControlRef: { current: null } + }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ) + } + ) + + result.current.handleOpenComment({ + id: 'note-1', + worktreeId: 'wt-child', + filePath: 'README.md', + source: 'markdown', + lineNumber: 3, + body: 'check', + createdAt: 1, + side: 'modified' + }) + + expect(openFile).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-child', filePath: '/tmp/wt-child/README.md' }), + { + placementWorkspaceId: 'folder:feature', + executionHostId: 'ssh:box', + targetGroupId: 'folder-group' + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts b/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts index 9e95aca9d83..57647095173 100644 --- a/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts +++ b/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts @@ -9,6 +9,11 @@ import type { } from '../../../../../../shared/git/diff/git-diff-compare-types' import type { DiffComment } from '../../../../../../shared/diff-comment-types' import type { GitStatusEntry } from '../../../../../../shared/git/status/git-status-types' +import type { OpenFileOptions } from '@/store/slices/editor/types/editor-files-slice' +import { + sourceControlScopeRuntimeEnvironmentId, + useSourceControlScope +} from '../panel/worktree-scope' import { cancelSourceControlEditorRevealFrames, requestSourceControlEditorRevealFrame @@ -38,11 +43,10 @@ export function useSourceControlNoteOpening({ openCommittedDiff: (entry: GitBranchChangeEntry) => void sourceControlRef: RefObject }): SourceControlNoteOpening { - const openFile = useAppStore((s) => s.openFile) - const setEditorViewMode = useAppStore((s) => s.setEditorViewMode) - const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode) - const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) - const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId) + const scope = useSourceControlScope() + const placementWorkspaceId = scope?.placementWorkspaceId + const executionHostId = scope?.executionHostId + const runtimeEnvironmentId = sourceControlScopeRuntimeEnvironmentId(scope) const pendingCommentEditorRevealFrameIdsRef = useRef([]) const setSourceControlRoot = useCallback( (node: HTMLDivElement | null) => { @@ -63,31 +67,47 @@ export function useSourceControlNoteOpening({ } const filePath = comment.filePath const commentId = comment.id + // A folder section opens the note in its folder's active group; the file stays the child worktree's. + const placementGroupId = placementWorkspaceId + ? useAppStore.getState().activeGroupIdByWorktree[placementWorkspaceId] + : undefined + const placement: OpenFileOptions | undefined = placementWorkspaceId + ? { + placementWorkspaceId, + executionHostId, + ...(placementGroupId ? { targetGroupId: placementGroupId } : {}) + } + : undefined + const scopedRuntime = runtimeEnvironmentId === undefined ? {} : { runtimeEnvironmentId } // Clear any dangling prior scroll request; only the diff branches below re-stamp it. cancelSourceControlEditorRevealFrames(pendingCommentEditorRevealFrameIdsRef) - setScrollToDiffCommentId(null) + useAppStore.getState().setScrollToDiffCommentId(null) if (getDiffCommentSource(comment) === 'markdown') { const absPath = joinPath(worktreePath, filePath) const language = detectLanguage(filePath) - setEditorViewMode(absPath, 'edit') - setMarkdownViewMode(absPath, 'source') - openFile({ - filePath: absPath, - relativePath: filePath, - worktreeId: activeWorktreeId, - language, - mode: 'edit' - }) - setPendingEditorReveal(null) + useAppStore.getState().setEditorViewMode(absPath, 'edit') + useAppStore.getState().setMarkdownViewMode(absPath, 'source') + useAppStore.getState().openFile( + { + filePath: absPath, + relativePath: filePath, + worktreeId: activeWorktreeId, + language, + mode: 'edit', + ...scopedRuntime + }, + placement + ) + useAppStore.getState().setPendingEditorReveal(null) requestSourceControlEditorRevealFrame(pendingCommentEditorRevealFrameIdsRef, () => { requestSourceControlEditorRevealFrame(pendingCommentEditorRevealFrameIdsRef, () => { - setPendingEditorReveal({ + useAppStore.getState().setPendingEditorReveal({ filePath: absPath, line: comment.lineNumber, column: 1, matchLength: 0 }) - setScrollToDiffCommentId(commentId) + useAppStore.getState().setScrollToDiffCommentId(commentId) }) }) return @@ -100,7 +120,7 @@ export function useSourceControlNoteOpening({ if (uncommitted) { handleOpenDiff(uncommitted) if (commentId) { - setScrollToDiffCommentId(commentId) + useAppStore.getState().setScrollToDiffCommentId(commentId) } return } @@ -108,37 +128,39 @@ export function useSourceControlNoteOpening({ if (branchEntry && branchSummary?.status === 'ready') { openCommittedDiff(branchEntry) if (commentId) { - setScrollToDiffCommentId(commentId) + useAppStore.getState().setScrollToDiffCommentId(commentId) } return } // If both diffs lost the file, open changes-mode editor fallback so DiffViewer can consume the reveal request. const absPath = joinPath(worktreePath, filePath) const language = detectLanguage(filePath) - openFile({ - filePath: absPath, - relativePath: filePath, - worktreeId: activeWorktreeId, - language, - mode: 'edit' - }) + useAppStore.getState().openFile( + { + filePath: absPath, + relativePath: filePath, + worktreeId: activeWorktreeId, + language, + mode: 'edit', + ...scopedRuntime + }, + placement + ) if (commentId) { - setEditorViewMode(absPath, 'changes') - setScrollToDiffCommentId(commentId) + useAppStore.getState().setEditorViewMode(absPath, 'changes') + useAppStore.getState().setScrollToDiffCommentId(commentId) } }, [ activeWorktreeId, branchEntries, + executionHostId, + placementWorkspaceId, + runtimeEnvironmentId, branchSummary, entries, handleOpenDiff, openCommittedDiff, - openFile, - setEditorViewMode, - setScrollToDiffCommentId, - setMarkdownViewMode, - setPendingEditorReveal, worktreePath ] ) diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/commit-surface.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/commit-surface.tsx index ad2a454dc5e..186c6a2efbf 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/commit-surface.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/commit-surface.tsx @@ -1,7 +1,6 @@ import { githubCreationImageUploadAddress } from '@/components/github/github-creation-image-upload-address' import { CreateHostedReviewComposer } from '../../hosted-review/creation/CreateHostedReviewComposer' import { CommitArea } from '../commit/commit-area' -import { writeCommitDraftForWorktree } from '../commit/commit-drafts' import type { SourceControlPanelReadyProps } from './panel-props' type SourceControlCommitSurfaceProps = SourceControlPanelReadyProps & { @@ -79,7 +78,7 @@ export function SourceControlCommitSurface({ sourceControlAiActionsVisible, stackParentReview, unresolvedConflicts, - updateCommitDrafts + updateCommitDraft } = model if (directCreatePrAction) { @@ -169,7 +168,7 @@ export function SourceControlCommitSurface({ if (!activeWorktreeId) { return } - updateCommitDrafts((prev) => writeCommitDraftForWorktree(prev, activeWorktreeId, value)) + updateCommitDraft(value) }} onGenerate={handleGenerateCommitMessageClick} onCancelGenerate={handleCancelGenerate} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx index c10656f51a7..209581a3edc 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx @@ -34,6 +34,7 @@ export function SourceControlDialogLayer({ baseRefDialogOpen, onBaseRefDialogOpenChange, baseRefRepoId, + baseRefHostId, pickerBaseRef, onSelectBaseRef, onUsePrimaryBaseRef, @@ -77,6 +78,7 @@ export function SourceControlDialogLayer({ onConfirmDiscard: DiscardDialogProps['onConfirm'] baseRefDialogOpen: boolean onBaseRefDialogOpenChange: (open: boolean) => void + baseRefHostId?: BaseRefPickerProps['hostId'] baseRefRepoId: string pickerBaseRef: BaseRefPickerProps['currentBaseRef'] onSelectBaseRef: BaseRefPickerProps['onSelect'] @@ -174,6 +176,7 @@ export function SourceControlDialogLayer({
+ onLaunched={(backgroundWorkspaceId) => + !backgroundWorkspaceId && toast.success( translate( 'auto.components.right.sidebar.SourceControl.e48caaf0dd', diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-dialogs.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-dialogs.tsx index c132e4a58f9..af29b7d4a2a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-dialogs.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-dialogs.tsx @@ -64,6 +64,7 @@ export function SourceControlPanelDialogs({ baseRefDialogOpen={baseRefDialogOpen} onBaseRefDialogOpenChange={setBaseRefDialogOpen} baseRefRepoId={activeRepo.id} + baseRefHostId={model.scope?.executionHostId ?? undefined} pickerBaseRef={pickerBaseRef} onSelectBaseRef={(ref) => { if (baseRefOwnedByWorktree && activeWorktreeId) { diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx index fb4eade8276..ee3694f8088 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx @@ -58,7 +58,11 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) { <>
) -} +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/section-viewport-presence.ts b/src/renderer/src/components/right-sidebar/source-control/panel/section-viewport-presence.ts new file mode 100644 index 00000000000..3e609859228 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/panel/section-viewport-presence.ts @@ -0,0 +1,28 @@ +import { useEffect, useState, type RefCallback } from 'react' + +/** Observe the actual viewport; retained bodies pause work without losing selection or focus. */ +export function useSectionOnScreen(root: Element | null): [RefCallback, boolean] { + const [element, setElement] = useState(null) + const [onScreen, setOnScreen] = useState(false) + useEffect(() => { + if (!element || !root) { + return + } + if (typeof IntersectionObserver !== 'function') { + setOnScreen(true) + return + } + const observer = new IntersectionObserver( + (entries) => { + const entry = entries.at(-1) + if (entry) { + setOnScreen(entry.isIntersecting) + } + }, + { root } + ) + observer.observe(element) + return () => observer.disconnect() + }, [element, root]) + return [setElement, onScreen] +} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/source-control-fix-split-button.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/source-control-fix-split-button.tsx index fa7e3075f0b..750498924a2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/source-control-fix-split-button.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/source-control-fix-split-button.tsx @@ -1,3 +1,4 @@ +import { useSourceControlScope, useSourceControlScopeOwner } from './worktree-scope' import React, { useState } from 'react' import { ChevronDown, RefreshCw, SlidersHorizontal, Sparkle } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -87,6 +88,8 @@ export function SourceControlFixSplitButton({ onFixWithDefaultAgent, onPromptDelivered }: SourceControlFixSplitButtonProps): React.JSX.Element { + const scope = useSourceControlScope() + const owner = useSourceControlScopeOwner() const [composerOpen, setComposerOpen] = useState(false) const canLaunch = Boolean(worktreeId && groupId && prompt && !disabledReason) const dividerClass = variant === 'default' ? 'border-primary-foreground/20' : 'border-border' @@ -151,6 +154,8 @@ export function SourceControlFixSplitButton({ title={dialogTitle} description={dialogDescription} baseCommandInput={prompt} + placementWorkspaceId={scope?.placementWorkspaceId} + initialCwd={scope ? owner?.worktree.path : undefined} worktreeId={worktreeId} groupId={groupId} connectionId={connectionId} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts index b065ae16f38..dbf380ffa1f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' import { useAppStore } from '@/store' import { useSourceControlAi } from '../ai/use-ai' import { useSourceControlFileListing } from '../listing/use-file-listing' @@ -8,7 +8,11 @@ import { useSourceControlPanelState } from './use-panel-state' /** Keep data foundation separate from action hooks so flows cannot become dependencies of the state they mutate. */ export function useSourceControlPanelFoundation() { const panelState = useSourceControlPanelState() - const updateSettingsOrThrow = useAppStore((s) => s.updateSettingsOrThrow) + const updateSettingsOrThrow = useCallback( + (...args: Parameters['updateSettingsOrThrow']>) => + useAppStore.getState().updateSettingsOrThrow(...args), + [] + ) const reviewContext = useSourceControlReviewContext(panelState) const { activeConnectionId, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts index 6c03dcc3148..b00ad04eff2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts @@ -16,12 +16,11 @@ export function useSourceControlPanelState() { activeWorktreeId, activeWorktreeInstanceId, branchSummary, - conflictOperationsByWorktree, + conflictOperation, isBranchVisible, isFolder, repositoryHuge, settings, - worktreeMap, worktreePath } = context @@ -37,8 +36,8 @@ export function useSourceControlPanelState() { }) const operationState = useSourceControlWorktreeOperationState({ activeWorktreeId, - conflictOperationsByWorktree, - worktreeMap + activeWorktreeInstanceId, + conflictOperation }) useSourceControlBranchLineTotalGate({ activeWorktreeId, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-view-state.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-view-state.ts index 8425ba138c9..e25dd502075 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-view-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-view-state.ts @@ -1,3 +1,4 @@ +import { useAppStore } from '@/store' import { useCallback, useMemo, useRef, useState } from 'react' import { BoundedMap } from '../../../../../../shared/bounded-map' import { resolveSourceControlGroupOrder } from '../../../../../../shared/source-control/listing/source-control-section-order' @@ -50,13 +51,16 @@ export function useSourceControlPanelViewState({ const isGitHistoryExpanded = !collapsedSections.has('history') const handleToggleSourceControlViewMode = useCallback(() => { - if (!settings) { + const current = useAppStore.getState().settings + if (!current) { return } updateSettings({ - sourceControlViewMode: getNextSourceControlViewMode(sourceControlViewMode) + sourceControlViewMode: getNextSourceControlViewMode( + normalizeSourceControlViewMode(current.sourceControlViewMode) + ) }) - }, [settings, sourceControlViewMode, updateSettings]) + }, [updateSettings]) // Reset during render; key-based remounts on switches caused Windows IPC storms. const [viewStateWorktreeId, setViewStateWorktreeId] = useState(activeWorktreeId) diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts index 64d5887c63a..4d98cafb724 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts @@ -1,222 +1,82 @@ -import { useCallback, useEffect, useRef, useState } from 'react' import { - loadSessionCommitDrafts, - saveSessionCommitDrafts -} from '@/lib/source-control/drafts/source-control-commit-draft-session' + useSourceControlState, + useSourceControlStoreView, + useSourceControlScope +} from './worktree-scope' +import { useCallback, useEffect } from 'react' +import { useStore } from 'zustand' +import { worktreeGitCopyHostId } from '@/lib/worktree-git-copy' import type { GitConflictOperation } from '../../../../../../shared/git/status/git-status-types' -import type { SourceControlActionError } from '../sync/action-error' -import { - createPrIntentCurrentTargetConflictsWithToken, - type CreatePrIntentRunToken -} from '../review/create-pr-intent-flow' -import type { CreatePrIntentNotice } from '../commit/commit-area-types' -import { readCommitDraftForWorktree, type CommitDraftsByWorktree } from '../commit/commit-drafts' -import { clearRemoteActionErrorsForCompletedConflictOperations } from '../sync/remote-refresh' +import { remoteActionErrorSettledByConflictOperation } from '../sync/remote-refresh' +import { getWorktreeOperationStore, writeWorktreeCommitDraft } from './worktree-operation-store' -/** Prune per-worktree drafts/errors/in-flight records when worktrees disappear so reused IDs inherit no stale state. */ +/** Bind the panel to its owner's operation record; admission reads `operationStore.getState()`. */ export function useSourceControlWorktreeOperationState({ activeWorktreeId, - conflictOperationsByWorktree, - worktreeMap + activeWorktreeInstanceId, + conflictOperation }: { activeWorktreeId: string | null - conflictOperationsByWorktree: Record - worktreeMap: ReadonlyMap + activeWorktreeInstanceId: string | undefined + conflictOperation: GitConflictOperation }) { - // Latch synchronously because async state cannot block double-clicks before rerender. - const commitInFlightRef = useRef>({}) - // Persist drafts in the renderer session cache across Source Control tab unmounts. - const [commitDrafts, setCommitDrafts] = useState(() => - loadSessionCommitDrafts() + const scope = useSourceControlScope() + // A folder section names its host; only the active panel falls back to the selected copy. + const selectedHostId = useSourceControlState((s) => + !scope && activeWorktreeId ? worktreeGitCopyHostId(s, activeWorktreeId) : null ) - const commitDraftsRef = useRef(commitDrafts) - const commitErrorsRef = useRef>({}) - const [commitErrors, setCommitErrors] = useState>({}) - const [remoteActionErrors, setRemoteActionErrors] = useState< - Record - >({}) - const remoteActionErrorSequenceByWorktreeRef = useRef>({}) - const previousConflictOperationsRef = useRef>({}) - // Track commits per worktree so navigation cannot allow a duplicate in-flight commit. - const [commitInFlightByWorktree, setCommitInFlightByWorktree] = useState>( - {} + const operationStore = getWorktreeOperationStore( + activeWorktreeId, + scope ? scope.executionHostId : selectedHostId, + activeWorktreeInstanceId ) - const [abortOperationInFlightByWorktree, setAbortOperationInFlightByWorktree] = useState< - Record - >({}) - const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false - const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false - // Track generation per worktree so navigation cannot cancel its running request. - const generateInFlightRef = useRef>({}) - const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState< - Record - >({}) - const [generateErrors, setGenerateErrors] = useState>({}) - const createPrInFlightRef = useRef>({}) - const [createPrInFlightByWorktree, setCreatePrInFlightByWorktree] = useState< - Record - >({}) - const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false - const createPrIntentInFlightRef = useRef>({}) - const createPrIntentRunTokenRef = useRef>({}) - const createPrIntentCurrentTargetRef = useRef({ - repoId: null as string | null, - worktreeId: null as string | null, - worktreePath: null as string | null, - branch: null as string | null, - baseRef: null as string | null - }) - const [createPrIntentInFlightByWorktree, setCreatePrIntentInFlightByWorktree] = useState< - Record - >({}) - const [createPrIntentNotices, setCreatePrIntentNotices] = useState< - Record - >({}) - const isCreatePrIntentInFlight = createPrIntentInFlightByWorktree[activeWorktreeId ?? ''] ?? false - const createPrIntentNotice = createPrIntentNotices[activeWorktreeId ?? ''] ?? null - const setCreatePrIntentNoticeForWorktree = useCallback( - (worktreeId: string, notice: CreatePrIntentNotice | null): void => { - setCreatePrIntentNotices((prev) => ({ ...prev, [worktreeId]: notice })) - }, - [] - ) - const createPrIntentRunStillOwnsWorktree = useCallback( - (token: CreatePrIntentRunToken): boolean => - createPrIntentRunTokenRef.current[token.worktreeId] === token, - [] - ) - const createPrIntentActiveTargetConflicts = useCallback( - (token: CreatePrIntentRunToken): boolean => - createPrIntentCurrentTargetConflictsWithToken(token, createPrIntentCurrentTargetRef.current), - [] + const operationView = useSourceControlStoreView(operationStore) + const commitMessage = useStore(operationView, (s) => s.draft) + const commitError = useStore(operationView, (s) => s.commitError) + const isCommitting = useStore(operationView, (s) => s.isCommitting) + const isAbortingOperation = useStore(operationView, (s) => s.isAbortingOperation) + const remoteActionError = useStore(operationView, (s) => s.remoteActionError) + const activeRemoteActionSequence = useStore(operationView, (s) => + s.remoteActionSequence > 0 ? s.remoteActionSequence : null ) + const isCreatingPr = useStore(operationView, (s) => s.isCreatingPr) + const isCreatePrIntentInFlight = useStore(operationView, (s) => s.createPrIntentRun !== null) + const createPrIntentNotice = useStore(operationView, (s) => s.createPrIntentNotice) - const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) - const commitError = commitErrors[activeWorktreeId ?? ''] ?? null - const remoteActionError = remoteActionErrors[activeWorktreeId ?? ''] ?? null - const activeRemoteActionSequence = activeWorktreeId - ? (remoteActionErrorSequenceByWorktreeRef.current[activeWorktreeId] ?? null) - : null - - useEffect(() => { - commitDraftsRef.current = commitDrafts - }, [commitDrafts]) - - const updateCommitDrafts = useCallback( - (updater: (drafts: CommitDraftsByWorktree) => CommitDraftsByWorktree): void => { - const next = updater(commitDraftsRef.current) - // Sync the draft ref before passive effects so awaited Create PR flows see current user edits. - commitDraftsRef.current = next - setCommitDrafts(next) - }, - [] + const updateCommitDraft = useCallback( + (update: string | ((draft: string) => string)): void => + writeWorktreeCommitDraft(operationStore, update), + [operationStore] ) - const setCommitErrorForWorktree = useCallback( - (worktreeId: string, message: string | null): void => { - commitErrorsRef.current = { ...commitErrorsRef.current, [worktreeId]: message } - setCommitErrors((prev) => ({ ...prev, [worktreeId]: message })) - }, - [] - ) - - // Prune removed worktrees before reused IDs can inherit stuck in-flight state. - useEffect(() => { - const pruneRecord = (prev: Record): Record => { - let changed = false - const next: Record = {} - for (const key of Object.keys(prev)) { - if (worktreeMap.has(key)) { - next[key] = prev[key] - } else { - changed = true - } - } - return changed ? next : prev - } - updateCommitDrafts((prev) => pruneRecord(prev)) - commitErrorsRef.current = pruneRecord(commitErrorsRef.current) - setCommitErrors((prev) => pruneRecord(prev)) - setRemoteActionErrors((prev) => pruneRecord(prev)) - setCommitInFlightByWorktree((prev) => pruneRecord(prev)) - setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev)) - setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) - setGenerateErrors((prev) => pruneRecord(prev)) - setCreatePrIntentInFlightByWorktree((prev) => pruneRecord(prev)) - setCreatePrIntentNotices((prev) => pruneRecord(prev)) - for (const key of Object.keys(commitInFlightRef.current)) { - if (!worktreeMap.has(key)) { - delete commitInFlightRef.current[key] - } - } - for (const key of Object.keys(remoteActionErrorSequenceByWorktreeRef.current)) { - if (!worktreeMap.has(key)) { - delete remoteActionErrorSequenceByWorktreeRef.current[key] - } - } - for (const key of Object.keys(generateInFlightRef.current)) { - if (!worktreeMap.has(key)) { - delete generateInFlightRef.current[key] - } - } - for (const key of Object.keys(createPrIntentInFlightRef.current)) { - if (!worktreeMap.has(key)) { - delete createPrIntentInFlightRef.current[key] - delete createPrIntentRunTokenRef.current[key] - } - } - }, [updateCommitDrafts, worktreeMap]) - - useEffect(() => { - saveSessionCommitDrafts(commitDrafts) - }, [commitDrafts]) useEffect(() => { // Terminal conflict resolution must clear stale failure banners once status observes the operation ending. - const previousConflictOperations = previousConflictOperationsRef.current - setRemoteActionErrors((prev) => - clearRemoteActionErrorsForCompletedConflictOperations({ - remoteActionErrors: prev, - previousConflictOperations, - currentConflictOperations: conflictOperationsByWorktree - }) - ) - previousConflictOperationsRef.current = conflictOperationsByWorktree - }, [conflictOperationsByWorktree]) + const { observedConflictOperation, remoteActionError } = operationStore.getState() + operationStore.setState({ + observedConflictOperation: conflictOperation, + ...(remoteActionError && + remoteActionErrorSettledByConflictOperation( + remoteActionError, + observedConflictOperation, + conflictOperation + ) + ? { remoteActionError: null } + : {}) + }) + }, [conflictOperation, operationStore]) return { - abortOperationInFlightByWorktree, activeRemoteActionSequence, - commitDraftsRef, commitError, - commitErrorsRef, - commitInFlightRef, commitMessage, - createPrInFlightRef, - createPrIntentActiveTargetConflicts, - createPrIntentCurrentTargetRef, - createPrIntentInFlightRef, createPrIntentNotice, - createPrIntentRunStillOwnsWorktree, - createPrIntentRunTokenRef, - generateErrors, - generateInFlightByWorktree, - generateInFlightRef, isAbortingOperation, isCommitting, isCreatePrIntentInFlight, isCreatingPr, + operationStore, remoteActionError, - remoteActionErrorSequenceByWorktreeRef, - setAbortOperationInFlightByWorktree, - setCommitErrorForWorktree, - setCommitInFlightByWorktree, - setCreatePrInFlightByWorktree, - setCreatePrIntentInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, - setGenerateErrors, - setGenerateInFlightByWorktree, - setRemoteActionErrors, - updateCommitDrafts + updateCommitDraft } } diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/worktree-operation-state.test.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/worktree-operation-state.test.tsx new file mode 100644 index 00000000000..94966ae3630 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/panel/worktree-operation-state.test.tsx @@ -0,0 +1,301 @@ +// @vitest-environment happy-dom +import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity' + +import { act, cleanup, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { useSourceControlHostedReviewCreated } from '../review/use-hosted-review-created' +import { useSourceControlStoreActions } from '../listing/use-store-actions' +import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { useSourceControlCommitAction } from '../commit/use-commit-action' +import { useSourceControlWorktreeOperationState } from './use-worktree-operation-state' +import { getWorktreeOperationStore, writeWorktreeCommitDraft } from './worktree-operation-store' +import { SourceControlScopeProvider } from './worktree-scope' + +const git = vi.hoisted(() => ({ + commit: vi.fn<(context: { connectionId?: string }, message: string) => Promise>() +})) +vi.mock('@/runtime/runtime-git-sync-client', () => ({ + commitRuntimeGit: git.commit, + completeRuntimeGitMerge: vi.fn() +})) + +const initialAppState = useAppStore.getInitialState() +afterEach(() => { + cleanup() + git.commit.mockReset() + useAppStore.setState(initialAppState, true) +}) + +function sectionWrapper( + worktreeId: string, + executionHostId: ExecutionHostId, + isActive = () => true +) { + const worktree = makeWorktree(worktreeId, worktreeId, { instanceId: 'instance-1' }) + const repo = { + ...makeRepo(), + connectionId: executionHostId === 'local' ? null : executionHostId.slice('ssh:'.length) + } + const owner = { + worktreeId, + repoId: repo.id, + executionHostId, + worktree, + attachedAt: 1, + repo, + placementWorkspaceId: 'folder:feature' + } + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +function useCommitSection(worktreeId: string) { + const state = useSourceControlWorktreeOperationState({ + activeWorktreeId: worktreeId, + activeWorktreeInstanceId: 'instance-1', + conflictOperation: 'unknown' + }) + const action = useSourceControlCommitAction({ + activeRepoTarget: { kind: 'local' }, + activeWorktree: null, + activeWorktreeId: worktreeId, + beginGitBranchCompareRequest: vi.fn(), + commitMessage: state.commitMessage, + compareBaseRef: null, + operationStore: state.operationStore, + refreshActiveGitStatusAfterMutation: async () => {}, + refreshBranchCompareRef: { current: async () => {} }, + refreshGitHistoryRef: { current: async () => {} }, + stagedCount: 1, + unresolvedConflictCount: 0, + updateCommitDraft: state.updateCommitDraft, + worktreePath: `/tmp/${worktreeId}` + }) + return { ...state, ...action } +} + +it('keeps drafts of same-id worktrees on different hosts apart and across remounts', () => { + const local = renderHook(() => useCommitSection('wt-drafts'), { + wrapper: sectionWrapper('wt-drafts', 'local') + }) + const remote = renderHook(() => useCommitSection('wt-drafts'), { + wrapper: sectionWrapper('wt-drafts', 'ssh:box') + }) + act(() => local.result.current.updateCommitDraft('fix: local copy')) + act(() => remote.result.current.updateCommitDraft('fix: remote copy')) + local.unmount() + + const remounted = renderHook(() => useCommitSection('wt-drafts'), { + wrapper: sectionWrapper('wt-drafts', 'local') + }) + expect(remounted.result.current.commitMessage).toBe('fix: local copy') + expect(remote.result.current.commitMessage).toBe('fix: remote copy') +}) + +it('keeps a slow commit latched after collapse so the re-expanded section cannot resubmit', async () => { + let finishCommit: (result: { success: boolean }) => void = () => {} + git.commit.mockImplementation( + () => + new Promise((resolve) => { + finishCommit = resolve + }) + ) + let active = true + const wrapper = sectionWrapper('wt-slow', 'local', () => active) + const first = renderHook(() => useCommitSection('wt-slow'), { wrapper }) + act(() => first.result.current.updateCommitDraft('feat: slow')) + let pending: Promise | undefined + act(() => { + pending = first.result.current.handleCommit() + }) + active = false + first.rerender() + const reexpanded = first + expect(reexpanded.result.current.isCommitting).toBe(true) + await act(async () => { + expect(await reexpanded.result.current.handleCommit()).toBe(false) + }) + expect(git.commit).toHaveBeenCalledTimes(1) + + await act(async () => { + finishCommit({ success: true }) + expect(await pending).toBe(true) + }) + expect(reexpanded.result.current.operationStore.getState().isCommitting).toBe(false) + expect(reexpanded.result.current.isCommitting).toBe(true) + expect(reexpanded.result.current.commitMessage).toBe('feat: slow') + active = true + first.rerender() + expect(reexpanded.result.current.isCommitting).toBe(false) + expect(reexpanded.result.current.commitMessage).toBe('') +}) + +it('commits a same-id host twin over its own connection without blocking the other copy', async () => { + git.commit.mockImplementation(() => new Promise(() => {})) + const local = renderHook(() => useCommitSection('wt-twin'), { + wrapper: sectionWrapper('wt-twin', 'local') + }) + const remote = renderHook(() => useCommitSection('wt-twin'), { + wrapper: sectionWrapper('wt-twin', 'ssh:box') + }) + act(() => remote.result.current.updateCommitDraft('fix: remote')) + act(() => { + void remote.result.current.handleCommit() + }) + + expect(git.commit).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-twin', connectionId: 'box' }), + 'fix: remote' + ) + expect(remote.result.current.isCommitting).toBe(true) + expect(local.result.current.isCommitting).toBe(false) +}) + +it('gives a worktree id reused by a new instance a fresh record', () => { + const original = getWorktreeOperationStore('wt-reused', 'local', 'instance-1') + original.setState({ draft: 'stale', isCommitting: true }) + act(() => { + useAppStore.setState({ + worktreesByRepo: { + 'repo-1': [makeWorktree('wt-reused', 'wt-reused', { instanceId: 'instance-2' })] + } + }) + }) + + const reused = getWorktreeOperationStore('wt-reused', 'local', 'instance-2') + expect(reused).not.toBe(original) + expect(reused.getState().draft).toBe('') + expect(reused.getState().isCommitting).toBe(false) +}) + +it('keeps a live draft when a non-authoritative catalog scan briefly drops the row', () => { + const row = makeWorktree('wt-blip', 'wt-blip', { instanceId: 'instance-1' }) + act(() => useAppStore.setState({ worktreesByRepo: { 'repo-1': [row] } })) + const store = getWorktreeOperationStore('wt-blip', 'local', 'instance-1') + writeWorktreeCommitDraft(store, 'feat: keep me') + act(() => useAppStore.setState({ worktreesByRepo: { 'repo-1': [] } })) + act(() => useAppStore.setState({ worktreesByRepo: { 'repo-1': [row] } })) + + const after = getWorktreeOperationStore('wt-blip', 'local', 'instance-1') + expect(after).toBe(store) + expect(after.getState().draft).toBe('feat: keep me') +}) + +it('retires only the removed host owner on authoritative purge and fences late draft writes', () => { + const local = getWorktreeOperationStore('wt-removed', 'local', 'instance-1') + const remote = getWorktreeOperationStore('wt-removed', 'ssh:box', 'instance-1') + writeWorktreeCommitDraft(local, 'local draft') + writeWorktreeCommitDraft(remote, 'remote draft') + useAppStore + .getState() + .purgeWorktreeTerminalState([hostWorktreeTarget({ id: 'wt-removed', hostId: 'ssh:box' })]) + writeWorktreeCommitDraft(remote, 'late remote draft') + const replacement = getWorktreeOperationStore('wt-removed', 'ssh:box', 'instance-1') + expect(replacement).not.toBe(remote) + expect(replacement.getState().draft).toBe('') + expect(getWorktreeOperationStore('wt-removed', 'local', 'instance-1')).toBe(local) + expect(local.getState().draft).toBe('local draft') + useAppStore + .getState() + .purgeWorktreeTerminalState([hostWorktreeTarget({ id: 'wt-removed', hostId: 'local' })]) + expect(getWorktreeOperationStore('wt-removed', 'local', 'instance-1').getState().draft).toBe('') +}) + +it('writes the base ref and opens aggregate diffs on the section host while the folder stays active', async () => { + const updateRepo = vi.fn() + const openAllDiffs = vi.fn() + useAppStore.setState({ + activeWorktreeId: 'folder:feature', + activeWorkspaceExecutionHostId: 'local', + updateRepo, + openAllDiffs + }) + const section = renderHook(() => useSourceControlStoreActions(), { + wrapper: sectionWrapper('wt-twin', 'ssh:box') + }) + await section.result.current.updateRepo('repo-1', { worktreeBaseRef: 'release' }) + expect(updateRepo).toHaveBeenCalledWith( + 'repo-1', + { worktreeBaseRef: 'release' }, + { hostId: 'ssh:box' } + ) + section.result.current.openAllDiffs('wt-twin', '/remote/repo') + expect(openAllDiffs).toHaveBeenCalledWith( + 'wt-twin', + '/remote/repo', + undefined, + undefined, + undefined, + { + placementWorkspaceId: 'folder:feature', + runtimeEnvironmentId: null, + executionHostId: 'ssh:box' + } + ) + expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature') +}) + +it('opens a section file here and its checks in the folder overview without changing workspace', () => { + const openFile = vi.fn(() => 'file') + const revealInExplorer = vi.fn() + const setRightSidebarOpen = vi.fn() + const setRightSidebarTab = vi.fn() + useAppStore.setState({ + activeWorktreeId: 'folder:feature', + activeGroupIdByWorktree: { 'folder:feature': 'folder-group' }, + openFile, + revealInExplorer, + setRightSidebarOpen, + setRightSidebarTab + }) + const section = renderHook( + () => { + const actions = useSourceControlStoreActions() + const checks = useSourceControlHostedReviewCreated({ + activeRepo: null, + activeWorktreeId: 'wt-child', + branchName: 'feature', + fallbackGitHubPRNumber: null, + linkedAzureDevOpsPR: null, + linkedBitbucketPR: null, + linkedGitHubPR: 123, + linkedGitLabMR: null, + linkedGiteaPR: null, + fetchHostedReviewForBranch: actions.fetchHostedReviewForBranch, + fetchPRForBranch: actions.fetchPRForBranch, + refreshActiveGitStatusAfterMutation: async () => {}, + setRightSidebarOpen: actions.setRightSidebarOpen, + setRightSidebarTab: actions.setRightSidebarTab, + updateWorktreeMeta: actions.updateWorktreeMeta + }) + return { actions, checks } + }, + { wrapper: sectionWrapper('wt-child', 'ssh:box') } + ) + section.result.current.actions.revealInExplorer('wt-child', '/remote/repo/a.ts') + expect(openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/remote/repo/a.ts', + worktreeId: 'wt-child', + mode: 'edit' + }), + { + placementWorkspaceId: 'folder:feature', + executionHostId: 'ssh:box', + runtimeEnvironmentId: null, + targetGroupId: 'folder-group', + preview: false + } + ) + expect(revealInExplorer).not.toHaveBeenCalled() + section.result.current.checks.openHostedReviewInChecks() + expect(setRightSidebarTab).toHaveBeenCalledWith('pr-checks') + expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature') +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/worktree-operation-store.ts b/src/renderer/src/components/right-sidebar/source-control/panel/worktree-operation-store.ts new file mode 100644 index 00000000000..9a4c40d7b84 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/panel/worktree-operation-store.ts @@ -0,0 +1,206 @@ +import { createStore, type StoreApi } from 'zustand/vanilla' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { getIndexedWorktreesById } from '@/store/worktree-repo-index' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { + clearSessionCommitDraftForWorktree, + loadSessionCommitDrafts, + saveSessionCommitDrafts, + subscribeWorktreeCommitDraftRetirement +} from '@/lib/source-control/drafts/source-control-commit-draft-session' +import { + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../../../shared/execution-host' +import type { GitConflictOperation } from '../../../../../../shared/git/status/git-status-types' +import type { SourceControlActionError } from '../sync/action-error' +import type { CreatePrIntentNotice } from '../commit/commit-area-types' +import { + createPrIntentCurrentTargetConflictsWithToken, + type CreatePrIntentCurrentTarget, + type CreatePrIntentRunToken +} from '../review/create-pr-intent-flow' + +/** Everything Source Control tracks for one worktree copy; lives past panel/section unmounts. */ +export type WorktreeOperationRecord = { + readonly draftKey: string + draft: string + commitError: string | null + isCommitting: boolean + isAbortingOperation: boolean + remoteActionError: SourceControlActionError | null + remoteActionSequence: number + generateInFlight: boolean + generateError: string | null + isCreatingPr: boolean + /** The run that owns this copy's Create PR intent; null when idle or invalidated. */ + createPrIntentRun: CreatePrIntentRunToken | null + createPrIntentNotice: CreatePrIntentNotice | null + createPrIntentTarget: CreatePrIntentCurrentTarget + observedConflictOperation: GitConflictOperation +} + +export type WorktreeOperationStore = StoreApi + +type Entry = { + store: WorktreeOperationStore + worktreeId: string + hostId: ExecutionHostId | null + instanceId: string | undefined +} + +const entries = new Map() +const retiredStores = new WeakSet() +let retiringSubscribed = false + +function isLocalHost(hostId: ExecutionHostId | null): boolean { + return hostId === null || hostId === LOCAL_EXECUTION_HOST_ID +} + +// Local copies keep the bare-id draft key so teardown's legacy clear still finds them. +function commitDraftKey(worktreeId: string, hostId: ExecutionHostId | null): string { + return isLocalHost(hostId) ? worktreeId : `${hostId}::${worktreeId}` +} + +function createRecord(draftKey: string): WorktreeOperationRecord { + return { + draftKey, + draft: loadSessionCommitDrafts()[draftKey] ?? '', + commitError: null, + isCommitting: false, + isAbortingOperation: false, + remoteActionError: null, + remoteActionSequence: 0, + generateInFlight: false, + generateError: null, + isCreatingPr: false, + createPrIntentRun: null, + createPrIntentNotice: null, + createPrIntentTarget: {}, + observedConflictOperation: 'unknown' + } +} + +// Inert record for a panel with no worktree; consumers refuse to act without one. +const UNOWNED_STORE: WorktreeOperationStore = createStore(() => createRecord('')) + +function retire(key: string, entry: Entry): void { + entries.delete(key) + retiredStores.add(entry.store) + // A late finally on the detached store can no longer claim the run or resurrect the draft. + entry.store.setState({ createPrIntentRun: null }) + clearSessionCommitDraftForWorktree(entry.store.getState().draftKey) +} + +function hostRows(entry: Entry, worktreesByRepo: AppState['worktreesByRepo']) { + return getIndexedWorktreesById(worktreesByRepo, entry.worktreeId).filter( + // Why unstamped rows match any host: discovery backfills hostId after the row first lands. + (row) => entry.hostId === null || row.hostId === undefined || row.hostId === entry.hostId + ) +} + +function entryIsRetired(entry: Entry, worktreesByRepo: AppState['worktreesByRepo']): boolean { + const rows = hostRows(entry, worktreesByRepo) + if (rows.length === 0) { + return false + } + // A reused id with a new instance is a different worktree. + return ( + entry.instanceId !== undefined && + rows.every((row) => row.instanceId !== undefined && row.instanceId !== entry.instanceId) + ) +} + +/** Called only by successful removal or an authoritative purge, never by catalog absence. */ +function retireRemovedWorktreeOperationStores(worktreeId: string, hostId?: ExecutionHostId): void { + for (const [key, entry] of entries) { + if (entry.worktreeId !== worktreeId) { + continue + } + if (hostId !== undefined && (entry.hostId ?? LOCAL_EXECUTION_HOST_ID) !== hostId) { + continue + } + retire(key, entry) + } +} + +function subscribeRetirement(): void { + retiringSubscribed = true + subscribeWorktreeCommitDraftRetirement(retireRemovedWorktreeOperationStores) + useAppStore.subscribe((state, previous) => { + const worktreesByRepo = selectWorktreesByRepo(state) + if (worktreesByRepo === selectWorktreesByRepo(previous)) { + return + } + for (const [key, entry] of entries) { + if (entryIsRetired(entry, worktreesByRepo)) { + retire(key, entry) + } + } + }) +} + +/** One store per host/worktree/instance; a reused id with a new instance never inherits state. */ +export function getWorktreeOperationStore( + worktreeId: string | null, + hostId: ExecutionHostId | null, + instanceId: string | undefined +): WorktreeOperationStore { + if (!worktreeId) { + return UNOWNED_STORE + } + if (!retiringSubscribed) { + subscribeRetirement() + } + const key = `${hostId ?? LOCAL_EXECUTION_HOST_ID}::${worktreeId}` + const existing = entries.get(key) + if (existing) { + if (!instanceId || !existing.instanceId || existing.instanceId === instanceId) { + // Why adopt: rows hydrate their instance id after the first render. + existing.instanceId ??= instanceId + return existing.store + } + retire(key, existing) + } + const store = createStore(() => + createRecord(commitDraftKey(worktreeId, hostId)) + ) + entries.set(key, { store, worktreeId, hostId, instanceId }) + return store +} + +/** Draft writes update the session cache at the boundary so remounts read the latest text. */ +export function writeWorktreeCommitDraft( + store: WorktreeOperationStore, + update: string | ((draft: string) => string) +): void { + const { draft, draftKey } = store.getState() + const next = typeof update === 'function' ? update(draft) : update + if (next === draft) { + return + } + store.setState({ draft: next }) + if (store !== UNOWNED_STORE && !retiredStores.has(store)) { + saveSessionCommitDrafts({ ...loadSessionCommitDrafts(), [draftKey]: next }) + } +} + +export function setCreatePrIntentNotice( + store: WorktreeOperationStore, + notice: CreatePrIntentNotice | null +): void { + store.setState({ createPrIntentNotice: notice }) +} + +/** The run still owns the copy and the user has not moved its branch or base underneath it. */ +export function createPrIntentRunIsCurrent( + store: WorktreeOperationStore, + token: CreatePrIntentRunToken +): boolean { + const { createPrIntentRun, createPrIntentTarget } = store.getState() + return ( + createPrIntentRun === token && + !createPrIntentCurrentTargetConflictsWithToken(token, createPrIntentTarget) + ) +} 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 new file mode 100644 index 00000000000..52922f7e3ca --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/panel/worktree-scope.tsx @@ -0,0 +1,152 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' +import { create, type StoreApi } from 'zustand' +import { AppStateViewContext, useAppStateValue } from '@/store/app-state-view' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { getConnectionId } from '@/lib/connection-context' +import type { Repo } from '../../../../../../shared/repo-types' +import type { AttachedWorktreeOwner } from '../../folder-workspace-attached-worktrees' + +export type SourceControlScope = AttachedWorktreeOwner & { + repo: Repo + placementWorkspaceId: string +} +/** Routing fields only, so worktree record churn (activity, unread, metadata) cannot refire scoped Git work. */ +export type SourceControlScopeRouting = Pick< + SourceControlScope, + 'worktreeId' | 'repoId' | 'executionHostId' | 'placementWorkspaceId' +> & { + connectionId: string | null + runtimeOwnerEnvironmentId: string | undefined +} +const WorktreeScope = createContext(null) +const WorktreeScopeOwner = createContext(null) +// Why default true: the selected workspace's panel has no section gate, as on main. +const SectionActive = createContext(true) +const mountedScopeCounts = new Map() + +/** Whether a folder section for this worktree is on screen, so its row actions can still apply. */ +export function isSourceControlScopeMounted(worktreeId: string | null): boolean { + return worktreeId !== null && mountedScopeCounts.has(worktreeId) +} +export function SourceControlScopeProvider({ + owner, + active = true, + children +}: { + owner: SourceControlScope + /** Expanded and on screen; inactive sections keep their view state but run no Git or review work. */ + active?: boolean + children?: ReactNode +}) { + // Hidden bodies retain their last snapshot and resume from current state when shown. + const store = useMemo(() => { + if (active) { + return useAppStore + } + const snapshot = useAppStore.getState() + return create(() => snapshot) + }, [active]) + const [visibleOwner, setVisibleOwner] = useState(owner) + if (active && visibleOwner !== owner) { + setVisibleOwner(owner) + } + const currentOwner = active ? owner : visibleOwner + const { worktreeId, repoId, executionHostId, placementWorkspaceId } = currentOwner + const connectionId = currentOwner.repo.connectionId ?? null + const runtimeOwnerEnvironmentId = currentOwner.worktree.runtimeOwnerEnvironmentId + const routing = useMemo( + () => ({ + worktreeId, + repoId, + executionHostId, + placementWorkspaceId, + connectionId, + runtimeOwnerEnvironmentId + }), + [ + worktreeId, + repoId, + executionHostId, + placementWorkspaceId, + connectionId, + runtimeOwnerEnvironmentId + ] + ) + useEffect(() => { + mountedScopeCounts.set(worktreeId, (mountedScopeCounts.get(worktreeId) ?? 0) + 1) + return () => { + const remaining = (mountedScopeCounts.get(worktreeId) ?? 1) - 1 + if (remaining > 0) { + mountedScopeCounts.set(worktreeId, remaining) + } else { + mountedScopeCounts.delete(worktreeId) + } + } + }, [worktreeId]) + return ( + + + + {children} + + + + ) +} +export function useSourceControlScope(): SourceControlScopeRouting | null { + return useContext(WorktreeScope) +} +/** Whether this panel may run Git/review work: always for the selected panel, expanded and on screen for a section. */ +export function useSourceControlSectionActive(): boolean { + return useContext(SectionActive) +} +/** The section's live worktree and repo records, for display state that must follow record updates. */ +export function useSourceControlScopeOwner(): SourceControlScope | null { + return useContext(WorktreeScopeOwner) +} + +/** The section's runtime environment, falling back to a runtime execution host. */ +export function sourceControlScopeRuntimeEnvironmentId( + scope: SourceControlScopeRouting | null +): string | null | undefined { + if (!scope) { + return undefined + } + return ( + scope.runtimeOwnerEnvironmentId ?? + (scope.executionHostId.startsWith('runtime:') + ? decodeURIComponent(scope.executionHostId.slice(8)) + : null) + ) +} + +/** A folder section's repo connection for its own worktree; otherwise the selected copy's. */ +export function sourceControlConnectionId( + scope: SourceControlScopeRouting | null, + worktreeId: string | null +): string | undefined { + return ( + (scope?.worktreeId === worktreeId ? scope.connectionId : getConnectionId(worktreeId)) ?? + undefined + ) +} + +/** Body context subscriptions stop while hidden; actions still read the live app store. */ +export function useSourceControlState(selector: (state: AppState) => T): T { + return useAppStateValue(selector) +} + +/** Pause a retained body's operation display without suspending the operation's owner. */ +export function useSourceControlStoreView( + store: Pick, 'getState' | 'getInitialState' | 'subscribe'> +) { + const active = useSourceControlSectionActive() + return useMemo(() => { + if (active) { + return store + } + const snapshot = store.getState() + return { getState: () => snapshot, getInitialState: () => snapshot, subscribe: () => () => {} } + }, [active, store]) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-branch-prep.ts b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-branch-prep.ts index f341173cb05..049f130e01e 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-branch-prep.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-branch-prep.ts @@ -1,6 +1,5 @@ import { translate } from '@/i18n/i18n' import { isBehindOnlyUpstream } from '../../../../../../shared/git/remotes/git-upstream-status' -import { readCommitDraftForWorktree, writeCommitDraftForWorktree } from '../commit/commit-drafts' import { getCreatePrIntentCommitFailureNoticeMessage, type CreatePrIntentRunToken @@ -8,33 +7,32 @@ import { import type { SourceControlOperationTarget } from '../listing/operation-target' import type { SourceControlCommitAction } from '../commit/use-commit-action' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' +import type { CreatePrIntentNotice } from '../commit/commit-area-types' import type { SourceControlRemoteActionRunner } from '../sync/use-remote-action-runner' import type { CreatePrIntentRunSnapshot } from './create-pr-intent-run-snapshot' import type { SourceControlCreatePrIntentCommitMessage } from './use-create-pr-intent-commit-message' /** Prepare a committed fast-forward-current branch; false stops the intent with only caller finally cleanup remaining. */ export async function runCreatePrIntentBranchPrep({ - commitDraftsRef, - commitErrorsRef, generateCommitMessageForCreatePrIntent, handleCommit, + operationStore, operationTarget, runRemoteAction, - setCreatePrIntentNoticeForWorktree, + setCreatePrIntentNotice, snapshot, token, - updateCommitDrafts + updateCommitDraft }: { - commitDraftsRef: SourceControlWorktreeOperationState['commitDraftsRef'] - commitErrorsRef: SourceControlWorktreeOperationState['commitErrorsRef'] generateCommitMessageForCreatePrIntent: SourceControlCreatePrIntentCommitMessage['generateCommitMessageForCreatePrIntent'] handleCommit: SourceControlCommitAction['handleCommit'] + operationStore: SourceControlWorktreeOperationState['operationStore'] operationTarget: SourceControlOperationTarget runRemoteAction: SourceControlRemoteActionRunner['runRemoteAction'] - setCreatePrIntentNoticeForWorktree: SourceControlWorktreeOperationState['setCreatePrIntentNoticeForWorktree'] + setCreatePrIntentNotice: (notice: CreatePrIntentNotice | null) => void snapshot: CreatePrIntentRunSnapshot token: CreatePrIntentRunToken - updateCommitDrafts: SourceControlWorktreeOperationState['updateCommitDrafts'] + updateCommitDraft: SourceControlWorktreeOperationState['updateCommitDraft'] }): Promise { const { abortIfStale, refreshIntentSnapshot, stageLatestIntentPaths } = snapshot @@ -44,7 +42,7 @@ export async function runCreatePrIntentBranchPrep({ // Fast-forward behind-only before commit to avoid creating divergence; ff-only never auto-merges. if (isBehindOnlyUpstream(snapshot.upstreamStatus)) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.createPrIntentFastForwarding', @@ -61,7 +59,7 @@ export async function runCreatePrIntentBranchPrep({ return false } if (earlyFfResult.status !== 'ok') { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', @@ -84,9 +82,9 @@ export async function runCreatePrIntentBranchPrep({ return true } - let message = readCommitDraftForWorktree(commitDraftsRef.current, token.worktreeId).trim() + let message = operationStore.getState().draft.trim() if (!message) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.8d8f5c6c94', @@ -98,7 +96,7 @@ export async function runCreatePrIntentBranchPrep({ return false } if (!generated.ok || !generated.message) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: generated.reason === 'settings' ? 'muted' : 'destructive', message: translate( generated.reason === 'settings' @@ -112,12 +110,8 @@ export async function runCreatePrIntentBranchPrep({ }) return false } - const draftAfterGeneration = readCommitDraftForWorktree( - commitDraftsRef.current, - token.worktreeId - ).trim() - if (draftAfterGeneration) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + if (operationStore.getState().draft.trim()) { + setCreatePrIntentNotice({ tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.fda060d6ce', @@ -127,10 +121,10 @@ export async function runCreatePrIntentBranchPrep({ return false } message = generated.message - updateCommitDrafts((prev) => writeCommitDraftForWorktree(prev, token.worktreeId, message)) + updateCommitDraft(message) } - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.b75cb1fd0c', @@ -153,8 +147,8 @@ export async function runCreatePrIntentBranchPrep({ if (abortIfStale()) { return false } - const commitFailure = commitErrorsRef.current[token.worktreeId] ?? null - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + const commitFailure = operationStore.getState().commitError + setCreatePrIntentNotice({ tone: 'destructive', message: getCreatePrIntentCommitFailureNoticeMessage(commitFailure, { fallback: translate( diff --git a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts index d26c6387a85..d66bcc271e2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts @@ -1,4 +1,3 @@ -import type { SourceControlAi } from '../ai/use-ai' import type { SourceControlStoreActions } from '../listing/use-store-actions' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' @@ -196,16 +195,9 @@ export type CreatePrIntentReviewOptions = Pick< > & { activeRepo: SourceControlWorktreeContext['activeRepo'] createHostedReview: SourceControlStoreActions['createHostedReview'] - createPrInFlightRef: SourceControlWorktreeOperationState['createPrInFlightRef'] - createPrIntentActiveTargetConflicts: SourceControlWorktreeOperationState['createPrIntentActiveTargetConflicts'] - createPrIntentCurrentTargetRef: SourceControlWorktreeOperationState['createPrIntentCurrentTargetRef'] - createPrIntentRunStillOwnsWorktree: SourceControlWorktreeOperationState['createPrIntentRunStillOwnsWorktree'] + operationStore: SourceControlWorktreeOperationState['operationStore'] getCreatePrIntentOperationTarget: SourceControlCreatePrIntentTarget['getCreatePrIntentOperationTarget'] handlePullRequestCreated: SourceControlHostedReviewCreated['handlePullRequestCreated'] hostedReviewCreateCopy: SourceControlHostedReviewState['hostedReviewCreateCopy'] prImageUploadPendingRef?: React.RefObject - resolvedPrCreationDefaults: SourceControlAi['resolvedPrCreationDefaults'] - setCreatePrInFlightByWorktree: SourceControlWorktreeOperationState['setCreatePrInFlightByWorktree'] - setCreatePrIntentNoticeForWorktree: SourceControlWorktreeOperationState['setCreatePrIntentNoticeForWorktree'] - settings: SourceControlWorktreeContext['settings'] } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-review-step.ts b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-review-step.ts index fca1b2a325b..986feb7c271 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-review-step.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-review-step.ts @@ -6,7 +6,7 @@ import { type CreatePrIntentRunToken } from './create-pr-intent-flow' import type { SourceControlOperationTarget } from '../listing/operation-target' -import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' +import type { CreatePrIntentNotice } from '../commit/commit-area-types' import type { SourceControlRemoteActionRunner } from '../sync/use-remote-action-runner' import type { CreatePrIntentRunSnapshot } from './create-pr-intent-run-snapshot' import type { SourceControlCreatePrIntentProbes } from './use-create-pr-intent-probes' @@ -19,7 +19,7 @@ export async function runCreatePrIntentReviewStep({ readHostedReviewCreationEligibilityForIntent, refreshBranchCompareForCreatePrIntent, runRemoteAction, - setCreatePrIntentNoticeForWorktree, + setCreatePrIntentNotice, snapshot, token }: { @@ -28,7 +28,7 @@ export async function runCreatePrIntentReviewStep({ readHostedReviewCreationEligibilityForIntent: SourceControlCreatePrIntentProbes['readHostedReviewCreationEligibilityForIntent'] refreshBranchCompareForCreatePrIntent: SourceControlCreatePrIntentProbes['refreshBranchCompareForCreatePrIntent'] runRemoteAction: SourceControlRemoteActionRunner['runRemoteAction'] - setCreatePrIntentNoticeForWorktree: SourceControlWorktreeOperationState['setCreatePrIntentNoticeForWorktree'] + setCreatePrIntentNotice: (notice: CreatePrIntentNotice | null) => void snapshot: CreatePrIntentRunSnapshot token: CreatePrIntentRunToken }): Promise { @@ -43,7 +43,7 @@ export async function runCreatePrIntentReviewStep({ return } if (!eligibility) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.d7492cafce', @@ -60,7 +60,7 @@ export async function runCreatePrIntentReviewStep({ return } if (eligibility.blockedReason === 'existing_review') { - setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + setCreatePrIntentNotice(null) return } @@ -78,7 +78,7 @@ export async function runCreatePrIntentReviewStep({ hasCurrentBranch: Boolean(token.branch) }) if (remoteStep === 'blocked' || remoteStep === 'none') { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'muted', // Diverged branches require explicit sync; automatic preparation would merge without consent. message: @@ -95,7 +95,7 @@ export async function runCreatePrIntentReviewStep({ return } - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'muted', // Keep translation keys literal so catalog verification can find them statically. message: @@ -130,7 +130,7 @@ export async function runCreatePrIntentReviewStep({ return } if (remoteResult.status !== 'ok') { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', @@ -163,7 +163,7 @@ export async function runCreatePrIntentReviewStep({ } // Prefer actionable blocked-reason guidance over a generic stop, including unavailable lookups. const blockedNotice = resolveBlockedCreateReviewNoticeMessage(eligibility) - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice({ tone: blockedNotice ? 'destructive' : 'muted', message: blockedNotice ?? diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts index 859b1d38e4b..e1464f61918 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts @@ -1,3 +1,6 @@ +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../../../shared/source-control/ai/generation/commit-message-host-key' +import { getRuntimeGitScope } from '@/runtime/runtime-git-client-context' +import { resolveSourceControlAiForOperation } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution' import { useCallback } from 'react' import { operationGitContext } from '../listing/operation-target' import { generateRuntimeCommitMessage } from '@/runtime/runtime-git-generation-client' @@ -5,7 +8,6 @@ import { useAppStore } from '@/store' import { isCustomAgentId } from '../../../../../../shared/source-control/ai/generation/commit-message-agent-spec' import type { CreatePrIntentRunToken } from './create-pr-intent-flow' import { hasConfiguredCommitMessageGenerationDefaults } from '../ai/text-generation-defaults' -import type { SourceControlAi } from '../ai/use-ai' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' import type { SourceControlCreatePrIntentTarget } from './use-create-pr-intent-target' @@ -13,20 +15,12 @@ import type { SourceControlCreatePrIntentTarget } from './use-create-pr-intent-t /** Pin generation to the run token’s worktree so navigation cannot retarget it. */ export function useSourceControlCreatePrIntentCommitMessage({ activeRepo, - generateInFlightRef, getCreatePrIntentOperationTarget, - resolvedCommitMessageAi, - setGenerateErrors, - setGenerateInFlightByWorktree, - settings + operationStore }: { activeRepo: SourceControlWorktreeContext['activeRepo'] - generateInFlightRef: SourceControlWorktreeOperationState['generateInFlightRef'] getCreatePrIntentOperationTarget: SourceControlCreatePrIntentTarget['getCreatePrIntentOperationTarget'] - resolvedCommitMessageAi: SourceControlAi['resolvedCommitMessageAi'] - setGenerateErrors: SourceControlWorktreeOperationState['setGenerateErrors'] - setGenerateInFlightByWorktree: SourceControlWorktreeOperationState['setGenerateInFlightByWorktree'] - settings: SourceControlWorktreeContext['settings'] + operationStore: SourceControlWorktreeOperationState['operationStore'] }) { const generateCommitMessageForCreatePrIntent = useCallback( async ( @@ -36,6 +30,18 @@ export function useSourceControlCreatePrIntentCommitMessage({ message?: string reason?: 'settings' | 'failed' | 'canceled' }> => { + const target = getCreatePrIntentOperationTarget(token) + const settings = useAppStore.getState().settings + const resolvedCommitMessageAi = settings + ? resolveSourceControlAiForOperation({ + settings, + repo: activeRepo, + operation: 'commitMessage', + discoveryHostKey: getCommitMessageModelDiscoveryHostKeyForScope( + getRuntimeGitScope(target.target ?? { kind: 'local' }, target.connectionId) + ) + }) + : null if ( !hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) || resolvedCommitMessageAi?.ok !== true @@ -48,14 +54,10 @@ export function useSourceControlCreatePrIntentCommitMessage({ return { ok: false, reason: 'settings' } } } - const target = getCreatePrIntentOperationTarget(token) - if (generateInFlightRef.current[target.worktreeId]) { + if (operationStore.getState().generateInFlight) { return { ok: false, reason: 'failed' } } - - generateInFlightRef.current[target.worktreeId] = true - setGenerateInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true })) - setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + operationStore.setState({ generateInFlight: true, generateError: null }) try { const result = await generateRuntimeCommitMessage( operationGitContext(target, useAppStore.getState().settings), @@ -65,34 +67,24 @@ export function useSourceControlCreatePrIntentCommitMessage({ ) if (!result.success) { if (!result.canceled) { - setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: result.error })) + operationStore.setState({ generateError: result.error }) } return { ok: false, reason: result.canceled ? 'canceled' : 'failed' } } useAppStore.getState().recordFeatureInteraction('ai-commit-generation') - setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + operationStore.setState({ generateError: null }) return { ok: true, message: result.message } } catch (error) { - setGenerateErrors((prev) => ({ - ...prev, - [target.worktreeId]: + operationStore.setState({ + generateError: error instanceof Error ? error.message : 'Failed to generate commit message' - })) + }) return { ok: false, reason: 'failed' } } finally { - setGenerateInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false })) - generateInFlightRef.current[target.worktreeId] = false + operationStore.setState({ generateInFlight: false }) } }, - [ - activeRepo, - generateInFlightRef, - getCreatePrIntentOperationTarget, - resolvedCommitMessageAi, - setGenerateErrors, - setGenerateInFlightByWorktree, - settings - ] + [activeRepo, getCreatePrIntentOperationTarget, operationStore] ) return { generateCommitMessageForCreatePrIntent } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts index 12f4cb9e890..bc4d10832dc 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts @@ -13,27 +13,16 @@ export function useSourceControlCreatePrIntentFlows( ) { const { activeRepo, - activeRepoTarget, activeWorktreeId, beginGitBranchCompareRequest, branchName, - commitDraftsRef, - commitErrorsRef, createHostedReview, - createPrInFlightRef, - createPrIntentActiveTargetConflicts, - createPrIntentCurrentTargetRef, - createPrIntentInFlightRef, - createPrIntentRunStillOwnsWorktree, - createPrIntentRunTokenRef, effectiveBaseRef, entries, fallbackGitHubPRNumber, getCreatePrIntentOperationTarget, getHostedReviewCreationEligibility, hostedReviewCreateCopy, - isCommitting, - isCreatingPr, isExecutingBulk, isFolder, isRemoteOperationActive, @@ -42,19 +31,15 @@ export function useSourceControlCreatePrIntentFlows( linkedGitHubPR, linkedGitLabMR, linkedGiteaPR, + operationStore, provisionalHostedReviewProvider, remoteStatus, - resolvedPrCreationDefaults, - setCreatePrInFlightByWorktree, - setCreatePrIntentInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, setGitBranchCompareResult, setGitStatus, setHostedReviewCreationState, setIsExecutingBulk, setUpstreamStatus, - settings, - updateCommitDrafts, + updateCommitDraft, updateWorktreeGitIdentity, worktreePath } = foundation @@ -65,24 +50,16 @@ export function useSourceControlCreatePrIntentFlows( const createPrIntentReview = useSourceControlCreatePrIntentReview({ activeRepo, createHostedReview, - createPrInFlightRef, - createPrIntentActiveTargetConflicts, - createPrIntentCurrentTargetRef, - createPrIntentRunStillOwnsWorktree, getCreatePrIntentOperationTarget, handlePullRequestCreated, hostedReviewCreateCopy, + operationStore, prBase, prImageUploadPendingRef: reviewFlows.prImageUploadPendingRef, - prBody, - resolvedPrCreationDefaults, - setCreatePrInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, - settings + prBody }) const createPrIntentProbes = useSourceControlCreatePrIntentProbes({ activeRepo, - activeRepoTarget, beginGitBranchCompareRequest, fallbackGitHubPRNumber, getCreatePrIntentOperationTarget, @@ -103,23 +80,16 @@ export function useSourceControlCreatePrIntentFlows( activeRepo, activeWorktreeId, branchName, - commitDraftsRef, - commitErrorsRef, createHostedReviewForCreatePrIntent: createPrIntentReview.createHostedReviewForCreatePrIntent, - createPrIntentActiveTargetConflicts, - createPrIntentInFlightRef, - createPrIntentRunStillOwnsWorktree, - createPrIntentRunTokenRef, effectiveBaseRef, entries, generateCommitMessageForCreatePrIntent, getCreatePrIntentOperationTarget, handleCommit, - isCommitting, - isCreatingPr, isExecutingBulk, isGenerating, isRemoteOperationActive, + operationStore, prGenerating, provisionalHostedReviewProvider, readHostedReviewCreationEligibilityForIntent: @@ -129,10 +99,8 @@ export function useSourceControlCreatePrIntentFlows( refreshGitStatusForCreatePrIntent: createPrIntentProbes.refreshGitStatusForCreatePrIntent, remoteStatus, runRemoteAction, - setCreatePrIntentInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, setIsExecutingBulk, - updateCommitDrafts, + updateCommitDraft, worktreePath }) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-jira-title.test.tsx b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-jira-title.test.tsx index efe0e6d242f..847a7b4ec69 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-jira-title.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-jira-title.test.tsx @@ -6,12 +6,10 @@ import { useAppStore } from '@/store' import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy' import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' import { getDefaultSettings } from '../../../../../../shared/constants' -import { - getDefaultSourceControlAiSettings, - DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS -} from '../../../../../../shared/source-control/ai/settings/source-control-ai-settings' +import { getDefaultSourceControlAiSettings } from '../../../../../../shared/source-control/ai/settings/source-control-ai-settings' import type { HostedReviewCreationEligibility } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' import { useSourceControlCreatePrIntentReview } from './use-create-pr-intent-review' +import { getWorktreeOperationStore } from '../panel/worktree-operation-store' const runtime = vi.hoisted(() => ({ generate: vi.fn() })) vi.mock('@/runtime/runtime-git-generation-client', () => ({ @@ -60,8 +58,9 @@ it.each(['github', 'gitlab'] as const)( title: 'Fix login', body: 'Details' } + const operationStore = getWorktreeOperationStore(`wt-1-${provider}`, null, undefined) + operationStore.setState({ createPrIntentRun: token, createPrIntentTarget: token }) const imagePending = { current: true } - const notice = vi.fn() const create = vi .fn() .mockResolvedValue({ ok: true, number: 100, url: 'https://example.com/reviews/100' }) @@ -69,6 +68,7 @@ it.each(['github', 'gitlab'] as const)( ...getDefaultSettings('/fixture'), sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' as const } } + useAppStore.setState({ settings: null }) const { result, rerender } = renderHook< { createHostedReviewForCreatePrIntent: ReturnType< @@ -77,14 +77,10 @@ it.each(['github', 'gitlab'] as const)( }, { settings: typeof settings | null } >( - ({ settings }) => + () => useSourceControlCreatePrIntentReview({ activeRepo: { ...makeRepo(), connectionId: 'owner' }, createHostedReview: create, - createPrInFlightRef: { current: {} }, - createPrIntentActiveTargetConflicts: () => false, - createPrIntentCurrentTargetRef: { current: token }, - createPrIntentRunStillOwnsWorktree: () => true, getCreatePrIntentOperationTarget: () => ({ target: { kind: 'local' }, worktreeId: 'wt-1', @@ -92,13 +88,10 @@ it.each(['github', 'gitlab'] as const)( }), handlePullRequestCreated: vi.fn(), hostedReviewCreateCopy: localizedHostedReviewCopy(provider), + operationStore, prBase: 'main', prImageUploadPendingRef: imagePending, - prBody: '', - resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, - setCreatePrInFlightByWorktree: vi.fn(), - setCreatePrIntentNoticeForWorktree: notice, - settings + prBody: '' }), { initialProps: { settings: null } } ) @@ -106,8 +99,7 @@ it.each(['github', 'gitlab'] as const)( result.current.createHostedReviewForCreatePrIntent(token, eligibility) ).resolves.toBe(false) expect(create).not.toHaveBeenCalled() - expect(notice).toHaveBeenCalledWith( - 'wt-1', + expect(operationStore.getState().createPrIntentNotice).toEqual( expect.objectContaining({ tone: 'muted', message: expect.any(String) }) ) imagePending.current = false @@ -123,6 +115,7 @@ it.each(['github', 'gitlab'] as const)( success: true, fields: { base: 'main', title: 'alt-42 ALT-42 Better login', body: 'Generated', draft: false } }) + useAppStore.setState({ settings }) rerender({ settings }) await expect( result.current.createHostedReviewForCreatePrIntent(token, eligibility) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts index e59a95aabf1..d8494c9dd34 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target' -import { getConnectionId } from '@/lib/connection-context' +import { operationGitContext } from '../listing/operation-target' import { getRuntimeGitBranchCompare } from '@/runtime/runtime-git-diff-client' import type { HostedReviewCreationEligibility } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' import { refreshGitStatusForWorktreeStrict } from '../sync/git-status-refresh' @@ -15,7 +15,6 @@ import type { SourceControlLinkedReviews } from './use-linked-reviews' /** Pin between-step probes to the run’s worktree/host so navigation cannot retarget them. */ export function useSourceControlCreatePrIntentProbes({ activeRepo, - activeRepoTarget, beginGitBranchCompareRequest, fallbackGitHubPRNumber, getCreatePrIntentOperationTarget, @@ -33,7 +32,6 @@ export function useSourceControlCreatePrIntentProbes({ updateWorktreeGitIdentity }: { activeRepo: SourceControlWorktreeContext['activeRepo'] - activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] beginGitBranchCompareRequest: SourceControlStoreActions['beginGitBranchCompareRequest'] fallbackGitHubPRNumber: SourceControlLinkedReviews['fallbackGitHubPRNumber'] getCreatePrIntentOperationTarget: SourceControlCreatePrIntentTarget['getCreatePrIntentOperationTarget'] @@ -58,19 +56,15 @@ export function useSourceControlCreatePrIntentProbes({ } const requestKey = `${token.worktreeId}:${baseRef}:${Date.now()}:create-pr-intent` beginGitBranchCompareRequest(token.worktreeId, requestKey, baseRef) + // The pinned copy answers, so a same-id twin on another host is never compared. const result = await getRuntimeGitBranchCompare( - { - target: requireGitOwnerTarget(activeRepoTarget), - worktreeId: token.worktreeId, - worktreePath: token.worktreePath, - connectionId: getConnectionId(token.worktreeId) ?? undefined - }, + operationGitContext(getCreatePrIntentOperationTarget(token)), baseRef ) setGitBranchCompareResult(token.worktreeId, requestKey, result) return result.summary.status === 'ready' ? (result.summary.commitsAhead ?? 0) : undefined }, - [activeRepoTarget, beginGitBranchCompareRequest, setGitBranchCompareResult] + [beginGitBranchCompareRequest, getCreatePrIntentOperationTarget, setGitBranchCompareResult] ) const readHostedReviewCreationEligibilityForIntent = useCallback( diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts index 50908eebf9c..414f24cdcaf 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts @@ -1,3 +1,4 @@ +import { resolveSourceControlAiPrCreationDefaults } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy' import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' import { getWorkspaceJiraIssueKey, @@ -28,26 +29,23 @@ import { type CreatePrIntentRunToken } from './create-pr-intent-flow' import { hasConfiguredSourceControlTextGenerationDefaults } from '../ai/text-generation-defaults' +import { + createPrIntentRunIsCurrent, + setCreatePrIntentNotice +} from '../panel/worktree-operation-store' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' /** Resolve unattended review fields without a composer; reveal only while the run owns the foreground worktree. */ export function useSourceControlCreatePrIntentReview({ activeRepo, createHostedReview, - createPrInFlightRef, - createPrIntentActiveTargetConflicts, - createPrIntentCurrentTargetRef, - createPrIntentRunStillOwnsWorktree, getCreatePrIntentOperationTarget, handlePullRequestCreated, hostedReviewCreateCopy, + operationStore, prBase, prImageUploadPendingRef, - prBody, - resolvedPrCreationDefaults, - setCreatePrInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, - settings + prBody }: CreatePrIntentReviewOptions) { const createHostedReviewForCreatePrIntent = useCallback( async ( @@ -58,7 +56,7 @@ export function useSourceControlCreatePrIntentReview({ if (!prImageUploadPendingRef?.current) { return false } - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'muted', message: translate( 'github.waitForImageUploads', @@ -82,7 +80,7 @@ export function useSourceControlCreatePrIntentReview({ composerBaseRef: prBase }).trim() if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(token.branch).toLowerCase()) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.ae743199cd', @@ -93,6 +91,10 @@ export function useSourceControlCreatePrIntentReview({ return false } + const currentDefaults = resolveSourceControlAiPrCreationDefaults({ + settings: useAppStore.getState().settings ?? {}, + repo: activeRepo + }) const jiraIssueKey = eligibility.provider === 'github' || eligibility.provider === 'gitlab' ? getWorkspaceJiraIssueKey( @@ -106,18 +108,18 @@ export function useSourceControlCreatePrIntentReview({ title: resolveCreateReviewSeedText({ branch: token.branch, eligibility, jiraIssueKey }) .title, body: eligibility.body ?? prBody, - draft: resolvedPrCreationDefaults.draft + draft: currentDefaults.draft } if ( shouldGenerateHostedReviewDetailsForIntent(eligibility) && hasConfiguredSourceControlTextGenerationDefaults({ actionId: 'pullRequest', - settings, + settings: useAppStore.getState().settings, repo: activeRepo }) ) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.createPrIntentGeneratingDetails', @@ -131,11 +133,11 @@ export function useSourceControlCreatePrIntentReview({ { ...fields, provider: eligibility.provider, - useTemplate: resolvedPrCreationDefaults.useTemplate + useTemplate: currentDefaults.useTemplate } ) if (generated.branchChangedByPreparation) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.createPrIntentBranchChangedDuringDetails', @@ -146,7 +148,7 @@ export function useSourceControlCreatePrIntentReview({ } const resolved = resolveCreatePrIntentGeneratedReviewFields(fields, generated) if (!resolved.ok) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: resolved.error }) @@ -155,7 +157,7 @@ export function useSourceControlCreatePrIntentReview({ fields = resolved.fields } catch (error) { console.warn('[SourceControl] Create PR intent detail generation failed', error) - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: error instanceof Error @@ -169,19 +171,15 @@ export function useSourceControlCreatePrIntentReview({ } } - if ( - imagesPending() || - !createPrIntentRunStillOwnsWorktree(token) || - createPrIntentActiveTargetConflicts(token) - ) { + if (imagesPending() || !createPrIntentRunIsCurrent(operationStore, token)) { return false } const createPrIntentIsForeground = (): boolean => - createPrIntentRunTokenMatches(token, createPrIntentCurrentTargetRef.current) + createPrIntentRunTokenMatches(token, operationStore.getState().createPrIntentTarget) const title = prefixJiraReviewTitle(fields.title, jiraIssueKey).trim() if (!title) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5', @@ -192,15 +190,14 @@ export function useSourceControlCreatePrIntentReview({ return false } - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.createPrIntentCreatingReview', 'Creating review…' ) }) - createPrInFlightRef.current[token.worktreeId] = true - setCreatePrInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: true })) + operationStore.setState({ isCreatingPr: true }) try { const result = await createHostedReview(activeRepo.path, { repoId: activeRepo.id, @@ -211,7 +208,7 @@ export function useSourceControlCreatePrIntentReview({ body: fields.body, draft: fields.draft, worktreePath: token.worktreePath, - useTemplate: resolvedPrCreationDefaults.useTemplate + useTemplate: currentDefaults.useTemplate }) if (result.ok) { @@ -230,10 +227,10 @@ export function useSourceControlCreatePrIntentReview({ openChecks } ) - if (openChecks && resolvedPrCreationDefaults.openAfterCreate) { + if (openChecks && currentDefaults.openAfterCreate) { openAppHttpLink(result.url, { worktreeId: token.worktreeId }) } - setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + setCreatePrIntentNotice(operationStore, null) return true } @@ -253,46 +250,36 @@ export function useSourceControlCreatePrIntentReview({ openChecks } ) - setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + setCreatePrIntentNotice(operationStore, null) return true } - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: localizedCreateFailure(result, eligibility.provider) }) return false } catch (error) { const message = localizedCreateException(error, hostedReviewCreateCopy.reviewLabel) - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message }) return false } finally { - createPrInFlightRef.current[token.worktreeId] = false - setCreatePrInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: false })) + operationStore.setState({ isCreatingPr: false }) } }, [ activeRepo, createHostedReview, - createPrInFlightRef, - createPrIntentActiveTargetConflicts, - createPrIntentCurrentTargetRef, - createPrIntentRunStillOwnsWorktree, getCreatePrIntentOperationTarget, handlePullRequestCreated, hostedReviewCreateCopy.reviewLabel, + operationStore, prBase, prImageUploadPendingRef, - prBody, - resolvedPrCreationDefaults.draft, - resolvedPrCreationDefaults.openAfterCreate, - resolvedPrCreationDefaults.useTemplate, - setCreatePrInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, - settings + prBody ] ) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-run.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-run.ts index 0318ab80636..fcab4460e43 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-run.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-run.ts @@ -1,3 +1,4 @@ +import { useSourceControlScope } from '../panel/worktree-scope' import { useCallback } from 'react' import { translate } from '@/i18n/i18n' import { createCreatePrIntentRunToken } from './create-pr-intent-flow' @@ -6,6 +7,11 @@ import type { SourceControlCommitAction } from '../commit/use-commit-action' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlFileListing } from '../listing/use-file-listing' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' +import { + createPrIntentRunIsCurrent, + setCreatePrIntentNotice +} from '../panel/worktree-operation-store' +import type { CreatePrIntentNotice } from '../commit/commit-area-types' import type { SourceControlRemoteActionRunner } from '../sync/use-remote-action-runner' import { runCreatePrIntentBranchPrep } from './create-pr-intent-branch-prep' import { runCreatePrIntentReviewStep } from './create-pr-intent-review-step' @@ -20,23 +26,16 @@ export function useSourceControlCreatePrIntentRun({ activeRepo, activeWorktreeId, branchName, - commitDraftsRef, - commitErrorsRef, createHostedReviewForCreatePrIntent, - createPrIntentActiveTargetConflicts, - createPrIntentInFlightRef, - createPrIntentRunStillOwnsWorktree, - createPrIntentRunTokenRef, effectiveBaseRef, entries, generateCommitMessageForCreatePrIntent, getCreatePrIntentOperationTarget, handleCommit, - isCommitting, - isCreatingPr, isExecutingBulk, isGenerating, isRemoteOperationActive, + operationStore, prGenerating, provisionalHostedReviewProvider, readHostedReviewCreationEligibilityForIntent, @@ -44,32 +43,23 @@ export function useSourceControlCreatePrIntentRun({ refreshGitStatusForCreatePrIntent, remoteStatus, runRemoteAction, - setCreatePrIntentInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, setIsExecutingBulk, - updateCommitDrafts, + updateCommitDraft, worktreePath }: { activeRepo: SourceControlWorktreeContext['activeRepo'] activeWorktreeId: string | null branchName: string - commitDraftsRef: SourceControlWorktreeOperationState['commitDraftsRef'] - commitErrorsRef: SourceControlWorktreeOperationState['commitErrorsRef'] createHostedReviewForCreatePrIntent: SourceControlCreatePrIntentReview['createHostedReviewForCreatePrIntent'] - createPrIntentActiveTargetConflicts: SourceControlWorktreeOperationState['createPrIntentActiveTargetConflicts'] - createPrIntentInFlightRef: SourceControlWorktreeOperationState['createPrIntentInFlightRef'] - createPrIntentRunStillOwnsWorktree: SourceControlWorktreeOperationState['createPrIntentRunStillOwnsWorktree'] - createPrIntentRunTokenRef: SourceControlWorktreeOperationState['createPrIntentRunTokenRef'] effectiveBaseRef: string | null entries: SourceControlWorktreeContext['entries'] generateCommitMessageForCreatePrIntent: SourceControlCreatePrIntentCommitMessage['generateCommitMessageForCreatePrIntent'] getCreatePrIntentOperationTarget: SourceControlCreatePrIntentTarget['getCreatePrIntentOperationTarget'] handleCommit: SourceControlCommitAction['handleCommit'] - isCommitting: boolean - isCreatingPr: boolean isExecutingBulk: SourceControlFileListing['isExecutingBulk'] isGenerating: boolean isRemoteOperationActive: boolean + operationStore: SourceControlWorktreeOperationState['operationStore'] prGenerating: boolean provisionalHostedReviewProvider: SourceControlHostedReviewProviderHint['provisionalHostedReviewProvider'] readHostedReviewCreationEligibilityForIntent: SourceControlCreatePrIntentProbes['readHostedReviewCreationEligibilityForIntent'] @@ -77,12 +67,11 @@ export function useSourceControlCreatePrIntentRun({ refreshGitStatusForCreatePrIntent: SourceControlCreatePrIntentProbes['refreshGitStatusForCreatePrIntent'] remoteStatus: SourceControlWorktreeContext['remoteStatus'] runRemoteAction: SourceControlRemoteActionRunner['runRemoteAction'] - setCreatePrIntentInFlightByWorktree: SourceControlWorktreeOperationState['setCreatePrIntentInFlightByWorktree'] - setCreatePrIntentNoticeForWorktree: SourceControlWorktreeOperationState['setCreatePrIntentNoticeForWorktree'] setIsExecutingBulk: SourceControlFileListing['setIsExecutingBulk'] - updateCommitDrafts: SourceControlWorktreeOperationState['updateCommitDrafts'] + updateCommitDraft: SourceControlWorktreeOperationState['updateCommitDraft'] worktreePath: string | null }): { runCreatePrIntent: () => Promise } { + const scope = useSourceControlScope() const runCreatePrIntent = useCallback(async (): Promise => { if ( !activeRepo || @@ -90,15 +79,16 @@ export function useSourceControlCreatePrIntentRun({ !worktreePath || !branchName || isExecutingBulk || - isCommitting || isGenerating || isRemoteOperationActive || - prGenerating || - isCreatingPr || - createPrIntentInFlightRef.current[activeWorktreeId] + prGenerating ) { return } + const { createPrIntentRun, isCommitting, isCreatingPr } = operationStore.getState() + if (createPrIntentRun || isCommitting || isCreatingPr) { + return + } const token = createCreatePrIntentRunToken({ repoId: activeRepo.id, @@ -110,7 +100,7 @@ export function useSourceControlCreatePrIntentRun({ // Pin the selected base across asynchronous commit/push steps. baseRef: effectiveBaseRef ?? null, // The run outlives navigation; its git answers stay on the copy it started from. - pinnedCopy: resolveWorktreeGitCopyNow(activeWorktreeId) + pinnedCopy: resolveWorktreeGitCopyNow(activeWorktreeId, scope?.executionHostId) }) const operationTarget = getCreatePrIntentOperationTarget(token) const snapshot = createCreatePrIntentRunSnapshot({ @@ -118,15 +108,14 @@ export function useSourceControlCreatePrIntentRun({ initialUpstreamStatus: remoteStatus, operationTarget, refreshGitStatusForCreatePrIntent, - runIsCurrent: () => - createPrIntentRunStillOwnsWorktree(token) && !createPrIntentActiveTargetConflicts(token), + runIsCurrent: () => createPrIntentRunIsCurrent(operationStore, token), setIsExecutingBulk, token }) - createPrIntentRunTokenRef.current[token.worktreeId] = token - createPrIntentInFlightRef.current[token.worktreeId] = true - setCreatePrIntentInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: true })) - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + const setNotice = (notice: CreatePrIntentNotice | null): void => + setCreatePrIntentNotice(operationStore, notice) + operationStore.setState({ createPrIntentRun: token }) + setNotice({ tone: 'muted', message: translate( 'auto.components.right.sidebar.SourceControl.d37e68f61d', @@ -136,16 +125,15 @@ export function useSourceControlCreatePrIntentRun({ try { const prepared = await runCreatePrIntentBranchPrep({ - commitDraftsRef, - commitErrorsRef, generateCommitMessageForCreatePrIntent, handleCommit, + operationStore, operationTarget, runRemoteAction, - setCreatePrIntentNoticeForWorktree, + setCreatePrIntentNotice: setNotice, snapshot, token, - updateCommitDrafts + updateCommitDraft }) if (!prepared) { return @@ -156,14 +144,14 @@ export function useSourceControlCreatePrIntentRun({ readHostedReviewCreationEligibilityForIntent, refreshBranchCompareForCreatePrIntent, runRemoteAction, - setCreatePrIntentNoticeForWorktree, + setCreatePrIntentNotice: setNotice, snapshot, token }) } catch (error) { console.warn('[SourceControl] Create PR intent failed', error) if (!snapshot.abortIfStale()) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, { + setNotice({ tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.d7492cafce', @@ -172,39 +160,27 @@ export function useSourceControlCreatePrIntentRun({ }) } } finally { - if (createPrIntentRunTokenRef.current[token.worktreeId] === token) { - createPrIntentInFlightRef.current[token.worktreeId] = false - createPrIntentRunTokenRef.current[token.worktreeId] = null - if (snapshot.wasAbortedByStaleTarget()) { - setCreatePrIntentNoticeForWorktree(token.worktreeId, null) - } - setCreatePrIntentInFlightByWorktree((prev) => ({ - ...prev, - [token.worktreeId]: false - })) + if (operationStore.getState().createPrIntentRun === token) { + operationStore.setState({ + createPrIntentRun: null, + ...(snapshot.wasAbortedByStaleTarget() ? { createPrIntentNotice: null } : {}) + }) } } }, [ activeRepo, activeWorktreeId, branchName, - commitDraftsRef, - commitErrorsRef, - createPrIntentActiveTargetConflicts, - createPrIntentInFlightRef, - createPrIntentRunStillOwnsWorktree, - createPrIntentRunTokenRef, createHostedReviewForCreatePrIntent, effectiveBaseRef, entries, generateCommitMessageForCreatePrIntent, getCreatePrIntentOperationTarget, handleCommit, - isCommitting, - isCreatingPr, isExecutingBulk, isGenerating, isRemoteOperationActive, + operationStore, prGenerating, readHostedReviewCreationEligibilityForIntent, refreshGitStatusForCreatePrIntent, @@ -212,10 +188,9 @@ export function useSourceControlCreatePrIntentRun({ provisionalHostedReviewProvider, remoteStatus, runRemoteAction, - setCreatePrIntentInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, + scope?.executionHostId, setIsExecutingBulk, - updateCommitDrafts, + updateCommitDraft, worktreePath ]) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts index d0bd7e61a7b..685264368fb 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' import { worktreeCopyPushTarget } from '@/lib/worktree-git-copy' import type { CreatePrIntentRunToken } from './create-pr-intent-flow' @@ -10,33 +10,34 @@ export function useSourceControlCreatePrIntentTarget({ activeRepoId, activeWorktreeId, branchName, - createPrIntentCurrentTargetRef, effectiveBaseRef, + operationStore, worktreePath }: { activeRepoId: string | null activeWorktreeId: string | null branchName: string - createPrIntentCurrentTargetRef: SourceControlWorktreeOperationState['createPrIntentCurrentTargetRef'] effectiveBaseRef: string | null + operationStore: SourceControlWorktreeOperationState['operationStore'] worktreePath: string | null }) { + const previousOperationStoreRef = useRef(operationStore) useEffect(() => { - createPrIntentCurrentTargetRef.current = { - repoId: activeRepoId ?? null, - worktreeId: activeWorktreeId ?? null, - worktreePath, - branch: branchName, - baseRef: effectiveBaseRef ?? null + // Navigating to another owner backgrounds the previous run; unmounting alone does not. + if (previousOperationStoreRef.current !== operationStore) { + previousOperationStoreRef.current.setState({ createPrIntentTarget: {} }) + previousOperationStoreRef.current = operationStore } - }, [ - activeRepoId, - activeWorktreeId, - branchName, - createPrIntentCurrentTargetRef, - effectiveBaseRef, - worktreePath - ]) + operationStore.setState({ + createPrIntentTarget: { + repoId: activeRepoId ?? null, + worktreeId: activeWorktreeId ?? null, + worktreePath, + branch: branchName, + baseRef: effectiveBaseRef ?? null + } + }) + }, [activeRepoId, activeWorktreeId, branchName, effectiveBaseRef, operationStore, worktreePath]) const getCreatePrIntentOperationTarget = useCallback( (token: CreatePrIntentRunToken): SourceControlOperationTarget => diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts index 5f45b527019..5cc57fd0d8a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts @@ -1,3 +1,5 @@ +import { resolveSourceControlAiEnabled } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy' +import { useAppStore } from '@/store' import { useCallback, useEffect } from 'react' import { shouldHydratePullRequestGenerationResult } from '@/store/slices/pull-request-generation' import { hasConfiguredSourceControlTextGenerationDefaults } from '../ai/text-generation-defaults' @@ -5,6 +7,7 @@ import type { SourceControlAi } from '../ai/use-ai' import { useCreatePullRequestDialogFields } from '../../hosted-review/creation/useCreatePullRequestDialogFields' import { useHostedReviewStackParent } from '../../hosted-review/provider-actions/useHostedReviewStackParent' import type { SourceControlStoreActions } from '../listing/use-store-actions' +import { useSourceControlSectionActive } from '../panel/worktree-scope' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlHostedReviewState } from './use-hosted-review-state' import type { SourceControlPullRequestGeneration } from './use-pull-request-generation' @@ -58,6 +61,7 @@ export function useSourceControlCreateReviewComposer({ updatePullRequestGenerationRecord: SourceControlStoreActions['updatePullRequestGenerationRecord'] worktreePath: string | null }) { + const sectionActive = useSourceControlSectionActive() const { aiGenerationEnabled: prAiGenerationEnabled, base: prBase, @@ -123,7 +127,7 @@ export function useSourceControlCreateReviewComposer({ } }) const stackParentReview = useHostedReviewStackParent({ - enabled: hostedReviewCreateProvider === 'github' && prStackedCreationSupported, + enabled: sectionActive && hostedReviewCreateProvider === 'github' && prStackedCreationSupported, repoPath: activeRepo?.path ?? '', repoId: activeRepo?.id ?? null, base: prBase, @@ -134,13 +138,17 @@ export function useSourceControlCreateReviewComposer({ }) const handleGeneratePullRequestFieldsClick = useCallback((): void => { - if (!sourceControlAiActionsVisible) { + const currentSettings = useAppStore.getState().settings + if ( + !currentSettings || + !resolveSourceControlAiEnabled({ settings: currentSettings, repo: activeRepo }) + ) { return } if ( hasConfiguredSourceControlTextGenerationDefaults({ actionId: 'pullRequest', - settings, + settings: useAppStore.getState().settings, repo: activeRepo ?? null }) ) { @@ -148,13 +156,7 @@ export function useSourceControlCreateReviewComposer({ return } openPullRequestGenerationDialog() - }, [ - activeRepo, - handleGeneratePullRequestFields, - openPullRequestGenerationDialog, - settings, - sourceControlAiActionsVisible - ]) + }, [activeRepo, handleGeneratePullRequestFields, openPullRequestGenerationDialog]) useEffect(() => { // Hydrate after the fields hook seeds defaults on remount, or those defaults overwrite generation. diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts index e658e76fe2e..4090e3b856f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts @@ -1,3 +1,4 @@ +import { useSourceControlScope } from '../panel/worktree-scope' import { useCallback } from 'react' import { toast } from 'sonner' import { @@ -49,6 +50,8 @@ export function useSourceControlHostedReviewCreated({ setRightSidebarTab: SourceControlStoreActions['setRightSidebarTab'] updateWorktreeMeta: SourceControlStoreActions['updateWorktreeMeta'] }) { + const scope = useSourceControlScope() + const checksTab = scope ? 'pr-checks' : 'checks' const handlePullRequestCreated = useCallback( async (result: CreatedHostedReview, context?: HostedReviewCreatedContext): Promise => { const repoPath = context?.repoPath ?? activeRepo?.path @@ -64,7 +67,7 @@ export function useSourceControlHostedReviewCreated({ ) if (openChecks) { setRightSidebarOpen(true) - setRightSidebarTab('checks') + setRightSidebarTab(checksTab) } try { const createdLink = resolveCreatedHostedReviewLink(result.provider, result.number) @@ -131,6 +134,7 @@ export function useSourceControlHostedReviewCreated({ } }, [ + checksTab, activeRepo, activeWorktreeId, branchName, @@ -150,8 +154,8 @@ export function useSourceControlHostedReviewCreated({ const openHostedReviewInChecks = useCallback(() => { setRightSidebarOpen(true) - setRightSidebarTab('checks') - }, [setRightSidebarOpen, setRightSidebarTab]) + setRightSidebarTab(checksTab) + }, [checksTab, setRightSidebarOpen, setRightSidebarTab]) const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise => { // AI detail generation may rebase; refresh moved HEAD before the user submits. diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts index c8d0bbe880e..3668622f306 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts @@ -1,3 +1,5 @@ +import { useAppStore } from '@/store' +import { resolveSourceControlAiPrCreationDefaults } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy' import { useCallback } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -10,10 +12,10 @@ import { localizedCreateFailure, localizedCreateException } from '../../hosted-review/provider-actions/hosted-review-create-failure-copy' -import type { SourceControlAi } from '../ai/use-ai' import type { SourceControlStoreActions } from '../listing/use-store-actions' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' +import { setCreatePrIntentNotice } from '../panel/worktree-operation-store' import type { SourceControlCreateReviewComposer } from './use-create-review-composer' import type { SourceControlHostedReviewCreated } from './use-hosted-review-created' import type { HostedReviewCreatedContext } from './hosted-review-creation-state' @@ -31,13 +33,13 @@ export function useSourceControlHostedReviewCreation({ activeWorktreeId, branchName, createHostedReview, - createPrInFlightRef, createStackedHostedReview, handleGeneratePullRequestFields, handlePullRequestCreated, hostedReviewCreateCopy, hostedReviewCreateProvider, hostedReviewCreation, + operationStore, prAiGenerationEnabled, prBase, prImageUploadPendingRef, @@ -48,10 +50,6 @@ export function useSourceControlHostedReviewCreation({ prGenerating, prTitle, pullRequestFieldsInitialized, - resolvedPrCreationDefaults, - setCreatePrInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, - settings, worktreePath }: Pick< SourceControlCreateReviewComposer, @@ -70,18 +68,14 @@ export function useSourceControlHostedReviewCreation({ activeWorktreeId: string | null branchName: string createHostedReview: SourceControlStoreActions['createHostedReview'] - createPrInFlightRef: SourceControlWorktreeOperationState['createPrInFlightRef'] createStackedHostedReview: SourceControlStoreActions['createStackedHostedReview'] handlePullRequestCreated: SourceControlHostedReviewCreated['handlePullRequestCreated'] hostedReviewCreateCopy: SourceControlHostedReviewState['hostedReviewCreateCopy'] hostedReviewCreateProvider: SourceControlHostedReviewState['hostedReviewCreateProvider'] hostedReviewCreation: SourceControlHostedReviewState['hostedReviewCreation'] + operationStore: SourceControlWorktreeOperationState['operationStore'] prImageUploadPendingRef?: React.RefObject prGenerating: boolean - resolvedPrCreationDefaults: SourceControlAi['resolvedPrCreationDefaults'] - setCreatePrInFlightByWorktree: SourceControlWorktreeOperationState['setCreatePrInFlightByWorktree'] - setCreatePrIntentNoticeForWorktree: SourceControlWorktreeOperationState['setCreatePrIntentNoticeForWorktree'] - settings: SourceControlWorktreeContext['settings'] worktreePath: string | null }) { const createPullRequest = useCallback( @@ -101,7 +95,7 @@ export function useSourceControlHostedReviewCreation({ // Blocked clicks show inline prerequisite guidance. const message = resolveBlockedCreateReviewNoticeMessage(hostedReviewCreation) if (message) { - setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message }) @@ -109,12 +103,16 @@ export function useSourceControlHostedReviewCreation({ return } + const currentDefaults = resolveSourceControlAiPrCreationDefaults({ + settings: useAppStore.getState().settings ?? {}, + repo: activeRepo + }) const fields = generated ?? { base: prBase, title: prTitle, body: prBody, draft: prDraft } const base = stripBaseRef(fields.base).trim() const title = fields.title.trim() if (!title) { - setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5', @@ -126,7 +124,7 @@ export function useSourceControlHostedReviewCreation({ } if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branchName).toLowerCase()) { - setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: translate( 'auto.components.right.sidebar.SourceControl.ae743199cd', @@ -137,7 +135,7 @@ export function useSourceControlHostedReviewCreation({ return } - setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) + setCreatePrIntentNotice(operationStore, null) const createdContext = (): HostedReviewCreatedContext => ({ repoPath: activeRepo.path, repoId: activeRepo.id, @@ -155,14 +153,14 @@ export function useSourceControlHostedReviewCreation({ body: fields.body, draft: fields.draft, worktreePath, - useTemplate: resolvedPrCreationDefaults.useTemplate + useTemplate: currentDefaults.useTemplate } const result = stacked ? await createStackedHostedReview(activeRepo.path, createInput) : await createHostedReview(activeRepo.path, createInput) if (result.ok) { - setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) + setCreatePrIntentNotice(operationStore, null) const context = createdContext() await handlePullRequestCreated( { @@ -172,7 +170,7 @@ export function useSourceControlHostedReviewCreation({ }, context ) - if (context.openChecks && resolvedPrCreationDefaults.openAfterCreate) { + if (context.openChecks && currentDefaults.openAfterCreate) { openAppHttpLink(result.url, { worktreeId: activeWorktreeId }) } return @@ -205,7 +203,7 @@ export function useSourceControlHostedReviewCreation({ } ) if (number) { - setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) + setCreatePrIntentNotice(operationStore, null) await handlePullRequestCreated( { provider: hostedReviewCreateProvider, @@ -233,12 +231,12 @@ export function useSourceControlHostedReviewCreation({ } } - setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: localizedCreateFailure(result, hostedReviewCreateProvider) }) } catch (error) { - setCreatePrIntentNoticeForWorktree(activeWorktreeId, { + setCreatePrIntentNotice(operationStore, { tone: 'destructive', message: localizedCreateException(error, hostedReviewCreateCopy.reviewLabel) }) @@ -262,9 +260,7 @@ export function useSourceControlHostedReviewCreation({ prDraft, prGenerating, prTitle, - resolvedPrCreationDefaults.openAfterCreate, - resolvedPrCreationDefaults.useTemplate, - setCreatePrIntentNoticeForWorktree, + operationStore, worktreePath ] ) @@ -280,32 +276,23 @@ export function useSourceControlHostedReviewCreation({ fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders, generatePullRequestFields: handleGeneratePullRequestFields, generationKey: activePullRequestGenerationKey, - repo: activeRepo, - settings + repo: activeRepo }) const handleCreatePullRequest = useCallback( async (stacked = false): Promise => { - const worktreeId = activeWorktreeId - if (!worktreeId || createPrInFlightRef.current[worktreeId]) { + if (!activeWorktreeId || operationStore.getState().isCreatingPr) { return } // Hold the click through creation so eligibility refreshes cannot close the composer during generation. - createPrInFlightRef.current[worktreeId] = true - setCreatePrInFlightByWorktree((prev) => ({ ...prev, [worktreeId]: true })) + operationStore.setState({ isCreatingPr: true }) try { await generateThenCreatePullRequest(stacked) } finally { - createPrInFlightRef.current[worktreeId] = false - setCreatePrInFlightByWorktree((prev) => ({ ...prev, [worktreeId]: false })) + operationStore.setState({ isCreatingPr: false }) } }, - [ - activeWorktreeId, - createPrInFlightRef, - generateThenCreatePullRequest, - setCreatePrInFlightByWorktree - ] + [activeWorktreeId, generateThenCreatePullRequest, operationStore] ) return { handleCreatePullRequest } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx index f145d2ba8e8..1b8af05dd2a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx @@ -6,7 +6,6 @@ import type * as RuntimeGitGenerationClient from '@/runtime/runtime-git-generati import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context' import { useAppStore } from '@/store' import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' -import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS } from '../../../../../../shared/source-control/ai/settings/source-control-ai-settings' const runtime = vi.hoisted(() => ({ cancel: vi.fn().mockResolvedValue(undefined), @@ -63,9 +62,7 @@ describe('useSourceControlPullRequestGeneration outcome', () => { allocatePullRequestGenerationRequestId: () => 11, branchName: 'user/alt-42-fix-login', hostedReviewCreateProvider: 'gitlab', - prGenerationRecords: {}, refreshGitStatusAfterPullRequestGeneration: vi.fn(), - resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, setPullRequestGenerationRecord: state.setPullRequestGenerationRecord, updatePullRequestGenerationRecord: state.updatePullRequestGenerationRecord, worktreePath: '/repo/wt' diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts index cbb485b31a9..622dfe4e4d9 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts @@ -1,7 +1,12 @@ +import { resolveSourceControlAiPrCreationDefaults } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy' +import { + useSourceControlState, + sourceControlConnectionId, + useSourceControlScope +} from '../panel/worktree-scope' import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' import { getWorkspaceJiraIssueKey } from '../../../../../../shared/jira/jira-review-title' import { useCallback } from 'react' -import { getConnectionId } from '@/lib/connection-context' import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy' import { cancelRuntimeGeneratePullRequestFields, @@ -25,7 +30,6 @@ import { } from '@/store/slices/pull-request-generation' import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit' import type { HostedReviewProvider } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' -import type { SourceControlAi } from '../ai/use-ai' import { normalizeHostedReviewBaseRef as stripBaseRef } from '../../../../../../shared/source-control/hosted-review/hosted-review-refs' import type { SourceControlStoreActions } from '../listing/use-store-actions' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' @@ -39,9 +43,7 @@ export function useSourceControlPullRequestGeneration({ allocatePullRequestGenerationRequestId, branchName, hostedReviewCreateProvider, - prGenerationRecords, refreshGitStatusAfterPullRequestGeneration, - resolvedPrCreationDefaults, setPullRequestGenerationRecord, updatePullRequestGenerationRecord, worktreePath @@ -52,22 +54,24 @@ export function useSourceControlPullRequestGeneration({ allocatePullRequestGenerationRequestId: SourceControlStoreActions['allocatePullRequestGenerationRequestId'] branchName: string hostedReviewCreateProvider: HostedReviewProvider - prGenerationRecords: SourceControlStoreActions['prGenerationRecords'] refreshGitStatusAfterPullRequestGeneration: SourceControlStatusRefresh['refreshGitStatusAfterPullRequestGeneration'] - resolvedPrCreationDefaults: SourceControlAi['resolvedPrCreationDefaults'] setPullRequestGenerationRecord: SourceControlStoreActions['setPullRequestGenerationRecord'] updatePullRequestGenerationRecord: SourceControlStoreActions['updatePullRequestGenerationRecord'] worktreePath: string | null }) { + const scope = useSourceControlScope() const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ worktreeId: activeWorktreeId, worktreePath, repoId: activeRepo?.id, branch: branchName }) - const activePullRequestGenerationRecordCandidate = activePullRequestGenerationKey - ? (prGenerationRecords[activePullRequestGenerationKey] ?? null) - : null + // Select only this owner's record so another worktree's generation cannot re-render this panel. + const activePullRequestGenerationRecordCandidate = useSourceControlState((s) => + activePullRequestGenerationKey + ? (s.pullRequestGenerationRecords[activePullRequestGenerationKey] ?? null) + : null + ) const activePullRequestGenerationRecord = activePullRequestGenerationRecordCandidate && activePullRequestGenerationRecordCandidate.context.repoId === activeRepo?.id && @@ -101,13 +105,19 @@ export function useSourceControlPullRequestGeneration({ ) { return undefined } + const currentDefaults = resolveSourceControlAiPrCreationDefaults({ + settings: useAppStore.getState().settings ?? {}, + repo: activeRepo + }) const requestId = allocatePullRequestGenerationRequestId() // Why one resolution: the result must land on this copy, not a same-path twin. - const copy = activeWorktreeId ? resolveWorktreeGitCopyNow(activeWorktreeId) : null + const copy = activeWorktreeId + ? resolveWorktreeGitCopyNow(activeWorktreeId, scope?.executionHostId) + : null const context: PullRequestGenerationContext = { worktreeId: activeWorktreeId, worktreePath, - connectionId: copy ? copy.connectionId : (getConnectionId(activeWorktreeId) ?? undefined), + connectionId: copy ? copy.connectionId : sourceControlConnectionId(scope, activeWorktreeId), ownerHostId: copy?.hostId ?? null, jiraIssueKey: (hostedReviewCreateProvider === 'github' || hostedReviewCreateProvider === 'gitlab') && @@ -146,7 +156,7 @@ export function useSourceControlPullRequestGeneration({ body: seed.body, draft: seed.draft, provider: hostedReviewCreateProvider, - useTemplate: resolvedPrCreationDefaults.useTemplate + useTemplate: currentDefaults.useTemplate }, overrides ) @@ -193,6 +203,7 @@ export function useSourceControlPullRequestGeneration({ return await settlePullRequestGenerationRequest(generationKey, requestId, request) }, [ + scope, activePullRequestGenerationKey, activeRepo, activeRepoTarget, @@ -201,7 +212,6 @@ export function useSourceControlPullRequestGeneration({ branchName, hostedReviewCreateProvider, refreshGitStatusAfterPullRequestGeneration, - resolvedPrCreationDefaults.useTemplate, setPullRequestGenerationRecord, updatePullRequestGenerationRecord, worktreePath @@ -212,7 +222,8 @@ export function useSourceControlPullRequestGeneration({ if (!activePullRequestGenerationKey) { return } - const record = prGenerationRecords[activePullRequestGenerationKey] + const record = + useAppStore.getState().pullRequestGenerationRecords[activePullRequestGenerationKey] if (!record || record.status !== 'running') { return } @@ -242,7 +253,7 @@ export function useSourceControlPullRequestGeneration({ } }) }) - }, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord]) + }, [activePullRequestGenerationKey, updatePullRequestGenerationRecord]) const handlePullRequestGenerationSeedRestored = useCallback((): void => { if (!activePullRequestGenerationKey || !activePullRequestGenerationRecord) { diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts index 4d74fde07a4..f1c92f9000f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts @@ -20,13 +20,13 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat activeWorktree, activeWorktreeId, branchName, - createPrIntentCurrentTargetRef, ensureHostedReviewPushTarget, hostedReviewCacheKey, hostedReviewEntry, hostedReviewEntryData, isBranchVisible, isFolder, + operationStore, remoteStatus, settings, worktreePath @@ -78,8 +78,8 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat activeRepoId, activeWorktreeId, branchName, - createPrIntentCurrentTargetRef, effectiveBaseRef, + operationStore, worktreePath }) const linkedReviews = useSourceControlLinkedReviews({ diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts index 564a47178eb..45afad8be28 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts @@ -18,7 +18,6 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda allocatePullRequestGenerationRequestId, branchName, createHostedReview, - createPrInFlightRef, createStackedHostedReview, effectiveBaseRef, fallbackGitHubPRNumber, @@ -40,14 +39,12 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda linkedGitLabMR, linkedGiteaPR, openPullRequestGenerationDialog, - prGenerationRecords, + operationStore, provisionalHostedReviewProvider, refreshActiveGitStatusAfterMutation, refreshGitStatusAfterPullRequestGeneration, remoteStatus, resolvedPrCreationDefaults, - setCreatePrInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, setHostedReviewCreationRequestState, setHostedReviewCreationState, setPullRequestGenerationRecord, @@ -84,9 +81,7 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda allocatePullRequestGenerationRequestId, branchName, hostedReviewCreateProvider, - prGenerationRecords, refreshGitStatusAfterPullRequestGeneration, - resolvedPrCreationDefaults, setPullRequestGenerationRecord, updatePullRequestGenerationRecord, worktreePath @@ -157,13 +152,13 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda activeWorktreeId, branchName, createHostedReview, - createPrInFlightRef, createStackedHostedReview, handleGeneratePullRequestFields: createReviewComposer.handleGeneratePullRequestFields, handlePullRequestCreated: hostedReviewCreated.handlePullRequestCreated, hostedReviewCreateCopy, hostedReviewCreateProvider, hostedReviewCreation, + operationStore, prAiGenerationEnabled: createReviewComposer.prAiGenerationEnabled, prBase, prImageUploadPendingRef: createReviewComposer.prImageUploadPendingRef, @@ -174,10 +169,6 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda prGenerating, prTitle, pullRequestFieldsInitialized: createReviewComposer.pullRequestFieldsInitialized, - resolvedPrCreationDefaults, - setCreatePrInFlightByWorktree, - setCreatePrIntentNoticeForWorktree, - settings, worktreePath }) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx index 375215bfe1d..2b84a3aa9c0 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx @@ -1,3 +1,4 @@ +import { useSourceControlState, useSourceControlScope } from '../panel/worktree-scope' import type React from 'react' import { Copy, Globe, Hash, Sparkles } from 'lucide-react' import { @@ -7,7 +8,6 @@ import { } from '@/components/ui/context-menu' import { translate } from '@/i18n/i18n' import type { GitHistoryItem } from '../../../../../../shared/git/history/git-history-types' -import { useAppStore } from '@/store' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' export type GitHistoryCommitAction = 'open-remote' | 'copy-hash' | 'copy-message' | 'explain' @@ -19,10 +19,12 @@ export function GitHistoryCommitContextMenu({ item: GitHistoryItem onAction: (action: GitHistoryCommitAction, item: GitHistoryItem) => void }): React.JSX.Element { - const managedBrowserCreationEnabled = useAppStore( + const scope = useSourceControlScope() + const managedBrowserCreationEnabled = useSourceControlState( (state) => - getClientCreationActionPolicy(state, state.activeWorktreeId)['managed-browser'].state === - 'enabled' + getClientCreationActionPolicy(state, scope?.worktreeId ?? state.activeWorktreeId)[ + 'managed-browser' + ].state === 'enabled' ) return ( diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-file-watch-refresh.ts b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-file-watch-refresh.ts index 5362b716a97..8fdad70083e 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-file-watch-refresh.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-file-watch-refresh.ts @@ -24,6 +24,7 @@ type UseGitStatusFileWatchRefreshParams = { activeConnectionId: string | null activeRepoSupportsGit: boolean activeWorktreeId: string | null + runtimeEnvironmentId?: string | null enabled: boolean fetchStatus: () => void gitStatusHugeByWorktree: Record | undefined @@ -58,6 +59,7 @@ export function shouldRefreshGitStatusForFileChange( } export function useGitStatusFileWatchRefresh({ + runtimeEnvironmentId, activeConnectionId, activeRepoSupportsGit, activeWorktreeId, @@ -72,7 +74,9 @@ export function useGitStatusFileWatchRefresh({ worktreePath }: UseGitStatusFileWatchRefreshParams): void { const activeRuntimeEnvironmentId = useAppStore((state) => - runtimeEnvironmentIdOrLocalUnlessTwin(state, activeWorktreeId) + runtimeEnvironmentId !== undefined + ? runtimeEnvironmentId + : runtimeEnvironmentIdOrLocalUnlessTwin(state, activeWorktreeId) ) const fetchStatusRef = useRef(fetchStatus) fetchStatusRef.current = fetchStatus diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-push-signal-dispatch.test.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-push-signal-dispatch.test.tsx new file mode 100644 index 00000000000..fd7a37468fc --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-push-signal-dispatch.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type * as CrossPlatformPath from '../../../../../../shared/cross-platform-path' +import type * as WorkspaceRecordSelectors from '@/store/workspace-record-selectors' +import type { + GitStatusMetadataChangedEvent, + Worktree +} from '../../../../../../shared/worktree/types' +import { useAppStore } from '@/store' +import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' + +const counters = vi.hoisted(() => ({ normalizations: 0, catalogReads: 0 })) +vi.mock('../../../../../../shared/cross-platform-path', async (original) => { + const actual = await original() + return { + ...actual, + normalizeRuntimePathForComparison: (value: string) => { + counters.normalizations += 1 + return actual.normalizeRuntimePathForComparison(value) + } + } +}) +vi.mock('@/store/workspace-record-selectors', async (original) => { + const actual = await original() + return { + ...actual, + selectWorktreesByRepo: (...args: Parameters) => { + counters.catalogReads += 1 + return actual.selectWorktreesByRepo(...args) + } + } +}) + +const metadataListeners = new Set<(signal: GitStatusMetadataChangedEvent) => void>() +const repoListeners = new Set<(signal: { repoId: string }) => void>() +let root: Root +let visibility: DocumentVisibilityState = 'visible' + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('api', { + worktrees: { + onChanged: (listener: (signal: { repoId: string }) => void) => { + repoListeners.add(listener) + return () => repoListeners.delete(listener) + }, + onGitStatusMetadataChanged: (listener: (signal: GitStatusMetadataChangedEvent) => void) => { + metadataListeners.add(listener) + return () => metadataListeners.delete(listener) + } + } + }) + visibility = 'visible' + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility }) + useAppStore.setState(useAppStore.getInitialState(), true) + root = createRoot(document.createElement('div')) +}) +afterEach(async () => { + await act(async () => root.unmount()) + Reflect.deleteProperty(document, 'visibilityState') + vi.unstubAllGlobals() +}) + +type ObserverSpec = { repoId: string; id: string; path: string | null } + +function Observer({ spec, fetches }: { spec: ObserverSpec; fetches: string[] }) { + useGitStatusPushSignalRefresh({ + enabled: true, + activeRepoId: spec.repoId, + activeWorktreeId: spec.id, + activeWorktreePath: spec.path, + fetchStatus: recorders(spec.id, fetches) + }) + return null +} +const recorderCache = new Map void>() +// Stable per observer, as the production scheduler callback is. +function recorders(id: string, fetches: string[]): () => void { + let recorder = recorderCache.get(id) + if (!recorder) { + recorder = () => fetches.push(id) + recorderCache.set(id, recorder) + } + return recorder +} + +function row(id: string, path: string): Worktree { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only id and path from catalog rows. + return { id, repoId: 'repo', path } as Worktree +} + +async function mount(specs: readonly ObserverSpec[], fetches: string[]): Promise { + await act(async () => + root.render( + <> + {specs.map((spec) => ( + + ))} + + ) + ) +} + +function send(signal: GitStatusMetadataChangedEvent): void { + for (const listener of metadataListeners) { + listener(signal) + } +} + +it.each([3, 30])( + 'refreshes exactly the attributed observers among %i same-repository worktrees', + async (count) => { + recorderCache.clear() + const fetches: string[] = [] + const siblings = Array.from({ length: count }, (_, i) => ({ + repoId: 'repo', + id: `wt-${i}`, + path: `/repo/wt-${i}` + })) + const windows = { repoId: 'repo', id: 'wt-win', path: 'C:\\Repo\\WT-Win' } + const pathless = { repoId: 'repo', id: 'wt-pathless', path: null } + const otherRepo = { repoId: 'other', id: 'other-0', path: '/other/wt-0' } + useAppStore.setState({ + worktreesByRepo: { + repo: [...siblings.map((spec) => row(spec.id, spec.path)), row(windows.id, windows.path)], + other: [row(otherRepo.id, otherRepo.path)] + } + }) + await mount([...siblings, windows, pathless, otherRepo], fetches) + const repoIds = [...siblings.map((spec) => spec.id), windows.id, pathless.id] + const knownPathCount = count + 1 + + const dispatch = (signal: GitStatusMetadataChangedEvent) => { + fetches.length = 0 + counters.normalizations = 0 + counters.catalogReads = 0 + send(signal) + return { recipients: [...fetches].sort(), ...counters } + } + + // A known sibling refreshes only itself, plus an observer whose path is unknown. + expect(dispatch({ repoId: 'repo', worktreePaths: ['/repo/wt-1'] })).toEqual({ + recipients: ['wt-1', 'wt-pathless'], + catalogReads: 1, + normalizations: knownPathCount + 1 + }) + // Several attributed paths, one spelled with a trailing slash. + expect(dispatch({ repoId: 'repo', worktreePaths: ['/repo/wt-0', '/repo/wt-2/'] })).toEqual({ + recipients: ['wt-0', 'wt-2', 'wt-pathless'], + catalogReads: 1, + normalizations: knownPathCount + 2 + }) + // Windows drive paths compare case- and separator-insensitively. + expect(dispatch({ repoId: 'repo', worktreePaths: ['c:/repo/wt-win/'] }).recipients).toEqual([ + 'wt-pathless', + 'wt-win' + ]) + // An unknown spelling may name any checkout, so the whole repository refreshes. + expect( + dispatch({ repoId: 'repo', worktreePaths: ['/repo/wt-1', '/elsewhere/link'] }).recipients + ).toEqual([...repoIds].sort()) + // Legacy and empty attribution refresh the repository without reading the catalog. + for (const worktreePaths of [undefined, []]) { + expect(dispatch({ repoId: 'repo', worktreePaths })).toEqual({ + recipients: [...repoIds].sort(), + catalogReads: 0, + normalizations: 0 + }) + } + + // A hidden window catches up on reveal instead. + visibility = 'hidden' + expect(dispatch({ repoId: 'repo', worktreePaths: ['/repo/wt-1'] })).toEqual({ + recipients: [], + catalogReads: 0, + normalizations: 0 + }) + visibility = 'visible' + + // A disposed observer stops receiving; the last one closes the app-wide subscription. + await mount([...siblings.slice(2), windows, otherRepo], fetches) + expect(dispatch({ repoId: 'repo', worktreePaths: ['/repo/wt-1'] }).recipients).toEqual([]) + await act(async () => root.render(null)) + expect(metadataListeners.size).toBe(0) + expect(repoListeners.size).toBe(0) + } +) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-push-signal-refresh.ts b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-push-signal-refresh.ts index 6bbbdee718c..0ffaf14fd41 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-push-signal-refresh.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-push-signal-refresh.ts @@ -1,83 +1,113 @@ -import { useEffect, useRef } from 'react' +import { useAppStore } from '@/store' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { useEffect } from 'react' import { isWindowVisible } from '@/lib/window-visibility-interval' import { ORCA_TERMINAL_COMMAND_FINISHED_EVENT, type TerminalCommandFinishedEventDetail } from '@/hooks/terminal-command-finished-event' import type { GitStatusMetadataChangedEvent } from '../../../../../../shared/worktree/types' -import { isGitStatusSignalForWorktree } from './git-status-signal-attribution' +import { normalizeRuntimePathForComparison } from '../../../../../../shared/cross-platform-path' +import { gitStatusSignalRecipientTest } from './git-status-signal-attribution' type UseGitStatusPushSignalRefreshParams = { activeRepoId: string | null activeWorktreeId: string | null activeWorktreePath: string | null enabled: boolean + /** Keep stable: a change re-registers the observer. */ fetchStatus: () => void - /** Keep stable: it re-subscribes the push signals when it changes. */ - getRepoWorktreePaths: (repoId: string) => readonly string[] } -// Push signals beat slow fallback polling after shell mutations; watcher debounce and coalesced pacing absorb bursts. +type Observer = { + repoId: string | null + worktreeId: string | null + /** Normalized once at registration so each dispatch is a set lookup. */ + normalizedPath: string | null + fetchStatus: () => void +} +const observers = new Set() + +// Fresh membership per signal: the catalog is the only source, so no cache needs invalidating. +function knownRepoWorktreePaths(repoId: string): readonly string[] { + return (selectWorktreesByRepo(useAppStore.getState())[repoId] ?? []).map( + (worktree) => worktree.path + ) +} +let disposeSignals: (() => void) | null = null +function subscribeSignals(): () => void { + const repoSignal = ({ repoId }: { repoId: string }) => { + if (!isWindowVisible()) { + return + } + for (const observer of observers) { + if (observer.repoId === repoId) { + observer.fetchStatus() + } + } + } + // One catalog read and K + A normalizations per signal, then a set lookup per observer. + const metadataSignal = (signal: GitStatusMetadataChangedEvent) => { + if (!isWindowVisible()) { + return + } + const isRecipient = gitStatusSignalRecipientTest(signal, knownRepoWorktreePaths) + for (const observer of observers) { + if (observer.repoId === signal.repoId && isRecipient(observer.normalizedPath)) { + observer.fetchStatus() + } + } + } + const commandSignal = (event: Event) => { + if (!isWindowVisible()) { + return + } + const detail = (event as CustomEvent).detail + for (const observer of observers) { + if (detail?.worktreeId === observer.worktreeId) { + observer.fetchStatus() + } + } + } + const unsubs = [ + window.api?.worktrees?.onChanged?.(repoSignal), + window.api?.worktrees?.onGitStatusMetadataChanged?.(metadataSignal) + ] + window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, commandSignal) + return () => { + for (const unsub of unsubs) { + unsub?.() + } + window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, commandSignal) + } +} export function useGitStatusPushSignalRefresh({ + enabled, activeRepoId, activeWorktreeId, activeWorktreePath, - enabled, - fetchStatus, - getRepoWorktreePaths + fetchStatus }: UseGitStatusPushSignalRefreshParams): void { - const fetchStatusRef = useRef(fetchStatus) - fetchStatusRef.current = fetchStatus - useEffect(() => { - if (!enabled || !activeRepoId) { + if (!enabled) { return } - // Remote web has no preload bridge; fallback polling still covers it. - const subscribeToWorktreesChanged = window.api?.worktrees?.onChanged - const subscribeToGitStatusMetadataChanged = window.api?.worktrees?.onGitStatusMetadataChanged - if (!subscribeToWorktreesChanged && !subscribeToGitStatusMetadataChanged) { - return + const observer: Observer = { + repoId: activeRepoId, + worktreeId: activeWorktreeId, + normalizedPath: activeWorktreePath + ? normalizeRuntimePathForComparison(activeWorktreePath) + : null, + fetchStatus } - const handleRepoSignal = ({ repoId }: { repoId: string }): void => { - if (repoId !== activeRepoId || !isWindowVisible()) { - return - } - fetchStatusRef.current() - } - // Ignore sibling-worktree index churn that cannot affect this checkout. - const handleGitStatusSignal = (signal: GitStatusMetadataChangedEvent): void => { - const paths = getRepoWorktreePaths(signal.repoId) - if (isGitStatusSignalForWorktree(signal, activeWorktreePath, paths)) { - handleRepoSignal(signal) - } - } - // Skip hidden-window nudges; visibility polling catches up on reveal. - const unsubs = [ - subscribeToWorktreesChanged?.(handleRepoSignal), - subscribeToGitStatusMetadataChanged?.(handleGitStatusSignal) - ].filter((unsubscribe): unsubscribe is () => void => typeof unsubscribe === 'function') + observers.add(observer) + disposeSignals ??= subscribeSignals() return () => { - for (const unsubscribe of unsubs) { - unsubscribe() + observers.delete(observer) + if (observers.size === 0) { + disposeSignals?.() + disposeSignals = null } } - }, [enabled, activeRepoId, activeWorktreePath, getRepoWorktreePaths]) - - useEffect(() => { - if (!enabled || !activeWorktreeId) { - return - } - const handleCommandFinished = (event: Event): void => { - const detail = (event as CustomEvent).detail - if (detail?.worktreeId !== activeWorktreeId || !isWindowVisible()) { - return - } - fetchStatusRef.current() - } - window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) - return () => { - window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) - } - }, [enabled, activeWorktreeId]) + }, [enabled, activeRepoId, activeWorktreeId, activeWorktreePath, fetchStatus]) } diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-refresh-scheduler.ts b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-refresh-scheduler.ts index a8a34f2f265..99f221b7a3a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-refresh-scheduler.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-refresh-scheduler.ts @@ -5,6 +5,12 @@ import { export type GitStatusRefreshReason = 'activity' | 'safety' +export function admissionTierForGitStatusRefreshReason( + reason: GitStatusRefreshReason +): 'status' | 'background' { + return reason === 'safety' ? 'background' : 'status' +} + // Share pacing across scheduler rebuilds so host/target changes cannot bypass the activity floor (#7983). export type GitStatusRefreshPacing = { lastRunEndedAt: number diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-signal-attribution.ts b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-signal-attribution.ts index 9ff6c66860a..70f04ea7c24 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-status-signal-attribution.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-status-signal-attribution.ts @@ -1,21 +1,28 @@ import type { GitStatusMetadataChangedEvent } from '../../../../../../shared/worktree/types' -import { areRuntimePathsEqual } from '../../../../../../shared/worktree/ownership' +import { normalizeRuntimePathForComparison } from '../../../../../../shared/cross-platform-path' -/** Unattributed signals from older main/SSH/global changes may affect any checkout. */ -export function isGitStatusSignalForWorktree( +const REFRESH_EVERY_OBSERVER = (): boolean => true + +/** Builds one signal's recipient test; observers pass their path already normalized, or null. */ +export function gitStatusSignalRecipientTest( signal: GitStatusMetadataChangedEvent, - activeWorktreePath: string | null, - repoWorktreePaths: readonly string[] -): boolean { + readKnownRepoWorktreePaths: (repoId: string) => readonly string[] +): (normalizedObserverPath: string | null) => boolean { const attributed = signal.worktreePaths - if (!attributed?.length || !activeWorktreePath) { - return true + // Unattributed signals from older main/SSH/global changes may affect any checkout. + if (!attributed?.length) { + return REFRESH_EVERY_OBSERVER } - if (attributed.some((path) => areRuntimePathsEqual(path, activeWorktreePath))) { - return true - } - // Skip only known siblings; unknown spellings such as symlinked roots may still name this checkout. - return !attributed.every((path) => - repoWorktreePaths.some((known) => areRuntimePathsEqual(path, known)) + const attributedPaths = new Set(attributed.map(normalizeRuntimePathForComparison)) + const knownPaths = new Set( + readKnownRepoWorktreePaths(signal.repoId).map(normalizeRuntimePathForComparison) ) + // Skip only known siblings; unknown spellings such as symlinked roots may still name this checkout. + for (const path of attributedPaths) { + if (!knownPaths.has(path)) { + return REFRESH_EVERY_OBSERVER + } + } + return (normalizedObserverPath) => + normalizedObserverPath === null || attributedPaths.has(normalizedObserverPath) } diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/remote-refresh.ts b/src/renderer/src/components/right-sidebar/source-control/sync/remote-refresh.ts index e799fa225a2..6e002721769 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/remote-refresh.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/remote-refresh.ts @@ -50,31 +50,15 @@ function remoteActionErrorMatchesSettledConflictOperation( return false } -export function clearRemoteActionErrorsForCompletedConflictOperations({ - remoteActionErrors, - previousConflictOperations, - currentConflictOperations -}: { - remoteActionErrors: Record - previousConflictOperations: Record - currentConflictOperations: Record -}): Record { - let next: Record | null = null - for (const [worktreeId, error] of Object.entries(remoteActionErrors)) { - if (!error) { - continue - } - const previousOperation = previousConflictOperations[worktreeId] ?? 'unknown' - const currentOperation = currentConflictOperations[worktreeId] ?? 'unknown' - if ( - previousOperation === 'unknown' || - currentOperation !== 'unknown' || - !remoteActionErrorMatchesSettledConflictOperation(error.kind, previousOperation) - ) { - continue - } - next ??= { ...remoteActionErrors } - next[worktreeId] = null - } - return next ?? remoteActionErrors +/** True once status shows the conflict operation behind this failure has ended. */ +export function remoteActionErrorSettledByConflictOperation( + error: SourceControlActionError, + previousOperation: GitConflictOperation, + currentOperation: GitConflictOperation +): boolean { + return ( + previousOperation !== 'unknown' && + currentOperation === 'unknown' && + remoteActionErrorMatchesSettledConflictOperation(error.kind, previousOperation) + ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts index 238d0be78cc..198dc6a21d0 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react' import { requireGitOwnerTarget } from './worktree-git-owner-target' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { getRuntimeGitBranchCompare } from '@/runtime/runtime-git-diff-client' import { useAppStore } from '@/store' import { createLoadingBranchCompareSummary } from '@/store/slices/editor/git/branch-compare-state' @@ -39,9 +39,7 @@ export function useSourceControlBranchCompare({ activeGitStatusHead: string | null remoteStatus: GitUpstreamStatus | undefined }) { - const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest) - const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult) - const clearGitBranchCompare = useAppStore((s) => s.clearGitBranchCompare) + const scope = useSourceControlScope() const branchCompareInFlightRef = useRef(false) const branchCompareRerunRef = useRef(null) const branchCompareRunPromiseRef = useRef | null>(null) @@ -63,9 +61,11 @@ export function useSourceControlBranchCompare({ } const requestKey = `${activeWorktreeId}:${compareBaseRef}:${Date.now()}` const summary = useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId] - beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, { - preserveExistingSummary: !!summary && summary.baseRef === compareBaseRef - }) + useAppStore + .getState() + .beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, { + preserveExistingSummary: !!summary && summary.baseRef === compareBaseRef + }) try { const result = await getRuntimeGitBranchCompare( { @@ -73,14 +73,14 @@ export function useSourceControlBranchCompare({ target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, - connectionId: getConnectionId(activeWorktreeId) ?? undefined + connectionId: sourceControlConnectionId(scope, activeWorktreeId) }, compareBaseRef, kind === 'interval' ? 'background' : 'interactive' ) - setGitBranchCompareResult(activeWorktreeId, requestKey, result) + useAppStore.getState().setGitBranchCompareResult(activeWorktreeId, requestKey, result) } catch (error) { - setGitBranchCompareResult(activeWorktreeId, requestKey, { + useAppStore.getState().setGitBranchCompareResult(activeWorktreeId, requestKey, { summary: { ...createLoadingBranchCompareSummary(compareBaseRef), compareRef: branchName, @@ -91,16 +91,7 @@ export function useSourceControlBranchCompare({ }) } }, - [ - activeRepoTarget, - activeWorktreeId, - beginGitBranchCompareRequest, - branchName, - compareBaseRef, - isFolder, - setGitBranchCompareResult, - worktreePath - ] + [scope, activeRepoTarget, activeWorktreeId, branchName, compareBaseRef, isFolder, worktreePath] ) const clearBranchComparePollTimer = useCallback((): void => { @@ -272,8 +263,8 @@ export function useSourceControlBranchCompare({ ) { return } - clearGitBranchCompare(activeWorktreeId) - }, [activeWorktreeId, clearGitBranchCompare, compareBaseRef, isFolder, remoteStatus]) + useAppStore.getState().clearGitBranchCompare(activeWorktreeId) + }, [activeWorktreeId, compareBaseRef, isFolder, remoteStatus]) return { refreshBranchCompare, refreshBranchCompareRef } } diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts index b06666a1296..7ef319dd331 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts @@ -3,7 +3,7 @@ import { requireGitOwnerTarget } from './worktree-git-owner-target' import { toast } from 'sonner' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { translate } from '@/i18n/i18n' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { abortRuntimeGitMerge, abortRuntimeGitRebase } from '@/runtime/runtime-git-sync-client' import type { GitConflictOperation } from '../../../../../../shared/git/status/git-status-types' import type { AbortConflictOperation } from '../listing/operation-target' @@ -16,25 +16,22 @@ export function useSourceControlConflictAbort({ activeRepoTarget, activeWorktreeId, conflictOperation, - isAbortingOperation, + operationStore, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - setAbortOperationInFlightByWorktree, - setRemoteActionErrors, worktreePath }: { activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null conflictOperation: GitConflictOperation - isAbortingOperation: boolean + operationStore: SourceControlWorktreeOperationState['operationStore'] refreshActiveGitStatusAfterMutation: SourceControlStatusRefresh['refreshActiveGitStatusAfterMutation'] refreshBranchCompareRef: React.RefObject<() => Promise> refreshGitHistoryRef: React.RefObject<() => Promise> - setAbortOperationInFlightByWorktree: SourceControlWorktreeOperationState['setAbortOperationInFlightByWorktree'] - setRemoteActionErrors: SourceControlWorktreeOperationState['setRemoteActionErrors'] worktreePath: string | null }) { + const scope = useSourceControlScope() const confirmAction = useConfirmationDialog() const handleAbortOperation = useCallback( @@ -43,7 +40,7 @@ export function useSourceControlConflictAbort({ !activeWorktreeId || !worktreePath || conflictOperation !== requestedOperation || - isAbortingOperation + operationStore.getState().isAbortingOperation ) { return } @@ -75,13 +72,12 @@ export function useSourceControlConflictAbort({ : translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge'), confirmVariant: 'destructive' }) - if (!confirmed) { + if (!confirmed || operationStore.getState().isAbortingOperation) { return } - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - setAbortOperationInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) - setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) + operationStore.setState({ isAbortingOperation: true, remoteActionError: null }) try { const context = { // Abort on the repo owner, which may differ from the focused host. @@ -117,16 +113,15 @@ export function useSourceControlConflictAbort({ ), { description: message } ) - setRemoteActionErrors((prev) => ({ - ...prev, - [activeWorktreeId]: { + operationStore.setState({ + remoteActionError: { kind: isRebase ? 'abort_rebase' : 'abort_merge', message, rawError: message } - })) + }) } finally { - setAbortOperationInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) + operationStore.setState({ isAbortingOperation: false }) refreshSourceControlAfterRemoteAction({ refreshGitStatus: refreshActiveGitStatusAfterMutation, refreshBranchCompare: refreshBranchCompareRef.current, @@ -135,16 +130,15 @@ export function useSourceControlConflictAbort({ } }, [ + scope, activeRepoTarget, activeWorktreeId, confirmAction, conflictOperation, - isAbortingOperation, + operationStore, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - setAbortOperationInFlightByWorktree, - setRemoteActionErrors, worktreePath ] ) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts index 407f985eafc..719f8d6e523 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts @@ -5,7 +5,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { getRuntimeGitCommitCompare } from '@/runtime/runtime-git-diff-client' import { getRuntimeGitRemoteCommitUrl } from '@/runtime/runtime-git-working-tree-client' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { detectLanguage } from '@/lib/language-detect' import { launchAgentInNewTab } from '@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-session/launch/routing/agent-launch-request-id' @@ -48,8 +48,10 @@ export function useGitHistoryCommitActions({ activeRepoTarget: RuntimeClientTarget | null resolveSplitTargetGroupId: (event?: SourceControlRowOpenEvent) => string | undefined }): GitHistoryCommitActions { - const openCommitAllDiffs = useAppStore((s) => s.openCommitAllDiffs) - const openCommitDiff = useAppStore((s) => s.openCommitDiff) + const scope = useSourceControlScope() + const executionHostId = scope?.executionHostId + const placementWorkspaceId = scope?.placementWorkspaceId + const runtimeEnvironmentId = scope?.runtimeOwnerEnvironmentId // Share compare results across expansion and diff opening to avoid a second round-trip. const commitCompareCacheRef = useRef>(new Map()) @@ -68,7 +70,7 @@ export function useGitHistoryCommitActions({ if (cached) { return cached.entries } - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) const result = await getRuntimeGitCommitCompare( { // Compare on the repo owner, which may differ from the focused host. @@ -91,7 +93,7 @@ export function useGitHistoryCommitActions({ commitCompareCacheRef.current.set(item.id, result) return result.entries }, - [activeRepoTarget, activeWorktreeId, worktreePath] + [activeRepoTarget, activeWorktreeId, scope, worktreePath] ) const openHistoryCommitDiff = useCallback( @@ -105,14 +107,17 @@ export function useGitHistoryCommitActions({ if (!cached) { return } - openCommitAllDiffs( - activeWorktreeId, - worktreePath, - cached.summary, - cached.entries, - item.subject, - item.message - ) + useAppStore + .getState() + .openCommitAllDiffs( + activeWorktreeId, + worktreePath, + cached.summary, + cached.entries, + item.subject, + item.message, + { placementWorkspaceId, runtimeEnvironmentId, executionHostId } + ) } catch (error) { toast.error( error instanceof Error @@ -124,7 +129,14 @@ export function useGitHistoryCommitActions({ ) } }, - [activeWorktreeId, loadCommitFiles, openCommitAllDiffs, worktreePath] + [ + activeWorktreeId, + loadCommitFiles, + placementWorkspaceId, + executionHostId, + runtimeEnvironmentId, + worktreePath + ] ) const openCommitFile = useCallback( @@ -141,7 +153,7 @@ export function useGitHistoryCommitActions({ return } const targetGroupId = resolveSplitTargetGroupId(event) - openCommitDiff( + useAppStore.getState().openCommitDiff( activeWorktreeId, worktreePath, entry, @@ -154,10 +166,23 @@ export function useGitHistoryCommitActions({ message: item.message }, detectLanguage(entry.path), - { targetGroupId, preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) } + { + targetGroupId, + placementWorkspaceId, + executionHostId, + runtimeEnvironmentId, + preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) + } ) }, - [activeWorktreeId, openCommitDiff, resolveSplitTargetGroupId, worktreePath] + [ + activeWorktreeId, + placementWorkspaceId, + executionHostId, + resolveSplitTargetGroupId, + runtimeEnvironmentId, + worktreePath + ] ) const copyCommitText = useCallback(async (text: string, label: string): Promise => { @@ -191,7 +216,7 @@ export function useGitHistoryCommitActions({ target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, - connectionId: getConnectionId(activeWorktreeId) ?? undefined + connectionId: sourceControlConnectionId(scope, activeWorktreeId) }, { sha: item.id } ) @@ -243,7 +268,7 @@ export function useGitHistoryCommitActions({ } void explainCommitWithDefaultAgent(activeWorktreeId, item) }, - [activeRepoTarget, activeWorktreeId, copyCommitText, worktreePath] + [activeRepoTarget, activeWorktreeId, copyCommitText, scope, worktreePath] ) return { loadCommitFiles, openHistoryCommitDiff, openCommitFile, handleCommitAction } diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts index 8ab4f285561..b248b7434ee 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react' import { requireGitOwnerTarget } from './worktree-git-owner-target' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { getRuntimeGitHistory } from '@/runtime/runtime-git-status-client' import type { GitHistoryPanelState } from './git-history-panel' import { takeRecentWorkspaceSwitchWake } from '@/lib/workspace-switch-user-act' @@ -40,6 +40,7 @@ export function useSourceControlGitHistory({ refreshGitHistory: (options?: HistoryRefresh) => Promise refreshGitHistoryRef: RefObject<() => Promise> } { + const scope = useSourceControlScope() const [gitHistoryByWorktree, setGitHistoryByWorktree] = useState< Record >({}) @@ -98,7 +99,7 @@ export function useSourceControlGitHistory({ } }) try { - const connectionId = getConnectionId(worktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, worktreeId) const result = await getRuntimeGitHistory( { // Read history on the repo owner, which may differ from the focused host. @@ -137,6 +138,7 @@ export function useSourceControlGitHistory({ } }, [ + scope, activeRepoTarget, activeWorktreeId, compareBaseRef, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-status-upstream-ref-watch.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-status-upstream-ref-watch.ts index dcddf59cb75..899c8ce1723 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-status-upstream-ref-watch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-status-upstream-ref-watch.ts @@ -1,20 +1,23 @@ import { useCallback, useEffect } from 'react' -import { getConnectionId } from '@/lib/connection-context' +import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy' import { setRuntimeGitStatusUpstreamRefWatch } from '@/runtime/runtime-git-status-client' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { GitStatusResult } from '../../../../../../shared/git/status/git-status-types' import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' -import { getWorktreeGitOwnerTarget } from './worktree-git-owner-target' export function useGitStatusUpstreamRefWatch(args: { enabled: boolean - executionHostId: string | null | undefined + executionHostId: ExecutionHostId | null | undefined worktreeId: string | null worktreePath: string | null }): (status: GitStatusResult) => void { - const owner = getWorktreeGitOwnerTarget(args.worktreeId) + const copy = args.worktreeId + ? resolveWorktreeGitCopyNow(args.worktreeId, args.executionHostId) + : null + const owner = copy?.transport // Key by primitive identity so new target objects cannot rearm the effect. const runtimeEnvironmentId = owner?.kind === 'environment' ? owner.environmentId : null - const connectionId = args.worktreeId ? (getConnectionId(args.worktreeId) ?? undefined) : undefined + const connectionId = copy?.connectionId const scope = args.enabled && args.executionHostId && owner ? `${args.executionHostId}\0${runtimeEnvironmentId}\0${args.worktreeId}\0${args.worktreePath}` @@ -43,12 +46,12 @@ export function useGitStatusUpstreamRefWatch(args: { ).catch(() => {}) }, [ + scope, args.executionHostId, args.worktreeId, args.worktreePath, connectionId, - runtimeEnvironmentId, - scope + runtimeEnvironmentId ] ) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts index 76e08702a7c..022a14a6708 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import { isSyncPushStageError } from '@/lib/source-control/remote/source-control-remote-error' import { shouldForcePushWithLeaseForUpstream } from '../../../../../../shared/git/remotes/git-upstream-status' import { @@ -44,16 +44,15 @@ export function useSourceControlRemoteActionRunner({ fetchBranch, grouped, handleCommit, + operationStore, pullBranch, pushBranch, rebaseFromBase, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - remoteActionErrorSequenceByWorktreeRef, remoteStatus, remoteStatusForActions, - setRemoteActionErrors, syncBranch, worktreePath }: { @@ -66,19 +65,19 @@ export function useSourceControlRemoteActionRunner({ fetchBranch: SourceControlStoreActions['fetchBranch'] grouped: SourceControlFileListing['grouped'] handleCommit: SourceControlCommitAction['handleCommit'] + operationStore: SourceControlWorktreeOperationState['operationStore'] pullBranch: SourceControlStoreActions['pullBranch'] pushBranch: SourceControlStoreActions['pushBranch'] rebaseFromBase: SourceControlStoreActions['rebaseFromBase'] refreshActiveGitStatusAfterMutation: SourceControlStatusRefresh['refreshActiveGitStatusAfterMutation'] refreshBranchCompareRef: React.RefObject<() => Promise> refreshGitHistoryRef: React.RefObject<() => Promise> - remoteActionErrorSequenceByWorktreeRef: SourceControlWorktreeOperationState['remoteActionErrorSequenceByWorktreeRef'] remoteStatus: SourceControlWorktreeContext['remoteStatus'] remoteStatusForActions: SourceControlWorktreeContext['remoteStatus'] - setRemoteActionErrors: SourceControlWorktreeOperationState['setRemoteActionErrors'] syncBranch: SourceControlStoreActions['syncBranch'] worktreePath: string | null }) { + const scope = useSourceControlScope() const runRemoteAction = useCallback( async ( kind: SourceControlRemoteActionKind, @@ -94,15 +93,15 @@ export function useSourceControlRemoteActionRunner({ target: activeRepoTarget, worktreeId: activeWorktreeId, worktreePath, - connectionId: getConnectionId(activeWorktreeId) ?? undefined, + connectionId: sourceControlConnectionId(scope, activeWorktreeId), pushTarget: activeWorktree?.pushTarget } : null) if (!target) { return { status: 'skipped' } } - const sequence = (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] ?? 0) + 1 - remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] = sequence + const sequence = operationStore.getState().remoteActionSequence + 1 + operationStore.setState({ remoteActionSequence: sequence, remoteActionError: null }) const targetIsActiveWorktree = target.worktreeId === activeWorktreeId const recoveryEntrySnapshot = captureSourceControlRecoveryEntrySnapshot( targetIsActiveWorktree @@ -114,7 +113,6 @@ export function useSourceControlRemoteActionRunner({ : [] ) const failureBranchName = targetIsActiveWorktree ? branchName || null : null - setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null })) try { if (kind === 'publish') { await pushBranch( @@ -208,13 +206,13 @@ export function useSourceControlRemoteActionRunner({ runtimeTarget: target.target } ) - if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] === sequence) { - setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + if (operationStore.getState().remoteActionSequence === sequence) { + operationStore.setState({ remoteActionError: null }) } return { status: 'ok' } } catch (error) { // Keep failures inline after dropdowns close; store actions already own the toast. - if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] !== sequence) { + if (operationStore.getState().remoteActionSequence !== sequence) { return { status: 'superseded' } } const actionError: SourceControlActionError = { @@ -228,7 +226,7 @@ export function useSourceControlRemoteActionRunner({ entriesSnapshotTotalCount: recoveryEntrySnapshot.totalCount, sequence } - setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: actionError })) + operationStore.setState({ remoteActionError: actionError }) return { status: 'failed', error: actionError } } finally { if (!options?.target) { @@ -241,6 +239,7 @@ export function useSourceControlRemoteActionRunner({ } }, [ + scope, activeRepoTarget, activeWorktree?.pushTarget, activeWorktreeId, @@ -251,14 +250,13 @@ export function useSourceControlRemoteActionRunner({ grouped.staged, grouped.unstaged, grouped.untracked, + operationStore, pullBranch, pushBranch, rebaseFromBase, refreshActiveGitStatusAfterMutation, refreshBranchCompareRef, refreshGitHistoryRef, - remoteActionErrorSequenceByWorktreeRef, - setRemoteActionErrors, syncBranch, worktreePath ] diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts index 08901e07b0d..c718e6c78e4 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts @@ -1,9 +1,10 @@ +import { useSourceControlState, useSourceControlScope } from '../panel/worktree-scope' import { useCallback, useEffect } from 'react' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { toast } from 'sonner' import { resolveWorktreeGitCopyNow, - useWorktreeGitCopyHostId, + worktreeGitCopyHostId, worktreeCopyPushTarget } from '@/lib/worktree-git-copy' import { getLocalPathOpenOwnerForRoute, isLocalPathOpenBlocked } from '@/lib/local-path-open-guard' @@ -46,11 +47,11 @@ export function useSourceControlStatusRefresh({ activeConnectionId: string | null activeWorktreeInstanceId?: string }): SourceControlStatusRefresh { - const setGitStatus = useAppStore((s) => s.setGitStatus) - const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) - const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus) - const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) - const activeCopyHostId = useWorktreeGitCopyHostId(activeWorktreeId) + const scope = useSourceControlScope() + const selectedCopyHostId = useSourceControlState((s) => + !scope && activeWorktreeId ? worktreeGitCopyHostId(s, activeWorktreeId) : null + ) + const activeCopyHostId = scope?.executionHostId ?? selectedCopyHostId const localIgnoreBlocked = isLocalPathOpenBlocked( getLocalPathOpenOwnerForRoute({ runtimeEnvironmentId: @@ -75,27 +76,11 @@ export function useSourceControlStatusRefresh({ worktreePath, connectionId: copy.connectionId, pushTarget: activePushTarget, - deps: { - setGitStatus, - updateWorktreeGitIdentity, - setUpstreamStatus, - fetchUpstreamStatus - }, + deps: useAppStore.getState(), request: { admissionTier: 'interactive', ...(signal ? { signal } : {}) } }) }, - [ - activeCopyHostId, - activeRepoTarget, - activeWorktreeId, - activePushTarget, - fetchUpstreamStatus, - isFolder, - setGitStatus, - setUpstreamStatus, - updateWorktreeGitIdentity, - worktreePath - ] + [activeCopyHostId, activeRepoTarget, activeWorktreeId, activePushTarget, isFolder, worktreePath] ) const refreshActiveGitStatusAfterMutation = useCallback(async (): Promise => { try { @@ -189,18 +174,13 @@ export function useSourceControlStatusRefresh({ context.worktreeId, context.ownerHostId ?? null ), - deps: { - setGitStatus, - updateWorktreeGitIdentity, - setUpstreamStatus, - fetchUpstreamStatus - } + deps: useAppStore.getState() }) } catch (error) { console.warn('[SourceControl] post-generation git status refresh failed', error) } }, - [fetchUpstreamStatus, isFolder, setGitStatus, setUpstreamStatus, updateWorktreeGitIdentity] + [isFolder] ) return { diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts index e909977ef14..a2da66f58db 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { getConnectionId } from '@/lib/connection-context' +import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope' import type { SourceControlStoreActions } from '../listing/use-store-actions' import type { SourceControlWorktreeContext } from '../listing/use-worktree-context' @@ -20,12 +20,13 @@ export function useSourceControlUpstreamStatusFetch({ isFolder: boolean worktreePath: string | null }): void { + const scope = useSourceControlScope() useEffect(() => { // Avoid Git subprocesses while the sidebar is hidden. if (!activeWorktreeId || !worktreePath || isFolder || !isBranchVisible) { return } - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const connectionId = sourceControlConnectionId(scope, activeWorktreeId) void fetchUpstreamStatus( activeWorktreeId, worktreePath, @@ -34,6 +35,7 @@ export function useSourceControlUpstreamStatusFetch({ { runtimeTarget: activeRepoTarget } ) }, [ + scope, activeRepoTarget, activeWorktree?.pushTarget, activeWorktreeId, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-worktree-git-status-observation.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-worktree-git-status-observation.ts new file mode 100644 index 00000000000..96b2b39a2e3 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-worktree-git-status-observation.ts @@ -0,0 +1,328 @@ +import type { OpenFile } from '@/store/slices/editor' +import type { SourceControlScope } from '../panel/worktree-scope' +import { sourceControlFileWatchRuntimeEnvironmentId } from './worktree-git-owner-target' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' +import { useAppStore } from '@/store/index' +import { useRepoById, useWorktreeById } from '@/store/selectors' +import { isGitRepoKind } from '../../../../../../shared/repo-kind' +import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' +import { refreshGitStatusForWorktree } from './git-status-refresh' +import { isWindowVisible } from '@/lib/window-visibility-interval' +import { takeRecentWorkspaceSwitchWake } from '@/lib/workspace-switch-user-act' +import { useRefreshOnHostContact } from './use-refresh-on-host-contact' +import { + hasInteractiveActiveGitStatusConsumer, + shouldPollActiveGitStatus +} from '@/lib/passive-macos-app-data-access' +import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy' +import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' +import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' +import { useGitStatusUpstreamRefWatch } from './use-git-status-upstream-ref-watch' +import { + admissionTierForGitStatusRefreshReason, + createGitStatusRefreshPacing, + createGitStatusRefreshScheduler, + type GitStatusRefreshPacing, + type GitStatusRefreshReason, + type GitStatusRefreshScheduler +} from './git-status-refresh-scheduler' + +export { admissionTierForGitStatusRefreshReason } from './git-status-refresh-scheduler' + +const NO_OWNER_FILES: OpenFile[] = [] + +const STATUS_SAFETY_INTERVAL_MS = 60_000 +const STATUS_ACTIVITY_DEBOUNCE_MS = 125 +// Retain the pre-scheduler floor so sustained change signals cannot run Git back-to-back (#7983). +const STATUS_ACTIVITY_MIN_GAP_MS = 3000 +// Duration-aware spacing keeps slow scans/probes from running nonstop beyond their timer cadence. +export const SLOW_GIT_POLL_BACKOFF = { + idleMultiplier: 1, + changeSignalMultiplier: 1, + maxIntervalMs: 5 * 60_000 +} + +/** Returns a coalesced refresh that runs through this observer's scheduler, tier and apply guards. */ +export function useWorktreeGitStatusObservation( + owner: SourceControlScope | null, + options: { enabled?: boolean } = {} +): () => void { + const enabled = options.enabled ?? true + const activeWorktreeId = useAppStore((s) => owner?.worktreeId ?? s.activeWorktreeId) + const selectedWorktree = useWorktreeById(owner ? null : activeWorktreeId) + const activeWorktree = owner?.worktree ?? selectedWorktree + const activeExecutionHostId = useAppStore( + (s) => owner?.executionHostId ?? getExecutionHostIdForWorktree(s, activeWorktreeId) + ) + const fileWatchRuntimeEnvironmentId = useAppStore((state) => + owner ? sourceControlFileWatchRuntimeEnvironmentId(state, owner) : undefined + ) + const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) + const setGitStatus = useAppStore((s) => s.setGitStatus) + const huge = useAppStore((s) => + activeWorktreeId ? s.gitStatusHugeByWorktree[activeWorktreeId] : undefined + ) + const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) + const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus) + const openFiles = useAppStore((s) => (owner ? NO_OWNER_FILES : s.openFiles)) + const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) + const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) + const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView) + const pollingAllowed = useAppStore((s) => + owner + ? options.enabled === true + : shouldPollActiveGitStatus({ + activeWorktreeId, + worktreePath: activeWorktree?.path ?? null, + rightSidebarOpen: s.rightSidebarOpen, + rightSidebarTab: s.rightSidebarTab, + rightSidebarExplorerView: s.rightSidebarExplorerView, + openFiles: s.openFiles + }) + ) + const interactiveConsumerVisible = useAppStore((s) => + owner + ? options.enabled === true + : hasInteractiveActiveGitStatusConsumer({ + activeWorktreeId, + worktreePath: activeWorktree?.path ?? null, + rightSidebarOpen: s.rightSidebarOpen, + rightSidebarTab: s.rightSidebarTab, + rightSidebarExplorerView: s.rightSidebarExplorerView, + openFiles: s.openFiles + }) + ) + + const worktreePath = activeWorktree?.path ?? null + const activePushTarget = activeWorktree?.pushTarget + const activeRepoId = activeWorktree?.repoId ?? null + const selectedRepo = useRepoById(owner ? null : activeRepoId) + const activeRepo = owner?.repo ?? selectedRepo + const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false + const activeConnectionId = activeRepo?.connectionId ?? null + const isActiveConnectionReady = useAppStore( + (s) => + !activeConnectionId || s.sshConnectionStates.get(activeConnectionId)?.status === 'connected' + ) + const canFetchActiveWorktreeGitStatus = + enabled && + !!activeWorktreeId && + !!worktreePath && + activeRepoSupportsGit && + pollingAllowed && + isActiveConnectionReady + // Pause only evidence-free polling: gating signal refreshes too would prevent a fresh result from clearing huge status. + const shouldPollActiveWorktreeGitStatus = + canFetchActiveWorktreeGitStatus && !!activeWorktreeId && !huge + const activeStatusPollScope = shouldPollActiveWorktreeGitStatus + ? `${activeExecutionHostId}\0${activeWorktreeId}\0${worktreePath}` + : null + // Main binds one upstream ref (the selected worktree's); sections must not add per-tick resolutions. + const publishUpstreamRefWatch = useGitStatusUpstreamRefWatch({ + enabled: canFetchActiveWorktreeGitStatus && !owner, + executionHostId: activeExecutionHostId, + worktreeId: activeWorktreeId, + worktreePath + }) + + const runFetchStatus = useCallback( + async (request: { + reason: GitStatusRefreshReason + signal: AbortSignal + shouldApply: () => boolean + }) => { + // Recheck eligibility after timer dispatch so changed liveness cannot launch stale work. + if ( + request.signal.aborted || + !isWindowVisible() || + !canFetchActiveWorktreeGitStatus || + !activeWorktreeId || + !worktreePath + ) { + return + } + try { + // Why one resolution: a same-path twin must not get this copy's transport, SSH target or answer. + const copy = resolveWorktreeGitCopyNow(activeWorktreeId, activeExecutionHostId) + if (!copy) { + return + } + // Only the first status after the user's own switch here may start WSL (E9); polls, remounts + // and restores stay gated. + const wakeWslDistro = takeRecentWorkspaceSwitchWake(activeWorktreeId, 'git-status') + await refreshGitStatusForWorktree({ + target: copy.transport, + ownerHostId: copy.hostId, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: copy.connectionId, + pushTarget: activePushTarget, + deps: { setGitStatus, updateWorktreeGitIdentity, setUpstreamStatus, fetchUpstreamStatus }, + request: { + admissionTier: admissionTierForGitStatusRefreshReason(request.reason), + ...(request.reason === 'safety' ? { reuseLineStats: true } : {}), + signal: request.signal, + shouldApply: request.shouldApply, + onStatusAccepted: publishUpstreamRefWatch, + ...(wakeWslDistro ? { wakeWslDistro: true } : {}) + } + }) + } catch {} + }, + [ + activeExecutionHostId, + activePushTarget, + activeWorktreeId, + fetchUpstreamStatus, + canFetchActiveWorktreeGitStatus, + publishUpstreamRefWatch, + worktreePath, + setGitStatus, + setUpstreamStatus, + updateWorktreeGitIdentity + ] + ) + + // Preserve scheduler lifetime across harmless rerenders so in-flight/trailing/pacing state remains authoritative. + const runFetchStatusRef = useRef(runFetchStatus) + const canApplyScheduledStatusRef = useRef(canFetchActiveWorktreeGitStatus) + useLayoutEffect(() => { + runFetchStatusRef.current = runFetchStatus + canApplyScheduledStatusRef.current = canFetchActiveWorktreeGitStatus + }, [runFetchStatus, canFetchActiveWorktreeGitStatus]) + const statusRefreshGenerationRef = useRef(0) + + const statusSchedulerRef = useRef(null) + // Persist pacing per worktree across host/target scheduler rebuilds so flapping cannot bypass the minimum gap. + const statusPacingRef = useRef<{ + key: string + pacing: GitStatusRefreshPacing + } | null>(null) + useEffect(() => { + const generation = ++statusRefreshGenerationRef.current + const pacingKey = `${activeWorktreeId}\0${worktreePath}` + let pacing = + statusPacingRef.current?.key === pacingKey ? statusPacingRef.current.pacing : undefined + if (!pacing) { + pacing = createGitStatusRefreshPacing() + statusPacingRef.current = { key: pacingKey, pacing } + } + const scheduler = createGitStatusRefreshScheduler( + ({ reason, signal }) => + runFetchStatusRef.current({ + reason, + signal, + shouldApply: () => + statusRefreshGenerationRef.current === generation && + canApplyScheduledStatusRef.current && + !signal.aborted && + isWindowVisible() + }), + { + safetyIntervalMs: STATUS_SAFETY_INTERVAL_MS, + activityDebounceMs: STATUS_ACTIVITY_DEBOUNCE_MS, + activityMinGapMs: STATUS_ACTIVITY_MIN_GAP_MS, + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF, + pacing + } + ) + statusSchedulerRef.current = scheduler + return () => { + statusRefreshGenerationRef.current += 1 + scheduler.dispose() + if (statusSchedulerRef.current === scheduler) { + statusSchedulerRef.current = null + } + } + // Target changes must invalidate in-flight results captured against an old remote/branch. + }, [activeExecutionHostId, activePushTarget, activeWorktreeId, worktreePath]) + + useEffect(() => { + const reconcile = (catchUp: boolean): void => { + const scheduler = statusSchedulerRef.current + if (!scheduler) { + return + } + if (!canFetchActiveWorktreeGitStatus || !isWindowVisible()) { + scheduler.pause() + return + } + if (activeStatusPollScope) { + scheduler.resumeSafety() + return + } + scheduler.suspendSafety() + if (catchUp) { + scheduler.refreshNow() + } + } + reconcile(false) + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') { + return + } + const handleVisibilityChange = (): void => reconcile(isWindowVisible()) + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [activeStatusPollScope, canFetchActiveWorktreeGitStatus]) + + useRefreshOnHostContact(activeExecutionHostId, statusSchedulerRef, canApplyScheduledStatusRef) + + const previousConsumerVisibilityRef = useRef({ + worktreeId: activeWorktreeId, + visible: interactiveConsumerVisible, + canFetch: canFetchActiveWorktreeGitStatus + }) + useEffect(() => { + const previous = previousConsumerVisibilityRef.current + if ( + interactiveConsumerVisible && + previous.worktreeId === activeWorktreeId && + !previous.visible && + previous.canFetch && + canFetchActiveWorktreeGitStatus && + isWindowVisible() + ) { + statusSchedulerRef.current?.refreshNow() + } + previousConsumerVisibilityRef.current = { + worktreeId: activeWorktreeId, + visible: interactiveConsumerVisible, + canFetch: canFetchActiveWorktreeGitStatus + } + }, [activeWorktreeId, canFetchActiveWorktreeGitStatus, interactiveConsumerVisible]) + + // Coalesce file/terminal/metadata evidence so one Git operation cannot fan out status subprocesses. + const fetchStatusOnChangeSignal = useCallback(() => { + statusSchedulerRef.current?.signal() + }, []) + + useGitStatusFileWatchRefresh({ + activeConnectionId, + activeRepoSupportsGit, + activeWorktreeId, + enabled: canFetchActiveWorktreeGitStatus, + runtimeEnvironmentId: fileWatchRuntimeEnvironmentId, + fetchStatus: fetchStatusOnChangeSignal, + gitStatusHugeByWorktree: activeWorktreeId ? { [activeWorktreeId]: huge } : {}, + isConnectionReady: () => isActiveConnectionReady, + openFiles, + rightSidebarExplorerView, + rightSidebarOpen, + rightSidebarTab, + worktreePath + }) + + useGitStatusPushSignalRefresh({ + activeRepoId, + activeWorktreeId, + activeWorktreePath: worktreePath, + enabled: canFetchActiveWorktreeGitStatus, + fetchStatus: fetchStatusOnChangeSignal + }) + + return useCallback(() => { + statusSchedulerRef.current?.refreshNow() + }, []) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/source-control/sync/useGitStatusPolling.ts index 03ab55716d0..cdae27c7e12 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/useGitStatusPolling.ts @@ -1,318 +1,29 @@ -import { useCallback, useEffect, useRef } from 'react' -import { useAppStore } from '@/store/index' -import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeById } from '@/store/selectors' -import { isGitRepoKind } from '../../../../../../shared/repo-kind' -import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' -import { refreshGitStatusForWorktree } from './git-status-refresh' -import { isWindowVisible } from '@/lib/window-visibility-interval' -import { takeRecentWorkspaceSwitchWake } from '@/lib/workspace-switch-user-act' -import { useRefreshOnHostContact } from './use-refresh-on-host-contact' +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { - hasInteractiveActiveGitStatusConsumer, - shouldPollActiveGitStatus -} from '@/lib/passive-macos-app-data-access' -import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy' -import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' -import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' + useWorktreeGitStatusObservation, + SLOW_GIT_POLL_BACKOFF +} from './use-worktree-git-status-observation' import { useStaleConflictOperationPolling } from './stale-conflict-operation-poll' -import { useGitStatusUpstreamRefWatch } from './use-git-status-upstream-ref-watch' -import { - createGitStatusRefreshPacing, - createGitStatusRefreshScheduler, - type GitStatusRefreshPacing, - type GitStatusRefreshReason, - type GitStatusRefreshScheduler -} from './git-status-refresh-scheduler' -import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' - -const STATUS_SAFETY_INTERVAL_MS = 60_000 -const STATUS_ACTIVITY_DEBOUNCE_MS = 125 -// Retain the pre-scheduler floor so sustained change signals cannot run Git back-to-back (#7983). -const STATUS_ACTIVITY_MIN_GAP_MS = 3000 -// Duration-aware spacing keeps slow scans/probes from running nonstop beyond their timer cadence. -export const SLOW_GIT_POLL_BACKOFF = { - idleMultiplier: 1, - changeSignalMultiplier: 1, - maxIntervalMs: 5 * 60_000 -} - -export function admissionTierForGitStatusRefreshReason( - reason: GitStatusRefreshReason -): 'status' | 'background' { - return reason === 'safety' ? 'background' : 'status' -} - +export { + useWorktreeGitStatusObservation, + SLOW_GIT_POLL_BACKOFF, + admissionTierForGitStatusRefreshReason +} from './use-worktree-git-status-observation' export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { + useWorktreeGitStatusObservation(null, options) const enabled = options.enabled ?? true const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const activeWorktree = useWorktreeById(activeWorktreeId) - const activeExecutionHostId = useAppStore((s) => - getExecutionHostIdForWorktree(s, activeWorktreeId) - ) const allWorktrees = useAllWorktrees() - const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) - const setGitStatus = useAppStore((s) => s.setGitStatus) - const gitStatusHugeByWorktree = useAppStore((s) => s.gitStatusHugeByWorktree) - const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) - const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus) - const setConflictOperation = useAppStore((s) => s.setConflictOperation) - const conflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) - const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) - const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView) - const openFiles = useAppStore((s) => s.openFiles) const repoMap = useRepoMap() - - const worktreePath = activeWorktree?.path ?? null - const activePushTarget = activeWorktree?.pushTarget - const activeRepoId = activeWorktree?.repoId ?? null - const activeRepo = useRepoById(activeRepoId) - const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false - const activeConnectionId = activeRepo?.connectionId ?? null + const conflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree) + const setConflictOperation = useAppStore((s) => s.setConflictOperation) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const isConnectionReady = useCallback( - (connectionId: string | null | undefined): boolean => - !connectionId || sshConnectionStates.get(connectionId)?.status === 'connected', + (id: string | null | undefined) => !id || sshConnectionStates.get(id)?.status === 'connected', [sshConnectionStates] ) - const activeGitStatusPollingArgs = { - activeWorktreeId, - worktreePath, - rightSidebarOpen, - rightSidebarTab, - rightSidebarExplorerView, - openFiles - } - const isActiveConnectionReady = isConnectionReady(activeConnectionId) - const canFetchActiveWorktreeGitStatus = - enabled && - !!activeWorktreeId && - !!worktreePath && - activeRepoSupportsGit && - shouldPollActiveGitStatus(activeGitStatusPollingArgs) && - isActiveConnectionReady - // Pause only evidence-free polling: gating signal refreshes too would prevent a fresh result from clearing huge status. - const shouldPollActiveWorktreeGitStatus = - canFetchActiveWorktreeGitStatus && - !!activeWorktreeId && - !gitStatusHugeByWorktree?.[activeWorktreeId] - const activeStatusPollScope = shouldPollActiveWorktreeGitStatus - ? `${activeExecutionHostId}\0${activeWorktreeId}\0${worktreePath}` - : null - // Opening any status consumer must refresh promptly, preserving the previous interactive-poll behavior. - const interactiveConsumerVisible = hasInteractiveActiveGitStatusConsumer( - activeGitStatusPollingArgs - ) - - const publishUpstreamRefWatch = useGitStatusUpstreamRefWatch({ - enabled: canFetchActiveWorktreeGitStatus, - executionHostId: activeExecutionHostId, - worktreeId: activeWorktreeId, - worktreePath - }) - - const runFetchStatus = useCallback( - async (request: { - reason: GitStatusRefreshReason - signal: AbortSignal - shouldApply: () => boolean - }) => { - // Recheck eligibility after timer dispatch so changed liveness cannot launch stale work. - if ( - request.signal.aborted || - !isWindowVisible() || - !canFetchActiveWorktreeGitStatus || - !activeWorktreeId || - !worktreePath - ) { - return - } - try { - // Why one resolution: a same-path twin must not get this copy's transport, SSH target or answer. - const copy = resolveWorktreeGitCopyNow(activeWorktreeId) - if (!copy) { - return - } - // Only the first status after the user's own switch here may start WSL (E9); polls, remounts - // and restores stay gated. - const wakeWslDistro = takeRecentWorkspaceSwitchWake(activeWorktreeId, 'git-status') - await refreshGitStatusForWorktree({ - target: copy.transport, - ownerHostId: copy.hostId, - worktreeId: activeWorktreeId, - worktreePath, - connectionId: copy.connectionId, - pushTarget: activePushTarget, - deps: { setGitStatus, updateWorktreeGitIdentity, setUpstreamStatus, fetchUpstreamStatus }, - request: { - admissionTier: admissionTierForGitStatusRefreshReason(request.reason), - ...(request.reason === 'safety' ? { reuseLineStats: true } : {}), - signal: request.signal, - shouldApply: request.shouldApply, - onStatusAccepted: publishUpstreamRefWatch, - ...(wakeWslDistro ? { wakeWslDistro: true } : {}) - } - }) - } catch {} - }, - [ - activePushTarget, - activeWorktreeId, - fetchUpstreamStatus, - canFetchActiveWorktreeGitStatus, - publishUpstreamRefWatch, - worktreePath, - setGitStatus, - setUpstreamStatus, - updateWorktreeGitIdentity - ] - ) - - // Preserve scheduler lifetime across harmless rerenders so in-flight/trailing/pacing state remains authoritative. - const runFetchStatusRef = useRef(runFetchStatus) - runFetchStatusRef.current = runFetchStatus - const canApplyScheduledStatusRef = useRef(canFetchActiveWorktreeGitStatus) - canApplyScheduledStatusRef.current = canFetchActiveWorktreeGitStatus - const statusRefreshGenerationRef = useRef(0) - - const statusSchedulerRef = useRef(null) - // Persist pacing per worktree across host/target scheduler rebuilds so flapping cannot bypass the minimum gap. - const statusPacingRef = useRef<{ - key: string - pacing: GitStatusRefreshPacing - } | null>(null) - useEffect(() => { - const generation = ++statusRefreshGenerationRef.current - const pacingKey = `${activeWorktreeId}\0${worktreePath}` - let pacing = - statusPacingRef.current?.key === pacingKey ? statusPacingRef.current.pacing : undefined - if (!pacing) { - pacing = createGitStatusRefreshPacing() - statusPacingRef.current = { key: pacingKey, pacing } - } - const scheduler = createGitStatusRefreshScheduler( - ({ reason, signal }) => - runFetchStatusRef.current({ - reason, - signal, - shouldApply: () => - statusRefreshGenerationRef.current === generation && - canApplyScheduledStatusRef.current && - !signal.aborted && - isWindowVisible() - }), - { - safetyIntervalMs: STATUS_SAFETY_INTERVAL_MS, - activityDebounceMs: STATUS_ACTIVITY_DEBOUNCE_MS, - activityMinGapMs: STATUS_ACTIVITY_MIN_GAP_MS, - slowTaskBackoff: SLOW_GIT_POLL_BACKOFF, - pacing - } - ) - statusSchedulerRef.current = scheduler - return () => { - statusRefreshGenerationRef.current += 1 - scheduler.dispose() - if (statusSchedulerRef.current === scheduler) { - statusSchedulerRef.current = null - } - } - // Target changes must invalidate in-flight results captured against an old remote/branch. - }, [activeExecutionHostId, activePushTarget, activeWorktreeId, worktreePath]) - - useEffect(() => { - const reconcile = (catchUp: boolean): void => { - const scheduler = statusSchedulerRef.current - if (!scheduler) { - return - } - if (!canFetchActiveWorktreeGitStatus || !isWindowVisible()) { - scheduler.pause() - return - } - if (activeStatusPollScope) { - scheduler.resumeSafety() - return - } - scheduler.suspendSafety() - if (catchUp) { - scheduler.refreshNow() - } - } - reconcile(false) - if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') { - return - } - const handleVisibilityChange = (): void => reconcile(isWindowVisible()) - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - }, [activeStatusPollScope, canFetchActiveWorktreeGitStatus]) - - useRefreshOnHostContact(activeExecutionHostId, statusSchedulerRef, canApplyScheduledStatusRef) - - const previousConsumerVisibilityRef = useRef({ - worktreeId: activeWorktreeId, - visible: interactiveConsumerVisible, - canFetch: canFetchActiveWorktreeGitStatus - }) - useEffect(() => { - const previous = previousConsumerVisibilityRef.current - if ( - interactiveConsumerVisible && - previous.worktreeId === activeWorktreeId && - !previous.visible && - previous.canFetch && - canFetchActiveWorktreeGitStatus && - isWindowVisible() - ) { - statusSchedulerRef.current?.refreshNow() - } - previousConsumerVisibilityRef.current = { - worktreeId: activeWorktreeId, - visible: interactiveConsumerVisible, - canFetch: canFetchActiveWorktreeGitStatus - } - }, [activeWorktreeId, canFetchActiveWorktreeGitStatus, interactiveConsumerVisible]) - - // Coalesce file/terminal/metadata evidence so one Git operation cannot fan out status subprocesses. - const fetchStatusOnChangeSignal = useCallback(() => { - statusSchedulerRef.current?.signal() - }, []) - - const getRepoWorktreePathsForSignal = useCallback( - (repoId: string) => - (selectWorktreesByRepo(useAppStore.getState())[repoId] ?? []).map( - (worktree) => worktree.path - ), - [] - ) - - useGitStatusFileWatchRefresh({ - activeConnectionId, - activeRepoSupportsGit, - activeWorktreeId, - enabled, - fetchStatus: fetchStatusOnChangeSignal, - gitStatusHugeByWorktree, - isConnectionReady, - openFiles, - rightSidebarExplorerView, - rightSidebarOpen, - rightSidebarTab, - worktreePath - }) - - useGitStatusPushSignalRefresh({ - activeRepoId, - activeWorktreeId, - activeWorktreePath: worktreePath, - enabled: canFetchActiveWorktreeGitStatus, - fetchStatus: fetchStatusOnChangeSignal, - getRepoWorktreePaths: getRepoWorktreePathsForSignal - }) - useStaleConflictOperationPolling({ enabled, activeWorktreeId, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/worktree-git-owner-target.ts b/src/renderer/src/components/right-sidebar/source-control/sync/worktree-git-owner-target.ts index 6d381d370f5..ddb9fd0ab61 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/worktree-git-owner-target.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/worktree-git-owner-target.ts @@ -1,10 +1,27 @@ import { useMemo } from 'react' -import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' +import { resolveOwner, runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { useAppStore } from '@/store/index' import type { AppState } from '@/store/types' import { UNRESOLVED_GIT_HOST_ERROR } from '@/store/slices/editor/actions/git-operation-target' +import type { AttachedWorktreeOwner } from '../../folder-workspace-attached-worktrees' + +export function sourceControlFileWatchRuntimeEnvironmentId( + state: AppState, + owner: AttachedWorktreeOwner +): string | null | undefined { + const match = resolveOwner(state, { + workspaceId: owner.worktreeId, + ...(owner.worktree.hostId ? { hostId: owner.executionHostId } : {}) + }) + // A runtime proxy to nested SSH cannot watch that filesystem either. + if (match.kind !== 'resolved' || match.owner.at !== 'local') { + return undefined + } + const endpoint = match.owner.endpoint + return endpoint.kind === 'environment' ? endpoint.environmentId : null +} function ownerTargetFor( state: AppState, diff --git a/src/renderer/src/components/settings/agents/AgentsPane.tsx b/src/renderer/src/components/settings/agents/AgentsPane.tsx index 8c2e59f93a2..0858722572e 100644 --- a/src/renderer/src/components/settings/agents/AgentsPane.tsx +++ b/src/renderer/src/components/settings/agents/AgentsPane.tsx @@ -1,10 +1,10 @@ +import { useAppStore } from '@/store' import { getClaudeSubagentNavigationTitle, getClaudeSubagentNavigationDescription } from './behavior/claude-subagent-navigation-copy' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import { getLocalExecutionHostLabel } from '../../../../../shared/execution-host' -import { useAppStore } from '@/store/index' import { AgentAwakeSetting } from './behavior/AgentAwakeSetting' import { AgentCacheTimerSection } from './behavior/AgentCacheTimerSection' import { AgentRuntimeSetting } from './runtime/AgentRuntimeSetting' @@ -55,7 +55,6 @@ export function AgentsPane({ wslDistros, wslCapabilitiesLoading }: AgentsPaneProps): React.JSX.Element { - const refreshLocalAgents = useAppStore((state) => state.refreshDetectedAgents) return (
refreshLocalAgents(undefined, { wakeWslDistro: true })} + refresh={() => + useAppStore.getState().refreshDetectedAgents(undefined, { wakeWslDistro: true }) + } wslSupportedPlatform={wslSupportedPlatform} wslAvailable={wslAvailable} wslDistros={wslDistros} diff --git a/src/renderer/src/components/settings/agents/behavior/CodexHistorySharingSetting.tsx b/src/renderer/src/components/settings/agents/behavior/CodexHistorySharingSetting.tsx index f187bf4b0f4..7c39a108e8f 100644 --- a/src/renderer/src/components/settings/agents/behavior/CodexHistorySharingSetting.tsx +++ b/src/renderer/src/components/settings/agents/behavior/CodexHistorySharingSetting.tsx @@ -1,4 +1,4 @@ -import { useAppStore } from '@/store/index' +import { useAppStateValue } from '@/store/app-state-view' import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' import type { GlobalSettings } from '../../../../../../shared/global-settings-types' import { getLocalExecutionHostLabel } from '../../../../../../shared/execution-host' @@ -22,7 +22,7 @@ export function CodexHistorySharingSetting({ hostScope: SettingsHostScope }): React.JSX.Element { const environmentId = runtimeTargetEnvironmentId(hostScope.target) - const hostName = useAppStore((state) => + const hostName = useAppStateValue((state) => environmentId ? state.runtimeEnvironments.find((entry) => entry.id === environmentId)?.name : null diff --git a/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsCatalog.tsx b/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsCatalog.tsx index cc9c1b5481b..a27013c181f 100644 --- a/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsCatalog.tsx +++ b/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsCatalog.tsx @@ -1,3 +1,4 @@ +import { useAppStateValue } from '@/store/app-state-view' import { AgentLaunchChoiceEditor } from '../../../agent/launch-settings/AgentLaunchChoiceEditor' import { agentChatPermissionModes } from '../../../../../../shared/agent-launch/permissions/agent-chat-permission-support' import { savedChatPermissionMode } from '../../../../../../shared/agent-launch/permissions/agent-chat-permission-mode' @@ -44,7 +45,6 @@ import { readChatProgram, resolveChatProgramContext } from '@/lib/host-chat-program' -import { useAppStore } from '@/store/index' import { readNativeDetectedAgents } from '@/store/slices/local-detected-agent-store-state' import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { AgentDefaultSetting } from './AgentDefaultSetting' @@ -101,7 +101,7 @@ export function AgentLaunchSettingsCatalog({ } = useDetectedAgents(detectionTarget, 'all') const detectedIds = useMemo(() => (detected ? new Set(detected) : null), [detected]) useHostStructuredAgentsVersion() - const installedHere = useAppStore(readNativeDetectedAgents) + const installedHere = useAppStateValue(readNativeDetectedAgents) const workspace = detectionTarget.kind === 'runtime' ? { kind: 'git-worktree' as const, runtimeEnvironmentId: detectionTarget.environmentId } @@ -109,7 +109,7 @@ export function AgentLaunchSettingsCatalog({ useChatProgramFacts(workspace, detectionTarget.kind !== 'ssh') const chosen = settings.defaultTuiAgent // The saved default reads as available where it opens as a chat without its CLI. - const defaultOpensAsChat = useAppStore((state) => + const defaultOpensAsChat = useAppStateValue((state) => chosen && chosen !== 'blank' && detectionTarget.kind !== 'ssh' ? agentChoiceOpensAsChat(state, chosen, workspace, detected) : false @@ -122,7 +122,7 @@ export function AgentLaunchSettingsCatalog({ .map((row) => row.agent) ) // Agents whose chat program is here without their CLI, like a downloaded chat runtime, are installed for chat. - const chatOnlyKey = useAppStore((state) => { + const chatOnlyKey = useAppStateValue((state) => { if (detectionTarget.kind === 'ssh') { return '' } diff --git a/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsSection.tsx b/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsSection.tsx index 2332c891074..cd2555b9483 100644 --- a/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsSection.tsx +++ b/src/renderer/src/components/settings/agents/launch/AgentLaunchSettingsSection.tsx @@ -1,3 +1,4 @@ +import { useAppStateValue } from '@/store/app-state-view' import { useChatSettingsOwnPermissions } from '@/runtime/structured-agent-session-host-capability' import { toast } from 'sonner' import { getAgentLabel } from '@/lib/agent-catalog' @@ -40,7 +41,7 @@ export function AgentLaunchSettingsSection({ renderPermissions: AgentPermissionsRenderer }): React.JSX.Element { const environmentId = runtimeTargetEnvironmentId(hostScope.target) - const environment = useAppStore((state) => + const environment = useAppStateValue((state) => environmentId ? state.runtimeEnvironments.find((entry) => entry.id === environmentId) : null ) return ( diff --git a/src/renderer/src/components/settings/section-layout/SearchableSetting.tsx b/src/renderer/src/components/settings/section-layout/SearchableSetting.tsx index c55d3c270b2..69b6ca50947 100644 --- a/src/renderer/src/components/settings/section-layout/SearchableSetting.tsx +++ b/src/renderer/src/components/settings/section-layout/SearchableSetting.tsx @@ -1,6 +1,6 @@ +import { useAppStateValue } from '@/store/app-state-view' import type React from 'react' import { cn } from '@/lib/utils' -import { useAppStore } from '../../../store/index' import { matchesSettingsSearch, type SettingsSearchEntry } from '../search/settings-search' type SearchableSettingProps = SettingsSearchEntry & { @@ -19,7 +19,7 @@ export function SearchableSetting({ className, id }: SearchableSettingProps): React.JSX.Element | null { - const query = useAppStore((state) => state.settingsSearchQuery) + const query = useAppStateValue((state) => state.settingsSearchQuery) if (!forceVisible && !matchesSettingsSearch(query, { title, description, keywords })) { return null } diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts index cbccb52ea6b..37e0a1db2bf 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts @@ -1,9 +1,9 @@ +import { useAppStateView } from '@/store/app-state-view' import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' import { useCallback, useMemo, useSyncExternalStore } from 'react' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' import { migrationUnsupportedToAgentStatusEntry } from '../../../../../../shared/migration-unsupported-agent-entry' -import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { EMPTY_LIVE_PTY_IDS, @@ -41,6 +41,7 @@ const subscribeToNothing = (): (() => void) => () => {} /** Share indexed worktree inputs across cards; derive live/retained row freshness from the available evidence. */ export function useWorktreeAgentRows(worktreeId: string, active = true): DashboardAgentRow[] { + const store = useAppStateView() const selectInputs = useMemo(() => { const selectAgentFreshness = createWorktreeAgentFreshnessSelector(worktreeId) const readInputs = (state: AppState) => @@ -76,7 +77,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa return next } }, [worktreeId, active]) - const getInputs = useCallback(() => selectInputs(useAppStore.getState()), [selectInputs]) + const getInputs = useCallback(() => selectInputs(store.getState()), [selectInputs, store]) const [ tabs, liveEntries, @@ -88,11 +89,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa runtimeAgentOrchestrationByPaneKey, paneForegroundAgentByPaneKey, agentFreshnessSignature - ] = useSyncExternalStore( - active ? useAppStore.subscribe : subscribeToNothing, - getInputs, - getInputs - ) + ] = useSyncExternalStore(active ? store.subscribe : subscribeToNothing, getInputs, getInputs) return useMemo(() => { if (!active) { @@ -114,7 +111,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa buildWorktreeAgentRows({ tabs: tabs ?? EMPTY_TABS, representedTabs: getStructuredAgentSessionTabsByPaneKey( - useAppStore.getState().unifiedTabsByWorktree + store.getState().unifiedTabsByWorktree ), entries, retained, @@ -129,6 +126,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa // eslint-disable-next-line react-hooks/exhaustive-deps }, [ active, + store, tabs, liveEntries, migrationUnsupported, diff --git a/src/renderer/src/components/source-control/BaseRefPicker.tsx b/src/renderer/src/components/source-control/BaseRefPicker.tsx index 1ff570d48f8..ede1932dd40 100644 --- a/src/renderer/src/components/source-control/BaseRefPicker.tsx +++ b/src/renderer/src/components/source-control/BaseRefPicker.tsx @@ -1,7 +1,7 @@ +import { useAppStateViewActive, useAppStateValue } from '@/store/app-state-view' import { useEffect, useRef, useState } from 'react' import { Button } from '../ui/button' import { Input } from '../ui/input' -import { useAppStore } from '@/store/index' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { searchRuntimeRepoBaseRefs, @@ -27,7 +27,8 @@ export function BaseRefPicker({ onUsePrimary }: BaseRefPickerProps): React.JSX.Element { // Resolve refs on the explicit host or the repo row’s owner. - const ownerHostId = useAppStore((state) => { + const viewActive = useAppStateViewActive() + const ownerHostId = useAppStateValue((state) => { if (hostId) { return hostId } @@ -66,7 +67,10 @@ export function BaseRefPicker({ // Drop the previous repo’s default before loading so its Following label cannot describe the new repo. setDefaultBaseRef(null) setRemoteCount(0) - if (!ownerHostId) { + }, [ownerHostId, repoId]) + + useEffect(() => { + if (!viewActive || !ownerHostId) { return } return watchRuntimeRepoBaseRefDefault(ownerHostId, repoId, { @@ -80,9 +84,12 @@ export function BaseRefPicker({ setRemoteCount(0) } }) - }, [ownerHostId, repoId]) + }, [ownerHostId, repoId, viewActive]) useEffect(() => { + if (!viewActive) { + return + } if (!isRuntimeRepoRefSearchQueryWithinLimit(baseRefQuery)) { setBaseRefResults([]) setIsSearchingBaseRefs(false) @@ -122,7 +129,7 @@ export function BaseRefPicker({ stale = true window.clearTimeout(timer) } - }, [baseRefQuery, ownerHostId, repoId]) + }, [baseRefQuery, ownerHostId, repoId, viewActive]) const effectiveBaseRef = currentBaseRef ?? defaultBaseRef diff --git a/src/renderer/src/components/source-control/BaseRefPicker.visibility.test.tsx b/src/renderer/src/components/source-control/BaseRefPicker.visibility.test.tsx new file mode 100644 index 00000000000..257dddd332c --- /dev/null +++ b/src/renderer/src/components/source-control/BaseRefPicker.visibility.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { create } from 'zustand' +import { AppStateViewContext } from '@/store/app-state-view' +import { BaseRefPicker } from './BaseRefPicker' + +vi.mock('@/runtime/runtime-repo-client', () => ({ + watchRuntimeRepoBaseRefDefault: () => () => {}, + searchRuntimeRepoBaseRefs: async () => [] +})) + +const snapshot = useAppStore.getState() +const frozen = create(() => snapshot) + +afterEach(cleanup) + +it('retains the branch search input when a section pauses and resumes observation', () => { + const picker = ( + {}} /> + ) + const view = render( + {picker} + ) + const search = view.getByRole('textbox') + fireEvent.change(search, { target: { value: 'feature/unfinished' } }) + + view.rerender( + {picker} + ) + view.rerender( + {picker} + ) + expect(view.getByRole('textbox')).toHaveProperty('value', 'feature/unfinished') +}) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index a3ef841c93a..390e87f1ad3 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,3 +1,4 @@ +import { editorTabWorkspace } from '@/store/slices/editor/tabs/workspace-editor-files' import type { EditorFileTabProps } from './editor-file-tab-props' import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' import { useCallback, useEffect, useRef, useState } from 'react' @@ -11,7 +12,9 @@ import { renameFileOnDisk } from '@/lib/rename-file' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { detectLanguage } from '@/lib/language-detect' import { FileTypeIcon } from '@/components/FileTypeIcon' -import { useRepoById, useWorktreeById } from '@/store/selectors' +import { useWorktreeById } from '@/store/selectors' +import { getOpenFileExecutionHostId } from '@/lib/unified-tab-host-ownership' +import { resolveReviewRepository } from '@/lib/review-owner' import { useAppStore } from '@/store' import { STATUS_COLORS } from '../../lib/git-path-decoration' import { STATUS_LABELS } from '../../../../shared/git/status/git-status-labels' @@ -59,8 +62,15 @@ export default function EditorFileTab({ dropIndicator, includeTopTabBorder = true }: EditorFileTabProps): React.JSX.Element { - const worktree = useWorktreeById(file.worktreeId) - const repo = useRepoById(worktree?.repoId ?? null) + const placedElsewhere = useAppStore((state) => + file.tabId + ? editorTabWorkspace(state.unifiedTabsByWorktree, file.tabId) !== file.worktreeId + : false + ) + const worktree = useWorktreeById(file.worktreeId, getOpenFileExecutionHostId(file)) + const repo = useAppStore((state) => + worktree ? resolveReviewRepository(state.repos, worktree) : null + ) // Keep dragged tabs anchored; only the insertion bar moves. const { attributes, listeners, setNodeRef } = useSortable({ // Split copies share a file entity; unified tab IDs keep each copy independently draggable. @@ -318,6 +328,7 @@ export default function EditorFileTab({ }} > {getEditorDisplayLabel(file)} + {placedElsewhere && repo ? ` · ${repo.displayName}` : ''} )} {isMissingFileMutation && !isRenaming && ( @@ -364,7 +375,7 @@ export default function EditorFileTab({ tabRoot ) : ( void hidePickerSearch?: boolean }): React.JSX.Element { - const desktopDefaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const desktopDefaultAgent = useAppStateValue((s) => s.settings?.defaultTuiAgent) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') const shortcutLabels = useAgentMenuShortcutLabels(pickerInventory ? model : undefined) const openAgentSettings = useCallback(() => { - openSettingsTarget({ pane: 'agents', repoId: null }) - openSettingsPage() - }, [openSettingsPage, openSettingsTarget]) + useAppStore.getState().openSettingsTarget({ pane: 'agents', repoId: null }) + useAppStore.getState().openSettingsPage() + }, []) if (model.state === 'loading') { // Why: rows fill in once when every answer is known, instead of reshuffling under the pointer. return ( diff --git a/src/renderer/src/components/tab-bar/agent-menu-shortcut-labels.ts b/src/renderer/src/components/tab-bar/agent-menu-shortcut-labels.ts index 6e138ebb48b..b91c0d48597 100644 --- a/src/renderer/src/components/tab-bar/agent-menu-shortcut-labels.ts +++ b/src/renderer/src/components/tab-bar/agent-menu-shortcut-labels.ts @@ -7,7 +7,7 @@ import { } from '../../../../shared/keybindings' import { resolveWindowShortcutAction } from '../../../../shared/window-shortcut-policy' import { getShortcutPlatform } from '@/lib/shortcut-platform' -import { useAppStore } from '@/store' +import { useAppStateValue } from '@/store/app-state-view' import { readAgentTabShortcutActions, resolveAgentTabShortcutAction @@ -19,7 +19,7 @@ import type { NewTabAgentMenuModel } from './new-tab-agent-menu-model' export function useAgentMenuShortcutLabels( model: NewTabAgentMenuModel | undefined ): Map | undefined { - const keybindings = useAppStore((s) => s.keybindings) + const keybindings = useAppStateValue((s) => s.keybindings) const worktreeId = model?.state === 'settled' ? model.pickerContext?.workspace.worktreeId : null return worktreeId ? agentMenuShortcutLabels(worktreeId, keybindings, getShortcutPlatform()) diff --git a/src/renderer/src/components/tab-bar/group-tab-order.test.ts b/src/renderer/src/components/tab-bar/group-tab-order.test.ts index 4ee68b916f1..5ac27a0b0f8 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.test.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.test.ts @@ -392,7 +392,9 @@ describe('getActiveTabNavOrder', () => { it('falls back to the legacy reconciled order when no active group exists', () => { const state = makeState({ tabBarOrderByWorktree: { wt: ['term-1', 'sim-1', 'e1', 'term-2'] }, - unifiedTabsByWorktree: { wt: [simulatorTab('sim-1', 'g1', 3)] }, + unifiedTabsByWorktree: { + wt: [simulatorTab('sim-1', 'g1', 3), editorTab('e1', 'g1', 'e1', 2)] + }, tabsByWorktree: { wt: [ makeTerminalTab({ id: 'term-1', worktreeId: 'wt' }), diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index 04b9a734036..404e00a18d8 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -1,3 +1,4 @@ +import { workspaceEditorFiles } from '@/store/slices/editor/tabs/workspace-editor-files' import type { Tab, TabGroup } from '../../../../shared/tab-types' import type { AppState } from '../../store/types' import { reconcileTabOrder } from './reconcile-order' @@ -155,7 +156,8 @@ export function getActiveTabNavOrder( ): VisibleTabRef[] { const terminalIds = ids.terminalIds ?? (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id) const editorIds = - ids.editorIds ?? state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) + ids.editorIds ?? + workspaceEditorFiles(state.openFiles, state.unifiedTabsByWorktree[worktreeId]).map((f) => f.id) const browserIds = ids.browserIds ?? (state.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id) const simulatorIds = diff --git a/src/renderer/src/components/tab-bar/recent-tab-switching.ts b/src/renderer/src/components/tab-bar/recent-tab-switching.ts index fe210bd88b1..0523a99b016 100644 --- a/src/renderer/src/components/tab-bar/recent-tab-switching.ts +++ b/src/renderer/src/components/tab-bar/recent-tab-switching.ts @@ -1,3 +1,4 @@ +import { workspaceEditorFiles } from '@/store/slices/editor/tabs/workspace-editor-files' import type { CtrlTabOrderMode, Tab, TabContentType, TabGroup } from '../../../../shared/tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import { readStructuredChatTabName } from '@/runtime/structured-chat-tab-name' @@ -153,8 +154,8 @@ export function buildRecentTabSwitcherModel( (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) ) const dirtyFileIds = new Set( - state.openFiles - .filter((file) => file.worktreeId === worktreeId && file.isDirty) + workspaceEditorFiles(state.openFiles, state.unifiedTabsByWorktree[worktreeId]) + .filter((file) => file.isDirty) .map((file) => file.id) ) const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true diff --git a/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.ts b/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.ts index 47e5153b1cc..c92d85025e5 100644 --- a/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.ts +++ b/src/renderer/src/components/tab-bar/use-agent-picker-refused-wake.ts @@ -1,3 +1,4 @@ +import { useAppStateValue } from '@/store/app-state-view' import { useEffect, useRef } from 'react' import { useAppStore } from '@/store' import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' @@ -17,7 +18,7 @@ export function useAgentPickerRefusedWake( : undefined // The target this open already woke; cleared on close so the next open may wake again. const wokenFor = useRef<{ worktreeId: string | null } | null>(null) - const failed = useAppStore( + const failed = useAppStateValue( (state) => worktreeId !== undefined && state.readLocalAgentInventory(worktreeId).state === 'failed' ) diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts index 3ca47afa6fa..8c1fbeb1027 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts @@ -1,7 +1,12 @@ +import { + useAppViewExternalStore, + useAppStateView, + useAppStateViewActive, + useAppStateValue +} from '@/store/app-state-view' import { nativeChatPreferenceFor } from '../../../../shared/structured-native-chat-launch-route' import type { AgentMenuRowsTarget, NewTabAgentMenuModel } from './new-tab-agent-menu-model' export type { AgentMenuRowsTarget, NewTabAgentMenuModel } from './new-tab-agent-menu-model' -import { useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' import { STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY as AVAILABILITY } from '../../../../shared/agent-session/structured-agent-session-surface-capabilities' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' @@ -11,7 +16,6 @@ import { runtimeHostContactForEntry } from '../../../../shared/remote-runtime/host-status/runtime-host-contact' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' -import { useAppStore } from '@/store' import { useAgentPickerRefusedWake } from './use-agent-picker-refused-wake' import { buildAgentLaunchRouteInput } from '@/lib/agent-session/launch/routing/agent-launch-route-input' import { workspaceKindForWorktreeId } from '../../../../shared/workspace-launch-kind' @@ -60,18 +64,20 @@ export function useAgentMenuObservation( detectionTarget: AgentDetectionTarget | undefined, options?: AgentMenuObservationOptions ) { + const state = useAppStateView().getState() + const viewActive = useAppStateViewActive() const worktreeId = typeof target === 'string' ? target : target?.workspace.worktreeId const launchCwd = typeof target === 'string' ? undefined : target?.launchCwd const unresolvedTarget = Boolean(worktreeId) && detectionTarget === undefined // Why: hidden worktrees stay mounted, so only the shown one holds its host's streams. - const worktreeShown = useAppStore( + const shown = useAppStateValue( (state) => typeof target === 'string' && state.activeWorktreeId === worktreeId ) // Why: acquiring while the workspace is shown means the first open already knows its agents. - const acquired = useDetectedAgents(open || worktreeShown ? detectionTarget : undefined) - useAgentPickerRefusedWake(options?.wakeOnOpen === true && open, detectionTarget) + const acquired = useDetectedAgents(viewActive && (open || shown) ? detectionTarget : undefined) + useAgentPickerRefusedWake(viewActive && options?.wakeOnOpen === true && open, detectionTarget) // Closing the menu or leaving the workspace must not erase the known inventory. - const storedIds = useAppStore((state) => + const storedIds = useAppStateValue((state) => detectionTarget ? readDetectedAgentsForTarget(state, detectionTarget) : null ) // No owner means no agents to offer; a later owner publication resumes normal detection. @@ -81,18 +87,18 @@ export function useAgentMenuObservation( isLoading: !unresolvedTarget && acquired.isLoading } useHostStructuredAgentsVersion() - const localCapabilities = useSyncExternalStore( + const localCapabilities = useAppViewExternalStore( subscribeLocalRuntimeCapabilityObservation, readLocalRuntimeCapabilityObservation, readLocalRuntimeCapabilityObservation ) // The observation owns these settings; picker consumers reuse its snapshot. - const desktopSettings = useAppStore.getState().settings + const desktopSettings = state.settings const workspace = typeof target === 'string' ? { kind: workspaceKindForWorktreeId(target), worktreeId: target } : (target?.workspace ?? { kind: 'floating' as const }) - const facts = useAppStore( + const facts = useAppStateValue( useShallow((state) => { const input = buildAgentLaunchRouteInput(state, { agent: 'claude', workspace }) const host = parseExecutionHostId(input.executionHostId) @@ -114,15 +120,14 @@ export function useAgentMenuObservation( } }) ) - const state = useAppStore.getState() const input = buildAgentLaunchRouteInput(state, { agent: 'claude', workspace }) const { settings, pending: preferencesPending } = useHostAgentMenuSettings( unresolvedTarget ? null : facts, desktopSettings, - open || worktreeShown, - open + viewActive && (open || shown), + viewActive && open ) - useAppStore( + useAppStateValue( useShallow((state) => [ state.settings?.defaultTuiAgent, state.settings?.disabledTuiAgents, @@ -145,7 +150,7 @@ export function useAgentMenuObservation( input.hostCapabilities?.includes(AVAILABILITY) === true && Boolean(structuredAgentSessionTargetForHost(facts.executionHostId)) const availability = useHostStructuredAgentAvailability( - published && !plain && !unresolvedTarget + viewActive && published && !plain && !unresolvedTarget ? { executionHostId: facts.executionHostId, runtimeId: facts.runtimeId, @@ -194,7 +199,7 @@ export function useAgentMenuObservation( null, localCapabilities.state === 'known' ? localCapabilities.capabilities : null ) - const holding = (open || worktreeShown) && !plain && !unresolvedTarget + const holding = viewActive && (open || shown) && !plain && !unresolvedTarget useChatProgramFacts(workspace, holding) useChatProgramFacts({ kind: 'floating' }, holding && !local) // Only dependencies that can enable an action hold the cold fill; failed inventory ends its own. diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts index 6608f5324de..6f3956841b4 100644 --- a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts @@ -106,9 +106,9 @@ export function useTabGroupItemProjections({ () => groupTabs .filter((item) => isEditorTabContentType(item.contentType)) - .map((item) => { + .map((item): GroupEditorItem | null => { const file = openFileById.get(item.entityId) - return file ? { ...file, tabId: item.id } : null + return file ? { ...file, isPreview: item.isPreview, tabId: item.id } : null }) .filter((item): item is GroupEditorItem => item !== null), [groupTabs, openFileById] diff --git a/src/renderer/src/components/tab-group/workspace-tab-close-commands.frame-flush.test.ts b/src/renderer/src/components/tab-group/workspace-tab-close-commands.frame-flush.test.ts index 871a2b8b695..7e9ad6c877e 100644 --- a/src/renderer/src/components/tab-group/workspace-tab-close-commands.frame-flush.test.ts +++ b/src/renderer/src/components/tab-group/workspace-tab-close-commands.frame-flush.test.ts @@ -33,6 +33,6 @@ it('closes the renamed file, prompting when it is dirty, after the frame flush', answer(true) await drainMicrotasks() - expect(requestEditorFileClose).toHaveBeenCalledWith('new', undefined) + expect(requestEditorFileClose).toHaveBeenCalledWith('new', { tabId: 'tab' }) expect(useAppStore.getState().unifiedTabsByWorktree[worktreeId]).toHaveLength(1) }) diff --git a/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts b/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts index 42a85f00cdf..68f21c57c1a 100644 --- a/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts +++ b/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts @@ -1,3 +1,4 @@ +import { editorContentIsReferenced } from '@/store/slices/editor/tabs/workspace-editor-files' import { createNonSecureContextUuid as createBrowserUuid } from '../../../../shared/non-secure-context-uuid' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' @@ -18,26 +19,23 @@ export function createWorkspaceTabCloseCommands({ worktreeId: string groupTabs: Tab[] }) { - const { closeUnifiedTab, closeFile } = useAppStore.getState() + const { closeUnifiedTab } = useAppStore.getState() const closeEditorIfUnreferenced = ( entityId: string, closingTabId: string, whenEmptied: (() => void) | null ) => { - const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some( - (item) => - item.id !== closingTabId && - item.entityId === entityId && - isEditorTabContentType(item.contentType) - ) + const otherReference = editorContentIsReferenced(useAppStore.getState(), entityId, closingTabId) if (!otherReference) { if (needsSavePromptOnClose(useAppStore.getState(), entityId)) { // Why: route through Terminal.tsx so the unsaved-confirmation save/discard queue stays centralized across all close paths. - requestEditorFileClose(entityId, whenEmptied ? { onClosed: whenEmptied } : undefined) + requestEditorFileClose(entityId, { + tabId: closingTabId, + ...(whenEmptied ? { onClosed: whenEmptied } : {}) + }) return false } - closeFile(entityId) } return true } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts index 9a492980e2c..fdf8bca62da 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -154,6 +154,9 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt : {}), ...(session.launchToken ? { launchToken: session.launchToken } : {}), ...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}), + ...(session.paneStartup?.trustWorktreeId + ? { trustWorktreeId: session.paneStartup.trustWorktreeId } + : {}), ...(session.paneStartup?.agentLaunch ? { agentLaunch: session.paneStartup.agentLaunch } : {}), ...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}), onPtyExit: session.onExit, diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts index 1f1f6fde9ff..46408ba9f55 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts @@ -1,3 +1,4 @@ +import { resolveAgentWorkspaceTrustOwner } from '../../../../../../shared/agent-workspace-trust-decision' import { terminalLaunchFacts } from '../../../../../../shared/agent-session/lifecycle/agent-session-resume' import { readTerminalSpawnDeferral } from '../../../../../../shared/host-spawn-admission' import { isRuntimeOwnedSshTargetId } from '../../../../../../shared/execution-host' @@ -86,7 +87,7 @@ export async function connectIpcPty( ? null : awaitAgentWorkspaceTrustDecision({ launchAgent: options.launchAgent ?? transportOptions.launchAgent, - workspaceId: transportOptions.worktreeId, + workspaceId: resolveAgentWorkspaceTrustOwner(transportOptions), connectionId: transportOptions.connectionId }) // Bounded: an unanswered prompt releases with today's default (agent-workspace-trust-decision-prompt). diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-spawn-request.ts index f2bbb8b7507..2c5edcb1410 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-spawn-request.ts @@ -52,6 +52,9 @@ export async function spawnIpcPty( agentArgs: transportOptions.agentArgsOverride, launchPreferences: transportOptions.agentLaunchPreferences, startupCwd: transportOptions.cwd, + ...(transportOptions.trustWorktreeId + ? { trustWorktreeId: transportOptions.trustWorktreeId } + : {}), placement: { tabId, leafId }, presentation: 'background', ...(transportOptions.terminalKittyKeyboardProtocol === true @@ -135,6 +138,9 @@ export async function spawnIpcPty( ...(connectOptions.initiallyHidden ? { initiallyHidden: true } : {}), ...(connectOptions.attachOnlyView ? { attachOnlyView: true } : {}), worktreeId, + ...(transportOptions.trustWorktreeId + ? { trustWorktreeId: transportOptions.trustWorktreeId } + : {}), ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), ...(placement && !admittedSessionId ? { placement } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-agent-trust-hold.test.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-agent-trust-hold.test.ts index 8ed57f5c571..4a0dca226e2 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-agent-trust-hold.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-agent-trust-hold.test.ts @@ -47,6 +47,25 @@ describe('createIpcPtyTransport: first-launch agent trust answer', () => { }) }) + it('reattaches a section terminal without asking or changing trust', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + await createIpcPtyTransport({ + ...CLAUDE_LAUNCH, + worktreeId: 'folder:placement', + trustWorktreeId: CLAUDE_LAUNCH.worktreeId + }).connect({ url: '', callbacks: {}, sessionId: 'existing-pty' }) + + expect(prompt.answer).not.toHaveBeenCalled() + expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]).toMatchObject({ + sessionId: 'existing-pty', + worktreeId: 'folder:placement', + trustWorktreeId: CLAUDE_LAUNCH.worktreeId + }) + expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]).not.toHaveProperty( + 'skipAgentWorkspaceTrust' + ) + }) + it("spawns with today's default when the prompt is released without an answer", async () => { prompt.answer.mockResolvedValueOnce(null) diff --git a/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts index 4da7cd87cd5..451d7f42bcc 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts @@ -312,6 +312,7 @@ export type IpcPtyTransportOptions = { launchToken?: string launchAgent?: TuiAgent agentLaunch?: AgentPaneLaunchRequest + trustWorktreeId?: string startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null executionHostId?: ExecutionHostId | null diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-create-outcome-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-create-outcome-recovery.test.ts index b1d13e4bc35..ab170f4fdcb 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-create-outcome-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-create-outcome-recovery.test.ts @@ -37,6 +37,56 @@ describe('createRemoteRuntimePtyTransport', () => { resetRemoteRuntimeTransport() }) + it('retries failed section trust negotiation before creating the agent with its owner', async () => { + vi.useFakeTimers() + try { + let reachable = false + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'status.get') { + if (!reachable) { + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1', 'agent-session.trust-worktree.v1'] + } + } + } + return { ok: true, result: { terminal: { handle: 'section-agent' } } } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'folder-x', + trustWorktreeId: 'worktree-y', + launchAgent: 'claude' + }) + try { + await transport.connect({ url: '', callbacks: {} }) + expect(runtimeCall.mock.calls.every(([args]) => args.method === 'status.get')).toBe(true) + + reachable = true + await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@section-agent')) + const creates = runtimeCall.mock.calls.filter( + ([args]) => args.method === 'terminal.createAgentSession' + ) + expect(creates).toHaveLength(1) + expect(creates[0][0].params).toMatchObject({ + worktree: 'id:folder-x', + trustWorktreeId: 'worktree-y' + }) + } finally { + transport.destroy?.() + } + } finally { + vi.useRealTimers() + } + }) + it('retries an unknown terminal-create outcome exactly once with the same mutation id', async () => { let createCalls = 0 runtimeCall.mockImplementation(async (args: { method: string; params?: unknown }) => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts index 329ab7949f6..da42cf2cbf6 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts @@ -1,3 +1,4 @@ +import { createAgentSessionTrustWorktreeOptions } from '@/runtime/agent-session-create-request' import { parseTerminalAttachmentLaunchResult } from '../../../../../../shared/terminal/pty/lifecycle/terminal-attachment-launch-result' import { REMOTE_RUNTIME_PTY_HONOURS_TAB_SHELL_OVERRIDE } from './remote-runtime-pty-shell' import { terminalLaunchFacts } from '../../../../../../shared/agent-session/lifecycle/agent-session-resume' @@ -221,6 +222,7 @@ export function createRemoteRuntimePtyTransport( runtimeEnvironmentId: string, opts: IpcPtyTransportOptions = {} ): PtyTransport { + const trustOptions = createAgentSessionTrustWorktreeOptions(opts.trustWorktreeId) const { command, startupCommandDelivery, @@ -3624,7 +3626,11 @@ export function createRemoteRuntimePtyTransport( } })()) : {} + const sectionTrustOptions = opts.trustWorktreeId + ? await trustOptions(createEnvironmentId) + : {} const legacyCreateParams = { + ...sectionTrustOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), clientMutationId: terminalCreateMutationId, ...(commandToSend !== undefined ? { command: commandToSend } : {}), @@ -3710,6 +3716,8 @@ export function createRemoteRuntimePtyTransport( withAgentSessionCreateOperationId( { ...keyboardOptions, + ...sectionTrustOptions, + ...(opts.trustWorktreeId && opts.cwd ? { startupCwd: opts.cwd } : {}), worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, ...(agentPrompt ? { prompt: agentPrompt } : {}), diff --git a/src/renderer/src/components/terminal-workspace-keydown.test.ts b/src/renderer/src/components/terminal-workspace-keydown.test.ts index 7e3e0e715eb..262654f6740 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.test.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.test.ts @@ -400,6 +400,7 @@ describe('shared tab navigation routing', () => { describe('tab.closeAll confirmation', () => { const worktreeId = controller.activeWorktreeId! const closeFile = vi.fn() + const closeUnifiedTabs = vi.fn() const queueEditorCloseRequests = vi.fn() let settle: (confirmed: boolean) => void @@ -413,7 +414,18 @@ describe('tab.closeAll confirmation', () => { openFiles: [makeOpenFile({ id: 'file-1', worktreeId })], editorBufferIdOf: {}, editorBufferViews: {}, - unifiedTabsByWorktree: { [worktreeId]: [] }, + unifiedTabsByWorktree: { + [worktreeId]: [ + makeUnifiedTab({ + id: 'file-tab', + entityId: 'file-1', + worktreeId, + groupId: 'group', + contentType: 'editor' + }) + ] + }, + closeUnifiedTabs, getActiveTab: () => null } confirmBulkClose.mockImplementation( @@ -460,7 +472,7 @@ describe('tab.closeAll confirmation', () => { await act(async () => { settle(true) }) - expect(closeFile).toHaveBeenCalledExactlyOnceWith('file-1') + expect(closeUnifiedTabs).toHaveBeenCalledExactlyOnceWith(['file-tab']) } ) @@ -483,7 +495,7 @@ describe('tab.closeAll confirmation', () => { settle(true) }) expect(closeFile).not.toHaveBeenCalled() - expect(queueEditorCloseRequests).toHaveBeenCalledExactlyOnceWith(['file-1']) + expect(queueEditorCloseRequests).toHaveBeenCalledExactlyOnceWith(['file-tab']) }) it('skips files pinned while shortcut confirmation is pending', async () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index f7b325b6f71..061f44a6663 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -56,11 +56,7 @@ vi.mock('@/runtime/web-session-tabs-sync/tracking-mappings', () => ({ resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock })) -import { - closeOtherTerminalTabs, - closeTerminalTab, - closeTerminalTabsToRight -} from './terminal-tab-actions' +import { closeOtherTerminalTabs, closeTerminalTab } from './terminal-tab-actions' import { terminalOwnerRows } from './terminal-tab-owner-rows.test-fixture' describe('closeTerminalTab', () => { @@ -398,46 +394,3 @@ describe('closeOtherTerminalTabs', () => { expect(closeTab).toHaveBeenNthCalledWith(2, 'close-b', { remoteCloseOwnedByHost: true }) }) }) - -describe('closeTerminalTabsToRight', () => { - beforeEach(() => { - vi.clearAllMocks() - isWebRuntimeSessionActiveMock.mockReturnValue(false) - }) - - it('delegates terminal tabs to the host while still closing local editor tabs to the right', () => { - const closeTab = vi.fn() - const closeFile = vi.fn() - isWebRuntimeSessionActiveMock.mockReturnValue(true) - getStateMock.mockReturnValue({ - ...terminalOwnerRows('web-runtime'), - tabsByWorktree: { - 'wt-1': [{ id: 'term-a' }, { id: 'term-b' }, { id: 'term-c' }] - }, - openFiles: [{ id: 'file-b', worktreeId: 'wt-1' }], - tabBarOrderByWorktree: { 'wt-1': ['term-a', 'file-b', 'term-b', 'term-c'] }, - closeTab, - closeFile - }) - - closeTerminalTabsToRight('term-a', 'wt-1') - - expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2) - expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - tabId: 'term-b', - environmentId: 'web-runtime', - reason: 'user' - }) - expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - tabId: 'term-c', - environmentId: 'web-runtime', - reason: 'user' - }) - expect(closeFile).toHaveBeenCalledWith('file-b') - expect(closeTab).toHaveBeenCalledTimes(2) - expect(closeTab).toHaveBeenNthCalledWith(1, 'term-b', { remoteCloseOwnedByHost: true }) - expect(closeTab).toHaveBeenNthCalledWith(2, 'term-c', { remoteCloseOwnedByHost: true }) - }) -}) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 629aab9e9e9..0d9016e0558 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -34,7 +34,7 @@ import { type PrecomputedTerminalCloseState } from './terminal-close-target' export type { PrecomputedTerminalCloseState } from './terminal-close-target' -export { closeOtherTerminalTabs, closeTerminalTabsToRight } from './terminal-tab-bulk-actions' +export { closeOtherTerminalTabs } from './terminal-tab-bulk-actions' type CloseTerminalTabOptions = { force?: boolean diff --git a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts index 8026b3ef628..e9320dcc745 100644 --- a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts @@ -5,9 +5,7 @@ import { import { closeWebRuntimeSessionTab } from '@/runtime/web-runtime-session-tab-lifecycle' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' import { useAppStore } from '@/store' -import { reconcileTabOrder } from '../tab-bar/reconcile-order' import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' -import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type' type TerminalTabBulkActionState = ReturnType @@ -56,59 +54,3 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | } } } - -export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string | null): void { - if (!activeWorktreeId) { - return - } - - const state = useAppStore.getState() - if (hasUnroutableTerminalWorktreeOwner(state, activeWorktreeId)) { - return - } - const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? [] - const currentEditorFiles = state.openFiles.filter((file) => file.worktreeId === activeWorktreeId) - const runtimeEnvironmentId = resolveTerminalWorktreeRoute( - state, - activeWorktreeId - )?.runtimeEnvironmentId - const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) - const terminalIds = currentTerminalTabs.map((tab) => tab.id) - const terminalIdSet = new Set(terminalIds) - const orderedIds = reconcileTabOrder( - state.tabBarOrderByWorktree[activeWorktreeId], - terminalIds, - currentEditorFiles.map((file) => file.id) - ) - - const index = orderedIds.indexOf(tabId) - if (index === -1) { - return - } - for (const id of orderedIds.slice(index + 1)) { - if (isPinnedVisibleTab(state, activeWorktreeId, id)) { - continue - } - if (terminalIdSet.has(id)) { - if (closeHostTerminalTabs) { - // Why: prune the mirror immediately, then close on its authoritative host so snapshots converge. - closeLocalTerminalTabState(id, { remoteCloseOwnedByHost: true }) - void closeWebRuntimeSessionTab({ - worktreeId: activeWorktreeId, - tabId: id, - environmentId: runtimeEnvironmentId, - reason: 'user' - }) - } else { - state.closeTab(id) - } - continue - } - const unifiedTab = (state.unifiedTabsByWorktree?.[activeWorktreeId] ?? []).find( - (tab) => tab.entityId === id && isEditorTabContentType(tab.contentType) - ) - if (!unifiedTab?.isPinned) { - useAppStore.getState().closeFile(id) - } - } -} diff --git a/src/renderer/src/components/terminal/use-worktree-files.ts b/src/renderer/src/components/terminal/use-worktree-files.ts index ee8fea3b988..370788fc575 100644 --- a/src/renderer/src/components/terminal/use-worktree-files.ts +++ b/src/renderer/src/components/terminal/use-worktree-files.ts @@ -1,15 +1,16 @@ import { useMemo } from 'react' +import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' - +import { workspaceEditorFiles } from '@/store/slices/editor/tabs/workspace-editor-files' export function useWorktreeFiles( openFiles: readonly OpenFile[], renderedActiveWorktreeId: string | null ): OpenFile[] { + const tabs = useAppStore((state) => + renderedActiveWorktreeId ? state.unifiedTabsByWorktree[renderedActiveWorktreeId] : undefined + ) return useMemo( - () => - renderedActiveWorktreeId - ? openFiles.filter((file) => file.worktreeId === renderedActiveWorktreeId) - : [], - [openFiles, renderedActiveWorktreeId] + () => (renderedActiveWorktreeId ? workspaceEditorFiles(openFiles, tabs) : []), + [openFiles, tabs, renderedActiveWorktreeId] ) } diff --git a/src/renderer/src/components/ui/use-overlay-focus-scope.ts b/src/renderer/src/components/ui/use-overlay-focus-scope.ts index 4cd10e9719f..3a1ee3330f1 100644 --- a/src/renderer/src/components/ui/use-overlay-focus-scope.ts +++ b/src/renderer/src/components/ui/use-overlay-focus-scope.ts @@ -13,26 +13,37 @@ import { registerOverlayFocusOwner } from '@/lib/overlay-focus-ownership' export const OverlayFocusScope = createContext | null>(null) +export const OverlayActivityOwner = createContext< + ((overlay: HTMLElement, open: boolean) => void) | null +>(null) + export function useOverlayFocusScope(forwardedRef?: Ref): { ref: RefCallback scope: RefObject } { const parent = useContext(OverlayFocusScope) + const activityOwner = useContext(OverlayActivityOwner) const scope = useRef(null) const [owner, setOwner] = useState(null) useLayoutEffect(() => { if (owner) { registerOverlayFocusOwner(owner, () => parent?.current ?? null) + const reportActivity = () => activityOwner?.(owner, owner.dataset.state !== 'closed') + reportActivity() const observer = new MutationObserver(() => { + reportActivity() if (owner.dataset.state === 'open') { registerOverlayFocusOwner(owner, () => parent?.current ?? null) } }) observer.observe(owner, { attributes: true, attributeFilter: ['data-state'] }) - return () => observer.disconnect() + return () => { + observer.disconnect() + activityOwner?.(owner, false) + } } return undefined - }, [owner, parent]) + }, [activityOwner, owner, parent]) const ref = useComposedRefs(forwardedRef, scope, setOwner) return { ref, scope } } diff --git a/src/renderer/src/components/use-terminal-bulk-close-actions.frame-flush.test.tsx b/src/renderer/src/components/use-terminal-bulk-close-actions.frame-flush.test.tsx index 0c723db6031..77377f015f5 100644 --- a/src/renderer/src/components/use-terminal-bulk-close-actions.frame-flush.test.tsx +++ b/src/renderer/src/components/use-terminal-bulk-close-actions.frame-flush.test.tsx @@ -33,6 +33,9 @@ it('closes a terminal at once while a framed editor waits for its flush', async }) const closeTab = vi.fn() const closeFile = vi.fn() + const closeUnifiedTabs = vi + .spyOn(useAppStore.getState(), 'closeUnifiedTabs') + .mockImplementation(() => {}) const { result } = renderHook(() => useTerminalBulkCloseActions({ activeWorktreeId: worktreeId, @@ -49,6 +52,7 @@ it('closes a terminal at once while a framed editor waits for its flush', async answer(true) await drainMicrotasks() - expect(closeFile).toHaveBeenCalledWith('doc') + expect(closeUnifiedTabs).toHaveBeenLastCalledWith(['doc']) + closeUnifiedTabs.mockRestore() expect(closeTab).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/use-terminal-bulk-close-actions.placement.test.tsx b/src/renderer/src/components/use-terminal-bulk-close-actions.placement.test.tsx new file mode 100644 index 00000000000..ba60b77f314 --- /dev/null +++ b/src/renderer/src/components/use-terminal-bulk-close-actions.placement.test.tsx @@ -0,0 +1,417 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TabsDrawnFromMain } from '@/store/slices/tabs-drawn-from-main.test-fixture' +import { + EDITOR_OWNER, + EDITOR_PLACEMENT, + seedEditorPlacementConsumers, + drawEditorPlacementConsumers +} from '@/store/slices/editor-placement-consumers.test-fixture' +import { useTerminalBulkCloseActions } from './use-terminal-bulk-close-actions' +import { useTerminalEditorCloseFoundation } from './use-terminal-editor-close-foundation' +import { useTerminalEditorCloseQueue } from './use-terminal-editor-close-queue' +import { useTerminalEditorCloseDialogActions } from './use-terminal-editor-close-dialog-actions' +import { + ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, + type EditorSaveAndCloseDetail +} from './editor/autosave/editor-autosave' +import { openNewTerminalTabInWorkspace } from '@/store/terminals/terminal-active-workspace-creation' +import { persistAgentLaunchTabOrder } from '@/lib/agent-session/launch/pane/launch-agent-tab-order' +import { beginPendingTab } from '@/runtime/layout-owner-pending-tabs' + +vi.mock('@/runtime/runtime-rpc-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await original()) +) +vi.mock('@/runtime/runtime-layout-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await original()) +) +vi.mock('./use-bulk-tab-close-confirmation', () => ({ + useBulkTabCloseConfirmation: + () => (_scope: string, select: () => string[], close: (ids: string[]) => void) => + close(select()) +})) + +let main: TabsDrawnFromMain | undefined +afterEach(() => { + cleanup() + main?.close() + main = undefined +}) +function mountCloseActions() { + return renderHook(() => { + const openFiles = useAppStore((state) => state.openFiles) + const state = useAppStore.getState() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the projection fields read by close hooks are needed. + const projection = { + ...state, + openFiles, + activeWorktreeId: EDITOR_PLACEMENT + } as unknown as Parameters[0] + const foundation = useTerminalEditorCloseFoundation(projection) + const queue = useTerminalEditorCloseQueue({ ...projection, ...foundation }) + const dialog = useTerminalEditorCloseDialogActions({ ...projection, ...foundation, ...queue }) + const bulk = useTerminalBulkCloseActions({ ...projection, ...queue }) + return { ...foundation, ...queue, ...dialog, ...bulk } + }) +} + +function seedEmptyPlacementWorkspace() { + seedEditorPlacementConsumers() + useAppStore.setState({ + openFiles: [], + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + tabBarOrderByWorktree: {} + }) +} + +async function openSharedBufferPlacements() { + seedEmptyPlacementWorkspace() + main = await drawEditorPlacementConsumers() + const file = { + filePath: '/repo/a.ts', + relativePath: 'a.ts', + language: 'typescript', + runtimeEnvironmentId: null, + mode: 'edit' as const + } + const explorerId = useAppStore + .getState() + .openFile( + { ...file, worktreeId: EDITOR_PLACEMENT }, + { executionHostId: 'local', preview: false } + ) + await main.settle() + const childId = useAppStore + .getState() + .openFile( + { ...file, worktreeId: EDITOR_OWNER }, + { placementWorkspaceId: EDITOR_PLACEMENT, executionHostId: 'local', preview: false } + ) + await main.settle() + expect(explorerId).toBe('/repo/a.ts') + expect(childId).toBe('editor:repo1%3A%3A%2Frepo:local:%2Frepo%2Fa.ts') + expect(useAppStore.getState().editorBufferIdOf[childId]).toBe( + useAppStore.getState().editorBufferIdOf[explorerId] + ) + useAppStore.getState().setEditorDraft(explorerId, 'unsaved shared text') + useAppStore.getState().markFileDirty(explorerId, true) + return { explorerId, childId } +} + +it('prompts once for distinct records sharing a buffer and retains the final tab after Save refusal', async () => { + const { childId } = await openSharedBufferPlacements() + const finalTab = useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT]!.at(-1)! + const hook = mountCloseActions() + act(() => hook.result.current.handleCloseAllFiles()) + await act(async () => main!.settle()) + expect(hook.result.current.saveDialogFileId).toBe(childId) + expect(hook.result.current.queuedEditorCloseTabId(childId)).toBe(finalTab.id) + expect( + useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT]?.map((tab) => tab.id) + ).toEqual([finalTab.id]) + expect(useAppStore.getState().editorDrafts[childId]).toBe('unsaved shared text') + let allowSave = false + const onSave = (event: Event) => { + if (!(event instanceof CustomEvent)) { + throw new Error('Expected save-and-close details') + } + const detail: EditorSaveAndCloseDetail = event.detail + expect(detail).toMatchObject({ fileId: childId, tabId: finalTab.id }) + if (!allowSave) { + detail.onNotClosed?.('not-saved') + return + } + useAppStore.getState().markFileDirty(childId, false) + useAppStore.getState().closeUnifiedTab(finalTab.id) + } + window.addEventListener(ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, onSave) + try { + await act(async () => hook.result.current.handleSaveDialogSave()) + expect(hook.result.current.saveDialogFileId).toBe(childId) + expect(useAppStore.getState().editorDrafts[childId]).toBe('unsaved shared text') + expect( + useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT]?.map((tab) => tab.id) + ).toEqual([finalTab.id]) + allowSave = true + await act(async () => { + await hook.result.current.handleSaveDialogSave() + await main!.settle() + }) + } finally { + window.removeEventListener(ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, onSave) + } + expect(hook.result.current.saveDialogFileId).toBeNull() + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT] ?? []).toEqual([]) + expect(useAppStore.getState().openFiles).toEqual([]) +}) + +it.each(['visible', 'pending'] as const)( + 'keeps the shared buffer without prompting when a %s child view survives Close All', + async (survivor) => { + const { childId } = await openSharedBufferPlacements() + let settlePending: (() => void) | undefined + if (survivor === 'visible') { + const file = useAppStore.getState().openFiles.find((file) => file.id === childId)! + useAppStore.getState().openFile(file, { placementWorkspaceId: EDITOR_OWNER, preview: false }) + await main!.settle() + } else { + const settle = beginPendingTab('pending-child-view', EDITOR_OWNER, childId, { kind: 'open' }) + settlePending = () => settle(null) + } + try { + const hook = mountCloseActions() + act(() => hook.result.current.handleCloseAllFiles()) + await act(async () => main!.settle()) + expect(hook.result.current.saveDialogFileId).toBeNull() + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT] ?? []).toEqual([]) + expect(useAppStore.getState().editorDrafts[childId]).toBe('unsaved shared text') + expect(useAppStore.getState().openFiles.find((file) => file.id === childId)?.isDirty).toBe( + true + ) + if (survivor === 'visible') { + expect( + useAppStore.getState().unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.entityId) + ).toEqual([childId]) + } + } finally { + settlePending?.() + } + } +) + +it('close-all removes folder placements while retaining child content, drafts and its own tab', async () => { + const file = seedEditorPlacementConsumers({ mode: 'edit' }) + main = await drawEditorPlacementConsumers() + useAppStore.getState().setEditorDraft(file.id, 'unsaved text') + useAppStore.getState().markFileDirty(file.id, true) + const hook = mountCloseActions() + act(() => hook.result.current.handleCloseAllFiles()) + await main.settle() + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT] ?? []).toEqual([]) + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual([ + 'y-tab' + ]) + expect(useAppStore.getState().editorDrafts[file.id]).toBe('unsaved text') + expect(hook.result.current.saveDialogFileId).toBeNull() + expect(useAppStore.getState().recentlyClosedEditorTabsByWorktree[EDITOR_PLACEMENT]).toHaveLength( + 2 + ) +}) + +it('confirms only the final dirty placement, keeps its tab through Save, and spares a later sibling', async () => { + const file = seedEditorPlacementConsumers({ mode: 'edit' }, false) + main = await drawEditorPlacementConsumers() + useAppStore.getState().setEditorDraft(file.id, 'unsaved text') + useAppStore.getState().markFileDirty(file.id, true) + const hook = mountCloseActions() + act(() => hook.result.current.handleCloseAllFiles()) + await act(async () => main!.settle()) + expect(hook.result.current.saveDialogFileId).toBe(file.id) + expect(hook.result.current.queuedEditorCloseTabId(file.id)).toBe('x-other-tab') + expect( + useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT]?.map((tab) => tab.id) + ).toEqual(['x-other-tab']) + useAppStore.getState().openFile(file, { placementWorkspaceId: EDITOR_OWNER, preview: false }) + await act(async () => main!.settle()) + const onSave = (event: Event) => { + if (!(event instanceof CustomEvent)) { + throw new Error('Expected save-and-close details') + } + const detail: EditorSaveAndCloseDetail = event.detail + expect(detail).toMatchObject({ fileId: file.id, tabId: 'x-other-tab' }) + useAppStore.getState().markFileDirty(file.id, false) + useAppStore.getState().closeUnifiedTab(detail.tabId!) + } + window.addEventListener(ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, onSave) + try { + await act(async () => { + await hook.result.current.handleSaveDialogSave() + await main!.settle() + }) + } finally { + window.removeEventListener(ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, onSave) + } + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT] ?? []).toEqual([]) + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_OWNER]).toHaveLength(1) + expect(useAppStore.getState().openFiles).toHaveLength(1) +}) + +it('does not discard a shared dirty record when another view opens during confirmation', async () => { + const file = seedEditorPlacementConsumers({ mode: 'edit' }, false) + main = await drawEditorPlacementConsumers() + useAppStore.getState().setEditorDraft(file.id, 'keep these edits') + useAppStore.getState().markFileDirty(file.id, true) + const hook = mountCloseActions() + act(() => hook.result.current.handleCloseAllFiles()) + await act(async () => main!.settle()) + useAppStore.getState().openFile(file, { placementWorkspaceId: EDITOR_OWNER, preview: false }) + await act(async () => main!.settle()) + await act(async () => { + await hook.result.current.handleSaveDialogDiscard() + await main!.settle() + }) + expect(useAppStore.getState().editorDrafts[file.id]).toBe('keep these edits') + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_OWNER]).toHaveLength(1) + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT] ?? []).toEqual([]) +}) + +it('keeps the placed content in mixed order when agent launch persists tab order', () => { + seedEditorPlacementConsumers() + persistAgentLaunchTabOrder(EDITOR_PLACEMENT, 'new-agent') + expect(useAppStore.getState().tabBarOrderByWorktree[EDITOR_PLACEMENT]).toEqual([ + 'content', + 'new-agent' + ]) +}) + +it.each([3, 30])( + 'publishes one recent-close history update for %i folder placements', + async (count) => { + seedEditorPlacementConsumers() + const source = useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT]![0]! + const views = Array.from({ length: count }, (_, index) => ({ + ...source, + id: `folder-view-${index}`, + entityId: `folder-content-${index}` + })) + const file = useAppStore.getState().openFiles[0]! + useAppStore.setState({ + openFiles: [ + file, + ...views.map((view, index) => ({ + ...file, + id: view.entityId, + filePath: `/repo/file-${index}.md`, + relativePath: `file-${index}.md` + })) + ], + unifiedTabsByWorktree: { + ...useAppStore.getState().unifiedTabsByWorktree, + [EDITOR_PLACEMENT]: views + }, + groupsByWorktree: { + ...useAppStore.getState().groupsByWorktree, + [EDITOR_PLACEMENT]: [ + { + id: 'x-source', + worktreeId: EDITOR_PLACEMENT, + activeTabId: views[0]!.id, + tabOrder: views.map((tab) => tab.id) + } + ] + }, + layoutByWorktree: { + ...useAppStore.getState().layoutByWorktree, + [EDITOR_PLACEMENT]: { type: 'leaf', groupId: 'x-source' } + }, + activeGroupIdByWorktree: { + ...useAppStore.getState().activeGroupIdByWorktree, + [EDITOR_PLACEMENT]: 'x-source' + } + }) + main = await drawEditorPlacementConsumers() + let historyUpdates = 0 + const unsubscribe = useAppStore.subscribe((state, previous) => { + if ( + state.recentlyClosedEditorTabsByWorktree !== previous.recentlyClosedEditorTabsByWorktree + ) { + historyUpdates += 1 + } + }) + try { + useAppStore.getState().closeUnifiedTabs(views.map((tab) => tab.id)) + await main.settle() + } finally { + unsubscribe() + } + expect(historyUpdates).toBe(1) + expect(useAppStore.getState().unifiedTabsByWorktree[EDITOR_PLACEMENT] ?? []).toEqual([]) + expect( + useAppStore.getState().unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id) + ).toEqual(['y-tab']) + } +) + +it('retains placement order across browser, terminal and agent openings', async () => { + seedEditorPlacementConsumers() + main = await drawEditorPlacementConsumers() + useAppStore.getState().createBrowserTab(EDITOR_PLACEMENT, 'https://example.com') + await main.settle() + const browserId = useAppStore.getState().browserTabsByWorktree[EDITOR_PLACEMENT]![0]!.id + expect(useAppStore.getState().tabBarOrderByWorktree[EDITOR_PLACEMENT]).toEqual([ + 'content', + browserId + ]) + await openNewTerminalTabInWorkspace(useAppStore.getState, EDITOR_PLACEMENT) + await main.settle() + const terminalId = useAppStore.getState().tabsByWorktree[EDITOR_PLACEMENT]![0]!.id + expect(useAppStore.getState().tabBarOrderByWorktree[EDITOR_PLACEMENT]).toEqual([ + 'content', + browserId, + terminalId + ]) + persistAgentLaunchTabOrder(EDITOR_PLACEMENT, terminalId) + expect(useAppStore.getState().tabBarOrderByWorktree[EDITOR_PLACEMENT]).toEqual([ + 'content', + browserId, + terminalId + ]) + const secondBrowser = useAppStore + .getState() + .createBrowserTab(EDITOR_PLACEMENT, 'https://example.org') + await main.settle() + expect(useAppStore.getState().tabBarOrderByWorktree[EDITOR_PLACEMENT]).toEqual([ + 'content', + browserId, + terminalId, + secondBrowser.id + ]) + expect( + useAppStore + .getState() + .unifiedTabsByWorktree[EDITOR_PLACEMENT]?.filter((tab) => tab.entityId === 'content') + .map((tab) => tab.id) + ).toEqual(['x-source-tab', 'x-other-tab']) +}) + +it('Cmd+J keeps an ordinary editor between the existing and new terminal', async () => { + seedEmptyPlacementWorkspace() + main = await drawEditorPlacementConsumers() + await openNewTerminalTabInWorkspace(useAppStore.getState, EDITOR_OWNER) + await main.settle() + const firstTerminal = useAppStore.getState().tabsByWorktree[EDITOR_OWNER]![0]!.id + const fileId = useAppStore.getState().openFile( + { + filePath: '/repo/a.ts', + relativePath: 'a.ts', + language: 'typescript', + mode: 'edit', + worktreeId: EDITOR_OWNER, + runtimeEnvironmentId: null + }, + { preview: false, executionHostId: 'local' } + ) + await main.settle() + expect(fileId).toBe('/repo/a.ts') + expect(useAppStore.getState().tabBarOrderByWorktree[EDITOR_OWNER]).toEqual([ + firstTerminal, + '/repo/a.ts' + ]) + await openNewTerminalTabInWorkspace(useAppStore.getState, EDITOR_OWNER) + await main.settle() + const secondTerminal = useAppStore.getState().tabsByWorktree[EDITOR_OWNER]![1]!.id + expect(useAppStore.getState().tabBarOrderByWorktree[EDITOR_OWNER]).toEqual([ + firstTerminal, + '/repo/a.ts', + secondTerminal + ]) +}) diff --git a/src/renderer/src/components/use-terminal-bulk-close-actions.ts b/src/renderer/src/components/use-terminal-bulk-close-actions.ts index dfd98912e02..57de61d7b8e 100644 --- a/src/renderer/src/components/use-terminal-bulk-close-actions.ts +++ b/src/renderer/src/components/use-terminal-bulk-close-actions.ts @@ -1,6 +1,8 @@ import { useCallback } from 'react' import { useAppStore } from '../store' -import { needsSavePromptOnClose } from '@/store/slices/editor/buffers/editor-buffer-close-rule' +import { editorTabsNeedingSavePrompt } from '@/store/slices/editor/tabs/editor-tab-close-target' +import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type' +import { afterPendingTabs } from '@/runtime/layout-owner-pending-tabs' import { hasEditorFrameFlush, runAfterEditorFramesFlush @@ -9,10 +11,7 @@ import { closeTerminalTab } from './terminal/terminal-tab-actions' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close' import { destroyWorkspaceWebviews } from '../store/slices/browser-webview-cleanup' -import { - getActiveWorktreeRuntimeEnvironmentId, - isPinnedEditorFileTab -} from './terminal-workspace-model' +import { getActiveWorktreeRuntimeEnvironmentId } from './terminal-workspace-model' import type { TerminalCloseController } from './use-terminal-close-actions' import { useBulkTabCloseConfirmation } from './use-bulk-tab-close-confirmation' @@ -22,8 +21,7 @@ export function useTerminalBulkCloseActions( 'activeWorktreeId' | 'closeBrowserTab' | 'closeFile' | 'closeTab' | 'queueEditorCloseRequests' > ) { - const { activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests } = - controller + const { activeWorktreeId, closeBrowserTab, closeTab, queueEditorCloseRequests } = controller const confirmBulkClose = useBulkTabCloseConfirmation() const closeTabBarTabsNow = useCallback( (tabIds: string[]) => { @@ -31,14 +29,29 @@ export function useTerminalBulkCloseActions( return } const state = useAppStore.getState() - const dirtyFileIds: string[] = [] - for (const id of tabIds) { - const unifiedTab = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).find( - (candidate) => candidate.id === id || candidate.entityId === id + const tabs = state.unifiedTabsByWorktree[activeWorktreeId] ?? [] + const byId = new Map(tabs.map((tab) => [tab.id, tab])) + const byEntity = new Map(tabs.map((tab) => [tab.entityId, tab])) + const selectedEditors = [ + ...new Set( + tabIds.flatMap((id) => { + const tab = byId.get(id) ?? byEntity.get(id) + return tab && !tab.isPinned && isEditorTabContentType(tab.contentType) ? [tab] : [] + }) ) + ] + const promptIds = editorTabsNeedingSavePrompt(state, selectedEditors) + const closingEditorIds = selectedEditors + .filter((tab) => !promptIds.has(tab.id)) + .map((tab) => tab.id) + for (const id of tabIds) { + const unifiedTab = byId.get(id) ?? byEntity.get(id) if (unifiedTab?.isPinned) { continue } + if (unifiedTab && isEditorTabContentType(unifiedTab.contentType)) { + continue + } let browserCloseOptions: { reason: 'cleanup' } | undefined if (unifiedTab?.contentType === 'browser') { const plan = closeBrowserWorkspaceTabOnHosts({ @@ -71,15 +84,6 @@ export function useTerminalBulkCloseActions( } if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) { closeTab(id) - } else if ( - state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id) - ) { - // Why fresh state: an earlier view of the same record may have just closed. - if (needsSavePromptOnClose(useAppStore.getState(), id)) { - dirtyFileIds.push(id) - continue - } - closeFile(id) } else if ( (state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id) ) { @@ -90,20 +94,23 @@ export function useTerminalBulkCloseActions( state.closeUnifiedTab(unifiedTab.id) } } - if (dirtyFileIds.length > 0) { - queueEditorCloseRequests(dirtyFileIds) + state.closeUnifiedTabs(closingEditorIds) + if (promptIds.size > 0) { + afterPendingTabs(closingEditorIds, () => queueEditorCloseRequests([...promptIds])) } }, - [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] + [activeWorktreeId, closeBrowserTab, closeTab, queueEditorCloseRequests] ) const closeTabBarTabs = useCallback( (tabIds: string[]) => { const state = useAppStore.getState() const tabs = activeWorktreeId ? (state.unifiedTabsByWorktree[activeWorktreeId] ?? []) : [] - const fileIdOf = (id: string): string => tabs.find((tab) => tab.id === id)?.entityId ?? id + const entityByTab = new Map(tabs.map((tab) => [tab.id, tab.entityId])) + const fileIdOf = (id: string): string => entityByTab.get(id) ?? id // Why: a custom editor frame may still hold edits the dirty checks must see; only its tabs wait. const framed = tabIds.filter((id) => hasEditorFrameFlush(state, fileIdOf(id))) - closeTabBarTabsNow(tabIds.filter((id) => !framed.includes(id))) + const framedIds = new Set(framed) + closeTabBarTabsNow(tabIds.filter((id) => !framedIds.has(id))) if (framed.length > 0) { runAfterEditorFramesFlush(state, framed.map(fileIdOf), () => closeTabBarTabsNow(framed)) } @@ -158,34 +165,13 @@ export function useTerminalBulkCloseActions( } confirmBulkClose( 'editors', - () => { - const state = useAppStore.getState() - return state.openFiles - .filter( - (file) => - file.worktreeId === activeWorktreeId && - !isPinnedEditorFileTab(state, activeWorktreeId, file.id) - ) - .map((file) => file.id) - }, - (fileIds) => - runAfterEditorFramesFlush(useAppStore.getState(), fileIds, () => { - const selectedIds = new Set(fileIds) - const files = useAppStore.getState().openFiles.filter((file) => selectedIds.has(file.id)) - const dirtyFileIds: string[] = [] - for (const file of files) { - if (needsSavePromptOnClose(useAppStore.getState(), file.id)) { - dirtyFileIds.push(file.id) - } else { - closeFile(file.id) - } - } - if (dirtyFileIds.length > 0) { - queueEditorCloseRequests(dirtyFileIds) - } - }) + () => + (useAppStore.getState().unifiedTabsByWorktree[activeWorktreeId] ?? []) + .filter((tab) => isEditorTabContentType(tab.contentType) && !tab.isPinned) + .map((tab) => tab.id), + closeTabBarTabs ) - }, [activeWorktreeId, closeFile, confirmBulkClose, queueEditorCloseRequests]) + }, [activeWorktreeId, closeTabBarTabs, confirmBulkClose]) return { closeTabBarTabs, diff --git a/src/renderer/src/components/use-terminal-create-actions.test.tsx b/src/renderer/src/components/use-terminal-create-actions.test.tsx index b3409554352..d63d33bd7fc 100644 --- a/src/renderer/src/components/use-terminal-create-actions.test.tsx +++ b/src/renderer/src/components/use-terminal-create-actions.test.tsx @@ -109,6 +109,7 @@ describe('useTerminalCreateActions creation gates', () => { mocks.state = { ...mocks.state, tabsByWorktree: { [WORKTREE_ID]: terminals }, + unifiedTabsByWorktree: {}, openFiles: [], tabBarOrderByWorktree: { [WORKTREE_ID]: ['browser-1', 'term-a', 'browser-1'] }, setTabBarOrder diff --git a/src/renderer/src/components/use-terminal-editor-close-dialog-actions.ts b/src/renderer/src/components/use-terminal-editor-close-dialog-actions.ts index 86f67d9130c..757f8a5e41e 100644 --- a/src/renderer/src/components/use-terminal-editor-close-dialog-actions.ts +++ b/src/renderer/src/components/use-terminal-editor-close-dialog-actions.ts @@ -1,3 +1,7 @@ +import { + editorCloseTarget, + editorTabNeedsSavePrompt +} from '@/store/slices/editor/tabs/editor-tab-close-target' import { useCallback, useEffect } from 'react' import { toast } from 'sonner' import { useAppStore } from '../store' @@ -17,6 +21,7 @@ import type { TerminalEditorCloseQueueController } from './use-terminal-editor-c export type TerminalEditorCloseDialogActionsInput = Pick< TerminalEditorCloseQueueController, + | 'queuedEditorCloseTabId' | 'advanceEditorCloseQueue' | 'closedReactionsRef' | 'inFlightSaveFileIdRef' @@ -36,6 +41,7 @@ export function useTerminalEditorCloseDialogActions( ) { const { advanceEditorCloseQueue, + queuedEditorCloseTabId, closedReactionsRef, inFlightSaveFileIdRef, isClosingRef, @@ -54,6 +60,7 @@ export function useTerminalEditorCloseDialogActions( } isClosingRef.current = true const fileId = saveDialogFileId + const tabId = queuedEditorCloseTabId(fileId) const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === fileId) if (!file) { settleQueuedClose(fileId) @@ -67,6 +74,7 @@ export function useTerminalEditorCloseDialogActions( const notClosed = new Promise((resolve) => { const detail: EditorSaveAndCloseDetail = { fileId, + tabId, onNotClosed: (reason) => { notClosedReason = reason resolve(false) @@ -78,7 +86,7 @@ export function useTerminalEditorCloseDialogActions( let closed = false try { // Why the race: a refused save keeps the tab open, so don't wait out the timeout for it. - closed = await Promise.race([waitForFileClosed(fileId, 10_000), notClosed]) + closed = await Promise.race([waitForFileClosed(fileId, 10_000, tabId), notClosed]) } finally { if (inFlightSaveFileIdRef.current === fileId) { inFlightSaveFileIdRef.current = null @@ -113,6 +121,7 @@ export function useTerminalEditorCloseDialogActions( releaseCloseDialogGuardAfterDebounce() }, [ advanceEditorCloseQueue, + queuedEditorCloseTabId, inFlightSaveFileIdRef, isClosingRef, releaseCloseDialogGuardAfterDebounce, @@ -129,12 +138,22 @@ export function useTerminalEditorCloseDialogActions( isClosingRef.current = true const fileId = saveDialogFileId setSaveDialogFileId(null) - await discardEditorFileChangesAndClose(fileId) + const tabId = queuedEditorCloseTabId(fileId) + const state = useAppStore.getState() + const tab = tabId ? editorCloseTarget(state, tabId).tab : undefined + if (tabId && (!tab || !editorTabNeedsSavePrompt(state, tab))) { + if (tab) { + state.closeUnifiedTab(tab.id) + } + } else { + await discardEditorFileChangesAndClose(fileId, tabId) + } settleQueuedClose(fileId) advanceEditorCloseQueue() releaseCloseDialogGuardAfterDebounce() }, [ advanceEditorCloseQueue, + queuedEditorCloseTabId, isClosingRef, releaseCloseDialogGuardAfterDebounce, saveDialogFileId, @@ -172,7 +191,7 @@ export function useTerminalEditorCloseDialogActions( if (onClosed) { closedReactionsRef.current.set(fileId, onClosed) } - queueEditorCloseRequests([fileId]) + queueEditorCloseRequests([customEvent.detail.tabId ?? fileId]) } window.addEventListener( ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, diff --git a/src/renderer/src/components/use-terminal-editor-close-queue.ts b/src/renderer/src/components/use-terminal-editor-close-queue.ts index a8a1d888c46..ab7ab95172d 100644 --- a/src/renderer/src/components/use-terminal-editor-close-queue.ts +++ b/src/renderer/src/components/use-terminal-editor-close-queue.ts @@ -1,9 +1,18 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { useAppStore } from '../store' import { needsSavePromptOnClose } from '@/store/slices/editor/buffers/editor-buffer-close-rule' import { runAfterEditorFramesFlush } from './editor/save/flush/editor-pending-flush' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import { isPinnedActiveEditorTab } from './terminal-workspace-model' +import { + editorCloseTarget, + editorTabNeedsSavePrompt +} from '@/store/slices/editor/tabs/editor-tab-close-target' +import { + editorPlacementIndex, + openFileIndex +} from '@/store/slices/editor/tabs/workspace-editor-files' +import { editorViewTab } from '@/store/slices/editor/tabs/editor-view-placement' import { selectWorkspaceSurface } from '@/lib/workspace-surface-reveal' import type { TerminalEditorCloseFoundation } from './use-terminal-editor-close-foundation' @@ -20,33 +29,46 @@ export function useTerminalEditorCloseQueue(controller: TerminalEditorCloseFound setSaveDialogFileId, windowCloseAfterDirtyRef } = controller - const waitForFileClosed = useCallback((fileId: string, timeoutMs: number): Promise => { - if (!useAppStore.getState().openFiles.some((file) => file.id === fileId)) { - return Promise.resolve(true) - } - return new Promise((resolve) => { - let unsub: (() => void) | null = null - const timeoutId = window.setTimeout(() => { - unsub?.() - resolve(false) - }, timeoutMs) - unsub = useAppStore.subscribe((state) => { - if (!state.openFiles.some((file) => file.id === fileId)) { + const closeTabIds = useRef(new Map()) + const queuedEditorCloseTabId = useCallback( + (fileId: string) => closeTabIds.current.get(fileId), + [] + ) + const waitForFileClosed = useCallback( + (fileId: string, timeoutMs: number, tabId?: string): Promise => { + const isClosed = (state: ReturnType) => + tabId + ? !editorPlacementIndex(state.unifiedTabsByWorktree).workspaceByTabId.has(tabId) + : !openFileIndex(state.openFiles).has(fileId) + if (isClosed(useAppStore.getState())) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + let unsub: (() => void) | null = null + const timeoutId = window.setTimeout(() => { + unsub?.() + resolve(false) + }, timeoutMs) + unsub = useAppStore.subscribe((state) => { + if (isClosed(state)) { + window.clearTimeout(timeoutId) + unsub?.() + resolve(true) + } + }) + if (isClosed(useAppStore.getState())) { window.clearTimeout(timeoutId) unsub?.() resolve(true) } }) - if (!useAppStore.getState().openFiles.some((file) => file.id === fileId)) { - window.clearTimeout(timeoutId) - unsub?.() - resolve(true) - } - }) - }, []) + }, + [] + ) const settleQueuedClose = useCallback( (fileId: string) => { + closeTabIds.current.delete(fileId) pendingEditorCloseQueueRef.current = pendingEditorCloseQueueRef.current.filter( (queuedId) => queuedId !== fileId ) @@ -64,12 +86,22 @@ export function useTerminalEditorCloseQueue(controller: TerminalEditorCloseFound return null } const state = useAppStore.getState() - if (!state.openFiles.some((candidate) => candidate.id === fileId)) { + if (!openFileIndex(state.openFiles).has(fileId)) { settleQueuedClose(fileId) continue } - if (!needsSavePromptOnClose(state, fileId)) { - closeFile(fileId) + const tabId = closeTabIds.current.get(fileId) + const tab = tabId ? editorCloseTarget(state, tabId).tab : undefined + if (tabId && !tab) { + settleQueuedClose(fileId) + continue + } + if (tab ? !editorTabNeedsSavePrompt(state, tab) : !needsSavePromptOnClose(state, fileId)) { + if (tab) { + state.closeUnifiedTab(tab.id) + } else { + closeFile(fileId) + } settleQueuedClose(fileId) continue } @@ -82,12 +114,18 @@ export function useTerminalEditorCloseQueue(controller: TerminalEditorCloseFound const nextFileId = getNextQueuedEditorClose() if (nextFileId) { const state = useAppStore.getState() - const file = state.openFiles.find((candidate) => candidate.id === nextFileId) - if (file && file.worktreeId !== state.activeWorktreeId) { - selectWorkspaceSurface(state, file.worktreeId) + const file = openFileIndex(state.openFiles).get(nextFileId) + const tabId = closeTabIds.current.get(nextFileId) + const tab = tabId ? editorCloseTarget(state, tabId).tab : undefined + const workspaceId = tab?.worktreeId ?? file?.worktreeId ?? state.activeWorktreeId + if (workspaceId && workspaceId !== state.activeWorktreeId) { + selectWorkspaceSurface(state, workspaceId) } - setActiveFile(nextFileId) - setActiveTabType('editor', file?.worktreeId ?? state.activeWorktreeId) + if (tab) { + state.activateTab(tab.id, { preservePreview: true }) + } + setActiveFile(nextFileId, workspaceId ?? undefined) + setActiveTabType('editor', workspaceId) setSaveDialogFileId(nextFileId) return } @@ -111,9 +149,25 @@ export function useTerminalEditorCloseQueue(controller: TerminalEditorCloseFound if (pendingWindowClose) { windowCloseAfterDirtyRef.current = pendingWindowClose } + const state = useAppStore.getState() + if (pendingEditorCloseQueueRef.current.length === 0) { + closeTabIds.current.clear() + } + const requests = fileIds.flatMap((id) => { + const { file, tab } = editorCloseTarget(state, id) + if (!file) { + return [] + } + if (tab && !pendingWindowClose) { + closeTabIds.current.set(file.id, tab.id) + } else { + closeTabIds.current.delete(file.id) + } + return [file.id] + }) pendingEditorCloseQueueRef.current = appendUniqueOpenFileIds( pendingEditorCloseQueueRef.current, - fileIds, + requests, new Set(useAppStore.getState().openFiles.map((file) => file.id)) ) advanceEditorCloseQueue() @@ -122,25 +176,44 @@ export function useTerminalEditorCloseQueue(controller: TerminalEditorCloseFound ) const handleCloseFile = useCallback( - (fileId: string) => { + (id: string) => { const state = useAppStore.getState() - if (activeWorktreeId && isPinnedActiveEditorTab(state, activeWorktreeId, fileId)) { + const requested = editorCloseTarget(state, id) + const file = requested.file + if (!file) { return } - // Why: a custom editor frame may still hold edits the dirty check must see. - runAfterEditorFramesFlush(state, [fileId], () => { + const tab = requested.tab ?? editorViewTab(state, file.id) + if ( + tab?.isPinned || + (activeWorktreeId && !tab && isPinnedActiveEditorTab(state, activeWorktreeId, file.id)) + ) { + return + } + runAfterEditorFramesFlush(state, [file.id], () => { const latest = useAppStore.getState() - if (needsSavePromptOnClose(latest, fileId)) { - queueEditorCloseRequests([fileId]) + const live = tab ? editorCloseTarget(latest, tab.id).tab : undefined + if (tab && !live) { return } - closeFile(fileId) + if ( + live ? editorTabNeedsSavePrompt(latest, live) : needsSavePromptOnClose(latest, file.id) + ) { + queueEditorCloseRequests([live?.id ?? file.id]) + return + } + if (live) { + latest.closeUnifiedTab(live.id) + } else { + closeFile(file.id) + } }) }, [activeWorktreeId, closeFile, queueEditorCloseRequests] ) return { + queuedEditorCloseTabId, waitForFileClosed, settleQueuedClose, getNextQueuedEditorClose, diff --git a/src/renderer/src/components/wsl-host-stopped/use-stopped-wsl-host.ts b/src/renderer/src/components/wsl-host-stopped/use-stopped-wsl-host.ts index ae12b48bfee..e6489a7049e 100644 --- a/src/renderer/src/components/wsl-host-stopped/use-stopped-wsl-host.ts +++ b/src/renderer/src/components/wsl-host-stopped/use-stopped-wsl-host.ts @@ -1,5 +1,5 @@ +import { useAppStateValue } from '@/store/app-state-view' import { useMemo } from 'react' -import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { selectStoppedWslDistro } from '@/lib/wsl-host-stopped-status' import { selectWorkspaceRuntimeEnvironmentId } from '../terminal-pane/remote-server-outage' @@ -31,8 +31,10 @@ export function selectStoppedWslHostEnvironmentId( } export function useStoppedWslHost(worktreeId: string | null | undefined): StoppedWslHost | null { - const environmentId = useAppStore((state) => selectStoppedWslHostEnvironmentId(state, worktreeId)) - const distro = useAppStore((state) => selectStoppedWslDistro(state, environmentId)) + const environmentId = useAppStateValue((state) => + selectStoppedWslHostEnvironmentId(state, worktreeId) + ) + const distro = useAppStateValue((state) => selectStoppedWslDistro(state, environmentId)) return useMemo( () => (environmentId && distro ? { environmentId, distro } : null), [environmentId, distro] diff --git a/src/renderer/src/hooks/editor-external-watch-scope.ts b/src/renderer/src/hooks/editor-external-watch-scope.ts index 83ee2a1eb59..b2f4f3ba7ea 100644 --- a/src/renderer/src/hooks/editor-external-watch-scope.ts +++ b/src/renderer/src/hooks/editor-external-watch-scope.ts @@ -19,6 +19,7 @@ function mostRecent( /** Watch active/recent workspaces plus bounded deleted/renamed-tab pins. */ export function selectWatchEligibleWorkspaceIds(args: { activeWorktreeId: string | null + sourceControlWorkspaceIds?: readonly string[] lastVisitedAtByWorktreeId: Readonly> | undefined openFiles: AppState['openFiles'] exists: (workspaceId: string) => boolean @@ -33,9 +34,14 @@ export function selectWatchEligibleWorkspaceIds(args: { const id = getWorktreeIdFromVisitKey(key) visitedAtById.set(id, Math.max(visitedAtById.get(id) ?? 0, visitedAt)) } + // Why outside the budget: displayed Source Control sections never take a recently used workspace's slot. + const sectionIds = new Set(args.sourceControlWorkspaceIds ?? []) + for (const id of sectionIds) { + ids.add(id) + } const warmCandidates = [...visitedAtById.keys()].filter( // Why: `exists` keeps deleted workspaces from holding warm slots. - (id) => id !== args.activeWorktreeId && args.exists(id) + (id) => !sectionIds.has(id) && id !== args.activeWorktreeId && args.exists(id) ) for (const id of mostRecent( warmCandidates, diff --git a/src/renderer/src/hooks/editor-external-watch-targets.ts b/src/renderer/src/hooks/editor-external-watch-targets.ts index 281b8acdb57..fc88cf9e93b 100644 --- a/src/renderer/src/hooks/editor-external-watch-targets.ts +++ b/src/renderer/src/hooks/editor-external-watch-targets.ts @@ -59,6 +59,8 @@ type WatchedTargetsSnapshot = { unwatchedTargetsKey: string } +const NO_SECTION_TARGETS: readonly EditorExternalWatchTarget[] = [] +let cachedSectionTargets = NO_SECTION_TARGETS let cachedOpenFiles: AppState['openFiles'] | null = null let cachedWorktreesByRepo: AppState['worktreesByRepo'] | null = null let cachedRepos: AppState['repos'] | null = null @@ -147,11 +149,13 @@ function addWatchConsumer( } export function selectEditorExternalWatchTargets( - state: EditorExternalWatchTargetState + state: EditorExternalWatchTargetState, + sectionTargets: readonly EditorExternalWatchTarget[] = NO_SECTION_TARGETS ): WatchedTargetsSnapshot { const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || undefined const activeWorkspaceExecutionHostId = state.activeWorkspaceExecutionHostId ?? null if ( + cachedSectionTargets === sectionTargets && cachedOpenFiles === state.openFiles && cachedWorktreesByRepo === selectWorktreesByRepo(state) && cachedRepos === state.repos && @@ -175,6 +179,7 @@ export function selectEditorExternalWatchTargets( const unwatchedConsumersByWorktreeId = new Map>() const watchEligibleIds = selectWatchEligibleWorkspaceIds({ activeWorktreeId: state.activeWorktreeId, + sourceControlWorkspaceIds: sectionTargets.map((target) => target.worktreeId), lastVisitedAtByWorktreeId: state.lastVisitedAtByWorktreeId, openFiles: state.openFiles, exists: (id) => @@ -229,6 +234,9 @@ export function selectEditorExternalWatchTargets( } const targetsByKey = resolveWatchTargets(state, consumersByWorktreeId) + for (const target of sectionTargets) { + targetsByKey.set(getEditorExternalWatchTargetKey(target), target) + } const unwatchedTargetKeys = new Set( [...resolveWatchTargets(state, unwatchedConsumersByWorktreeId).keys()].filter( (key) => !targetsByKey.has(key) @@ -242,6 +250,7 @@ export function selectEditorExternalWatchTargets( const nextTargets = sortedEntries.map(([, target]) => target) const targetsKey = sortedEntries.map(([key]) => key).join('|') const unwatchedTargetsKey = [...unwatchedTargetKeys].sort().join('|') + cachedSectionTargets = sectionTargets cachedOpenFiles = state.openFiles cachedWorktreesByRepo = selectWorktreesByRepo(state) cachedRepos = state.repos diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts index 64898b7ef13..9d644b6e8f2 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -73,6 +73,7 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { : {}), ...(data.launchToken ? { launchToken: data.launchToken } : {}), ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), + ...(data.trustWorktreeId ? { trustWorktreeId: data.trustWorktreeId } : {}), ...(data.startupCommandDelivery ? { startupCommandDelivery: data.startupCommandDelivery } : {}) diff --git a/src/renderer/src/hooks/source-control-watch-consumers.ts b/src/renderer/src/hooks/source-control-watch-consumers.ts new file mode 100644 index 00000000000..ba1ef59155c --- /dev/null +++ b/src/renderer/src/hooks/source-control-watch-consumers.ts @@ -0,0 +1,57 @@ +import { useEffect, useSyncExternalStore } from 'react' +import type { EditorExternalWatchTarget } from './editor-external-watch-targets' +const consumers = new Map() +const listeners = new Set<() => void>() +let snapshot: readonly EditorExternalWatchTarget[] = [] +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} +const getSnapshot = () => snapshot +let publishQueued = false +// Why no cap: every mounted section is a displayed repository model, like the reference's per-repository watcher. +// Why a microtask: a panel mounting N sections recomputes watch targets once, not N times. +function publish() { + if (publishQueued) { + return + } + publishQueued = true + queueMicrotask(() => { + publishQueued = false + snapshot = [...consumers.values()] + for (const listener of listeners) { + listener() + } + }) +} +/** Folder Source Control sections' watch targets, unioned outside the ordinary warm-workspace budget. */ +export function useSourceControlWatchConsumers() { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} +export function useSourceControlWatchConsumer( + key: string, + target: EditorExternalWatchTarget | null +): void { + const path = target?.worktreePath + const id = target?.worktreeId + const connection = target?.connectionId + const runtime = target?.runtimeEnvironmentId + useEffect(() => { + if (!id || !path) { + return + } + consumers.set(key, { + worktreeId: id, + worktreePath: path, + connectionId: connection, + runtimeEnvironmentId: runtime ?? null + }) + publish() + return () => { + consumers.delete(key) + publish() + } + }, [key, id, path, connection, runtime]) +} diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.ts b/src/renderer/src/hooks/useAgentDetectionTarget.ts index 02ab2ea8c80..e61008893c4 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.ts @@ -1,5 +1,5 @@ +import { useAppStateValue } from '@/store/app-state-view' import { useMemo } from 'react' -import { useAppStore } from '@/store' import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' import { runtimeEnvironmentIdForWorkspaceOwner } from '@/lib/resolve-owner' @@ -107,6 +107,6 @@ export function parseAgentDetectionTargetKey( export function useAgentDetectionTargetForWorktree( worktreeId: string | null ): AgentDetectionTarget | undefined { - const key = useAppStore((s) => getAgentDetectionTargetKeyForWorktree(s, worktreeId)) + const key = useAppStateValue((s) => getAgentDetectionTargetKeyForWorktree(s, worktreeId)) return useMemo(() => parseAgentDetectionTargetKey(key), [key]) } diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index 5518a47f1f7..428b3e82816 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -1,3 +1,4 @@ +import { useAppStateViewActive, useAppStateValue } from '@/store/app-state-view' import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' import { getLocalAgentPreflightContext } from '@/lib/local-preflight-context' @@ -45,6 +46,7 @@ export function useDetectedAgents( connectionId: AgentDetectionTarget | string | null | undefined, inventory: 'enabled' | 'all' = 'enabled' ): UseDetectedAgentsResult { + const viewActive = useAppStateViewActive() const target = normalizeAgentDetectionTarget(connectionId) const observedRemoteTargetKeysRef = useRef>(new Set()) // Undefined ownership stays loading so remote hydration cannot flash local agents. @@ -64,7 +66,7 @@ export function useDetectedAgents( ? getRuntimeAgentInventoryKey(targetId, runtimeWorktreeId) : targetId const includeDisabled = inventory === 'all' - const completeLocalContextKey = useAppStore((s) => + const completeLocalContextKey = useAppStateValue((s) => targetKind === 'local' && includeDisabled ? localAgentInventoryContextKey( getLocalAgentPreflightContext(s, undefined, undefined, localWorktreeId), @@ -81,7 +83,7 @@ export function useDetectedAgents( ? `runtime:${recordKey}` : null // Why: a re-pair clears the record; re-running the effect re-probes for a surface still waiting. - const pairingRevision = useAppStore((s) => { + const pairingRevision = useAppStateValue((s) => { if (targetKind !== 'runtime' || !targetId) { return undefined } @@ -91,7 +93,7 @@ export function useDetectedAgents( // Why: a probe that failed while the host was down must re-run once contact returns, and only then. // 'down' only when the host's status says so; a never-asked host is still probed as before. - const runtimeContactEpoch = useAppStore((s) => { + const runtimeContactEpoch = useAppStateValue((s) => { if (targetKind !== 'runtime' || !targetId) { return null } @@ -106,13 +108,13 @@ export function useDetectedAgents( // The SSH counterpart: its disconnect clears the list, so only the return to connected re-lists it. // Null when the connection has no state yet: probe as before. - const sshConnected = useAppStore((s) => { + const sshConnected = useAppStateValue((s) => { const status = targetKind === 'ssh' && targetId ? s.sshConnectionStates.get(targetId)?.status : undefined return status === undefined ? null : status === 'connected' }) - const detectedIds = useAppStore((s) => { + const detectedIds = useAppStateValue((s) => { if (isUnknown) { return null } @@ -126,7 +128,7 @@ export function useDetectedAgents( ? (s.localDetectedAgentIdsByContext[localContextKey] ?? null) : s.detectedAgentIds }) - const isLoading = useAppStore((s) => { + const isLoading = useAppStateValue((s) => { if (isUnknown) { return true } @@ -140,7 +142,7 @@ export function useDetectedAgents( ? (s.isDetectingLocalAgentsByContext[localContextKey] ?? false) : s.isDetectingAgents }) - const isRefreshing = useAppStore((s) => { + const isRefreshing = useAppStateValue((s) => { if (targetKind === 'runtime' && recordKey) { return s.isRefreshingRuntimeAgents[recordKey] ?? false } @@ -154,7 +156,7 @@ export function useDetectedAgents( ? (s.isRefreshingLocalAgentsByContext[localContextKey] ?? false) : s.isRefreshingAgents }) - const needsServerUpdate = useAppStore((s) => + const needsServerUpdate = useAppStateValue((s) => targetKind === 'runtime' && recordKey ? s.runtimeAgentDetectionNeedsServerUpdate[recordKey] === true : false @@ -186,7 +188,7 @@ export function useDetectedAgents( }, [includeDisabled, isUnknown, localWorktreeId, runtimeWorktreeId, targetKind, targetId]) useEffect(() => { - if (isUnknown) { + if (!viewActive || isUnknown) { return } const isNewRemoteTarget = @@ -234,6 +236,7 @@ export function useDetectedAgents( } } }, [ + viewActive, includeDisabled, isUnknown, targetKind, diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index b56f56549f8..5bdc2463f92 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'react' +import { useSourceControlWatchConsumers } from './source-control-watch-consumers' +import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' import { subscribeEditorRuntimeFileWatch } from './editor-runtime-file-watch' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' @@ -34,7 +35,13 @@ function warnExternalWatchFailure(target: EditorExternalWatchTarget, err: unknow } export function useEditorExternalWatch(): void { - const snapshot = useAppStore(selectEditorExternalWatchTargets) + const sectionTargets = useSourceControlWatchConsumers() + const snapshot = useAppStore( + useCallback( + (state) => selectEditorExternalWatchTargets(state, sectionTargets), + [sectionTargets] + ) + ) const targetsRef = useRef([]) const unwatchedTargetKeysRef = useRef>(new Set()) const remoteWatchUnsubsRef = useRef(new Map void>()) diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts index 68f48956bd2..80c457bfad6 100644 --- a/src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts @@ -15,6 +15,17 @@ vi.mock('@/components/terminal/terminal-tab-actions', () => ({ closeTerminalTab: closeTerminalTabMock })) +vi.mock('@/runtime/runtime-rpc-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await original()) +) +vi.mock('@/runtime/runtime-layout-client', async (original) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await original()) +) + describe('useIpcEvents browser tab close routing', () => { beforeEach(() => { vi.resetModules() @@ -28,27 +39,30 @@ describe('useIpcEvents browser tab close routing', () => { const closeSessionTabListenerRef: { current: CloseSessionTabListener | null } = { current: null } - const closeFile = vi.fn() - const closeUnifiedTab = vi.fn() + const { createEditorTabsStore } = await import('../store/slices/editor-slice-test-harness') + const store = createEditorTabsStore() await useIpcEventsForCloseRouting({ closeSessionTabListenerRef, - getState: () => ({ - recordClientHostedBrowserCloseIntents: vi.fn(), - closeFile, - closeUnifiedTab, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: { - 'wt-1': [{ id: 'host-tab-1', entityId: 'file-1', contentType: 'editor', isPinned: false }] - } - }) + getState: store.getState }) - closeSessionTabListenerRef.current?.({ tabId: 'host-tab-1', worktreeId: 'wt-1' }) + const fileId = store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + const tab = store.getState().unifiedTabsByWorktree['wt-1']?.find((t) => t.entityId === fileId) + expect(tab).toBeDefined() - // Editor close must remove openFiles via closeFile or host publication resurrects it. - expect(closeFile).toHaveBeenCalledWith('file-1') - expect(closeUnifiedTab).not.toHaveBeenCalled() + closeSessionTabListenerRef.current?.({ tabId: tab!.id, worktreeId: 'wt-1' }) + + await vi.waitFor(() => { + expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([]) + expect(store.getState().openFiles).toEqual([]) + }) }) it('acknowledges whole-tab close only after the fresh session is durably persisted', async () => { diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 606012db793..e3491164d63 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -1,3 +1,4 @@ +import { useAppStateValue } from '@/store/app-state-view' import { formatKeybinding, formatKeybindingList, @@ -6,7 +7,6 @@ import { type KeybindingActionId, type KeybindingOverrides } from '../../../shared/keybindings' -import { useAppStore } from '../store' import { getShortcutPlatform } from '../lib/shortcut-platform' export { getShortcutPlatform } @@ -71,7 +71,7 @@ export function formatPrimaryShortcutLabel( } export function useShortcutLabel(actionId: KeybindingActionId): string { - const keybindings = useAppStore((state) => state.keybindings) + const keybindings = useAppStateValue((state) => state.keybindings) return formatShortcutLabel(actionId, keybindings) } @@ -102,7 +102,7 @@ export function formatOptionalPrimaryShortcutLabel( } export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { - const keybindings = useAppStore((state) => state.keybindings) + const keybindings = useAppStateValue((state) => state.keybindings) return formatOptionalShortcutLabel(actionId, keybindings) } @@ -123,7 +123,7 @@ export function formatShortcutKeyComboDetails( export function useShortcutKeyComboDetails( actionId: KeybindingActionId ): ShortcutKeyComboDetails[] { - const keybindings = useAppStore((state) => state.keybindings) + const keybindings = useAppStateValue((state) => state.keybindings) return formatShortcutKeyComboDetails(actionId, keybindings) } diff --git a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts index fe7f9318cf4..3819e0b0c54 100644 --- a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts +++ b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts @@ -1,5 +1,5 @@ +import { useAppStateValue } from '@/store/app-state-view' import type { PublicKnownRuntimeEnvironment } from '../../../shared/remote-runtime/host-catalog/runtime-environments' -import { useAppStore } from '@/store' import { getWindowsTerminalCapabilityOwnerKey } from '@/lib/windows-terminal-capabilities' import { isWebClientLocation } from '@/lib/web-client-location' @@ -39,7 +39,7 @@ export function useWindowsTerminalCapabilityOwnerKey( sshConnectionId?: string | null ): string { const isWebClient = isWebClientLocation() - return useAppStore((state) => + return useAppStateValue((state) => resolveWindowsTerminalCapabilityOwnerKey({ activeRuntimeEnvironmentId, isWebClient, diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 1551c58fd71..ce99a32ad2b 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -13935,7 +13935,8 @@ "clearNotesFromFile_other": "{{count}} Notizen aus {{path}} löschen?", "markResolved": "Als gelöst markieren", "keepFile": "Datei behalten", - "keepDeletion": "Löschung beibehalten" + "keepDeletion": "Löschung beibehalten", + "refreshAll": "Alle aktualisieren" }, "SourceControlAgentActionDialog": { "8e856842d1": "Der ausgewählte Agent konnte nicht gestartet werden.", @@ -18296,6 +18297,10 @@ "actionsUnavailable": "Plugin-Aktionen sind in diesem Client nicht verfügbar.", "messageTooLarge": "Die Nachricht überschreitet die Größenbeschränkung.", "tooManyRequests": "Zu viele Anfragen." + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "Diesen Worktree öffnen", + "reconnect": "Stelle die Verbindung wieder her, um die Änderungen dieses Worktrees zu sehen." } }, "link": { @@ -21015,6 +21020,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "In {{value0}} gestartet", "actionSaveFailed": "Diese Aktion konnte nicht gespeichert werden. In den Einstellungen erneut speichern.", "discard": { "deleteUntrackedFileDescription": "Diese nicht versionierte Datei wird dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b6a40b254b1..c1db9637361 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14294,7 +14294,8 @@ "clearNotesFromFile_other": "Clear {{count}} notes from {{path}}?", "markResolved": "Mark as resolved", "keepFile": "Keep file", - "keepDeletion": "Keep deletion" + "keepDeletion": "Keep deletion", + "refreshAll": "Refresh all" }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", @@ -18695,6 +18696,10 @@ "loadFailedKeptLastGood": "{{value0}} failed to load. Showing the last version that loaded.", "pluginMessage": "Message from {{value0}}: {{value1}}", "reloaded": "Reloaded" + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "Open this worktree", + "reconnect": "Reconnect to see this worktree’s changes." } }, "link": { @@ -21661,6 +21666,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "Started in {{value0}}", "actionSaveFailed": "Could not save this action. Try saving it again in Settings.", "discard": { "deleteUntrackedFileDescription": "This will permanently delete this untracked file. This cannot be undone.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b8f6ec17e7d..3992f14046c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13625,7 +13625,8 @@ "clearNotesFromFile_many": "¿Borrar {{count}} notas de {{path}}?", "markResolved": "Marcar como resuelto", "keepFile": "Conservar archivo", - "keepDeletion": "Mantener eliminación" + "keepDeletion": "Mantener eliminación", + "refreshAll": "Actualizar todo" }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", @@ -18049,6 +18050,10 @@ "actionsUnavailable": "Las acciones de plugins no están disponibles en este cliente.", "messageTooLarge": "El mensaje supera el límite de tamaño.", "tooManyRequests": "Demasiadas solicitudes." + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "Abrir este worktree", + "reconnect": "Vuelve a conectarte para ver los cambios de este worktree." } }, "link": { @@ -21825,6 +21830,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "Iniciado en {{value0}}", "actionSaveFailed": "No se pudo guardar esta acción. Intenta guardarla de nuevo en Ajustes.", "discard": { "deleteUntrackedFileDescription": "Esto eliminará permanentemente este archivo sin seguimiento. No se puede deshacer.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 57471fef88f..cdbdbe8de19 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -13850,7 +13850,8 @@ "clearNotesFromFile_many": "Effacer {{count}} notes de {{path}} ?", "markResolved": "Marquer comme résolu", "keepFile": "Conserver le fichier", - "keepDeletion": "Conserver la suppression" + "keepDeletion": "Conserver la suppression", + "refreshAll": "Tout actualiser" }, "SourceControlAgentActionDialog": { "8e856842d1": "Impossible de démarrer l'agent sélectionné.", @@ -18274,6 +18275,10 @@ "actionsUnavailable": "Les actions de plugin ne sont pas disponibles dans ce client.", "messageTooLarge": "Le message dépasse la taille limite.", "tooManyRequests": "Trop de requêtes." + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "Ouvrir ce worktree", + "reconnect": "Reconnectez-vous pour voir les modifications de ce worktree." } }, "link": { @@ -21363,6 +21368,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "Démarré dans {{value0}}", "actionSaveFailed": "Impossible d’enregistrer cette action. Réessayez dans les paramètres.", "unlinkedPr": { "status": "PR #{{number}} non lié" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6ebe3100fd2..fc6c104fc51 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13297,7 +13297,8 @@ "clearNotesFromFile_other": "{{path}} から {{count}} 件のメモを削除しますか?", "markResolved": "解決済みとしてマーク", "keepFile": "ファイルを残す", - "keepDeletion": "削除を維持" + "keepDeletion": "削除を維持", + "refreshAll": "すべて更新" }, "SourceControlAgentActionDialog": { "8e856842d1": "選択した Agent を開始できませんでした。", @@ -17597,6 +17598,10 @@ "actionsUnavailable": "このクライアントではプラグイン操作を利用できません。", "messageTooLarge": "メッセージがサイズ制限を超えています。", "tooManyRequests": "リクエストが多すぎます。" + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "このワークツリーを開く", + "reconnect": "再接続して、このワークツリーの変更を表示してください。" } }, "link": { @@ -20730,6 +20735,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "{{value0}} で開始しました", "actionSaveFailed": "このアクションを保存できませんでした。設定でもう一度保存してください。", "unlinkedPr": { "status": "PR #{{number}} のリンクが解除されました" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0f23a680ee8..0407a4cda3e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13297,7 +13297,8 @@ "clearNotesFromFile_other": "{{path}}에서 메모 {{count}}개를 지울까요?", "markResolved": "해결됨으로 표시", "keepFile": "파일 유지", - "keepDeletion": "삭제 유지" + "keepDeletion": "삭제 유지", + "refreshAll": "모두 새로 고침" }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", @@ -17597,6 +17598,10 @@ "actionsUnavailable": "이 클라이언트에서는 플러그인 작업을 사용할 수 없습니다.", "messageTooLarge": "메시지가 크기 제한을 초과합니다.", "tooManyRequests": "요청이 너무 많습니다." + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "이 워크트리 열기", + "reconnect": "이 워크트리의 변경 사항을 보려면 다시 연결하세요." } }, "link": { @@ -20730,6 +20735,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "{{value0}}에서 시작했습니다", "actionSaveFailed": "이 작업을 저장하지 못했습니다. 설정에서 다시 저장해 보세요.", "unlinkedPr": { "status": "PR #{{number}} 연결 해제됨" diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 6d5bf189d26..2e0a2e85ebf 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19,6 +19,7 @@ "scanTooLarge": "A varredura da Abertura rápida é grande demais ({{reason}})." }, "sourceControl": { + "sectionAgentStarted": "Iniciado em {{value0}}", "unlinkedPr": { "status": "PR #{{number}} desvinculado" }, @@ -9948,7 +9949,8 @@ "clearNotesFromFile_many": "Limpar {{count}} notas de {{path}}?", "markResolved": "Marcar como resolvido", "keepFile": "Manter arquivo", - "keepDeletion": "Manter exclusão" + "keepDeletion": "Manter exclusão", + "refreshAll": "Atualizar tudo" }, "checks": { "panel": { @@ -18319,6 +18321,10 @@ "actionsUnavailable": "As ações de plugin não estão disponíveis neste cliente.", "messageTooLarge": "A mensagem excede o limite de tamanho.", "tooManyRequests": "Muitas requisições." + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "Abrir este worktree", + "reconnect": "Reconecte para ver as alterações deste worktree." } }, "cmd": { diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 225536eaea1..b115cdbc389 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -14325,7 +14325,8 @@ "clearNotesFromFile_other": "Удалить {{count}} заметки из {{path}}?", "markResolved": "Отметить как решённый", "keepFile": "Сохранить файл", - "keepDeletion": "Сохранить удаление" + "keepDeletion": "Сохранить удаление", + "refreshAll": "Обновить всё" }, "SourceControlAgentActionDialog": { "8e856842d1": "Не удалось запустить выбранного агента.", @@ -18846,6 +18847,10 @@ "actionsUnavailable": "Действия плагина недоступны в этом клиенте.", "messageTooLarge": "Сообщение превышает допустимый размер.", "tooManyRequests": "Слишком много запросов." + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "Открыть это рабочее дерево", + "reconnect": "Подключитесь снова, чтобы увидеть изменения в этом рабочем дереве." } }, "link": { @@ -21705,6 +21710,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "Запущено в {{value0}}", "actionSaveFailed": "Не удалось сохранить действие. Повторите сохранение в настройках.", "discard": { "deleteUntrackedFileDescription": "Этот неотслеживаемый файл будет безвозвратно удалён. Это действие нельзя отменить.", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 65ba7966e34..9192ff70d79 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -13919,7 +13919,8 @@ "clearNotesFromFile_one": "{{path}} dosyasındaki {{count}} not silinsin mi?", "markResolved": "Çözüldü olarak işaretle", "keepFile": "Dosyayı koru", - "keepDeletion": "Silmeyi koru" + "keepDeletion": "Silmeyi koru", + "refreshAll": "Tümünü yenile" }, "SourceControlAgentActionDialog": { "8e856842d1": "Seçilen ajan başlatılamadı.", @@ -18280,6 +18281,10 @@ "actionsUnavailable": "Eklenti işlemleri bu istemcide kullanılamıyor.", "messageTooLarge": "Mesaj boyut sınırını aşıyor.", "tooManyRequests": "Çok fazla istek." + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "Bu çalışma ağacını aç", + "reconnect": "Bu çalışma ağacının değişikliklerini görmek için yeniden bağlanın." } }, "link": { @@ -20996,6 +21001,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "{{value0}} içinde başlatıldı", "actionSaveFailed": "Bu eylem kaydedilemedi. Ayarlar bölümünden yeniden kaydetmeyi deneyin.", "discard": { "deleteUntrackedFileDescription": "Bu işlem, izlenmeyen bu dosyayı kalıcı olarak siler. Bu işlem geri alınamaz.", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 108cc0bdced..86c35d7cb3c 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -13746,7 +13746,8 @@ "clearNotesFromFile_other": "清除 {{path}} 中的 {{count}} 則筆記?", "markResolved": "標記為已解決", "keepFile": "保留檔案", - "keepDeletion": "保留刪除" + "keepDeletion": "保留刪除", + "refreshAll": "全部重新整理" }, "SourceControlAgentActionDialog": { "8e856842d1": "無法啟動選定的代理程式。", @@ -18046,6 +18047,10 @@ "actionsUnavailable": "此用戶端不支援外掛操作。", "messageTooLarge": "訊息超過大小限制。", "tooManyRequests": "請求過多。" + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "開啟此工作樹", + "reconnect": "請重新連線以查看此工作樹的變更。" } }, "link": { @@ -20698,6 +20703,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "已在 {{value0}} 中啟動", "actionSaveFailed": "無法儲存此動作。請在「設定」中再試著儲存。", "discard": { "deleteUntrackedFileDescription": "這將永久刪除此未追蹤的檔案。此操作無法復原。", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 37a60cb252c..1b52c29b39b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13297,7 +13297,8 @@ "clearNotesFromFile_other": "清除 {{path}} 中的 {{count}} 条备注?", "markResolved": "标记为已解决", "keepFile": "保留文件", - "keepDeletion": "保留删除" + "keepDeletion": "保留删除", + "refreshAll": "全部刷新" }, "SourceControlAgentActionDialog": { "8e856842d1": "无法启动选定的智能体。", @@ -17597,6 +17598,10 @@ "actionsUnavailable": "此客户端不支持插件操作。", "messageTooLarge": "消息超过大小限制。", "tooManyRequests": "请求过多。" + }, + "FolderWorkspaceSourceControl": { + "openWorktree": "打开此工作树", + "reconnect": "请重新连接以查看此工作树的更改。" } }, "link": { @@ -20729,6 +20734,7 @@ } }, "sourceControl": { + "sectionAgentStarted": "已在 {{value0}} 中启动", "actionSaveFailed": "无法保存此操作。请在设置中重试保存。", "unlinkedPr": { "status": "PR #{{number}} 未链接" diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/agent-launch-funnel-test-harness.ts b/src/renderer/src/lib/agent-session/launch/new-tab/agent-launch-funnel-test-harness.ts index 6ad56ef12ce..bf91cab567a 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/agent-launch-funnel-test-harness.ts @@ -34,6 +34,7 @@ export function createLaunchFunnelStore(): { repos: { id: string; connectionId: string | null; path: string }[] allWorktrees: ReturnType tabsByWorktree: Record + unifiedTabsByWorktree: Record openFiles: { id: string; worktreeId: string }[] browserTabsByWorktree: Record tabBarOrderByWorktree: Record @@ -57,6 +58,7 @@ export function createLaunchFunnelStore(): { repos: [], allWorktrees: vi.fn(() => []), tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + unifiedTabsByWorktree: {}, openFiles: [], browserTabsByWorktree: {}, tabBarOrderByWorktree: {}, diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-args.ts b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-args.ts index a01ac768f53..6a782efa8df 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-args.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-args.ts @@ -30,6 +30,8 @@ export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { /** On terminals, a flag `agentArgs` sets itself wins over the inherited saved choice. */ sessionOptionsYieldToAgentArgs?: true initialCwd?: string | null + /** Unsaved section root whose trust is prepared without changing placement. */ + trustWorktreeId?: string /** How to deliver the prompt: `draft` leaves it editable, `submit-after-ready` sends it once the TUI is ready. */ promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready' /** Telemetry surface that initiated this launch. Defaults to the tab-bar quick-launch entry point. */ diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts index 2ae7c811445..b2c49411d93 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts @@ -1,10 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { planQueuedPaneAgentLaunch } from '../../../../../../main/ipc/pty/pane-agent-launch.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../../../shared/constants' import { createTestStore, seedStore } from '../../../../store/slices/store-test-helpers' -import { makeWorktree } from '../../../../store/slices/worktrees-slice-test-fixtures' +import { + makeWorktree, + makeFolderWorkspace +} from '../../../../store/slices/worktrees-slice-test-fixtures' +import { resolveQueuedInitialCwd } from '@/components/terminal-pane/terminal-pane-lifecycle-primitives' +import { settleAgentLaunchPaneStart } from '../pane/agent-launch-pane-start' +import { sourceControlActionRecipeEntry } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection' import { createStoreCascadesMockApi } from '../../../../store/slices/store-cascades-test-harness' vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => @@ -28,9 +35,20 @@ vi.mock('@/store/index', () => ({ } })) +vi.mock('@/lib/host-chat-program-acquisition', async (original) => ({ + ...(await original()), + acquireAgentLaunchChoices: async () => ({ detected: ['claude', 'codex'] }) +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) + +vi.mock('@/lib/agent-session/launch/prompt/launch-agent-tab-prompt-paste', () => ({ + pasteAgentLaunchPromptOnceReady: async () => ({ delivered: true, failureNotified: false }) +})) + createStoreCascadesMockApi() -import { makeFolderWorkspace } from '@/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-fixtures' +import { makeFolderWorkspace as makeLaunchFolderWorkspace } from '@/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-fixtures' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' afterEach(() => { @@ -71,8 +89,8 @@ describe('launchAgentInNewTab main-window surface', () => { nativeChatProviders: { claude: true } }, folderWorkspaces: [ - makeFolderWorkspace({ id: 'captured', executionHostId: 'local' }), - makeFolderWorkspace({ id: 'captured', executionHostId: 'runtime:rival' }) + makeLaunchFolderWorkspace({ id: 'captured', executionHostId: 'local' }), + makeLaunchFolderWorkspace({ id: 'captured', executionHostId: 'runtime:rival' }) ] })) store.getState().setActiveFolderWorkspace('captured', 'local') @@ -151,3 +169,118 @@ describe('launchAgentInNewTab main-window surface', () => { expect(state.activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('terminal') }) }) + +// A new tab can mount during its publication; its first spawn must already own the cwd. +it.each( + (['claude', 'codex'] as const).flatMap((agent) => + (['resolveConflicts', 'fixCommitFailure'] as const).map((actionId) => ({ agent, actionId })) + ) +)( + 'starts section $actionId with $agent outside the folder on its first mount', + async ({ agent, actionId }) => { + const store = seedMainWindowOnEditor() + const folder = makeFolderWorkspace({ + id: 'feature', + folderPath: '/fixture/feat-x', + executionHostId: 'local' + }) + const childPath = '/Users/qa/orca/workspaces/app/s-fix' + store.setState({ + folderWorkspaces: [folder], + worktreesByRepo: { + repo1: [ + makeWorktree({ id: MAIN_WORKTREE_ID, repoId: 'repo1', path: childPath, hostId: 'local' }) + ] + } + }) + store.getState().setActiveWorktree('folder:feature') + const spawnCwds: string[] = [] + const starts: ReturnType['pendingStartupByTabId'][string][] = [] + const mounted = new Set( + (store.getState().tabsByWorktree['folder:feature'] ?? []).map((tab) => tab.id) + ) + const unsubscribe = store.subscribe((state) => { + for (const tab of state.tabsByWorktree['folder:feature'] ?? []) { + if (mounted.has(tab.id) || tab.launchAgent !== agent) { + continue + } + mounted.add(tab.id) + starts.push(state.pendingStartupByTabId[tab.id]) + spawnCwds.push( + resolveQueuedInitialCwd( + undefined, + () => state.consumeTabInitialCwd(tab.id), + tab.startupCwd ?? folder.folderPath + ).startupCwd + ) + if (tab.agentLaunchPane) { + settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, 'section-pty') + } + } + }) + try { + const { runSourceControlAgentActionStart } = + await import('@/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart') + const recipe = sourceControlActionRecipeEntry({ agentId: agent, surface: 'terminal' }) + const { + launchSourceControlRecoveryAgentWithDefault, + getDefaultSourceControlRecoveryLaunchCopy + } = await import('@/components/right-sidebar/source-control/ai/recovery-launch') + const launched = + actionId === 'fixCommitFailure' + ? await launchSourceControlRecoveryAgentWithDefault({ + activeWorktreeId: MAIN_WORKTREE_ID, + placementWorkspaceId: 'folder:feature', + initialCwd: childPath, + activeGroupId: 'folder:feature', + activeSourceControlLaunchPlatform: 'darwin', + sourceRepoConnectionId: null, + executionHostId: 'local', + actionId, + basePrompt: 'Fix the failed commit', + getLaunchActionRecipe: () => recipe, + getStoreState: store.getState, + copy: getDefaultSourceControlRecoveryLaunchCopy('commit') + }) + : await runSourceControlAgentActionStart({ + selectedAgent: agent, + trimmedCommandInput: 'Resolve the conflicts', + recipe, + saveTargetValue: 'none', + actionId: 'resolveConflicts', + repoId: 'repo1', + repo: store.getState().repos[0] ?? null, + worktreeId: MAIN_WORKTREE_ID, + placementWorkspaceId: 'folder:feature', + initialCwd: childPath, + groupId: 'folder:feature', + promptDelivery: 'submit-after-ready', + launchPlatform: 'darwin', + launchSource: 'conflict_resolution', + onClose: () => {} + }) + expect(launched).toBe(true) + expect(spawnCwds).toEqual(['/Users/qa/orca/workspaces/app/s-fix']) + expect(starts).toHaveLength(1) + expect(starts[0].trustWorktreeId).toBe(MAIN_WORKTREE_ID) + const planned = await planQueuedPaneAgentLaunch( + starts[0].agentLaunch, + store.getState().settings ?? {}, + { + worktreeId: 'folder:feature', + tabId: 'section-tab', + leafId: '11111111-1111-4111-8111-111111111111' + }, + { trustWorktreeId: starts[0].trustWorktreeId, cwd: spawnCwds[0] } + ) + expect(planned).toMatchObject({ + worktreeId: 'folder:feature', + trustWorktreeId: MAIN_WORKTREE_ID, + cwd: '/Users/qa/orca/workspaces/app/s-fix', + launchAgent: agent + }) + } finally { + unsubscribe() + } + } +) diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-test-fixture.ts b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-test-fixture.ts index 316fad99e52..512d5ed0b7e 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-test-fixture.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-test-fixture.ts @@ -83,6 +83,7 @@ export const store = { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + unifiedTabsByWorktree: {}, openFiles: [] as { id: string; worktreeId: string }[], browserTabsByWorktree: {} as Record, tabBarOrderByWorktree: {} as Record, diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-windows-quoting.test.ts index a07357814d9..ae53f910a2c 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-windows-quoting.test.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-windows-quoting.test.ts @@ -55,6 +55,7 @@ const store = { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + unifiedTabsByWorktree: {}, openFiles: [] as { id: string; worktreeId: string }[], browserTabsByWorktree: {} as Record, tabBarOrderByWorktree: {} as Record, diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.test.ts index 9519a8a3b83..6c4d75fabbc 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.test.ts @@ -25,6 +25,27 @@ afterEach(() => { }) describe('launchAgentInNewTab', () => { + it('carries section trust ownership only in the transient pending startup', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + launchAgentInNewTab({ + requestId: 'section-trust-test', + agent: 'claude', + worktreeId: 'wt-1', + requestedSurface: 'terminal', + initialCwd: '/outside/section', + trustWorktreeId: 'repo::/outside/section', + prompt: 'Resolve conflicts', + promptDelivery: 'auto-submit' + }) + expect(queuedStartups()[0]).toMatchObject({ + trustWorktreeId: 'repo::/outside/section', + launchAgent: 'claude' + }) + expect(mockCreateTab.mock.calls[0][0]).toBe('wt-1') + expect(mockCreateTab.mock.calls[0][3]).not.toHaveProperty('trustWorktreeId') + expect(mockCreateTab.mock.calls[0][3]?.startupCwd).toBe('/outside/section') + }) + it.each(['created', 'failed'] as const)( 'settles a %s requested host agent beside existing surfaces using its captured owner', async (creation) => { diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.ts b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.ts index 2d7856b2e98..645389bd386 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab.ts @@ -73,6 +73,7 @@ function openAgentInNewTab( agentArgs, sessionOptions, initialCwd, + trustWorktreeId, promptDelivery = 'auto-submit', launchSource, quickCommandLabel, @@ -190,6 +191,7 @@ function openAgentInNewTab( workspaceExecutionHostId: args.workspaceExecutionHostId, groupId, cwd: initialCwd, + ...(trustWorktreeId ? { trustWorktreeId } : {}), buildStartup, sessionOptions, prompt: trimmedPrompt, @@ -234,6 +236,7 @@ function openAgentInNewTab( ...(agentArgs !== undefined ? { agentArgs } : {}), ...(sessionOptions ? { sessionOptions } : {}), ...(initialCwd?.trim() ? { cwd: initialCwd } : {}), + ...(trustWorktreeId ? { trustWorktreeId } : {}), launchSource: launchSource ?? 'tab_bar_quick_launch', quickCommandLabel, ...(activate === false ? { activate: false } : {}), @@ -253,9 +256,11 @@ function openAgentInNewTab( const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, + ...(initialCwd?.trim() ? { startupCwd: initialCwd } : {}), ...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), ...(activate === false ? { activate: false } : {}), pendingStartup: { + ...(trustWorktreeId ? { trustWorktreeId } : {}), command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, @@ -274,10 +279,6 @@ function openAgentInNewTab( } } }) - if (initialCwd?.trim()) { - // Why: queue before mount so local, WSL, and SSH continuations preserve their subdirectory. - store.queueTabInitialCwd(tab.id, initialCwd) - } // Safe for native-prefill agents: delivery skips paste when their draft already arrived at launch. if (hasPrompt && promptDelivery === 'draft' && pasteDraftAfterLaunch === null) { // Native argv prefill skips the paste callback, so seed the chat composer here. diff --git a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-web-host-tab.ts b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-web-host-tab.ts index 56b20ee0bc7..54d241946a2 100644 --- a/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-web-host-tab.ts @@ -42,6 +42,7 @@ export async function launchAgentInWebHostTab(args: { environmentId: string | null groupId?: string cwd?: string | null + trustWorktreeId?: string buildStartup: (choices: AgentLaunchChoices | undefined) => LaunchAgentStartupPromptPlan sessionOptions?: Readonly> prompt: string @@ -118,9 +119,10 @@ export async function launchAgentInWebHostTab(args: { launch = { worktreeId, environmentId, + ...(args.trustWorktreeId ? { trustWorktreeId: args.trustWorktreeId } : {}), targetGroupId: groupId, activate: args.activate !== false, - ...(!hasPrompt && args.activate === false ? { selectWorktree: false } : {}), + ...(args.activate === false ? { selectWorktree: false } : {}), ...(cwd?.trim() ? { cwd } : {}), ...(viewMode ? { viewMode } : {}), agentSessionKind: 'fresh', diff --git a/src/renderer/src/lib/agent-session/launch/pane/agent-launch-through-host.ts b/src/renderer/src/lib/agent-session/launch/pane/agent-launch-through-host.ts index 2b714e4f805..9d9da90092f 100644 --- a/src/renderer/src/lib/agent-session/launch/pane/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-session/launch/pane/agent-launch-through-host.ts @@ -21,6 +21,7 @@ export type HostAgentLaunchArgs = { /** Absent uses the settings default; `null` means no arguments. */ agentArgs?: string | null cwd?: string + trustWorktreeId?: string /** The launch's session options; the host decodes booleans sent as strings by catalog kind. */ sessionOptions?: Record launchSource?: LaunchSource @@ -66,11 +67,13 @@ export function launchAgentThroughHost(args: HostAgentLaunchArgs): { agentLaunchPane: { leafId }, launchAgent: args.agent, quickCommandLabel: args.quickCommandLabel, + ...(args.cwd?.trim() ? { startupCwd: args.cwd } : {}), ...(args.activate === false ? { activate: false } : {}), ...(args.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), ...(args.viewMode ? { viewMode: args.viewMode } : {}), // Queued before the pane mounts, as "+" queues its command: its first spawn carries the launch. pendingStartup: { + ...(args.trustWorktreeId ? { trustWorktreeId: args.trustWorktreeId } : {}), command: '', launchAgent: args.agent, agentLaunch: { @@ -79,9 +82,6 @@ export function launchAgentThroughHost(args: HostAgentLaunchArgs): { } } }) - if (args.cwd?.trim()) { - store.queueTabInitialCwd(tabId, args.cwd) - } rememberAgentLaunchPanePrompt(tabId, args.prompt) seedNativeChatAppliedSessionOptions(tabId, args.agent, args.sessionOptions) // An activated launch must reveal Terminal or it can remain hidden behind an editor. diff --git a/src/renderer/src/lib/agent-session/launch/pane/launch-agent-tab-order.ts b/src/renderer/src/lib/agent-session/launch/pane/launch-agent-tab-order.ts index 2286ae41280..55828372b0d 100644 --- a/src/renderer/src/lib/agent-session/launch/pane/launch-agent-tab-order.ts +++ b/src/renderer/src/lib/agent-session/launch/pane/launch-agent-tab-order.ts @@ -1,12 +1,14 @@ import { useAppStore } from '@/store/index' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' +import { workspaceEditorFiles } from '@/store/slices/editor/tabs/workspace-editor-files' export function persistAgentLaunchTabOrder(worktreeId: string, tabId: string): void { const store = useAppStore.getState() const terminalIds = (store.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - const editorIds = store.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((file) => file.id) + const editorIds = workspaceEditorFiles( + store.openFiles, + store.unifiedTabsByWorktree[worktreeId] + ).map((file) => file.id) const browserIds = (store.browserTabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) const order = reconcileTabOrder( store.tabBarOrderByWorktree[worktreeId], diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts index 1707c823ddd..774bf615938 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts @@ -235,7 +235,6 @@ describe('a new start beside an unconfirmed or retried chat', () => { recipe: sourceControlActionRecipeEntry({ commandInputTemplate: '{basePrompt}' }), saveTargetValue: 'none', actionId: 'resolveComments', - settings: null, repo: null, worktreeId: WORKTREE_ID, promptDelivery: 'submit-after-ready', diff --git a/src/renderer/src/lib/chat-provider-agents.ts b/src/renderer/src/lib/chat-provider-agents.ts index 43ff11b16e8..aa2dd7c1b42 100644 --- a/src/renderer/src/lib/chat-provider-agents.ts +++ b/src/renderer/src/lib/chat-provider-agents.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react' +import { useAppViewExternalStore } from '@/store/app-state-view' import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' import { isTuiAgent } from '../../../shared/agent-launch/catalog/tui-agent-config' import { @@ -25,7 +25,7 @@ export function chatProviderAgents( } export function useHostStructuredAgentsVersion(): number { - return useSyncExternalStore( + return useAppViewExternalStore( subscribeHostStructuredAgents, hostStructuredAgentsVersion, hostStructuredAgentsVersion diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index 02785ec7ad0..8a9077da1f8 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -10,7 +10,7 @@ export { resolveEditorOwnerRoute, type EditorFileOperationProvenance } from './editor-file-operation-provenance' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { parseExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '@/store/types' import type { OpenFile } from '@/store/slices/editor' @@ -73,7 +73,8 @@ export function getEditorFileReadContext( state, file.worktreeId, file.runtimeEnvironmentId, - file.runtimeEnvironmentId !== undefined + file.runtimeEnvironmentId !== undefined, + file.externalSshTargetId ? toSshExecutionHostId(file.externalSshTargetId) : undefined ) const route = provenance.generation.route const workspaceScope = parseWorkspaceKey(file.worktreeId) diff --git a/src/renderer/src/lib/editor-file-operation-provenance.ts b/src/renderer/src/lib/editor-file-operation-provenance.ts index 4caab32bebb..ad7ad70b865 100644 --- a/src/renderer/src/lib/editor-file-operation-provenance.ts +++ b/src/renderer/src/lib/editor-file-operation-provenance.ts @@ -14,6 +14,7 @@ import { import { getFloatingWorkspaceOperationRoute, resolveWorktreeOperationRoute, + resolveWorktreeOperationRouteResultForHost, type WorktreeOperationRoute, type WorktreeOperationRouteResolution, type WorktreeOperationRouteState @@ -28,7 +29,7 @@ import { export type EditorFileOperationProvenance = { generation: WorktreeOperationGenerationSnapshot - ownershipProjection: 'explicit' | 'legacy' + ownershipProjection: 'explicit' | 'qualified' | 'legacy' childRepo?: { id: string; path: string } expectedSshConnectionGeneration?: number } @@ -58,12 +59,14 @@ export function captureEditorFileOperationProvenance( state: EditorOwnerState, worktreeId: string, ownerHint: string | null | undefined, - ownerHintProvided: boolean + ownerHintProvided: boolean, + executionHostId?: ExecutionHostId ): EditorFileOperationProvenance { const owner = resolveEditorOwnerRoute( state, worktreeId, - state.activeWorktreeId === worktreeId ? state.activeWorkspaceExecutionHostId : null + executionHostId ?? + (state.activeWorktreeId === worktreeId ? state.activeWorkspaceExecutionHostId : null) ) const ownershipProjection = owner.ownershipProjection const hintedRuntimeEnvironmentId = ownerHint?.trim() || null @@ -117,7 +120,11 @@ function resolveEditorOperationAuthority( return undefined } } else if (worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - const explicit = resolveExplicitWorktreeOperationRouteResult(state, worktreeId) + const explicit = resolveWorktreeOperationRouteResultForHost( + state, + worktreeId, + route.executionHostId + ) if (explicit.kind !== 'resolved' || JSON.stringify(explicit.route) !== JSON.stringify(route)) { return undefined } @@ -146,6 +153,14 @@ export function resolveEditorOwnerRoute( return { kind: 'resolved', route: floatingRoute, ownershipProjection: 'explicit' } } const ownerState = focusFreeRouteStateOf(state) + if (folderHostId && parseWorkspaceKey(worktreeId)?.type !== 'folder') { + const qualified = resolveWorktreeOperationRouteResultForHost( + ownerState, + worktreeId, + folderHostId + ) + return { ...qualified, ownershipProjection: 'qualified' } + } const scope = parseWorkspaceKey(worktreeId) const explicit: WorktreeOperationRouteResolution = scope?.type === 'folder' @@ -232,12 +247,15 @@ function resolveCurrentEditorRoute( const owner = resolveEditorOwnerRoute( state, worktreeId, - provenance.generation.route.executionHostId + provenance.ownershipProjection === 'qualified' || + parseWorkspaceKey(worktreeId)?.type === 'folder' + ? provenance.generation.route.executionHostId + : undefined ) - if (owner.ownershipProjection === 'explicit' && owner.kind === 'resolved') { + if (owner.ownershipProjection !== 'legacy' && owner.kind === 'resolved') { return owner.route } - if (owner.kind === 'ambiguous' || provenance.ownershipProjection === 'explicit') { + if (owner.kind === 'ambiguous' || provenance.ownershipProjection !== 'legacy') { return null } // Folder workspaces must still confirm their live owner rather than retain a published-worktree hint. diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 492896c8266..c3ced159b8f 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -1,3 +1,4 @@ +import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location' import { openPlannedFileBrowserLink, type FileBrowserLinkTarget } from './file-preview-link-open' import type { BrowserLinkOpenContext, @@ -134,7 +135,12 @@ export function useWorkspaceFileBrowserActionPredicate( function openDocPreviewTab( state: AppState, - params: { filePath: string; worktreeId: string; activate: boolean }, + params: { + filePath: string + worktreeId: string + activate: boolean + placementWorkspaceId?: string + }, placement?: Pick ): void { const docLocation = { @@ -143,12 +149,21 @@ function openDocPreviewTab( filePath: params.filePath } // Reuse the document’s tab; opening another would mint a second grant for the same file. - const existing = findWorkspaceShowingDoc(state, docLocation) + const placementWorkspaceId = params.placementWorkspaceId ?? params.worktreeId + const existing = + placementWorkspaceId === params.worktreeId + ? findWorkspaceShowingDoc(state, docLocation) + : (state.browserTabsByWorktree[placementWorkspaceId] ?? []).flatMap((tab) => { + const page = (state.browserPagesByWorkspace[tab.id] ?? []).find((candidate) => + browserPageDocLocationsEqual(candidate.docLocation ?? null, docLocation) + ) + return page ? [{ workspaceId: tab.id, pageId: page.id }] : [] + })[0] if (existing) { if ( !params.activate || !activateBrowserWorkspaceTab({ - worktreeId: params.worktreeId, + worktreeId: placementWorkspaceId, ...existing }) ) { @@ -157,7 +172,7 @@ function openDocPreviewTab( } return } - state.createBrowserTab(params.worktreeId, ORCA_BROWSER_BLANK_URL, { + state.createBrowserTab(placementWorkspaceId, ORCA_BROWSER_BLANK_URL, { docLocation, title: basename(params.filePath) || params.filePath, ...placement, @@ -227,14 +242,15 @@ export function openFilePreviewToSide(params: { filePath: string worktreeId: string sourceGroupId: string | null + placementWorkspaceId?: string }): void { if (!canPreviewLanguage(params.language)) { return } const state = useAppStore.getState() - const worktreeId = params.worktreeId - const plan = getWorkspaceFilePreviewPlan(state, worktreeId, params.filePath) + const worktreeId = params.placementWorkspaceId ?? params.worktreeId + const plan = getWorkspaceFilePreviewPlan(state, params.worktreeId, params.filePath) if (plan.status === 'unsupported') { toast.error(plan.message) return @@ -260,7 +276,16 @@ export function openFilePreviewToSide(params: { : { split: { sourceGroupId, splitDirection: 'right' as const, activate } } if (plan.status === 'doc-preview') { - openDocPreviewTab(state, { filePath: params.filePath, worktreeId, activate: false }, placement) + openDocPreviewTab( + state, + { + filePath: params.filePath, + worktreeId: params.worktreeId, + placementWorkspaceId: worktreeId, + activate: false + }, + placement + ) return } diff --git a/src/renderer/src/lib/host-chat-program-acquisition.ts b/src/renderer/src/lib/host-chat-program-acquisition.ts index 41f4f86122d..1a917893315 100644 --- a/src/renderer/src/lib/host-chat-program-acquisition.ts +++ b/src/renderer/src/lib/host-chat-program-acquisition.ts @@ -1,4 +1,9 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' +import { + useAppViewExternalStore, + useAppStateViewActive, + useAppStateValue +} from '@/store/app-state-view' +import { useEffect, useMemo } from 'react' import { useAppStore } from '@/store' import type { ProspectiveWorkspace } from './agent-session/launch/routing/agent-launch-route-input' import { @@ -24,7 +29,7 @@ import { } from './agent-detection-target-inventory' export const useChatProgramFactsVersion = (): number => - useSyncExternalStore( + useAppViewExternalStore( subscribeChatProgramFacts, readChatProgramFactsVersion, readChatProgramFactsVersion @@ -58,18 +63,19 @@ export function holdChatProgramFacts(context: ReturnType