mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): make Add project a normal row in the location dialog
This commit is contained in:
@@ -584,24 +584,24 @@ test("the location dialog lists every ready server's projects in one list and ta
|
||||
const search = dialog.getByRole("combobox")
|
||||
await expect(search).toBeFocused()
|
||||
const options = dialog.getByRole("option")
|
||||
const rows = dialog.locator(".command-palette-results").getByRole("option")
|
||||
const add = options.filter({ hasText: "Add project" })
|
||||
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 })
|
||||
// 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 pinned above the scrolling list and its heading, yet the first project is still the opening row,
|
||||
// not the draft's current project (the second row).
|
||||
await expect(options.first()).toHaveText("Add project")
|
||||
// "Add project" is the first row of the scrolling list, under its 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 heading = await dialog.getByText("Projects", { exact: true }).boundingBox()
|
||||
|
||||
return row && heading ? row.y + row.height <= heading.y : false
|
||||
return row && heading ? row.y >= heading.y + heading.height : false
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
@@ -612,25 +612,28 @@ test("the location dialog lists every ready server's projects in one list and ta
|
||||
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)")
|
||||
// The pinned row shares the list's width and icon column.
|
||||
// "Add project" shares the project rows' width and icon column.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const field = await dialog.locator('[data-component="text-input-v2"]').boundingBox()
|
||||
const row = await rows.nth(0).boundingBox()
|
||||
const pinned = await add.boundingBox()
|
||||
const first = await add.boundingBox()
|
||||
const icon = await add.locator(".command-palette-row-icon").boundingBox()
|
||||
const avatar = await rows.nth(0).locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
|
||||
return field && row && pinned && icon && avatar
|
||||
? Math.max(Math.abs(field.width - row.width), Math.abs(pinned.width - row.width), Math.abs(icon.x - avatar.x))
|
||||
return field && row && first && icon && avatar
|
||||
? Math.max(Math.abs(field.width - row.width), Math.abs(first.width - row.width), Math.abs(icon.x - avatar.x))
|
||||
: Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
// Searching narrows the projects and keeps "Add project" pinned.
|
||||
// Searching narrows the projects and keeps "Add project" first; with no match it is the active row.
|
||||
await search.fill("apollo")
|
||||
await expect(names).toHaveText(["opencodeapollo", "sstapollo"])
|
||||
await expect(add).toBeVisible()
|
||||
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")
|
||||
|
||||
// The worktree step keeps the server's tag beside the project.
|
||||
await search.fill("opencode")
|
||||
@@ -656,7 +659,7 @@ test("the location dialog lists every ready server's projects in one list and ta
|
||||
await expect(search).toHaveValue("opencode")
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// ArrowUp from the first project reaches the pinned row above it; adding a project picks one of the ready servers
|
||||
// ArrowUp from the first project reaches "Add project" above it; 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")
|
||||
|
||||
@@ -66,19 +66,3 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.new-session-location-list {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* "Add project" stays above the scrolling project list. */
|
||||
.new-session-location-pinned {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
padding: 0 6px 6px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ function NewSessionLocationDialog(props: {
|
||||
|
||||
const addEntry: LocationEntry = { id: "add", type: "add" }
|
||||
|
||||
// Remote projects also match their server's name, which their tag shows. "Add project" is pinned above the list but
|
||||
// navigates last, so the first project stays the opening row and ArrowUp from it wraps to "Add project" above.
|
||||
// Remote projects also match their server's name, which their tag shows. "Add project" is listed first 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()
|
||||
@@ -166,11 +166,9 @@ function NewSessionLocationDialog(props: {
|
||||
return projectEntries()
|
||||
}
|
||||
|
||||
const pinned = () => !props.projectsOnly && !store.drilled && !store.picking
|
||||
|
||||
const drillable = (project: PromptProject) => !props.projectsOnly && !!project.id && project.vcs === "git"
|
||||
|
||||
const listEntries = () => entries().filter((entry) => entry.type !== "add")
|
||||
const rows = () => (entries().at(-1) === addEntry ? [addEntry, ...entries().slice(0, -1)] : entries())
|
||||
|
||||
const title = () => {
|
||||
if (store.drilled) return language.t("session.new.workspace.runIn")
|
||||
@@ -309,72 +307,6 @@ 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">
|
||||
<DialogBody class="command-palette-body">
|
||||
@@ -421,27 +353,92 @@ function NewSessionLocationDialog(props: {
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div id={typeahead.listID} class="new-session-location-list" role="listbox">
|
||||
<Show when={pinned()}>
|
||||
<div class="new-session-location-pinned">{option(addEntry)}</div>
|
||||
</Show>
|
||||
<div class="command-palette-scroll" data-scrollable ref={(el) => (resultsRef = el)}>
|
||||
<div class="command-palette-results">
|
||||
<Show
|
||||
when={listEntries().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>
|
||||
}
|
||||
>
|
||||
<div class="command-palette-group">
|
||||
<div class="command-palette-group-title">{title()}</div>
|
||||
<For each={listEntries()}>{option}</For>
|
||||
<div class="command-palette-scroll" data-scrollable ref={(el) => (resultsRef = el)}>
|
||||
<div id={typeahead.listID} class="command-palette-results" role="listbox">
|
||||
<Show
|
||||
when={rows().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>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="command-palette-group">
|
||||
<div class="command-palette-group-title">{title()}</div>
|
||||
<For each={rows()}>
|
||||
{(entry) => (
|
||||
<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>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!props.projectsOnly}>
|
||||
|
||||
Reference in New Issue
Block a user