From bd9a8d978897a748bb3b6cfc724f932d68368bec Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 10 Feb 2026 21:44:08 +0000 Subject: [PATCH] feat(ui): add Git Changes tab Adds repo-wide git changes view with refresh controls and keeps right drawer shortcuts fixed while tabs scroll. --- .../components/instance/instance-shell2.tsx | 659 ++++++++++++++++-- .../ui/src/lib/i18n/messages/en/instance.ts | 4 +- packages/ui/src/lib/unified-diff-reverse.ts | 86 +++ packages/ui/src/styles/panels/right-panel.css | 22 + packages/ui/src/types/diff.d.ts | 5 + 5 files changed, 735 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/lib/unified-diff-reverse.ts create mode 100644 packages/ui/src/types/diff.d.ts diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 57d35376..594fc640 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -11,9 +11,9 @@ import { type Component, } from "solid-js" import type { ToolState } from "@opencode-ai/sdk" -import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" +import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import { Accordion } from "@kobalte/core" -import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid" +import { ChevronDown, RefreshCw, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import AppBar from "@suid/material/AppBar" import Box from "@suid/material/Box" import Drawer from "@suid/material/Drawer" @@ -73,6 +73,7 @@ import { useI18n } from "../../lib/i18n" import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../stores/worktrees" import { MonacoDiffViewer } from "../file-viewer/monaco-diff-viewer" import { MonacoFileViewer } from "../file-viewer/monaco-file-viewer" +import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../lib/unified-diff-reverse" import { SESSION_SIDEBAR_EVENT, type SessionSidebarRequestAction, @@ -108,10 +109,13 @@ const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2" const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1" const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1" +const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1" const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1" const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1" const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1" const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1" +const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1" +const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" @@ -119,7 +123,7 @@ const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel- type LayoutMode = "desktop" | "tablet" | "phone" -type RightPanelTab = "changes" | "files" | "status" +type RightPanelTab = "changes" | "git-changes" | "files" | "status" const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) const clampRightWidth = (value: number) => { @@ -146,6 +150,7 @@ function readStoredRightPanelTab(defaultValue: RightPanelTab): RightPanelTab { const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY) if (stored === "status") return "status" if (stored === "changes") return "changes" + if (stored === "git-changes") return "git-changes" if (stored === "files") return "files" // Migrate from v1 (where the stored values were the internal tab ids). @@ -231,7 +236,8 @@ const InstanceShell2: Component = (props) => { const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) - const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "files" | null>(null) + const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320) + const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null) const [splitResizeStartX, setSplitResizeStartX] = createSignal(0) const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0) @@ -240,6 +246,9 @@ const InstanceShell2: Component = (props) => { const [changesListOpen, setChangesListOpen] = createSignal(true) const [changesListTouched, setChangesListTouched] = createSignal(false) + const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) + const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) + createEffect(() => { // Default behavior: when nothing is selected, keep the file list open. // Once the user explicitly toggles it, we stop auto-opening. @@ -275,15 +284,18 @@ const InstanceShell2: Component = (props) => { const listLayoutKey = createMemo(() => (isPhoneLayout() ? "phone" : "nonphone")) - const listOpenStorageKey = (tab: "changes" | "files") => { + const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => { const layout = listLayoutKey() if (tab === "changes") { return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY } + if (tab === "git-changes") { + return layout === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY + } return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY } - const persistListOpen = (tab: "changes" | "files", value: boolean) => { + const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => { if (typeof window === "undefined") return window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false") } @@ -310,6 +322,15 @@ const InstanceShell2: Component = (props) => { setChangesListOpen(true) setChangesListTouched(false) } + + const gitPersisted = readStoredBool(listOpenStorageKey("git-changes")) + if (gitPersisted !== null) { + setGitChangesListOpen(gitPersisted) + setGitChangesListTouched(true) + } else { + setGitChangesListOpen(true) + setGitChangesListTouched(false) + } }) const persistPinIfSupported = (side: "left" | "right", value: boolean) => { @@ -388,6 +409,7 @@ const InstanceShell2: Component = (props) => { setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320))) setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320))) + setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320))) const handleResize = () => { const width = clampWidth(window.innerWidth * 0.3) @@ -890,9 +912,14 @@ const InstanceShell2: Component = (props) => { return Math.min(max, Math.max(min, Math.floor(value))) } - const persistSplitWidth = (mode: "changes" | "files", width: number) => { + const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => { if (typeof window === "undefined") return - const key = mode === "changes" ? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY + const key = + mode === "changes" + ? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY + : mode === "git-changes" + ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY + : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY window.localStorage.setItem(key, String(width)) } @@ -912,13 +939,14 @@ const InstanceShell2: Component = (props) => { const delta = event.clientX - splitResizeStartX() const next = clampSplitWidth(splitResizeStartWidth() + delta) if (mode === "changes") setChangesSplitWidth(next) + else if (mode === "git-changes") setGitChangesSplitWidth(next) else setFilesSplitWidth(next) } function splitMouseUp() { const mode = activeSplitResize() if (mode) { - const width = mode === "changes" ? changesSplitWidth() : filesSplitWidth() + const width = mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() persistSplitWidth(mode, width) } stopSplitResize() @@ -933,35 +961,38 @@ const InstanceShell2: Component = (props) => { const delta = touch.clientX - splitResizeStartX() const next = clampSplitWidth(splitResizeStartWidth() + delta) if (mode === "changes") setChangesSplitWidth(next) + else if (mode === "git-changes") setGitChangesSplitWidth(next) else setFilesSplitWidth(next) } function splitTouchEnd() { const mode = activeSplitResize() if (mode) { - const width = mode === "changes" ? changesSplitWidth() : filesSplitWidth() + const width = mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() persistSplitWidth(mode, width) } stopSplitResize() } - const startSplitResize = (mode: "changes" | "files", clientX: number) => { + const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => { if (typeof document === "undefined") return setActiveSplitResize(mode) setSplitResizeStartX(clientX) - setSplitResizeStartWidth(mode === "changes" ? changesSplitWidth() : filesSplitWidth()) + setSplitResizeStartWidth( + mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(), + ) document.addEventListener("mousemove", splitMouseMove) document.addEventListener("mouseup", splitMouseUp) document.addEventListener("touchmove", splitTouchMove, { passive: false }) document.addEventListener("touchend", splitTouchEnd) } - const handleSplitResizeMouseDown = (mode: "changes" | "files") => (event: MouseEvent) => { + const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => { event.preventDefault() startSplitResize(mode, event.clientX) } - const handleSplitResizeTouchStart = (mode: "changes" | "files") => (event: TouchEvent) => { + const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => { const touch = event.touches[0] if (!touch) return event.preventDefault() @@ -1228,8 +1259,34 @@ const InstanceShell2: Component = (props) => { return getDefaultWorktreeSlug(props.instance.id) }) + const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer())) + + const [gitStatusEntries, setGitStatusEntries] = createSignal(null) + const [gitStatusLoading, setGitStatusLoading] = createSignal(false) + const [gitStatusError, setGitStatusError] = createSignal(null) + const [gitSelectedPath, setGitSelectedPath] = createSignal(null) + const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false) + const [gitSelectedError, setGitSelectedError] = createSignal(null) + const [gitSelectedBefore, setGitSelectedBefore] = createSignal(null) + const [gitSelectedAfter, setGitSelectedAfter] = createSignal(null) + + const gitMostChangedPath = createMemo(() => { + const entries = gitStatusEntries() + if (!Array.isArray(entries) || entries.length === 0) return null + const candidates = entries.filter((item) => item && item.status !== "deleted") + if (candidates.length === 0) return null + const best = candidates.reduce((currentBest, item) => { + const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0) + const score = (item?.added ?? 0) + (item?.removed ?? 0) + if (score > bestScore) return item + if (score < bestScore) return currentBest + return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest + }, candidates[0]) + return typeof best?.path === "string" ? best.path : null + }) + createEffect(() => { - // Reset browser state when worktree context changes. + // Reset tab state when worktree context changes. worktreeSlugForViewer() setBrowserPath(".") setBrowserEntries(null) @@ -1238,9 +1295,110 @@ const InstanceShell2: Component = (props) => { setBrowserSelectedContent(null) setBrowserSelectedError(null) setBrowserSelectedLoading(false) + + setGitStatusEntries(null) + setGitStatusError(null) + setGitStatusLoading(false) + setGitSelectedPath(null) + setGitSelectedLoading(false) + setGitSelectedError(null) + setGitSelectedBefore(null) + setGitSelectedAfter(null) }) - const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer())) + const loadGitStatus = async (force = false) => { + if (!force && gitStatusEntries() !== null) return + setGitStatusLoading(true) + setGitStatusError(null) + try { + const list = await requestData(browserClient().file.status(), "file.status") + setGitStatusEntries(Array.isArray(list) ? list : []) + } catch (error) { + setGitStatusError(error instanceof Error ? error.message : "Failed to load git status") + setGitStatusEntries([]) + } finally { + setGitStatusLoading(false) + } + } + + async function openGitFile(path: string) { + setGitSelectedPath(path) + setGitSelectedLoading(true) + setGitSelectedError(null) + setGitSelectedBefore(null) + setGitSelectedAfter(null) + + const list = gitStatusEntries() || [] + const entry = list.find((item) => item.path === path) || null + if (entry?.status === "deleted") { + setGitSelectedError("Deleted file diff is not available yet") + setGitSelectedLoading(false) + return + } + + // Phone: treat file selection as a commit action and close the overlay. + if (isPhoneLayout()) { + setGitChangesListOpen(false) + } + + try { + const content = await requestData(browserClient().file.read({ path }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") { + throw new Error("Binary file cannot be displayed") + } + if (encoding === "base64") { + throw new Error("Binary file cannot be displayed") + } + const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null + if (afterText === null) { + throw new Error("Unsupported file type") + } + + setGitSelectedAfter(afterText) + + if (entry?.status === "added") { + setGitSelectedBefore("") + return + } + + const diffText = + typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0 + ? String((content as any).diff) + : (content as any)?.patch + ? buildUnifiedDiffFromSdkPatch((content as any).patch) + : "" + + const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText) + if (beforeText === null) { + throw new Error("Unable to calculate diff for this file") + } + setGitSelectedBefore(beforeText) + } catch (error) { + setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes") + } finally { + setGitSelectedLoading(false) + } + } + + createEffect(() => { + if (rightPanelTab() !== "git-changes") return + const entries = gitStatusEntries() + if (entries === null) return + if (gitSelectedPath()) return + const next = gitMostChangedPath() + if (!next) return + void openGitFile(next) + }) + + const refreshGitStatus = async () => { + await loadGitStatus(true) + const selected = gitSelectedPath() + if (selected) { + void openGitFile(selected) + } + } const bestDiffFile = createMemo(() => { const diffs = activeSessionDiffs() @@ -1342,6 +1500,13 @@ const InstanceShell2: Component = (props) => { void loadBrowserEntries(browserPath()) }) + createEffect(() => { + if (rightPanelTab() !== "git-changes") return + if (gitStatusLoading()) return + if (gitStatusEntries() !== null) return + void loadGitStatus() + }) + const renderFilesTabContent = () => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") { @@ -1694,6 +1859,36 @@ const InstanceShell2: Component = (props) => { const headerDisplayedPath = () => browserSelectedPath() || browserPath() + const refreshFilesTab = async () => { + void loadBrowserEntries(browserPath()) + const selected = browserSelectedPath() + if (selected) { + // Refresh file content without altering overlay state. + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + try { + const content = await requestData(browserClient().file.read({ path: selected }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") { + throw new Error("Binary file cannot be displayed") + } + if (encoding === "base64") { + throw new Error("Binary file cannot be displayed") + } + const text = (content as any)?.content + if (typeof text !== "string") { + throw new Error("Unsupported file type") + } + setBrowserSelectedContent(text) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + } + return (
@@ -1715,6 +1910,18 @@ const InstanceShell2: Component = (props) => { {(err) => {err()}}
+ +
@@ -1910,6 +2117,369 @@ const InstanceShell2: Component = (props) => { ) } + const renderGitChangesTabContent = () => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") { + return ( +
+ Select a session to view changes. +
+ ) + } + + const entries = gitStatusEntries() + if (entries === null) { + return ( +
+ Loading git changes… +
+ ) + } + + const nonDeleted = entries.filter((item) => item && item.status !== "deleted") + if (nonDeleted.length === 0) { + return ( +
+ No git changes yet. +
+ ) + } + + const sorted = [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.added === "number" ? item.added : 0 + acc.deletions += typeof item.removed === "number" ? item.removed : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + const selectedPath = gitSelectedPath() + const fallbackPath = gitMostChangedPath() + const selectedEntry = + sorted.find((item) => item.path === selectedPath) || (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) + const scopeKey = `${props.instance.id}:git:${worktreeSlugForViewer()}` + + const toggleGitList = () => { + setGitChangesListTouched(true) + setGitChangesListOpen((current) => { + const next = !current + persistListOpen("git-changes", next) + return next + }) + } + + return ( +
+
+
+ + + + {selectedEntry?.path || ""} + + +
+ + +{totals.additions} + + + -{totals.deletions} + + + {(err) => {err()}} + +
+ + +
+
+ +
+ +
+
+ + + + +
+
+
+ + No file selected. +
+ } + > + {(file) => ( + + )} +
+ } + > + {(err) => ( +
+ {err()} +
+ )} + + } + > +
+ Loading… +
+ +
+
+ } + > +
+
+
+ + {(item) => ( +
{ + void openGitFile(item.path) + }} + > +
+
+ {item.path} +
+
+ + deleted + + + <> + +{item.added} + -{item.removed} + + +
+
+
+ )} +
+
+
+ +
+ + + + + + + + + + ) + } + const renderStatusSessionChanges = () => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") { @@ -2197,32 +2767,31 @@ const InstanceShell2: Component = (props) => {
+
+ + + + + + + (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} + > + {rightPinned() ? : } + + +
-
- - - - - - - (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} - > - {rightPinned() ? : } - - -
-
+