Compare commits

...

1 Commits

Author SHA1 Message Date
Adam
87e4495713 chore: cleanup (memory leaks) 2026-02-09 19:09:50 -06:00
15 changed files with 401 additions and 131 deletions

View File

@@ -39,6 +39,13 @@ interface EditRowProps {
}
function AddRow(props: AddRowProps) {
let frame: number | undefined
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
@@ -51,7 +58,9 @@ function AddRow(props: AddRowProps) {
}}
ref={(el) => {
// Position relative to input-wrapper
requestAnimationFrame(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
frame = undefined
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
if (wrapper instanceof HTMLElement) {
wrapper.appendChild(el)
@@ -151,6 +160,16 @@ export function DialogSelectServer() {
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const fetcher = platform.fetch ?? globalThis.fetch
const addPreview = { value: 0 }
const editPreview = { value: 0 }
let scrollFrame: number | undefined
onCleanup(() => {
addPreview.value += 1
editPreview.value += 1
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
})
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
@@ -161,16 +180,24 @@ export function DialogSelectServer() {
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
const previewStatus = async (
value: string,
setStatus: (value: boolean | undefined) => void,
token: { value: number },
) => {
const run = token.value + 1
token.value = run
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
if (token.value !== run) return
setStatus(result.healthy)
}
const resetAdd = () => {
addPreview.value += 1
setStore("addServer", {
url: "",
error: "",
@@ -180,6 +207,7 @@ export function DialogSelectServer() {
}
const resetEdit = () => {
editPreview.value += 1
setStore("editServer", {
id: undefined,
value: "",
@@ -227,21 +255,36 @@ export function DialogSelectServer() {
})
})
async function refreshHealth() {
const results: Record<string, ServerHealth> = {}
await Promise.all(
items().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
items()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
let alive = true
let run = 0
const refresh = async () => {
const id = run + 1
run = id
const results: Record<string, ServerHealth> = {}
await Promise.all(
items().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
if (!alive) return
if (id !== run) return
setStore("status", reconcile(results))
}
void refresh()
const interval = setInterval(() => {
void refresh()
}, 10_000)
onCleanup(() => {
alive = false
clearInterval(interval)
})
})
async function select(value: string, persist?: boolean) {
@@ -259,13 +302,15 @@ export function DialogSelectServer() {
const handleAddChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, (next) => setStore("addServer", { status: next }))
void previewStatus(value, (next) => setStore("addServer", { status: next }), addPreview)
}
const scrollListToBottom = () => {
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
if (!scroll) return
requestAnimationFrame(() => {
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
scroll.scrollTop = scroll.scrollHeight
})
}
@@ -273,7 +318,7 @@ export function DialogSelectServer() {
const handleEditChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { value, error: "" })
void previewStatus(value, (next) => setStore("editServer", { status: next }))
void previewStatus(value, (next) => setStore("editServer", { status: next }), editPreview)
}
async function handleAdd(value: string) {

View File

@@ -1,4 +1,4 @@
import { Accessor } from "solid-js"
import { Accessor, onCleanup } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
@@ -72,14 +72,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return language.t("common.requestFailed")
}
const clearPending = (sessionID: string) => {
const queued = pending.get(sessionID)
if (!queued) return false
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
return true
}
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
const queued = pending.get(sessionID)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
if (clearPending(sessionID)) {
return Promise.resolve()
}
return sdk.client.session
@@ -89,6 +94,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch(() => {})
}
onCleanup(() => {
const sessionID = params.id
if (!sessionID) return
clearPending(sessionID)
})
const restoreCommentItems = (items: CommentItem[]) => {
for (const item of items) {
prompt.context.add({
@@ -366,8 +377,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timeoutMs = 5 * 60 * 1000
const timer = { id: undefined as number | undefined }
let timedOut = false
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
timedOut = true
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
}, timeoutMs)
})
@@ -378,6 +391,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
pending.delete(session.id)
if (controller.signal.aborted) return false
if (timedOut) {
WorktreeState.forget(sessionDirectory, "worktree wait timeout")
}
if (result.status === "failed") throw new Error(result.message)
return true
}

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, type Component } from "solid-js"
import { For, Show, createMemo, onCleanup, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -21,6 +21,14 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
editing: false,
sending: false,
})
let focusTimer: number | undefined
let alive = true
onCleanup(() => {
alive = false
if (focusTimer === undefined) return
clearTimeout(focusTimer)
})
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
@@ -45,7 +53,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
sdk.client.question
.reply({ requestID: props.request.id, answers })
.catch(fail)
.finally(() => setStore("sending", false))
.finally(() => {
if (!alive) return
setStore("sending", false)
})
}
const reject = () => {
@@ -55,7 +66,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
sdk.client.question
.reject({ requestID: props.request.id })
.catch(fail)
.finally(() => setStore("sending", false))
.finally(() => {
if (!alive) return
setStore("sending", false)
})
}
const submit = () => {
@@ -218,7 +232,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
ref={(el) => {
if (focusTimer !== undefined) clearTimeout(focusTimer)
focusTimer = window.setTimeout(() => {
focusTimer = undefined
el.focus()
}, 0)
}}
type="text"
data-slot="custom-input"
placeholder={language.t("ui.question.custom.placeholder")}

View File

@@ -116,6 +116,11 @@ export function SessionHeader() {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
let alive = true
onCleanup(() => {
alive = false
})
const list = os()
const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : []
if (apps.length === 0) return
@@ -129,6 +134,7 @@ export function SessionHeader() {
}),
),
).then((entries) => {
if (!alive) return
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})

View File

@@ -1,5 +1,5 @@
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { Show, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,6 +20,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
let editTimer: ReturnType<typeof setTimeout> | undefined
let blurTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (editTimer !== undefined) clearTimeout(editTimer)
if (blurTimer !== undefined) clearTimeout(blurTimer)
})
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
@@ -74,15 +81,23 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
e.preventDefault()
}
if (editTimer !== undefined) clearTimeout(editTimer)
if (blurTimer !== undefined) clearTimeout(blurTimer)
setStore("blurEnabled", false)
setStore("title", props.terminal.title)
setStore("editing", true)
setTimeout(() => {
editTimer = setTimeout(() => {
editTimer = undefined
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
if (!input) return
input.focus()
input.select()
setTimeout(() => setStore("blurEnabled", true), 100)
blurTimer = setTimeout(() => {
blurTimer = undefined
setStore("blurEnabled", true)
}, 100)
}, 10)
}

View File

@@ -1,4 +1,4 @@
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { Component, Show, createMemo, createResource, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -13,31 +13,35 @@ import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const playDemoSound = (src: string) => {
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src)
}, 100)
}
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const demoSound = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as ReturnType<typeof setTimeout> | undefined,
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const playDemoSound = (src: string) => {
demoSound.cleanup?.()
if (demoSound.timeout !== undefined) clearTimeout(demoSound.timeout)
demoSound.timeout = setTimeout(() => {
demoSound.cleanup = playSound(src)
}, 100)
}
onCleanup(() => {
if (demoSound.timeout !== undefined) clearTimeout(demoSound.timeout)
demoSound.cleanup?.()
demoSound.cleanup = undefined
demoSound.timeout = undefined
})
const [store, setStore] = createStore({
checking: false,
})

