5a193aa6bb32

add Tab-to-expand package details in search widget: Tab toggles the detail pane between description (collapsed) and metadata (homepage + license + attr), fetched on demand via api.packageMeta (one 'nix eval --json .meta', parses homepage + license array/object/string), cached by attr in its own arena, with a 'loading...'/'details unavailable' state; loop unified to a single render point; '[tab] info' added to controls — detail pane stays exactly 4 lines so WIDGET_LINES is preserved; build+tests green

time
2026-06-05 03:37 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
nina finishing brief
parent
05d1462ec09f
2 files src/api.zig+73 −0src/search.zig+151 −87
src/api.zig+73 −0
@@ -128,6 +128,79 @@128128     return license;129129 }130130 131+pub const PkgMeta = struct {132+    homepage: []const u8 = "",133+    license: []const u8 = "",134+};135+136+// Evaluate a package's meta for the expanded search detail pane. One137+// `nix eval --json .meta` call; returns null if the package can't be evaluated138+// (broken, renamed, etc.). Returned strings are duped into `arena`.139+pub fn packageMeta(arena: std.mem.Allocator, io: std.Io, attr: []const u8) ?PkgMeta {140+    const ref = std.fmt.allocPrint(arena, "nixpkgs#{s}.meta", .{attr}) catch return null;141+    const result = std.process.run(arena, io, .{142+        .argv = &.{ "nix", "eval", "--json", ref },143+    }) catch return null;144+    switch (result.term) {145+        .exited => |code| if (code != 0) return null,146+        else => return null,147+    }148+    const parsed = std.json.parseFromSlice(std.json.Value, arena, result.stdout, .{}) catch return null;149+    defer parsed.deinit();150+    if (parsed.value != .object) return null;151+    const o = parsed.value.object;152+    return .{153+        .homepage = jsonFirstString(arena, o.get("homepage")),154+        .license = licenseStr(arena, o.get("license")),155+    };156+}157+158+// homepage is usually a string but occasionally a list — take the first string.159+fn jsonFirstString(arena: std.mem.Allocator, v: ?std.json.Value) []const u8 {160+    const val = v orelse return "";161+    switch (val) {162+        .string => |s| return arena.dupe(u8, s) catch "",163+        .array => |arr| {164+            if (arr.items.len > 0 and arr.items[0] == .string) return arena.dupe(u8, arr.items[0].string) catch "";165+            return "";166+        },167+        else => return "",168+    }169+}170+171+// license may be a single attrset, a list of attrsets, or a bare string. Join the172+// fullName (or shortName) fields into one readable line. Result is duped into arena.173+fn licenseStr(arena: std.mem.Allocator, v: ?std.json.Value) []const u8 {174+    const val = v orelse return "";175+    switch (val) {176+        .string => |s| return arena.dupe(u8, s) catch "",177+        .object => |obj| return arena.dupe(u8, licenseName(obj)) catch "",178+        .array => |arr| {179+            var buf: std.ArrayList(u8) = .empty;180+            for (arr.items) |item| {181+                if (item != .object) continue;182+                const name = licenseName(item.object);183+                if (name.len == 0) continue;184+                if (buf.items.len > 0) buf.appendSlice(arena, ", ") catch {};185+                buf.appendSlice(arena, name) catch {};186+            }187+            return buf.toOwnedSlice(arena) catch "";188+        },189+        else => return "",190+    }191+}192+193+// Returns a slice into the parsed JSON arena (caller dupes before that frees).194+fn licenseName(obj: std.json.ObjectMap) []const u8 {195+    if (obj.get("fullName")) |n| {196+        if (n == .string) return n.string;197+    }198+    if (obj.get("shortName")) |n| {199+        if (n == .string) return n.string;200+    }201+    return "";202+}203+131204 pub fn searchOptions(gpa: std.mem.Allocator, io: std.Io, query: []const u8) ![]NixOption {132205     const result = std.process.run(gpa, io, .{133206         .argv = &.{ "nix", "eval", "--json", "nixpkgs#lib.optionAttrSetToDocList", "nixpkgs#nixosModules" },

view file

