Compare commits

...
3 changed files with 64 additions and 30 deletions

No files matched your search

@@ -532,6 +532,29 @@ test("restores review state and the side-panel tab per session", async ({ page }
await expect(review).toHaveAttribute("aria-selected", "true")
})
test("shows and restores last turn changes from the session diff", async ({ page }) => {
const sessionID = "ses_reviewturn"
await openSession(page, { name: "ReviewTurn", vcsDiff: [fileDiff("src/alpha.ts")] })
await page.route(`**/api/session/${sessionID}/diff**`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [fileDiff("src/delta.ts")] }),
}),
)
const panel = page.locator("#review-panel")
await page.getByRole("button", { name: "Toggle review" }).click()
await page.getByRole("button", { name: "Git changes" }).click()
await page.getByRole("option", { name: "Last turn changes" }).click()
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
await expect(panel.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("delta.ts")
await page.reload()
await expectSessionTitle(page, "ReviewTurn")
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
await expect(panel.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("delta.ts")
})
test("keeps the review state a session stored before extensions", async ({ page }) => {
const directory = "C:/OpenCode/ReviewLegacy"
await openSession(page, {
+39 -28
View File
@@ -2,7 +2,7 @@ import type { FileDiffInfo } from "@opencode/client/promise"
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { checksum } from "@opencode/util/encode"
import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query"
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { debounce } from "@solid-primitives/scheduled"
import { createComputed, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@@ -17,7 +17,6 @@ import {
} from "./kinds"
export type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type FileSelection = { startLine: number; endLine: number; startChar: number; endChar: number }
export type Demand = { tree: number; files: number; panel: number; details: number }
@@ -109,12 +108,10 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
) {
list.push("branch")
}
// Turn snapshots are captured only for Git sessions.
if (project?.vcs === "git" && view.id) list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
})
const vcsKey = createMemo(
() =>
[
@@ -130,22 +127,25 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
const demand = input.demand
return demand.tree + demand.files + demand.panel > 0
})
const vcsQuery = createQuery(() => {
const value = vcsMode()
const turnKey = () => [ctx.id, view.server.id, "session-turn", view.id] as const
const diffQuery = createQuery(() => {
const value = mode()
const turn = value === "turn"
return {
queryKey: [...vcsKey(), value] as const,
queryKey: turn ? turnKey() : ([...vcsKey(), value] as const),
enabled: view.server.connected && wantsReview() && !!view.project?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
? () =>
// A finished turn does not change on focus or filesystem events; refresh it when the session goes idle.
refetchOnWindowFocus: !turn,
queryFn: turn
? () => view.server.client.session.diff({ sessionID: view.id })
: () =>
view.server.client.vcs
.diff({
location: { directory: directory() },
mode: value === "git" ? "working" : value,
})
.then((result) => result.data)
: skipToken,
.then((result) => result.data),
}
})
// The summary's changes row: the session directory's working tree, loaded only while the summary shows.
@@ -180,20 +180,17 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
on(
() => !layout.narrow() && layout.side.opened(view),
(open, previous) => {
if (!open || previous || vcsQuery.isFetching) return
if (!open || previous || diffQuery.isFetching) return
if (input.demand.tree > 0) {
refresh()
return
}
if (vcsMode() && view.server.connected && view.project?.vcs) void vcsQuery.refetch()
if (view.server.connected && view.project?.vcs) void diffQuery.refetch()
},
{ defer: true },
),
)
const diffs = (): FileDiffInfo[] => {
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return []
}
const diffs = (): FileDiffInfo[] => (diffQuery.isFetched ? (diffQuery.data ?? []) : [])
const renderable = createMemo(() => diffs().filter(filterRenderableDiff))
const kinds = createMemo(() => reviewDiffKinds(renderable()))
const activeFile = () => {
@@ -205,17 +202,12 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
// A project without VCS never enables diffQuery, so its status stays "pending" forever.
const project = view.project
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
return !diffQuery.isPending
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
const root = reviewRootDirectory(view.project?.worktree ?? directory())
const scoped = reviewDiffDirectory(root, path)
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
if (!diff || !source) return undefined
@@ -223,6 +215,24 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
if (reviewDiffNeedsLoad(diff)) return undefined
return diff
}
const value = mode()
// Oversized full-file patches come back empty; bounded context usually fits.
if (value === "turn") {
return queryClient
.fetchQuery({
queryKey: [...turnKey(), "bounded", version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () => view.server.client.session.diff({ sessionID: view.id, context: 3 }),
})
.then((result) => valid(result.find((diff) => diff.file === path)))
.catch((error: unknown) => {
console.debug("[session-review] failed to load bounded turn diff", { path, error })
return undefined
})
}
const root = reviewRootDirectory(view.project?.worktree ?? directory())
const scoped = reviewDiffDirectory(root, path)
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
@@ -376,6 +386,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
(next, previous) => {
if (next !== "idle" || previous === undefined || previous === "idle") return
refresh()
void queryClient.invalidateQueries({ queryKey: turnKey() })
},
{ defer: true },
),
@@ -416,7 +427,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
count,
deferRender: () => state.deferRender,
details: (): FileDiffInfo[] | undefined => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
diffVersion: () => vcsQuery.dataUpdatedAt,
diffVersion: () => diffQuery.dataUpdatedAt,
diffs,
renderable,
kinds,
+2 -2
View File
@@ -27,7 +27,7 @@ export function ReviewTitle(props: { review: ReviewModel }) {
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
const ctx = useExtension()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return ctx.t("empty.git")
@@ -60,7 +60,7 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
const ctx = useExtension()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
return (
<Switch>