mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* fix(repos): remove a paired computer's deleted projects from every connected device A project deleted on a paired Orca host stayed in every connected client's sidebar and could not be removed there. Two independent defects: 1. Host-local repo IPC mutations only sent `repos:changed` to the host's own renderer (src/main/ipc/repos.ts:2711). The runtime client-event stream was fed only by mutations arriving over runtime RPC, and clients refetch a remote catalog only on a `reposChanged` event -- there is no polling on desktop -- so the deleted rows persisted indefinitely. The shared `notifyReposChanged` helper now also calls the new `OrcaRuntimeService.notifyReposChangedForRemoteClients()` (src/main/runtime/orca-runtime.ts:5175), mirroring the existing `notifyWorktreesChangedForRemoteClients` precedent. This covers every repo, project-group and folder-workspace IPC mutation, so renames, colors, reorders and adds propagate too. 2. Deleting the ghost row on the client routed `repo.rm` to the owner, which answered `repo_not_found`. `removeProject` wrapped its whole body in one try/catch, so the rejection aborted the local purge before the `set()` (src/renderer/src/store/slices/repos.ts:3466) and the delete button appeared to do nothing. Only `repo_not_found` is now tolerated; any other failure still keeps the row, and an opt-in `errorFeedback: 'toast'` makes it visible at the three single-project user-initiated entry points. Bulk and background callers keep today's silence plus their own aggregate reporting. Closes #11994 Co-authored-by: Orca <help@stably.ai> * fix(repos): revert inert RepositoryPane removeProject arg The settings pane's only render site drops the argument; the toast is already delivered by removeSettingsProjectFromAllHosts. Co-authored-by: Orca <help@stably.ai> * fix(repos): scope duplicate-repo-id deletes to the owning execution host Cover the cross-host collisions #11994's broadcast now fans out to every paired device. Same-name projects on different hosts were already isolated (per-host UUIDs, host-scoped catalog merge and purge) and are pinned by regression tests. Two same-repo-id paths were not: `repo.rm` with a `path:`/`name:` selector and `deleteProjectHostSetup` both resolved one row and then deleted by bare id, taking the sibling host's registration with it. Co-authored-by: Orca <help@stably.ai> * test(mobile): align the poll-interval rationale with the new reposChanged emission Co-authored-by: Orca <help@stably.ai> * fix(repos): resolve deleteProjectHostSetup's repo row only on the setup's own host The sibling-host fallback could only ever pick a row on a host the caller did not name; with no exact match the setup is stale and the existing path already drops just the setup. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
130 lines
4.2 KiB
TypeScript
130 lines
4.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
import { startHostWorktreeRefresh } from './host-worktree-refresh'
|
|
|
|
const appState = vi.hoisted(() => ({
|
|
currentState: 'active',
|
|
listener: null as ((state: string) => void) | null,
|
|
remove: vi.fn()
|
|
}))
|
|
|
|
vi.mock('react-native', () => ({
|
|
AppState: {
|
|
get currentState() {
|
|
return appState.currentState
|
|
},
|
|
addEventListener: (_event: string, listener: (state: string) => void) => {
|
|
appState.listener = listener
|
|
return { remove: appState.remove }
|
|
}
|
|
}
|
|
}))
|
|
|
|
describe('startHostWorktreeRefresh', () => {
|
|
let eventListener: ((payload: unknown) => void) | null
|
|
let fetchWorktrees: ReturnType<typeof vi.fn>
|
|
let fetchRepoMetadata: ReturnType<typeof vi.fn>
|
|
let unsubscribe: ReturnType<typeof vi.fn>
|
|
let stop: (() => void) | null
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
appState.currentState = 'active'
|
|
appState.listener = null
|
|
appState.remove.mockClear()
|
|
eventListener = null
|
|
fetchWorktrees = vi.fn().mockResolvedValue(undefined)
|
|
fetchRepoMetadata = vi.fn().mockResolvedValue(undefined)
|
|
unsubscribe = vi.fn()
|
|
stop = null
|
|
})
|
|
|
|
function start(): void {
|
|
const client = {
|
|
subscribe: vi.fn(
|
|
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
|
eventListener = listener
|
|
return unsubscribe
|
|
}
|
|
)
|
|
} as unknown as RpcClient
|
|
stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
|
|
}
|
|
|
|
afterEach(() => {
|
|
stop?.()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('keeps the worktree poll active but skips ticks while backgrounded', async () => {
|
|
start()
|
|
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
|
|
|
appState.currentState = 'background'
|
|
await vi.advanceTimersByTimeAsync(6_000)
|
|
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
|
|
|
appState.currentState = 'active'
|
|
await vi.advanceTimersByTimeAsync(3_000)
|
|
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('refreshes both snapshots immediately on foreground return', () => {
|
|
start()
|
|
fetchWorktrees.mockClear()
|
|
fetchRepoMetadata.mockClear()
|
|
|
|
appState.currentState = 'active'
|
|
appState.listener?.('active')
|
|
|
|
expect(fetchWorktrees).toHaveBeenCalledWith({ allowDuringModal: true })
|
|
expect(fetchRepoMetadata).toHaveBeenCalledWith({ queueIfInFlight: true })
|
|
})
|
|
|
|
it('polls repo metadata on the interval while foregrounded and skips it while backgrounded', async () => {
|
|
start()
|
|
// Mount force-fetch.
|
|
expect(fetchRepoMetadata).toHaveBeenCalledTimes(1)
|
|
|
|
// Foregrounded: repo.list rides the interval as a convergence safety-net for hosts on an
|
|
// older build, whose Settings edits emit no runtime reposChanged (the callee self-throttles).
|
|
await vi.advanceTimersByTimeAsync(9_000)
|
|
expect(fetchWorktrees).toHaveBeenCalledTimes(4)
|
|
expect(fetchRepoMetadata).toHaveBeenCalledTimes(4)
|
|
|
|
// Backgrounded: neither snapshot is polled.
|
|
fetchWorktrees.mockClear()
|
|
fetchRepoMetadata.mockClear()
|
|
appState.currentState = 'background'
|
|
await vi.advanceTimersByTimeAsync(9_000)
|
|
expect(fetchWorktrees).not.toHaveBeenCalled()
|
|
expect(fetchRepoMetadata).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refreshes worktrees and repo metadata on reposChanged', () => {
|
|
start()
|
|
fetchWorktrees.mockClear()
|
|
fetchRepoMetadata.mockClear()
|
|
|
|
eventListener?.({ type: 'reposChanged' })
|
|
|
|
expect(fetchWorktrees).toHaveBeenCalledOnce()
|
|
expect(fetchRepoMetadata).toHaveBeenCalledOnce()
|
|
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
|
|
})
|
|
|
|
it('refreshes worktrees on worktreesChanged and both snapshots after stream replay', () => {
|
|
start()
|
|
fetchWorktrees.mockClear()
|
|
fetchRepoMetadata.mockClear()
|
|
|
|
eventListener?.({ type: 'worktreesChanged', repoId: 'repo-1' })
|
|
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
|
|
|
eventListener?.({ type: 'ready', subscriptionId: 'events-1' })
|
|
eventListener?.({ type: 'ready', subscriptionId: 'events-2' })
|
|
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
|
|
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
|
|
})
|
|
})
|