perf(ui): memoize changes lists and reduce stream rendering
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -32,14 +32,18 @@ interface ChangesTabProps {
|
||||
}
|
||||
|
||||
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
const sessionId = createMemo(() => props.activeSessionId())
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
|
||||
|
||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||
const diffs = hasSession ? props.activeSessionDiffs() : null
|
||||
const sorted = createMemo<any[]>(() => {
|
||||
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 = sorted.reduce(
|
||||
const totals = createMemo(() => {
|
||||
return sorted().reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||
@@ -47,41 +51,61 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
},
|
||||
{ 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 mostChanged = createMemo<any | null>(() => {
|
||||
const items = sorted()
|
||||
if (items.length === 0) return null
|
||||
return items.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
|
||||
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
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return best
|
||||
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||
}, items[0])
|
||||
})
|
||||
|
||||
// Auto-select the most-changed file if none selected.
|
||||
const selectedFileData = createMemo<any | null>(() => {
|
||||
const currentSelected = props.selectedFile()
|
||||
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
|
||||
|
||||
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
|
||||
|
||||
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 items = sorted()
|
||||
if (currentSelected) {
|
||||
const match = items.find((f) => f.file === currentSelected)
|
||||
if (match) return match
|
||||
}
|
||||
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 = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
||||
when={selected && hasSession() && sortedList.length > 0 ? selected : null}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||
@@ -90,7 +114,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey}
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
@@ -109,11 +133,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<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={() => {
|
||||
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||
}}
|
||||
@@ -134,11 +158,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<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={() => {
|
||||
props.onSelectFile(item.file, true)
|
||||
}}
|
||||
@@ -159,8 +183,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
@@ -171,10 +193,10 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
|
||||
<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 class="files-tab-stat-value">+{totalsValue.additions}</span>
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 { RefreshCw } from "lucide-solid"
|
||||
@@ -46,17 +46,18 @@ interface GitChangesTabProps {
|
||||
}
|
||||
|
||||
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
const sessionId = createMemo(() => props.activeSessionId())
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||
|
||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||
const entries = hasSession ? props.entries() : null
|
||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
||||
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)
|
||||
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||
: []
|
||||
|
||||
const totals = sorted.reduce(
|
||||
const totals = createMemo(() => {
|
||||
return sorted().reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||
@@ -64,21 +65,33 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
})
|
||||
|
||||
const nonDeleted = 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 nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
|
||||
|
||||
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
||||
const list = sorted()
|
||||
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 found =
|
||||
list.find((item) => item.path === selectedPath) ||
|
||||
(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 = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
@@ -91,12 +104,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
selectedEntry &&
|
||||
selected &&
|
||||
props.selectedBefore() !== null &&
|
||||
props.selectedAfter() !== null &&
|
||||
selectedEntry.status !== "deleted"
|
||||
selected.status !== "deleted"
|
||||
? {
|
||||
path: selectedEntry.path,
|
||||
path: selected.path,
|
||||
before: props.selectedBefore() as string,
|
||||
after: props.selectedAfter() as string,
|
||||
}
|
||||
@@ -109,16 +122,16 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(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()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
)}
|
||||
<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()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -141,8 +154,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
@@ -173,8 +186,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
@@ -204,19 +217,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
|
||||
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
|
||||
</span>
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
|
||||
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
|
||||
</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 class="files-tab-stat-value">+{totalsValue.additions}</span>
|
||||
</span>
|
||||
<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>
|
||||
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
@@ -226,23 +239,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
class="files-header-icon-button"
|
||||
title={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" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrapMode={props.diffWordWrapMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrapMode={props.diffWordWrapMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function getMessageAnchorId(messageId: string) {
|
||||
return `message-anchor-${messageId}`
|
||||
}
|
||||
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 300
|
||||
|
||||
interface MessageBlockListProps {
|
||||
instanceId: string
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user