search.zig

19.0 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");78const WIDGET_LINES: usize = 18;9const RESULT_ROWS: usize = 5;10const STDIN_FD = std.posix.STDIN_FILENO;1112// Debounce window: a keystroke defers the nix search until typing pauses this13// long. nix search is synchronous and blocks the widget for ~1s, so the window14// must be long enough that a normal between-keystroke pause doesn't trigger it —15// it should only fire once you've actually stopped typing.16const DEBOUNCE_MS: i64 = 500;1718// Milliseconds elapsed since `since` on the monotonic (awake) clock. std has no19// milliTimestamp in 0.16; this mirrors the Io.Clock pattern used in apply().20fn elapsedMs(io: std.Io, since: std.Io.Timestamp) i64 {21    const ns = @max(0, since.untilNow(io, .awake).toNanoseconds());22    return @intCast(@divFloor(ns, 1_000_000));23}2425// What the detail pane shows for the selected package. Tab toggles between the26// collapsed (description) view and the expanded meta view; the latter is fetched27// on demand, so it passes through loading and (on eval failure) unavailable.28const Detail = union(enum) {29    collapsed,30    loading,31    unavailable,32    meta: api.PkgMeta,33};3435pub fn run(36    gpa: std.mem.Allocator,37    io: std.Io,38    initial_query: []const u8,39    mode: Mode,40    environ: *const std.process.Environ.Map,41) !?types.NixPackage {42    // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,43    // CI), print plain results and return instead of the raw-mode widget, which44    // needs a real tty and would block waiting for keys.45    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode, environ);4647    var saved: std.posix.termios = undefined;48    try rawModeEnable(STDIN_FD, &saved);49    defer rawModeDisable(STDIN_FD, saved);5051    var arena = std.heap.ArenaAllocator.init(gpa);52    defer arena.deinit();5354    var query_buf: [256]u8 = undefined;55    var query_len: usize = @min(initial_query.len, query_buf.len - 1);56    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);5758    var packages: []types.NixPackage = &.{};59    var opts: []api.NixOption = &.{};60    var selection: usize = 0;6162    // Expanded detail (Tab): the selected package's meta, fetched on demand into63    // its own arena and cached by attr so re-visiting is instant. Kept separate64    // from the search arena, which is reset on every new search.65    var expanded = false;66    var meta_arena = std.heap.ArenaAllocator.init(gpa);67    defer meta_arena.deinit();68    var meta: ?api.PkgMeta = null;69    var meta_attr: []const u8 = "";7071    // Initial search72    _ = arena.reset(.free_all);73    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ);7475    // Initial render (prints WIDGET_LINES lines)76    renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .collapsed);77    output.flush();7879    // Key loop with debounced search. A keystroke marks the query dirty and80    // stamps the time; the nix search fires only once typing pauses for81    // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so82    // there is no blank flash mid-typing.83    var chosen: ?types.NixPackage = null;84    var query_dirty = false;85    var query_changed_at = std.Io.Clock.awake.now(io);86    loop: while (true) {87        // Block until a key arrives, unless a search is pending — then wake when88        // the debounce window closes so we can fire it.89        var timeout_ms: i32 = -1;90        if (query_dirty) {91            const remaining = DEBOUNCE_MS - elapsedMs(io, query_changed_at);92            timeout_ms = if (remaining <= 0) 0 else @intCast(remaining);93        }9495        var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};96        const ready = std.posix.poll(&pfd, timeout_ms) catch 0;9798        if (ready == 0) {99            // Poll timed out. If no search is pending, nothing changed — keep waiting.100            if (!(query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS)) continue;101            query_dirty = false;102            // Paint "searching..." over the stale results before the blocking nix103            // search, so the ~1s pause reads as progress, not a freeze.104            output.cursorUp(WIDGET_LINES);105            renderWidget(query_buf[0..query_len], packages, opts, selection, mode, true, .collapsed);106            output.flush();107108            _ = arena.reset(.free_all);109            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ);110            // New result set: reset to the top and drop cached meta (it belonged to111            // the previous results). Navigating during the window could otherwise112            // leave selection past the new end and renderWidget would index OOB.113            selection = 0;114            meta = null;115            // fall through to the common render below116        } else {117            var buf: [4]u8 = undefined;118            const n = std.posix.read(STDIN_FD, &buf) catch break;119            if (n == 0) break;120121            // The query is a live text field, so every printable key types into it —122            // single-letter actions would collide with package names (the 'i' in123            // "ripgrep" must not trigger install). Only non-text keys act: Enter124            // selects, Tab expands detail, arrows navigate, Esc cancels.125            if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {126                switch (buf[2]) {127                    'A' => if (selection > 0) { selection -= 1; }, // up128                    'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down129                    else => {},130                }131            } else switch (buf[0]) {132                0x1b => break :loop, // Esc: cancel (chosen stays null)133                '\t' => expanded = !expanded, // Tab: toggle package detail134                '\r', '\n' => { // Enter: select the highlighted package135                    if (mode == .packages and packages.len > selection) {136                        chosen = packages[selection];137                    }138                    break :loop;139                },140                127, 8 => { // backspace141                    if (query_len > 0) {142                        query_len -= 1;143                        query_dirty = true;144                        query_changed_at = std.Io.Clock.awake.now(io);145                        selection = 0;146                    }147                },148                else => |ch| {149                    if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {150                        query_buf[query_len] = ch;151                        query_len += 1;152                        query_dirty = true;153                        query_changed_at = std.Io.Clock.awake.now(io);154                        selection = 0;155                    }156                },157            }158        }159160        // Common render after a search or a key. When expanded, resolve the detail161        // pane by fetching the selected package's meta on demand (blocking eval),162        // painting a "loading" frame first and caching by attr so re-visiting is163        // instant.164        const count = resultCount(packages, opts, mode);165        const sel = if (count == 0) 0 else @min(selection, count - 1);166        var detail: Detail = .collapsed;167        if (expanded and mode == .packages and count > 0) {168            const cur = packages[sel].attr;169            if (meta == null or !std.mem.eql(u8, meta_attr, cur)) {170                output.cursorUp(WIDGET_LINES);171                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .loading);172                output.flush();173                _ = meta_arena.reset(.free_all);174                meta_attr = meta_arena.allocator().dupe(u8, cur) catch "";175                meta = api.packageMeta(meta_arena.allocator(), io, cur);176            }177            detail = if (meta) |mm| Detail{ .meta = mm } else Detail.unavailable;178        }179        output.cursorUp(WIDGET_LINES);180        renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, detail);181        output.flush();182    }183184    // Clear the widget area185    output.cursorUp(WIDGET_LINES);186    var i: usize = 0;187    while (i < WIDGET_LINES) : (i += 1) {188        output.raw(output.C_CLEAR_EOL ++ "\n");189    }190    output.cursorUp(WIDGET_LINES);191    output.flush();192193    // The chosen package's strings live in the search arena, which deinits as this194    // function returns — dupe into the caller's allocator so it survives.195    if (chosen) |pkg| return dupePackage(gpa, pkg);196    return null;197}198199fn dupePackage(gpa: std.mem.Allocator, pkg: types.NixPackage) types.NixPackage {200    return .{201        .attr = gpa.dupe(u8, pkg.attr) catch "",202        .pname = gpa.dupe(u8, pkg.pname) catch "",203        .version = gpa.dupe(u8, pkg.version) catch "",204        .description = gpa.dupe(u8, pkg.description) catch "",205    };206}207208pub const Mode = enum { packages, options };209210fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption, environ: *const std.process.Environ.Map) []types.NixPackage {211    // An empty query matches the entire catalog (100k+ rows) — a multi-second212    // fetch that would freeze the widget. Show nothing until there's a query.213    if (std.mem.trim(u8, query, " \t").len == 0) {214        opts.* = &.{};215        return &.{};216    }217    switch (mode) {218        .packages => {219            const packages: []types.NixPackage = api.searchPackages(arena, io, environ, query) catch &.{};220            sanitizeDescriptions(arena, packages);221            return packages;222        },223        .options => {224            const options: []api.NixOption = api.searchOptions(arena, io, query) catch &.{};225            sanitizeOptionDescriptions(arena, options);226            opts.* = options;227            return &.{};228        },229    }230}231232// nix search's JSON descriptions come from nixpkgs/NixOS module metadata, which233// upstream authors control — a malicious description could otherwise smuggle234// escape sequences into both the TUI widget and the plain (piped) renderer,235// since both read straight from this shared result set (NINA-016). pname/attr236// are Nix identifiers and version strings are not free text, so only the237// free-form description needs sanitizing.238fn sanitizeDescriptions(arena: std.mem.Allocator, packages: []types.NixPackage) void {239    for (packages) |*pkg| {240        pkg.description = output.sanitize(arena, pkg.description) catch pkg.description;241    }242}243244fn sanitizeOptionDescriptions(arena: std.mem.Allocator, options: []api.NixOption) void {245    for (options) |*opt| {246        opt.description = output.sanitize(arena, opt.description) catch opt.description;247    }248}249250fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {251    return switch (mode) {252        .packages => packages.len,253        .options => opts.len,254    };255}256257fn stdinIsTty(io: std.Io) bool {258    return std.Io.File.stdin().isTty(io) catch false;259}260261// Plain, non-interactive rendering for non-tty stdin. Same pink :: header and262// result count the widget shows, then one line per result; no cursor control.263fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, environ: *const std.process.Environ.Map) !?types.NixPackage {264    // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.265    if (std.mem.trim(u8, query, " \t").len == 0) {266        output.searchEmptyQuery();267        output.flush();268        return null;269    }270    var arena = std.heap.ArenaAllocator.init(gpa);271    defer arena.deinit();272    var opts: []api.NixOption = &.{};273    const packages = doSearch(arena.allocator(), io, query, mode, &opts, environ);274    const count = resultCount(packages, opts, mode);275    const mode_str = if (mode == .packages) "nixpkgs" else "options";276    output.searchPlainHeader(mode_str, query, count);277    if (count == 0) {278        output.searchPlainNoResults();279        output.flush();280        return null;281    }282    switch (mode) {283        // unfree=false: per-result meta.unfree would need an eval per package (too284        // slow for the list); the TUI detail pane is the place to surface it.285        .packages => for (packages) |pkg| output.searchPlainPackage(pkg.pname, pkg.version, pkg.description, false),286        .options => for (opts) |opt| output.searchPlainOption(opt.name, opt.type_str),287    }288    output.flush();289    return null;290}291292// A key hint with dim brackets and a pink letter: dim '[', pink label, dim ']'.293// Comptime-built so it's a plain string literal — the widget always renders raw294// ANSI (it only runs on a real tty).295fn keyHint(comptime label: []const u8) []const u8 {296    return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET;297}298299fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool, detail: Detail) void {300    const count = resultCount(packages, opts, mode);301    const CLR = output.C_CLEAR_EOL;302    const RST = output.C_RESET;303    const PKN = output.C_PINK;304    const BLD = output.C_BOLD;305    const DIM = output.C_DIM;306    const CYN = output.C_CYAN;307308    // Clamp to the current result count: a caller could hand us a selection from a309    // larger, now-replaced result set. Never index past the end.310    const sel = if (count == 0) 0 else @min(selection, count - 1);311312    // Line 1: header. While the (blocking) search runs, show "searching..." so the313    // pause reads as work-in-progress, not a frozen widget.314    output.raw(CLR);315    const mode_str = if (mode == .packages) "nixpkgs" else "options";316    if (searching) {317        output.p("{s}:: {s}search {s} {s}  {s}searching...{s}\n", .{318            PKN, RST, mode_str, query, DIM, RST,319        });320    } else {321        output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{322            PKN, RST, mode_str, query, DIM, count, RST,323        });324    }325326    // Line 2: blank327    output.raw(CLR ++ "\n");328329    // Lines 3-7: results (up to RESULT_ROWS), scrolled so selection stays visible330    const scroll: usize = if (sel >= RESULT_ROWS) sel - RESULT_ROWS + 1 else 0;331    var i: usize = 0;332    while (i < RESULT_ROWS) : (i += 1) {333        const idx = scroll + i;334        output.raw(CLR);335        if (idx < count) {336            const selected = (idx == sel);337            switch (mode) {338                .packages => {339                    const pkg = packages[idx];340                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";341                    const name_pre = if (selected) PKN ++ BLD else "";342                    const name_suf = if (selected) RST else "";343                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, CYN, pkg.version, RST, pkg.description });344                },345                .options => {346                    const opt = opts[idx];347                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";348                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });349                },350            }351        } else {352            output.raw("\n");353        }354    }355356    // Line 8: blank357    output.raw(CLR ++ "\n");358359    // Lines 9-12: selected package detail. Line 9 (name+version) is shared; the360    // next three lines depend on `detail`. Every arm emits exactly 3 lines so the361    // widget stays WIDGET_LINES tall.362    if (count > 0 and mode == .packages) {363        const pkg = packages[sel];364        output.raw(CLR);365        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, CYN, pkg.version, RST });366        switch (detail) {367            .collapsed => {368                output.raw(CLR);369                output.p("   {s}\n", .{pkg.description});370                output.raw(CLR ++ "\n");371                output.raw(CLR);372                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });373            },374            .loading => {375                output.raw(CLR);376                output.p("   {s}loading details...{s}\n", .{ DIM, RST });377                output.raw(CLR ++ "\n");378                output.raw(CLR);379                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });380            },381            .unavailable => {382                output.raw(CLR);383                output.p("   {s}details unavailable{s}\n", .{ DIM, RST });384                output.raw(CLR ++ "\n");385                output.raw(CLR);386                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });387            },388            .meta => |m| {389                const home = if (m.homepage.len > 0) m.homepage else "—";390                const lic = if (m.license.len > 0) m.license else "—";391                output.raw(CLR);392                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "home", RST, CYN, home, RST });393                output.raw(CLR);394                output.p("   {s}{s:<8}{s}{s}\n", .{ DIM, "license", RST, lic });395                output.raw(CLR);396                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "attr", RST, CYN, pkg.attr, RST });397            },398        }399    } else if (count > 0 and mode == .options) {400        const opt = opts[sel];401        output.raw(CLR);402        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });403        output.raw(CLR);404        output.p("   {s}\n", .{opt.description});405        output.raw(CLR ++ "\n");406        output.raw(CLR ++ "\n");407    } else {408        output.raw(CLR ++ "\n");409        output.raw(CLR ++ "\n");410        output.raw(CLR ++ "\n");411        output.raw(CLR ++ "\n");412    }413414    // Line 13: blank415    output.raw(CLR ++ "\n");416417    // Lines 14-17: controls + padding to reach WIDGET_LINES418    output.raw(CLR);419    if (mode == .packages) {420        output.p("   {s} install  {s} info  {s}[up/down]{s} nav  {s} cancel\n", .{421            keyHint("enter"), keyHint("tab"), DIM, RST, keyHint("esc"),422        });423    } else {424        output.p("   {s}[up/down]{s} nav  {s} cancel\n", .{ DIM, RST, keyHint("esc") });425    }426    // Padding to reach WIDGET_LINES (18)427    var pad: usize = 14;428    while (pad < WIDGET_LINES) : (pad += 1) {429        output.raw(CLR ++ "\n");430    }431}432433fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {434    var tio = try std.posix.tcgetattr(fd);435    saved.* = tio;436    // Same flag names exist on every POSIX termios target Zig exposes (linux,437    // macos, the *BSDs), so one body covers all of them — the old linux-only438    // guard left macos in cooked mode (echo on, line-buffered). OPOST is left439    // on so output keeps translating "\n" -> "\r\n" (cursor to column 0).440    tio.lflag.ECHO = false;441    tio.lflag.ICANON = false;442    tio.lflag.ISIG = false;443    tio.lflag.IEXTEN = false;444    tio.iflag.ICRNL = false;445    tio.iflag.IXON = false;446    tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;447    tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;448    try std.posix.tcsetattr(fd, .FLUSH, tio);449}450451fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {452    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};453}