mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
fix: keep delete dialog status stable during catalog refreshes (#41864)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -501,7 +501,6 @@
|
||||
1 src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteNestedWorktreesDialog.tsx
|
||||
1 src/renderer/src/components/sidebar/workspace-actions/deletion/RemovalUnsavedFilesNotice.tsx
|
||||
1 src/renderer/src/components/sidebar/workspace-actions/deletion/WorktreeForceDeleteButton.tsx
|
||||
1 src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-status-hydration.ts
|
||||
1 src/renderer/src/components/sidebar/workspace-actions/parenting/WorktreeParentPickerPopover.tsx
|
||||
3 src/renderer/src/components/sidebar/workspace-actions/parenting/use-worktree-parent-picker-transition.ts
|
||||
7 src/renderer/src/components/sidebar/workspace-board/drag/use-workspace-kanban-card-pointer-drag.ts
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { GitStatusResult } from '../../../../../../shared/git/status/git-status-types'
|
||||
import { getRuntimeGitStatus } from '@/runtime/source-control/git/runtime-git-status-client'
|
||||
import { useDeleteWorktreeStatusHydration } from './use-delete-worktree-status-hydration'
|
||||
|
||||
const state = vi.hoisted(() => {
|
||||
const repos: Repo[] = []
|
||||
return {
|
||||
repos,
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
gitStatusByWorktree: {}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), {
|
||||
getState: () => state
|
||||
})
|
||||
}))
|
||||
vi.mock('@/runtime/source-control/git/runtime-git-status-client', () => ({
|
||||
getRuntimeGitStatus: vi.fn()
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
state.repos = []
|
||||
})
|
||||
|
||||
describe('delete status snapshot continuity', () => {
|
||||
it('keeps loaded paths when catalog objects refresh for the same workspace', async () => {
|
||||
vi.mocked(getRuntimeGitStatus)
|
||||
.mockResolvedValueOnce({
|
||||
entries: [{ path: 'keep-visible.ts', status: 'modified', area: 'unstaged' }],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
.mockImplementation(() => new Promise(() => {}))
|
||||
const target = makeWorktree({ id: 'feature', repoId: 'repo', hostId: 'local' })
|
||||
const targets = [target]
|
||||
const view = renderHook(useDeleteWorktreeStatusHydration, {
|
||||
initialProps: {
|
||||
isOpen: true,
|
||||
deleteTargets: targets,
|
||||
visibleTargets: targets,
|
||||
repoMap: new Map<string, Repo>()
|
||||
}
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(view.result.current.get('local|feature')).toEqual([
|
||||
{ path: 'keep-visible.ts', status: 'modified', area: 'unstaged' }
|
||||
])
|
||||
)
|
||||
|
||||
state.repos = [...state.repos]
|
||||
view.rerender({
|
||||
isOpen: true,
|
||||
deleteTargets: [{ ...target, lastActivityAt: 42 }],
|
||||
visibleTargets: [{ ...target }],
|
||||
repoMap: new Map()
|
||||
})
|
||||
|
||||
expect(view.result.current.get('local|feature')).toEqual([
|
||||
{ path: 'keep-visible.ts', status: 'modified', area: 'unstaged' }
|
||||
])
|
||||
})
|
||||
|
||||
it('replaces the snapshot when the same host changes its SSH connection', async () => {
|
||||
const repo: Repo = {
|
||||
id: 'repo',
|
||||
path: '/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '',
|
||||
addedAt: 1,
|
||||
executionHostId: 'runtime:fixture',
|
||||
connectionId: 'first'
|
||||
}
|
||||
state.repos = [repo]
|
||||
const target = makeWorktree({ id: 'feature', repoId: 'repo', hostId: 'runtime:fixture' })
|
||||
let finish: (status: GitStatusResult) => void = () => {
|
||||
throw new Error('Status read has not started')
|
||||
}
|
||||
vi.mocked(getRuntimeGitStatus)
|
||||
.mockResolvedValueOnce({ entries: [], conflictOperation: 'unknown' })
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const props = {
|
||||
isOpen: true,
|
||||
deleteTargets: [target],
|
||||
visibleTargets: [target],
|
||||
repoMap: new Map([['repo', repo]])
|
||||
}
|
||||
const view = renderHook(useDeleteWorktreeStatusHydration, { initialProps: props })
|
||||
await waitFor(() => expect(view.result.current.get('runtime:fixture|feature')).toEqual([]))
|
||||
|
||||
state.repos = [{ ...repo, connectionId: 'second' }]
|
||||
view.rerender({ ...props, repoMap: new Map([['repo', state.repos[0]!]]) })
|
||||
expect(view.result.current.get('runtime:fixture|feature')).toBeUndefined()
|
||||
await act(async () => {
|
||||
finish({
|
||||
entries: [{ path: 'second-host.ts', status: 'added', area: 'staged' }],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
expect(view.result.current.get('runtime:fixture|feature')).toEqual([
|
||||
{ path: 'second-host.ts', status: 'added', area: 'staged' }
|
||||
])
|
||||
})
|
||||
})
|
||||
+46
-25
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useEffectEvent, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/workspace/ownership/connection-context'
|
||||
import { runtimeTargetForWorkspaceOwner } from '@/lib/workspace/ownership/resolve-owner'
|
||||
@@ -51,20 +51,24 @@ export function useDeleteWorktreeStatusHydration({
|
||||
repoMap: ReadonlyMap<string, Repo>
|
||||
}): ReadonlyMap<string, GitStatusResult['entries'] | null> {
|
||||
const repos = useAppStore((state) => state.repos)
|
||||
const generation = isOpen ? deleteTargets.map(getWorktreeHostIdentity).join('\n') : ''
|
||||
const generationRef = useRef(generation)
|
||||
const [statusByIdentity, setStatusByIdentity] = useState<
|
||||
Map<string, GitStatusResult['entries'] | null>
|
||||
>(() => new Map())
|
||||
const currentStatusByIdentity =
|
||||
generationRef.current === generation ? statusByIdentity : EMPTY_STATUS_BY_IDENTITY
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current = generation
|
||||
setStatusByIdentity(new Map())
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
const generation = isOpen
|
||||
? deleteTargets
|
||||
.map((target) =>
|
||||
JSON.stringify({
|
||||
identity: getWorktreeHostIdentity(target),
|
||||
isMainWorktree: target.isMainWorktree,
|
||||
isFolderWorkspace: isFolderWorkspaceDelete(repoMap, target),
|
||||
context: resolveDeleteTargetGitContext(target, repos)
|
||||
})
|
||||
)
|
||||
.sort()
|
||||
.join('\n')
|
||||
: ''
|
||||
const [result, setResult] = useState<{
|
||||
generation: string
|
||||
statuses: Map<string, GitStatusResult['entries'] | null>
|
||||
}>({ generation: '', statuses: new Map() })
|
||||
const startChecks = useEffectEvent((signal: AbortSignal) => {
|
||||
const gitStatusByWorktree = useAppStore.getState().gitStatusByWorktree
|
||||
const currentState = useAppStore.getState()
|
||||
const targets = orderDeleteWorktreeStatusHydrationTargets({
|
||||
@@ -75,39 +79,56 @@ export function useDeleteWorktreeStatusHydration({
|
||||
activeWorktreeId: currentState.activeWorktreeId,
|
||||
activeExecutionHostId: currentState.activeWorkspaceExecutionHostId
|
||||
})
|
||||
const controller = new AbortController()
|
||||
for (const target of targets) {
|
||||
const identity = getWorktreeHostIdentity(target)
|
||||
const publish = (entries: GitStatusResult['entries'] | null): void =>
|
||||
setResult((current) => ({
|
||||
generation,
|
||||
statuses: new Map(current.generation === generation ? current.statuses : []).set(
|
||||
identity,
|
||||
entries
|
||||
)
|
||||
}))
|
||||
const existingStatus = target.hostId ? undefined : gitStatusByWorktree[target.id]
|
||||
if (existingStatus) {
|
||||
setStatusByIdentity((current) => new Map(current).set(identity, existingStatus))
|
||||
publish(existingStatus)
|
||||
continue
|
||||
}
|
||||
const gitContext = resolveDeleteTargetGitContext(target, repos)
|
||||
if (!gitContext) {
|
||||
setStatusByIdentity((current) => new Map(current).set(identity, null))
|
||||
publish(null)
|
||||
continue
|
||||
}
|
||||
void getRuntimeGitStatus(gitContext, {
|
||||
admissionTier: 'background',
|
||||
includeLineStats: false,
|
||||
signal: controller.signal
|
||||
signal
|
||||
})
|
||||
.then((status) => {
|
||||
if (!controller.signal.aborted && generationRef.current === generation) {
|
||||
setStatusByIdentity((current) => new Map(current).set(identity, status.entries))
|
||||
if (!signal.aborted) {
|
||||
publish(status.entries)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted && generationRef.current === generation) {
|
||||
setStatusByIdentity((current) => new Map(current).set(identity, null))
|
||||
if (!signal.aborted) {
|
||||
publish(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Catalog refreshes replace arrays; restart only when targets or their read routes change.
|
||||
useEffect(() => {
|
||||
setResult({ generation, statuses: new Map() })
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
startChecks(controller.signal)
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [deleteTargets, generation, isOpen, repoMap, repos, visibleTargets])
|
||||
}, [generation, isOpen])
|
||||
|
||||
return currentStatusByIdentity
|
||||
return result.generation === generation ? result.statuses : EMPTY_STATUS_BY_IDENTITY
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user