Search (MicroSearch)
Platform-level command palette for searching within the app whose tab is open. The platform owns the endpoint, the access checks, the matching and the ranking; each app owns only what it searches and who may see it.
JS: public/js/microsearch.js
CSS: public/js/microsearch.css
Backend: core/search.py (registry and matcher), handlers/search.py (dispatcher)
Trigger: Ctrl+K / Cmd+K, or click the navbar search bar
How it works
- The user opens the palette and types. MicroSearch debounces (200ms) and sends
POST /searchwith{app, q, types}. A reply that arrives after a newer search was sent is discarded. - The dispatcher refuses an app the member cannot open (the same check as opening the app), then loads the member's permissions for that app.
- Each searchable type may name permission keys; a type the member holds none of is never searched.
- The app's handler queries its own tables inside its own visibility rules, through the shared trigram matcher.
- The dispatcher ranks: under All, every result best match first; with chips chosen, chip order, best match first within each.
Endpoints
POST /search {"app": "tasks", "q": "PL-417", "types": ["task"], "filters": {...}}
GET /search?app=tasks&q=PL-417&types=task
Both run the same code. GET passes any other query parameter through as a filter (the Files picker sends type, owner, after, before, min_size, max_size). The response is the standard collection envelope (many()): results are in data.
Result fields
| Field | Type | Description |
|---|---|---|
type |
str |
One of the app's registered searchable types. |
id |
str |
Short identifier shown as a badge before the title. Can be empty. |
title |
str |
Primary text. |
subtitle |
str |
Secondary text (context, status, parent). |
url |
str |
Navigation URL when the result is selected. |
score |
float |
Match strength. Results arrive already ranked. |
Matching
core.search.Match builds the WHERE clause and score for a set of columns:
- Substrings and IDs: each query word must appear in the record (
PL-41findsPL-417). - Typos: a word that does not appear still matches a word in the record within 1 edit (4 or 5 letters) or 2 edits (6 or more), so
fshesandifshesboth findfishes. Candidates come from thepg_trgmindex, edit distance fromfuzzystrmatch. - Ranking: a word found in a name column outranks one found in a detail column; an exact match on the first name column (an ID, a code) ranks highest, then a prefix.
Every searched column set has a trigram index built on exactly the expression core.search.document() produces, in the app's own migration.
Implementing search for an app
1. Write the handler
from core.search import Search, search_rows
async def api_search(req: Request, ctx: Context, search: Search) -> list:
if not search.wants("document"):
return []
rows = await search_rows(
ctx.db.raw, search, source="paper_documents", columns="id, title", names=["title"],
scope="workspace_id = $1 AND NOT is_deleted AND (visibility = $2 OR id = ANY($3::text[]))",
params=[ctx.db.workspace_id, VISIBILITY_WORKSPACE, list(await accessible_doc_ids(ctx))],
)
return [{"type": "document", "id": r["id"], "title": r["title"], "score": r["score"],
"url": f"/app/papers/doc/{r['id']}"} for r in rows]
scope is the app's own visibility rule, exactly as its list endpoint applies it. For text that lives outside a table (a resolved name, a mailbox subject), score it with rank_texts(), the same matcher.
2. Register it, in chip order
from core.search import Searchable, register
register("papers", api_search, [Searchable("document")])
register("crm", crm_search, [Searchable("pipeline", (DEALS_VIEW,)), Searchable("deal", (DEALS_VIEW,))])
A type's keys are the ones its list endpoint requires; holding any one is enough. A platform search that checks its own caller passes gate= instead of being checked as an app.
3. Index what it searches
CREATE INDEX IF NOT EXISTS paper_documents_search_trgm ON paper_documents
USING gin ((lower(coalesce(title, ''))) gin_trgm_ops);
4. Include the search init partial
templates/partials/search_init.html, included in index.html inside {% block body %}:
<div x-data="{}" x-init="$nextTick(() => {
MicroSearch.init({
app: 'papers',
apiBase: '{{ ctx.api_url | default("http://api.local.localhost:8000") }}',
searchables: [
{ type: 'document', label: __('Documents'), icon: 'description', color: '#3B82F6' },
],
});
})"></div>
The searchables order must match the registration order.
UI anatomy
┌─────────────────────────────────────────────────┐
│ 🔍 Search Tasks... Esc │ ← input row
├─────────────────────────────────────────────────┤
│ All ● Tasks ● Boards ● Schedules ... │ ← legend (All plus type filters)
├─────────────────────────────────────────────────┤
│ ● PL-417 Upgrade production Amebo to 0.9.4 │ ← best match first
│ Product Launch · Done │
│ ◆ PL Product Launch │
│ 406 tasks · 300 done │
├─────────────────────────────────────────────────┤
│ ↑↓ navigate Enter open Esc close │ ← footer hints
└─────────────────────────────────────────────────┘
Legend filtering
All is active by default and searches every type the member may see, best match first. Clicking a type chip narrows to the chosen types; clicking All clears the choice. An app with one type shows no All chip.
CSS classes
| Class | Element | Purpose |
|---|---|---|
.ms-overlay |
div |
Fixed full-screen backdrop with blur. Click outside palette to close. |
.ms-palette |
div |
The command palette container. Max-width 620px, centered. |
.ms-input-row |
div |
Search input row with icon and Esc badge. |
.ms-input |
input |
The search text input. |
.ms-kbd |
kbd |
Keyboard shortcut badge. |
.ms-legend |
div |
Row of filterable type chips. |
.ms-legend-chip |
button |
All, or one type filter. .active when selected. |
.ms-dot |
span |
8px colored circle. Used in legend chips and results. |
.ms-results |
div |
Scrollable results container (hidden scrollbar). |
.ms-result |
a |
Single result row. |
.ms-result-selected |
.ms-result |
Keyboard-selected result (highlighted). |
.ms-result-id |
span |
ID badge before the title. |
.ms-result-subtitle |
div |
Secondary line of text. |
.ms-empty |
div |
Empty/loading state message. |
.ms-footer |
div |
Keyboard hint bar at the bottom. |
html.ms-open |
html |
Added when palette is open. Prevents body scroll. |
Keyboard shortcuts
| Shortcut | Action |
|---|---|
Ctrl+K / Cmd+K |
Open/close the search palette |
Esc |
Close the palette |
↑ / ↓ |
Navigate results |
Enter |
Open the selected result |