Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions src/components/Dropdown/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ export const DROPDOWN_SEARCH = {
// Composite Class Strings (for easy use)
// ==============================================

/**
* Sticky bordered header row above a panel's scrollable list. Shared by the
* search header and by header rows that carry a title plus actions instead.
*/
const PANEL_HEADER_ROW = [
"flex",
"shrink-0",
"items-center",
DROPDOWN_ITEM.gapClass,
"px-3",
"py-1.5",
"border-b",
"border-solid",
"border-border-2",
].join(" ");

/**
* Complete class string for dropdown panel container
* Usage: <div className={DROPDOWN_CLASSES.panel}>...</div>
Expand Down Expand Up @@ -390,17 +406,10 @@ export const DROPDOWN_CLASSES = {
].join(" "),

/** Search input container */
searchContainer: [
"flex",
"shrink-0",
"items-center",
DROPDOWN_ITEM.gapClass,
"px-3",
"py-1.5",
"border-b",
"border-solid",
"border-border-2",
].join(" "),
searchContainer: PANEL_HEADER_ROW,

/** Panel header row carrying a title and actions instead of a search input. */
panelHeaderRow: PANEL_HEADER_ROW,

/** Search input */
searchInput: [
Expand Down
91 changes: 90 additions & 1 deletion src/hooks/git/useBranchPullRequestStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
getGitCredentialForRemote,
getPRLocal,
} from "@src/api/tauri/github";
import { clearBranchPullRequestStatusCache } from "@src/services/git/branchPullRequestStatus";
import {
BRANCH_CI_POLL_BASE_MS,
BRANCH_CI_POLL_MAX_MS,
clearBranchPullRequestStatusCache,
} from "@src/services/git/branchPullRequestStatus";

import {
type UseBranchPullRequestStatusOptions,
Expand Down Expand Up @@ -142,9 +146,31 @@ describe("useBranchPullRequestStatus", () => {
act(() => root.unmount());
container.remove();
Reflect.deleteProperty(document, "visibilityState");
vi.useRealTimers();
vi.clearAllMocks();
});

function runningChecks() {
return {
sha: "abc",
state: "pending",
check_runs: [
{
id: 1,
name: "test",
status: "in_progress",
conclusion: null,
details_url: null,
started_at: null,
completed_at: null,
output_title: null,
app_name: "CI",
},
],
statuses: [],
};
}

afterAll(() => {
Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT");
});
Expand Down Expand Up @@ -274,6 +300,69 @@ describe("useBranchPullRequestStatus", () => {
expect(latest.compareUrl).toContain("feature-new");
});

it("re-reads while checks run and stops once they settle", async () => {
vi.useFakeTimers();
getChecksLocalMock
.mockResolvedValueOnce(runningChecks())
.mockResolvedValueOnce(runningChecks());

await act(async () => {
root.render(
createElement(Probe, {
options: {
repoId: "repo-1",
repoPath: "/repo",
branchName: "feature",
poll: true,
},
onValue: () => undefined,
})
);
});
expect(getChecksLocalMock).toHaveBeenCalledTimes(1);

await act(async () => {
await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_BASE_MS);
});
expect(getChecksLocalMock).toHaveBeenCalledTimes(2);

// Second poll returns the settled default (`success`), so the schedule ends
// even though far more than the max interval elapses afterwards.
await act(async () => {
await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_BASE_MS * 2);
});
expect(getChecksLocalMock).toHaveBeenCalledTimes(3);

await act(async () => {
await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_MAX_MS * 4);
});
expect(getChecksLocalMock).toHaveBeenCalledTimes(3);
});

it("never schedules a poll when tracing is not requested", async () => {
vi.useFakeTimers();
getChecksLocalMock.mockResolvedValue(runningChecks());

await act(async () => {
root.render(
createElement(Probe, {
options: {
repoId: "repo-1",
repoPath: "/repo",
branchName: "feature",
},
onValue: () => undefined,
})
);
});
expect(getChecksLocalMock).toHaveBeenCalledTimes(1);

await act(async () => {
await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_MAX_MS * 4);
});
expect(getChecksLocalMock).toHaveBeenCalledTimes(1);
});