src/search.zig+151 −87
@@ -23,6 +23,16 @@2323     return @intCast(@divFloor(ns, 1_000_000));2424 }2525 26+// 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.29+const Detail = union(enum) {30+    collapsed,31+    loading,32+    unavailable,33+    meta: api.PkgMeta,34+};35+2636 pub fn run(2737     gpa: std.mem.Allocator,2838     io: std.Io,@@ -49,12 +59,21 @@4959     var opts: []api.NixOption = &.{};5060     var selection: usize = 0;5161 62+    // 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 = "";70+5271     // Initial search5372     _ = arena.reset(.free_all);5473     packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);5574 5675     // Initial render (prints WIDGET_LINES lines)57-    renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);76+    renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .collapsed);5877     output.flush();5978 6079     // Key loop with debounced search. A keystroke marks the query dirty and@@ -77,90 +96,105 @@7796         const ready = std.posix.poll(&pfd, timeout_ms) catch 0;7897 7998         if (ready == 0) {80-            // Poll timed out: typing has paused long enough — run the search now.81-            if (query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS) {82-                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();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();88107 89-                _ = arena.reset(.free_all);90-                packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);91-                // New result set: reset selection to the top. Navigating during the92-                // debounce window could otherwise leave selection past the new end,93-                // and renderWidget would index out of bounds.94-                selection = 0;95-                output.cursorUp(WIDGET_LINES);96-                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);97-                output.flush();108+            _ = arena.reset(.free_all);109+            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);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;120+121+            if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {122+                switch (buf[2]) {123+                    'A' => if (selection > 0) { selection -= 1; }, // up124+                    'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down125+                    else => {},126+                }127+            } else switch (buf[0]) {128+                'k' => if (selection > 0) { selection -= 1; },129+                'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },130+                0x1b, 'q' => break :loop,131+                '\t' => expanded = !expanded, // Tab: toggle package detail132+                'i', '\r' => {133+                    if (mode == .packages and packages.len > selection) {134+                        chosen = .{ .package = packages[selection], .action = .profile };135+                    }136+                    break :loop;137+                },138+                's' => {139+                    if (mode == .packages and packages.len > selection) {140+                        chosen = .{ .package = packages[selection], .action = .system };141+                    }142+                    break :loop;143+                },144+                't' => {145+                    if (mode == .packages and packages.len > selection) {146+                        chosen = .{ .package = packages[selection], .action = .profile };147+                        // flag: try mode — caller handles148+                    }149+                    break :loop;150+                },151+                'c' => {152+                    // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)153+                    if (mode == .packages and packages.len > selection) {154+                        copyToClipboard(gpa, io, packages[selection].attr);155+                    }156+                },157+                127 => { // backspace158+                    if (query_len > 0) {159+                        query_len -= 1;160+                        query_dirty = true;161+                        query_changed_at = std.Io.Clock.awake.now(io);162+                        selection = 0;163+                    }164+                },165+                else => |ch| {166+                    if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {167+                        query_buf[query_len] = ch;168+                        query_len += 1;169+                        query_dirty = true;170+                        query_changed_at = std.Io.Clock.awake.now(io);171+                        selection = 0;172+                    }173+                },98174             }99-            continue;100175         }101176 102-        var buf: [4]u8 = undefined;103-        const n = std.posix.read(STDIN_FD, &buf) catch break;104-        if (n == 0) break;105-106-        if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {107-            switch (buf[2]) {108-                'A' => if (selection > 0) { selection -= 1; }, // up109-                'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down110-                else => {},177+        // Common render after a search or a key. When expanded, resolve the detail178+        // pane by fetching the selected package's meta on demand (blocking eval),179+        // painting a "loading" frame first and caching by attr so re-visiting is180+        // instant.181+        const count = resultCount(packages, opts, mode);182+        const sel = if (count == 0) 0 else @min(selection, count - 1);183+        var detail: Detail = .collapsed;184+        if (expanded and mode == .packages and count > 0) {185+            const cur = packages[sel].attr;186+            if (meta == null or !std.mem.eql(u8, meta_attr, cur)) {187+                output.cursorUp(WIDGET_LINES);188+                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .loading);189+                output.flush();190+                _ = meta_arena.reset(.free_all);191+                meta_attr = meta_arena.allocator().dupe(u8, cur) catch "";192+                meta = api.packageMeta(meta_arena.allocator(), io, cur);111193             }112-        } else switch (buf[0]) {113-            'k' => if (selection > 0) { selection -= 1; },114-            'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },115-            0x1b, 'q' => break :loop,116-            'i', '\r' => {117-                if (mode == .packages and packages.len > selection) {118-                    chosen = .{ .package = packages[selection], .action = .profile };119-                }120-                break :loop;121-            },122-            's' => {123-                if (mode == .packages and packages.len > selection) {124-                    chosen = .{ .package = packages[selection], .action = .system };125-                }126-                break :loop;127-            },128-            't' => {129-                if (mode == .packages and packages.len > selection) {130-                    chosen = .{ .package = packages[selection], .action = .profile };131-                    // flag: try mode — caller handles132-                }133-                break :loop;134-            },135-            'c' => {136-                // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)137-                if (mode == .packages and packages.len > selection) {138-                    copyToClipboard(gpa, io, packages[selection].attr);139-                }140-            },141-            127 => { // 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-            },194+            detail = if (meta) |mm| Detail{ .meta = mm } else Detail.unavailable;158195         }159-160-        // Redraw immediately so the query line and selection feel responsive; the161-        // search itself is deferred to the debounce tick above.162196         output.cursorUp(WIDGET_LINES);163-        renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);197+        renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, detail);164198         output.flush();165199     }166200 @@ -243,7 +277,7 @@243277     return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET;244278 }245279 246-fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool) void {280+fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool, detail: Detail) void {247281     const count = resultCount(packages, opts, mode);248282     const CLR = output.C_CLEAR_EOL;249283     const RST = output.C_RESET;@@ -303,16 +337,46 @@303337     // Line 8: blank304338     output.raw(CLR ++ "\n");305339 306-    // Lines 9-12: selected package detail340+    // Lines 9-12: selected package detail. Line 9 (name+version) is shared; the341+    // next three lines depend on `detail`. Every arm emits exactly 3 lines so the342+    // widget stays WIDGET_LINES tall.307343     if (count > 0 and mode == .packages) {308344         const pkg = packages[sel];309345         output.raw(CLR);310346         output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, CYN, pkg.version, RST });311-        output.raw(CLR);312-        output.p("   {s}\n", .{pkg.description});313-        output.raw(CLR ++ "\n");314-        output.raw(CLR);315-        output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });347+        switch (detail) {348+            .collapsed => {349+                output.raw(CLR);350+                output.p("   {s}\n", .{pkg.description});351+                output.raw(CLR ++ "\n");352+                output.raw(CLR);353+                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });354+            },355+            .loading => {356+                output.raw(CLR);357+                output.p("   {s}loading details...{s}\n", .{ DIM, RST });358+                output.raw(CLR ++ "\n");359+                output.raw(CLR);360+                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });361+            },362+            .unavailable => {363+                output.raw(CLR);364+                output.p("   {s}details unavailable{s}\n", .{ DIM, RST });365+                output.raw(CLR ++ "\n");366+                output.raw(CLR);367+                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });368+            },369+            .meta => |m| {370+                const home = if (m.homepage.len > 0) m.homepage else "—";371+                const lic = if (m.license.len > 0) m.license else "—";372+                output.raw(CLR);373+                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "home", RST, CYN, home, RST });374+                output.raw(CLR);375+                output.p("   {s}{s:<8}{s}{s}\n", .{ DIM, "license", RST, lic });376+                output.raw(CLR);377+                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "attr", RST, CYN, pkg.attr, RST });378+            },379+        }316380     } else if (count > 0 and mode == .options) {317381         const opt = opts[sel];318382         output.raw(CLR);@@ -334,8 +398,8 @@334398     // Lines 14-17: controls + padding to reach WIDGET_LINES335399     output.raw(CLR);336400     if (mode == .packages) {337-        output.p("   {s} profile  {s} system  {s} try  {s} copy  {s}[up/down]{s} nav  {s} exit\n", .{338-            keyHint("i"), keyHint("s"), keyHint("t"), keyHint("c"), DIM, RST, keyHint("esc"),401+        output.p("   {s} profile  {s} system  {s} try  {s} copy  {s} info  {s} exit\n", .{402+            keyHint("i"), keyHint("s"), keyHint("t"), keyHint("c"), keyHint("tab"), keyHint("esc"),339403         });340404     } else {341405         output.p("   {s} insert  {s}[up/down]{s} nav  {s} exit\n", .{ keyHint("i"), DIM, RST, keyHint("esc") });

view file