mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
feat(app): refine project and worktree picker (#59397)
This commit is contained in:
committed by
GitHub
Unverified
parent
4a85158e27
commit
115113931c
@@ -146,31 +146,39 @@ test("Search commands lists only commands and runs the chosen one", async ({ pag
|
||||
await expect(page).toHaveURL("/settings")
|
||||
})
|
||||
|
||||
test("Search projects opens a new session's project picker from a session and from Home", async ({ page }) => {
|
||||
test("Search projects opens the full location picker from a session, draft, and Home", async ({ page }) => {
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
const picker = page.getByRole("combobox", { name: "Search projects", exact: true })
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
await input.fill("search projects")
|
||||
await dialog.getByRole("option", { name: /^Search projects/ }).click()
|
||||
await openPaletteWorkspace(page)
|
||||
const shortcut = (await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta+Shift+O" : "Control+Shift+O"
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(picker).toBeFocused()
|
||||
// Just the projects: no "Add project", no worktrees and no key hints.
|
||||
const projects = page.getByRole("dialog").getByRole("option")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveText(/command-palette/)
|
||||
await expect(projects.getByRole("button", { name: "Worktrees", exact: true })).toHaveCount(0)
|
||||
await expect(page.locator(".new-session-location-hints")).toHaveCount(0)
|
||||
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
|
||||
await picker.fill("command-palette")
|
||||
await expect(projects).toHaveCount(2)
|
||||
await expect(projects.getByRole("button", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
await picker.press("ArrowRight")
|
||||
await expect(page.getByRole("option", { name: /^New worktree/ })).toBeVisible()
|
||||
await page.getByRole("combobox").press("Escape")
|
||||
await picker.press("Escape")
|
||||
await expect(picker).toHaveCount(0)
|
||||
await expect(editor).toBeFocused()
|
||||
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(picker).toBeFocused()
|
||||
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
|
||||
await picker.press("Escape")
|
||||
|
||||
await page.goto("/")
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
|
||||
).toBeEnabled()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+O")
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(picker).toBeFocused()
|
||||
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
|
||||
})
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -179,7 +179,7 @@ test("Home lists the used server projects by recent activity, hides them, and li
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
|
||||
await expect(view.rows).toHaveText(["oldest"])
|
||||
|
||||
// The location dialog lists every server project once: used ones first, then the rest, each by recency.
|
||||
// Closing a project moves it out of the added list, but it remains discoverable in the server inventory.
|
||||
await page
|
||||
.locator('[data-component="home-project-row"]')
|
||||
.filter({ hasText: "oldest" })
|
||||
@@ -188,13 +188,9 @@ test("Home lists the used server projects by recent activity, hides them, and li
|
||||
.click()
|
||||
await newSessionLocation(page).click()
|
||||
const options = page.getByRole("dialog").getByRole("option")
|
||||
await expect(options.locator(".command-palette-title")).toHaveText([
|
||||
"Add project",
|
||||
"oldest",
|
||||
"fresh",
|
||||
"unused",
|
||||
"recent",
|
||||
])
|
||||
await expect(options.locator(".command-palette-title")).toHaveText(["oldest", "Other projects", "Add project"])
|
||||
await options.filter({ hasText: "Other projects" }).click()
|
||||
await expect(options.locator(".command-palette-title")).toHaveText(["fresh", "unused", "recent"])
|
||||
await options.filter({ hasText: "C:/OpenCode/recent" }).click()
|
||||
await expect(newSessionLocation(page)).toContainText("recent")
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
|
||||
@@ -51,6 +51,29 @@ test("the new session screen does not show session details", async ({ page }) =>
|
||||
await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const row of [
|
||||
{
|
||||
name: "POSIX",
|
||||
directory: "/Users/alice/.local/share/opencode/worktree/123456/my-project",
|
||||
pattern: /^\/Users\/.*…\/123456\/my-project$/,
|
||||
},
|
||||
{
|
||||
name: "Windows",
|
||||
directory: "C:\\Users\\alice\\Documents\\opencode\\worktree\\123456\\my-project",
|
||||
pattern: /^C:\\.*…\\123456\\my-project$/,
|
||||
},
|
||||
]) {
|
||||
test(`the location path keeps its start and final two segments on ${row.name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 600, height: 700 })
|
||||
await openDraft(page, { name: "A project with a long name", directory: row.directory })
|
||||
const path = page.locator('[data-slot="new-session-location-path"]')
|
||||
await expect(path).toHaveText(row.pattern)
|
||||
await expect.poll(() => path.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true)
|
||||
await path.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText(row.directory)
|
||||
})
|
||||
}
|
||||
|
||||
test("the combined location picker shows checkout branches and chooses the base for a new worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -60,7 +83,7 @@ test("the combined location picker shows checkout branches and chooses the base
|
||||
|
||||
const view = await openWorktreeDraft(page, {
|
||||
name: "BranchContext",
|
||||
project: { name: "OpenCode" },
|
||||
project: { name: "Custom project" },
|
||||
vcs: (directory) => ({
|
||||
current: directory === existing ? "model-picker-dialog" : "timeline-quotes",
|
||||
default: "main",
|
||||
@@ -126,7 +149,12 @@ test("the combined location picker shows checkout branches and chooses the base
|
||||
await select(/^stellar-orchid /)
|
||||
await expect(screen.getByText("model-picker-dialog", { exact: true })).toBeVisible()
|
||||
await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0)
|
||||
await select(/^BranchContext /)
|
||||
await newSessionLocation(page).click()
|
||||
await page.getByRole("combobox", { name: "Search projects", exact: true }).press("ArrowRight")
|
||||
await page.getByRole("combobox", { name: "Search worktrees", exact: true }).fill("Custom project")
|
||||
await expect(page.getByRole("option")).toHaveCount(1)
|
||||
await page.getByRole("option", { name: /Custom project C:\/OpenCode\/BranchContext/ }).click()
|
||||
await expect(view.editor).toBeFocused()
|
||||
await expect(screen.getByText("timeline-quotes", { exact: true })).toBeVisible()
|
||||
await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0)
|
||||
await select(/^New worktree/)
|
||||
@@ -649,9 +677,7 @@ 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,
|
||||
}) => {
|
||||
test("the location dialog separates added projects from server projects and tags the remote ones", async ({ page }) => {
|
||||
const apollo = "http://apollo.test:4096"
|
||||
|
||||
const entry = (id: string, directory: string, active: number) =>
|
||||
@@ -698,36 +724,33 @@ test("the location dialog lists every ready server's projects in one list and ta
|
||||
await expect(search).toBeFocused()
|
||||
const options = dialog.getByRole("option")
|
||||
const add = options.filter({ hasText: "Add project" })
|
||||
await expect(add.locator('[data-component="keybind-v2"]')).toContainText("O")
|
||||
const rows = options.filter({ has: page.locator(".new-session-location-name") })
|
||||
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 })
|
||||
const heading = dialog.getByText("Projects", { exact: true })
|
||||
const addButton = dialog.getByRole("button", { name: "Add project", 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"])
|
||||
// "Add project" is the first row of the scrolling list, above the Projects heading, yet the first project is still
|
||||
// the opening row, not the draft's current project (the second row).
|
||||
await expect(dialog.locator("[data-scrollable]").getByRole("option").first()).toHaveText("Add project")
|
||||
await expect(rows).toHaveCount(4)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await add.boundingBox()
|
||||
const title = await heading.boundingBox()
|
||||
|
||||
return row && title ? row.y + row.height <= title.y : false
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
// The search field also ends with an "Add project" button, next in the Tab order.
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(addButton).toBeFocused()
|
||||
await search.focus()
|
||||
const back = dialog.getByRole("button", { name: "Back to projects", exact: true })
|
||||
// Only the projects added to this client open expanded; other ready-server projects remain discoverable.
|
||||
await expect(names).toHaveText(["opencodeapollo", "opencode"])
|
||||
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"])
|
||||
const others = options.filter({ hasText: "Other projects" })
|
||||
await expect(others).toBeVisible()
|
||||
await expect(others.locator(".new-session-location-count")).toHaveText("2")
|
||||
await expect(dialog.locator(".command-palette-group-title")).toHaveCount(0)
|
||||
await expect(options.locator(".command-palette-title")).toHaveText([
|
||||
"opencode",
|
||||
"opencode",
|
||||
"Other projects",
|
||||
"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")
|
||||
|
||||
// The picker shares the palette's full-width search, compact rows, and single-line titles.
|
||||
await expect(rows.nth(0)).toHaveCSS("height", "36px")
|
||||
await expect(rows.nth(0)).toHaveCSS("display", "flex")
|
||||
await expect(options.nth(0)).toHaveCSS("height", "28px")
|
||||
await expect(dialog.locator(".palette-menu-search [data-component='text-input-v2']")).toHaveCSS("height", "28px")
|
||||
await expect(dialog.locator(".new-session-location-hints")).toHaveCount(0)
|
||||
await expect(dialog.locator("..")).toHaveCSS("width", "480px")
|
||||
await expect(options.nth(0)).toHaveCSS("display", "flex")
|
||||
await expect(names.nth(0).locator(".command-palette-title")).toHaveCSS("font-size", "13px")
|
||||
await expect(names.nth(0).locator(".command-palette-title")).toHaveCSS("white-space", "nowrap")
|
||||
await expect(rows.nth(0)).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
@@ -746,65 +769,99 @@ test("the location dialog lists every ready server's projects in one list and ta
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
// Searching narrows the projects and keeps "Add project" first; with no match it is the active row.
|
||||
await others.click()
|
||||
await expect(names).toHaveText(["console", "sstapollo"])
|
||||
await expect(back).toHaveText("Other projects")
|
||||
await search.fill("no-project-matches")
|
||||
await expect(options).toHaveCount(0)
|
||||
await expect(back).toBeVisible()
|
||||
await search.fill("")
|
||||
await expect(names).toHaveText(["console", "sstapollo"])
|
||||
await search.press("ArrowLeft")
|
||||
await expect(names).toHaveText(["opencodeapollo", "opencode"])
|
||||
await search.press("ArrowRight")
|
||||
await expect(names).toHaveText(["console", "sstapollo"])
|
||||
await search.press("Backspace")
|
||||
await search.press("Enter")
|
||||
await expect(names).toHaveText(["console", "sstapollo"])
|
||||
await search.press("Escape")
|
||||
await expect(names).toHaveText(["opencodeapollo", "opencode"])
|
||||
await others.click()
|
||||
await back.click()
|
||||
await expect(names).toHaveText(["opencodeapollo", "opencode"])
|
||||
|
||||
await search.fill("apollo")
|
||||
await expect(names).toHaveText(["opencodeapollo", "sstapollo"])
|
||||
await expect(options.first()).toHaveText("Add project")
|
||||
await search.fill("nothing matches")
|
||||
await expect(options).toHaveText(["Add project"])
|
||||
await expect(add).toHaveAttribute("aria-selected", "true")
|
||||
await expect(heading).toHaveCount(0)
|
||||
await expect(dialog.locator(".command-palette-group-title")).toHaveText("Other projects")
|
||||
|
||||
// The worktree step keeps the server's tag beside the project.
|
||||
// The worktree step keeps the project's avatar and server tag in its main-checkout row.
|
||||
await search.fill("opencode")
|
||||
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"])
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ArrowRight")
|
||||
// The worktree step has no "Add project".
|
||||
await expect(add).toHaveCount(0)
|
||||
await expect(addButton).toHaveCount(0)
|
||||
await expect(chip).toContainText("opencode")
|
||||
await expect(chip.locator('[data-component="tag"]')).toHaveText("apollo")
|
||||
await expect(back).toHaveText("Run session in")
|
||||
await expect(options.nth(0).locator('[data-component="tag"]')).toHaveText("apollo")
|
||||
await expect(options.locator(".command-palette-title")).toHaveText(["opencode", "New worktree", "opencode-beta"])
|
||||
await expect(dialog.getByRole("separator")).toHaveCount(1)
|
||||
await search.fill("opencode-beta")
|
||||
await expect(options).toHaveCount(1)
|
||||
await expect(dialog.getByRole("separator")).toHaveCount(0)
|
||||
await search.fill("no-worktree-matches")
|
||||
await expect(options).toHaveCount(0)
|
||||
await expect(dialog.getByRole("separator")).toHaveCount(0)
|
||||
await search.fill("")
|
||||
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
// The chip's avatar sits on the same column as the row icons below it.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const avatar = await chip.locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
const icon = await options.nth(0).locator(".command-palette-row-icon").boundingBox()
|
||||
|
||||
return avatar && icon ? Math.abs(avatar.x - icon.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(0.5)
|
||||
await page.keyboard.press("Backspace")
|
||||
await expect(search).toHaveValue("opencode")
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// ArrowUp from the first project reaches "Add project" above it; adding a project picks one of the ready servers
|
||||
// first, then browses that server.
|
||||
// Adding a project picks one of the ready servers first, then browses that server.
|
||||
await search.fill("")
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await expect(add).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await expect(chip).toHaveText("Add project")
|
||||
await expect(addButton).toHaveCount(0)
|
||||
// The search field's button starts the same flow.
|
||||
await page.keyboard.press("Backspace")
|
||||
await expect(addButton).toBeVisible()
|
||||
await page.keyboard.press("Tab")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(chip).toHaveText("Add project")
|
||||
await expect(options.nth(3)).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press((await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta+o" : "Control+o")
|
||||
await expect(back).toHaveText("Servers")
|
||||
// The server the app runs beside shows its hostname and "This computer" instead of its address.
|
||||
await expect(options).toHaveText(["hailmaryThis computer", "apollo"])
|
||||
await back.click()
|
||||
await options.filter({ hasText: "Add project" }).click()
|
||||
await expect(options).toHaveText(["hailmaryThis computer", "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)
|
||||
})
|
||||
|
||||
for (const binding of ["mod+alt+p", "none"]) {
|
||||
test(`the location dialog honors the Open project shortcut override: ${binding}`, async ({ page }) => {
|
||||
await openDraft(page, {
|
||||
name: "ProjectShortcut",
|
||||
fileList: () => [],
|
||||
seed: { settings: { keybinds: { "project.open": binding } } },
|
||||
})
|
||||
await newSessionLocation(page).click()
|
||||
const search = page.getByRole("combobox", { name: "Search projects", exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
const keys = page.getByRole("option", { name: /^Add project/ }).locator('[data-component="keybind-v2"]')
|
||||
|
||||
if (binding === "none") await expect(keys).toHaveCount(0)
|
||||
|
||||
if (binding !== "none") await expect(keys).toContainText("P")
|
||||
|
||||
const modifier = (await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta" : "Control"
|
||||
await search.press(`${modifier}+o`)
|
||||
await search.fill("still searching")
|
||||
await expect(search).toHaveValue("still searching")
|
||||
|
||||
if (binding === "none") return
|
||||
|
||||
await search.press(`${modifier}+Alt+p`)
|
||||
await expect(page.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
const accentRoot = "C:/OpenCode/WorkspaceAccent"
|
||||
|
||||
const accentWorkspace = `${accentRoot}/.worktrees/feature`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import type { Schema } from "effect"
|
||||
import {
|
||||
NO_PROVIDER,
|
||||
REMOTE_SERVER,
|
||||
@@ -20,9 +21,9 @@ export type WorkspaceInput = Partial<Omit<MockServerConfig, "directory" | "proje
|
||||
name: string
|
||||
directory?: string
|
||||
// Merged over the default project.
|
||||
project?: Record<string, unknown>
|
||||
project?: Partial<Parameters<typeof project>[0]>
|
||||
// Defaults to one session titled `name`. Omitted fields come from `session()`.
|
||||
sessions?: ({ id: string; title?: string } & Record<string, unknown>)[]
|
||||
sessions?: (Partial<Parameters<typeof session>[0]> & { id: string })[]
|
||||
// Merged over the default seed: the project used before, and a tab for every session.
|
||||
seed?: SeedInput
|
||||
}
|
||||
@@ -31,7 +32,7 @@ export type WorkspaceInput = Partial<Omit<MockServerConfig, "directory" | "proje
|
||||
export async function mockWorkspace(page: Page, input: WorkspaceInput) {
|
||||
const slug = input.name.toLowerCase().replace(/[^a-z0-9]+/g, "_")
|
||||
const directory = input.directory ?? `C:/OpenCode/${input.name}`
|
||||
const projectID = typeof input.project?.id === "string" ? input.project.id : `proj_${slug}`
|
||||
const projectID = input.project?.id ?? `proj_${slug}`
|
||||
|
||||
const sessions = (input.sessions ?? [{ id: `ses_${slug}`, title: input.name }]).map((item) =>
|
||||
session({ directory, projectID, ...item }),
|
||||
@@ -94,9 +95,9 @@ export function newSessionLocation(page: Page) {
|
||||
export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: unknown }>()
|
||||
const calls: string[] = []
|
||||
const worktreeRequests: { url: URL; body: Record<string, unknown> }[] = []
|
||||
const creates: Record<string, unknown>[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const worktreeRequests: { url: URL; body: Schema.JsonObject }[] = []
|
||||
const creates: Schema.JsonObject[] = []
|
||||
const prompts: { sessionID: string; body: Schema.JsonObject }[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
const url = new URL(request.url())
|
||||
@@ -140,7 +141,9 @@ export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { dr
|
||||
await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT })
|
||||
await newSessionLocation(page).click()
|
||||
// The dialog opens on projects; → lists the current project's worktrees.
|
||||
await page.keyboard.press("ArrowRight")
|
||||
const search = page.getByRole("combobox", { name: "Search projects", exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
await search.press("ArrowRight")
|
||||
await page.getByRole("option", { name: /^New worktree/ }).click()
|
||||
await expect(newSessionLocation(page)).toContainText("New worktree")
|
||||
await expect(editor).toBeEditable()
|
||||
|
||||
@@ -1,7 +1,150 @@
|
||||
[data-component="dialog-v2"]:has(.new-session-location) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"]:has(.new-session-location) [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 24px), 480px);
|
||||
height: auto;
|
||||
max-height: min(calc(100vh - 96px), 480px);
|
||||
min-height: 0;
|
||||
margin-top: max(48px, calc((100vh - 480px) / 2));
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
}
|
||||
|
||||
.new-session-location .palette-menu-search {
|
||||
padding: 6px 6px 0;
|
||||
}
|
||||
|
||||
.new-session-location .palette-menu-search [data-component="text-input-v2"] {
|
||||
height: 28px;
|
||||
padding-inline-end: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.new-session-location .palette-menu-search [data-slot="text-input-v2-value"] {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.new-session-location .palette-menu-search [data-slot="text-input-v2-leading-icon"] {
|
||||
padding-inline-start: 6px;
|
||||
}
|
||||
|
||||
.new-session-location .palette-menu-search [data-slot="text-input-v2-input"]::placeholder {
|
||||
color: var(--v2-text-text-base);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.new-session-location-divider {
|
||||
position: relative;
|
||||
height: 9px;
|
||||
flex-shrink: 0;
|
||||
margin-block: 4px;
|
||||
}
|
||||
|
||||
.new-session-location-divider::after {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 4px;
|
||||
border-top: 0.5px solid var(--v2-border-border-base);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.new-session-location .command-palette-results {
|
||||
gap: 4px;
|
||||
padding: 0 6px 6px;
|
||||
}
|
||||
|
||||
.new-session-location .command-palette-group {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-session-location-action-divider {
|
||||
height: 1px;
|
||||
flex-shrink: 0;
|
||||
margin-block: 2px;
|
||||
margin-inline: -6px;
|
||||
background: var(--v2-border-border-muted);
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
|
||||
.new-session-location .command-palette-group-title {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
color: var(--v2-text-text-muted);
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.new-session-location-back {
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-session-location-back:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.new-session-location .palette-menu-row[data-size="compact"] {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.new-session-location .palette-menu-row:is([data-active], :focus-visible) {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.new-session-location .command-palette-row-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-session-location .command-palette-description {
|
||||
flex: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.new-session-location .new-session-location-workspace .command-palette-description {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05px;
|
||||
}
|
||||
|
||||
.new-session-location-workspace .command-palette-row-icon {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.new-session-location-workspace .command-palette-row-text > .command-palette-title {
|
||||
max-width: 60%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-session-location .new-session-location-option .command-palette-title {
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.new-session-location-other {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.new-session-location-count {
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.new-session-location-drill {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -9,40 +152,12 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* The project name truncates before its server tag. */
|
||||
/* Keep the name legible while the path gives up space first. */
|
||||
.new-session-location-name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
max-width: 55%;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -62,45 +177,17 @@
|
||||
.new-session-location-tag > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
line-height: 16px;
|
||||
line-height: var(--line-height-compact);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.new-session-location-add {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px 0 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.new-session-location-add:is(:hover, :focus-visible) {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.new-session-location-add:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Narrow screens keep only the icon; the tooltip names it. */
|
||||
@media (max-width: 480px) {
|
||||
.new-session-location-add {
|
||||
width: 24px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
@media (max-width: 640px) {
|
||||
.new-session-location .palette-menu-row:has(.command-palette-description) {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.new-session-location-add > span {
|
||||
display: none;
|
||||
.new-session-location-name {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
ProjectAvatar,
|
||||
} from "@opencode/ui/project-avatar"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { createTypeahead } from "@opencode/ui/typeahead"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createEffect, createMemo, createResource, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PromptProject, PromptProjectController, PromptServer } from "@/new-session/project/selector"
|
||||
import type { NewSessionWorkspaceController } from "@/new-session/workspace/controller"
|
||||
@@ -22,6 +22,7 @@ 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 { useCommand } from "@/shell/commands/command"
|
||||
import { workspaceSelectionDirectories } from "@/workspaces/paths"
|
||||
import "@/shell/commands/dialog.css"
|
||||
import "@/shell/palette-menu.css"
|
||||
@@ -30,13 +31,13 @@ import "./dialog.css"
|
||||
type LocationEntry =
|
||||
| { id: string; type: "project"; project: PromptProject }
|
||||
| { id: string; type: "add" }
|
||||
| { id: string; type: "other" }
|
||||
| { id: string; type: "server"; server: PromptServer }
|
||||
| { id: string; type: "workspace"; value: string; directory: string }
|
||||
|
||||
/**
|
||||
* 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. Search projects passes
|
||||
* `projectsOnly` for just the project list: no "Add project", no worktrees and no key hints, like the other palettes.
|
||||
* Opens the new-session location dialog: added projects across ready servers, a separate list of other server projects,
|
||||
* and the worktree destinations for each Git project. Adding a project picks the server when there are several.
|
||||
*/
|
||||
export function useNewSessionLocationDialog(input: {
|
||||
project: PromptProjectController
|
||||
@@ -45,14 +46,13 @@ export function useNewSessionLocationDialog(input: {
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
|
||||
return (options?: { onWorkspace?: (value: string) => void; projectsOnly?: boolean }) => {
|
||||
return (options?: { onWorkspace?: (value: string) => void }) => {
|
||||
void dialog.show(
|
||||
() => (
|
||||
<NewSessionLocationDialog
|
||||
project={input.project}
|
||||
workspace={input.workspace}
|
||||
onWorkspace={options?.onWorkspace}
|
||||
projectsOnly={options?.projectsOnly}
|
||||
/>
|
||||
),
|
||||
input.onDone,
|
||||
@@ -64,10 +64,10 @@ function NewSessionLocationDialog(props: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
onWorkspace?: (value: string) => void
|
||||
projectsOnly?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -76,10 +76,12 @@ function NewSessionLocationDialog(props: {
|
||||
drilled?: PromptProject
|
||||
/** Picking the server to add a project to. */
|
||||
picking?: boolean
|
||||
/** Browsing server projects this client has not added. */
|
||||
others: boolean
|
||||
query: string
|
||||
/** The projects view to restore when stepping back out of a project or the server step. */
|
||||
back?: { query: string; active: string }
|
||||
}>({ query: "" })
|
||||
back?: { query: string; active: string; others: boolean }
|
||||
}>({ query: "", others: false })
|
||||
|
||||
const connection = (project: PromptProject) =>
|
||||
project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server
|
||||
@@ -124,18 +126,28 @@ function NewSessionLocationDialog(props: {
|
||||
return !query || values.some((value) => value?.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const addEntry: LocationEntry = { id: "add", type: "add" }
|
||||
const otherCount = createMemo(() => props.project.projects().filter((project) => !project.used).length)
|
||||
|
||||
// Remote projects also match their server's name, which their tag shows. "Add project" is shown first, above the
|
||||
// heading, but navigates last, so the first project stays the opening row and the arrow keys' wrap-around still
|
||||
// follows the rows on screen.
|
||||
const projectEntries = createMemo<LocationEntry[]>(() => [
|
||||
...props.project
|
||||
.projects()
|
||||
.filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name))
|
||||
.map((project) => ({ id: projectEntryID(project), type: "project" as const, project })),
|
||||
...(props.projectsOnly ? [] : [addEntry]),
|
||||
])
|
||||
const projectEntries = createMemo<LocationEntry[]>(() => {
|
||||
return [
|
||||
...props.project
|
||||
.projects()
|
||||
.filter((project) => (store.others ? !project.used : project.used || !!store.query.trim()))
|
||||
// Remote projects also match their server's name, which their tag shows.
|
||||
.filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name))
|
||||
.map((project) => ({ id: projectEntryID(project), type: "project" as const, project })),
|
||||
...(!store.others
|
||||
? [
|
||||
...(!store.query.trim() && otherCount() ? [{ id: "other", type: "other" as const }] : []),
|
||||
{ id: "add", type: "add" as const },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
const firstOther = createMemo(
|
||||
() => projectEntries().find((entry) => entry.type === "project" && !entry.project.used)?.id,
|
||||
)
|
||||
|
||||
const serverEntries = createMemo<LocationEntry[]>(() =>
|
||||
props.project
|
||||
@@ -170,16 +182,16 @@ function NewSessionLocationDialog(props: {
|
||||
return projectEntries()
|
||||
}
|
||||
|
||||
const drillable = (project: PromptProject) => !props.projectsOnly && !!project.id && project.vcs === "git"
|
||||
|
||||
const grouped = () => entries().filter((entry) => entry !== addEntry)
|
||||
const drillable = (project: PromptProject) => !!project.id && project.vcs === "git"
|
||||
|
||||
const title = () => {
|
||||
if (store.drilled) return language.t("session.new.workspace.runIn")
|
||||
|
||||
if (store.picking) return language.t("session.new.location.servers")
|
||||
|
||||
return language.t("home.projects")
|
||||
if (store.others) return language.t("session.new.location.otherProjects")
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const placeholder = () => {
|
||||
@@ -191,6 +203,21 @@ function NewSessionLocationDialog(props: {
|
||||
}
|
||||
|
||||
const choosesServer = () => props.project.servers().length > 1
|
||||
command.register("new-session-location", () => [
|
||||
{
|
||||
id: "project.open",
|
||||
title: language.t("command.project.open"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+o",
|
||||
onSelect: () => choose({ id: "add", type: "add" }),
|
||||
},
|
||||
])
|
||||
createEventListener(document, "keydown", (event) => {
|
||||
if (event.isComposing || !command.matches("project.open", event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
command.trigger("project.open", "keybind")
|
||||
})
|
||||
|
||||
// Every step opens on its first row; the user's choice holds while it stays in the results.
|
||||
const typeahead = createTypeahead<LocationEntry>({
|
||||
@@ -215,7 +242,7 @@ function NewSessionLocationDialog(props: {
|
||||
})
|
||||
|
||||
function workspaceTitle(project: PromptProject, value: string) {
|
||||
if (value === "main") return getFilename(project.worktree)
|
||||
if (value === "main") return displayName(project)
|
||||
|
||||
if (value === "create") return language.t("workspace.new")
|
||||
|
||||
@@ -223,18 +250,33 @@ function NewSessionLocationDialog(props: {
|
||||
}
|
||||
|
||||
function drill(project: PromptProject) {
|
||||
setStore({ drilled: project, query: "", back: { query: store.query, active: projectEntryID(project) } })
|
||||
setStore({
|
||||
drilled: project,
|
||||
query: "",
|
||||
back: { query: store.query, active: projectEntryID(project), others: store.others },
|
||||
})
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
function pick() {
|
||||
setStore({ picking: true, query: "", back: { query: store.query, active: "add" } })
|
||||
setStore({
|
||||
drilled: undefined,
|
||||
picking: true,
|
||||
query: "",
|
||||
back: { query: store.query, active: "add", others: store.others },
|
||||
})
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
function back() {
|
||||
const active = store.back?.active
|
||||
setStore({ drilled: undefined, picking: undefined, query: store.back?.query ?? "", back: undefined })
|
||||
const active = store.drilled || store.picking ? store.back?.active : "other"
|
||||
setStore({
|
||||
drilled: undefined,
|
||||
picking: undefined,
|
||||
others: store.drilled || store.picking ? (store.back?.others ?? false) : false,
|
||||
query: store.drilled || store.picking ? (store.back?.query ?? "") : "",
|
||||
back: undefined,
|
||||
})
|
||||
typeahead.setActive(active)
|
||||
inputRef?.focus()
|
||||
}
|
||||
@@ -242,6 +284,13 @@ function NewSessionLocationDialog(props: {
|
||||
function choose(entry: LocationEntry | undefined) {
|
||||
if (!entry) return
|
||||
|
||||
if (entry.type === "other") {
|
||||
setStore({ others: true, query: "" })
|
||||
inputRef?.focus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.type === "add" && choosesServer()) {
|
||||
pick()
|
||||
|
||||
@@ -286,26 +335,36 @@ function NewSessionLocationDialog(props: {
|
||||
const atEnd = target.selectionStart === target.value.length && target.selectionEnd === target.value.length
|
||||
const atStart = target.selectionStart === 0 && target.selectionEnd === 0
|
||||
const active = activeEntry()
|
||||
const forward = language.direction() === "rtl" ? "ArrowLeft" : "ArrowRight"
|
||||
const backward = language.direction() === "rtl" ? "ArrowRight" : "ArrowLeft"
|
||||
|
||||
if (event.key === "ArrowRight" && atEnd && active?.type === "project" && drillable(active.project)) {
|
||||
if (event.key === forward && atEnd && active?.type === "project" && drillable(active.project)) {
|
||||
event.preventDefault()
|
||||
drill(active.project)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight" && atEnd && active?.type === "add" && choosesServer()) {
|
||||
if (event.key === forward && atEnd && active?.type === "add" && choosesServer()) {
|
||||
event.preventDefault()
|
||||
pick()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (active?.type === "other" && atEnd && event.key === forward) {
|
||||
event.preventDefault()
|
||||
choose(active)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(store.drilled || store.picking) &&
|
||||
((event.key === "ArrowLeft" && atStart) || (event.key === "Backspace" && !target.value))
|
||||
(store.drilled || store.picking || store.others) &&
|
||||
((event.key === backward && atStart) || (event.key === "Backspace" && !target.value) || event.key === "Escape")
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
back()
|
||||
|
||||
return
|
||||
@@ -314,74 +373,8 @@ function NewSessionLocationDialog(props: {
|
||||
typeahead.onKeyDown(event)
|
||||
}
|
||||
|
||||
const option = (entry: LocationEntry) => (
|
||||
<button
|
||||
type="button"
|
||||
id={typeahead.optionID(entry)}
|
||||
class="command-palette-row palette-menu-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
|
||||
typeahead.setActive(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 palette-menu-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 palette-menu-title">{item().server.name}</span>
|
||||
<Show when={item().server.hint}>
|
||||
{(hint) => <span class="command-palette-description">{hint()}</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>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog class="command-palette" size="large">
|
||||
<Dialog class="command-palette new-session-location" size="large">
|
||||
<DialogBody class="command-palette-body">
|
||||
<div class="palette-menu-search">
|
||||
<TextInput
|
||||
@@ -393,100 +386,176 @@ function NewSessionLocationDialog(props: {
|
||||
appearance="large"
|
||||
aria-label={placeholder()}
|
||||
placeholder={placeholder()}
|
||||
leadingIcon={
|
||||
<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)}
|
||||
generated={getProjectAvatarGenerated(project())}
|
||||
/>
|
||||
<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>
|
||||
}
|
||||
leadingIcon={<Icon name="magnifying-glass" />}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
data-owns-escape={store.drilled || store.picking || store.others ? "" : undefined}
|
||||
aria-controls={typeahead.listID}
|
||||
aria-activedescendant={typeahead.activeID()}
|
||||
onInput={(event) => setStore("query", event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
trailing={
|
||||
<Show when={!props.projectsOnly && !store.drilled && !store.picking}>
|
||||
<Tooltip placement="top" value={language.t("session.new.project.add")}>
|
||||
<button
|
||||
type="button"
|
||||
class="new-session-location-add"
|
||||
aria-label={language.t("session.new.project.add")}
|
||||
onClick={() => choose(addEntry)}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span>{language.t("session.new.project.add")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="new-session-location-divider" aria-hidden="true" />
|
||||
<div class="command-palette-scroll" data-scrollable ref={(el) => (resultsRef = el)}>
|
||||
<div id={typeahead.listID} class="command-palette-results" role="listbox">
|
||||
<Show
|
||||
when={entries().length > 0}
|
||||
fallback={
|
||||
// The worktree list may still be loading; hold the row's height without flashing a label.
|
||||
<div class="command-palette-state" classList={{ invisible: !!store.drilled && listed.loading }}>
|
||||
{language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* One group, so the heading follows "Add project" at the row rhythm rather than the gap between groups. */}
|
||||
<div class="command-palette-group">
|
||||
<Show when={entries().includes(addEntry)}>{option(addEntry)}</Show>
|
||||
<Show when={grouped().length > 0}>
|
||||
<div class="command-palette-group-title">{title()}</div>
|
||||
<For each={grouped()}>{option}</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="command-palette-group">
|
||||
<Show when={title()}>
|
||||
{(value) => (
|
||||
<button
|
||||
type="button"
|
||||
class="command-palette-group-title new-session-location-back"
|
||||
aria-label={language.t("session.new.location.back")}
|
||||
onClick={back}
|
||||
>
|
||||
{value()}
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={entries().length > 0}
|
||||
fallback={
|
||||
// The worktree list may still be loading; hold the row's height without flashing a label.
|
||||
<div class="command-palette-state" classList={{ invisible: !!store.drilled && listed.loading }}>
|
||||
{language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={entries()}>
|
||||
{(entry, index) => (
|
||||
<>
|
||||
<Show
|
||||
when={
|
||||
store.query.trim() &&
|
||||
!store.drilled &&
|
||||
!store.picking &&
|
||||
!store.others &&
|
||||
entry.id === firstOther()
|
||||
}
|
||||
>
|
||||
<div class="command-palette-group-title">
|
||||
{language.t("session.new.location.otherProjects")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
index() > 0 &&
|
||||
(entry.type === "add" ||
|
||||
(entry.type === "workspace" &&
|
||||
entry.value !== "main" &&
|
||||
entry.value !== "create" &&
|
||||
["main", "create"].includes(entries()[index() - 1]!.id)))
|
||||
}
|
||||
>
|
||||
<div class="new-session-location-action-divider" role="separator" />
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
id={typeahead.optionID(entry)}
|
||||
class="command-palette-row palette-menu-row"
|
||||
data-size="compact"
|
||||
classList={{
|
||||
"new-session-location-option": entry.type === "other" || entry.type === "add",
|
||||
"new-session-location-workspace": entry.type === "workspace",
|
||||
}}
|
||||
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
|
||||
typeahead.setActive(entry.id)
|
||||
}}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onFocus={() => typeahead.setActive(entry.id)}
|
||||
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 === "other"}>
|
||||
<div class="command-palette-row-main new-session-location-other">
|
||||
<Icon name="menu" class="command-palette-row-icon" />
|
||||
<span class="command-palette-title palette-menu-title">
|
||||
{language.t("session.new.location.otherProjects")}
|
||||
</span>
|
||||
<span class="new-session-location-count">{otherCount()}</span>
|
||||
</div>
|
||||
<DrillButton
|
||||
label={language.t("session.new.location.otherProjects")}
|
||||
onDrill={() => choose(entry)}
|
||||
/>
|
||||
</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 palette-menu-title">
|
||||
{language.t("session.new.project.add")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={command.keybindParts("project.open").length}>
|
||||
<Keybind keys={command.keybindParts("project.open")} variant="neutral" class="shrink-0" />
|
||||
</Show>
|
||||
<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 palette-menu-title">{item().server.name}</span>
|
||||
<Show when={item().server.hint}>
|
||||
{(hint) => <span class="command-palette-description">{hint()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={entry.type === "workspace" && store.drilled && entry}>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item().value === "main"}
|
||||
fallback={
|
||||
<WorkspaceRow
|
||||
title={workspaceTitle(store.drilled!, item().value)}
|
||||
value={item().value}
|
||||
description={
|
||||
item().value === "create"
|
||||
? language.t("session.new.workspace.new.tooltip")
|
||||
: item().directory
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ProjectRow
|
||||
project={store.drilled!}
|
||||
path={item().directory}
|
||||
drillLabel={language.t("session.new.location.worktrees")}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!props.projectsOnly}>
|
||||
<div class="new-session-location-hints">
|
||||
<span>
|
||||
<Keybind keys={["↵"]} variant="neutral" />
|
||||
{language.t("session.new.location.select")}
|
||||
</span>
|
||||
<Show
|
||||
when={store.drilled || store.picking}
|
||||
fallback={
|
||||
<span>
|
||||
<Keybind keys={["→"]} variant="neutral" />
|
||||
{activeEntry()?.type === "add" && choosesServer()
|
||||
? language.t("session.new.location.servers")
|
||||
: language.t("session.new.location.worktrees")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Keybind keys={["←"]} variant="neutral" />
|
||||
{language.t("session.new.location.back")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -529,27 +598,11 @@ function DrillButton(props: { label: string; onDrill: () => void }) {
|
||||
props.onDrill()
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-right" size="small" />
|
||||
<Icon name="chevron-right" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// The negative start margin cancels the chip's padding, so its icon sits where the search icon would and lines up with
|
||||
// the row icons, leaving the chip the same 6px inset from the field on every side.
|
||||
function BackChip(props: { label: string; onBack: () => void; children: JSX.Element }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="-ms-1.5 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 project lives on; projects on the current server have none. */
|
||||
function ServerTag(props: { server?: PromptServer }) {
|
||||
return (
|
||||
@@ -576,11 +629,7 @@ function WorkspaceRow(props: { title: string; value: string; description: string
|
||||
return (
|
||||
<>
|
||||
<div class="command-palette-row-main">
|
||||
<Icon
|
||||
name={workspaceIcon(props.value)}
|
||||
class="command-palette-row-icon"
|
||||
classList={{ "!text-v2-icon-icon-accent": props.value !== "main" && props.value !== "create" }}
|
||||
/>
|
||||
<Icon name={workspaceIcon(props.value)} class="command-palette-row-icon" />
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title palette-menu-title">{props.title}</span>
|
||||
<span class="command-palette-description" dir="ltr">
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
restoreFocus: model.restoreFocus,
|
||||
project: {
|
||||
empty: project.empty,
|
||||
open: () => location({ projectsOnly: true }),
|
||||
open: () => location(),
|
||||
},
|
||||
workspace: {
|
||||
enabled: workspace.bar.visible,
|
||||
@@ -67,7 +67,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
if (!ready) return
|
||||
|
||||
// Search projects opened this draft; the picker returns focus to the composer when it closes.
|
||||
if (layout.draft.projectSearch.take()) location({ projectsOnly: true })
|
||||
if (layout.draft.projectSearch.take()) location()
|
||||
else model.restoreFocus()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Slash } from "@opencode/ui/slash"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { Show, createMemo, createSignal, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
@@ -221,9 +223,7 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"];
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={open}
|
||||
>
|
||||
<span class="truncate">
|
||||
{language.t("home.providerTip")}
|
||||
</span>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
@@ -249,6 +249,29 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"];
|
||||
)
|
||||
}
|
||||
|
||||
// Keeps as many leading segments as fit, then "…/", then the last two segments. The cut always lands on a slash.
|
||||
function fitPath(value: string, width: number, font: string) {
|
||||
const separator = value.includes("/") ? "/" : "\\"
|
||||
const parts = value.split(separator)
|
||||
const context = document.createElement("canvas").getContext("2d")
|
||||
|
||||
if (!context) return { text: value, minimum: 0 }
|
||||
context.font = font
|
||||
const fits = (text: string) => context.measureText(text).width <= width
|
||||
const first = parts[0] === "" ? 2 : 1
|
||||
|
||||
const candidates = Array.from({ length: Math.max(0, parts.length - 2 - first) }, (_, index) =>
|
||||
[...parts.slice(0, parts.length - 3 - index), "…", ...parts.slice(-2)].join(separator),
|
||||
)
|
||||
|
||||
const minimum = candidates.at(-1) ?? value
|
||||
|
||||
return {
|
||||
text: !width || fits(value) ? value : (candidates.find(fits) ?? minimum),
|
||||
minimum: context.measureText(minimum).width,
|
||||
}
|
||||
}
|
||||
|
||||
/** The composer tray: combined location picker, Git context, and the session's directory. */
|
||||
function NewSessionLocation(props: {
|
||||
project: PromptProject
|
||||
@@ -260,7 +283,8 @@ function NewSessionLocation(props: {
|
||||
onClick: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
const command = useCommand()
|
||||
const [layout, setLayout] = createStore({ width: 0, font: "" })
|
||||
|
||||
const worktree = () => {
|
||||
if (props.workspace === "main") return
|
||||
@@ -276,6 +300,8 @@ function NewSessionLocation(props: {
|
||||
return props.workspace === "main" ? props.root : props.workspace
|
||||
}
|
||||
|
||||
const fitted = createMemo(() => fitPath(path() ?? "", layout.width, layout.font))
|
||||
|
||||
return (
|
||||
<div class="relative flex min-h-9 min-w-0 items-center gap-3 ps-2 pe-1.5 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
|
||||
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-1 py-1.5">
|
||||
@@ -337,22 +363,56 @@ function NewSessionLocation(props: {
|
||||
</div>
|
||||
<Show when={path()}>
|
||||
{(directory) => (
|
||||
<div class="flex min-w-0 flex-1 justify-end pe-1.5 @max-[560px]:hidden">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 justify-end pe-1.5 @max-[560px]:hidden"
|
||||
style={{ "min-width": `min(calc(100% - 12px), ${Math.ceil(fitted().minimum) + 26}px)` }}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={directory()}
|
||||
disabled={!truncated()}
|
||||
class="flex min-w-0"
|
||||
disabled={fitted().text === directory() && fitted().minimum <= layout.width}
|
||||
class="flex min-w-0 flex-1"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))
|
||||
ref={(row) =>
|
||||
createResizeObserver(row, () => {
|
||||
const icon = row.querySelector("[data-slot=new-session-location-icon]")
|
||||
const style = getComputedStyle(row)
|
||||
|
||||
setLayout({
|
||||
width: row.clientWidth - (icon?.clientWidth ?? 0) - parseFloat(style.columnGap),
|
||||
font: style.font,
|
||||
})
|
||||
})
|
||||
}
|
||||
data-slot="new-session-location-path"
|
||||
class="block min-w-0 truncate text-v2-text-text-faint [direction:rtl] [text-align:end]"
|
||||
class="flex min-w-0 flex-1 items-center justify-end gap-1.5"
|
||||
dir="ltr"
|
||||
>
|
||||
<bdi dir="ltr">{directory()}</bdi>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex items-center gap-2">
|
||||
{language.t("session.location.toggle")}
|
||||
<Keybind keys={command.keybindParts("session.location.cycle")} variant="neutral" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="flex shrink-0" data-slot="new-session-location-icon">
|
||||
<Icon
|
||||
name={worktree() ? "outline-worktree" : "monitor"}
|
||||
size="small"
|
||||
class="text-v2-icon-icon-muted"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span
|
||||
data-slot="new-session-location-path"
|
||||
class="min-w-0 overflow-hidden whitespace-nowrap text-v2-text-text-faint [direction:rtl] [text-align:end]"
|
||||
>
|
||||
<bdi dir="ltr">{fitted().text}</bdi>
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
when={creating()}
|
||||
fallback={
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 ps-1.5 pe-2 text-v2-text-text-faint">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-faint" />
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={observe} dir="auto" class="min-w-0 truncate">
|
||||
{label()}
|
||||
</span>
|
||||
|
||||
@@ -60,6 +60,7 @@ export const dict = {
|
||||
"command.usage.view": "View usage",
|
||||
"command.usage.view.description": "See your OpenCode Go allowance and Console budgets",
|
||||
"command.session.location.cycle": "Cycle session location",
|
||||
"session.location.toggle": "Toggle session location",
|
||||
|
||||
"command.palette": "Search everything",
|
||||
"command.commands.search": "Search commands",
|
||||
@@ -1128,6 +1129,7 @@ export const dict = {
|
||||
"session.new.workspace.search.placeholder": "Search worktrees",
|
||||
"session.new.workspace.search.empty": "No matching worktrees",
|
||||
"session.new.location.select": "Select",
|
||||
"session.new.location.otherProjects": "Other projects",
|
||||
"session.new.location.worktrees": "Worktrees",
|
||||
"session.new.location.back": "Back to projects",
|
||||
"session.new.location.servers": "Servers",
|
||||
|
||||
Reference in New Issue
Block a user