Skip to content

Commit a8260eb

Browse files
Sync github Viewed files (backnotprop#393)
1 parent 81f29df commit a8260eb

8 files changed

Lines changed: 324 additions & 10 deletions

File tree

apps/pi-extension/server/pr.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
fetchPRContext as fetchPRContextCore,
1111
fetchPR as fetchPRCore,
1212
fetchPRFileContent as fetchPRFileContentCore,
13+
fetchPRViewedFiles as fetchPRViewedFilesCore,
1314
getUser as getUserCore,
15+
markPRFilesViewed as markPRFilesViewedCore,
1416
type PRRef,
1517
type PRReviewFileComment,
1618
type PRRuntime,
@@ -89,3 +91,16 @@ export function submitPRReview(
8991
fileComments,
9092
);
9193
}
94+
95+
export function fetchPRViewedFiles(ref: PRRef): Promise<Record<string, boolean>> {
96+
return fetchPRViewedFilesCore(prRuntime, ref);
97+
}
98+
99+
export function markPRFilesViewed(
100+
ref: PRRef,
101+
prNodeId: string,
102+
filePaths: string[],
103+
viewed: boolean,
104+
): Promise<void> {
105+
return markPRFilesViewedCore(prRuntime, ref, prNodeId, filePaths, viewed);
106+
}

