| 1 | import { app } from "./bridge"; |
| 2 | import { t } from "./i18n"; |
| 3 | import type { RewindResultView, TabMeta } from "./types"; |
| 4 | |
| 5 | export async function commitRewindWithPreview( |
| 6 | sourceTabId: string, |
| 7 | turn: number, |
| 8 | scope: "conversation" | "code" | "both", |
| 9 | ): Promise<RewindResultView> { |
| 10 | const plan = await app.PreviewRewindForTab(sourceTabId, turn, scope); |
| 11 | const remoteLegacy = !plan?.ok && /remote host uses legacy rewind semantics/i.test(plan?.error || ""); |
| 12 | if (!remoteLegacy) { |
| 13 | const canCommit = scope === "conversation" |
| 14 | ? plan?.canConversation |
| 15 | : scope === "both" |
| 16 | ? plan?.canConversation && plan?.canFiles |
| 17 | : plan?.canFiles; |
| 18 | if (!plan?.ok || !canCommit) { |
| 19 | return { |
| 20 | ok: false, |
| 21 | error: plan?.error |
| 22 | || plan?.disabledReason |
| 23 | || (plan?.conflicts?.length ? plan.conflicts.join("; ") : "") |
| 24 | || "rewind unavailable", |
| 25 | conflicts: plan?.conflicts, |
| 26 | }; |
| 27 | } |
| 28 | if (scope !== "conversation" && rewindNeedsCoverageConfirm(plan)) { |
| 29 | const gaps = (plan.coverageGaps || []).filter((gap) => !isScratchCoverageGap(gap)).join("\n"); |
| 30 | if (!window.confirm(t("rewind.confirmPartialCoverage", { gaps: gaps || t("rewind.partialCoverageUnknown") }))) { |
| 31 | return { ok: false, error: "rewind cancelled" }; |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | return app.CommitRewindForTab(sourceTabId, remoteLegacy ? "" : (plan.planId || ""), turn, scope); |
| 36 | } |
| 37 | |
| 38 | export function partialRewindNotice(result: RewindResultView): string { |
| 39 | if (!result.partial) return ""; |
| 40 | const summary = t("rewind.partialRestoreFailed"); |
| 41 | const detail = result.error |
| 42 | || (result.conflicts?.length ? result.conflicts.join("; ") : ""); |
| 43 | return detail ? `${summary} ${detail}` : summary; |
| 44 | } |
| 45 | |
| 46 | function isScratchCoverageGap(gap: string): boolean { |
| 47 | return gap === "scratch" || gap.startsWith("scratch:"); |
| 48 | } |
| 49 | |
| 50 | function rewindNeedsCoverageConfirm(plan: { coverage?: string; coverageGaps?: string[]; expiredFilePayload?: boolean; legacy?: boolean }): boolean { |
| 51 | if (plan.expiredFilePayload || plan.legacy) return true; |
| 52 | const projectGaps = (plan.coverageGaps || []).filter((gap) => !isScratchCoverageGap(gap)); |
| 53 | return projectGaps.length > 0 || (plan.coverage === "partial" && projectGaps.length > 0); |
| 54 | } |
| 55 | |
| 56 | export function rewindFailureDetail(result?: RewindResultView | null): string { |
| 57 | return result?.error |
| 58 | || (result?.conflicts?.length ? result.conflicts.join("; ") : "") |
| 59 | || "rewind failed"; |
| 60 | } |
| 61 | |
| 62 | export function rewindOutcome(result: RewindResultView): RewindResultView & { ok: true } { |
| 63 | return { ...result, ok: true, tabId: result.tabId || result.tab?.id }; |
| 64 | } |
| 65 | |
| 66 | export async function settleRewindTarget( |
| 67 | result: RewindResultView, |
| 68 | adopt: (tab: TabMeta) => Promise<unknown>, |
| 69 | waitUntilReady: (tabId: string) => Promise<unknown>, |
| 70 | ): Promise<string | undefined> { |
| 71 | const targetTabId = result.tab?.id || result.tabId; |
| 72 | if (result.tab?.id) await adopt(result.tab); |
| 73 | else if (targetTabId) await waitUntilReady(targetTabId); |
| 74 | return targetTabId; |
| 75 | } |
| 76 | |
| 77 | export function dispatchPartialRewindNotice( |
| 78 | notice: string, |
| 79 | sourceTabId: string, |
| 80 | targetTabId: string | undefined, |
| 81 | notify: (tabId: string, text: string) => void, |
| 82 | ): void { |
| 83 | if (!notice) return; |
| 84 | notify(sourceTabId, notice); |
| 85 | if (targetTabId && targetTabId !== sourceTabId) notify(targetTabId, notice); |
| 86 | } |
| 87 | |
| 88 | export function undoCommittedRewind(sourceTabId: string, transactionId: string): Promise<RewindResultView> { |
| 89 | return app.UndoRewindForTab(sourceTabId, transactionId); |
| 90 | } |
| 91 |