mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): keep command palette results steady while searching (#57479)
This commit is contained in:
committed by
GitHub
Unverified
parent
690b47887c
commit
da84835eee
@@ -263,3 +263,118 @@ test("navigation replaces commands without retaining disposed owners", async ({
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps matching session and file results steady across keystrokes while searches are pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openPaletteWorkspace(page)
|
||||
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
|
||||
await page.keyboard.press(mac ? "Meta+Shift+P" : "Control+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
const input = dialog.getByRole("textbox")
|
||||
await expect(input).toBeFocused()
|
||||
const sessionOption = dialog.getByRole("option", { name: /Palette fixture session/ })
|
||||
const matchingFile = dialog.getByRole("option", { name: "packages/desktop/ palette.ts", exact: true })
|
||||
const nonMatchingFile = dialog.getByRole("option", { name: "src/app/ other.ts", exact: true })
|
||||
await expect(sessionOption).toBeVisible()
|
||||
|
||||
const pendingFiles = Promise.withResolvers<void>()
|
||||
const pendingSessions = Promise.withResolvers<void>()
|
||||
|
||||
await page.route("**/api/fs/find?*", async (route) => {
|
||||
const query = new URL(route.request().url()).searchParams.get("query") ?? ""
|
||||
|
||||
if (query !== "p") await pendingFiles.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data:
|
||||
query === "p"
|
||||
? [
|
||||
{ path: "packages/desktop/palette.ts", type: "file" },
|
||||
{ path: "src/app/other.ts", type: "file" },
|
||||
]
|
||||
: [{ path: "packages/desktop/palette.ts", type: "file" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await pendingSessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [{ ...paletteSession, location: { directory: paletteSession.directory } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await input.press("p")
|
||||
await expect(matchingFile).toBeVisible()
|
||||
await expect(nonMatchingFile).toBeVisible()
|
||||
await expect(sessionOption).toBeVisible()
|
||||
|
||||
const probe = await page.evaluateHandle(() => {
|
||||
const results = document.querySelector(".command-palette-results")!
|
||||
|
||||
const state = {
|
||||
emptyFrames: 0,
|
||||
initialSessionEl:
|
||||
Array.from(results.querySelectorAll('[role="option"]')).find((el) =>
|
||||
el.textContent?.includes("Palette fixture session"),
|
||||
) ?? null,
|
||||
initialFileEl:
|
||||
Array.from(results.querySelectorAll('[role="option"]')).find((el) => el.textContent?.includes("palette.ts")) ??
|
||||
null,
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (results.querySelectorAll('[role="option"]').length === 0 || results.querySelector(".command-palette-state")) {
|
||||
state.emptyFrames++
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(results, { childList: true, subtree: true })
|
||||
|
||||
return {
|
||||
finish() {
|
||||
observer.disconnect()
|
||||
|
||||
const currentSessionEl = Array.from(results.querySelectorAll('[role="option"]')).find((el) =>
|
||||
el.textContent?.includes("Palette fixture session"),
|
||||
)
|
||||
|
||||
const currentFileEl = Array.from(results.querySelectorAll('[role="option"]')).find((el) =>
|
||||
el.textContent?.includes("palette.ts"),
|
||||
)
|
||||
|
||||
return {
|
||||
emptyFrames: state.emptyFrames,
|
||||
sameSessionNode: Boolean(state.initialSessionEl && state.initialSessionEl === currentSessionEl),
|
||||
sameFileNode: Boolean(state.initialFileEl && state.initialFileEl === currentFileEl),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
for (const key of "alette") {
|
||||
await input.press(key)
|
||||
await expect(sessionOption).toBeVisible()
|
||||
await expect(matchingFile).toBeVisible()
|
||||
await expect(nonMatchingFile).toHaveCount(0)
|
||||
}
|
||||
|
||||
await expect(sessionOption).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
pendingFiles.resolve()
|
||||
pendingSessions.resolve()
|
||||
|
||||
await expect(sessionOption).toBeVisible()
|
||||
await expect(matchingFile).toBeVisible()
|
||||
|
||||
const stability = await probe.evaluate((tracker) => tracker.finish())
|
||||
|
||||
expect(stability).toEqual({
|
||||
emptyFrames: 0,
|
||||
sameSessionNode: true,
|
||||
sameFileNode: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import {
|
||||
createCommandPaletteCommandEntry,
|
||||
createKnownServerSessions,
|
||||
createServerSessionEntries,
|
||||
type CommandPaletteEntry,
|
||||
type PaletteHighlight,
|
||||
@@ -31,6 +32,7 @@ export function HomeCommandPalette(props: {
|
||||
|
||||
const sessions = createServerSessionEntries({
|
||||
server: ServerConnection.key(props.server),
|
||||
known: createKnownServerSessions(server),
|
||||
projects: server.projects.list,
|
||||
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
|
||||
@@ -63,9 +65,12 @@ export function HomeCommandPalette(props: {
|
||||
}
|
||||
|
||||
const items = (query: string) => {
|
||||
if (!query) return commandEntries().slice(0, 5)
|
||||
if (!query) return [...commandEntries().slice(0, 5), ...sessions.items("")]
|
||||
|
||||
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
|
||||
return [
|
||||
...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)),
|
||||
...sessions.items(query),
|
||||
]
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -77,7 +82,7 @@ export function HomeCommandPalette(props: {
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("palette.search.placeholder.home")}
|
||||
items={items}
|
||||
sources={[sessions]}
|
||||
sources={[sessions.search]}
|
||||
highlight={highlight}
|
||||
select={select}
|
||||
close={() => dialog.close()}
|
||||
|
||||
@@ -13,32 +13,25 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { getRelativeTime } from "@/shell/time"
|
||||
import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette"
|
||||
import { createCommandPaletteSearch } from "./search"
|
||||
import { createCommandPaletteSearch, matchesCommandPaletteEntry } from "./search"
|
||||
import "./dialog.css"
|
||||
|
||||
function groups(entries: CommandPaletteEntry[]) {
|
||||
const map = new Map<string, CommandPaletteEntry[]>()
|
||||
export { matchesCommandPaletteEntry }
|
||||
|
||||
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
|
||||
|
||||
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
|
||||
}
|
||||
|
||||
export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: string) {
|
||||
const value = query.toLowerCase()
|
||||
|
||||
return [entry.title, entry.description, entry.category, entry.keywords].some((text) =>
|
||||
text?.toLowerCase().includes(value),
|
||||
)
|
||||
function groupKey(entry: CommandPaletteEntry) {
|
||||
return entry.type === "session" ? "session" : `${entry.type}:${entry.category}`
|
||||
}
|
||||
|
||||
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
|
||||
const items = (q: string) => {
|
||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries(), ...palette.sessions.items("")]
|
||||
|
||||
return palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q))
|
||||
return [
|
||||
...palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)),
|
||||
...palette.sessions.items(q),
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -46,9 +39,9 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
|
||||
placeholder={palette.language.t("palette.search.placeholder")}
|
||||
items={items}
|
||||
sources={[
|
||||
palette.sessions,
|
||||
palette.sessions.search,
|
||||
async (query, signal) => {
|
||||
if (!query) return []
|
||||
if (!query) return palette.recentFileEntries()
|
||||
const files = await palette.file.searchFiles(query, { signal })
|
||||
const category = palette.language.t("palette.group.files")
|
||||
|
||||
@@ -76,7 +69,13 @@ export function CommandPaletteView(props: {
|
||||
|
||||
const search = createCommandPaletteSearch({ query: () => store.query, items: props.items, sources: props.sources })
|
||||
const visibleEntries = search.items
|
||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||
const entryById = createMemo(() => new Map(visibleEntries().map((entry) => [entry.id, entry] as const)))
|
||||
|
||||
const groupKeys = createMemo<string[]>((prev) => {
|
||||
const next = [...new Set(visibleEntries().map(groupKey))]
|
||||
|
||||
return prev && prev.length === next.length && prev.every((key, index) => key === next[index]) ? prev : next
|
||||
})
|
||||
|
||||
// Follow the first result until the user explicitly chooses a row.
|
||||
const activeEntry = createMemo(
|
||||
@@ -154,7 +153,14 @@ export function CommandPaletteView(props: {
|
||||
appearance="large"
|
||||
placeholder={props.placeholder}
|
||||
leadingIcon={<Icon name="magnifying-glass" />}
|
||||
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
|
||||
onInput={(event) => {
|
||||
const query = event.currentTarget.value
|
||||
const active = store.active ? entryById().get(store.active) : undefined
|
||||
setStore({
|
||||
query,
|
||||
active: active && matchesCommandPaletteEntry(active, query) ? store.active : undefined,
|
||||
})
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -164,34 +170,55 @@ export function CommandPaletteView(props: {
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="command-palette-state">
|
||||
{search.loading() ? language.t("common.loading") : language.t("palette.empty")}
|
||||
{search.loading()
|
||||
? language.t("common.loading")
|
||||
: search.failed()
|
||||
? language.t("common.requestFailed")
|
||||
: language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groupedEntries()}>
|
||||
{(group) => (
|
||||
<div class="command-palette-group">
|
||||
<Show when={group.category}>
|
||||
<div class="command-palette-group-title">{group.category}</div>
|
||||
</Show>
|
||||
<For each={group.entries}>
|
||||
{(item) => (
|
||||
<PaletteRow
|
||||
item={item}
|
||||
active={activeEntry()?.id === item.id}
|
||||
language={language}
|
||||
sessionOpen={
|
||||
item.server && item.sessionID
|
||||
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
||||
: false
|
||||
}
|
||||
onActive={() => setStore("active", item.id)}
|
||||
onSelect={() => props.select(item)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
<For each={groupKeys()}>
|
||||
{(key) => {
|
||||
const entries = createMemo(() => visibleEntries().filter((entry) => groupKey(entry) === key))
|
||||
const category = createMemo(() => entries()[0]?.category ?? "")
|
||||
|
||||
const ids = createMemo<string[]>((prev) => {
|
||||
const next = entries().map((entry) => entry.id)
|
||||
|
||||
return prev && prev.length === next.length && prev.every((id, index) => id === next[index])
|
||||
? prev
|
||||
: next
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="command-palette-group">
|
||||
<Show when={category()}>
|
||||
<div class="command-palette-group-title">{category()}</div>
|
||||
</Show>
|
||||
<For each={ids()}>
|
||||
{(id) => {
|
||||
const item = () => entryById().get(id) ?? entries()[0]!
|
||||
|
||||
return (
|
||||
<PaletteRow
|
||||
item={item()}
|
||||
active={activeEntry()?.id === id}
|
||||
language={language}
|
||||
sessionOpen={
|
||||
item().server && item().sessionID
|
||||
? openSessions().has(`${item().server}\0${item().sessionID}`)
|
||||
: false
|
||||
}
|
||||
onActive={() => setStore("active", id)}
|
||||
onSelect={() => props.select(item())}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { getFilename } from "@opencode/util/path"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/shell/commands/command"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useGlobal, type ServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
@@ -16,6 +18,7 @@ import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useExtensionHost } from "@/runtime/extension/host"
|
||||
import { useExtensionAttachment } from "@/runtime/extension/host-apis"
|
||||
import { looksLikeSessionID } from "@/session/search"
|
||||
import { matchesCommandPaletteEntry } from "./search"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
id: string
|
||||
@@ -143,6 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
|
||||
const sessions = createServerSessionEntries({
|
||||
server: ServerConnection.key(serverSDK.server),
|
||||
known: createKnownServerSessions(serverCtx),
|
||||
projects: serverCtx.projects.list,
|
||||
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
get: (sessionID, signal) => serverSDK.api.session.get({ sessionID }, { signal }),
|
||||
@@ -223,8 +227,13 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
|
||||
}
|
||||
}
|
||||
|
||||
type SessionSearchState = {
|
||||
abort?: AbortController
|
||||
}
|
||||
|
||||
export function createServerSessionEntries(props: {
|
||||
server: ServerConnection.Key
|
||||
known?: () => SessionInfo[]
|
||||
projects: () => Project[]
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||
get: (sessionID: string, signal: AbortSignal) => Promise<SessionInfo>
|
||||
@@ -233,16 +242,54 @@ export function createServerSessionEntries(props: {
|
||||
recentCategory: () => string
|
||||
recentLimit?: number
|
||||
}) {
|
||||
let abort: AbortController | undefined
|
||||
const state: SessionSearchState = {}
|
||||
const [store, setStore] = createStore<{ loaded: SessionInfo[] }>({ loaded: [] })
|
||||
|
||||
onCleanup(() => abort?.abort())
|
||||
// Let the newest metadata win duplicates before ranking the merged sessions newest-first.
|
||||
const known = createMemo(() =>
|
||||
[
|
||||
...new Map(
|
||||
[...(props.known?.() ?? []), ...store.loaded]
|
||||
.toSorted((a, b) => a.time.updated - b.time.updated)
|
||||
.map((session) => [session.id, session] as const),
|
||||
).values(),
|
||||
].toSorted((a, b) => b.time.updated - a.time.updated),
|
||||
)
|
||||
|
||||
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||
onCleanup(() => state.abort?.abort())
|
||||
|
||||
const entries = (sessions: SessionInfo[], search: string): CommandPaletteEntry[] => {
|
||||
const projects = props.projects()
|
||||
const category = search ? props.category() : props.recentCategory()
|
||||
|
||||
return sessions.flatMap((session) => {
|
||||
if (session.time.archived) return []
|
||||
|
||||
const project = projectForSession(session, projects)
|
||||
|
||||
return [
|
||||
{
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : getFilename(session.location.directory),
|
||||
category,
|
||||
directory: session.location.directory,
|
||||
sessionID: session.id,
|
||||
server: props.server,
|
||||
project,
|
||||
updated: session.time.updated,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const search = async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||
const search = text.trim()
|
||||
|
||||
abort?.abort()
|
||||
state.abort?.abort()
|
||||
const current = new AbortController()
|
||||
abort = current
|
||||
state.abort = current
|
||||
|
||||
// Typed searches wait for a pause; an empty query lists recent sessions right away.
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -258,13 +305,11 @@ export function createServerSessionEntries(props: {
|
||||
})
|
||||
|
||||
if (current.signal.aborted) return []
|
||||
const projects = props.projects()
|
||||
const category = search ? props.category() : props.recentCategory()
|
||||
|
||||
const entries = await Promise.all([
|
||||
const [listed, exact] = await Promise.all([
|
||||
props.load(search, current.signal).then(
|
||||
(result) => result.data,
|
||||
() => [],
|
||||
(result) => ({ items: result.data, failed: false }),
|
||||
() => ({ items: [], failed: true }),
|
||||
),
|
||||
looksLikeSessionID(search)
|
||||
? props.get(search, current.signal).then(
|
||||
@@ -272,31 +317,51 @@ export function createServerSessionEntries(props: {
|
||||
() => [],
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]).then(([listed, exact]) =>
|
||||
[...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()].flatMap(
|
||||
(session) => {
|
||||
if (session.time.archived) return []
|
||||
])
|
||||
|
||||
const project = projectForSession(session, projects)
|
||||
if (current.signal.aborted) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : getFilename(session.location.directory),
|
||||
category,
|
||||
directory: session.location.directory,
|
||||
sessionID: session.id,
|
||||
server: props.server,
|
||||
project,
|
||||
updated: session.time.updated,
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
if (listed.failed && exact.length === 0) throw new Error("Session search failed")
|
||||
|
||||
const loaded = [...new Map([...exact, ...listed.items].map((session) => [session.id, session] as const)).values()]
|
||||
// Keep names learned while this palette is open available to subsequent keystrokes.
|
||||
setStore("loaded", (previous) => [
|
||||
...new Map([...previous, ...loaded].map((session) => [session.id, session] as const)).values(),
|
||||
])
|
||||
|
||||
// The local collection owns the recent rows, including newly loaded ones.
|
||||
return search ? entries(loaded, search) : []
|
||||
}
|
||||
|
||||
return Object.assign(search, {
|
||||
search,
|
||||
items(text: string) {
|
||||
const search = text.trim()
|
||||
const all = entries(known(), search)
|
||||
|
||||
if (!search) return all.slice(0, props.recentLimit ?? ENTRY_LIMIT)
|
||||
|
||||
return all.filter((entry) => matchesCommandPaletteEntry(entry, search)).slice(0, 50)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createKnownServerSessions(server: ServerCtx) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return () => {
|
||||
// Reuse the Home index already loaded before opening the palette; do not fetch an index for search.
|
||||
const cached =
|
||||
queryClient.getQueryData<SessionInfo[]>(["home-sessions", server.sdk.server]) ??
|
||||
queryClient.getQueryData<SessionInfo[]>(["home-sessions", ServerConnection.key(server.sdk.server)]) ??
|
||||
[]
|
||||
|
||||
const merged = new Map(
|
||||
[...cached, ...server.data.session.list()].map((session) => [session.id, session] as const),
|
||||
)
|
||||
|
||||
return search ? entries : entries.slice(0, props.recentLimit ?? ENTRY_LIMIT)
|
||||
return server.data.session.apply(
|
||||
[...merged.values()].filter((session) => !session.parentID && !session.time.archived),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { uniqueCommandPaletteEntries, type CommandPaletteEntry } from "./palette"
|
||||
|
||||
type SourceResult = {
|
||||
query: string
|
||||
items: CommandPaletteEntry[]
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
type SourceState = {
|
||||
abort?: AbortController
|
||||
seen: CommandPaletteEntry[]
|
||||
}
|
||||
|
||||
const emptyResult: SourceResult = { query: "", items: [], failed: false }
|
||||
|
||||
export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: string) {
|
||||
const value = query.trim().toLowerCase()
|
||||
|
||||
if (!value) return true
|
||||
|
||||
return [entry.title, entry.description, entry.category, entry.keywords, entry.path, entry.sessionID].some((text) =>
|
||||
text?.toLowerCase().includes(value),
|
||||
)
|
||||
}
|
||||
|
||||
export function createCommandPaletteSearch(props: {
|
||||
query: () => string
|
||||
items: (query: string) => CommandPaletteEntry[]
|
||||
@@ -10,37 +33,82 @@ export function createCommandPaletteSearch(props: {
|
||||
const local = createMemo(() => props.items(query()))
|
||||
|
||||
const sources = props.sources.map((load) => {
|
||||
let abort: AbortController | undefined
|
||||
onCleanup(() => abort?.abort())
|
||||
const state: SourceState = { seen: [] }
|
||||
onCleanup(() => state.abort?.abort())
|
||||
|
||||
const [result] = createResource(
|
||||
const [result] = createResource<SourceResult, string>(
|
||||
query,
|
||||
async (query) => {
|
||||
abort?.abort()
|
||||
async (query, info) => {
|
||||
state.abort?.abort()
|
||||
const current = new AbortController()
|
||||
abort = current
|
||||
state.abort = current
|
||||
const previous = info.value ?? emptyResult
|
||||
|
||||
return { query, items: await load(query, current.signal).catch(() => []) }
|
||||
return load(query, current.signal).then(
|
||||
(items) => {
|
||||
if (current.signal.aborted) return previous
|
||||
|
||||
if (query && items.length > 0) {
|
||||
state.seen = uniqueCommandPaletteEntries([...items, ...state.seen])
|
||||
}
|
||||
|
||||
return { query, items, failed: false }
|
||||
},
|
||||
() => (current.signal.aborted ? previous : { query, items: [], failed: true }),
|
||||
)
|
||||
},
|
||||
// Remote searches must not suspend the dialog's local results on first render.
|
||||
{ initialValue: { query: "", items: [] as CommandPaletteEntry[] } },
|
||||
{ initialValue: emptyResult },
|
||||
)
|
||||
|
||||
return result
|
||||
return { result, state }
|
||||
})
|
||||
|
||||
const loading = () => sources.some((source) => source.result.loading)
|
||||
|
||||
const items = createMemo<CommandPaletteEntry[]>(() => {
|
||||
const current = query()
|
||||
|
||||
const entries = uniqueCommandPaletteEntries([
|
||||
...local(),
|
||||
...sources.flatMap((source) => {
|
||||
const result =
|
||||
source.result.state === "ready" || source.result.state === "refreshing" ? source.result.latest : emptyResult
|
||||
|
||||
if (result.query === current) return result.items
|
||||
|
||||
if (!current) return []
|
||||
|
||||
const matched = uniqueCommandPaletteEntries([...result.items, ...source.state.seen]).filter((item) =>
|
||||
matchesCommandPaletteEntry(item, current),
|
||||
)
|
||||
|
||||
if (matched.length > 0) return matched
|
||||
|
||||
return result.query && current.toLowerCase().startsWith(result.query.toLowerCase()) ? result.items : []
|
||||
}),
|
||||
])
|
||||
|
||||
if (!current) return entries
|
||||
|
||||
const order = { command: 0, session: 1, file: 2 }
|
||||
|
||||
return entries.toSorted(
|
||||
(a, b) =>
|
||||
order[a.type] - order[b.type] ||
|
||||
(a.type === "session" && b.type === "session" ? (b.updated ?? 0) - (a.updated ?? 0) : 0),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
items: createMemo(() =>
|
||||
uniqueCommandPaletteEntries([
|
||||
...local(),
|
||||
...sources.flatMap((source) => {
|
||||
// Never keep results for an older query selectable while the next one loads.
|
||||
const result = source.latest
|
||||
items,
|
||||
loading,
|
||||
failed: () =>
|
||||
sources.some((source) => {
|
||||
const result =
|
||||
source.result.state === "ready" || source.result.state === "refreshing" ? source.result.latest : emptyResult
|
||||
|
||||
return result.query === query() ? result.items : []
|
||||
}),
|
||||
]),
|
||||
),
|
||||
loading: () => sources.some((source) => source.loading),
|
||||
return result.query === query() && result.failed
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { createServerSessionEntries, type CommandPaletteEntry } from "./palette"
|
||||
import { createKnownServerSessions, createServerSessionEntries, type CommandPaletteEntry } from "./palette"
|
||||
import { CommandPaletteView } from "./dialog"
|
||||
|
||||
const RECENT_LIMIT = 10
|
||||
@@ -15,14 +15,17 @@ export function DialogSessionSearch() {
|
||||
const language = useLanguage()
|
||||
const tabs = useTabs()
|
||||
const registry = useServers()
|
||||
|
||||
const servers = registry.visible.filter(
|
||||
(conn) => global.servers.health[ServerConnection.key(conn)]?.healthy !== false,
|
||||
)
|
||||
|
||||
const loaders = servers.map((conn) => {
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
|
||||
const load = createServerSessionEntries({
|
||||
server: ServerConnection.key(conn),
|
||||
known: createKnownServerSessions(ctx),
|
||||
projects: ctx.projects.list,
|
||||
load: (search, signal) => ctx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
get: (sessionID, signal) => ctx.sdk.api.session.get({ sessionID }, { signal }),
|
||||
@@ -34,21 +37,32 @@ export function DialogSessionSearch() {
|
||||
|
||||
const tag = registry.tag(conn)
|
||||
|
||||
if (servers.length < 2 || !tag) return load
|
||||
const format = (entries: CommandPaletteEntry[]) =>
|
||||
servers.length < 2 || !tag
|
||||
? entries
|
||||
: entries.map((entry) => ({
|
||||
...entry,
|
||||
description: language.t("palette.session.location", { project: entry.description ?? "", server: tag }),
|
||||
}))
|
||||
|
||||
return (query: string) =>
|
||||
load(query).then((entries) =>
|
||||
entries.map((entry) => ({
|
||||
...entry,
|
||||
description: language.t("palette.session.location", { project: entry.description ?? "", server: tag }),
|
||||
})),
|
||||
)
|
||||
return {
|
||||
items: (query: string) => format(load.items(query)),
|
||||
search: (query: string) => load.search(query).then(format),
|
||||
}
|
||||
})
|
||||
|
||||
const items = (query: string) => {
|
||||
const entries = loaders
|
||||
.flatMap((loader) => loader.items(query))
|
||||
.toSorted((a, b) => (b.updated ?? 0) - (a.updated ?? 0))
|
||||
|
||||
return query ? entries : entries.slice(0, RECENT_LIMIT)
|
||||
}
|
||||
|
||||
const sessions = async (query: string) => {
|
||||
const entries = (await Promise.all(loaders.map((load) => load(query))))
|
||||
const entries = (await Promise.all(loaders.map((loader) => loader.search(query))))
|
||||
.flat()
|
||||
.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))
|
||||
.toSorted((a, b) => (b.updated ?? 0) - (a.updated ?? 0))
|
||||
|
||||
return query ? entries : entries.slice(0, RECENT_LIMIT)
|
||||
}
|
||||
@@ -68,7 +82,7 @@ export function DialogSessionSearch() {
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("home.sessions.search.placeholder")}
|
||||
items={() => []}
|
||||
items={items}
|
||||
sources={[sessions]}
|
||||
highlight={() => {}}
|
||||
select={select}
|
||||
|
||||
@@ -111,4 +111,36 @@ describe("command palette search", () => {
|
||||
expect(root.search.items()).toEqual([copy, file])
|
||||
root.dispose()
|
||||
})
|
||||
|
||||
test("filters previous remote results synchronously while a refined query is pending", async () => {
|
||||
const first = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const second = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const otherFile: CommandPaletteEntry = { id: "file: readme.md", type: "file", title: "readme.md", category: "Files" }
|
||||
const ready = Promise.withResolvers<void>()
|
||||
|
||||
const root = createRoot((dispose) => {
|
||||
const [query, setQuery] = createSignal("c")
|
||||
|
||||
const search = createCommandPaletteSearch({
|
||||
query,
|
||||
items: (q) => (q === "c" ? [copy] : []),
|
||||
sources: [(q) => (q === "c" ? first.promise : second.promise)],
|
||||
})
|
||||
|
||||
createComputed(() => {
|
||||
if (search.items().some((entry) => entry.id === file.id)) ready.resolve()
|
||||
})
|
||||
|
||||
return { search, setQuery, dispose }
|
||||
})
|
||||
|
||||
first.resolve([file, otherFile])
|
||||
await ready.promise
|
||||
expect(root.search.items()).toEqual([copy, file, otherFile])
|
||||
|
||||
root.setQuery("copy")
|
||||
expect(root.search.loading()).toBe(true)
|
||||
expect(root.search.items()).toEqual([file])
|
||||
root.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,6 +121,47 @@ describe("command palette sessions", () => {
|
||||
expect(gets).toEqual([exact.id])
|
||||
expect(result).toMatchObject([{ sessionID: exact.id, title: exact.title }])
|
||||
})
|
||||
|
||||
test("matches known and loaded sessions synchronously while remote search is pending", async () => {
|
||||
const server = ServerConnection.Key.make("selected-server")
|
||||
|
||||
const remote = {
|
||||
...session,
|
||||
id: "session-2",
|
||||
title: "Remote palette session",
|
||||
time: { created: 2, updated: 4 },
|
||||
}
|
||||
|
||||
await createRoot(async (dispose) => {
|
||||
try {
|
||||
const sessions = createServerSessionEntries({
|
||||
server,
|
||||
known: () => [session],
|
||||
projects: () => [project],
|
||||
load: async () => ({ data: [remote] }),
|
||||
get: async () => session,
|
||||
untitled: () => "Untitled",
|
||||
category: () => "Session",
|
||||
recentCategory: () => "Recent sessions",
|
||||
})
|
||||
|
||||
expect(sessions.items("")).toMatchObject([
|
||||
{ sessionID: session.id, title: session.title, category: "Recent sessions" },
|
||||
])
|
||||
expect(sessions.items("palette")).toMatchObject([
|
||||
{ sessionID: session.id, title: session.title, category: "Session" },
|
||||
])
|
||||
|
||||
await sessions.search("remote")
|
||||
expect(sessions.items("palette")).toMatchObject([
|
||||
{ sessionID: remote.id, title: remote.title, category: "Session" },
|
||||
{ sessionID: session.id, title: session.title, category: "Session" },
|
||||
])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("command palette commands", () => {
|
||||
|
||||
Reference in New Issue
Block a user