mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-25 14:55:28 +00:00
fix(app): narrow desktop v2 beta changes
This commit is contained in:
parent
2a5a1d8ec0
commit
834ce320b7
17 changed files with 187 additions and 559 deletions
|
|
@ -31,7 +31,9 @@ import {
|
|||
FileAttachmentPart,
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
|
|
@ -71,10 +73,10 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
|||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { createQuery, useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions, useServerSync } from "@/context/server-sync"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
|
@ -121,11 +123,9 @@ const EXAMPLES = [
|
|||
"prompt.example.25",
|
||||
] as const
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const navigate = useNavigate()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
const sync = useSync()
|
||||
|
|
@ -133,6 +133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const files = useFile()
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const server = useServer()
|
||||
const comments = useComments()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
|
|
@ -141,14 +142,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
let projectSearchRef: HTMLInputElement | undefined
|
||||
let branchSearchRef: HTMLInputElement | undefined
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 56
|
||||
|
|
@ -290,13 +289,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
applyingHistory: false,
|
||||
})
|
||||
const [picker, setPicker] = createStore({
|
||||
projectDirectory: undefined as string | undefined,
|
||||
sessionDirectory: undefined as string | undefined,
|
||||
worktreeName: undefined as string | undefined,
|
||||
projectOpen: false,
|
||||
branchOpen: false,
|
||||
projectSearch: "",
|
||||
branchSearch: "",
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
|
|
@ -1131,9 +1125,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionProjectDirectory: USE_V2_INPUT ? newSessionProjectDirectory : undefined,
|
||||
newSessionWorktree: USE_V2_INPUT ? newSessionWorktree : () => props.newSessionWorktree,
|
||||
newSessionWorktreeBranch: USE_V2_INPUT ? () => picker.worktreeName : undefined,
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
|
|
@ -1353,153 +1345,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
)
|
||||
}
|
||||
const inheritedProject = createMemo(() => projectForDirectory(sdk.directory) ?? projects()[0])
|
||||
const selectedProject = createMemo(() => {
|
||||
const selected = picker.projectDirectory
|
||||
if (selected) return projects().find((project) => pathKey(project.worktree) === pathKey(selected))
|
||||
return inheritedProject()
|
||||
})
|
||||
const selectedProjectStore = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return
|
||||
return serverSync.child(project.worktree, { bootstrap: false })[0]
|
||||
})
|
||||
const workspaceQuery = createQuery(() => {
|
||||
const project = selectedProject()
|
||||
return {
|
||||
queryKey: ["prompt-input", "workspaces", project?.worktree ?? ""] as const,
|
||||
enabled: newSession() && picker.branchOpen && project?.vcs === "git",
|
||||
staleTime: 10_000,
|
||||
queryFn: async () => {
|
||||
if (!project) return []
|
||||
await sdk.client.experimental.workspace.syncList({ directory: project.worktree })
|
||||
return sdk.client.experimental.workspace
|
||||
.list({ directory: project.worktree })
|
||||
.then((x) => (x.data ?? []).filter((workspace) => workspace.type === "worktree" && workspace.directory))
|
||||
},
|
||||
}
|
||||
})
|
||||
const workspaceByDirectory = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(workspaceQuery.isSuccess ? workspaceQuery.data : []).flatMap((workspace) =>
|
||||
workspace.directory ? [[pathKey(workspace.directory), workspace] as const] : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const branchQuery = createQuery(() => {
|
||||
const project = selectedProject()
|
||||
return {
|
||||
queryKey: ["prompt-input", "branches", project?.worktree ?? ""] as const,
|
||||
enabled: newSession() && picker.branchOpen && project?.vcs === "git",
|
||||
staleTime: 10_000,
|
||||
queryFn: async () => {
|
||||
if (!project) return []
|
||||
return sdk.client.worktree.branches({ directory: project.worktree }).then((x) => x.data ?? [])
|
||||
},
|
||||
}
|
||||
})
|
||||
const rootBranch = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return "main"
|
||||
const store = selectedProjectStore()
|
||||
if (store?.vcs?.default_branch) return store.vcs.default_branch
|
||||
if (pathKey(project.worktree) === pathKey(sdk.directory)) return sync.data.vcs?.default_branch ?? sync.data.vcs?.branch ?? "main"
|
||||
return "main"
|
||||
})
|
||||
const branchOptions = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project || project.vcs !== "git") return []
|
||||
const existing = new Set<string>()
|
||||
const options: Array<{ directory: string; branch: string; create?: boolean }> = [
|
||||
{ directory: project.worktree, branch: rootBranch() },
|
||||
...(project.sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
branch:
|
||||
workspaceByDirectory().get(pathKey(directory))?.branch ??
|
||||
(pathKey(directory) === pathKey(sdk.directory) ? sync.data.vcs?.branch : undefined) ??
|
||||
getFilename(directory),
|
||||
})),
|
||||
]
|
||||
for (const option of options) existing.add(option.branch)
|
||||
return [
|
||||
...options,
|
||||
...(branchQuery.isSuccess ? branchQuery.data : [])
|
||||
.filter((branch) => !existing.has(branch))
|
||||
.map((branch) => ({ directory: CREATE_WORKTREE, branch, create: true })),
|
||||
]
|
||||
})
|
||||
const inheritedSessionDirectory = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return
|
||||
if (pathKey(project.worktree) === pathKey(projectForDirectory(sdk.directory)?.worktree ?? "")) return sdk.directory
|
||||
return project.worktree
|
||||
})
|
||||
const selectedSessionDirectory = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return props.newSessionWorktree ?? MAIN_WORKTREE
|
||||
if (picker.sessionDirectory === CREATE_WORKTREE) return CREATE_WORKTREE
|
||||
const sessionDirectory = picker.sessionDirectory
|
||||
if (sessionDirectory && branchOptions().some((option) => pathKey(option.directory) === pathKey(sessionDirectory))) {
|
||||
return sessionDirectory
|
||||
}
|
||||
return inheritedSessionDirectory() ?? project.worktree
|
||||
})
|
||||
const currentBranch = createMemo(() => {
|
||||
if (selectedSessionDirectory() === CREATE_WORKTREE) {
|
||||
return {
|
||||
directory: CREATE_WORKTREE,
|
||||
branch: picker.worktreeName ?? language.t("session.new.branch.new"),
|
||||
}
|
||||
}
|
||||
const selected = selectedSessionDirectory()
|
||||
return branchOptions().find((option) => pathKey(option.directory) === pathKey(selected)) ?? branchOptions()[0]
|
||||
})
|
||||
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
|
||||
const projectResults = createMemo(() => {
|
||||
const search = picker.projectSearch.trim().toLowerCase()
|
||||
if (!search) return projects()
|
||||
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
||||
})
|
||||
const branchResults = createMemo(() => {
|
||||
const search = picker.branchSearch.trim().toLowerCase()
|
||||
if (!search) return branchOptions()
|
||||
return branchOptions().filter((option) => option.branch.toLowerCase().includes(search))
|
||||
})
|
||||
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
|
||||
const branchActionLabel = createMemo(() => {
|
||||
const search = picker.branchSearch.trim()
|
||||
if (search && branchResults().length === 0) return language.t("session.new.branch.add", { branch: search })
|
||||
return language.t("session.new.branch.new")
|
||||
})
|
||||
function newSessionProjectDirectory() {
|
||||
return selectedProject()?.worktree
|
||||
}
|
||||
function newSessionWorktree() {
|
||||
const project = selectedProject()
|
||||
if (!project) return props.newSessionWorktree
|
||||
const selected = selectedSessionDirectory()
|
||||
if (selected === CREATE_WORKTREE) return CREATE_WORKTREE
|
||||
if (selected === project.worktree) return MAIN_WORKTREE
|
||||
return selected
|
||||
}
|
||||
const selectProject = (worktree: string) => {
|
||||
setPicker({
|
||||
projectDirectory: worktree,
|
||||
sessionDirectory: undefined,
|
||||
worktreeName: undefined,
|
||||
projectOpen: false,
|
||||
projectSearch: "",
|
||||
})
|
||||
restoreFocus()
|
||||
}
|
||||
const selectBranch = (directory: string, name?: string) => {
|
||||
setPicker({
|
||||
sessionDirectory: directory,
|
||||
worktreeName: name,
|
||||
branchOpen: false,
|
||||
branchSearch: "",
|
||||
})
|
||||
restoreFocus()
|
||||
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
|
||||
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
||||
|
|
@ -1537,39 +1401,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
onSearchClear: () => setPicker("projectSearch", ""),
|
||||
searchRef: (el) => (projectSearchRef = el),
|
||||
}))
|
||||
const branchPickerState = createMemo<ComposerPickerState>(() => ({
|
||||
open: picker.branchOpen,
|
||||
trigger: {
|
||||
action: "prompt-branch",
|
||||
icon: "branch",
|
||||
label: currentBranch()?.branch ?? language.t("session.new.branch.new"),
|
||||
class: "max-w-[160px]",
|
||||
style: control(),
|
||||
onPress: () => setPicker("branchOpen", true),
|
||||
},
|
||||
search: picker.branchSearch,
|
||||
searchPlaceholder: language.t("session.new.branch.search"),
|
||||
clearLabel: language.t("common.clear"),
|
||||
items: branchResults().map((branch) => ({
|
||||
icon: "branch",
|
||||
label: branch.branch,
|
||||
selected: currentBranch()?.directory === branch.directory && currentBranch()?.branch === branch.branch,
|
||||
onSelect: () => selectBranch(branch.directory, branch.create ? branch.branch : undefined),
|
||||
})),
|
||||
action: {
|
||||
icon: "plus",
|
||||
label: branchActionLabel(),
|
||||
onSelect: () => selectBranch(CREATE_WORKTREE, picker.branchSearch.trim() || undefined),
|
||||
},
|
||||
onOpenChange: (open) => {
|
||||
setPicker("branchOpen", open)
|
||||
if (open) requestAnimationFrame(() => branchSearchRef?.focus())
|
||||
},
|
||||
onSearchInput: (value) => setPicker("branchSearch", value),
|
||||
onSearchClear: () => setPicker("branchSearch", ""),
|
||||
searchRef: (el) => (branchSearchRef = el),
|
||||
listClass: "max-h-[200px] overflow-y-auto",
|
||||
}))
|
||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
|
|
@ -1746,9 +1577,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
<Show when={newSession() && selectedProject()}>
|
||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
||||
<ComposerPicker state={projectPickerState()} />
|
||||
<Show when={branchOptions().length > 0}>
|
||||
<ComposerPicker state={branchPickerState()} />
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -1795,7 +1623,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest('[data-action^="prompt-"]')) {
|
||||
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||
return
|
||||
}
|
||||
editorRef?.focus()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
|||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
const createdWorktrees: Array<{ directory: string; input: unknown }> = []
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
|
|
@ -51,10 +50,7 @@ const clientFor = (directory: string) => {
|
|||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async (input: unknown) => {
|
||||
createdWorktrees.push({ directory, input })
|
||||
return { data: { directory: `${directory}/new` } }
|
||||
},
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +211,6 @@ beforeEach(() => {
|
|||
params = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
createdWorktrees.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
|
|
@ -286,93 +281,6 @@ describe("prompt submit worktree selection", () => {
|
|||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("creates new sessions in the selected project", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionProjectDirectory: () => "/repo/other",
|
||||
newSessionWorktree: () => "main",
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(createdClients).toEqual(["/repo/other"])
|
||||
expect(createdSessions).toEqual(["/repo/other"])
|
||||
expect(sentShell).toEqual(["/repo/other"])
|
||||
expect(promoted).toEqual([{ directory: "/repo/other", sessionID: "session-1" }])
|
||||
})
|
||||
|
||||
test("passes selected branch when creating a worktree", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionProjectDirectory: () => "/repo/main",
|
||||
newSessionWorktree: () => "create",
|
||||
newSessionWorktreeBranch: () => "figma-plugin",
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(createdWorktrees).toEqual([
|
||||
{ directory: "/repo/main", input: { directory: "/repo/main", worktreeCreateInput: { branch: "figma-plugin" } } },
|
||||
])
|
||||
expect(createdSessions).toEqual(["/repo/main/new"])
|
||||
})
|
||||
|
||||
test("ignores new-session project selection for active sessions", async () => {
|
||||
params = { id: "session-1" }
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionProjectDirectory: () => "/repo/other",
|
||||
newSessionWorktree: () => "main",
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(createdClients).toEqual([])
|
||||
expect(sentShell).toEqual(["/repo/main"])
|
||||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
|
|
|||
|
|
@ -184,9 +184,7 @@ type PromptSubmitInput = {
|
|||
resetHistoryNavigation: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
setPopover: (popover: "at" | "slash" | null) => void
|
||||
newSessionProjectDirectory?: Accessor<string | undefined>
|
||||
newSessionWorktree?: Accessor<string | undefined>
|
||||
newSessionWorktreeBranch?: Accessor<string | undefined>
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
shouldQueue?: Accessor<boolean>
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
|
|
@ -315,25 +313,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const isNewSession = !params.id
|
||||
const projectDirectory = isNewSession ? (input.newSessionProjectDirectory?.() ?? sdk.directory) : sdk.directory
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client =
|
||||
projectDirectory === sdk.directory
|
||||
? sdk.client
|
||||
: sdk.createClient({
|
||||
directory: projectDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
let client = sdk.client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const branch = input.newSessionWorktreeBranch?.()?.trim()
|
||||
const createdWorktree = await client.worktree
|
||||
.create(branch ? { directory: projectDirectory, worktreeCreateInput: { branch } } : { directory: projectDirectory })
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
|
@ -358,8 +349,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (projectDirectory !== sdk.directory) serverSync.child(projectDirectory)
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk.createClient({
|
||||
directory: sessionDirectory,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
|||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
|
||||
|
|
@ -535,7 +535,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
|||
<div class="flex items-center gap-0">
|
||||
<Show when={props.state.statusVisible}>
|
||||
<Tooltip placement="bottom" value={props.state.statusLabel}>
|
||||
<StatusPopover variant="v2" />
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
|
|
|
|||
|
|
@ -444,18 +444,6 @@ export const SettingsGeneral: Component = () => {
|
|||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showTerminal.title")}
|
||||
description={language.t("settings.general.row.showTerminal.description")}
|
||||
>
|
||||
<div data-action="settings-show-terminal">
|
||||
<Switch
|
||||
checked={settings.general.showTerminal()}
|
||||
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
|
|
|
|||
|
|
@ -363,22 +363,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const serverItems = createMemo(() =>
|
||||
sortedServers().map((conn) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
return {
|
||||
key,
|
||||
conn,
|
||||
health: health[key],
|
||||
blocked: health[key]?.healthy === false,
|
||||
active: !!server.current && key === ServerConnection.key(server.current),
|
||||
onSelect: () => {
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
|
|
@ -389,21 +373,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
const serverState: ServerStatusState = {
|
||||
servers: serverItems,
|
||||
defaultKey: defaultServer.key,
|
||||
ariaLabel: language.t("status.popover.ariaLabel"),
|
||||
serversLabel: language.t("status.popover.tab.servers"),
|
||||
defaultLabel: language.t("common.default"),
|
||||
manageLabel: language.t("status.popover.action.manageServers"),
|
||||
onManage: () => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
|
|
@ -435,7 +404,68 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="servers">
|
||||
<ServerStatusList state={serverState} />
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const blocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !blocked(),
|
||||
"cursor-not-allowed": blocked(),
|
||||
}}
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={blocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
|
|
|
|||
|
|
@ -8,15 +8,76 @@ import { useLanguage } from "@/context/language"
|
|||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
const DirectoryBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||
|
||||
export function StatusPopover(props: { variant?: "v2"; scope?: "server" }) {
|
||||
if (props.scope === "server") return <ServerStatusPopover variant={props.variant} />
|
||||
return <DirectoryStatusPopover variant={props.variant} />
|
||||
export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
if (warn) return "warning" as const
|
||||
})
|
||||
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class: "titlebar-icon w-8 h-6 p-0 box-border",
|
||||
"aria-label": language.t("status.popover.trigger"),
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<div class="badge-mask-tight size-4 flex items-center justify-center">
|
||||
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
|
||||
"bg-icon-critical-base":
|
||||
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
|
||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
shift={-168}
|
||||
>
|
||||
<Show when={shown()}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
>
|
||||
<Body shown={shown} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectoryStatusPopover(props: { variant?: "v2" }) {
|
||||
export function StatusPopoverV2(props: { scope?: "server" }) {
|
||||
if (props.scope === "server") return <ServerStatusPopover />
|
||||
return <DirectoryStatusPopover />
|
||||
}
|
||||
|
||||
function DirectoryStatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
|
|
@ -31,7 +92,6 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) {
|
|||
})
|
||||
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
variant: props.variant,
|
||||
shown: shown(),
|
||||
ready: ready(),
|
||||
healthy: healthy(),
|
||||
|
|
@ -41,7 +101,7 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) {
|
|||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<DirectoryBody shown={shown} />
|
||||
<Body shown={shown} />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
|
@ -49,12 +109,11 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) {
|
|||
return <StatusPopoverView state={state()} />
|
||||
}
|
||||
|
||||
function ServerStatusPopover(props: { variant?: "v2" }) {
|
||||
function ServerStatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
variant: props.variant,
|
||||
shown: shown(),
|
||||
ready: server.healthy() !== undefined,
|
||||
healthy: server.healthy() === true,
|
||||
|
|
@ -72,7 +131,6 @@ function ServerStatusPopover(props: { variant?: "v2" }) {
|
|||
}
|
||||
|
||||
type StatusPopoverState = {
|
||||
variant?: "v2"
|
||||
shown: boolean
|
||||
ready: boolean
|
||||
healthy: boolean
|
||||
|
|
@ -113,49 +171,22 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
|||
shift: -168,
|
||||
}
|
||||
|
||||
if (props.state.variant === "v2") {
|
||||
return (
|
||||
<Popover
|
||||
open={props.state.shown}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
triggerAs={IconButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||
<div classList={statusDotClass()} class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]" />
|
||||
</div>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
{props.state.body()}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={props.state.shown}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
triggerAs={Button}
|
||||
triggerAs={IconButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class: "titlebar-icon w-8 h-6 p-0 box-border",
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<div class="badge-mask-tight size-4 flex items-center justify-center">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} size="small" />
|
||||
</div>
|
||||
<div classList={statusDotClass()} class="-top-px -right-px size-1.5" />
|
||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||
<div classList={statusDotClass()} class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]" />
|
||||
</div>
|
||||
}
|
||||
{...popoverProps}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { StatusPopover } from "@/components/status-popover"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
|
|
@ -668,7 +668,7 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
|
|||
<TitlebarUpdatePill state={props.state.update} />
|
||||
<Show when={props.state.statusVisible}>
|
||||
<Tooltip placement="bottom" value={props.state.statusLabel}>
|
||||
<StatusPopover variant="v2" scope="server" />
|
||||
<StatusPopoverV2 scope="server" />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
|
||||
|
|
|
|||
|
|
@ -589,9 +589,6 @@ export const dict = {
|
|||
"session.new.project.new": "New project",
|
||||
"session.new.project.search": "Search projects",
|
||||
"session.new.project.add": "Add project",
|
||||
"session.new.branch.search": "Search branches",
|
||||
"session.new.branch.new": "New branch",
|
||||
"session.new.branch.add": "Add branch: {{branch}}",
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
|
|
@ -772,7 +769,7 @@ export const dict = {
|
|||
"settings.general.row.followup.option.queue": "Queue",
|
||||
"settings.general.row.followup.option.steer": "Steer",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
|
|
|
|||
|
|
@ -70,10 +70,11 @@ function HomeDesign() {
|
|||
const selectedProject = createMemo(
|
||||
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
|
||||
)
|
||||
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return []
|
||||
return [project.worktree, ...(project.sandboxes ?? [])]
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const sessionLoad = useQuery(() => ({
|
||||
|
|
@ -148,6 +149,14 @@ function HomeDesign() {
|
|||
})
|
||||
}
|
||||
|
||||
const unseenCount = (project: LocalProject) =>
|
||||
directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
||||
|
||||
const clearNotifications = (project: LocalProject) =>
|
||||
directories(project)
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
|
||||
function openSession(session: Session) {
|
||||
const project = projectForSession(session, projects(), projectByID())
|
||||
layout.projects.open(project?.worktree ?? session.directory)
|
||||
|
|
@ -199,8 +208,8 @@ function HomeDesign() {
|
|||
layout.projects.close(directory)
|
||||
if (state.project === directory) setState("project", undefined)
|
||||
}}
|
||||
clearNotifications={notification.project.markViewed}
|
||||
unseenCount={notification.project.unseenCount}
|
||||
clearNotifications={clearNotifications}
|
||||
unseenCount={unseenCount}
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
|
|
@ -277,8 +286,8 @@ function HomeProjectColumn(props: {
|
|||
chooseProject: () => void
|
||||
editProject: (project: LocalProject) => void
|
||||
closeProject: (directory: string) => void
|
||||
clearNotifications: (directory: string) => void
|
||||
unseenCount: (directory: string) => number
|
||||
clearNotifications: (project: LocalProject) => void
|
||||
unseenCount: (project: LocalProject) => number
|
||||
openSettings: () => void
|
||||
openHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
|
|
@ -316,7 +325,7 @@ function HomeProjectColumn(props: {
|
|||
<HomeProjectRow
|
||||
project={project}
|
||||
selected={props.selected === project.worktree}
|
||||
unseenCount={props.unseenCount(project.worktree)}
|
||||
unseenCount={props.unseenCount(project)}
|
||||
selectProject={props.selectProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
|
|
@ -358,7 +367,7 @@ function HomeProjectRow(props: {
|
|||
openNewSession: (directory: string) => void
|
||||
editProject: (project: LocalProject) => void
|
||||
closeProject: (directory: string) => void
|
||||
clearNotifications: (directory: string) => void
|
||||
clearNotifications: (project: LocalProject) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
|
|
@ -409,7 +418,7 @@ function HomeProjectRow(props: {
|
|||
</MenuV2.Item>
|
||||
<MenuV2.Item
|
||||
disabled={props.unseenCount === 0}
|
||||
onSelect={() => props.clearNotifications(props.project.worktree)}
|
||||
onSelect={() => props.clearNotifications(props.project)}
|
||||
>
|
||||
<Icon name="circle-check" size="small" />
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ export const ToolListQuery = Schema.Struct({
|
|||
})
|
||||
|
||||
const WorktreeList = Schema.Array(Schema.String)
|
||||
const WorktreeBranchList = Schema.Array(Schema.String)
|
||||
const WorktreeErrorName = Schema.Union([
|
||||
Schema.Literal("WorktreeNotGitError"),
|
||||
Schema.Literal("WorktreeNameGenerationFailedError"),
|
||||
|
|
@ -88,7 +87,6 @@ export const ExperimentalPaths = {
|
|||
tool: "/experimental/tool",
|
||||
toolIDs: "/experimental/tool/ids",
|
||||
worktree: "/experimental/worktree",
|
||||
worktreeBranch: "/experimental/worktree/branch",
|
||||
worktreeReset: "/experimental/worktree/reset",
|
||||
session: "/experimental/session",
|
||||
resource: "/experimental/resource",
|
||||
|
|
@ -167,17 +165,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||
description: "List all sandbox worktrees for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("worktreeBranch", ExperimentalPaths.worktreeBranch, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(WorktreeBranchList, "List of git branch names"),
|
||||
error: WorktreeApiError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.branches",
|
||||
summary: "List git branches",
|
||||
description: "List all local git branches for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||
disableCodecs: true,
|
||||
query: WorkspaceRoutingQuery,
|
||||
|
|
|
|||
|
|
@ -103,10 +103,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
|||
return yield* project.sandboxes(ctx.project.id)
|
||||
})
|
||||
|
||||
const worktreeBranch = Effect.fn("ExperimentalHttpApi.worktreeBranch")(function* () {
|
||||
return yield* mapWorktreeError(worktreeSvc.branches())
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
|
||||
payload: Worktree.CreateInput | undefined
|
||||
}) {
|
||||
|
|
@ -162,7 +158,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
|||
.handle("tool", tool)
|
||||
.handle("toolIDs", toolIDs)
|
||||
.handle("worktree", worktree)
|
||||
.handle("worktreeBranch", worktreeBranch)
|
||||
.handle("worktreeCreate", worktreeCreate)
|
||||
.handle("worktreeRemove", worktreeRemove)
|
||||
.handle("worktreeReset", worktreeReset)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export type Info = Schema.Schema.Type<typeof Info>
|
|||
|
||||
export const CreateInput = Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
branch: Schema.optional(Schema.String),
|
||||
startCommand: Schema.optional(
|
||||
Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }),
|
||||
),
|
||||
|
|
@ -135,10 +134,9 @@ function failedRemoves(...chunks: string[]) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
readonly makeWorktreeInfo: (options?: { name?: string; branch?: string; detached?: boolean }) => Effect.Effect<Info, Error>
|
||||
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info, Error>
|
||||
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void, Error>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly branches: () => Effect.Effect<string[], Error>
|
||||
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[], Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<boolean, Error>
|
||||
readonly reset: (input: ResetInput) => Effect.Effect<boolean, Error>
|
||||
|
|
@ -187,18 +185,17 @@ export const layer: Layer.Layer<
|
|||
const candidate = Effect.fn("Worktree.candidate")(function* (input: {
|
||||
root: string
|
||||
name?: string
|
||||
branch?: string
|
||||
detached?: boolean
|
||||
}) {
|
||||
const ctx = yield* InstanceState.context
|
||||
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
||||
const name = input.name ? (attempt === 0 ? input.name : `${input.name}-${Slug.create()}`) : Slug.create()
|
||||
const branch = input.branch ?? (input.detached ? undefined : `opencode/${name}`)
|
||||
const branch = input.detached ? undefined : `opencode/${name}`
|
||||
const directory = pathSvc.join(input.root, name)
|
||||
|
||||
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
||||
|
||||
if (branch && !input.branch) {
|
||||
if (branch) {
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
||||
if (branchCheck.code === 0) continue
|
||||
|
|
@ -211,7 +208,6 @@ export const layer: Layer.Layer<
|
|||
|
||||
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: {
|
||||
name?: string
|
||||
branch?: string
|
||||
detached?: boolean
|
||||
}) {
|
||||
const ctx = yield* InstanceState.context
|
||||
|
|
@ -222,24 +218,14 @@ export const layer: Layer.Layer<
|
|||
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
|
||||
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
|
||||
return yield* candidate({
|
||||
root,
|
||||
name: input?.name ? slugify(input.name) : input?.branch ? slugify(input.branch) : "",
|
||||
branch: input?.branch?.trim() || undefined,
|
||||
detached: input?.detached,
|
||||
})
|
||||
return yield* candidate({ root, name: input?.name ? slugify(input.name) : "", detached: input?.detached })
|
||||
})
|
||||
|
||||
const setup = Effect.fnUntraced(function* (info: Info) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const branchExists = info.branch
|
||||
? (yield* git(["show-ref", "--verify", "--quiet", `refs/heads/${info.branch}`], { cwd: ctx.worktree })).code === 0
|
||||
: false
|
||||
const created = yield* git(
|
||||
info.branch
|
||||
? branchExists
|
||||
? ["worktree", "add", "--no-checkout", info.directory, info.branch]
|
||||
: ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory]
|
||||
? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory]
|
||||
: ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"],
|
||||
{ cwd: ctx.worktree },
|
||||
)
|
||||
|
|
@ -311,27 +297,11 @@ export const layer: Layer.Layer<
|
|||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
||||
const info = yield* makeWorktreeInfo({ name: input?.name, branch: input?.branch })
|
||||
const info = yield* makeWorktreeInfo({ name: input?.name })
|
||||
yield* createFromInfo(info, input?.startCommand)
|
||||
return info
|
||||
})
|
||||
|
||||
const branches = Effect.fn("Worktree.branches")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") return []
|
||||
|
||||
const result = yield* git(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd: ctx.worktree })
|
||||
if (result.code !== 0) {
|
||||
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git branches" })
|
||||
}
|
||||
|
||||
return result.text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
})
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: string) {
|
||||
const abs = pathSvc.resolve(input)
|
||||
const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||
|
|
@ -633,7 +603,7 @@ export const layer: Layer.Layer<
|
|||
return true
|
||||
})
|
||||
|
||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, branches, list, remove, reset })
|
||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,19 +110,6 @@ describe("Worktree", () => {
|
|||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"uses requested branch as branch and directory base",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ branch: "feat/new-sidebar" })
|
||||
|
||||
expect(info.name).toBe("feat-new-sidebar")
|
||||
expect(info.branch).toBe("feat/new-sidebar")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"slugifies the provided name",
|
||||
() =>
|
||||
|
|
@ -194,20 +181,6 @@ describe("Worktree", () => {
|
|||
})
|
||||
|
||||
describe("create + remove lifecycle", () => {
|
||||
it.instance(
|
||||
"lists local branches",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const svc = yield* Worktree.Service
|
||||
|
||||
yield* git(test.directory, ["branch", "feat/new-sidebar"])
|
||||
|
||||
expect(yield* svc.branches()).toEqual(expect.arrayContaining(["feat/new-sidebar"]))
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"create returns worktree info and remove cleans up",
|
||||
() =>
|
||||
|
|
@ -241,21 +214,6 @@ describe("Worktree", () => {
|
|||
{ git: true },
|
||||
)
|
||||
|
||||
wintest(
|
||||
"create uses requested branch",
|
||||
() =>
|
||||
withCreatedWorktree({ branch: "figma-plugin" }, ({ info, ready }) =>
|
||||
Effect.gen(function* () {
|
||||
expect(info.name).toBe("figma-plugin")
|
||||
expect(info.branch).toBe("figma-plugin")
|
||||
expect(ready.branch).toBe("figma-plugin")
|
||||
|
||||
expect((yield* git(info.directory, ["symbolic-ref", "--short", "HEAD"])).trim()).toBe("figma-plugin")
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"create with custom name",
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -463,7 +463,6 @@ const scenarios: Scenario[] = [
|
|||
.json(200, array, "status"),
|
||||
http.protected.get("/experimental/tool/ids", "tool.ids").json(200, array),
|
||||
http.protected.get("/experimental/worktree", "worktree.list").json(200, array),
|
||||
http.protected.get("/experimental/worktree/branch", "worktree.branches").json(200, array),
|
||||
http.protected
|
||||
.post("/experimental/worktree", "worktree.create")
|
||||
.mutating()
|
||||
|
|
|
|||
|
|
@ -274,8 +274,6 @@ import type {
|
|||
VcsGetResponses,
|
||||
VcsStatusErrors,
|
||||
VcsStatusResponses,
|
||||
WorktreeBranchesErrors,
|
||||
WorktreeBranchesResponses,
|
||||
WorktreeCreateErrors,
|
||||
WorktreeCreateInput,
|
||||
WorktreeCreateResponses,
|
||||
|
|
@ -1390,36 +1388,6 @@ export class Worktree extends HeyApiClient {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List git branches
|
||||
*
|
||||
* List all local git branches for the current project.
|
||||
*/
|
||||
public branches<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<WorktreeBranchesResponses, WorktreeBranchesErrors, ThrowOnError>({
|
||||
url: "/experimental/worktree/branch",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset worktree
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1448,7 +1448,6 @@ export type WorktreeError = {
|
|||
|
||||
export type WorktreeCreateInput = {
|
||||
name?: string
|
||||
branch?: string
|
||||
/**
|
||||
* Additional startup script to run after the project's start command
|
||||
*/
|
||||
|
|
@ -4434,34 +4433,6 @@ export type WorktreeCreateResponses = {
|
|||
|
||||
export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
|
||||
|
||||
export type WorktreeBranchesData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/experimental/worktree/branch"
|
||||
}
|
||||
|
||||
export type WorktreeBranchesErrors = {
|
||||
/**
|
||||
* WorktreeError | InvalidRequestError
|
||||
*/
|
||||
400: WorktreeError | InvalidRequestError
|
||||
}
|
||||
|
||||
export type WorktreeBranchesError = WorktreeBranchesErrors[keyof WorktreeBranchesErrors]
|
||||
|
||||
export type WorktreeBranchesResponses = {
|
||||
/**
|
||||
* List of git branch names
|
||||
*/
|
||||
200: Array<string>
|
||||
}
|
||||
|
||||
export type WorktreeBranchesResponse = WorktreeBranchesResponses[keyof WorktreeBranchesResponses]
|
||||
|
||||
export type WorktreeResetData = {
|
||||
body?: WorktreeResetInput
|
||||
path?: never
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue