Vendor opencode source for docker build
This commit is contained in:
43
opencode/packages/app/src/context/command-keybind.test.ts
Normal file
43
opencode/packages/app/src/context/command-keybind.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { formatKeybind, matchKeybind, parseKeybind } from "./command"
|
||||
|
||||
describe("command keybind helpers", () => {
|
||||
test("parseKeybind handles aliases and multiple combos", () => {
|
||||
const keybinds = parseKeybind("control+option+k, mod+shift+comma")
|
||||
|
||||
expect(keybinds).toHaveLength(2)
|
||||
expect(keybinds[0]).toEqual({
|
||||
key: "k",
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: true,
|
||||
})
|
||||
expect(keybinds[1]?.shift).toBe(true)
|
||||
expect(keybinds[1]?.key).toBe("comma")
|
||||
expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
|
||||
})
|
||||
|
||||
test("parseKeybind treats none and empty as disabled", () => {
|
||||
expect(parseKeybind("none")).toEqual([])
|
||||
expect(parseKeybind("")).toEqual([])
|
||||
})
|
||||
|
||||
test("matchKeybind normalizes punctuation keys", () => {
|
||||
const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
|
||||
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("formatKeybind returns human readable output", () => {
|
||||
const display = formatKeybind("ctrl+alt+arrowup")
|
||||
|
||||
expect(display).toContain("↑")
|
||||
expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
|
||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||
expect(formatKeybind("none")).toBe("")
|
||||
})
|
||||
})
|
||||
25
opencode/packages/app/src/context/command.test.ts
Normal file
25
opencode/packages/app/src/context/command.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { upsertCommandRegistration } from "./command"
|
||||
|
||||
describe("upsertCommandRegistration", () => {
|
||||
test("replaces keyed registrations", () => {
|
||||
const one = () => [{ id: "one", title: "One" }]
|
||||
const two = () => [{ id: "two", title: "Two" }]
|
||||
|
||||
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
|
||||
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0]?.options).toBe(two)
|
||||
})
|
||||
|
||||
test("keeps unkeyed registrations additive", () => {
|
||||
const one = () => [{ id: "one", title: "One" }]
|
||||
const two = () => [{ id: "two", title: "Two" }]
|
||||
|
||||
const next = upsertCommandRegistration([{ options: one }], { options: two })
|
||||
|
||||
expect(next).toHaveLength(2)
|
||||
expect(next[0]?.options).toBe(two)
|
||||
expect(next[1]?.options).toBe(one)
|
||||
})
|
||||
})
|
||||
365
opencode/packages/app/src/context/command.tsx
Normal file
365
opencode/packages/app/src/context/command.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
|
||||
function actionId(id: string) {
|
||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||
return id.slice(SUGGESTED_PREFIX.length)
|
||||
}
|
||||
|
||||
function normalizeKey(key: string) {
|
||||
if (key === ",") return "comma"
|
||||
if (key === "+") return "plus"
|
||||
if (key === " ") return "space"
|
||||
return key.toLowerCase()
|
||||
}
|
||||
|
||||
function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
|
||||
const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
|
||||
return `${key}:${mask}`
|
||||
}
|
||||
|
||||
function signatureFromEvent(event: KeyboardEvent) {
|
||||
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
|
||||
}
|
||||
|
||||
export type KeybindConfig = string
|
||||
|
||||
export interface Keybind {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
}
|
||||
|
||||
export interface CommandOption {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
suggested?: boolean
|
||||
disabled?: boolean
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
export type CommandCatalogItem = {
|
||||
title: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
}
|
||||
|
||||
export type CommandRegistration = {
|
||||
key?: string
|
||||
options: Accessor<CommandOption[]>
|
||||
}
|
||||
|
||||
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
|
||||
if (entry.key === undefined) return [entry, ...registrations]
|
||||
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
return config.split(",").map((combo) => {
|
||||
const parts = combo.trim().toLowerCase().split("+")
|
||||
const keybind: Keybind = {
|
||||
key: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case "ctrl":
|
||||
case "control":
|
||||
keybind.ctrl = true
|
||||
break
|
||||
case "meta":
|
||||
case "cmd":
|
||||
case "command":
|
||||
keybind.meta = true
|
||||
break
|
||||
case "mod":
|
||||
if (IS_MAC) keybind.meta = true
|
||||
else keybind.ctrl = true
|
||||
break
|
||||
case "alt":
|
||||
case "option":
|
||||
keybind.alt = true
|
||||
break
|
||||
case "shift":
|
||||
keybind.shift = true
|
||||
break
|
||||
default:
|
||||
keybind.key = part
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keybind
|
||||
})
|
||||
}
|
||||
|
||||
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
|
||||
const eventKey = normalizeKey(event.key)
|
||||
|
||||
for (const kb of keybinds) {
|
||||
const keyMatch = kb.key === eventKey
|
||||
const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
|
||||
const metaMatch = kb.meta === (event.metaKey || false)
|
||||
const shiftMatch = kb.shift === (event.shiftKey || false)
|
||||
const altMatch = kb.alt === (event.altKey || false)
|
||||
|
||||
if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function formatKeybind(config: string): string {
|
||||
if (!config || config === "none") return ""
|
||||
|
||||
const keybinds = parseKeybind(config)
|
||||
if (keybinds.length === 0) return ""
|
||||
|
||||
const kb = keybinds[0]
|
||||
const parts: string[] = []
|
||||
|
||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
|
||||
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
|
||||
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
|
||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const keys: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
plus: "+",
|
||||
space: "Space",
|
||||
}
|
||||
const key = kb.key.toLowerCase()
|
||||
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
registrations: [] as CommandRegistration[],
|
||||
suspendCount: 0,
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<Record<string, CommandCatalogItem>>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
const custom = settings.keybinds.get(actionId(id))
|
||||
const config = custom ?? def
|
||||
if (!config || config === "none") return
|
||||
return config
|
||||
}
|
||||
|
||||
const registered = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const all: CommandOption[] = []
|
||||
|
||||
for (const reg of store.registrations) {
|
||||
for (const opt of reg.options()) {
|
||||
if (seen.has(opt.id)) {
|
||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||
warnedDuplicates.add(opt.id)
|
||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen.add(opt.id)
|
||||
all.push(opt)
|
||||
}
|
||||
}
|
||||
|
||||
return all
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
for (const opt of registered()) {
|
||||
const id = actionId(opt.id)
|
||||
setCatalog(id, {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const resolved = registered().map((opt) => ({
|
||||
...opt,
|
||||
keybind: bind(opt.id, opt.keybind),
|
||||
}))
|
||||
|
||||
const suggested = resolved.filter((x) => x.suggested && !x.disabled)
|
||||
|
||||
return [
|
||||
...suggested.map((x) => ({
|
||||
...x,
|
||||
id: SUGGESTED_PREFIX + x.id,
|
||||
category: language.t("command.category.suggested"),
|
||||
})),
|
||||
...resolved,
|
||||
]
|
||||
})
|
||||
|
||||
const suspended = () => store.suspendCount > 0
|
||||
|
||||
const palette = createMemo(() => {
|
||||
const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
||||
const keybinds = parseKeybind(config)
|
||||
return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
|
||||
})
|
||||
|
||||
const keymap = createMemo(() => {
|
||||
const map = new Map<string, CommandOption>()
|
||||
for (const option of options()) {
|
||||
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
|
||||
if (option.disabled) continue
|
||||
if (!option.keybind) continue
|
||||
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
for (const kb of keybinds) {
|
||||
if (!kb.key) continue
|
||||
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
|
||||
if (map.has(sig)) continue
|
||||
map.set(sig, option)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
|
||||
for (const option of options()) {
|
||||
if (option.id === id || option.id === "suggested." + id) {
|
||||
option.onSelect?.(source)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
run("file.open", "palette")
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (suspended() || dialog.active) return
|
||||
|
||||
const sig = signatureFromEvent(event)
|
||||
|
||||
if (palette().has(sig)) {
|
||||
event.preventDefault()
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
|
||||
const option = keymap().get(sig)
|
||||
if (!option) return
|
||||
event.preventDefault()
|
||||
option.onSelect?.("keybind")
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
function register(cb: () => CommandOption[]): void
|
||||
function register(key: string, cb: () => CommandOption[]): void
|
||||
function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
|
||||
const id = typeof key === "string" ? key : undefined
|
||||
const next = typeof key === "function" ? key : cb
|
||||
if (!next) return
|
||||
const options = createMemo(next)
|
||||
const entry: CommandRegistration = {
|
||||
key: id,
|
||||
options,
|
||||
}
|
||||
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
|
||||
onCleanup(() => {
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||
run(id, source)
|
||||
},
|
||||
keybind(id: string) {
|
||||
if (id === PALETTE_ID) {
|
||||
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||
}
|
||||
|
||||
const base = actionId(id)
|
||||
const option = options().find((x) => actionId(x.id) === base)
|
||||
if (option?.keybind) return formatKeybind(option.keybind)
|
||||
|
||||
const meta = catalog[base]
|
||||
const config = bind(base, meta?.keybind)
|
||||
if (!config) return ""
|
||||
return formatKeybind(config)
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
get catalog() {
|
||||
return catalogOptions()
|
||||
},
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
111
opencode/packages/app/src/context/comments.test.ts
Normal file
111
opencode/packages/app/src/context/comments.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import type { LineComment } from "./comments"
|
||||
|
||||
let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
use: () => undefined,
|
||||
provider: () => undefined,
|
||||
}),
|
||||
}))
|
||||
const mod = await import("./comments")
|
||||
createCommentSessionForTest = mod.createCommentSessionForTest
|
||||
})
|
||||
|
||||
function line(file: string, id: string, time: number): LineComment {
|
||||
return {
|
||||
id,
|
||||
file,
|
||||
comment: id,
|
||||
time,
|
||||
selection: { start: 1, end: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
describe("comments session indexing", () => {
|
||||
test("keeps file list behavior and aggregate chronological order", () => {
|
||||
createRoot((dispose) => {
|
||||
const now = Date.now()
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
|
||||
"b.ts": [line("b.ts", "b-mid", now + 10_000)],
|
||||
})
|
||||
|
||||
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
|
||||
expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
|
||||
|
||||
const next = comments.add({
|
||||
file: "b.ts",
|
||||
comment: "next",
|
||||
selection: { start: 2, end: 2 },
|
||||
})
|
||||
|
||||
expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
|
||||
expect(comments.all().map((item) => item.time)).toEqual(
|
||||
comments
|
||||
.all()
|
||||
.map((item) => item.time)
|
||||
.slice()
|
||||
.sort((a, b) => a - b),
|
||||
)
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("remove updates file and aggregate indexes consistently", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
|
||||
"b.ts": [line("b.ts", "shared", 30)],
|
||||
})
|
||||
|
||||
comments.setFocus({ file: "a.ts", id: "shared" })
|
||||
comments.setActive({ file: "a.ts", id: "shared" })
|
||||
comments.remove("a.ts", "shared")
|
||||
|
||||
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
|
||||
expect(
|
||||
comments
|
||||
.all()
|
||||
.filter((item) => item.id === "shared")
|
||||
.map((item) => item.file),
|
||||
).toEqual(["b.ts"])
|
||||
expect(comments.focus()).toBeNull()
|
||||
expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("clear resets file and aggregate indexes plus focus state", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "a1", 10)],
|
||||
})
|
||||
|
||||
const next = comments.add({
|
||||
file: "b.ts",
|
||||
comment: "next",
|
||||
selection: { start: 2, end: 2 },
|
||||
})
|
||||
|
||||
comments.setActive({ file: "b.ts", id: next.id })
|
||||
comments.clear()
|
||||
|
||||
expect(comments.list("a.ts")).toEqual([])
|
||||
expect(comments.list("b.ts")).toEqual([])
|
||||
expect(comments.all()).toEqual([])
|
||||
expect(comments.focus()).toBeNull()
|
||||
expect(comments.active()).toBeNull()
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
185
opencode/packages/app/src/context/comments.tsx
Normal file
185
opencode/packages/app/src/context/comments.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
time: number
|
||||
}
|
||||
|
||||
type CommentFocus = { file: string; id: string }
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_COMMENT_SESSIONS = 20
|
||||
|
||||
type CommentStore = {
|
||||
comments: Record<string, LineComment[]>
|
||||
}
|
||||
|
||||
function aggregate(comments: Record<string, LineComment[]>) {
|
||||
return Object.keys(comments)
|
||||
.flatMap((file) => comments[file] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
function insert(items: LineComment[], next: LineComment) {
|
||||
const index = items.findIndex((item) => item.time > next.time)
|
||||
if (index < 0) return [...items, next]
|
||||
return [...items.slice(0, index), next, ...items.slice(index)]
|
||||
}
|
||||
|
||||
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||
const [state, setState] = createStore({
|
||||
focus: null as CommentFocus | null,
|
||||
active: null as CommentFocus | null,
|
||||
all: aggregate(store.comments),
|
||||
})
|
||||
|
||||
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||
setState("focus", value)
|
||||
|
||||
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||
setState("active", value)
|
||||
|
||||
const list = (file: string) => store.comments[file] ?? []
|
||||
|
||||
const add = (input: Omit<LineComment, "id" | "time">) => {
|
||||
const next: LineComment = {
|
||||
id: crypto.randomUUID(),
|
||||
time: Date.now(),
|
||||
...input,
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("comments", input.file, (items) => [...(items ?? []), next])
|
||||
setState("all", (items) => insert(items, next))
|
||||
setFocus({ file: input.file, id: next.id })
|
||||
})
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const remove = (file: string, id: string) => {
|
||||
batch(() => {
|
||||
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
|
||||
setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
|
||||
setFocus((current) => (current?.id === id ? null : current))
|
||||
})
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
batch(() => {
|
||||
setStore("comments", reconcile({}))
|
||||
setState("all", [])
|
||||
setFocus(null)
|
||||
setActive(null)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
all: () => state.all,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
focus: () => state.focus,
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
active: () => state.active,
|
||||
setActive,
|
||||
clearActive: () => setActive(null),
|
||||
reindex: () => setState("all", aggregate(store.comments)),
|
||||
}
|
||||
}
|
||||
|
||||
export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
|
||||
const [store, setStore] = createStore<CommentStore>({ comments })
|
||||
return createCommentSessionState(store, setStore)
|
||||
}
|
||||
|
||||
function createCommentSession(dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.scoped(dir, id, "comments", [legacy]),
|
||||
createStore<CommentStore>({
|
||||
comments: {},
|
||||
}),
|
||||
)
|
||||
const session = createCommentSessionState(store, setStore)
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
session.reindex()
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
list: session.list,
|
||||
all: session.all,
|
||||
add: session.add,
|
||||
remove: session.remove,
|
||||
clear: session.clear,
|
||||
focus: session.focus,
|
||||
setFocus: session.setFocus,
|
||||
clearFocus: session.clearFocus,
|
||||
active: session.active,
|
||||
setActive: session.setActive,
|
||||
clearActive: session.clearActive,
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useComments, provider: CommentsProvider } = createSimpleContext({
|
||||
name: "Comments",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const cache = createScopedCache(
|
||||
(key) => {
|
||||
const split = key.lastIndexOf("\n")
|
||||
const dir = split >= 0 ? key.slice(0, split) : key
|
||||
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
||||
return createRoot((dispose) => ({
|
||||
value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
|
||||
dispose,
|
||||
}))
|
||||
},
|
||||
{
|
||||
maxEntries: MAX_COMMENT_SESSIONS,
|
||||
dispose: (entry) => entry.dispose(),
|
||||
},
|
||||
)
|
||||
|
||||
onCleanup(() => cache.clear())
|
||||
|
||||
const load = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||
return cache.get(key).value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
list: (file: string) => session().list(file),
|
||||
all: () => session().all(),
|
||||
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
||||
remove: (file: string, id: string) => session().remove(file, id),
|
||||
clear: () => session().clear(),
|
||||
focus: () => session().focus(),
|
||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||
clearFocus: () => session().clearFocus(),
|
||||
active: () => session().active(),
|
||||
setActive: (active: CommentFocus | null) => session().setActive(active),
|
||||
clearActive: () => session().clearActive(),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import {
|
||||
evictContentLru,
|
||||
getFileContentBytesTotal,
|
||||
getFileContentEntryCount,
|
||||
removeFileContentBytes,
|
||||
resetFileContentLru,
|
||||
setFileContentBytes,
|
||||
touchFileContent,
|
||||
} from "./file/content-cache"
|
||||
|
||||
describe("file content eviction accounting", () => {
|
||||
afterEach(() => {
|
||||
resetFileContentLru()
|
||||
})
|
||||
|
||||
test("updates byte totals incrementally for set, overwrite, remove, and reset", () => {
|
||||
setFileContentBytes("a", 10)
|
||||
setFileContentBytes("b", 15)
|
||||
expect(getFileContentBytesTotal()).toBe(25)
|
||||
expect(getFileContentEntryCount()).toBe(2)
|
||||
|
||||
setFileContentBytes("a", 5)
|
||||
expect(getFileContentBytesTotal()).toBe(20)
|
||||
expect(getFileContentEntryCount()).toBe(2)
|
||||
|
||||
touchFileContent("a")
|
||||
expect(getFileContentBytesTotal()).toBe(20)
|
||||
|
||||
removeFileContentBytes("b")
|
||||
expect(getFileContentBytesTotal()).toBe(5)
|
||||
expect(getFileContentEntryCount()).toBe(1)
|
||||
|
||||
resetFileContentLru()
|
||||
expect(getFileContentBytesTotal()).toBe(0)
|
||||
expect(getFileContentEntryCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("evicts by entry cap using LRU order", () => {
|
||||
for (const i of Array.from({ length: 41 }, (_, n) => n)) {
|
||||
setFileContentBytes(`f-${i}`, 1)
|
||||
}
|
||||
|
||||
const evicted: string[] = []
|
||||
evictContentLru(undefined, (path) => evicted.push(path))
|
||||
|
||||
expect(evicted).toEqual(["f-0"])
|
||||
expect(getFileContentEntryCount()).toBe(40)
|
||||
expect(getFileContentBytesTotal()).toBe(40)
|
||||
})
|
||||
|
||||
test("evicts by byte cap while preserving protected entries", () => {
|
||||
const chunk = 8 * 1024 * 1024
|
||||
setFileContentBytes("a", chunk)
|
||||
setFileContentBytes("b", chunk)
|
||||
setFileContentBytes("c", chunk)
|
||||
|
||||
const evicted: string[] = []
|
||||
evictContentLru(new Set(["a"]), (path) => evicted.push(path))
|
||||
|
||||
expect(evicted).toEqual(["b"])
|
||||
expect(getFileContentEntryCount()).toBe(2)
|
||||
expect(getFileContentBytesTotal()).toBe(chunk * 2)
|
||||
})
|
||||
})
|
||||
263
opencode/packages/app/src/context/file.tsx
Normal file
263
opencode/packages/app/src/context/file.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
import {
|
||||
approxBytes,
|
||||
evictContentLru,
|
||||
getFileContentBytesTotal,
|
||||
getFileContentEntryCount,
|
||||
hasFileContent,
|
||||
removeFileContentBytes,
|
||||
resetFileContentLru,
|
||||
setFileContentBytes,
|
||||
touchFileContent,
|
||||
} from "./file/content-cache"
|
||||
import { createFileViewCache } from "./file/view-cache"
|
||||
import { createFileTreeStore } from "./file/tree-store"
|
||||
import { invalidateFromWatcher } from "./file/watcher"
|
||||
import {
|
||||
selectionFromLines,
|
||||
type FileState,
|
||||
type FileSelection,
|
||||
type FileViewState,
|
||||
type SelectedLineRange,
|
||||
} from "./file/types"
|
||||
|
||||
export type { FileSelection, SelectedLineRange, FileViewState, FileState }
|
||||
export { selectionFromLines }
|
||||
export {
|
||||
evictContentLru,
|
||||
getFileContentBytesTotal,
|
||||
getFileContentEntryCount,
|
||||
removeFileContentBytes,
|
||||
resetFileContentLru,
|
||||
setFileContentBytes,
|
||||
touchFileContent,
|
||||
}
|
||||
|
||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
name: "File",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
useSync()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
|
||||
const scope = createMemo(() => sdk.directory)
|
||||
const path = createPathHelpers(scope)
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const [store, setStore] = createStore<{
|
||||
file: Record<string, FileState>
|
||||
}>({
|
||||
file: {},
|
||||
})
|
||||
|
||||
const tree = createFileTreeStore({
|
||||
scope,
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.listFailed.title"),
|
||||
description: message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const evictContent = (keep?: Set<string>) => {
|
||||
evictContentLru(keep, (target) => {
|
||||
if (!store.file[target]) return
|
||||
setStore(
|
||||
"file",
|
||||
target,
|
||||
produce((draft) => {
|
||||
draft.content = undefined
|
||||
draft.loaded = false
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
scope()
|
||||
inflight.clear()
|
||||
resetFileContentLru()
|
||||
batch(() => {
|
||||
setStore("file", reconcile({}))
|
||||
tree.reset()
|
||||
})
|
||||
})
|
||||
|
||||
const viewCache = createFileViewCache()
|
||||
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||
|
||||
const ensure = (file: string) => {
|
||||
if (!file) return
|
||||
if (store.file[file]) return
|
||||
setStore("file", file, { path: file, name: getFilename(file) })
|
||||
}
|
||||
|
||||
const load = (input: string, options?: { force?: boolean }) => {
|
||||
const file = path.normalize(input)
|
||||
if (!file) return Promise.resolve()
|
||||
|
||||
const directory = scope()
|
||||
const key = `${directory}\n${file}`
|
||||
ensure(file)
|
||||
|
||||
const current = store.file[file]
|
||||
if (!options?.force && current?.loaded) return Promise.resolve()
|
||||
|
||||
const pending = inflight.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loading = true
|
||||
draft.error = undefined
|
||||
}),
|
||||
)
|
||||
|
||||
const promise = sdk.client.file
|
||||
.read({ path: file })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.loading = false
|
||||
draft.content = content
|
||||
}),
|
||||
)
|
||||
|
||||
if (!content) return
|
||||
touchFileContent(file, approxBytes(content))
|
||||
evictContent(new Set([file]))
|
||||
})
|
||||
.catch((e) => {
|
||||
if (scope() !== directory) return
|
||||
setStore(
|
||||
"file",
|
||||
file,
|
||||
produce((draft) => {
|
||||
draft.loading = false
|
||||
draft.error = e.message
|
||||
}),
|
||||
)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
description: e.message,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key)
|
||||
})
|
||||
|
||||
inflight.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false") =>
|
||||
sdk.client.find.files({ query, dirs }).then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
)
|
||||
|
||||
const stop = sdk.event.listen((e) => {
|
||||
invalidateFromWatcher(e.details, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
loadFile: (file) => {
|
||||
void load(file, { force: true })
|
||||
},
|
||||
node: tree.node,
|
||||
isDirLoaded: tree.isLoaded,
|
||||
refreshDir: (dir) => {
|
||||
void tree.listDir(dir, { force: true })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const get = (input: string) => {
|
||||
const file = path.normalize(input)
|
||||
const state = store.file[file]
|
||||
const content = state?.content
|
||||
if (!content) return state
|
||||
if (hasFileContent(file)) {
|
||||
touchFileContent(file)
|
||||
return state
|
||||
}
|
||||
touchFileContent(file, approxBytes(content))
|
||||
return state
|
||||
}
|
||||
|
||||
const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
|
||||
const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
|
||||
const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
|
||||
|
||||
const setScrollTop = (input: string, top: number) => {
|
||||
view().setScrollTop(path.normalize(input), top)
|
||||
}
|
||||
|
||||
const setScrollLeft = (input: string, left: number) => {
|
||||
view().setScrollLeft(path.normalize(input), left)
|
||||
}
|
||||
|
||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||
view().setSelectedLines(path.normalize(input), range)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
viewCache.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
ready: () => view().ready(),
|
||||
normalize: path.normalize,
|
||||
tab: path.tab,
|
||||
pathFromTab: path.pathFromTab,
|
||||
tree: {
|
||||
list: tree.listDir,
|
||||
refresh: (input: string) => tree.listDir(input, { force: true }),
|
||||
state: tree.dirState,
|
||||
children: tree.children,
|
||||
expand: tree.expandDir,
|
||||
collapse: tree.collapseDir,
|
||||
toggle(input: string) {
|
||||
if (tree.dirState(input)?.expanded) {
|
||||
tree.collapseDir(input)
|
||||
return
|
||||
}
|
||||
tree.expandDir(input)
|
||||
},
|
||||
},
|
||||
get,
|
||||
load,
|
||||
scrollTop,
|
||||
scrollLeft,
|
||||
setScrollTop,
|
||||
setScrollLeft,
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
searchFiles: (query: string) => search(query, "false"),
|
||||
searchFilesAndDirectories: (query: string) => search(query, "true"),
|
||||
}
|
||||
},
|
||||
})
|
||||
88
opencode/packages/app/src/context/file/content-cache.ts
Normal file
88
opencode/packages/app/src/context/file/content-cache.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
const MAX_FILE_CONTENT_ENTRIES = 40
|
||||
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
const lru = new Map<string, number>()
|
||||
let total = 0
|
||||
|
||||
export function approxBytes(content: FileContent) {
|
||||
const patchBytes =
|
||||
content.patch?.hunks.reduce((sum, hunk) => {
|
||||
return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0)
|
||||
}, 0) ?? 0
|
||||
|
||||
return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
|
||||
}
|
||||
|
||||
function setBytes(path: string, nextBytes: number) {
|
||||
const prev = lru.get(path)
|
||||
if (prev !== undefined) total -= prev
|
||||
lru.delete(path)
|
||||
lru.set(path, nextBytes)
|
||||
total += nextBytes
|
||||
}
|
||||
|
||||
function touch(path: string, bytes?: number) {
|
||||
const prev = lru.get(path)
|
||||
if (prev === undefined && bytes === undefined) return
|
||||
setBytes(path, bytes ?? prev ?? 0)
|
||||
}
|
||||
|
||||
function remove(path: string) {
|
||||
const prev = lru.get(path)
|
||||
if (prev === undefined) return
|
||||
lru.delete(path)
|
||||
total -= prev
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lru.clear()
|
||||
total = 0
|
||||
}
|
||||
|
||||
export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
|
||||
const set = keep ?? new Set<string>()
|
||||
|
||||
while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
|
||||
const path = lru.keys().next().value
|
||||
if (!path) return
|
||||
|
||||
if (set.has(path)) {
|
||||
touch(path)
|
||||
if (lru.size <= set.size) return
|
||||
continue
|
||||
}
|
||||
|
||||
remove(path)
|
||||
evict(path)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetFileContentLru() {
|
||||
reset()
|
||||
}
|
||||
|
||||
export function setFileContentBytes(path: string, bytes: number) {
|
||||
setBytes(path, bytes)
|
||||
}
|
||||
|
||||
export function removeFileContentBytes(path: string) {
|
||||
remove(path)
|
||||
}
|
||||
|
||||
export function touchFileContent(path: string, bytes?: number) {
|
||||
touch(path, bytes)
|
||||
}
|
||||
|
||||
export function getFileContentBytesTotal() {
|
||||
return total
|
||||
}
|
||||
|
||||
export function getFileContentEntryCount() {
|
||||
return lru.size
|
||||
}
|
||||
|
||||
export function hasFileContent(path: string) {
|
||||
return lru.has(path)
|
||||
}
|
||||
352
opencode/packages/app/src/context/file/path.test.ts
Normal file
352
opencode/packages/app/src/context/file/path.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path"
|
||||
|
||||
describe("file path helpers", () => {
|
||||
test("normalizes file inputs against workspace root", () => {
|
||||
const path = createPathHelpers(() => "/repo")
|
||||
expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
|
||||
expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalizeDir("src/components///")).toBe("src/components")
|
||||
expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
|
||||
expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
|
||||
expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps query/hash stripping behavior stable", () => {
|
||||
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
||||
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
||||
expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
|
||||
})
|
||||
|
||||
test("unquotes git escaped octal path strings", () => {
|
||||
expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
|
||||
expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
|
||||
expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("encodeFilePath", () => {
|
||||
describe("Linux/Unix paths", () => {
|
||||
test("should handle Linux absolute path", () => {
|
||||
const linuxPath = "/home/user/project/README.md"
|
||||
const result = encodeFilePath(linuxPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
// Should create a valid URL
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/home/user/project/README.md")
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toBe("/home/user/project/README.md")
|
||||
})
|
||||
|
||||
test("should handle Linux path with special characters", () => {
|
||||
const linuxPath = "/home/user/file#name with spaces.txt"
|
||||
const result = encodeFilePath(linuxPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/home/user/file%23name%20with%20spaces.txt")
|
||||
})
|
||||
|
||||
test("should handle Linux relative path", () => {
|
||||
const relativePath = "src/components/App.tsx"
|
||||
const result = encodeFilePath(relativePath)
|
||||
|
||||
expect(result).toBe("src/components/App.tsx")
|
||||
})
|
||||
|
||||
test("should handle Linux root directory", () => {
|
||||
const result = encodeFilePath("/")
|
||||
expect(result).toBe("/")
|
||||
})
|
||||
|
||||
test("should handle Linux path with all special chars", () => {
|
||||
const path = "/path/to/file#with?special%chars&more.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("%23") // #
|
||||
expect(result).toContain("%3F") // ?
|
||||
expect(result).toContain("%25") // %
|
||||
expect(result).toContain("%26") // &
|
||||
})
|
||||
})
|
||||
|
||||
describe("macOS paths", () => {
|
||||
test("should handle macOS absolute path", () => {
|
||||
const macPath = "/Users/kelvin/Projects/opencode/README.md"
|
||||
const result = encodeFilePath(macPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/Users/kelvin/Projects/opencode/README.md")
|
||||
})
|
||||
|
||||
test("should handle macOS path with spaces", () => {
|
||||
const macPath = "/Users/kelvin/My Documents/file.txt"
|
||||
const result = encodeFilePath(macPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("My%20Documents")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Windows paths", () => {
|
||||
test("should handle Windows absolute path with backslashes", () => {
|
||||
const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
// Should create a valid, parseable URL
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toContain("README.bs.md")
|
||||
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
|
||||
})
|
||||
|
||||
test("should handle mixed separator path (Windows + Unix)", () => {
|
||||
// This is what happens in build-request-parts.ts when concatenating paths
|
||||
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
|
||||
const result = encodeFilePath(mixedPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
|
||||
})
|
||||
|
||||
test("should handle Windows path with spaces", () => {
|
||||
const windowsPath = "C:\\Program Files\\MyApp\\file with spaces.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("Program%20Files")
|
||||
expect(result).toContain("file%20with%20spaces.txt")
|
||||
})
|
||||
|
||||
test("should handle Windows path with special chars in filename", () => {
|
||||
const windowsPath = "D:\\projects\\file#name with ?marks.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("file%23name%20with%20%3Fmarks.txt")
|
||||
})
|
||||
|
||||
test("should handle Windows root directory", () => {
|
||||
const windowsPath = "C:\\"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/C%3A/")
|
||||
})
|
||||
|
||||
test("should handle Windows relative path with backslashes", () => {
|
||||
const windowsPath = "src\\components\\App.tsx"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
|
||||
// Relative paths shouldn't get the leading slash
|
||||
expect(result).toBe("src/components/App.tsx")
|
||||
})
|
||||
|
||||
test("should NOT create invalid URL like the bug report", () => {
|
||||
// This is the exact scenario from bug report by @alexyaroshuk
|
||||
const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
// The bug was creating: file://D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md
|
||||
expect(result).not.toContain("%5C") // Should not have encoded backslashes
|
||||
expect(result).not.toBe("D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md")
|
||||
|
||||
// Should be valid
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
})
|
||||
|
||||
test("should handle lowercase drive letters", () => {
|
||||
const windowsPath = "c:\\users\\test\\file.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/c%3A/users/test/file.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cross-platform compatibility", () => {
|
||||
test("should preserve Unix paths unchanged (except encoding)", () => {
|
||||
const unixPath = "/usr/local/bin/app"
|
||||
const result = encodeFilePath(unixPath)
|
||||
expect(result).toBe("/usr/local/bin/app")
|
||||
})
|
||||
|
||||
test("should normalize Windows paths for cross-platform use", () => {
|
||||
const windowsPath = "C:\\Users\\test\\file.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
// Should convert to forward slashes and add leading /
|
||||
expect(result).not.toContain("\\")
|
||||
expect(result).toMatch(/^\/[A-Za-z]%3A\//)
|
||||
})
|
||||
|
||||
test("should handle relative paths the same on all platforms", () => {
|
||||
const unixRelative = "src/app.ts"
|
||||
const windowsRelative = "src\\app.ts"
|
||||
|
||||
const unixResult = encodeFilePath(unixRelative)
|
||||
const windowsResult = encodeFilePath(windowsRelative)
|
||||
|
||||
// Both should normalize to forward slashes
|
||||
expect(unixResult).toBe("src/app.ts")
|
||||
expect(windowsResult).toBe("src/app.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
test("should handle empty path", () => {
|
||||
const result = encodeFilePath("")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("should handle path with multiple consecutive slashes", () => {
|
||||
const result = encodeFilePath("//path//to///file.txt")
|
||||
// Multiple slashes should be preserved (backend handles normalization)
|
||||
expect(result).toBe("//path//to///file.txt")
|
||||
})
|
||||
|
||||
test("should encode Unicode characters", () => {
|
||||
const unicodePath = "/home/user/文档/README.md"
|
||||
const result = encodeFilePath(unicodePath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
// Unicode should be encoded
|
||||
expect(result).toContain("%E6%96%87%E6%A1%A3")
|
||||
})
|
||||
|
||||
test("should handle already normalized Windows path", () => {
|
||||
// Path that's already been normalized (has / before drive letter)
|
||||
const alreadyNormalized = "/D:/path/file.txt"
|
||||
const result = encodeFilePath(alreadyNormalized)
|
||||
|
||||
// Should not add another leading slash
|
||||
expect(result).toBe("/D%3A/path/file.txt")
|
||||
expect(result).not.toContain("//D")
|
||||
})
|
||||
|
||||
test("should handle just drive letter", () => {
|
||||
const justDrive = "D:"
|
||||
const result = encodeFilePath(justDrive)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(result).toBe("/D%3A")
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
})
|
||||
|
||||
test("should handle Windows path with trailing backslash", () => {
|
||||
const trailingBackslash = "C:\\Users\\test\\"
|
||||
const result = encodeFilePath(trailingBackslash)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/C%3A/Users/test/")
|
||||
})
|
||||
|
||||
test("should handle very long paths", () => {
|
||||
const longPath = "C:\\Users\\test\\" + "verylongdirectoryname\\".repeat(20) + "file.txt"
|
||||
const result = encodeFilePath(longPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).not.toContain("\\")
|
||||
})
|
||||
|
||||
test("should handle paths with dots", () => {
|
||||
const pathWithDots = "C:\\Users\\..\\test\\.\\file.txt"
|
||||
const result = encodeFilePath(pathWithDots)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
// Dots should be preserved (backend normalizes)
|
||||
expect(result).toContain("..")
|
||||
expect(result).toContain("/./")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Regression tests for PR #12424", () => {
|
||||
test("should handle file with # in name", () => {
|
||||
const path = "/path/to/file#name.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/path/to/file%23name.txt")
|
||||
})
|
||||
|
||||
test("should handle file with ? in name", () => {
|
||||
const path = "/path/to/file?name.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/path/to/file%3Fname.txt")
|
||||
})
|
||||
|
||||
test("should handle file with % in name", () => {
|
||||
const path = "/path/to/file%name.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/path/to/file%25name.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Integration with file:// URL construction", () => {
|
||||
test("should work with query parameters (Linux)", () => {
|
||||
const path = "/home/user/file.txt"
|
||||
const encoded = encodeFilePath(path)
|
||||
const fileUrl = `file://${encoded}?start=10&end=20`
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
expect(url.pathname).toBe("/home/user/file.txt")
|
||||
})
|
||||
|
||||
test("should work with query parameters (Windows)", () => {
|
||||
const path = "C:\\Users\\test\\file.txt"
|
||||
const encoded = encodeFilePath(path)
|
||||
const fileUrl = `file://${encoded}?start=10&end=20`
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
})
|
||||
|
||||
test("should parse correctly in URL constructor (Linux)", () => {
|
||||
const path = "/var/log/app.log"
|
||||
const fileUrl = `file://${encodeFilePath(path)}`
|
||||
const url = new URL(fileUrl)
|
||||
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toBe("/var/log/app.log")
|
||||
})
|
||||
|
||||
test("should parse correctly in URL constructor (Windows)", () => {
|
||||
const path = "D:\\logs\\app.log"
|
||||
const fileUrl = `file://${encodeFilePath(path)}`
|
||||
const url = new URL(fileUrl)
|
||||
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toContain("app.log")
|
||||
})
|
||||
})
|
||||
})
|
||||
143
opencode/packages/app/src/context/file/path.ts
Normal file
143
opencode/packages/app/src/context/file/path.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export function stripFileProtocol(input: string) {
|
||||
if (!input.startsWith("file://")) return input
|
||||
return input.slice("file://".length)
|
||||
}
|
||||
|
||||
export function stripQueryAndHash(input: string) {
|
||||
const hashIndex = input.indexOf("#")
|
||||
const queryIndex = input.indexOf("?")
|
||||
|
||||
if (hashIndex !== -1 && queryIndex !== -1) {
|
||||
return input.slice(0, Math.min(hashIndex, queryIndex))
|
||||
}
|
||||
|
||||
if (hashIndex !== -1) return input.slice(0, hashIndex)
|
||||
if (queryIndex !== -1) return input.slice(0, queryIndex)
|
||||
return input
|
||||
}
|
||||
|
||||
export function unquoteGitPath(input: string) {
|
||||
if (!input.startsWith('"')) return input
|
||||
if (!input.endsWith('"')) return input
|
||||
const body = input.slice(1, -1)
|
||||
const bytes: number[] = []
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const char = body[i]!
|
||||
if (char !== "\\") {
|
||||
bytes.push(char.charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
const next = body[i + 1]
|
||||
if (!next) {
|
||||
bytes.push("\\".charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
if (next >= "0" && next <= "7") {
|
||||
const chunk = body.slice(i + 1, i + 4)
|
||||
const match = chunk.match(/^[0-7]{1,3}/)
|
||||
if (!match) {
|
||||
bytes.push(next.charCodeAt(0))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
bytes.push(parseInt(match[0], 8))
|
||||
i += match[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
const escaped =
|
||||
next === "n"
|
||||
? "\n"
|
||||
: next === "r"
|
||||
? "\r"
|
||||
: next === "t"
|
||||
? "\t"
|
||||
: next === "b"
|
||||
? "\b"
|
||||
: next === "f"
|
||||
? "\f"
|
||||
: next === "v"
|
||||
? "\v"
|
||||
: next === "\\" || next === '"'
|
||||
? next
|
||||
: undefined
|
||||
|
||||
bytes.push((escaped ?? next).charCodeAt(0))
|
||||
i++
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(new Uint8Array(bytes))
|
||||
}
|
||||
|
||||
export function decodeFilePath(input: string) {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeFilePath(filepath: string): string {
|
||||
// Normalize Windows paths: convert backslashes to forward slashes
|
||||
let normalized = filepath.replace(/\\/g, "/")
|
||||
|
||||
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
normalized = "/" + normalized
|
||||
}
|
||||
|
||||
// Encode each path segment (preserving forward slashes as path separators)
|
||||
return normalized
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")
|
||||
}
|
||||
|
||||
export function createPathHelpers(scope: () => string) {
|
||||
const normalize = (input: string) => {
|
||||
const root = scope()
|
||||
const prefix = root.endsWith("/") ? root : root + "/"
|
||||
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
|
||||
|
||||
if (path.startsWith(prefix)) {
|
||||
path = path.slice(prefix.length)
|
||||
}
|
||||
|
||||
if (path.startsWith(root)) {
|
||||
path = path.slice(root.length)
|
||||
}
|
||||
|
||||
if (path.startsWith("./")) {
|
||||
path = path.slice(2)
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
path = path.slice(1)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const tab = (input: string) => {
|
||||
const path = normalize(input)
|
||||
return `file://${encodeFilePath(path)}`
|
||||
}
|
||||
|
||||
const pathFromTab = (tabValue: string) => {
|
||||
if (!tabValue.startsWith("file://")) return
|
||||
return normalize(tabValue)
|
||||
}
|
||||
|
||||
const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
|
||||
|
||||
return {
|
||||
normalize,
|
||||
tab,
|
||||
pathFromTab,
|
||||
normalizeDir,
|
||||
}
|
||||
}
|
||||
170
opencode/packages/app/src/context/file/tree-store.ts
Normal file
170
opencode/packages/app/src/context/file/tree-store.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type DirectoryState = {
|
||||
expanded: boolean
|
||||
loaded?: boolean
|
||||
loading?: boolean
|
||||
error?: string
|
||||
children?: string[]
|
||||
}
|
||||
|
||||
type TreeStoreOptions = {
|
||||
scope: () => string
|
||||
normalizeDir: (input: string) => string
|
||||
list: (input: string) => Promise<FileNode[]>
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
export function createFileTreeStore(options: TreeStoreOptions) {
|
||||
const [tree, setTree] = createStore<{
|
||||
node: Record<string, FileNode>
|
||||
dir: Record<string, DirectoryState>
|
||||
}>({
|
||||
node: {},
|
||||
dir: { "": { expanded: true } },
|
||||
})
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
|
||||
const reset = () => {
|
||||
inflight.clear()
|
||||
setTree("node", reconcile({}))
|
||||
setTree("dir", reconcile({}))
|
||||
setTree("dir", "", { expanded: true })
|
||||
}
|
||||
|
||||
const ensureDir = (path: string) => {
|
||||
if (tree.dir[path]) return
|
||||
setTree("dir", path, { expanded: false })
|
||||
}
|
||||
|
||||
const listDir = (input: string, opts?: { force?: boolean }) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
|
||||
const current = tree.dir[dir]
|
||||
if (!opts?.force && current?.loaded) return Promise.resolve()
|
||||
|
||||
const pending = inflight.get(dir)
|
||||
if (pending) return pending
|
||||
|
||||
setTree(
|
||||
"dir",
|
||||
dir,
|
||||
produce((draft) => {
|
||||
draft.loading = true
|
||||
draft.error = undefined
|
||||
}),
|
||||
)
|
||||
|
||||
const directory = options.scope()
|
||||
|
||||
const promise = options
|
||||
.list(dir)
|
||||
.then((nodes) => {
|
||||
if (options.scope() !== directory) return
|
||||
const prevChildren = tree.dir[dir]?.children ?? []
|
||||
const nextChildren = nodes.map((node) => node.path)
|
||||
const nextSet = new Set(nextChildren)
|
||||
|
||||
setTree(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
const removedDirs: string[] = []
|
||||
|
||||
for (const child of prevChildren) {
|
||||
if (nextSet.has(child)) continue
|
||||
const existing = draft[child]
|
||||
if (existing?.type === "directory") removedDirs.push(child)
|
||||
delete draft[child]
|
||||
}
|
||||
|
||||
if (removedDirs.length > 0) {
|
||||
const keys = Object.keys(draft)
|
||||
for (const key of keys) {
|
||||
for (const removed of removedDirs) {
|
||||
if (!key.startsWith(removed + "/")) continue
|
||||
delete draft[key]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
draft[node.path] = node
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
setTree(
|
||||
"dir",
|
||||
dir,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.loading = false
|
||||
draft.children = nextChildren
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (options.scope() !== directory) return
|
||||
setTree(
|
||||
"dir",
|
||||
dir,
|
||||
produce((draft) => {
|
||||
draft.loading = false
|
||||
draft.error = e.message
|
||||
}),
|
||||
)
|
||||
options.onError(e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(dir)
|
||||
})
|
||||
|
||||
inflight.set(dir, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
const expandDir = (input: string) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", true)
|
||||
void listDir(dir)
|
||||
}
|
||||
|
||||
const collapseDir = (input: string) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", false)
|
||||
}
|
||||
|
||||
const dirState = (input: string) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
return tree.dir[dir]
|
||||
}
|
||||
|
||||
const children = (input: string) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
const ids = tree.dir[dir]?.children
|
||||
if (!ids) return []
|
||||
const out: FileNode[] = []
|
||||
for (const id of ids) {
|
||||
const node = tree.node[id]
|
||||
if (node) out.push(node)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return {
|
||||
listDir,
|
||||
expandDir,
|
||||
collapseDir,
|
||||
dirState,
|
||||
children,
|
||||
node: (path: string) => tree.node[path],
|
||||
isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
|
||||
reset,
|
||||
}
|
||||
}
|
||||
41
opencode/packages/app/src/context/file/types.ts
Normal file
41
opencode/packages/app/src/context/file/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
startChar: number
|
||||
endLine: number
|
||||
endChar: number
|
||||
}
|
||||
|
||||
export type SelectedLineRange = {
|
||||
start: number
|
||||
end: number
|
||||
side?: "additions" | "deletions"
|
||||
endSide?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export type FileViewState = {
|
||||
scrollTop?: number
|
||||
scrollLeft?: number
|
||||
selectedLines?: SelectedLineRange | null
|
||||
}
|
||||
|
||||
export type FileState = {
|
||||
path: string
|
||||
name: string
|
||||
loaded?: boolean
|
||||
loading?: boolean
|
||||
error?: string
|
||||
content?: FileContent
|
||||
}
|
||||
|
||||
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||
const startLine = Math.min(range.start, range.end)
|
||||
const endLine = Math.max(range.start, range.end)
|
||||
return {
|
||||
startLine,
|
||||
endLine,
|
||||
startChar: 0,
|
||||
endChar: 0,
|
||||
}
|
||||
}
|
||||
136
opencode/packages/app/src/context/file/view-cache.ts
Normal file
136
opencode/packages/app/src/context/file/view-cache.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import type { FileViewState, SelectedLineRange } from "./types"
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_FILE_VIEW_SESSIONS = 20
|
||||
const MAX_VIEW_FILES = 500
|
||||
|
||||
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||
if (range.start <= range.end) return range
|
||||
|
||||
const startSide = range.side
|
||||
const endSide = range.endSide ?? startSide
|
||||
|
||||
return {
|
||||
...range,
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
side: endSide,
|
||||
endSide: startSide !== endSide ? startSide : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function createViewSession(dir: string, id: string | undefined) {
|
||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [view, setView, _, ready] = persisted(
|
||||
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
||||
createStore<{
|
||||
file: Record<string, FileViewState>
|
||||
}>({
|
||||
file: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const meta = { pruned: false }
|
||||
|
||||
const pruneView = (keep?: string) => {
|
||||
const keys = Object.keys(view.file)
|
||||
if (keys.length <= MAX_VIEW_FILES) return
|
||||
|
||||
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
||||
if (drop.length === 0) return
|
||||
|
||||
setView(
|
||||
produce((draft) => {
|
||||
for (const key of drop) {
|
||||
delete draft.file[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
pruneView()
|
||||
})
|
||||
|
||||
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
||||
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
||||
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
||||
|
||||
const setScrollTop = (path: string, top: number) => {
|
||||
setView("file", path, (current) => {
|
||||
if (current?.scrollTop === top) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
scrollTop: top,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
const setScrollLeft = (path: string, left: number) => {
|
||||
setView("file", path, (current) => {
|
||||
if (current?.scrollLeft === left) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
scrollLeft: left,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
||||
const next = range ? normalizeSelectedLines(range) : null
|
||||
setView("file", path, (current) => {
|
||||
if (current?.selectedLines === next) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
selectedLines: next,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
scrollTop,
|
||||
scrollLeft,
|
||||
selectedLines,
|
||||
setScrollTop,
|
||||
setScrollLeft,
|
||||
setSelectedLines,
|
||||
}
|
||||
}
|
||||
|
||||
export function createFileViewCache() {
|
||||
const cache = createScopedCache(
|
||||
(key) => {
|
||||
const split = key.lastIndexOf("\n")
|
||||
const dir = split >= 0 ? key.slice(0, split) : key
|
||||
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
||||
return createRoot((dispose) => ({
|
||||
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
|
||||
dispose,
|
||||
}))
|
||||
},
|
||||
{
|
||||
maxEntries: MAX_FILE_VIEW_SESSIONS,
|
||||
dispose: (entry) => entry.dispose(),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
load: (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||
return cache.get(key).value
|
||||
},
|
||||
clear: () => cache.clear(),
|
||||
}
|
||||
}
|
||||
118
opencode/packages/app/src/context/file/watcher.test.ts
Normal file
118
opencode/packages/app/src/context/file/watcher.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { invalidateFromWatcher } from "./watcher"
|
||||
|
||||
describe("file watcher invalidation", () => {
|
||||
test("reloads open files and refreshes loaded parent on add", () => {
|
||||
const loads: string[] = []
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: (path) => path === "src/new.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => undefined,
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
|
||||
expect(loads).toEqual(["src/new.ts"])
|
||||
expect(refresh).toEqual(["src"])
|
||||
})
|
||||
|
||||
test("refreshes only changed loaded directory nodes", () => {
|
||||
const refresh: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({
|
||||
path: "src/file.ts",
|
||||
type: "file",
|
||||
name: "file.ts",
|
||||
absolute: "/repo/src/file.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
|
||||
expect(refresh).toEqual(["src"])
|
||||
})
|
||||
|
||||
test("ignores invalid or git watcher updates", () => {
|
||||
const refresh: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => true,
|
||||
loadFile: () => {
|
||||
throw new Error("should not load")
|
||||
},
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "project.updated",
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
|
||||
expect(refresh).toEqual([])
|
||||
})
|
||||
})
|
||||
52
opencode/packages/app/src/context/file/watcher.ts
Normal file
52
opencode/packages/app/src/context/file/watcher.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type WatcherEvent = {
|
||||
type: string
|
||||
properties: unknown
|
||||
}
|
||||
|
||||
type WatcherOps = {
|
||||
normalize: (input: string) => string
|
||||
hasFile: (path: string) => boolean
|
||||
loadFile: (path: string) => void
|
||||
node: (path: string) => FileNode | undefined
|
||||
isDirLoaded: (path: string) => boolean
|
||||
refreshDir: (path: string) => void
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
const kind = typeof props?.event === "string" ? props.event : undefined
|
||||
if (!rawPath) return
|
||||
if (!kind) return
|
||||
|
||||
const path = ops.normalize(rawPath)
|
||||
if (!path) return
|
||||
if (path.startsWith(".git/")) return
|
||||
|
||||
if (ops.hasFile(path)) {
|
||||
ops.loadFile(path)
|
||||
}
|
||||
|
||||
if (kind === "change") {
|
||||
const dir = (() => {
|
||||
if (path === "") return ""
|
||||
const node = ops.node(path)
|
||||
if (node?.type !== "directory") return
|
||||
return path
|
||||
})()
|
||||
if (dir === undefined) return
|
||||
if (!ops.isDirLoaded(dir)) return
|
||||
ops.refreshDir(dir)
|
||||
return
|
||||
}
|
||||
if (kind !== "add" && kind !== "unlink") return
|
||||
|
||||
const parent = path.split("/").slice(0, -1).join("/")
|
||||
if (!ops.isDirLoaded(parent)) return
|
||||
|
||||
ops.refreshDir(parent)
|
||||
}
|
||||
108
opencode/packages/app/src/context/global-sdk.tsx
Normal file
108
opencode/packages/app/src/context/global-sdk.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||
name: "GlobalSDK",
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
signal: abort.signal,
|
||||
fetch: platform.fetch,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = { directory: string; payload: Event }
|
||||
|
||||
let queue: Array<Queued | undefined> = []
|
||||
let buffer: Array<Queued | undefined> = []
|
||||
const coalesced = new Map<string, number>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
if (!event) continue
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const events = await eventSdk.global.event()
|
||||
let yielded = Date.now()
|
||||
for await (const event of events.stream) {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = undefined
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < 8) continue
|
||||
yielded = Date.now()
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
})()
|
||||
.finally(flush)
|
||||
.catch(() => undefined)
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
flush()
|
||||
})
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return { url: server.url, client: sdk, event: emitter }
|
||||
},
|
||||
})
|
||||
136
opencode/packages/app/src/context/global-sync.test.ts
Normal file
136
opencode/packages/app/src/context/global-sync.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
canDisposeDirectory,
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
pickDirectoriesToEvict,
|
||||
} from "./global-sync"
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
const now = 5_000
|
||||
const picks = pickDirectoriesToEvict({
|
||||
stores: ["a", "b", "c", "d"],
|
||||
state: new Map([
|
||||
["a", { lastAccessAt: 1_000 }],
|
||||
["b", { lastAccessAt: 4_900 }],
|
||||
["c", { lastAccessAt: 4_800 }],
|
||||
["d", { lastAccessAt: 3_000 }],
|
||||
]),
|
||||
pins: new Set(["a"]),
|
||||
max: 2,
|
||||
ttl: 1_500,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(picks).toEqual(["d", "c"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadRootSessionsWithFallback", () => {
|
||||
test("uses limited roots query when supported", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
let fallback = 0
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 10,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
return { data: [] }
|
||||
},
|
||||
onFallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||
expect(fallback).toBe(0)
|
||||
})
|
||||
|
||||
test("falls back to full roots query on limited-query failure", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
let fallback = 0
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
if (query.limit) throw new Error("unsupported")
|
||||
return { data: [] }
|
||||
},
|
||||
onFallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "dir", roots: true, limit: 25 },
|
||||
{ directory: "dir", roots: true },
|
||||
])
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
})
|
||||
|
||||
test("marks has-more for full-limit limited fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
|
||||
})
|
||||
|
||||
test("keeps exact total when limited fetch is under limit", () => {
|
||||
expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe("canDisposeDirectory", () => {
|
||||
test("rejects pinned or inflight directories", () => {
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: true,
|
||||
booting: false,
|
||||
loadingSessions: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: false,
|
||||
booting: true,
|
||||
loadingSessions: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: false,
|
||||
booting: false,
|
||||
loadingSessions: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("accepts idle unpinned directory store", () => {
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: false,
|
||||
booting: false,
|
||||
loadingSessions: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
365
opencode/packages/app/src/context/global-sync.tsx
Normal file
365
opencode/packages/app/src/context/global-sync.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import {
|
||||
type Config,
|
||||
type Path,
|
||||
type Project,
|
||||
type ProviderAuthResponse,
|
||||
type ProviderListResponse,
|
||||
createOpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import type { InitError } from "../pages/error"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
untrack,
|
||||
getOwner,
|
||||
useContext,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
Switch,
|
||||
Match,
|
||||
} from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { sanitizeProject } from "./global-sync/utils"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||
|
||||
const stats = {
|
||||
evictions: 0,
|
||||
loadSessionsFallback: 0,
|
||||
}
|
||||
|
||||
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||
createStore({ value: [] as Project[] }),
|
||||
)
|
||||
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||
ready: false,
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: projectCache.value,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
|
||||
const updateStats = (activeDirectoryStores: number) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
;(
|
||||
globalThis as {
|
||||
__OPENCODE_GLOBAL_SYNC_STATS?: {
|
||||
activeDirectoryStores: number
|
||||
evictions: number
|
||||
loadSessionsFullFetchFallback: number
|
||||
}
|
||||
}
|
||||
).__OPENCODE_GLOBAL_SYNC_STATS = {
|
||||
activeDirectoryStores,
|
||||
evictions: stats.evictions,
|
||||
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
|
||||
}
|
||||
}
|
||||
|
||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||
|
||||
const queue = createRefreshQueue({
|
||||
paused,
|
||||
bootstrap,
|
||||
bootstrapInstance,
|
||||
})
|
||||
|
||||
const children = createChildStoreManager({
|
||||
owner,
|
||||
markStats: updateStats,
|
||||
incrementEvictions: () => {
|
||||
stats.evictions += 1
|
||||
updateStats(Object.keys(children.children).length)
|
||||
},
|
||||
isBooting: (directory) => booting.has(directory),
|
||||
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
||||
onBootstrap: (directory) => {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
},
|
||||
})
|
||||
|
||||
const sdkFor = (directory: string) => {
|
||||
const cached = sdkCache.get(directory)
|
||||
if (cached) return cached
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdkCache.set(directory, sdk)
|
||||
return sdk
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!projectCacheReady()) return
|
||||
if (globalStore.project.length !== 0) return
|
||||
const cached = projectCache.value
|
||||
if (cached.length === 0) return
|
||||
setGlobalStore("project", cached)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!projectCacheReady()) return
|
||||
const projects = globalStore.project
|
||||
if (projects.length === 0) {
|
||||
const cachedLength = untrack(() => projectCache.value.length)
|
||||
if (cachedLength !== 0) return
|
||||
}
|
||||
setProjectCache("value", projects.map(sanitizeProject))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (globalStore.reload !== "complete") return
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
const pending = sessionLoads.get(directory)
|
||||
if (pending) return pending
|
||||
|
||||
children.pin(directory)
|
||||
const [store, setStore] = children.child(directory, { bootstrap: false })
|
||||
const meta = sessionMeta.get(directory)
|
||||
if (meta && meta.limit >= store.limit) {
|
||||
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
|
||||
if (next.length !== store.session.length) {
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
}
|
||||
children.unpin(directory)
|
||||
return
|
||||
}
|
||||
|
||||
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||
const promise = loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => globalSDK.client.session.list(query),
|
||||
onFallback: () => {
|
||||
stats.loadSessionsFallback += 1
|
||||
updateStats(Object.keys(children.children).length)
|
||||
},
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const limit = store.limit
|
||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||
const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
|
||||
setStore(
|
||||
"sessionTotal",
|
||||
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
|
||||
)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
|
||||
})
|
||||
|
||||
sessionLoads.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
sessionLoads.delete(directory)
|
||||
children.unpin(directory)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function bootstrapInstance(directory: string) {
|
||||
if (!directory) return
|
||||
const pending = booting.get(directory)
|
||||
if (pending) return pending
|
||||
|
||||
children.pin(directory)
|
||||
const promise = (async () => {
|
||||
const child = children.ensureChild(directory)
|
||||
const cache = children.vcsCache.get(directory)
|
||||
if (!cache) return
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
sdk,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
loadSessions,
|
||||
})
|
||||
})()
|
||||
|
||||
booting.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
booting.delete(directory)
|
||||
children.unpin(directory)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: queue.refresh,
|
||||
setGlobalProject(next) {
|
||||
if (typeof next === "function") {
|
||||
setGlobalStore("project", produce(next))
|
||||
return
|
||||
}
|
||||
setGlobalStore("project", next)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const existing = children.children[directory]
|
||||
if (!existing) return
|
||||
children.mark(directory)
|
||||
const [store, setStore] = existing
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
directory,
|
||||
store,
|
||||
setStore,
|
||||
push: queue.push,
|
||||
vcsCache: children.vcsCache.get(directory),
|
||||
loadLsp: () => {
|
||||
sdkFor(directory)
|
||||
.lsp.status()
|
||||
.then((x) => setStore("lsp", x.data ?? []))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
onCleanup(() => {
|
||||
queue.dispose()
|
||||
})
|
||||
onCleanup(() => {
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
children.disposeDirectory(directory)
|
||||
}
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
||||
connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
setGlobalStore,
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void bootstrap()
|
||||
})
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
children.projectMeta(directory, patch)
|
||||
}
|
||||
|
||||
function projectIcon(directory: string, value: string | undefined) {
|
||||
children.projectIcon(directory, value)
|
||||
}
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
get error() {
|
||||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
bootstrap,
|
||||
updateConfig: (config: Config) => {
|
||||
setGlobalStore("reload", "pending")
|
||||
return globalSDK.client.global.config.update({ config }).finally(() => {
|
||||
setTimeout(() => {
|
||||
setGlobalStore("reload", "complete")
|
||||
}, 1000)
|
||||
})
|
||||
},
|
||||
project: {
|
||||
loadSessions,
|
||||
meta: projectMeta,
|
||||
icon: projectIcon,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
||||
|
||||
export function GlobalSyncProvider(props: ParentProps) {
|
||||
const value = createGlobalSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={value.ready}>
|
||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function useGlobalSync() {
|
||||
const context = useContext(GlobalSyncContext)
|
||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
195
opencode/packages/app/src/context/global-sync/bootstrap.ts
Normal file
195
opencode/packages/app/src/context/global-sync/bootstrap.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
type Config,
|
||||
type Path,
|
||||
type PermissionRequest,
|
||||
type Project,
|
||||
type ProviderAuthResponse,
|
||||
type ProviderListResponse,
|
||||
type QuestionRequest,
|
||||
createOpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { cmp, normalizeProviderList } from "./utils"
|
||||
import type { State, VcsCache } from "./types"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: ReturnType<typeof createOpencodeClient>
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const health = await input.globalSDK.global
|
||||
.health()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!health?.healthy) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.connectErrorTitle,
|
||||
description: input.connectErrorDescription,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = [
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.auth().then((x) => {
|
||||
input.setGlobalStore("provider_auth", x.data ?? {})
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
|
||||
const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
input.setGlobalStore("ready", true)
|
||||
}
|
||||
|
||||
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
|
||||
return input.reduce<Record<string, T[]>>((acc, item) => {
|
||||
if (!item?.id || !item.sessionID) return acc
|
||||
const list = acc[item.sessionID]
|
||||
if (list) list.push(item)
|
||||
if (!list) acc[item.sessionID] = [item]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: ReturnType<typeof createOpencodeClient>
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
}) {
|
||||
input.setStore("status", "loading")
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
||||
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const project = getFilename(input.directory)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: `Failed to reload ${project}`, description: message })
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
if (input.store.status !== "complete") input.setStore("status", "partial")
|
||||
|
||||
Promise.all([
|
||||
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
||||
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
||||
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
||||
input.loadSessions(input.directory),
|
||||
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
||||
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
input.setStore("status", "complete")
|
||||
})
|
||||
}
|
||||
263
opencode/packages/app/src/context/global-sync/child-store.ts
Normal file
263
opencode/packages/app/src/context/global-sync/child-store.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
type ChildOptions,
|
||||
type DirState,
|
||||
type IconCache,
|
||||
type MetaCache,
|
||||
type ProjectMeta,
|
||||
type State,
|
||||
type VcsCache,
|
||||
} from "./types"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
owner: Owner
|
||||
markStats: (activeDirectoryStores: number) => void
|
||||
incrementEvictions: () => void
|
||||
isBooting: (directory: string) => boolean
|
||||
isLoadingSessions: (directory: string) => boolean
|
||||
onBootstrap: (directory: string) => void
|
||||
onDispose: (directory: string) => void
|
||||
}) {
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
const vcsCache = new Map<string, VcsCache>()
|
||||
const metaCache = new Map<string, MetaCache>()
|
||||
const iconCache = new Map<string, IconCache>()
|
||||
const lifecycle = new Map<string, DirState>()
|
||||
const pins = new Map<string, number>()
|
||||
const ownerPins = new WeakMap<object, Set<string>>()
|
||||
const disposers = new Map<string, () => void>()
|
||||
|
||||
const mark = (directory: string) => {
|
||||
if (!directory) return
|
||||
lifecycle.set(directory, { lastAccessAt: Date.now() })
|
||||
runEviction()
|
||||
}
|
||||
|
||||
const pin = (directory: string) => {
|
||||
if (!directory) return
|
||||
pins.set(directory, (pins.get(directory) ?? 0) + 1)
|
||||
mark(directory)
|
||||
}
|
||||
|
||||
const unpin = (directory: string) => {
|
||||
if (!directory) return
|
||||
const next = (pins.get(directory) ?? 0) - 1
|
||||
if (next > 0) {
|
||||
pins.set(directory, next)
|
||||
return
|
||||
}
|
||||
pins.delete(directory)
|
||||
runEviction()
|
||||
}
|
||||
|
||||
const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
|
||||
|
||||
const pinForOwner = (directory: string) => {
|
||||
const current = getOwner()
|
||||
if (!current) return
|
||||
if (current === input.owner) return
|
||||
const key = current as object
|
||||
const set = ownerPins.get(key)
|
||||
if (set?.has(directory)) return
|
||||
if (set) set.add(directory)
|
||||
if (!set) ownerPins.set(key, new Set([directory]))
|
||||
pin(directory)
|
||||
onCleanup(() => {
|
||||
const set = ownerPins.get(key)
|
||||
if (set) {
|
||||
set.delete(directory)
|
||||
if (set.size === 0) ownerPins.delete(key)
|
||||
}
|
||||
unpin(directory)
|
||||
})
|
||||
}
|
||||
|
||||
function disposeDirectory(directory: string) {
|
||||
if (
|
||||
!canDisposeDirectory({
|
||||
directory,
|
||||
hasStore: !!children[directory],
|
||||
pinned: pinned(directory),
|
||||
booting: input.isBooting(directory),
|
||||
loadingSessions: input.isLoadingSessions(directory),
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
vcsCache.delete(directory)
|
||||
metaCache.delete(directory)
|
||||
iconCache.delete(directory)
|
||||
lifecycle.delete(directory)
|
||||
const dispose = disposers.get(directory)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
disposers.delete(directory)
|
||||
}
|
||||
delete children[directory]
|
||||
input.onDispose(directory)
|
||||
input.markStats(Object.keys(children).length)
|
||||
return true
|
||||
}
|
||||
|
||||
function runEviction() {
|
||||
const stores = Object.keys(children)
|
||||
if (stores.length === 0) return
|
||||
const list = pickDirectoriesToEvict({
|
||||
stores,
|
||||
state: lifecycle,
|
||||
pins: new Set(stores.filter(pinned)),
|
||||
max: MAX_DIR_STORES,
|
||||
ttl: DIR_IDLE_TTL_MS,
|
||||
now: Date.now(),
|
||||
})
|
||||
if (list.length === 0) return
|
||||
for (const directory of list) {
|
||||
if (!disposeDirectory(directory)) continue
|
||||
input.incrementEvictions()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureChild(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
const vcs = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "vcs", ["vcs.v1"]),
|
||||
createStore({ value: undefined as VcsInfo | undefined }),
|
||||
),
|
||||
)
|
||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||
const vcsStore = vcs[0]
|
||||
const vcsReady = vcs[3]
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
||||
|
||||
const meta = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "project", ["project.v1"]),
|
||||
createStore({ value: undefined as ProjectMeta | undefined }),
|
||||
),
|
||||
)
|
||||
if (!meta) throw new Error("Failed to create persisted project metadata")
|
||||
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
||||
|
||||
const icon = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "icon", ["icon.v1"]),
|
||||
createStore({ value: undefined as string | undefined }),
|
||||
),
|
||||
)
|
||||
if (!icon) throw new Error("Failed to create persisted project icon")
|
||||
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||
|
||||
const init = () =>
|
||||
createRoot((dispose) => {
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: meta[0].value,
|
||||
icon: icon[0].value,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
children[directory] = child
|
||||
disposers.set(directory, dispose)
|
||||
|
||||
createEffect(() => {
|
||||
if (!vcsReady()) return
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
createEffect(() => {
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
createEffect(() => {
|
||||
child[1]("icon", icon[0].value)
|
||||
})
|
||||
})
|
||||
|
||||
runWithOwner(input.owner, init)
|
||||
input.markStats(Object.keys(children).length)
|
||||
}
|
||||
mark(directory)
|
||||
const childStore = children[directory]
|
||||
if (!childStore) throw new Error("Failed to create store")
|
||||
return childStore
|
||||
}
|
||||
|
||||
function child(directory: string, options: ChildOptions = {}) {
|
||||
const childStore = ensureChild(directory)
|
||||
pinForOwner(directory)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
}
|
||||
return childStore
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cached = metaCache.get(directory)
|
||||
if (!cached) return
|
||||
const previous = store.projectMeta ?? {}
|
||||
const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
|
||||
const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
|
||||
const next = {
|
||||
...previous,
|
||||
...patch,
|
||||
icon,
|
||||
commands,
|
||||
}
|
||||
cached.setStore("value", next)
|
||||
setStore("projectMeta", next)
|
||||
}
|
||||
|
||||
function projectIcon(directory: string, value: string | undefined) {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cached = iconCache.get(directory)
|
||||
if (!cached) return
|
||||
if (store.icon === value) return
|
||||
cached.setStore("value", value)
|
||||
setStore("icon", value)
|
||||
}
|
||||
|
||||
return {
|
||||
children,
|
||||
ensureChild,
|
||||
child,
|
||||
projectMeta,
|
||||
projectIcon,
|
||||
mark,
|
||||
pin,
|
||||
unpin,
|
||||
pinned,
|
||||
disposeDirectory,
|
||||
runEviction,
|
||||
vcsCache,
|
||||
metaCache,
|
||||
iconCache,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
|
||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
time: {
|
||||
created: 1,
|
||||
updated: 1,
|
||||
archived: input.archived,
|
||||
},
|
||||
}) as Session
|
||||
|
||||
const userMessage = (id: string, sessionID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
}) as Message
|
||||
|
||||
const textPart = (id: string, sessionID: string, messageID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
}) as Part
|
||||
|
||||
const baseState = (input: Partial<State> = {}) =>
|
||||
({
|
||||
status: "complete",
|
||||
agent: [],
|
||||
command: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider: {} as State["provider"],
|
||||
config: {} as State["config"],
|
||||
path: { directory: "/tmp" } as State["path"],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 10,
|
||||
message: {},
|
||||
part: {},
|
||||
...input,
|
||||
}) as State
|
||||
|
||||
describe("applyGlobalEvent", () => {
|
||||
test("upserts project.updated in sorted position", () => {
|
||||
const project = [{ id: "a" }, { id: "c" }] as Project[]
|
||||
let refreshCount = 0
|
||||
applyGlobalEvent({
|
||||
event: { type: "project.updated", properties: { id: "b" } },
|
||||
project,
|
||||
refresh: () => {
|
||||
refreshCount += 1
|
||||
},
|
||||
setGlobalProject(next) {
|
||||
if (typeof next === "function") next(project)
|
||||
},
|
||||
})
|
||||
|
||||
expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
|
||||
expect(refreshCount).toBe(0)
|
||||
})
|
||||
|
||||
test("handles global.disposed by triggering refresh", () => {
|
||||
let refreshCount = 0
|
||||
applyGlobalEvent({
|
||||
event: { type: "global.disposed" },
|
||||
project: [],
|
||||
refresh: () => {
|
||||
refreshCount += 1
|
||||
},
|
||||
setGlobalProject() {},
|
||||
})
|
||||
|
||||
expect(refreshCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
test("inserts root sessions in sorted order and updates sessionTotal", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [rootSession({ id: "b" })],
|
||||
sessionTotal: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
|
||||
expect(store.sessionTotal).toBe(2)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.sessionTotal).toBe(2)
|
||||
})
|
||||
|
||||
test("cleans session caches when archived", () => {
|
||||
const message = userMessage("msg_1", "ses_1")
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
|
||||
sessionTotal: 2,
|
||||
message: { ses_1: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] },
|
||||
permission: { ses_1: [] },
|
||||
question: { ses_1: [] },
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
|
||||
expect(store.sessionTotal).toBe(1)
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const pushes: string[] = []
|
||||
let lspLoads = 0
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "server.instance.disposed" },
|
||||
store,
|
||||
setStore,
|
||||
push(directory) {
|
||||
pushes.push(directory)
|
||||
},
|
||||
directory: "/tmp",
|
||||
loadLsp() {
|
||||
lspLoads += 1
|
||||
},
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "lsp.updated" },
|
||||
store,
|
||||
setStore,
|
||||
push(directory) {
|
||||
pushes.push(directory)
|
||||
},
|
||||
directory: "/tmp",
|
||||
loadLsp() {
|
||||
lspLoads += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(pushes).toEqual(["/tmp"])
|
||||
expect(lspLoads).toBe(1)
|
||||
})
|
||||
})
|
||||
319
opencode/packages/app/src/context/global-sync/event-reducer.ts
Normal file
319
opencode/packages/app/src/context/global-sync/event-reducer.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
|
||||
refresh: () => void
|
||||
}) {
|
||||
if (input.event.type === "global.disposed") {
|
||||
input.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
if (input.event.type !== "project.updated") return
|
||||
const properties = input.event.properties as Project
|
||||
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setGlobalProject((draft) => {
|
||||
draft[result.index] = { ...draft[result.index], ...properties }
|
||||
})
|
||||
return
|
||||
}
|
||||
input.setGlobalProject((draft) => {
|
||||
draft.splice(result.index, 0, properties)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
|
||||
if (!sessionID) return
|
||||
const hasAny =
|
||||
store.message[sessionID] !== undefined ||
|
||||
store.session_diff[sessionID] !== undefined ||
|
||||
store.todo[sessionID] !== undefined ||
|
||||
store.permission[sessionID] !== undefined ||
|
||||
store.question[sessionID] !== undefined ||
|
||||
store.session_status[sessionID] !== undefined
|
||||
if (!hasAny) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[sessionID]
|
||||
if (messages) {
|
||||
for (const message of messages) {
|
||||
const id = message?.id
|
||||
if (!id) continue
|
||||
delete draft.part[id]
|
||||
}
|
||||
}
|
||||
delete draft.message[sessionID]
|
||||
delete draft.session_diff[sessionID]
|
||||
delete draft.todo[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.question[sessionID]
|
||||
delete draft.session_status[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function applyDirectoryEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
push: (directory: string) => void
|
||||
directory: string
|
||||
loadLsp: () => void
|
||||
vcsCache?: VcsCache
|
||||
}) {
|
||||
const event = input.event
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
input.push(input.directory)
|
||||
return
|
||||
}
|
||||
case "session.created": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setStore("session", result.index, reconcile(info))
|
||||
break
|
||||
}
|
||||
const next = input.store.session.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
break
|
||||
}
|
||||
case "session.updated": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (info.time.archived) {
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
if (result.found) {
|
||||
input.setStore("session", result.index, reconcile(info))
|
||||
break
|
||||
}
|
||||
const next = input.store.session.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
break
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = (event.properties as { info: Message }).info
|
||||
const messages = input.store.message[info.sessionID]
|
||||
if (!messages) {
|
||||
input.setStore("message", info.sessionID, [info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
input.setStore("message", info.sessionID, result.index, reconcile(info))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"message",
|
||||
info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[props.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
input.setStore("part", part.messageID, result.index, reconcile(part))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"part",
|
||||
part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
const parts = input.store.part[props.messageID]
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
const list = draft.part[props.messageID]
|
||||
if (!list) return
|
||||
const next = Binary.search(list, props.partID, (p) => p.id)
|
||||
if (!next.found) return
|
||||
list.splice(next.index, 1)
|
||||
if (list.length === 0) delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch: string }
|
||||
const next = { branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
break
|
||||
}
|
||||
case "permission.asked": {
|
||||
const permission = event.properties as PermissionRequest
|
||||
const permissions = input.store.permission[permission.sessionID]
|
||||
if (!permissions) {
|
||||
input.setStore("permission", permission.sessionID, [permission])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(permissions, permission.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"permission",
|
||||
permission.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, permission)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "permission.replied": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
const permissions = input.store.permission[props.sessionID]
|
||||
if (!permissions) break
|
||||
const result = Binary.search(permissions, props.requestID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
input.setStore(
|
||||
"permission",
|
||||
props.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = input.store.question[question.sessionID]
|
||||
if (!questions) {
|
||||
input.setStore("question", question.sessionID, [question])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(questions, question.id, (q) => q.id)
|
||||
if (result.found) {
|
||||
input.setStore("question", question.sessionID, result.index, reconcile(question))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"question",
|
||||
question.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, question)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
const questions = input.store.question[props.sessionID]
|
||||
if (!questions) break
|
||||
const result = Binary.search(questions, props.requestID, (q) => q.id)
|
||||
if (!result.found) break
|
||||
input.setStore(
|
||||
"question",
|
||||
props.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "lsp.updated": {
|
||||
input.loadLsp()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
28
opencode/packages/app/src/context/global-sync/eviction.ts
Normal file
28
opencode/packages/app/src/context/global-sync/eviction.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { DisposeCheck, EvictPlan } from "./types"
|
||||
|
||||
export function pickDirectoriesToEvict(input: EvictPlan) {
|
||||
const overflow = Math.max(0, input.stores.length - input.max)
|
||||
let pendingOverflow = overflow
|
||||
const sorted = input.stores
|
||||
.filter((dir) => !input.pins.has(dir))
|
||||
.slice()
|
||||
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
|
||||
const output: string[] = []
|
||||
for (const dir of sorted) {
|
||||
const last = input.state.get(dir)?.lastAccessAt ?? 0
|
||||
const idle = input.now - last >= input.ttl
|
||||
if (!idle && pendingOverflow <= 0) continue
|
||||
output.push(dir)
|
||||
if (pendingOverflow > 0) pendingOverflow -= 1
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function canDisposeDirectory(input: DisposeCheck) {
|
||||
if (!input.directory) return false
|
||||
if (!input.hasStore) return false
|
||||
if (input.pinned) return false
|
||||
if (input.booting) return false
|
||||
if (input.loadingSessions) return false
|
||||
return true
|
||||
}
|
||||
83
opencode/packages/app/src/context/global-sync/queue.ts
Normal file
83
opencode/packages/app/src/context/global-sync/queue.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
type QueueInput = {
|
||||
paused: () => boolean
|
||||
bootstrap: () => Promise<void>
|
||||
bootstrapInstance: (directory: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function createRefreshQueue(input: QueueInput) {
|
||||
const queued = new Set<string>()
|
||||
let root = false
|
||||
let running = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const take = (count: number) => {
|
||||
if (queued.size === 0) return [] as string[]
|
||||
const items: string[] = []
|
||||
for (const item of queued) {
|
||||
queued.delete(item)
|
||||
items.push(item)
|
||||
if (items.length >= count) break
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
void drain()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const push = (directory: string) => {
|
||||
if (!directory) return
|
||||
queued.add(directory)
|
||||
if (input.paused()) return
|
||||
schedule()
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
root = true
|
||||
if (input.paused()) return
|
||||
schedule()
|
||||
}
|
||||
|
||||
async function drain() {
|
||||
if (running) return
|
||||
running = true
|
||||
try {
|
||||
while (true) {
|
||||
if (input.paused()) return
|
||||
if (root) {
|
||||
root = false
|
||||
await input.bootstrap()
|
||||
await tick()
|
||||
continue
|
||||
}
|
||||
const dirs = take(2)
|
||||
if (dirs.length === 0) return
|
||||
await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
|
||||
await tick()
|
||||
}
|
||||
} finally {
|
||||
running = false
|
||||
if (input.paused()) return
|
||||
if (root || queued.size) schedule()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
refresh,
|
||||
clear(directory: string) {
|
||||
queued.delete(directory)
|
||||
},
|
||||
dispose() {
|
||||
if (!timer) return
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { RootLoadArgs } from "./types"
|
||||
|
||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||
try {
|
||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
} catch {
|
||||
input.onFallback()
|
||||
const result = await input.list({ directory: input.directory, roots: true })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: false,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||
if (!input.limited) return input.count
|
||||
if (input.count < input.limit) return input.count
|
||||
return input.count + 1
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { trimSessions } from "./session-trim"
|
||||
|
||||
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
time: {
|
||||
created: input.created,
|
||||
updated: input.updated,
|
||||
archived: input.archived,
|
||||
},
|
||||
}) as Session
|
||||
|
||||
describe("trimSessions", () => {
|
||||
test("keeps base roots and recent roots beyond the limit", () => {
|
||||
const now = 1_000_000
|
||||
const list = [
|
||||
session({ id: "a", created: now - 100_000 }),
|
||||
session({ id: "b", created: now - 90_000 }),
|
||||
session({ id: "c", created: now - 80_000 }),
|
||||
session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
|
||||
session({ id: "e", created: now - 60_000, archived: now - 10 }),
|
||||
]
|
||||
|
||||
const result = trimSessions(list, { limit: 2, permission: {}, now })
|
||||
expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
|
||||
})
|
||||
|
||||
test("keeps children when root is kept, permission exists, or child is recent", () => {
|
||||
const now = 1_000_000
|
||||
const list = [
|
||||
session({ id: "root-1", created: now - 1000 }),
|
||||
session({ id: "root-2", created: now - 2000 }),
|
||||
session({ id: "z-root", created: now - 30_000_000 }),
|
||||
session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
|
||||
session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
|
||||
session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
|
||||
session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
|
||||
]
|
||||
|
||||
const result = trimSessions(list, {
|
||||
limit: 2,
|
||||
permission: {
|
||||
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.map((x) => x.id)).toEqual([
|
||||
"child-kept-by-permission",
|
||||
"child-kept-by-recency",
|
||||
"child-kept-by-root",
|
||||
"root-1",
|
||||
"root-2",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { cmp } from "./utils"
|
||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
|
||||
export function sessionUpdatedAt(session: Session) {
|
||||
return session.time.updated ?? session.time.created
|
||||
}
|
||||
|
||||
export function compareSessionRecent(a: Session, b: Session) {
|
||||
const aUpdated = sessionUpdatedAt(a)
|
||||
const bUpdated = sessionUpdatedAt(b)
|
||||
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||
return cmp(a.id, b.id)
|
||||
}
|
||||
|
||||
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
||||
if (limit <= 0) return [] as Session[]
|
||||
const selected: Session[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const session of sessions) {
|
||||
if (!session?.id) continue
|
||||
if (seen.has(session.id)) continue
|
||||
seen.add(session.id)
|
||||
if (sessionUpdatedAt(session) <= cutoff) continue
|
||||
const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
|
||||
if (index === -1) selected.push(session)
|
||||
if (index !== -1) selected.splice(index, 0, session)
|
||||
if (selected.length > limit) selected.pop()
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
export function trimSessions(
|
||||
input: Session[],
|
||||
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
|
||||
) {
|
||||
const limit = Math.max(0, options.limit)
|
||||
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
|
||||
const all = input
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const roots = all.filter((s) => !s.parentID)
|
||||
const children = all.filter((s) => !!s.parentID)
|
||||
const base = roots.slice(0, limit)
|
||||
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
|
||||
const keepRoots = [...base, ...recent]
|
||||
const keepRootIds = new Set(keepRoots.map((s) => s.id))
|
||||
const keepChildren = children.filter((s) => {
|
||||
if (s.parentID && keepRootIds.has(s.parentID)) return true
|
||||
const perms = options.permission[s.id] ?? []
|
||||
if (perms.length > 0) return true
|
||||
return sessionUpdatedAt(s) > cutoff
|
||||
})
|
||||
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
134
opencode/packages/app/src/context/global-sync/types.ts
Normal file
134
opencode/packages/app/src/context/global-sync/types.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
FileDiff,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
export type ProjectMeta = {
|
||||
name?: string
|
||||
icon?: {
|
||||
override?: string
|
||||
color?: string
|
||||
}
|
||||
commands?: {
|
||||
start?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider: ProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
sessionTotal: number
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiff[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
limit: number
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
store: Store<{ value: VcsInfo | undefined }>
|
||||
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type MetaCache = {
|
||||
store: Store<{ value: ProjectMeta | undefined }>
|
||||
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type IconCache = {
|
||||
store: Store<{ value: string | undefined }>
|
||||
setStore: SetStoreFunction<{ value: string | undefined }>
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
export type ChildOptions = {
|
||||
bootstrap?: boolean
|
||||
}
|
||||
|
||||
export type DirState = {
|
||||
lastAccessAt: number
|
||||
}
|
||||
|
||||
export type EvictPlan = {
|
||||
stores: string[]
|
||||
state: Map<string, DirState>
|
||||
pins: Set<string>
|
||||
max: number
|
||||
ttl: number
|
||||
now: number
|
||||
}
|
||||
|
||||
export type DisposeCheck = {
|
||||
directory: string
|
||||
hasStore: boolean
|
||||
pinned: boolean
|
||||
booting: boolean
|
||||
loadingSessions: boolean
|
||||
}
|
||||
|
||||
export type RootLoadArgs = {
|
||||
directory: string
|
||||
limit: number
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||
onFallback: () => void
|
||||
}
|
||||
|
||||
export type RootLoadResult = {
|
||||
data?: Session[]
|
||||
limit: number
|
||||
limited: boolean
|
||||
}
|
||||
|
||||
export const MAX_DIR_STORES = 30
|
||||
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||
export const SESSION_RECENT_LIMIT = 50
|
||||
25
opencode/packages/app/src/context/global-sync/utils.ts
Normal file
25
opencode/packages/app/src/context/global-sync/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
all: input.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeProject(project: Project) {
|
||||
if (!project.icon?.url && !project.icon?.override) return project
|
||||
return {
|
||||
...project,
|
||||
icon: {
|
||||
...project.icon,
|
||||
url: undefined,
|
||||
override: undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
225
opencode/packages/app/src/context/highlights.tsx
Normal file
225
opencode/packages/app/src/context/highlights.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
|
||||
|
||||
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
|
||||
|
||||
type Store = {
|
||||
version?: string
|
||||
}
|
||||
|
||||
type ParsedRelease = {
|
||||
tag?: string
|
||||
highlights: Highlight[]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getText(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim()
|
||||
return text.length > 0 ? text : undefined
|
||||
}
|
||||
|
||||
if (typeof value === "number") return String(value)
|
||||
return
|
||||
}
|
||||
|
||||
function normalizeVersion(value: string | undefined) {
|
||||
const text = value?.trim()
|
||||
if (!text) return
|
||||
return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text
|
||||
}
|
||||
|
||||
function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
|
||||
if (!isRecord(value)) return
|
||||
const type = getText(value.type)?.toLowerCase()
|
||||
const src = getText(value.src) ?? getText(value.url)
|
||||
if (!src) return
|
||||
if (type !== "image" && type !== "video") return
|
||||
|
||||
return { type, src, alt }
|
||||
}
|
||||
|
||||
function parseHighlight(value: unknown): Highlight | undefined {
|
||||
if (!isRecord(value)) return
|
||||
|
||||
const title = getText(value.title)
|
||||
if (!title) return
|
||||
|
||||
const description = getText(value.description) ?? getText(value.shortDescription)
|
||||
if (!description) return
|
||||
|
||||
const media = parseMedia(value.media, title)
|
||||
return { title, description, media }
|
||||
}
|
||||
|
||||
function parseRelease(value: unknown): ParsedRelease | undefined {
|
||||
if (!isRecord(value)) return
|
||||
const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name)
|
||||
|
||||
if (!Array.isArray(value.highlights)) {
|
||||
return { tag, highlights: [] }
|
||||
}
|
||||
|
||||
const highlights = value.highlights.flatMap((group) => {
|
||||
if (!isRecord(group)) return []
|
||||
|
||||
const source = getText(group.source)
|
||||
if (!source) return []
|
||||
if (!source.toLowerCase().includes("desktop")) return []
|
||||
|
||||
if (Array.isArray(group.items)) {
|
||||
return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined)
|
||||
}
|
||||
|
||||
const item = parseHighlight(group)
|
||||
if (!item) return []
|
||||
return [item]
|
||||
})
|
||||
|
||||
return { tag, highlights }
|
||||
}
|
||||
|
||||
function parseChangelog(value: unknown): ParsedRelease[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return
|
||||
if (!Array.isArray(value.releases)) return
|
||||
|
||||
return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
|
||||
}
|
||||
|
||||
function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) {
|
||||
const current = normalizeVersion(input.current)
|
||||
const previous = normalizeVersion(input.previous)
|
||||
const releases = input.releases
|
||||
|
||||
const start = (() => {
|
||||
if (!current) return 0
|
||||
const index = releases.findIndex((release) => normalizeVersion(release.tag) === current)
|
||||
return index === -1 ? 0 : index
|
||||
})()
|
||||
|
||||
const end = (() => {
|
||||
if (!previous) return releases.length
|
||||
const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous)
|
||||
return index === -1 ? releases.length : index
|
||||
})()
|
||||
|
||||
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
|
||||
const seen = new Set<string>()
|
||||
const unique = highlights.filter((highlight) => {
|
||||
const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join(
|
||||
"\n",
|
||||
)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
return unique.slice(0, 5)
|
||||
}
|
||||
|
||||
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
|
||||
name: "Highlights",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
|
||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
||||
const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const state = { started: false }
|
||||
|
||||
const markSeen = () => {
|
||||
if (!platform.version) return
|
||||
setStore("version", platform.version)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (state.started) return
|
||||
if (!ready()) return
|
||||
if (!settings.ready()) return
|
||||
if (!platform.version) return
|
||||
state.started = true
|
||||
|
||||
const previous = store.version
|
||||
if (!previous) {
|
||||
setStore("version", platform.version)
|
||||
return
|
||||
}
|
||||
|
||||
if (previous === platform.version) return
|
||||
|
||||
setFrom(previous)
|
||||
setTo(platform.version)
|
||||
|
||||
if (!settings.general.releaseNotes()) {
|
||||
markSeen()
|
||||
return
|
||||
}
|
||||
|
||||
const fetcher = platform.fetch ?? fetch
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => {
|
||||
controller.abort()
|
||||
const id = timer()
|
||||
if (id === undefined) return
|
||||
clearTimeout(id)
|
||||
})
|
||||
|
||||
fetcher(CHANGELOG_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
||||
.then((json) => {
|
||||
if (!json) return
|
||||
const releases = parseChangelog(json)
|
||||
if (!releases) return
|
||||
if (releases.length === 0) return
|
||||
const highlights = sliceHighlights({
|
||||
releases,
|
||||
current: platform.version,
|
||||
previous,
|
||||
})
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (highlights.length === 0) {
|
||||
markSeen()
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
markSeen()
|
||||
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
|
||||
}, 500)
|
||||
setTimer(timer)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
from,
|
||||
to,
|
||||
get last() {
|
||||
return store.version
|
||||
},
|
||||
markSeen,
|
||||
}
|
||||
},
|
||||
})
|
||||
226
opencode/packages/app/src/context/language.tsx
Normal file
226
opencode/packages/app/src/context/language.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
||||
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
|
||||
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
|
||||
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
|
||||
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
|
||||
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
|
||||
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
|
||||
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
|
||||
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
|
||||
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
||||
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
||||
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
||||
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
| "zh"
|
||||
| "zht"
|
||||
| "ko"
|
||||
| "de"
|
||||
| "es"
|
||||
| "fr"
|
||||
| "da"
|
||||
| "ja"
|
||||
| "pl"
|
||||
| "ru"
|
||||
| "ar"
|
||||
| "no"
|
||||
| "br"
|
||||
| "th"
|
||||
| "bs"
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
|
||||
const LOCALES: readonly Locale[] = [
|
||||
"en",
|
||||
"zh",
|
||||
"zht",
|
||||
"ko",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"da",
|
||||
"ja",
|
||||
"pl",
|
||||
"ru",
|
||||
"bs",
|
||||
"ar",
|
||||
"no",
|
||||
"br",
|
||||
"th",
|
||||
]
|
||||
|
||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
zh,
|
||||
zht,
|
||||
ko,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
da,
|
||||
ja,
|
||||
pl,
|
||||
ru,
|
||||
ar,
|
||||
no,
|
||||
br,
|
||||
th,
|
||||
bs,
|
||||
}
|
||||
void PARITY_CHECK
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator !== "object") return "en"
|
||||
|
||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
if (language.toLowerCase().startsWith("zh")) {
|
||||
if (language.toLowerCase().includes("hant")) return "zht"
|
||||
return "zh"
|
||||
}
|
||||
if (language.toLowerCase().startsWith("ko")) return "ko"
|
||||
if (language.toLowerCase().startsWith("de")) return "de"
|
||||
if (language.toLowerCase().startsWith("es")) return "es"
|
||||
if (language.toLowerCase().startsWith("fr")) return "fr"
|
||||
if (language.toLowerCase().startsWith("da")) return "da"
|
||||
if (language.toLowerCase().startsWith("ja")) return "ja"
|
||||
if (language.toLowerCase().startsWith("pl")) return "pl"
|
||||
if (language.toLowerCase().startsWith("ru")) return "ru"
|
||||
if (language.toLowerCase().startsWith("ar")) return "ar"
|
||||
if (
|
||||
language.toLowerCase().startsWith("no") ||
|
||||
language.toLowerCase().startsWith("nb") ||
|
||||
language.toLowerCase().startsWith("nn")
|
||||
)
|
||||
return "no"
|
||||
if (language.toLowerCase().startsWith("pt")) return "br"
|
||||
if (language.toLowerCase().startsWith("th")) return "th"
|
||||
if (language.toLowerCase().startsWith("bs")) return "bs"
|
||||
}
|
||||
|
||||
return "en"
|
||||
}
|
||||
|
||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||
name: "Language",
|
||||
init: () => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
createStore({
|
||||
locale: detectLocale() as Locale,
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => {
|
||||
if (store.locale === "zh") return "zh"
|
||||
if (store.locale === "zht") return "zht"
|
||||
if (store.locale === "ko") return "ko"
|
||||
if (store.locale === "de") return "de"
|
||||
if (store.locale === "es") return "es"
|
||||
if (store.locale === "fr") return "fr"
|
||||
if (store.locale === "da") return "da"
|
||||
if (store.locale === "ja") return "ja"
|
||||
if (store.locale === "pl") return "pl"
|
||||
if (store.locale === "ru") return "ru"
|
||||
if (store.locale === "ar") return "ar"
|
||||
if (store.locale === "no") return "no"
|
||||
if (store.locale === "br") return "br"
|
||||
if (store.locale === "th") return "th"
|
||||
if (store.locale === "bs") return "bs"
|
||||
return "en"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = locale()
|
||||
if (store.locale === current) return
|
||||
setStore("locale", current)
|
||||
})
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const dict = createMemo<Dictionary>(() => {
|
||||
if (locale() === "en") return base
|
||||
if (locale() === "zh") return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }
|
||||
if (locale() === "zht") return { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }
|
||||
if (locale() === "de") return { ...base, ...i18n.flatten({ ...de, ...uiDe }) }
|
||||
if (locale() === "es") return { ...base, ...i18n.flatten({ ...es, ...uiEs }) }
|
||||
if (locale() === "fr") return { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }
|
||||
if (locale() === "da") return { ...base, ...i18n.flatten({ ...da, ...uiDa }) }
|
||||
if (locale() === "ja") return { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }
|
||||
if (locale() === "pl") return { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }
|
||||
if (locale() === "ru") return { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }
|
||||
if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
|
||||
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
|
||||
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
|
||||
if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
|
||||
if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
|
||||
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
|
||||
})
|
||||
|
||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
||||
|
||||
const labelKey: Record<Locale, keyof Dictionary> = {
|
||||
en: "language.en",
|
||||
zh: "language.zh",
|
||||
zht: "language.zht",
|
||||
ko: "language.ko",
|
||||
de: "language.de",
|
||||
es: "language.es",
|
||||
fr: "language.fr",
|
||||
da: "language.da",
|
||||
ja: "language.ja",
|
||||
pl: "language.pl",
|
||||
ru: "language.ru",
|
||||
ar: "language.ar",
|
||||
no: "language.no",
|
||||
br: "language.br",
|
||||
th: "language.th",
|
||||
bs: "language.bs",
|
||||
}
|
||||
|
||||
const label = (value: Locale) => t(labelKey[value])
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document !== "object") return
|
||||
document.documentElement.lang = locale()
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
locale,
|
||||
locales: LOCALES,
|
||||
label,
|
||||
t,
|
||||
setLocale(next: Locale) {
|
||||
setStore("locale", next)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
36
opencode/packages/app/src/context/layout-scroll.test.ts
Normal file
36
opencode/packages/app/src/context/layout-scroll.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createScrollPersistence } from "./layout-scroll"
|
||||
|
||||
describe("createScrollPersistence", () => {
|
||||
test("debounces persisted scroll writes", async () => {
|
||||
const snapshot = {
|
||||
session: {
|
||||
review: { x: 0, y: 0 },
|
||||
},
|
||||
} as Record<string, Record<string, { x: number; y: number }>>
|
||||
const writes: Array<Record<string, { x: number; y: number }>> = []
|
||||
const scroll = createScrollPersistence({
|
||||
debounceMs: 10,
|
||||
getSnapshot: (sessionKey) => snapshot[sessionKey],
|
||||
onFlush: (sessionKey, next) => {
|
||||
snapshot[sessionKey] = next
|
||||
writes.push(next)
|
||||
},
|
||||
})
|
||||
|
||||
for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
|
||||
scroll.setScroll("session", "review", { x: 0, y: i })
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
|
||||
|
||||
scroll.setScroll("session", "review", { x: 0, y: 30 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
scroll.dispose()
|
||||
})
|
||||
})
|
||||
118
opencode/packages/app/src/context/layout-scroll.ts
Normal file
118
opencode/packages/app/src/context/layout-scroll.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
export type SessionScroll = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type ScrollMap = Record<string, SessionScroll>
|
||||
|
||||
type Options = {
|
||||
debounceMs?: number
|
||||
getSnapshot: (sessionKey: string) => ScrollMap | undefined
|
||||
onFlush: (sessionKey: string, scroll: ScrollMap) => void
|
||||
}
|
||||
|
||||
export function createScrollPersistence(opts: Options) {
|
||||
const wait = opts.debounceMs ?? 200
|
||||
const [cache, setCache] = createStore<Record<string, ScrollMap>>({})
|
||||
const dirty = new Set<string>()
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function clone(input?: ScrollMap) {
|
||||
const out: ScrollMap = {}
|
||||
if (!input) return out
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
const pos = input[key]
|
||||
if (!pos) continue
|
||||
out[key] = { x: pos.x, y: pos.y }
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function seed(sessionKey: string) {
|
||||
if (cache[sessionKey]) return
|
||||
setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
|
||||
}
|
||||
|
||||
function scroll(sessionKey: string, tab: string) {
|
||||
seed(sessionKey)
|
||||
return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab]
|
||||
}
|
||||
|
||||
function schedule(sessionKey: string) {
|
||||
const prev = timers.get(sessionKey)
|
||||
if (prev) clearTimeout(prev)
|
||||
timers.set(
|
||||
sessionKey,
|
||||
setTimeout(() => flush(sessionKey), wait),
|
||||
)
|
||||
}
|
||||
|
||||
function setScroll(sessionKey: string, tab: string, pos: SessionScroll) {
|
||||
seed(sessionKey)
|
||||
|
||||
const prev = cache[sessionKey]?.[tab]
|
||||
if (prev?.x === pos.x && prev?.y === pos.y) return
|
||||
|
||||
setCache(sessionKey, tab, { x: pos.x, y: pos.y })
|
||||
dirty.add(sessionKey)
|
||||
schedule(sessionKey)
|
||||
}
|
||||
|
||||
function flush(sessionKey: string) {
|
||||
const timer = timers.get(sessionKey)
|
||||
if (timer) clearTimeout(timer)
|
||||
timers.delete(sessionKey)
|
||||
|
||||
if (!dirty.has(sessionKey)) return
|
||||
dirty.delete(sessionKey)
|
||||
|
||||
opts.onFlush(sessionKey, clone(cache[sessionKey]))
|
||||
}
|
||||
|
||||
function flushAll() {
|
||||
const keys = Array.from(dirty)
|
||||
if (keys.length === 0) return
|
||||
|
||||
for (const key of keys) {
|
||||
flush(key)
|
||||
}
|
||||
}
|
||||
|
||||
function drop(keys: string[]) {
|
||||
if (keys.length === 0) return
|
||||
|
||||
for (const key of keys) {
|
||||
const timer = timers.get(key)
|
||||
if (timer) clearTimeout(timer)
|
||||
timers.delete(key)
|
||||
dirty.delete(key)
|
||||
}
|
||||
|
||||
setCache(
|
||||
produce((draft) => {
|
||||
for (const key of keys) {
|
||||
delete draft[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
drop(Array.from(timers.keys()))
|
||||
}
|
||||
|
||||
return {
|
||||
cache,
|
||||
drop,
|
||||
flush,
|
||||
flushAll,
|
||||
scroll,
|
||||
seed,
|
||||
setScroll,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
69
opencode/packages/app/src/context/layout.test.ts
Normal file
69
opencode/packages/app/src/context/layout.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
|
||||
|
||||
describe("layout session-key helpers", () => {
|
||||
test("couples touch and scroll seed in order", () => {
|
||||
const calls: string[] = []
|
||||
const result = ensureSessionKey(
|
||||
"dir/a",
|
||||
(key) => calls.push(`touch:${key}`),
|
||||
(key) => calls.push(`seed:${key}`),
|
||||
)
|
||||
|
||||
expect(result).toBe("dir/a")
|
||||
expect(calls).toEqual(["touch:dir/a", "seed:dir/a"])
|
||||
})
|
||||
|
||||
test("reads dynamic accessor keys lazily", () => {
|
||||
const seen: string[] = []
|
||||
|
||||
createRoot((dispose) => {
|
||||
const [key, setKey] = createSignal("dir/one")
|
||||
const read = createSessionKeyReader(key, (value) => seen.push(value))
|
||||
|
||||
expect(read()).toBe("dir/one")
|
||||
setKey("dir/two")
|
||||
expect(read()).toBe("dir/two")
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(seen).toEqual(["dir/one", "dir/two"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("pruneSessionKeys", () => {
|
||||
test("keeps active key and drops lowest-used keys", () => {
|
||||
const drop = pruneSessionKeys({
|
||||
keep: "k4",
|
||||
max: 3,
|
||||
used: new Map([
|
||||
["k1", 1],
|
||||
["k2", 2],
|
||||
["k3", 3],
|
||||
["k4", 4],
|
||||
]),
|
||||
view: ["k1", "k2", "k4"],
|
||||
tabs: ["k1", "k3", "k4"],
|
||||
})
|
||||
|
||||
expect(drop).toEqual(["k1"])
|
||||
expect(drop.includes("k4")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not prune without keep key", () => {
|
||||
const drop = pruneSessionKeys({
|
||||
keep: undefined,
|
||||
max: 1,
|
||||
used: new Map([
|
||||
["k1", 1],
|
||||
["k2", 2],
|
||||
]),
|
||||
view: ["k1"],
|
||||
tabs: ["k2"],
|
||||
})
|
||||
|
||||
expect(drop).toEqual([])
|
||||
})
|
||||
})
|
||||
837
opencode/packages/app/src/context/layout.tsx
Normal file
837
opencode/packages/app/src/context/layout.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServer } from "./server"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||
|
||||
export function getAvatarColors(key?: string) {
|
||||
if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
|
||||
return {
|
||||
background: `var(--avatar-background-${key})`,
|
||||
foreground: `var(--avatar-text-${key})`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: "var(--surface-info-base)",
|
||||
foreground: "var(--text-base)",
|
||||
}
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
}
|
||||
|
||||
type TabHandoff = {
|
||||
dir: string
|
||||
id: string
|
||||
at: number
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
|
||||
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
|
||||
touch(key)
|
||||
seed(key)
|
||||
return key
|
||||
}
|
||||
|
||||
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
|
||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||
return () => {
|
||||
const value = key()
|
||||
ensure(value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneSessionKeys(input: {
|
||||
keep?: string
|
||||
max: number
|
||||
used: Map<string, number>
|
||||
view: string[]
|
||||
tabs: string[]
|
||||
}) {
|
||||
if (!input.keep) return []
|
||||
|
||||
const keys = new Set<string>([...input.view, ...input.tabs])
|
||||
if (keys.size <= input.max) return []
|
||||
|
||||
const score = (key: string) => {
|
||||
if (key === input.keep) return Number.MAX_SAFE_INTEGER
|
||||
return input.used.get(key) ?? 0
|
||||
}
|
||||
|
||||
return Array.from(keys)
|
||||
.sort((a, b) => score(b) - score(a))
|
||||
.slice(input.max)
|
||||
}
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
const globalSdk = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const server = useServer()
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
const migrate = (value: unknown) => {
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const sidebar = value.sidebar
|
||||
const migratedSidebar = (() => {
|
||||
if (!isRecord(sidebar)) return sidebar
|
||||
if (typeof sidebar.workspaces !== "boolean") return sidebar
|
||||
return {
|
||||
...sidebar,
|
||||
workspaces: {},
|
||||
workspacesDefault: sidebar.workspaces,
|
||||
}
|
||||
})()
|
||||
|
||||
const review = value.review
|
||||
const fileTree = value.fileTree
|
||||
const migratedFileTree = (() => {
|
||||
if (!isRecord(fileTree)) return fileTree
|
||||
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
||||
|
||||
const width = typeof fileTree.width === "number" ? fileTree.width : 344
|
||||
return {
|
||||
...fileTree,
|
||||
opened: true,
|
||||
width: width === 260 ? 344 : width,
|
||||
tab: "changes",
|
||||
}
|
||||
})()
|
||||
|
||||
const migratedReview = (() => {
|
||||
if (!isRecord(review)) return review
|
||||
if (typeof review.panelOpened === "boolean") return review
|
||||
|
||||
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
|
||||
return {
|
||||
...review,
|
||||
panelOpened: opened,
|
||||
}
|
||||
})()
|
||||
|
||||
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
|
||||
return {
|
||||
...value,
|
||||
sidebar: migratedSidebar,
|
||||
review: migratedReview,
|
||||
fileTree: migratedFileTree,
|
||||
}
|
||||
}
|
||||
|
||||
const target = Persist.global("layout", ["layout.v6"])
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...target, migrate },
|
||||
createStore({
|
||||
sidebar: {
|
||||
opened: false,
|
||||
width: 344,
|
||||
workspaces: {} as Record<string, boolean>,
|
||||
workspacesDefault: false,
|
||||
},
|
||||
terminal: {
|
||||
height: 280,
|
||||
opened: false,
|
||||
},
|
||||
review: {
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
panelOpened: true,
|
||||
},
|
||||
fileTree: {
|
||||
opened: true,
|
||||
width: 344,
|
||||
tab: "changes" as "changes" | "all",
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
},
|
||||
mobileSidebar: {
|
||||
opened: false,
|
||||
},
|
||||
sessionTabs: {} as Record<string, SessionTabs>,
|
||||
sessionView: {} as Record<string, SessionView>,
|
||||
handoff: {
|
||||
tabs: undefined as TabHandoff | undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||
const meta = { active: undefined as string | undefined, pruned: false }
|
||||
const used = new Map<string, number>()
|
||||
|
||||
const SESSION_STATE_KEYS = [
|
||||
{ key: "prompt", legacy: "prompt", version: "v2" },
|
||||
{ key: "terminal", legacy: "terminal", version: "v1" },
|
||||
{ key: "file-view", legacy: "file", version: "v1" },
|
||||
] as const
|
||||
|
||||
const dropSessionState = (keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
const parts = key.split("/")
|
||||
const dir = parts[0]
|
||||
const session = parts[1]
|
||||
if (!dir) continue
|
||||
|
||||
for (const entry of SESSION_STATE_KEYS) {
|
||||
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
|
||||
void removePersisted(target)
|
||||
|
||||
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
||||
void removePersisted({ key: legacyKey })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prune(keep?: string) {
|
||||
const drop = pruneSessionKeys({
|
||||
keep,
|
||||
max: MAX_SESSION_KEYS,
|
||||
used,
|
||||
view: Object.keys(store.sessionView),
|
||||
tabs: Object.keys(store.sessionTabs),
|
||||
})
|
||||
if (drop.length === 0) return
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
for (const key of drop) {
|
||||
delete draft.sessionView[key]
|
||||
delete draft.sessionTabs[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
scroll.drop(drop)
|
||||
dropSessionState(drop)
|
||||
|
||||
for (const key of drop) {
|
||||
used.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function touch(sessionKey: string) {
|
||||
meta.active = sessionKey
|
||||
used.set(sessionKey, Date.now())
|
||||
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
|
||||
meta.pruned = true
|
||||
prune(sessionKey)
|
||||
}
|
||||
|
||||
const scroll = createScrollPersistence({
|
||||
debounceMs: 250,
|
||||
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
||||
onFlush: (sessionKey, next) => {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const keep = meta.active ?? sessionKey
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: next })
|
||||
prune(keep)
|
||||
return
|
||||
}
|
||||
|
||||
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
|
||||
prune(keep)
|
||||
},
|
||||
})
|
||||
|
||||
const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
const active = meta.active
|
||||
if (!active) return
|
||||
meta.pruned = true
|
||||
prune(active)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const flush = () => batch(() => scroll.flushAll())
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
flush()
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", flush)
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pagehide", flush)
|
||||
document.removeEventListener("visibilitychange", handleVisibility)
|
||||
scroll.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
|
||||
const colorRequested = new Map<string, AvatarColorKey>()
|
||||
|
||||
function pickAvailableColor(used: Set<string>): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
|
||||
if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
|
||||
return available[Math.floor(Math.random() * available.length)]
|
||||
}
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
|
||||
const projectID = childStore.project
|
||||
const metadata = projectID
|
||||
? globalSync.data.project.find((x) => x.id === projectID)
|
||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
|
||||
const local = childStore.projectMeta
|
||||
const localOverride =
|
||||
local?.name !== undefined ||
|
||||
local?.commands?.start !== undefined ||
|
||||
local?.icon?.override !== undefined ||
|
||||
local?.icon?.color !== undefined
|
||||
|
||||
const base = {
|
||||
...(metadata ?? {}),
|
||||
...project,
|
||||
icon: {
|
||||
url: metadata?.icon?.url,
|
||||
override: metadata?.icon?.override ?? childStore.icon,
|
||||
color: metadata?.icon?.color,
|
||||
},
|
||||
}
|
||||
|
||||
const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
|
||||
if (!isGlobal) return base
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: base.id ?? "global",
|
||||
name: local?.name,
|
||||
commands: local?.commands,
|
||||
icon: {
|
||||
url: base.icon?.url,
|
||||
override: local?.icon?.override,
|
||||
color: local?.icon?.color,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const roots = createMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const project of globalSync.data.project) {
|
||||
const sandboxes = project.sandboxes ?? []
|
||||
for (const sandbox of sandboxes) {
|
||||
map.set(sandbox, project.worktree)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const rootFor = (directory: string) => {
|
||||
const map = roots()
|
||||
if (map.size === 0) return directory
|
||||
|
||||
const visited = new Set<string>()
|
||||
const chain = [directory]
|
||||
|
||||
while (chain.length) {
|
||||
const current = chain[chain.length - 1]
|
||||
if (!current) return directory
|
||||
|
||||
const next = map.get(current)
|
||||
if (!next) return current
|
||||
|
||||
if (visited.has(next)) return directory
|
||||
visited.add(next)
|
||||
chain.push(next)
|
||||
}
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const projects = server.projects.list()
|
||||
const seen = new Set(projects.map((project) => project.worktree))
|
||||
|
||||
batch(() => {
|
||||
for (const project of projects) {
|
||||
const root = rootFor(project.worktree)
|
||||
if (root === project.worktree) continue
|
||||
|
||||
server.projects.close(project.worktree)
|
||||
|
||||
if (!seen.has(root)) {
|
||||
server.projects.open(root)
|
||||
seen.add(root)
|
||||
}
|
||||
|
||||
if (project.expanded) server.projects.expand(root)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const enriched = createMemo(() => server.projects.list().map(enrich))
|
||||
const list = createMemo(() => {
|
||||
const projects = enriched()
|
||||
return projects.map((project) => {
|
||||
const color = project.icon?.color ?? colors[project.worktree]
|
||||
if (!color) return project
|
||||
const icon = project.icon ? { ...project.icon, color } : { color }
|
||||
return { ...project, icon }
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
if (!globalSync.ready) return
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) colorRequested.delete(project.worktree)
|
||||
}
|
||||
|
||||
const used = new Set<string>()
|
||||
for (const project of projects) {
|
||||
const color = project.icon?.color ?? colors[project.worktree]
|
||||
if (color) used.add(color)
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) continue
|
||||
const worktree = project.worktree
|
||||
const existing = colors[worktree]
|
||||
const color = existing ?? pickAvailableColor(used)
|
||||
if (!existing) {
|
||||
used.add(color)
|
||||
setColors(worktree, color)
|
||||
}
|
||||
if (!project.id) continue
|
||||
|
||||
const requested = colorRequested.get(worktree)
|
||||
if (requested === color) continue
|
||||
colorRequested.set(worktree, color)
|
||||
|
||||
if (project.id === "global") {
|
||||
globalSync.project.meta(worktree, { icon: { color } })
|
||||
continue
|
||||
}
|
||||
|
||||
void globalSdk.client.project
|
||||
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
Promise.all(
|
||||
server.projects.list().map((project) => {
|
||||
return globalSync.project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
handoff: {
|
||||
tabs: createMemo(() => store.handoff?.tabs),
|
||||
setTabs(dir: string, id: string) {
|
||||
setStore("handoff", "tabs", { dir, id, at: Date.now() })
|
||||
},
|
||||
clearTabs() {
|
||||
if (!store.handoff?.tabs) return
|
||||
setStore("handoff", "tabs", undefined)
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
list,
|
||||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
globalSync.project.loadSessions(root)
|
||||
server.projects.open(root)
|
||||
},
|
||||
close(directory: string) {
|
||||
server.projects.close(directory)
|
||||
},
|
||||
expand(directory: string) {
|
||||
server.projects.expand(directory)
|
||||
},
|
||||
collapse(directory: string) {
|
||||
server.projects.collapse(directory)
|
||||
},
|
||||
move(directory: string, toIndex: number) {
|
||||
server.projects.move(directory, toIndex)
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
opened: createMemo(() => store.sidebar.opened),
|
||||
open() {
|
||||
setStore("sidebar", "opened", true)
|
||||
},
|
||||
close() {
|
||||
setStore("sidebar", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
setStore("sidebar", "opened", (x) => !x)
|
||||
},
|
||||
width: createMemo(() => store.sidebar.width),
|
||||
resize(width: number) {
|
||||
setStore("sidebar", "width", width)
|
||||
},
|
||||
workspaces(directory: string) {
|
||||
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
||||
},
|
||||
setWorkspaces(directory: string, value: boolean) {
|
||||
setStore("sidebar", "workspaces", directory, value)
|
||||
},
|
||||
toggleWorkspaces(directory: string) {
|
||||
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
||||
setStore("sidebar", "workspaces", directory, !current)
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
height: createMemo(() => store.terminal.height),
|
||||
resize(height: number) {
|
||||
setStore("terminal", "height", height)
|
||||
},
|
||||
},
|
||||
review: {
|
||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||
if (!store.review) {
|
||||
setStore("review", { diffStyle, panelOpened: true })
|
||||
return
|
||||
}
|
||||
setStore("review", "diffStyle", diffStyle)
|
||||
},
|
||||
},
|
||||
fileTree: {
|
||||
opened: createMemo(() => store.fileTree?.opened ?? true),
|
||||
width: createMemo(() => store.fileTree?.width ?? 344),
|
||||
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
||||
setTab(tab: "changes" | "all") {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 344, tab })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "tab", tab)
|
||||
},
|
||||
open() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", true)
|
||||
},
|
||||
close() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: false, width: 344, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", (x) => !x)
|
||||
},
|
||||
resize(width: number) {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "width", width)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => store.session?.width ?? 600),
|
||||
resize(width: number) {
|
||||
if (!store.session) {
|
||||
setStore("session", { width })
|
||||
return
|
||||
}
|
||||
setStore("session", "width", width)
|
||||
},
|
||||
},
|
||||
mobileSidebar: {
|
||||
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||
show() {
|
||||
setStore("mobileSidebar", "opened", true)
|
||||
},
|
||||
hide() {
|
||||
setStore("mobileSidebar", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
setStore("mobileSidebar", "opened", (x) => !x)
|
||||
},
|
||||
},
|
||||
pendingMessage: {
|
||||
set(sessionKey: string, messageID: string) {
|
||||
const at = Date.now()
|
||||
touch(sessionKey)
|
||||
const current = store.sessionView[sessionKey]
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, {
|
||||
scroll: {},
|
||||
pendingMessage: messageID,
|
||||
pendingMessageAt: at,
|
||||
})
|
||||
prune(meta.active ?? sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
setStore(
|
||||
"sessionView",
|
||||
sessionKey,
|
||||
produce((draft) => {
|
||||
draft.pendingMessage = messageID
|
||||
draft.pendingMessageAt = at
|
||||
}),
|
||||
)
|
||||
},
|
||||
consume(sessionKey: string) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const message = current?.pendingMessage
|
||||
const at = current?.pendingMessageAt
|
||||
if (!message || !at) return
|
||||
|
||||
setStore(
|
||||
"sessionView",
|
||||
sessionKey,
|
||||
produce((draft) => {
|
||||
delete draft.pendingMessage
|
||||
delete draft.pendingMessageAt
|
||||
}),
|
||||
)
|
||||
|
||||
if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
|
||||
return message
|
||||
},
|
||||
},
|
||||
view(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
||||
|
||||
function setTerminalOpened(next: boolean) {
|
||||
const current = store.terminal
|
||||
if (!current) {
|
||||
setStore("terminal", { height: 280, opened: next })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.opened ?? false
|
||||
if (value === next) return
|
||||
setStore("terminal", "opened", next)
|
||||
}
|
||||
|
||||
function setReviewPanelOpened(next: boolean) {
|
||||
const current = store.review
|
||||
if (!current) {
|
||||
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.panelOpened ?? true
|
||||
if (value === next) return
|
||||
setStore("review", "panelOpened", next)
|
||||
}
|
||||
|
||||
return {
|
||||
scroll(tab: string) {
|
||||
return scroll.scroll(key(), tab)
|
||||
},
|
||||
setScroll(tab: string, pos: SessionScroll) {
|
||||
scroll.setScroll(key(), tab, pos)
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
open() {
|
||||
setTerminalOpened(true)
|
||||
},
|
||||
close() {
|
||||
setTerminalOpened(false)
|
||||
},
|
||||
toggle() {
|
||||
setTerminalOpened(!terminalOpened())
|
||||
},
|
||||
},
|
||||
reviewPanel: {
|
||||
opened: reviewPanelOpened,
|
||||
open() {
|
||||
setReviewPanelOpened(true)
|
||||
},
|
||||
close() {
|
||||
setReviewPanelOpened(false)
|
||||
},
|
||||
toggle() {
|
||||
setReviewPanelOpened(!reviewPanelOpened())
|
||||
},
|
||||
},
|
||||
review: {
|
||||
open: createMemo(() => s().reviewOpen),
|
||||
setOpen(open: string[]) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: open,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, open)) return
|
||||
setStore("sessionView", session, "reviewOpen", open)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
tabs(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||
setActive(tab: string | undefined) {
|
||||
const session = key()
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
}
|
||||
},
|
||||
setAll(all: string[]) {
|
||||
const session = key()
|
||||
const next = all.filter((tab) => tab !== "review")
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "all", next)
|
||||
}
|
||||
},
|
||||
async open(tab: string) {
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session] ?? { all: [] }
|
||||
|
||||
if (tab === "review") {
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (tab === "context") {
|
||||
const all = [tab, ...current.all.filter((x) => x !== tab)]
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all, active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (!current.all.includes(tab)) {
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [tab], active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "all", [...current.all, tab])
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: current.all, active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
},
|
||||
close(tab: string) {
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
|
||||
if (tab === "review") {
|
||||
if (current.active !== tab) return
|
||||
setStore("sessionTabs", session, "active", current.all[0])
|
||||
return
|
||||
}
|
||||
|
||||
const all = current.all.filter((x) => x !== tab)
|
||||
if (current.active !== tab) {
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
return
|
||||
}
|
||||
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
|
||||
batch(() => {
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
setStore("sessionTabs", session, "active", next)
|
||||
})
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"sessionTabs",
|
||||
session,
|
||||
"all",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
229
opencode/packages/app/src/context/local.tsx
Normal file
229
opencode/packages/app/src/context/local.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useModels } from "@/context/models"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const providers = useProviders()
|
||||
|
||||
function isModelValid(model: ModelKey) {
|
||||
const provider = providers.all().find((x) => x.id === model.providerID)
|
||||
return (
|
||||
!!provider?.models[model.modelID] &&
|
||||
providers
|
||||
.connected()
|
||||
.map((p) => p.id)
|
||||
.includes(model.providerID)
|
||||
)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const [store, setStore] = createStore<{
|
||||
current?: string
|
||||
}>({
|
||||
current: list()[0]?.name,
|
||||
})
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
const available = list()
|
||||
if (available.length === 0) return undefined
|
||||
return available.find((x) => x.name === store.current) ?? available[0]
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
if (name && available.some((x) => x.name === name)) {
|
||||
setStore("current", name)
|
||||
return
|
||||
}
|
||||
setStore("current", available[0].name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
let next = available.findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = available.length - 1
|
||||
if (next >= available.length) next = 0
|
||||
const value = available[next]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const models = useModels()
|
||||
|
||||
const [ephemeral, setEphemeral] = createStore<{
|
||||
model: Record<string, ModelKey | undefined>
|
||||
}>({
|
||||
model: {},
|
||||
})
|
||||
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of models.recent.list()) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
const defaults = providers.default()
|
||||
for (const p of providers.connected()) {
|
||||
const configured = defaults[p.id]
|
||||
if (configured) {
|
||||
const key = { providerID: p.id, modelID: configured }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const first = Object.values(p.models)[0]
|
||||
if (!first) continue
|
||||
const key = { providerID: p.id, modelID: first.id }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
if (!a) return undefined
|
||||
const key = getFirstValidModel(
|
||||
() => ephemeral.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)
|
||||
if (!key) return undefined
|
||||
return models.find(key)
|
||||
})
|
||||
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const recentList = recent()
|
||||
const currentModel = current()
|
||||
if (!currentModel) return
|
||||
|
||||
const index = recentList.findIndex(
|
||||
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
|
||||
)
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
if (next < 0) next = recentList.length - 1
|
||||
if (next >= recentList.length) next = 0
|
||||
|
||||
const val = recentList[next]
|
||||
if (!val) return
|
||||
|
||||
model.set({
|
||||
providerID: val.provider.id,
|
||||
modelID: val.id,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list: models.list,
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) models.setVisibility(model, true)
|
||||
if (options?.recent && model) models.recent.push(model)
|
||||
})
|
||||
},
|
||||
visible(model: ModelKey) {
|
||||
return models.visible(model)
|
||||
},
|
||||
setVisibility(model: ModelKey, visible: boolean) {
|
||||
models.setVisibility(model, visible)
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
const m = current()
|
||||
if (!m) return undefined
|
||||
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
|
||||
},
|
||||
list() {
|
||||
const m = current()
|
||||
if (!m) return []
|
||||
if (!m.variants) return []
|
||||
return Object.keys(m.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = current()
|
||||
if (!m) return
|
||||
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
const currentVariant = this.current()
|
||||
if (!currentVariant) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
const index = variants.indexOf(currentVariant)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(undefined)
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
},
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||
model,
|
||||
agent,
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
140
opencode/packages/app/src/context/models.tsx
Normal file
140
opencode/packages/app/src/context/models.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
|
||||
type Store = {
|
||||
user: User[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||
name: "Models",
|
||||
init: () => {
|
||||
const providers = useProviders()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const available = createMemo(() =>
|
||||
providers.connected().flatMap((p) =>
|
||||
Object.values(p.models).map((m) => ({
|
||||
...m,
|
||||
provider: p,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
models,
|
||||
groupBy((x) => x.family),
|
||||
values(),
|
||||
(groups) =>
|
||||
groups.flatMap((g) => {
|
||||
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
||||
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
||||
}),
|
||||
),
|
||||
),
|
||||
values(),
|
||||
flat(),
|
||||
),
|
||||
)
|
||||
|
||||
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
|
||||
|
||||
const visibility = createMemo(() => {
|
||||
const map = new Map<string, Visibility>()
|
||||
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
|
||||
return map
|
||||
})
|
||||
|
||||
const list = createMemo(() =>
|
||||
available().map((m) => ({
|
||||
...m,
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
latest: m.name.includes("(latest)"),
|
||||
})),
|
||||
)
|
||||
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
|
||||
|
||||
function update(model: ModelKey, state: Visibility) {
|
||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
if (index >= 0) {
|
||||
setStore("user", index, { visibility: state })
|
||||
return
|
||||
}
|
||||
setStore("user", store.user.length, { ...model, visibility: state })
|
||||
}
|
||||
|
||||
const visible = (model: ModelKey) => {
|
||||
const key = `${model.providerID}:${model.modelID}`
|
||||
const state = visibility().get(key)
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
const m = find(model)
|
||||
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
update(model, state ? "show" : "hide")
|
||||
}
|
||||
|
||||
const push = (model: ModelKey) => {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
|
||||
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
|
||||
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
|
||||
|
||||
const setVariant = (model: ModelKey, value: string | undefined) => {
|
||||
const key = variantKey(model)
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value })
|
||||
return
|
||||
}
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
list,
|
||||
find,
|
||||
visible,
|
||||
setVisibility,
|
||||
recent: {
|
||||
list: createMemo(() => store.recent),
|
||||
push,
|
||||
},
|
||||
variant: {
|
||||
get: getVariant,
|
||||
set: setVariant,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
66
opencode/packages/app/src/context/notification-index.ts
Normal file
66
opencode/packages/app/src/context/notification-index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type NotificationIndexItem = {
|
||||
directory?: string
|
||||
session?: string
|
||||
viewed: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
|
||||
const sessionAll = new Map<string, T[]>()
|
||||
const sessionUnseen = new Map<string, T[]>()
|
||||
const sessionUnseenCount = new Map<string, number>()
|
||||
const sessionUnseenHasError = new Map<string, boolean>()
|
||||
const projectAll = new Map<string, T[]>()
|
||||
const projectUnseen = new Map<string, T[]>()
|
||||
const projectUnseenCount = new Map<string, number>()
|
||||
const projectUnseenHasError = new Map<string, boolean>()
|
||||
|
||||
for (const notification of list) {
|
||||
const session = notification.session
|
||||
if (session) {
|
||||
const all = sessionAll.get(session)
|
||||
if (all) all.push(notification)
|
||||
else sessionAll.set(session, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = sessionUnseen.get(session)
|
||||
if (unseen) unseen.push(notification)
|
||||
else sessionUnseen.set(session, [notification])
|
||||
|
||||
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
|
||||
if (notification.type === "error") sessionUnseenHasError.set(session, true)
|
||||
}
|
||||
}
|
||||
|
||||
const directory = notification.directory
|
||||
if (directory) {
|
||||
const all = projectAll.get(directory)
|
||||
if (all) all.push(notification)
|
||||
else projectAll.set(directory, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = projectUnseen.get(directory)
|
||||
if (unseen) unseen.push(notification)
|
||||
else projectUnseen.set(directory, [notification])
|
||||
|
||||
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
|
||||
if (notification.type === "error") projectUnseenHasError.set(directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
all: sessionAll,
|
||||
unseen: sessionUnseen,
|
||||
unseenCount: sessionUnseenCount,
|
||||
unseenHasError: sessionUnseenHasError,
|
||||
},
|
||||
project: {
|
||||
all: projectAll,
|
||||
unseen: projectUnseen,
|
||||
unseenCount: projectUnseenCount,
|
||||
unseenHasError: projectUnseenHasError,
|
||||
},
|
||||
}
|
||||
}
|
||||
73
opencode/packages/app/src/context/notification.test.ts
Normal file
73
opencode/packages/app/src/context/notification.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildNotificationIndex } from "./notification-index"
|
||||
|
||||
type Notification = {
|
||||
type: "turn-complete" | "error"
|
||||
session: string
|
||||
directory: string
|
||||
viewed: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
const turn = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "turn-complete",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
const error = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "error",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
describe("buildNotificationIndex", () => {
|
||||
test("builds unseen counts and unseen error flags", () => {
|
||||
const list = [
|
||||
turn("s1", "d1", false),
|
||||
error("s1", "d1", false),
|
||||
turn("s1", "d1", true),
|
||||
turn("s2", "d1", false),
|
||||
error("s3", "d2", true),
|
||||
]
|
||||
|
||||
const index = buildNotificationIndex(list)
|
||||
|
||||
expect(index.session.all.get("s1")?.length).toBe(3)
|
||||
expect(index.session.unseen.get("s1")?.length).toBe(2)
|
||||
expect(index.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(index.session.unseenHasError.get("s1")).toBe(true)
|
||||
|
||||
expect(index.session.unseenCount.get("s2")).toBe(1)
|
||||
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
|
||||
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
|
||||
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
|
||||
|
||||
expect(index.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(index.project.unseenHasError.get("d1")).toBe(true)
|
||||
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
|
||||
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
|
||||
})
|
||||
|
||||
test("updates selectors after viewed transitions", () => {
|
||||
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
|
||||
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
|
||||
|
||||
const before = buildNotificationIndex(list)
|
||||
const after = buildNotificationIndex(next)
|
||||
|
||||
expect(before.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(before.session.unseenHasError.get("s1")).toBe(true)
|
||||
expect(before.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(before.project.unseenHasError.get("d1")).toBe(true)
|
||||
|
||||
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
|
||||
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
|
||||
expect(after.project.unseenCount.get("d1")).toBe(1)
|
||||
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
|
||||
})
|
||||
})
|
||||
199
opencode/packages/app/src/context/notification.tsx
Normal file
199
opencode/packages/app/src/context/notification.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { buildNotificationIndex } from "./notification-index"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
session?: string
|
||||
metadata?: any
|
||||
time: number
|
||||
viewed: boolean
|
||||
}
|
||||
|
||||
type TurnCompleteNotification = NotificationBase & {
|
||||
type: "turn-complete"
|
||||
}
|
||||
|
||||
type ErrorNotification = NotificationBase & {
|
||||
type: "error"
|
||||
error: EventSessionError["properties"]["error"]
|
||||
}
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
|
||||
const MAX_NOTIFICATIONS = 500
|
||||
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
|
||||
|
||||
function pruneNotifications(list: Notification[]) {
|
||||
const cutoff = Date.now() - NOTIFICATION_TTL_MS
|
||||
const pruned = list.filter((n) => n.time >= cutoff)
|
||||
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
|
||||
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
return decode64(params.dir)
|
||||
})
|
||||
|
||||
const currentSession = createMemo(() => params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
|
||||
const meta = { pruned: false }
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
setStore("list", pruneNotifications(store.list))
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
setStore("list", (list) => pruneNotifications([...list, notification]))
|
||||
}
|
||||
|
||||
const index = createMemo(() => buildNotificationIndex(store.list))
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
const viewed = (sessionID?: string) => {
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeDirectory) return false
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session.idle": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const session = match.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewed(sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(
|
||||
language.t("notification.session.responseReady.title"),
|
||||
session?.title ?? sessionID,
|
||||
href,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "session.error": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
||||
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewed(sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
ready,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return index().session.all.get(session) ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index().session.unseen.get(session) ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index().session.unseenCount.get(session) ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index().session.unseenHasError.get(session) ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
setStore("list", (n) => n.session === session, "viewed", true)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return index().project.all.get(directory) ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index().project.unseen.get(directory) ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index().project.unseenCount.get(directory) ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index().project.unseenHasError.get(directory) ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
setStore("list", (n) => n.directory === directory, "viewed", true)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
186
opencode/packages/app/src/context/permission.tsx
Normal file
186
opencode/packages/app/src/context/permission.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
directory?: string
|
||||
}) => void
|
||||
|
||||
function shouldAutoAccept(perm: PermissionRequest) {
|
||||
return perm.permission === "edit"
|
||||
}
|
||||
|
||||
function isNonAllowRule(rule: unknown) {
|
||||
if (!rule) return false
|
||||
if (typeof rule === "string") return rule !== "allow"
|
||||
if (typeof rule !== "object") return false
|
||||
if (Array.isArray(rule)) return false
|
||||
|
||||
for (const action of Object.values(rule)) {
|
||||
if (action !== "allow") return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasAutoAcceptPermissionConfig(permission: unknown) {
|
||||
if (!permission) return false
|
||||
if (typeof permission === "string") return permission !== "allow"
|
||||
if (typeof permission !== "object") return false
|
||||
if (Array.isArray(permission)) return false
|
||||
|
||||
const config = permission as Record<string, unknown>
|
||||
if (isNonAllowRule(config.edit)) return true
|
||||
if (isNonAllowRule(config.write)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return false
|
||||
const [store] = globalSync.child(directory)
|
||||
return hasAutoAcceptPermissionConfig(store.config.permission)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("permission", ["permission.v3"]),
|
||||
createStore({
|
||||
autoAcceptEdits: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
globalSDK.client.permission.respond(input).catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function acceptKey(sessionID: string, directory?: string) {
|
||||
if (!directory) return sessionID
|
||||
return `${base64Encode(directory)}/${sessionID}`
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
|
||||
}
|
||||
|
||||
const unsubscribe = globalSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
const perm = event.properties
|
||||
if (!isAutoAccepting(perm.sessionID, e.name)) return
|
||||
if (!shouldAutoAccept(perm)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAcceptEdits[key] = true
|
||||
delete draft.autoAcceptEdits[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (perm.sessionID !== sessionID) continue
|
||||
if (!shouldAutoAccept(perm)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
const key = directory ? acceptKey(sessionID, directory) : undefined
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (key) delete draft.autoAcceptEdits[key]
|
||||
delete draft.autoAcceptEdits[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
|
||||
},
|
||||
isAutoAccepting,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
}
|
||||
},
|
||||
})
|
||||
75
opencode/packages/app/src/context/platform.tsx
Normal file
75
opencode/packages/app/src/context/platform.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: "web" | "desktop"
|
||||
|
||||
/** Desktop OS (Tauri only) */
|
||||
os?: "macos" | "windows" | "linux"
|
||||
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
/** Open a URL in the default browser */
|
||||
openLink(url: string): void
|
||||
|
||||
/** Open a local path in a local app (desktop only) */
|
||||
openPath?(path: string, app?: string): Promise<void>
|
||||
|
||||
/** Restart the app */
|
||||
restart(): Promise<void>
|
||||
|
||||
/** Navigate back in history */
|
||||
back(): void
|
||||
|
||||
/** Navigate forward in history */
|
||||
forward(): void
|
||||
|
||||
/** Send a system notification (optional deep link) */
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
|
||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||
|
||||
/** Open native file picker dialog (Tauri only) */
|
||||
openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||
|
||||
/** Save file picker dialog (Tauri only) */
|
||||
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
|
||||
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Check for updates (Tauri only) */
|
||||
checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
|
||||
|
||||
/** Install updates (Tauri only) */
|
||||
update?(): Promise<void>
|
||||
|
||||
/** Fetch override */
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServerUrl?(): Promise<string | null> | string | null
|
||||
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
|
||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||
parseMarkdown?(markdown: string): Promise<string>
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
/** Check if an editor app exists (desktop only) */
|
||||
checkAppExists?(appName: string): Promise<boolean>
|
||||
}
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
name: "Platform",
|
||||
init: (props: { value: Platform }) => {
|
||||
return props.value
|
||||
},
|
||||
})
|
||||
246
opencode/packages/app/src/context/prompt.tsx
Normal file
246
opencode/packages/app/src/context/prompt.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return (
|
||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||
)
|
||||
}
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
const partA = promptA[i]
|
||||
const partB = promptB[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "file") {
|
||||
const fileA = partA as FileAttachmentPart
|
||||
const fileB = partB as FileAttachmentPart
|
||||
if (fileA.path !== fileB.path) return false
|
||||
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
|
||||
}
|
||||
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_PROMPT_SESSIONS = 20
|
||||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type PromptCacheEntry = {
|
||||
value: PromptSession
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
function createPromptSession(dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.scoped(dir, id, "prompt", [legacy]),
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}>({
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function keyForItem(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) {
|
||||
return `${key}:c=${item.commentID}`
|
||||
}
|
||||
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
current: createMemo(() => store.prompt),
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||
context: {
|
||||
items: createMemo(() => store.context.items),
|
||||
add(item: ContextItem) {
|
||||
const key = keyForItem(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
},
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) {
|
||||
entry.dispose()
|
||||
}
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
onCleanup(disposeAll)
|
||||
|
||||
const prune = () => {
|
||||
while (cache.size > MAX_PROMPT_SESSIONS) {
|
||||
const first = cache.keys().next().value
|
||||
if (!first) return
|
||||
const entry = cache.get(first)
|
||||
entry?.dispose()
|
||||
cache.delete(first)
|
||||
}
|
||||
}
|
||||
|
||||
const load = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
cache.set(key, existing)
|
||||
return existing.value
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createPromptSession(dir, id),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
cache.set(key, entry)
|
||||
prune()
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
context: {
|
||||
items: () => session().context.items(),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
remove: (key: string) => session().context.remove(key),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||
reset: () => session().reset(),
|
||||
}
|
||||
},
|
||||
})
|
||||
48
opencode/packages/app/src/context/sdk.tsx
Normal file
48
opencode/packages/app/src/context/sdk.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { directory: Accessor<string> }) => {
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
||||
const directory = createMemo(props.directory)
|
||||
const client = createMemo(() =>
|
||||
createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory: directory(),
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}>()
|
||||
|
||||
createEffect(() => {
|
||||
const unsub = globalSDK.event.on(directory(), (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
})
|
||||
|
||||
return {
|
||||
get directory() {
|
||||
return directory()
|
||||
},
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
event: emitter,
|
||||
get url() {
|
||||
return globalSDK.url
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
208
opencode/packages/app/src/context/server.tsx
Normal file
208
opencode/packages/app/src/context/server.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { checkServerHealth } from "@/utils/server-health"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return
|
||||
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
|
||||
return withProtocol.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export function serverDisplayName(url: string) {
|
||||
if (!url) return ""
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function projectsKey(url: string) {
|
||||
if (!url) return ""
|
||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||
return url
|
||||
}
|
||||
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultUrl: string }) => {
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
createStore({
|
||||
list: [] as string[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
}),
|
||||
)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
active: "",
|
||||
healthy: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
const healthy = () => state.healthy
|
||||
|
||||
function setActive(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
setState("active", url)
|
||||
}
|
||||
|
||||
function add(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
|
||||
const fallback = normalizeServerUrl(props.defaultUrl)
|
||||
if (fallback && url === fallback) {
|
||||
setState("active", url)
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
if (!store.list.includes(url)) {
|
||||
setStore("list", store.list.length, url)
|
||||
}
|
||||
setState("active", url)
|
||||
})
|
||||
}
|
||||
|
||||
function remove(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
|
||||
const list = store.list.filter((x) => x !== url)
|
||||
const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
|
||||
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setState("active", next)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (state.active) return
|
||||
const url = normalizeServerUrl(props.defaultUrl)
|
||||
if (!url) return
|
||||
setState("active", url)
|
||||
})
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
|
||||
|
||||
createEffect(() => {
|
||||
const url = state.active
|
||||
if (!url) return
|
||||
|
||||
setState("healthy", undefined)
|
||||
|
||||
let alive = true
|
||||
let busy = false
|
||||
|
||||
const run = () => {
|
||||
if (busy) return
|
||||
busy = true
|
||||
void check(url)
|
||||
.then((next) => {
|
||||
if (!alive) return
|
||||
setState("healthy", next)
|
||||
})
|
||||
.finally(() => {
|
||||
busy = false
|
||||
})
|
||||
}
|
||||
|
||||
run()
|
||||
const interval = setInterval(run, 10_000)
|
||||
|
||||
onCleanup(() => {
|
||||
alive = false
|
||||
clearInterval(interval)
|
||||
})
|
||||
})
|
||||
|
||||
const origin = createMemo(() => projectsKey(state.active))
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const isLocal = createMemo(() => origin() === "local")
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
healthy,
|
||||
isLocal,
|
||||
get url() {
|
||||
return state.active
|
||||
},
|
||||
get name() {
|
||||
return serverDisplayName(state.active)
|
||||
},
|
||||
get list() {
|
||||
return store.list
|
||||
},
|
||||
setActive,
|
||||
add,
|
||||
remove,
|
||||
projects: {
|
||||
list: projectsList,
|
||||
open(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
if (current.find((x) => x.worktree === directory)) return
|
||||
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
|
||||
},
|
||||
close(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
setStore(
|
||||
"projects",
|
||||
key,
|
||||
current.filter((x) => x.worktree !== directory),
|
||||
)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const index = current.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", key, index, "expanded", true)
|
||||
},
|
||||
collapse(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const index = current.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", key, index, "expanded", false)
|
||||
},
|
||||
move(directory: string, toIndex: number) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const fromIndex = current.findIndex((x) => x.worktree === directory)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return
|
||||
const result = [...current]
|
||||
const [item] = result.splice(fromIndex, 1)
|
||||
result.splice(toIndex, 0, item)
|
||||
setStore("projects", key, result)
|
||||
},
|
||||
last() {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
return store.lastProject[key]
|
||||
},
|
||||
touch(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
setStore("lastProject", key, directory)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
177
opencode/packages/app/src/context/settings.tsx
Normal file
177
opencode/packages/app/src/context/settings.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
permissions: boolean
|
||||
errors: boolean
|
||||
}
|
||||
|
||||
export interface SoundSettings {
|
||||
agent: string
|
||||
permissions: string
|
||||
errors: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
general: {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
font: string
|
||||
}
|
||||
keybinds: Record<string, string>
|
||||
permissions: {
|
||||
autoApprove: boolean
|
||||
}
|
||||
notifications: NotificationSettings
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
font: "ibm-plex-mono",
|
||||
},
|
||||
keybinds: {},
|
||||
permissions: {
|
||||
autoApprove: false,
|
||||
},
|
||||
notifications: {
|
||||
agent: true,
|
||||
permissions: true,
|
||||
errors: false,
|
||||
},
|
||||
sounds: {
|
||||
agent: "staplebops-01",
|
||||
permissions: "staplebops-02",
|
||||
errors: "nope-03",
|
||||
},
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
const monoFonts: Record<string, string> = {
|
||||
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
get current() {
|
||||
return store
|
||||
},
|
||||
general: {
|
||||
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
|
||||
setAutoSave(value: boolean) {
|
||||
setStore("general", "autoSave", value)
|
||||
},
|
||||
releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes),
|
||||
setReleaseNotes(value: boolean) {
|
||||
setStore("general", "releaseNotes", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: createMemo(() => store.updates?.startup ?? defaultSettings.updates.startup),
|
||||
setStartup(value: boolean) {
|
||||
setStore("updates", "startup", value)
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
setStore("appearance", "fontSize", value)
|
||||
},
|
||||
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "font", value)
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
get: (action: string) => store.keybinds?.[action],
|
||||
set(action: string, keybind: string) {
|
||||
setStore("keybinds", action, keybind)
|
||||
},
|
||||
reset(action: string) {
|
||||
setStore("keybinds", action, undefined!)
|
||||
},
|
||||
resetAll() {
|
||||
setStore("keybinds", reconcile({}))
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
|
||||
setAutoApprove(value: boolean) {
|
||||
setStore("permissions", "autoApprove", value)
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
|
||||
setAgent(value: boolean) {
|
||||
setStore("notifications", "agent", value)
|
||||
},
|
||||
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
|
||||
setPermissions(value: boolean) {
|
||||
setStore("notifications", "permissions", value)
|
||||
},
|
||||
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
|
||||
setErrors(value: boolean) {
|
||||
setStore("notifications", "errors", value)
|
||||
},
|
||||
},
|
||||
sounds: {
|
||||
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
|
||||
setAgent(value: string) {
|
||||
setStore("sounds", "agent", value)
|
||||
},
|
||||
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
|
||||
setPermissions(value: string) {
|
||||
setStore("sounds", "permissions", value)
|
||||
},
|
||||
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
|
||||
setErrors(value: string) {
|
||||
setStore("sounds", "errors", value)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
56
opencode/packages/app/src/context/sync-optimistic.test.ts
Normal file
56
opencode/packages/app/src/context/sync-optimistic.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
|
||||
|
||||
const userMessage = (id: string, sessionID: string): Message => ({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
})
|
||||
|
||||
const textPart = (id: string, sessionID: string, messageID: string): Part => ({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
})
|
||||
|
||||
describe("sync optimistic reducers", () => {
|
||||
test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
|
||||
const sessionID = "ses_1"
|
||||
const draft = {
|
||||
message: { [sessionID]: [userMessage("msg_2", sessionID)] },
|
||||
part: {} as Record<string, Part[] | undefined>,
|
||||
}
|
||||
|
||||
applyOptimisticAdd(draft, {
|
||||
sessionID,
|
||||
message: userMessage("msg_1", sessionID),
|
||||
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
|
||||
})
|
||||
|
||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
|
||||
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
||||
})
|
||||
|
||||
test("applyOptimisticRemove removes message and part entries", () => {
|
||||
const sessionID = "ses_1"
|
||||
const draft = {
|
||||
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
|
||||
part: {
|
||||
msg_1: [textPart("prt_1", sessionID, "msg_1")],
|
||||
msg_2: [textPart("prt_2", sessionID, "msg_2")],
|
||||
} as Record<string, Part[] | undefined>,
|
||||
}
|
||||
|
||||
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
|
||||
|
||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
|
||||
expect(draft.part.msg_1).toBeUndefined()
|
||||
expect(draft.part.msg_2).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
358
opencode/packages/app/src/context/sync.tsx
Normal file
358
opencode/packages/app/src/context/sync.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (!messages) {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
}
|
||||
draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
type Child = ReturnType<(typeof globalSync)["child"]>
|
||||
type Setter = Child[1]
|
||||
|
||||
const current = createMemo(() => globalSync.child(sdk.directory))
|
||||
const target = (directory?: string) => {
|
||||
if (!directory || directory === sdk.directory) return current()
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const chunk = 400
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number>,
|
||||
complete: {} as Record<string, boolean>,
|
||||
loading: {} as Record<string, boolean>,
|
||||
})
|
||||
|
||||
const getSession = (sessionID: string) => {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
}
|
||||
|
||||
const limitFor = (count: number) => {
|
||||
if (count <= chunk) return chunk
|
||||
return Math.ceil(count / chunk) * chunk
|
||||
}
|
||||
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof sdk.client
|
||||
setStore: Setter
|
||||
sessionID: string
|
||||
limit: number
|
||||
}) => {
|
||||
const key = keyFor(input.directory, input.sessionID)
|
||||
if (meta.loading[key]) return
|
||||
|
||||
setMeta("loading", key, true)
|
||||
await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
|
||||
.then((messages) => {
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
|
||||
|
||||
for (const message of items) {
|
||||
input.setStore(
|
||||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
setMeta("limit", key, input.limit)
|
||||
setMeta("complete", key, next.length < input.limit)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return current()[0]
|
||||
},
|
||||
get set(): Setter {
|
||||
return current()[1]
|
||||
},
|
||||
get status() {
|
||||
return current()[0].status
|
||||
},
|
||||
get ready() {
|
||||
return current()[0].status !== "loading"
|
||||
},
|
||||
get project() {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
||||
if (match.found) return globalSync.data.project[match.index]
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
get: getSession,
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
const [, setStore] = target(input.directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
applyOptimisticAdd(draft as OptimisticStore, input)
|
||||
}),
|
||||
)
|
||||
},
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
const [, setStore] = target(input.directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
applyOptimisticRemove(draft as OptimisticStore, input)
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
}) {
|
||||
const message: Message = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
}
|
||||
const [, setStore] = target()
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
applyOptimisticAdd(draft as OptimisticStore, {
|
||||
sessionID: input.sessionID,
|
||||
message,
|
||||
parts: input.parts,
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = (() => {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
return match.found
|
||||
})()
|
||||
|
||||
const hasMessages = store.message[sessionID] !== undefined
|
||||
const hydrated = meta.limit[key] !== undefined
|
||||
if (hasSession && hasMessages && hydrated) return
|
||||
const pending = inflight.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const count = store.message[sessionID]?.length ?? 0
|
||||
const limit = hydrated ? (meta.limit[key] ?? chunk) : limitFor(count)
|
||||
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
hasMessages && hydrated
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
const promise = Promise.all([sessionReq, messagesReq])
|
||||
.then(() => {})
|
||||
.finally(() => {
|
||||
inflight.delete(key)
|
||||
})
|
||||
|
||||
inflight.set(key, promise)
|
||||
return promise
|
||||
},
|
||||
async diff(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
if (store.session_diff[sessionID] !== undefined) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
const pending = inflightDiff.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const promise = retry(() => client.session.diff({ sessionID }))
|
||||
.then((diff) => {
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
})
|
||||
.finally(() => {
|
||||
inflightDiff.delete(key)
|
||||
})
|
||||
|
||||
inflightDiff.set(key, promise)
|
||||
return promise
|
||||
},
|
||||
async todo(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
if (store.todo[sessionID] !== undefined) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
const pending = inflightTodo.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const promise = retry(() => client.session.todo({ sessionID }))
|
||||
.then((todo) => {
|
||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||
})
|
||||
.finally(() => {
|
||||
inflightTodo.delete(key)
|
||||
})
|
||||
|
||||
inflightTodo.set(key, promise)
|
||||
return promise
|
||||
},
|
||||
history: {
|
||||
more(sessionID: string) {
|
||||
const store = current()[0]
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
if (store.message[sessionID] === undefined) return false
|
||||
if (meta.limit[key] === undefined) return false
|
||||
if (meta.complete[key]) return false
|
||||
return true
|
||||
},
|
||||
loading(sessionID: string) {
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count = chunk) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
|
||||
const currentLimit = meta.limit[key] ?? chunk
|
||||
await loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit: currentLimit + count,
|
||||
})
|
||||
},
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
setStore("limit", (x) => x + count)
|
||||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return current()[0].path.directory
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
38
opencode/packages/app/src/context/terminal.test.ts
Normal file
38
opencode/packages/app/src/context/terminal.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
use: () => undefined,
|
||||
provider: () => undefined,
|
||||
}),
|
||||
}))
|
||||
const mod = await import("./terminal")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
test("uses workspace-only directory cache key", () => {
|
||||
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLegacyTerminalStorageKeys", () => {
|
||||
test("keeps workspace storage path when no legacy session id", () => {
|
||||
expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"])
|
||||
})
|
||||
|
||||
test("includes legacy session path before workspace path", () => {
|
||||
expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([
|
||||
"/repo/terminal/session-123.v1",
|
||||
"/repo/terminal.v1",
|
||||
])
|
||||
})
|
||||
})
|
||||
283
opencode/packages/app/src/context/terminal.tsx
Normal file
283
opencode/packages/app/src/context/terminal.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export type LocalPTY = {
|
||||
id: string
|
||||
title: string
|
||||
titleNumber: number
|
||||
rows?: number
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
tail?: string
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_TERMINAL_SESSIONS = 20
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||
return `${dir}:${WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
|
||||
if (!legacySessionID) return [`${dir}/terminal.v1`]
|
||||
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
|
||||
}
|
||||
|
||||
type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
|
||||
|
||||
type TerminalCacheEntry = {
|
||||
value: TerminalSession
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
|
||||
const numberFromTitle = (title: string) => {
|
||||
const match = title.match(/^Terminal (\d+)$/)
|
||||
if (!match) return
|
||||
const value = Number(match[1])
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
return value
|
||||
}
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.workspace(dir, "terminal", legacy),
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
}>({
|
||||
all: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const unsub = sdk.event.on("pty.exited", (event) => {
|
||||
const id = event.properties.id
|
||||
if (!store.all.some((x) => x.id === id)) return
|
||||
batch(() => {
|
||||
setStore(
|
||||
"all",
|
||||
store.all.filter((x) => x.id !== id),
|
||||
)
|
||||
if (store.active === id) {
|
||||
const remaining = store.all.filter((x) => x.id !== id)
|
||||
setStore("active", remaining[0]?.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const meta = { migrated: false }
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.migrated) return
|
||||
meta.migrated = true
|
||||
|
||||
setStore("all", (all) => {
|
||||
const next = all.map((pty) => {
|
||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||
if (direct !== undefined) return pty
|
||||
const parsed = numberFromTitle(pty.title)
|
||||
if (parsed === undefined) return pty
|
||||
return { ...pty, titleNumber: parsed }
|
||||
})
|
||||
if (next.every((pty, index) => pty === all[index])) return all
|
||||
return next
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => Object.values(store.all)),
|
||||
active: createMemo(() => store.active),
|
||||
new() {
|
||||
const existingTitleNumbers = new Set(
|
||||
store.all.flatMap((pty) => {
|
||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||
if (direct !== undefined) return [direct]
|
||||
const parsed = numberFromTitle(pty.title)
|
||||
if (parsed === undefined) return []
|
||||
return [parsed]
|
||||
}),
|
||||
)
|
||||
|
||||
const nextNumber =
|
||||
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
|
||||
(number) => !existingTitleNumbers.has(number),
|
||||
) ?? 1
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: `Terminal ${nextNumber}` })
|
||||
.then((pty) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
const newTerminal = {
|
||||
id,
|
||||
title: pty.data?.title ?? "Terminal",
|
||||
titleNumber: nextNumber,
|
||||
}
|
||||
setStore("all", (all) => {
|
||||
const newAll = [...all, newTerminal]
|
||||
return newAll
|
||||
})
|
||||
setStore("active", id)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to create terminal", e)
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
if (index !== -1) {
|
||||
setStore("all", index, (existing) => ({ ...existing, ...pty }))
|
||||
}
|
||||
sdk.client.pty
|
||||
.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to update terminal", e)
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const clone = await sdk.client.pty
|
||||
.create({
|
||||
title: pty.title,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to clone terminal", e)
|
||||
return undefined
|
||||
})
|
||||
if (!clone?.data) return
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
setStore("all", index, {
|
||||
id: clone.data.id,
|
||||
title: clone.data.title ?? pty.title,
|
||||
titleNumber: pty.titleNumber,
|
||||
})
|
||||
if (active) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
},
|
||||
next() {
|
||||
const index = store.all.findIndex((x) => x.id === store.active)
|
||||
if (index === -1) return
|
||||
const nextIndex = (index + 1) % store.all.length
|
||||
setStore("active", store.all[nextIndex]?.id)
|
||||
},
|
||||
previous() {
|
||||
const index = store.all.findIndex((x) => x.id === store.active)
|
||||
if (index === -1) return
|
||||
const prevIndex = index === 0 ? store.all.length - 1 : index - 1
|
||||
setStore("active", store.all[prevIndex]?.id)
|
||||
},
|
||||
async close(id: string) {
|
||||
batch(() => {
|
||||
const filtered = store.all.filter((x) => x.id !== id)
|
||||
if (store.active === id) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
const next = index > 0 ? index - 1 : 0
|
||||
setStore("active", filtered[next]?.id)
|
||||
}
|
||||
setStore("all", filtered)
|
||||
})
|
||||
|
||||
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
||||
console.error("Failed to close terminal", e)
|
||||
})
|
||||
},
|
||||
move(id: string, to: number) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"all",
|
||||
produce((all) => {
|
||||
all.splice(to, 0, all.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
||||
name: "Terminal",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const params = useParams()
|
||||
const cache = new Map<string, TerminalCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) {
|
||||
entry.dispose()
|
||||
}
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
onCleanup(disposeAll)
|
||||
|
||||
const prune = () => {
|
||||
while (cache.size > MAX_TERMINAL_SESSIONS) {
|
||||
const first = cache.keys().next().value
|
||||
if (!first) return
|
||||
const entry = cache.get(first)
|
||||
entry?.dispose()
|
||||
cache.delete(first)
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkspace = (dir: string, legacySessionID?: string) => {
|
||||
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
cache.set(key, existing)
|
||||
return existing.value
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
cache.set(key, entry)
|
||||
prune()
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||
|
||||
return {
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: () => workspace().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
open: (id: string) => workspace().open(id),
|
||||
close: (id: string) => workspace().close(id),
|
||||
move: (id: string, to: number) => workspace().move(id, to),
|
||||
next: () => workspace().next(),
|
||||
previous: () => workspace().previous(),
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user