Files
openidea/src/features/github/data/interaction.ts
T
imacross 059fd8a69b feat(github): pull request tool window, timeline and review flow (#477)
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication.

- List with search, quick filters and paging; a tab per pull request and a New Pull Request tab.
- Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen.
- Timeline editor: comments, review threads, reactions and suggested changes.
- Review in the diff viewer and, for the checked-out branch, in the editor (review mode).
- New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands.
- CSP: img-src allows https://avatars.githubusercontent.com for avatars.

Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane.

Co-authored-by: imacross <30753807+imacross@users.noreply.github.com>
2026-10-31 07:50:43 +08:00

87 lines
3.1 KiB
TypeScript

/**
* When each pull request was last seen (IntelliJ's `GHPRPersistentInteractionState`),
* stored per project; the list's unread dots compare it with `updatedAt` (`isSeen` in
* `model.ts`). Entries of pull requests updated since and older than
* `CLEAR_LAST_SEEN_AFTER_DAYS` are dropped.
*/
import { create } from "zustand";
import { useProject } from "@/platform/project";
import type { GHPullRequestShort, GHUser } from "../api/types";
import { CLEAR_LAST_SEEN_AFTER_DAYS, isSeen } from "./model";
const key = (root: string) => `openidea.github.lastSeen.v1:${root}`;
const projectRoot = () => useProject.getState().project?.root ?? null;
function load(root: string | null): Record<string, number> {
if (!root) return {};
try {
const v = JSON.parse(globalThis.localStorage?.getItem(key(root)) ?? "{}") as unknown;
if (!v || typeof v !== "object") return {};
return Object.fromEntries(Object.entries(v).filter(([, t]) => typeof t === "number")) as Record<string, number>;
} catch {
return {};
}
}
function save(root: string | null, lastSeen: Record<string, number>) {
if (!root) return;
try {
globalThis.localStorage?.setItem(key(root), JSON.stringify(lastSeen));
} catch {
/* storage unavailable */
}
}
/** Last-seen times (ms) by pull request node id, for the open project. */
export const useLastSeen = create<{ root: string | null; lastSeen: Record<string, number> }>(() => ({
root: projectRoot(),
lastSeen: load(projectRoot()),
}));
function current(): Record<string, number> {
const root = projectRoot();
if (useLastSeen.getState().root !== root) useLastSeen.setState({ root, lastSeen: load(root) });
return useLastSeen.getState().lastSeen;
}
/** Records that the pull request with node id `prId` was seen now. */
export function markSeen(prId: string, at = Date.now()) {
const lastSeen = { ...current(), [prId]: at };
useLastSeen.setState({ lastSeen });
save(projectRoot(), lastSeen);
}
/**
* `isSeen` with the stored time; forgets entries that are stale (unseen updates, older than 14 days).
* @public
*/
export function isPullRequestSeen(
pr: Pick<GHPullRequestShort, "id" | "author" | "reviewRequests" | "updatedAt">,
user: Pick<GHUser, "id">,
now = Date.now(),
): boolean {
const all = current();
const lastSeen = all[pr.id];
const seen = isSeen(pr, user, lastSeen);
if (!seen && lastSeen !== undefined && lastSeen < now - CLEAR_LAST_SEEN_AFTER_DAYS * 86_400_000) {
const { [pr.id]: _, ...rest } = all;
useLastSeen.setState({ lastSeen: rest });
save(projectRoot(), rest);
}
return seen;
}
/** React hook: whether `pr` is seen; re-renders when the last-seen times change. */
export function usePullRequestSeen(
pr: Pick<GHPullRequestShort, "id" | "author" | "reviewRequests" | "updatedAt">,
user: Pick<GHUser, "id"> | null | undefined,
): boolean {
const lastSeen = useLastSeen((s) => s.lastSeen[pr.id]);
return !user || isSeen(pr, user, lastSeen);
}
useProject.subscribe((s, prev) => {
const root = s.project?.root ?? null;
if (root !== (prev.project?.root ?? null)) useLastSeen.setState({ root, lastSeen: load(root) });
});