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
↓ nina-5a193aa6.tar.gzatlas at this save
files changed2 files
src/api.zig+73 −0src/search.zig+151 −87src/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" },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") });