mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 04:59:58 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae7a954a8 |
@@ -591,7 +591,6 @@
|
||||
"react-dom": "^19.2.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -86,8 +86,8 @@ function TargetServerRoute(props: ParentProps) {
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn()}>
|
||||
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
@@ -130,14 +130,16 @@ function DraftRoute() {
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
const directory = () => props.draft.directory
|
||||
const serverKey = () => props.draft.server
|
||||
|
||||
return (
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn()}>
|
||||
<ServerSyncProvider server={conn()}>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<SDKProvider directory={props.draft.directory}>
|
||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ModelsProvider directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
@@ -235,7 +237,7 @@ function DesktopCommands() {
|
||||
}
|
||||
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: string
|
||||
directory?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||
/>
|
||||
<Show when={platform.setForceFocus}>
|
||||
{platform.setForceFocus && (
|
||||
<ToggleCell
|
||||
active={state.focus}
|
||||
inline={props.inline}
|
||||
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||
onClick={() => void toggleFocus()}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
@@ -40,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
|
||||
}
|
||||
|
||||
export const DialogConnectProvider: Component<{
|
||||
directory?: string
|
||||
directory?: Accessor<string | undefined>
|
||||
controller?: ReturnType<typeof useProviderConnectController>
|
||||
}> = (props) => {
|
||||
const fallback = useProviderConnectController()
|
||||
@@ -136,11 +136,15 @@ export const DialogConnectProvider: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
||||
function ProviderPicker(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||
const providers = useProviders(() => props.directory)
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const language = useLanguage()
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
@@ -207,8 +211,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
||||
const providers = useProviders(() => props.directory)
|
||||
function ProviderPickerV2(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
@@ -356,7 +364,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
|
||||
|
||||
function ProviderConnection(props: {
|
||||
provider: string
|
||||
directory?: string
|
||||
directory?: Accessor<string | undefined>
|
||||
onBack: () => void
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
@@ -366,8 +374,8 @@ function ProviderConnection(props: {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(() => props.directory)
|
||||
const directory = () => props.directory ?? decode64(params.dir)
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, Index, Show } from "solid-js"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -83,71 +83,61 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Show
|
||||
when={isLast()}
|
||||
fallback={
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
{language.t("dialog.releaseNotes.action.next")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isLast() ? (
|
||||
<Button variant="primary" size="large" onClick={handleClose}>
|
||||
{language.t("dialog.releaseNotes.action.getStarted")}
|
||||
</Button>
|
||||
</Show>
|
||||
) : (
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
{language.t("dialog.releaseNotes.action.next")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||
{language.t("dialog.releaseNotes.action.hideFuture")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={paged()}>
|
||||
{paged() && (
|
||||
<div class="flex items-center gap-1.5 -my-2.5">
|
||||
<Index each={props.highlights}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
{props.highlights.map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
classList={{
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
}}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||
classList={{
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
}}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||
classList={{
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</Index>
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Media content (edge to edge) */}
|
||||
<Show when={feature()?.media}>
|
||||
{(media) => (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
<Show
|
||||
when={media().type === "image"}
|
||||
fallback={
|
||||
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={media().src}
|
||||
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
{feature()?.media && (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
{feature()!.media!.type === "image" ? (
|
||||
<img
|
||||
src={feature()!.media!.src}
|
||||
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
|
||||
const handleConnectProvider = () => {
|
||||
close("provider")
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
trigger={props.trigger}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
current={controller.current}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./dialog-manage-models").then((module) => {
|
||||
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: string | undefined
|
||||
current: () => string | undefined
|
||||
select: (item: ModelItem) => void
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const initialActive = () => {
|
||||
const selected = props.current
|
||||
const selected = props.current()
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={props.current}>
|
||||
<MenuV2.RadioGroup value={props.current()}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={props.current === modelKey(item) ? true : undefined}
|
||||
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6 w-full"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -96,16 +96,13 @@ export function ServerRow(props: ServerRowProps) {
|
||||
{(conn) => (
|
||||
<div class="flex flex-row gap-3">
|
||||
<span>
|
||||
<Show
|
||||
when={conn().http.username}
|
||||
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
|
||||
>
|
||||
{conn().http.username ? (
|
||||
<span class="text-text-weak">{conn().http.username}</span>
|
||||
</Show>
|
||||
) : (
|
||||
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
|
||||
)}
|
||||
</span>
|
||||
<Show when={conn().http.password}>
|
||||
<span class="text-text-weak">••••••••</span>
|
||||
</Show>
|
||||
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
|
||||
|
||||
export function SortableTabV2(props: {
|
||||
tab: string
|
||||
index: number
|
||||
index: () => number
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
|
||||
return props.tab
|
||||
},
|
||||
get index() {
|
||||
return props.index
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
|
||||
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTabV2(props: {
|
||||
terminal: LocalPTY
|
||||
index: number
|
||||
index: () => number
|
||||
newLayout: boolean
|
||||
onClose?: () => void
|
||||
}): JSX.Element {
|
||||
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
|
||||
return props.terminal.id
|
||||
},
|
||||
get index() {
|
||||
return props.index
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -425,9 +424,9 @@ function SettingsKeybindsV2() {
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
keybind={controller.catalog.keybind}
|
||||
active={controller.capture.active()}
|
||||
active={controller.capture.active}
|
||||
onCapture={controller.capture.toggle}
|
||||
hasOverrides={controller.settings.hasOverrides()}
|
||||
hasOverrides={controller.settings.hasOverrides}
|
||||
onReset={controller.settings.reset}
|
||||
/>
|
||||
)
|
||||
@@ -438,9 +437,9 @@ function SettingsKeybindsV2View(props: {
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
keybind: (id: string) => string
|
||||
active: string | null
|
||||
active: () => string | null
|
||||
onCapture: (id: string) => void
|
||||
hasOverrides: boolean
|
||||
hasOverrides: () => boolean
|
||||
onReset: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -453,7 +452,7 @@ function SettingsKeybindsV2View(props: {
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
@@ -499,12 +498,12 @@ function SettingsKeybindsV2View(props: {
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"settings-v2-keybind-button": true,
|
||||
"settings-v2-keybind-button--active": props.active === id,
|
||||
"settings-v2-keybind-button--active": props.active() === id,
|
||||
}}
|
||||
onClick={() => props.onCapture(id)}
|
||||
>
|
||||
<Show
|
||||
when={props.active === id}
|
||||
when={props.active() === id}
|
||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
@@ -675,6 +674,8 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const List = props.v2 ? SettingsListV2 : SettingsList
|
||||
|
||||
const groups = (
|
||||
<div
|
||||
classList={{
|
||||
@@ -699,7 +700,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
>
|
||||
{language.t(groupKey[group])}
|
||||
</h3>
|
||||
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
|
||||
<List>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
@@ -734,7 +735,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Dynamic>
|
||||
</List>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const DialogSettings: Component<{
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
@@ -30,14 +30,14 @@ const PROVIDER_NOTES = [
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsProvidersV2: Component<{
|
||||
directory: string | undefined
|
||||
directory: Accessor<string | undefined>
|
||||
onBack?: () => void
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const providers = useProviders(props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
@@ -116,7 +116,7 @@ export const SettingsProvidersV2: Component<{
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||
await serverSdk()
|
||||
.api.integration.get({ integrationID: providerID, location })
|
||||
.then(async (integration) => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import {
|
||||
type Accessor,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
For,
|
||||
type JSXElement,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -100,7 +109,7 @@ type ServerStatusItem = {
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const global = useGlobal()
|
||||
@@ -116,6 +125,10 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.shown()) return
|
||||
})
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
@@ -134,7 +147,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
() => (props.shown() ? sdk().directory : undefined),
|
||||
(directory) =>
|
||||
sdk()
|
||||
.api.plugin.list({ location: { directory } })
|
||||
|
||||
@@ -74,7 +74,7 @@ export function StatusPopover() {
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
>
|
||||
<Body shown={shown()} />
|
||||
<Body shown={shown} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
@@ -114,7 +114,7 @@ function DirectoryStatusPopover() {
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<Body shown={shown()} />
|
||||
<Body shown={shown} />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -175,7 +175,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
|
||||
const directory = sdk().directory
|
||||
const url = sdk().url
|
||||
let container!: HTMLDivElement
|
||||
|
||||
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: SessionInfo | undefined
|
||||
session: () => SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
@@ -52,22 +52,22 @@ export function TabNavItem(props: {
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session
|
||||
const session = props.session()
|
||||
return session ? sessionLabel(session) : props.fallbackTitle
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return displayName(project() ?? { worktree: session.location.directory })
|
||||
})
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
||||
@@ -80,7 +80,7 @@ export function TabNavItem(props: {
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
@@ -121,7 +121,7 @@ export function TabNavItem(props: {
|
||||
const closeRename = async (save: boolean) => {
|
||||
if (rename.isPending || !editing()) return
|
||||
|
||||
const original = props.session?.title ?? ""
|
||||
const original = props.session()?.title ?? ""
|
||||
const next = (titleEl.textContent ?? "").trim()
|
||||
|
||||
titleEl.scrollLeft = 0
|
||||
@@ -146,7 +146,7 @@ export function TabNavItem(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||
const session = props.session
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title ?? ""
|
||||
setEditing(true)
|
||||
@@ -213,7 +213,7 @@ export function TabNavItem(props: {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
@@ -221,14 +221,14 @@ export function TabNavItem(props: {
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Show
|
||||
when={props.session}
|
||||
when={props.session()}
|
||||
keyed
|
||||
fallback={
|
||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||
@@ -267,7 +267,7 @@ export function TabNavItem(props: {
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session?.title ?? ""
|
||||
titleEl.textContent = props.session()?.title ?? ""
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
@@ -308,7 +308,7 @@ export function TabNavItem(props: {
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session?.title,
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
@@ -323,7 +323,7 @@ export function DraftTabItem(props: {
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
suppressNavigation?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
@@ -366,14 +366,14 @@ export function DraftTabItem(props: {
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (props.suppressNavigation) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (props.suppressNavigation) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
|
||||
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: number
|
||||
active: boolean
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
forceTruncate: boolean
|
||||
session: SessionInfo | undefined
|
||||
session: () => SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
>
|
||||
<TabNavItem
|
||||
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
|
||||
onRename={props.onRename}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active}
|
||||
active={props.active()}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
|
||||
function SessionTabEntry(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: number
|
||||
active: boolean
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
forceTruncate: boolean
|
||||
serverCtx: ServerCtx | undefined
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx
|
||||
const ctx = props.serverCtx()
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||
let prefetched = false
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx
|
||||
const ctx = props.serverCtx()
|
||||
if (!value || !ctx) return
|
||||
|
||||
ctx.sync.session.remember({ ...value, title })
|
||||
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
|
||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx
|
||||
const currentCtx = props.serverCtx()
|
||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
|
||||
createEffect(() => props.onVisibleChange(visible()))
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx
|
||||
const ctx = props.serverCtx()
|
||||
const value = session()
|
||||
if (!ctx || !value || prefetched) return
|
||||
prefetched = true
|
||||
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
|
||||
index={props.index}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
session={session()}
|
||||
session={session}
|
||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||
onRename={rename}
|
||||
onNavigate={props.onNavigate}
|
||||
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
|
||||
function DraftTabSlot(props: {
|
||||
tab: Extract<Tab, { type: "draft" }>
|
||||
id: string
|
||||
index: number
|
||||
active: boolean
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
title: string
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
>
|
||||
<DraftTabItem
|
||||
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
|
||||
title={props.title}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active}
|
||||
active={props.active()}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
||||
|
||||
export function TitlebarTabStrip(props: {
|
||||
tabs: Tab[]
|
||||
currentTab: Tab | undefined
|
||||
currentTab: () => Tab | undefined
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
|
||||
])
|
||||
|
||||
function selectAdjacentTab(offset: -1 | 1) {
|
||||
const current = props.currentTab
|
||||
const current = props.currentTab()
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||
if (next) props.onNavigate(next)
|
||||
@@ -350,10 +350,10 @@ export function TitlebarTabStrip(props: {
|
||||
<SessionTabEntry
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
index={visibleIndex}
|
||||
active={() => props.currentTab() === tab}
|
||||
forceTruncate={props.forceTruncate}
|
||||
serverCtx={serverCtx()}
|
||||
serverCtx={serverCtx}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
@@ -368,8 +368,8 @@ export function TitlebarTabStrip(props: {
|
||||
<DraftTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
index={visibleIndex}
|
||||
active={() => props.currentTab() === tab}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
|
||||
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
|
||||
export type TitlebarUpdate = {
|
||||
version: string | undefined
|
||||
installing: boolean
|
||||
version: () => string | undefined
|
||||
installing: () => boolean
|
||||
install: () => void
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const installing = props.update?.installing ?? false
|
||||
const version = props.update?.version
|
||||
const installing = props.update?.installing() ?? false
|
||||
const version = props.update?.version()
|
||||
return {
|
||||
visible: version !== undefined || installing,
|
||||
installing,
|
||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
currentTab={currentTab}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
@@ -657,10 +657,12 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={["local", "beta", "dev"].includes(channel)}>
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
</Show>
|
||||
<>
|
||||
{["local", "beta", "dev"].includes(channel) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, type JSX } from "solid-js"
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -50,20 +50,7 @@ export function WindowsAppMenu(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||
<Show
|
||||
when={props.variant === "v2"}
|
||||
fallback={
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.variant === "v2" ? (
|
||||
<div
|
||||
data-component="desktop-icon-button"
|
||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||
@@ -78,31 +65,39 @@ export function WindowsAppMenu(props: {
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
) : (
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="desktop-app-menu">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
|
||||
{(menu) => (
|
||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
|
||||
{(entry) => {
|
||||
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
||||
if (entry.type === "separator") return <DropdownMenu.Separator />
|
||||
return (
|
||||
<DesktopMenuItem
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</DesktopMenuSubmenu>
|
||||
)}
|
||||
</For>
|
||||
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||
{menu.items
|
||||
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||
.map((entry) =>
|
||||
entry.type === "separator" ? (
|
||||
<DropdownMenu.Separator />
|
||||
) : (
|
||||
<DesktopMenuItem
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</DesktopMenuSubmenu>
|
||||
))}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
|
||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||
name: "Models",
|
||||
gate: false,
|
||||
init: (props: { directory?: string } = {}) => {
|
||||
const providers = useProviders(() => props.directory)
|
||||
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
|
||||
@@ -6,9 +6,12 @@ export type { DirectorySDK } from "./server-sdk"
|
||||
const context = createSimpleContext({
|
||||
name: "SDK",
|
||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||
init: (props: { directory: string }) => {
|
||||
init: (props: { directory: string | Accessor<string> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => serverSDK().ensureDirSdkContext(props.directory))
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK().ensureDirSdkContext(directory)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Event } from "@/types"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
@@ -270,13 +270,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
name: "ServerSDK",
|
||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
return createMemo<ServerSDK>(() => {
|
||||
const conn = props.server ?? server.current
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.ensureServerCtx(conn).sdk
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
@@ -699,13 +699,13 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
||||
name: "ServerSync",
|
||||
// Returns an accessor so the resolved server can change reactively without
|
||||
// re-instantiating the subtree (mirrors useServerSDK).
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
return createMemo<ServerSync>(() => {
|
||||
const conn = props.server ?? server.current
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.ensureServerCtx(conn).sync
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
@@ -15,9 +15,9 @@ import { useServerSync } from "@/context/server-sync"
|
||||
|
||||
export function DirectoryDataProvider(
|
||||
props: ParentProps<{
|
||||
directory: string
|
||||
directory: string | Accessor<string>
|
||||
draftID?: string
|
||||
server?: ServerConnection.Key
|
||||
server?: Accessor<ServerConnection.Key | undefined>
|
||||
}>,
|
||||
) {
|
||||
const location = useLocation()
|
||||
@@ -25,16 +25,17 @@ export function DirectoryDataProvider(
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const directory = () => props.directory
|
||||
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
if (props.server) return sessionHref(props.server, sessionID)
|
||||
const server = props.server?.()
|
||||
if (server) return sessionHref(server, sessionID)
|
||||
return `/${slug()}/session/${sessionID}`
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID || props.server) return
|
||||
if (props.draftID || props.server?.()) return
|
||||
const next = sync().data.path.directory
|
||||
if (!next || next === directory()) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
|
||||
@@ -23,8 +23,8 @@ export function Home() {
|
||||
>
|
||||
<ScrollView
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={scroll.viewport.thumbTrack()}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget()}
|
||||
thumbContainer={scroll.viewport.thumbTrack}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget}
|
||||
viewportRef={scroll.viewport.setViewport}
|
||||
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
|
||||
onWheel={scroll.viewport.containOuterWheel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
@@ -29,16 +29,16 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
|
||||
|
||||
export type HomeProjectsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
servers: ServerConnection.Any[]
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
selection: HomeProjectSelection
|
||||
homedir: string
|
||||
servers: Accessor<ServerConnection.Any[]>
|
||||
projects: Accessor<LocalProject[]>
|
||||
recentlyClosed: Accessor<LocalProject[]>
|
||||
selection: Accessor<HomeProjectSelection>
|
||||
homedir: Accessor<string>
|
||||
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||
collapsed: (server: ServerConnection.Any) => boolean
|
||||
canDefaultServer: boolean
|
||||
defaultServerKey: ServerConnection.Key | null | undefined
|
||||
canDefaultServer: Accessor<boolean>
|
||||
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
onWheel: (event: WheelEvent) => void
|
||||
@@ -81,7 +81,9 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||
<Show
|
||||
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
|
||||
>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -89,8 +91,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers()[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
@@ -98,20 +100,25 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
</div>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={props.servers.length > 1}
|
||||
when={props.servers().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
when={props.projects().length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
<HomeProjectList
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
server={props.servers()[0]}
|
||||
items={props.projects()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={props.servers}>
|
||||
<For each={props.servers()}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||
@@ -123,7 +130,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
server={item}
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
|
||||
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
|
||||
collapsed={collapsed()}
|
||||
health={props.serverHealth(item)}
|
||||
/>
|
||||
@@ -270,8 +277,8 @@ function HomeServerRow(props: {
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(props.language)}
|
||||
canDefault={props.canDefaultServer}
|
||||
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
|
||||
canDefault={props.canDefaultServer()}
|
||||
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
|
||||
canRemove={props.canRemoveServer(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
@@ -332,7 +339,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
|
||||
if (props.selection.server !== ServerConnection.key(props.server))
|
||||
if (props.selection().server !== ServerConnection.key(props.server))
|
||||
props.onSelectProject(props.server, source.id.toString())
|
||||
}}
|
||||
>
|
||||
@@ -343,7 +350,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
||||
row's sortable unregisters on unmount) and discarding animations.
|
||||
String keys keep row elements alive and move them on reorder. */}
|
||||
<For each={props.items.map((project) => project.worktree)}>
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index()} />}
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
@@ -353,7 +360,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
||||
function HomeProjectSlot(
|
||||
props: HomeProjectListProps & {
|
||||
worktree: string
|
||||
index: number
|
||||
index: () => number
|
||||
},
|
||||
) {
|
||||
const initial = props.items.find((item) => item.worktree === props.worktree)
|
||||
@@ -369,9 +376,10 @@ function HomeProjectSlot(
|
||||
project={project()}
|
||||
server={props.server}
|
||||
index={props.index}
|
||||
serverSelected={props.selection.server === ServerConnection.key(props.server)}
|
||||
serverSelected={props.selection().server === ServerConnection.key(props.server)}
|
||||
selected={
|
||||
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree
|
||||
props.selection().server === ServerConnection.key(props.server) &&
|
||||
props.selection().directory === props.worktree
|
||||
}
|
||||
unseen={props.unseenCount(props.server, project())}
|
||||
/>
|
||||
@@ -417,7 +425,7 @@ function HomeRecentlyClosedRow(
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedir
|
||||
const home = props.homedir()
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
@@ -443,7 +451,7 @@ function HomeProjectRow(
|
||||
HomeProjectsContextMenuProps & {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
index: number
|
||||
index: () => number
|
||||
serverSelected: boolean
|
||||
selected: boolean
|
||||
unseen: number
|
||||
@@ -456,7 +464,7 @@ function HomeProjectRow(
|
||||
return props.project.worktree
|
||||
},
|
||||
get index() {
|
||||
return props.index
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let pointerDownSelected: boolean | undefined
|
||||
|
||||
@@ -6,16 +6,16 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
||||
return (
|
||||
<HomeProjectsView
|
||||
language={props.projects.copy.language}
|
||||
servers={props.projects.server.list()}
|
||||
projects={props.projects.project.list()}
|
||||
recentlyClosed={props.projects.project.recentlyClosed()}
|
||||
selection={props.projects.selection.value()}
|
||||
homedir={props.projects.project.homedir()}
|
||||
servers={props.projects.server.list}
|
||||
projects={props.projects.project.list}
|
||||
recentlyClosed={props.projects.project.recentlyClosed}
|
||||
selection={props.projects.selection.value}
|
||||
homedir={props.projects.project.homedir}
|
||||
serverHealth={props.projects.server.health}
|
||||
projectsForServer={props.projects.server.projects}
|
||||
collapsed={props.projects.server.collapsed}
|
||||
canDefaultServer={props.projects.server.canDefault()}
|
||||
defaultServerKey={props.projects.server.defaultKey()}
|
||||
canDefaultServer={props.projects.server.canDefault}
|
||||
defaultServerKey={props.projects.server.defaultKey}
|
||||
canRevealProject={props.projects.project.canReveal}
|
||||
unseenCount={props.projects.project.unseenCount}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
|
||||
@@ -295,13 +295,13 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
|
||||
|
||||
export function HomeSessionStatusController(props: {
|
||||
server: ServerConnection.Key
|
||||
server: Accessor<ServerConnection.Key>
|
||||
record: HomeSessionRecord
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
|
||||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
props.server,
|
||||
() => props.record.session.location.directory,
|
||||
() => props.record.session.id,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo, For, Show, Suspense } from "solid-js"
|
||||
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
@@ -38,17 +38,17 @@ function isBackgroundOpen(event: MouseEvent) {
|
||||
|
||||
export type HomeSessionsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
groups: HomeSessionGroup[]
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
canCreateSession: boolean
|
||||
searchValue: string
|
||||
searchPlaceholder: string
|
||||
searchOpen: boolean
|
||||
searchLoading: boolean
|
||||
searchResults: HomeSessionRecord[]
|
||||
searchActive: string
|
||||
searchNoResultsLabel: string
|
||||
groups: Accessor<HomeSessionGroup[]>
|
||||
showProjectName: Accessor<boolean>
|
||||
server: Accessor<ServerConnection.Key>
|
||||
canCreateSession: Accessor<boolean>
|
||||
searchValue: Accessor<string>
|
||||
searchPlaceholder: Accessor<string>
|
||||
searchOpen: Accessor<boolean>
|
||||
searchLoading: Accessor<boolean>
|
||||
searchResults: Accessor<HomeSessionRecord[]>
|
||||
searchActive: Accessor<string>
|
||||
searchNoResultsLabel: Accessor<string>
|
||||
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
onCreateSession: () => void
|
||||
@@ -81,7 +81,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||
<HomeSessionSearch {...props} />
|
||||
<Suspense>
|
||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
||||
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
@@ -113,16 +113,16 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.groups.length > 0}
|
||||
when={props.groups().length > 0}
|
||||
fallback={
|
||||
<HomeSessionsEmpty
|
||||
onNewSession={props.canCreateSession ? props.onCreateSession : undefined}
|
||||
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={props.groups}>
|
||||
<For each={props.groups()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
@@ -132,7 +132,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
|
||||
</div>
|
||||
@@ -205,7 +205,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
|
||||
<Show when={props.searchOpen}>
|
||||
<Show when={props.searchOpen()}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
class={`
|
||||
@@ -217,7 +217,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<Show
|
||||
when={!props.searchLoading}
|
||||
when={!props.searchLoading()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
||||
<Spinner class="size-4" />
|
||||
@@ -225,7 +225,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.searchResults.length > 0}
|
||||
when={props.searchResults().length > 0}
|
||||
fallback={
|
||||
<p
|
||||
class={`
|
||||
@@ -233,7 +233,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
text-v2-text-text-muted [font-weight:440]
|
||||
`}
|
||||
>
|
||||
{props.searchNoResultsLabel}
|
||||
{props.searchNoResultsLabel()}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
@@ -248,12 +248,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
</p>
|
||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.searchResults}>
|
||||
<For each={props.searchResults()}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
{...props}
|
||||
record={record}
|
||||
selected={props.searchActive === homeSessionSearchKey(record)}
|
||||
selected={props.searchActive() === homeSessionSearchKey(record)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -280,14 +280,16 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
|
||||
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
|
||||
`}
|
||||
value={props.searchValue}
|
||||
placeholder={props.searchPlaceholder}
|
||||
aria-label={props.searchPlaceholder}
|
||||
aria-expanded={props.searchOpen}
|
||||
value={props.searchValue()}
|
||||
placeholder={props.searchPlaceholder()}
|
||||
aria-label={props.searchPlaceholder()}
|
||||
aria-expanded={props.searchOpen()}
|
||||
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
props.searchActive && props.searchOpen ? `home-session-search-option-${props.searchActive}` : undefined
|
||||
props.searchActive() && props.searchOpen()
|
||||
? `home-session-search-option-${props.searchActive()}`
|
||||
: undefined
|
||||
}
|
||||
onFocus={props.onSearchFocus}
|
||||
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
|
||||
@@ -298,7 +300,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
event.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (!props.searchOpen || props.searchResults.length === 0) return
|
||||
if (!props.searchOpen() || props.searchResults().length === 0) return
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
@@ -316,14 +318,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={props.searchValue}>
|
||||
<Show when={props.searchValue()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="relative z-20 shrink-0"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
aria-label={props.searchPlaceholder}
|
||||
aria-label={props.searchPlaceholder()}
|
||||
onClick={() => {
|
||||
props.onSearchClose()
|
||||
props.onSearchFocus()
|
||||
@@ -343,7 +345,7 @@ function HomeSessionSearchResultRow(
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
return (
|
||||
@@ -414,7 +416,7 @@ function HomeSessionGroupHeader(props: {
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -11,17 +11,17 @@ export function HomeSessions(props: {
|
||||
return (
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups()}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={props.sessions.session.server()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
searchValue={props.search.query.value()}
|
||||
searchPlaceholder={props.search.query.placeholder()}
|
||||
searchOpen={props.search.query.open()}
|
||||
searchLoading={props.search.result.loading()}
|
||||
searchResults={props.search.result.list()}
|
||||
searchActive={props.search.result.active()}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel()}
|
||||
groups={props.sessions.data.groups}
|
||||
showProjectName={props.sessions.session.showProjectName}
|
||||
server={props.sessions.session.server}
|
||||
canCreateSession={props.sessions.session.canCreate}
|
||||
searchValue={props.search.query.value}
|
||||
searchPlaceholder={props.search.query.placeholder}
|
||||
searchOpen={props.search.query.open}
|
||||
searchLoading={props.search.result.loading}
|
||||
searchResults={props.search.result.list}
|
||||
searchActive={props.search.result.active}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel}
|
||||
titleOpacity={props.scroll.header.titleOpacity}
|
||||
isOpenTab={props.sessions.tab.isOpen}
|
||||
onCreateSession={props.sessions.session.create}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onMount, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
@@ -9,17 +9,15 @@ export default function Layout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ debugTools: true })
|
||||
|
||||
onMount(() => setV2Toast(true))
|
||||
createEffect(() => setV2Toast(true))
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
get version() {
|
||||
version: () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return undefined
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
},
|
||||
get installing() {
|
||||
return platform.updater?.state().status === "installing"
|
||||
},
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
install: () => void platform.updater?.install(),
|
||||
}
|
||||
|
||||
@@ -42,9 +40,7 @@ export default function Layout(props: ParentProps) {
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
<DebugBar inline />
|
||||
</Show>
|
||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function NewSessionPage() {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
|
||||
<NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<NewSessionView input={draft.input} project={project} workspace={workspace} />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import createPresence from "solid-presence"
|
||||
@@ -74,14 +74,14 @@ export function NewSessionView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
|
||||
export function NewSessionStatus(props: { mount: Accessor<HTMLElement | null>; visible: Accessor<boolean> }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Show when={props.mount} keyed>
|
||||
<Show when={props.mount()} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount}>
|
||||
<Show when={props.visible}>
|
||||
<Show when={props.visible()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
@@ -116,7 +116,7 @@ function ProviderTip() {
|
||||
})
|
||||
const openProviders = () => {
|
||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ export function TargetSessionRouteContent() {
|
||||
return (
|
||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||
// when session content falls back to the route error boundary.
|
||||
<TargetServerScopedProviders directory={directory()} sessionID={params.id}>
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
@@ -183,15 +183,17 @@ export function SessionRouteErrorBoundary(
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<ErrorPage error={error} />}>
|
||||
fallback={(error) =>
|
||||
settings.general.newLayoutDesigns() ? (
|
||||
<SessionRouteFrame padded={props.padded}>
|
||||
<SessionPanelFrame newLayout raised={!!props.sessionID}>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
</Show>
|
||||
)}
|
||||
) : (
|
||||
<ErrorPage error={error} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
@@ -251,6 +253,7 @@ function ResolvedTargetSessionRoute() {
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
const directory = createMemo(() => current()?.session.location.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
@@ -267,13 +270,11 @@ function ResolvedTargetSessionRoute() {
|
||||
// the terminal. Same-workspace tab switches keep it open because warm
|
||||
// targets resolve synchronously from the sync cache.
|
||||
<Show when={directory()}>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir()}>
|
||||
<DirectoryDataProvider directory={dir()} server={serverKey()}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -291,7 +292,9 @@ function TargetSessionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(props: ParentProps<{ directory?: string; sessionID?: string }>) {
|
||||
function TargetServerScopedProviders(
|
||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
@@ -300,10 +303,10 @@ function TargetServerScopedProviders(props: ParentProps<{ directory?: string; se
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: string }) {
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
@@ -1235,8 +1238,8 @@ export default function Page() {
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={reviewEmpty(input)}
|
||||
diffs={reviewDiffs()}
|
||||
view={controller.layout.view()}
|
||||
diffs={reviewDiffs}
|
||||
view={controller.layout.view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
@@ -1269,12 +1272,8 @@ export default function Page() {
|
||||
get empty() {
|
||||
return reviewEmptyV2()
|
||||
},
|
||||
get diffs() {
|
||||
return reviewDiffs()
|
||||
},
|
||||
get diffsReady() {
|
||||
return reviewReady()
|
||||
},
|
||||
diffs: reviewDiffs,
|
||||
diffsReady: reviewReady,
|
||||
get diffVersion() {
|
||||
return vcsQuery.dataUpdatedAt
|
||||
},
|
||||
@@ -2073,11 +2072,11 @@ export default function Page() {
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture()}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
@@ -2252,14 +2251,7 @@ export default function Page() {
|
||||
width: sessionPanelWidth(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
|
||||
{sessionPanelContent()}
|
||||
</SessionPanelFrame>
|
||||
}
|
||||
>
|
||||
{settings.general.newLayoutDesigns() ? (
|
||||
<Show when={sessionPanelKey()} keyed>
|
||||
{(_) => (
|
||||
<SessionPanelFrame newLayout raised={!!controller.identity.params.id}>
|
||||
@@ -2267,7 +2259,11 @@ export default function Page() {
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
) : (
|
||||
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
|
||||
{sessionPanelContent()}
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
|
||||
<Show when={desktopSessionResizeOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
@@ -2291,13 +2287,13 @@ export default function Page() {
|
||||
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview()}
|
||||
diffs={reviewDiffs()}
|
||||
diffsReady={reviewReady()}
|
||||
empty={reviewEmptyText()}
|
||||
hasReview={hasReview()}
|
||||
reviewHasFocusableContent={hasReview()}
|
||||
reviewCount={reviewCount()}
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={activeReviewFile()}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
@@ -2313,13 +2309,13 @@ export default function Page() {
|
||||
<div class="min-h-0 flex-1">
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview()}
|
||||
diffs={reviewDiffs()}
|
||||
diffsReady={reviewReady()}
|
||||
empty={reviewEmptyText()}
|
||||
hasReview={hasReview()}
|
||||
reviewHasFocusableContent={hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount()}
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanelV2}
|
||||
reviewSidebarToggle={(disabled) => (
|
||||
<SessionReviewV2SidebarToggle
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Index, Match, Switch, createEffect, createMemo } from "solid-js"
|
||||
import { Index, createEffect, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -142,16 +142,15 @@ export function SessionTodoDock(props: {
|
||||
}}
|
||||
>
|
||||
<Index each={progress()}>
|
||||
{(item) => (
|
||||
<Switch fallback={<span>{item()}</span>}>
|
||||
<Match when={item() === doneToken}>
|
||||
<AnimatedNumber value={done()} />
|
||||
</Match>
|
||||
<Match when={item() === totalToken}>
|
||||
<AnimatedNumber value={total()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
{(item) =>
|
||||
item() === doneToken ? (
|
||||
<AnimatedNumber value={done()} />
|
||||
) : item() === totalToken ? (
|
||||
<AnimatedNumber value={total()} />
|
||||
) : (
|
||||
<span>{item()}</span>
|
||||
)
|
||||
}
|
||||
</Index>
|
||||
</span>
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createMemo, For, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
@@ -326,13 +326,11 @@ export const Playground = {
|
||||
<button onClick={cycle} style={btn(step() > 0)}>
|
||||
Cycle progress ({step()}/3 done)
|
||||
</button>
|
||||
<For each={[0, 1, 2, 3]}>
|
||||
{(value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
{[0, 1, 2, 3].map((value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
|
||||
|
||||
@@ -19,8 +19,8 @@ type ReviewDiff = FileDiffInfo
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: ReviewDiff[]
|
||||
view: ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffs: () => ReviewDiff[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
onViewFile?: (file: string) => void
|
||||
@@ -77,7 +77,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
if (!el || !layout.ready() || userInteracted) return
|
||||
if (el.clientHeight === 0 || el.clientWidth === 0) return
|
||||
|
||||
const s = props.view.scroll("review")
|
||||
const s = props.view().scroll("review")
|
||||
if (!s || (s.x === 0 && s.y === 0)) return
|
||||
|
||||
const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
@@ -111,14 +111,14 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
if (!layout.ready()) return
|
||||
if (el.clientHeight === 0 || el.clientWidth === 0) return
|
||||
|
||||
props.view.setScroll("review", {
|
||||
props.view().setScroll("review", {
|
||||
x: el.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.diffs.length
|
||||
props.diffs().length
|
||||
props.diffStyle
|
||||
if (!layout.ready()) return
|
||||
queueRestore()
|
||||
@@ -145,14 +145,14 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onDiffRendered={queueRestore}
|
||||
open={props.view.review.open()}
|
||||
onOpenChange={props.view.review.setOpen}
|
||||
open={props.view().review.open()}
|
||||
onOpenChange={props.view().review.setOpen}
|
||||
classes={{
|
||||
root: props.classes?.root ?? "pr-3",
|
||||
header: props.classes?.header ?? "px-3",
|
||||
container: props.classes?.container ?? "pl-3",
|
||||
}}
|
||||
diffs={props.diffs}
|
||||
diffs={props.diffs()}
|
||||
diffStyle={props.diffStyle}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
onViewFile={props.onViewFile}
|
||||
|
||||
@@ -65,13 +65,13 @@ function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
empty: string
|
||||
hasReview: boolean
|
||||
reviewHasFocusableContent: boolean
|
||||
reviewCount: number
|
||||
canReview: () => boolean
|
||||
diffs: () => ReviewDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewHasFocusableContent: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
|
||||
fileBrowserState?: SessionFileBrowserState
|
||||
@@ -113,7 +113,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
|
||||
const diffs = createMemo(() => props.diffs.filter(renderDiff))
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
@@ -177,7 +177,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview: () => props.canReview,
|
||||
hasReview: props.canReview,
|
||||
fileBrowser: () => !!props.fileBrowserState,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
@@ -348,7 +348,7 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
@@ -356,8 +356,8 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview}>
|
||||
<div>{props.reviewCount}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -463,12 +463,12 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
|
||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
@@ -559,14 +559,14 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||
>
|
||||
{props.hasReview
|
||||
? language.t("session.review.filesChanged", { count: props.reviewCount })
|
||||
{props.hasReview()
|
||||
? language.t("session.review.filesChanged", { count: props.reviewCount() })
|
||||
: language.t("session.tab.review")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
@@ -611,7 +611,7 @@ export function SessionSidePanel(props: {
|
||||
fallback={
|
||||
<SortableTabV2
|
||||
tab={tab}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
index={() => tabs().all().indexOf(tab)}
|
||||
temporary={temporaryTab() === tab}
|
||||
onTabClose={tabs().close}
|
||||
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||
@@ -691,12 +691,12 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
|
||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
@@ -782,14 +782,14 @@ export function SessionSidePanel(props: {
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<>
|
||||
{props.reviewCount}{" "}
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{language.t("session.review.filesChanged", { count: props.reviewCount })}
|
||||
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
|
||||
</Show>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
@@ -799,9 +799,9 @@ export function SessionSidePanel(props: {
|
||||
<Show when={fileTreeTab() === "changes"}>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview || !props.diffsReady}>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={props.diffsReady}
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
|
||||
@@ -273,7 +273,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => (
|
||||
<SortableTerminalTabV2 terminal={pty} index={index()} newLayout={newLayout()} onClose={close} />
|
||||
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
|
||||
@@ -210,11 +210,11 @@ type MessageTimelineProps = {
|
||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: boolean
|
||||
hasScrollGesture: () => boolean
|
||||
onUserScroll: () => void
|
||||
onHistoryScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
shouldAnchorBottom: boolean
|
||||
shouldAnchorBottom: () => boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
@@ -243,7 +243,7 @@ function MessageTimelineView(
|
||||
const ownerSessionKey = props.data.sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom
|
||||
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
|
||||
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const sessionID = props.data.sessionID
|
||||
@@ -338,7 +338,7 @@ function MessageTimelineView(
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementOffset: observeElementOffsetReconnectAware,
|
||||
initialOffset: () => (props.shouldAnchorBottom ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
@@ -376,11 +376,11 @@ function MessageTimelineView(
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
let resizeAnchorScheduled = false
|
||||
const anchorResizedBottom = () => {
|
||||
if (resizeAnchorScheduled || props.hasScrollGesture) return
|
||||
if (resizeAnchorScheduled || props.hasScrollGesture()) return
|
||||
resizeAnchorScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeAnchorScheduled = false
|
||||
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
@@ -405,10 +405,10 @@ function MessageTimelineView(
|
||||
})
|
||||
}
|
||||
resizeItem(index, size)
|
||||
if (root && props.shouldAnchorBottom) anchorResizedBottom()
|
||||
if (root && props.shouldAnchorBottom()) anchorResizedBottom()
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
|
||||
if (props.shouldAnchorBottom) return false
|
||||
if (props.shouldAnchorBottom()) return false
|
||||
const first = virtualizer.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
@@ -429,18 +429,18 @@ function MessageTimelineView(
|
||||
let overscanFrame: number | undefined
|
||||
onMount(() => {
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
|
||||
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
overscanFrame = undefined
|
||||
if (renderOverscan() < 20) setRenderOverscan(20)
|
||||
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
|
||||
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
if (timelineRows().length === 0) return
|
||||
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
@@ -552,7 +552,7 @@ function MessageTimelineView(
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
props.onHistoryScroll()
|
||||
if (!props.hasScrollGesture) return
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onUserScroll()
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
@@ -709,21 +709,21 @@ function MessageTimelineView(
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
|
||||
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
|
||||
const anchor = () => {
|
||||
const row = input.row
|
||||
const row = input.row()
|
||||
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
||||
}
|
||||
const previousAssistantPart = () => {
|
||||
const row = input.row
|
||||
const row = input.row()
|
||||
return row._tag === "AssistantPart" && row.previousAssistantPart
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
|
||||
data-message-id={input.row.userMessageID}
|
||||
data-timeline-row={input.row._tag}
|
||||
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
|
||||
data-message-id={input.row().userMessageID}
|
||||
data-timeline-row={input.row()._tag}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
@@ -748,7 +748,7 @@ function MessageTimelineView(
|
||||
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
||||
)
|
||||
return (
|
||||
<TimelineRowFrame row={commentStripRow()}>
|
||||
<TimelineRowFrame row={commentStripRow}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
@@ -797,7 +797,7 @@ function MessageTimelineView(
|
||||
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
|
||||
})
|
||||
return (
|
||||
<TimelineRowFrame row={userMessageRow()}>
|
||||
<TimelineRowFrame row={userMessageRow}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
@@ -819,7 +819,7 @@ function MessageTimelineView(
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
<TimelineRowFrame row={turnDividerRow()}>
|
||||
<TimelineRowFrame row={turnDividerRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
@@ -835,7 +835,7 @@ function MessageTimelineView(
|
||||
case "AssistantPart": {
|
||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||
return (
|
||||
<TimelineRowFrame row={assistantPartRow()}>
|
||||
<TimelineRowFrame row={assistantPartRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
@@ -850,7 +850,7 @@ function MessageTimelineView(
|
||||
case "Thinking": {
|
||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||
return (
|
||||
<TimelineRowFrame row={thinkingRow()}>
|
||||
<TimelineRowFrame row={thinkingRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
@@ -863,7 +863,7 @@ function MessageTimelineView(
|
||||
case "Retry": {
|
||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow()}>
|
||||
<TimelineRowFrame row={retryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
@@ -873,7 +873,7 @@ function MessageTimelineView(
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow()}>
|
||||
<TimelineRowFrame row={diffSummaryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||
</div>
|
||||
@@ -883,7 +883,7 @@ function MessageTimelineView(
|
||||
case "Error": {
|
||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||
return (
|
||||
<TimelineRowFrame row={errorRow()}>
|
||||
<TimelineRowFrame row={errorRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<Card variant="error" class="error-card">
|
||||
{errorRow().text}
|
||||
|
||||
@@ -35,8 +35,8 @@ type ReviewDiff = FileDiffInfo
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
diffs: () => ReviewDiff[]
|
||||
diffsReady: () => boolean
|
||||
diffVersion?: number
|
||||
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
|
||||
activeFile?: string
|
||||
@@ -56,7 +56,7 @@ export type ReviewPanelV2Props = {
|
||||
export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
const sdk = useSDK()
|
||||
|
||||
const diffs = createMemo(() => props.diffs.filter(filterRenderableDiff))
|
||||
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
|
||||
const filteredFiles = createMemo(() =>
|
||||
filterReviewFiles(
|
||||
diffs().map((diff) => diff.file),
|
||||
@@ -122,11 +122,11 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
state={props.state}
|
||||
diffsReady={props.diffsReady}
|
||||
onSelectFile={props.onSelectFile}
|
||||
diffs={diffs()}
|
||||
filteredFiles={filteredFiles()}
|
||||
searching={searching()}
|
||||
kinds={treeKinds()}
|
||||
activeDiff={activeDiff()}
|
||||
diffs={diffs}
|
||||
filteredFiles={filteredFiles}
|
||||
searching={searching}
|
||||
kinds={treeKinds}
|
||||
activeDiff={activeDiff}
|
||||
/>
|
||||
}
|
||||
activeFile={activeDiff()}
|
||||
@@ -170,19 +170,19 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
function ReviewPanelV2Sidebar(props: {
|
||||
title?: JSX.Element
|
||||
state: ReviewPanelV2State
|
||||
diffsReady: boolean
|
||||
diffsReady: () => boolean
|
||||
onSelectFile: (path: string) => void
|
||||
diffs: RenderDiff[]
|
||||
filteredFiles: string[]
|
||||
searching: boolean
|
||||
kinds: ReturnType<typeof reviewDiffKinds>
|
||||
activeDiff: string | undefined
|
||||
diffs: () => RenderDiff[]
|
||||
filteredFiles: () => string[]
|
||||
searching: () => boolean
|
||||
kinds: () => ReturnType<typeof reviewDiffKinds>
|
||||
activeDiff: () => string | undefined
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
|
||||
const highlightedPath = createMemo(() => {
|
||||
if (!props.searching) return undefined
|
||||
const files = props.filteredFiles
|
||||
if (!props.searching()) return undefined
|
||||
const files = props.filteredFiles()
|
||||
if (files.length === 0) return undefined
|
||||
const explicit = explicitHighlight()
|
||||
if (explicit && files.includes(explicit)) return explicit
|
||||
@@ -190,8 +190,8 @@ function ReviewPanelV2Sidebar(props: {
|
||||
})
|
||||
|
||||
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
|
||||
if (!props.searching) return
|
||||
applyFileListKeyDown(event, props.filteredFiles, highlightedPath(), {
|
||||
if (!props.searching()) return
|
||||
applyFileListKeyDown(event, props.filteredFiles(), highlightedPath(), {
|
||||
onHighlight: setExplicitHighlight,
|
||||
onSelect: props.onSelectFile,
|
||||
})
|
||||
@@ -202,7 +202,7 @@ function ReviewPanelV2Sidebar(props: {
|
||||
open={props.state.sidebarOpened()}
|
||||
transition={props.state.sidebarTransition()}
|
||||
title={props.title}
|
||||
stats={<DiffChanges changes={props.diffs} />}
|
||||
stats={<DiffChanges changes={props.diffs()} />}
|
||||
filter={props.state.filter()}
|
||||
onFilterChange={props.state.setFilter}
|
||||
onFilterKeyDown={onFilterKeyDown}
|
||||
@@ -212,7 +212,7 @@ function ReviewPanelV2Sidebar(props: {
|
||||
maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX}
|
||||
>
|
||||
<Show
|
||||
when={props.diffsReady}
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
@@ -221,25 +221,25 @@ function ReviewPanelV2Sidebar(props: {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.searching}
|
||||
when={props.searching()}
|
||||
fallback={
|
||||
<FileTreeV2
|
||||
allowed={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
allowed={props.filteredFiles()}
|
||||
kinds={props.kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
active={props.activeDiff()}
|
||||
onFileClick={(node) => props.onSelectFile(node.path)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.filteredFiles.length > 0}
|
||||
when={props.filteredFiles().length > 0}
|
||||
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
|
||||
>
|
||||
<SessionFileListV2
|
||||
files={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
active={props.activeDiff}
|
||||
files={props.filteredFiles()}
|
||||
kinds={props.kinds()}
|
||||
active={props.activeDiff()}
|
||||
highlighted={highlightedPath()}
|
||||
onFileClick={(path) => {
|
||||
setExplicitHighlight(path)
|
||||
|
||||
@@ -22,7 +22,6 @@ await rm(outdir, { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipWebUi = process.argv.includes("--skip-web-ui")
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
@@ -47,17 +46,13 @@ const allTargets: {
|
||||
{ os: "win32", arch: "x64", avx2: false },
|
||||
]
|
||||
|
||||
const targets =
|
||||
requestedTarget !== undefined
|
||||
? allTargets.filter((item) => targetName(item) === requestedTarget)
|
||||
: singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) return false
|
||||
if (item.avx2 === false) return baselineFlag
|
||||
return item.abi === undefined
|
||||
})
|
||||
: allTargets
|
||||
if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`)
|
||||
const targets = singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) return false
|
||||
if (item.avx2 === false) return baselineFlag
|
||||
return item.abi === undefined
|
||||
})
|
||||
: allTargets
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi })
|
||||
@@ -86,7 +81,15 @@ for (const item of targets) {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const target = targetName(item)
|
||||
const target = [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
@@ -140,15 +143,3 @@ for (const item of targets) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function targetName(item: (typeof allTargets)[number]) {
|
||||
return [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ function UpdateFooter(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
|
||||
<Monogram ink={monogramInk} />
|
||||
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<CellLine cells={header()} />
|
||||
|
||||
@@ -56,7 +56,6 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const timing = ensureTiming(options)
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
@@ -80,18 +79,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const registration = yield* registered(options.file, true, timing.requestTimeout)
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
if (registration.timedOut && info !== undefined) {
|
||||
timeouts = {
|
||||
info,
|
||||
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* evict(info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
@@ -99,8 +86,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
if (compatible && service.state === "failed")
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* kill(service, timing)
|
||||
yield* announce("version-mismatch", service.version)
|
||||
yield* kill(service, options, timing).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
@@ -133,8 +120,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
|
||||
const registration = yield* registered(options.file, true)
|
||||
if (registration.service !== undefined) yield* kill(registration.service, defaultEnsureTiming)
|
||||
if (registration.service === undefined && registration.info !== undefined)
|
||||
return yield* Effect.fail(
|
||||
new Error("Background service is not responding; stop its process manually and try again"),
|
||||
)
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
@@ -243,20 +234,10 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
||||
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
||||
return (yield* registered(options.file, true)).service
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
// discovery window.
|
||||
// Poll until an authenticated stop exits, bounded by the configured stop window.
|
||||
const poll = (timing: EnsureTiming) =>
|
||||
Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
|
||||
|
||||
const signal = (pid: number, name: NodeJS.Signals) =>
|
||||
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
|
||||
|
||||
const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
|
||||
Effect.orElseSucceed(() => false),
|
||||
@@ -265,44 +246,14 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (
|
||||
service: LocalService,
|
||||
options: { readonly file?: string },
|
||||
timing: EnsureTiming,
|
||||
) {
|
||||
const kill = Effect.fnUntraced(function* (service: LocalService, timing: EnsureTiming) {
|
||||
const requested = yield* requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
// A stale registration may point at a reused PID. Authenticate again
|
||||
// immediately before the legacy signal fallback.
|
||||
const current = yield* find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (requested === "rejected") return yield* Effect.fail(new Error("Background service rejected the stop request"))
|
||||
if (requested === "unsupported")
|
||||
return yield* Effect.fail(new Error("Background service does not support authenticated stop requests"))
|
||||
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGKILL")
|
||||
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
|
||||
return yield* Effect.fail(new Error("Background service accepted the stop request but did not exit"))
|
||||
})
|
||||
|
||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
||||
@@ -317,7 +268,8 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
if (response === undefined) return "rejected" as const
|
||||
if (response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
const decoded = decodeStopResponse(body)
|
||||
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
|
||||
|
||||
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const timing = ensureTiming(options)
|
||||
const deadline = Date.now() + timing.promiseTimeout
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
@@ -61,19 +60,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
@@ -81,8 +67,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
await kill(service, timing)
|
||||
announce("version-mismatch", service.version)
|
||||
await kill(service, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
@@ -110,8 +96,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const existing = await find(options)
|
||||
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
|
||||
const registration = await registered(options.file, true)
|
||||
if (registration.service !== undefined) await kill(registration.service, defaultEnsureTiming)
|
||||
if (registration.service === undefined && registration.info !== undefined)
|
||||
throw new Error("Background service is not responding; stop its process manually and try again")
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -199,16 +187,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
|
||||
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
return (await registered(options.file, true)).service
|
||||
}
|
||||
|
||||
function signal(pid: number, name: NodeJS.Signals) {
|
||||
try {
|
||||
process.kill(pid, name)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stopped(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
@@ -226,37 +204,12 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
|
||||
return false
|
||||
}
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (await waitUntilStopped(info.pid, timing)) return
|
||||
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
async function kill(service: LocalService, timing: EnsureTiming) {
|
||||
const requested = await requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
const current = await find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (requested === "rejected") throw new Error("Background service rejected the stop request")
|
||||
if (requested === "unsupported") throw new Error("Background service does not support authenticated stop requests")
|
||||
if (await waitUntilStopped(service.info.pid, timing)) return
|
||||
|
||||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid, timing)))
|
||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
throw new Error("Background service accepted the stop request but did not exit")
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
@@ -267,7 +220,8 @@ async function requestStop(service: LocalService, timeout = defaultEnsureTiming.
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}).catch(() => undefined)
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
if (response === undefined) return "rejected" as const
|
||||
if (response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
||||
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
||||
return "accepted" as const
|
||||
|
||||
@@ -28,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old" || mode === "reject-stop") version = "old"
|
||||
if (mode === "old" || mode === "reject-stop" || mode === "stop-hanging" || mode === "stop-accepted-hanging") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const id = crypto.randomUUID()
|
||||
@@ -40,7 +40,15 @@ const server = Bun.serve({
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return Response.json({ accepted: false })
|
||||
}
|
||||
if (pathname === "/api/service/stop" && mode === "graceful") {
|
||||
if (pathname === "/api/service/stop" && mode === "stop-hanging") {
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
if (pathname === "/api/service/stop" && mode === "stop-accepted-hanging") {
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return Response.json({ accepted: true })
|
||||
}
|
||||
if (pathname === "/api/service/stop" && (mode === "graceful" || mode === "old" || mode === "incompatible")) {
|
||||
const body = await request.json()
|
||||
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
|
||||
await writeFile(registration + ".stop", JSON.stringify(body))
|
||||
@@ -63,7 +71,13 @@ const server = Bun.serve({
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
||||
if (
|
||||
mode === "starting" ||
|
||||
mode === "graceful" ||
|
||||
mode === "reject-stop" ||
|
||||
mode === "stop-hanging" ||
|
||||
mode === "stop-accepted-hanging"
|
||||
)
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
|
||||
@@ -100,7 +100,7 @@ test("reports a bounded contender stderr tail with native promises", async () =>
|
||||
expect(error.message.length).toBeLessThan(9_000)
|
||||
}, 10_000)
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
test("never evicts an unresponsive registered service automatically", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
|
||||
@@ -111,19 +111,25 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await ensure({
|
||||
const result = ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
command: [process.execPath, fixture, registration, "record-start"],
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
await waitForLines(registration + ".requests", 3)
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
await expect(result).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("explicit native stop refuses to signal an unidentified unresponsive PID", async () => {
|
||||
const registration = await setup("hanging")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await expect(Service.stop({ file: registration })).rejects.toThrow("stop its process manually")
|
||||
|
||||
expect(process.kill(info.pid, 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
@@ -135,6 +141,29 @@ test("requests graceful stop of the exact service instance", async () => {
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
})
|
||||
|
||||
test.each([
|
||||
["reject-stop", "rejected the stop request"],
|
||||
["stop-hanging", "rejected the stop request"],
|
||||
["stop-accepted-hanging", "accepted the stop request but did not exit"],
|
||||
["legacy", "does not support authenticated stop requests"],
|
||||
])("native replacement fails closed for %s service stop", async (mode, message) => {
|
||||
const registration = await setup(mode)
|
||||
const directory = await temp()
|
||||
const contender = join(directory, "contender.json")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await expect(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, contender, "record-start"],
|
||||
}),
|
||||
).rejects.toThrow(message)
|
||||
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect(process.kill(info.pid, 0)).toBe(true)
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
@@ -156,3 +185,14 @@ async function waitForFile(file: string) {
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function waitForLines(file: string, count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const text = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (text.trim().split("\n").length >= count) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
|
||||
}
|
||||
|
||||
@@ -118,29 +118,39 @@ test("reports a failed registered service without spawning", async () => {
|
||||
expect(process.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
test("never evicts an unresponsive registered service automatically", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await run(
|
||||
const controller = new AbortController()
|
||||
const result = Effect.runPromise(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
command: [process.execPath, fixture, registration, "record-start"],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
await waitForLines(registration + ".requests", 3)
|
||||
controller.abort()
|
||||
await result.catch(() => undefined)
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
})
|
||||
|
||||
test("explicit stop refuses to signal an unidentified unresponsive PID", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
|
||||
await expect(run(Service.stop({ file: registration }))).rejects.toThrow("stop its process manually")
|
||||
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
@@ -161,36 +171,39 @@ test("does not spawn contenders while an incompatible service rejects replacemen
|
||||
const contender = join(directory, "contender.json")
|
||||
const existing = spawn(registration, "reject-stop")
|
||||
await waitForFile(registration)
|
||||
const controller = new AbortController()
|
||||
const starting = Effect.runPromise(
|
||||
const starting = run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, contender, "record-start"],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
{ signal: controller.signal },
|
||||
}),
|
||||
)
|
||||
|
||||
await waitForLines(registration + ".stop-attempts", 2)
|
||||
controller.abort()
|
||||
await starting.catch(() => undefined)
|
||||
await expect(starting).rejects.toThrow("Background service rejected the stop request")
|
||||
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect((await Bun.file(registration + ".stop-attempts").text()).trim().split("\n")).toHaveLength(1)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
test.each([
|
||||
["stop-hanging", "rejected the stop request"],
|
||||
["stop-accepted-hanging", "accepted the stop request but did not exit"],
|
||||
["legacy", "does not support authenticated stop requests"],
|
||||
])("replacement fails closed for %s service stop", async (mode, message) => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "legacy")
|
||||
const contender = join(directory, "contender.json")
|
||||
const existing = spawn(registration, mode)
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
const result = run(
|
||||
ensure({ file: registration, version: "test", command: [process.execPath, fixture, contender, "record-start"] }),
|
||||
)
|
||||
|
||||
await expect(result).rejects.toThrow("Missing service command")
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
await existing.exited
|
||||
await expect(result).rejects.toThrow(message)
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("waits for a slow winner while bounding lock probes", async () => {
|
||||
|
||||
@@ -99,11 +99,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly allowsAll: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
@@ -159,24 +154,6 @@ const layer = Layer.effect(
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
|
||||
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
for (let index = relevant.length - 1; index >= 0; index--) {
|
||||
const rule = relevant[index]
|
||||
if (rule.resource !== "*") {
|
||||
if (rule.effect !== "allow") return false
|
||||
continue
|
||||
}
|
||||
return rule.effect === "allow"
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Permission.Ruleset) {
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
@@ -338,7 +315,7 @@ const layer = Layer.effect(
|
||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
|
||||
return Service.of({ ask, assert, reply, get, forSession, list })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -149,50 +149,36 @@ export const Plugin = {
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const unrestricted =
|
||||
(yield* permission.allowsAll({
|
||||
sessionID: context.sessionID,
|
||||
action: name,
|
||||
agent: context.agent,
|
||||
})) &&
|
||||
(yield* permission.allowsAll({
|
||||
sessionID: context.sessionID,
|
||||
action: "external_directory",
|
||||
agent: context.agent,
|
||||
}))
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
invocation.cwd = target.absolute
|
||||
finalTimeout = invocation.timeout
|
||||
if (!unrestricted) {
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
||||
)
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) =>
|
||||
items.findIndex((other) => other.resource === item.resource) === index,
|
||||
)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
|
||||
Layer.mock(Permission.Service, {
|
||||
allowsAll: () => Effect.succeed(false),
|
||||
...overrides,
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const globalProjectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
@@ -112,31 +112,6 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("proves only unconditional configured allows", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Permission.Service
|
||||
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
|
||||
|
||||
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates against an explicit provider-turn agent", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
|
||||
|
||||
@@ -22,7 +22,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { DateTime, Effect, Layer, LayerMap, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const model = LanguageModel.make({
|
||||
@@ -30,6 +29,14 @@ const model = LanguageModel.make({
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||
})
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
let requests: LLMRequest[] = []
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
stream: (request: LLMRequest) => {
|
||||
@@ -66,7 +73,7 @@ const it = testEffect(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[Project.node, globalProjectLayer],
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -29,9 +29,16 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -44,7 +51,7 @@ const it = testEffect(
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -33,8 +33,6 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { executeTool, registerToolPlugin } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
@@ -52,7 +50,25 @@ const readToolNode = makeLocationNode({
|
||||
],
|
||||
})
|
||||
|
||||
const permission = permissionLayer({ assert: () => Effect.void })
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: () => Effect.void,
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
|
||||
@@ -76,7 +92,7 @@ const testLayer = AppNodeBuilder.build(
|
||||
Image.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[Location.node, tempLocationLayer],
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -16,14 +16,21 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -16,13 +16,20 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -12,13 +12,20 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -44,7 +44,6 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
|
||||
const cassetteName = "session-runner/openai-chat-streams-text"
|
||||
@@ -56,7 +55,17 @@ if (process.env.RECORD === "true") {
|
||||
const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory })
|
||||
const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
|
||||
const client = LLMClient.layer.pipe(Layer.provide(executor))
|
||||
const permission = permissionLayer()
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const model = OpenAIChat.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://api.openai.com/v1" },
|
||||
|
||||
@@ -74,7 +74,6 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, S
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
@@ -219,7 +218,17 @@ const permissionFail = {
|
||||
}),
|
||||
}),
|
||||
}
|
||||
const permission = permissionLayer()
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, ToolInfo>>, options?: Tool.Options) =>
|
||||
registry.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
|
||||
|
||||
@@ -19,7 +19,6 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const editToolNode = makeLocationNode({
|
||||
@@ -44,22 +43,30 @@ let denyAction: string | undefined
|
||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
|
||||
@@ -19,7 +19,6 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const patchToolNode = makeLocationNode({
|
||||
@@ -39,26 +38,34 @@ let editApproved = false
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
if (input.action === "edit") editApproved = true
|
||||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
if (input.action === "edit") editApproved = true
|
||||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
@@ -29,22 +28,30 @@ const questionInput = {
|
||||
},
|
||||
],
|
||||
}
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
|
||||
@@ -23,7 +23,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
@@ -71,24 +70,32 @@ const reader = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
let allow = true
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
),
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const testFileSystem = Layer.effect(
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
const globToolNode = makeLocationNode({
|
||||
@@ -50,12 +49,20 @@ const withTools = <A, E, R>(
|
||||
],
|
||||
[
|
||||
Permission.node,
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions?.push(input)
|
||||
}),
|
||||
}),
|
||||
Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions?.push(input)
|
||||
}),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -39,38 +39,42 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = Session.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const allowedActions = new Set<string>()
|
||||
let denyAction: string | undefined
|
||||
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
||||
|
||||
const permission = permissionLayer({
|
||||
allowsAll: (input) => Effect.succeed(allowedActions.has(input.action)),
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
allowedActions.clear()
|
||||
denyAction = undefined
|
||||
afterPermission = () => Effect.void
|
||||
}
|
||||
@@ -333,30 +337,6 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"skips command decomposition when shell and external directories are unrestricted",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
allowedActions.add("shell")
|
||||
allowedActions.add("external_directory")
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-unrestricted")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"captures stderr-only and mixed stdout/stderr output",
|
||||
() =>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { it } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
@@ -53,22 +52,30 @@ describe("SkillTool", () => {
|
||||
let current = [info]
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
let deny = false
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const skills = Layer.succeed(
|
||||
Skill.Service,
|
||||
Skill.Service.of({
|
||||
|
||||
@@ -13,7 +13,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const webFetchToolNode = makeLocationNode({
|
||||
@@ -37,7 +36,17 @@ const http = Layer.succeed(
|
||||
),
|
||||
),
|
||||
)
|
||||
const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) })
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const toolLayer = (replacements: LayerNode.Replacements = []) =>
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -15,7 +15,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { webSearchHost } from "./plugin/host"
|
||||
|
||||
@@ -67,9 +66,17 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const permission = permissionLayer({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
})
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
|
||||
@@ -19,7 +19,6 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const writeToolNode = makeLocationNode({
|
||||
@@ -34,22 +33,30 @@ const writes: string[] = []
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
let denyAction: string | undefined
|
||||
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Desktop package notes
|
||||
|
||||
- Follow Solid best practices, leave a comment when violating this: https://www.brenelz.com/posts/solid-js-best-practices/
|
||||
- Renderer process should only call `window.api` from `src/preload`.
|
||||
- Main process should register IPC handlers in `src/main/ipc.ts`.
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { downloadCliToResources, windowsify } from "./utils"
|
||||
import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
|
||||
|
||||
type ServerSource = { type: "build" } | { type: "download"; version: string }
|
||||
type DevOptions = { server: ServerSource; electron: string[] }
|
||||
@@ -37,12 +38,18 @@ function selectOptions(): DevOptions {
|
||||
}
|
||||
|
||||
async function prepareServer(source: ServerSource) {
|
||||
if (source.type === "download")
|
||||
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
|
||||
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
|
||||
if (process.platform !== "win32") return
|
||||
process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD = join(import.meta.dirname, "../../cli/script/build.ts")
|
||||
process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT = join(import.meta.dirname, "../resources/opencode-cli-wsl")
|
||||
const destination = windowsify("resources/opencode-cli-dev")
|
||||
if (source.type === "download") return downloadCliToResources(source.version, destination)
|
||||
return buildCliToResources(destination, developmentStateHome())
|
||||
}
|
||||
|
||||
function developmentStateHome() {
|
||||
const appData = (() => {
|
||||
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
|
||||
if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
|
||||
return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
|
||||
})()
|
||||
return join(appData, "ai.opencode.desktop.dev")
|
||||
}
|
||||
|
||||
async function startDesktop(args: string[]) {
|
||||
|
||||
@@ -86,6 +86,34 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
|
||||
console.log(`Copied ${cli.package}@${version} to ${dest}`)
|
||||
}
|
||||
|
||||
export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
||||
try {
|
||||
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
|
||||
{
|
||||
...process.env,
|
||||
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
|
||||
},
|
||||
)
|
||||
if (stateHome && (await Bun.file(dest).exists())) {
|
||||
const child = Bun.spawn([dest, "service", "stop"], {
|
||||
env: { ...process.env, XDG_STATE_HOME: stateHome },
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const exitCode = await child.exited
|
||||
if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
|
||||
}
|
||||
await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
await prepareCli(dest)
|
||||
|
||||
console.log(`Built local CLI at ${dest}`)
|
||||
}
|
||||
|
||||
async function prepareCli(dest: string) {
|
||||
if (process.platform !== "win32") await chmod(dest, 0o755)
|
||||
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { parseCliVersion } from "./cli-version"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -18,64 +17,36 @@ type Logger = {
|
||||
|
||||
export async function startBackgroundCli(logger: Logger) {
|
||||
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
|
||||
const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV
|
||||
const developmentVersion = process.env.OPENCODE_VERSION ?? "local"
|
||||
const cli = development
|
||||
? {
|
||||
version: developmentVersion,
|
||||
command: [
|
||||
"bun",
|
||||
"run",
|
||||
"--cwd",
|
||||
development,
|
||||
`--define=OPENCODE_VERSION=${JSON.stringify(developmentVersion)}`,
|
||||
"src/index.ts",
|
||||
],
|
||||
binary: undefined,
|
||||
}
|
||||
: await resolveBundledCli(isolated, logger)
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseVersion(await run(bundled, ["--version"], logger))
|
||||
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
const service = await Service.ensure({
|
||||
file:
|
||||
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
|
||||
? join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version: cli.version,
|
||||
command: [...cli.command, "serve", "--service"],
|
||||
version,
|
||||
command: [binary, "serve", "--service"],
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
logger.log("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version: cli.version,
|
||||
version,
|
||||
...endpoint(service.url),
|
||||
})
|
||||
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
|
||||
if (isolated) await cleanCliStages(binary, logger)
|
||||
return {
|
||||
url: service.url,
|
||||
username: service.auth.username,
|
||||
password: service.auth.password,
|
||||
version: cli.version,
|
||||
wslBuild:
|
||||
app.isPackaged || !process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD || !process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT
|
||||
? undefined
|
||||
: {
|
||||
script: process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD,
|
||||
output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBundledCli(isolated: boolean, logger: Logger) {
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseCliVersion(await run(bundled, ["--version"], logger))
|
||||
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
|
||||
return { version, binary, command: [binary] }
|
||||
}
|
||||
|
||||
async function cleanCliStages(binary: string, logger: Logger) {
|
||||
const current = dirname(binary)
|
||||
const root = dirname(current)
|
||||
@@ -132,6 +103,13 @@ async function run(binary: string, args: string[], logger: Logger) {
|
||||
)
|
||||
}
|
||||
|
||||
function parseVersion(output: string) {
|
||||
const marker = output.lastIndexOf(" v")
|
||||
const version = marker === -1 ? output : output.slice(marker + 2)
|
||||
if (!version) throw new Error("V2 CLI did not provide a version")
|
||||
return version
|
||||
}
|
||||
|
||||
function endpoint(url: string | undefined) {
|
||||
if (!url || !URL.canParse(url)) return {}
|
||||
const parsed = new URL(url)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export function parseCliVersion(output: string) {
|
||||
const marker = output.lastIndexOf(" v")
|
||||
const version = marker === -1 ? output : output.slice(marker + 2)
|
||||
if (!version) throw new Error("V2 CLI did not provide a version")
|
||||
return version
|
||||
}
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
setDockIcon,
|
||||
restoreMainWindows,
|
||||
} from "./windows"
|
||||
import { createWslServersController } from "./wsl/servers"
|
||||
import { registerWslIpcHandlers } from "./wsl/ipc"
|
||||
import { spawnWslSidecar } from "./wsl/sidecar"
|
||||
import { migrate } from "./migrate"
|
||||
import { cleanupStoreFiles } from "./store-cleanup"
|
||||
import { startBackgroundCli } from "./background-cli"
|
||||
@@ -132,10 +134,25 @@ const main = Effect.gen(function* () {
|
||||
logger = initLogging()
|
||||
initCrashReporter()
|
||||
|
||||
let stopWslServers = async () => {}
|
||||
const wslServers = createWslServersController(
|
||||
VERSION,
|
||||
async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
{
|
||||
logger: {
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
},
|
||||
)
|
||||
const stopSidecars = async () => wslServers.stopAll()
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers().finally(() => {
|
||||
void stopSidecars().finally(() => {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
})
|
||||
@@ -188,12 +205,12 @@ const main = Effect.gen(function* () {
|
||||
|
||||
app.on("before-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers()
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers()
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
app.on("child-process-gone", (_event, details) => {
|
||||
@@ -211,7 +228,7 @@ const main = Effect.gen(function* () {
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers().finally(() => app.quit())
|
||||
void stopSidecars().finally(() => app.quit())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,7 +253,7 @@ const main = Effect.gen(function* () {
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
const updater = setupAutoUpdater(() => stopWslServers())
|
||||
const updater = setupAutoUpdater(stopSidecars)
|
||||
const menuDeps = {
|
||||
trigger: (id: string) => {
|
||||
const win = getLastFocusedWindow()
|
||||
@@ -276,6 +293,7 @@ const main = Effect.gen(function* () {
|
||||
},
|
||||
})
|
||||
registerUpdaterIpc(updater)
|
||||
registerWslIpcHandlers(wslServers)
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
updateTimer.unref()
|
||||
@@ -293,15 +311,17 @@ const main = Effect.gen(function* () {
|
||||
useEnvProxy()
|
||||
|
||||
logger.log("starting v2 background service")
|
||||
const background = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
stopWslServers = yield* Effect.promise(() => startWslServers(background))
|
||||
|
||||
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url: background.url,
|
||||
username: background.username,
|
||||
password: background.password,
|
||||
url: sidecar.url,
|
||||
username: sidecar.username,
|
||||
password: sidecar.password,
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
|
||||
}
|
||||
|
||||
logger.log("loading task finished")
|
||||
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
|
||||
|
||||
@@ -320,41 +340,4 @@ const main = Effect.gen(function* () {
|
||||
if (windows.length) createMenu(menuDeps)
|
||||
})
|
||||
|
||||
async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) {
|
||||
if (process.platform !== "win32") {
|
||||
registerWslIpcHandlers()
|
||||
return async () => {}
|
||||
}
|
||||
|
||||
const { createWslServersController } = await import("./wsl/servers")
|
||||
const { spawnWslSidecar } = await import("./wsl/sidecar")
|
||||
const local = cli.wslBuild
|
||||
const controller = createWslServersController({
|
||||
cli: { version: cli.version },
|
||||
installCli: local
|
||||
? async (distro) => {
|
||||
const { buildLocalWslCli } = await import("./wsl/local")
|
||||
const { installWslCli } = await import("./wsl/runtime")
|
||||
await installWslCli(distro, {
|
||||
version: cli.version,
|
||||
binary: await buildLocalWslCli({ ...local, version: cli.version }),
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
spawnSidecar: async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
logger: {
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
})
|
||||
registerWslIpcHandlers(controller)
|
||||
controller.startConfiguredServers()
|
||||
return async () => controller.stopServers()
|
||||
}
|
||||
|
||||
Effect.runFork(main)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import type { IpcMainInvokeEvent } from "electron"
|
||||
import type { WslServersController } from "./servers"
|
||||
import { requireWslIpcString, requireWslIpcStrings } from "./policy"
|
||||
import type { WslServersState } from "../../preload/types"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export function registerWslIpcHandlers(controller?: WslServersController) {
|
||||
if (!controller) {
|
||||
export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
if (process.platform !== "win32") {
|
||||
registerUnavailableWslIpcHandlers()
|
||||
return
|
||||
}
|
||||
@@ -66,18 +67,6 @@ export function registerWslIpcHandlers(controller?: WslServersController) {
|
||||
)
|
||||
}
|
||||
|
||||
function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
function requireWslIpcStrings(name: string, value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
|
||||
const values = value.map((item) => requireWslIpcString(name, item))
|
||||
if (values.length) return values
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
function registerUnavailableWslIpcHandlers() {
|
||||
const unavailable = () => {
|
||||
throw new Error(nativeT("desktop.wsl.error.windowsOnly"))
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export async function buildLocalWslCli(input: { version: string; script: string; output: string }) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-wsl-cli-"))
|
||||
const root = join(dirname(input.script), "../../..")
|
||||
const packageManager = (JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { packageManager: string })
|
||||
.packageManager
|
||||
const target = `linux-${process.arch}`
|
||||
try {
|
||||
await execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
await execFileAsync(
|
||||
"bunx",
|
||||
[
|
||||
packageManager,
|
||||
input.script,
|
||||
`--target=opencode2-${target}`,
|
||||
"--skip-install",
|
||||
"--skip-web-ui",
|
||||
`--outdir=${directory}`,
|
||||
],
|
||||
{ cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true },
|
||||
)
|
||||
await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output)
|
||||
return input.output
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types"
|
||||
|
||||
export function wslServerIdToRestart(servers: WslServerItem[], distro: string) {
|
||||
return servers.find((item) => item.config.distro === distro)?.config.id
|
||||
}
|
||||
|
||||
export function clearWslDistroState(
|
||||
distroProbes: Record<string, WslDistroProbe>,
|
||||
opencodeChecks: Record<string, WslOpencodeCheck>,
|
||||
distro: string,
|
||||
) {
|
||||
const nextDistroProbes = { ...distroProbes }
|
||||
const nextOpencodeChecks = { ...opencodeChecks }
|
||||
delete nextDistroProbes[distro]
|
||||
delete nextOpencodeChecks[distro]
|
||||
return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks }
|
||||
}
|
||||
|
||||
export function wslTerminalArgs(distro?: string | null) {
|
||||
return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])]
|
||||
}
|
||||
|
||||
export function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
export function requireWslIpcStrings(name: string, value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
|
||||
const values = value.map((item) => requireWslIpcString(name, item))
|
||||
if (values.length > 0) return values
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
|
||||
import { parseCliVersion } from "../cli-version"
|
||||
import { wslTerminalArgs } from "./policy"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export type WslCommandLine = {
|
||||
@@ -31,11 +31,6 @@ export type RunWslOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type WslCliBuild = {
|
||||
version: string
|
||||
binary?: string
|
||||
}
|
||||
|
||||
const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
@@ -257,34 +252,28 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
||||
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
|
||||
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
|
||||
].join("; ")
|
||||
const result = await runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installWsl"))
|
||||
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
export async function installWslDistro(distro: string, opts?: RunWslOptions) {
|
||||
const result = await runInteractiveCommand(
|
||||
export async function installWslDistro(name: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
["--install", "-d", distro, "--web-download", "--no-launch"],
|
||||
["--install", "-d", name, "--web-download", "--no-launch"],
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro }))
|
||||
}
|
||||
|
||||
export async function installWslCli(distro: string, cli: WslCliBuild, opts?: RunWslOptions) {
|
||||
const result = await runInteractiveCommand(
|
||||
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro),
|
||||
wslArgs(
|
||||
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
|
||||
distro,
|
||||
),
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installOpencode"))
|
||||
}
|
||||
|
||||
export function wslCliInstallCommand(cli: WslCliBuild) {
|
||||
const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --"
|
||||
if (!cli.binary) return `${installer} --version ${shellEscape(cli.version)}`
|
||||
return `${installer} --binary "$(wslpath -a ${shellEscape(cli.binary)})"`
|
||||
}
|
||||
|
||||
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
|
||||
@@ -318,11 +307,11 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWslCli(distro: string, opts?: RunWslOptions) {
|
||||
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
|
||||
return firstLine(
|
||||
(
|
||||
await runWslSh(
|
||||
'if [ -x "$HOME/.opencode/bin/opencode2" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode2"; fi',
|
||||
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
|
||||
distro,
|
||||
opts,
|
||||
)
|
||||
@@ -330,15 +319,14 @@ export async function resolveWslCli(distro: string, opts?: RunWslOptions) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function readWslCliVersion(command: string, distro: string, opts?: RunWslOptions) {
|
||||
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
|
||||
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
|
||||
const output = firstLine(result.stdout)
|
||||
return output ? parseCliVersion(output) : null
|
||||
return firstLine(result.stdout)
|
||||
}
|
||||
|
||||
export function openWslTerminal(distro?: string | null) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
|
||||
const child = spawn("cmd.exe", wslTerminalArgs(distro), {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
@@ -398,11 +386,6 @@ export function summarize(value: string) {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function requireSuccess(result: WslCommandResult, fallback: string) {
|
||||
if (result.code === 0) return
|
||||
throw new Error(summarize(result.stderr || result.stdout) || fallback)
|
||||
}
|
||||
|
||||
export function shellEscape(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
@@ -1,80 +1,153 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { WslServerConfig } from "../../preload/types"
|
||||
import { wslCliInstallCommand } from "./runtime"
|
||||
import { createWslServersController } from "./servers"
|
||||
|
||||
type ControllerOptions = Parameters<typeof createWslServersController>[0]
|
||||
import {
|
||||
clearWslDistroState,
|
||||
requireWslIpcString,
|
||||
requireWslIpcStrings,
|
||||
wslServerIdToRestart,
|
||||
wslTerminalArgs,
|
||||
} from "./policy"
|
||||
import {
|
||||
expectOpencodeVersion,
|
||||
pendingRestartAfterWslInstall,
|
||||
pollWslHealth,
|
||||
wslServerIdsToStartOnInitialize,
|
||||
} from "./startup"
|
||||
import { createWslServersController, type WslServerConfig } from "./servers"
|
||||
|
||||
let persistedServers: WslServerConfig[] = []
|
||||
let releaseOpencodeResolve: (() => void) | undefined
|
||||
|
||||
test("passes a local CLI path directly to the V2 installer", () => {
|
||||
expect(wslCliInstallCommand({ version: "local", binary: "C:\\build\\opencode2" })).toBe(
|
||||
`curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --binary "$(wslpath -a 'C:\\build\\opencode2')"`,
|
||||
test("starts every configured WSL server on initialization", () => {
|
||||
expect(
|
||||
wslServerIdsToStartOnInitialize([
|
||||
{ id: "wsl:Debian", distro: "Debian" },
|
||||
{ id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" },
|
||||
]),
|
||||
).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"])
|
||||
})
|
||||
|
||||
test("rejects an update that did not install the desktop version", () => {
|
||||
expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow()
|
||||
expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow(
|
||||
"OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2",
|
||||
)
|
||||
})
|
||||
|
||||
test("installs and verifies the bundled CLI version", async () => {
|
||||
persistedServers = []
|
||||
const installs: string[][] = []
|
||||
const controller = createWslServersController(
|
||||
testControllerOptions({
|
||||
installCli: async (distro, cli) => {
|
||||
installs.push([distro, cli.version])
|
||||
test("restarts an existing distro server after updating OpenCode", () => {
|
||||
expect(
|
||||
wslServerIdToRestart(
|
||||
[
|
||||
{
|
||||
config: { id: "wsl:Debian", distro: "Debian" },
|
||||
runtime: { kind: "ready", url: "", username: null, password: null },
|
||||
},
|
||||
],
|
||||
"Debian",
|
||||
),
|
||||
).toBe("wsl:Debian")
|
||||
expect(wslServerIdToRestart([], "Debian")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears cached distro probes when removing a WSL server", () => {
|
||||
expect(
|
||||
clearWslDistroState(
|
||||
{ Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } },
|
||||
{
|
||||
Debian: {
|
||||
distro: "Debian",
|
||||
resolvedPath: "/home/luke/.opencode/bin/opencode",
|
||||
version: "1.16.2",
|
||||
expectedVersion: "1.16.2",
|
||||
matchesDesktop: true,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
||||
}),
|
||||
)
|
||||
|
||||
await controller.installOpencode("Debian")
|
||||
|
||||
expect(installs).toEqual([["Debian", "0.0.0-next-16365"]])
|
||||
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
|
||||
"Debian",
|
||||
),
|
||||
).toEqual({ distroProbes: {}, opencodeChecks: {} })
|
||||
})
|
||||
|
||||
test("rejects a WSL CLI version that differs from the bundled version", async () => {
|
||||
test("opens terminals for distro names containing spaces", () => {
|
||||
expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"])
|
||||
})
|
||||
|
||||
test("stops health polling when sidecar startup settles", async () => {
|
||||
const abort = new AbortController()
|
||||
let checks = 0
|
||||
const polling = pollWslHealth(
|
||||
async () => {
|
||||
checks++
|
||||
return false
|
||||
},
|
||||
abort.signal,
|
||||
1,
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
abort.abort()
|
||||
await polling
|
||||
const settled = checks
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(checks).toBe(settled)
|
||||
})
|
||||
|
||||
test("validates WSL IPC identifiers at the module boundary", () => {
|
||||
expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
|
||||
expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"])
|
||||
expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
|
||||
expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
|
||||
expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro")
|
||||
})
|
||||
|
||||
test("derives a required Windows restart from the post-install runtime probe", () => {
|
||||
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
|
||||
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
|
||||
})
|
||||
|
||||
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
|
||||
persistedServers = []
|
||||
releaseOpencodeResolve = undefined
|
||||
const controller = createWslServersController(
|
||||
testControllerOptions({
|
||||
installCli: async () => undefined,
|
||||
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
||||
readCliVersion: async () => "0.0.0-next-older",
|
||||
"1.16.2",
|
||||
async () => ({
|
||||
listener: {
|
||||
stop: () => undefined,
|
||||
onExit: () => undefined,
|
||||
},
|
||||
url: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
}),
|
||||
testControllerOptions(),
|
||||
)
|
||||
|
||||
await expect(controller.installOpencode("Debian")).rejects.toThrow(
|
||||
"OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365",
|
||||
)
|
||||
await controller.addServer("Debian")
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
})
|
||||
|
||||
test("stops a running WSL server before replacing its CLI", async () => {
|
||||
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
|
||||
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
|
||||
const events: string[] = []
|
||||
releaseOpencodeResolve = undefined
|
||||
const controller = createWslServersController(
|
||||
testControllerOptions({
|
||||
spawnSidecar: async () => {
|
||||
events.push("start")
|
||||
return {
|
||||
stop: async () => {
|
||||
events.push("stop")
|
||||
},
|
||||
onExit: () => undefined,
|
||||
url: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
}
|
||||
},
|
||||
installCli: async () => {
|
||||
events.push("install")
|
||||
},
|
||||
}),
|
||||
"1.16.2",
|
||||
async () => new Promise<never>(() => undefined),
|
||||
testControllerOptions(),
|
||||
)
|
||||
controller.startConfiguredServers()
|
||||
await waitFor(() => controller.getState().servers[0]?.runtime.kind === "ready")
|
||||
|
||||
await controller.installOpencode("Debian")
|
||||
await controller.initialize()
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(events).toEqual(["start", "stop", "install", "start"])
|
||||
await controller.stopServers()
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
})
|
||||
|
||||
test("probes addable distros in parallel before checking OpenCode", async () => {
|
||||
@@ -82,20 +155,18 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
|
||||
const started: string[] = []
|
||||
const release = new Map<string, () => void>()
|
||||
const opencode: string[] = []
|
||||
const controller = createWslServersController(
|
||||
testControllerOptions({
|
||||
spawnSidecar: pendingSidecar,
|
||||
probeDistro: async (distro) => {
|
||||
started.push(distro)
|
||||
await new Promise<void>((resolve) => release.set(distro, resolve))
|
||||
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
|
||||
},
|
||||
resolveCli: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode2"
|
||||
},
|
||||
}),
|
||||
)
|
||||
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
|
||||
...testControllerOptions(),
|
||||
probeDistro: async (distro) => {
|
||||
started.push(distro)
|
||||
await new Promise<void>((resolve) => release.set(distro, resolve))
|
||||
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
|
||||
},
|
||||
resolveOpencode: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
})
|
||||
|
||||
const task = controller.probeAddable(["Debian", "Ubuntu"])
|
||||
await waitFor(() => started.length === 2)
|
||||
@@ -113,22 +184,20 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
|
||||
test("does not check OpenCode in addable distros that cannot execute commands", async () => {
|
||||
persistedServers = []
|
||||
const opencode: string[] = []
|
||||
const controller = createWslServersController(
|
||||
testControllerOptions({
|
||||
spawnSidecar: pendingSidecar,
|
||||
probeDistro: async (distro) => ({
|
||||
name: distro,
|
||||
canExecute: distro === "Debian",
|
||||
hasBash: distro === "Debian",
|
||||
hasCurl: distro === "Debian",
|
||||
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
|
||||
}),
|
||||
resolveCli: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode2"
|
||||
},
|
||||
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
|
||||
...testControllerOptions(),
|
||||
probeDistro: async (distro) => ({
|
||||
name: distro,
|
||||
canExecute: distro === "Debian",
|
||||
hasBash: distro === "Debian",
|
||||
hasCurl: distro === "Debian",
|
||||
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
|
||||
}),
|
||||
)
|
||||
resolveOpencode: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
})
|
||||
|
||||
await controller.probeAddable(["Debian", "Ubuntu"])
|
||||
|
||||
@@ -145,24 +214,18 @@ async function waitFor(check: () => boolean) {
|
||||
throw new Error("Timed out waiting for condition")
|
||||
}
|
||||
|
||||
function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions {
|
||||
function testControllerOptions() {
|
||||
return {
|
||||
cli: { version: "0.0.0-next-16365" },
|
||||
spawnSidecar: async () => ({
|
||||
stop: async () => undefined,
|
||||
onExit: () => undefined,
|
||||
url: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
}),
|
||||
readServers: () => persistedServers,
|
||||
writeServers: (servers: WslServerConfig[]) => {
|
||||
persistedServers = servers
|
||||
},
|
||||
readCliVersion: async () => "0.0.0-next-16365",
|
||||
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
||||
...overrides,
|
||||
readCommandVersion: async () => "1.16.2",
|
||||
resolveOpencode: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseOpencodeResolve = resolve
|
||||
})
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const pendingSidecar = async () => new Promise<never>(() => undefined)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type {
|
||||
WslDistroProbe,
|
||||
WslInstalledDistro,
|
||||
WslJob,
|
||||
WslOnlineDistro,
|
||||
WslOpencodeCheck,
|
||||
WslRuntimeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
@@ -10,24 +13,25 @@ import type {
|
||||
} from "../../preload/types"
|
||||
import { WSL_SERVERS_KEY } from "../store-keys"
|
||||
import { getStore } from "../store"
|
||||
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
|
||||
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
|
||||
import { nativeT } from "../native-translations"
|
||||
import {
|
||||
installWslCli,
|
||||
installWslDistro,
|
||||
installWslOpencode,
|
||||
installWslRuntimeElevated,
|
||||
listInstalledWslDistros,
|
||||
listOnlineWslDistros,
|
||||
openWslTerminal,
|
||||
probeWslDistro,
|
||||
probeWslRuntime,
|
||||
readWslCliVersion,
|
||||
resolveWslCli,
|
||||
type WslCliBuild,
|
||||
readWslCommandVersion,
|
||||
resolveWslOpencode,
|
||||
summarize,
|
||||
} from "./runtime"
|
||||
|
||||
type RunningSidecar = {
|
||||
stop: () => Promise<void>
|
||||
onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
@@ -41,15 +45,12 @@ type ControllerLogger = {
|
||||
}
|
||||
|
||||
type WslServersControllerOptions = {
|
||||
cli: WslCliBuild
|
||||
spawnSidecar: SpawnSidecar
|
||||
logger?: ControllerLogger
|
||||
readServers?: () => WslServerConfig[]
|
||||
writeServers?: (servers: WslServerConfig[]) => void
|
||||
installCli?: typeof installWslCli
|
||||
probeDistro?: typeof probeWslDistro
|
||||
resolveCli?: typeof resolveWslCli
|
||||
readCliVersion?: typeof readWslCliVersion
|
||||
resolveOpencode?: typeof resolveWslOpencode
|
||||
readCommandVersion?: typeof readWslCommandVersion
|
||||
}
|
||||
|
||||
export type WslServersController = ReturnType<typeof createWslServersController>
|
||||
@@ -58,13 +59,20 @@ export function wslServerIdForDistro(distro: string) {
|
||||
return `wsl:${distro}`
|
||||
}
|
||||
|
||||
export function createWslServersController(options: WslServersControllerOptions) {
|
||||
export function createWslServersController(
|
||||
appVersion: string,
|
||||
spawnSidecar: SpawnSidecar,
|
||||
options?: WslServersControllerOptions,
|
||||
) {
|
||||
let state: WslServersState = initialState()
|
||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const readServers = options.readServers ?? readPersistedServers
|
||||
const writeServers = options.writeServers ?? writePersistedServers
|
||||
const probeDistro = options.probeDistro ?? probeWslDistro
|
||||
const startAttempts = new Map<string, number>()
|
||||
let jobAbort: AbortController | undefined
|
||||
const logger = options?.logger
|
||||
const readServers = options?.readServers ?? readPersistedServers
|
||||
const writeServers = options?.writeServers ?? writePersistedServers
|
||||
const probeDistro = options?.probeDistro ?? probeWslDistro
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener({ type: "state", state })
|
||||
@@ -75,11 +83,29 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
emit()
|
||||
}
|
||||
|
||||
const persistServers = (servers: WslServerConfig[]) => {
|
||||
writeServers(servers)
|
||||
}
|
||||
|
||||
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
|
||||
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
|
||||
setState({ servers: next })
|
||||
}
|
||||
|
||||
const beginJob = (job: WslJob): AbortController => {
|
||||
jobAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
jobAbort = abort
|
||||
setState({ job })
|
||||
return abort
|
||||
}
|
||||
|
||||
const endJob = (abort: AbortController) => {
|
||||
if (jobAbort !== abort) return
|
||||
jobAbort = undefined
|
||||
setState({ job: null })
|
||||
}
|
||||
|
||||
const refreshFromStore = () => {
|
||||
const persisted = readServers()
|
||||
const items: WslServerItem[] = persisted.map((config) => {
|
||||
@@ -96,7 +122,7 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
updateServer(id, (item) => ({ ...item, runtime }))
|
||||
}
|
||||
|
||||
const setCliCheck = (distro: string, check: WslOpencodeCheck) => {
|
||||
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
|
||||
setState({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
@@ -105,24 +131,24 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
})
|
||||
}
|
||||
|
||||
const inspectCli = async (distro: string) => {
|
||||
const resolved = await (options.resolveCli ?? resolveWslCli)(distro)
|
||||
const version = resolved ? await (options.readCliVersion ?? readWslCliVersion)(resolved, distro) : null
|
||||
return cliCheck(distro, resolved, version, options.cli.version)
|
||||
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
|
||||
const version = resolved
|
||||
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
|
||||
: null
|
||||
return opencodeCheck(distro, resolved, version, appVersion)
|
||||
}
|
||||
|
||||
const refreshCliCheck = async (distro: string) => {
|
||||
const check = await inspectCli(distro)
|
||||
setCliCheck(distro, check)
|
||||
return check
|
||||
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
setOpencodeCheck(distro, await checkOpencode(distro, opts))
|
||||
}
|
||||
|
||||
const probeAddableDistros = async (distros: string[]) => {
|
||||
const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => {
|
||||
const unique = [...new Set(distros)]
|
||||
const distroProbes = await Promise.all(
|
||||
unique
|
||||
.filter((distro) => !state.distroProbes[distro])
|
||||
.map(async (distro) => [distro, await probeDistro(distro)] as const),
|
||||
.map(async (distro) => [distro, await probeDistro(distro, opts)] as const),
|
||||
)
|
||||
if (distroProbes.length) {
|
||||
setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } })
|
||||
@@ -132,37 +158,86 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
unique
|
||||
.filter((distro) => distroProbeReady(state.distroProbes[distro]))
|
||||
.filter((distro) => !state.opencodeChecks[distro])
|
||||
.map(async (distro) => [distro, await inspectCli(distro)] as const),
|
||||
.map(async (distro) => [distro, await checkOpencode(distro, opts)] as const),
|
||||
)
|
||||
if (opencodeChecks.length) {
|
||||
setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } })
|
||||
}
|
||||
}
|
||||
|
||||
const refreshCliCheckSafely = (id: string, distro: string) => {
|
||||
return refreshCliCheck(distro).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
options.logger?.error("wsl CLI check failed", { id, distro, message })
|
||||
})
|
||||
const hasServer = (id: string, distro: string) => {
|
||||
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
|
||||
}
|
||||
|
||||
const refreshCliChecks = async () => {
|
||||
await Promise.all(state.servers.map((item) => refreshCliCheckSafely(item.config.id, item.config.distro)))
|
||||
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
|
||||
void checkOpencode(distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(id, distro)) return
|
||||
setOpencodeCheck(distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", { id, distro, message })
|
||||
})
|
||||
}
|
||||
|
||||
const refreshDistroLists = async () => {
|
||||
const [installed, online] = await Promise.all([listInstalledWslDistros(), listOnlineWslDistros()])
|
||||
const refreshOpencodeChecks = async () => {
|
||||
await Promise.all(
|
||||
state.servers.map((item) =>
|
||||
checkOpencode(item.config.distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(item.config.id, item.config.distro)) return
|
||||
setOpencodeCheck(item.config.distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", {
|
||||
id: item.config.id,
|
||||
distro: item.config.distro,
|
||||
message,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
|
||||
const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)])
|
||||
return { installed, online }
|
||||
}
|
||||
|
||||
const nextStartAttempt = (id: string) => {
|
||||
const next = (startAttempts.get(id) ?? 0) + 1
|
||||
startAttempts.set(id, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const invalidateStartAttempt = (id: string) => {
|
||||
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const isCurrentStartAttempt = (id: string, attempt: number) => {
|
||||
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
|
||||
}
|
||||
|
||||
const startServer = async (id: string) => {
|
||||
const item = state.servers.find((x) => x.config.id === id)
|
||||
if (!item) return
|
||||
await stopServer(id)
|
||||
const attempt = nextStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
setRuntime(id, { kind: "starting" })
|
||||
options.logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
|
||||
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
|
||||
try {
|
||||
const sidecar = await options.spawnSidecar(item.config.distro)
|
||||
const sidecar = await spawnSidecar(item.config.distro)
|
||||
if (!isCurrentStartAttempt(id, attempt)) {
|
||||
try {
|
||||
sidecar.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors for stale sidecars
|
||||
}
|
||||
return
|
||||
}
|
||||
sidecars.set(id, sidecar)
|
||||
setRuntime(id, {
|
||||
kind: "ready",
|
||||
@@ -170,36 +245,51 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
username: sidecar.username,
|
||||
password: sidecar.password,
|
||||
})
|
||||
sidecar.onExit((code, signal) => {
|
||||
sidecar.listener.onExit((code, signal) => {
|
||||
if (sidecars.get(id) !== sidecar) return
|
||||
sidecars.delete(id)
|
||||
const message = startupFailure(code, signal)
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
options.logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
})
|
||||
void refreshCliCheckSafely(id, item.config.distro)
|
||||
options.logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
refreshOpencodeCheckBackground(id, item.config.distro)
|
||||
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
options.logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
|
||||
// Without this, an Ubuntu-style silent failure leaves no trace in
|
||||
// main.log — the controller captures the message in its state but
|
||||
// nothing surfaces unless the user opens the WSL servers dialog.
|
||||
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
|
||||
}
|
||||
}
|
||||
|
||||
const stopServer = async (id: string) => {
|
||||
const stopServerInternal = async (id: string) => {
|
||||
const existing = sidecars.get(id)
|
||||
if (!existing) return
|
||||
sidecars.delete(id)
|
||||
await existing.stop()
|
||||
setRuntime(id, { kind: "stopped" })
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors
|
||||
}
|
||||
}
|
||||
|
||||
const runJob = async <T>(job: WslJob, runner: () => Promise<T>) => {
|
||||
setState({ job })
|
||||
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
|
||||
const abort = beginJob(job)
|
||||
try {
|
||||
return await runner()
|
||||
} finally {
|
||||
setState({ job: null })
|
||||
const value = await runner(abort)
|
||||
endJob(abort)
|
||||
return value
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
endJob(abort)
|
||||
return undefined
|
||||
}
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
endJob(abort)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,15 +302,15 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
|
||||
startConfiguredServers() {
|
||||
async initialize() {
|
||||
refreshFromStore()
|
||||
void refreshCliChecks()
|
||||
state.servers.forEach((item) => void startServer(item.config.id))
|
||||
void refreshOpencodeChecks()
|
||||
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
|
||||
},
|
||||
|
||||
async probeRuntime() {
|
||||
await runJob({ kind: "runtime", startedAt: Date.now() }, async () => {
|
||||
const runtime = await probeWslRuntime()
|
||||
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
setState({
|
||||
runtime,
|
||||
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
|
||||
@@ -229,48 +319,62 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
},
|
||||
|
||||
async refreshDistros() {
|
||||
await runJob({ kind: "distros", startedAt: Date.now() }, async () => {
|
||||
setState(await refreshDistroLists())
|
||||
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
|
||||
setState(await refreshDistroLists({ signal: abort.signal }))
|
||||
})
|
||||
},
|
||||
|
||||
async installWsl() {
|
||||
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async () => {
|
||||
await installWslRuntimeElevated()
|
||||
const runtime = await probeWslRuntime()
|
||||
setState({ runtime, pendingRestart: !runtime.available })
|
||||
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslRuntimeElevated({ signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl")
|
||||
throw new Error(message)
|
||||
}
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) })
|
||||
})
|
||||
},
|
||||
|
||||
async installDistro(distro: string) {
|
||||
await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => {
|
||||
await installWslDistro(distro)
|
||||
const distros = await refreshDistroLists()
|
||||
const probe = await probeDistro(distro)
|
||||
async installDistro(name: string) {
|
||||
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslDistro(name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message =
|
||||
summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name })
|
||||
throw new Error(message)
|
||||
}
|
||||
const distros = await refreshDistroLists({ signal: abort.signal })
|
||||
const probe = await probeDistro(name, { signal: abort.signal })
|
||||
setState({
|
||||
...distros,
|
||||
distroProbes: { ...state.distroProbes, [distro]: probe },
|
||||
distroProbes: { ...state.distroProbes, [name]: probe },
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async probeAddable(distros: string[]) {
|
||||
if (!distros.length) return
|
||||
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, () => probeAddableDistros(distros))
|
||||
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => {
|
||||
await probeAddableDistros(distros, { signal: abort.signal })
|
||||
})
|
||||
},
|
||||
|
||||
async installOpencode(distro: string) {
|
||||
await runJob({ kind: "install-opencode", distro, startedAt: Date.now() }, async () => {
|
||||
const id = state.servers.find((item) => item.config.distro === distro)?.config.id
|
||||
if (id) await stopServer(id)
|
||||
await (options.installCli ?? installWslCli)(distro, options.cli)
|
||||
requireMatchingCli(await refreshCliCheck(distro), options.cli.version)
|
||||
async installOpencode(name: string) {
|
||||
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode"))
|
||||
}
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
||||
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
|
||||
const id = wslServerIdToRestart(state.servers, name)
|
||||
if (id) await startServer(id)
|
||||
})
|
||||
},
|
||||
|
||||
async openTerminal(distro: string) {
|
||||
await openWslTerminal(distro)
|
||||
async openTerminal(name: string) {
|
||||
await openWslTerminal(name)
|
||||
},
|
||||
|
||||
async addServer(distro: string): Promise<WslServerConfig> {
|
||||
@@ -282,7 +386,7 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
id,
|
||||
distro,
|
||||
}
|
||||
writeServers([...readServers(), config])
|
||||
persistServers([...readServers(), config])
|
||||
setState({
|
||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
||||
})
|
||||
@@ -292,19 +396,27 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
|
||||
async removeServer(id: string) {
|
||||
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
|
||||
await stopServer(id)
|
||||
invalidateStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
const remaining = readServers().filter((item) => item.id !== id)
|
||||
writeServers(remaining)
|
||||
persistServers(remaining)
|
||||
setState({
|
||||
servers: state.servers.filter((item) => item.config.id !== id),
|
||||
...(distro ? removeDistroState(state, distro) : {}),
|
||||
...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}),
|
||||
})
|
||||
},
|
||||
|
||||
startServer,
|
||||
|
||||
async stopServers() {
|
||||
await Promise.all([...sidecars.values()].map((sidecar) => sidecar.stop()))
|
||||
stopAll() {
|
||||
for (const item of state.servers) invalidateStartAttempt(item.config.id)
|
||||
for (const existing of sidecars.values()) {
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sidecars.clear()
|
||||
},
|
||||
}
|
||||
@@ -352,7 +464,7 @@ function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
||||
]
|
||||
}
|
||||
|
||||
function cliCheck(
|
||||
function opencodeCheck(
|
||||
distro: string,
|
||||
resolvedPath: string | null,
|
||||
version: string | null,
|
||||
@@ -388,25 +500,6 @@ function cliCheck(
|
||||
}
|
||||
}
|
||||
|
||||
function requireMatchingCli(check: WslOpencodeCheck, expected: string) {
|
||||
if (check.version === expected) return
|
||||
throw new Error(
|
||||
nativeT("desktop.wsl.error.updateVersion", {
|
||||
distro: check.distro,
|
||||
installed: check.version ?? nativeT("desktop.wsl.error.noVersion"),
|
||||
expected,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeDistroState(state: WslServersState, distro: string) {
|
||||
const distroProbes = { ...state.distroProbes }
|
||||
const opencodeChecks = { ...state.opencodeChecks }
|
||||
delete distroProbes[distro]
|
||||
delete opencodeChecks[distro]
|
||||
return { distroProbes, opencodeChecks }
|
||||
}
|
||||
|
||||
function distroProbeReady(probe: WslDistroProbe | undefined) {
|
||||
return !!probe?.canExecute && probe.hasBash && probe.hasCurl
|
||||
}
|
||||
@@ -414,3 +507,17 @@ function distroProbeReady(probe: WslDistroProbe | undefined) {
|
||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
|
||||
return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" })
|
||||
}
|
||||
|
||||
// Re-export types used by callers
|
||||
export type {
|
||||
WslInstalledDistro,
|
||||
WslOnlineDistro,
|
||||
WslRuntimeCheck,
|
||||
WslDistroProbe,
|
||||
WslOpencodeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:net"
|
||||
import { app } from "electron"
|
||||
import { checkHealth } from "../server"
|
||||
import { type WslCommandLine, resolveWslCli, shellEscape, wslArgs } from "./runtime"
|
||||
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
|
||||
import { pollWslHealth } from "./startup"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export type WslSidecar = {
|
||||
stop: () => Promise<void>
|
||||
onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
@@ -18,7 +18,7 @@ export async function spawnWslSidecar(
|
||||
distro: string,
|
||||
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
|
||||
): Promise<WslSidecar> {
|
||||
const opencode = await resolveWslCli(distro)
|
||||
const opencode = await resolveWslOpencode(distro)
|
||||
if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro }))
|
||||
|
||||
const port = await allocatePort()
|
||||
@@ -35,7 +35,7 @@ export async function spawnWslSidecar(
|
||||
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
|
||||
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
|
||||
'export XDG_STATE_HOME="$HOME/.local/state"',
|
||||
`exec ${shellEscape(opencode)} --log-level ${app.isPackaged ? "warn" : "info"} serve --hostname 0.0.0.0 --port ${port}`,
|
||||
`exec ${shellEscape(opencode)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`,
|
||||
].join("\n")
|
||||
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
@@ -80,27 +80,16 @@ export async function spawnWslSidecar(
|
||||
startup.abort()
|
||||
})
|
||||
return {
|
||||
stop: async () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
await new Promise<void>((resolve) => {
|
||||
child.once("exit", () => resolve())
|
||||
child.kill()
|
||||
})
|
||||
listener: {
|
||||
stop: () => child.kill(),
|
||||
onExit: (cb) => child.once("exit", cb),
|
||||
},
|
||||
onExit: (cb) => child.once("exit", cb),
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
if (await check()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
function allocatePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
|
||||
return servers.map((server) => server.id)
|
||||
}
|
||||
|
||||
export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") {
|
||||
if (installed === expected) return
|
||||
throw new Error(
|
||||
nativeT("desktop.wsl.error.updateVersion", {
|
||||
distro,
|
||||
installed: installed ?? nativeT("desktop.wsl.error.noVersion"),
|
||||
expected,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available
|
||||
|
||||
export async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
|
||||
while (!signal.aborted) {
|
||||
if (await check()) return
|
||||
await abortableDelay(interval, signal)
|
||||
}
|
||||
}
|
||||
|
||||
function abortableDelay(duration: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const done = () => {
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
const timeout = setTimeout(done, duration)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -120,9 +120,7 @@ describe("opencode-drive", () => {
|
||||
expect(initStatus).toBe(0)
|
||||
const artifacts = initOutput.trim()
|
||||
expect(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).exists()).toBe(true)
|
||||
expect(
|
||||
Bun.JSONC.parse(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).text()),
|
||||
).toMatchObject({
|
||||
expect(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({
|
||||
model: "simulation/gpt-sim-model",
|
||||
snapshots: false,
|
||||
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
||||
@@ -767,15 +765,13 @@ describe("opencode-drive", () => {
|
||||
gitWriteError: expect.stringContaining("must not modify Git metadata"),
|
||||
matches: true,
|
||||
})
|
||||
expect(
|
||||
Bun.JSONC.parse(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).text()),
|
||||
).toMatchObject({
|
||||
expect(await Bun.file(join(artifacts, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({
|
||||
autoupdate: false,
|
||||
model: "simulation/gpt-sim-model",
|
||||
providers: { simulation: { models: { "gpt-sim-model": {} } } },
|
||||
test: { declared: true, setup: true },
|
||||
})
|
||||
expect(Bun.JSONC.parse(await Bun.file(join(artifacts, "files", ".opencode", "tui.jsonc")).text())).toEqual({
|
||||
expect(await Bun.file(join(artifacts, "files", ".opencode", "tui.jsonc")).json()).toEqual({
|
||||
test: { declared: true, setup: true },
|
||||
})
|
||||
const backendEvents = (await Bun.file(join(artifacts, "backend-events.jsonl")).text())
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("instance configuration", () => {
|
||||
const root = await initializeInstance()
|
||||
artifacts.push(root)
|
||||
|
||||
expect(Bun.JSONC.parse(await Bun.file(join(root, "files", ".opencode", "opencode.jsonc")).text())).toMatchObject({
|
||||
expect(await Bun.file(join(root, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({
|
||||
model: "simulation/gpt-sim-model",
|
||||
providers: {
|
||||
simulation: {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { feedbackIssueUrl } from "../src/feedback"
|
||||
import { annotationUrl, readAnnotations } from "../src/annotations"
|
||||
|
||||
describe("catalog feedback", () => {
|
||||
test("opens a prefilled issue for an exact capture", () => {
|
||||
@@ -19,39 +18,4 @@ describe("catalog feedback", () => {
|
||||
expect(url.searchParams.get("body")).toContain("`skill-picker`")
|
||||
expect(url.searchParams.get("body")).toContain("screen=skill-picker&set=opencode")
|
||||
})
|
||||
|
||||
test("round-trips a capture annotation document through the URL fragment", () => {
|
||||
const document = {
|
||||
version: 1 as const,
|
||||
identifier: "skill-picker",
|
||||
variant: "opencode",
|
||||
annotations: [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }],
|
||||
}
|
||||
const url = new URL(annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document))
|
||||
|
||||
expect(url.hash).toStartWith("#annotations=")
|
||||
expect(readAnnotations(url, "skill-picker", "opencode")).toEqual(document.annotations)
|
||||
expect(readAnnotations(url, "other-screen", "opencode")).toEqual([])
|
||||
})
|
||||
|
||||
test("includes human and machine-readable annotations in the issue", () => {
|
||||
const annotations = [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }]
|
||||
const document = { version: 1 as const, identifier: "skill-picker", variant: "opencode", annotations }
|
||||
const url = new URL(
|
||||
feedbackIssueUrl({
|
||||
title: "Skill picker",
|
||||
identifier: "skill-picker",
|
||||
deepLink: annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document),
|
||||
variant: "opencode",
|
||||
annotations,
|
||||
document,
|
||||
}),
|
||||
)
|
||||
const body = url.searchParams.get("body") ?? ""
|
||||
|
||||
expect(body).toContain("## 1. Row 5, column 13")
|
||||
expect(body).toContain("This label needs more contrast.")
|
||||
expect(body).toContain("<summary>Annotation data</summary>")
|
||||
expect(body).toContain('"row": 4')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import wrangler from "../wrangler.jsonc"
|
||||
import { assetPath, metaTags } from "../worker"
|
||||
|
||||
const catalog = {
|
||||
variants: [
|
||||
{ id: "opencode", label: "Opencode" },
|
||||
{ id: "tokyonight", label: "Tokyo Night" },
|
||||
],
|
||||
screens: [
|
||||
{
|
||||
id: "home",
|
||||
title: "Home",
|
||||
summary: "",
|
||||
frames: [{ variantId: "opencode" }, { variantId: "tokyonight" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
import { assetPath } from "../worker"
|
||||
|
||||
describe("catalog worker", () => {
|
||||
test("serves the app shell for catalog routes", () => {
|
||||
@@ -32,25 +17,4 @@ describe("catalog worker", () => {
|
||||
test("leaves HTML routing to the worker", () => {
|
||||
expect(wrangler.assets.html_handling).toBe("none")
|
||||
})
|
||||
|
||||
test("injects a per-capture Open Graph card for deep links", () => {
|
||||
const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home&set=tokyonight"), catalog)
|
||||
expect(tags).toContain('content="Home — OpenCode Terminal Catalog"')
|
||||
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--tokyonight.png"')
|
||||
expect(tags).toContain("Tokyo Night theme")
|
||||
expect(tags).toContain('name="twitter:card" content="summary_large_image"')
|
||||
})
|
||||
|
||||
test("defaults the theme to the first variant", () => {
|
||||
const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home"), catalog)
|
||||
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--opencode.png"')
|
||||
})
|
||||
|
||||
test("falls back to the default card for unknown or missing captures", () => {
|
||||
for (const search of ["", "?screen=missing", "?screen=home&set=missing"]) {
|
||||
const tags = metaTags(new URL(`https://dev.opencode.ai/lab/catalog${search}`), catalog)
|
||||
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/default.png"')
|
||||
expect(tags).toContain('content="OpenCode Terminal Catalog"')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"dev": "bun ./server.ts",
|
||||
"lint": "cd ../../.. && bun run lint -- packages/lab/catalog/src packages/lab/catalog/catalog packages/lab/catalog/scripts packages/lab/catalog/scenarios packages/lab/catalog/server.ts packages/lab/catalog/worker.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/ && bun ./scripts/generate-og.ts",
|
||||
"build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/",
|
||||
"deploy": "bun run generate && bun run build && bunx wrangler deploy",
|
||||
"doctor": "bunx -y react-doctor@latest .",
|
||||
"bench:capture-flow": "bun ./scripts/bench-capture-flow.ts",
|
||||
@@ -28,7 +28,6 @@
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* Generates Open Graph cards for the catalog: one 1200x630 PNG per captured
|
||||
* screen/theme pair plus a default card, written to dist/og. Runs after the
|
||||
* production build so the images deploy as plain static assets.
|
||||
*/
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { GlobalFonts, createCanvas } from "@napi-rs/canvas"
|
||||
import type { SKRSContext2D, Canvas } from "@napi-rs/canvas"
|
||||
import {
|
||||
CellHeight,
|
||||
CellWidth,
|
||||
DimAlpha,
|
||||
FontSize,
|
||||
StrikethroughOffset,
|
||||
TextStyle,
|
||||
UnderlineOffset,
|
||||
baselineOffset,
|
||||
drawBlockGlyph,
|
||||
} from "opencode-drive/frame"
|
||||
|
||||
const CardWidth = 1200
|
||||
const CardHeight = 630
|
||||
const Pad = 56
|
||||
|
||||
const FontStack = `"OpenCode Mono", "OpenCode Symbols", "OpenCode Symbols 2", "OpenCode Math"`
|
||||
|
||||
const driveRoot = new URL("../../", Bun.pathToFileURL(Bun.resolveSync("opencode-drive/frame", import.meta.dir)).href)
|
||||
for (const [file, family] of [
|
||||
["commit-mono/CommitMono-400-Regular.otf", "OpenCode Mono"],
|
||||
["commit-mono/CommitMono-700-Regular.otf", "OpenCode Mono"],
|
||||
["commit-mono/CommitMono-400-Italic.otf", "OpenCode Mono"],
|
||||
["commit-mono/CommitMono-700-Italic.otf", "OpenCode Mono"],
|
||||
["noto/NotoSansSymbols.ttf", "OpenCode Symbols"],
|
||||
["noto/NotoSansSymbols2-Regular.ttf", "OpenCode Symbols 2"],
|
||||
["noto/NotoSansMath-Regular.ttf", "OpenCode Math"],
|
||||
] as const) {
|
||||
const path = fileURLToPath(new URL(`assets/fonts/${file}`, driveRoot))
|
||||
if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register OG font: ${path}`)
|
||||
}
|
||||
|
||||
interface FrameSpan {
|
||||
readonly text: string
|
||||
readonly fg: readonly [number, number, number, number]
|
||||
readonly bg: readonly [number, number, number, number]
|
||||
readonly attributes: number
|
||||
readonly width: number
|
||||
}
|
||||
|
||||
interface FrameArtifact {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly lines: ReadonlyArray<{ readonly spans: ReadonlyArray<FrameSpan> }>
|
||||
}
|
||||
|
||||
function color([red, green, blue, alpha]: FrameSpan["fg"], opacity = 1) {
|
||||
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`
|
||||
}
|
||||
|
||||
/** Mirrors the browser canvas renderer in src/components/TerminalFrame.tsx. */
|
||||
function renderFrame(frame: FrameArtifact) {
|
||||
const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight)
|
||||
const context = canvas.getContext("2d")
|
||||
context.fillStyle = "#080808"
|
||||
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
context.textBaseline = "alphabetic"
|
||||
context.textAlign = "center"
|
||||
|
||||
frame.lines.forEach((line, row) => {
|
||||
let column = 0
|
||||
line.spans.forEach((span) => {
|
||||
const attributes = span.attributes & 0xff
|
||||
const inverse = Boolean(attributes & TextStyle.inverse)
|
||||
const hidden = Boolean(attributes & TextStyle.invisible)
|
||||
const foreground = inverse ? span.bg : span.fg
|
||||
const background = inverse ? span.fg : span.bg
|
||||
const chars = [...span.text]
|
||||
let remaining = span.width
|
||||
|
||||
chars.forEach((char, index) => {
|
||||
const cells = Math.max(1, remaining - (chars.length - index - 1))
|
||||
const x = column * CellWidth
|
||||
const y = row * CellHeight
|
||||
if (background[3]) {
|
||||
context.fillStyle = color(background)
|
||||
context.fillRect(x, y, cells * CellWidth, CellHeight)
|
||||
}
|
||||
if (!hidden && char.codePointAt(0) !== 0x0a00) {
|
||||
context.fillStyle = color(foreground, attributes & TextStyle.dim ? DimAlpha : 1)
|
||||
if (!drawBlockGlyph(context, char, x, y, cells)) {
|
||||
const font = `${attributes & TextStyle.italic ? "italic " : ""}${attributes & TextStyle.bold ? "700 " : "400 "}${FontSize}px ${FontStack}`
|
||||
context.font = font
|
||||
context.fillText(char, x + (cells * CellWidth) / 2, y + baselineOffset(context, font), cells * CellWidth)
|
||||
}
|
||||
if (attributes & TextStyle.underline) context.fillRect(x, y + UnderlineOffset, cells * CellWidth, 1)
|
||||
if (attributes & TextStyle.strikethrough) context.fillRect(x, y + StrikethroughOffset, cells * CellWidth, 1)
|
||||
}
|
||||
column += cells
|
||||
remaining -= cells
|
||||
})
|
||||
while (remaining-- > 0) {
|
||||
if (background[3]) {
|
||||
context.fillStyle = color(background)
|
||||
context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight)
|
||||
}
|
||||
column++
|
||||
}
|
||||
})
|
||||
})
|
||||
return canvas
|
||||
}
|
||||
|
||||
function drawChrome(context: SKRSContext2D, kicker: string, title: string, chip: string) {
|
||||
context.fillStyle = "#0a0a0a"
|
||||
context.fillRect(0, 0, CardWidth, CardHeight)
|
||||
|
||||
context.textAlign = "left"
|
||||
context.textBaseline = "alphabetic"
|
||||
context.font = `700 20px ${FontStack}`
|
||||
context.fillStyle = "#6f6e69"
|
||||
context.fillText(kicker.toUpperCase(), Pad, Pad + 16)
|
||||
|
||||
context.font = `700 46px ${FontStack}`
|
||||
context.fillStyle = "#ededed"
|
||||
context.fillText(title, Pad - 2, Pad + 74, CardWidth - Pad * 2 - 220)
|
||||
|
||||
if (chip !== "") {
|
||||
context.font = `400 20px ${FontStack}`
|
||||
const width = context.measureText(chip).width + 36
|
||||
const x = CardWidth - Pad - width
|
||||
context.strokeStyle = "#2c2c2c"
|
||||
context.lineWidth = 1
|
||||
context.beginPath()
|
||||
context.roundRect(x, Pad + 38, width, 40, 20)
|
||||
context.stroke()
|
||||
context.fillStyle = "#9c9b96"
|
||||
context.fillText(chip, x + 18, Pad + 65)
|
||||
}
|
||||
}
|
||||
|
||||
/** Frame peeks from the bottom edge like a window, cropped by the card. */
|
||||
function drawFramePeek(context: SKRSContext2D, frame: Canvas) {
|
||||
const top = 182
|
||||
const width = CardWidth - Pad * 2
|
||||
const scale = width / frame.width
|
||||
const visible = CardHeight - top
|
||||
const radius = 10
|
||||
|
||||
context.save()
|
||||
context.beginPath()
|
||||
context.roundRect(Pad, top, width, visible + radius, radius)
|
||||
context.clip()
|
||||
context.drawImage(
|
||||
frame,
|
||||
0,
|
||||
0,
|
||||
frame.width,
|
||||
Math.min(frame.height, (visible + radius) / scale),
|
||||
Pad,
|
||||
top,
|
||||
width,
|
||||
Math.min(frame.height * scale, visible + radius),
|
||||
)
|
||||
context.restore()
|
||||
|
||||
context.strokeStyle = "#2c2c2c"
|
||||
context.lineWidth = 2
|
||||
context.beginPath()
|
||||
context.roundRect(Pad + 1, top + 1, width - 2, visible + radius, radius)
|
||||
context.stroke()
|
||||
}
|
||||
|
||||
const root = new URL("../", import.meta.url)
|
||||
const outDir = new URL("dist/og/", root)
|
||||
const catalog = await Bun.file(new URL("dist/catalog.json", root)).json()
|
||||
|
||||
const variants = catalog.variants as ReadonlyArray<{ id: string; label: string }>
|
||||
const screens = catalog.screens as ReadonlyArray<{
|
||||
id: string
|
||||
title: string
|
||||
frames: ReadonlyArray<{ variantId: string; src: string }>
|
||||
}>
|
||||
|
||||
let generated = 0
|
||||
for (const screen of screens) {
|
||||
for (const frame of screen.frames) {
|
||||
const variant = variants.find((candidate) => candidate.id === frame.variantId)
|
||||
if (!variant) continue
|
||||
const artifact = (await Bun.file(new URL(frame.src, new URL("dist/", root))).json()) as FrameArtifact
|
||||
const card = createCanvas(CardWidth, CardHeight)
|
||||
const context = card.getContext("2d")
|
||||
drawChrome(context, "OpenCode · Terminal Catalog", screen.title, variant.label)
|
||||
drawFramePeek(context, renderFrame(artifact))
|
||||
await Bun.write(new URL(`${screen.id}--${variant.id}.png`, outDir), card.toBuffer("image/png"))
|
||||
generated++
|
||||
}
|
||||
}
|
||||
|
||||
const home = screens.find((screen) => screen.id === "home")
|
||||
const homeFrame = home?.frames.find((frame) => frame.variantId === variants[0]?.id) ?? home?.frames[0]
|
||||
const card = createCanvas(CardWidth, CardHeight)
|
||||
const context = card.getContext("2d")
|
||||
drawChrome(context, "OpenCode", "Terminal Catalog", `${screens.length} screens · ${variants.length} themes`)
|
||||
if (homeFrame) {
|
||||
const artifact = (await Bun.file(new URL(homeFrame.src, new URL("dist/", root))).json()) as FrameArtifact
|
||||
drawFramePeek(context, renderFrame(artifact))
|
||||
}
|
||||
await Bun.write(new URL("default.png", outDir), card.toBuffer("image/png"))
|
||||
|
||||
console.log(`generated ${generated + 1} Open Graph cards in dist/og`)
|
||||
@@ -334,7 +334,6 @@ export function App({ catalog }: AppProps) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (ui.viewerOpen) return
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
@@ -350,18 +349,18 @@ export function App({ catalog }: AppProps) {
|
||||
states: ui.facets.state,
|
||||
}),
|
||||
)
|
||||
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen])
|
||||
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.viewerOpen || !selectedScreen) return
|
||||
const url = new URL(
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
catalogDeepLink(selectedScreen.id, {
|
||||
flowId: ui.mode === "flows" ? activeFlow?.id : undefined,
|
||||
variantId: activeVariant.id,
|
||||
}),
|
||||
)
|
||||
if (window.location.hash.startsWith("#annotations=")) url.hash = window.location.hash
|
||||
window.history.replaceState(null, "", url)
|
||||
}, [activeVariant.id, activeFlow?.id, selectedScreen, ui.mode, ui.viewerOpen])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -484,7 +483,6 @@ export function App({ catalog }: AppProps) {
|
||||
</main>
|
||||
{ui.viewerOpen && selectedScreen ? (
|
||||
<Viewer
|
||||
key={`${selectedScreen.id}:${activeVariant.id}`}
|
||||
screen={selectedScreen}
|
||||
identifier={
|
||||
ui.mode === "flows" && activeFlow?.replayable ? `${activeFlow.id}/${selectedScreen.id}` : selectedScreen.id
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
export interface Annotation {
|
||||
readonly id: string
|
||||
readonly row: number
|
||||
readonly column: number
|
||||
readonly note: string
|
||||
}
|
||||
|
||||
export interface AnnotationDocument {
|
||||
readonly version: 1
|
||||
readonly identifier: string
|
||||
readonly variant: string
|
||||
readonly annotations: ReadonlyArray<Annotation>
|
||||
}
|
||||
|
||||
const FragmentKey = "annotations"
|
||||
const MaxAnnotations = 24
|
||||
const MaxNoteLength = 2_000
|
||||
|
||||
export function annotationUrl(deepLink: string, document: AnnotationDocument) {
|
||||
const url = new URL(deepLink)
|
||||
url.hash = `${FragmentKey}=${encode(document)}`
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function readAnnotations(url: URL, identifier: string, variant: string): ReadonlyArray<Annotation> {
|
||||
const params = new URLSearchParams(url.hash.slice(1))
|
||||
const encoded = params.get(FragmentKey)
|
||||
if (!encoded) return []
|
||||
const value = decode(encoded)
|
||||
if (!isDocument(value) || value.identifier !== identifier || value.variant !== variant) return []
|
||||
return value.annotations
|
||||
}
|
||||
|
||||
export function readAnnotationDraft(value: string): ReadonlyArray<Annotation> {
|
||||
try {
|
||||
const annotations: unknown = JSON.parse(value)
|
||||
return isAnnotations(annotations) ? annotations : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function encode(value: AnnotationDocument) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(value))
|
||||
return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""))
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function decode(value: string): unknown {
|
||||
try {
|
||||
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"))
|
||||
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isDocument(value: unknown): value is AnnotationDocument {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const document = value as Partial<AnnotationDocument>
|
||||
if (document.version !== 1 || typeof document.identifier !== "string" || typeof document.variant !== "string")
|
||||
return false
|
||||
return isAnnotations(document.annotations)
|
||||
}
|
||||
|
||||
function isAnnotations(value: unknown): value is ReadonlyArray<Annotation> {
|
||||
if (!Array.isArray(value) || value.length > MaxAnnotations) return false
|
||||
return value.every(
|
||||
(annotation) =>
|
||||
annotation &&
|
||||
typeof annotation === "object" &&
|
||||
typeof annotation.id === "string" &&
|
||||
Number.isInteger(annotation.row) &&
|
||||
annotation.row >= 0 &&
|
||||
Number.isInteger(annotation.column) &&
|
||||
annotation.column >= 0 &&
|
||||
typeof annotation.note === "string" &&
|
||||
annotation.note.length <= MaxNoteLength,
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { Annotation } from "../annotations"
|
||||
|
||||
interface AnnotationEditorProps {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly annotations: ReadonlyArray<Annotation>
|
||||
readonly onAdd: (row: number, column: number, note: string) => void
|
||||
readonly onChange: (id: string, note: string) => void
|
||||
readonly onDelete: (id: string) => void
|
||||
readonly issueLink: string
|
||||
readonly onDone: () => void
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
readonly id?: string
|
||||
readonly row: number
|
||||
readonly column: number
|
||||
readonly note: string
|
||||
}
|
||||
|
||||
export function AnnotationEditor(props: AnnotationEditorProps) {
|
||||
const [draft, setDraft] = useState<Draft>()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const complete = props.annotations.filter((annotation) => annotation.note.trim() !== "")
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
textareaRef.current?.setSelectionRange(draft.note.length, draft.note.length)
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [draft?.id, draft?.row, draft?.column])
|
||||
|
||||
const save = () => {
|
||||
if (!draft?.note.trim()) return
|
||||
if (draft.id) props.onChange(draft.id, draft.note.trim())
|
||||
else props.onAdd(draft.row, draft.column, draft.note.trim())
|
||||
setDraft(undefined)
|
||||
}
|
||||
|
||||
const edit = (annotation: Annotation) =>
|
||||
setDraft({ id: annotation.id, row: annotation.row, column: annotation.column, note: annotation.note })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="annotation-layer"
|
||||
aria-label="Click the terminal to add an annotation"
|
||||
onPointerDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const column = Math.min(
|
||||
props.cols - 1,
|
||||
Math.max(0, Math.floor(((event.clientX - bounds.left) / bounds.width) * props.cols)),
|
||||
)
|
||||
const row = Math.min(
|
||||
props.rows - 1,
|
||||
Math.max(0, Math.floor(((event.clientY - bounds.top) / bounds.height) * props.rows)),
|
||||
)
|
||||
setDraft({ row, column, note: "" })
|
||||
}}
|
||||
>
|
||||
{props.annotations.map((annotation, index) => (
|
||||
<button
|
||||
key={annotation.id}
|
||||
type="button"
|
||||
className={`annotation-pin${annotation.id === draft?.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${((annotation.column + 0.5) / props.cols) * 100}%`,
|
||||
top: `${((annotation.row + 0.5) / props.rows) * 100}%`,
|
||||
}}
|
||||
aria-label={`Edit annotation ${index + 1}, row ${annotation.row + 1}, column ${annotation.column + 1}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => edit(annotation)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))}
|
||||
{draft ? (
|
||||
<div
|
||||
className={`annotation-composer${draft.row > props.rows / 2 ? " above" : ""}`}
|
||||
style={{
|
||||
left: `clamp(9rem, ${((draft.column + 0.5) / props.cols) * 100}%, calc(100% - 9rem))`,
|
||||
top: `${((draft.row + 0.5) / props.rows) * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header>
|
||||
<span>{draft.id ? "Edit annotation" : "New annotation"}</span>
|
||||
<small>
|
||||
R{draft.row + 1} · C{draft.column + 1}
|
||||
</small>
|
||||
</header>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="annotation-note"
|
||||
aria-label="Annotation note"
|
||||
value={draft.note}
|
||||
maxLength={2_000}
|
||||
rows={2}
|
||||
placeholder="What should change?"
|
||||
onChange={(event) => setDraft({ ...draft, note: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setDraft(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<footer>
|
||||
{draft.id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-composer-delete"
|
||||
onClick={() => {
|
||||
if (draft.id) props.onDelete(draft.id)
|
||||
setDraft(undefined)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="button" onClick={() => setDraft(undefined)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="annotation-composer-save" disabled={!draft.note.trim()} onClick={save}>
|
||||
{draft.id ? "Save" : "Add"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
<aside className="annotation-panel" aria-label="Capture annotations">
|
||||
<header>
|
||||
<div>
|
||||
<strong>Annotations</strong>
|
||||
<span>
|
||||
{props.annotations.length === 0 ? "Click anywhere on the terminal" : `${props.annotations.length} placed`}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={props.onDone}>
|
||||
Done
|
||||
</button>
|
||||
</header>
|
||||
<div className="annotation-list">
|
||||
{props.annotations.map((annotation, index) => (
|
||||
<button key={annotation.id} type="button" className="annotation-list-row" onClick={() => edit(annotation)}>
|
||||
<span className="annotation-list-pin">{index + 1}</span>
|
||||
<span>
|
||||
<small>
|
||||
Row {annotation.row + 1} · Column {annotation.column + 1}
|
||||
</small>
|
||||
<strong>{annotation.note}</strong>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer>
|
||||
<a
|
||||
className="annotation-issue"
|
||||
href={complete.length === 0 ? undefined : props.issueLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-disabled={complete.length === 0}
|
||||
>
|
||||
Open GitHub issue
|
||||
</a>
|
||||
<span>
|
||||
{complete.length === 0
|
||||
? "Add a note to continue"
|
||||
: `${complete.length} note${complete.length === 1 ? "" : "s"} will be included`}
|
||||
</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user