mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
feat(web): CommandPalette uses localSearch (TASK-1365) (#511)
* feat(web): CommandPalette uses localSearch (TASK-1365)
Phase 3c of the local-first read model (PLAN-1343 / DOC-1342): wire
the global CommandPalette / top-bar search to localSearch.
Behavior
- Default scope: search the current workspace's in-memory MiniSearch
index synchronously on every keystroke. No network round-trip, no
200ms debounce — sub-millisecond typing.
- "All workspaces" toggle: when on, also search every other workspace
whose localIndex is `'ready'` (i.e. already hydrated this session).
Results from every ready workspace are merged by score, ties broken
by `updated_at DESC`. Toggle state persists to localStorage so it
survives reloads. Hidden when only one workspace is ready (no
point showing a no-op toggle).
- Cross-workspace navigation: each local result carries its source
workspace slug + owner_username so `selectResult` navigates to the
right route — `selectResult` falls back to `workspaceStore.current`
for server hits.
- Server fallback paths:
* `body:` / `content:` queries — local index doesn't hold the
rich-text body, so server FTS is the only way to grep.
* No ready workspaces yet (cold session) — falls through to
`api.search` so the palette still works pre-bootstrap.
- Reactive: a single `$effect` watches `query`, `searchAllWorkspaces`,
filter chips, every workspace's `localSearch.epoch`, and the
current workspace's bootstrap state — so SSE-driven upserts and
hot toggle flips re-rank without manual `doSearch()` calls. The
`oninput={doSearch}` handler is removed; reactivity does the work.
- Drops `result-count`, `loadMore`, and facets on the local path —
local results aren't paginated (everything's in RAM, capped at
`PAGE_SIZE * 2` for display); facets are a server-only feature.
Parent: PLAN-1343.
* fix(web): hide Load more on local search path per Codex review (round 1)
Codex round 1 P2: `total` was set to the pre-slice
`filtered.length` while `results` was capped at `PAGE_SIZE * 2`.
That made the "Load more" affordance show when local matches
exceeded the cap — and clicking it would call `api.search`, which
injects single-workspace server-FTS rows into the local
(potentially cross-workspace) result set.
Set `total = results.length` on the local path so the affordance
stays hidden. Local results are all in RAM; if the result count
exceeds the display cap the right answer is a tighter query, not
a paginated server fetch.
* fix(web): current-workspace fallback + filter chip persistence per Codex review (round 2)
Codex round 2 P2 #1: with `searchAllWorkspaces` on, if the current
workspace was still bootstrapping but any OTHER workspace was ready,
`ready.length > 0` sent the search down the local-only path —
omitting current-workspace results entirely. The toggle is meant to
widen the search, never to replace the current workspace.
Add an explicit `currentReady` check: only take the local path
when the current workspace is ready. Otherwise fall through to the
server (which will return the current workspace's results too).
Codex round 2 P2 #2: filter chips were gated on `facets` (server
only). Switching from server → local with a filter active would
hide the chip but keep the filter applied. Add a fallback row that
shows the active chip(s) on the local path so they're visible and
clearable.
* fix(web): stale-response guard + status filter under-fill per Codex review (round 3)
Codex round 3 P2 #1: server search responses had no stale-response
guard. A request started while `currentReady` was false (or for a
`body:` query) could return after the local path had already
rendered and clobber local results — including reintroducing
`total > results.length` and the Load more button. Snapshot the
query + `searchAllWorkspaces` flag at dispatch; only apply the
response if both still match. The same guard protects the catch
and finally branches.
Codex round 3 P2 #2: local status filtering was applied AFTER the
per-workspace `localSearch.search(... limit: 20)` cap, so a status
chip could under-fill or empty the result set even when matching
items existed beyond the cap. Expand the per-workspace pull by 5x
when `filterStatus` is active so the post-fetch filter has
headroom. (The collection filter doesn't need this because it's
passed directly to `localSearch.search`, which filters inside
the index walk.)
* fix(web): full-scope stale-response guard per Codex review (round 4)
Codex round 4 P2: the R3 stale-response guard only snapshotted
`query` and `searchAllWorkspaces`. An in-flight server response
could still clobber newer local results after `currentReady`
flipped to true, or overwrite results after a filter chip changed
with the same query.
Snapshot the full dispatch scope (query, toggle, filter chips,
current workspace slug, currentReady-vs-body branch) at request
time and gate every `results = ...` site on `isSameDispatch()`.
Once the current workspace hydrates, a server response from a
`!currentReady` snapshot is no longer authoritative.
* fix(web): read live currentReady + guard loadMore per Codex review (round 5)
Codex round 5 P2 #1: my R4 `isSameDispatch` captured
`currentReady` at dispatch time, so the check stayed stale once
the index hydrated mid-request — the cold server response still
matched and clobbered the local results that the readiness effect
had just produced. Switch to reading live state via
`localIndex.bootstrapStateFor(snapshotWsSlug)` inside the guard;
captured `snapshotCurrentReady` is removed.
Codex round 5 P2 #2: `loadMore` only snapshotted query/filters. A
server-page request in flight could append rows after the scope
changed (current workspace hydrated, all-workspaces toggle flipped).
Add the same full-scope guard — query, toggle, both filter chips,
workspace slug, and live-readiness — and bail if any has shifted.
* fix(web): loadMore handles body: prefix correctly per Codex review (round 6)
Codex round 6 P2: `loadMore` was sending the raw `query` to
`api.search`, so page-2 of a `body:foo` search hit the server
with the literal `body:foo` token. Worse, the live-readiness guard
from R5 dropped body: page-2 responses whenever the current
workspace was ready — but body: searches NEED the server (the
local index doesn't carry content), so they should bypass that
guard.
Parse the query in `loadMore` and send the stripped `parsed.text`
when the body prefix is present. Body queries are now exempt from
the live-readiness drop; only non-body server pagination needs to
worry about the path swap.
* fix(web): body queries skip local-index dependency tracking per Codex review (round 7)
Codex round 7 P2: the search-dispatch effect always tracked
`localIndex.bootstrapStateFor` and `localSearch.epoch` for every
workspace, including for body: queries. An SSE-driven epoch bump or
hydration completion mid-flight would re-fire doSearch from offset 0
and wipe an in-flight `loadMore` append on the body: path.
Gate the local-index dependency reads on `!parsed.body`. Body
queries hit server FTS exclusively (the local index doesn't carry
content), so their result set is unaffected by client-side mutations;
skipping the tracking eliminates the loadMore race without losing
incremental-update behavior for local searches.
* fix(web): short-circuit local-state reads in doSearch for body queries per Codex review (round 8)
Codex round 8 P2: even after R7 made the `$effect` skip explicit
localIndex/epoch reads for body queries, `doSearch()` still
synchronously called `readyWorkspaces()` and
`localIndex.bootstrapStateFor()` BEFORE branching on `parsed.body`.
Those reads register as reactive dependencies of the caller, so a
mid-flight SSE bump or hydration completion would still re-fire
doSearch and clobber an in-flight body: `loadMore` append.
Reorder doSearch: parse first, then short-circuit both
`currentReady` and `readyWorkspaces()` to constants when
`parsed.body` is true. Body queries are server-authoritative;
nothing in local state can change their result set, so they pay
no reactivity tax on the local index.
* fix(web): bare body:/content: queries no-op per Codex review (round 9)
Codex round 9 P3: bare `body:` / `content:` with no following
text was falling back to the raw query, shipping the literal
`body:` token to `/search`. `loadMore` had the same fallback.
Short-circuit both paths: when `parsed.body` is true and
`parsed.text` is empty, clear results / bail. There's nothing
useful to search for until the user keeps typing.
This commit is contained in:
@@ -3,19 +3,49 @@
|
||||
import { api } from '$lib/api/client';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
import { localSearch, parseSearchQuery } from '$lib/stores/localSearch.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import type { SearchResult, SearchFacets, SearchFilters } from '$lib/types';
|
||||
import type {
|
||||
SearchResult,
|
||||
SearchFacets,
|
||||
SearchFilters,
|
||||
Item,
|
||||
ItemIndexRow,
|
||||
} from '$lib/types';
|
||||
import { getFieldValue, itemUrlId, formatItemRef } from '$lib/types';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
|
||||
const RECENT_SEARCHES_KEY = 'pad-recent-searches';
|
||||
const MAX_RECENT = 10;
|
||||
const PAGE_SIZE = 20;
|
||||
// Cross-workspace results merged from N ready workspaces — cap the
|
||||
// fan-out so an extreme power user with 50 hydrated workspaces
|
||||
// doesn't pay a 50× O(matching-docs) cost per keystroke. 20 is the
|
||||
// max display anyway (PAGE_SIZE), but pulling the top 60 across
|
||||
// workspaces (20 each from top 3) gives enough headroom for the
|
||||
// merge step to keep representative coverage. PLAN-1343 / TASK-1365.
|
||||
const LOCAL_PER_WS_LIMIT = 20;
|
||||
|
||||
// Augmented result — adds the source workspace so cross-workspace
|
||||
// navigation lands on the right URL. For results returned by the
|
||||
// server `/search` endpoint (single-workspace path), `workspace`
|
||||
// stays undefined and `selectResult` falls back to
|
||||
// `workspaceStore.current`. For local results, the workspace is
|
||||
// resolved from `workspaceStore.workspaces`.
|
||||
interface AugmentedSearchResult extends SearchResult {
|
||||
workspace?: { slug: string; owner_username: string };
|
||||
}
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
let results = $state<AugmentedSearchResult[]>([]);
|
||||
let total = $state(0);
|
||||
let facets = $state<SearchFacets | undefined>(undefined);
|
||||
// `searchAllWorkspaces` toggle: when on, search every workspace
|
||||
// whose `localIndex` is `'ready'` in addition to the current one.
|
||||
// Non-ready workspaces fall back to server search transparently.
|
||||
// Stored in localStorage so the toggle survives reloads.
|
||||
let searchAllWorkspaces = $state(loadAllWorkspacesPref());
|
||||
// -1 means "no result armed". The user must press an arrow key (or type a
|
||||
// bare number) before Enter will navigate. See BUG-864.
|
||||
let selectedIdx = $state(-1);
|
||||
@@ -93,9 +123,9 @@
|
||||
};
|
||||
});
|
||||
|
||||
function buildFilters(offset = 0): SearchFilters {
|
||||
function buildFilters(offset = 0, wsSlug?: string): SearchFilters {
|
||||
const filters: SearchFilters = {
|
||||
workspace: workspaceStore.current?.slug,
|
||||
workspace: wsSlug ?? workspaceStore.current?.slug,
|
||||
limit: PAGE_SIZE,
|
||||
offset
|
||||
};
|
||||
@@ -104,9 +134,68 @@
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a localSearch `{ id, score }[]` hit list into the
|
||||
* SearchResult shape consumed by the palette template. Drops hits
|
||||
* whose row has fallen out of `localIndex` (stale rebuild race) and
|
||||
* tags each result with its source workspace so cross-workspace
|
||||
* navigation works. TASK-1365.
|
||||
*/
|
||||
function materializeLocalHits(
|
||||
wsSlug: string,
|
||||
ownerUsername: string,
|
||||
hits: { id: string; score: number }[],
|
||||
): AugmentedSearchResult[] {
|
||||
const out: AugmentedSearchResult[] = [];
|
||||
for (const h of hits) {
|
||||
const row = localIndex.findByIdOrSlug(wsSlug, h.id);
|
||||
if (!row) continue;
|
||||
// `Item` widens `ItemIndexRow` by adding `content` (always ''
|
||||
// since the local index doesn't carry the rich-text body).
|
||||
// The CommandPalette only reads title/fields/ref/etc., so an
|
||||
// empty content body is fine — and matches the same widening
|
||||
// pattern the collection page uses at TASK-1357.
|
||||
const item: Item = { ...(row as ItemIndexRow), content: '' } as Item;
|
||||
out.push({
|
||||
item,
|
||||
snippet: '',
|
||||
rank: h.score,
|
||||
workspace: { slug: wsSlug, owner_username: ownerUsername },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the workspaces eligible for in-RAM local search:
|
||||
* - Always the current workspace if it's `'ready'`.
|
||||
* - When `searchAllWorkspaces` is on, every other workspace whose
|
||||
* `localIndex.bootstrapStateFor` is `'ready'`.
|
||||
*
|
||||
* Non-ready workspaces are NOT included here — they don't have a
|
||||
* MiniSearch index built yet, and triggering bootstrap from the
|
||||
* search palette would be surprising. They fall back to server
|
||||
* search via the alternate path.
|
||||
*/
|
||||
function readyWorkspaces(): { slug: string; owner_username: string }[] {
|
||||
const out: { slug: string; owner_username: string }[] = [];
|
||||
const current = workspaceStore.current;
|
||||
if (current && localIndex.bootstrapStateFor(current.slug) === 'ready') {
|
||||
out.push({ slug: current.slug, owner_username: current.owner_username ?? '' });
|
||||
}
|
||||
if (!searchAllWorkspaces) return out;
|
||||
for (const ws of workspaceStore.workspaces) {
|
||||
if (current && ws.slug === current.slug) continue;
|
||||
if (localIndex.bootstrapStateFor(ws.slug) !== 'ready') continue;
|
||||
out.push({ slug: ws.slug, owner_username: ws.owner_username ?? '' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
if (!query.trim()) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
results = [];
|
||||
total = 0;
|
||||
facets = undefined;
|
||||
@@ -114,39 +203,270 @@
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await api.search(query, buildFilters(0));
|
||||
// Defensive: some backends / error paths can send `null` for
|
||||
// an absent array. Coalesce so downstream `.length` is safe.
|
||||
results = resp.results ?? [];
|
||||
total = resp.total ?? 0;
|
||||
facets = resp.facets;
|
||||
// BUG-864: do NOT auto-arm the first result. The user must
|
||||
// press an arrow key (or type a bare number + Enter) to
|
||||
// trigger navigation.
|
||||
selectedIdx = -1;
|
||||
} catch {
|
||||
results = [];
|
||||
total = 0;
|
||||
facets = undefined;
|
||||
} finally {
|
||||
loading = false;
|
||||
|
||||
const parsed = parseSearchQuery(trimmed);
|
||||
const currentSlug = workspaceStore.current?.slug;
|
||||
// CRITICAL: don't read `localIndex.bootstrapStateFor` or
|
||||
// `localSearch.epoch` for body queries — those reads register
|
||||
// as reactive dependencies of the caller (`$effect`), and an
|
||||
// SSE-driven epoch bump or hydration completion mid-flight
|
||||
// would then re-fire doSearch and wipe an in-flight `loadMore`
|
||||
// append. Body queries are server-authoritative; nothing in
|
||||
// local state can change their result set. Codex round 8 P2
|
||||
// of TASK-1365.
|
||||
const currentReady = parsed.body
|
||||
? true
|
||||
: !!currentSlug && localIndex.bootstrapStateFor(currentSlug) === 'ready';
|
||||
const ready = parsed.body ? [] : readyWorkspaces();
|
||||
|
||||
// Server-only paths:
|
||||
// - `body:` / `content:` queries — local index doesn't hold
|
||||
// the rich-text body, server FTS is the only way to grep.
|
||||
// - Current workspace not yet hydrated — fall through to the
|
||||
// server even if other workspaces are ready, so the user
|
||||
// never misses results from where they are. The
|
||||
// `searchAllWorkspaces` toggle is meant to widen, never
|
||||
// to replace. Codex round 2 P2.
|
||||
// - No ready workspaces at all (cold session) — fall through
|
||||
// to the server so the palette still works pre-bootstrap.
|
||||
// All three use a 200ms debounce on the network call.
|
||||
if (parsed.body || !currentReady || ready.length === 0) {
|
||||
loading = true;
|
||||
// Snapshot the FULL dispatch scope at request time
|
||||
// (Codex rounds 4-5 P2): the response is only valid if
|
||||
// every dimension that determines what we render is still
|
||||
// the same. `isSameDispatch` must READ LIVE state for
|
||||
// readiness — capturing `currentReady` at dispatch left
|
||||
// the check stale once the index hydrated mid-request.
|
||||
const snapshotQuery = trimmed;
|
||||
const snapshotAllWs = searchAllWorkspaces;
|
||||
const snapshotBody = parsed.body;
|
||||
const snapshotFilterCollection = filterCollection;
|
||||
const snapshotFilterStatus = filterStatus;
|
||||
const snapshotWsSlug = currentSlug;
|
||||
const isSameDispatch = () => {
|
||||
if (query.trim() !== snapshotQuery) return false;
|
||||
if (searchAllWorkspaces !== snapshotAllWs) return false;
|
||||
if (filterCollection !== snapshotFilterCollection) return false;
|
||||
if (filterStatus !== snapshotFilterStatus) return false;
|
||||
if (workspaceStore.current?.slug !== snapshotWsSlug) return false;
|
||||
// Body queries grep content the local index doesn't
|
||||
// hold, so they're authoritative regardless of
|
||||
// hydration state — only the dimensions checked above
|
||||
// can invalidate them.
|
||||
if (snapshotBody) return true;
|
||||
// Non-body server fetches were triggered because the
|
||||
// current workspace wasn't ready. If it's NOW ready,
|
||||
// the local path is the authoritative source and the
|
||||
// server response is stale. Read live state.
|
||||
const liveCurrentReady =
|
||||
!!snapshotWsSlug &&
|
||||
localIndex.bootstrapStateFor(snapshotWsSlug) === 'ready';
|
||||
if (liveCurrentReady) return false;
|
||||
return true;
|
||||
};
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
// Bare `body:` / `content:` with no following text:
|
||||
// `parsed.text` is empty and we'd otherwise fall back
|
||||
// to the raw query (`body:`) and ship that literal
|
||||
// token to the server. Treat as a no-op until the
|
||||
// user keeps typing. Codex round 9 P3 of TASK-1365.
|
||||
if (parsed.body && !parsed.text) {
|
||||
if (isSameDispatch()) {
|
||||
results = [];
|
||||
total = 0;
|
||||
facets = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const serverQuery = parsed.body ? parsed.text : trimmed;
|
||||
if (!serverQuery.trim()) {
|
||||
if (isSameDispatch()) {
|
||||
results = [];
|
||||
total = 0;
|
||||
facets = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resp = await api.search(serverQuery, buildFilters(0));
|
||||
if (!isSameDispatch()) return;
|
||||
results = resp.results ?? [];
|
||||
total = resp.total ?? 0;
|
||||
facets = resp.facets;
|
||||
selectedIdx = -1;
|
||||
} catch {
|
||||
if (isSameDispatch()) {
|
||||
results = [];
|
||||
total = 0;
|
||||
facets = undefined;
|
||||
}
|
||||
} finally {
|
||||
if (isSameDispatch()) loading = false;
|
||||
}
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local synchronous path. For each ready workspace, run
|
||||
// localSearch.search and materialize to SearchResult shape.
|
||||
// Merge by score descending — ties broken by `updated_at DESC`
|
||||
// for stability.
|
||||
//
|
||||
// Per-workspace limit: when a status filter chip is active, we
|
||||
// expand the per-workspace pull so the post-fetch
|
||||
// `filterStatus` filter has enough headroom to find matches
|
||||
// outside the top 20 (Codex round 3 P2). The status filter
|
||||
// isn't an index-aware operation on the local path — it walks
|
||||
// the parsed `fields` blob after materialization — so a tight
|
||||
// per-ws cap could drop valid lower-ranked matches.
|
||||
const perWsLimit = filterStatus
|
||||
? LOCAL_PER_WS_LIMIT * 5
|
||||
: LOCAL_PER_WS_LIMIT;
|
||||
const merged: AugmentedSearchResult[] = [];
|
||||
for (const ws of ready) {
|
||||
const hits = localSearch.search(ws.slug, trimmed, {
|
||||
collection: filterCollection ?? undefined,
|
||||
limit: perWsLimit,
|
||||
});
|
||||
merged.push(...materializeLocalHits(ws.slug, ws.owner_username, hits));
|
||||
}
|
||||
merged.sort((a, b) => {
|
||||
if (b.rank !== a.rank) return b.rank - a.rank;
|
||||
// Stable tie-break by updated_at DESC then id ASC.
|
||||
const aU = a.item.updated_at ?? '';
|
||||
const bU = b.item.updated_at ?? '';
|
||||
if (aU !== bU) return aU < bU ? 1 : -1;
|
||||
return a.item.id < b.item.id ? -1 : 1;
|
||||
});
|
||||
|
||||
// Apply the status filter chip if active. (Collection filter is
|
||||
// passed through to localSearch.search above.)
|
||||
const filtered = filterStatus
|
||||
? merged.filter((r) => getFieldValue(r.item, 'status') === filterStatus)
|
||||
: merged;
|
||||
|
||||
// Truncate the rendered list to PAGE_SIZE * 2 so cross-workspace
|
||||
// power users still see a representative top slice. CRITICAL:
|
||||
// set `total = results.length` so the "Load more" affordance
|
||||
// stays hidden — loadMore hits the server, which would
|
||||
// inject scope-mismatched (and duplicate) rows into the local
|
||||
// result set. Codex round 1 P2. Local results are all in RAM;
|
||||
// if more than `PAGE_SIZE * 2` match, the right answer is a
|
||||
// more specific query, not a paginated fetch.
|
||||
results = filtered.slice(0, PAGE_SIZE * 2);
|
||||
total = results.length;
|
||||
// Facets are server-only; clear them on the local path so the
|
||||
// chip row hides cleanly.
|
||||
facets = undefined;
|
||||
selectedIdx = -1;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
// Re-run the search whenever any tracked dependency changes:
|
||||
// - `query` typed by the user (keystroke or recent-search click)
|
||||
// - `searchAllWorkspaces` toggle
|
||||
// - Any ready workspace's localSearch epoch (SSE-driven upserts /
|
||||
// removes) — without this, an open-palette user wouldn't see
|
||||
// freshly-created items even though the underlying index
|
||||
// updated. PLAN-1343 / TASK-1365.
|
||||
//
|
||||
// EXCEPTION: body queries don't depend on local index state. They
|
||||
// hit the server, which is the only source of truth for content
|
||||
// text. Skip the readiness / epoch reads for body queries so a
|
||||
// concurrent SSE bump (or hydration completion) can't re-fire
|
||||
// doSearch from offset 0 and wipe an in-flight `loadMore` append.
|
||||
// Codex round 7 P2 of TASK-1365.
|
||||
//
|
||||
// `doSearch` handles the empty-query case internally by clearing
|
||||
// results — so the effect can fire on every transition (including
|
||||
// "user backspaced to empty") without leaving stale results visible.
|
||||
$effect(() => {
|
||||
if (!uiStore.searchOpen) return;
|
||||
void query;
|
||||
void searchAllWorkspaces;
|
||||
void filterCollection;
|
||||
void filterStatus;
|
||||
const trimmed = query.trim();
|
||||
const parsed = trimmed ? parseSearchQuery(trimmed) : null;
|
||||
if (!parsed?.body) {
|
||||
void localIndex.bootstrapStateFor(workspaceStore.current?.slug ?? '');
|
||||
for (const ws of workspaceStore.workspaces) {
|
||||
void localSearch.epoch(ws.slug);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
doSearch();
|
||||
});
|
||||
|
||||
function loadAllWorkspacesPref(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('pad-search-all-workspaces') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAllWorkspaces() {
|
||||
searchAllWorkspaces = !searchAllWorkspaces;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'pad-search-all-workspaces',
|
||||
searchAllWorkspaces ? 'true' : 'false',
|
||||
);
|
||||
} catch {
|
||||
// Storage unavailable (private mode, quota) — silently
|
||||
// degrade; the toggle still works for the session.
|
||||
}
|
||||
// The reactive effect picks up the flip; no explicit re-run.
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore || results.length >= total) return;
|
||||
// Only the server path ever sets `total > results.length` — the
|
||||
// local path pins them equal — so a `loadMore` always means
|
||||
// fetching another server page. Snapshot the dispatch scope
|
||||
// and bail if any of it has shifted before the response lands.
|
||||
// Body queries are always server-authoritative (the local
|
||||
// index doesn't carry content), so they bypass the
|
||||
// live-readiness guard. Codex rounds 5-6 P2.
|
||||
loadingMore = true;
|
||||
const snapshotQuery = query;
|
||||
const snapshotAllWs = searchAllWorkspaces;
|
||||
const snapshotCollection = filterCollection;
|
||||
const snapshotStatus = filterStatus;
|
||||
const snapshotWsSlug = workspaceStore.current?.slug;
|
||||
// Parse once and use the stripped query for the API call so
|
||||
// `body:foo` → page 2 sends `foo`, not the literal `body:foo`.
|
||||
// A bare `body:` / `content:` with no following text is a
|
||||
// no-op — there's nothing to load a second page of.
|
||||
const parsed = parseSearchQuery(query.trim());
|
||||
if (parsed.body && !parsed.text) {
|
||||
loadingMore = false;
|
||||
return;
|
||||
}
|
||||
const serverQuery = parsed.body ? parsed.text : query;
|
||||
try {
|
||||
const resp = await api.search(query, buildFilters(results.length));
|
||||
// Discard if query or filters changed while loading
|
||||
if (query !== snapshotQuery || filterCollection !== snapshotCollection || filterStatus !== snapshotStatus) return;
|
||||
const resp = await api.search(serverQuery, buildFilters(results.length));
|
||||
if (
|
||||
query !== snapshotQuery ||
|
||||
searchAllWorkspaces !== snapshotAllWs ||
|
||||
filterCollection !== snapshotCollection ||
|
||||
filterStatus !== snapshotStatus ||
|
||||
workspaceStore.current?.slug !== snapshotWsSlug
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!parsed.body) {
|
||||
// Non-body server paging: if the current workspace
|
||||
// hydrated while the request was in flight, the main
|
||||
// effect has likely already swapped to the local
|
||||
// path and replaced `results`. Appending more server
|
||||
// rows would inject scope-mismatched results.
|
||||
const liveCurrentReady =
|
||||
!!snapshotWsSlug &&
|
||||
localIndex.bootstrapStateFor(snapshotWsSlug) === 'ready';
|
||||
if (liveCurrentReady) return;
|
||||
}
|
||||
results = [...results, ...(resp.results ?? [])];
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -156,18 +476,18 @@
|
||||
}
|
||||
|
||||
function applyFilter(type: 'collection' | 'status', value: string) {
|
||||
// The reactive search effect re-runs on filterCollection /
|
||||
// filterStatus changes, so no explicit doSearch() needed.
|
||||
if (type === 'collection') {
|
||||
filterCollection = filterCollection === value ? null : value;
|
||||
} else {
|
||||
filterStatus = filterStatus === value ? null : value;
|
||||
}
|
||||
doSearch();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filterCollection = null;
|
||||
filterStatus = null;
|
||||
doSearch();
|
||||
}
|
||||
|
||||
function scrollSelectedIntoView() {
|
||||
@@ -230,10 +550,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(r: SearchResult) {
|
||||
function selectResult(r: AugmentedSearchResult) {
|
||||
saveRecentSearch(query.trim());
|
||||
const ws = workspaceStore.current?.slug;
|
||||
const wsUsername = workspaceStore.current?.owner_username;
|
||||
// Local (cross-workspace) hits ship their source workspace on
|
||||
// the result. Server hits don't — they implicitly came from the
|
||||
// current workspace because `buildFilters` scopes the request.
|
||||
// Resolve via fallback so both paths work. TASK-1365.
|
||||
const ws = r.workspace?.slug ?? workspaceStore.current?.slug;
|
||||
const wsUsername =
|
||||
r.workspace?.owner_username ?? workspaceStore.current?.owner_username;
|
||||
const collSlug = r.item.collection_slug;
|
||||
if (ws && wsUsername && collSlug) {
|
||||
goto(`/${wsUsername}/${ws}/${collSlug}/${itemUrlId(r.item)}`);
|
||||
@@ -242,8 +567,9 @@
|
||||
}
|
||||
|
||||
function useRecentSearch(q: string) {
|
||||
// Assignment alone re-triggers the reactive search effect; the
|
||||
// explicit doSearch() call is no longer needed.
|
||||
query = q;
|
||||
doSearch();
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
@@ -300,13 +626,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderResultCard(r: SearchResult, i: number): { ref: string | null; status: string | undefined; priority: string | undefined } {
|
||||
function renderResultCard(r: AugmentedSearchResult, i: number): { ref: string | null; status: string | undefined; priority: string | undefined } {
|
||||
void i;
|
||||
return {
|
||||
ref: formatItemRef(r.item),
|
||||
status: getFieldValue(r.item, 'status'),
|
||||
priority: getFieldValue(r.item, 'priority')
|
||||
};
|
||||
}
|
||||
|
||||
// Derived: ready workspaces other than the current one. Used to gate
|
||||
// the "search all workspaces" toggle visibility — no point showing
|
||||
// it when the user only has the current workspace hydrated.
|
||||
let otherReadyWorkspaceCount = $derived.by(() => {
|
||||
const current = workspaceStore.current?.slug;
|
||||
let n = 0;
|
||||
for (const ws of workspaceStore.workspaces) {
|
||||
if (ws.slug === current) continue;
|
||||
if (localIndex.bootstrapStateFor(ws.slug) === 'ready') n += 1;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if uiStore.searchOpen}
|
||||
@@ -334,7 +674,6 @@
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
oninput={doSearch}
|
||||
placeholder="Search items, collections, docs..."
|
||||
class="search-input"
|
||||
/>
|
||||
@@ -361,7 +700,36 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter chips -->
|
||||
<!--
|
||||
"All workspaces" toggle (TASK-1365). Only surfaced when the
|
||||
user has more than one ready workspace — if you only have
|
||||
the current workspace hydrated, the toggle would be a no-op
|
||||
and just adds chrome.
|
||||
-->
|
||||
{#if otherReadyWorkspaceCount > 0}
|
||||
<div class="scope-row">
|
||||
<button
|
||||
class="scope-toggle"
|
||||
class:active={searchAllWorkspaces}
|
||||
onclick={toggleAllWorkspaces}
|
||||
title="When on, search every workspace that's already loaded this session"
|
||||
>
|
||||
<span class="scope-dot" class:on={searchAllWorkspaces}></span>
|
||||
<span>All workspaces ({otherReadyWorkspaceCount + 1} ready)</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
Filter chips. Surface in three cases:
|
||||
1. Server search returned facets (full chip vocabulary).
|
||||
2. Local path with an active filter — show the active
|
||||
chip(s) alone so the user can see + clear them.
|
||||
Without this branch, switching from server → local
|
||||
with a filter chip selected would hide the chip but
|
||||
keep filtering, leaving the user no clear way to
|
||||
remove it. Codex round 2 P2 of TASK-1365.
|
||||
-->
|
||||
{#if facets && query.trim()}
|
||||
<div class="filters-row">
|
||||
<div class="filter-chips">
|
||||
@@ -400,6 +768,34 @@
|
||||
<button class="clear-filters" onclick={clearFilters}>Clear filters</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hasFilters && query.trim()}
|
||||
<div class="filters-row">
|
||||
<div class="filter-chips">
|
||||
{#if filterCollection}
|
||||
{@const coll = collectionStore.collections.find((c) => c.slug === filterCollection)}
|
||||
<button
|
||||
class="filter-chip active"
|
||||
onclick={() => applyFilter('collection', filterCollection!)}
|
||||
>
|
||||
<span class="chip-icon">{coll?.icon || '📦'}</span>
|
||||
<span class="chip-label">{coll?.name || filterCollection}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if filterStatus}
|
||||
<button
|
||||
class="filter-chip status-chip active"
|
||||
onclick={() => applyFilter('status', filterStatus!)}
|
||||
>
|
||||
<span
|
||||
class="chip-dot"
|
||||
style="background: {statusColor(filterStatus)};"
|
||||
></span>
|
||||
<span class="chip-label">{filterStatus.replace(/_/g, ' ')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="clear-filters" onclick={clearFilters}>Clear filters</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Result count -->
|
||||
@@ -638,6 +1034,48 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* "All workspaces" scope toggle (TASK-1365) */
|
||||
.scope-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scope-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.scope-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.scope-toggle.active {
|
||||
color: var(--accent-blue);
|
||||
border-color: color-mix(in srgb, var(--accent-blue) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--accent-blue) 10%, transparent);
|
||||
}
|
||||
.scope-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.scope-dot.on {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.filters-row {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user