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:
Dax Raad
2026-10-19 10:30:00 -04:00
Unverified
parent 8b3bc17ee3
commit 97168420ed
8 changed files with 465 additions and 213 deletions
@@ -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;
}
+243 -187
View File
@@ -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"),
+2
View File
@@ -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%;
}