mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
fix: avoid repeated macOS privacy prompts (#1524)
* fix: avoid repeated macos privacy prompts * fix: reduce background worktree permission probes * chore: pin oxlint for ci * fix: preserve optional rpc params with zod 4.4 * fix: preserve optional inline rpc params with zod 4.4
This commit is contained in:
@@ -11,7 +11,7 @@ export function useGitStatusPolling(): void {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
|
||||
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
|
||||
const setGitStatus = useAppStore((s) => s.setGitStatus)
|
||||
const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus)
|
||||
const setConflictOperation = useAppStore((s) => s.setConflictOperation)
|
||||
@@ -56,11 +56,25 @@ export function useGitStatusPolling(): void {
|
||||
connectionId
|
||||
})) as GitStatusResult
|
||||
setGitStatus(activeWorktreeId, status)
|
||||
// Why: branch switches can happen inside a terminal. `git status
|
||||
// --branch` gives us the new identity without a separate worktree-list
|
||||
// poll that would repeatedly touch repo/worktree roots.
|
||||
updateWorktreeGitIdentity(activeWorktreeId, {
|
||||
head: status.head,
|
||||
branch: status.branch
|
||||
})
|
||||
await fetchUpstreamStatus(activeWorktreeId, worktreePath, connectionId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [activeRepoSupportsGit, activeWorktreeId, fetchUpstreamStatus, worktreePath, setGitStatus])
|
||||
}, [
|
||||
activeRepoSupportsGit,
|
||||
activeWorktreeId,
|
||||
fetchUpstreamStatus,
|
||||
worktreePath,
|
||||
setGitStatus,
|
||||
updateWorktreeGitIdentity
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatus()
|
||||
@@ -83,29 +97,6 @@ export function useGitStatusPolling(): void {
|
||||
}
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRepoId || !activeRepoSupportsGit) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: checkout/switch operations happen inside the terminal, outside the
|
||||
// renderer's normal worktree-change events. Poll the active repo's worktree
|
||||
// list so a branch change updates the sidebar's PR key instead of leaving
|
||||
// the previous merged PR attached to this worktree indefinitely.
|
||||
void fetchWorktrees(activeRepoId)
|
||||
const intervalId = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
void fetchWorktrees(activeRepoId)
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
const onFocus = (): void => void fetchWorktrees(activeRepoId)
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => {
|
||||
clearInterval(intervalId)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
}
|
||||
}, [activeRepoId, activeRepoSupportsGit, fetchWorktrees])
|
||||
|
||||
// Why: poll conflict operation for non-active worktrees that have a stale
|
||||
// non-unknown operation. This is a lightweight fs-only check (no git status)
|
||||
// so it won't cause performance issues even with many worktrees.
|
||||
|
||||
@@ -108,6 +108,10 @@ export type WorktreeSlice = {
|
||||
* one-shot at hydration time. See design §4.4.
|
||||
*/
|
||||
purgeWorktreeTerminalState: (worktreeIds: string[]) => void
|
||||
updateWorktreeGitIdentity: (
|
||||
worktreeId: string,
|
||||
identity: { head?: string; branch?: string }
|
||||
) => void
|
||||
}
|
||||
|
||||
export function findWorktreeById(
|
||||
|
||||
@@ -167,6 +167,37 @@ describe('fetchWorktrees', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateWorktreeGitIdentity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('updates branch identity from git status without fetching worktrees', () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
head: 'old-head',
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 3 } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
expect(store.getState().sortEpoch).toBe(4)
|
||||
expect(mockApi.worktrees.list).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeWorktree state cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -652,6 +683,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
|
||||
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(2)
|
||||
expect(store.getState().tabsByWorktree).toEqual({
|
||||
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
|
||||
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
|
||||
@@ -667,6 +699,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
||||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(4)
|
||||
expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,24 +114,24 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
// (design §2c), which means the user would still need a second restart
|
||||
// post-upgrade to reclaim memory.
|
||||
//
|
||||
// Safety gate: fetchWorktrees swallows IPC errors (catch at :93-95)
|
||||
// and short-circuits on empty-replace when cached data exists
|
||||
// (empty-guard at :82-84). Neither signal bubbles up to the caller, so
|
||||
// we can't distinguish "threw" from "returned []" from "returned ≥1"
|
||||
// by inspecting post-state alone. If we declared the union of
|
||||
// worktreesByRepo authoritative without confirming every repo
|
||||
// succeeded, a single transient git error at launch would wipe every
|
||||
// tabsByWorktree entry for the affected repo — the exact data-loss
|
||||
// class the empty-guard exists to prevent. Probe the IPC directly to
|
||||
// get the precise per-repo result, and defer the purge until every
|
||||
// repo returns success AND at least one has >0 worktrees. In steady
|
||||
// state this fires on the first fully-successful launch; in the
|
||||
// degraded state it simply waits.
|
||||
// Safety gate: fetchWorktrees swallows IPC errors and short-circuits on
|
||||
// empty-replace when cached data exists. Neither signal bubbles up to the
|
||||
// caller, so we probe the IPC directly to get the per-repo success signal,
|
||||
// then apply that same payload to state instead of listing each repo again.
|
||||
const results = await Promise.all(
|
||||
repos.map(async (r) => {
|
||||
try {
|
||||
const list = await window.api.worktrees.list({ repoId: r.id })
|
||||
await get().fetchWorktrees(r.id)
|
||||
const current = get().worktreesByRepo[r.id]
|
||||
if (
|
||||
!areWorktreesEqual(current, list) &&
|
||||
!(list.length === 0 && current && current.length > 0)
|
||||
) {
|
||||
set((s) => ({
|
||||
worktreesByRepo: { ...s.worktreesByRepo, [r.id]: list },
|
||||
sortEpoch: s.sortEpoch + 1
|
||||
}))
|
||||
}
|
||||
return { repoId: r.id, ok: list.length > 0 }
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch worktrees for repo ${r.id}:`, err)
|
||||
@@ -162,6 +162,39 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
set({ hasHydratedWorktreePurge: true })
|
||||
},
|
||||
|
||||
updateWorktreeGitIdentity: (worktreeId, identity) => {
|
||||
set((s) => {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const current = s.worktreesByRepo[repoId]
|
||||
if (!current) {
|
||||
return {}
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const next = current.map((worktree) => {
|
||||
if (worktree.id !== worktreeId) {
|
||||
return worktree
|
||||
}
|
||||
const nextHead = identity.head ?? worktree.head
|
||||
const nextBranch = identity.branch ?? worktree.branch
|
||||
if (nextHead === worktree.head && nextBranch === worktree.branch) {
|
||||
return worktree
|
||||
}
|
||||
changed = true
|
||||
return { ...worktree, head: nextHead, branch: nextBranch }
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
worktreesByRepo: { ...s.worktreesByRepo, [repoId]: next },
|
||||
sortEpoch: s.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
createWorktree: async (
|
||||
repoId,
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user