search.zig

18.4 KB · raw

1// search.zig — inline 18-line TUI widget, no alternate screen2// Raw terminal mode for interactive key handling (POSIX: linux + macos).3const std = @import("std");4const types = @import("types.zig");5const output = @import("output.zig");6const api = @import("api.zig");7const rawterm = @import("rawterm.zig");89const WIDGET_LINES: usize = 18;10const RESULT_ROWS: usize = 5;11const STDIN_FD = rawterm.STDIN_FD;1213// Debounce window: a keystroke defers the nix search until typing pauses this14// 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.17const DEBOUNCE_MS: i64 = 500;1819// Milliseconds elapsed since `since` on the monotonic (awake) clock. std has no20// milliTimestamp in 0.16; this mirrors the Io.Clock pattern used in apply().21fn elapsedMs(io: std.Io, since: std.Io.Timestamp) i64 {22    const ns = @max(0, since.untilNow(io, .awake).toNanoseconds());23    return @intCast(@divFloor(ns, 1_000_000));24}2526// What the detail pane shows for the selected package. Tab toggles between the27// collapsed (description) view and the expanded meta view; the latter is fetched28// on demand, so it passes through loading and (on eval failure) unavailable.29const Detail = union(enum) {30    collapsed,31    loading,32    unavailable,33    meta: api.PkgMeta,34};3536pub fn run(37    gpa: std.mem.Allocator,38    io: std.Io,39    initial_query: []const u8,40    mode: Mode,41    environ: *const std.process.Environ.Map,42) !?types.NixPackage {43    // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,44    // CI), print plain results and return instead of the raw-mode widget, which45    // needs a real tty and would block waiting for keys.46    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode, environ);4748    var saved: std.posix.termios = undefined;49    try rawterm.rawModeEnable(STDIN_FD, &saved);50    defer rawterm.rawModeDisable(STDIN_FD, saved);5152    var arena = std.heap.ArenaAllocator.init(gpa);53    defer arena.deinit();5455    var query_buf: [256]u8 = undefined;56    var query_len: usize = @min(initial_query.len, query_buf.len - 1);57    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);5859    var packages: []types.NixPackage = &.{};60    var opts: []api.NixOption = &.{};61    var selection: usize = 0;6263    // Expanded detail (Tab): the selected package's meta, fetched on demand into64    // its own arena and cached by attr so re-visiting is instant. Kept separate65    // from the search arena, which is reset on every new search.66    var expanded = false;67    var meta_arena = std.heap.ArenaAllocator.init(gpa);68    defer meta_arena.deinit();69    var meta: ?api.PkgMeta = null;70    var meta_attr: []const u8 = "";7172    // Initial search73    _ = arena.reset(.free_all);74    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ);7576    // Initial render (prints WIDGET_LINES lines)77    renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .collapsed);78    output.flush();7980    // Key loop with debounced search. A keystroke marks the query dirty and81    // stamps the time; the nix search fires only once typing pauses for82    // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so83    // there is no blank flash mid-typing.84    var chosen: ?types.NixPackage = null;85    var query_dirty = false;86    var query_changed_at = std.Io.Clock.awake.now(io);87    loop: while (true) {88        // Block until a key arrives, unless a search is pending — then wake when89        // the debounce window closes so we can fire it.90        var timeout_ms: i32 = -1;91        if (query_dirty) {92            const remaining = DEBOUNCE_MS - elapsedMs(io, query_changed_at);93            timeout_ms = if (remaining <= 0) 0 else @intCast(remaining);94        }9596        var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};97        const ready = std.posix.poll(&pfd, timeout_ms) catch 0;9899        if (ready == 0) {100            // Poll timed out. If no search is pending, nothing changed — keep waiting.101            if (!(query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS)) continue;102            query_dirty = false;103            // Paint "searching..." over the stale results before the blocking nix104            // search, so the ~1s pause reads as progress, not a freeze.105            output.cursorUp(WIDGET_LINES);106            renderWidget(query_buf[0..query_len], packages, opts, selection, mode, true, .collapsed);107            output.flush();108109            _ = arena.reset(.free_all);110            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ);111            // New result set: reset to the top and drop cached meta (it belonged to112            // the previous results). Navigating during the window could otherwise113            // leave selection past the new end and renderWidget would index OOB.114            selection = 0;115            meta = null;116            // fall through to the common render below117        } else {118            var buf: [4]u8 = undefined;119            const n = std.posix.read(STDIN_FD, &buf) catch break;120            if (n == 0) break;121122            // The query is a live text field, so every printable key types into it —123            // single-letter actions would collide with package names (the 'i' in124            // "ripgrep" must not trigger install). Only non-text keys act: Enter125            // selects, Tab expands detail, arrows navigate, Esc cancels.126            if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {127                switch (buf[2]) {128                    'A' => if (selection > 0) { selection -= 1; }, // up129                    'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down130                    else => {},131                }132            } else switch (buf[0]) {133                0x1b => break :loop, // Esc: cancel (chosen stays null)134                '\t' => expanded = !expanded, // Tab: toggle package detail135                '\r', '\n' => { // Enter: select the highlighted package136                    if (mode == .packages and packages.len > selection) {137                        chosen = packages[selection];138                    }139                    break :loop;140                },141                127, 8 => { // backspace142                    if (query_len > 0) {143                        query_len -= 1;144                        query_dirty = true;145                        query_changed_at = std.Io.Clock.awake.now(io);146                        selection = 0;147                    }148                },149                else => |ch| {150                    if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {151                        query_buf[query_len] = ch;152                        query_len += 1;153                        query_dirty = true;154                        query_changed_at = std.Io.Clock.awake.now(io);155                        selection = 0;156                    }157                },158            }159        }160161        // Common render after a search or a key. When expanded, resolve the detail162        // pane by fetching the selected package's meta on demand (blocking eval),163        // painting a "loading" frame first and caching by attr so re-visiting is164        // instant.165        const count = resultCount(packages, opts, mode);166        const sel = if (count == 0) 0 else @min(selection, count - 1);167        var detail: Detail = .collapsed;168        if (expanded and mode == .packages and count > 0) {169            const cur = packages[sel].attr;170            if (meta == null or !std.mem.eql(u8, meta_attr, cur)) {171                output.cursorUp(WIDGET_LINES);172                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .loading);173                output.flush();174                _ = meta_arena.reset(.free_all);175                meta_attr = meta_arena.allocator().dupe(u8, cur) catch "";176                meta = api.packageMeta(meta_arena.allocator(), io, cur);177            }178            detail = if (meta) |mm| Detail{ .meta = mm } else Detail.unavailable;179        }180        output.cursorUp(WIDGET_LINES);181        renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, detail);182        output.flush();183    }184185    // Clear the widget area186    output.cursorUp(WIDGET_LINES);187    var i: usize = 0;188    while (i < WIDGET_LINES) : (i += 1) {189        output.raw(output.C_CLEAR_EOL ++ "\n");190    }191    output.cursorUp(WIDGET_LINES);192    output.flush();193194    // The chosen package's strings live in the search arena, which deinits as this195    // function returns — dupe into the caller's allocator so it survives.196    if (chosen) |pkg| return dupePackage(gpa, pkg);197    return null;198}199200fn dupePackage(gpa: std.mem.Allocator, pkg: types.NixPackage) types.NixPackage {201    return .{202        .attr = gpa.dupe(u8, pkg.attr) catch "",203        .pname = gpa.dupe(u8, pkg.pname) catch "",204        .version = gpa.dupe(u8, pkg.version) catch "",205        .description = gpa.dupe(u8, pkg.description) catch "",206    };207}208209pub const Mode = enum { packages, options };210211fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption, environ: *const std.process.Environ.Map) []types.NixPackage {212    // An empty query matches the entire catalog (100k+ rows) — a multi-second213    // fetch that would freeze the widget. Show nothing until there's a query.214    if (std.mem.trim(u8, query, " \t").len == 0) {215        opts.* = &.{};216        return &.{};217    }218    switch (mode) {219        .packages => {220            const packages: []types.NixPackage = api.searchPackages(arena, io, environ, query) catch &.{};221            sanitizeDescriptions(arena, packages);222            return packages;223        },224        .options => {225            const options: []api.NixOption = api.searchOptions(arena, io, query) catch &.{};226            sanitizeOptionDescriptions(arena, options);227            opts.* = options;228            return &.{};229        },230    }231}232233// nix search's JSON descriptions come from nixpkgs/NixOS module metadata, which234// upstream authors control — a malicious description could otherwise smuggle235// escape sequences into both the TUI widget and the plain (piped) renderer,236// since both read straight from this shared result set (NINA-016). pname/attr237// are Nix identifiers and version strings are not free text, so only the238// free-form description needs sanitizing.239fn sanitizeDescriptions(arena: std.mem.Allocator, packages: []types.NixPackage) void {240    for (packages) |*pkg| {241        pkg.description = output.sanitize(arena, pkg.description) catch pkg.description;242    }243}244245fn sanitizeOptionDescriptions(arena: std.mem.Allocator, options: []api.NixOption) void {246    for (options) |*opt| {247        opt.description = output.sanitize(arena, opt.description) catch opt.description;248    }249}250251fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {252    return switch (mode) {253        .packages => packages.len,254        .options => opts.len,255    };256}257258fn stdinIsTty(io: std.Io) bool {259    return std.Io.File.stdin().isTty(io) catch false;260}261262// Plain, non-interactive rendering for non-tty stdin. Same pink :: header and263// result count the widget shows, then one line per result; no cursor control.264fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, environ: *const std.process.Environ.Map) !?types.NixPackage {265    // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.266    if (std.mem.trim(u8, query, " \t").len == 0) {267        output.searchEmptyQuery();268        output.flush();269        return null;270    }271    var arena = std.heap.ArenaAllocator.init(gpa);272    defer arena.deinit();273    var opts: []api.NixOption = &.{};274    const packages = doSearch(arena.allocator(), io, query, mode, &opts, environ);275    const count = resultCount(packages, opts, mode);276    const mode_str = if (mode == .packages) "nixpkgs" else "options";277    output.searchPlainHeader(mode_str, query, count);278    if (count == 0) {279        output.searchPlainNoResults();280        output.flush();281        return null;282    }283    switch (mode) {284        // unfree=false: per-result meta.unfree would need an eval per package (too285        // slow for the list); the TUI detail pane is the place to surface it.286        .packages => for (packages) |pkg| output.searchPlainPackage(pkg.pname, pkg.version, pkg.description, false),287        .options => for (opts) |opt| output.searchPlainOption(opt.name, opt.type_str),288    }289    output.flush();290    return null;291}292293// A key hint with dim brackets and a pink letter: dim '[', pink label, dim ']'.294// Comptime-built so it's a plain string literal — the widget always renders raw295// ANSI (it only runs on a real tty).296fn keyHint(comptime label: []const u8) []const u8 {297    return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET;298}299300fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool, detail: Detail) void {301    const count = resultCount(packages, opts, mode);302    const CLR = output.C_CLEAR_EOL;303    const RST = output.C_RESET;304    const PKN = output.C_PINK;305    const BLD = output.C_BOLD;306    const DIM = output.C_DIM;307    const CYN = output.C_CYAN;308309    // Clamp to the current result count: a caller could hand us a selection from a310    // larger, now-replaced result set. Never index past the end.311    const sel = if (count == 0) 0 else @min(selection, count - 1);312313    // Line 1: header. While the (blocking) search runs, show "searching..." so the314    // pause reads as work-in-progress, not a frozen widget.315    output.raw(CLR);316    const mode_str = if (mode == .packages) "nixpkgs" else "options";317    if (searching) {318        output.p("{s}:: {s}search {s} {s}  {s}searching...{s}\n", .{319            PKN, RST, mode_str, query, DIM, RST,320        });321    } else {322        output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{323            PKN, RST, mode_str, query, DIM, count, RST,324        });325    }326327    // Line 2: blank328    output.raw(CLR ++ "\n");329330    // Lines 3-7: results (up to RESULT_ROWS), scrolled so selection stays visible331    const scroll: usize = if (sel >= RESULT_ROWS) sel - RESULT_ROWS + 1 else 0;332    var i: usize = 0;333    while (i < RESULT_ROWS) : (i += 1) {334        const idx = scroll + i;335        output.raw(CLR);336        if (idx < count) {337            const selected = (idx == sel);338            switch (mode) {339                .packages => {340                    const pkg = packages[idx];341                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";342                    const name_pre = if (selected) PKN ++ BLD else "";343                    const name_suf = if (selected) RST else "";344                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, CYN, pkg.version, RST, pkg.description });345                },346                .options => {347                    const opt = opts[idx];348                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";349                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });350                },351            }352        } else {353            output.raw("\n");354        }355    }356357    // Line 8: blank358    output.raw(CLR ++ "\n");359360    // Lines 9-12: selected package detail. Line 9 (name+version) is shared; the361    // next three lines depend on `detail`. Every arm emits exactly 3 lines so the362    // widget stays WIDGET_LINES tall.363    if (count > 0 and mode == .packages) {364        const pkg = packages[sel];365        output.raw(CLR);366        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, CYN, pkg.version, RST });367        switch (detail) {368            .collapsed => {369                output.raw(CLR);370                output.p("   {s}\n", .{pkg.description});371                output.raw(CLR ++ "\n");372                output.raw(CLR);373                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });374            },375            .loading => {376                output.raw(CLR);377                output.p("   {s}loading details...{s}\n", .{ DIM, RST });378                output.raw(CLR ++ "\n");379                output.raw(CLR);380                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });381            },382            .unavailable => {383                output.raw(CLR);384                output.p("   {s}details unavailable{s}\n", .{ DIM, RST });385                output.raw(CLR ++ "\n");386                output.raw(CLR);387                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });388            },389            .meta => |m| {390                const home = if (m.homepage.len > 0) m.homepage else "—";391                const lic = if (m.license.len > 0) m.license else "—";392                output.raw(CLR);393                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "home", RST, CYN, home, RST });394                output.raw(CLR);395                output.p("   {s}{s:<8}{s}{s}\n", .{ DIM, "license", RST, lic });396                output.raw(CLR);397                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "attr", RST, CYN, pkg.attr, RST });398            },399        }400    } else if (count > 0 and mode == .options) {401        const opt = opts[sel];402        output.raw(CLR);403        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });404        output.raw(CLR);405        output.p("   {s}\n", .{opt.description});406        output.raw(CLR ++ "\n");407        output.raw(CLR ++ "\n");408    } else {409        output.raw(CLR ++ "\n");410        output.raw(CLR ++ "\n");411        output.raw(CLR ++ "\n");412        output.raw(CLR ++ "\n");413    }414415    // Line 13: blank416    output.raw(CLR ++ "\n");417418    // Lines 14-17: controls + padding to reach WIDGET_LINES419    output.raw(CLR);420    if (mode == .packages) {421        output.p("   {s} install  {s} info  {s}[up/down]{s} nav  {s} cancel\n", .{422            keyHint("enter"), keyHint("tab"), DIM, RST, keyHint("esc"),423        });424    } else {425        output.p("   {s}[up/down]{s} nav  {s} cancel\n", .{ DIM, RST, keyHint("esc") });426    }427    // Padding to reach WIDGET_LINES (18)428    var pad: usize = 14;429    while (pad < WIDGET_LINES) : (pad += 1) {430        output.raw(CLR ++ "\n");431    }432}433434// Raw mode enable/disable, including signal-safe restoration on435// SIGINT/SIGTERM/SIGHUP, now lives in rawterm.zig — shared with man.zig436// (NINA-014).