← XeFM crftwr/xefm on GitHub · craftware

Search Results Pane Implementation

Accepting a hit from the progressive search dialog does not navigate to that one file — it feeds the whole result set into the active pane as a flat, virtual listing (“Search Results” pane, à la Total Commander’s Feed to listbox). The result set spans many directories, and every existing pane operation (copy/move, archive, view/diff, delete/rename, sort & filter, info, edit, run-command) then acts on it as if it were an ordinary directory.

Source: xefm/app.py (the app-side wiring), xefm/file_list_manager.py (the listing choke point), xefm/file_pane.py (name-column rendering). Tests: test/test_search_results_pane.py.

The dialog that produces these hits — the live, search-as-you-type filename/content finder, its cancel-on-keystroke background worker, and its result caps — is ProgressiveSearchDialog in xefm/progressive_search_dialog.py; see that module’s docstring for the threading model. This document covers only what happens after a hit is accepted (feeding the result set into the pane).


Why a virtual pane (not an in-dialog action surface)

Three approaches were considered: (A) navigate-then-operate on one file — the status quo, too thin; (B) mark files and act inside the search dialog — rejected because it rebuilds a second selection + operations surface that duplicates what the pane already offers; (C) feed results into a virtual pane — chosen. C reuses the real selection UI, the real menu, and every existing op.

The enabling fact: operations read their targets from pane["files"] + pane["selected_files"], and each target is a self-describing Path. Copy / move send to the other pane, so a flat listing of scattered paths makes those operations Just Work — the cost is concentrated in teaching the pane model that its listing may be virtual, not in touching each operation.


The virtual-pane data model

A pane becomes virtual by carrying a virtual marker alongside its normal fields (set by XeFMApp._feed_search_results, or by an imported list — see FILE_LIST_IMPORT_IMPLEMENTATION.md, which writes "kind": "list" with a title in place of mode / query):

pane["virtual"] = {
    "kind": "search",
    "root":  Path,                 # search root the walk started from
    "mode":  "filename" | "content",
    "query": str,
    "results": list[Path],         # full found set — the immutable source of truth
    "meta":  dict[str, dict],      # per-path extras, keyed by str(path)
}

meta carries what isn’t part of the Path — chiefly, for content hits, the matched line number and text ({"line": int, "text": str}). It is not rendered in the file list; it surfaces in the Info dialog and drives reveal-at-line. A filename-search set leaves it empty.


Single choke point: XeFMApp._list_virtual

All virtual listing funnels through XeFMApp._list_virtual, the virtual counterpart of _list_pane. On a worker thread it calls FileListManager.compute_virtual_listing (xefm/file_list_manager.py), which reads each path’s attributes once — that read is the existence check too (xefm.path_list.probe) — then filters + sorts in memory. The result lands through the ordinary _result_queue, where a prepare step writes the survivors back into results and prunes meta (prune_virtual) on the UI thread. So sort, filter, and post-operation reconciliation all Just Work, and every existing _refresh caller is unchanged.

It used to be synchronous — an exists() and then a stat() per path, on the UI thread, on every refresh. With a result set capped at 1,000 local paths that went unnoticed; an imported list has no cap and may be ssh:// rows, where the same loop is a network round trip per row with the window frozen. FileListManager.refresh_files keeps the synchronous form of the same pass for non-UI callers.

XeFMApp._relist — the shared re-listing entry point that _refresh also goes through, see ASYNC_LISTING_SYSTEM.md — routes a virtual pane to _list_virtual. The subsystems that assumed files == children of path each got a virtual guard:


Operations on a virtual pane

_selected_or_focused(pane) returns scattered Paths; unless noted, the operation consumes that and works unchanged.

Op Notes
Copy / move Source = scattered paths; dest = other pane. Post-op re-stat drops moved sources. A virtual destination is blocked with a message; the same-dir guard is skipped for a virtual source.
Archive create Operates on an arbitrary path list already; archive lands in the other pane.
View / Diff / Edit Read the focused / selected Path(s) directly.
Delete / Rename / batch-rename Use entry.parent / name; post-op re-stat drops or re-points affected entries.
Info / details For a content hit, appends the matched line number (+ text) from virtual["meta"].
Sort / Filter Re-sort / re-filter the listing snapshot in memory (_resort), not a directory re-list. The existing sort/filter actions just set the knobs and call _relist; no new key bindings.
Compare & Select (W) Works with a results view on either side — the engine joins two feeds of Paths by name, and a virtual pane’s rows are real paths. Both feeds are the panes’ displayed listings (sorted + filtered). Since a result set spans directories, the other side can hold several same-named candidates; an entry is selected when any of them satisfies the relations (a directory listing has unique names, so this generalization is a no-op there). Selecting keeps the pane virtual.
Run-command Passes absolute paths with cwd = search root (bare names with cwd=pane["path"] would not resolve for scattered files). An imported list with no common root runs from pane["path"].

Post-operation reconciliation. A virtual pane can’t re-list, so after a mutating op _relist re-reads each Path in results on a worker (dropping vanished ones, re-pointing renamed ones), re-applies sort + filter, and clamps focused_index / selected_files to the survivors.


Entry & reveal UX

Entry — feed-by-default. The dialog’s hint band says so, in the owner’s words: ProgressiveSearchDialog reports the accepted row and nothing more, so _open_search passes accept_hint="results to pane" and the band reads Enter results to pane. It used to read Enter open, which promised the one highlighted file — the opposite of what accepting does.

The dialog’s on_accept closes it and calls _feed_search_results(mode, dialog.results, root, query, focus=value) with the dialog’s full result list plus the accepted hit. For content mode, results collapse to one entry per file (operations act on files, not lines), keeping the first match’s {line, text} in virtual["meta"]. The set is fed at the dialog’s _RESULT_CAP (1000); the cap is noted in the header banner.

The accepted value does not navigate, but it does decide where the cursor lands: _focus_result places the cursor on that hit’s row in the fed listing and scrolls it into view (issue #224). Matching is by full path, not name — a result set spans directories, so two hits can share a basename. The fed order is the walk order while pane["files"] is sorted, so the row must be looked up after refresh_files. Feeding without a focus (or with one that filtered out) leaves the cursor at the top, as before.

Reveal a result’s location. Since accept no longer navigates, the pane-sync keys reveal the highlighted hit’s real location, driven by whichever pane holds the results — so you can stand on a normal pane (results on the other side) and pull the highlighted hit’s location in. Neither key destroys the results listing.

The name column shows each hit’s root-relative path (FilePane._display_name, middle-elided) so a scattered result set reveals where each file lives.

That path is also what the pane sorts, filters and searches by (#383). It used to show the path and order by the bare basename, so the name column looked unsorted and the directory part of every visible row could not be matched. Both now come from one string — see Filename Normalization System, which also covers the Compare and Select checkbox that extends the same choice to pairing.

Copy Name(s) (copy_names_to_clipboard) hands over that same root-relative path (#433). A bare a.txt on the clipboard says nothing about which of several scattered hits it was, and the point of the command is to carry off what the row says. It copies name_key.rel_name, not compare_name: a pasted name gets typed at a shell or an editor, where it has to address the file that is on disk, so it goes out verbatim instead of in the NFC form the column renders. On an ordinary directory pane rel_name is the basename, so nothing changes there. Copy Full Path(s) keeps its own meaning on both kinds of pane — the whole path, never the relative one.


Scope

First cut is local-filesystem results only (no S3/SSH-remote result sets), no persistent/saved-search abstraction, and — deliberately — no second in-dialog operations surface (approach B above). If both panes are virtual, copy/move into the virtual destination is blocked with a clear message rather than supported.