Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/app/component-tests/session-question-dock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, story } from "../../storybook/playwright/story"

story("shows question navigation shortcuts", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎"))
const next = component.getByRole("button", { name: "Next", exact: true })
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
await next.click()
await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut)
const back = component.getByRole("button", { name: "Back", exact: true })
const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←"))
await expect(back).toHaveText("Back")
await back.hover()
await expect(page.getByRole("tooltip")).toContainText(backShortcut)
await page.keyboard.press(
await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")),
)
await expect(next).toBeVisible()
})
46 changes: 37 additions & 9 deletions packages/app/component-tests/timeline-detail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ for (const direction of ["ltr", "rtl"]) {
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 900 })
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
await expect(component.locator('[data-slot="timeline-detail-scale"]')).toHaveCSS("margin-top", "16px")
const advanced = component.locator('[data-slot="timeline-detail-advanced"]')
await expect(advanced).toHaveCSS("margin-top", "0px")
await expect(advanced).toHaveCSS("padding-top", "8px")
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
"color",
await component
Expand All @@ -177,28 +181,46 @@ for (const direction of ["ltr", "rtl"]) {
),
)
if (theme === "light") {
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
await expect(track.locator("span").last()).toHaveCSS("background-image", "none")
await expect(track).toHaveCSS(
"--timeline-detail-marker-background",
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
)
}
if (theme === "dark") {
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
await expect(track.locator("span").last()).not.toHaveCSS("background-image", "none")
}
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
const list = component.locator('[data-slot="timeline-detail-list"]')
const columns = component.locator('[data-slot="timeline-detail-columns"]')
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
const advancedTrigger = component.getByRole("button", { name: "Advanced", exact: true })
expect(
await advancedTrigger.evaluate((element) => {
const trigger = element.getBoundingClientRect()
const heading = document
.querySelector('[data-slot="timeline-detail-columns"] > :nth-child(2)')!
.getBoundingClientRect()
return Math.abs(trigger.y + trigger.height / 2 - (heading.y + heading.height / 2))
}),
).toBeLessThan(1)
expect(
await list.evaluate((element) => {
const list = element.getBoundingClientRect()
const trigger = document
.querySelector('[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"]')!
.getBoundingClientRect()
return list.y - trigger.bottom
}),
).toBe(8)
for (const [column, field] of [
[2, "placement"],
[3, "details"],
] as const) {
const heading = await component
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const heading = await columns.locator(`> :nth-child(${column})`).evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const toggle = await component
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
.evaluate((element) => {
Expand All @@ -207,7 +229,7 @@ for (const direction of ["ltr", "rtl"]) {
})
expect(Math.abs(heading - toggle)).toBeLessThan(1)
}
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "8px")
for (const width of [900, 320]) {
await page.setViewportSize({ width, height: 900 })
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
Expand All @@ -227,6 +249,7 @@ for (const direction of ["ltr", "rtl"]) {

const slider = component.getByRole("slider", { name: "Timeline detail" })
const track = component.locator('[data-slot="timeline-detail-track"]')
await expect(slider).not.toHaveCSS("cursor", "pointer")
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
await component
.locator('[data-slot="timeline-detail-scale"]')
Expand All @@ -237,6 +260,11 @@ for (const direction of ["ltr", "rtl"]) {
for (const position of [0, 1, 2, 3, 4]) {
if (position > 0) await slider.press("ArrowUp")
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
await expect(track.locator("span[data-selected]")).toHaveCount(position + 1)
await expect(track.locator("span").nth(position)).toHaveCSS(
"background-color",
await track.evaluate((element) => getComputedStyle(element, "::before").backgroundColor),
)
const fill = await track.evaluate((element) => {
const style = getComputedStyle(element, "::before")
return {
Expand Down
42 changes: 37 additions & 5 deletions packages/app/e2e/regression/settings-project-edges.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ for (const colorScheme of ["light", "dark"] as const) {
test.describe(colorScheme, () => {
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })

test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
test("project list actions and edges stay inside the settings scrollport", async ({ page }, info) => {
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
id: `project-${index}`,
name,
Expand Down Expand Up @@ -40,18 +40,45 @@ for (const colorScheme of ["light", "dark"] as const) {
const panel = settings.getByRole("tabpanel")
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
const list = panel.locator('[data-component="settings-list"]')
await expect(list).toHaveAttribute("data-variant", "catalog")
await expect(list).toHaveCSS("padding-left", "16px")
await expect(list).toHaveCSS("padding-right", "16px")
const projectButton = panel.getByRole("button", { name: "rebase", exact: true })
const projectRow = projectButton.locator("..")
const projectName = projectButton.getByText("rebase", { exact: true })
await expect(projectName).toHaveCSS("font-weight", "530")
await expect(projectName).toHaveCSS("line-height", "20px")
await expect(projectRow).toHaveCSS("padding-top", "16px")
await expect(projectRow).toHaveCSS("padding-bottom", "16px")
expect(
Math.abs(
(await projectButton.evaluate((element) => element.getBoundingClientRect().height)) -
(await projectRow.evaluate((element) => element.getBoundingClientRect().height)),
),
).toBeLessThanOrEqual(1)
const chevron = projectButton.locator('svg:has(use[href="#opencode-v2-icon-chevron-right"])')
await expect(chevron).toHaveCSS("opacity", "0")
await projectRow.hover()
await expect(chevron).toHaveCSS("opacity", "1")
const more = projectRow.getByRole("button", { name: "More options", exact: true })
await more.click()
const menu = page.getByRole("menu")
await expect(menu.getByRole("menuitem")).toHaveText(["New session", "Clear notifications", "Close"])
await expect(menu.getByRole("separator")).toHaveCount(1)
await panel.getByRole("heading", { name: "Projects", exact: true }).click()
await expect(menu).toBeHidden()
await page.evaluate(() => document.fonts.ready)

for (const width of [1280, 1050, 960, 720, 600]) {
await page.setViewportSize({ width, height: 720 })
await page.mouse.move(0, 0)
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
// Raised cards paint a half-pixel border outside their box. The scrollport
// must leave room for that border and the soft shadow on both sides.
// The catalog card and its rows stay fully inside every horizontal clip ancestor.
await expect
.poll(() =>
panel.getByText("rebase", { exact: true }).evaluate((label) => {
const row = label.parentElement!.parentElement!
projectRow.evaluate((row) => {
const bounds = row.getBoundingClientRect()
const clips = []
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
Expand All @@ -73,6 +100,11 @@ for (const colorScheme of ["light", "dark"] as const) {
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()

const secondProject = panel.getByRole("button", { name: "dinocms", exact: true })
await secondProject.locator("..").getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
await expect(secondProject).toHaveCount(0)

await page.setViewportSize({ width: 1280, height: 260 })
await panel.getByText("rebase", { exact: true }).hover()
await page.mouse.wheel(0, 400)
Expand Down
86 changes: 61 additions & 25 deletions packages/app/e2e/regression/settings-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ function projectList(count: number) {
}))
}

async function persistProjects(page: Page, projects: ReturnType<typeof projectList>) {
await page.evaluate((projects) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
...value,
projects: { ...value.projects, local: projects.map((project) => ({ worktree: project.canonical })) },
}),
)
}, projects)
}

function ui(page: Page) {
const settings = page.getByTestId("settings-screen")
return {
Expand All @@ -50,6 +63,14 @@ test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, config)
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
await page.addInitScript((directory) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
if (value.projects?.local) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ ...value, projects: { ...value.projects, local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
await page.getByRole("button", { name: "Settings", exact: true }).click()
Expand Down Expand Up @@ -172,9 +193,22 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
await expect(result).toBeFocused()
})

test("Shortcuts search keeps focus while filtering", async ({ page }) => {
const view = ui(page)
await view.settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search shortcuts", exact: true })
await search.press("p")
await expect(search).toBeFocused()
await page.keyboard.type("alette")
await expect(search).toHaveValue("palette")
await expect(view.settings.getByText("Command palette", { exact: true })).toBeVisible()
})

for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
const inventory = projectList(count)
await page.route("**/api/project", (route) => route.fulfill({ json: inventory }))
await persistProjects(page, inventory)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
Expand Down Expand Up @@ -208,24 +242,14 @@ for (const count of [7, 8]) {
})
}

test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
test("Projects search focuses with a qualifying persisted inventory", async ({ page }) => {
const projects = projectList(8)
await page.route("**/api/project", (route) => route.fulfill({ json: projects }))
await persistProjects(page, projects)
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
Expand Down Expand Up @@ -265,11 +289,13 @@ test("qualified project results preserve query and selection on return", async (
})

test("returning from a project restores a scrolled result list", async ({ page }) => {
const projects = projectList(30)
await page.route("**/api/project", (route) =>
route.fulfill({
json: projectList(30),
json: projects,
}),
)
await persistProjects(page, projects)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
Expand Down Expand Up @@ -371,14 +397,17 @@ test("multi-server results navigate to the named server and hide search in neste
body: Buffer.from(await response.arrayBuffer()),
})
})
await page.addInitScript(
(server) =>
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
),
server,
)
await page.addInitScript((server) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
...value,
list: [{ type: "http", displayName: "Build server", http: { url: server } }],
projects: { ...value.projects, [server]: [{ worktree: "/remote/opencode", expanded: true }] },
}),
)
}, server)
await page.reload()
const view = ui(page)
await view.search.fill("MCPs")
Expand All @@ -393,6 +422,13 @@ test("multi-server results navigate to the named server and hide search in neste
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server projects")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(view.settings.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
await expect(view.settings.locator('[data-component="settings-list"][data-variant="catalog"]')).toBeVisible()
await expect(view.settings.getByRole("button", { name: "OpenCode", exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server OpenCode name")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.results.getByRole("option").click()
Expand Down
37 changes: 21 additions & 16 deletions packages/app/src/home/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode/client/promise"
import { pathKey } from "@/workspaces/path-key"
import { addProjects } from "./projects/add"

export function createHomeController() {
const layout = useLayout()
Expand Down Expand Up @@ -41,6 +43,24 @@ export function createHomeController() {
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
void ctx.sync.worktrees.list(id).then(() => ctx.sync.worktrees.refresh(id))
})
createEffect(() => {
// The project list is empty until the server store hydrates; clearing the restored
// selection against it would persist the loss.
if (!servers.hydrated()) return
const current = selection()
const directory = current.directory
if (!directory) return
const conn = servers.visible.find((conn) => ServerConnection.key(conn) === current.server)
if (!conn) return
if (
global
.ensureServerCtx(conn)
.projects.list()
.some((project) => pathKey(project.worktree) === pathKey(directory))
)
return
setSelection({ server: current.server })
})

function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
Expand Down Expand Up @@ -99,23 +119,8 @@ export function createHomeController() {
setSelection(toggleHomeProjectSelection(selection(), key, directory))
},
add: (conn: ServerConnection.Any, directories: string[]) => {
const directory = directories[0]
const directory = addProjects(global.ensureServerCtx(conn), directories)
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach((item) => {
if (ctx.projects.list().some((project) => project.worktree === item)) return
const location = { directory: item }
void ctx.sdk.api.file
.list({ path: ".", location })
.then(async (files) => {
// TODO: Initialize empty directories when V2 exposes a native Git init API.
return ctx.sdk.api.location.get({ location }).then((result) => result.project)
})
.then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id))
.catch(() => undefined)
ctx.projects.open(item)
})
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
},
openNewSession: () => {
Expand Down
Loading
Loading