perf(ui): memoize changes lists and reduce stream rendering

This commit is contained in:
Shantur Rathore
2026-02-28 10:31:32 +00:00
parent 482313f662
commit c51e71c7a2
4 changed files with 141 additions and 100 deletions

View File

@@ -1,4 +1,4 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
@@ -32,14 +32,18 @@ interface ChangesTabProps {
} }
const ChangesTab: Component<ChangesTabProps> = (props) => { const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => { const sessionId = createMemo(() => props.activeSessionId())
const sessionId = props.activeSessionId() const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
const hasSession = Boolean(sessionId && sessionId !== "info") const sorted = createMemo<any[]>(() => {
const diffs = hasSession ? props.activeSessionDiffs() : null const list = diffs()
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
})
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : [] const totals = createMemo(() => {
const totals = sorted.reduce( return sorted().reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0 acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
@@ -47,41 +51,61 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const mostChanged = sorted.length const mostChanged = createMemo<any | null>(() => {
? sorted.reduce((best, item) => { const items = sorted()
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 if (items.length === 0) return null
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 return items.reduce((best, item) => {
const bestScore = bestAdd + bestDel 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 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 del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del const score = add + del
if (score > bestScore) return item if (score > bestScore) return item
if (score < bestScore) return best if (score < bestScore) return best
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
}, sorted[0]) }, items[0])
: null })
// Auto-select the most-changed file if none selected. const selectedFileData = createMemo<any | null>(() => {
const currentSelected = props.selectedFile() const currentSelected = props.selectedFile()
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged const items = sorted()
if (currentSelected) {
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}` const match = items.find((f) => f.file === currentSelected)
if (match) return match
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")
} }
return mostChanged()
})
const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`)
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
const currentDiffs = diffs()
if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading")
if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const headerPath = createMemo(() => {
const file = selectedFileData()
return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes")
})
const renderContent = (): JSX.Element => {
const sortedList = sorted()
const totalsValue = totals()
const selected = selectedFileData()
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null} when={selected && hasSession() && sortedList.length > 0 ? selected : null}
fallback={ fallback={
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span> <span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
@@ -90,7 +114,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <MonacoDiffViewer
scopeKey={scopeKey} scopeKey={scopeKey()}
path={String(file().file || "")} path={String(file().file || "")}
before={String((file() as any).before || "")} before={String((file() as any).before || "")}
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
@@ -109,11 +133,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListPanel = () => ( const renderListPanel = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}> <Show when={sortedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, props.isPhoneLayout()) props.onSelectFile(item.file, props.isPhoneLayout())
}} }}
@@ -134,11 +158,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}> <Show when={sortedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, true) props.onSelectFile(item.file, true)
}} }}
@@ -159,8 +183,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
</Show> </Show>
) )
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
@@ -171,10 +193,10 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span> <span class="files-tab-stat-value">+{totalsValue.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span> <span class="files-tab-stat-value">-{totalsValue.deletions}</span>
</span> </span>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
@@ -46,17 +46,18 @@ interface GitChangesTabProps {
} }
const GitChangesTab: Component<GitChangesTabProps> = (props) => { const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => { const sessionId = createMemo(() => props.activeSessionId())
const sessionId = props.activeSessionId() const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
const entries = createMemo(() => (hasSession() ? props.entries() : null))
const hasSession = Boolean(sessionId && sessionId !== "info") const sorted = createMemo<GitFileStatus[]>(() => {
const entries = hasSession ? props.entries() : null const list = entries()
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
})
const sorted = Array.isArray(entries) const totals = createMemo(() => {
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) return sorted().reduce(
: []
const totals = sorted.reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0 acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0 acc.deletions += typeof item.removed === "number" ? item.removed : 0
@@ -64,21 +65,33 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const nonDeleted = sorted.filter((item) => item && item.status !== "deleted") const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
const emptyViewerMessage = () => {
if (!hasSession) return "Select a session to view changes."
if (entries === null) return "Loading git changes…"
if (nonDeleted.length === 0) return "No git changes yet."
return "No file selected."
}
const selectedEntry = createMemo<GitFileStatus | null>(() => {
const list = sorted()
const selectedPath = props.selectedPath() const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath() const fallbackPath = props.mostChangedPath()
const selectedEntry = const found =
sorted.find((item) => item.path === selectedPath) || list.find((item) => item.path === selectedPath) ||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) (fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
return found ?? null
})
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return "Select a session to view changes."
const currentEntries = entries()
if (currentEntries === null) return "Loading git changes…"
if (nonDeleted().length === 0) return "No git changes yet."
return "No file selected."
})
const renderContent = (): JSX.Element => {
const totalsValue = totals()
const selected = selectedEntry()
const sortedList = sorted()
const nonDeletedList = nonDeleted()
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
@@ -91,12 +104,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
fallback={ fallback={
<Show <Show
when={ when={
selectedEntry && selected &&
props.selectedBefore() !== null && props.selectedBefore() !== null &&
props.selectedAfter() !== null && props.selectedAfter() !== null &&
selectedEntry.status !== "deleted" selected.status !== "deleted"
? { ? {
path: selectedEntry.path, path: selected.path,
before: props.selectedBefore() as string, before: props.selectedBefore() as string,
after: props.selectedAfter() as string, after: props.selectedAfter() as string,
} }
@@ -109,16 +122,16 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <MonacoDiffViewer
scopeKey={props.scopeKey()} scopeKey={props.scopeKey()}
path={String(file().path || "")} path={String(file().path || "")}
before={String((file() as any).before || "")} before={String((file() as any).before || "")}
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()} wordWrap={props.diffWordWrapMode()}
/> />
)} )}
</Show> </Show>
} }
> >
@@ -141,8 +154,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div> const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListPanel = () => ( const renderListPanel = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}> <Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -173,8 +186,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}> <Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -204,19 +217,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
<> <>
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}> <span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span> <span class="file-path-text">{selected?.path || "Git Changes"}</span>
</span> </span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span> <span class="files-tab-stat-value">+{totalsValue.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span> <span class="files-tab-stat-value">-{totalsValue.deletions}</span>
</span> </span>
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -226,23 +239,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
class="files-header-icon-button" class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")} title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={!hasSession || props.statusLoading() || entries === null} disabled={!hasSession() || props.statusLoading() || entries() === null}
style={{ "margin-left": "auto" }} style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()} onClick={() => props.onRefresh()}
> >
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button> </button>
<DiffToolbar <DiffToolbar
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()} wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange} onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange} onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange} onWordWrapModeChange={props.onWordWrapModeChange}
/> />
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()} viewer={renderViewer()}
listOpen={props.listOpen()} listOpen={props.listOpen()}

View File

@@ -8,7 +8,7 @@ export function getMessageAnchorId(messageId: string) {
return `message-anchor-${messageId}` return `message-anchor-${messageId}`
} }
const VIRTUAL_ITEM_MARGIN_PX = 800 const VIRTUAL_ITEM_MARGIN_PX = 300
interface MessageBlockListProps { interface MessageBlockListProps {
instanceId: string instanceId: string

View File

@@ -8,6 +8,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.0625rem; gap: 0.0625rem;
/* Reduce render + paint work for offscreen (but mounted) blocks.
Keep a conservative intrinsic size to avoid scroll jumps. */
content-visibility: auto;
contain-intrinsic-size: 1px 400px;
contain: layout paint style;
} }
.virtual-item-wrapper { .virtual-item-wrapper {