diff --git a/app-prefixable/src/components/review-panel.tsx b/app-prefixable/src/components/review-panel.tsx index b69f58b2..2f03df71 100644 --- a/app-prefixable/src/components/review-panel.tsx +++ b/app-prefixable/src/components/review-panel.tsx @@ -10,7 +10,8 @@ import * as Diff from "diff"; import type { FileDiff, FileNode } from "../sdk/client"; import { useSDK } from "../context/sdk"; import { useEvents } from "../context/events"; -import { useLayout } from "../context/layout"; +import { useLayout, type ReviewMode } from "../context/layout"; +import { useSync } from "../context/sync"; import { FileTree } from "./file-tree"; import { FileViewer } from "./file-viewer"; @@ -41,10 +42,27 @@ interface ReviewPanelProps { sessionId: string; } +const REVIEW_MODES: Array<{ value: ReviewMode; label: string }> = [ + { value: "session", label: "Session" }, + { value: "git", label: "Git" }, + { value: "branch", label: "Branch" }, + { value: "turn", label: "Turn" }, +]; + +function lastUserMessageID( + messages: Array<{ info: { id: string; role: string } }>, +) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].info.role === "user") return messages[i].info.id; + } + return undefined; +} + export function ReviewPanel(props: ReviewPanelProps) { const { client, directory } = useSDK(); const events = useEvents(); const layout = useLayout(); + const sync = useSync(); const [diffs, setDiffs] = createSignal([]); const [selected, setSelected] = createSignal(null); @@ -52,52 +70,120 @@ export function ReviewPanel(props: ReviewPanelProps) { const [tab, setTab] = createSignal<"changes" | "all">("changes"); const [isGitRepo, setIsGitRepo] = createSignal(null); // null = unknown + const mode = () => layout.review.mode(); + const lastUserMessageId = createMemo(() => + lastUserMessageID(sync.messages(props.sessionId)), + ); + // Track the latest request to prevent race conditions let version = 0; - async function checkGitRepo() { - try { - // Try to get VCS info - if it fails or returns no branch, it's not a git repo - const res = await client.vcs.get({ directory }); - setIsGitRepo(res.data?.branch !== undefined); - } catch { + function isNotGitRepoError(error: unknown) { + if (!error) return false; + if (typeof error === "string") { + return /not a git repository|not a repository|outside repository/i.test(error); + } + if (typeof error !== "object") return false; + + const obj = error as Record; + const message = [obj.message, obj.error, obj.detail] + .filter((item) => typeof item === "string") + .join(" "); + if (!message) return false; + return /not a git repository|not a repository|outside repository/i.test(message); + } + + async function checkGitRepo(current: number) { + const res = await client.vcs.get({ directory }, { throwOnError: false }); + if (current !== version) return; + if (res.data) { + setIsGitRepo(true); + return; + } + if (isNotGitRepoError(res.error)) { setIsGitRepo(false); + return; + } + setIsGitRepo(null); + } + + function setFiles(files: FileDiff[]) { + setDiffs(files); + const paths = files.map((d) => d.file); + const sel = selected(); + if (!sel || !paths.includes(sel)) { + setSelected(files.length > 0 ? files[0].file : null); } } async function loadDiffs() { const current = ++version; + const currentMode = mode(); + if (currentMode === "git" || currentMode === "branch") setIsGitRepo(null); setLoading(true); try { + if (currentMode === "git" || currentMode === "branch") { + const res = await client.vcs.diff({ directory, mode: currentMode }); + if (current !== version) return; + if (res.data) { + setFiles(res.data); + setIsGitRepo(true); + return; + } + setFiles([]); + await checkGitRepo(current); + return; + } + + if (currentMode === "turn") { + const messageID = lastUserMessageId(); + if (!messageID) { + if (current !== version) return; + setFiles([]); + setIsGitRepo(true); + return; + } + const res = await client.session.diff({ + sessionID: props.sessionId, + directory, + messageID, + }); + if (current !== version) return; + if (res.data) { + setFiles(res.data); + setIsGitRepo(true); + return; + } + setFiles([]); + await checkGitRepo(current); + return; + } + const res = await client.session.diff({ sessionID: props.sessionId, directory }); - // Only update state if this is still the latest request if (current !== version) return; if (res.data) { - setDiffs(res.data); - setIsGitRepo(true); // If we got diffs, it's definitely a git repo - // Auto-select first file if none selected or selection no longer exists - const files = res.data.map((d) => d.file); - const sel = selected(); - if (!sel || !files.includes(sel)) { - setSelected(res.data.length > 0 ? res.data[0].file : null); - } - } else { - // No diffs returned - check if it's because no git repo - await checkGitRepo(); + setFiles(res.data); + setIsGitRepo(true); + return; } + setFiles([]); + await checkGitRepo(current); } catch (e) { if (current !== version) return; console.error("[ReviewPanel] Failed to load diffs:", e); // Check if it's a git repo issue - await checkGitRepo(); + setFiles([]); + await checkGitRepo(current); } finally { if (current === version) setLoading(false); } } - // Load diffs when session changes + // Load diffs when session, mode, or relevant turn changes createEffect(() => { const id = props.sessionId; + const currentMode = mode(); + if (currentMode === "turn") lastUserMessageId(); if (id) { // Reset selection when session changes setSelected(null); @@ -119,16 +205,9 @@ export function ReviewPanel(props: ReviewPanelProps) { sessionID?: string; diff?: FileDiff[]; }; - if (eventProps.sessionID === id && eventProps.diff) { - setDiffs(eventProps.diff); - // Auto-select first file if none selected or selection no longer exists - const files = eventProps.diff.map((d) => d.file); - const sel = selected(); - if (!sel || !files.includes(sel)) { - setSelected( - eventProps.diff.length > 0 ? eventProps.diff[0].file : null, - ); - } + if (mode() === "session" && eventProps.sessionID === id && eventProps.diff) { + setFiles(eventProps.diff); + setIsGitRepo(true); } } // Reload on session status idle to catch completed changes @@ -142,7 +221,7 @@ export function ReviewPanel(props: ReviewPanelProps) { } } if (event.type === "vcs.branch.updated") { - loadDiffs(); + if (mode() === "git" || mode() === "branch") loadDiffs(); } }); @@ -195,6 +274,13 @@ export function ReviewPanel(props: ReviewPanelProps) { }); const count = createMemo(() => diffs().length); + const isGitMode = createMemo(() => mode() === "git" || mode() === "branch"); + const emptyText = createMemo(() => { + if (mode() === "git") return "No uncommitted changes"; + if (mode() === "branch") return "No branch changes"; + if (mode() === "turn") return "No changes in the last turn"; + return "No changes in this session"; + }); // List of changed file paths for filtering const diffFiles = createMemo(() => diffs().map((d) => d.file)); @@ -330,6 +416,8 @@ export function ReviewPanel(props: ReviewPanelProps) { >
- {/* Tabs for Changed Files / All Files */} + {/* Review mode selector + Tabs for Changed Files / All Files */} +
+ + {(item) => ( + + )} + +
} > - No changes in this session + {emptyText()} } @@ -469,7 +586,7 @@ export function ReviewPanel(props: ReviewPanelProps) { > {count() > 0 ? "Select a file to view changes" - : "No changes in this session"} + : emptyText()} } diff --git a/app-prefixable/src/context/layout.tsx b/app-prefixable/src/context/layout.tsx index c1497f85..913af2a4 100644 --- a/app-prefixable/src/context/layout.tsx +++ b/app-prefixable/src/context/layout.tsx @@ -12,9 +12,12 @@ const LAYOUT_STORAGE_KEY = "opencode.layout"; const DEFAULT_REVIEW_WIDTH = 320; const DEFAULT_INFO_WIDTH = 256; const DEFAULT_SIDEBAR_WIDTH = 256; +const DEFAULT_REVIEW_MODE = "session"; export const SIDEBAR_MIN_WIDTH = 180; export const SIDEBAR_MAX_WIDTH = 480; +export type ReviewMode = "session" | "git" | "branch" | "turn"; + interface PanelState { opened: boolean; width?: number; @@ -29,6 +32,7 @@ interface LayoutState { review: PanelState; info: PanelState; sidebar: { width?: number }; + reviewMode?: ReviewMode; tabs?: FileTab[]; activeTab?: string | null; // null = Review tab, string = file path } @@ -38,10 +42,12 @@ interface LayoutContextValue { review: { opened: () => boolean; width: () => number; + mode: () => ReviewMode; toggle: () => void; open: () => void; close: () => void; resize: (width: number) => void; + setMode: (mode: ReviewMode) => void; }; // Info panel (todos, context usage) info: { @@ -69,6 +75,10 @@ interface LayoutContextValue { const LayoutContext = createContext(); +function isReviewMode(value: unknown): value is ReviewMode { + return value === "session" || value === "git" || value === "branch" || value === "turn"; +} + function basename(path: string) { const idx = path.lastIndexOf("/"); return idx === -1 ? path : path.slice(idx + 1); @@ -98,6 +108,9 @@ function loadState(): LayoutState { sidebar: { width: parsed.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH, }, + reviewMode: isReviewMode(parsed.reviewMode) + ? parsed.reviewMode + : DEFAULT_REVIEW_MODE, tabs, activeTab, }; @@ -109,6 +122,7 @@ function loadState(): LayoutState { review: { opened: false, width: DEFAULT_REVIEW_WIDTH }, info: { opened: false, width: DEFAULT_INFO_WIDTH }, sidebar: { width: DEFAULT_SIDEBAR_WIDTH }, + reviewMode: DEFAULT_REVIEW_MODE, tabs: [], activeTab: null, }; @@ -130,6 +144,9 @@ export function LayoutProvider(props: ParentProps) { const [reviewWidth, setReviewWidth] = createSignal( initial.review.width ?? DEFAULT_REVIEW_WIDTH, ); + const [reviewMode, setReviewMode] = createSignal( + initial.reviewMode ?? DEFAULT_REVIEW_MODE, + ); // Info panel state const [infoOpened, setInfoOpened] = createSignal(initial.info.opened); @@ -156,6 +173,7 @@ export function LayoutProvider(props: ParentProps) { review: { opened: reviewOpened(), width: reviewWidth() }, info: { opened: infoOpened(), width: infoWidth() }, sidebar: { width: sidebarWidth() }, + reviewMode: reviewMode(), tabs: fileTabs(), activeTab: activeTab(), }); @@ -165,6 +183,7 @@ export function LayoutProvider(props: ParentProps) { review: { opened: reviewOpened, width: reviewWidth, + mode: reviewMode, toggle: () => { setReviewOpened((v) => !v); persist(); @@ -181,6 +200,11 @@ export function LayoutProvider(props: ParentProps) { setReviewWidth(width); persist(); }, + setMode: (mode: ReviewMode) => { + if (reviewMode() === mode) return; + setReviewMode(mode); + persist(); + }, }, info: { opened: infoOpened, diff --git a/app-prefixable/src/sdk/gen/sdk.gen.ts b/app-prefixable/src/sdk/gen/sdk.gen.ts index af79c44a..1e3e7713 100644 --- a/app-prefixable/src/sdk/gen/sdk.gen.ts +++ b/app-prefixable/src/sdk/gen/sdk.gen.ts @@ -163,6 +163,7 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -3014,6 +3015,36 @@ export class Vcs extends HeyApiClient { ...params, }) } + + /** + * Get VCS diff + * + * Get git or branch diff for the current project directory. + */ + public diff( + parameters?: { + directory?: string + mode?: "git" | "branch" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "mode" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff", + ...options, + ...params, + }) + } } export class Command extends HeyApiClient { diff --git a/app-prefixable/src/sdk/gen/types.gen.ts b/app-prefixable/src/sdk/gen/types.gen.ts index efb7e202..64cde1c6 100644 --- a/app-prefixable/src/sdk/gen/types.gen.ts +++ b/app-prefixable/src/sdk/gen/types.gen.ts @@ -4905,6 +4905,25 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsDiffData = { + body?: never + path?: never + query?: { + directory?: string + mode?: "git" | "branch" + } + url: "/vcs/diff" +} + +export type VcsDiffResponses = { + /** + * Diff + */ + 200: Array +} + +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] + export type CommandListData = { body?: never path?: never diff --git a/app-prefixable/tests/api-contract.test.ts b/app-prefixable/tests/api-contract.test.ts index 41d1b1d4..438df55e 100644 --- a/app-prefixable/tests/api-contract.test.ts +++ b/app-prefixable/tests/api-contract.test.ts @@ -219,6 +219,27 @@ describe("OpenCode API Contract", () => { }); }); + // VCS Endpoints + describe("VCS API", () => { + test("GET /vcs endpoint exists", async () => { + if (skipIfNoServer()) return; + const res = await fetch(`${BASE_URL}/vcs`); + expect(res.status).not.toBe(404); + }); + + test("GET /vcs/diff?mode=git endpoint exists", async () => { + if (skipIfNoServer()) return; + const res = await fetch(`${BASE_URL}/vcs/diff?mode=git`); + expect(res.status).not.toBe(404); + }); + + test("GET /vcs/diff?mode=branch endpoint exists", async () => { + if (skipIfNoServer()) return; + const res = await fetch(`${BASE_URL}/vcs/diff?mode=branch`); + expect(res.status).not.toBe(404); + }); + }); + // PTY Endpoints describe("PTY API", () => { test("GET /pty returns pty list", async () => {