refactor(ui): modularize instance shell
Split InstanceShell2 into focused shell modules (drawer chrome/resize, session context/cache, sidebar, right panel tabs/components) to improve maintainability while preserving behavior.
This commit is contained in:
@@ -0,0 +1,829 @@
|
||||
import {
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
type Component,
|
||||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||
|
||||
import type { Instance } from "../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||
import type { Session } from "../../../../types/session"
|
||||
import type { DrawerViewState } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
|
||||
|
||||
import ChangesTab from "./tabs/ChangesTab"
|
||||
import FilesTab from "./tabs/FilesTab"
|
||||
import GitChangesTab from "./tabs/GitChangesTab"
|
||||
import StatusTab from "./tabs/StatusTab"
|
||||
|
||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import {
|
||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||
readStoredBool,
|
||||
readStoredEnum,
|
||||
readStoredPanelWidth,
|
||||
readStoredRightPanelTab,
|
||||
} from "../storage"
|
||||
|
||||
interface RightPanelProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
instanceId: string
|
||||
instance: Instance
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
activeSession: Accessor<Session | null>
|
||||
activeSessionDiffs: Accessor<any[] | undefined>
|
||||
|
||||
latestTodoState: Accessor<ToolState | null>
|
||||
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
rightDrawerWidth: Accessor<number>
|
||||
rightDrawerWidthInitialized: Accessor<boolean>
|
||||
rightDrawerState: Accessor<DrawerViewState>
|
||||
rightPinned: Accessor<boolean>
|
||||
onCloseRightDrawer: () => void
|
||||
onPinRightDrawer: () => void
|
||||
onUnpinRightDrawer: () => void
|
||||
|
||||
setContentEl: (el: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||
"plan",
|
||||
"background-processes",
|
||||
"mcp",
|
||||
"lsp",
|
||||
"plugins",
|
||||
])
|
||||
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
|
||||
|
||||
const [browserPath, setBrowserPath] = createSignal(".")
|
||||
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
|
||||
const [browserLoading, setBrowserLoading] = createSignal(false)
|
||||
const [browserError, setBrowserError] = createSignal<string | null>(null)
|
||||
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
|
||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||
|
||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||
)
|
||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
||||
)
|
||||
|
||||
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||
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)
|
||||
|
||||
const [filesListOpen, setFilesListOpen] = createSignal(true)
|
||||
const [filesListTouched, setFilesListTouched] = createSignal(false)
|
||||
const [changesListOpen, setChangesListOpen] = createSignal(true)
|
||||
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||
|
||||
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
||||
|
||||
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" | "git-changes" | "files", value: boolean) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||
const layout = listLayoutKey()
|
||||
layout
|
||||
|
||||
const filesPersisted = readStoredBool(listOpenStorageKey("files"))
|
||||
if (filesPersisted !== null) {
|
||||
setFilesListOpen(filesPersisted)
|
||||
setFilesListTouched(true)
|
||||
} else {
|
||||
setFilesListOpen(true)
|
||||
setFilesListTouched(false)
|
||||
}
|
||||
|
||||
const changesPersisted = readStoredBool(listOpenStorageKey("changes"))
|
||||
if (changesPersisted !== null) {
|
||||
setChangesListOpen(changesPersisted)
|
||||
setChangesListTouched(true)
|
||||
} else {
|
||||
setChangesListOpen(true)
|
||||
setChangesListTouched(false)
|
||||
}
|
||||
|
||||
const gitPersisted = readStoredBool(listOpenStorageKey("git-changes"))
|
||||
if (gitPersisted !== null) {
|
||||
setGitChangesListOpen(gitPersisted)
|
||||
setGitChangesListTouched(true)
|
||||
} else {
|
||||
setGitChangesListOpen(true)
|
||||
setGitChangesListTouched(false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Default behavior: when nothing is selected, keep the file list open.
|
||||
// Once the user explicitly toggles it, we stop auto-opening.
|
||||
if (rightPanelTab() !== "files") return
|
||||
if (filesListTouched()) return
|
||||
if (!browserSelectedPath()) {
|
||||
setFilesListOpen(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
||||
})
|
||||
|
||||
const clampSplitWidth = (value: number) => {
|
||||
const min = 200
|
||||
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||
const max = Math.min(560, maxByDrawer)
|
||||
return Math.min(max, Math.max(min, Math.floor(value)))
|
||||
}
|
||||
|
||||
const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (splitWidthsInitialized()) return
|
||||
if (!props.rightDrawerWidthInitialized()) return
|
||||
setSplitWidthsInitialized(true)
|
||||
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 persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => {
|
||||
if (typeof window === "undefined") return
|
||||
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))
|
||||
}
|
||||
|
||||
function stopSplitResize() {
|
||||
setActiveSplitResize(null)
|
||||
if (typeof document === "undefined") return
|
||||
splitPointerDrag.stop()
|
||||
}
|
||||
|
||||
function splitMouseMove(event: MouseEvent) {
|
||||
const mode = activeSplitResize()
|
||||
if (!mode) return
|
||||
event.preventDefault()
|
||||
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() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||
persistSplitWidth(mode, width)
|
||||
}
|
||||
stopSplitResize()
|
||||
}
|
||||
|
||||
function splitTouchMove(event: TouchEvent) {
|
||||
const mode = activeSplitResize()
|
||||
if (!mode) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
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() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||
persistSplitWidth(mode, width)
|
||||
}
|
||||
stopSplitResize()
|
||||
}
|
||||
|
||||
const splitPointerDrag = useGlobalPointerDrag({
|
||||
onMouseMove: splitMouseMove,
|
||||
onMouseUp: splitMouseUp,
|
||||
onTouchMove: splitTouchMove,
|
||||
onTouchEnd: splitTouchEnd,
|
||||
})
|
||||
|
||||
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
|
||||
if (typeof document === "undefined") return
|
||||
setActiveSplitResize(mode)
|
||||
setSplitResizeStartX(clientX)
|
||||
setSplitResizeStartWidth(
|
||||
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
|
||||
)
|
||||
splitPointerDrag.start()
|
||||
}
|
||||
|
||||
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
startSplitResize(mode, event.clientX)
|
||||
}
|
||||
|
||||
const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
startSplitResize(mode, touch.clientX)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stopSplitResize()
|
||||
})
|
||||
|
||||
const worktreeSlugForViewer = createMemo(() => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (sessionId && sessionId !== "info") {
|
||||
return getWorktreeSlugForSession(props.instanceId, sessionId)
|
||||
}
|
||||
return getDefaultWorktreeSlug(props.instanceId)
|
||||
})
|
||||
|
||||
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, 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(() => {
|
||||
// Reset tab state when worktree context changes.
|
||||
worktreeSlugForViewer()
|
||||
setBrowserPath(".")
|
||||
setBrowserEntries(null)
|
||||
setBrowserError(null)
|
||||
setBrowserSelectedPath(null)
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
|
||||
setGitStatusEntries(null)
|
||||
setGitStatusError(null)
|
||||
setGitStatusLoading(false)
|
||||
setGitSelectedPath(null)
|
||||
setGitSelectedLoading(false)
|
||||
setGitSelectedError(null)
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
})
|
||||
|
||||
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 (props.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 diffs = props.activeSessionDiffs()
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||
const best = diffs.reduce((currentBest, item) => {
|
||||
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
|
||||
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
|
||||
const bestScore = bestAdd + bestDel
|
||||
|
||||
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||
const score = add + del
|
||||
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return currentBest
|
||||
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
|
||||
}, diffs[0])
|
||||
return typeof (best as any)?.file === "string" ? (best as any).file : null
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const next = bestDiffFile()
|
||||
if (!next) return
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return
|
||||
|
||||
const current = selectedFile()
|
||||
if (current && diffs.some((d) => d.file === current)) return
|
||||
setSelectedFile(next)
|
||||
})
|
||||
|
||||
const normalizeBrowserPath = (input: string) => {
|
||||
const raw = String(input || ".").trim()
|
||||
if (!raw || raw === "./") return "."
|
||||
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
return cleaned === "" ? "." : cleaned
|
||||
}
|
||||
|
||||
const getParentPath = (path: string): string | null => {
|
||||
const current = normalizeBrowserPath(path)
|
||||
if (current === ".") return null
|
||||
const parts = current.split("/").filter(Boolean)
|
||||
parts.pop()
|
||||
return parts.length ? parts.join("/") : "."
|
||||
}
|
||||
|
||||
const loadBrowserEntries = async (path: string) => {
|
||||
const normalized = normalizeBrowserPath(path)
|
||||
setBrowserLoading(true)
|
||||
setBrowserError(null)
|
||||
try {
|
||||
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
|
||||
setBrowserPath(normalized)
|
||||
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
|
||||
} catch (error) {
|
||||
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
|
||||
setBrowserEntries([])
|
||||
} finally {
|
||||
setBrowserLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openBrowserFile = async (path: string) => {
|
||||
setBrowserSelectedPath(path)
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedContent(null)
|
||||
|
||||
// Phone: treat file selection as a commit action and close the overlay.
|
||||
if (props.isPhoneLayout()) {
|
||||
setFilesListOpen(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 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)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "files") return
|
||||
if (browserLoading()) return
|
||||
if (browserEntries() !== null) return
|
||||
void loadBrowserEntries(browserPath())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "git-changes") return
|
||||
if (gitStatusLoading()) return
|
||||
if (gitStatusEntries() !== null) return
|
||||
void loadGitStatus()
|
||||
})
|
||||
|
||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||
setSelectedFile(file)
|
||||
if (closeList) {
|
||||
setChangesListOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChangesList = () => {
|
||||
setChangesListTouched(true)
|
||||
setChangesListOpen((current) => {
|
||||
const next = !current
|
||||
persistListOpen("changes", next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleFilesList = () => {
|
||||
setFilesListTouched(true)
|
||||
setFilesListOpen((current) => {
|
||||
const next = !current
|
||||
persistListOpen("files", next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleGitList = () => {
|
||||
setGitChangesListTouched(true)
|
||||
setGitChangesListOpen((current) => {
|
||||
const next = !current
|
||||
persistListOpen("git-changes", next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const browserParentPath = createMemo(() => getParentPath(browserPath()))
|
||||
const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`)
|
||||
const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`)
|
||||
|
||||
const openChangesTabFromStatus = (file?: string) => {
|
||||
if (file) {
|
||||
setSelectedFile(file)
|
||||
}
|
||||
setRightPanelTab("changes")
|
||||
}
|
||||
|
||||
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
|
||||
createEffect(() => {
|
||||
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||
if (statusSectionIds.every((id) => currentExpanded.has(id))) return
|
||||
setRightPanelExpandedItems(statusSectionIds)
|
||||
})
|
||||
|
||||
const handleAccordionChange = (values: string[]) => {
|
||||
setRightPanelExpandedItems(values)
|
||||
}
|
||||
|
||||
const tabClass = (tab: RightPanelTab) =>
|
||||
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full" ref={props.setContentEl}>
|
||||
<div class="right-panel-tab-bar">
|
||||
<div class="tab-container">
|
||||
<div class="tab-strip-shortcuts text-primary">
|
||||
<Show when={props.rightDrawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||
onClick={props.onCloseRightDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
|
||||
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
|
||||
>
|
||||
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("changes")}
|
||||
aria-selected={rightPanelTab() === "changes"}
|
||||
onClick={() => setRightPanelTab("changes")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("git-changes")}
|
||||
aria-selected={rightPanelTab() === "git-changes"}
|
||||
onClick={() => setRightPanelTab("git-changes")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("files")}
|
||||
aria-selected={rightPanelTab() === "files"}
|
||||
onClick={() => setRightPanelTab("files")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("status")}
|
||||
aria-selected={rightPanelTab() === "status"}
|
||||
onClick={() => setRightPanelTab("status")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip-spacer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show when={rightPanelTab() === "changes"}>
|
||||
<ChangesTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "git-changes"}>
|
||||
<GitChangesTab
|
||||
t={props.t}
|
||||
activeSessionId={props.activeSessionId}
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onOpenFile={(path) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
onToggleList={toggleGitList}
|
||||
splitWidth={gitChangesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "files"}>
|
||||
<FilesTab
|
||||
t={props.t}
|
||||
browserPath={browserPath}
|
||||
browserEntries={browserEntries}
|
||||
browserLoading={browserLoading}
|
||||
browserError={browserError}
|
||||
browserSelectedPath={browserSelectedPath}
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||
onOpenFile={(path) => void openBrowserFile(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "status"}>
|
||||
<StatusTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
instance={props.instance}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSession={props.activeSession}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
latestTodoState={props.latestTodoState}
|
||||
backgroundProcessList={props.backgroundProcessList}
|
||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||
expandedItems={rightPanelExpandedItems}
|
||||
onExpandedItemsChange={handleAccordionChange}
|
||||
onOpenChangesTab={openChangesTabFromStatus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RightPanel
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface DiffToolbarProps {
|
||||
viewMode: DiffViewMode
|
||||
contextMode: DiffContextMode
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
}
|
||||
|
||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||
return (
|
||||
<div class="file-viewer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "split"}
|
||||
onClick={() => props.onViewModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "unified"}
|
||||
onClick={() => props.onViewModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
||||
aria-pressed={props.contextMode === "collapsed"}
|
||||
onClick={() => props.onContextModeChange("collapsed")}
|
||||
title="Hide unchanged regions"
|
||||
>
|
||||
Collapsed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
|
||||
aria-pressed={props.contextMode === "expanded"}
|
||||
onClick={() => props.onContextModeChange("expanded")}
|
||||
title="Show full file"
|
||||
>
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiffToolbar
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
|
||||
interface OverlayListProps {
|
||||
ariaLabel: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const OverlayList: Component<OverlayListProps> = (props) => {
|
||||
return (
|
||||
<div class="file-list-overlay" role="dialog" aria-label={props.ariaLabel}>
|
||||
<div class="file-list-scroll">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OverlayList
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
|
||||
import OverlayList from "./OverlayList"
|
||||
|
||||
type SplitFilePanelList = {
|
||||
panel: () => JSX.Element
|
||||
overlay: () => JSX.Element
|
||||
}
|
||||
|
||||
interface SplitFilePanelProps {
|
||||
header: JSX.Element
|
||||
list: SplitFilePanelList
|
||||
viewer: JSX.Element
|
||||
|
||||
listOpen: boolean
|
||||
onToggleList: () => void
|
||||
|
||||
splitWidth: number
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
|
||||
isPhoneLayout: boolean
|
||||
overlayAriaLabel: string
|
||||
}
|
||||
|
||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||
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={props.onToggleList}>
|
||||
{props.listOpen ? "Hide files" : "Show files"}
|
||||
</button>
|
||||
|
||||
{props.header}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files-tab-body">
|
||||
<Show
|
||||
when={!props.isPhoneLayout && props.listOpen}
|
||||
fallback={props.viewer}
|
||||
>
|
||||
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
|
||||
<div class="file-list-panel">
|
||||
<div class="file-list-scroll">{props.list.panel()}</div>
|
||||
</div>
|
||||
<div
|
||||
class="file-split-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize file list"
|
||||
onMouseDown={props.onResizeMouseDown}
|
||||
onTouchStart={props.onResizeTouchStart}
|
||||
/>
|
||||
{props.viewer}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.isPhoneLayout}>
|
||||
<Show when={props.listOpen}>
|
||||
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SplitFilePanel
|
||||
@@ -0,0 +1,224 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface ChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
instanceId: string
|
||||
activeSessionId: Accessor<string | null>
|
||||
activeSessionDiffs: Accessor<any[] | undefined>
|
||||
|
||||
selectedFile: Accessor<string | null>
|
||||
onSelectFile: (file: string, closeList: boolean) => void
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
return (
|
||||
<div class="right-panel-empty">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (diffs === undefined) {
|
||||
return (
|
||||
<div class="right-panel-empty">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) {
|
||||
return (
|
||||
<div class="right-panel-empty">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||
const totals = sorted.reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||
return acc
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
const mostChanged = sorted.reduce((best, item) => {
|
||||
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
||||
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
||||
const bestScore = bestAdd + bestDel
|
||||
|
||||
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||
const score = add + del
|
||||
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return best
|
||||
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||
}, sorted[0])
|
||||
|
||||
// Auto-select the most-changed file if none selected.
|
||||
const currentSelected = props.selectedFile()
|
||||
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
|
||||
|
||||
const scopeKey = `${props.instanceId}:${sessionId}`
|
||||
|
||||
const isBinaryDiff = (item: any) => {
|
||||
const before = typeof item?.before === "string" ? item.before : ""
|
||||
const after = typeof item?.after === "string" ? item.after : ""
|
||||
if (before.length === 0 && after.length === 0) {
|
||||
// OpenCode stores empty before/after for binaries.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={selectedFileData}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceShell.filesShell.viewerEmpty")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<Show
|
||||
when={!isBinaryDiff(file())}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Binary file cannot be displayed</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderListPanel = () => (
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||
}}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.file}>
|
||||
{item.file}
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<span class="file-list-item-additions">+{item.additions}</span>
|
||||
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onSelectFile(item.file, true)
|
||||
}}
|
||||
title={item.file}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.file}>
|
||||
{item.file}
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<span class="file-list-item-additions">+{item.additions}</span>
|
||||
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selectedFileData?.file || ""}>
|
||||
{selectedFileData?.file || ""}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Changes"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default ChangesTab
|
||||
@@ -0,0 +1,189 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
interface FilesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
browserPath: Accessor<string>
|
||||
browserEntries: Accessor<FileNode[] | null>
|
||||
browserLoading: Accessor<boolean>
|
||||
browserError: Accessor<string | null>
|
||||
|
||||
browserSelectedPath: Accessor<string | null>
|
||||
browserSelectedContent: Accessor<string | null>
|
||||
browserSelectedLoading: Accessor<boolean>
|
||||
browserSelectedError: Accessor<string | null>
|
||||
|
||||
parentPath: Accessor<string | null>
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
onLoadEntries: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (props.browserLoading() && props.browserEntries() === null) {
|
||||
return (
|
||||
<div class="right-panel-empty">
|
||||
<span class="text-xs">Loading files...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const entries = props.browserEntries() || []
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
const aDir = a.type === "directory" ? 0 : 1
|
||||
const bDir = b.type === "directory" ? 0 : 1
|
||||
if (aDir !== bDir) return aDir - bDir
|
||||
return String(a.name || "").localeCompare(String(b.name || ""))
|
||||
})
|
||||
|
||||
const parent = props.parentPath()
|
||||
|
||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={props.browserSelectedLoading()}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.browserSelectedError()}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
props.browserSelectedPath() && props.browserSelectedContent() !== null
|
||||
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
|
||||
: null
|
||||
}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Select a file to preview</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
const renderList = () => (
|
||||
<>
|
||||
<Show when={parent}>
|
||||
{(p) => (
|
||||
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={p()}>
|
||||
..
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
if (item.type === "directory") {
|
||||
props.onLoadEntries(item.path)
|
||||
return
|
||||
}
|
||||
props.onOpenFile(item.path)
|
||||
}}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<span class="text-[10px] text-secondary">{item.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<div class="files-tab-stats">
|
||||
<span class="files-tab-stat">
|
||||
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
|
||||
{headerDisplayedPath()}
|
||||
</span>
|
||||
</span>
|
||||
<Show when={props.browserLoading()}>
|
||||
<span>Loading…</span>
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
style={{ "margin-left": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderList, overlay: renderList }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Files"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default FilesTab
|
||||
@@ -0,0 +1,262 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
|
||||
entries: Accessor<GitFileStatus[] | null>
|
||||
statusLoading: Accessor<boolean>
|
||||
statusError: Accessor<string | null>
|
||||
|
||||
selectedPath: Accessor<string | null>
|
||||
selectedLoading: Accessor<boolean>
|
||||
selectedError: Accessor<string | null>
|
||||
selectedBefore: Accessor<string | null>
|
||||
selectedAfter: Accessor<string | null>
|
||||
mostChangedPath: Accessor<string | null>
|
||||
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
|
||||
onOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
return (
|
||||
<div class="right-panel-empty">
|
||||
<span class="text-xs">Select a session to view changes.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const entries = props.entries()
|
||||
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 = props.selectedPath()
|
||||
const fallbackPath = props.mostChangedPath()
|
||||
const selectedEntry =
|
||||
sorted.find((item) => item.path === selectedPath) ||
|
||||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={props.selectedLoading()}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.selectedError()}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
selectedEntry &&
|
||||
props.selectedBefore() !== null &&
|
||||
props.selectedAfter() !== null &&
|
||||
selectedEntry.status !== "deleted"
|
||||
? {
|
||||
path: selectedEntry.path,
|
||||
before: props.selectedBefore() as string,
|
||||
after: props.selectedAfter() as string,
|
||||
}
|
||||
: null
|
||||
}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">No file selected.</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={String(file().path || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.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>
|
||||
)
|
||||
|
||||
const renderListPanel = () => (
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onOpenFile(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>
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => props.onOpenFile(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>
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<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={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.statusLoading()}
|
||||
style={{ "margin-left": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Git Changes"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default GitChangesTab
|
||||
@@ -0,0 +1,294 @@
|
||||
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
|
||||
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||
import type { Session } from "../../../../../types/session"
|
||||
|
||||
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||
|
||||
interface StatusTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
instanceId: string
|
||||
instance: Instance
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
activeSession: Accessor<Session | null>
|
||||
activeSessionDiffs: Accessor<any[] | undefined>
|
||||
|
||||
latestTodoState: Accessor<ToolState | null>
|
||||
|
||||
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
|
||||
expandedItems: Accessor<string[]>
|
||||
onExpandedItemsChange: (values: string[]) => void
|
||||
|
||||
onOpenChangesTab: (file?: string) => void
|
||||
}
|
||||
|
||||
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||
|
||||
const renderStatusSessionChanges = () => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (diffs === undefined) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||
const totals = sorted.reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||
return acc
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3 min-h-0">
|
||||
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
|
||||
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
|
||||
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
|
||||
<div class="flex flex-col">
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
|
||||
onClick={() => props.onOpenChangesTab(item.file)}
|
||||
title={props.t("instanceShell.sessionChanges.actions.show")}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div
|
||||
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
|
||||
title={item.file}
|
||||
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
|
||||
>
|
||||
{item.file}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
|
||||
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
|
||||
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPlanSectionContent = () => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const todoState = props.latestTodoState()
|
||||
if (!todoState) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
|
||||
}
|
||||
|
||||
const renderBackgroundProcesses = () => {
|
||||
const processes = props.backgroundProcessList()
|
||||
if (processes.length === 0) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={processes}>
|
||||
{(process) => (
|
||||
<div class="status-process-card">
|
||||
<div class="status-process-header">
|
||||
<span class="status-process-title">{process.title}</span>
|
||||
<div class="status-process-meta">
|
||||
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||
<span>
|
||||
{props.t("instanceShell.backgroundProcesses.output", {
|
||||
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
|
||||
})}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-process-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenBackgroundOutput(process)}
|
||||
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||
title={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||
>
|
||||
<TerminalSquare class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
disabled={process.status !== "running"}
|
||||
onClick={() => props.onStopBackgroundProcess(process.id)}
|
||||
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||
>
|
||||
<XOctagon class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => props.onTerminateBackgroundProcess(process.id)}
|
||||
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusSections = [
|
||||
{
|
||||
id: "session-changes",
|
||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||
render: renderStatusSessionChanges,
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||
render: renderPlanSectionContent,
|
||||
},
|
||||
{
|
||||
id: "background-processes",
|
||||
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||
render: renderBackgroundProcesses,
|
||||
},
|
||||
{
|
||||
id: "mcp",
|
||||
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
sections={["mcp"]}
|
||||
showSectionHeadings={false}
|
||||
class="space-y-2"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lsp",
|
||||
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
sections={["lsp"]}
|
||||
showSectionHeadings={false}
|
||||
class="space-y-2"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
sections={["plugins"]}
|
||||
showSectionHeadings={false}
|
||||
class="space-y-2"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div class="status-tab-container">
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Accordion.Root
|
||||
class="right-panel-accordion"
|
||||
collapsible
|
||||
multiple
|
||||
value={props.expandedItems()}
|
||||
onChange={props.onExpandedItemsChange}
|
||||
>
|
||||
<For each={statusSections}>
|
||||
{(section) => (
|
||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span>{props.t(section.labelKey)}</span>
|
||||
<ChevronDown
|
||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusTab
|
||||
@@ -0,0 +1,5 @@
|
||||
export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
||||
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
|
||||
export type DiffContextMode = "expanded" | "collapsed"
|
||||
Reference in New Issue
Block a user