it("does not expose a closed PR or request checks for it", async () => {
findPullRequestLocalMock.mockResolvedValue({
number: 12,
Expand Down
92 changes: 86 additions & 6 deletions src/hooks/git/useBranchPullRequestStatus.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { getGitDefaultBranch } from "@src/api/http/git/branches";
import { getGitRemotes } from "@src/api/http/git/remotes";
Expand All @@ -18,6 +18,7 @@ import {
getCachedBranchPullRequestStatus,
isBranchPullRequestStatusFresh,
loadBranchPullRequestStatusCoalesced,
nextBranchCiPollDelayMs,
resolveBranchCiStatus,
setCachedBranchPullRequestStatus,
} from "@src/services/git/branchPullRequestStatus";
Expand Down Expand Up @@ -50,13 +51,22 @@ export interface UseBranchPullRequestStatusOptions {
branchName?: string;
repoId?: string;
repoPath?: string;
/**
* Keep re-reading CI while checks can still change, stopping once every run
* has reported. Off by default — only surfaces that visibly trace CI (the
* status-bar menu) should pay for the extra requests. See
* {@link nextBranchCiPollDelayMs} for the schedule.
*/
poll?: boolean;
}

export interface UseBranchPullRequestStatusResult extends Omit<
BranchPullRequestStatusState,
"scopeKey"
> {
ciStatus: BranchCiStatus | null;
/** Re-reads PR + checks now, bypassing the status TTL. */
refresh: () => void;
}

function isGitHubRemote(remoteUrl: string): boolean {
Expand Down Expand Up @@ -111,9 +121,16 @@ export function useBranchPullRequestStatus({
branchName,
repoId,
repoPath,
poll = false,
}: UseBranchPullRequestStatusOptions): UseBranchPullRequestStatusResult {
const [state, setState] = useState<BranchPullRequestStatusState>(EMPTY_STATE);
const generationRef = useRef(0);
const loadRef = useRef<((options?: { force?: boolean }) => void) | null>(
null
);
const pollTimerRef = useRef<number | null>(null);
const pollAttemptRef = useRef(0);
const pollHeadShaRef = useRef<string | null>(null);
const scopeKey =
repoPath && branchName
? `${repoId ?? "default"}|${repoPath}|${branchName}`
Expand All @@ -122,18 +139,56 @@ export function useBranchPullRequestStatus({
useEffect(() => {
let disposed = false;

const clearPollTimer = () => {
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};

// A different repo/branch is a different CI timeline — restart the backoff.
pollAttemptRef.current = 0;
pollHeadShaRef.current = null;

if (!repoPath || !branchName) {
generationRef.current += 1;
loadRef.current = null;
clearPollTimer();
return;
}

const load = async () => {
// A new head commit restarts the backoff, so a push is picked up at the
// fast interval instead of inheriting the previous run's cooled-off delay.
const scheduleNextPoll = (snapshot: BranchPullRequestStatusSnapshot) => {
clearPollTimer();
if (!poll || disposed) return;

const headSha = snapshot.checks?.sha ?? null;
if (headSha !== pollHeadShaRef.current) {
pollHeadShaRef.current = headSha;
pollAttemptRef.current = 0;
}

const delay = nextBranchCiPollDelayMs({
...snapshot,
attempt: pollAttemptRef.current,
});
if (delay == null) return;

pollAttemptRef.current += 1;
pollTimerRef.current = window.setTimeout(() => {
loadRef.current?.({ force: true });
}, delay);
};

const load = async (options?: { force?: boolean }) => {
if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
return;
}
const force = options?.force === true;
const generation = ++generationRef.current;
const isCurrent = () => !disposed && generation === generationRef.current;

Expand All @@ -156,12 +211,14 @@ export function useBranchPullRequestStatus({
(remote) => remote.name === "origin"
);
if (!origin?.url || !isGitHubRemote(origin.url)) {
clearPollTimer();
setState(EMPTY_STATE);
return;
}

const repoFullName = parseGithubRepoFullName(origin.url);
if (!repoFullName) {
clearPollTimer();
setState(EMPTY_STATE);
return;
}
Expand All @@ -183,7 +240,7 @@ export function useBranchPullRequestStatus({
repoFullName,
});
const cached = getCachedBranchPullRequestStatus(cacheKey);
const cachedIsFresh = isBranchPullRequestStatusFresh(cached);
const cachedIsFresh = !force && isBranchPullRequestStatusFresh(cached);

setState({
compareUrl,
Expand All @@ -196,7 +253,10 @@ export function useBranchPullRequestStatus({
refreshing: Boolean(cached && !cachedIsFresh),
scopeKey,
});
if (cachedIsFresh) return;
if (cachedIsFresh) {
if (cached) scheduleNextPoll(cached);
return;
}

try {
const snapshot = await loadBranchPullRequestStatusCoalesced(
Expand All @@ -214,8 +274,12 @@ export function useBranchPullRequestStatus({
refreshing: false,
scopeKey,
});
scheduleNextPoll(snapshot);
} catch {
if (!isCurrent()) return;
// Leave the poll timer cleared: a failed read is retried by an
// explicit refresh or the next visibility return, not by a timer.
clearPollTimer();
setState((current) => ({
...current,
loading: false,
Expand All @@ -224,9 +288,16 @@ export function useBranchPullRequestStatus({
}
};

loadRef.current = (options) => {
void load(options);
};

const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void load();
} else {
// Nothing to trace behind a hidden window; the return trip re-reads.
clearPollTimer();
}
};

Expand All @@ -243,14 +314,16 @@ export function useBranchPullRequestStatus({
return () => {
disposed = true;
generationRef.current += 1;
loadRef.current = null;
clearPollTimer();
if (typeof document !== "undefined") {
document.removeEventListener(
"visibilitychange",
handleVisibilityChange
);
}
};
}, [branchName, repoId, repoPath, scopeKey]);
}, [branchName, poll, repoId, repoPath, scopeKey]);

const visibleState =
state.scopeKey === scopeKey
Expand All @@ -277,6 +350,13 @@ export function useBranchPullRequestStatus({
]
);

const refresh = useCallback(() => {
// Asking by hand is a signal of interest: drop back to the fast interval
// instead of inheriting however far the backoff had already cooled.
pollAttemptRef.current = 0;
loadRef.current?.({ force: true });
}, []);

const { scopeKey: _scopeKey, ...result } = visibleState;
return { ...result, ciStatus };
return { ...result, ciStatus, refresh };
}
12 changes: 12 additions & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,18 @@
"searchPlaceholder": "Search ports…",
"noSearchResults": "No matching ports"
},
"ci": {
"openPullRequest": "Open pull request on GitHub",
"viewDetails": "View check details",
"refresh": "Refresh checks",
"unavailable": "Checks could not be read",
"sections": {
"failed": "Failed",
"running": "Running",
"skipped": "Skipped",
"passed": "Passed"
}
},
"noLanguageServicesActive": "No language services active",
"linesSelected": "{{count}} lines selected",
"charsSelected": "{{count}} selected",
Expand Down
Loading
Loading