diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index c9533407586..ceb2f7e8959 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -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() + const pendingSessions = Promise.withResolvers() + + 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, + }) +}) diff --git a/packages/app/src/home/start/command-palette.tsx b/packages/app/src/home/start/command-palette.tsx index 80297881cf8..1d82539d2ed 100644 --- a/packages/app/src/home/start/command-palette.tsx +++ b/packages/app/src/home/start/command-palette.tsx @@ -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: { dialog.close()} diff --git a/packages/app/src/shell/commands/dialog.tsx b/packages/app/src/shell/commands/dialog.tsx index 06d6d2c020e..7ddcc30775d 100644 --- a/packages/app/src/shell/commands/dialog.tsx +++ b/packages/app/src/shell/commands/dialog.tsx @@ -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() +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((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={} - 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} /> @@ -164,34 +170,55 @@ export function CommandPaletteView(props: { when={visibleEntries().length > 0} fallback={
- {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")}
} > - - {(group) => ( -
- -
{group.category}
-
- - {(item) => ( - setStore("active", item.id)} - onSelect={() => props.select(item)} - /> - )} - -
- )} + + {(key) => { + const entries = createMemo(() => visibleEntries().filter((entry) => groupKey(entry) === key)) + const category = createMemo(() => entries()[0]?.category ?? "") + + const ids = createMemo((prev) => { + const next = entries().map((entry) => entry.id) + + return prev && prev.length === next.length && prev.every((id, index) => id === next[index]) + ? prev + : next + }) + + return ( +
+ +
{category()}
+
+ + {(id) => { + const item = () => entryById().get(id) ?? entries()[0]! + + return ( + setStore("active", id)} + onSelect={() => props.select(item())} + /> + ) + }} + +
+ ) + }}
diff --git a/packages/app/src/shell/commands/palette.ts b/packages/app/src/shell/commands/palette.ts index 4e14cf36e33..1b09edf54e2 100644 --- a/packages/app/src/shell/commands/palette.ts +++ b/packages/app/src/shell/commands/palette.ts @@ -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 @@ -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 => { + 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 => { 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((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(["home-sessions", server.sdk.server]) ?? + queryClient.getQueryData(["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), + ) } } diff --git a/packages/app/src/shell/commands/search.ts b/packages/app/src/shell/commands/search.ts index 5307a73089c..da5875f6aac 100644 --- a/packages/app/src/shell/commands/search.ts +++ b/packages/app/src/shell/commands/search.ts @@ -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( 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(() => { + 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 + }), } } diff --git a/packages/app/src/shell/commands/session-search.tsx b/packages/app/src/shell/commands/session-search.tsx index 71a7062682a..6be52d70c18 100644 --- a/packages/app/src/shell/commands/session-search.tsx +++ b/packages/app/src/shell/commands/session-search.tsx @@ -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 ( []} + items={items} sources={[sessions]} highlight={() => {}} select={select} diff --git a/packages/app/test-browser/command-palette-search.test.ts b/packages/app/test-browser/command-palette-search.test.ts index b9287b64514..868d6275b0b 100644 --- a/packages/app/test-browser/command-palette-search.test.ts +++ b/packages/app/test-browser/command-palette-search.test.ts @@ -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() + const second = Promise.withResolvers() + const otherFile: CommandPaletteEntry = { id: "file: readme.md", type: "file", title: "readme.md", category: "Files" } + const ready = Promise.withResolvers() + + 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() + }) }) diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts index 86f40f81bb6..e846fa2dc18 100644 --- a/packages/app/test-browser/command-palette.test.ts +++ b/packages/app/test-browser/command-palette.test.ts @@ -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", () => {