mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
Show Source Control for folder workspaces (#38831)
* Separate editor tab placement from content ownership * Show scoped Source Control sections for attached worktrees * Preserve active diff refresh and validate section catalogs * Complete folder section test worktree metadata * Use contextual types for scoped Source Control actions * Preserve optional scoped action parameters * Preserve editor ownership through restore, mirroring and workspace removal * Retire Source Control drafts only after confirmed owner removal * Limit section work to visible expanded repositories * Keep editor follow-up actions and bulk closes in their invoking views * Track the consolidated editor order writer in its owning module * Use complete typed fixtures for repair contracts * Preserve live editor placement during layout readback * Pass projected editor placement directly to serialization * Read attached worktree lineage from shared graph module * Retain inferred owner checks and scope placement retirement * Preserve selection in untouched workspaces during purge * Exercise editor placements in existing launch and lifecycle fixtures * Preserve shared buffer prompts and editor strip order * Pause inactive section context subscriptions * Keep section recovery and file actions in the folder workspace * Describe attached Source Control in folder workspace CLI help * Attribute Git metadata signals once per dispatch * Watch mounted source control sections outside the warm budget * Resolve section watches only for served filesystem roots * Restore restart-stable editor identity across SSH opens * Apply section terminal routing to explicit Chat choices * Include cross-store descendants in folder workspace panels * Keep cross-host recovery on its existing workspace route * Start cross-host section agents in their workspace without navigation * Update memoized Source Control labels on language changes * Remove unused terminal close-to-right implementation * Suspend inactive Source Control body observers * Retain base ref query while section observation pauses * Cover retained base ref search across section visibility * Resolve note menu ownership from the retained snapshot * Preserve section agent working directories and background placement * Keep Source Control sections active while their overlays are open * Read current settings when Source Control commands execute * Adapt editor and browser test stores to view subscriptions * Preserve bound store hooks for ordinary app views * Trust agent starts outside the placement workspace * Trust the section worktree through transient launch ownership * Extract editor follow-up file actions from the panel * Fail section launches when trust capability lookup fails Preserve explicit unsupported-host fallback, propagate lookup failures to the existing launch error path, and let the existing transport retry refresh rejected capability probes. * Follow the moved pane launch test in web configs * Preserve section file ownership during refresh and close * Share tab order removal across editor close paths * Verify companion editor close through the real store * Close all workspace split copies for legacy mobile file requests * Keep unmatched mobile close requests within their workspace * Reuse the agent menu render snapshot * Keep attached folder roots scoped to their resolved host * Apply repository trust decisions to the launch trust owner
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -120,7 +120,7 @@ ORCA worktree set --worktree active --pr null --gitlab-mr null --json
|
||||
ORCA worktree rm --worktree id:<repoId>::<worktreePath> --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 <selector>` 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.
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ const ORCA_CLI_MARKDOWN = [
|
||||
"ORCA worktree rm --worktree id:<repoId>::<worktreePath> --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 <selector>` 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.",
|
||||
"",
|
||||
|
||||
@@ -14,7 +14,7 @@ export const REPO_COMMAND_SPECS: CommandSpec[] = [
|
||||
usage: 'orca repo add --path <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']
|
||||
|
||||
@@ -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<void> | 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]
|
||||
|
||||
@@ -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<typeof ClaudeFolderTrustFile.grantClaudeWorkspaceTrust>()
|
||||
}))
|
||||
|
||||
// The host filesystem uses Windows path resolution for WSL UNC identities.
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodePath>()
|
||||
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, {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ export type PtySpawnIpcArgs = {
|
||||
resumeOrigin?: unknown
|
||||
launchToken?: unknown
|
||||
launchAgent?: TuiAgent
|
||||
trustWorktreeId?: string
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
connectionId?: string | null
|
||||
worktreeId?: string
|
||||
|
||||
@@ -16,7 +16,10 @@ export async function planQueuedPaneAgentLaunch(
|
||||
tabId: 'tab-1',
|
||||
leafId: '11111111-1111-4111-8111-111111111111'
|
||||
},
|
||||
pane: Pick<PtySpawnIpcArgs, 'connectionId' | 'commandDelivery' | 'startupCommandDelivery'> = {}
|
||||
pane: Pick<
|
||||
PtySpawnIpcArgs,
|
||||
'connectionId' | 'commandDelivery' | 'startupCommandDelivery' | 'trustWorktreeId' | 'cwd'
|
||||
> = {}
|
||||
): Promise<PtySpawnIpcArgs> {
|
||||
const runtime = new OrcaRuntimeService(partialRuntimeStore({ getSettings: () => settings }))
|
||||
const discovery = vi.spyOn(runtime, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
|
||||
|
||||
@@ -118,6 +118,7 @@ export type RuntimePtySpawnArgs = {
|
||||
cwd?: string
|
||||
command?: string
|
||||
launchAgent?: TuiAgent
|
||||
trustWorktreeId?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchToken?: string
|
||||
commandDelivery?: 'renderer' | 'provider'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -75,6 +75,7 @@ export type RuntimePtyController = {
|
||||
cwd?: string
|
||||
command?: string
|
||||
launchAgent?: TuiAgent
|
||||
trustWorktreeId?: string
|
||||
launchConfig?: WorktreeStartupLaunch['launchConfig']
|
||||
launchToken?: string
|
||||
commandDelivery?: 'renderer' | 'provider'
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
+3
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -55,6 +55,7 @@ export type PtyApi = {
|
||||
resumeOrigin?: 'saved'
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
trustWorktreeId?: string
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
connectionId?: string | null
|
||||
worktreeId?: string
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ptySessionControlApi = {
|
||||
resumeOrigin?: 'saved'
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
trustWorktreeId?: string
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
connectionId?: string | null
|
||||
worktreeId?: string
|
||||
|
||||
@@ -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`])
|
||||
})
|
||||
})
|
||||
|
||||
+69
@@ -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<Repo>[]
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ReturnType<typeof useAppStore.getState>['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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}) => (
|
||||
<>
|
||||
<button onClick={onOpenDiffTargetFile}>Open</button>
|
||||
<button onClick={() => onEditorToggleChange('rich')}>Rich</button>
|
||||
<button onClick={onOpenMarkdownPreview}>Markdown preview</button>
|
||||
<button onClick={onOpenPreviewToSide}>Side preview</button>
|
||||
</>
|
||||
)
|
||||
}))
|
||||
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(<EditorPanel activeFileId={file.id} activeViewStateId="x-source-tab" />)
|
||||
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(<EditorPanel activeFileId={file.id} activeViewStateId="x-source-tab" />)
|
||||
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(<EditorPanel activeFileId={file.id} activeViewStateId="x-source-tab" />)
|
||||
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([])
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> => {
|
||||
|
||||
@@ -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<EditorRequestFileCloseDetail, 'onClosed'>
|
||||
options?: Pick<EditorRequestFileCloseDetail, 'onClosed' | 'tabId'>
|
||||
): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<EditorRequestFileCloseDetail>(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
+148
@@ -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'])
|
||||
}
|
||||
)
|
||||
+67
-189
@@ -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<OpenFile['branchCompare']> | null
|
||||
canOpenWorkspaceFileBrowserForPath: (path: string) => boolean
|
||||
commitCompare: NonNullable<OpenFile['commitCompare']> | null
|
||||
@@ -55,10 +51,17 @@ export function useCombinedDiffSectionActions({
|
||||
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
|
||||
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
|
||||
}): 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<Map<number, monacoEditor.IStandaloneCodeEditor>>(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<string, { operation: DiffSection['operation']; content: string }>()
|
||||
)
|
||||
const runSectionSave = useCallback(
|
||||
async (
|
||||
sectionKey: string,
|
||||
mode: 'overwrite' | undefined,
|
||||
operation: NonNullable<DiffSection['operation']>
|
||||
) => {
|
||||
// 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<string, Promise<void>>())
|
||||
const handleSectionSave = useCallback(
|
||||
(index: number, mode?: 'overwrite'): Promise<void> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -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<DiffSection[]>
|
||||
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
|
||||
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
|
||||
}) {
|
||||
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
|
||||
const writtenBySectionRef = useRef(
|
||||
new Map<string, { operation: DiffSection['operation']; content: string }>()
|
||||
)
|
||||
const runSectionSave = useCallback(
|
||||
async (
|
||||
sectionKey: string,
|
||||
mode: 'overwrite' | undefined,
|
||||
operation: NonNullable<DiffSection['operation']>
|
||||
) => {
|
||||
// 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<string, Promise<void>>())
|
||||
const handleSectionSave = useCallback(
|
||||
(index: number, mode?: 'overwrite'): Promise<void> => {
|
||||
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 }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof getEditorPanelRenderModel>, '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 }
|
||||
}
|
||||
+1
@@ -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 }
|
||||
|
||||
@@ -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<TNote extends DiffCommentDeliverySnapshot>({
|
||||
onOpenRequestHandled,
|
||||
onDelivered
|
||||
}: NotesSendMenuProps<TNote>): 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<SendableNotesScope<TNote>[]>(() => {
|
||||
void inFlightVersion
|
||||
@@ -147,7 +146,7 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
if (scope.notes.length === 0) {
|
||||
return
|
||||
}
|
||||
openAgentSendPopoverTargetMode({
|
||||
useAppStore.getState().openAgentSendPopoverTargetMode({
|
||||
id: targetModeId,
|
||||
worktreeId,
|
||||
source,
|
||||
@@ -158,15 +157,7 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
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<TNote extends DiffCommentDeliverySnapshot>({
|
||||
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(() => {
|
||||
|
||||
+58
-39
@@ -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<typeof store.getState>) => {
|
||||
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]
|
||||
|
||||
@@ -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<void> {
|
||||
export async function discardEditorFileChangesAndClose(
|
||||
fileId: string,
|
||||
tabId?: string
|
||||
): Promise<void> {
|
||||
const release = await discardEditorBuffer(fileId)
|
||||
try {
|
||||
useAppStore.getState().closeFile(fileId)
|
||||
if (tabId) {
|
||||
useAppStore.getState().closeUnifiedTab(tabId)
|
||||
} else {
|
||||
useAppStore.getState().closeFile(fileId)
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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<HTMLDivElement | null>
|
||||
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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
+693
@@ -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<string>(),
|
||||
bodyRenders: new Map<string, number>()
|
||||
}))
|
||||
const pending = (): Promise<never> => new Promise(() => {})
|
||||
|
||||
vi.mock('./source-control/panel/panel', async (original) => {
|
||||
const { SourceControlPanel: Panel } = await original<typeof PanelModule>()
|
||||
const { useSourceControlScope } = await import('./source-control/panel/worktree-scope')
|
||||
return {
|
||||
SourceControlPanel: () => {
|
||||
const id = useSourceControlScope()?.worktreeId ?? 'selected'
|
||||
return (
|
||||
<Profiler
|
||||
id={id}
|
||||
onRender={() => probes.bodyRenders.set(id, (probes.bodyRenders.get(id) ?? 0) + 1)}
|
||||
>
|
||||
<Panel />
|
||||
</Profiler>
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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<typeof WorktreeGitCopy>()),
|
||||
resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({
|
||||
worktreeId: id,
|
||||
hostId,
|
||||
transport: { kind: 'local' }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-git-status-client', async (original) => ({
|
||||
...(await original<typeof GitStatusClient>()),
|
||||
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<typeof GitDiffClient>()),
|
||||
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<typeof RepoClient>()),
|
||||
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<Element, IntersectionObserver>()
|
||||
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<void> {
|
||||
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<AppState>)
|
||||
return worktrees
|
||||
}
|
||||
|
||||
async function render(node: ReactNode): Promise<void> {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<ConfirmationDialogContext.Provider value={refuseConfirmation}>
|
||||
<TooltipProvider>
|
||||
<Profiler id="source-control" onRender={() => (renders += 1)}>
|
||||
{node}
|
||||
</Profiler>
|
||||
</TooltipProvider>
|
||||
</ConfirmationDialogContext.Provider>
|
||||
)
|
||||
)
|
||||
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(<FolderWorkspaceSourceControlPanel />)
|
||||
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<typeof bodyWork>[] = []
|
||||
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(<FolderWorkspaceSourceControlPanel />)
|
||||
const collapsedSection = container.querySelector(
|
||||
`[data-source-control-worktree="${collapsed!.id}"]`
|
||||
)!
|
||||
await act(async () =>
|
||||
collapsedSection.querySelector<HTMLButtonElement>('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(<SourceControlPanel />)
|
||||
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(<FolderWorkspaceSourceControlPanel />)
|
||||
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<HTMLTextAreaElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-expanded]')!.click()
|
||||
))
|
||||
expect(section?.textContent).toContain('1 selected')
|
||||
expect(section!.querySelector('[data-source-control-path="latest.ts"]')).toBeTruthy()
|
||||
expect(section!.querySelector<HTMLTextAreaElement>('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(<FolderWorkspaceSourceControlPanel />)
|
||||
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<HTMLElement>('[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(<FolderWorkspaceSourceControlPanel />)
|
||||
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(<FolderWorkspaceSourceControlPanel />)
|
||||
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<HTMLButtonElement>('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()
|
||||
})
|
||||
@@ -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<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', () => ({
|
||||
resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({
|
||||
worktreeId: id,
|
||||
hostId,
|
||||
transport: { kind: 'local' }
|
||||
})
|
||||
}))
|
||||
vi.mock('../../../../shared/worktree/git-identity-display', async (original) => {
|
||||
const actual = await original<typeof GitIdentityDisplay>()
|
||||
return {
|
||||
...actual,
|
||||
getWorktreeGitIdentityDisplay: (
|
||||
...args: Parameters<typeof actual.getWorktreeGitIdentityDisplay>
|
||||
) => {
|
||||
probes.header(args[0].branch)
|
||||
return actual.getWorktreeGitIdentityDisplay(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('./source-control/panel/panel', () => ({
|
||||
SourceControlPanel: () => {
|
||||
const scope = useSourceControlScope()
|
||||
probes.body(scope?.worktreeId)
|
||||
return <div data-commit-owner={scope?.worktreeId} />
|
||||
}
|
||||
}))
|
||||
|
||||
// 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<AppState>)
|
||||
return worktrees
|
||||
}
|
||||
async function render(visible = true) {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<Watches />
|
||||
{visible && <FolderWorkspaceSourceControlPanel />}
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>('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(<ScopedStatusObserver owner={owner} />))
|
||||
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(<ActiveStatusObserver />))
|
||||
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<AppState>)
|
||||
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'
|
||||
])
|
||||
})
|
||||
@@ -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<boolean | undefined>()
|
||||
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<HTMLElement>())
|
||||
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(() => <SourceControlPanel />, [])
|
||||
const identity = getWorktreeGitIdentityDisplay(owner.worktree)
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="border-b border-border"
|
||||
data-source-control-worktree={id}
|
||||
>
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setExpanded(!isExpanded)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-2 text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight className={`size-3 shrink-0 ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
<span className="truncate font-medium">{owner.repo.displayName}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{owner.worktree.displayName ?? owner.worktree.branch}
|
||||
</span>
|
||||
<GitBranch className="ml-auto size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-muted-foreground">
|
||||
{identity?.kind === 'branch' ? identity.branchName : owner.worktree.branch}
|
||||
</span>
|
||||
{!!remote?.ahead && (
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
<ArrowUp className="size-3" />
|
||||
{remote.ahead}
|
||||
</span>
|
||||
)}
|
||||
{!!remote?.behind && (
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
<ArrowDown className="size-3" />
|
||||
{remote.behind}
|
||||
</span>
|
||||
)}
|
||||
{entries.length > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 text-2xs">{entries.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0"
|
||||
onClick={() => useAppStore.getState().setActiveWorktree(id, owner.executionHostId)}
|
||||
aria-label={translate(
|
||||
'auto.components.rightSidebar.FolderWorkspaceSourceControl.openWorktree',
|
||||
'Open this worktree'
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate(
|
||||
'auto.components.rightSidebar.FolderWorkspaceSourceControl.openWorktree',
|
||||
'Open this worktree'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isExpanded &&
|
||||
(!connected ? (
|
||||
<div className="px-4 pb-2 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.rightSidebar.FolderWorkspaceSourceControl.reconnect',
|
||||
'Reconnect to see this worktree’s changes.'
|
||||
)}
|
||||
</div>
|
||||
) : !bodyShown ? (
|
||||
<div className="h-24 space-y-2 p-3" aria-busy="true">
|
||||
<div className="h-2 w-3/4 rounded bg-muted" />
|
||||
<div className="h-2 w-1/2 rounded bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
!bodyMounted && <div className="h-24" aria-hidden="true" />
|
||||
))}
|
||||
{bodyMounted && (
|
||||
// A read error renders the body's existing failure banner with Retry.
|
||||
<div hidden={!bodyShown}>
|
||||
<OverlayActivityOwner.Provider value={onOverlayActivity}>
|
||||
<SourceControlScopeProvider owner={owner} active={bodyActive}>
|
||||
{panel}
|
||||
</SourceControlScopeProvider>
|
||||
</OverlayActivityOwner.Provider>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
},
|
||||
(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<HTMLDivElement | null>(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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{resolution.folderWorkspace?.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.attachedCount',
|
||||
'{{count}} attached worktrees',
|
||||
{ count: owners.length, defaultValue_one: '{{count}} attached worktree' }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={refresh}
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.refreshAll',
|
||||
'Refresh all'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.right.sidebar.SourceControl.refreshAll', 'Refresh all')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{owners.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.emptyTitle',
|
||||
'No attached worktrees yet'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 max-w-64 text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.emptyCopy',
|
||||
'Worktrees created from this workspace will show up here.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={setScrollRoot} className="scrollbar-sleek min-h-0 flex-1 overflow-y-auto">
|
||||
{owners.map((owner) => (
|
||||
<WorktreeSection
|
||||
key={attachedWorktreeOwnerKey(owner)}
|
||||
owner={owner}
|
||||
scrollRoot={scrollRoot}
|
||||
refreshers={refreshers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+341
@@ -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<typeof WorktreeGitCopy>()),
|
||||
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<typeof vi.fn>
|
||||
}
|
||||
const fsListeners = new Set<(payload: FsChangedPayload) => void>()
|
||||
let runtimeStreams: RuntimeStream[] = []
|
||||
let watchWorktree: ReturnType<typeof vi.fn>
|
||||
let unwatchWorktree: ReturnType<typeof vi.fn>
|
||||
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<Worktree> = {}) {
|
||||
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<string, number>
|
||||
changed?: ReadonlySet<string>
|
||||
}) {
|
||||
const byRepo: Record<string, Worktree[]> = {}
|
||||
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<AppState>)
|
||||
}
|
||||
|
||||
function WatchOwner() {
|
||||
useEditorExternalWatch()
|
||||
return null
|
||||
}
|
||||
async function render(panel = true) {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<WatchOwner />
|
||||
{panel && <FolderWorkspaceSourceControlPanel />}
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
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])
|
||||
}
|
||||
)
|
||||
@@ -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<string>
|
||||
>(() => 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<string> = 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 (
|
||||
<WorktreeCard
|
||||
key={worktree.id}
|
||||
key={identity}
|
||||
worktree={worktree}
|
||||
repo={repoById.get(worktree.repoId)}
|
||||
repo={resolveReviewRepository(repos, worktree) ?? undefined}
|
||||
isActive={activeWorktreeId === worktree.id}
|
||||
isActiveSurface={false}
|
||||
hideRepoBadge={false}
|
||||
@@ -76,7 +87,7 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
|
||||
!lineageCollapsed && hasSafeLineageChildren
|
||||
? safeLineageChildren.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
key={getWorktreeHostIdentity(child)}
|
||||
onClick={stopNestedWorktreeCardBubble}
|
||||
onDoubleClick={stopNestedWorktreeCardBubble}
|
||||
onDragStart={stopNestedWorktreeCardBubble}
|
||||
@@ -96,7 +107,7 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
|
||||
? (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleLineage(worktree.id)
|
||||
toggleLineage(identity)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
+1
-4
@@ -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 }
|
||||
}
|
||||
|
||||
+167
-1
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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<string, Worktree[]>
|
||||
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<string, Worktree>(
|
||||
Object.values(worktreesByRepo).flatMap((copies) =>
|
||||
copies.map((row) => [getWorktreeHostIdentity(row), row] as const)
|
||||
)
|
||||
)
|
||||
const nestedChildIds = new Set<string>()
|
||||
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<string, Worktree>()
|
||||
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<string, Worktree[]>()
|
||||
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<string, WorkspaceLineage[]>()
|
||||
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<string, AttachedWorktreeOwner>()
|
||||
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<string, Worktree[]>()
|
||||
const nested = new Set<string>()
|
||||
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<string, readonly Worktree[]>
|
||||
): Map<string, Worktree> {
|
||||
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,
|
||||
|
||||
+2
-2
@@ -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<Repo, 'sourceControlAi'> | null
|
||||
/** Generation preferences; routing comes from `target`. */
|
||||
settings: AppState['settings']
|
||||
settings: SourceControlAiSettingsInput | null
|
||||
/** The worktree owner's transport. */
|
||||
target: RuntimeClientTarget | null
|
||||
submitting: boolean
|
||||
|
||||
+15
-8
@@ -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,
|
||||
|
||||
+2
-5
@@ -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<SourceControlAiPrCreationDefaults>
|
||||
@@ -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,
|
||||
|
||||
+1
-2
@@ -67,8 +67,7 @@ function useComposer(key: string) {
|
||||
fieldsAreSeedPlaceholders: fields.fieldsAreSeedPlaceholders,
|
||||
generatePullRequestFields: fields.handleGenerate,
|
||||
generationKey: key,
|
||||
repo: null,
|
||||
settings: null
|
||||
repo: null
|
||||
})
|
||||
return fields
|
||||
}
|
||||
|
||||
+4
-7
@@ -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<PullRequestGenerationOutcome | void>
|
||||
generationKey: string | null
|
||||
repo: Pick<Repo, 'sourceControlAi'> | 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
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
-1
@@ -232,7 +232,6 @@ export function useCreatePullRequestDialogFields({
|
||||
draft,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
settings,
|
||||
target,
|
||||
eligibility,
|
||||
resolvedPrDefaults,
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
@@ -31,7 +36,12 @@ export function RightSidebarPanelContent({
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
{effectiveTab === 'explorer' && <FileExplorer />}
|
||||
{effectiveTab === 'source-control' && <SourceControl />}
|
||||
{effectiveTab === 'source-control' &&
|
||||
(isFolderWorkspace ? (
|
||||
rightSidebarOpen && <FolderWorkspaceSourceControl />
|
||||
) : (
|
||||
<SourceControl />
|
||||
))}
|
||||
{effectiveTab === 'checks' && <ChecksPanel />}
|
||||
{/* SSH forwarding needs raw ports.detect data absent from the workspace-scoped status popover. */}
|
||||
{effectiveTab === 'ports' && (
|
||||
|
||||
+2
-2
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
+3
-1
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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<AppState, 'settings' | 'repos'> &
|
||||
WorktreeAgentInventoryState
|
||||
Omit<WorktreeAgentInventoryState, 'repos'>
|
||||
|
||||
export type SourceControlAiControllerParams = {
|
||||
settings: GlobalSettings | null
|
||||
settings: SourceControlRenderSettings | null
|
||||
/** The repo owner's transport; it scopes model discovery. */
|
||||
target: RuntimeClientTarget | null
|
||||
activeRepo: Repo | null
|
||||
|
||||
@@ -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<AppState, 'settings' | 'repos'>
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+132
-4
@@ -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,
|
||||
|
||||
+47
-11
@@ -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<Repo, 'id' | 'sourceControlAi'> | null
|
||||
repo: Pick<Repo, 'id' | 'sourceControlAi' | 'connectionId' | 'executionHostId'> | 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()
|
||||
}
|
||||
|
||||
+40
@@ -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 })
|
||||
}
|
||||
)
|
||||
}
|
||||
+14
-11
@@ -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<Repo, 'id' | 'sourceControlAi'> | 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<string, string> = {}
|
||||
@@ -227,7 +230,7 @@ export function SourceControlTextGenerationDialogForm({
|
||||
)
|
||||
return
|
||||
}
|
||||
onGenerate(params)
|
||||
onGenerate(buildParams(useAppStore.getState().settings) ?? params)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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<Repo, 'id' | 'sourceControlAi'> | null
|
||||
discoveryHostKey: string
|
||||
/** Omitted by workspace-less callers (Settings dry-run); `null` means "linked to nothing". */
|
||||
|
||||
+105
-3
@@ -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,
|
||||
|
||||
@@ -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<SourceControlAgentActionDialogProps>
|
||||
@@ -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<void> =>
|
||||
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<void> =>
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+67
@@ -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' })
|
||||
)
|
||||
})
|
||||
+6
-5
@@ -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,
|
||||
|
||||
+13
-7
@@ -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) }
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+12
-6
@@ -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<CurrentActionSelection>(
|
||||
() =>
|
||||
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
|
||||
|
||||
+12
-9
@@ -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<Repo, 'id' | 'sourceControlAi'> | 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<void>
|
||||
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
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-types'
|
||||
|
||||
export type CommitDraftsByWorktree = Record<string, string>
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
+29
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+11
-2
@@ -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
|
||||
}
|
||||
|
||||
+12
-7
@@ -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<void>
|
||||
}) {
|
||||
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,
|
||||
|
||||
+26
-43
@@ -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<void>>
|
||||
refreshGitHistoryRef: React.RefObject<() => Promise<void>>
|
||||
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
|
||||
])
|
||||
|
||||
+9
-41
@@ -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
|
||||
})
|
||||
|
||||
|
||||
+9
-19
@@ -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<string, boolean> } = { current: {} }
|
||||
const operationStore = getWorktreeOperationStore(WORKTREE_ID, null, undefined)
|
||||
let latest: ReturnType<typeof useSourceControlCommitMessageGeneration> | 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<void> | 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('')
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user