perf: pause hidden sidebar decoration refreshes (#2603)
This commit is contained in:
parent
cb8596af6e
commit
fdf293e4a3
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
@ -10,6 +10,9 @@ export type WorkspacePortGroup = {
|
|||
const portsByWorktreeCache = new WeakMap<WorkspacePortScanResult, Map<string, WorkspacePort[]>>()
|
||||
const workspaceGroupsCache = new WeakMap<WorkspacePortScanResult, WorkspacePortGroup[]>()
|
||||
const externalPortsCache = new WeakMap<WorkspacePortScanResult, WorkspacePort[]>()
|
||||
const EMPTY_PORTS_BY_WORKTREE = new Map<string, WorkspacePort[]>()
|
||||
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<string, WorkspacePort[]> {
|
||||
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<string, WorkspacePort[]>()
|
||||
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<string, WorkspacePortGroup>()
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
store.getState().refreshAllGitHub()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockApi.gh.issue).toHaveBeenCalledWith({
|
||||
repoPath,
|
||||
repoId: 'repo-1',
|
||||
number: 123
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createGitHubSlice.refreshGitHubForWorktree', () => {
|
||||
|
|
|
|||
|
|
@ -930,6 +930,10 @@ function evictStaleEntries<T>(
|
|||
return pruned
|
||||
}
|
||||
|
||||
function shouldRefreshIssueDecorations(state: AppState): boolean {
|
||||
return (state.worktreeCardProperties ?? []).includes('issue')
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function debouncedSaveCache(state: AppState): void {
|
||||
|
|
@ -2497,6 +2501,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue