mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-28 13:33:35 +08:00
feat(app): improve project settings and actions (#49423)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
co-authored by
LukeParkerDev
parent
5a9448acbb
commit
c8cc984aa0
@@ -172,64 +172,6 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
|
||||
await expect(result).toBeFocused()
|
||||
})
|
||||
|
||||
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) }))
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(count)
|
||||
await view.search.clear()
|
||||
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 / })
|
||||
await expect(projects).toHaveCount(count)
|
||||
if (count === 7) {
|
||||
await expect(search).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill(" CODE 06 ")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveAccessibleName("OpenCode 06")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill("missing-project")
|
||||
await expect(projects).toHaveCount(0)
|
||||
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(projects).toHaveCount(count)
|
||||
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("OpenCode 06")
|
||||
await projects.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
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 expect(search).toBeFocused()
|
||||
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
|
||||
})
|
||||
|
||||
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
|
||||
|
||||
@@ -8,11 +8,8 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
|
||||
import type { SshItem } from "@/servers/ssh/types"
|
||||
|
||||
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
|
||||
// The store retains more history than is displayed. Consumers filter recently closed entries
|
||||
// against the live project list (dropping deleted projects) and then cap the visible count via
|
||||
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
|
||||
// filtered out do not evict still-visible ones from the persisted store.
|
||||
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
|
||||
// Retain closed paths until reopened so settings can exclude them from the server inventory.
|
||||
// The Home page independently limits the visible recently closed entries.
|
||||
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
@@ -51,6 +48,7 @@ export function createServerProjects(input: {
|
||||
}
|
||||
return {
|
||||
list: current,
|
||||
closed: currentClosed,
|
||||
recentlyClosed: currentClosed,
|
||||
remove,
|
||||
open(directory: string) {
|
||||
@@ -72,10 +70,7 @@ export function createServerProjects(input: {
|
||||
close(directory: string) {
|
||||
remove(directory)
|
||||
const key = pathKey(directory)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
||||
0,
|
||||
RECENTLY_CLOSED_HISTORY_LIMIT,
|
||||
)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
|
||||
setStore("recentlyClosed", input.scope(), closed)
|
||||
},
|
||||
expand(directory: string) {
|
||||
|
||||
@@ -146,7 +146,11 @@ function createServerController(
|
||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||
// Without this, different subdirectories of the same git repo would share the same
|
||||
// icon from the database instead of using their individual overrides.
|
||||
const base = { ...metadata, ...project }
|
||||
const base = {
|
||||
...metadata,
|
||||
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
|
||||
...project,
|
||||
}
|
||||
if (childStore.icon) {
|
||||
return { ...base, icon: { ...base.icon, override: childStore.icon } }
|
||||
}
|
||||
@@ -174,6 +178,7 @@ function createServerController(
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
resolve: enrich,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
notification,
|
||||
|
||||
@@ -6,12 +6,19 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
export function SettingsSearchEmpty(props: { query: string }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ query: props.query })
|
||||
let quoted: HTMLSpanElement | undefined
|
||||
let container: HTMLDivElement | undefined
|
||||
let measure: HTMLSpanElement | undefined
|
||||
const text = (query: string) => language.t("settings.search.empty.query", { query })
|
||||
const update = () => {
|
||||
if (!quoted || !measure) return
|
||||
const width = quoted.getBoundingClientRect().width
|
||||
if (!container || !measure) return
|
||||
const style = getComputedStyle(container)
|
||||
measure.textContent = language.t("settings.search.empty", { query: "" })
|
||||
// Measure the available query space independently of its current truncated text.
|
||||
const width =
|
||||
container.clientWidth -
|
||||
parseFloat(style.paddingInlineStart) -
|
||||
parseFloat(style.paddingInlineEnd) -
|
||||
measure.getBoundingClientRect().width
|
||||
const fits = (query: string) => {
|
||||
measure!.textContent = text(query)
|
||||
return measure!.getBoundingClientRect().width <= width
|
||||
@@ -40,21 +47,22 @@ export function SettingsSearchEmpty(props: { query: string }) {
|
||||
|
||||
createEffect(update)
|
||||
onMount(() => {
|
||||
if (!quoted || !measure) return
|
||||
if (!container || !measure) return
|
||||
// The measuring text also observes font changes that do not resize the available space.
|
||||
createResizeObserver([quoted, measure], update)
|
||||
createResizeObserver([container, measure], update)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={container}
|
||||
class="settings-search-empty"
|
||||
role="status"
|
||||
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
|
||||
>
|
||||
{language.rich("settings.search.empty", {
|
||||
query: (
|
||||
<span ref={quoted} class="settings-search-empty-quoted">
|
||||
<span class="settings-search-empty-quoted">
|
||||
<bdi dir="auto">{text(state.query)}</bdi>
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
color: var(--v2-icon-icon-faint);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-search
|
||||
:is(.settings-search, .settings-projects-search)
|
||||
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
|
||||
background: transparent;
|
||||
color: var(--v2-icon-icon-base);
|
||||
@@ -37,19 +38,20 @@
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
|
||||
--mask-start: 16px;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
|
||||
--mask-end: 16px;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
|
||||
--mask-direction: to left;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
:is(.settings-search, .settings-projects-search)
|
||||
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
mask-image: linear-gradient(
|
||||
var(--mask-direction, to right),
|
||||
transparent,
|
||||
|
||||
@@ -7,14 +7,18 @@ import { sshName, type SshItem } from "@/servers/ssh/types"
|
||||
import type { ServerCtx } from "@/runtime/server/runtime"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
|
||||
export function settingsProjects(context: ServerCtx) {
|
||||
export function settingsProjects(context: {
|
||||
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
|
||||
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
|
||||
}) {
|
||||
const tracked = context.projects.list()
|
||||
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
|
||||
const closed = new Set(context.projects.closed().map(pathKey))
|
||||
return [
|
||||
...tracked,
|
||||
...context.sync.data.project
|
||||
.filter((project) => !paths.has(pathKey(project.worktree)))
|
||||
.map((project) => ({ ...project, expanded: false })),
|
||||
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
|
||||
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -803,6 +803,78 @@
|
||||
padding-inline-end: 28px;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-component="text-input-v2"] {
|
||||
height: 36px;
|
||||
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-input"] {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
|
||||
padding-inline-start: 16px;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-value"] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
margin-inline-end: -4px;
|
||||
}
|
||||
|
||||
.settings-projects-empty {
|
||||
width: 60%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.settings-projects-empty .settings-search-empty {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.settings-projects-empty .settings-search-empty-quoted {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.settings-project-row {
|
||||
position: relative;
|
||||
margin-inline: 1px;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
|
||||
.settings-project-row:hover {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-project-row-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 73px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
border-radius: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.settings-project-row-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-tab-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -294,8 +294,6 @@ function RootSettings() {
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
autofocus={!surface.search.state.selected}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: ServerConnection.key(server),
|
||||
@@ -378,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) {
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: props.entry.key,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { InlineInput } from "@opencode/ui/inline-input"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { displayName, errorMessage } from "@/shell/layout/helpers"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
|
||||
export function SettingsProjectRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
onOpen: (project: LocalProject) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const global = useGlobal()
|
||||
const [store, setStore] = createStore({
|
||||
menu: undefined as { x: number; y: number } | undefined,
|
||||
editor: undefined as { draft: string; saving: boolean } | undefined,
|
||||
})
|
||||
let row: HTMLDivElement | undefined
|
||||
let button: HTMLButtonElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let outside = false
|
||||
const openMenu = (x: number, y: number) => {
|
||||
if (!row) return
|
||||
const bounds = row.getBoundingClientRect()
|
||||
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
|
||||
}
|
||||
const openEditor = () => {
|
||||
setStore("editor", { draft: displayName(props.project), saving: false })
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
const closeEditor = () => {
|
||||
if (store.editor?.saving) return
|
||||
setStore("editor", undefined)
|
||||
}
|
||||
const saveEditor = async () => {
|
||||
if (!store.editor || store.editor.saving) return
|
||||
const name = store.editor.draft.trim()
|
||||
if (!name || name === displayName(props.project)) {
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => button?.focus())
|
||||
return
|
||||
}
|
||||
setStore("editor", "saving", true)
|
||||
const context = global.ensureServerCtx(props.server)
|
||||
const value = name === getFilename(props.project.worktree) ? "" : name
|
||||
const saved = await (props.project.id && props.project.id !== "global"
|
||||
? context.sdk.api.project
|
||||
.update({ projectID: props.project.id, name: value })
|
||||
.then((project) => context.sync.project.update(project))
|
||||
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
|
||||
)
|
||||
.then(() => true)
|
||||
.catch((error: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
const restore = document.activeElement === document.body || document.activeElement === input
|
||||
if (saved) setStore("editor", undefined)
|
||||
if (!saved) setStore("editor", "saving", false)
|
||||
if (!restore) return
|
||||
requestAnimationFrame(() => (saved ? button : input)?.focus())
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={row}
|
||||
data-component="settings-project-row"
|
||||
class="settings-project-row group"
|
||||
onContextMenu={(event) => {
|
||||
if (store.editor) return
|
||||
event.preventDefault()
|
||||
openMenu(event.clientX, event.clientY)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!store.editor}
|
||||
fallback={
|
||||
<div class="settings-project-row-content">
|
||||
<ProjectRowContent project={props.project}>
|
||||
<InlineInput
|
||||
ref={input}
|
||||
aria-label={language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={store.editor?.draft ?? ""}
|
||||
disabled={store.editor?.saving}
|
||||
class="settings-project-row-name w-full outline-none"
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => button?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
</ProjectRowContent>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
aria-label={displayName(props.project)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!store.menu}
|
||||
class="settings-project-row-content"
|
||||
onClick={() => props.onOpen(props.project)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
|
||||
event.preventDefault()
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
openMenu(bounds.left + 12, bounds.bottom)
|
||||
}}
|
||||
>
|
||||
<ProjectRowContent project={props.project}>
|
||||
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
|
||||
</ProjectRowContent>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
</Show>
|
||||
<Menu
|
||||
modal={false}
|
||||
placement="bottom-start"
|
||||
gutter={2}
|
||||
open={!!store.menu}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setStore("menu", undefined)
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as="span"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
class="pointer-events-none absolute size-px"
|
||||
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onInteractOutside={() => {
|
||||
outside = true
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const restore = !outside && !store.editor
|
||||
outside = false
|
||||
if (restore) requestAnimationFrame(() => button?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
|
||||
<Show
|
||||
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
if (!platform.openPath) return
|
||||
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
const next = row?.nextElementSibling ?? row?.previousElementSibling
|
||||
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
|
||||
requestAnimationFrame(() => next?.querySelector("button")?.focus())
|
||||
}}
|
||||
>
|
||||
{language.t("common.close")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
|
||||
return (
|
||||
<span class="flex items-start gap-2.5 min-w-0 flex-1">
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
{props.children}
|
||||
<bdi
|
||||
dir="ltr"
|
||||
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
|
||||
title={props.project.worktree}
|
||||
>
|
||||
{props.project.worktree}
|
||||
</bdi>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,43 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
|
||||
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { SettingsSearchEmpty } from "../search-empty"
|
||||
import { settingsProjects } from "../servers/inventory"
|
||||
import { SettingsProjectRow } from "./project-row"
|
||||
import "@/settings/search.css"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const SettingsProjects: Component<{
|
||||
server: ServerConnection.Any
|
||||
active?: boolean
|
||||
autofocus?: boolean
|
||||
onOpenProject: (project: LocalProject) => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
|
||||
let search: HTMLInputElement | undefined
|
||||
const updateOverflow = () => {
|
||||
if (!search) return
|
||||
const offset = Math.abs(search.scrollLeft)
|
||||
setStore("overflow", {
|
||||
start: offset > 1,
|
||||
end: search.scrollWidth - search.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
createEffect(on(() => store.filter, updateOverflow))
|
||||
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
|
||||
const searchable = createMemo(() => projects().length > 7)
|
||||
const filtered = createMemo(() => {
|
||||
const query = searchable() ? store.filter.trim().toLowerCase() : ""
|
||||
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => (props.active ?? true) && searchable(),
|
||||
(active) => {
|
||||
if (!active) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.active !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!searchable()) setStore("filter", "")
|
||||
})
|
||||
@@ -54,16 +52,24 @@ export const SettingsProjects: Component<{
|
||||
</div>
|
||||
</div>
|
||||
<Show when={searchable()}>
|
||||
<div class="settings-tab-search">
|
||||
<div class="settings-tab-search settings-projects-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
ref={(element) => {
|
||||
search = element
|
||||
createResizeObserver(element, updateOverflow)
|
||||
}}
|
||||
type="search"
|
||||
appearance="base"
|
||||
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||
value={store.filter}
|
||||
data-overflow-start={store.overflow.start}
|
||||
data-overflow-end={store.overflow.end}
|
||||
onScroll={updateOverflow}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.projects.search.placeholder")}
|
||||
aria-label={language.t("settings.projects.search.placeholder")}
|
||||
showClearButton={!!store.filter}
|
||||
clearIcon="circle-xmark"
|
||||
onClearClick={() => {
|
||||
setStore("filter", "")
|
||||
search?.focus({ preventScroll: true })
|
||||
@@ -81,32 +87,26 @@ export const SettingsProjects: Component<{
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
<Show
|
||||
when={store.filter.trim()}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="settings-projects-empty">
|
||||
<SettingsSearchEmpty query={store.filter} />
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<For each={filtered()}>
|
||||
<Key each={filtered()} by="worktree">
|
||||
{(project) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={displayName(project)}
|
||||
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
|
||||
onClick={() => props.onOpenProject(project)}
|
||||
>
|
||||
<span class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ProjectIcon project={project} class="shrink-0" />
|
||||
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
|
||||
)}
|
||||
</For>
|
||||
</Key>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user