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).
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.
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)
}
pane["files"] holds the currently displayed view — results after the
pane’s own sort + filter applied in memory. results is the source; files
is the derived view.pane["path"] is retained as the search root for display/context, but is
never re-listed while virtual.selected_files, focused_index, scroll_offset behave exactly as normal.pane.get("virtual") as None; every guard below is
an if pane.get("virtual") branch, so real panes are untouched.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.
XeFMApp._list_virtualAll 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:
pane["path"] (that
would destroy the result set); _relist re-reads the surviving result paths
on a worker and re-applies the in-memory sort/filter instead. A result landing
after the pane left the set (⌫, a navigation) is dropped._handle_reload_request) skips virtual
panes; there is no single directory to watch, so monitoring is suspended and
the set is a snapshot.path — enter-directory, go-up, and
jump-to-favorite/drive/history call _exit_virtual first, which clears
pane["virtual"] and returns the pane to a normal listing. This is the natural
“exit.”⌕ "query" — N results banner
instead of the path, so it’s clear this is a result set, not a directory._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 — 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.
sync_current_to_other) = “go to the other pane’s location, cursor
there.” If the other pane is the results view, its location is the
highlighted hit’s directory (cursor on that file) — so from a normal pane you
pull the highlighted hit’s location here. If this pane is the results
view, O behaves like a normal pane: leave the results and open the other
pane’s directory, cursor synced. Both go through _go_to_dir; neither keeps a
stale virtual listing.sync_other_to_current → _reveal_result_other: from the
results pane, open the highlighted result in the other pane, keeping the
results intact. Blocked (with a message) if the other pane is the results view.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.
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.