View File

@@ -60,21 +60,36 @@ export function StatusPopover() {
})
})
async function refreshHealth() {
const results: Record<string, ServerHealth> = {}
await Promise.all(
servers().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
servers()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
let alive = true
let run = 0
const refresh = async () => {
const id = run + 1
run = id
const results: Record<string, ServerHealth> = {}
await Promise.all(
servers().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
if (!alive) return
if (id !== run) return
setStore("status", reconcile(results))
}
void refresh()
const interval = setInterval(() => {
void refresh()
}, 10_000)
onCleanup(() => {
alive = false
clearInterval(interval)
})
})
const mcpItems = createMemo(() =>
@@ -85,6 +100,25 @@ export function StatusPopover() {
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const refreshDefaultServerUrl = (alive: () => boolean = () => true) => {
const result = platform.getDefaultServerUrl?.()
if (!result) {
if (!alive()) return
setStore("defaultServerUrl", undefined)
return
}
void Promise.resolve(result)
.then((url) => {
if (!alive()) return
setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined)
})
.catch(() => {
if (!alive()) return
setStore("defaultServerUrl", undefined)
})
}
const toggleMcp = async (name: string) => {
if (store.loading) return
setStore("loading", name)
@@ -118,21 +152,12 @@ export function StatusPopover() {
const serverCount = createMemo(() => sortedServers().length)
const refreshDefaultServerUrl = () => {
const result = platform.getDefaultServerUrl?.()
if (!result) {
setStore("defaultServerUrl", undefined)
return
}
if (result instanceof Promise) {
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
return
}
setStore("defaultServerUrl", normalizeServerUrl(result))
}
createEffect(() => {
refreshDefaultServerUrl()
let alive = true
onCleanup(() => {
alive = false
})
refreshDefaultServerUrl(() => alive)
})
return (

View File

@@ -157,6 +157,7 @@ export default function Layout(props: ParentProps) {
onCleanup(() => {
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
aim.reset()
scrollContainerRef = undefined
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -321,6 +322,33 @@ export default function Layout(props: ParentProps) {
const toastBySession = new Map<string, number>()
const alertedAtBySession = new Map<string, number>()
const cooldownMs = 5000
const trackedSessionLimit = 500
const trimAlertedSessions = () => {
while (alertedAtBySession.size > trackedSessionLimit) {
const oldest = alertedAtBySession.keys().next().value as string | undefined
if (!oldest) return
alertedAtBySession.delete(oldest)
}
}
const trimToastSessions = () => {
while (toastBySession.size > trackedSessionLimit) {
const oldest = toastBySession.keys().next().value as string | undefined
if (!oldest) return
const toastId = toastBySession.get(oldest)
if (toastId !== undefined) toaster.dismiss(toastId)
toastBySession.delete(oldest)
alertedAtBySession.delete(oldest)
}
}
const clearTrackedSession = (sessionKey: string) => {
const toastId = toastBySession.get(sessionKey)
if (toastId !== undefined) toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type === "worktree.ready") {
@@ -361,6 +389,7 @@ export default function Layout(props: ParentProps) {
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
if (now - lastAlerted < cooldownMs) return
alertedAtBySession.set(sessionKey, now)
trimAlertedSessions()
if (e.details.type === "permission.asked") {
playSound(soundSrc(settings.sounds.permissions()))
@@ -390,38 +419,39 @@ export default function Layout(props: ParentProps) {
actions: [
{
label: language.t("notification.action.goToSession"),
onClick: () => navigate(href),
onClick: () => {
clearTrackedSession(sessionKey)
navigate(href)
},
},
{
label: language.t("common.dismiss"),
onClick: "dismiss",
onClick: () => clearTrackedSession(sessionKey),
},
],
})
toastBySession.set(sessionKey, toastId)
trimToastSessions()
})
onCleanup(() => {
for (const toastId of toastBySession.values()) {
toaster.dismiss(toastId)
}
toastBySession.clear()
alertedAtBySession.clear()
unsub()
})
onCleanup(unsub)
createEffect(() => {
const currentSession = params.id
if (!currentDir() || !currentSession) return
const sessionKey = `${currentDir()}:${currentSession}`
const toastId = toastBySession.get(sessionKey)
if (toastId !== undefined) {
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
clearTrackedSession(sessionKey)
const [store] = globalSync.child(currentDir(), { bootstrap: false })
const childSessions = store.session.filter((s) => s.parentID === currentSession)
for (const child of childSessions) {
const childKey = `${currentDir()}:${child.id}`
const childToastId = toastBySession.get(childKey)
if (childToastId !== undefined) {
toaster.dismiss(childToastId)
toastBySession.delete(childKey)
alertedAtBySession.delete(childKey)
}
clearTrackedSession(childKey)
}
})
})
@@ -634,9 +664,41 @@ export default function Layout(props: ParentProps) {
globalSDK.url
prefetchToken.value += 1
for (const q of prefetchQueues.values()) {
for (const [directory, q] of prefetchQueues) {
q.pending.length = 0
q.pendingSet.clear()
if (q.running > 0) continue
if (q.inflight.size > 0) continue
prefetchQueues.delete(directory)
}
})
createEffect(() => {
const projects = layout.projects.list()
const activeDirectory = currentDir()
const keep = new Set<string>()
for (const project of projects) {
keep.add(project.worktree)
for (const sandbox of project.sandboxes ?? []) {
keep.add(sandbox)
}
}
if (activeDirectory) keep.add(activeDirectory)
for (const [directory, queue] of prefetchQueues) {
if (keep.has(directory)) continue
queue.pending.length = 0
queue.pendingSet.clear()
if (queue.running > 0) continue
if (queue.inflight.size > 0) continue
prefetchQueues.delete(directory)
}
for (const directory of prefetchedByDir.keys()) {
if (keep.has(directory)) continue
prefetchedByDir.delete(directory)
}
})
@@ -1151,8 +1213,16 @@ export default function Layout(props: ParentProps) {
}
function closeProject(directory: string) {
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
const next = layout.projects.list()[index + 1]
const projects = layout.projects.list()
const index = projects.findIndex((x) => x.worktree === directory)
const next = projects[index + 1]
const current = projects[index]
WorktreeState.forget(directory, "project closed")
for (const sandbox of current?.sandboxes ?? []) {
WorktreeState.forget(sandbox, "project closed")
}
layout.projects.close(directory)
if (next) navigateToProject(next.worktree)
else navigate("/")
@@ -1216,6 +1286,7 @@ export default function Layout(props: ParentProps) {
if (!result) return
WorktreeState.forget(directory, "workspace deleted")
layout.projects.close(directory)
layout.projects.open(root)

View File

@@ -32,8 +32,18 @@ export function FileTabContent(props: {
}) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let commentBlurTimer: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
const frames = new Set<number>()
const raf = (run: () => void) => {
const id = requestAnimationFrame(() => {
frames.delete(id)
run()
})
frames.add(id)
}
const path = createMemo(() => props.file.pathFromTab(props.tab))
const state = createMemo(() => {
@@ -204,7 +214,7 @@ export function FileTabContent(props: {
}
const scheduleComments = () => {
requestAnimationFrame(updateComments)
raf(updateComments)
}
createEffect(() => {
@@ -232,7 +242,7 @@ export function FileTabContent(props: {
setOpenedComment(target.id)
setCommenting(null)
props.file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => props.comments.clearFocus())
raf(() => props.comments.clearFocus())
})
const getCodeScroll = () => {
@@ -327,7 +337,7 @@ export function FileTabContent(props: {
() => state()?.loaded,
(loaded) => {
if (!loaded) return
requestAnimationFrame(restoreScroll)
raf(restoreScroll)
},
{ defer: true },
),
@@ -338,7 +348,7 @@ export function FileTabContent(props: {
() => props.file.ready(),
(ready) => {
if (!ready) return
requestAnimationFrame(restoreScroll)
raf(restoreScroll)
},
{ defer: true },
),
@@ -350,7 +360,7 @@ export function FileTabContent(props: {
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
raf(restoreScroll)
},
),
)
@@ -360,8 +370,11 @@ export function FileTabContent(props: {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (commentBlurTimer !== undefined) clearTimeout(commentBlurTimer)
for (const frame of frames) cancelAnimationFrame(frame)
frames.clear()
})
const renderCode = (source: string, wrapperClass: string) => (
@@ -383,8 +396,8 @@ export function FileTabContent(props: {
selectedLines={selectedLines()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(scheduleComments)
raf(restoreScroll)
raf(scheduleComments)
}}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
@@ -452,7 +465,9 @@ export function FileTabContent(props: {
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (commentBlurTimer !== undefined) clearTimeout(commentBlurTimer)
commentBlurTimer = window.setTimeout(() => {
commentBlurTimer = undefined
if (!document.activeElement || !current.contains(document.activeElement)) {
setCommenting(null)
}
@@ -478,12 +493,7 @@ export function FileTabContent(props: {
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
<img src={imageDataUrl()} alt={path()} class="max-w-full" onLoad={() => raf(restoreScroll)} />
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>

View File

@@ -71,6 +71,15 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const frames = new Set<number>()
const raf = (run: () => void) => {
const id = requestAnimationFrame(() => {
frames.delete(id)
run()
})
frames.add(id)
}
const sdk = useSDK()
@@ -114,15 +123,16 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
on(
() => props.diffs().length,
() => {
requestAnimationFrame(restoreScroll)
raf(restoreScroll)
},
{ defer: true },
),
)
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
if (frame !== undefined) cancelAnimationFrame(frame)
for (const id of frames) cancelAnimationFrame(id)
frames.clear()
})
return (
@@ -135,7 +145,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
restoreScroll()
}}
onScroll={handleScroll}
onDiffRendered={() => requestAnimationFrame(restoreScroll)}
onDiffRendered={() => raf(restoreScroll)}
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{

View File

@@ -26,6 +26,21 @@ export const useSessionHashScroll = (input: {
scheduleScrollState: (el: HTMLDivElement) => void
consumePendingMessage: (key: string) => string | undefined
}) => {
const frames = new Set<number>()
const raf = (run: () => void) => {
const id = requestAnimationFrame(() => {
frames.delete(id)
run()
})
frames.add(id)
}
onCleanup(() => {
for (const id of frames) cancelAnimationFrame(id)
frames.clear()
})
const clearMessageHash = () => {
if (!window.location.hash) return
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
@@ -55,10 +70,10 @@ export const useSessionHashScroll = (input: {
input.setTurnStart(index)
input.scheduleTurnBackfill()
requestAnimationFrame(() => {
raf(() => {
const el = document.getElementById(input.anchor(message.id))
if (!el) {
requestAnimationFrame(() => {
raf(() => {
const next = document.getElementById(input.anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
@@ -75,7 +90,7 @@ export const useSessionHashScroll = (input: {
const el = document.getElementById(input.anchor(message.id))
if (!el) {
updateHash(message.id)
requestAnimationFrame(() => {
raf(() => {
const next = document.getElementById(input.anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
@@ -87,7 +102,7 @@ export const useSessionHashScroll = (input: {
return
}
requestAnimationFrame(() => {
raf(() => {
const next = document.getElementById(input.anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
@@ -138,7 +153,7 @@ export const useSessionHashScroll = (input: {
createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
raf(() => applyHash("auto"))
})
createEffect(() => {
@@ -156,12 +171,12 @@ export const useSessionHashScroll = (input: {
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
input.autoScroll.pause()
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
raf(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return
const handler = () => requestAnimationFrame(() => applyHash("auto"))
const handler = () => raf(() => applyHash("auto"))
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})

View File

@@ -22,6 +22,13 @@ const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
function clearTracked(id: string, map: Map<string, string>) {
for (const [k, value] of map) {
if (value !== id) continue
map.delete(k)
}
}
const required = [
"session:params",
"session:data-ready",
@@ -66,6 +73,8 @@ function flush(id: string, reason: "complete" | "timeout") {
)
navs.delete(id)
clearTracked(id, pending)
clearTracked(id, active)
}
function maybeFlush(id: string) {

View File

@@ -18,6 +18,7 @@ const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const LOCAL_PREFIX = "opencode."
const fallback = new Map<string, boolean>()
const FALLBACK_MAX_SCOPES = 200
const CACHE_MAX_ENTRIES = 500
const CACHE_MAX_BYTES = 8 * 1024 * 1024
@@ -70,7 +71,13 @@ function fallbackDisabled(scope: string) {
}
function fallbackSet(scope: string) {
if (fallback.has(scope)) fallback.delete(scope)
fallback.set(scope, true)
while (fallback.size > FALLBACK_MAX_SCOPES) {
const oldest = fallback.keys().next().value as string | undefined
if (!oldest) return
fallback.delete(oldest)
}
}
function quota(error: unknown) {

View File

@@ -43,4 +43,15 @@ describe("Worktree", () => {
expect(await waiting).toEqual({ status: "failed", message: "permission denied" })
expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" })
})
test("forget clears state and resolves pending waiters", async () => {
const key = dir("forget")
Worktree.pending(key)
const waiting = Worktree.wait(key)
Worktree.forget(`${key}/`, "closed")
expect(await waiting).toEqual({ status: "failed", message: "closed" })
expect(Worktree.get(key)).toBeUndefined()
})
})

View File

@@ -21,6 +21,13 @@ const waiters = new Map<
}
>()
function resolveWaiter(key: string, next: State) {
const waiter = waiters.get(key)
if (!waiter) return
waiters.delete(key)
waiter.resolve(next)
}
function deferred() {
const box = { resolve: (_: State) => {} }
const promise = new Promise<State>((resolve) => {
@@ -43,19 +50,18 @@ export const Worktree = {
const key = normalize(directory)
const next = { status: "ready" } as const
state.set(key, next)
const waiter = waiters.get(key)
if (!waiter) return
waiters.delete(key)
waiter.resolve(next)
resolveWaiter(key, next)
},
failed(directory: string, message: string) {
const key = normalize(directory)
const next = { status: "failed", message } as const
state.set(key, next)
const waiter = waiters.get(key)
if (!waiter) return
waiters.delete(key)
waiter.resolve(next)
resolveWaiter(key, next)
},
forget(directory: string, message = "cancelled") {
const key = normalize(directory)
state.delete(key)
resolveWaiter(key, { status: "failed", message })
},
wait(directory: string) {
const key = normalize(directory)