Compare commits

..
629 changed files with 8549 additions and 154136 deletions
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -7
View File
@@ -55,12 +55,6 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Install ffmpeg
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -72,7 +66,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+4 -4
View File
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
@@ -114,8 +114,8 @@ completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune
+544 -856
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -33,7 +33,6 @@
"packages": [
"packages/*",
"packages/console/*",
"packages/lab/*",
"packages/stats/*",
"packages/slack"
],
@@ -121,8 +120,7 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.10.2",
"vitest": "4.1.10"
"turbo": "2.10.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
+10 -8
View File
@@ -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
}>
+3 -3
View File
@@ -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>
),
}))
-1
View File
@@ -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
+12 -10
View File
@@ -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>
+3 -3
View File
@@ -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"]),
+5 -2
View File
@@ -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 -3
View File
@@ -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
})
+3 -3
View File
@@ -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
})
+7 -6
View File
@@ -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)
+2 -2
View File
@@ -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 -11
View File
@@ -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}
+6 -10
View File
@@ -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>
)
+1 -1
View File
@@ -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} />)
})
}
+44 -48
View File
@@ -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)
+16 -25
View File
@@ -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()} />
+2 -2
View File
@@ -19,7 +19,7 @@ import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Project } from "@opencode-ai/schema/project"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { Worktree } from "@opencode-ai/schema/worktree"
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Provider } from "@opencode-ai/schema/provider"
@@ -60,7 +60,7 @@ const effectTypeReferences = [
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
...namespaceTypes("Worktree", "@opencode-ai/schema/worktree", Worktree),
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
+21 -29
View File
@@ -34,7 +34,7 @@ import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { Question } from "@opencode-ai/schema/question"
import type { Reference } from "@opencode-ai/schema/reference"
import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
@@ -1230,17 +1230,9 @@ export type Endpoint13_1Input = {
export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly current: ProjectCurrentOperation<E>
readonly directories: ProjectDirectoriesOperation<E>
}
export type Endpoint14_0Input = {
@@ -1549,36 +1541,36 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint24_0Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint24_0Output = ProjectCopy.Copy
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: Worktree.StrategyID
readonly directory: AbsolutePath
readonly force: boolean
readonly name?: string | undefined
}
export type Endpoint24_1Output = void
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint24_2Output = void
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export interface ProjectCopyApi<E = never> {
readonly create: ProjectCopyCreateOperation<E>
readonly remove: ProjectCopyRemoveOperation<E>
readonly refresh: ProjectCopyRefreshOperation<E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
readonly create: WorktreeCreateOperation<E>
readonly remove: WorktreeRemoveOperation<E>
readonly refresh: WorktreeRefreshOperation<E>
}
export type Endpoint25_0Input = {
@@ -1691,7 +1683,7 @@ export interface AppApi<E = never> {
readonly shell: ShellApi<E>
readonly question: QuestionApi<E>
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly worktree: WorktreeApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
+22 -33
View File
@@ -139,8 +139,6 @@ import type {
Endpoint13_0Output,
Endpoint13_1Input,
Endpoint13_1Output,
Endpoint13_2Input,
Endpoint13_2Output,
Endpoint14_0Input,
Endpoint14_0Output,
Endpoint14_1Input,
@@ -216,6 +214,8 @@ import type {
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
@@ -876,19 +876,7 @@ const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
preserveEffect<Endpoint13_2Output>()(
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
list: Endpoint13_0(raw),
current: Endpoint13_1(raw),
directories: Endpoint13_2(raw),
})
const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) })
const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) =>
preserveEffect<Endpoint14_0Output>()(
@@ -1212,36 +1200,37 @@ const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) =>
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["projectCopy.create"]({
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["projectCopy.remove"]({
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint24_0(raw),
remove: Endpoint24_1(raw),
refresh: Endpoint24_2(raw),
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
})
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
@@ -1334,7 +1323,7 @@ const adaptClient = (raw: RawClient) => ({
shell: adaptGroup21(raw["server.shell"]),
question: adaptGroup22(raw["server.question"]),
reference: adaptGroup23(raw["server.reference"]),
projectCopy: adaptGroup24(raw["server.projectCopy"]),
worktree: adaptGroup24(raw["server.worktree"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
+1 -1
View File
@@ -33,7 +33,7 @@ export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
+29 -33
View File
@@ -133,8 +133,6 @@ import type {
ProjectListOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
ProjectDirectoriesOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
@@ -206,12 +204,14 @@ import type {
QuestionRejectOutput,
ReferenceListInput,
ReferenceListOutput,
ProjectCopyCreateInput,
ProjectCopyCreateOutput,
ProjectCopyRemoveInput,
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
WorktreeListInput,
WorktreeListOutput,
WorktreeCreateInput,
WorktreeCreateOutput,
WorktreeRemoveInput,
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -1255,18 +1255,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
request<ProjectDirectoriesOutput>(
{
method: "GET",
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
form: {
request: {
@@ -1736,13 +1724,23 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
projectCopy: {
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopyCreateOutput>(
worktree: {
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
declaredStatuses: [400, 401],
@@ -1750,12 +1748,11 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRemoveOutput>(
remove: (input: WorktreeRemoveInput, requestOptions?: RequestOptions) =>
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1763,12 +1760,11 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRefreshOutput>(
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+25 -41
View File
@@ -291,8 +291,6 @@ export type ProjectTime = { created: number; updated: number; initialized?: numb
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectDirectory = { directory: string; strategy?: string }
export type FormMetadata = { [x: string]: JsonValue }
export type FormValue = string | number | boolean | Array<string>
@@ -370,7 +368,9 @@ export type ReferenceGitSource = {
hidden?: boolean
}
export type ProjectCopyCopy = { directory: string }
export type WorktreeDirectory = { directory: string; strategy?: string }
export type WorktreeInfo = { directory: string }
export type VcsBranch = { current?: string; default?: string }
@@ -828,20 +828,20 @@ export type PluginUpdated = {
data: {}
}
export type ProjectDirectoriesUpdated = {
export type WorktreeUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directories.updated"
type: "worktree.updated"
location?: LocationRef
data: { projectID: string }
}
export type ProjectDirectoryResolved = {
export type WorktreeResolved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directory.resolved"
type: "worktree.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
@@ -1369,8 +1369,6 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectDirectories = Array<ProjectDirectory>
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -1520,6 +1518,8 @@ export type SessionStatus2 = {
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type WorktreeList = Array<WorktreeDirectory>
export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
@@ -2102,8 +2102,8 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectDirectoriesUpdated
| ProjectDirectoryResolved
| WorktreeUpdated
| WorktreeResolved
| CommandUpdated
| ConfigUpdated
| SkillUpdated
@@ -2304,12 +2304,12 @@ export type QuestionNotFoundError = {
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
export type ProjectCopyError = {
readonly name: "ProjectCopyError"
export type WorktreeError = {
readonly name: "WorktreeError"
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
}
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type HealthGetOutput = ServiceHealth
@@ -4348,15 +4348,6 @@ export type ProjectCurrentInput = {
export type ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5642,37 +5633,30 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type ProjectCopyCreateInput = {
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
}
export type ProjectCopyCreateOutput = ProjectCopyCopy
export type WorktreeCreateOutput = WorktreeInfo
export type ProjectCopyRemoveInput = {
export type WorktreeRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
export type ProjectCopyRemoveOutput = void
export type WorktreeRemoveOutput = void
export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type ProjectCopyRefreshOutput = void
export type WorktreeRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
+33 -18
View File
@@ -29,7 +29,7 @@ test("exposes every standard HTTP API group", () => {
"shell",
"question",
"reference",
"projectCopy",
"worktree",
"vcs",
"debug",
"migration",
@@ -49,7 +49,8 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
test("config.get returns ordered config entries for a location", async () => {
@@ -251,30 +252,44 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("project methods use the public HTTP contract", async () => {
const requests: string[] = []
test("worktree methods use the global project contract", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push(url)
if (url.includes("/directories")) return Response.json([])
return Response.json({ id: "proj_test", directory: "/tmp/project" })
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
if (request.method === "POST" && !request.url.endsWith("/refresh"))
return Response.json({ directory: "/tmp/worktrees/api" })
return new Response(null, { status: 204 })
},
})
const current = await client.project.current({ location: { workspace: "wrk_test" } })
const directories = await client.project.directories({
projectID: current.id,
location: { directory: current.directory },
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
expect(
await client.worktree.create({
projectID: "proj_test",
strategy: "git",
directory: "/tmp/worktrees",
name: "api",
}),
).toEqual({ directory: "/tmp/worktrees/api" })
await client.worktree.remove({
projectID: "proj_test",
directory: "/tmp/worktrees/api",
force: false,
})
await client.worktree.refresh({ projectID: "proj_test" })
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
expect(directories).toEqual([])
expect(requests).toEqual([
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
])
expect(await requests[1]?.json()).toEqual({ strategy: "git", directory: "/tmp/worktrees", name: "api" })
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("shell list and remove use the public HTTP contract", async () => {
+191 -41
View File
@@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
"prevIds": [
"5c1aa56b-c3ee-4283-9a84-c0bf626dc604"
],
"ddl": [
{
"name": "account_state",
@@ -76,6 +78,10 @@
"name": "workspace",
"entityType": "tables"
},
{
"name": "worktree",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
@@ -1437,9 +1443,53 @@
"table": "workspace"
},
{
"columns": ["active_account_id"],
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "worktree"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "worktree"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "strategy",
"entityType": "columns",
"table": "worktree"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "worktree"
},
{
"columns": [
"active_account_id"
],
"tableTo": "account",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1448,9 +1498,13 @@
"table": "account_state"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"columnsTo": [
"aggregate_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1459,9 +1513,13 @@
"table": "event"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1470,9 +1528,13 @@
"table": "permission"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1481,9 +1543,13 @@
"table": "project_directory"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1492,9 +1558,13 @@
"table": "instruction_entry"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1503,9 +1573,13 @@
"table": "instruction_state"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1514,9 +1588,13 @@
"table": "session_inbox"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1525,9 +1603,13 @@
"table": "session_message"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1536,9 +1618,13 @@
"table": "session_pending"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1547,126 +1633,190 @@
"table": "session_v2"
},
{
"columns": ["email", "url"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_worktree_project_id_project_id_fk",
"entityType": "fks",
"table": "worktree"
},
{
"columns": [
"email",
"url"
],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": ["session_id", "key"],
"columns": [
"session_id",
"key"
],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": ["id"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "worktree_pk",
"entityType": "pks",
"table": "worktree"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": ["key"],
"columns": [
"key"
],
"nameExplicit": false,
"name": "kv_pk",
"table": "kv",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["hash"],
"columns": [
"hash"
],
"nameExplicit": false,
"name": "instruction_blob_pk",
"table": "instruction_blob",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pending_pk",
"table": "session_pending",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_v2_pk",
"table": "session_v2",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
@@ -1962,4 +2112,4 @@
}
],
"renames": []
}
}
+2
View File
@@ -42,6 +42,7 @@ import m39 from "./migration/20260805200742_import_legacy_credentials.js"
import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
export const migrations = [
m00,
@@ -87,4 +88,5 @@ export const migrations = [
m40,
m41,
m42,
m43,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,35 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260812213948_worktree",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`worktree\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
INSERT INTO \`worktree\` (\`project_id\`, \`directory\`, \`strategy\`, \`time_created\`)
SELECT
\`project_id\`,
\`directory\`,
CASE
WHEN \`strategy\` = 'git_worktree' THEN 'git'
WHEN \`strategy\` IS NOT NULL THEN \`strategy\`
WHEN \`type\` = 'git_worktree' THEN 'git'
END,
\`time_created\`
FROM \`project_directory\`;
`)
})
},
}
export default migration
+10
View File
@@ -225,6 +225,16 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`last_used_at\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`worktree\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
+2 -3
View File
@@ -25,7 +25,7 @@ import { MCP } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { ProjectCopy } from "./project/copy.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Question } from "./question.js"
import { Shell } from "./shell.js"
@@ -66,8 +66,7 @@ const locationServiceNodes = [
AISDK.node,
Plugin.node,
PluginSupervisor.node,
ProjectCopy.node,
ProjectCopy.refreshNode,
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
Pty.node,
+1 -24
View File
@@ -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 })
}),
)
+26 -25
View File
@@ -2,20 +2,20 @@ export * as Project from "./project.js"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import { and, asc, desc, eq } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Event } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectDirectories } from "./project/directories.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID
@@ -26,18 +26,9 @@ export type Vcs = ProjectSchema.Vcs
export const Current = ProjectSchema.Current
export type Current = ProjectSchema.Current
export const Directory = ProjectSchema.Directory
export type Directory = ProjectSchema.Directory
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = ProjectSchema.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = ProjectSchema.Directories
export type Directories = typeof Directories.Type
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -56,7 +47,6 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
@@ -95,18 +85,19 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
const announcing = new Set<string>()
const persist = Effect.fnUntraced(function* (project: Resolved) {
yield* upsertProject(db, project).pipe(Effect.orDie)
if (!project.vcs) return project
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
{ projectID: project.id, directory: project.canonical },
]
if (project.directory !== project.canonical)
directories.push({
projectID: project.id,
directory: project.directory,
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
strategy: project.vcs.type === "git" ? "git" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact (copy.ts registers copy directories directly; those never strand
@@ -119,11 +110,25 @@ const layer = Layer.effect(
if (announcing.has(key)) continue
announcing.add(key)
yield* Effect.gen(function* () {
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
const stored = yield* db
.select({ directory: WorktreeTable.directory })
.from(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, item.projectID), eq(WorktreeTable.directory, item.directory)))
.get()
.pipe(Effect.orDie)
if (stored) return
yield* bus.publish(
Event.Resolved,
Worktree.Event.Resolved,
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
{
commit: () =>
db
.insert(WorktreeTable)
.values({ project_id: item.projectID, directory: item.directory, strategy: item.strategy })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie, Effect.asVoid),
},
)
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
}
@@ -140,10 +145,6 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
return yield* projectDirectories.list(input.projectID)
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -257,12 +258,12 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, directories, resolve })
return Service.of({ list, resolve })
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
})
@@ -1,35 +0,0 @@
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Git } from "../git.js"
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy.js"
export function makeGitWorktreeStrategy(input: {
git: Git.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
return {
id: StrategyID.make("git_worktree"),
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
const repository = yield* input.git.repo.discover(options.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
yield* input.git.worktree.create({ repository, directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.repo.discover(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.repo.discover(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
const entries = yield* input.git.worktree.list(found)
return yield* Effect.forEach(entries, (entry) =>
input.canonical(entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
} satisfies Strategy
}
-272
View File
@@ -1,272 +0,0 @@
export * as ProjectCopy from "./copy.js"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { AbsolutePath } from "../schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Project } from "../project.js"
import { ProjectDirectories } from "./directories.js"
import { makeGitWorktreeStrategy } from "./copy-strategies.js"
import { Slug } from "../util/slug.js"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Location } from "../location.js"
import { Event } from "@opencode-ai/schema/project-directories"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
export const StrategyID = ProjectCopy.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = ProjectCopy.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = ProjectCopy.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: Project.ID,
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Copy = ProjectCopy.Copy
export type Copy = typeof Copy.Type
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "copy"]),
}).annotate({ identifier: "ProjectCopy.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"ProjectCopy.SourceDirectoryNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"ProjectCopy.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"ProjectCopy.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"ProjectCopy.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"ProjectCopy.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export { Event }
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const copies = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
const result = yield* copies.refresh({ projectID: location.project.id })
yield* Effect.logInfo("project copy refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const directories = yield* ProjectDirectories.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
const resolved = AbsolutePath.make(yield* fs.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
const strategy = makeGitWorktreeStrategy({ git, canonical })
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
const sourceDirectory = yield* canonical(input)
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
return sourceDirectory
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
return strategy
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
while (yield* fs.existsSafe(copyDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: copyDirectory,
sourceDirectory,
})
yield* changed(
input.projectID,
yield* directories.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
behavior: "replace",
}),
)
return result
})
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
const copyDirectory = yield* canonical(input.directory)
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: copyDirectory,
force: input.force,
})
yield* changed(
input.projectID,
yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
)
})
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
const stored = yield* directories.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
directories.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
behavior: "replace",
},
tx,
),
),
removed: Effect.forEach(removed, (directory) =>
directories.remove({ projectID: input.projectID, directory }, tx),
),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
create,
remove,
refresh,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, ProjectDirectories.node, Bus.node, Database.node],
})
export const refreshNode = makeLocationNode({
name: "project-copy-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
-128
View File
@@ -1,128 +0,0 @@
export * as ProjectDirectories from "./directories.js"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath } from "../schema.js"
import { ProjectSchema } from "./schema.js"
import { ProjectDirectoryTable } from "./sql.js"
import type { EffectDrizzleSqlite } from "../database/drizzle.js"
import type { Project } from "../project.js"
export type Directory = Project.Directory
export const CreateInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
strategy: Schema.optional(Schema.String),
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
})
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
})
export type RemoveInput = typeof RemoveInput.Type
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ListInput = ProjectSchema.DirectoriesInput
export type ListInput = typeof ListInput.Type
export const ListOutput = ProjectSchema.Directories
export type ListOutput = typeof ListOutput.Type
export interface Interface {
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
readonly get: (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) => Effect.Effect<Directory | undefined>
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
const insert = (tx ?? db)
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
const query =
input.behavior === "replace"
? insert.onConflictDoUpdate({
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
: isNotNull(ProjectDirectoryTable.strategy),
})
: insert.onConflictDoNothing()
return (
(yield* query.returning({ directory: ProjectDirectoryTable.directory }).get().pipe(Effect.orDie)) !== undefined
)
})
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.returning({ directory: ProjectDirectoryTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
})
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) {
const row = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
})
return Service.of({
list,
get,
create,
remove,
})
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
-9
View File
@@ -10,18 +10,9 @@ export type ID = typeof ID.Type
export const Current = Project.Current
export type Current = typeof Current.Type
export const Directory = Project.Directory
export type Directory = typeof Directory.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = Project.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Project.Directories
export type Directories = typeof Directories.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
+1
View File
@@ -23,6 +23,7 @@ export const ProjectTable = sqliteTable("project", {
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
/** @deprecated Use WorktreeTable from worktree/sql instead. */
export const ProjectDirectoryTable = sqliteTable(
"project_directory",
{
+2 -2
View File
@@ -18,7 +18,7 @@ import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money"
import { Event } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
@@ -442,7 +442,7 @@ const layer = Layer.effectDiscard(
// Sessions whose ownership came from the directory's previous resolution
// follow its new identity. Location, transcript, instructions, and recency
// are untouched: the session did not move, its directory got identified.
yield* bus.project(Event.Resolved, (event) =>
yield* bus.project(Worktree.Event.Resolved, (event) =>
Effect.gen(function* () {
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
if (stale.length === 0) return
+27 -41
View File
@@ -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}`)),
+349
View File
@@ -0,0 +1,349 @@
export * as Worktree from "./worktree.js"
import { Context, Effect, Layer, Schema } from "effect"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { ProjectSchema } from "./project/schema.js"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Location } from "./location.js"
import { Worktree } from "@opencode-ai/schema/worktree"
import { WorktreeTable } from "./worktree/sql.js"
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
import { WorktreeGit } from "./worktree/git.js"
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export const StrategyID = Worktree.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Worktree.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Worktree.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: ProjectSchema.ID,
}).annotate({ identifier: "Worktree.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "Worktree.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Info = Worktree.Info
export type Info = typeof Info.Type
export const ListInput = Worktree.ListInput
export type ListInput = typeof ListInput.Type
export const List = Worktree.List
export type List = typeof List.Type
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "worktree"]),
}).annotate({ identifier: "Worktree.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"Worktree.SourceDirectoryNotFoundError",
{ projectID: ProjectSchema.ID },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"Worktree.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"Worktree.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"Worktree.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"Worktree.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export const Event = Worktree.Event
interface StoredInput {
readonly projectID: ProjectSchema.ID
readonly directory: AbsolutePath
readonly strategy?: string
}
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const worktrees = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
const result = yield* worktrees.refresh({ projectID: location.project.id })
yield* Effect.logInfo("worktree refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const ops = {
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, projectID))
.orderBy(desc(WorktreeTable.time_created), asc(WorktreeTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
}),
create: Effect.fnUntraced(function* (input: StoredInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
: isNotNull(WorktreeTable.strategy),
})
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
remove: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
}
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
const gitStrategy = yield* WorktreeGit.make
yield* register(gitStrategy).pipe(Effect.orDie)
const strategies = () => Array.from(registry.values())
const source = Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
const checked = yield* Effect.forEach(
yield* ops.list(projectID),
(item) =>
canonical(fs, item.directory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
{ concurrency: "unbounded" },
)
const found = checked.find((directory) => directory !== undefined)
if (!found) return yield* new SourceDirectoryNotFoundError({ projectID })
return found
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
while (yield* fs.existsSafe(worktreeDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: worktreeDirectory,
sourceDirectory,
})
yield* changed(
input.projectID,
yield* ops.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
}),
)
return result
})
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
directory: worktreeDirectory,
force: input.force,
})
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
})
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
const stored = yield* ops.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "worktree" ? strategy.id : undefined,
})),
),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
ops.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
},
tx,
),
),
removed: Effect.forEach(removed, (directory) => ops.remove(input.projectID, directory, tx)),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
register,
list: ops.list,
create,
remove,
refresh,
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
})
export const refreshNode = makeLocationNode({
name: "worktree-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
+16
View File
@@ -0,0 +1,16 @@
export * as WorktreeDirectory from "./directory.js"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AbsolutePath } from "../schema.js"
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"Worktree.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
export const canonical = Effect.fnUntraced(function* (fs: FSUtil.Interface, input: AbsolutePath) {
const resolved = AbsolutePath.make(yield* fs.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
+39
View File
@@ -0,0 +1,39 @@
export * as WorktreeGit from "./git.js"
import { Effect } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { canonical, DirectoryUnavailableError } from "./directory.js"
import type { ListEntry, Strategy } from "../worktree.js"
export const make = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
return {
id: Worktree.StrategyID.make("git"),
create: Effect.fn("Worktree.Git.create")(function* (input) {
const repository = yield* git.repo.discover(input.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory })
yield* git.worktree.create({ repository, directory: input.directory })
return { directory: yield* canonical(fs, input.directory) }
}),
remove: Effect.fn("Worktree.Git.remove")(function* (input) {
const repository = yield* git.repo.discover(input.directory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.directory })
yield* git.worktree.remove({ repository, directory: input.directory, force: input.force })
}),
list: Effect.fn("Worktree.Git.list")(function* (directory) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new DirectoryUnavailableError({ directory })
const entries = yield* git.worktree.list(repository)
return yield* Effect.forEach(entries, (entry) =>
canonical(fs, entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
} satisfies Strategy
})
+20
View File
@@ -0,0 +1,20 @@
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { absoluteColumn } from "../database/path.js"
import { ProjectSchema } from "../project/schema.js"
import { ProjectTable } from "../project/sql.js"
export const WorktreeTable = sqliteTable(
"worktree",
{
project_id: text()
.$type<ProjectSchema.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
directory: absoluteColumn().notNull(),
strategy: text(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
)
@@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -127,6 +128,31 @@ describe("DatabaseMigration", () => {
)
})
test("copies project directories into worktrees without removing the old table", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
yield* db.run(
sql`CREATE TABLE project_directory (project_id text NOT NULL, directory text NOT NULL, type text, strategy text, time_created integer NOT NULL, PRIMARY KEY (project_id, directory))`,
)
yield* db.run(
sql`INSERT INTO project_directory (project_id, directory, type, strategy, time_created) VALUES ('project', '/root', 'main', NULL, 1), ('project', '/legacy', 'git_worktree', NULL, 2), ('project', '/strategy', NULL, 'git_worktree', 3), ('project', '/custom', NULL, 'acme/snapshot', 4)`,
)
yield* DatabaseMigration.applyOnly(db, [worktreeMigration])
expect(yield* db.all(sql`SELECT directory, strategy FROM worktree ORDER BY directory`)).toEqual([
{ directory: "/custom", strategy: "acme/snapshot" },
{ directory: "/legacy", strategy: "git" },
{ directory: "/root", strategy: null },
{ directory: "/strategy", strategy: "git" },
])
expect(yield* db.get(sql`SELECT count(*) AS count FROM project_directory`)).toEqual({ count: 4 })
}),
)
})
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "auth.json")
@@ -78,7 +78,6 @@ describe("node build", () => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
-8
View File
@@ -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,
})
-11
View File
@@ -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([]),
}),
)
-1
View File
@@ -13,7 +13,6 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
-25
View File
@@ -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" }])
@@ -1,69 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node])))
const projectID = Project.ID.make("project-directories")
const directory = AbsolutePath.make("/tmp/project-directories")
function setup() {
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie),
)
}
describe("ProjectDirectories", () => {
it.effect("decodes directory schemas", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
}),
)
it.effect("creates once and ignores conflicts", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
expect(yield* service.create({ projectID, directory })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
}),
)
it.effect("returns an empty list for missing projects", () =>
Effect.gen(function* () {
const service = yield* ProjectDirectories.Service
expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([])
}),
)
it.effect("replaces the strategy when requested", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
yield* service.create({ projectID, directory, strategy: "old/strategy" })
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
}),
)
})
+11 -2
View File
@@ -2,11 +2,13 @@ import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/util/hash"
import { tmpdir } from "./fixture/tmpdir"
@@ -365,8 +367,15 @@ describe("Project.resolve", () => {
time: { created: 1, initialized: 2 },
})
expect(
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }])
(yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, id))
.all()
.pipe(Effect.orDie))
.map((item) => ({ directory: item.directory, strategy: item.strategy ?? undefined }))
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
).toEqual([{ directory: yield* real(tmp.path), strategy: undefined }, { directory: yield* real(worktree), strategy: "git" }])
}),
)
})
+8 -2
View File
@@ -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,13 @@ 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 }),
}),
)
let requests: LLMRequest[] = []
const client = Layer.mock(LLMClient.Service)({
stream: (request: LLMRequest) => {
@@ -66,7 +72,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],
],
),
+9 -3
View File
@@ -29,9 +29,15 @@ 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 }),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@@ -44,7 +50,7 @@ const it = testEffect(
]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
@@ -137,7 +143,7 @@ describe("Session.create", () => {
.where(eq(EventTable.aggregate_id, project.id))
.all()
.pipe(Effect.orDie)
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
expect(announced.map((event) => event.type)).toEqual(["worktree.resolved.1"])
}),
),
)
@@ -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,24 @@ 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 }),
}),
)
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 +91,7 @@ const testLayer = AppNodeBuilder.build(
Image.node,
]),
[
[Project.node, globalProjectLayer],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
[Location.node, tempLocationLayer],
[Permission.node, permission],
+9 -3
View File
@@ -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,20 @@ 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 }),
}),
)
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],
],
),
+11 -5
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Event } from "@opencode-ai/schema/project-directories"
import { Effect, Layer } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -16,13 +16,19 @@ 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 }),
}),
)
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],
],
),
@@ -92,7 +98,7 @@ describe("Session.move", () => {
projectID: Project.ID.global,
})
// The former directory becomes a project after the session left it.
yield* bus.publish(Event.Resolved, {
yield* bus.publish(Worktree.Event.Resolved, {
projectID: Project.ID.make("adopting"),
directory: previous,
previous: Project.ID.global,
+9 -3
View File
@@ -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,19 @@ 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 }),
}),
)
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" },
+11 -2
View File
@@ -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 })),
+9 -7
View File
@@ -6,7 +6,7 @@ import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Project } from "@opencode-ai/schema/project"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { Worktree } from "@opencode-ai/schema/worktree"
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
@@ -39,7 +39,7 @@ test("Core reuses the canonical shared schemas", async () => {
coreModel,
corePermission,
corePermissionV1,
coreProjectCopy,
coreWorktree,
corePty,
coreProject,
coreProvider,
@@ -60,7 +60,7 @@ test("Core reuses the canonical shared schemas", async () => {
import("@opencode-ai/core/model"),
import("@opencode-ai/core/permission"),
import("@opencode-ai/core/v1/permission"),
import("@opencode-ai/core/project/copy"),
import("@opencode-ai/core/worktree"),
import("@opencode-ai/core/pty"),
import("@opencode-ai/core/project/schema"),
import("@opencode-ai/core/provider"),
@@ -112,14 +112,16 @@ test("Core reuses the canonical shared schemas", async () => {
[corePermission.Rule, Permission.Rule],
[corePermission.Ruleset, Permission.Ruleset],
[corePermissionV1.Event, PermissionV1.Event],
[coreProjectCopy.Event, ProjectDirectories.Event],
[coreWorktree.CreateInput, Worktree.CreateInput],
[coreWorktree.RemoveInput, Worktree.RemoveInput],
[coreWorktree.Info, Worktree.Info],
[coreWorktree.ListInput, Worktree.ListInput],
[coreWorktree.List, Worktree.List],
[coreWorktree.Event, Worktree.Event],
[corePty.Info, Pty.Info],
[corePty.Event, Pty.Event],
[coreProject.ID, Project.ID],
[coreProject.Current, Project.Current],
[coreProject.Directory, Project.Directory],
[coreProject.DirectoriesInput, Project.DirectoriesInput],
[coreProject.Directories, Project.Directories],
[coreReference.LocalSource, Reference.LocalSource],
[coreReference.GitSource, Reference.GitSource],
[coreReference.Source, Reference.Source],
+23 -16
View File
@@ -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),
+27 -20
View File
@@ -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),
+23 -16
View File
@@ -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({
+25 -18
View File
@@ -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(
+14 -7
View File
@@ -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"),
}),
),
],
]),
),
+24 -44
View File
@@ -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",
() =>
+23 -16
View File
@@ -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({
+11 -2
View File
@@ -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],

Some files were not shown because too many files have changed in this diff Show More