| 1 | import { forwardRef, useEffect, useState } from "react"; |
| 2 | import { app } from "../lib/bridge"; |
| 3 | import { useT } from "../lib/i18n"; |
| 4 | import { normalizeTurnChanges } from "../lib/turnResult"; |
| 5 | import type { TurnChanges, TurnFileChange, WireCompletionSummary } from "../lib/types"; |
| 6 | import { DiffView } from "./DiffView"; |
| 7 | import { TurnResultSummary } from "./TurnResultSummary"; |
| 8 | import { WorkspaceTurnVerification } from "./WorkspaceTurnVerification"; |
| 9 | |
| 10 | function FrozenFileDiff({ tabId, sessionPath, turn, resultId, file }: { tabId: string; sessionPath: string; turn: number; resultId: string; file: TurnFileChange }) { |
| 11 | const t = useT(); |
| 12 | const key = `${tabId}\u0000${sessionPath}\u0000${resultId}\u0000${file.path}`; |
| 13 | const [resource, setResource] = useState<{ key: string; file?: TurnFileChange | null }>(); |
| 14 | useEffect(() => { |
| 15 | let active = true; |
| 16 | void app.WorkspaceTurnChangeDetail(tabId, sessionPath, turn, resultId, file.path) |
| 17 | .then(value => { if (active) setResource({ key, file: value }); }) |
| 18 | .catch(() => { if (active) setResource({ key, file: null }); }); |
| 19 | return () => { active = false; }; |
| 20 | }, [file.path, key, resultId, sessionPath, tabId, turn]); |
| 21 | const detail = resource?.key === key ? resource.file : undefined; |
| 22 | return <section className="turn-file-diff" aria-label={file.path}> |
| 23 | <h4>{file.path}</h4> |
| 24 | {detail === undefined ? <p role="status">{t("completion.loadingResult")}</p> |
| 25 | : !detail ? <p>{t("completion.diffUnavailable")}</p> |
| 26 | : detail.binary ? <p>{t("completion.binary")}</p> |
| 27 | : detail.modeOnly ? <p>{t("completion.modeOnly")}</p> |
| 28 | : detail.unavailable ? <p>{t("completion.detailLimited")}</p> |
| 29 | : detail.patch ? <DiffView diff={detail.patch} /> : <p>{t("completion.noNetChanges")}</p>} |
| 30 | </section>; |
| 31 | } |
| 32 | |
| 33 | export const WorkspaceTurnResult = forwardRef<HTMLElement, { |
| 34 | summary: WireCompletionSummary; |
| 35 | tabId: string; |
| 36 | sessionPath: string; |
| 37 | initialView?: "changes" | "checks"; |
| 38 | onAllChanges(): void; |
| 39 | }>(function WorkspaceTurnResult({ summary, tabId, sessionPath, initialView = "checks", onAllChanges }, ref) { |
| 40 | const t = useT(); |
| 41 | const recorded = summary.receipt?.diff; |
| 42 | const resultId = recorded?.id ?? ""; |
| 43 | const turn = recorded?.turn ?? summary.checkpointTurn; |
| 44 | const key = `${tabId}\u0000${sessionPath}\u0000${resultId}`; |
| 45 | const [selected, setSelected] = useState<{ key: string; path: string }>(); |
| 46 | const [view, setView] = useState(initialView); |
| 47 | const [resource, setResource] = useState<{ key: string; result?: TurnChanges }>(); |
| 48 | useEffect(() => { |
| 49 | if (!resultId || turn === undefined || !sessionPath) return; |
| 50 | let active = true; |
| 51 | void app.WorkspaceTurnChanges(tabId, sessionPath, turn, resultId) |
| 52 | .then(result => { if (active) setResource({ key, result: normalizeTurnChanges(result) }); }) |
| 53 | .catch(() => { if (active) setResource({ key }); }); |
| 54 | return () => { active = false; }; |
| 55 | }, [key, resultId, sessionPath, tabId, turn]); |
| 56 | const current = resource?.key === key ? resource : undefined; |
| 57 | const available = current?.result?.id === resultId && current?.result?.coverage !== "unknown"; |
| 58 | const files = recorded?.files ?? []; |
| 59 | const file = selected?.key === key ? files.find(file => file.path === selected.path) : undefined; |
| 60 | return <section ref={ref} className="workspace-turn-result" aria-label={t("notice.completionChangesTitle")}> |
| 61 | <header className="workspace-turn-result__head"> |
| 62 | <div className="workspace-turn-result__tabs" role="group" aria-label={t("notice.completionChangesTitle")}> |
| 63 | <button className="btn btn--small" aria-pressed={view === "changes"} onClick={() => setView("changes")}>{t("completion.turnDiff")}</button> |
| 64 | <button className="btn btn--small" aria-pressed={view === "checks"} onClick={() => setView("checks")}>{t("completion.panelTitle")}</button> |
| 65 | </div> |
| 66 | <button className="btn btn--small" onClick={onAllChanges}>{t("completion.allChanges")}</button> |
| 67 | </header> |
| 68 | {turn !== undefined && <p className="workspace-turn-result__label">{t("completion.turnLabel", { count: turn + 1 })}</p>} |
| 69 | {view === "checks" ? <WorkspaceTurnVerification summary={summary} tabId={tabId} sessionPath={sessionPath} /> : <> |
| 70 | <TurnResultSummary summary={summary} /> |
| 71 | {(!resultId || !sessionPath || current && !available) && <p className="workspace-note">{t("completion.diffUnavailable")}</p>} |
| 72 | {resultId && sessionPath && !current && <p role="status">{t("completion.loadingResult")}</p>} |
| 73 | <div className="turn-file-list"> |
| 74 | {files.map(entry => <button className="turn-file-list__entry" key={entry.path} disabled={!available} aria-pressed={file?.path === entry.path} onClick={() => setSelected({ key, path: entry.path })}> |
| 75 | <span>{entry.path}</span><span>{entry.binary ? t("completion.binary") : entry.modeOnly ? t("completion.modeOnly") : entry.uncounted ? t("completion.linesUnknown") : <><span className="turn-result-added">+{entry.added}</span>{" "}<span className="turn-result-removed">−{entry.removed}</span></>}</span> |
| 76 | </button>)} |
| 77 | </div> |
| 78 | {file && available && turn !== undefined && <FrozenFileDiff tabId={tabId} sessionPath={sessionPath} turn={turn} resultId={resultId} file={file} />} |
| 79 | </>} |
| 80 | </section>; |
| 81 | }); |
| 82 |