diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 60528f0b6..c9ac81841 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -45,6 +45,7 @@ import { writeWorkspaceDragData } from './workspace-status' import { getWorktreeCardPrDisplay } from './worktree-card-pr-display' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { hasActiveWorkspaceActivity } from '@/lib/worktree-activity-state' +import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' import { runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktree } from './sleep-worktree-flow' import { getWorkspaceQuickActionKind } from './worktree-card-quick-action' @@ -264,10 +265,16 @@ const WorktreeCard = React.memo(function WorktreeCard({ if (isWebClient()) { return } - if (repo && !isFolder && !worktree.isBare && hostedReviewCacheKey && showPR) { + if (!repo || isFolder || worktree.isBare || !hostedReviewCacheKey || !showPR) { + return + } + const refreshHostedReviewIfVisible = (): void => { + if (!isWindowVisible()) { + return + } // Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR // number from metadata or the visible cache whenever we have one. - fetchHostedReviewForBranch(repo.path, branch, { + void fetchHostedReviewForBranch(repo.path, branch, { repoId: repo.id, linkedGitHubPR: worktree.linkedPR ?? null, fallbackGitHubPR: fallbackGitHubPRNumber, @@ -275,6 +282,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ staleWhileRevalidate: true }) } + refreshHostedReviewIfVisible() + window.addEventListener('focus', refreshHostedReviewIfVisible) + document.addEventListener('visibilitychange', refreshHostedReviewIfVisible) + return () => { + window.removeEventListener('focus', refreshHostedReviewIfVisible) + document.removeEventListener('visibilitychange', refreshHostedReviewIfVisible) + } }, [ repo, isFolder, @@ -305,21 +319,35 @@ const WorktreeCard = React.memo(function WorktreeCard({ return } - fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) + const issueNumber = worktree.linkedIssue - // Background poll as fallback (activity triggers handle the fast path) - const interval = setInterval(() => { - fetchIssue(repo.path, worktree.linkedIssue!, { repoId: repo.id }) - }, 5 * 60_000) // 5 minutes - - return () => clearInterval(interval) + // Background poll as fallback (activity triggers handle the fast path). + // The interval itself is stopped while hidden so issue cards do not keep + // long-lived workspaces waking just to skip their fetch. + return installWindowVisibilityInterval({ + run: () => void fetchIssue(repo.path, issueNumber, { repoId: repo.id }), + intervalMs: 5 * 60_000 + }) }, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue]) useEffect(() => { if (!worktree.linkedLinearIssue || !showIssue) { return } - void fetchLinearIssue(worktree.linkedLinearIssue) + const linearIssueId = worktree.linkedLinearIssue + const refreshLinearIssueIfVisible = (): void => { + if (!isWindowVisible()) { + return + } + void fetchLinearIssue(linearIssueId) + } + refreshLinearIssueIfVisible() + window.addEventListener('focus', refreshLinearIssueIfVisible) + document.addEventListener('visibilitychange', refreshLinearIssueIfVisible) + return () => { + window.removeEventListener('focus', refreshLinearIssueIfVisible) + document.removeEventListener('visibilitychange', refreshLinearIssueIfVisible) + } }, [worktree.linkedLinearIssue, fetchLinearIssue, showIssue]) // Stable click handler – ignore clicks that are really text selections. diff --git a/src/renderer/src/lib/workspace-port-groups.test.ts b/src/renderer/src/lib/workspace-port-groups.test.ts new file mode 100644 index 000000000..ae76580e8 --- /dev/null +++ b/src/renderer/src/lib/workspace-port-groups.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { + getExternalWorkspacePorts, + getWorkspacePortGroups, + getWorkspacePortsByWorktreeId +} from './workspace-port-groups' + +describe('workspace port group caches', () => { + it('returns stable empty references when no scan result exists', () => { + expect(getWorkspacePortsByWorktreeId(null)).toBe(getWorkspacePortsByWorktreeId(undefined)) + expect(getWorkspacePortGroups(null)).toBe(getWorkspacePortGroups(undefined)) + expect(getExternalWorkspacePorts(null)).toBe(getExternalWorkspacePorts(undefined)) + }) +}) diff --git a/src/renderer/src/lib/workspace-port-groups.ts b/src/renderer/src/lib/workspace-port-groups.ts index d0c88b698..92010ffe2 100644 --- a/src/renderer/src/lib/workspace-port-groups.ts +++ b/src/renderer/src/lib/workspace-port-groups.ts @@ -10,6 +10,9 @@ export type WorkspacePortGroup = { const portsByWorktreeCache = new WeakMap>() const workspaceGroupsCache = new WeakMap() const externalPortsCache = new WeakMap() +const EMPTY_PORTS_BY_WORKTREE = new Map() +const EMPTY_WORKSPACE_PORT_GROUPS: WorkspacePortGroup[] = [] +const EMPTY_EXTERNAL_PORTS: WorkspacePort[] = [] function comparePorts(a: WorkspacePort, b: WorkspacePort): number { return a.port - b.port || (a.processName ?? '').localeCompare(b.processName ?? '') @@ -18,14 +21,15 @@ function comparePorts(a: WorkspacePort, b: WorkspacePort): number { export function getWorkspacePortsByWorktreeId( scan: WorkspacePortScanResult | null | undefined ): Map { - if (scan) { - const cached = portsByWorktreeCache.get(scan) - if (cached) { - return cached - } + if (!scan) { + return EMPTY_PORTS_BY_WORKTREE + } + const cached = portsByWorktreeCache.get(scan) + if (cached) { + return cached } const grouped = new Map() - for (const port of scan?.ports ?? []) { + for (const port of scan.ports) { if (port.kind !== 'workspace') { continue } @@ -39,23 +43,22 @@ export function getWorkspacePortsByWorktreeId( for (const ports of grouped.values()) { ports.sort(comparePorts) } - if (scan) { - portsByWorktreeCache.set(scan, grouped) - } + portsByWorktreeCache.set(scan, grouped) return grouped } export function getWorkspacePortGroups( scan: WorkspacePortScanResult | null | undefined ): WorkspacePortGroup[] { - if (scan) { - const cached = workspaceGroupsCache.get(scan) - if (cached) { - return cached - } + if (!scan) { + return EMPTY_WORKSPACE_PORT_GROUPS + } + const cached = workspaceGroupsCache.get(scan) + if (cached) { + return cached } const groupsByWorktreeId = new Map() - for (const port of scan?.ports ?? []) { + for (const port of scan.ports) { if (port.kind !== 'workspace') { continue } @@ -78,24 +81,21 @@ export function getWorkspacePortGroups( a.displayName.localeCompare(b.displayName) || (a.ports[0]?.port ?? 0) - (b.ports[0]?.port ?? 0) ) - if (scan) { - workspaceGroupsCache.set(scan, groups) - } + workspaceGroupsCache.set(scan, groups) return groups } export function getExternalWorkspacePorts( scan: WorkspacePortScanResult | null | undefined ): WorkspacePort[] { - if (scan) { - const cached = externalPortsCache.get(scan) - if (cached) { - return cached - } + if (!scan) { + return EMPTY_EXTERNAL_PORTS } - const ports = (scan?.ports ?? []).filter((port) => port.kind !== 'workspace').sort(comparePorts) - if (scan) { - externalPortsCache.set(scan, ports) + const cached = externalPortsCache.get(scan) + if (cached) { + return cached } + const ports = scan.ports.filter((port) => port.kind !== 'workspace').sort(comparePorts) + externalPortsCache.set(scan, ports) return ports } diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index db1b6a607..860d85595 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -2111,6 +2111,78 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() }) + it('does not fetch linked issue details when the issue card section is hidden', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedIssue: 123 + } + ] + } + } as unknown as Partial) + + store.getState().refreshGitHubForWorktreeIfStale(worktreeId) + await Promise.resolve() + + expect(mockApi.gh.issue).not.toHaveBeenCalled() + }) + + it('fetches linked issue details when the issue card section is visible', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + groupBy: 'repo', + worktreeCardProperties: ['issue'], + rightSidebarOpen: false, + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedIssue: 123 + } + ] + } + } as unknown as Partial) + + store.getState().refreshGitHubForWorktreeIfStale(worktreeId) + await Promise.resolve() + + expect(mockApi.gh.issue).toHaveBeenCalledWith({ + repoPath, + repoId: 'repo-1', + number: 123 + }) + }) + it('enqueues active PR refresh IPC for connected SSH-backed repos', () => { const store = createTestStore() const repoPath = '/repo' @@ -2398,6 +2470,78 @@ describe('createGitHubSlice.refreshAllGitHub', () => { timeoutMs: 30_000 }) }) + + it('does not refresh stale linked issues when the issue card section is hidden', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false, + lastActivityAt: 1, + linkedIssue: 123 + } + ] + } + } as unknown as Partial) + + store.getState().refreshAllGitHub() + await Promise.resolve() + + expect(mockApi.gh.issue).not.toHaveBeenCalled() + }) + + it('refreshes stale linked issues when the issue card section is visible', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + groupBy: 'repo', + worktreeCardProperties: ['issue'], + rightSidebarOpen: false, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false, + lastActivityAt: 1, + linkedIssue: 123 + } + ] + } + } as unknown as Partial) + + store.getState().refreshAllGitHub() + await Promise.resolve() + + expect(mockApi.gh.issue).toHaveBeenCalledWith({ + repoPath, + repoId: 'repo-1', + number: 123 + }) + }) }) describe('createGitHubSlice.refreshGitHubForWorktree', () => { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 9b902bdf9..d0f9e1784 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -930,6 +930,10 @@ function evictStaleEntries( return pruned } +function shouldRefreshIssueDecorations(state: AppState): boolean { + return (state.worktreeCardProperties ?? []).includes('issue') +} + let saveTimer: ReturnType | null = null function debouncedSaveCache(state: AppState): void { @@ -2497,6 +2501,7 @@ export const createGitHubSlice: StateCreator = (s const now = Date.now() const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] const cardProps = state.worktreeCardProperties ?? [] + const shouldRefreshIssues = shouldRefreshIssueDecorations(state) const isPRStatusGrouping = state.groupBy === 'pr-status' const rightSidebarShowsPR = state.rightSidebarOpen && @@ -2530,7 +2535,7 @@ export const createGitHubSlice: StateCreator = (s } } } - if (wt.linkedIssue) { + if (shouldRefreshIssues && wt.linkedIssue) { const issueKey = repoScopedCacheKey(repo.path, repo.id, String(wt.linkedIssue)) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { @@ -2612,7 +2617,7 @@ export const createGitHubSlice: StateCreator = (s } } } - if (worktree.linkedIssue) { + if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) } }, @@ -2795,7 +2800,7 @@ export const createGitHubSlice: StateCreator = (s } } - if (worktree.linkedIssue) { + if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { const issueKey = repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue)) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {