Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/app/e2e/regression/mobile-session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ test.describe("touch", () => {
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(fixture.expected.sourceTitle)
await expect(trigger).toBeFocused()
await trigger.click()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
Expand Down
2 changes: 1 addition & 1 deletion packages/app/e2e/regression/multi-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ test("a project's auto-accept choice overrides the default in both directions",
onPermissionReply: (reply) => replies.push(reply.permissionID),
},
})
await seed(page, { tabs: [api.id, web.id] })
await seed(page, { tabs: [api.id, web.id], usedProjects: { local: ["proj_api", "proj_web"] } })
const dock = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
const settings = page.getByTestId("settings-screen")

Expand Down
8 changes: 6 additions & 2 deletions packages/app/e2e/regression/settings-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,12 @@ test("section search takes typed input, clears in place, and escapes in steps",
})

for (const count of [0, 7, 8]) {
test(`Projects search appears from 8 projects (${count} projects)`, async ({ page }) => {
const view = await open(page, { count })
test(`Projects search appears from 8 added projects (${count} projects)`, async ({ page }) => {
const view = await open(page, {
count,
seed: { usedProjects: { local: Array.from({ length: count }, (_, index) => `proj_search_${index}`) } },
})

await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
Expand Down
42 changes: 39 additions & 3 deletions packages/app/e2e/regression/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,45 @@ test("project list menus rename, edit, add, and stay inside the scrollport", asy
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
const panel = settings.getByRole("tabpanel")
const list = panel.getByRole("list")
await expect(list.getByRole("listitem")).toHaveCount(4)
const card = list.getByRole("listitem").filter({ hasText: "rebase" })
await expect(list.getByRole("listitem")).toHaveCount(1)
await expect(list.getByRole("button", { name: "rebase", exact: true })).toBeVisible()
const others = panel.getByRole("button", { name: "Other projects", exact: true })
await expect(others).toContainText("3")
await expect(others).toHaveAttribute("aria-expanded", "false")
await others.click()
await expect(others).toHaveAttribute("aria-expanded", "true")
await expect(panel.getByRole("listitem")).toHaveCount(4)
await others.click()
const search = panel.getByRole("searchbox", { name: "Search projects" })
await search.fill("/projects/dinocms")
await expect(panel.getByRole("listitem")).toHaveCount(1)
await expect(panel.getByRole("button", { name: "dinocms", exact: true })).toBeVisible()
await expect(panel.getByRole("heading", { name: "Other projects", exact: true })).toBeVisible()
await search.fill("")
await expect(panel.getByRole("listitem")).toHaveCount(1)
await others.click()
await expect(panel.getByRole("listitem")).toHaveCount(4)

const addTrigger = panel
.getByRole("listitem")
.filter({ hasText: "dinocms" })
.getByRole("button", { name: "More options", exact: true })

await addTrigger.focus()
await addTrigger.press("Enter")
const menu = page.getByRole("menu")
await expect(menu.getByRole("menuitem")).toHaveText(["Add project", "Edit", "Rename"])
const addProject = menu.getByRole("menuitem", { name: "Add project", exact: true })
await expect(addProject).toBeFocused()
await addProject.press("Enter")
await expect(menu).toBeHidden()
await expect(others).toContainText("2")
const addedProjects = panel.getByRole("list").filter({ hasText: "rebase" })
await expect(addedProjects.getByRole("listitem")).toHaveCount(2)
await expect(addedProjects.getByRole("button", { name: "dinocms", exact: true })).toBeFocused()
const card = panel.getByRole("listitem").filter({ hasText: "rebase" })
const more = card.getByRole("button", { name: "More options", exact: true })
await more.click()
const menu = page.getByRole("menu")
await expect(menu.getByRole("menuitem")).toHaveText(["Edit", "Rename", "Clear notifications"])
await menu.getByRole("menuitem", { name: "Edit", exact: true }).click()
await expect(panel.getByRole("heading", { name: "rebase", exact: true })).toBeVisible()
Expand Down Expand Up @@ -545,6 +579,8 @@ test("project list menus rename, edit, add, and stay inside the scrollport", asy
await select.click()
await expect(picker).toBeHidden()
await expect(panel.getByRole("heading", { name: "added", exact: true })).toBeVisible()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(page.locator('[data-component="home-project-row"]').filter({ hasText: "dinocms" })).toBeVisible()
})

test("project settings open as a nested view that keeps its route", async ({ page }) => {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,7 @@ export const dict = {
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage projects and their settings",
"settings.projects.other": "Other projects",
"settings.projects.empty": "No projects found",
"settings.projects.empty.title": "No projects yet",
"settings.projects.empty.description": "Add a project to get started",
Expand Down
35 changes: 24 additions & 11 deletions packages/app/src/settings/workspaces/project-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
import { fileManagerApp } from "@/home/projects/file-manager"
import { useRevealProject } from "@/home/projects/reveal"
import { pathKey } from "@/workspaces/path-key"

export const ProjectOptions: Component<{
server: ServerConnection.Any
Expand All @@ -17,6 +18,7 @@ export const ProjectOptions: Component<{
onOpenChange?: (open: boolean) => void
onEdit?: () => void
onRename?: () => void
onAdd?: () => void
class?: string
size?: "small" | "normal" | "large"
}> = (props) => {
Expand All @@ -28,21 +30,25 @@ export const ProjectOptions: Component<{

const directories = () => [props.project.worktree, ...(props.project.sandboxes ?? [])]

const added = () =>
context()
.projects.used()
.some((project) => pathKey(project.worktree) === pathKey(props.project.worktree))

const hasUnread = () => directories().some((directory) => context().notification.project.hasUnread(directory))

const clearNotifications = () => {
const notification = context().notification
directories().forEach((directory) => notification.project.markViewed(directory))
}

const addProject = () => {
Comment thread
usrnk1 marked this conversation as resolved.
Resolved
props.onAdd?.()
context().projects.touch(props.project.worktree)
}

return (
<Menu
gutter={4}
modal={false}
placement="bottom-end"
open={props.open}
onOpenChange={props.onOpenChange}
>
<Menu gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
Expand All @@ -53,18 +59,25 @@ export const ProjectOptions: Component<{
/>
<Menu.Portal>
<Menu.Content>
<Show when={!added()}>
<Menu.Item onSelect={addProject}>{language.t("home.project.add")}</Menu.Item>
</Show>
<Show when={props.onEdit} keyed>
{(edit) => <Menu.Item onSelect={edit}>{language.t("common.edit")}</Menu.Item>}
</Show>
<Show when={props.onRename} keyed>
{(rename) => <Menu.Item onSelect={rename}>{language.t("common.rename")}</Menu.Item>}
</Show>
<Show when={revealProject.available(props.server)}>
<Menu.Item onSelect={() => revealProject.reveal(props.server, props.project)}>{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}</Menu.Item>
<Menu.Item onSelect={() => revealProject.reveal(props.server, props.project)}>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Show when={added()}>
<Menu.Item disabled={!hasUnread()} onSelect={clearNotifications}>
{language.t("sidebar.project.clearNotifications")}
</Menu.Item>
</Show>
<Menu.Item disabled={!hasUnread()} onSelect={clearNotifications}>
{language.t("sidebar.project.clearNotifications")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
Expand Down
15 changes: 12 additions & 3 deletions packages/app/src/settings/workspaces/project-row.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Show } from "solid-js"
import { Show, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { InlineInput } from "@opencode/ui/inline-input"
import { getFilename } from "@opencode/util/path"
Expand All @@ -16,18 +16,26 @@ export function SettingsProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
onOpen: (project: LocalProject) => void
onAdd?: () => void
onMount?: (button: HTMLButtonElement) => void
}) {
const language = useLanguage()
const global = useGlobal()

const [store, setStore] = createStore({
const [store, setStore] = createStore<{
menu: boolean
editor?: { draft: string; saving: boolean }
}>({
menu: false,
editor: undefined as { draft: string; saving: boolean } | undefined,
})

let button: HTMLButtonElement | undefined
let input: HTMLInputElement | undefined

onMount(() => {
if (button) props.onMount?.(button)
})

const openEditor = () => {
setStore("editor", { draft: displayName(props.project), saving: false })
requestAnimationFrame(() => {
Expand Down Expand Up @@ -147,6 +155,7 @@ export function SettingsProjectRow(props: {
onOpenChange={(open) => setStore("menu", open)}
onEdit={() => props.onOpen(props.project)}
onRename={openEditor}
onAdd={props.onAdd}
/>
</Show>
</div>
Expand Down
109 changes: 92 additions & 17 deletions packages/app/src/settings/workspaces/projects.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Show, createEffect, createMemo, type Component, type JSX } from "solid-js"
import { Show, createEffect, createMemo, on, type Component, type JSX } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
Expand All @@ -16,6 +16,7 @@ import { SettingsSearchField } from "../search-field"
import { SettingsProjectRow } from "./project-row"
import "@/settings/search.css"
import "@/settings/settings.css"
import "./project.css"

export const SettingsProjects: Component<{
server: ServerConnection.Any
Expand All @@ -26,21 +27,42 @@ export const SettingsProjects: Component<{
const language = useLanguage()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [store, setStore] = createStore({ filter: "" })
const [store, setStore] = createStore({ filter: "", others: false })
const context = createMemo(() => global.ensureServerCtx(props.server))
const projects = createMemo(() => context().projects.list())
const searchable = createMemo(() => projects().length > 7)

const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
let focus: string | undefined

return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
})
const projects = createMemo(() => context().projects.unique())

const used = createMemo(
() =>
new Set(
context()
.projects.used()
.map((project) => project.id),
),
)

const others = createMemo(() => projects().filter((project) => !used().has(project.id)))
const searchable = createMemo(() => projects().length > 7 || others().length > 0)
const query = createMemo(() => (searchable() ? store.filter.trim().toLowerCase() : ""))

const filtered = createMemo(() =>
projects().filter(
(project) =>
!query() || [displayName(project), project.worktree].some((value) => value.toLowerCase().includes(query())),
),
)

const added = createMemo(() => filtered().filter((project) => used().has(project.id)))
const otherResults = createMemo(() => filtered().filter((project) => !used().has(project.id)))

createEffect(() => {
if (!searchable()) setStore("filter", "")
})

createEffect(on(context, () => setStore("others", false)))

const addProject = () =>
pickDirectory({
server: props.server,
Expand Down Expand Up @@ -108,9 +130,7 @@ export const SettingsProjects: Component<{
class="settings-project-empty-card flex flex-col items-center gap-2 py-24 text-center"
>
<Icon name="folder" size="large" class="mb-2 text-v2-icon-icon-muted" />
<div class="text-13-medium text-v2-text-text-base">
{language.t("settings.projects.empty.title")}
</div>
<div class="text-13-medium text-v2-text-text-base">{language.t("settings.projects.empty.title")}</div>
<div class="text-13-regular text-v2-text-text-muted">
{language.t("settings.projects.empty.description")}
</div>
Expand All @@ -121,12 +141,67 @@ export const SettingsProjects: Component<{
</Show>
}
>
<div role="list" class="settings-project-list">
<Key each={filtered()} by="worktree">
{(project) => (
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
)}
</Key>
<div class="flex flex-col gap-3">
<Show when={added().length > 0}>
<div role="list" class="settings-project-list">
<Key each={added()} by="worktree">
{(project) => (
<SettingsProjectRow
project={project()}
server={props.server}
onOpen={props.onOpenProject}
onMount={(button) => {
if (focus !== project().worktree) return
focus = undefined
requestAnimationFrame(() => button.focus())
}}
/>
)}
</Key>
</div>
</Show>
<Show when={otherResults().length > 0}>
<Show
when={!query()}
fallback={
<h3 class="text-13-medium text-v2-text-text-muted">{language.t("settings.projects.other")}</h3>
}
>
<button
type="button"
class="settings-models-group-trigger project-settings-shared-trigger"
aria-label={language.t("settings.projects.other")}
aria-expanded={store.others}
onClick={() => setStore("others", (value) => !value)}
>
<span class="settings-models-group-chevron">
<Icon
name="fill-triangle-down"
size="small"
classList={{ "project-settings-shared-chevron": true, open: store.others }}
/>
</span>
<span class="project-settings-shared-label">
<span class="settings-models-group-title">{language.t("settings.projects.other")}</span>
<span class="project-settings-shared-count">{others().length}</span>
</span>
</button>
</Show>
<Show when={store.others || query()}>
<div role="list" class="settings-project-list">
<Key each={otherResults()} by="worktree">
{(project) => (
<SettingsProjectRow
project={project()}
server={props.server}
onOpen={props.onOpenProject}
onAdd={() => (focus = project().worktree)}
/>
)}
</Key>
</div>
</Show>
</Show>
</div>
</Show>
</div>
Expand Down
Loading