05d1462ec09f

make search feel responsive (only search when you stop typing): debounce 250->500ms so a normal between-keystroke pause no longer triggers the blocking nix search; show 'searching...' over stale results while the search runs so the pause reads as progress not a freeze; guard empty query in the widget's doSearch (was fetching the whole 108k catalog on open — verified now shows '0 results' instantly) — build+tests green

time
2026-06-05 03:28 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
nina finishing brief
parent
da3d2938f6d0
1 file src/search.zig+31 −10
src/search.zig+31 −10
@@ -11,8 +11,10 @@1111 const STDIN_FD = std.posix.STDIN_FILENO;1212 1313 // Debounce window: a keystroke defers the nix search until typing pauses this14-// long. Without it, every character fired a full `nix search`, stalling mid-word.15-const DEBOUNCE_MS: i64 = 250;14+// long. nix search is synchronous and blocks the widget for ~1s, so the window15+// must be long enough that a normal between-keystroke pause doesn't trigger it —16+// it should only fire once you've actually stopped typing.17+const DEBOUNCE_MS: i64 = 500;1618 1719 // Milliseconds elapsed since `since` on the monotonic (awake) clock. std has no1820 // milliTimestamp in 0.16; this mirrors the Io.Clock pattern used in apply().@@ -52,7 +54,7 @@5254     packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);5355 5456     // Initial render (prints WIDGET_LINES lines)55-    renderWidget(query_buf[0..query_len], packages, opts, selection, mode);57+    renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);5658     output.flush();5759 5860     // Key loop with debounced search. A keystroke marks the query dirty and@@ -78,6 +80,12 @@7880             // Poll timed out: typing has paused long enough — run the search now.7981             if (query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS) {8082                 query_dirty = false;83+                // Paint "searching..." over the stale results before the blocking84+                // nix search, so the ~1s pause reads as progress, not a freeze.85+                output.cursorUp(WIDGET_LINES);86+                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, true);87+                output.flush();88+8189                 _ = arena.reset(.free_all);8290                 packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);8391                 // New result set: reset selection to the top. Navigating during the@@ -85,7 +93,7 @@8593                 // and renderWidget would index out of bounds.8694                 selection = 0;8795                 output.cursorUp(WIDGET_LINES);88-                renderWidget(query_buf[0..query_len], packages, opts, selection, mode);96+                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);8997                 output.flush();9098             }9199             continue;@@ -152,7 +160,7 @@152160         // Redraw immediately so the query line and selection feel responsive; the153161         // search itself is deferred to the debounce tick above.154162         output.cursorUp(WIDGET_LINES);155-        renderWidget(query_buf[0..query_len], packages, opts, selection, mode);163+        renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);156164         output.flush();157165     }158166 @@ -171,6 +179,12 @@171179 pub const Mode = enum { packages, options };172180 173181 fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {182+    // An empty query matches the entire catalog (100k+ rows) — a multi-second183+    // fetch that would freeze the widget. Show nothing until there's a query.184+    if (std.mem.trim(u8, query, " \t").len == 0) {185+        opts.* = &.{};186+        return &.{};187+    }174188     switch (mode) {175189         .packages => return api.searchPackages(arena, io, query) catch &.{},176190         .options => {@@ -229,7 +243,7 @@229243     return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET;230244 }231245 232-fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode) void {246+fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool) void {233247     const count = resultCount(packages, opts, mode);234248     const CLR = output.C_CLEAR_EOL;235249     const RST = output.C_RESET;@@ -242,12 +256,19 @@242256     // larger, now-replaced result set. Never index past the end.243257     const sel = if (count == 0) 0 else @min(selection, count - 1);244258 245-    // Line 1: header259+    // Line 1: header. While the (blocking) search runs, show "searching..." so the260+    // pause reads as work-in-progress, not a frozen widget.246261     output.raw(CLR);247262     const mode_str = if (mode == .packages) "nixpkgs" else "options";248-    output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{249-        PKN, RST, mode_str, query, DIM, count, RST,250-    });263+    if (searching) {264+        output.p("{s}:: {s}search {s} {s}  {s}searching...{s}\n", .{265+            PKN, RST, mode_str, query, DIM, RST,266+        });267+    } else {268+        output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{269+            PKN, RST, mode_str, query, DIM, count, RST,270+        });271+    }251272 252273     // Line 2: blank253274     output.raw(CLR ++ "\n");

view file