From c8cc984aa02db2046fe240fdf9b9aa8013dbdf5b Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:39:21 -0600 Subject: [PATCH] feat(app): improve project settings and actions (#49423) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- .../e2e/regression/settings-search.spec.ts | 58 ----- packages/app/src/runtime/server/registry.tsx | 13 +- packages/app/src/runtime/server/runtime.tsx | 7 +- packages/app/src/settings/search-empty.tsx | 20 +- packages/app/src/settings/search.css | 14 +- .../app/src/settings/servers/inventory.ts | 10 +- packages/app/src/settings/settings.css | 72 ++++++ packages/app/src/settings/shell.tsx | 3 - .../src/settings/workspaces/project-row.tsx | 230 ++++++++++++++++++ .../app/src/settings/workspaces/projects.tsx | 82 +++---- 10 files changed, 382 insertions(+), 127 deletions(-) create mode 100644 packages/app/src/settings/workspaces/project-row.tsx diff --git a/packages/app/e2e/regression/settings-search.spec.ts b/packages/app/e2e/regression/settings-search.spec.ts index e7851ab2379..b9e48a7b05c 100644 --- a/packages/app/e2e/regression/settings-search.spec.ts +++ b/packages/app/e2e/regression/settings-search.spec.ts @@ -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() - 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)) { diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index 87c0273b8e3..eb7ad29f212 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -8,11 +8,8 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc import type { SshItem } from "@/servers/ssh/types" type ServerState = ReturnType["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) { diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index f1f99b53647..4026fe35d9e 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -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, diff --git a/packages/app/src/settings/search-empty.tsx b/packages/app/src/settings/search-empty.tsx index c7d97c24457..ae861981467 100644 --- a/packages/app/src/settings/search-empty.tsx +++ b/packages/app/src/settings/search-empty.tsx @@ -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 ( <>
{language.rich("settings.search.empty", { query: ( - + {text(state.query)} ), diff --git a/packages/app/src/settings/search.css b/packages/app/src/settings/search.css index 2dcac30f69d..63503ecf8c4 100644 --- a/packages/app/src/settings/search.css +++ b/packages/app/src/settings/search.css @@ -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, diff --git a/packages/app/src/settings/servers/inventory.ts b/packages/app/src/settings/servers/inventory.ts index b9ae3a0bf8a..a228a0f0ce8 100644 --- a/packages/app/src/settings/servers/inventory.ts +++ b/packages/app/src/settings/servers/inventory.ts @@ -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 + sync: { data: Pick } +}) { 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 })), ] } diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index b33937ce9b6..aa18d7828d1 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -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%; diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 2f39054554b..42854b04cc0 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -294,8 +294,6 @@ function RootSettings() { surface.openProject({ server: ServerConnection.key(server), @@ -378,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) { surface.openProject({ server: props.entry.key, diff --git a/packages/app/src/settings/workspaces/project-row.tsx b/packages/app/src/settings/workspaces/project-row.tsx new file mode 100644 index 00000000000..3db6c6d32f7 --- /dev/null +++ b/packages/app/src/settings/workspaces/project-row.tsx @@ -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 ( +
{ + if (store.editor) return + event.preventDefault() + openMenu(event.clientX, event.clientY) + }} + > + + + 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} + /> + +
+ } + > + + + { + if (!open) setStore("menu", undefined) + }} + > + +
+ ) +} + +function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) { + return ( + + + + {props.children} + + {props.project.worktree} + + + + ) +} diff --git a/packages/app/src/settings/workspaces/projects.tsx b/packages/app/src/settings/workspaces/projects.tsx index d002ab8fdec..3561255fa12 100644 --- a/packages/app/src/settings/workspaces/projects.tsx +++ b/packages/app/src/settings/workspaces/projects.tsx @@ -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<{ -