mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
fix(gui-plugins): drive context export and scroll, heap capture and memory diagnostics through state machines (#62001)
This commit is contained in:
committed by
GitHub
Unverified
parent
621f0ee53f
commit
026dedb16e
@@ -52,6 +52,37 @@ story("the debug bar stays open across a remount until its close button hides it
|
||||
expect(result).toEqual({ opened: true, saved: { visible: false, motion: false }, reopened: false })
|
||||
})
|
||||
|
||||
story("running memory diagnostics pin the bar without a close button, and its Knobs still open", async ({ page }) => {
|
||||
const result = await page.evaluate(async (modules) => {
|
||||
const { mountPlugins, until } = await import(modules.fixture)
|
||||
const debug = await import(modules.debug)
|
||||
const definition = { ...debug.default, id: "debug", windowEntry: () => import(modules.renderer) }
|
||||
const memory = { state: () => ({ on: true, env: false }), set: async () => undefined, on: () => () => {} }
|
||||
|
||||
// The user hid the bar and left the Knobs open before turning diagnostics on.
|
||||
const host = mountPlugins({
|
||||
definitions: [definition],
|
||||
stored: { "plugin.debug.bar": { visible: false, motion: true } },
|
||||
ipc: (token: { readonly id: string }) => (token.id === "debug.memory" ? memory : undefined),
|
||||
})
|
||||
|
||||
host.release()
|
||||
await until(() => host.container.querySelector("aside") !== null)
|
||||
await until(() => document.querySelector('button[aria-label="Close Knobs"]') !== null)
|
||||
|
||||
const view = {
|
||||
close: host.container.querySelector('button[aria-label="Hide debug bar"]') !== null,
|
||||
knobs: document.querySelector('button[aria-label="Close Knobs"]') !== null,
|
||||
}
|
||||
|
||||
host.unmount()
|
||||
|
||||
return view
|
||||
}, modules)
|
||||
|
||||
expect(result).toEqual({ close: false, knobs: true })
|
||||
})
|
||||
|
||||
story("a feature page's Enabled switch turns that feature on after another feature's page", async ({ page }) => {
|
||||
const result = await page.evaluate(
|
||||
async (modules) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeClient } from "@opencode/client/promise"
|
||||
import { fetchSessionExport, sessionExportFilename } from "./export"
|
||||
|
||||
/* oxlint-disable anti-slop/require-safety-comment-for-type-assertion, anti-slop/no-chained-type-assertions -- partial fixtures stand in for the client methods the export calls */
|
||||
|
||||
describe("sessionExportFilename", () => {
|
||||
test("shortens long titles to fit the filename limit", () => {
|
||||
expect(sessionExportFilename({ id: "ses_123", title: "a".repeat(300) })).toBe(`${"a".repeat(250)}.json`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchSessionExport", () => {
|
||||
test("cancelling the export aborts every request it sent, on every page", async () => {
|
||||
const controller = new AbortController()
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
const api = {
|
||||
session: {
|
||||
get: async (_input: { sessionID: string }, options: { signal: AbortSignal }) => {
|
||||
signals.push(options.signal)
|
||||
|
||||
return { id: "ses_1" }
|
||||
},
|
||||
},
|
||||
message: {
|
||||
list: async (input: { cursor?: string }, options: { signal: AbortSignal }) => {
|
||||
signals.push(options.signal)
|
||||
|
||||
return input.cursor ? { data: [], cursor: {} } : { data: [], cursor: { next: "page-2" } }
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<OpenCodeClient, "session" | "message">
|
||||
|
||||
await fetchSessionExport({ sessionID: "ses_1", api, signal: controller.signal })
|
||||
|
||||
expect(signals.map((signal) => signal.aborted)).toEqual([false, false, false])
|
||||
controller.abort()
|
||||
expect(signals.map((signal) => signal.aborted)).toEqual([true, true, true])
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,40 @@
|
||||
import type { OpenCodeClient } from "@opencode/client/promise"
|
||||
import type { OpenCodeClient, SessionInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
|
||||
export type SessionExportData = {
|
||||
info: SessionInfo
|
||||
messages: SessionMessageInfo[]
|
||||
}
|
||||
|
||||
/** Bounds each request rather than the whole export, so a long history still exports. */
|
||||
export const EXPORT_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
export async function fetchSessionExport(input: {
|
||||
sessionID: string
|
||||
api: Pick<OpenCodeClient, "session" | "message">
|
||||
}) {
|
||||
signal?: AbortSignal
|
||||
}): Promise<SessionExportData> {
|
||||
const signal = () =>
|
||||
input.signal
|
||||
? AbortSignal.any([input.signal, AbortSignal.timeout(EXPORT_REQUEST_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(EXPORT_REQUEST_TIMEOUT_MS)
|
||||
|
||||
const [info, first] = await Promise.all([
|
||||
input.api.session.get({ sessionID: input.sessionID }),
|
||||
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }),
|
||||
input.api.session.get({ sessionID: input.sessionID }, { signal: signal() }),
|
||||
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }, { signal: signal() }),
|
||||
])
|
||||
|
||||
const pages = [first]
|
||||
|
||||
while (pages.at(-1)?.cursor.next) {
|
||||
pages.push(
|
||||
await input.api.message.list({
|
||||
sessionID: input.sessionID,
|
||||
limit: 200,
|
||||
cursor: pages.at(-1)!.cursor.next ?? undefined,
|
||||
}),
|
||||
await input.api.message.list(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
limit: 200,
|
||||
cursor: pages.at(-1)!.cursor.next ?? undefined,
|
||||
},
|
||||
{ signal: signal() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +50,7 @@ export function sessionExportFilename(session: { id: string; title?: string; slu
|
||||
const clean = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||
.slice(0, 250)
|
||||
.replace(/^-+|-+$/g, "")
|
||||
|
||||
return `${clean || session.id}.json`
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionExportData } from "./export"
|
||||
import {
|
||||
createContextScrollState,
|
||||
createSessionExportState,
|
||||
selectSessionExporting,
|
||||
transitionContextScroll,
|
||||
transitionSessionExport,
|
||||
type ContextScrollEvent,
|
||||
type ContextScrollState,
|
||||
type SessionExportEvent,
|
||||
type SessionExportState,
|
||||
} from "./machine"
|
||||
|
||||
/* oxlint-disable anti-slop/require-safety-comment-for-type-assertion -- the export only passes this record through */
|
||||
const data: SessionExportData = { info: { id: "ses_1" } as SessionExportData["info"], messages: [] }
|
||||
|
||||
function exportAfter(...events: SessionExportEvent[]) {
|
||||
return events.reduce<SessionExportState>(
|
||||
(state, event) => transitionSessionExport(state, event).state,
|
||||
createSessionExportState(),
|
||||
)
|
||||
}
|
||||
|
||||
describe("transitionSessionExport", () => {
|
||||
test("a second click while the export loads or the save dialog is open does nothing", () => {
|
||||
const requested: SessionExportEvent = { type: "export.requested", sessionKey: "a", sessionID: "ses_1" }
|
||||
const loading = exportAfter(requested)
|
||||
|
||||
expect(transitionSessionExport(loading, requested)).toEqual({ state: loading, effects: [] })
|
||||
expect(transitionSessionExport(loading, requested).state).toBe(loading)
|
||||
expect(selectSessionExporting(loading, "a")).toBe(true)
|
||||
|
||||
const saving = transitionSessionExport(loading, { type: "export.fetched", attempt: 1, sessionKey: "a", data })
|
||||
|
||||
expect(saving.state).toBe(loading)
|
||||
expect(saving.effects).toEqual([{ type: "export.save", attempt: 1, sessionKey: "a", data }])
|
||||
expect(transitionSessionExport(saving.state, requested).state).toBe(loading)
|
||||
})
|
||||
|
||||
test("another session exports alongside, and each result settles only its own", () => {
|
||||
const state = exportAfter(
|
||||
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
|
||||
{ type: "export.requested", sessionKey: "b", sessionID: "ses_2" },
|
||||
)
|
||||
|
||||
expect(state).toEqual({ status: "active", issued: 2, exporting: { a: 1, b: 2 } })
|
||||
|
||||
const saved = transitionSessionExport(state, {
|
||||
type: "export.saved",
|
||||
attempt: 2,
|
||||
sessionKey: "b",
|
||||
filename: "b.json",
|
||||
saved: true,
|
||||
})
|
||||
|
||||
expect(saved.effects).toEqual([{ type: "toast.exported", filename: "b.json" }])
|
||||
expect(selectSessionExporting(saved.state, "a")).toBe(true)
|
||||
expect(selectSessionExporting(saved.state, "b")).toBe(false)
|
||||
})
|
||||
|
||||
test("a cancelled save dialog ends the export without a toast; a failure toasts its message", () => {
|
||||
const loading = exportAfter({ type: "export.requested", sessionKey: "a", sessionID: "ses_1" })
|
||||
|
||||
const cancelled = transitionSessionExport(loading, {
|
||||
type: "export.saved",
|
||||
attempt: 1,
|
||||
sessionKey: "a",
|
||||
filename: "a.json",
|
||||
saved: false,
|
||||
})
|
||||
|
||||
const failed = transitionSessionExport(loading, {
|
||||
type: "export.failed",
|
||||
attempt: 1,
|
||||
sessionKey: "a",
|
||||
message: "The operation timed out.",
|
||||
})
|
||||
|
||||
expect(cancelled.effects).toEqual([])
|
||||
expect(selectSessionExporting(cancelled.state, "a")).toBe(false)
|
||||
expect(failed.effects).toEqual([{ type: "toast.export-failed", message: "The operation timed out." }])
|
||||
expect(selectSessionExporting(failed.state, "a")).toBe(false)
|
||||
})
|
||||
|
||||
test("a result of an earlier export is ignored", () => {
|
||||
const state = exportAfter(
|
||||
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
|
||||
{ type: "export.failed", attempt: 1, sessionKey: "a" },
|
||||
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
|
||||
)
|
||||
|
||||
expect(transitionSessionExport(state, { type: "export.fetched", attempt: 1, sessionKey: "a", data })).toEqual({
|
||||
state,
|
||||
effects: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("closing the tab aborts every export and silences late results", () => {
|
||||
const state = exportAfter(
|
||||
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
|
||||
{ type: "export.requested", sessionKey: "b", sessionID: "ses_2" },
|
||||
)
|
||||
|
||||
const disposed = transitionSessionExport(state, { type: "disposed" })
|
||||
|
||||
expect(disposed.effects).toEqual([
|
||||
{ type: "export.abort", attempt: 1 },
|
||||
{ type: "export.abort", attempt: 2 },
|
||||
])
|
||||
expect(
|
||||
transitionSessionExport(disposed.state, { type: "export.failed", attempt: 1, sessionKey: "a", message: "abort" }),
|
||||
).toEqual({ state: disposed.state, effects: [] })
|
||||
})
|
||||
})
|
||||
|
||||
const showA: ContextScrollEvent = { type: "target.synced", sessionKey: "a", active: true }
|
||||
|
||||
function scrollAfter(...events: ContextScrollEvent[]) {
|
||||
return events.reduce<ContextScrollState>(
|
||||
(state, event) => transitionContextScroll(state, event).state,
|
||||
createContextScrollState(),
|
||||
)
|
||||
}
|
||||
|
||||
describe("transitionContextScroll", () => {
|
||||
test("restores a frame after the session shows", () => {
|
||||
expect(transitionContextScroll(createContextScrollState(), showA)).toEqual({
|
||||
state: { status: "restoring", sessionKey: "a", attempt: 1, frame: true, applied: undefined },
|
||||
effects: [{ type: "frame.schedule", kind: "restore", attempt: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
test("a message that renders after the user scrolled does not jump back to the stored offset", () => {
|
||||
const state = scrollAfter(
|
||||
showA,
|
||||
{ type: "restore.applied", attempt: 1, applied: undefined },
|
||||
{ type: "interacted" },
|
||||
{ type: "scrolled", position: { x: 0, y: 500 } },
|
||||
)
|
||||
|
||||
const rendered = transitionContextScroll(state, { type: "content.changed" })
|
||||
|
||||
expect(rendered).toEqual({ state, effects: [] })
|
||||
expect(state.status === "tracking" && state.attempt).toBe(3)
|
||||
expect(transitionContextScroll(state, { type: "save.flushed", attempt: 3 }).effects).toEqual([
|
||||
{ type: "save.write", sessionKey: "a", position: { x: 0, y: 500 } },
|
||||
])
|
||||
})
|
||||
|
||||
test("switching session stores the last offset for the session left, then restores the next", () => {
|
||||
const state = scrollAfter(showA, { type: "scrolled", position: { x: 0, y: 800 } })
|
||||
|
||||
expect(transitionContextScroll(state, { type: "target.synced", sessionKey: "b", active: true })).toEqual({
|
||||
state: { status: "restoring", sessionKey: "b", attempt: 3, frame: true, applied: undefined },
|
||||
effects: [
|
||||
{ type: "frame.cancel" },
|
||||
{ type: "save.write", sessionKey: "a", position: { x: 0, y: 800 } },
|
||||
{ type: "frame.schedule", kind: "restore", attempt: 3 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("closing the tab stores the offset the save frame had not written yet", () => {
|
||||
const state = scrollAfter(showA, { type: "interacted" }, { type: "scrolled", position: { x: 0, y: 420 } })
|
||||
|
||||
expect(transitionContextScroll(state, { type: "disposed" })).toEqual({
|
||||
state: { status: "disposed" },
|
||||
effects: [{ type: "frame.cancel" }, { type: "save.write", sessionKey: "a", position: { x: 0, y: 420 } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("the restore's own scroll event is not the user's, so a clamped restore retries as content grows", () => {
|
||||
const applied = scrollAfter(showA, { type: "restore.applied", attempt: 1, applied: { x: 0, y: 200 } })
|
||||
const echoed = transitionContextScroll(applied, { type: "scrolled", position: { x: 0, y: 200 } })
|
||||
|
||||
expect(echoed).toEqual({
|
||||
state: { status: "restoring", sessionKey: "a", attempt: 1, frame: false, applied: undefined },
|
||||
effects: [],
|
||||
})
|
||||
expect(transitionContextScroll(echoed.state, { type: "content.changed" }).effects).toEqual([
|
||||
{ type: "frame.schedule", kind: "restore", attempt: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
test("user input cancels a scheduled restore", () => {
|
||||
expect(transitionContextScroll(scrollAfter(showA), { type: "interacted" })).toEqual({
|
||||
state: { status: "tracking", sessionKey: "a", attempt: 2, pending: undefined },
|
||||
effects: [{ type: "frame.cancel" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("frames of an earlier attempt are ignored", () => {
|
||||
const restoring = scrollAfter(
|
||||
showA,
|
||||
{ type: "restore.applied", attempt: 1, applied: undefined },
|
||||
{ type: "content.changed" },
|
||||
)
|
||||
|
||||
expect(transitionContextScroll(restoring, { type: "restore.applied", attempt: 1, applied: undefined }).state).toBe(
|
||||
restoring,
|
||||
)
|
||||
|
||||
const tracking = scrollAfter(
|
||||
showA,
|
||||
{ type: "scrolled", position: { x: 0, y: 10 } },
|
||||
{ type: "save.flushed", attempt: 2 },
|
||||
)
|
||||
|
||||
expect(transitionContextScroll(tracking, { type: "save.flushed", attempt: 2 }).state).toBe(tracking)
|
||||
})
|
||||
|
||||
test("a session whose layout cannot be read stores its pending offset and ignores scrolling", () => {
|
||||
const state = scrollAfter(showA, { type: "scrolled", position: { x: 0, y: 30 } })
|
||||
const hidden = transitionContextScroll(state, { type: "target.synced", sessionKey: "a", active: false })
|
||||
|
||||
expect(hidden).toEqual({
|
||||
state: { status: "inactive", attempt: 3 },
|
||||
effects: [{ type: "frame.cancel" }, { type: "save.write", sessionKey: "a", position: { x: 0, y: 30 } }],
|
||||
})
|
||||
expect(transitionContextScroll(hidden.state, { type: "scrolled", position: { x: 0, y: 40 } }).state).toBe(
|
||||
hidden.state,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { SessionExportData } from "./export"
|
||||
|
||||
// --- Export session ---
|
||||
|
||||
/**
|
||||
* The Context tab stays mounted while the routed session changes, so exports are keyed by the session they run for
|
||||
* (`SessionRef.key`). An export holds its key from the first request until the save dialog answers.
|
||||
*/
|
||||
export type SessionExportState =
|
||||
| {
|
||||
readonly status: "active"
|
||||
readonly issued: number
|
||||
readonly exporting: Readonly<Record<string, number>>
|
||||
}
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
export type SessionExportEvent =
|
||||
| { readonly type: "export.requested"; readonly sessionKey: string; readonly sessionID: string }
|
||||
| {
|
||||
readonly type: "export.fetched"
|
||||
readonly attempt: number
|
||||
readonly sessionKey: string
|
||||
readonly data: SessionExportData
|
||||
}
|
||||
| {
|
||||
readonly type: "export.saved"
|
||||
readonly attempt: number
|
||||
readonly sessionKey: string
|
||||
readonly filename: string
|
||||
readonly saved: boolean
|
||||
}
|
||||
| { readonly type: "export.failed"; readonly attempt: number; readonly sessionKey: string; readonly message?: string }
|
||||
| { readonly type: "disposed" }
|
||||
|
||||
export type SessionExportEffect =
|
||||
| { readonly type: "export.fetch"; readonly attempt: number; readonly sessionKey: string; readonly sessionID: string }
|
||||
| {
|
||||
readonly type: "export.save"
|
||||
readonly attempt: number
|
||||
readonly sessionKey: string
|
||||
readonly data: SessionExportData
|
||||
}
|
||||
| { readonly type: "export.abort"; readonly attempt: number }
|
||||
| { readonly type: "toast.exported"; readonly filename: string }
|
||||
| { readonly type: "toast.export-failed"; readonly message?: string }
|
||||
|
||||
export type SessionExportTransition = {
|
||||
readonly state: SessionExportState
|
||||
readonly effects: readonly SessionExportEffect[]
|
||||
}
|
||||
|
||||
export function createSessionExportState(): SessionExportState {
|
||||
return { status: "active", issued: 0, exporting: {} }
|
||||
}
|
||||
|
||||
export function transitionSessionExport(state: SessionExportState, event: SessionExportEvent): SessionExportTransition {
|
||||
if (state.status === "disposed") return { state, effects: [] }
|
||||
|
||||
if (event.type === "disposed") {
|
||||
return {
|
||||
state: { status: "disposed" },
|
||||
effects: Object.values(state.exporting).map((attempt) => ({ type: "export.abort", attempt })),
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "export.requested") {
|
||||
// A second click while the pages load or the save dialog is open would export, and ask where to save, again.
|
||||
if (state.exporting[event.sessionKey] !== undefined) return { state, effects: [] }
|
||||
const attempt = state.issued + 1
|
||||
|
||||
return {
|
||||
state: { ...state, issued: attempt, exporting: { ...state.exporting, [event.sessionKey]: attempt } },
|
||||
effects: [{ type: "export.fetch", attempt, sessionKey: event.sessionKey, sessionID: event.sessionID }],
|
||||
}
|
||||
}
|
||||
|
||||
if (state.exporting[event.sessionKey] !== event.attempt) return { state, effects: [] }
|
||||
|
||||
if (event.type === "export.fetched") {
|
||||
return {
|
||||
state,
|
||||
effects: [{ type: "export.save", attempt: event.attempt, sessionKey: event.sessionKey, data: event.data }],
|
||||
}
|
||||
}
|
||||
|
||||
const next = { ...state, exporting: omit(state.exporting, event.sessionKey) }
|
||||
|
||||
if (event.type === "export.failed") {
|
||||
return { state: next, effects: [{ type: "toast.export-failed", message: event.message }] }
|
||||
}
|
||||
|
||||
// A cancelled save dialog is not a failure.
|
||||
return { state: next, effects: event.saved ? [{ type: "toast.exported", filename: event.filename }] : [] }
|
||||
}
|
||||
|
||||
/** Whether the session's export is loading or waiting for the save dialog. */
|
||||
export function selectSessionExporting(state: SessionExportState, sessionKey: string) {
|
||||
return state.status === "active" && state.exporting[sessionKey] !== undefined
|
||||
}
|
||||
|
||||
// --- Scroll offset of the tab ---
|
||||
|
||||
export type ScrollPosition = {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's scroll offset, stored per session. `restoring` applies the stored offset a frame after the session shows
|
||||
* and again after each content change, because the content renders progressively and clamps the first restore.
|
||||
* `applied` is the offset the last restore left, so its own scroll event is not taken for the user's. The first
|
||||
* scroll or input of the user moves to `tracking`, which stores the user's offset once per frame and never restores
|
||||
* over it. `inactive` covers a session whose layout cannot be read yet.
|
||||
*/
|
||||
export type ContextScrollState =
|
||||
| { readonly status: "inactive"; readonly attempt: number }
|
||||
| {
|
||||
readonly status: "restoring"
|
||||
readonly sessionKey: string
|
||||
readonly attempt: number
|
||||
readonly frame: boolean
|
||||
readonly applied: ScrollPosition | undefined
|
||||
}
|
||||
| {
|
||||
readonly status: "tracking"
|
||||
readonly sessionKey: string
|
||||
readonly attempt: number
|
||||
readonly pending: ScrollPosition | undefined
|
||||
}
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
export type ContextScrollEvent =
|
||||
/** The routed session, or whether its layout can be read (`active`), changed. */
|
||||
| { readonly type: "target.synced"; readonly sessionKey: string; readonly active: boolean }
|
||||
| { readonly type: "content.changed" }
|
||||
| { readonly type: "restore.applied"; readonly attempt: number; readonly applied: ScrollPosition | undefined }
|
||||
/** Wheel, pointer, touch or key input on the scroller. */
|
||||
| { readonly type: "interacted" }
|
||||
| { readonly type: "scrolled"; readonly position: ScrollPosition }
|
||||
| { readonly type: "save.flushed"; readonly attempt: number }
|
||||
| { readonly type: "disposed" }
|
||||
|
||||
export type ContextScrollEffect =
|
||||
| { readonly type: "frame.schedule"; readonly kind: "restore" | "save"; readonly attempt: number }
|
||||
| { readonly type: "frame.cancel" }
|
||||
| { readonly type: "save.write"; readonly sessionKey: string; readonly position: ScrollPosition }
|
||||
|
||||
export type ContextScrollTransition = {
|
||||
readonly state: ContextScrollState
|
||||
readonly effects: readonly ContextScrollEffect[]
|
||||
}
|
||||
|
||||
export function createContextScrollState(): ContextScrollState {
|
||||
return { status: "inactive", attempt: 0 }
|
||||
}
|
||||
|
||||
const NO_SCROLL_EFFECTS: readonly ContextScrollEffect[] = []
|
||||
|
||||
export function transitionContextScroll(state: ContextScrollState, event: ContextScrollEvent): ContextScrollTransition {
|
||||
if (state.status === "disposed") return { state, effects: NO_SCROLL_EFFECTS }
|
||||
|
||||
if (event.type === "disposed") return { state: { status: "disposed" }, effects: leave(state) }
|
||||
|
||||
if (event.type === "target.synced") {
|
||||
if (!event.active) {
|
||||
if (state.status === "inactive") return { state, effects: NO_SCROLL_EFFECTS }
|
||||
|
||||
return { state: { status: "inactive", attempt: state.attempt + 1 }, effects: leave(state) }
|
||||
}
|
||||
|
||||
if (state.status !== "inactive" && state.sessionKey === event.sessionKey) {
|
||||
return { state, effects: NO_SCROLL_EFFECTS }
|
||||
}
|
||||
|
||||
// The previous session keeps the offset the user left it at; the next one restores its own.
|
||||
const attempt = state.attempt + 1
|
||||
|
||||
return {
|
||||
state: { status: "restoring", sessionKey: event.sessionKey, attempt, frame: true, applied: undefined },
|
||||
effects: [...leave(state), { type: "frame.schedule", kind: "restore", attempt }],
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status === "inactive") return { state, effects: NO_SCROLL_EFFECTS }
|
||||
|
||||
if (state.status === "restoring") {
|
||||
if (event.type === "content.changed") {
|
||||
if (state.frame) return { state, effects: NO_SCROLL_EFFECTS }
|
||||
const attempt = state.attempt + 1
|
||||
|
||||
return {
|
||||
state: { ...state, attempt, frame: true },
|
||||
effects: [{ type: "frame.schedule", kind: "restore", attempt }],
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "restore.applied") {
|
||||
if (!state.frame || event.attempt !== state.attempt) return { state, effects: NO_SCROLL_EFFECTS }
|
||||
|
||||
return { state: { ...state, frame: false, applied: event.applied }, effects: NO_SCROLL_EFFECTS }
|
||||
}
|
||||
|
||||
if (event.type === "interacted") {
|
||||
return {
|
||||
state: { status: "tracking", sessionKey: state.sessionKey, attempt: state.attempt + 1, pending: undefined },
|
||||
effects: state.frame ? [{ type: "frame.cancel" }] : NO_SCROLL_EFFECTS,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "scrolled") {
|
||||
if (state.applied && same(state.applied, event.position)) {
|
||||
return { state: { ...state, applied: undefined }, effects: NO_SCROLL_EFFECTS }
|
||||
}
|
||||
|
||||
const attempt = state.attempt + 1
|
||||
const save: ContextScrollEffect = { type: "frame.schedule", kind: "save", attempt }
|
||||
|
||||
return {
|
||||
state: { status: "tracking", sessionKey: state.sessionKey, attempt, pending: event.position },
|
||||
effects: state.frame ? [{ type: "frame.cancel" }, save] : [save],
|
||||
}
|
||||
}
|
||||
|
||||
return { state, effects: NO_SCROLL_EFFECTS }
|
||||
}
|
||||
|
||||
if (event.type === "scrolled") {
|
||||
if (!state.pending) {
|
||||
const attempt = state.attempt + 1
|
||||
|
||||
return {
|
||||
state: { ...state, attempt, pending: event.position },
|
||||
effects: [{ type: "frame.schedule", kind: "save", attempt }],
|
||||
}
|
||||
}
|
||||
|
||||
if (same(state.pending, event.position)) return { state, effects: NO_SCROLL_EFFECTS }
|
||||
|
||||
// The save frame already scheduled stores the latest offset.
|
||||
return { state: { ...state, pending: event.position }, effects: NO_SCROLL_EFFECTS }
|
||||
}
|
||||
|
||||
if (event.type === "save.flushed") {
|
||||
if (!state.pending || event.attempt !== state.attempt) return { state, effects: NO_SCROLL_EFFECTS }
|
||||
|
||||
return {
|
||||
state: { ...state, pending: undefined },
|
||||
effects: [{ type: "save.write", sessionKey: state.sessionKey, position: state.pending }],
|
||||
}
|
||||
}
|
||||
|
||||
// The DOM holds the user's offset now: content changes no longer restore over it.
|
||||
return { state, effects: NO_SCROLL_EFFECTS }
|
||||
}
|
||||
|
||||
/** Cancels the scheduled frame and stores the offset still waiting for it. */
|
||||
function leave(state: Exclude<ContextScrollState, { status: "disposed" }>): readonly ContextScrollEffect[] {
|
||||
if (state.status === "restoring" && state.frame) return [{ type: "frame.cancel" }]
|
||||
|
||||
if (state.status !== "tracking" || !state.pending) return NO_SCROLL_EFFECTS
|
||||
|
||||
return [{ type: "frame.cancel" }, { type: "save.write", sessionKey: state.sessionKey, position: state.pending }]
|
||||
}
|
||||
|
||||
function same(a: ScrollPosition, b: ScrollPosition) {
|
||||
return a.x === b.x && a.y === b.y
|
||||
}
|
||||
|
||||
function omit(record: Readonly<Record<string, number>>, key: string) {
|
||||
return Object.fromEntries(Object.entries(record).filter(([entry]) => entry !== key))
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createMemo, on, onCleanup, onMount, For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Accordion } from "@opencode/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode/ui/sticky-accordion-header"
|
||||
import { showToast } from "@opencode/ui/toast"
|
||||
@@ -15,6 +17,17 @@ import { createKeyed, usePlugin, type MountedSession } from "@opencode/plugin/gu
|
||||
import { catalogModel, syncCatalog } from "./catalog"
|
||||
import { fetchSessionExport, sessionExportFilename } from "./export"
|
||||
import { createSessionContextFormatter } from "./format"
|
||||
import {
|
||||
createContextScrollState,
|
||||
createSessionExportState,
|
||||
selectSessionExporting,
|
||||
transitionContextScroll,
|
||||
transitionSessionExport,
|
||||
type ContextScrollEffect,
|
||||
type ContextScrollEvent,
|
||||
type SessionExportEffect,
|
||||
type SessionExportEvent,
|
||||
} from "./machine"
|
||||
|
||||
function Stat(props: { label: string; value: JSX.Element }) {
|
||||
return (
|
||||
@@ -37,13 +50,7 @@ function RawMessageContent(props: { message: SessionMessageInfo; onRendered: ()
|
||||
})
|
||||
|
||||
return (
|
||||
<File
|
||||
mode="text"
|
||||
file={file()}
|
||||
overflow="wrap"
|
||||
class="select-text"
|
||||
onRendered={() => requestAnimationFrame(props.onRendered)}
|
||||
/>
|
||||
<File mode="text" file={file()} overflow="wrap" class="select-text" onRendered={props.onRendered} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,95 +208,191 @@ export default function SessionContextTab(props: { session: MountedSession }) {
|
||||
{ label: "stats.lastActivity", value: () => formatter().time(context()?.message.time.created) },
|
||||
] satisfies { label: string; value: () => JSX.Element }[]
|
||||
|
||||
const exportSession = async () => {
|
||||
const sessionID = props.session.id
|
||||
const [exportState, setExportState] = createSignal(createSessionExportState())
|
||||
const exportControllers = new Map<number, AbortController>()
|
||||
const exporting = () => selectSessionExporting(exportState(), props.session.key)
|
||||
|
||||
if (!sessionID) return
|
||||
const dispatchExport = (event: SessionExportEvent) => {
|
||||
const next = transitionSessionExport(exportState(), event)
|
||||
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
api: props.session.server.client,
|
||||
})
|
||||
setExportState(next.state)
|
||||
next.effects.forEach(
|
||||
(effect) =>
|
||||
void Promise.try(() => runExport(effect)).catch((cause: unknown) => console.error("[context] export", cause)),
|
||||
)
|
||||
}
|
||||
|
||||
const filename = sessionExportFilename(data.info)
|
||||
const runExport = (effect: SessionExportEffect) => {
|
||||
if (effect.type === "export.fetch") {
|
||||
const controller = new AbortController()
|
||||
|
||||
if (!(await system.save({ name: filename, content: JSON.stringify(data, null, 2) }))) return
|
||||
showToast({
|
||||
const failed = (cause: unknown) =>
|
||||
dispatchExport({
|
||||
type: "export.failed",
|
||||
attempt: effect.attempt,
|
||||
sessionKey: effect.sessionKey,
|
||||
message: cause instanceof Error ? cause.message : undefined,
|
||||
})
|
||||
|
||||
exportControllers.set(effect.attempt, controller)
|
||||
// Runs inside the click that asked for it, so the routed session is the one being exported.
|
||||
const api = props.session.server.client
|
||||
void Promise.try(() => fetchSessionExport({ sessionID: effect.sessionID, api, signal: controller.signal }))
|
||||
.then(
|
||||
(data) =>
|
||||
dispatchExport({ type: "export.fetched", attempt: effect.attempt, sessionKey: effect.sessionKey, data }),
|
||||
failed,
|
||||
)
|
||||
.finally(() => exportControllers.delete(effect.attempt))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.type === "export.save") {
|
||||
const filename = sessionExportFilename(effect.data.info)
|
||||
|
||||
void Promise.try(() => system.save({ name: filename, content: JSON.stringify(effect.data, null, 2) })).then(
|
||||
(saved) =>
|
||||
dispatchExport({
|
||||
type: "export.saved",
|
||||
attempt: effect.attempt,
|
||||
sessionKey: effect.sessionKey,
|
||||
filename,
|
||||
saved,
|
||||
}),
|
||||
(cause: unknown) =>
|
||||
dispatchExport({
|
||||
type: "export.failed",
|
||||
attempt: effect.attempt,
|
||||
sessionKey: effect.sessionKey,
|
||||
message: cause instanceof Error ? cause.message : undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.type === "export.abort") return exportControllers.get(effect.attempt)?.abort()
|
||||
|
||||
if (effect.type === "toast.exported") {
|
||||
return void showToast({
|
||||
variant: "success",
|
||||
// Solid resolves JSX accessors under the toast's render owner, not this imperative call site.
|
||||
icon: () => <Icon name="circle-check" />,
|
||||
title: ctx.t("export.success.title"),
|
||||
description: ctx.t("export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: ctx.t("export.failed.title"),
|
||||
description: err instanceof Error ? err.message : ctx.t("export.failed.description"),
|
||||
description: ctx.t("export.success.description", { filename: effect.filename }),
|
||||
})
|
||||
}
|
||||
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: ctx.t("export.failed.title"),
|
||||
description: effect.message ?? ctx.t("export.failed.description"),
|
||||
})
|
||||
}
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
const exportSession = () => {
|
||||
if (!props.session.id) return
|
||||
dispatchExport({ type: "export.requested", sessionKey: props.session.key, sessionID: props.session.id })
|
||||
}
|
||||
|
||||
onCleanup(() => dispatchExport({ type: "disposed" }))
|
||||
|
||||
let scrollState = createContextScrollState()
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
// The session the scroll machine last targeted; a switch writes its final offset after props.session moved on.
|
||||
let scrollSession: MountedSession | undefined
|
||||
const contentChanged: ContextScrollEvent = { type: "content.changed" }
|
||||
const interacted: ContextScrollEvent = { type: "interacted" }
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
const dispatchScroll = (event: ContextScrollEvent) => {
|
||||
const next = transitionContextScroll(scrollState, event)
|
||||
|
||||
if (!el) return
|
||||
|
||||
const s = layout.scroll.get(props.session, "context")
|
||||
|
||||
if (!s) return
|
||||
|
||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
scrollState = next.state
|
||||
next.effects.forEach(
|
||||
(effect) =>
|
||||
void Promise.try(() => runScroll(effect)).catch((cause: unknown) => console.error("[context] scroll", cause)),
|
||||
)
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
pending = {
|
||||
x: event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
const runScroll = (effect: ContextScrollEffect) => {
|
||||
if (effect.type === "save.write") {
|
||||
const session = props.session.key === effect.sessionKey ? props.session : scrollSession
|
||||
|
||||
if (session?.key === effect.sessionKey) layout.scroll.set(session, "context", effect.position)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (frame !== undefined) return
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
|
||||
if (effect.type === "frame.cancel") return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
|
||||
const next = pending
|
||||
pending = undefined
|
||||
if (effect.kind === "save") return dispatchScroll({ type: "save.flushed", attempt: effect.attempt })
|
||||
|
||||
if (!next) return
|
||||
|
||||
layout.scroll.set(props.session, "context", next)
|
||||
dispatchScroll({ type: "restore.applied", attempt: effect.attempt, applied: restoreScroll() })
|
||||
})
|
||||
}
|
||||
|
||||
// Restores the stored scroll a frame after the messages change; on mount the viewport ref restores it.
|
||||
/** Applies the stored offset, or the top for a session without one, and returns where the scroller moved to. */
|
||||
const restoreScroll = () => {
|
||||
const el = scroller
|
||||
|
||||
if (!el) return
|
||||
const stored = layout.scroll.get(props.session, "context") ?? { x: 0, y: 0 }
|
||||
const before = { x: el.scrollLeft, y: el.scrollTop }
|
||||
|
||||
if (el.scrollTop !== stored.y) el.scrollTop = stored.y
|
||||
|
||||
if (el.scrollLeft !== stored.x) el.scrollLeft = stored.x
|
||||
|
||||
if (el.scrollTop === before.y && el.scrollLeft === before.x) return
|
||||
|
||||
return { x: el.scrollLeft, y: el.scrollTop }
|
||||
}
|
||||
|
||||
// Follows the routed session and whether its stored layout can be read.
|
||||
createKeyed(
|
||||
createMemo(on(messages, (list) => list, { defer: true })),
|
||||
() => void requestAnimationFrame(restoreScroll),
|
||||
() => ({ session: props.session, active: layout.ready() && props.session.location !== undefined }),
|
||||
(target) => {
|
||||
dispatchScroll({ type: "target.synced", sessionKey: target.session.key, active: target.active })
|
||||
scrollSession = target.session
|
||||
},
|
||||
{ equals: (previous, next) => previous.session.key === next.session.key && previous.active === next.active },
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
// New messages grow the content, so a restore clamped by shorter content can now reach its offset.
|
||||
createKeyed(
|
||||
createMemo(on(messages, (list) => list, { defer: true })),
|
||||
() => dispatchScroll(contentChanged),
|
||||
)
|
||||
|
||||
// Writes the offset the save frame has not stored yet.
|
||||
onCleanup(() => dispatchScroll({ type: "disposed" }))
|
||||
|
||||
return (
|
||||
<div
|
||||
class="@container h-full overflow-y-auto"
|
||||
data-scrollable
|
||||
ref={(el) => {
|
||||
scroll = el
|
||||
// The ref runs before the content renders into the element.
|
||||
onMount(restoreScroll)
|
||||
scroller = el
|
||||
// Input of the user's own takes over from restoring, even before it scrolls.
|
||||
makeEventListener(el, "wheel", () => dispatchScroll(interacted), { passive: true })
|
||||
makeEventListener(el, "pointerdown", () => dispatchScroll(interacted), { passive: true })
|
||||
makeEventListener(el, "touchstart", () => dispatchScroll(interacted), { passive: true })
|
||||
makeEventListener(el, "keydown", () => dispatchScroll(interacted))
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onScroll={(event) =>
|
||||
dispatchScroll({
|
||||
type: "scrolled",
|
||||
position: { x: event.currentTarget.scrollLeft, y: event.currentTarget.scrollTop },
|
||||
})
|
||||
}
|
||||
>
|
||||
<div data-slot="session-usage-content" class="px-4 pt-4 pb-6 flex flex-col gap-6 md:px-6 md:pb-10 md:gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
@@ -314,15 +417,20 @@ export default function SessionContextTab(props: { session: MountedSession }) {
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
|
||||
disabled={!props.session.id || exporting()}
|
||||
onClick={exportSession}
|
||||
>
|
||||
<Icon name="download" size="small" />
|
||||
<Show when={exporting()} fallback={<Icon name="download" size="small" />}>
|
||||
<Loader class="size-4 shrink-0" />
|
||||
</Show>
|
||||
<span>{ctx.t("export.session")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Accordion multiple>
|
||||
<For each={messages()}>
|
||||
{(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />}
|
||||
{(message) => (
|
||||
<RawMessage message={message} onRendered={() => dispatchScroll(contentChanged)} time={formatter().time} />
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const HeapSnapshots = Ipc.define({
|
||||
methods: {
|
||||
/**
|
||||
* Writes heap snapshots of the desktop's own server, every app window and the main process into a new folder,
|
||||
* with a `manifest.json` written last. One capture runs at a time; a second call waits for the first. A snapshot
|
||||
* with a `manifest.json` written last. One capture runs at a time; a call while one runs gets that capture's result. A snapshot
|
||||
* that fails is recorded in the manifest and the others are still written.
|
||||
*/
|
||||
write: {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createHeapCaptureState,
|
||||
createHeapCommandState,
|
||||
isHeapCommandEnabled,
|
||||
transitionHeapCapture,
|
||||
transitionHeapCommand,
|
||||
type HeapCaptureEvent,
|
||||
type HeapCaptureReply,
|
||||
type HeapCaptureState,
|
||||
type HeapCommandEvent,
|
||||
type HeapCommandState,
|
||||
} from "./heap-machine"
|
||||
|
||||
const reply = (): HeapCaptureReply => ({ resolve: () => undefined, reject: () => undefined })
|
||||
|
||||
const result = { directory: "/downloads/opencode-heap-1", complete: true }
|
||||
|
||||
const command = (value = reply()): HeapCaptureEvent => ({
|
||||
type: "command.requested",
|
||||
root: "/downloads",
|
||||
includeServer: true,
|
||||
reply: value,
|
||||
})
|
||||
|
||||
const signal: HeapCaptureEvent = { type: "signal.received", root: "/heap", includeServer: false }
|
||||
|
||||
function captureAfter(...events: HeapCaptureEvent[]) {
|
||||
return events.reduce<HeapCaptureState>(
|
||||
(state, event) => transitionHeapCapture(state, event).state,
|
||||
createHeapCaptureState(),
|
||||
)
|
||||
}
|
||||
|
||||
describe("transitionHeapCapture", () => {
|
||||
test("a second command while one captures joins it instead of writing another folder", () => {
|
||||
const first = reply()
|
||||
const second = reply()
|
||||
const started = transitionHeapCapture(createHeapCaptureState(), command(first))
|
||||
const joined = transitionHeapCapture(started.state, command(second))
|
||||
|
||||
expect(started.effects).toEqual([
|
||||
{ type: "capture.run", attempt: 1, root: "/downloads", includeServer: true },
|
||||
])
|
||||
expect(joined.effects).toEqual([])
|
||||
expect(transitionHeapCapture(joined.state, { type: "capture.succeeded", attempt: 1, result })).toEqual({
|
||||
state: { status: "idle", attempt: 1 },
|
||||
effects: [{ type: "replies.settle", replies: [first, second], outcome: { ok: true, result } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("repeated signals coalesce, and each source queues at most one job behind the other", () => {
|
||||
const signals = captureAfter(signal)
|
||||
|
||||
expect(transitionHeapCapture(signals, signal).state).toBe(signals)
|
||||
|
||||
const first = reply()
|
||||
const second = reply()
|
||||
const behindSignal = captureAfter(signal, command(first), command(second))
|
||||
|
||||
expect(behindSignal).toMatchObject({
|
||||
active: { source: "signal" },
|
||||
pending: { source: "command", replies: [first, second] },
|
||||
})
|
||||
expect(transitionHeapCapture(behindSignal, signal).state).toBe(behindSignal)
|
||||
|
||||
const behindCommand = captureAfter(command(), signal)
|
||||
|
||||
expect(behindCommand).toMatchObject({ active: { source: "command" }, pending: { source: "signal" } })
|
||||
expect(transitionHeapCapture(behindCommand, signal).state).toBe(behindCommand)
|
||||
})
|
||||
|
||||
test("a failed capture still starts the queued one", () => {
|
||||
const first = reply()
|
||||
const error = new Error("ENOSPC")
|
||||
const state = captureAfter(command(first), signal)
|
||||
|
||||
expect(transitionHeapCapture(state, { type: "capture.failed", attempt: 1, error })).toEqual({
|
||||
state: {
|
||||
status: "capturing",
|
||||
attempt: 2,
|
||||
active: { source: "signal", root: "/heap", includeServer: false },
|
||||
pending: undefined,
|
||||
},
|
||||
effects: [
|
||||
{ type: "replies.settle", replies: [first], outcome: { ok: false, error } },
|
||||
{ type: "capture.run", attempt: 2, root: "/heap", includeServer: false },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("a queued capture starts only once the running one settles, however long it runs", () => {
|
||||
const queued = reply()
|
||||
const running = transitionHeapCapture(captureAfter(signal), command(queued))
|
||||
|
||||
expect(running.effects).toEqual([])
|
||||
expect(transitionHeapCapture(running.state, command()).effects).toEqual([])
|
||||
|
||||
const failed = transitionHeapCapture(running.state, { type: "capture.failed", attempt: 1, error: new Error("EIO") })
|
||||
|
||||
expect(failed.state).toMatchObject({ status: "capturing", attempt: 2, active: { replies: [queued] } })
|
||||
expect(failed.effects).toEqual([
|
||||
{ type: "signal.failed", error: "EIO" },
|
||||
{ type: "capture.run", attempt: 2, root: "/downloads", includeServer: true },
|
||||
])
|
||||
expect(transitionHeapCapture(failed.state, { type: "capture.succeeded", attempt: 1, result }).state).toBe(
|
||||
failed.state,
|
||||
)
|
||||
})
|
||||
|
||||
test("disposing rejects every waiting command, and later commands at once", () => {
|
||||
const first = reply()
|
||||
const second = reply()
|
||||
const disposed = transitionHeapCapture(captureAfter(signal, command(first), command(second)), { type: "disposed" })
|
||||
|
||||
expect(disposed).toEqual({
|
||||
state: { status: "disposed" },
|
||||
effects: [
|
||||
{
|
||||
type: "replies.settle",
|
||||
replies: [first, second],
|
||||
outcome: { ok: false, error: new Error("Heap capture disposed") },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const late = reply()
|
||||
|
||||
expect(transitionHeapCapture(disposed.state, command(late)).effects).toEqual([
|
||||
{ type: "replies.settle", replies: [late], outcome: { ok: false, error: new Error("Heap capture disposed") } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function commandAfter(...events: HeapCommandEvent[]) {
|
||||
return events.reduce<HeapCommandState>(
|
||||
(state, event) => transitionHeapCommand(state, event).state,
|
||||
createHeapCommandState(),
|
||||
)
|
||||
}
|
||||
|
||||
describe("transitionHeapCommand", () => {
|
||||
test("running the command again while it captures shows no second toast", () => {
|
||||
const capturing = commandAfter({ type: "run" })
|
||||
|
||||
expect(transitionHeapCommand(capturing, { type: "run" })).toEqual({ state: capturing, effects: [] })
|
||||
expect(isHeapCommandEnabled(capturing)).toBe(false)
|
||||
})
|
||||
|
||||
test("each outcome replaces the loading toast and offers the command again", () => {
|
||||
const capturing = commandAfter({ type: "run" })
|
||||
const succeeded = transitionHeapCommand(capturing, { type: "succeeded", attempt: 1, result })
|
||||
const failed = transitionHeapCommand(capturing, { type: "failed", attempt: 1 })
|
||||
|
||||
expect(succeeded).toEqual({
|
||||
state: { status: "idle", issued: 1 },
|
||||
effects: [{ type: "toast.result", attempt: 1, result }],
|
||||
})
|
||||
expect(failed.effects).toEqual([{ type: "toast.failed", attempt: 1 }])
|
||||
expect(isHeapCommandEnabled(succeeded.state)).toBe(true)
|
||||
expect(transitionHeapCommand(commandAfter({ type: "run" }, { type: "failed", attempt: 1 }), { type: "run" })).toEqual(
|
||||
{ state: { status: "capturing", attempt: 2 }, effects: [{ type: "capture.start", attempt: 2 }] },
|
||||
)
|
||||
})
|
||||
|
||||
test("disposing dismisses the loading toast and silences a late failure", () => {
|
||||
const disposed = transitionHeapCommand(commandAfter({ type: "run" }), { type: "disposed" })
|
||||
|
||||
expect(disposed).toEqual({ state: { status: "disposed" }, effects: [{ type: "capture.abort", attempt: 1 }] })
|
||||
expect(transitionHeapCommand(disposed.state, { type: "failed", attempt: 1 }).effects).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
export type HeapCaptureResult = {
|
||||
readonly directory: string
|
||||
readonly complete: boolean
|
||||
}
|
||||
|
||||
/** Answers one `HeapSnapshots.write` call. */
|
||||
export type HeapCaptureReply = {
|
||||
readonly resolve: (result: HeapCaptureResult) => void
|
||||
readonly reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/** A command capture answers every caller that joined it; a signal capture answers nobody and logs a failure. */
|
||||
export type HeapCaptureJob =
|
||||
| {
|
||||
readonly source: "command"
|
||||
readonly root: string
|
||||
readonly includeServer: boolean
|
||||
readonly replies: readonly HeapCaptureReply[]
|
||||
}
|
||||
| { readonly source: "signal"; readonly root: string; readonly includeServer: boolean }
|
||||
|
||||
/**
|
||||
* Main's heap snapshot queue. One capture runs (`active`) and at most one of the other source waits (`pending`):
|
||||
* a repeated command joins the command job, and a repeated `SIGUSR2` joins the signal job, so repeats never fill the
|
||||
* disk with queued dumps. `pending` starts only once the running capture settles, so two never run at once. The capture
|
||||
* has no deadline of its own: each step that waits on another process is bounded and records a timeout as a failed
|
||||
* snapshot, so a capture always settles through its steps.
|
||||
*/
|
||||
export type HeapCaptureState =
|
||||
| { readonly status: "idle"; readonly attempt: number }
|
||||
| {
|
||||
readonly status: "capturing"
|
||||
readonly attempt: number
|
||||
readonly active: HeapCaptureJob
|
||||
readonly pending: HeapCaptureJob | undefined
|
||||
}
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
export type HeapCaptureEvent =
|
||||
| {
|
||||
readonly type: "command.requested"
|
||||
readonly root: string
|
||||
readonly includeServer: boolean
|
||||
readonly reply: HeapCaptureReply
|
||||
}
|
||||
| { readonly type: "signal.received"; readonly root: string; readonly includeServer: boolean }
|
||||
| { readonly type: "capture.succeeded"; readonly attempt: number; readonly result: HeapCaptureResult }
|
||||
| { readonly type: "capture.failed"; readonly attempt: number; readonly error: Error }
|
||||
| { readonly type: "disposed" }
|
||||
|
||||
export type HeapCaptureEffect =
|
||||
| { readonly type: "capture.run"; readonly attempt: number; readonly root: string; readonly includeServer: boolean }
|
||||
| {
|
||||
readonly type: "replies.settle"
|
||||
readonly replies: readonly HeapCaptureReply[]
|
||||
readonly outcome:
|
||||
| { readonly ok: true; readonly result: HeapCaptureResult }
|
||||
| { readonly ok: false; readonly error: Error }
|
||||
}
|
||||
| { readonly type: "signal.failed"; readonly error: string }
|
||||
|
||||
export type HeapCaptureTransition = {
|
||||
readonly state: HeapCaptureState
|
||||
readonly effects: readonly HeapCaptureEffect[]
|
||||
}
|
||||
|
||||
export function createHeapCaptureState(): HeapCaptureState {
|
||||
return { status: "idle", attempt: 0 }
|
||||
}
|
||||
|
||||
export function transitionHeapCapture(state: HeapCaptureState, event: HeapCaptureEvent): HeapCaptureTransition {
|
||||
if (state.status === "disposed") {
|
||||
if (event.type !== "command.requested") return { state, effects: [] }
|
||||
|
||||
return { state, effects: [settle([event.reply], { ok: false, error: new Error("Heap capture disposed") })] }
|
||||
}
|
||||
|
||||
if (event.type === "disposed") {
|
||||
if (state.status === "idle") return { state: { status: "disposed" }, effects: [] }
|
||||
const replies = [state.active, state.pending].flatMap((job) => (job?.source === "command" ? job.replies : []))
|
||||
|
||||
return {
|
||||
state: { status: "disposed" },
|
||||
effects: replies.length ? [settle(replies, { ok: false, error: new Error("Heap capture disposed") })] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "command.requested") {
|
||||
if (state.status === "idle") {
|
||||
return start(state.attempt + 1, {
|
||||
source: "command",
|
||||
root: event.root,
|
||||
includeServer: event.includeServer,
|
||||
replies: [event.reply],
|
||||
})
|
||||
}
|
||||
|
||||
if (state.active.source === "command") {
|
||||
return {
|
||||
state: { ...state, active: { ...state.active, replies: [...state.active.replies, event.reply] } },
|
||||
effects: [],
|
||||
}
|
||||
}
|
||||
|
||||
// A signal capture is running: the command waits for it, and later commands join it there.
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
pending: {
|
||||
source: "command",
|
||||
root: event.root,
|
||||
includeServer: event.includeServer,
|
||||
replies: state.pending?.source === "command" ? [...state.pending.replies, event.reply] : [event.reply],
|
||||
},
|
||||
},
|
||||
effects: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "signal.received") {
|
||||
if (state.status === "idle") {
|
||||
return start(state.attempt + 1, { source: "signal", root: event.root, includeServer: event.includeServer })
|
||||
}
|
||||
|
||||
if (state.active.source === "signal" || state.pending) return { state, effects: [] }
|
||||
|
||||
return {
|
||||
state: { ...state, pending: { source: "signal", root: event.root, includeServer: event.includeServer } },
|
||||
effects: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status === "idle" || event.attempt !== state.attempt) return { state, effects: [] }
|
||||
|
||||
const outcome =
|
||||
event.type === "capture.succeeded"
|
||||
? { ok: true as const, result: event.result }
|
||||
: { ok: false as const, error: event.error }
|
||||
|
||||
const settled: HeapCaptureEffect[] =
|
||||
state.active.source === "command"
|
||||
? [settle(state.active.replies, outcome)]
|
||||
: outcome.ok
|
||||
? []
|
||||
: [{ type: "signal.failed", error: outcome.error.message }]
|
||||
|
||||
if (!state.pending) return { state: { status: "idle", attempt: state.attempt }, effects: settled }
|
||||
const next = start(state.attempt + 1, state.pending)
|
||||
|
||||
return { state: next.state, effects: [...settled, ...next.effects] }
|
||||
}
|
||||
|
||||
function start(attempt: number, job: HeapCaptureJob): HeapCaptureTransition {
|
||||
return {
|
||||
state: { status: "capturing", attempt, active: job, pending: undefined },
|
||||
effects: [{ type: "capture.run", attempt, root: job.root, includeServer: job.includeServer }],
|
||||
}
|
||||
}
|
||||
|
||||
function settle(
|
||||
replies: readonly HeapCaptureReply[],
|
||||
outcome: Extract<HeapCaptureEffect, { type: "replies.settle" }>["outcome"],
|
||||
): HeapCaptureEffect {
|
||||
return { type: "replies.settle", replies, outcome }
|
||||
}
|
||||
|
||||
// --- The palette command in a window ---
|
||||
|
||||
/** One window's "Capture heap snapshots" command: a run while one is in flight shows nothing new. */
|
||||
export type HeapCommandState =
|
||||
| { readonly status: "idle"; readonly issued: number }
|
||||
| { readonly status: "capturing"; readonly attempt: number }
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
export type HeapCommandEvent =
|
||||
| { readonly type: "run" }
|
||||
| { readonly type: "succeeded"; readonly attempt: number; readonly result: HeapCaptureResult }
|
||||
| { readonly type: "failed"; readonly attempt: number }
|
||||
| { readonly type: "disposed" }
|
||||
|
||||
export type HeapCommandEffect =
|
||||
/** Shows the loading toast and asks main for the capture. */
|
||||
| { readonly type: "capture.start"; readonly attempt: number }
|
||||
/** Dismisses the loading toast and stops waiting for main. */
|
||||
| { readonly type: "capture.abort"; readonly attempt: number }
|
||||
| { readonly type: "toast.result"; readonly attempt: number; readonly result: HeapCaptureResult }
|
||||
| { readonly type: "toast.failed"; readonly attempt: number }
|
||||
|
||||
export type HeapCommandTransition = {
|
||||
readonly state: HeapCommandState
|
||||
readonly effects: readonly HeapCommandEffect[]
|
||||
}
|
||||
|
||||
export function createHeapCommandState(): HeapCommandState {
|
||||
return { status: "idle", issued: 0 }
|
||||
}
|
||||
|
||||
export function transitionHeapCommand(state: HeapCommandState, event: HeapCommandEvent): HeapCommandTransition {
|
||||
if (state.status === "disposed") return { state, effects: [] }
|
||||
|
||||
if (event.type === "disposed") {
|
||||
if (state.status === "idle") return { state: { status: "disposed" }, effects: [] }
|
||||
|
||||
return { state: { status: "disposed" }, effects: [{ type: "capture.abort", attempt: state.attempt }] }
|
||||
}
|
||||
|
||||
if (event.type === "run") {
|
||||
if (state.status === "capturing") return { state, effects: [] }
|
||||
const attempt = state.issued + 1
|
||||
|
||||
return { state: { status: "capturing", attempt }, effects: [{ type: "capture.start", attempt }] }
|
||||
}
|
||||
|
||||
if (state.status === "idle" || event.attempt !== state.attempt) return { state, effects: [] }
|
||||
|
||||
return {
|
||||
state: { status: "idle", issued: state.attempt },
|
||||
effects: [
|
||||
event.type === "succeeded"
|
||||
? { type: "toast.result", attempt: event.attempt, result: event.result }
|
||||
: { type: "toast.failed", attempt: event.attempt },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the palette offers the command: not while this window's capture runs. */
|
||||
export function isHeapCommandEnabled(state: HeapCommandState) {
|
||||
return state.status === "idle"
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
import { app, contentTracing, screen, type BrowserWindow } from "electron"
|
||||
import { Schema } from "effect"
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises"
|
||||
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { getHeapSpaceStatistics, getHeapStatistics, writeHeapSnapshot } from "node:v8"
|
||||
import type { Plugin } from "@opencode/plugin/gui/main"
|
||||
import { HeapSnapshots, MemoryDiagnostics } from "./contract"
|
||||
import {
|
||||
createHeapCaptureState,
|
||||
transitionHeapCapture,
|
||||
type HeapCaptureEffect,
|
||||
type HeapCaptureEvent,
|
||||
type HeapCaptureResult,
|
||||
} from "./heap-machine"
|
||||
import { summarize, traceConfig } from "./memory-infra"
|
||||
import { startMemoryLog } from "./memory-log"
|
||||
import { collectMemorySample, memoryLogFile } from "./memory-log"
|
||||
import {
|
||||
createMemoryDiagnosticsState,
|
||||
isMemoryDiagnosticsOn,
|
||||
isRendererProbing,
|
||||
transitionMemoryDiagnostics,
|
||||
type MemoryDiagnosticsEffect,
|
||||
type MemoryDiagnosticsEvent,
|
||||
} from "./memory-machine"
|
||||
import { debugBuild, memoryLogInterval } from "./options"
|
||||
import { captureServer } from "./server-heap"
|
||||
import { signalIncludesServer, snapshotDirectory } from "./signal"
|
||||
@@ -18,8 +33,8 @@ type Snapshot = { readonly process: "main" | "window"; readonly pid: number; rea
|
||||
| { readonly error: string }
|
||||
)
|
||||
|
||||
/** The last capture; the next one starts after it settles. */
|
||||
type Queue = { tail: Promise<unknown> }
|
||||
/** How long a step that waits on another process may take before the manifest records it as failed. */
|
||||
const SNAPSHOT_TIMEOUT_MS = 5 * 60_000
|
||||
|
||||
/** What a window's page holds, read from inside it. */
|
||||
const PageStats = Schema.Struct({
|
||||
@@ -53,12 +68,7 @@ const pageStatsScript = `(() => {
|
||||
}
|
||||
})()`
|
||||
|
||||
/** Stops the running memory diagnostics, while they run. */
|
||||
type Running = { stop?: () => void }
|
||||
|
||||
const setup: Plugin.MainSetup = (ctx) => {
|
||||
const queue: Queue = { tail: Promise.resolve() }
|
||||
|
||||
const capture = async (root: string, includeServer: boolean) => {
|
||||
const directory = path.join(root, `opencode-heap-${new Date().toISOString().replace(/[:.]/g, "")}`)
|
||||
|
||||
@@ -83,7 +93,13 @@ const setup: Plugin.MainSetup = (ctx) => {
|
||||
// snapshot can double a process's memory, and a leaking app may not have room for every window at once. Main goes
|
||||
// last: writing its own heap blocks the loop that receives the others.
|
||||
const [serverSnapshot, windowSnapshots] = await Promise.all([
|
||||
includeServer ? captureServer(ctx.serverEndpoints.get("sidecar"), directory, ctx.signal) : undefined,
|
||||
includeServer
|
||||
? captureServer(
|
||||
ctx.serverEndpoints.get("sidecar"),
|
||||
directory,
|
||||
AbortSignal.any([ctx.signal, AbortSignal.timeout(SNAPSHOT_TIMEOUT_MS)]),
|
||||
)
|
||||
: undefined,
|
||||
(async () => {
|
||||
const list: Snapshot[] = []
|
||||
|
||||
@@ -116,7 +132,9 @@ const setup: Plugin.MainSetup = (ctx) => {
|
||||
}),
|
||||
)
|
||||
|
||||
const gpu = await app.getGPUInfo("basic").catch((error: Error) => ({ error: error.message }))
|
||||
const gpu = await within(app.getGPUInfo("basic"), 15_000, "The GPU process did not answer within 15 seconds.").catch(
|
||||
(error: Error) => ({ error: error.message }),
|
||||
)
|
||||
|
||||
// One readable answer to "where is the memory", per process, in MiB.
|
||||
await write(directory, "memory.json", {
|
||||
@@ -161,72 +179,159 @@ const setup: Plugin.MainSetup = (ctx) => {
|
||||
return { directory, complete }
|
||||
}
|
||||
|
||||
const enqueue = (directory: string, includeServer: boolean) => {
|
||||
const run = queue.tail.then(() => capture(directory, includeServer))
|
||||
queue.tail = run.catch(() => undefined)
|
||||
let heapState = createHeapCaptureState()
|
||||
|
||||
return run
|
||||
const dispatchHeap = (event: HeapCaptureEvent) => {
|
||||
const next = transitionHeapCapture(heapState, event)
|
||||
|
||||
heapState = next.state
|
||||
next.effects.forEach(
|
||||
(effect) =>
|
||||
void Promise.try(() => runHeap(effect)).catch((cause: unknown) =>
|
||||
ctx.log.write("error", "heap snapshot queue effect failed", { error: String(cause) }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const runHeap = (effect: HeapCaptureEffect) => {
|
||||
if (effect.type === "replies.settle") {
|
||||
const outcome = effect.outcome
|
||||
|
||||
return effect.replies.forEach((reply) => (outcome.ok ? reply.resolve(outcome.result) : reply.reject(outcome.error)))
|
||||
}
|
||||
|
||||
if (effect.type === "signal.failed") {
|
||||
return ctx.log.write("error", "heap snapshot capture failed", { error: effect.error })
|
||||
}
|
||||
|
||||
void Promise.try(() => capture(effect.root, effect.includeServer)).then(
|
||||
(result) => dispatchHeap({ type: "capture.succeeded", attempt: effect.attempt, result }),
|
||||
(cause: unknown) =>
|
||||
dispatchHeap({
|
||||
type: "capture.failed",
|
||||
attempt: effect.attempt,
|
||||
error: cause instanceof Error ? cause : new Error(String(cause)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
ctx.onCleanup(() => dispatchHeap({ type: "disposed" }))
|
||||
|
||||
ctx.provide(HeapSnapshots, {
|
||||
// A window that closes meanwhile does not cancel the capture.
|
||||
write: () => enqueue(app.getPath("downloads"), true),
|
||||
write: () =>
|
||||
new Promise<HeapCaptureResult>((resolve, reject) =>
|
||||
dispatchHeap({
|
||||
type: "command.requested",
|
||||
root: app.getPath("downloads"),
|
||||
includeServer: true,
|
||||
reply: { resolve, reject },
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
if (!debugBuild(ctx.app)) return
|
||||
|
||||
// The environment turns diagnostics on for this launch; otherwise the debug bar's stored toggle does.
|
||||
const envInterval = memoryLogInterval(process.env.OPENCODE_DESKTOP_MEMORY_LOG)
|
||||
const interval = envInterval ?? 30_000
|
||||
// Without the launch switch, Chromium quantizes renderer heap sizes and refreshes them rarely.
|
||||
const precise = app.commandLine.hasSwitch("enable-precise-memory-info")
|
||||
// Whether the debug bar turned memory diagnostics on.
|
||||
const stored = ctx.storage.store("memory", { schema: Schema.Boolean, initial: false })
|
||||
const running: Running = {}
|
||||
const directory = process.platform === "win32" ? undefined : snapshotDirectory(process.env)
|
||||
let capturing = false
|
||||
let memoryState = createMemoryDiagnosticsState()
|
||||
let memoryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let memoryFile = ""
|
||||
let unsubscribeSignal: (() => void) | undefined
|
||||
|
||||
// Signals coalesce in the queue; repeated signals must not fill the disk with queued dumps.
|
||||
const onSignal = () => {
|
||||
// Signals coalesce while a capture is running; repeated signals must not fill the disk with queued dumps.
|
||||
if (!directory || capturing || ctx.signal.aborted) return
|
||||
capturing = true
|
||||
void enqueue(directory, signalIncludesServer(process.env))
|
||||
.catch((error: Error) => ctx.log.write("error", "heap snapshot capture failed", { error: error.message }))
|
||||
.finally(() => {
|
||||
capturing = false
|
||||
})
|
||||
if (!directory) return
|
||||
dispatchHeap({ type: "signal.received", root: directory, includeServer: signalIncludesServer(process.env) })
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
const stopLog = startMemoryLog(envInterval ?? 30_000, ctx.log)
|
||||
const dispatchMemory = (event: MemoryDiagnosticsEvent) => {
|
||||
const next = transitionMemoryDiagnostics(memoryState, event)
|
||||
|
||||
if (!directory) return stopLog
|
||||
memoryState = next.state
|
||||
next.effects.forEach(
|
||||
(effect) =>
|
||||
void Promise.try(() => runMemory(effect)).catch((cause: unknown) =>
|
||||
ctx.log.write("error", "memory diagnostics effect failed", { error: String(cause) }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Installed the first time diagnostics turn on, and kept: a script's late signal while they are off, or while
|
||||
// the plugin reloads, is logged and ignored instead of killing the app.
|
||||
ProcessSignal.install("SIGUSR2", () => ctx.log.write("warn", "heap snapshot signal ignored: diagnostics are off"))
|
||||
const unsubscribe = ProcessSignal.subscribe("SIGUSR2", onSignal)
|
||||
ctx.log.write("info", "heap snapshot signal enabled", { pid: process.pid, signal: "SIGUSR2", directory })
|
||||
const runMemory = (effect: MemoryDiagnosticsEffect) => {
|
||||
if (effect.type === "log.start") {
|
||||
memoryFile = memoryLogFile()
|
||||
ctx.log.write("info", "memory diagnostics started", { file: memoryFile, intervalMs: interval, precise })
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
stopLog()
|
||||
if (!directory) return
|
||||
|
||||
// Installed the first time diagnostics turn on, and kept: a script's late signal while they are off, or while
|
||||
// the plugin reloads, is logged and ignored instead of killing the app.
|
||||
ProcessSignal.install("SIGUSR2", () => ctx.log.write("warn", "heap snapshot signal ignored: diagnostics are off"))
|
||||
unsubscribeSignal = ProcessSignal.subscribe("SIGUSR2", onSignal)
|
||||
ctx.log.write("info", "heap snapshot signal enabled", { pid: process.pid, signal: "SIGUSR2", directory })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.type === "log.stop") {
|
||||
unsubscribeSignal?.()
|
||||
unsubscribeSignal = undefined
|
||||
ctx.log.write("info", "memory diagnostics stopped", { file: memoryFile })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.type === "timer.schedule") {
|
||||
memoryTimer = setTimeout(() => dispatchMemory({ type: "tick", run: effect.run }), interval)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.type === "timer.cancel") return clearTimeout(memoryTimer)
|
||||
|
||||
if (effect.type === "sample.report") {
|
||||
return ctx.log.write("error", "memory diagnostics sample failed", { error: effect.error })
|
||||
}
|
||||
|
||||
const failed = (cause: unknown) =>
|
||||
dispatchMemory({
|
||||
type: "sample.failed",
|
||||
attempt: effect.attempt,
|
||||
error: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
|
||||
if (effect.type === "sample.write") {
|
||||
void Promise.try(() => appendFile(memoryFile, effect.line, { mode: 0o600 })).then(
|
||||
() => dispatchMemory({ type: "sample.written", attempt: effect.attempt }),
|
||||
failed,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.try(() =>
|
||||
collectMemorySample(memoryFile, precise, {
|
||||
pending: (contentsId) => isRendererProbing(memoryState, contentsId),
|
||||
started: (contentsId) => dispatchMemory({ type: "probe.started", contentsId }),
|
||||
settled: (contentsId) => dispatchMemory({ type: "probe.settled", contentsId }),
|
||||
}),
|
||||
).then((line) => dispatchMemory({ type: "sample.collected", attempt: effect.attempt, line }), failed)
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const on = envInterval !== undefined || stored.value
|
||||
|
||||
if (on && !running.stop) running.stop = start()
|
||||
|
||||
if (on || !running.stop) return
|
||||
running.stop()
|
||||
running.stop = undefined
|
||||
}
|
||||
const sync = () => dispatchMemory({ type: "synced", enabled: envInterval !== undefined || stored.value })
|
||||
|
||||
// Started before the Ipc is provided, so the first state windows receive already says whether diagnostics run.
|
||||
sync()
|
||||
ctx.onCleanup(() => running.stop?.())
|
||||
ctx.onCleanup(() => dispatchMemory({ type: "disposed" }))
|
||||
|
||||
const provider = ctx.provide(MemoryDiagnostics, {
|
||||
state: () => ({ on: running.stop !== undefined, env: envInterval !== undefined }),
|
||||
state: () => ({ on: isMemoryDiagnosticsOn(memoryState), env: envInterval !== undefined }),
|
||||
set: (enabled) => {
|
||||
stored.set(enabled)
|
||||
sync()
|
||||
@@ -240,27 +345,27 @@ const setup: Plugin.MainSetup = (ctx) => {
|
||||
async function recordNative(directory: string) {
|
||||
const file = path.join(directory, "memory-infra.json")
|
||||
|
||||
return (
|
||||
return within(
|
||||
contentTracing
|
||||
.startRecording(traceConfig)
|
||||
// The first detailed dump arrives within a periodic interval of starting.
|
||||
.then(() => new Promise((resolve) => setTimeout(resolve, 2_500)))
|
||||
.then(() => contentTracing.stopRecording(file))
|
||||
.then(async () => ({ file: path.basename(file), summary: summarize(await readFile(file, "utf8")) }))
|
||||
.catch((error: Error) => ({ error: error.message }))
|
||||
)
|
||||
.then(async () => ({ file: path.basename(file), summary: summarize(await readFile(file, "utf8")) })),
|
||||
15_000,
|
||||
"The memory trace did not finish within 15 seconds.",
|
||||
).catch((error: Error) => ({ error: error.message }))
|
||||
}
|
||||
|
||||
async function describeWindow(window: BrowserWindow) {
|
||||
const contents = window.webContents
|
||||
|
||||
// A hung or busy renderer never answers, and the snapshots wait for this step, so give up after a few seconds.
|
||||
const page = await Promise.race([
|
||||
const page = await within(
|
||||
contents.executeJavaScript(pageStatsScript).then(decodePageStats),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("The window did not answer within 3 seconds.")), 3_000),
|
||||
),
|
||||
]).catch((error: Error) => ({ error: error.message }))
|
||||
3_000,
|
||||
"The window did not answer within 3 seconds.",
|
||||
).catch((error: Error) => ({ error: error.message }))
|
||||
|
||||
return {
|
||||
window: window.id,
|
||||
@@ -278,12 +383,29 @@ function captureWindow(window: BrowserWindow, directory: string) {
|
||||
const pid = contents.getOSProcessId()
|
||||
const file = `window-${window.id}-${pid}.heapsnapshot`
|
||||
|
||||
return contents.takeHeapSnapshot(path.join(directory, file)).then(
|
||||
// A hung renderer is recorded as failed, so the other windows and main are still captured.
|
||||
return within(
|
||||
contents.takeHeapSnapshot(path.join(directory, file)),
|
||||
SNAPSHOT_TIMEOUT_MS,
|
||||
"The window did not write its heap snapshot within 5 minutes.",
|
||||
).then(
|
||||
(): Snapshot => ({ process: "window", pid, window: window.id, file }),
|
||||
(error: Error): Snapshot => ({ process: "window", pid, window: window.id, error: error.message }),
|
||||
)
|
||||
}
|
||||
|
||||
/** Rejects with `message` when `promise` takes longer than `ms`. */
|
||||
function within<Value>(promise: Promise<Value>, ms: number, message: string) {
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
deadline = setTimeout(() => reject(new Error(message)), ms)
|
||||
}),
|
||||
]).finally(() => clearTimeout(deadline))
|
||||
}
|
||||
|
||||
function write<Value>(directory: string, file: string, value: Value) {
|
||||
return writeFile(path.join(directory, file), JSON.stringify(value, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { app, webContents } from "electron"
|
||||
import { Schema } from "effect"
|
||||
import type { Log } from "@opencode/plugin/gui/main"
|
||||
import { memoryRecord, RendererMemory, type RendererRecord } from "./memory-record"
|
||||
|
||||
const RENDERER_PROBE_TIMEOUT_MS = 2_000
|
||||
|
||||
// Runs only while diagnostics are on; no preload hooks, observers, or store subscriptions.
|
||||
const probe = `(() => {
|
||||
const memory = performance.memory;
|
||||
@@ -14,67 +15,51 @@ const probe = `(() => {
|
||||
};
|
||||
})()`
|
||||
|
||||
/** Appends a sample every `interval` ms to a new JSONL file in the desktop logs, which exported debug logs include. */
|
||||
export function startMemoryLog(interval: number, log: Log) {
|
||||
const file = path.join(
|
||||
app.getPath("userData"),
|
||||
"logs",
|
||||
`memory-${new Date().toISOString().replace(/[:.]/g, "")}.jsonl`,
|
||||
/** A new JSONL file in the desktop logs, which exported debug logs include. */
|
||||
export function memoryLogFile() {
|
||||
return path.join(app.getPath("userData"), "logs", `memory-${new Date().toISOString().replace(/[:.]/g, "")}.jsonl`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects one sample line for `file`, creating its folder, so appending it is the only step left. A renderer whose
|
||||
* earlier probe has not settled is skipped, and a probe that takes too long is reported without waiting for it.
|
||||
*/
|
||||
export async function collectMemorySample(
|
||||
file: string,
|
||||
precise: boolean,
|
||||
probes: {
|
||||
readonly pending: (contentsId: number) => boolean
|
||||
readonly started: (contentsId: number) => void
|
||||
readonly settled: (contentsId: number) => void
|
||||
},
|
||||
) {
|
||||
const metrics = app.getAppMetrics()
|
||||
|
||||
const renderers = await Promise.all(
|
||||
webContents.getAllWebContents().map(async (contents): Promise<RendererRecord> => {
|
||||
const identity = { id: contents.id, pid: contents.getOSProcessId(), type: contents.getType() }
|
||||
|
||||
if (probes.pending(contents.id)) return { ...identity, error: "Previous renderer sample is still pending" }
|
||||
probes.started(contents.id)
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined
|
||||
const reading = contents.executeJavaScript(probe).then(Schema.decodeUnknownSync(RendererMemory))
|
||||
void reading.finally(() => probes.settled(contents.id)).catch(() => undefined)
|
||||
|
||||
return Promise.race([
|
||||
reading,
|
||||
new Promise<never>((_, reject) => {
|
||||
deadline = setTimeout(() => reject(new Error("Renderer sample timed out")), RENDERER_PROBE_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
.then(
|
||||
(sample) => ({ ...identity, sample }),
|
||||
(error: Error) => ({ ...identity, error: error.message }),
|
||||
)
|
||||
.finally(() => clearTimeout(deadline))
|
||||
}),
|
||||
)
|
||||
|
||||
// Without the launch switch, Chromium quantizes renderer heap sizes and refreshes them rarely.
|
||||
const precise = app.commandLine.hasSwitch("enable-precise-memory-info")
|
||||
const pending = new WeakSet<Electron.WebContents>()
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
|
||||
const sample = async () => {
|
||||
const metrics = app.getAppMetrics()
|
||||
|
||||
const renderers = await Promise.all(
|
||||
webContents.getAllWebContents().map(async (contents): Promise<RendererRecord> => {
|
||||
const identity = { id: contents.id, pid: contents.getOSProcessId(), type: contents.getType() }
|
||||
|
||||
if (pending.has(contents)) return { ...identity, error: "Previous renderer sample is still pending" }
|
||||
pending.add(contents)
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined
|
||||
// A hung renderer must not stop native-memory logging or accumulate evaluation requests.
|
||||
const reading = contents.executeJavaScript(probe).then(Schema.decodeUnknownSync(RendererMemory))
|
||||
void reading.finally(() => pending.delete(contents)).catch(() => undefined)
|
||||
|
||||
return Promise.race([
|
||||
reading,
|
||||
new Promise<never>((_, reject) => {
|
||||
deadline = setTimeout(() => reject(new Error("Renderer sample timed out")), 2000)
|
||||
}),
|
||||
])
|
||||
.then(
|
||||
(sample) => ({ ...identity, sample }),
|
||||
(error: Error) => ({ ...identity, error: error.message }),
|
||||
)
|
||||
.finally(() => clearTimeout(deadline))
|
||||
}),
|
||||
)
|
||||
|
||||
if (stopped) return
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await appendFile(file, `${JSON.stringify(memoryRecord(metrics, renderers, precise))}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
void sample()
|
||||
.catch((error: Error) => log.write("error", "memory diagnostics sample failed", { error: error.message }))
|
||||
.finally(() => {
|
||||
if (!stopped) timer = setTimeout(tick, interval)
|
||||
})
|
||||
}
|
||||
|
||||
log.write("info", "memory diagnostics started", { file, intervalMs: interval, precise })
|
||||
timer = setTimeout(tick, interval)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
clearTimeout(timer)
|
||||
log.write("info", "memory diagnostics stopped", { file })
|
||||
}
|
||||
return `${JSON.stringify(memoryRecord(metrics, renderers, precise))}\n`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createMemoryDiagnosticsState,
|
||||
debugBarView,
|
||||
isMemoryDiagnosticsOn,
|
||||
isRendererProbing,
|
||||
transitionMemoryDiagnostics,
|
||||
type MemoryDiagnosticsEvent,
|
||||
type MemoryDiagnosticsState,
|
||||
} from "./memory-machine"
|
||||
|
||||
const on: MemoryDiagnosticsEvent = { type: "synced", enabled: true }
|
||||
|
||||
const off: MemoryDiagnosticsEvent = { type: "synced", enabled: false }
|
||||
|
||||
function after(...events: MemoryDiagnosticsEvent[]) {
|
||||
return events.reduce<MemoryDiagnosticsState>(
|
||||
(state, event) => transitionMemoryDiagnostics(state, event).state,
|
||||
createMemoryDiagnosticsState(),
|
||||
)
|
||||
}
|
||||
|
||||
describe("transitionMemoryDiagnostics", () => {
|
||||
test("turning on opens a run and waits an interval for the first sample", () => {
|
||||
const started = transitionMemoryDiagnostics(createMemoryDiagnosticsState(), on)
|
||||
|
||||
expect(started.effects).toEqual([
|
||||
{ type: "log.start", run: 1 },
|
||||
{ type: "timer.schedule", run: 1 },
|
||||
])
|
||||
expect(isMemoryDiagnosticsOn(started.state)).toBe(true)
|
||||
expect(transitionMemoryDiagnostics(started.state, on).state).toBe(started.state)
|
||||
})
|
||||
|
||||
test("a sample still collecting when diagnostics turn off writes nothing and schedules nothing", () => {
|
||||
const sampling = after(on, { type: "tick", run: 1 })
|
||||
const stopped = transitionMemoryDiagnostics(sampling, off)
|
||||
|
||||
expect(stopped.effects).toEqual([{ type: "timer.cancel" }, { type: "log.stop", run: 1 }])
|
||||
expect(
|
||||
transitionMemoryDiagnostics(stopped.state, { type: "sample.collected", attempt: 2, line: "{}\n" }).effects,
|
||||
).toEqual([])
|
||||
expect(transitionMemoryDiagnostics(stopped.state, { type: "sample.written", attempt: 2 }).effects).toEqual([])
|
||||
|
||||
// Nor once diagnostics are on again: the sample belongs to the earlier run.
|
||||
const restarted = after(on, { type: "tick", run: 1 }, off, on)
|
||||
|
||||
expect(
|
||||
transitionMemoryDiagnostics(restarted, { type: "sample.collected", attempt: 2, line: "{}\n" }).state,
|
||||
).toBe(restarted)
|
||||
})
|
||||
|
||||
test("a hung renderer stays skipped across off and on until its probe settles", () => {
|
||||
const state = after(on, { type: "probe.started", contentsId: 7 }, off, on)
|
||||
|
||||
expect(isRendererProbing(state, 7)).toBe(true)
|
||||
expect(isRendererProbing(transitionMemoryDiagnostics(state, { type: "probe.settled", contentsId: 7 }).state, 7)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("a sample writes its line, then waits for the next interval; a failure is logged and waits too", () => {
|
||||
const sampling = transitionMemoryDiagnostics(after(on), { type: "tick", run: 1 })
|
||||
|
||||
expect(sampling.effects).toEqual([{ type: "sample.collect", attempt: 2 }])
|
||||
expect(transitionMemoryDiagnostics(sampling.state, { type: "tick", run: 1 }).state).toBe(sampling.state)
|
||||
|
||||
const collected = transitionMemoryDiagnostics(sampling.state, {
|
||||
type: "sample.collected",
|
||||
attempt: 2,
|
||||
line: "{}\n",
|
||||
})
|
||||
|
||||
expect(collected.effects).toEqual([{ type: "sample.write", attempt: 2, line: "{}\n" }])
|
||||
expect(transitionMemoryDiagnostics(collected.state, { type: "sample.written", attempt: 2 }).effects).toEqual([
|
||||
{ type: "timer.schedule", run: 1 },
|
||||
])
|
||||
expect(
|
||||
transitionMemoryDiagnostics(sampling.state, { type: "sample.failed", attempt: 2, error: "EACCES" }).effects,
|
||||
).toEqual([
|
||||
{ type: "sample.report", error: "EACCES" },
|
||||
{ type: "timer.schedule", run: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test("disposing stops a running log", () => {
|
||||
expect(transitionMemoryDiagnostics(after(on), { type: "disposed" })).toEqual({
|
||||
state: { status: "disposed" },
|
||||
effects: [{ type: "timer.cancel" }, { type: "log.stop", run: 1 }],
|
||||
})
|
||||
expect(transitionMemoryDiagnostics(after(), { type: "disposed" }).effects).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("debugBarView", () => {
|
||||
test("running diagnostics pin the bar without a close button, and the Knobs open from it", () => {
|
||||
expect(debugBarView({ visible: false, motion: true }, { on: true })).toEqual({
|
||||
visibility: "pinned",
|
||||
open: true,
|
||||
closable: false,
|
||||
togglable: false,
|
||||
motion: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("otherwise the stored visibility decides", () => {
|
||||
expect(debugBarView({ visible: true, motion: false }, { on: false })).toEqual({
|
||||
visibility: "shown",
|
||||
open: true,
|
||||
closable: true,
|
||||
togglable: true,
|
||||
motion: false,
|
||||
})
|
||||
expect(debugBarView({ visible: false, motion: true }, undefined)).toMatchObject({ open: false, motion: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Main's memory diagnostics. `on` writes one sample per interval to the run's JSONL file: `waiting` for the timer,
|
||||
* then `sampling` while the sample is collected and appended. A sample of an earlier attempt, or of a run that turned
|
||||
* off, writes nothing. `probing` lists the `WebContents.id`s whose renderer probe has not settled, across runs, so a
|
||||
* hung renderer gets one evaluation at a time however often diagnostics turn off and on. `issued` numbers runs and
|
||||
* samples alike.
|
||||
*/
|
||||
export type MemoryDiagnosticsState =
|
||||
| { readonly status: "off"; readonly issued: number; readonly probing: ReadonlySet<number> }
|
||||
| {
|
||||
readonly status: "on"
|
||||
readonly issued: number
|
||||
readonly run: number
|
||||
readonly sampling: number | undefined
|
||||
readonly probing: ReadonlySet<number>
|
||||
}
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
export type MemoryDiagnosticsEvent =
|
||||
/** Whether diagnostics should run: the environment asks for them, or the debug bar's stored toggle does. */
|
||||
| { readonly type: "synced"; readonly enabled: boolean }
|
||||
| { readonly type: "tick"; readonly run: number }
|
||||
| { readonly type: "probe.started"; readonly contentsId: number }
|
||||
| { readonly type: "probe.settled"; readonly contentsId: number }
|
||||
| { readonly type: "sample.collected"; readonly attempt: number; readonly line: string }
|
||||
| { readonly type: "sample.written"; readonly attempt: number }
|
||||
| { readonly type: "sample.failed"; readonly attempt: number; readonly error: string }
|
||||
| { readonly type: "disposed" }
|
||||
|
||||
export type MemoryDiagnosticsEffect =
|
||||
/** Opens the run's file and, where the platform has it, the heap snapshot signal. */
|
||||
| { readonly type: "log.start"; readonly run: number }
|
||||
| { readonly type: "log.stop"; readonly run: number }
|
||||
| { readonly type: "timer.schedule"; readonly run: number }
|
||||
| { readonly type: "timer.cancel" }
|
||||
| { readonly type: "sample.collect"; readonly attempt: number }
|
||||
| { readonly type: "sample.write"; readonly attempt: number; readonly line: string }
|
||||
| { readonly type: "sample.report"; readonly error: string }
|
||||
|
||||
export type MemoryDiagnosticsTransition = {
|
||||
readonly state: MemoryDiagnosticsState
|
||||
readonly effects: readonly MemoryDiagnosticsEffect[]
|
||||
}
|
||||
|
||||
export function createMemoryDiagnosticsState(): MemoryDiagnosticsState {
|
||||
return { status: "off", issued: 0, probing: new Set() }
|
||||
}
|
||||
|
||||
export function transitionMemoryDiagnostics(
|
||||
state: MemoryDiagnosticsState,
|
||||
event: MemoryDiagnosticsEvent,
|
||||
): MemoryDiagnosticsTransition {
|
||||
if (state.status === "disposed") return { state, effects: [] }
|
||||
|
||||
if (event.type === "disposed") {
|
||||
return { state: { status: "disposed" }, effects: state.status === "on" ? stop(state.run) : [] }
|
||||
}
|
||||
|
||||
if (event.type === "probe.started") {
|
||||
if (state.probing.has(event.contentsId)) return { state, effects: [] }
|
||||
|
||||
return { state: { ...state, probing: new Set([...state.probing, event.contentsId]) }, effects: [] }
|
||||
}
|
||||
|
||||
if (event.type === "probe.settled") {
|
||||
if (!state.probing.has(event.contentsId)) return { state, effects: [] }
|
||||
|
||||
return {
|
||||
state: { ...state, probing: new Set([...state.probing].filter((id) => id !== event.contentsId)) },
|
||||
effects: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "synced") {
|
||||
if (event.enabled === (state.status === "on")) return { state, effects: [] }
|
||||
|
||||
if (state.status === "on") {
|
||||
return { state: { status: "off", issued: state.issued, probing: state.probing }, effects: stop(state.run) }
|
||||
}
|
||||
|
||||
const run = state.issued + 1
|
||||
|
||||
return {
|
||||
state: { status: "on", issued: run, run, sampling: undefined, probing: state.probing },
|
||||
effects: [
|
||||
{ type: "log.start", run },
|
||||
{ type: "timer.schedule", run },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status === "off") return { state, effects: [] }
|
||||
|
||||
if (event.type === "tick") {
|
||||
if (event.run !== state.run || state.sampling !== undefined) return { state, effects: [] }
|
||||
const attempt = state.issued + 1
|
||||
|
||||
return { state: { ...state, issued: attempt, sampling: attempt }, effects: [{ type: "sample.collect", attempt }] }
|
||||
}
|
||||
|
||||
if (event.attempt !== state.sampling) return { state, effects: [] }
|
||||
|
||||
if (event.type === "sample.collected") {
|
||||
return { state, effects: [{ type: "sample.write", attempt: event.attempt, line: event.line }] }
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sampling: undefined },
|
||||
effects: [
|
||||
...(event.type === "sample.failed" ? [{ type: "sample.report" as const, error: event.error }] : []),
|
||||
{ type: "timer.schedule", run: state.run },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether diagnostics run, as the debug bar shows them. */
|
||||
export function isMemoryDiagnosticsOn(state: MemoryDiagnosticsState) {
|
||||
return state.status === "on"
|
||||
}
|
||||
|
||||
/** Whether a renderer's earlier probe is still unanswered, so the next sample skips it. */
|
||||
export function isRendererProbing(state: MemoryDiagnosticsState, contentsId: number) {
|
||||
return state.status !== "disposed" && state.probing.has(contentsId)
|
||||
}
|
||||
|
||||
function stop(run: number): MemoryDiagnosticsEffect[] {
|
||||
return [{ type: "timer.cancel" }, { type: "log.stop", run }]
|
||||
}
|
||||
|
||||
// --- The debug bar in a window ---
|
||||
|
||||
/**
|
||||
* `pinned` keeps the bar open while memory diagnostics run, so they are never silently on; it has no close button
|
||||
* then, and the Knobs workspace follows the bar rather than the stored visibility.
|
||||
*/
|
||||
export type DebugBarVisibility = "hidden" | "shown" | "pinned"
|
||||
|
||||
export function debugBarView(
|
||||
bar: { readonly visible: boolean; readonly motion: boolean },
|
||||
memory: { readonly on: boolean } | undefined,
|
||||
) {
|
||||
const visibility: DebugBarVisibility = memory?.on ? "pinned" : bar.visible ? "shown" : "hidden"
|
||||
|
||||
return {
|
||||
visibility,
|
||||
open: visibility !== "hidden",
|
||||
closable: visibility === "shown",
|
||||
togglable: visibility !== "pinned",
|
||||
motion: visibility !== "hidden" && bar.motion,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { showToast, toaster } from "@opencode/ui/toast"
|
||||
import { lazy, Show, Suspense } from "solid-js"
|
||||
import { createMemo, createSignal, lazy, onCleanup, Show, Suspense } from "solid-js"
|
||||
import { createKeyed, Command, Slot, type Plugin } from "@opencode/plugin/gui"
|
||||
import type Debug from "./index"
|
||||
import { HeapSnapshots, MemoryDiagnostics } from "./contract"
|
||||
import {
|
||||
createHeapCommandState,
|
||||
isHeapCommandEnabled,
|
||||
transitionHeapCommand,
|
||||
type HeapCommandEffect,
|
||||
type HeapCommandEvent,
|
||||
} from "./heap-machine"
|
||||
import { debugBarView } from "./memory-machine"
|
||||
|
||||
const setup: Plugin.Setup<typeof Debug> = (ctx) => {
|
||||
const DebugBar = lazy(() => import("./bar"))
|
||||
@@ -11,8 +19,6 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
|
||||
const motionTools = import.meta.env.DEV || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
const Motion = motionTools ? lazy(() => import("./motion")) : undefined
|
||||
const bar = ctx.stores.bar
|
||||
const visible = () => bar.value.visible
|
||||
const motion = () => bar.value.motion
|
||||
|
||||
const toggle = () => {
|
||||
bar.update((draft) => {
|
||||
@@ -35,6 +41,8 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
|
||||
return live.status === "active" ? live.value.state() : undefined
|
||||
}
|
||||
|
||||
const barView = createMemo(() => debugBarView(bar.value, memoryState()))
|
||||
|
||||
const memoryControl = () => {
|
||||
const live = memory()
|
||||
const state = memoryState()
|
||||
@@ -64,6 +72,8 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
|
||||
title: ctx.t("command.toggle"),
|
||||
group: ctx.t("command.category.view"),
|
||||
keywords: "dev bar developer tools diagnostics",
|
||||
// Running diagnostics keep the bar open.
|
||||
enabled: barView().togglable,
|
||||
run: toggle,
|
||||
}),
|
||||
)
|
||||
@@ -72,7 +82,60 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
|
||||
|
||||
// Offered only while main answers: never on the web, and not while the plugin restarts.
|
||||
if (desktop)
|
||||
createKeyed(ctx.use(HeapSnapshots), (heap) =>
|
||||
createKeyed(ctx.use(HeapSnapshots), (heap) => {
|
||||
const [heapState, setHeapState] = createSignal(createHeapCommandState())
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const toasts = new Map<number, ReturnType<typeof showToast>>()
|
||||
|
||||
const dispatch = (event: HeapCommandEvent) => {
|
||||
const next = transitionHeapCommand(heapState(), event)
|
||||
|
||||
setHeapState(next.state)
|
||||
next.effects.forEach(
|
||||
(effect) =>
|
||||
void Promise.try(() => run(effect)).catch((cause: unknown) => console.error("[debug] heap", cause)),
|
||||
)
|
||||
}
|
||||
|
||||
const run = (effect: HeapCommandEffect) => {
|
||||
if (effect.type === "capture.start") {
|
||||
const controller = new AbortController()
|
||||
|
||||
controllers.set(effect.attempt, controller)
|
||||
toasts.set(effect.attempt, showToast({ variant: "loading", title: ctx.t("heap.loading"), persistent: true }))
|
||||
void Promise.try(() => heap.write({ signal: AbortSignal.any([ctx.signal, controller.signal]) })).then(
|
||||
(result) => dispatch({ type: "succeeded", attempt: effect.attempt, result }),
|
||||
() => dispatch({ type: "failed", attempt: effect.attempt }),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const loading = toasts.get(effect.attempt)
|
||||
|
||||
controllers.get(effect.attempt)?.abort()
|
||||
controllers.delete(effect.attempt)
|
||||
toasts.delete(effect.attempt)
|
||||
|
||||
// Without an id, dismiss clears every toast.
|
||||
if (loading !== undefined) toaster.dismiss(loading)
|
||||
|
||||
if (effect.type === "capture.abort") return
|
||||
|
||||
if (effect.type === "toast.failed") return void showToast({ variant: "error", title: ctx.t("heap.failed") })
|
||||
const result = effect.result
|
||||
|
||||
showToast({
|
||||
variant: result.complete ? "success" : "error",
|
||||
title: ctx.t(result.complete ? "heap.done" : "heap.partial"),
|
||||
description: result.directory,
|
||||
persistent: true,
|
||||
actions: [{ label: ctx.t("heap.reveal"), onClick: () => void desktop.reveal(result.directory) }],
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => dispatch({ type: "disposed" }))
|
||||
|
||||
ctx.add(Command, {
|
||||
id: "heap",
|
||||
get title() {
|
||||
@@ -84,46 +147,31 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
|
||||
get group() {
|
||||
return ctx.t("command.category.settings")
|
||||
},
|
||||
run: async () => {
|
||||
const loading = showToast({ variant: "loading", title: ctx.t("heap.loading"), persistent: true })
|
||||
|
||||
const result = await heap.write({ signal: ctx.signal }).catch(() => undefined)
|
||||
|
||||
toaster.dismiss(loading)
|
||||
|
||||
if (ctx.signal.aborted) return
|
||||
|
||||
if (!result) return void showToast({ variant: "error", title: ctx.t("heap.failed") })
|
||||
|
||||
showToast({
|
||||
variant: result.complete ? "success" : "error",
|
||||
title: ctx.t(result.complete ? "heap.done" : "heap.partial"),
|
||||
description: result.directory,
|
||||
persistent: true,
|
||||
actions: [{ label: ctx.t("heap.reveal"), onClick: () => void desktop.reveal(result.directory) }],
|
||||
})
|
||||
get enabled() {
|
||||
return isHeapCommandEnabled(heapState())
|
||||
},
|
||||
}),
|
||||
)
|
||||
run: () => dispatch({ type: "run" }),
|
||||
})
|
||||
})
|
||||
|
||||
ctx.add(Slot, {
|
||||
at: "window.bottom",
|
||||
render: () => (
|
||||
<>
|
||||
<Show when={visible() || memoryState()?.on}>
|
||||
<Show when={barView().open}>
|
||||
<Suspense>
|
||||
<DebugBar
|
||||
devTools={import.meta.env.DEV}
|
||||
memory={memoryControl()}
|
||||
onClose={hide}
|
||||
motion={Motion ? { open: motion(), toggle: toggleMotion } : undefined}
|
||||
onClose={barView().closable ? hide : undefined}
|
||||
motion={Motion ? { open: barView().motion, toggle: toggleMotion } : undefined}
|
||||
inline
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
{Motion && (
|
||||
// The Knobs workspace opens only from the debug bar's KNOBS cell, and shows while the bar does.
|
||||
<Show when={visible() && motion()}>
|
||||
<Show when={barView().motion}>
|
||||
<Suspense>
|
||||
<Motion title={ctx.t("motion.title")} onClose={toggleMotion} />
|
||||
</Suspense>
|
||||
|
||||
Reference in New Issue
Block a user