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
↓ nina-05d1462e.tar.gzatlas at this save
files changed1 file
src/search.zig+31 −10src/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");