Skip to content

Commit c8cc984

Browse files
iamdavidhillHona
andauthored
feat(app): improve project settings and actions (#49423)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
1 parent 5a9448a commit c8cc984

10 files changed

Lines changed: 382 additions & 127 deletions

File tree

‎packages/app/e2e/regression/settings-search.spec.ts‎

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -172,64 +172,6 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
172172
await expect(result).toBeFocused()
173173
})
174174

175-
for (const count of [7, 8]) {
176-
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
177-
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
178-
await page.reload()
179-
const view = ui(page)
180-
await view.search.fill("OpenCode")
181-
await expect(view.results.getByRole("option")).toHaveCount(count)
182-
await view.search.clear()
183-
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
184-
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
185-
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
186-
await expect(projects).toHaveCount(count)
187-
if (count === 7) {
188-
await expect(search).toHaveCount(0)
189-
return
190-
}
191-
await expect(search).toBeFocused()
192-
await search.fill(" CODE 06 ")
193-
await expect(projects).toHaveCount(1)
194-
await expect(projects).toHaveAccessibleName("OpenCode 06")
195-
await expect(search).toBeVisible()
196-
await search.fill("missing-project")
197-
await expect(projects).toHaveCount(0)
198-
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
199-
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
200-
await expect(search).toBeFocused()
201-
await expect(projects).toHaveCount(count)
202-
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
203-
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
204-
await expect(search).toBeFocused()
205-
await search.fill("OpenCode 06")
206-
await projects.click()
207-
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
208-
})
209-
}
210-
211-
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
212-
const inventory = Promise.withResolvers<void>()
213-
await page.route("**/api/project", async (route) => {
214-
await inventory.promise
215-
await route.fulfill({ json: projectList(8) })
216-
})
217-
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
218-
await page.reload()
219-
await requested
220-
const view = ui(page)
221-
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
222-
try {
223-
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
224-
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
225-
await expect(search).toHaveCount(0)
226-
} finally {
227-
inventory.resolve()
228-
}
229-
await expect(search).toBeFocused()
230-
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
231-
})
232-
233175
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
234176
const view = ui(page)
235177
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {

‎packages/app/src/runtime/server/registry.tsx‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,8 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
88
import type { SshItem } from "@/servers/ssh/types"
99

1010
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
11-
// The store retains more history than is displayed. Consumers filter recently closed entries
12-
// against the live project list (dropping deleted projects) and then cap the visible count via
13-
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
14-
// filtered out do not evict still-visible ones from the persisted store.
15-
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
11+
// Retain closed paths until reopened so settings can exclude them from the server inventory.
12+
// The Home page independently limits the visible recently closed entries.
1613
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
1714

1815
export function normalizeServerUrl(input: string) {
@@ -51,6 +48,7 @@ export function createServerProjects(input: {
5148
}
5249
return {
5350
list: current,
51+
closed: currentClosed,
5452
recentlyClosed: currentClosed,
5553
remove,
5654
open(directory: string) {
@@ -72,10 +70,7 @@ export function createServerProjects(input: {
7270
close(directory: string) {
7371
remove(directory)
7472
const key = pathKey(directory)
75-
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
76-
0,
77-
RECENTLY_CLOSED_HISTORY_LIMIT,
78-
)
73+
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
7974
setStore("recentlyClosed", input.scope(), closed)
8075
},
8176
expand(directory: string) {

‎packages/app/src/runtime/server/runtime.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@ function createServerController(
146146
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
147147
// Without this, different subdirectories of the same git repo would share the same
148148
// icon from the database instead of using their individual overrides.
149-
const base = { ...metadata, ...project }
149+
const base = {
150+
...metadata,
151+
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
152+
...project,
153+
}
150154
if (childStore.icon) {
151155
return { ...base, icon: { ...base.icon, override: childStore.icon } }
152156
}
@@ -174,6 +178,7 @@ function createServerController(
174178
projects: {
175179
...projects,
176180
list: projectsList,
181+
resolve: enrich,
177182
recentlyClosed: recentlyClosedList,
178183
},
179184
notification,

‎packages/app/src/settings/search-empty.tsx‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ import { useLanguage } from "@/runtime/i18n/language"
66
export function SettingsSearchEmpty(props: { query: string }) {
77
const language = useLanguage()
88
const [state, setState] = createStore({ query: props.query })
9-
let quoted: HTMLSpanElement | undefined
9+
let container: HTMLDivElement | undefined
1010
let measure: HTMLSpanElement | undefined
1111
const text = (query: string) => language.t("settings.search.empty.query", { query })
1212
const update = () => {
13-
if (!quoted || !measure) return
14-
const width = quoted.getBoundingClientRect().width
13+
if (!container || !measure) return
14+
const style = getComputedStyle(container)
15+
measure.textContent = language.t("settings.search.empty", { query: "" })
16+
// Measure the available query space independently of its current truncated text.
17+
const width =
18+
container.clientWidth -
19+
parseFloat(style.paddingInlineStart) -
20+
parseFloat(style.paddingInlineEnd) -
21+
measure.getBoundingClientRect().width
1522
const fits = (query: string) => {
1623
measure!.textContent = text(query)
1724
return measure!.getBoundingClientRect().width <= width
@@ -40,21 +47,22 @@ export function SettingsSearchEmpty(props: { query: string }) {
4047

4148
createEffect(update)
4249
onMount(() => {
43-
if (!quoted || !measure) return
50+
if (!container || !measure) return
4451
// The measuring text also observes font changes that do not resize the available space.
45-
createResizeObserver([quoted, measure], update)
52+
createResizeObserver([container, measure], update)
4653
})
4754

4855
return (
4956
<>
5057
<div
58+
ref={container}
5159
class="settings-search-empty"
5260
role="status"
5361
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
5462
>
5563
{language.rich("settings.search.empty", {
5664
query: (
57-
<span ref={quoted} class="settings-search-empty-quoted">
65+
<span class="settings-search-empty-quoted">
5866
<bdi dir="auto">{text(state.query)}</bdi>
5967
</span>
6068
),

‎packages/app/src/settings/search.css‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
visibility: hidden;
1616
}
1717

18-
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
18+
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
1919
color: var(--v2-icon-icon-faint);
20+
cursor: default;
2021
}
2122

22-
.settings-search
23+
:is(.settings-search, .settings-projects-search)
2324
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
2425
background: transparent;
2526
color: var(--v2-icon-icon-base);
@@ -37,19 +38,20 @@
3738
background: var(--v2-overlay-simple-overlay-hover);
3839
}
3940

40-
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
41+
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
4142
--mask-start: 16px;
4243
}
4344

44-
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
45+
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
4546
--mask-end: 16px;
4647
}
4748

48-
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
49+
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
4950
--mask-direction: to left;
5051
}
5152

52-
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
53+
:is(.settings-search, .settings-projects-search)
54+
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
5355
mask-image: linear-gradient(
5456
var(--mask-direction, to right),
5557
transparent,

‎packages/app/src/settings/servers/inventory.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import { sshName, type SshItem } from "@/servers/ssh/types"
77
import type { ServerCtx } from "@/runtime/server/runtime"
88
import { pathKey } from "@/workspaces/path-key"
99

10-
export function settingsProjects(context: ServerCtx) {
10+
export function settingsProjects(context: {
11+
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
12+
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
13+
}) {
1114
const tracked = context.projects.list()
1215
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
16+
const closed = new Set(context.projects.closed().map(pathKey))
1317
return [
1418
...tracked,
1519
...context.sync.data.project
16-
.filter((project) => !paths.has(pathKey(project.worktree)))
17-
.map((project) => ({ ...project, expanded: false })),
20+
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
21+
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
1822
]
1923
}
2024

‎packages/app/src/settings/settings.css‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,78 @@
803803
padding-inline-end: 28px;
804804
}
805805

806+
.settings-projects-search [data-component="text-input-v2"] {
807+
height: 36px;
808+
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
809+
box-shadow: none;
810+
}
811+
812+
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
813+
background: var(--v2-background-bg-layer-02);
814+
}
815+
816+
.settings-projects-search [data-slot="text-input-v2-input"] {
817+
padding-inline-end: 0;
818+
}
819+
820+
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
821+
padding-inline-start: 16px;
822+
}
823+
824+
.settings-projects-search [data-slot="text-input-v2-value"] {
825+
gap: 12px;
826+
}
827+
828+
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
829+
margin-inline-end: -4px;
830+
}
831+
832+
.settings-projects-empty {
833+
width: 60%;
834+
margin-inline: auto;
835+
}
836+
837+
.settings-projects-empty .settings-search-empty {
838+
justify-content: center;
839+
padding-inline: 0;
840+
}
841+
842+
.settings-projects-empty .settings-search-empty-quoted {
843+
flex: 0 1 auto;
844+
}
845+
846+
.settings-project-row {
847+
position: relative;
848+
margin-inline: 1px;
849+
border-radius: 8px;
850+
background: var(--v2-background-bg-layer-01);
851+
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
852+
transition: background-color 120ms;
853+
}
854+
855+
.settings-project-row:hover {
856+
background: var(--v2-background-bg-layer-02);
857+
}
858+
859+
.settings-project-row-content {
860+
display: flex;
861+
width: 100%;
862+
min-height: 73px;
863+
align-items: center;
864+
justify-content: space-between;
865+
gap: 20px;
866+
padding: 16px;
867+
border-radius: inherit;
868+
text-align: start;
869+
}
870+
871+
.settings-project-row-name {
872+
font-size: 13px;
873+
font-weight: 530;
874+
line-height: var(--line-height-compact);
875+
color: var(--v2-text-text-base);
876+
}
877+
806878
.settings-tab-search-clear {
807879
position: absolute;
808880
top: 50%;

‎packages/app/src/settings/shell.tsx‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,6 @@ function RootSettings() {
294294
<Tabs.Content value="projects" class="settings-panel">
295295
<SettingsProjects
296296
server={server}
297-
active={surface.view().tab === "projects"}
298-
autofocus={!surface.search.state.selected}
299297
onOpenProject={(project) =>
300298
surface.openProject({
301299
server: ServerConnection.key(server),
@@ -378,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) {
378376
<Tabs.Content value="projects" class="settings-panel">
379377
<SettingsProjects
380378
server={server}
381-
active={surface.view().tab === "projects"}
382379
onOpenProject={(project) =>
383380
surface.openProject({
384381
server: props.entry.key,

0 commit comments

Comments
 (0)