Visitar URL original
fix(app): show failed and offline review diffs instead of a clean tree · anomalyco/opencode@0d03683 · GitHub
Skip to content

Commit 0d03683

Browse files
committed
fix(app): show failed and offline review diffs instead of a clean tree
1 parent fb26077 commit 0d03683

7 files changed

Lines changed: 847 additions & 105 deletions

File tree

‎packages/app/e2e/regression/review.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,29 @@ test("desktop review waits for the session's stored mode before it loads changes
17791779
expect(working).toEqual([])
17801780
})
17811781

1782+
// A failed request used to settle like an empty one, so the review claimed the working tree was clean.
1783+
test("a failed diff request offers a retry instead of a clean working tree", async ({ page }) => {
1784+
await openSession(page, { name: "ReviewDiffFailure", vcsDiff: [fileDiff("src/alpha.ts")] })
1785+
const changes = { failing: true }
1786+
await page.route(/\/api\/vcs\/diff\?/, (route) => {
1787+
if (!changes.failing || new URL(route.request().url()).searchParams.get("untracked") !== "folders")
1788+
return route.fallback()
1789+
1790+
return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" }, json: {} })
1791+
})
1792+
const panel = page.locator("#review-panel")
1793+
const failure = panel.getByRole("alert")
1794+
1795+
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
1796+
await openReviewLauncher(panel)
1797+
await expect(failure.getByText("Couldn't load changes", { exact: true })).toBeVisible()
1798+
await expect(panel.locator('[data-slot="session-review-v2-empty-changes"]')).toHaveCount(0)
1799+
changes.failing = false
1800+
await failure.getByRole("button", { name: "Try again", exact: true }).click()
1801+
await expect(panel.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("alpha.ts")
1802+
await expect(failure).toHaveCount(0)
1803+
})
1804+
17821805
test("a chosen mode the session stops offering shows as Git, and comes back when it is offered again", async ({
17831806
page,
17841807
}) => {

‎packages/gui-plugins/src/review/i18n/en.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export default {
1414
"git.description": "Track, review, and undo changes in this project",
1515
loadingChanges: "Loading changes…",
1616
noChanges: "No changes",
17+
"load.failed": "Couldn't load changes",
18+
"load.timeout": "The server took too long to answer",
19+
offline: "Can't reach this server",
1720
"settings.wrapLines.title": "Wrap lines",
1821
"settings.wrapLines.description": "Wrap long lines in mobile diffs instead of scrolling horizontally",
1922
}
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { FileDiffInfo } from "@opencode/client/promise"
3+
import {
4+
INITIAL_REVIEW_DIFF_STATE,
5+
reviewDiffReady,
6+
reviewDiffView,
7+
transitionReviewDiff,
8+
type ReviewDiffContext,
9+
type ReviewDiffEffect,
10+
type ReviewDiffEvent,
11+
type ReviewDiffState,
12+
type ReviewDiffTarget,
13+
} from "./machine"
14+
15+
const git: ReviewDiffTarget = {
16+
kind: "vcs",
17+
serverID: "local",
18+
directory: "C:/repo",
19+
mode: "git",
20+
branch: "feature",
21+
defaultBranch: "dev",
22+
}
23+
24+
const branch: ReviewDiffTarget = { ...git, mode: "branch" }
25+
26+
const turn: ReviewDiffTarget = { kind: "turn", serverID: "local", sessionID: "ses_1" }
27+
28+
const alpha: FileDiffInfo[] = [{ file: "src/alpha.ts", patch: "", additions: 1, deletions: 0, status: "modified" }]
29+
30+
const beta: FileDiffInfo[] = [{ file: "src/beta.ts", patch: "", additions: 2, deletions: 1, status: "modified" }]
31+
32+
function ready(target: ReviewDiffTarget, input: { connected?: boolean; demanded?: boolean } = {}): ReviewDiffEvent {
33+
const context: ReviewDiffContext = {
34+
kind: "ready",
35+
target,
36+
connected: input.connected ?? true,
37+
demanded: input.demanded ?? true,
38+
}
39+
40+
return { type: "context.changed", context }
41+
}
42+
43+
// Replays events through the pure machine and collects every effect it asked for, in order.
44+
function play(state: ReviewDiffState, ...events: ReviewDiffEvent[]) {
45+
return events.reduce<{ state: ReviewDiffState; effects: readonly ReviewDiffEffect[] }>(
46+
(result, event) => {
47+
const next = transitionReviewDiff(result.state, event)
48+
49+
return { state: next.state, effects: [...result.effects, ...next.effects] }
50+
},
51+
{ state, effects: [] },
52+
)
53+
}
54+
55+
// The review panel shows Git changes that loaded as attempt 1.
56+
function loaded() {
57+
return play(INITIAL_REVIEW_DIFF_STATE, ready(git), {
58+
type: "fetch.succeeded",
59+
attempt: 1,
60+
rows: { rows: alpha, version: 10 },
61+
}).state
62+
}
63+
64+
describe("a request that fails", () => {
65+
test("shows the failure with a retry instead of a clean working tree", () => {
66+
const failed = play(INITIAL_REVIEW_DIFF_STATE, ready(git), {
67+
type: "fetch.failed",
68+
attempt: 1,
69+
message: "git failed",
70+
})
71+
72+
expect(failed.effects).toEqual([{ type: "fetch", target: git, attempt: 1 }])
73+
expect(reviewDiffView(failed.state)).toEqual({
74+
status: "error",
75+
failure: { type: "rejected", message: "git failed" },
76+
})
77+
expect(reviewDiffReady(failed.state)).toBe(false)
78+
79+
const retried = play(
80+
failed.state,
81+
{ type: "retry" },
82+
{
83+
type: "fetch.succeeded",
84+
attempt: 2,
85+
rows: { rows: alpha, version: 20 },
86+
},
87+
)
88+
89+
expect(retried.effects).toEqual([{ type: "fetch", target: git, attempt: 2 }])
90+
expect(reviewDiffView(retried.state)).toEqual({ status: "ready", rows: alpha, version: 20 })
91+
expect(reviewDiffReady(retried.state)).toBe(true)
92+
})
93+
94+
test("times out instead of loading forever, and a timed-out refresh keeps the diffs shown", () => {
95+
const timedOut = play(INITIAL_REVIEW_DIFF_STATE, ready(git), { type: "fetch.timed-out", attempt: 1 }).state
96+
97+
expect(reviewDiffView(timedOut)).toEqual({ status: "error", failure: { type: "timeout" } })
98+
99+
const refreshed = play(loaded(), { type: "refresh", source: "vcs" }, { type: "fetch.timed-out", attempt: 2 })
100+
101+
expect(refreshed.effects).toEqual([{ type: "fetch", target: git, attempt: 2 }])
102+
expect(reviewDiffView(refreshed.state)).toEqual({ status: "ready", rows: alpha, version: 10 })
103+
})
104+
105+
test("a failed refresh keeps the diffs shown", () => {
106+
const state = play(
107+
loaded(),
108+
{ type: "window.focused" },
109+
{ type: "fetch.failed", attempt: 2, message: "boom" },
110+
).state
111+
112+
expect(reviewDiffView(state)).toEqual({ status: "ready", rows: alpha, version: 10 })
113+
})
114+
115+
test("a retry outside a failure does nothing", () => {
116+
const state = loaded()
117+
118+
expect(transitionReviewDiff(state, { type: "retry" }).state).toBe(state)
119+
})
120+
})
121+
122+
describe("a disconnected server", () => {
123+
test("shows offline instead of loading forever, and loads once it reconnects", () => {
124+
const unresolved = play(INITIAL_REVIEW_DIFF_STATE, { type: "context.changed", context: { kind: "offline" } })
125+
126+
expect(reviewDiffView(unresolved.state)).toEqual({ status: "offline" })
127+
expect(unresolved.effects).toEqual([])
128+
129+
const offline = play(unresolved.state, ready(git, { connected: false }))
130+
131+
expect(reviewDiffView(offline.state)).toEqual({ status: "offline" })
132+
expect(offline.effects).toEqual([])
133+
134+
const reconnected = play(offline.state, ready(git))
135+
136+
expect(reviewDiffView(reconnected.state)).toEqual({ status: "loading" })
137+
expect(reconnected.effects).toEqual([{ type: "fetch", target: git, attempt: 1 }])
138+
})
139+
140+
test("a disconnect ends the request and drops its late answer", () => {
141+
const state = play(INITIAL_REVIEW_DIFF_STATE, ready(git), ready(git, { connected: false }), {
142+
type: "fetch.succeeded",
143+
attempt: 1,
144+
rows: { rows: alpha, version: 10 },
145+
})
146+
147+
expect(state.effects).toEqual([{ type: "fetch", target: git, attempt: 1 }, { type: "abort" }])
148+
expect(reviewDiffView(state.state)).toEqual({ status: "offline" })
149+
})
150+
151+
test("keeps loaded diffs while disconnected and refreshes them on reconnect", () => {
152+
const disconnected = play(loaded(), ready(git, { connected: false }))
153+
154+
expect(reviewDiffView(disconnected.state)).toEqual({ status: "ready", rows: alpha, version: 10 })
155+
expect(transitionReviewDiff(disconnected.state, { type: "side.opened" }).state).toBe(disconnected.state)
156+
157+
const reconnected = play(disconnected.state, ready(git))
158+
159+
expect(reconnected.effects).toEqual([{ type: "fetch", target: git, attempt: 2 }])
160+
expect(reviewDiffView(reconnected.state)).toEqual({ status: "ready", rows: alpha, version: 10 })
161+
})
162+
})
163+
164+
describe("the side region opening", () => {
165+
test("waits for the session store before it requests the stored mode", () => {
166+
const waiting = play(INITIAL_REVIEW_DIFF_STATE, { type: "context.changed", context: { kind: "waiting" } }).state
167+
const opened = transitionReviewDiff(waiting, { type: "side.opened" })
168+
169+
expect(opened.state).toBe(waiting)
170+
expect(opened.effects).toEqual([])
171+
172+
const idle = play(waiting, ready(branch, { demanded: false }), { type: "side.opened" })
173+
174+
expect(idle.effects).toEqual([{ type: "fetch", target: branch, attempt: 1 }])
175+
})
176+
177+
test("does not request again while a request runs", () => {
178+
const loading = play(INITIAL_REVIEW_DIFF_STATE, ready(git)).state
179+
180+
expect(transitionReviewDiff(loading, { type: "side.opened" }).state).toBe(loading)
181+
})
182+
})
183+
184+
describe("another target", () => {
185+
test("aborts the running request and drops its answers", () => {
186+
const switched = play(
187+
INITIAL_REVIEW_DIFF_STATE,
188+
ready(git),
189+
ready(branch),
190+
{ type: "fetch.succeeded", attempt: 1, rows: { rows: alpha, version: 10 } },
191+
{ type: "fetch.failed", attempt: 1, message: "aborted" },
192+
)
193+
194+
expect(switched.effects).toEqual([
195+
{ type: "fetch", target: git, attempt: 1 },
196+
{ type: "abort" },
197+
{ type: "fetch", target: branch, attempt: 2 },
198+
])
199+
expect(reviewDiffView(switched.state)).toEqual({ status: "loading" })
200+
201+
const settled = play(switched.state, { type: "fetch.succeeded", attempt: 2, rows: { rows: beta, version: 20 } })
202+
203+
expect(reviewDiffView(settled.state)).toEqual({ status: "ready", rows: beta, version: 20 })
204+
})
205+
206+
test("shows what an earlier request loaded for it while it loads again", () => {
207+
const state = play(loaded(), {
208+
type: "context.changed",
209+
context: { kind: "ready", target: branch, connected: true, demanded: true, cached: { rows: beta, version: 5 } },
210+
})
211+
212+
expect(state.effects).toEqual([{ type: "fetch", target: branch, attempt: 2 }])
213+
expect(reviewDiffView(state.state)).toEqual({ status: "ready", rows: beta, version: 5 })
214+
})
215+
216+
test("never carries the previous target's failure", () => {
217+
const failed = play(INITIAL_REVIEW_DIFF_STATE, ready(git), { type: "fetch.failed", attempt: 1, message: "boom" })
218+
const switched = play(failed.state, ready(turn))
219+
220+
expect(reviewDiffView(switched.state)).toEqual({ status: "loading" })
221+
})
222+
})
223+
224+
describe("background refreshes", () => {
225+
test("follow the source that changed while something shows the changes", () => {
226+
const state = loaded()
227+
228+
expect(play(state, { type: "refresh", source: "vcs" }).effects).toEqual([
229+
{ type: "fetch", target: git, attempt: 2 },
230+
])
231+
expect(transitionReviewDiff(state, { type: "refresh", source: "turn" }).state).toBe(state)
232+
233+
const hidden = play(state, ready(git, { demanded: false })).state
234+
235+
expect(transitionReviewDiff(hidden, { type: "refresh", source: "vcs" }).state).toBe(hidden)
236+
expect(transitionReviewDiff(hidden, { type: "window.focused" }).state).toBe(hidden)
237+
expect(play(hidden, ready(git)).effects).toEqual([{ type: "fetch", target: git, attempt: 2 }])
238+
})
239+
240+
test("a focused window refreshes Git changes but not a finished turn", () => {
241+
const changes = play(INITIAL_REVIEW_DIFF_STATE, ready(turn), {
242+
type: "fetch.succeeded",
243+
attempt: 1,
244+
rows: { rows: alpha, version: 10 },
245+
}).state
246+
247+
expect(transitionReviewDiff(changes, { type: "window.focused" }).state).toBe(changes)
248+
expect(play(changes, { type: "refresh", source: "turn" }).effects).toEqual([
249+
{ type: "fetch", target: turn, attempt: 2 },
250+
])
251+
})
252+
253+
test("a change during a request restarts it", () => {
254+
const state = play(INITIAL_REVIEW_DIFF_STATE, ready(git), { type: "refresh", source: "vcs" })
255+
256+
expect(state.effects).toEqual([
257+
{ type: "fetch", target: git, attempt: 1 },
258+
{ type: "abort" },
259+
{ type: "fetch", target: git, attempt: 2 },
260+
])
261+
})
262+
})
263+
264+
test("disposal aborts the running request and ignores every later event", () => {
265+
const disposed = play(INITIAL_REVIEW_DIFF_STATE, ready(git), { type: "disposed" })
266+
267+
expect(disposed.effects).toEqual([{ type: "fetch", target: git, attempt: 1 }, { type: "abort" }])
268+
269+
const late = play(
270+
disposed.state,
271+
{ type: "fetch.succeeded", attempt: 1, rows: { rows: alpha, version: 10 } },
272+
ready(branch),
273+
{ type: "retry" },
274+
)
275+
276+
expect(late.state).toBe(disposed.state)
277+
expect(late.effects).toEqual([])
278+
})
279+
280+
test("a project without a repository is ready without a request", () => {
281+
const state = play(loaded(), { type: "context.changed", context: { kind: "no-vcs" } })
282+
283+
expect(state.effects).toEqual([])
284+
expect(reviewDiffView(state.state)).toEqual({ status: "no-vcs" })
285+
expect(reviewDiffReady(state.state)).toBe(true)
286+
})

0 commit comments

Comments
 (0)