Files
CodeNomad/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx
Shantur Rathore d34e0163e3 fix(ui): keep right panel layout in empty states
Render SplitFilePanel consistently and move empty/loading messages into the viewer area so the right drawer keeps its standard layout even when there are no session diffs, no git changes, or files are still loading.
2026-02-11 10:51:27 +00:00

223 lines
8.1 KiB
TypeScript

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()
const hasSession = Boolean(sessionId && sessionId !== "info")
const diffs = hasSession ? props.activeSessionDiffs() : null
const sorted = Array.isArray(diffs) ? [...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.length
? 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])
: null
// 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}:${hasSession ? sessionId : "no-session"}`
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 emptyViewerMessage = () => {
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
}
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 && hasSession && Array.isArray(diffs) && diffs.length > 0}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</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 renderEmptyList = () => (
<div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
)
const renderListPanel = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
<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>
</Show>
)
const renderListOverlay = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
<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>
</Show>
)
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
return (
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={headerPath()}>
{headerPath()}
</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