mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-10 02:44:21 +00:00
Compare commits
1 Commits
beta
...
opencode/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e4495713 |
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>>)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user