feat(ui): add Git Changes tab
Adds repo-wide git changes view with refresh controls and keeps right drawer shortcuts fixed while tabs scroll.
This commit is contained in:
@@ -11,9 +11,9 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
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 { 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 AppBar from "@suid/material/AppBar"
|
||||||
import Box from "@suid/material/Box"
|
import Box from "@suid/material/Box"
|
||||||
import Drawer from "@suid/material/Drawer"
|
import Drawer from "@suid/material/Drawer"
|
||||||
@@ -73,6 +73,7 @@ import { useI18n } from "../../lib/i18n"
|
|||||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../stores/worktrees"
|
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../stores/worktrees"
|
||||||
import { MonacoDiffViewer } from "../file-viewer/monaco-diff-viewer"
|
import { MonacoDiffViewer } from "../file-viewer/monaco-diff-viewer"
|
||||||
import { MonacoFileViewer } from "../file-viewer/monaco-file-viewer"
|
import { MonacoFileViewer } from "../file-viewer/monaco-file-viewer"
|
||||||
|
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../lib/unified-diff-reverse"
|
||||||
import {
|
import {
|
||||||
SESSION_SIDEBAR_EVENT,
|
SESSION_SIDEBAR_EVENT,
|
||||||
type SessionSidebarRequestAction,
|
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 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_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_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_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_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_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_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_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"
|
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 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 clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||||
const clampRightWidth = (value: number) => {
|
const clampRightWidth = (value: number) => {
|
||||||
@@ -146,6 +150,7 @@ function readStoredRightPanelTab(defaultValue: RightPanelTab): RightPanelTab {
|
|||||||
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
|
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
|
||||||
if (stored === "status") return "status"
|
if (stored === "status") return "status"
|
||||||
if (stored === "changes") return "changes"
|
if (stored === "changes") return "changes"
|
||||||
|
if (stored === "git-changes") return "git-changes"
|
||||||
if (stored === "files") return "files"
|
if (stored === "files") return "files"
|
||||||
|
|
||||||
// Migrate from v1 (where the stored values were the internal tab ids).
|
// Migrate from v1 (where the stored values were the internal tab ids).
|
||||||
@@ -231,7 +236,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||||
const [filesSplitWidth, setFilesSplitWidth] = 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 [splitResizeStartX, setSplitResizeStartX] = createSignal(0)
|
||||||
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
|
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
|
||||||
|
|
||||||
@@ -240,6 +246,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [changesListOpen, setChangesListOpen] = createSignal(true)
|
const [changesListOpen, setChangesListOpen] = createSignal(true)
|
||||||
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||||
|
|
||||||
|
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||||
|
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// Default behavior: when nothing is selected, keep the file list open.
|
// Default behavior: when nothing is selected, keep the file list open.
|
||||||
// Once the user explicitly toggles it, we stop auto-opening.
|
// Once the user explicitly toggles it, we stop auto-opening.
|
||||||
@@ -275,15 +284,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const listLayoutKey = createMemo(() => (isPhoneLayout() ? "phone" : "nonphone"))
|
const listLayoutKey = createMemo(() => (isPhoneLayout() ? "phone" : "nonphone"))
|
||||||
|
|
||||||
const listOpenStorageKey = (tab: "changes" | "files") => {
|
const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => {
|
||||||
const layout = listLayoutKey()
|
const layout = listLayoutKey()
|
||||||
if (tab === "changes") {
|
if (tab === "changes") {
|
||||||
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
|
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
|
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
|
if (typeof window === "undefined") return
|
||||||
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
||||||
}
|
}
|
||||||
@@ -310,6 +322,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
setChangesListOpen(true)
|
setChangesListOpen(true)
|
||||||
setChangesListTouched(false)
|
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) => {
|
const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
|
||||||
@@ -388,6 +409,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
||||||
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_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 handleResize = () => {
|
||||||
const width = clampWidth(window.innerWidth * 0.3)
|
const width = clampWidth(window.innerWidth * 0.3)
|
||||||
@@ -890,9 +912,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
return Math.min(max, Math.max(min, Math.floor(value)))
|
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
|
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))
|
window.localStorage.setItem(key, String(width))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,13 +939,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const delta = event.clientX - splitResizeStartX()
|
const delta = event.clientX - splitResizeStartX()
|
||||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
if (mode === "changes") setChangesSplitWidth(next)
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
else setFilesSplitWidth(next)
|
else setFilesSplitWidth(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitMouseUp() {
|
function splitMouseUp() {
|
||||||
const mode = activeSplitResize()
|
const mode = activeSplitResize()
|
||||||
if (mode) {
|
if (mode) {
|
||||||
const width = mode === "changes" ? changesSplitWidth() : filesSplitWidth()
|
const width = mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||||
persistSplitWidth(mode, width)
|
persistSplitWidth(mode, width)
|
||||||
}
|
}
|
||||||
stopSplitResize()
|
stopSplitResize()
|
||||||
@@ -933,35 +961,38 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const delta = touch.clientX - splitResizeStartX()
|
const delta = touch.clientX - splitResizeStartX()
|
||||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
if (mode === "changes") setChangesSplitWidth(next)
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
else setFilesSplitWidth(next)
|
else setFilesSplitWidth(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitTouchEnd() {
|
function splitTouchEnd() {
|
||||||
const mode = activeSplitResize()
|
const mode = activeSplitResize()
|
||||||
if (mode) {
|
if (mode) {
|
||||||
const width = mode === "changes" ? changesSplitWidth() : filesSplitWidth()
|
const width = mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||||
persistSplitWidth(mode, width)
|
persistSplitWidth(mode, width)
|
||||||
}
|
}
|
||||||
stopSplitResize()
|
stopSplitResize()
|
||||||
}
|
}
|
||||||
|
|
||||||
const startSplitResize = (mode: "changes" | "files", clientX: number) => {
|
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
setActiveSplitResize(mode)
|
setActiveSplitResize(mode)
|
||||||
setSplitResizeStartX(clientX)
|
setSplitResizeStartX(clientX)
|
||||||
setSplitResizeStartWidth(mode === "changes" ? changesSplitWidth() : filesSplitWidth())
|
setSplitResizeStartWidth(
|
||||||
|
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
|
||||||
|
)
|
||||||
document.addEventListener("mousemove", splitMouseMove)
|
document.addEventListener("mousemove", splitMouseMove)
|
||||||
document.addEventListener("mouseup", splitMouseUp)
|
document.addEventListener("mouseup", splitMouseUp)
|
||||||
document.addEventListener("touchmove", splitTouchMove, { passive: false })
|
document.addEventListener("touchmove", splitTouchMove, { passive: false })
|
||||||
document.addEventListener("touchend", splitTouchEnd)
|
document.addEventListener("touchend", splitTouchEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSplitResizeMouseDown = (mode: "changes" | "files") => (event: MouseEvent) => {
|
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
startSplitResize(mode, event.clientX)
|
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]
|
const touch = event.touches[0]
|
||||||
if (!touch) return
|
if (!touch) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1228,8 +1259,34 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
return getDefaultWorktreeSlug(props.instance.id)
|
return getDefaultWorktreeSlug(props.instance.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer()))
|
||||||
|
|
||||||
|
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
||||||
|
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||||
|
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||||
|
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const gitMostChangedPath = createMemo<string | null>(() => {
|
||||||
|
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(() => {
|
createEffect(() => {
|
||||||
// Reset browser state when worktree context changes.
|
// Reset tab state when worktree context changes.
|
||||||
worktreeSlugForViewer()
|
worktreeSlugForViewer()
|
||||||
setBrowserPath(".")
|
setBrowserPath(".")
|
||||||
setBrowserEntries(null)
|
setBrowserEntries(null)
|
||||||
@@ -1238,9 +1295,110 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
setBrowserSelectedContent(null)
|
setBrowserSelectedContent(null)
|
||||||
setBrowserSelectedError(null)
|
setBrowserSelectedError(null)
|
||||||
setBrowserSelectedLoading(false)
|
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<GitFileStatus[]>(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<FileContent>(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<string | null>(() => {
|
const bestDiffFile = createMemo<string | null>(() => {
|
||||||
const diffs = activeSessionDiffs()
|
const diffs = activeSessionDiffs()
|
||||||
@@ -1342,6 +1500,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
void loadBrowserEntries(browserPath())
|
void loadBrowserEntries(browserPath())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() !== "git-changes") return
|
||||||
|
if (gitStatusLoading()) return
|
||||||
|
if (gitStatusEntries() !== null) return
|
||||||
|
void loadGitStatus()
|
||||||
|
})
|
||||||
|
|
||||||
const renderFilesTabContent = () => {
|
const renderFilesTabContent = () => {
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!sessionId || sessionId === "info") {
|
if (!sessionId || sessionId === "info") {
|
||||||
@@ -1694,6 +1859,36 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const headerDisplayedPath = () => browserSelectedPath() || browserPath()
|
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<FileContent>(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 (
|
return (
|
||||||
<div class="files-tab-container">
|
<div class="files-tab-container">
|
||||||
<div class="files-tab-header">
|
<div class="files-tab-header">
|
||||||
@@ -1715,6 +1910,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
{(err) => <span class="text-error">{err()}</span>}
|
{(err) => <span class="text-error">{err()}</span>}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="files-header-icon-button"
|
||||||
|
title={t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
aria-label={t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
disabled={browserLoading()}
|
||||||
|
style={{ "margin-left": "auto" }}
|
||||||
|
onClick={() => void refreshFilesTab()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`h-4 w-4${browserLoading() ? " animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1910,6 +2117,369 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderGitChangesTabContent = () => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info") {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty">
|
||||||
|
<span class="text-xs">Select a session to view changes.</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = gitStatusEntries()
|
||||||
|
if (entries === null) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty">
|
||||||
|
<span class="text-xs">Loading git changes…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonDeleted = entries.filter((item) => item && item.status !== "deleted")
|
||||||
|
if (nonDeleted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty">
|
||||||
|
<span class="text-xs">No git changes yet.</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div class="files-tab-container">
|
||||||
|
<div class="files-tab-header">
|
||||||
|
<div class="files-tab-header-row">
|
||||||
|
<button type="button" class="files-toggle-button" onClick={toggleGitList}>
|
||||||
|
{gitChangesListOpen() ? "Hide files" : "Show files"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="files-tab-selected-path" title={selectedEntry?.path || ""}>
|
||||||
|
{selectedEntry?.path || ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="files-tab-stats" style={{ "flex": "0 0 auto" }}>
|
||||||
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
|
<span class="files-tab-stat-value">+{totals.additions}</span>
|
||||||
|
</span>
|
||||||
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
|
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||||
|
</span>
|
||||||
|
<Show when={gitStatusError()}>
|
||||||
|
{(err) => <span class="text-error">{err()}</span>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="files-header-icon-button"
|
||||||
|
title={t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
aria-label={t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
disabled={gitStatusLoading()}
|
||||||
|
style={{ "margin-left": "auto" }}
|
||||||
|
onClick={() => void refreshGitStatus()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`h-4 w-4${gitStatusLoading() ? " animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="files-tab-body">
|
||||||
|
<Show
|
||||||
|
when={!isPhoneLayout() && gitChangesListOpen()}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-header">
|
||||||
|
<div class="file-viewer-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffViewMode() === "split" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffViewMode() === "split"}
|
||||||
|
onClick={() => setDiffViewMode("split")}
|
||||||
|
>
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffViewMode() === "unified" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffViewMode() === "unified"}
|
||||||
|
onClick={() => setDiffViewMode("unified")}
|
||||||
|
>
|
||||||
|
Unified
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffContextMode() === "collapsed" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffContextMode() === "collapsed"}
|
||||||
|
onClick={() => setDiffContextMode("collapsed")}
|
||||||
|
title="Hide unchanged regions"
|
||||||
|
>
|
||||||
|
Collapsed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffContextMode() === "expanded" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffContextMode() === "expanded"}
|
||||||
|
onClick={() => setDiffContextMode("expanded")}
|
||||||
|
title="Show full file"
|
||||||
|
>
|
||||||
|
Expanded
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={gitSelectedLoading()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={gitSelectedError()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
selectedEntry &&
|
||||||
|
gitSelectedBefore() !== null &&
|
||||||
|
gitSelectedAfter() !== null &&
|
||||||
|
selectedEntry.status !== "deleted"
|
||||||
|
? {
|
||||||
|
path: selectedEntry.path,
|
||||||
|
before: gitSelectedBefore() as string,
|
||||||
|
after: gitSelectedAfter() as string,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">No file selected.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(file) => (
|
||||||
|
<MonacoDiffViewer
|
||||||
|
scopeKey={scopeKey}
|
||||||
|
path={String(file().path || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={diffViewMode()}
|
||||||
|
contextMode={diffContextMode()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(err) => (
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{err()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="files-split" style={{ "--files-pane-width": `${gitChangesSplitWidth()}px` }}>
|
||||||
|
<div class="file-list-panel">
|
||||||
|
<div class="file-list-scroll">
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${gitSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
void openGitFile(item.path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
{item.path}
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<Show when={item.status === "deleted"}>
|
||||||
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.status !== "deleted"}>
|
||||||
|
<>
|
||||||
|
<span class="file-list-item-additions">+{item.added}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="file-split-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize file list"
|
||||||
|
onMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||||
|
onTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||||
|
/>
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-header">
|
||||||
|
<div class="file-viewer-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffViewMode() === "split" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffViewMode() === "split"}
|
||||||
|
onClick={() => setDiffViewMode("split")}
|
||||||
|
>
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffViewMode() === "unified" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffViewMode() === "unified"}
|
||||||
|
onClick={() => setDiffViewMode("unified")}
|
||||||
|
>
|
||||||
|
Unified
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffContextMode() === "collapsed" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffContextMode() === "collapsed"}
|
||||||
|
onClick={() => setDiffContextMode("collapsed")}
|
||||||
|
title="Hide unchanged regions"
|
||||||
|
>
|
||||||
|
Collapsed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${diffContextMode() === "expanded" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffContextMode() === "expanded"}
|
||||||
|
onClick={() => setDiffContextMode("expanded")}
|
||||||
|
title="Show full file"
|
||||||
|
>
|
||||||
|
Expanded
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={gitSelectedLoading()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={gitSelectedError()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
selectedEntry &&
|
||||||
|
gitSelectedBefore() !== null &&
|
||||||
|
gitSelectedAfter() !== null &&
|
||||||
|
selectedEntry.status !== "deleted"
|
||||||
|
? {
|
||||||
|
path: selectedEntry.path,
|
||||||
|
before: gitSelectedBefore() as string,
|
||||||
|
after: gitSelectedAfter() as string,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">No file selected.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(file) => (
|
||||||
|
<MonacoDiffViewer
|
||||||
|
scopeKey={scopeKey}
|
||||||
|
path={String(file().path || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={diffViewMode()}
|
||||||
|
contextMode={diffContextMode()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(err) => (
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{err()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={isPhoneLayout()}>
|
||||||
|
<Show when={gitChangesListOpen()}>
|
||||||
|
<div class="file-list-overlay" role="dialog" aria-label="Git Changes">
|
||||||
|
<div class="file-list-scroll">
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${gitSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => void openGitFile(item.path)}
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
{item.path}
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<Show when={item.status === "deleted"}>
|
||||||
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.status !== "deleted"}>
|
||||||
|
<>
|
||||||
|
<span class="file-list-item-additions">+{item.added}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderStatusSessionChanges = () => {
|
const renderStatusSessionChanges = () => {
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!sessionId || sessionId === "info") {
|
if (!sessionId || sessionId === "info") {
|
||||||
@@ -2197,32 +2767,31 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||||
<div class="right-panel-tab-bar">
|
<div class="right-panel-tab-bar">
|
||||||
<div class="tab-container">
|
<div class="tab-container">
|
||||||
|
<div class="tab-strip-shortcuts text-primary">
|
||||||
|
<Show when={rightDrawerState() === "floating-open"}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={t("instanceShell.rightDrawer.toggle.close")}
|
||||||
|
title={t("instanceShell.rightDrawer.toggle.close")}
|
||||||
|
onClick={closeRightDrawer}
|
||||||
|
>
|
||||||
|
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
<Show when={!isPhoneLayout()}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
|
||||||
|
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
||||||
|
>
|
||||||
|
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<div class="tab-scroll">
|
<div class="tab-scroll">
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
<div class="tab-strip-shortcuts text-primary">
|
|
||||||
<Show when={rightDrawerState() === "floating-open"}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="inherit"
|
|
||||||
aria-label={t("instanceShell.rightDrawer.toggle.close")}
|
|
||||||
title={t("instanceShell.rightDrawer.toggle.close")}
|
|
||||||
onClick={closeRightDrawer}
|
|
||||||
>
|
|
||||||
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
<Show when={!isPhoneLayout()}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="inherit"
|
|
||||||
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
|
|
||||||
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
|
||||||
>
|
|
||||||
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-strip-tabs" role="tablist" aria-label={t("instanceShell.rightPanel.tabs.ariaLabel")}>
|
<div class="tab-strip-tabs" role="tablist" aria-label={t("instanceShell.rightPanel.tabs.ariaLabel")}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2233,6 +2802,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<span class="tab-label">{t("instanceShell.rightPanel.tabs.changes")}</span>
|
<span class="tab-label">{t("instanceShell.rightPanel.tabs.changes")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("git-changes")}
|
||||||
|
aria-selected={rightPanelTab() === "git-changes"}
|
||||||
|
onClick={() => setRightPanelTab("git-changes")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -2261,6 +2839,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<Show when={rightPanelTab() === "changes"}>{renderFilesTabContent()}</Show>
|
<Show when={rightPanelTab() === "changes"}>{renderFilesTabContent()}</Show>
|
||||||
|
<Show when={rightPanelTab() === "git-changes"}>{renderGitChangesTabContent()}</Show>
|
||||||
<Show when={rightPanelTab() === "files"}>{renderBrowserTabContent()}</Show>
|
<Show when={rightPanelTab() === "files"}>{renderBrowserTabContent()}</Show>
|
||||||
<Show when={rightPanelTab() === "status"}>{renderStatusTabContent()}</Show>
|
<Show when={rightPanelTab() === "status"}>{renderStatusTabContent()}</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,10 +86,12 @@ export const instanceMessages = {
|
|||||||
"instanceShell.empty.description": "Select a session to view messages",
|
"instanceShell.empty.description": "Select a session to view messages",
|
||||||
|
|
||||||
"instanceShell.rightPanel.title": "Status Panel",
|
"instanceShell.rightPanel.title": "Status Panel",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Changes",
|
"instanceShell.rightPanel.tabs.changes": "Session Changes",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Git Changes",
|
||||||
"instanceShell.rightPanel.tabs.files": "Files",
|
"instanceShell.rightPanel.tabs.files": "Files",
|
||||||
"instanceShell.rightPanel.tabs.status": "Status",
|
"instanceShell.rightPanel.tabs.status": "Status",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
|
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
|
||||||
|
|||||||
86
packages/ui/src/lib/unified-diff-reverse.ts
Normal file
86
packages/ui/src/lib/unified-diff-reverse.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { applyPatch, parsePatch } from "diff"
|
||||||
|
|
||||||
|
type ParsedPatchIndex = ReturnType<typeof parsePatch>[number]
|
||||||
|
type ParsedHunk = ParsedPatchIndex["hunks"][number]
|
||||||
|
|
||||||
|
type SdkPatch = {
|
||||||
|
oldFileName: string
|
||||||
|
newFileName: string
|
||||||
|
oldHeader?: string
|
||||||
|
newHeader?: string
|
||||||
|
hunks: Array<{
|
||||||
|
oldStart: number
|
||||||
|
oldLines: number
|
||||||
|
newStart: number
|
||||||
|
newLines: number
|
||||||
|
lines: Array<string>
|
||||||
|
}>
|
||||||
|
index?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function invertPatchLine(line: string): string {
|
||||||
|
if (!line) return line
|
||||||
|
const op = line[0]
|
||||||
|
if (op === "+") return `-${line.slice(1)}`
|
||||||
|
if (op === "-") return `+${line.slice(1)}`
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseParsedHunk(hunk: ParsedHunk): ParsedHunk {
|
||||||
|
return {
|
||||||
|
oldStart: hunk.newStart,
|
||||||
|
oldLines: hunk.newLines,
|
||||||
|
newStart: hunk.oldStart,
|
||||||
|
newLines: hunk.oldLines,
|
||||||
|
lines: hunk.lines.map(invertPatchLine),
|
||||||
|
linedelimiters: Array.isArray((hunk as any).linedelimiters) ? (hunk as any).linedelimiters : [],
|
||||||
|
} as ParsedHunk
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseParsedIndex(index: ParsedPatchIndex): ParsedPatchIndex {
|
||||||
|
const hunks = Array.isArray(index.hunks) ? index.hunks : []
|
||||||
|
return {
|
||||||
|
...index,
|
||||||
|
oldFileName: (index as any).newFileName,
|
||||||
|
newFileName: (index as any).oldFileName,
|
||||||
|
oldHeader: (index as any).newHeader,
|
||||||
|
newHeader: (index as any).oldHeader,
|
||||||
|
hunks: hunks.map(reverseParsedHunk),
|
||||||
|
} as ParsedPatchIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnifiedDiffFromSdkPatch(patch: SdkPatch): string {
|
||||||
|
const oldName = patch.oldFileName || "a/file"
|
||||||
|
const newName = patch.newFileName || "b/file"
|
||||||
|
const oldHeader = patch.oldHeader ? `\t${patch.oldHeader}` : ""
|
||||||
|
const newHeader = patch.newHeader ? `\t${patch.newHeader}` : ""
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
if (patch.index) {
|
||||||
|
// jsdiff can parse arbitrary metadata lines before file headers.
|
||||||
|
lines.push(`Index: ${patch.index}`)
|
||||||
|
}
|
||||||
|
lines.push(`--- ${oldName}${oldHeader}`)
|
||||||
|
lines.push(`+++ ${newName}${newHeader}`)
|
||||||
|
for (const hunk of patch.hunks || []) {
|
||||||
|
const oldRange = hunk.oldLines === 1 ? `${hunk.oldStart}` : `${hunk.oldStart},${hunk.oldLines}`
|
||||||
|
const newRange = hunk.newLines === 1 ? `${hunk.newStart}` : `${hunk.newStart},${hunk.newLines}`
|
||||||
|
lines.push(`@@ -${oldRange} +${newRange} @@`)
|
||||||
|
for (const line of hunk.lines || []) {
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${lines.join("\n")}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryReverseApplyUnifiedDiff(afterText: string, diffText: string): string | null {
|
||||||
|
const normalized = (diffText ?? "").trim()
|
||||||
|
if (!normalized) return null
|
||||||
|
|
||||||
|
const parsed = parsePatch(diffText)
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) return null
|
||||||
|
|
||||||
|
const reversed = reverseParsedIndex(parsed[0])
|
||||||
|
const result = applyPatch(afterText ?? "", reversed)
|
||||||
|
return typeof result === "string" ? result : null
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@
|
|||||||
@apply flex items-center justify-between gap-1 px-2 pt-2 pb-0;
|
@apply flex items-center justify-between gap-1 px-2 pt-2 pb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep the shortcuts (close/pin) fixed; only the tabs should scroll. */
|
||||||
|
.right-panel-tab-bar .tab-scroll {
|
||||||
|
@apply flex-1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shortcuts on the left side - match left drawer icon colors */
|
/* Shortcuts on the left side - match left drawer icon colors */
|
||||||
.tab-strip-shortcuts {
|
.tab-strip-shortcuts {
|
||||||
@apply flex items-center gap-1 flex-shrink-0;
|
@apply flex items-center gap-1 flex-shrink-0;
|
||||||
@@ -114,6 +120,22 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-header-icon-button {
|
||||||
|
@apply inline-flex items-center justify-center shrink-0 w-7 h-7 border border-base transition-colors;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-header-icon-button:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-header-icon-button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.files-tab-body {
|
.files-tab-body {
|
||||||
@apply flex flex-col flex-1 min-h-0;
|
@apply flex flex-col flex-1 min-h-0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
5
packages/ui/src/types/diff.d.ts
vendored
Normal file
5
packages/ui/src/types/diff.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare module "diff" {
|
||||||
|
// Minimal types for the jsdiff package used in git-change reconstruction.
|
||||||
|
export function parsePatch(input: string, options?: any): any[]
|
||||||
|
export function applyPatch(source: string, patch: any, options?: any): string | false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user