Recompute focus/visibility staleness at event time, retry closed/merged sidebar associations on the no-PR cadence, and assert refresh failures preserve prior PR status. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
1000 lines
36 KiB
TypeScript
1000 lines
36 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
|
|
import { mapWithConcurrency } from '@/lib/concurrency';
|
|
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
|
|
const PR_REVALIDATE_TTL_MS = 90_000;
|
|
const PR_REVALIDATE_INTERVAL_MS = 15_000;
|
|
const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000;
|
|
const PR_BOOTSTRAP_RETRY_DELAYS_MS = [2_000, 5_000] as const;
|
|
const PR_OPEN_BUSY_INTERVAL_MS = 60_000;
|
|
const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000;
|
|
const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000;
|
|
const PR_STATUS_REFRESH_CONCURRENCY = 4;
|
|
const PR_PERSIST_TTL_MS = 12 * 60 * 60_000;
|
|
const PR_STATUS_STORAGE_KEY = 'openchamber.github-pr-status';
|
|
const PR_MAX_ENTRIES = 200;
|
|
|
|
const isTerminalPrState = (state: string | null | undefined): boolean => state === 'closed' || state === 'merged';
|
|
const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => {
|
|
const checks = status?.checks;
|
|
if (!checks) {
|
|
return false;
|
|
}
|
|
return checks.state === 'pending' || checks.pending > 0;
|
|
};
|
|
|
|
const getOpenPrRefreshInterval = (status: GitHubPullRequestStatus | null): number => {
|
|
if (isPendingChecks(status)) return PR_OPEN_BUSY_INTERVAL_MS;
|
|
if (status?.checks && status.checks.state !== 'pending') return PR_OPEN_STABLE_INTERVAL_MS;
|
|
return PR_OPEN_DEFAULT_INTERVAL_MS;
|
|
};
|
|
|
|
export const getGitHubPrStatusKey = (directory: string, branch: string, remoteName?: string | null): string => {
|
|
return JSON.stringify([getRuntimeKey(), directory, branch, remoteName ?? 'auto']);
|
|
};
|
|
|
|
type RefreshOptions = {
|
|
force?: boolean;
|
|
onlyExistingPr?: boolean;
|
|
silent?: boolean;
|
|
markInitialResolved?: boolean;
|
|
};
|
|
|
|
type PrTrackingTarget = {
|
|
directory: string;
|
|
branch: string;
|
|
remoteName?: string | null;
|
|
};
|
|
|
|
type PrRuntimeParams = {
|
|
runtimeKey?: string;
|
|
directory: string;
|
|
branch: string;
|
|
remoteName: string | null;
|
|
canShow: boolean;
|
|
github?: RuntimeAPIs['github'];
|
|
githubAuthChecked: boolean;
|
|
githubConnected: boolean | null;
|
|
};
|
|
|
|
type PrEntryIdentity = {
|
|
runtimeKey: string;
|
|
directory: string;
|
|
branch: string;
|
|
remoteName: string | null;
|
|
};
|
|
|
|
type PrStatusEntry = {
|
|
status: GitHubPullRequestStatus | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
isInitialStatusResolved: boolean;
|
|
lastRefreshAt: number;
|
|
lastDiscoveryPollAt: number;
|
|
watchers: number;
|
|
params: PrRuntimeParams | null;
|
|
identity: PrEntryIdentity | null;
|
|
resolvedRemoteName: string | null;
|
|
paramsRevision: number;
|
|
};
|
|
|
|
type PersistedPrStatusEntry = Pick<
|
|
PrStatusEntry,
|
|
'status' | 'isInitialStatusResolved' | 'lastRefreshAt' | 'lastDiscoveryPollAt' | 'identity' | 'resolvedRemoteName'
|
|
>;
|
|
|
|
type GitHubPrStatusStore = {
|
|
entries: Record<string, PrStatusEntry>;
|
|
activeRequestCount: number;
|
|
totalRequestCount: number;
|
|
ensureEntry: (key: string) => void;
|
|
setParams: (key: string, params: PrRuntimeParams) => void;
|
|
startWatching: (key: string) => void;
|
|
stopWatching: (key: string) => void;
|
|
refresh: (key: string, options?: RefreshOptions) => Promise<void>;
|
|
refreshTargets: (targets: PrTrackingTarget[], options?: RefreshOptions) => Promise<void>;
|
|
updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void;
|
|
resetForRuntimeSwitch: () => void;
|
|
};
|
|
|
|
const timers = new Map<string, number>();
|
|
const bootstrapTimers = new Map<string, number[]>();
|
|
const inFlightBySignature = new Map<string, symbol>();
|
|
const lastRefreshBySignature = new Map<string, number>();
|
|
let prRuntimeGeneration = 0;
|
|
|
|
// Global concurrency gate for PR-status network requests.
|
|
//
|
|
// PR status is non-critical chrome, but each request can be slow (the server
|
|
// makes many serial GitHub API calls and GitHub secondary-rate-limits bursts,
|
|
// so a single request can take 20s+). The browser allows only ~6 concurrent
|
|
// HTTP/1.1 connections per origin. Without this cap, watching N worktrees fires
|
|
// N PR-status requests at once (each startWatching() calls refresh() directly,
|
|
// bypassing refreshTargets' batch limiter), which saturates the connection pool
|
|
// and starves the critical path (bootstrap session.status, diffs, sending
|
|
// messages) for the full duration — the whole UI appears frozen on startup.
|
|
//
|
|
// Capping concurrency low guarantees free sockets remain for critical traffic.
|
|
const PR_STATUS_NETWORK_CONCURRENCY = 2;
|
|
let prStatusNetworkActive = 0;
|
|
const prStatusNetworkWaiters: Array<() => void> = [];
|
|
|
|
const acquirePrStatusNetworkSlot = (): Promise<void> => {
|
|
if (prStatusNetworkActive < PR_STATUS_NETWORK_CONCURRENCY) {
|
|
prStatusNetworkActive += 1;
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise<void>((resolve) => {
|
|
prStatusNetworkWaiters.push(resolve);
|
|
});
|
|
};
|
|
|
|
const releasePrStatusNetworkSlot = (): void => {
|
|
const next = prStatusNetworkWaiters.shift();
|
|
if (next) {
|
|
// Hand the slot directly to the next waiter — keep the active count steady.
|
|
next();
|
|
return;
|
|
}
|
|
prStatusNetworkActive = Math.max(0, prStatusNetworkActive - 1);
|
|
};
|
|
|
|
const createEntry = (): PrStatusEntry => ({
|
|
status: null,
|
|
isLoading: false,
|
|
error: null,
|
|
isInitialStatusResolved: false,
|
|
lastRefreshAt: 0,
|
|
lastDiscoveryPollAt: 0,
|
|
watchers: 0,
|
|
params: null,
|
|
identity: null,
|
|
resolvedRemoteName: null,
|
|
paramsRevision: 0,
|
|
});
|
|
|
|
const getIdentityFromEntry = (entry: PrStatusEntry | null | undefined): PrEntryIdentity | null => {
|
|
if (entry?.params?.directory && entry.params.branch) {
|
|
return {
|
|
directory: entry.params.directory,
|
|
branch: entry.params.branch,
|
|
runtimeKey: entry.params.runtimeKey ?? entry.identity?.runtimeKey ?? getRuntimeKey(),
|
|
remoteName: entry.params.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null,
|
|
};
|
|
}
|
|
if (!entry?.identity?.directory || !entry.identity.branch) {
|
|
return null;
|
|
}
|
|
return entry.identity;
|
|
};
|
|
|
|
const getSignatureFromEntry = (entry: PrStatusEntry | null | undefined): string | null => {
|
|
const identity = getIdentityFromEntry(entry);
|
|
if (!identity?.directory || !identity.branch) {
|
|
return null;
|
|
}
|
|
return JSON.stringify([identity.runtimeKey, identity.directory, identity.branch, identity.remoteName ?? 'auto']);
|
|
};
|
|
|
|
const parseStatusKey = (key: string): { runtimeKey: string; directory: string; branch: string; remote: string } | null => {
|
|
try {
|
|
const parsed: unknown = JSON.parse(key);
|
|
if (!Array.isArray(parsed) || parsed.length !== 4 || parsed.some((part) => typeof part !== 'string')) {
|
|
return null;
|
|
}
|
|
const [runtimeKey, directory, branch, remote] = parsed as [string, string, string, string];
|
|
return { runtimeKey, directory, branch, remote };
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Best already-resolved entry for the same directory+branch under a different
|
|
* remote key. Used to seed a freshly created entry so switching remote keys
|
|
* (e.g. 'auto' -> 'origin') shows the known PR immediately instead of a
|
|
* "checking status" flash; the entry's own refresh remains authoritative.
|
|
*/
|
|
const findResolvedSiblingEntry = (
|
|
entries: Record<string, PrStatusEntry>,
|
|
key: string,
|
|
): PrStatusEntry | null => {
|
|
const target = parseStatusKey(key);
|
|
if (!target) {
|
|
return null;
|
|
}
|
|
|
|
let fallback: PrStatusEntry | null = null;
|
|
for (const [entryKey, entry] of Object.entries(entries)) {
|
|
if (entryKey === key || !entry.isInitialStatusResolved || !entry.status) {
|
|
continue;
|
|
}
|
|
// Never seed a fresh key from a closed/merged association — that is what
|
|
// made stale terminal PRs reappear after remote-key switches.
|
|
if (isTerminalPrState(entry.status.pr?.state)) {
|
|
continue;
|
|
}
|
|
const parsed = parseStatusKey(entryKey);
|
|
if (!parsed
|
|
|| parsed.runtimeKey !== target.runtimeKey
|
|
|| parsed.directory !== target.directory
|
|
|| parsed.branch !== target.branch) {
|
|
continue;
|
|
}
|
|
const resolvedRemote = entry.resolvedRemoteName ?? entry.status.resolvedRemoteName ?? null;
|
|
if (target.remote !== 'auto' && resolvedRemote && resolvedRemote !== target.remote) {
|
|
continue;
|
|
}
|
|
if (parsed.remote === 'auto') {
|
|
return entry;
|
|
}
|
|
fallback = fallback ?? entry;
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
/**
|
|
* Freshest known status for a directory+branch across ALL remote-keyed
|
|
* entries. Passive readers (e.g. the git-view PR chip) should use this
|
|
* instead of a single key: the entry being actively watched/refreshed may be
|
|
* keyed by a concrete remote while the 'auto' entry goes stale.
|
|
*/
|
|
export const getFreshestPrStatusForBranch = (
|
|
entries: Record<string, PrStatusEntry>,
|
|
directory: string,
|
|
branch: string,
|
|
): GitHubPullRequestStatus | null => {
|
|
const runtimeKey = getRuntimeKey();
|
|
let best: PrStatusEntry | null = null;
|
|
for (const [key, entry] of Object.entries(entries)) {
|
|
if (!entry.status) {
|
|
continue;
|
|
}
|
|
const parsed = parseStatusKey(key);
|
|
if (!parsed
|
|
|| parsed.runtimeKey !== runtimeKey
|
|
|| parsed.directory !== directory
|
|
|| parsed.branch !== branch) {
|
|
continue;
|
|
}
|
|
if (!best || entry.lastRefreshAt > best.lastRefreshAt) {
|
|
best = entry;
|
|
}
|
|
}
|
|
return best?.status ?? null;
|
|
};
|
|
|
|
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
|
|
return Object.entries(entries)
|
|
.filter(([, entry]) => getSignatureFromEntry(entry) === signature)
|
|
.map(([key]) => key);
|
|
};
|
|
|
|
const mergeParams = (entry: PrStatusEntry, next: PrRuntimeParams): PrStatusEntry => {
|
|
const runtimeKey = next.runtimeKey ?? getRuntimeKey();
|
|
const remoteName = next.remoteName ?? entry.params?.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null;
|
|
const paramsChanged = !entry.params
|
|
|| entry.params.runtimeKey !== runtimeKey
|
|
|| entry.params.directory !== next.directory
|
|
|| entry.params.branch !== next.branch
|
|
|| entry.params.remoteName !== remoteName
|
|
|| entry.params.canShow !== next.canShow
|
|
|| entry.params.github !== next.github
|
|
|| entry.params.githubAuthChecked !== next.githubAuthChecked
|
|
|| entry.params.githubConnected !== next.githubConnected;
|
|
return {
|
|
...entry,
|
|
paramsRevision: paramsChanged ? entry.paramsRevision + 1 : entry.paramsRevision,
|
|
...(paramsChanged ? { isLoading: false, error: null } : {}),
|
|
params: entry.params
|
|
? {
|
|
...entry.params,
|
|
...next,
|
|
runtimeKey,
|
|
remoteName,
|
|
}
|
|
: {
|
|
...next,
|
|
runtimeKey,
|
|
remoteName,
|
|
},
|
|
identity: {
|
|
runtimeKey,
|
|
directory: next.directory,
|
|
branch: next.branch,
|
|
remoteName,
|
|
},
|
|
};
|
|
};
|
|
|
|
const getFetchableParams = (entry: PrStatusEntry | null | undefined): PrRuntimeParams | null => {
|
|
if (!entry?.params?.canShow || !entry.params.github?.prStatus) {
|
|
return null;
|
|
}
|
|
return {
|
|
...entry.params,
|
|
remoteName: entry.params.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null,
|
|
};
|
|
};
|
|
|
|
const pickFetchParamsForSignature = (
|
|
entries: Record<string, PrStatusEntry>,
|
|
signature: string,
|
|
preferredKey: string,
|
|
): PrRuntimeParams | null => {
|
|
const keys = getKeysBySignature(entries, signature);
|
|
const candidates = keys
|
|
.map((key) => getFetchableParams(entries[key]))
|
|
.filter((params): params is PrRuntimeParams => Boolean(params));
|
|
|
|
if (candidates.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const preferred = getFetchableParams(entries[preferredKey]);
|
|
if (preferred && getSignatureFromEntry(entries[preferredKey]) === signature) {
|
|
return preferred;
|
|
}
|
|
|
|
const withResolvedRemote = keys
|
|
.map((key) => entries[key])
|
|
.find((entry) => Boolean(entry?.resolvedRemoteName && getFetchableParams(entry)));
|
|
if (withResolvedRemote) {
|
|
return getFetchableParams(withResolvedRemote);
|
|
}
|
|
|
|
const withRemote = candidates.find((params) => Boolean(params.remoteName));
|
|
if (withRemote) {
|
|
return withRemote;
|
|
}
|
|
|
|
return candidates[0] ?? null;
|
|
};
|
|
|
|
const toPersistedEntry = (entry: PrStatusEntry): PersistedPrStatusEntry => ({
|
|
status: entry.status,
|
|
isInitialStatusResolved: entry.isInitialStatusResolved,
|
|
lastRefreshAt: entry.lastRefreshAt,
|
|
lastDiscoveryPollAt: entry.lastDiscoveryPollAt,
|
|
identity: getIdentityFromEntry(entry),
|
|
resolvedRemoteName: entry.resolvedRemoteName ?? entry.status?.resolvedRemoteName ?? null,
|
|
});
|
|
|
|
const stripTerminalPersistedStatus = (
|
|
status: GitHubPullRequestStatus | null | undefined,
|
|
): GitHubPullRequestStatus | null => {
|
|
if (!status) {
|
|
return null;
|
|
}
|
|
if (!isTerminalPrState(status.pr?.state)) {
|
|
return status;
|
|
}
|
|
// Persisted closed/merged branch associations are not live authority. Keep
|
|
// repo/remote continuity so refresh can resume without briefly showing the
|
|
// stale terminal PR.
|
|
return {
|
|
...status,
|
|
pr: null,
|
|
checks: undefined,
|
|
canMerge: undefined,
|
|
};
|
|
};
|
|
|
|
const hydrateEntry = (entry: PersistedPrStatusEntry | undefined): PrStatusEntry => {
|
|
const status = stripTerminalPersistedStatus(entry?.status);
|
|
const hadTerminalPr = Boolean(entry?.status?.pr) && !status?.pr;
|
|
return {
|
|
...createEntry(),
|
|
status,
|
|
isInitialStatusResolved: hadTerminalPr ? false : (entry?.isInitialStatusResolved ?? false),
|
|
lastRefreshAt: entry?.lastRefreshAt ?? 0,
|
|
lastDiscoveryPollAt: entry?.lastDiscoveryPollAt ?? 0,
|
|
identity: entry?.identity ?? null,
|
|
resolvedRemoteName: entry?.resolvedRemoteName ?? entry?.status?.resolvedRemoteName ?? null,
|
|
};
|
|
};
|
|
|
|
const boundEntries = (entries: Record<string, PrStatusEntry>): Record<string, PrStatusEntry> => {
|
|
const all = Object.entries(entries);
|
|
if (all.length <= PR_MAX_ENTRIES) return entries;
|
|
return Object.fromEntries(all
|
|
.sort(([, left], [, right]) => {
|
|
const leftProtected = left.watchers > 0 || left.isLoading;
|
|
const rightProtected = right.watchers > 0 || right.isLoading;
|
|
if (leftProtected !== rightProtected) return leftProtected ? -1 : 1;
|
|
return Math.max(right.lastRefreshAt, right.lastDiscoveryPollAt)
|
|
- Math.max(left.lastRefreshAt, left.lastDiscoveryPollAt);
|
|
})
|
|
.slice(0, PR_MAX_ENTRIES));
|
|
};
|
|
|
|
export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
entries: {},
|
|
activeRequestCount: 0,
|
|
totalRequestCount: 0,
|
|
|
|
resetForRuntimeSwitch: () => {
|
|
prRuntimeGeneration += 1;
|
|
for (const timerId of timers.values()) window.clearInterval(timerId);
|
|
for (const timerIds of bootstrapTimers.values()) timerIds.forEach((timerId) => window.clearTimeout(timerId));
|
|
timers.clear();
|
|
bootstrapTimers.clear();
|
|
inFlightBySignature.clear();
|
|
lastRefreshBySignature.clear();
|
|
set((state) => ({
|
|
activeRequestCount: 0,
|
|
entries: Object.fromEntries(Object.entries(state.entries).map(([key, entry]) => [key, {
|
|
...entry,
|
|
watchers: 0,
|
|
isLoading: false,
|
|
params: null,
|
|
paramsRevision: entry.paramsRevision + 1,
|
|
}])),
|
|
}));
|
|
},
|
|
|
|
ensureEntry: (key) => {
|
|
set((state) => {
|
|
if (state.entries[key]) {
|
|
return state;
|
|
}
|
|
const sibling = findResolvedSiblingEntry(state.entries, key);
|
|
const seeded: PrStatusEntry = sibling
|
|
? {
|
|
...createEntry(),
|
|
status: sibling.status,
|
|
isInitialStatusResolved: true,
|
|
resolvedRemoteName: sibling.resolvedRemoteName ?? sibling.status?.resolvedRemoteName ?? null,
|
|
}
|
|
: createEntry();
|
|
return {
|
|
entries: boundEntries({
|
|
...state.entries,
|
|
[key]: seeded,
|
|
}),
|
|
};
|
|
});
|
|
},
|
|
|
|
setParams: (key, params) => {
|
|
set((state) => {
|
|
const current = state.entries[key] ?? createEntry();
|
|
return {
|
|
entries: {
|
|
...state.entries,
|
|
[key]: mergeParams(current, params),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
startWatching: (key) => {
|
|
set((state) => {
|
|
const current = state.entries[key] ?? createEntry();
|
|
return {
|
|
entries: {
|
|
...state.entries,
|
|
[key]: {
|
|
...current,
|
|
watchers: current.watchers + 1,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
if (timers.has(key)) {
|
|
return;
|
|
}
|
|
|
|
const runBootstrapRefresh = (delayMs: number) => {
|
|
const timerId = window.setTimeout(() => {
|
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
|
return;
|
|
}
|
|
const entry = get().entries[key];
|
|
if (!entry || entry.watchers <= 0) {
|
|
return;
|
|
}
|
|
// Bootstrap retries only help discovery before any PR is known. Once a
|
|
// terminal PR is cached, the discovery interval owns revalidation.
|
|
if (entry.status?.pr) {
|
|
return;
|
|
}
|
|
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
|
|
}, delayMs);
|
|
const existing = bootstrapTimers.get(key) ?? [];
|
|
existing.push(timerId);
|
|
bootstrapTimers.set(key, existing);
|
|
};
|
|
|
|
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
|
|
PR_BOOTSTRAP_RETRY_DELAYS_MS.forEach((delay) => runBootstrapRefresh(delay));
|
|
|
|
const timerId = window.setInterval(() => {
|
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
|
return;
|
|
}
|
|
|
|
const entry = get().entries[key];
|
|
if (!entry || entry.watchers <= 0) {
|
|
return;
|
|
}
|
|
|
|
const hasPr = Boolean(entry.status?.pr);
|
|
const isTerminal = isTerminalPrState(entry.status?.pr?.state);
|
|
// Missing PR and terminal (closed/merged) PRs both need discovery:
|
|
// a new open PR may exist for the same head, or the association may
|
|
// need to clear to an authoritative empty result.
|
|
if (!hasPr || isTerminal) {
|
|
const now = Date.now();
|
|
if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) {
|
|
return;
|
|
}
|
|
set((state) => {
|
|
const current = state.entries[key];
|
|
if (!current) {
|
|
return state;
|
|
}
|
|
return {
|
|
entries: {
|
|
...state.entries,
|
|
[key]: {
|
|
...current,
|
|
lastDiscoveryPollAt: now,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
|
|
return;
|
|
}
|
|
|
|
const elapsed = Date.now() - entry.lastRefreshAt;
|
|
const nextInterval = getOpenPrRefreshInterval(entry.status);
|
|
if (elapsed < nextInterval) {
|
|
return;
|
|
}
|
|
|
|
void get().refresh(key, { force: true, onlyExistingPr: true, silent: true, markInitialResolved: true });
|
|
}, PR_REVALIDATE_INTERVAL_MS);
|
|
|
|
timers.set(key, timerId);
|
|
},
|
|
|
|
stopWatching: (key) => {
|
|
set((state) => {
|
|
const current = state.entries[key];
|
|
if (!current) {
|
|
return state;
|
|
}
|
|
|
|
const watchers = Math.max(0, current.watchers - 1);
|
|
return {
|
|
entries: {
|
|
...state.entries,
|
|
[key]: {
|
|
...current,
|
|
watchers,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const entry = get().entries[key];
|
|
if (entry && entry.watchers > 0) {
|
|
return;
|
|
}
|
|
|
|
const timerId = timers.get(key);
|
|
if (typeof timerId === 'number') {
|
|
window.clearInterval(timerId);
|
|
}
|
|
timers.delete(key);
|
|
|
|
const pendingBootstrapTimers = bootstrapTimers.get(key);
|
|
if (pendingBootstrapTimers && pendingBootstrapTimers.length > 0) {
|
|
pendingBootstrapTimers.forEach((id) => {
|
|
window.clearTimeout(id);
|
|
});
|
|
}
|
|
bootstrapTimers.delete(key);
|
|
},
|
|
|
|
refresh: async (key, options) => {
|
|
const state = get();
|
|
const entry = state.entries[key];
|
|
const signature = getSignatureFromEntry(entry);
|
|
|
|
if (!entry || !signature) {
|
|
return;
|
|
}
|
|
const signatureKeys = getKeysBySignature(state.entries, signature);
|
|
const hasExistingPr = signatureKeys.some((signatureKey) => Boolean(state.entries[signatureKey]?.status?.pr));
|
|
if (options?.onlyExistingPr && !hasExistingPr) {
|
|
return;
|
|
}
|
|
const lastRefreshAt = lastRefreshBySignature.get(signature) ?? 0;
|
|
if (!options?.force && Date.now() - lastRefreshAt < PR_REVALIDATE_TTL_MS) {
|
|
return;
|
|
}
|
|
if (inFlightBySignature.has(signature)) {
|
|
return;
|
|
}
|
|
|
|
const params = pickFetchParamsForSignature(state.entries, signature, key);
|
|
if (!params) {
|
|
return;
|
|
}
|
|
|
|
const requestToken = Symbol(signature);
|
|
const runtimeGeneration = prRuntimeGeneration;
|
|
const paramsRevision = entry.paramsRevision;
|
|
const runtimeKey = entry.params?.runtimeKey ?? entry.identity?.runtimeKey ?? getRuntimeKey();
|
|
const isCurrent = () => (
|
|
runtimeGeneration === prRuntimeGeneration
|
|
&& runtimeKey === getRuntimeKey()
|
|
&& inFlightBySignature.get(signature) === requestToken
|
|
&& get().entries[key]?.paramsRevision === paramsRevision
|
|
);
|
|
inFlightBySignature.set(signature, requestToken);
|
|
|
|
set((prev) => {
|
|
const nextEntries = { ...prev.entries };
|
|
signatureKeys.forEach((signatureKey) => {
|
|
const current = nextEntries[signatureKey];
|
|
if (!current) {
|
|
return;
|
|
}
|
|
nextEntries[signatureKey] = {
|
|
...current,
|
|
isLoading: options?.silent ? current.isLoading : true,
|
|
error: null,
|
|
};
|
|
});
|
|
return {
|
|
entries: nextEntries,
|
|
};
|
|
});
|
|
|
|
if (params.githubAuthChecked && params.githubConnected === false) {
|
|
if (!isCurrent()) return;
|
|
set((prev) => {
|
|
const nextEntries = { ...prev.entries };
|
|
signatureKeys.forEach((signatureKey) => {
|
|
const current = nextEntries[signatureKey];
|
|
if (!current) {
|
|
return;
|
|
}
|
|
nextEntries[signatureKey] = {
|
|
...current,
|
|
status: { connected: false },
|
|
error: null,
|
|
isLoading: options?.silent ? current.isLoading : false,
|
|
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
|
};
|
|
});
|
|
return {
|
|
entries: nextEntries,
|
|
};
|
|
});
|
|
if (inFlightBySignature.get(signature) === requestToken) inFlightBySignature.delete(signature);
|
|
return;
|
|
}
|
|
|
|
if (!params.github?.prStatus) {
|
|
if (!isCurrent()) return;
|
|
set((prev) => {
|
|
const nextEntries = { ...prev.entries };
|
|
signatureKeys.forEach((signatureKey) => {
|
|
const current = nextEntries[signatureKey];
|
|
if (!current) {
|
|
return;
|
|
}
|
|
nextEntries[signatureKey] = {
|
|
...current,
|
|
status: null,
|
|
error: 'GitHub runtime API unavailable',
|
|
isLoading: options?.silent ? current.isLoading : false,
|
|
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
|
};
|
|
});
|
|
return {
|
|
entries: nextEntries,
|
|
};
|
|
});
|
|
if (inFlightBySignature.get(signature) === requestToken) inFlightBySignature.delete(signature);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
set((prev) => ({
|
|
...prev,
|
|
activeRequestCount: prev.activeRequestCount + 1,
|
|
totalRequestCount: prev.totalRequestCount + 1,
|
|
}));
|
|
await acquirePrStatusNetworkSlot();
|
|
let next: GitHubPullRequestStatus;
|
|
try {
|
|
if (!isCurrent()) return;
|
|
// Failed requests need the same non-forced cooldown as successful
|
|
// refreshes. Record only work that reaches the network slot so a
|
|
// stale queued request cannot suppress its replacement.
|
|
lastRefreshBySignature.set(signature, Date.now());
|
|
next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force });
|
|
} finally {
|
|
releasePrStatusNetworkSlot();
|
|
}
|
|
if (!isCurrent()) return;
|
|
set((prev) => {
|
|
const nextEntries = { ...prev.entries };
|
|
signatureKeys.forEach((signatureKey) => {
|
|
const current = nextEntries[signatureKey];
|
|
if (!current) {
|
|
return;
|
|
}
|
|
|
|
// Freshness guard: the server may serve this response from its
|
|
// cache. If we already hold newer data (e.g. checks derived
|
|
// from a fresher pulls/context fetch), keep it and only clear
|
|
// the loading flag — never regress to an older snapshot.
|
|
const currentFetchedAt = current.status?.fetchedAt;
|
|
const nextFetchedAt = next.fetchedAt;
|
|
if (typeof currentFetchedAt === 'number'
|
|
&& typeof nextFetchedAt === 'number'
|
|
&& nextFetchedAt < currentFetchedAt) {
|
|
nextEntries[signatureKey] = {
|
|
...current,
|
|
error: null,
|
|
isLoading: options?.silent ? current.isLoading : false,
|
|
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
|
lastRefreshAt: Date.now(),
|
|
};
|
|
return;
|
|
}
|
|
|
|
const prevPr = current.status?.pr;
|
|
const nextPr = next.pr;
|
|
const shouldCarryBody = Boolean(
|
|
nextPr
|
|
&& prevPr
|
|
&& nextPr.number === prevPr.number
|
|
&& (!nextPr.body || !nextPr.body.trim())
|
|
&& typeof prevPr.body === 'string'
|
|
&& prevPr.body.trim().length > 0,
|
|
);
|
|
|
|
const status = shouldCarryBody && nextPr && prevPr?.body
|
|
? {
|
|
...next,
|
|
pr: {
|
|
...nextPr,
|
|
body: prevPr.body,
|
|
},
|
|
}
|
|
: next;
|
|
|
|
const resolvedRemoteName = status.resolvedRemoteName ?? current.resolvedRemoteName ?? params.remoteName ?? null;
|
|
const identity = getIdentityFromEntry(current) ?? {
|
|
runtimeKey,
|
|
directory: params.directory,
|
|
branch: params.branch,
|
|
remoteName: params.remoteName ?? null,
|
|
};
|
|
|
|
nextEntries[signatureKey] = {
|
|
...current,
|
|
status,
|
|
error: null,
|
|
isLoading: options?.silent ? current.isLoading : false,
|
|
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
|
lastRefreshAt: Date.now(),
|
|
resolvedRemoteName,
|
|
identity: {
|
|
...identity,
|
|
remoteName: resolvedRemoteName ?? identity.remoteName ?? null,
|
|
},
|
|
};
|
|
});
|
|
|
|
return {
|
|
entries: nextEntries,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
if (!isCurrent()) return;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
set((prev) => {
|
|
const nextEntries = { ...prev.entries };
|
|
signatureKeys.forEach((signatureKey) => {
|
|
const current = nextEntries[signatureKey];
|
|
if (!current) {
|
|
return;
|
|
}
|
|
nextEntries[signatureKey] = {
|
|
...current,
|
|
error: message || 'Failed to load PR status',
|
|
isLoading: options?.silent ? current.isLoading : false,
|
|
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
|
};
|
|
});
|
|
return {
|
|
entries: nextEntries,
|
|
};
|
|
});
|
|
} finally {
|
|
if (inFlightBySignature.get(signature) === requestToken) inFlightBySignature.delete(signature);
|
|
if (runtimeGeneration === prRuntimeGeneration) {
|
|
set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) }));
|
|
}
|
|
}
|
|
},
|
|
|
|
refreshTargets: async (targets, options) => {
|
|
const keys = Array.from(new Set(
|
|
targets
|
|
.map((target) => {
|
|
const directory = target.directory.trim();
|
|
const branch = target.branch.trim();
|
|
if (!directory || !branch) {
|
|
return null;
|
|
}
|
|
return getGitHubPrStatusKey(directory, branch, target.remoteName ?? null);
|
|
})
|
|
.filter((key): key is string => Boolean(key)),
|
|
));
|
|
|
|
await mapWithConcurrency(keys, PR_STATUS_REFRESH_CONCURRENCY, (key) => get().refresh(key, options));
|
|
},
|
|
|
|
updateStatus: (key, updater) => {
|
|
set((state) => {
|
|
const current = state.entries[key] ?? createEntry();
|
|
return {
|
|
entries: {
|
|
...state.entries,
|
|
[key]: {
|
|
...current,
|
|
status: updater(current.status),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
}),
|
|
{
|
|
name: PR_STATUS_STORAGE_KEY,
|
|
storage: createDeferredSafeJSONStorage(),
|
|
version: 2,
|
|
migrate: (persistedState, version) => version < 2 ? { entries: {} } : persistedState,
|
|
partialize: (state) => ({
|
|
entries: Object.fromEntries(
|
|
Object.entries(state.entries)
|
|
.filter(([, entry]) => {
|
|
const identity = getIdentityFromEntry(entry);
|
|
if (!identity?.directory || !identity.branch) {
|
|
return false;
|
|
}
|
|
// Do not persist closed/merged branch associations — they become
|
|
// permanently sticky without a discovery refresh after reload.
|
|
if (isTerminalPrState(entry.status?.pr?.state)) {
|
|
return false;
|
|
}
|
|
const freshness = Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt);
|
|
return freshness > 0 && Date.now() - freshness < PR_PERSIST_TTL_MS;
|
|
})
|
|
.sort(([, left], [, right]) => Math.max(right.lastRefreshAt, right.lastDiscoveryPollAt)
|
|
- Math.max(left.lastRefreshAt, left.lastDiscoveryPollAt))
|
|
.slice(0, PR_MAX_ENTRIES)
|
|
.map(([key, entry]) => [key, toPersistedEntry(entry)]),
|
|
),
|
|
}),
|
|
merge: (persistedState, currentState) => {
|
|
const persistedEntries = (persistedState as { entries?: Record<string, PersistedPrStatusEntry> } | undefined)?.entries ?? {};
|
|
const current = currentState as GitHubPrStatusStore;
|
|
return {
|
|
...current,
|
|
entries: Object.fromEntries(
|
|
Object.entries(persistedEntries)
|
|
.filter(([, entry]) => Boolean(
|
|
entry.identity?.runtimeKey
|
|
&& Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt) > 0
|
|
&& Date.now() - Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt) < PR_PERSIST_TTL_MS,
|
|
))
|
|
.slice(0, PR_MAX_ENTRIES)
|
|
.map(([key, entry]) => [key, hydrateEntry(entry)]),
|
|
),
|
|
};
|
|
},
|
|
},
|
|
),
|
|
);
|
|
export type PrVisualSummary = {
|
|
number: number;
|
|
visualState: string;
|
|
prState: string;
|
|
draft: boolean;
|
|
title: string | null;
|
|
url: string | null;
|
|
base: string | null;
|
|
head: string | null;
|
|
checks: { state: string; total: number; success: number; failure: number; pending: number } | null;
|
|
canMerge: boolean | null;
|
|
mergeableState: string | null;
|
|
repo: { owner: string; repo: string } | null;
|
|
};
|
|
|
|
const derivePrVisualState = (status: GitHubPullRequestStatus | null): string | null => {
|
|
const pr = status?.pr;
|
|
if (!pr) return null;
|
|
if (pr.state === 'merged') return 'merged';
|
|
if (pr.state === 'closed') return 'closed';
|
|
if (pr.draft) return 'draft';
|
|
const checksFailed = status?.checks?.state === 'failure';
|
|
const ms = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
|
|
const notMergeable = pr.mergeable === false || ms === 'blocked' || ms === 'dirty';
|
|
if (checksFailed || notMergeable) return 'blocked';
|
|
return 'open';
|
|
};
|
|
|
|
const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => {
|
|
const vs = derivePrVisualState(entry.status ?? null);
|
|
const pr = entry.status?.pr;
|
|
if (!vs || !pr?.number) return null;
|
|
return {
|
|
number: pr.number,
|
|
visualState: vs,
|
|
prState: pr.state,
|
|
draft: Boolean(pr.draft),
|
|
title: typeof pr.title === 'string' && pr.title.trim().length > 0 ? pr.title : null,
|
|
url: typeof pr.url === 'string' && pr.url.trim().length > 0 ? pr.url : null,
|
|
base: typeof pr.base === 'string' && pr.base.trim().length > 0 ? pr.base : null,
|
|
head: typeof pr.head === 'string' && pr.head.trim().length > 0 ? pr.head : null,
|
|
checks: entry.status?.checks
|
|
? { state: entry.status.checks.state, total: entry.status.checks.total, success: entry.status.checks.success, failure: entry.status.checks.failure, pending: entry.status.checks.pending }
|
|
: null,
|
|
canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null,
|
|
mergeableState: typeof pr.mergeableState === 'string' ? pr.mergeableState : null,
|
|
repo: entry.status?.repo ? { owner: entry.status.repo.owner, repo: entry.status.repo.repo } : null,
|
|
};
|
|
};
|
|
|
|
const summarySignature = (s: PrVisualSummary): string =>
|
|
`${s.number}:${s.visualState}:${s.prState}:${s.draft}:${s.title ?? ''}:${s.url ?? ''}:${s.base ?? ''}:${s.head ?? ''}:${s.canMerge ?? ''}:${s.mergeableState ?? ''}:${s.checks?.state ?? ''}:${s.checks?.total ?? ''}:${s.checks?.success ?? ''}:${s.checks?.failure ?? ''}:${s.checks?.pending ?? ''}:${s.repo?.owner ?? ''}:${s.repo?.repo ?? ''}`;
|
|
|
|
// Per-key summary cache so many independent row subscribers (one key each)
|
|
// keep referential stability.
|
|
// Practically bounded by the number of worktree branches observed in a
|
|
// session; the explicit cap below guards long-running documents that rotate
|
|
// through many branches/runtimes (entries are tiny; insertion-order eviction
|
|
// only costs a one-frame identity change for the evicted key's subscriber).
|
|
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
|
|
const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>();
|
|
|
|
export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => {
|
|
return useGitHubPrStatusStore((state) => {
|
|
if (!key) return null;
|
|
const entry = state.entries[key];
|
|
const summary = entry ? deriveSummary(entry) : null;
|
|
if (!summary) {
|
|
prSummaryCacheByKey.delete(key);
|
|
return null;
|
|
}
|
|
const sig = summarySignature(summary);
|
|
const cached = prSummaryCacheByKey.get(key);
|
|
if (cached && cached.sig === sig) return cached.summary;
|
|
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
|
|
const oldestKey = prSummaryCacheByKey.keys().next().value;
|
|
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
|
|
}
|
|
prSummaryCacheByKey.set(key, { sig, summary });
|
|
return summary;
|
|
});
|
|
};
|