Fix terminal create cleanup and targeted listing (#5454)
This commit is contained in:
parent
da386fae8c
commit
94857be0d0
|
|
@ -52,6 +52,7 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s
|
|||
import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest } from './rpc/core'
|
||||
import { TERMINAL_METHODS } from './rpc/methods/terminal'
|
||||
|
|
@ -1083,6 +1084,242 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(shown.ptyId).toBe('pty-1')
|
||||
})
|
||||
|
||||
it('keeps targeted terminal lists from adopting controller PTYs for other worktrees', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
...MOCK_GIT_WORKTREES,
|
||||
{
|
||||
path: '/tmp/worktree-b',
|
||||
head: 'def',
|
||||
branch: 'feature/bar',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: '/tmp/worktree-a/nested',
|
||||
head: 'ghi',
|
||||
branch: 'feature/nested',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: 'target-controller-pty', cwd: '/tmp/worktree-a/src', title: 'target' },
|
||||
{ id: 'other-controller-pty', cwd: '/tmp/worktree-b/src', title: 'other' },
|
||||
{
|
||||
id: 'repo-1::/tmp/worktree-b@@other-controller-pty',
|
||||
cwd: '/tmp/worktree-a/src',
|
||||
title: 'prefixed other'
|
||||
},
|
||||
{ id: 'nested-controller-pty', cwd: '/tmp/worktree-a/nested/src', title: 'nested' }
|
||||
]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
const terminals = await runtime.listTerminals(`path:${TEST_WORKTREE_PATH}`)
|
||||
|
||||
expect(terminals.terminals).toHaveLength(1)
|
||||
expect(terminals.terminals[0]).toMatchObject({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
worktreePath: TEST_WORKTREE_PATH
|
||||
})
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('target-controller-pty')).toBe(true)
|
||||
expect(internals.ptysById.has('other-controller-pty')).toBe(false)
|
||||
expect(internals.ptysById.has('repo-1::/tmp/worktree-b@@other-controller-pty')).toBe(false)
|
||||
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps explicit-id terminal lists from resolving all worktrees', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(
|
||||
new Error('all-worktree resolution should be skipped')
|
||||
)
|
||||
const runtime = createRuntime()
|
||||
const ptyId = `${TEST_WORKTREE_ID}@@daemon-controller-pty`
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: ptyId, cwd: '/unresolved/cwd', title: 'daemon shell' },
|
||||
{ id: 'cwd-only-pty', cwd: TEST_WORKTREE_PATH, title: 'cwd shell' }
|
||||
]
|
||||
})
|
||||
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([
|
||||
TEST_WORKTREE_ID,
|
||||
TEST_WORKTREE_ID
|
||||
])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has(ptyId)).toBe(true)
|
||||
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches explicit-id cwd PTYs when the resolved worktree cache is incomplete', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
||||
{
|
||||
path: '/tmp/worktree-a/nested',
|
||||
head: 'ghi',
|
||||
branch: 'feature/nested',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtime = createRuntime()
|
||||
await runtime.listTerminals()
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(
|
||||
new Error('explicit-id fallback should not rescan worktrees')
|
||||
)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: 'cwd-only-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'cwd shell' },
|
||||
{ id: 'nested-controller-pty', cwd: `${TEST_WORKTREE_PATH}/nested/src`, title: 'nested' }
|
||||
]
|
||||
})
|
||||
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
|
||||
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps explicit-id cold-cache terminal lists from adopting nested worktree PTYs', async () => {
|
||||
const nestedWorktreeId = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}/nested`
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(
|
||||
new Error('explicit-id fallback should not rescan worktrees')
|
||||
)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getAllWorktreeMeta: () => ({
|
||||
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
|
||||
[nestedWorktreeId]: makeWorktreeMeta()
|
||||
}),
|
||||
getWorktreeMeta: (worktreeId: string) =>
|
||||
({
|
||||
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
|
||||
[nestedWorktreeId]: makeWorktreeMeta()
|
||||
})[worktreeId]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: 'cwd-only-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'cwd shell' },
|
||||
{ id: 'nested-controller-pty', cwd: `${TEST_WORKTREE_PATH}/nested/src`, title: 'nested' }
|
||||
]
|
||||
})
|
||||
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
|
||||
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps explicit-id cold-cache terminal lists from classifying unrelated same-repo worktrees', async () => {
|
||||
const siblingWorktreePath = '/tmp/worktree-sibling'
|
||||
const siblingWorktreeId = `${TEST_REPO_ID}::${siblingWorktreePath}`
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(
|
||||
new Error('explicit-id fallback should not rescan worktrees')
|
||||
)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getAllWorktreeMeta: () => ({
|
||||
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
|
||||
[siblingWorktreeId]: makeWorktreeMeta()
|
||||
}),
|
||||
getWorktreeMeta: (worktreeId: string) =>
|
||||
({
|
||||
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
|
||||
[siblingWorktreeId]: makeWorktreeMeta()
|
||||
})[worktreeId]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: 'target-cwd-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'target' },
|
||||
{ id: 'sibling-cwd-pty', cwd: `${siblingWorktreePath}/src`, title: 'sibling' }
|
||||
]
|
||||
})
|
||||
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('target-cwd-pty')).toBe(true)
|
||||
expect(internals.ptysById.has('sibling-cwd-pty')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores cwd-only controller PTYs for malformed explicit worktree IDs', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(
|
||||
new Error('malformed explicit-id fallback should not rescan worktrees')
|
||||
)
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: 'cwd-only-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'cwd shell' }
|
||||
]
|
||||
})
|
||||
|
||||
const terminals = await runtime.listTerminals(`id:${TEST_REPO_ID}::`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals).toEqual([])
|
||||
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
|
||||
expect(internals.ptysById.has('cwd-only-pty')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches explicit-id cwd PTYs for folder workspace instance IDs', async () => {
|
||||
const folderWorktreeId = `${TEST_REPO_ID}::${TEST_FOLDER_WORKSPACE_PATH}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111`
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(
|
||||
new Error('folder explicit-id fallback should not rescan worktrees')
|
||||
)
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: 'folder-cwd-pty', cwd: `${TEST_FOLDER_WORKSPACE_PATH}/src`, title: 'folder shell' }
|
||||
]
|
||||
})
|
||||
|
||||
const terminals = await runtime.listTerminals(`id:${folderWorktreeId}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([folderWorktreeId])
|
||||
expect(terminals.terminals[0]?.worktreePath).toBe(TEST_FOLDER_WORKSPACE_PATH)
|
||||
})
|
||||
|
||||
it('routes PTY output through the PTY leaf index in large terminal graphs', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const liveLeafCount = 2773
|
||||
|
|
|
|||
|
|
@ -127,7 +127,11 @@ import {
|
|||
import { isLinearUuid } from '../../shared/linear-uuid'
|
||||
import type { FeatureInteractionId } from '../../shared/feature-interactions'
|
||||
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import {
|
||||
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
|
||||
splitWorktreeId,
|
||||
splitWorktreeIdForFilesystem
|
||||
} from '../../shared/worktree-id'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectHostSetupWorktreeMeta
|
||||
|
|
@ -6387,17 +6391,59 @@ export class OrcaRuntimeService {
|
|||
throw new Error('invalid_limit')
|
||||
}
|
||||
const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null
|
||||
const targetWorktreeId = worktreeSelector
|
||||
? (getExplicitWorktreeIdSelector(worktreeSelector) ??
|
||||
(await this.resolveWorktreeSelector(worktreeSelector)).id)
|
||||
const explicitTargetWorktreeId = worktreeSelector
|
||||
? getExplicitWorktreeIdSelector(worktreeSelector)
|
||||
: null
|
||||
const worktreesById = await this.getResolvedWorktreeMap()
|
||||
const initialResolvedWorktreeCache = this.resolvedWorktreeCache
|
||||
const cachedResolvedWorktrees =
|
||||
initialResolvedWorktreeCache && initialResolvedWorktreeCache.expiresAt > Date.now()
|
||||
? initialResolvedWorktreeCache.worktrees
|
||||
: null
|
||||
const cachedExplicitTargetWorktree =
|
||||
explicitTargetWorktreeId && cachedResolvedWorktrees
|
||||
? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ??
|
||||
null)
|
||||
: null
|
||||
const parsedExplicitTargetWorktree =
|
||||
explicitTargetWorktreeId && !cachedExplicitTargetWorktree
|
||||
? this.buildResolvedWorktreeFromId(explicitTargetWorktreeId)
|
||||
: null
|
||||
const targetWorktree =
|
||||
worktreeSelector && !explicitTargetWorktreeId
|
||||
? await this.resolveWorktreeSelector(worktreeSelector)
|
||||
: (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree)
|
||||
const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null
|
||||
const classificationResolvedWorktreeCache = this.resolvedWorktreeCache
|
||||
const classificationResolvedWorktrees =
|
||||
targetWorktreeId &&
|
||||
classificationResolvedWorktreeCache &&
|
||||
classificationResolvedWorktreeCache.expiresAt > Date.now()
|
||||
? includeTargetResolvedWorktree(
|
||||
classificationResolvedWorktreeCache.worktrees,
|
||||
targetWorktree
|
||||
)
|
||||
: targetWorktreeId && explicitTargetWorktreeId
|
||||
? this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree)
|
||||
: null
|
||||
const worktreesById =
|
||||
targetWorktreeId && targetWorktree
|
||||
? new Map([[targetWorktree.id, targetWorktree]])
|
||||
: targetWorktreeId
|
||||
? new Map()
|
||||
: await this.getResolvedWorktreeMap()
|
||||
if (graphEpoch !== null) {
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
}
|
||||
|
||||
const resolvedWorktrees = [...worktreesById.values()]
|
||||
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
|
||||
const resolvedWorktrees =
|
||||
targetWorktreeId && classificationResolvedWorktrees
|
||||
? classificationResolvedWorktrees
|
||||
: targetWorktreeId && targetWorktree
|
||||
? [targetWorktree]
|
||||
: targetWorktreeId
|
||||
? []
|
||||
: [...worktreesById.values()]
|
||||
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees, targetWorktreeId)
|
||||
|
||||
const livePtyWorktreeIds = new Set<string>()
|
||||
for (const pty of this.ptysById.values()) {
|
||||
|
|
@ -13666,6 +13712,70 @@ export class OrcaRuntimeService {
|
|||
return this.store as unknown as Store
|
||||
}
|
||||
|
||||
private buildResolvedWorktreeFromId(worktreeId: string): ResolvedWorktree | null {
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
if (!parsed?.repoId || !parsed.worktreePath) {
|
||||
return null
|
||||
}
|
||||
const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId)
|
||||
const git = {
|
||||
path: parsed.worktreePath,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false
|
||||
}
|
||||
const meta = this.store?.getWorktreeMeta(worktreeId)
|
||||
const merged = mergeWorktree(parsed.repoId, git, meta, repo?.displayName)
|
||||
return {
|
||||
...merged,
|
||||
id: worktreeId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git,
|
||||
displayName: merged.displayName,
|
||||
comment: merged.comment
|
||||
}
|
||||
}
|
||||
|
||||
private listKnownResolvedWorktreesForExplicitTarget(
|
||||
targetWorktreeId: string,
|
||||
targetWorktree: ResolvedWorktree | null
|
||||
): ResolvedWorktree[] {
|
||||
if (!this.store || !targetWorktree) {
|
||||
return []
|
||||
}
|
||||
const target = splitWorktreeIdForFilesystem(targetWorktreeId)
|
||||
if (!target?.repoId || !target.worktreePath) {
|
||||
return []
|
||||
}
|
||||
const worktreeIds = new Set(
|
||||
Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => {
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
return (
|
||||
parsed?.repoId === target.repoId &&
|
||||
Boolean(parsed.worktreePath) &&
|
||||
(isPathInsideOrEqual(target.worktreePath, parsed.worktreePath) ||
|
||||
isPathInsideOrEqual(parsed.worktreePath, target.worktreePath))
|
||||
)
|
||||
})
|
||||
)
|
||||
worktreeIds.add(targetWorktreeId)
|
||||
|
||||
const resolved: ResolvedWorktree[] = []
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const worktree =
|
||||
worktreeId === targetWorktreeId
|
||||
? targetWorktree
|
||||
: this.buildResolvedWorktreeFromId(worktreeId)
|
||||
if (worktree) {
|
||||
resolved.push(worktree)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private async listResolvedWorktrees(): Promise<ResolvedWorktree[]> {
|
||||
if (!this.store) {
|
||||
return []
|
||||
|
|
@ -14029,7 +14139,8 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
private async refreshPtyWorktreeRecordsFromController(
|
||||
resolvedWorktrees: ResolvedWorktree[]
|
||||
resolvedWorktrees: ResolvedWorktree[],
|
||||
targetWorktreeId: string | null = null
|
||||
): Promise<void> {
|
||||
if (!this.ptyController?.listProcesses) {
|
||||
return
|
||||
|
|
@ -14048,6 +14159,9 @@ export class OrcaRuntimeService {
|
|||
const worktreeId =
|
||||
inferWorktreeIdFromPtyId(session.id) ??
|
||||
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
|
||||
if (targetWorktreeId && worktreeId !== targetWorktreeId) {
|
||||
continue
|
||||
}
|
||||
if (worktreeId) {
|
||||
this.recordPtyWorktree(session.id, worktreeId, {
|
||||
connected: true
|
||||
|
|
@ -19117,6 +19231,16 @@ function parseRuntimeWorktreeId(
|
|||
return parsed
|
||||
}
|
||||
|
||||
function includeTargetResolvedWorktree(
|
||||
resolvedWorktrees: ResolvedWorktree[],
|
||||
targetWorktree: ResolvedWorktree | null
|
||||
): ResolvedWorktree[] {
|
||||
if (!targetWorktree || resolvedWorktrees.some((worktree) => worktree.id === targetWorktree.id)) {
|
||||
return resolvedWorktrees
|
||||
}
|
||||
return [...resolvedWorktrees, targetWorktree]
|
||||
}
|
||||
|
||||
function findResolvedWorktreeIdForPath(
|
||||
resolvedWorktrees: ResolvedWorktree[],
|
||||
cwd: string
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import {
|
|||
seedStore
|
||||
} from './store-test-helpers'
|
||||
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
|
||||
import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers'
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -1569,6 +1570,446 @@ describe('setActiveWorktree', () => {
|
|||
expect(replacement.title).toBe('Terminal 1')
|
||||
})
|
||||
|
||||
it('preserves cleanup-owned references when there are no orphan terminals', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: [
|
||||
makeUnifiedTab({
|
||||
id: 'terminal-1',
|
||||
entityId: 'terminal-1',
|
||||
worktreeId: wt,
|
||||
groupId: 'group-1'
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'terminal-1': []
|
||||
},
|
||||
activeTabId: 'terminal-1',
|
||||
activeTabIdByWorktree: {
|
||||
[wt]: 'terminal-1'
|
||||
}
|
||||
})
|
||||
|
||||
const state = store.getState()
|
||||
const patch = buildOrphanTerminalCleanupPatch(state, wt, new Set())
|
||||
const referenceKeys = [
|
||||
'tabsByWorktree',
|
||||
'ptyIdsByTabId',
|
||||
'runtimePaneTitlesByTabId',
|
||||
'expandedPaneByTabId',
|
||||
'canExpandPaneByTabId',
|
||||
'terminalLayoutsByTabId',
|
||||
'pendingStartupByTabId',
|
||||
'pendingSetupSplitByTabId',
|
||||
'pendingIssueCommandSplitByTabId',
|
||||
'tabBarOrderByWorktree',
|
||||
'cacheTimerByKey',
|
||||
'activeTabIdByWorktree'
|
||||
] as const
|
||||
|
||||
for (const key of referenceKeys) {
|
||||
expect(patch[key]).toBe(state[key])
|
||||
}
|
||||
expect(patch.activeTabId).toBe(state.activeTabId)
|
||||
})
|
||||
|
||||
it('removes orphan terminal caches while creating a replacement tab', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const orphanId = 'orphan-terminal'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: orphanId, worktreeId: wt })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: []
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[orphanId]: []
|
||||
},
|
||||
runtimePaneTitlesByTabId: {
|
||||
[orphanId]: { 1: 'stale' }
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[orphanId]: makeLayout()
|
||||
},
|
||||
pendingStartupByTabId: {
|
||||
[orphanId]: { command: 'codex' }
|
||||
},
|
||||
tabBarOrderByWorktree: {
|
||||
[wt]: [orphanId]
|
||||
},
|
||||
cacheTimerByKey: {
|
||||
[`${orphanId}:seed`]: 123
|
||||
},
|
||||
activeTabId: orphanId,
|
||||
activeTabIdByWorktree: {
|
||||
[wt]: orphanId
|
||||
}
|
||||
})
|
||||
|
||||
const replacement = store.getState().createTab(wt)
|
||||
const s = store.getState()
|
||||
|
||||
expect(s.tabsByWorktree[wt]?.map((tab) => tab.id)).toEqual([replacement.id])
|
||||
expect(s.ptyIdsByTabId[orphanId]).toBeUndefined()
|
||||
expect(s.runtimePaneTitlesByTabId[orphanId]).toBeUndefined()
|
||||
expect(s.terminalLayoutsByTabId[orphanId]).toBeUndefined()
|
||||
expect(s.pendingStartupByTabId[orphanId]).toBeUndefined()
|
||||
expect(s.cacheTimerByKey[`${orphanId}:seed`]).toBeUndefined()
|
||||
expect(s.terminalLayoutsByTabId[replacement.id]).toEqual(makeLayout())
|
||||
})
|
||||
|
||||
it('clears orphan active terminal state while creating an inactive replacement tab', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const orphanId = 'orphan-terminal'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: orphanId, worktreeId: wt })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: []
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: 'group-1',
|
||||
worktreeId: wt,
|
||||
activeTabId: orphanId,
|
||||
tabOrder: [orphanId]
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[orphanId]: []
|
||||
},
|
||||
activeTabId: orphanId,
|
||||
activeTabIdByWorktree: {
|
||||
[wt]: orphanId
|
||||
}
|
||||
})
|
||||
|
||||
const replacement = store.getState().createTab(wt, undefined, undefined, { activate: false })
|
||||
const s = store.getState()
|
||||
|
||||
expect(s.tabsByWorktree[wt]?.map((tab) => tab.id)).toEqual([replacement.id])
|
||||
expect(s.activeTabId).toBeNull()
|
||||
expect(s.activeTabIdByWorktree[wt]).toBe(replacement.id)
|
||||
expect(s.groupsByWorktree[wt]?.[0]?.activeTabId).toBe(replacement.id)
|
||||
expect(s.groupsByWorktree[wt]?.[0]?.tabOrder).toEqual([replacement.id])
|
||||
})
|
||||
|
||||
it('uses cleanup active fallback when inactive creation removes an orphan active tab', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const orphanId = 'orphan-terminal'
|
||||
const existingId = 'existing-terminal'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [
|
||||
makeTab({ id: orphanId, worktreeId: wt }),
|
||||
makeTab({ id: existingId, worktreeId: wt })
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: [
|
||||
makeUnifiedTab({
|
||||
id: existingId,
|
||||
entityId: existingId,
|
||||
worktreeId: wt,
|
||||
groupId: 'group-a'
|
||||
})
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: 'group-a',
|
||||
worktreeId: wt,
|
||||
activeTabId: existingId,
|
||||
tabOrder: [existingId]
|
||||
}),
|
||||
makeTabGroup({
|
||||
id: 'group-b',
|
||||
worktreeId: wt,
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[orphanId]: [],
|
||||
[existingId]: []
|
||||
},
|
||||
activeTabId: orphanId,
|
||||
activeTabIdByWorktree: {
|
||||
[wt]: orphanId
|
||||
}
|
||||
})
|
||||
|
||||
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
|
||||
const s = store.getState()
|
||||
|
||||
expect(s.activeTabId).toBeNull()
|
||||
expect(s.activeTabIdByWorktree[wt]).toBe(existingId)
|
||||
expect(s.tabsByWorktree[wt]?.map((tab) => tab.id)).toEqual([existingId, created.id])
|
||||
expect(s.groupsByWorktree[wt]?.find((group) => group.id === 'group-b')).toMatchObject({
|
||||
activeTabId: created.id,
|
||||
tabOrder: [created.id]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps surviving target-group tab active when inactive creation removes an orphan', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const orphanId = 'orphan-terminal'
|
||||
const existingId = 'existing-terminal'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [
|
||||
makeTab({ id: orphanId, worktreeId: wt }),
|
||||
makeTab({ id: existingId, worktreeId: wt })
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: [
|
||||
makeUnifiedTab({
|
||||
id: existingId,
|
||||
entityId: existingId,
|
||||
worktreeId: wt,
|
||||
groupId: 'group-1'
|
||||
})
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: 'group-1',
|
||||
worktreeId: wt,
|
||||
activeTabId: orphanId,
|
||||
tabOrder: [orphanId, existingId],
|
||||
recentTabIds: [orphanId]
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[orphanId]: [],
|
||||
[existingId]: []
|
||||
},
|
||||
activeTabId: orphanId,
|
||||
activeTabIdByWorktree: {
|
||||
[wt]: orphanId
|
||||
}
|
||||
})
|
||||
|
||||
const created = store.getState().createTab(wt, 'group-1', undefined, { activate: false })
|
||||
const s = store.getState()
|
||||
|
||||
expect(s.activeTabIdByWorktree[wt]).toBe(existingId)
|
||||
expect(s.groupsByWorktree[wt]?.[0]).toMatchObject({
|
||||
activeTabId: existingId,
|
||||
tabOrder: [existingId, created.id],
|
||||
recentTabIds: [existingId]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps inactive terminal creation active state scoped to the target group', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const existingId = 'existing-terminal'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: existingId, worktreeId: wt })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: [
|
||||
makeUnifiedTab({
|
||||
id: existingId,
|
||||
entityId: existingId,
|
||||
worktreeId: wt,
|
||||
groupId: 'group-a'
|
||||
})
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: 'group-a',
|
||||
worktreeId: wt,
|
||||
activeTabId: existingId,
|
||||
tabOrder: [existingId]
|
||||
}),
|
||||
makeTabGroup({
|
||||
id: 'group-b',
|
||||
worktreeId: wt,
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[existingId]: []
|
||||
},
|
||||
activeTabId: existingId,
|
||||
activeTabIdByWorktree: {
|
||||
[wt]: existingId
|
||||
}
|
||||
})
|
||||
|
||||
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
|
||||
const groups = store.getState().groupsByWorktree[wt] ?? []
|
||||
|
||||
expect(store.getState().activeTabIdByWorktree[wt]).toBe(existingId)
|
||||
expect(groups.find((group) => group.id === 'group-a')?.activeTabId).toBe(existingId)
|
||||
expect(groups.find((group) => group.id === 'group-b')?.activeTabId).toBe(created.id)
|
||||
expect(groups.find((group) => group.id === 'group-b')?.tabOrder).toEqual([created.id])
|
||||
})
|
||||
|
||||
it('clears orphan terminal state from non-target groups during tab creation', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const orphanId = 'orphan-terminal'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: orphanId, worktreeId: wt })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: []
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: 'group-a',
|
||||
worktreeId: wt,
|
||||
activeTabId: orphanId,
|
||||
tabOrder: [orphanId],
|
||||
recentTabIds: [orphanId]
|
||||
}),
|
||||
makeTabGroup({
|
||||
id: 'group-b',
|
||||
worktreeId: wt,
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[orphanId]: []
|
||||
}
|
||||
})
|
||||
|
||||
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
|
||||
const groups = store.getState().groupsByWorktree[wt] ?? []
|
||||
|
||||
expect(groups.find((group) => group.id === 'group-a')).toMatchObject({
|
||||
activeTabId: null,
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
})
|
||||
expect(groups.find((group) => group.id === 'group-b')).toMatchObject({
|
||||
activeTabId: created.id,
|
||||
tabOrder: [created.id]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps surviving non-target group tab active when inactive creation removes an orphan', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const orphanId = 'orphan-terminal'
|
||||
const existingId = 'existing-terminal'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [
|
||||
makeTab({ id: orphanId, worktreeId: wt }),
|
||||
makeTab({ id: existingId, worktreeId: wt })
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: [
|
||||
makeUnifiedTab({
|
||||
id: existingId,
|
||||
entityId: existingId,
|
||||
worktreeId: wt,
|
||||
groupId: 'group-a'
|
||||
})
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: 'group-a',
|
||||
worktreeId: wt,
|
||||
activeTabId: orphanId,
|
||||
tabOrder: [orphanId, existingId],
|
||||
recentTabIds: [orphanId]
|
||||
}),
|
||||
makeTabGroup({
|
||||
id: 'group-b',
|
||||
worktreeId: wt,
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[orphanId]: [],
|
||||
[existingId]: []
|
||||
}
|
||||
})
|
||||
|
||||
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
|
||||
const groups = store.getState().groupsByWorktree[wt] ?? []
|
||||
|
||||
expect(groups.find((group) => group.id === 'group-a')).toMatchObject({
|
||||
activeTabId: existingId,
|
||||
tabOrder: [existingId],
|
||||
recentTabIds: [existingId]
|
||||
})
|
||||
expect(groups.find((group) => group.id === 'group-b')).toMatchObject({
|
||||
activeTabId: created.id,
|
||||
tabOrder: [created.id]
|
||||
})
|
||||
})
|
||||
|
||||
// Why: unread flags are ephemeral UI state — they must not linger past the
|
||||
// lifetime of the tab/pane they point at. A stale flag on a closed tab
|
||||
// would render a bell the user can never dismiss because the tab (and
|
||||
|
|
|
|||
|
|
@ -66,6 +66,24 @@ export function buildOrphanTerminalCleanupPatch(
|
|||
| 'activeTabIdByWorktree'
|
||||
| 'activeTabId'
|
||||
> {
|
||||
if (orphanTerminalIds.size === 0) {
|
||||
return {
|
||||
tabsByWorktree: state.tabsByWorktree,
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
|
||||
expandedPaneByTabId: state.expandedPaneByTabId,
|
||||
canExpandPaneByTabId: state.canExpandPaneByTabId,
|
||||
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
|
||||
pendingStartupByTabId: state.pendingStartupByTabId,
|
||||
pendingSetupSplitByTabId: state.pendingSetupSplitByTabId,
|
||||
pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId,
|
||||
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
|
||||
cacheTimerByKey: state.cacheTimerByKey,
|
||||
activeTabIdByWorktree: state.activeTabIdByWorktree,
|
||||
activeTabId: state.activeTabId
|
||||
}
|
||||
}
|
||||
|
||||
const nextTabs = (state.tabsByWorktree[worktreeId] ?? []).filter(
|
||||
(tab) => !orphanTerminalIds.has(tab.id)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -702,6 +702,36 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
id,
|
||||
'terminal'
|
||||
)
|
||||
const groupsForWorktree = groupsByWorktree[worktreeId] ?? []
|
||||
const cleanedGroups =
|
||||
orphanTerminalIds.size === 0
|
||||
? groupsForWorktree
|
||||
: groupsForWorktree.map((entry) => {
|
||||
// Why: orphan cleanup must repair every group before adding the
|
||||
// new tab, or inactive/background creation can revive stale focus.
|
||||
const tabOrder = dedupeTabOrder(entry.tabOrder).filter(
|
||||
(tabId) => !orphanTerminalIds.has(tabId)
|
||||
)
|
||||
const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder)
|
||||
const replacedActiveTabId = Boolean(
|
||||
entry.activeTabId && orphanTerminalIds.has(entry.activeTabId)
|
||||
)
|
||||
const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null
|
||||
const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId
|
||||
return {
|
||||
...entry,
|
||||
activeTabId,
|
||||
tabOrder,
|
||||
recentTabIds:
|
||||
replacedActiveTabId && activeTabId
|
||||
? pushRecentTabId(recentTabIds, activeTabId)
|
||||
: recentTabIds
|
||||
}
|
||||
})
|
||||
const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group
|
||||
const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter(
|
||||
(tabId) => !orphanTerminalIds.has(tabId)
|
||||
)
|
||||
const unifiedTab = existingTerminalTab ?? {
|
||||
id,
|
||||
entityId: id,
|
||||
|
|
@ -714,16 +744,21 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
: {}),
|
||||
customLabel: tab.customTitle,
|
||||
color: tab.color,
|
||||
sortOrder: dedupeTabOrder(group.tabOrder).length,
|
||||
sortOrder: cleanedGroupOrder.length,
|
||||
createdAt: tab.createdAt
|
||||
}
|
||||
const nextGroupOrder = dedupeTabOrder([...group.tabOrder, unifiedTab.id])
|
||||
const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id])
|
||||
const nextRecent = shouldActivate
|
||||
? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id)
|
||||
: sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder)
|
||||
: sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder)
|
||||
const cleanedActiveTabIdForWorktree = orphanCleanupPatch.activeTabIdByWorktree[worktreeId]
|
||||
const cleanedGroupActiveTabId =
|
||||
cleanedTargetGroup.activeTabId && !orphanTerminalIds.has(cleanedTargetGroup.activeTabId)
|
||||
? cleanedTargetGroup.activeTabId
|
||||
: null
|
||||
const nextActiveTabIdForWorktree = shouldActivate
|
||||
? tab.id
|
||||
: (s.activeTabIdByWorktree[worktreeId] ?? group.activeTabId ?? tab.id)
|
||||
: (cleanedActiveTabIdForWorktree ?? cleanedGroupActiveTabId ?? tab.id)
|
||||
return {
|
||||
...orphanCleanupPatch,
|
||||
tabsByWorktree: {
|
||||
|
|
@ -741,9 +776,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
},
|
||||
groupsByWorktree: {
|
||||
...groupsByWorktree,
|
||||
[worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], {
|
||||
...group,
|
||||
activeTabId: shouldActivate ? unifiedTab.id : (group.activeTabId ?? unifiedTab.id),
|
||||
[worktreeId]: updateGroup(cleanedGroups, {
|
||||
...cleanedTargetGroup,
|
||||
activeTabId: shouldActivate
|
||||
? unifiedTab.id
|
||||
: (cleanedGroupActiveTabId ?? unifiedTab.id),
|
||||
tabOrder: nextGroupOrder,
|
||||
recentTabIds: nextRecent
|
||||
})
|
||||
|
|
@ -753,17 +790,17 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
...s.layoutByWorktree,
|
||||
[worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id }
|
||||
},
|
||||
activeTabId: shouldActivate ? tab.id : s.activeTabId,
|
||||
activeTabId: shouldActivate ? tab.id : orphanCleanupPatch.activeTabId,
|
||||
activeTabIdByWorktree: {
|
||||
...s.activeTabIdByWorktree,
|
||||
...orphanCleanupPatch.activeTabIdByWorktree,
|
||||
[worktreeId]: nextActiveTabIdForWorktree
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
...s.ptyIdsByTabId,
|
||||
...orphanCleanupPatch.ptyIdsByTabId,
|
||||
[tab.id]: options?.initialPtyId ? [options.initialPtyId] : []
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
...s.terminalLayoutsByTabId,
|
||||
...orphanCleanupPatch.terminalLayoutsByTabId,
|
||||
[tab.id]: emptyLayoutSnapshot()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue