Skip to content

Add a command palette for searching models, records and commands - #1121

Open
vahidzhe wants to merge 4 commits into
smithyhq:mainfrom
vahidzhe:feat/command-palette
Open

Add a command palette for searching models, records and commands#1121
vahidzhe wants to merge 4 commits into
smithyhq:mainfrom
vahidzhe:feat/command-palette

Conversation

@vahidzhe

@vahidzhe vahidzhe commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Adds a command palette to the admin: a modal that searches registered models,
records inside them, and commands, from any page.

Animation

Why

Finding a specific record today means picking the right model from the sidebar,
loading its list page, then searching within it. On an admin with a few dozen
models that is three steps and two page loads for something the user could name
immediately.

How it works

The palette answers three questions and each has a deliberately different cost.

Model names are matched against the in-memory view registry. No database
access, so this stays instant regardless of how many models are registered.

Records in one model are searched after clicking Search inside on a row,
which pins that model as the scope. Exactly one query runs against exactly one
model, again independent of how many exist.

Records across models are searched when typing without a scope. This is the
only mode that fans out, so it is opt-in per model, capped, and skipped entirely
for terms shorter than palette_search_min_chars.

class UserAdmin(ModelView, model=User):
    column_searchable_list = [User.name, User.email]
    palette_search = True        # joins the unscoped search
    palette_search_limit = 5     # rows returned per model

palette_search defaults to False. Adding the palette to an existing admin
therefore changes nothing about query load until a maintainer opts a model in.
Models that are not opted in remain reachable through Search inside, since
scoping is an explicit user action costing a single query.

Admin takes palette_search_min_chars (default 2) and
palette_search_max_models (default 8). Queries run concurrently on async
engines; sync engines go through the existing worker-thread path.

Scoped search

Clicking Search inside pins a model. The chip's × clears it.

image image

Commands

Below the matches, the palette offers commands for the best-matching model.
With an empty box no commands are shown, since there is no match to act on.
ModelView.palette_commands returns them and can be overridden:

def palette_commands(self, request: Request) -> list[dict]:
    commands = super().palette_commands(request)
    commands.append(
        {
            "label": "Export users as CSV",
            "url": str(
                request.url_for(
                    "admin:export", identity=self.identity, export_type="csv"
                )
            ),
            "icon": "↓",
            "badge": "csv",
        }
    )
    return commands
image

Permissions

The palette exposes nothing the rest of the admin would not:

Rule Effect
is_visible / is_accessible view is not listed, not searched, 404 if named as a scope
check_can_view_details rows the user cannot open never appear in results
can_view_details = False model is skipped, there is nothing to navigate to
can_create = False no "create" command
login_required the endpoint sits behind the same guard as every admin route

How it renders in each configuration

The trigger lives in the top bar. That bar previously only rendered when a
language switcher was configured, so this PR makes the <header>
unconditional and leaves the switcher itself conditional. All four combinations
were checked:

i18n_config authentication_backend Top bar contains
set set trigger, language switcher
set unset trigger, language switcher
unset set trigger
unset unset trigger
image image

Translations

Every string is translated, including those rendered by JavaScript as results
come in. 19 new strings were added to the az, de, ru and tr catalogs
(71/71 for each).

Strings that interpolate a model name are whole sentences with placeholders
rather than concatenated fragments, so word order stays translatable:

"Go to {name}"       ->  az: "{name} səhifəsinə keç"
                         de: "Zu {name} wechseln"
"Records in {name}"  ->  az: "{name} içindəki qeydlər"

Braces rather than %(name)s: Jinja's gettext treats %(...)s as its own
interpolation and raises KeyError when the variable is not supplied at render
time, which is the case here since substitution happens in the browser.

image

… names

against the in-memory view registry without touching the database, searches
records inside a single scoped model with exactly one query, and fans out
across opted-in models for unscoped searches.

Models join the unscoped search by setting palette_search = True. The default
is False so that adding the palette to an existing admin never starts querying
every registered table. The fan-out is capped by palette_search_max_models and
gated by palette_search_min_chars, and runs concurrently on async engines.

Below the matches the palette offers 'go to' and 'create' for the model that
best fits the current term. ModelView.palette_commands returns them and can be
overridden to add, replace or reorder entries.

is_visible, is_accessible and check_can_view_details are all enforced, so the
palette cannot surface a model or row the user could not otherwise reach, and
the endpoint sits behind the same login_required as every other admin route.

The top bar previously rendered only when a language switcher was configured,
so it is now unconditional with the switcher itself left conditional.

All strings are translated, including those rendered by JavaScript. Strings
interpolating a model name are whole sentences with {name} and {count}
placeholders substituted client-side, so translators control word order.
Comment on lines +36 to +38
function saPaletteEsc(value) {
return $("<div>").text(value == null ? "" : value).html();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This func escapes for element content, not for attribute values. $("

").text(v).html() is Element.innerHTML over a text node, and the HTML serialization algorithm for that context only replaces &, <, > and U+00A0 — " is preserved verbatim.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class Doc(Base):
    slug = Column(String(200), primary_key=True)   # user-suppliable slug
    title = Column(String(80))

s.add(Doc(slug='x" tabindex=0 autofocus onfocus=window.__pwned=1 y="',
          title="quarterly report"))

Endpoint:

{"pk": "x\" tabindex=0 autofocus onfocus=window.__pwned=1 y=\"",
 "url": "http://testserver/admin/doc/details/x\" tabindex=0 autofocus onfocus=window.__pwned=1 y=\""}

which is rendered:

DOM attrs: class="sa-row", data-url="http://testserver/admin/doc/details/x",
           tabindex="0", autofocus="", onfocus="window.__pwned=1", y=""

window.__pwned is 1 with zero user interaction — autofocus + tabindex makes the injected row focusable and fires onfocus on render. An onmouseover variant fires as soon as the admin's cursor crosses the results list.

    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");

Add these

Comment thread sqladmin/palette.py Outdated
Comment on lines +40 to +49
def palette_base_query(view: ModelView) -> Select:
"""Base statement for palette search.

Kept separate from ``ModelView.list_query`` (which takes a ``Request`` and
may apply per-request scoping that does not make sense for a global search).
Relationships are intentionally *not* eager-loaded: the label is rendered
from already-selected columns, so we avoid extra joins.
"""

return select(view.model)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returns 500 for any model that has relationship and uses that in str.

smth like this

class Player(Base):
    team = relationship("Team")
    def __str__(self):
        return f"{self.name} ({self.team.name if self.team else '-'})"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smth:

def palette_base_query(view: ModelView) -> Select:
    stmt = select(view.model)
    for relation in view._list_relations:
        stmt = stmt.options(selectinload(relation))
    return stmt

Comment thread sqladmin/palette.py Outdated
Comment on lines +43 to +44
Kept separate from ``ModelView.list_query`` (which takes a ``Request`` and
may apply per-request scoping that does not make sense for a global search).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it does. If I check user session in request, then that should apply to global as well

Comment thread docs/command_palette.md Outdated
Comment on lines +150 to +155
def palette_search_query(self, term: str) -> Select:
return (
select(Article)
.where(Article.search_vector.match(term))
.limit(self.palette_search_limit)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not correct, your views does not have palette_search_query function

Comment on lines +138 to +154
function saPaletteFetch() {
// Native trim: jQuery 4.0 removed $.trim.
var term = ($("#sa-palette-input").val() || "").trim();
$.ajax({
url: window.SA_PALETTE_URL,
method: "GET",
dataType: "json",
data: saPaletteScope ? { q: term, scope: saPaletteScope } : { q: term },
headers: { "X-Requested-With": "XMLHttpRequest" },
success: saPaletteRender,
error: function () {
$("#sa-palette-results").html(
'<div class="sa-empty">' + saPaletteEsc(saPaletteText("searchFailed")) + "</div>"
);
}
});
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This func fires a new $.ajax on every debounce tick and never cancels the previous one, and saPaletteRender reads the term back out of the input box at render time (line 158) rather than using the term the response was for. Responses are not guaranteed to arrive in order, and the shorter term is usually the slower query because it matches more rows — so the common typing pattern loses the race.

Comment thread sqladmin/statics/js/palette.js Outdated
Comment on lines +69 to +77
function saPaletteSingular(name) {
if (/ies$/.test(name)) {
return name.slice(0, -3) + "y";
}
if (/s$/.test(name)) {
return name.slice(0, -1);
}
return name;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used anywhere

Comment thread sqladmin/palette.py
Comment on lines +79 to +80
if not view._search_fields or not view.can_view_details:
return []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not tested. Reachable via a scoped search against a model with can_view_details = False or with no column_searchable_list

Comment thread sqladmin/application.py Outdated
Comment on lines +1016 to +1025
@login_required
async def palette(self, request: Request) -> Response:
"""Command-palette search endpoint.

Model name matches are served from the in-memory view registry (no DB
access). A ``?scope=<identity>`` query runs a single query against one
model. An unscoped query fans out only across models that set
``palette_search = True``, capped and run concurrently. See
``sqladmin.palette.build_palette_response`` for details.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

login_required answers an expired session with a 302 to the HTML login page. jQuery follows it, dataType: "json" fails to parse the login page, and the user sees "Search failed" with no hint that they need to log in again. Returning 401 for X-Requested-With: XMLHttpRequest (which palette.js:146 already sends) and having the error handler redirect would be a nicer dead end.

Comment thread sqladmin/application.py
Comment on lines +519 to +520
palette_search_min_chars: int = 2,
palette_search_max_models: int = 8,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate values: max(0, value)

Comment thread sqladmin/models.py
Comment on lines +348 to +357
palette_search: ClassVar[bool] = False
"""Whether this model joins *unscoped* command-palette search.

Defaults to ``False`` so adding the palette never silently fans a query out
across every model. Reachable from an unscoped query only when this is
``True`` and ``column_searchable_list`` is set. Scoped search (a single
model picked first) always works regardless, being one query on one model.
"""

palette_search_limit: ClassVar[int] = 5

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this a little down/up. You kinda split search related args (column_searchable_list, search_auto_submit)

@mmzeynalli mmzeynalli added the needs-update There are stuff that needs updated and reviewed again label Aug 4, 2026
@vahidzhe

Copy link
Copy Markdown
Contributor Author

@mmzeynalli Thanks, especially for the XSS one. Fixed all ten, pushed as a new commit
(force-push would break the thread links).

37 tests now (was 30), passing on SQLite and a real PostgreSQL 16. ruff, mypy
and mkdocs build --strict all clean.


saPaletteEsc -> quotes not escaped. Confirmed your payload breaks out of
data-url before the fix. " and ' are now escaped too.

Relationship in __str__ crashes. Reproduced the DetachedInstanceError.
Fixed by eager-loading view._list_relations, same as list() and
get_model_objects() already do. Limitation: only covers relations listed in
column_list -> documented that convention explicitly since it wasn't written
down anywhere before.

list_query scoping should apply. Agreed. palette_base_query now builds
on view.list_query(request) instead of a bare select(model), so this and
the relationship fix share one change. Added a test with a tenant-filtered
list_query.

Docs example calls a method that doesn't exist. Correct -> it was a module
function, never actually invoked on self. palette_search_query is now a
real overridable method on ModelView, same pattern as palette_commands.

Debounce race condition. Fixed with a request sequence counter plus
xhr.abort() on the previous request; stale responses are dropped instead of
overwriting a newer result.

saPaletteSingular unused. Removed, along with saPaletteTerm (same
issue, found while fixing the race condition above).

Untested can_view_details=False / no-column_searchable_list path. Added
two tests covering both, reached via a scope.

302 redirect breaks the JSON client. Added palette_login_required, a
narrow guard used only by this endpoint, returning 401 JSON instead of an HTML
redirect. palette.js treats 401 as "go to login". Did not touch the shared
login_required -> it's correct for every other route.

Unvalidated negative config values. Clamped with max(0, value).

ClassVar placement. Moved palette_search / palette_search_limit next
to search_auto_submit, with the other search-related attributes.

Ready for another pass.

@mmzeynalli mmzeynalli removed the needs-update There are stuff that needs updated and reviewed again label Aug 15, 2026

@mmzeynalli mmzeynalli left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one I'd call blocking is the javascript: sink in palette.js — it's one line to guard. The asyncio.gather failure isolation and the | tojson escaper are correctness; the "one query" badge, the silent model cap, and the layout change are accuracy and scope.

On the layout change specifically: it affects every deployment, not just palette users, so it needs calling out in the description even if it stays in this PR.

$(document).on("click", "#sa-palette-results .sa-row", function () {
var url = $(this).attr("data-url");
if (url) {
window.location.href = url;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data-url is escaped correctly as an attribute, but this executes javascript: URLs. A command with url: "javascript:alert('sink')" fires on click.

Nothing server-side can produce that today, since every URL comes from request.url_for. But palette_commands is documented as returning an arbitrary url and the docstring example builds one, so a command URL derived from a DB field would be stored XSS. Can we guard at the sink?

if (url && /^(\/|https?:)/i.test(url)) { window.location.href = url; }

Comment thread sqladmin/palette.py

stmt = view.list_query(request)
for relation in view._list_relations:
stmt = stmt.options(selectinload(relation))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The eager-loading is right — the DetachedInstanceError reasoning in the docstring is correct. But it means "exactly one query" isn't accurate, and the UI shows a badge that literally says "one query" (oneQuery in palette.html).

Counted with an engine listener, scoped search on a model with one relationship in column_list:

SQL statements issued -> 2
  SELECT doc.id, doc.title, doc.tag_id FROM doc WHERE lower(...) LIKE ...
  SELECT tag.id, tag.name FROM tag WHERE tag.id IN (?, ?, ?, ?, ?)

With three relations, it's four. Could the badge go, or become 1 + len(view._list_relations)?

Comment thread sqladmin/palette.py
searchable = [v for v in accessible if v.palette_search and v._search_fields][
: admin.palette_search_max_models
]
chunks = await asyncio.gather(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without return_exceptions=True, one bad model breaks the whole palette. I gave one model a palette_search_query that raises:

unscoped, one model raises -> 500
scoped into healthy model  -> 200, 1 records

palette_search_query is a documented override, and a transient error on one table would do the same. Could we degrade instead — return_exceptions=True, log it, skip that model's chunk? The user loses one model's hits rather than the search box.

Comment thread sqladmin/palette.py
# ---- unscoped record fan-out (opt-in models only) -----------------------
records: list[PaletteResult] = []
if len(term) >= admin.palette_search_min_chars:
searchable = [v for v in accessible if v.palette_search and v._search_fields][

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cap slices in registration order, so which models get searched is incidental. With 15 searchable models registered I get hits from 7 and no indication about the rest — a term that only matches the twelfth model reads as "Nothing found".

The cap is right; the silence is the problem. Could the response carry searched/total counts so the UI can say "searched 8 of 15 models, keep typing to narrow"?

Comment on lines +25 to +48
<script>
window.SA_PALETTE_URL = "{{ url_for('admin:palette') }}";
window.SA_PALETTE_I18N = {
models: "{{ _('Models') }}",
modelsHint: "{{ _('registry, no database query') }}",
commands: "{{ _('Commands') }}",
records: "{{ _('Records') }}",
// {name} is substituted client-side so translators keep word order.
recordsIn: "{{ _('Records in {name}') }}",
oneQuery: "{{ _('one query') }}",
searchInside: "{{ _('Search inside') }}",
goTo: "{{ _('Go to {name}') }}",
create: "{{ _('Create {name}') }}",
page: "{{ _('page') }}",
new: "{{ _('new') }}",
open: "{{ _('open') }}",
more: "{{ _('{count} more, keep typing to narrow') }}",
noMatches: "{{ _('No matches') }}",
nothingFound: "{{ _('Nothing found') }}",
searchFailed: "{{ _('Search failed') }}",
searchPlaceholder: "{{ _('Search models, records, commands') }}",
searchInsidePlaceholder: "{{ _('Search inside {name}') }}"
};
</script> No newline at end of file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still persists

<div class="container-fluid">
{# Language switcher, aligned to the right. #}
{# Command palette trigger, aligned to the left. #}
<button type="button" class="sa-palette-trigger" data-sa-palette-open

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hunk does two things beyond adding the trigger: it moves Logout out of the sidebar into the topbar, and makes the topbar render unconditionally where it was gated on show_switcher. Rendered against both trees with an auth backend and no i18n config:

main : logout inside sidebar: True   <header> topbar exists: False
head : logout inside sidebar: False  <header> topbar exists: True

So every deployment gets a new topbar and a relocated Logout, whether or not they use the palette. Reasonable — the trigger needs a home — but it will break anyone who overrode layout.html, so it deserves a line in the PR description and the changelog.

@mmzeynalli mmzeynalli added the needs-update There are stuff that needs updated and reviewed again label Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-update There are stuff that needs updated and reviewed again

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants