mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): merge projects across servers in the location dialog
- One project list across every server, ordered by time.active across servers with used projects first, then the rest. - Remote projects carry a server tag (globe for web servers, terminal for SSH/WSL); local projects have none. The worktree step's project chip keeps the tag. - Servers that are offline or not ready contribute no projects. - Search also matches the server name shown in the tag. - Add project is a single row; with several ready servers it opens a server step before the folder picker. - The dialog and its worktree step open at the top with the first row active instead of jumping to the current project. - Long project names no longer overflow stacked rows on narrow screens.
This commit is contained in:
@@ -1,7 +1,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 { draftHref, expectPath, NO_PROVIDER, project, provider, seed, sessionHref, T0 } from "../utils/app"
|
||||
import { mockOpenCodeServer, type MockAnswer } from "../utils/mock-server"
|
||||
import { newSessionLocation, openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace"
|
||||
|
||||
const directory = "C:/OpenCode/WorkspacePending"
|
||||
@@ -406,6 +406,90 @@ test("executes a selected slash command after creating its worktree", async ({ p
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("the location dialog lists every ready server's projects in one list and tags the remote ones", async ({
|
||||
page,
|
||||
}) => {
|
||||
const apollo = "http://apollo.test:4096"
|
||||
const entry = (id: string, directory: string, active: number) =>
|
||||
project({ id, directory, time: { created: T0, updated: T0, active: T0 + active } })
|
||||
const remote = entry("proj_apollo_opencode", "/home/dax/opencode", 4)
|
||||
const files: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
|
||||
if (url.origin === apollo && url.pathname === "/api/fs/list") files.push(url.pathname)
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
server: apollo,
|
||||
directory: remote.worktree,
|
||||
project: remote,
|
||||
projects: () => [remote, entry("proj_apollo_sst", "/home/dax/sst", 3)],
|
||||
worktrees: () => [{ directory: remote.worktree }, { directory: "/home/dax/opencode-beta", strategy: "git" }],
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
fileList: () => [],
|
||||
})
|
||||
// hailmary is listed but unreachable, so it contributes no projects and cannot take a new one.
|
||||
await seed(page, {
|
||||
servers: [
|
||||
{ url: apollo, name: "apollo" },
|
||||
{ url: "http://127.0.0.1:4999", name: "hailmary" },
|
||||
],
|
||||
usedProjects: { [apollo]: [remote.id] },
|
||||
})
|
||||
const local = entry("proj_opencode", "C:/work/opencode", 2)
|
||||
await openDraft(page, {
|
||||
name: "opencode",
|
||||
directory: local.worktree,
|
||||
project: { time: local.time },
|
||||
projects: () => [local, entry("proj_console", "C:/work/console", 9)],
|
||||
})
|
||||
|
||||
await newSessionLocation(page).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
const search = dialog.getByRole("textbox")
|
||||
await expect(search).toBeFocused()
|
||||
const options = dialog.getByRole("option")
|
||||
const names = options.locator(".new-session-location-name")
|
||||
const paths = options.locator(".command-palette-description")
|
||||
const chip = dialog.getByRole("button", { name: "Back to projects", exact: true })
|
||||
// Used projects first, then the rest, each by recent activity across servers; only remote ones carry a tag.
|
||||
await expect(names).toHaveText(["opencodeapollo", "opencode", "console", "sstapollo"])
|
||||
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode", "C:/work/console", "/home/dax/sst"])
|
||||
await expect(options.last()).toHaveText("Add project")
|
||||
// It opens on the first row, not on the draft's current project (the second row).
|
||||
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await search.fill("apollo")
|
||||
await expect(names).toHaveText(["opencodeapollo", "sstapollo"])
|
||||
|
||||
// The worktree step keeps the server's tag beside the project.
|
||||
await search.fill("opencode")
|
||||
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"])
|
||||
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await expect(chip).toContainText("opencode")
|
||||
await expect(chip.locator('[data-component="tag"]')).toHaveText("apollo")
|
||||
await expect(options.locator(".command-palette-title")).toHaveText(["opencode", "opencode-beta", "New worktree"])
|
||||
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("Backspace")
|
||||
await expect(search).toHaveValue("opencode")
|
||||
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Adding a project picks one of the ready servers first, then browses that server.
|
||||
await search.fill("")
|
||||
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await expect(options.nth(4)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await expect(chip).toHaveText("Add project")
|
||||
await expect(options).toHaveText([/^127\.0\.0\.1:\d+$/, "apollo"])
|
||||
await options.filter({ hasText: "apollo" }).click()
|
||||
await expect(page.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
|
||||
await expect.poll(() => files.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const accentRoot = "C:/OpenCode/WorkspaceAccent"
|
||||
|
||||
const accentWorkspace = `${accentRoot}/.worktrees/feature`
|
||||
|
||||
@@ -37,3 +37,32 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* The project name truncates before its server tag. */
|
||||
.new-session-location-name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.new-session-location-tag {
|
||||
max-width: 160px;
|
||||
flex-shrink: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.new-session-location-tag [data-slot="icon-svg"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-session-location-tag > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { Dialog, DialogBody } from "@opencode/ui/dialog"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
@@ -10,10 +11,10 @@ import {
|
||||
} 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 { getFilename } from "@opencode/util/path"
|
||||
import { createEffect, createMemo, createResource, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PromptProject, PromptProjectController } from "@/new-session/project/selector"
|
||||
import type { PromptProject, PromptProjectController, PromptServer } 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"
|
||||
@@ -24,11 +25,15 @@ 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 }
|
||||
| { id: string; type: "project"; project: PromptProject }
|
||||
| { id: string; type: "add" }
|
||||
| { id: string; type: "server"; server: PromptServer }
|
||||
| { id: string; type: "workspace"; value: string; directory: string }
|
||||
|
||||
/** Opens the new-session location dialog: projects first, and → on a Git project lists where the session can run. */
|
||||
/**
|
||||
* Opens the new-session location dialog: one project list across every ready server, → on a Git project lists where
|
||||
* the session can run, and → on "Add project" picks the server when there are several.
|
||||
*/
|
||||
export function useNewSessionLocationDialog(input: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
@@ -62,9 +67,11 @@ function NewSessionLocationDialog(props: {
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore] = createStore<{
|
||||
drilled?: PromptProject
|
||||
/** Picking the server to add a project to. */
|
||||
picking?: boolean
|
||||
query: string
|
||||
active?: string
|
||||
/** The projects view to restore when stepping back out of a project. */
|
||||
/** The projects view to restore when stepping back out of a project or the server step. */
|
||||
back?: { query: string; active: string }
|
||||
}>({ query: "" })
|
||||
|
||||
@@ -97,83 +104,68 @@ function NewSessionLocationDialog(props: {
|
||||
return !query || values.some((value) => value?.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const projectEntries = createMemo<LocationEntry[]>(() => {
|
||||
const grouped = props.project.servers().length > 1
|
||||
const projects = props.project
|
||||
// Remote projects also match their server's name, which their tag shows.
|
||||
const projectEntries = createMemo<LocationEntry[]>(() => [
|
||||
...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,
|
||||
}))
|
||||
.filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name))
|
||||
.map((project) => ({ id: projectEntryID(project), type: "project" as const, project })),
|
||||
{ id: "add", type: "add" as const },
|
||||
])
|
||||
|
||||
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 serverEntries = createMemo<LocationEntry[]>(() =>
|
||||
props.project
|
||||
.servers()
|
||||
.filter((server) => matches(server.name))
|
||||
.map((server) => ({ id: `server:${server.key}`, type: "server" as const, server })),
|
||||
)
|
||||
|
||||
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 },
|
||||
{ id: "main", type: "workspace" as const, 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 },
|
||||
{ id: "create", type: "workspace" as const, 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)
|
||||
const entries = () => {
|
||||
if (store.drilled) return workspaceEntries()
|
||||
|
||||
if (last?.group === entry.group) last.entries.push(entry)
|
||||
if (store.picking) return serverEntries()
|
||||
|
||||
if (last?.group !== entry.group) all.push({ group: entry.group, entries: [entry] })
|
||||
return projectEntries()
|
||||
}
|
||||
|
||||
return all
|
||||
}, []),
|
||||
)
|
||||
const title = () => {
|
||||
if (store.drilled) return language.t("session.new.workspace.runIn")
|
||||
|
||||
const currentEntry = () =>
|
||||
entries().find((entry) => {
|
||||
if (entry.type === "project") return props.project.isSelected(entry.project)
|
||||
if (store.picking) return language.t("session.new.location.servers")
|
||||
|
||||
if (entry.type === "workspace") return isCurrentWorkspace(entry)
|
||||
return language.t("home.projects")
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
const placeholder = () => {
|
||||
if (store.drilled) return language.t("session.new.workspace.search.placeholder")
|
||||
|
||||
// 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],
|
||||
)
|
||||
if (store.picking) return language.t("session.new.location.servers.search")
|
||||
|
||||
return language.t("session.new.project.search")
|
||||
}
|
||||
|
||||
const choosesServer = () => props.project.servers().length > 1
|
||||
|
||||
// Every step opens on its first row; the user's choice wins until the results change.
|
||||
const activeEntry = createMemo(() => entries().find((entry) => entry.id === store.active) ?? entries()[0])
|
||||
|
||||
let resultsRef: HTMLDivElement | undefined
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
@@ -194,32 +186,27 @@ function NewSessionLocationDialog(props: {
|
||||
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}` },
|
||||
back: { query: store.query, active: projectEntryID(project) },
|
||||
})
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
function back() {
|
||||
const project = store.drilled
|
||||
function pick() {
|
||||
setStore({ picking: true, query: "", active: undefined, back: { query: store.query, active: "add" } })
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
function back() {
|
||||
setStore({
|
||||
drilled: undefined,
|
||||
picking: undefined,
|
||||
query: store.back?.query ?? "",
|
||||
active: store.back?.active ?? (project ? `project:${project.server?.key ?? ""}:${project.worktree}` : undefined),
|
||||
active: store.back?.active,
|
||||
back: undefined,
|
||||
})
|
||||
inputRef?.focus()
|
||||
@@ -228,9 +215,22 @@ function NewSessionLocationDialog(props: {
|
||||
function choose(entry: LocationEntry | undefined) {
|
||||
if (!entry) return
|
||||
|
||||
if (entry.type === "add" && choosesServer()) {
|
||||
pick()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.type === "add") {
|
||||
dialog.close()
|
||||
props.project.add(entry.server)
|
||||
props.project.add()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.type === "server") {
|
||||
dialog.close()
|
||||
props.project.add(entry.server.key)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -288,7 +288,17 @@ function NewSessionLocationDialog(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (store.drilled && ((event.key === "ArrowLeft" && atStart) || (event.key === "Backspace" && !target.value))) {
|
||||
if (event.key === "ArrowRight" && atEnd && active?.type === "add" && choosesServer()) {
|
||||
event.preventDefault()
|
||||
pick()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(store.drilled || store.picking) &&
|
||||
((event.key === "ArrowLeft" && atStart) || (event.key === "Backspace" && !target.value))
|
||||
) {
|
||||
event.preventDefault()
|
||||
back()
|
||||
}
|
||||
@@ -305,36 +315,31 @@ function NewSessionLocationDialog(props: {
|
||||
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")
|
||||
}
|
||||
aria-label={placeholder()}
|
||||
placeholder={placeholder()}
|
||||
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>
|
||||
<Switch fallback={<Icon name="magnifying-glass" />}>
|
||||
<Match when={store.drilled}>
|
||||
{(project) => (
|
||||
<BackChip label={language.t("session.new.location.back")} onBack={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>
|
||||
<ServerTag server={project().server} />
|
||||
</BackChip>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={store.picking}>
|
||||
<BackChip label={language.t("session.new.location.back")} onBack={back}>
|
||||
<Icon name="plus" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("session.new.project.add")}</span>
|
||||
</BackChip>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -350,72 +355,74 @@ function NewSessionLocationDialog(props: {
|
||||
</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>
|
||||
<div class="command-palette-group">
|
||||
<div class="command-palette-group-title">{title()}</div>
|
||||
<For each={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"}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={choosesServer()}>
|
||||
<DrillButton label={language.t("session.new.location.servers")} onDrill={pick} />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={entry.type === "server" && entry}>
|
||||
{(item) => (
|
||||
<div class="command-palette-row-main">
|
||||
<Icon name={serverIcon(item().server)} class="command-palette-row-icon" />
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title">{item().server.name}</span>
|
||||
</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>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
@@ -425,11 +432,13 @@ function NewSessionLocationDialog(props: {
|
||||
{language.t("session.new.location.select")}
|
||||
</span>
|
||||
<Show
|
||||
when={store.drilled}
|
||||
when={store.drilled || store.picking}
|
||||
fallback={
|
||||
<span>
|
||||
<Keybind keys={["→"]} variant="neutral" />
|
||||
{language.t("session.new.location.worktrees")}
|
||||
{activeEntry()?.type === "add" && choosesServer()
|
||||
? language.t("session.new.location.servers")
|
||||
: language.t("session.new.location.worktrees")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -455,31 +464,64 @@ function ProjectRow(props: { project: PromptProject; path: string; onDrill?: ()
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title">{displayName(props.project)}</span>
|
||||
<span class="new-session-location-name">
|
||||
<span class="command-palette-title">{displayName(props.project)}</span>
|
||||
<ServerTag server={props.project.server} />
|
||||
</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>
|
||||
<Show when={props.onDrill}>{(onDrill) => <DrillButton label={props.drillLabel} onDrill={onDrill()} />}</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DrillButton(props: { label: string; onDrill: () => void }) {
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
aria-label={props.label}
|
||||
class="new-session-location-drill"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onDrill()
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-right" size="small" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function BackChip(props: { label: string; onBack: () => void; children: JSX.Element }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 max-w-[280px] 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={props.label}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={props.onBack}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The server a remote project lives on; local projects have none. */
|
||||
function ServerTag(props: { server?: PromptServer }) {
|
||||
return (
|
||||
<Show when={tagged(props.server)}>
|
||||
{(server) => (
|
||||
<Badge class="new-session-location-tag" variant="subtle" title={server().label}>
|
||||
<Icon name={serverIcon(server())} size="small" />
|
||||
<span>{server().name}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRow(props: { title: string; value: string; description: string }) {
|
||||
return (
|
||||
<>
|
||||
@@ -503,3 +545,17 @@ function WorkspaceRow(props: { title: string; value: string; description: string
|
||||
function drillable(project: PromptProject) {
|
||||
return !!project.id && project.vcs === "git"
|
||||
}
|
||||
|
||||
function projectEntryID(project: PromptProject) {
|
||||
return `project:${project.server?.key ?? ""}:${project.worktree}`
|
||||
}
|
||||
|
||||
function tagged(server?: PromptServer) {
|
||||
return server?.kind === "local" ? undefined : server
|
||||
}
|
||||
|
||||
function serverIcon(server: PromptServer) {
|
||||
if (server.kind === "local") return "monitor"
|
||||
|
||||
return server.kind === "extension" ? "terminal" : "globe"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { serverName, ServerConnection, useServers } from "@/runtime/server/regis
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { workspaceSelectionDestination } from "@/workspaces/paths"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import type { PromptProjectControls } from "./selector"
|
||||
import type { PromptProjectControls, PromptServer } from "./selector"
|
||||
|
||||
export function createComposerProjectControls(props: { draftId: string; worktree: () => string }) {
|
||||
const servers = useServers()
|
||||
@@ -18,22 +18,33 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
const projectServer = () => serverSDK.server
|
||||
const projectServerCtx = useServerCtx(projectServer)
|
||||
|
||||
// Every server project, one per directory: the ones this client has used first, then the rest, each by recency.
|
||||
const ordered = (ctx: ServerCtx) => {
|
||||
// Every server project, one per directory, marked as used by this client or not; the selector orders them.
|
||||
const listed = (ctx: ServerCtx, server?: PromptServer) => {
|
||||
const used = ctx.projects.used()
|
||||
const ids = new Set(used.map((project) => project.id))
|
||||
|
||||
return [...used, ...ctx.projects.unique().filter((project) => !ids.has(project.id))]
|
||||
return [
|
||||
...used.map((project) => ({ ...project, used: true, server })),
|
||||
...ctx.projects
|
||||
.unique()
|
||||
.filter((project) => !ids.has(project.id))
|
||||
.map((project) => ({ ...project, used: false, server })),
|
||||
]
|
||||
}
|
||||
|
||||
// Servers that are down or still connecting contribute nothing.
|
||||
const ready = createMemo(() =>
|
||||
servers.list.filter((connection) => {
|
||||
if (connection.type === "extension" && connection.state !== "ready") return false
|
||||
|
||||
return global.servers.health[ServerConnection.key(connection)]?.healthy !== false
|
||||
}),
|
||||
)
|
||||
|
||||
const projects = createMemo(() => {
|
||||
if (servers.list.length <= 1) return ordered(projectServerCtx())
|
||||
if (servers.list.length <= 1) return listed(projectServerCtx())
|
||||
|
||||
return servers.list.flatMap((connection) => {
|
||||
const server = { key: ServerConnection.key(connection), name: serverName(connection) }
|
||||
|
||||
return ordered(global.ensureServerCtx(connection)).map((project) => ({ ...project, server }))
|
||||
})
|
||||
return ready().flatMap((connection) => listed(global.ensureServerCtx(connection), promptServer(connection)))
|
||||
})
|
||||
|
||||
const selectProject = (directory: string, serverKey?: string, workspace?: string) => {
|
||||
@@ -74,6 +85,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
|
||||
return createMemo<PromptProjectControls>(() => ({
|
||||
available: projects(),
|
||||
servers: servers.list.length > 1 ? ready().map(promptServer) : undefined,
|
||||
directory: location().directory,
|
||||
projectID: location().current?.project.id,
|
||||
server: servers.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
|
||||
@@ -81,3 +93,12 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
add: addProject,
|
||||
}))
|
||||
}
|
||||
|
||||
function promptServer(connection: ServerConnection.Any): PromptServer {
|
||||
return {
|
||||
key: ServerConnection.key(connection),
|
||||
name: serverName(connection),
|
||||
kind: ServerConnection.local(connection) ? "local" : connection.type === "extension" ? "extension" : "remote",
|
||||
label: connection.label,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,12 @@ describe("new session project selection", () => {
|
||||
})
|
||||
|
||||
test("does not select a matching project ID from another server", () => {
|
||||
const remote = { id: "shared", worktree: "/remote", server: { key: "remote", name: "Remote" } }
|
||||
const local = { id: "shared", worktree: "/local", server: { key: "local", name: "Local" } }
|
||||
const remote = {
|
||||
id: "shared",
|
||||
worktree: "/remote",
|
||||
server: { key: "remote", name: "Remote", kind: "remote" as const },
|
||||
}
|
||||
const local = { id: "shared", worktree: "/local", server: { key: "local", name: "Local", kind: "local" as const } }
|
||||
|
||||
const controls: PromptProjectControls = {
|
||||
available: [remote, local],
|
||||
@@ -51,4 +55,43 @@ describe("new session project selection", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("lists every server's projects in one list: used first, then the rest, each by recent activity", () => {
|
||||
const local = { key: "local", name: "Local Server", kind: "local" as const }
|
||||
const apollo = { key: "http://apollo:4096", name: "apollo", kind: "remote" as const }
|
||||
const entry = (id: string, server: typeof local | typeof apollo, active: number, used: boolean) => ({
|
||||
id,
|
||||
worktree: `/${id}`,
|
||||
server,
|
||||
used,
|
||||
time: { active },
|
||||
})
|
||||
|
||||
// The same directory on two servers stays two rows.
|
||||
const controls: PromptProjectControls = {
|
||||
available: [
|
||||
entry("local-unused", local, 9, false),
|
||||
entry("local-used", local, 2, true),
|
||||
entry("opencode", local, 1, true),
|
||||
entry("apollo-unused", apollo, 5, false),
|
||||
entry("opencode", apollo, 3, true),
|
||||
],
|
||||
directory: "/elsewhere",
|
||||
server: "local",
|
||||
select() {},
|
||||
add() {},
|
||||
}
|
||||
|
||||
createRoot((dispose) => {
|
||||
const controller = createPromptProjectController({ controls: () => controls })
|
||||
expect(controller.projects().map((project) => `${project.server?.name}:${project.id}`)).toEqual([
|
||||
"apollo:opencode",
|
||||
"Local Server:local-used",
|
||||
"Local Server:opencode",
|
||||
"Local Server:local-unused",
|
||||
"apollo:apollo-unused",
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { type Accessor } from "solid-js"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
|
||||
/** A server listed in the location dialog; "local" servers get no tag. */
|
||||
export type PromptServer = {
|
||||
key: string
|
||||
name: string
|
||||
kind: "local" | "remote" | "extension"
|
||||
/** The extension's short badge, e.g. "SSH". */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type PromptProject = {
|
||||
name?: string
|
||||
id?: string
|
||||
@@ -11,11 +20,16 @@ export type PromptProject = {
|
||||
sandboxes?: string[]
|
||||
worktrees?: { directory: string; strategy?: string }[]
|
||||
icon?: { color?: string; url?: string; override?: string }
|
||||
server?: { key: string; name: string }
|
||||
time?: { active: number }
|
||||
/** This client has used the project on its server. */
|
||||
used?: boolean
|
||||
server?: PromptServer
|
||||
}
|
||||
|
||||
export type PromptProjectControls = {
|
||||
available: PromptProject[]
|
||||
/** The ready servers a project can be added to, when more than one server is listed. */
|
||||
servers?: PromptServer[]
|
||||
directory: string
|
||||
projectID?: string
|
||||
server?: string
|
||||
@@ -40,7 +54,16 @@ export function createPromptProjectController(input: { controls: Accessor<Prompt
|
||||
)
|
||||
}
|
||||
|
||||
const selected = () => current() ?? input.controls().available[0]
|
||||
// One list across servers: used projects first, then the rest, each by recent activity.
|
||||
const projects = createMemo(() =>
|
||||
input
|
||||
.controls()
|
||||
.available.toSorted(
|
||||
(a, b) => Number(!!b.used) - Number(!!a.used) || (b.time?.active ?? 0) - (a.time?.active ?? 0),
|
||||
),
|
||||
)
|
||||
|
||||
const selected = () => current() ?? projects()[0]
|
||||
|
||||
const matches = (project: PromptProject, other?: PromptProject) =>
|
||||
!!other && pathKey(other.worktree) === pathKey(project.worktree) && other.server?.key === project.server?.key
|
||||
@@ -49,15 +72,8 @@ export function createPromptProjectController(input: { controls: Accessor<Prompt
|
||||
selected,
|
||||
isSelected: (project: PromptProject) => matches(project, selected()),
|
||||
empty: () => input.controls().available.length === 0,
|
||||
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,
|
||||
),
|
||||
projects,
|
||||
servers: () => input.controls().servers ?? [],
|
||||
labels: {
|
||||
add: () => language.t("session.new.project.add"),
|
||||
new: () => language.t("session.new.project.new"),
|
||||
|
||||
@@ -1030,6 +1030,8 @@ export const dict = {
|
||||
"session.new.location.select": "Select",
|
||||
"session.new.location.worktrees": "Worktrees",
|
||||
"session.new.location.back": "Back to projects",
|
||||
"session.new.location.servers": "Servers",
|
||||
"session.new.location.servers.search": "Search servers",
|
||||
"settings.tab.workspaces": "Worktrees",
|
||||
"settings.workspaces.description": "Review worktrees and manage disk usage",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.command-palette-title,
|
||||
.command-palette-description {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user