diff --git a/src/panel.zig b/src/panel.zig index 50ba82b..244e803 100644 --- a/src/panel.zig +++ b/src/panel.zig @@ -1,6 +1,6 @@ const std = @import("std"); -// Generic transient panel stack state, independent of rendering and concrete panel types. +// Generic transient panel stack state, independent of concrete product panels. // req: ui/001, ui/002, session/003, testing/001, testing/002, testing/003, testing/004 test { @@ -9,17 +9,54 @@ test { pub const Error = error{ InvalidPanelTitle, + InvalidListItem, + InvalidListFilter, NoPanelOpen, + ActivePanelIsNotList, + EmptyList, +}; + +pub const PanelKind = enum { + empty, + list, +}; + +pub const ListItem = struct { + text: []u8, +}; + +pub const ListState = struct { + items: std.ArrayList(ListItem) = .empty, + filter: std.ArrayList(u8) = .empty, + cursor: usize = 0, + selected: ?[]u8 = null, + + fn deinit(self: *ListState, allocator: std.mem.Allocator) void { + for (self.items.items) |item| allocator.free(item.text); + self.items.deinit(allocator); + self.filter.deinit(allocator); + if (self.selected) |selected| allocator.free(selected); + self.* = undefined; + } }; pub const Panel = struct { title: []u8, + kind: PanelKind = .empty, + list: ?ListState = null, + + fn deinit(self: *Panel, allocator: std.mem.Allocator) void { + allocator.free(self.title); + if (self.list) |*list| list.deinit(allocator); + self.* = undefined; + } }; pub const State = struct { depth: usize, active_index: ?usize, active_title: ?[]const u8, + active_kind: ?PanelKind, }; pub const Stack = struct { @@ -32,7 +69,7 @@ pub const Stack = struct { } pub fn deinit(self: *Stack) void { - for (self.panels.items) |panel| self.allocator.free(panel.title); + for (self.panels.items) |*panel| panel.deinit(self.allocator); self.panels.deinit(self.allocator); self.* = undefined; } @@ -45,10 +82,30 @@ pub const Stack = struct { self.active_index = self.panels.items.len - 1; } + pub fn openList(self: *Stack, title: []const u8, items: []const []const u8) !void { + try validateTitle(title); + if (items.len == 0) return Error.EmptyList; + + var list = ListState{}; + errdefer list.deinit(self.allocator); + for (items) |item| { + try validateListItem(item); + const owned_item = try self.allocator.dupe(u8, item); + errdefer self.allocator.free(owned_item); + try list.items.append(self.allocator, .{ .text = owned_item }); + } + + const owned_title = try self.allocator.dupe(u8, title); + errdefer self.allocator.free(owned_title); + try self.panels.append(self.allocator, .{ .title = owned_title, .kind = .list, .list = list }); + self.active_index = self.panels.items.len - 1; + self.clampListCursor(); + } + pub fn closeActive(self: *Stack) !void { const index = self.active_index orelse return Error.NoPanelOpen; - const removed = self.panels.orderedRemove(index); - self.allocator.free(removed.title); + var removed = self.panels.orderedRemove(index); + removed.deinit(self.allocator); if (self.panels.items.len == 0) { self.active_index = null; } else if (index >= self.panels.items.len) { @@ -68,12 +125,48 @@ pub const Stack = struct { self.active_index = if (index == 0) self.panels.items.len - 1 else index - 1; } + pub fn filterList(self: *Stack, filter: []const u8) !void { + try validateFilter(filter); + var list = try self.activeList(); + list.filter.clearRetainingCapacity(); + try list.filter.appendSlice(self.allocator, filter); + list.cursor = 0; + self.clampListCursor(); + } + + pub fn listDown(self: *Stack) !void { + var list = try self.activeList(); + const visible = self.visibleCount() catch 0; + if (visible == 0) return; + if (list.cursor + 1 < visible) list.cursor += 1; + } + + pub fn listUp(self: *Stack) !void { + var list = try self.activeList(); + if (list.cursor > 0) list.cursor -= 1; + } + + pub fn selectList(self: *Stack) ![]const u8 { + var list = try self.activeList(); + const selected = self.visibleItemAt(list.cursor) orelse return Error.EmptyList; + const owned = try self.allocator.dupe(u8, selected); + if (list.selected) |old| self.allocator.free(old); + list.selected = owned; + return list.selected.?; + } + + pub fn cancelList(self: *Stack) !void { + _ = try self.activeList(); + try self.closeActive(); + } + pub fn state(self: *const Stack) State { const index = self.active_index; return .{ .depth = self.panels.items.len, .active_index = index, .active_title = if (index) |i| self.panels.items[i].title else null, + .active_kind = if (index) |i| self.panels.items[i].kind else null, }; } @@ -93,6 +186,100 @@ pub const Stack = struct { } return out.toOwnedSlice(allocator); } + + pub fn summaryAlloc(self: *const Stack, allocator: std.mem.Allocator) ![]u8 { + const index = self.active_index orelse return allocator.dupe(u8, "-"); + const panel = &self.panels.items[index]; + switch (panel.kind) { + .empty => return allocator.dupe(u8, "empty"), + .list => { + const list = &panel.list.?; + const visible = try self.visibleCount(); + return std.fmt.allocPrint( + allocator, + "list:filter={s},cursor={d},visible={d},selected={s}", + .{ + if (list.filter.items.len == 0) "-" else list.filter.items, + list.cursor, + visible, + if (list.selected) |selected| selected else "-", + }, + ); + }, + } + } + + pub fn activeListRowsAlloc(self: *const Stack, allocator: std.mem.Allocator, max_rows: usize) ![][]u8 { + const index = self.active_index orelse return Error.NoPanelOpen; + const panel = &self.panels.items[index]; + if (panel.kind != .list) return Error.ActivePanelIsNotList; + const list = &panel.list.?; + + var rows = std.ArrayList([]u8).empty; + errdefer { + for (rows.items) |row| allocator.free(row); + rows.deinit(allocator); + } + var visible_index: usize = 0; + for (list.items.items) |item| { + if (!matchesFilter(item.text, list.filter.items)) continue; + if (rows.items.len >= max_rows) break; + const marker: []const u8 = if (visible_index == list.cursor) "> " else " "; + const row = try std.fmt.allocPrint(allocator, "{s}{s}", .{ marker, item.text }); + try rows.append(allocator, row); + visible_index += 1; + } + if (rows.items.len == 0 and max_rows > 0) { + try rows.append(allocator, try allocator.dupe(u8, " no matches")); + } + return rows.toOwnedSlice(allocator); + } + + fn activeList(self: *Stack) !*ListState { + const index = self.active_index orelse return Error.NoPanelOpen; + const panel = &self.panels.items[index]; + if (panel.kind != .list) return Error.ActivePanelIsNotList; + return &panel.list.?; + } + + fn visibleItemAt(self: *const Stack, wanted_index: usize) ?[]const u8 { + const index = self.active_index orelse return null; + const panel = &self.panels.items[index]; + if (panel.kind != .list) return null; + const list = &panel.list.?; + var visible_index: usize = 0; + for (list.items.items) |item| { + if (!matchesFilter(item.text, list.filter.items)) continue; + if (visible_index == wanted_index) return item.text; + visible_index += 1; + } + return null; + } + + fn visibleCount(self: *const Stack) !usize { + const index = self.active_index orelse return Error.NoPanelOpen; + const panel = &self.panels.items[index]; + if (panel.kind != .list) return Error.ActivePanelIsNotList; + const list = &panel.list.?; + var count: usize = 0; + for (list.items.items) |item| { + if (matchesFilter(item.text, list.filter.items)) count += 1; + } + return count; + } + + fn clampListCursor(self: *Stack) void { + const index = self.active_index orelse return; + const panel = &self.panels.items[index]; + if (panel.kind != .list) return; + const list = &panel.list.?; + const visible = self.visibleCount() catch 0; + if (visible == 0) { + list.cursor = 0; + } else if (list.cursor >= visible) { + list.cursor = visible - 1; + } + } }; fn validateTitle(title: []const u8) !void { @@ -103,6 +290,25 @@ fn validateTitle(title: []const u8) !void { } } +fn validateListItem(item: []const u8) !void { + if (item.len == 0) return Error.InvalidListItem; + if (!std.unicode.utf8ValidateSlice(item)) return Error.InvalidListItem; + for (item) |byte| { + if (byte <= 0x20 or byte == '|') return Error.InvalidListItem; + } +} + +fn validateFilter(filter: []const u8) !void { + if (!std.unicode.utf8ValidateSlice(filter)) return Error.InvalidListFilter; + for (filter) |byte| { + if (byte <= 0x20 or byte == '|') return Error.InvalidListFilter; + } +} + +fn matchesFilter(item: []const u8, filter: []const u8) bool { + return filter.len == 0 or std.mem.indexOf(u8, item, filter) != null; +} + test "regular: opens nested panels and tracks active path" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); @@ -139,6 +345,55 @@ test "regular: next previous and close preserve stack order" { try std.testing.expectEqualStrings("files>[git]", path); } +test "regular: list panel filters moves and selects" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try stack.openList("files", &.{ "src/main.zig", "src/panel.zig", "README.md" }); + try stack.filterList("src"); + try stack.listDown(); + const selected = try stack.selectList(); + try std.testing.expectEqualStrings("src/panel.zig", selected); + + const summary = try stack.summaryAlloc(std.testing.allocator); + defer std.testing.allocator.free(summary); + try std.testing.expectEqualStrings("list:filter=src,cursor=1,visible=2,selected=src/panel.zig", summary); +} + +test "regular: active list rows expose cursor and empty match state" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try stack.openList("files", &.{ "a.zig", "b.zig" }); + try stack.listDown(); + const rows = try stack.activeListRowsAlloc(std.testing.allocator, 4); + defer { + for (rows) |row| std.testing.allocator.free(row); + std.testing.allocator.free(rows); + } + try std.testing.expectEqualStrings(" a.zig", rows[0]); + try std.testing.expectEqualStrings("> b.zig", rows[1]); + + try stack.filterList("none"); + const no_match_rows = try stack.activeListRowsAlloc(std.testing.allocator, 4); + defer { + for (no_match_rows) |row| std.testing.allocator.free(row); + std.testing.allocator.free(no_match_rows); + } + try std.testing.expectEqualStrings(" no matches", no_match_rows[0]); +} + +test "regular: list cancel closes the active list panel" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try stack.open("root"); + try stack.openList("files", &.{"a.zig"}); + try stack.cancelList(); + try std.testing.expectEqual(@as(usize, 1), stack.state().depth); + try std.testing.expectEqualStrings("root", stack.state().active_title.?); +} + test "adversarial: empty stack operations fail clearly" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); @@ -163,3 +418,17 @@ test "adversarial: invalid panel titles are rejected" { try std.testing.expectError(Error.InvalidPanelTitle, stack.open(&bad_utf8)); try std.testing.expectEqual(@as(usize, 0), stack.state().depth); } + +test "adversarial: list rejects invalid items filters and non-list operations" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try std.testing.expectError(Error.EmptyList, stack.openList("files", &.{})); + try std.testing.expectError(Error.InvalidListItem, stack.openList("files", &.{"bad item"})); + try stack.open("plain"); + try std.testing.expectError(Error.ActivePanelIsNotList, stack.filterList("src")); + try std.testing.expectError(Error.ActivePanelIsNotList, stack.listDown()); + try stack.closeActive(); + try stack.openList("files", &.{"a.zig"}); + try std.testing.expectError(Error.InvalidListFilter, stack.filterList("bad filter")); +} diff --git a/src/protocol.zig b/src/protocol.zig index 964cca0..51d7a55 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -31,6 +31,12 @@ fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, if (std.mem.eql(u8, command, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward); if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] }); if (std.mem.startsWith(u8, command, "panel_open ")) return panelRespond(allocator, session, .{ .open = command[11..] }); + if (std.mem.startsWith(u8, command, "list_open ")) return listOpenRespond(allocator, session, command[10..]); + if (std.mem.startsWith(u8, command, "list_filter ")) return listRespond(allocator, session, .{ .filter = command[12..] }); + if (std.mem.eql(u8, command, "list_down")) return listRespond(allocator, session, .down); + if (std.mem.eql(u8, command, "list_up")) return listRespond(allocator, session, .up); + if (std.mem.eql(u8, command, "list_select")) return listRespond(allocator, session, .select); + if (std.mem.eql(u8, command, "list_cancel")) return listRespond(allocator, session, .cancel); if (std.mem.eql(u8, command, "panel_close")) return panelRespond(allocator, session, .close); if (std.mem.eql(u8, command, "panel_next")) return panelRespond(allocator, session, .next); if (std.mem.eql(u8, command, "panel_prev")) return panelRespond(allocator, session, .previous); @@ -89,13 +95,61 @@ fn panelRespond(allocator: std.mem.Allocator, session: *session_mod.Session, com return stateResponse(allocator, session); } +const ListCommand = union(enum) { + filter: []const u8, + down, + up, + select, + cancel, +}; + +fn listOpenRespond(allocator: std.mem.Allocator, session: *session_mod.Session, payload: []const u8) ![]u8 { + const split = std.mem.indexOfScalar(u8, payload, ' ') orelse return allocator.dupe(u8, "err invalid list open\n"); + const title = payload[0..split]; + const items_payload = payload[split + 1 ..]; + var items = std.ArrayList([]const u8).empty; + defer items.deinit(allocator); + var iter = std.mem.splitScalar(u8, items_payload, '|'); + while (iter.next()) |item| try items.append(allocator, item); + session.openListPanel(title, items.items) catch |err| switch (err) { + error.InvalidPanelTitle => return allocator.dupe(u8, "err invalid panel title\n"), + error.InvalidListItem => return allocator.dupe(u8, "err invalid list item\n"), + error.EmptyList => return allocator.dupe(u8, "err empty list\n"), + else => return err, + }; + return stateResponse(allocator, session); +} + +fn listRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: ListCommand) ![]u8 { + switch (command) { + .filter => |filter| session.filterListPanel(filter) catch |err| return listError(allocator, err), + .down => session.listPanelDown() catch |err| return listError(allocator, err), + .up => session.listPanelUp() catch |err| return listError(allocator, err), + .select => _ = session.selectListPanel() catch |err| return listError(allocator, err), + .cancel => session.cancelListPanel() catch |err| return listError(allocator, err), + } + return stateResponse(allocator, session); +} + +fn listError(allocator: std.mem.Allocator, err: anyerror) ![]u8 { + return switch (err) { + error.NoPanelOpen => allocator.dupe(u8, "err no panel open\n"), + error.ActivePanelIsNotList => allocator.dupe(u8, "err active panel is not list\n"), + error.InvalidListFilter => allocator.dupe(u8, "err invalid list filter\n"), + error.EmptyList => allocator.dupe(u8, "err empty list\n"), + else => err, + }; +} + fn stateResponse(allocator: std.mem.Allocator, session: *session_mod.Session) ![]u8 { const snap = try session.snapshot(); const panel_path = try session.panelPathAlloc(allocator); defer allocator.free(panel_path); + const panel_summary = try session.panelSummaryAlloc(allocator); + defer allocator.free(panel_summary); return std.fmt.allocPrint( allocator, - "ok state cursor_byte={d} cursor_cell={d} bytes_len={d} panel_depth={d} active_panel={s} panel_path={s}\n", + "ok state cursor_byte={d} cursor_cell={d} bytes_len={d} panel_depth={d} active_panel={s} panel_path={s} panel_summary={s}\n", .{ snap.cursor_byte, snap.cursor_cell, @@ -103,6 +157,7 @@ fn stateResponse(allocator: std.mem.Allocator, session: *session_mod.Session) ![ snap.panel_depth, if (snap.active_panel_title) |title| title else "-", panel_path, + panel_summary, }, ); } @@ -113,15 +168,15 @@ test "regular: protocol opens bytes, reports state, and dispatches commands" { const open = try handleLine(std.testing.allocator, &session, "open café\n"); defer std.testing.allocator.free(open); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=5 panel_depth=0 active_panel=- panel_path=-\n", open); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=5 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", open); const moved = try handleLine(std.testing.allocator, &session, "command move_right\n"); defer std.testing.allocator.free(moved); - try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=5 panel_depth=0 active_panel=- panel_path=-\n", moved); + try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=5 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", moved); const inserted = try handleLine(std.testing.allocator, &session, "command insert 🔥\n"); defer std.testing.allocator.free(inserted); - try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=3 bytes_len=9 panel_depth=0 active_panel=- panel_path=-\n", inserted); + try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=3 bytes_len=9 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", inserted); } test "regular: protocol delete command removes a whole UTF-8 codepoint" { @@ -137,7 +192,7 @@ test "regular: protocol delete command removes a whole UTF-8 codepoint" { response = try handleLine(std.testing.allocator, &session, "command delete_backward\n"); defer std.testing.allocator.free(response); - try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=1 panel_depth=0 active_panel=- panel_path=-\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=1 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response); } test "adversarial: protocol rejects unknown requests and commands" { @@ -160,7 +215,7 @@ test "adversarial: protocol reports no buffer and invalid utf8 without corruptin const no_buffer = try handleLine(std.testing.allocator, &session, "state\n"); defer std.testing.allocator.free(no_buffer); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=0 active_panel=- panel_path=-\n", no_buffer); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", no_buffer); try session.openFixture("safe"); const before = try session.snapshot(); @@ -189,7 +244,7 @@ test "regular: protocol pair commands insert delimiters with cursor between them response = try handleLine(std.testing.allocator, &session, "command pair parens\n"); defer std.testing.allocator.free(response); - try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=5 bytes_len=6 panel_depth=0 active_panel=- panel_path=-\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=5 bytes_len=6 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response); try std.testing.expectEqualStrings("call()", (try session.snapshot()).bytes); } @@ -211,22 +266,22 @@ test "regular: protocol opens switches and closes generic panels" { { const response = try handleLine(std.testing.allocator, &session, "command panel_open files\n"); defer std.testing.allocator.free(response); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files]\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=empty\n", response); } { const response = try handleLine(std.testing.allocator, &session, "command panel_open diagnostics\n"); defer std.testing.allocator.free(response); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=2 active_panel=diagnostics panel_path=files>[diagnostics]\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=2 active_panel=diagnostics panel_path=files>[diagnostics] panel_summary=empty\n", response); } { const response = try handleLine(std.testing.allocator, &session, "command panel_prev\n"); defer std.testing.allocator.free(response); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=2 active_panel=files panel_path=[files]>diagnostics\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=2 active_panel=files panel_path=[files]>diagnostics panel_summary=empty\n", response); } { const response = try handleLine(std.testing.allocator, &session, "command panel_close\n"); defer std.testing.allocator.free(response); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=diagnostics panel_path=[diagnostics]\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=diagnostics panel_path=[diagnostics] panel_summary=empty\n", response); } } @@ -245,3 +300,56 @@ test "adversarial: protocol rejects invalid panel titles and empty panel actions try std.testing.expectEqualStrings("err no panel open\n", response); } } + +test "regular: protocol list panel filters moves selects and remains inspectable" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + { + const response = try handleLine(std.testing.allocator, &session, "command list_open files src/main.zig|src/panel.zig|README.md\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=-,cursor=0,visible=3,selected=-\n", response); + } + { + const response = try handleLine(std.testing.allocator, &session, "command list_filter src\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=src,cursor=0,visible=2,selected=-\n", response); + } + { + const response = try handleLine(std.testing.allocator, &session, "command list_down\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=src,cursor=1,visible=2,selected=-\n", response); + } + { + const response = try handleLine(std.testing.allocator, &session, "command list_select\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=src,cursor=1,visible=2,selected=src/panel.zig\n", response); + } +} + +test "regular: protocol list cancel closes list panel" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + var response = try handleLine(std.testing.allocator, &session, "command list_open files a.zig|b.zig\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command list_cancel\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response); +} + +test "adversarial: protocol list errors are explicit" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + { + const response = try handleLine(std.testing.allocator, &session, "command list_open files bad item\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("err invalid list item\n", response); + } + { + const response = try handleLine(std.testing.allocator, &session, "command list_filter src\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("err no panel open\n", response); + } +} diff --git a/src/replay.zig b/src/replay.zig index 45b66fb..bf14e4c 100644 --- a/src/replay.zig +++ b/src/replay.zig @@ -183,12 +183,12 @@ test "regular: golden open move edit save recording replays deterministically" { defer recorder.deinit(); try recorder.protocolLine("open let café = 1"); - try recorder.expectResponse("ok state cursor_byte=0 cursor_cell=0 bytes_len=13 panel_depth=0 active_panel=- panel_path=-"); + try recorder.expectResponse("ok state cursor_byte=0 cursor_cell=0 bytes_len=13 panel_depth=0 active_panel=- panel_path=- panel_summary=-"); try recorder.protocolLine("command move_right"); try recorder.protocolLine("command move_right"); try recorder.protocolLine("command move_right"); try recorder.inputInsert("🔥"); - try recorder.expectResponse("ok state cursor_byte=7 cursor_cell=5 bytes_len=17 panel_depth=0 active_panel=- panel_path=-"); + try recorder.expectResponse("ok state cursor_byte=7 cursor_cell=5 bytes_len=17 panel_depth=0 active_panel=- panel_path=- panel_summary=-"); try recorder.saveCheckpoint("let🔥 café = 1"); const result = try replayText(std.testing.allocator, recorder.text()); @@ -201,7 +201,7 @@ test "regular: replay can be rerun with identical result" { \\protocol open abc \\protocol command move_right \\input insert é - \\expect-response ok state cursor_byte=3 cursor_cell=2 bytes_len=5 panel_depth=0 active_panel=- panel_path=- + \\expect-response ok state cursor_byte=3 cursor_cell=2 bytes_len=5 panel_depth=0 active_panel=- panel_path=- panel_summary=- \\save aébc \\ ; diff --git a/src/session.zig b/src/session.zig index c96ac9d..9bb26da 100644 --- a/src/session.zig +++ b/src/session.zig @@ -151,6 +151,10 @@ pub const Session = struct { try self.panels.open(title); } + pub fn openListPanel(self: *Session, title: []const u8, items: []const []const u8) !void { + try self.panels.openList(title, items); + } + pub fn closePanel(self: *Session) !void { try self.panels.closeActive(); } @@ -163,10 +167,38 @@ pub const Session = struct { try self.panels.previous(); } + pub fn filterListPanel(self: *Session, filter: []const u8) !void { + try self.panels.filterList(filter); + } + + pub fn listPanelDown(self: *Session) !void { + try self.panels.listDown(); + } + + pub fn listPanelUp(self: *Session) !void { + try self.panels.listUp(); + } + + pub fn selectListPanel(self: *Session) ![]const u8 { + return self.panels.selectList(); + } + + pub fn cancelListPanel(self: *Session) !void { + try self.panels.cancelList(); + } + pub fn panelPathAlloc(self: *const Session, allocator: std.mem.Allocator) ![]u8 { return self.panels.pathAlloc(allocator); } + pub fn panelSummaryAlloc(self: *const Session, allocator: std.mem.Allocator) ![]u8 { + return self.panels.summaryAlloc(allocator); + } + + pub fn activeListRowsAlloc(self: *const Session, allocator: std.mem.Allocator, max_rows: usize) ![][]u8 { + return self.panels.activeListRowsAlloc(allocator, max_rows); + } + pub fn snapshot(self: *const Session) !Snapshot { const panel_state = self.panels.state(); if (self.buffer) |*buffer| { diff --git a/src/socket.zig b/src/socket.zig index a8d289c..e440759 100644 --- a/src/socket.zig +++ b/src/socket.zig @@ -175,7 +175,7 @@ test "regular: local socket state request observes a running session" { defer std.testing.allocator.free(response); thread.join(); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=3 panel_depth=0 active_panel=- panel_path=-\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=3 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response); } test "regular: local socket command mutates session state" { @@ -197,7 +197,7 @@ test "regular: local socket command mutates session state" { defer std.testing.allocator.free(response); thread.join(); - try std.testing.expectEqualStrings("ok state cursor_byte=3 cursor_cell=2 bytes_len=5 panel_depth=0 active_panel=- panel_path=-\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=3 cursor_cell=2 bytes_len=5 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response); } test "adversarial: socket transport returns explicit protocol errors" { @@ -236,11 +236,11 @@ test "regular: local socket panel command exposes inspectable panel state" { const response = try request(std.testing.allocator, path, "command panel_open files"); defer std.testing.allocator.free(response); thread.join(); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files]\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=empty\n", response); thread = try std.Thread.spawn(.{}, Server.acceptOnce, .{&server}); const state = try request(std.testing.allocator, path, "state"); defer std.testing.allocator.free(state); thread.join(); - try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files]\n", state); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=empty\n", state); } diff --git a/src/tui.zig b/src/tui.zig index f092e78..290b34e 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -94,6 +94,12 @@ pub const Client = struct { if (std.mem.startsWith(u8, line, "key ")) return self.handleTraceKey(line[4..]); if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]); if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line); + if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line); + if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line); + if (std.mem.eql(u8, line, "list_down")) return self.applyProtocol("command list_down"); + if (std.mem.eql(u8, line, "list_up")) return self.applyProtocol("command list_up"); + if (std.mem.eql(u8, line, "list_select")) return self.applyProtocol("command list_select"); + if (std.mem.eql(u8, line, "list_cancel")) return self.applyProtocol("command list_cancel"); if (std.mem.eql(u8, line, "panel_close")) return self.applyProtocol("command panel_close"); if (std.mem.eql(u8, line, "panel_next")) return self.applyProtocol("command panel_next"); if (std.mem.eql(u8, line, "panel_prev")) return self.applyProtocol("command panel_prev"); @@ -174,7 +180,20 @@ pub const Client = struct { try out.append(allocator, '\n'); body_lines_used += 1; - if (body_lines_used < max_body_lines) { + const remaining_rows = max_body_lines - body_lines_used; + const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null; + if (rows) |list_rows| { + defer { + for (list_rows) |row| allocator.free(row); + allocator.free(list_rows); + } + for (list_rows) |row| { + if (body_lines_used >= max_body_lines) break; + try appendVisibleCells(allocator, &out, row, self.viewport.width); + try out.append(allocator, '\n'); + body_lines_used += 1; + } + } else if (body_lines_used < max_body_lines) { const title = snap.active_panel_title.?; const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title}); defer allocator.free(detail); @@ -599,3 +618,50 @@ test "adversarial: invalid panel title and empty close recover without changing try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); try assertLinesFit(frame, 16); } + +test "regular: list panel filters moves selects and renders rows" { + var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md"); + try client.handleTraceLine("list_filter src"); + try client.handleTraceLine("list_down"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "> src/panel.zig") != null); + try assertLinesFit(frame, 32); + + try client.handleTraceLine("list_select"); + const summary = try client.session.panelSummaryAlloc(std.testing.allocator); + defer std.testing.allocator.free(summary); + try std.testing.expectEqualStrings("list:filter=src,cursor=1,visible=2,selected=src/panel.zig", summary); +} + +test "regular: list cancel returns to previous editor surface" { + var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("open abc"); + try client.handleTraceLine("list_open files a.zig|b.zig"); + try client.handleTraceLine("list_cancel"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "panel") == null); +} + +test "adversarial: list no-match state and invalid actions recover" { + var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("list_open files a.zig|b.zig"); + try client.handleTraceLine("list_filter none"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "no matches") != null); + try assertLinesFit(frame, 24); + + try client.handleTraceLine("list_cancel"); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("list_down")); +}