apps/pi-extension/server/serverReview.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ import { listenOnPort } from "./network.js";
4747
import {
4848
fetchPRContext,
4949
fetchPRFileContent,
50+
fetchPRViewedFiles,
5051
getPRUser,
52+
markPRFilesViewed,
5153
submitPRReview,
5254
} from "./pr.js";
5355
import { getRepoInfo } from "./project.js";
@@ -119,6 +121,19 @@ export async function startReviewServer(options: {
119121
const isPRMode = !!prMeta;
120122
const prRef = prMeta ? prRefFromMetadata(prMeta) : null;
121123
const platformUser = prRef ? await getPRUser(prRef) : null;
124+
125+
// Fetch GitHub viewed file state (non-blocking — errors are silently ignored)
126+
let initialViewedFiles: string[] = [];
127+
if (isPRMode && prRef) {
128+
try {
129+
const viewedMap = await fetchPRViewedFiles(prRef);
130+
initialViewedFiles = Object.entries(viewedMap)
131+
.filter(([, isViewed]) => isViewed)
132+
.map(([path]) => path);
133+
} catch {
134+
// Non-fatal: viewed state is best-effort
135+
}
136+
}
122137
const repoInfo = prMeta
123138
? {
124139
display: getDisplayRepo(prMeta),
@@ -280,6 +295,7 @@ export async function startReviewServer(options: {
280295
shareBaseUrl,
281296
repoInfo,
282297
...(isPRMode && { prMetadata: prMeta, platformUser }),
298+
...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
283299
...(currentError ? { error: currentError } : {}),
284300
});
285301
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
@@ -354,6 +370,39 @@ export async function startReviewServer(options: {
354370
500,
355371
);
356372
}
373+
} else if (url.pathname === "/api/pr-viewed" && req.method === "POST") {
374+
if (!isPRMode || !prMeta || !prRef) {
375+
json(res, { error: "Not in PR mode" }, 400);
376+
return;
377+
}
378+
if (prMeta.platform !== "github") {
379+
json(res, { error: "Viewed sync only supported for GitHub" }, 400);
380+
return;
381+
}
382+
const prNodeId = prMeta.prNodeId;
383+
if (!prNodeId) {
384+
json(res, { error: "PR node ID not available" }, 400);
385+
return;
386+
}
387+
try {
388+
const body = await parseBody(req);
389+
await markPRFilesViewed(
390+
prRef,
391+
prNodeId,
392+
body.filePaths as string[],
393+
body.viewed as boolean,
394+
);
395+
json(res, { ok: true });
396+
} catch (err) {
397+
json(
398+
res,
399+
{
400+
error:
401+
err instanceof Error ? err.message : "Failed to update viewed state",
402+
},
403+
500,
404+
);
405+
}
357406
} else if (url.pathname === "/api/file-content" && req.method === "GET") {
358407
const filePath = url.searchParams.get("path");
359408
if (!filePath) {

bun.lock

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/review-editor/App.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ const ReviewApp: React.FC = () => {
387387
repoInfo?: { display: string; branch?: string };
388388
prMetadata?: PRMetadata;
389389
platformUser?: string;
390+
viewedFiles?: string[];
390391
error?: string;
391392
isWSL?: boolean;
392393
}) => {
@@ -408,6 +409,10 @@ const ReviewApp: React.FC = () => {
408409
if (data.repoInfo) setRepoInfo(data.repoInfo);
409410
if (data.prMetadata) setPrMetadata(data.prMetadata);
410411
if (data.platformUser) setPlatformUser(data.platformUser);
412+
// Initialize viewed files from GitHub's state (set before draft restore so draft takes precedence)
413+
if (data.viewedFiles && data.viewedFiles.length > 0) {
414+
setViewedFiles(new Set(data.viewedFiles));
415+
}
411416
if (data.error) setDiffError(data.error);
412417
if (data.isWSL) setIsWSL(true);
413418
})
@@ -531,14 +536,26 @@ const ReviewApp: React.FC = () => {
531536
const handleToggleViewed = useCallback((filePath: string) => {
532537
setViewedFiles(prev => {
533538
const next = new Set(prev);
534-
if (next.has(filePath)) {
535-
next.delete(filePath);
536-
} else {
539+
const willBeViewed = !prev.has(filePath);
540+
if (willBeViewed) {
537541
next.add(filePath);
542+
} else {
543+
next.delete(filePath);
544+
}
545+
// Sync viewed state to GitHub (fire and forget — best effort)
546+
// Capture willBeViewed inside the callback to ensure correctness with React batching
547+
if (prMetadata && prMetadata.platform === 'github') {
548+
fetch('/api/pr-viewed', {
549+
method: 'POST',
550+
headers: { 'Content-Type': 'application/json' },
551+
body: JSON.stringify({ filePaths: [filePath], viewed: willBeViewed }),
552+
}).catch(() => {
553+
// Silently ignore — viewed sync is best-effort
554+
});
538555
}
539556
return next;
540557
});
541-
}, []);
558+
}, [prMetadata]);
542559

543560
// Derive worktree path and base diff type from the composite diffType string
544561
const { activeWorktreePath, activeDiffBase } = useMemo(() => {

packages/server/pr.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
fetchPRContext as fetchPRContextCore,
1919
fetchPRFileContent as fetchPRFileContentCore,
2020
submitPRReview as submitPRReviewCore,
21+
fetchPRViewedFiles as fetchPRViewedFilesCore,
22+
markPRFilesViewed as markPRFilesViewedCore,
2123
prRefFromMetadata,
2224
getPlatformLabel,
2325
getMRLabel,
@@ -29,6 +31,7 @@ import {
2931

3032
export type { PRRef, PRMetadata, PRContext, PRReviewFileComment } from "@plannotator/shared/pr-provider";
3133
export { prRefFromMetadata, getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo, getCliName, getCliInstallUrl } from "@plannotator/shared/pr-provider";
34+
export type { GithubPRMetadata } from "@plannotator/shared/pr-provider";
3235

3336
const runtime: PRRuntime = {
3437
async runCommand(cmd, args) {
@@ -105,3 +108,18 @@ export function submitPRReview(
105108
): Promise<void> {
106109
return submitPRReviewCore(runtime, ref, headSha, action, body, fileComments);
107110
}
111+
112+
export function fetchPRViewedFiles(
113+
ref: PRRef,
114+
): Promise<Record<string, boolean>> {
115+
return fetchPRViewedFilesCore(runtime, ref);
116+
}
117+
118+
export function markPRFilesViewed(
119+
ref: PRRef,
120+
prNodeId: string,
121+
filePaths: string[],
122+
viewed: boolean,
123+
): Promise<void> {
124+
return markPRFilesViewedCore(runtime, ref, prNodeId, filePaths, viewed);
125+
}

packages/server/review.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { getRepoInfo } from "./repo";
1515
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers";
1616
import { contentHash, deleteDraft } from "./draft";
1717
import { createEditorAnnotationHandler } from "./editor-annotations";
18-
import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr";
18+
import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr";
1919
import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai";
2020
import { isWSL } from "./browser";
2121

@@ -202,6 +202,23 @@ export async function startReviewServer(
202202
const prRef = isPRMode ? prRefFromMetadata(prMetadata) : null;
203203
const platformUser = prRef ? await getPRUser(prRef) : null;
204204

205+
// Fetch GitHub viewed file state (non-blocking — errors are silently ignored)
206+
let initialViewedFiles: string[] = [];
207+
if (isPRMode && prRef) {
208+
console.log("[plannotator] Fetching PR viewed files for", prRef);
209+
try {
210+
const viewedMap = await fetchPRViewedFiles(prRef);
211+
console.log("[plannotator] PR viewed files map:", viewedMap);
212+
initialViewedFiles = Object.entries(viewedMap)
213+
.filter(([, isViewed]) => isViewed)
214+
.map(([path]) => path);
215+
console.log("[plannotator] Initial viewed files:", initialViewedFiles);
216+
} catch (err) {
217+
// Non-fatal: viewed state is best-effort
218+
console.warn("[plannotator] Could not fetch PR viewed files:", err instanceof Error ? err.message : err);
219+
}
220+
}
221+
205222
// Decision promise
206223
let resolveDecision: (result: {
207224
approved: boolean;
@@ -242,6 +259,7 @@ export async function startReviewServer(
242259
repoInfo,
243260
isWSL: wslFlag,
244261
...(isPRMode && { prMetadata, platformUser }),
262+
...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
245263
...(currentError && { error: currentError }),
246264
});
247265
}
@@ -462,6 +480,38 @@ export async function startReviewServer(
462480
}
463481
}
464482

483+
// API: Mark/unmark PR files as viewed on GitHub (PR mode, GitHub only)
484+
if (url.pathname === "/api/pr-viewed" && req.method === "POST") {
485+
if (!isPRMode || !prMetadata) {
486+
console.log("[plannotator] /api/pr-viewed: not in PR mode");
487+
return Response.json({ error: "Not in PR mode" }, { status: 400 });
488+
}
489+
if (prMetadata.platform !== "github") {
490+
console.log("[plannotator] /api/pr-viewed: platform is", prMetadata.platform, "(not github)");
491+
return Response.json({ error: "Viewed sync only supported for GitHub" }, { status: 400 });
492+
}
493+
const prNodeId = prMetadata.prNodeId;
494+
if (!prNodeId) {
495+
console.log("[plannotator] /api/pr-viewed: prNodeId missing from metadata:", prMetadata);
496+
return Response.json({ error: "PR node ID not available" }, { status: 400 });
497+
}
498+
try {
499+
const body = (await req.json()) as {
500+
filePaths: string[];
501+
viewed: boolean;
502+
};
503+
console.log("[plannotator] /api/pr-viewed: marking", body.filePaths, "as viewed=", body.viewed, "prNodeId=", prNodeId);
504+
await markPRFilesViewed(prRef!, prNodeId, body.filePaths, body.viewed);
505+
console.log("[plannotator] /api/pr-viewed: success");
506+
return Response.json({ ok: true });
507+
} catch (err) {
508+
const message =
509+
err instanceof Error ? err.message : "Failed to update viewed state";
510+
console.error("[plannotator] /api/pr-viewed error:", message);
511+
return Response.json({ error: message }, { status: 500 });
512+
}
513+
}
514+
465515
// AI endpoints
466516
if (aiEndpoints && url.pathname.startsWith("/api/ai/")) {
467517
const handler = aiEndpoints[url.pathname as keyof AIEndpoints];

0 commit comments

Comments
 (0)