mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): pick new session location from a project and worktree dialog
Replace the new-session project menu and nested worktree submenu with one palette-style dialog: projects first, → lists a Git project's worktrees. The location row under the composer shows only the project and its path, left-aligned so changing it does not shift the layout.
This commit is contained in:
@@ -2,7 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
|
||||
import { draftHref, expectPath, provider, sessionHref } from "../utils/app"
|
||||
import type { MockAnswer } from "../utils/mock-server"
|
||||
import { openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace"
|
||||
import { newSessionLocation, openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace"
|
||||
|
||||
const directory = "C:/OpenCode/WorkspacePending"
|
||||
|
||||
@@ -281,7 +281,7 @@ for (const row of [
|
||||
|
||||
if (row.followUp) return
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(newSessionLocation(page)).toContainText("New worktree")
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
await expect(tabLink(page, pending.sessionID)).toHaveCount(0)
|
||||
expect(mock.creates).toEqual([])
|
||||
@@ -298,7 +298,7 @@ test("retains the draft and reuses the created workspace after session creation
|
||||
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
|
||||
await expect(editor(page)).toHaveText(text)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible()
|
||||
await expect(newSessionLocation(page)).toContainText("pending-workspace")
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
@@ -346,7 +346,7 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(editor(page)).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(newSessionLocation(page)).toContainText("New worktree")
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${draftHref(draftID)}"]`)).toHaveCount(1)
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
|
||||
@@ -87,6 +87,11 @@ export async function openDraft(page: Page, input: WorkspaceInput & { draftID?:
|
||||
// Opens a draft on the local project with "New worktree" selected. Worktree creation stays pending until the test
|
||||
// resolves `worktree`; POST order (`worktree`, `session`, `prompt`), worktree requests, session creates and prompts are
|
||||
// recorded. The draft tab comes first, then a tab per session.
|
||||
// The new-session trigger that shows the draft's project and worktree and opens the location dialog.
|
||||
export function newSessionLocation(page: Page) {
|
||||
return page.locator('[data-component="new-session"] [data-action="prompt-project"]')
|
||||
}
|
||||
|
||||
export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: unknown }>()
|
||||
const calls: string[] = []
|
||||
@@ -134,9 +139,11 @@ export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { dr
|
||||
await page.goto(draftHref(draftID))
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT })
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await newSessionLocation(page).click()
|
||||
// The dialog opens on projects; → lists the current project's worktrees.
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await page.getByRole("option", { name: /^New worktree/ }).click()
|
||||
await expect(newSessionLocation(page)).toContainText("New worktree")
|
||||
await expect(editor).toBeEditable()
|
||||
|
||||
return { ...workspace, draftID, editor, worktree, calls, worktreeRequests, creates, prompts }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.new-session-location-drill {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--v2-icon-icon-faint);
|
||||
}
|
||||
|
||||
.command-palette-row[data-active] .new-session-location-drill {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.new-session-location-drill:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.new-session-location-hints {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 14px;
|
||||
border-top: 0.5px solid var(--v2-border-border-muted);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.new-session-location-hints > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { Dialog, DialogBody } from "@opencode/ui/dialog"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import {
|
||||
displayName,
|
||||
getProjectAvatarSource,
|
||||
getProjectAvatarVariant,
|
||||
ProjectAvatar,
|
||||
} from "@opencode/ui/project-avatar"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { createEffect, createMemo, createResource, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PromptProject, PromptProjectController } from "@/new-session/project/selector"
|
||||
import type { NewSessionWorkspaceController } from "@/new-session/workspace/controller"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { workspaceSelectionDirectories } from "@/workspaces/paths"
|
||||
import "@/shell/commands/dialog.css"
|
||||
import "./dialog.css"
|
||||
|
||||
type LocationEntry =
|
||||
| { id: string; type: "project"; group: string; project: PromptProject }
|
||||
| { id: string; type: "add"; group: string; server?: string }
|
||||
| { id: string; type: "workspace"; group: string; value: string; directory: string }
|
||||
|
||||
/** Opens the new-session location dialog: projects first, and → on a Git project lists where the session can run. */
|
||||
export function useNewSessionLocationDialog(input: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
onDone: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
|
||||
return (options?: { onWorkspace?: (value: string) => void }) => {
|
||||
void dialog.show(
|
||||
() => (
|
||||
<NewSessionLocationDialog
|
||||
project={input.project}
|
||||
workspace={input.workspace}
|
||||
onWorkspace={options?.onWorkspace}
|
||||
/>
|
||||
),
|
||||
input.onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function NewSessionLocationDialog(props: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
onWorkspace?: (value: string) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore] = createStore<{
|
||||
drilled?: PromptProject
|
||||
query: string
|
||||
active?: string
|
||||
/** The projects view to restore when stepping back out of a project. */
|
||||
back?: { query: string; active: string }
|
||||
}>({ query: "" })
|
||||
|
||||
const connection = (project: PromptProject) =>
|
||||
project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server
|
||||
|
||||
const context = (project: PromptProject) => {
|
||||
const target = connection(project)
|
||||
|
||||
return target ? global.ensureServerCtx(target) : undefined
|
||||
}
|
||||
|
||||
const [listed, listedActions] = createResource(
|
||||
() => store.drilled,
|
||||
(project) =>
|
||||
context(project)
|
||||
?.sdk.api.worktree.list({ projectID: project.id! })
|
||||
.catch(() => project.worktrees ?? []) ?? Promise.resolve(project.worktrees ?? []),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
serverSDK.event.listen((event) => {
|
||||
if (event.type === "worktree.updated" && event.data.projectID === store.drilled?.id) void listedActions.refetch()
|
||||
}),
|
||||
)
|
||||
|
||||
const matches = (...values: (string | undefined)[]) => {
|
||||
const query = store.query.trim().toLowerCase()
|
||||
|
||||
return !query || values.some((value) => value?.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const projectEntries = createMemo<LocationEntry[]>(() => {
|
||||
const grouped = props.project.servers().length > 1
|
||||
const projects = props.project
|
||||
.projects()
|
||||
.filter((project) => matches(displayName(project), project.worktree))
|
||||
.map((project) => ({
|
||||
id: `project:${project.server?.key ?? ""}:${project.worktree}`,
|
||||
type: "project" as const,
|
||||
group: grouped ? project.server!.name : language.t("home.projects"),
|
||||
project,
|
||||
}))
|
||||
|
||||
if (!grouped) return [...projects, { id: "add", type: "add" as const, group: language.t("home.projects") }]
|
||||
|
||||
// Projects keep their server grouping; adding a project stays one row per server at the end.
|
||||
return [
|
||||
...props.project
|
||||
.servers()
|
||||
.flatMap((server) => projects.filter((entry) => entry.project.server?.key === server.key)),
|
||||
...props.project.servers().map((server) => ({
|
||||
id: `add:${server.key}`,
|
||||
type: "add" as const,
|
||||
group: language.t("session.new.project.add"),
|
||||
server: server.key,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
const workspaceEntries = createMemo<LocationEntry[]>(() => {
|
||||
const project = store.drilled
|
||||
|
||||
if (!project) return []
|
||||
const group = language.t("session.new.workspace.runIn")
|
||||
const directories = workspaceSelectionDirectories(project, listed.latest ?? project.worktrees ?? [])
|
||||
|
||||
return [
|
||||
{ id: "main", type: "workspace" as const, group, value: "main", directory: project.worktree },
|
||||
...directories.map((directory) => ({
|
||||
id: `worktree:${directory}`,
|
||||
type: "workspace" as const,
|
||||
group,
|
||||
value: directory,
|
||||
directory,
|
||||
})),
|
||||
{ id: "create", type: "workspace" as const, group, value: "create", directory: project.worktree },
|
||||
].filter((entry) => matches(workspaceTitle(project, entry.value), entry.directory))
|
||||
})
|
||||
|
||||
const entries = () => (store.drilled ? workspaceEntries() : projectEntries())
|
||||
const groups = createMemo(() =>
|
||||
entries().reduce<{ group: string; entries: LocationEntry[] }[]>((all, entry) => {
|
||||
const last = all.at(-1)
|
||||
|
||||
if (last?.group === entry.group) last.entries.push(entry)
|
||||
|
||||
if (last?.group !== entry.group) all.push({ group: entry.group, entries: [entry] })
|
||||
|
||||
return all
|
||||
}, []),
|
||||
)
|
||||
|
||||
const currentEntry = () =>
|
||||
entries().find((entry) => {
|
||||
if (entry.type === "project") return props.project.isSelected(entry.project)
|
||||
|
||||
if (entry.type === "workspace") return isCurrentWorkspace(entry)
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Follow the current location, then the first result, until the user explicitly chooses a row.
|
||||
const activeEntry = createMemo(
|
||||
() =>
|
||||
entries().find((entry) => entry.id === store.active) ??
|
||||
(store.query.trim() ? undefined : currentEntry()) ??
|
||||
entries()[0],
|
||||
)
|
||||
|
||||
let resultsRef: HTMLDivElement | undefined
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!activeEntry()) return
|
||||
const frame = requestAnimationFrame(() =>
|
||||
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }),
|
||||
)
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
})
|
||||
|
||||
function workspaceTitle(project: PromptProject, value: string) {
|
||||
if (value === "main") return getFilename(project.worktree)
|
||||
|
||||
if (value === "create") return language.t("workspace.new")
|
||||
|
||||
return getFilename(value)
|
||||
}
|
||||
|
||||
function isCurrentWorkspace(entry: Extract<LocationEntry, { type: "workspace" }>) {
|
||||
if (!store.drilled || !props.project.isSelected(store.drilled)) return false
|
||||
const current = props.workspace.selection.value()
|
||||
|
||||
if (entry.value === "main" || entry.value === "create") return current === entry.value
|
||||
|
||||
return current !== "main" && current !== "create" && sameDirectory(current, entry.value)
|
||||
}
|
||||
|
||||
function drill(project: PromptProject) {
|
||||
setStore({
|
||||
drilled: project,
|
||||
query: "",
|
||||
active: undefined,
|
||||
back: { query: store.query, active: `project:${project.server?.key ?? ""}:${project.worktree}` },
|
||||
})
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
function back() {
|
||||
const project = store.drilled
|
||||
|
||||
setStore({
|
||||
drilled: undefined,
|
||||
query: store.back?.query ?? "",
|
||||
active: store.back?.active ?? (project ? `project:${project.server?.key ?? ""}:${project.worktree}` : undefined),
|
||||
back: undefined,
|
||||
})
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
function choose(entry: LocationEntry | undefined) {
|
||||
if (!entry) return
|
||||
|
||||
if (entry.type === "add") {
|
||||
dialog.close()
|
||||
props.project.add(entry.server)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.type === "project") {
|
||||
props.project.select(entry.project)
|
||||
dialog.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const project = store.drilled
|
||||
|
||||
if (!project) return
|
||||
|
||||
if (props.project.isSelected(project)) props.workspace.selection.set(entry.value)
|
||||
|
||||
if (!props.project.isSelected(project)) props.project.select(project, entry.value)
|
||||
props.onWorkspace?.(entry.value)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
const move = (delta: -1 | 1) => {
|
||||
const list = entries()
|
||||
|
||||
if (list.length === 0) return
|
||||
const index = list.findIndex((entry) => entry.id === activeEntry()?.id)
|
||||
setStore("active", list[(index + delta + list.length) % list.length].id)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.currentTarget as HTMLInputElement
|
||||
const atEnd = target.selectionStart === target.value.length && target.selectionEnd === target.value.length
|
||||
const atStart = target.selectionStart === 0 && target.selectionEnd === 0
|
||||
const active = activeEntry()
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
move(event.key === "ArrowDown" ? 1 : -1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
choose(active)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight" && atEnd && active?.type === "project" && drillable(active.project)) {
|
||||
event.preventDefault()
|
||||
drill(active.project)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (store.drilled && ((event.key === "ArrowLeft" && atStart) || (event.key === "Backspace" && !target.value))) {
|
||||
event.preventDefault()
|
||||
back()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog class="command-palette" size="large">
|
||||
<DialogBody class="command-palette-body">
|
||||
<div class="command-palette-search">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={store.query}
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
appearance="large"
|
||||
aria-label={
|
||||
store.drilled
|
||||
? language.t("session.new.workspace.search.placeholder")
|
||||
: language.t("session.new.project.search")
|
||||
}
|
||||
placeholder={
|
||||
store.drilled
|
||||
? language.t("session.new.workspace.search.placeholder")
|
||||
: language.t("session.new.project.search")
|
||||
}
|
||||
leadingIcon={
|
||||
<Show when={store.drilled} fallback={<Icon name="magnifying-glass" />}>
|
||||
{(project) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 max-w-[200px] items-center gap-1.5 rounded-sm bg-v2-background-bg-base px-1.5 text-13-medium text-v2-text-text-base shadow-[var(--v2-elevation-raised)]"
|
||||
aria-label={language.t("session.new.location.back")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={back}
|
||||
>
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
fallback={displayName(project())}
|
||||
src={getProjectAvatarSource(project().id, project().icon)}
|
||||
variant={getProjectAvatarVariant(project().icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 truncate">{displayName(project())}</span>
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<ScrollView class="command-palette-scroll" viewportRef={(el) => (resultsRef = el)}>
|
||||
<div class="command-palette-results" role="listbox">
|
||||
<Show
|
||||
when={entries().length > 0}
|
||||
fallback={
|
||||
<div class="command-palette-state">
|
||||
{store.drilled && listed.loading ? language.t("common.loading") : language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="command-palette-group">
|
||||
<div class="command-palette-group-title">{group.group}</div>
|
||||
<For each={group.entries}>
|
||||
{(entry) => (
|
||||
<button
|
||||
type="button"
|
||||
class="command-palette-row"
|
||||
role="option"
|
||||
aria-selected={activeEntry()?.id === entry.id}
|
||||
data-active={activeEntry()?.id === entry.id ? "" : undefined}
|
||||
onMouseMove={(event) => {
|
||||
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
|
||||
if (event.movementX === 0 && event.movementY === 0) return
|
||||
setStore("active", entry.id)
|
||||
}}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => choose(entry)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={entry.type === "project" && entry}>
|
||||
{(item) => (
|
||||
<ProjectRow
|
||||
project={item().project}
|
||||
path={item().project.worktree}
|
||||
onDrill={drillable(item().project) ? () => drill(item().project) : undefined}
|
||||
drillLabel={language.t("session.new.location.worktrees")}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={entry.type === "add" && entry}>
|
||||
{(item) => (
|
||||
<div class="command-palette-row-main">
|
||||
<Icon name="plus" class="command-palette-row-icon" />
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title">{language.t("session.new.project.add")}</span>
|
||||
<Show when={item().server && props.project.servers().length > 1}>
|
||||
<span class="command-palette-description">
|
||||
{props.project.servers().find((server) => server.key === item().server)?.name}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={entry.type === "workspace" && store.drilled && entry}>
|
||||
{(item) => (
|
||||
<WorkspaceRow
|
||||
title={workspaceTitle(store.drilled!, item().value)}
|
||||
value={item().value}
|
||||
description={
|
||||
item().value === "create"
|
||||
? language.t("session.new.workspace.new.tooltip")
|
||||
: item().directory
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div class="new-session-location-hints">
|
||||
<span>
|
||||
<Keybind keys={["↵"]} variant="neutral" />
|
||||
{language.t("session.new.location.select")}
|
||||
</span>
|
||||
<Show
|
||||
when={store.drilled}
|
||||
fallback={
|
||||
<span>
|
||||
<Keybind keys={["→"]} variant="neutral" />
|
||||
{language.t("session.new.location.worktrees")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Keybind keys={["←"]} variant="neutral" />
|
||||
{language.t("session.new.location.back")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectRow(props: { project: PromptProject; path: string; onDrill?: () => void; drillLabel: string }) {
|
||||
return (
|
||||
<>
|
||||
<div class="command-palette-row-main">
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title">{displayName(props.project)}</span>
|
||||
<span class="command-palette-description" dir="ltr">
|
||||
{props.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.onDrill}>
|
||||
{(onDrill) => (
|
||||
<span
|
||||
role="button"
|
||||
aria-label={props.drillLabel}
|
||||
class="new-session-location-drill"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDrill()()
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-right" size="small" />
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRow(props: { title: string; value: string; description: string }) {
|
||||
return (
|
||||
<>
|
||||
<div class="command-palette-row-main">
|
||||
<Icon
|
||||
name={props.value === "main" ? "monitor" : props.value === "create" ? "plus" : "outline-worktree"}
|
||||
class="command-palette-row-icon"
|
||||
classList={{ "!text-v2-icon-icon-accent": props.value !== "main" && props.value !== "create" }}
|
||||
/>
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title">{props.title}</span>
|
||||
<span class="command-palette-description" dir="ltr">
|
||||
{props.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function drillable(project: PromptProject) {
|
||||
return !!project.id && project.vcs === "git"
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
})
|
||||
})
|
||||
|
||||
const selectProject = (worktree: string, serverKey?: string) => {
|
||||
const selectProject = (directory: string, serverKey?: string, workspace?: string) => {
|
||||
const connection = serverKey
|
||||
? servers.list.find((connection) => ServerConnection.key(connection) === serverKey)
|
||||
: projectServer()
|
||||
@@ -39,12 +39,12 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
if (!connection) return
|
||||
|
||||
const target = global.ensureServerCtx(connection)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
target.projects.open(directory)
|
||||
target.projects.touch(directory)
|
||||
tabs.updateDraft(props.draftId, {
|
||||
server: ServerConnection.key(connection),
|
||||
directory: worktree,
|
||||
worktree: workspaceSelectionDestination(props.worktree(), location().directory),
|
||||
directory,
|
||||
worktree: workspace ?? workspaceSelectionDestination(props.worktree(), location().directory),
|
||||
branch: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("new session project selection", () => {
|
||||
}
|
||||
|
||||
createRoot((dispose) => {
|
||||
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
|
||||
const controller = createPromptProjectController({ controls: () => controls })
|
||||
expect(controller.selected()).toEqual(current)
|
||||
dispose()
|
||||
})
|
||||
@@ -46,7 +46,7 @@ describe("new session project selection", () => {
|
||||
}
|
||||
|
||||
createRoot((dispose) => {
|
||||
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
|
||||
const controller = createPromptProjectController({ controls: () => controls })
|
||||
expect(controller.selected()).toEqual(local)
|
||||
dispose()
|
||||
})
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
Show,
|
||||
splitProps,
|
||||
type Accessor,
|
||||
type ComponentProps,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { type Accessor } from "solid-js"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import {
|
||||
displayName,
|
||||
getProjectAvatarSource,
|
||||
getProjectAvatarVariant,
|
||||
ProjectAvatar,
|
||||
} from "@opencode/ui/project-avatar"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
|
||||
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
|
||||
export type PromptProject = {
|
||||
name?: string
|
||||
id?: string
|
||||
worktree: string
|
||||
vcs?: string
|
||||
sandboxes?: string[]
|
||||
worktrees?: { directory: string; strategy?: string }[]
|
||||
icon?: { color?: string; url?: string; override?: string }
|
||||
server?: { key: string; name: string }
|
||||
}
|
||||
@@ -36,29 +19,13 @@ export type PromptProjectControls = {
|
||||
directory: string
|
||||
projectID?: string
|
||||
server?: string
|
||||
select: (worktree: string, server?: string) => void
|
||||
/** Moves the draft to a project; `workspace` is "main", "create", or a worktree directory. */
|
||||
select: (directory: string, server?: string, workspace?: string) => void
|
||||
add: (title: string, server?: string) => void
|
||||
}
|
||||
|
||||
const actionPrefix = "action:"
|
||||
|
||||
const projectPrefix = "project:"
|
||||
|
||||
function projectKey(project: PromptProject) {
|
||||
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
|
||||
}
|
||||
|
||||
function actionKey(server?: string) {
|
||||
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
|
||||
}
|
||||
|
||||
export function createPromptProjectController(input: {
|
||||
controls: Accessor<PromptProjectControls>
|
||||
onDone: () => void
|
||||
}) {
|
||||
export function createPromptProjectController(input: { controls: Accessor<PromptProjectControls> }) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const current = () => {
|
||||
const controls = input.controls()
|
||||
@@ -75,423 +42,38 @@ export function createPromptProjectController(input: {
|
||||
|
||||
const selected = () => current() ?? input.controls().available[0]
|
||||
|
||||
const projects = () => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
|
||||
if (!search) return input.controls().available
|
||||
|
||||
return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
const servers = () =>
|
||||
input
|
||||
.controls()
|
||||
.available.map((project) => project.server)
|
||||
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
|
||||
|
||||
const keys = () => {
|
||||
if (servers().length <= 1) {
|
||||
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
|
||||
}
|
||||
|
||||
return [
|
||||
...servers().flatMap((server) =>
|
||||
projects()
|
||||
.filter((project) => project.server?.key === server!.key)
|
||||
.map(projectKey),
|
||||
),
|
||||
actionKey(),
|
||||
]
|
||||
}
|
||||
|
||||
const initialActive = () => {
|
||||
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
||||
const options = keys()
|
||||
|
||||
if (selectedKey && options.includes(selectedKey)) return selectedKey
|
||||
|
||||
return options[0] ?? ""
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
input.onDone()
|
||||
}
|
||||
|
||||
const select = (project: PromptProject) => {
|
||||
if (
|
||||
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
|
||||
project.server?.key !== current()?.server?.key
|
||||
) {
|
||||
input.controls().select(project.worktree, project.server?.key)
|
||||
}
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
const add = (server?: string) => {
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
input.controls().add(language.t("command.project.open"), server)
|
||||
}
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
}
|
||||
const matches = (project: PromptProject, other?: PromptProject) =>
|
||||
!!other && pathKey(other.worktree) === pathKey(project.worktree) && other.server?.key === project.server?.key
|
||||
|
||||
return {
|
||||
selected,
|
||||
isSelected: (project: PromptProject) => matches(project, selected()),
|
||||
empty: () => input.controls().available.length === 0,
|
||||
projects,
|
||||
servers,
|
||||
projectKey,
|
||||
actionKey,
|
||||
open: () => store.open,
|
||||
search: () => store.search,
|
||||
active: () => store.active,
|
||||
projects: () => input.controls().available,
|
||||
servers: () =>
|
||||
input
|
||||
.controls()
|
||||
.available.map((project) => project.server)
|
||||
.filter(
|
||||
(server, index, all): server is NonNullable<typeof server> =>
|
||||
!!server && all.findIndex((item) => item?.key === server.key) === index,
|
||||
),
|
||||
labels: {
|
||||
add: () => language.t("session.new.project.add"),
|
||||
clear: () => language.t("common.clear"),
|
||||
new: () => language.t("session.new.project.new"),
|
||||
search: () => language.t("session.new.project.search"),
|
||||
},
|
||||
add,
|
||||
select,
|
||||
setOpen(open: boolean) {
|
||||
if (open) {
|
||||
setStore({ open: true, active: initialActive() })
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
add(server?: string) {
|
||||
input.controls().add(language.t("command.project.open"), server)
|
||||
},
|
||||
setSearch,
|
||||
clearSearch() {
|
||||
setStore({ search: "", active: initialActive() })
|
||||
setTimeout(() => searchRef?.focus())
|
||||
},
|
||||
setActive(key: string) {
|
||||
setStore("active", key)
|
||||
},
|
||||
moveActive(delta: number) {
|
||||
const options = keys()
|
||||
|
||||
if (options.length === 0) return
|
||||
const index = options.indexOf(store.active)
|
||||
const start = index === -1 ? 0 : index
|
||||
setStore("active", options[(start + delta + options.length) % options.length])
|
||||
},
|
||||
activeProject() {
|
||||
return store.active.startsWith(projectPrefix)
|
||||
? projects().find((project) => projectKey(project) === store.active)
|
||||
: undefined
|
||||
},
|
||||
activeServer() {
|
||||
return store.active.startsWith(actionPrefix)
|
||||
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
||||
: undefined
|
||||
},
|
||||
activeAction() {
|
||||
return store.active.startsWith(actionPrefix)
|
||||
},
|
||||
setSearchRef(el: HTMLInputElement) {
|
||||
searchRef = el
|
||||
},
|
||||
focusSearch() {
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
},
|
||||
handleSearchKeydown(event: KeyboardEvent) {
|
||||
return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch)
|
||||
select(project: PromptProject, workspace?: string) {
|
||||
if (matches(project, current())) return
|
||||
input.controls().select(project.worktree, project.server?.key, workspace)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||
|
||||
export function PromptProjectSelector(props: {
|
||||
controller: PromptProjectController
|
||||
placement?: "bottom" | "bottom-start"
|
||||
}) {
|
||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
const dismiss = createMenuDismissController(() => contentRef)
|
||||
let triggerFrame: number | undefined
|
||||
|
||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||
const ready = () => {
|
||||
if (!element.isConnected) {
|
||||
triggerFrame = requestAnimationFrame(ready)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
triggerFrame = undefined
|
||||
setTriggerReady(true)
|
||||
}
|
||||
|
||||
ready()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
|
||||
})
|
||||
|
||||
const activeItem = () =>
|
||||
props.controller.active()
|
||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||
: undefined
|
||||
|
||||
const selectProject = (project: PromptProject) => {
|
||||
dismiss.preventTriggerRestore()
|
||||
props.controller.setOpen(false)
|
||||
dismiss.afterClose(() => props.controller.select(project))
|
||||
}
|
||||
|
||||
const selectAction = (server?: string) => {
|
||||
dismiss.preventTriggerRestore()
|
||||
props.controller.setOpen(false)
|
||||
dismiss.afterClose(() => props.controller.add(server))
|
||||
}
|
||||
|
||||
const selectActive = () => {
|
||||
const project = props.controller.activeProject()
|
||||
|
||||
if (project) {
|
||||
selectProject(project)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (props.controller.activeAction() && props.controller.servers().length > 1) {
|
||||
const item = activeItem()
|
||||
item?.focus()
|
||||
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
selectAction(props.controller.activeServer())
|
||||
}
|
||||
|
||||
const moveActive = (delta: number) => {
|
||||
props.controller.moveActive(delta)
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
|
||||
const focusPreviousControl = () => {
|
||||
const target = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
)
|
||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||
.findLast((element) => element.offsetParent !== null)
|
||||
|
||||
dismiss.preventTriggerRestore()
|
||||
target?.focus()
|
||||
queueMicrotask(() => {
|
||||
if (props.controller.open()) props.controller.setOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
const selectedValue = () => {
|
||||
const project = props.controller.selected()
|
||||
|
||||
return project ? props.controller.projectKey(project) : undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.controller.open()) return
|
||||
const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event)
|
||||
document.addEventListener("keydown", handler, true)
|
||||
onCleanup(() => document.removeEventListener("keydown", handler, true))
|
||||
})
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={triggerReady() && props.controller.open()}
|
||||
placement={props.placement ?? "bottom"}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) dismiss.allowTriggerRestore()
|
||||
props.controller.setOpen(open)
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
ref={contentRef}
|
||||
id="prompt-project-menu"
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||
onFocusOutside={dismiss.preventTriggerRestore}
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => props.controller.setSearchRef(el)}
|
||||
value={props.controller.search()}
|
||||
placeholder={props.controller.labels.search()}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="prompt-project-menu"
|
||||
aria-activedescendant={props.controller.active() || undefined}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.shiftKey) {
|
||||
focusPreviousControl()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
activeItem()?.focus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.controller.setOpen(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey || event.metaKey) return
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={props.controller.search().trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => props.controller.clearSearch()}
|
||||
aria-label={props.controller.labels.clear()}
|
||||
>
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="my-0.5 max-h-[224px] overflow-y-auto">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<Menu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={props.controller
|
||||
.servers()
|
||||
.filter((server) =>
|
||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||
)}
|
||||
>
|
||||
{(server) => (
|
||||
<div>
|
||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||
{server!.name}
|
||||
</div>
|
||||
<Menu.RadioGroup value={selectedValue()}>
|
||||
<For
|
||||
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
|
||||
>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="-mx-0.5 h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col pt-0.5">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<ProjectAction
|
||||
server={props.controller.servers()[0]?.key}
|
||||
controller={props.controller}
|
||||
onSelect={selectAction}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu.Sub>
|
||||
<Menu.SubTrigger
|
||||
id={props.controller.actionKey()}
|
||||
data-option-key={props.controller.actionKey()}
|
||||
class={projectActionClass}
|
||||
classList={{
|
||||
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
|
||||
}}
|
||||
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[224px] min-w-[180px] overflow-y-auto rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
|
||||
</For>
|
||||
</Menu.SubContent>
|
||||
</Menu.Portal>
|
||||
</Menu.Sub>
|
||||
</Show>
|
||||
</div>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptProjectAddButton(props: { controller: PromptProjectController }) {
|
||||
return (
|
||||
<button
|
||||
@@ -506,118 +88,3 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
|
||||
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
|
||||
const project = () => local.controller.selected()
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
data-action="prompt-project"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{
|
||||
...local.classList,
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
||||
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
||||
"text-v2-text-text-muted": local.controller.open(),
|
||||
}}
|
||||
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
||||
onKeyDown={(event) => {
|
||||
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={project()}
|
||||
fallback={<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{(item) => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(item())}
|
||||
src={getProjectAvatarSource(item().id, item().icon)}
|
||||
variant={getProjectAvatarVariant(item().icon?.color)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="min-w-0 truncate leading-5">
|
||||
{project() ? displayName(project()!) : local.controller.labels.new()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectItem(props: {
|
||||
project: PromptProject
|
||||
controller: PromptProjectController
|
||||
onSelect: (project: PromptProject) => void
|
||||
}) {
|
||||
const key = () => props.controller.projectKey(props.project)
|
||||
|
||||
return (
|
||||
<Menu.RadioItem
|
||||
id={key()}
|
||||
value={key()}
|
||||
data-option-key={key()}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
|
||||
closeOnSelect
|
||||
onMouseEnter={() => {
|
||||
props.controller.setActive(key())
|
||||
props.controller.focusSearch()
|
||||
}}
|
||||
onSelect={() => props.onSelect(props.project)}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 truncate leading-5">{displayName(props.project)}</span>
|
||||
</Menu.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
const projectActionClass =
|
||||
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
|
||||
function ProjectAction(props: {
|
||||
server?: string
|
||||
controller: PromptProjectController
|
||||
onSelect: (server?: string) => void
|
||||
}) {
|
||||
const key = () => props.controller.actionKey(props.server)
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
id={key()}
|
||||
data-option-key={key()}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
|
||||
onMouseEnter={() => {
|
||||
props.controller.setActive(key())
|
||||
props.controller.focusSearch()
|
||||
}}
|
||||
onSelect={() => props.onSelect(props.server)}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span class="min-w-0 truncate leading-5">{props.controller.labels.add()}</span>
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
|
||||
return (
|
||||
<Menu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.server.name}</span>
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createPromptProjectController } from "@/new-session/project/selector"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { useNewSessionLocationDialog } from "@/new-session/location/dialog"
|
||||
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { useSettingsServers } from "@/settings/servers/inventory"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, on, untrack } from "solid-js"
|
||||
import { createComposerModel } from "@/composer/model"
|
||||
@@ -16,25 +15,11 @@ import { createDraftMcpControls } from "./mcp"
|
||||
export default function NewSessionPage(props: { draftId: string }) {
|
||||
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const tabs = useTabs()
|
||||
const servers = useSettingsServers()
|
||||
const settingsSurface = useSettingsSurface()
|
||||
|
||||
const draftTab = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
|
||||
)
|
||||
|
||||
const openWorkspaces = () => {
|
||||
const draft = draftTab()
|
||||
|
||||
if (servers().length > 1 && draft) {
|
||||
settingsSurface.openServer(draft.server, "workspaces")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
settingsSurface.open("workspaces")
|
||||
}
|
||||
|
||||
const workspace = createNewSessionWorkspaceController({
|
||||
selectedWorktree: () => draftTab()?.worktree,
|
||||
selectedBranch: () => draftTab()?.branch,
|
||||
@@ -44,7 +29,6 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
setSelectedBranch: (branch) => {
|
||||
if (search.draftId) tabs.updateDraft(search.draftId, { branch })
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
|
||||
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
|
||||
@@ -60,16 +44,14 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
const model = createComposerModel(composer.adapter)
|
||||
useComposerCommands({ model: composer.model })
|
||||
|
||||
const project = createPromptProjectController({
|
||||
controls: composer.project,
|
||||
onDone: model.restoreFocus,
|
||||
})
|
||||
const project = createPromptProjectController({ controls: composer.project })
|
||||
const location = useNewSessionLocationDialog({ project, workspace, onDone: model.restoreFocus })
|
||||
|
||||
useNewSessionCommands({
|
||||
restoreFocus: model.restoreFocus,
|
||||
project: {
|
||||
empty: project.empty,
|
||||
open: () => project.setOpen(true),
|
||||
open: () => location(),
|
||||
},
|
||||
workspace: {
|
||||
enabled: workspace.bar.visible,
|
||||
@@ -104,7 +86,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} location={location} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,17 +2,24 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import {
|
||||
displayName,
|
||||
getProjectAvatarSource,
|
||||
getProjectAvatarVariant,
|
||||
ProjectAvatar,
|
||||
} from "@opencode/ui/project-avatar"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
|
||||
import { PromptBranchSelector } from "@/new-session/workspace/selector"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
PromptProjectSelector,
|
||||
type PromptProject,
|
||||
type PromptProjectController,
|
||||
} from "@/new-session/project/selector"
|
||||
import type { useNewSessionLocationDialog } from "@/new-session/location/dialog"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -38,6 +45,7 @@ export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
location: ReturnType<typeof useNewSessionLocationDialog>
|
||||
}) {
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
@@ -64,39 +72,46 @@ export function NewSessionView(props: {
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<NewSessionWordmark />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<div class="mt-8 flex flex-col gap-3">
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
<Show when={props.project.selected()}>
|
||||
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center justify-center gap-0 text-v2-text-text-faint">
|
||||
<PromptProjectSelector controller={props.project} placement="bottom" />
|
||||
<Show
|
||||
when={props.workspace.bar.visible()}
|
||||
fallback={
|
||||
<PromptGitStatus
|
||||
branch={props.workspace.bar.branch()}
|
||||
noGit={!props.workspace.project.git()}
|
||||
class="ms-1"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
value={props.workspace.selection.value()}
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branches={props.workspace.project.branches()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onboarding={onboardingReady() && !onboarding.used}
|
||||
onChange={select}
|
||||
onCreate={props.workspace.selection.create}
|
||||
onSearch={props.workspace.project.searchBranches}
|
||||
onDone={props.composer.restoreFocus}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
{(project) => (
|
||||
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center gap-0 ps-2.5 text-v2-text-text-faint">
|
||||
<NewSessionLocationTrigger
|
||||
project={project()}
|
||||
workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"}
|
||||
root={props.workspace.project.root()}
|
||||
onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()}
|
||||
onClick={() =>
|
||||
props.location({
|
||||
onWorkspace: (value) => {
|
||||
if (value !== "main") setOnboarding("used", true)
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
props.workspace.bar.visible() &&
|
||||
props.workspace.selection.value() === "create" &&
|
||||
props.workspace.bar.branch()
|
||||
}
|
||||
>
|
||||
{(branch) => (
|
||||
<PromptBranchSelector
|
||||
branch={branch()}
|
||||
branches={props.workspace.project.branches()}
|
||||
onCreate={props.workspace.selection.create}
|
||||
onSearch={props.workspace.project.searchBranches}
|
||||
onDone={props.composer.restoreFocus}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,3 +258,71 @@ function NewSessionTips(props: {
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionLocationTrigger(props: {
|
||||
project: PromptProject
|
||||
/** "main", "create", or a worktree directory. */
|
||||
workspace: string
|
||||
root: string
|
||||
onboarding: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
const path = () => {
|
||||
if (props.workspace === "main") return props.root
|
||||
if (props.workspace === "create") return language.t("workspace.new")
|
||||
|
||||
return props.workspace
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="prompt-project"
|
||||
aria-haspopup="dialog"
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-7 min-w-0 max-w-full items-center gap-2 rounded-sm px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="max-w-[200px] shrink-0 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
<span dir="ltr" class="min-w-0 truncate text-v2-text-text-faint">
|
||||
{path()}
|
||||
</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isWorkspaceSelection,
|
||||
workspaceDefaultSelection,
|
||||
workspaceSelectionDestination,
|
||||
workspaceSelectionDirectories,
|
||||
} from "@/workspaces/paths"
|
||||
|
||||
export function resolveNewSessionWorktree(input: { enabled: boolean; selected?: string; fallback?: string }) {
|
||||
@@ -53,7 +54,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
selectedBranch: () => string | undefined
|
||||
setSelectedWorktree: (worktree: string | undefined) => void
|
||||
setSelectedBranch: (branch: string | undefined) => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -119,15 +119,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
|
||||
if (!project) return []
|
||||
|
||||
const directories = [
|
||||
...worktreeItems().map((item) => item.directory),
|
||||
...project.worktrees.map((item) => item.directory),
|
||||
...(project.sandboxes ?? []),
|
||||
]
|
||||
|
||||
return directories
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory))
|
||||
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
|
||||
return workspaceSelectionDirectories(project, worktreeItems())
|
||||
})
|
||||
|
||||
const managedWorktrees = createMemo(() => {
|
||||
@@ -186,8 +178,8 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const [branches] = createResource(
|
||||
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
|
||||
({ directory, search }) =>
|
||||
serverSDK.api.vcs
|
||||
.branch.list({ location: { directory }, search, limit: 50 })
|
||||
serverSDK.api.vcs.branch
|
||||
.list({ location: { directory }, search, limit: 50 })
|
||||
.then((response) => ({ directory, search, data: response.data }))
|
||||
.catch(() => ({ directory, search, data: [] })),
|
||||
)
|
||||
@@ -295,7 +287,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
].slice(0, 50)
|
||||
},
|
||||
searchBranches,
|
||||
openAll: input.onViewAll,
|
||||
},
|
||||
bar: {
|
||||
visible,
|
||||
|
||||
@@ -1,351 +1,120 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { WorkspaceSubmenu } from "@/workspaces/submenu"
|
||||
|
||||
export function PromptWorkspaceSelector(props: {
|
||||
value: string
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
/** The base branch for a new worktree. */
|
||||
export function PromptBranchSelector(props: {
|
||||
branch: string
|
||||
branches: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
variant?: "inline" | "summary"
|
||||
onChange: (value: string) => void
|
||||
onCreate: (branch: string) => void
|
||||
onSearch: (search: string) => void
|
||||
onDone?: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const summary = () => props.variant === "summary"
|
||||
|
||||
const placement = createMemo(() =>
|
||||
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
|
||||
)
|
||||
|
||||
const [search, setSearch] = createStore({ branches: "" })
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
const branchTruncation = createTruncatedText()
|
||||
|
||||
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
||||
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
|
||||
if (selected() === "create") return "plus"
|
||||
|
||||
return "outline-worktree"
|
||||
}
|
||||
|
||||
const select = (value: string) => {
|
||||
pending = { type: "select", value }
|
||||
}
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setSearch({ branches: "" })
|
||||
props.onSearch("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const action = pending
|
||||
pending = undefined
|
||||
|
||||
if (action?.type === "select") props.onChange(action.value)
|
||||
|
||||
if (action?.type === "create") props.onCreate(action.branch)
|
||||
|
||||
if (action?.type === "viewAll") {
|
||||
props.onViewAll()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
props.onDone?.()
|
||||
}
|
||||
|
||||
const label = () => {
|
||||
if (selected() === "main")
|
||||
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
|
||||
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={!summary()}>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
</Show>
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class={summary() ? "min-w-0 w-full" : "min-w-0"}
|
||||
>
|
||||
<Menu
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
overflowPadding={24}
|
||||
modal={summary() ? false : undefined}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Menu.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={icon()}
|
||||
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-[200px]">
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("session.new.workspace.runIn")}</Menu.GroupLabel>
|
||||
<Menu.Item onSelect={() => select("main")}>
|
||||
<Icon name="monitor" />
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={language.t("session.new.workspace.local.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
</Tooltip>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => select("create")}>
|
||||
<Icon name="plus" />
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={language.t("session.new.workspace.new.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</Tooltip>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<WorkspaceSubmenu
|
||||
directories={props.workspaces}
|
||||
selected={selected()}
|
||||
onSelect={select}
|
||||
onViewAll={() => (pending = { type: "viewAll" })}
|
||||
/>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
<Show
|
||||
when={selected() === "create" && props.branch}
|
||||
fallback={
|
||||
summary() ? (
|
||||
<Show when={props.branch}>
|
||||
<div class="session-summary-row">
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{props.branch}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
) : (
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
disabled={!branchTruncation.truncated()}
|
||||
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu placement={placement()} gutter={4} modal={summary() ? false : undefined} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted"
|
||||
}
|
||||
>
|
||||
<Icon name="branch-out" size={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
|
||||
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
|
||||
branch: props.branch!,
|
||||
})}
|
||||
</span>
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
// Kobalte defers its list autofocus until after the focus scope opens.
|
||||
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
branchSearchInput = element
|
||||
}}
|
||||
value={search.branches}
|
||||
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => {
|
||||
setSearch("branches", event.currentTarget.value)
|
||||
props.onSearch(event.currentTarget.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<Show when={search.branches.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSearch("branches", "")
|
||||
props.onSearch("")
|
||||
}}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="max-h-[224px] overflow-y-auto">
|
||||
<Menu.RadioGroup value={props.branch}>
|
||||
<For each={props.branches}>
|
||||
{(branch) => (
|
||||
<Menu.RadioItem
|
||||
value={branch}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
closeOnSelect
|
||||
onSelect={() => (pending = { type: "create", branch })}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{branch}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</div>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const truncation = createTruncatedText()
|
||||
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
|
||||
if (!props.branch) return undefined
|
||||
|
||||
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||
|
||||
return props.branch
|
||||
}
|
||||
|
||||
const icon = () => {
|
||||
if (props.noGit) return "monitor"
|
||||
|
||||
if (props.from) return "branch-out"
|
||||
|
||||
return "branch"
|
||||
}
|
||||
let input: HTMLInputElement | undefined
|
||||
let pending: string | undefined
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={value()}
|
||||
disabled={!truncation.truncated()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={truncation.observe} class="min-w-0 truncate">
|
||||
{value()}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch })}
|
||||
disabled={!truncation.truncated()}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setSearch({ branches: "" })
|
||||
props.onSearch("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const branch = pending
|
||||
pending = undefined
|
||||
|
||||
if (branch) props.onCreate(branch)
|
||||
|
||||
props.onDone?.()
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={truncation.observe} class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch })}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
// Kobalte defers its list autofocus until after the focus scope opens.
|
||||
setTimeout(() => requestAnimationFrame(() => input?.focus({ preventScroll: true })))
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={input}
|
||||
value={search.branches}
|
||||
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => {
|
||||
setSearch("branches", event.currentTarget.value)
|
||||
props.onSearch(event.currentTarget.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (["Escape", "ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<Show when={search.branches.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSearch("branches", "")
|
||||
props.onSearch("")
|
||||
}}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="max-h-[224px] overflow-y-auto">
|
||||
<Menu.RadioGroup value={props.branch}>
|
||||
<For each={props.branches}>
|
||||
{(branch) => (
|
||||
<Menu.RadioItem
|
||||
value={branch}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
closeOnSelect
|
||||
onSelect={() => (pending = branch)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{branch}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</div>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -978,6 +978,9 @@ export const dict = {
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search worktrees",
|
||||
"session.new.workspace.search.empty": "No matching worktrees",
|
||||
"session.new.location.select": "Select",
|
||||
"session.new.location.worktrees": "Worktrees",
|
||||
"session.new.location.back": "Back to projects",
|
||||
"settings.tab.workspaces": "Worktrees",
|
||||
"settings.workspaces.description": "Review worktrees and manage disk usage",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
|
||||
@@ -12,6 +12,17 @@ export function workspaceDirectories(project: WorkspaceProject) {
|
||||
return (project.sandboxes ?? []).filter((directory) => !sameDirectory(project.worktree, directory))
|
||||
}
|
||||
|
||||
/** Every non-root directory a new session can run in, from the server inventory and the project's cached lists. */
|
||||
export function workspaceSelectionDirectories(project: WorkspaceProject, listed: readonly WorktreeDirectory[]) {
|
||||
return [
|
||||
...listed.map((item) => item.directory),
|
||||
...(project.worktrees ?? []).map((item) => item.directory),
|
||||
...(project.sandboxes ?? []),
|
||||
]
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory))
|
||||
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
|
||||
}
|
||||
|
||||
export function managedWorkspaceDirectories(project: WorkspaceProject) {
|
||||
return (project.worktrees ?? [])
|
||||
.filter((worktree) => worktree.strategy !== undefined)
|
||||
|
||||
Reference in New Issue
Block a user