From 230f8f9129644364a11e8e7ad39da4fd28534991 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 02:32:46 +0200 Subject: [PATCH] Add generic session panel stack --- src/main.zig | 2 + src/panel.zig | 165 +++++++++++++++++++++++++++++++++++++++++++++++ src/protocol.zig | 104 +++++++++++++++++++++++++---- src/replay.zig | 6 +- src/session.zig | 88 +++++++++++++++++++++++-- src/socket.zig | 25 ++++++- 6 files changed, 369 insertions(+), 21 deletions(-) create mode 100644 src/panel.zig diff --git a/src/main.zig b/src/main.zig index dffce0b..4532c97 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,7 @@ const input = @import("input.zig"); const layout = @import("layout.zig"); const leader = @import("leader.zig"); const mobile_acceptance = @import("mobile_acceptance.zig"); +const panel = @import("panel.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session = @import("session.zig"); @@ -125,6 +126,7 @@ test { _ = layout; _ = leader; _ = mobile_acceptance; + _ = panel; _ = protocol; _ = replay; _ = session; diff --git a/src/panel.zig b/src/panel.zig new file mode 100644 index 0000000..50ba82b --- /dev/null +++ b/src/panel.zig @@ -0,0 +1,165 @@ +const std = @import("std"); + +// Generic transient panel stack state, independent of rendering and concrete panel types. +// req: ui/001, ui/002, session/003, testing/001, testing/002, testing/003, testing/004 + +test { + _ = Stack; +} + +pub const Error = error{ + InvalidPanelTitle, + NoPanelOpen, +}; + +pub const Panel = struct { + title: []u8, +}; + +pub const State = struct { + depth: usize, + active_index: ?usize, + active_title: ?[]const u8, +}; + +pub const Stack = struct { + allocator: std.mem.Allocator, + panels: std.ArrayList(Panel) = .empty, + active_index: ?usize = null, + + pub fn init(allocator: std.mem.Allocator) Stack { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Stack) void { + for (self.panels.items) |panel| self.allocator.free(panel.title); + self.panels.deinit(self.allocator); + self.* = undefined; + } + + pub fn open(self: *Stack, title: []const u8) !void { + try validateTitle(title); + const owned = try self.allocator.dupe(u8, title); + errdefer self.allocator.free(owned); + try self.panels.append(self.allocator, .{ .title = owned }); + self.active_index = self.panels.items.len - 1; + } + + 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); + if (self.panels.items.len == 0) { + self.active_index = null; + } else if (index >= self.panels.items.len) { + self.active_index = self.panels.items.len - 1; + } else { + self.active_index = index; + } + } + + pub fn next(self: *Stack) !void { + const index = self.active_index orelse return Error.NoPanelOpen; + self.active_index = (index + 1) % self.panels.items.len; + } + + pub fn previous(self: *Stack) !void { + const index = self.active_index orelse return Error.NoPanelOpen; + self.active_index = if (index == 0) self.panels.items.len - 1 else index - 1; + } + + 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, + }; + } + + pub fn pathAlloc(self: *const Stack, allocator: std.mem.Allocator) ![]u8 { + if (self.panels.items.len == 0) return allocator.dupe(u8, "-"); + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + for (self.panels.items, 0..) |panel, index| { + if (index > 0) try out.append(allocator, '>'); + if (self.active_index != null and self.active_index.? == index) { + try out.append(allocator, '['); + try out.appendSlice(allocator, panel.title); + try out.append(allocator, ']'); + } else { + try out.appendSlice(allocator, panel.title); + } + } + return out.toOwnedSlice(allocator); + } +}; + +fn validateTitle(title: []const u8) !void { + if (title.len == 0) return Error.InvalidPanelTitle; + if (!std.unicode.utf8ValidateSlice(title)) return Error.InvalidPanelTitle; + for (title) |byte| { + if (byte <= 0x20 or byte == '>' or byte == '[' or byte == ']') return Error.InvalidPanelTitle; + } +} + +test "regular: opens nested panels and tracks active path" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try stack.open("files"); + try stack.open("diagnostics"); + + const state = stack.state(); + try std.testing.expectEqual(@as(usize, 2), state.depth); + try std.testing.expectEqual(@as(?usize, 1), state.active_index); + try std.testing.expectEqualStrings("diagnostics", state.active_title.?); + + const path = try stack.pathAlloc(std.testing.allocator); + defer std.testing.allocator.free(path); + try std.testing.expectEqualStrings("files>[diagnostics]", path); +} + +test "regular: next previous and close preserve stack order" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try stack.open("files"); + try stack.open("git"); + try stack.open("jobs"); + try stack.previous(); + try std.testing.expectEqualStrings("git", stack.state().active_title.?); + try stack.next(); + try std.testing.expectEqualStrings("jobs", stack.state().active_title.?); + try stack.closeActive(); + try std.testing.expectEqualStrings("git", stack.state().active_title.?); + + const path = try stack.pathAlloc(std.testing.allocator); + defer std.testing.allocator.free(path); + try std.testing.expectEqualStrings("files>[git]", path); +} + +test "adversarial: empty stack operations fail clearly" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try std.testing.expectError(Error.NoPanelOpen, stack.closeActive()); + try std.testing.expectError(Error.NoPanelOpen, stack.next()); + try std.testing.expectError(Error.NoPanelOpen, stack.previous()); + + const path = try stack.pathAlloc(std.testing.allocator); + defer std.testing.allocator.free(path); + try std.testing.expectEqualStrings("-", path); +} + +test "adversarial: invalid panel titles are rejected" { + var stack = Stack.init(std.testing.allocator); + defer stack.deinit(); + + try std.testing.expectError(Error.InvalidPanelTitle, stack.open("")); + try std.testing.expectError(Error.InvalidPanelTitle, stack.open("two words")); + try std.testing.expectError(Error.InvalidPanelTitle, stack.open("bad>path")); + const bad_utf8 = [_]u8{ 0xc3, 0x28 }; + try std.testing.expectError(Error.InvalidPanelTitle, stack.open(&bad_utf8)); + try std.testing.expectEqual(@as(usize, 0), stack.state().depth); +} diff --git a/src/protocol.zig b/src/protocol.zig index 05d1b26..964cca0 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -30,6 +30,10 @@ fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right); 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.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); if (std.mem.startsWith(u8, command, "pair ")) { const pair = pairByName(command[5..]) orelse return allocator.dupe(u8, "err unknown pair\n"); return dispatchAndRespond(allocator, session, .{ .insert_pair = pair }); @@ -56,14 +60,50 @@ fn dispatchAndRespond(allocator: std.mem.Allocator, session: *session_mod.Sessio return stateResponse(allocator, session); } +const PanelCommand = union(enum) { + open: []const u8, + close, + next, + previous, +}; + +fn panelRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: PanelCommand) ![]u8 { + switch (command) { + .open => |title| session.openPanel(title) catch |err| switch (err) { + error.InvalidPanelTitle => return allocator.dupe(u8, "err invalid panel title\n"), + else => return err, + }, + .close => session.closePanel() catch |err| switch (err) { + error.NoPanelOpen => return allocator.dupe(u8, "err no panel open\n"), + else => return err, + }, + .next => session.nextPanel() catch |err| switch (err) { + error.NoPanelOpen => return allocator.dupe(u8, "err no panel open\n"), + else => return err, + }, + .previous => session.previousPanel() catch |err| switch (err) { + error.NoPanelOpen => return allocator.dupe(u8, "err no panel open\n"), + else => return err, + }, + } + return stateResponse(allocator, session); +} + fn stateResponse(allocator: std.mem.Allocator, session: *session_mod.Session) ![]u8 { - const snap = session.snapshot() catch |err| switch (err) { - error.NoBufferOpen => return allocator.dupe(u8, "err no buffer open\n"), - }; + const snap = try session.snapshot(); + const panel_path = try session.panelPathAlloc(allocator); + defer allocator.free(panel_path); return std.fmt.allocPrint( allocator, - "ok state cursor_byte={d} cursor_cell={d} bytes_len={d}\n", - .{ snap.cursor_byte, snap.cursor_cell, snap.bytes.len }, + "ok state cursor_byte={d} cursor_cell={d} bytes_len={d} panel_depth={d} active_panel={s} panel_path={s}\n", + .{ + snap.cursor_byte, + snap.cursor_cell, + snap.bytes.len, + snap.panel_depth, + if (snap.active_panel_title) |title| title else "-", + panel_path, + }, ); } @@ -73,15 +113,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\n", 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); 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\n", 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); 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\n", 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); } test "regular: protocol delete command removes a whole UTF-8 codepoint" { @@ -97,7 +137,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\n", 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); } test "adversarial: protocol rejects unknown requests and commands" { @@ -120,7 +160,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("err no buffer open\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=-\n", no_buffer); try session.openFixture("safe"); const before = try session.snapshot(); @@ -149,7 +189,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\n", 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("call()", (try session.snapshot()).bytes); } @@ -163,3 +203,45 @@ test "adversarial: protocol rejects unknown pair names" { try std.testing.expectEqualStrings("err unknown pair\n", response); try std.testing.expectEqualStrings("abc", (try session.snapshot()).bytes); } + +test "regular: protocol opens switches and closes generic panels" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + { + 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); + } + { + 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); + } + { + 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); + } + { + 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); + } +} + +test "adversarial: protocol rejects invalid panel titles and empty panel actions" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + { + const response = try handleLine(std.testing.allocator, &session, "command panel_open bad title\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("err invalid panel title\n", response); + } + { + const response = try handleLine(std.testing.allocator, &session, "command panel_close\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 a3b95e1..45b66fb 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"); + try recorder.expectResponse("ok state cursor_byte=0 cursor_cell=0 bytes_len=13 panel_depth=0 active_panel=- panel_path=-"); 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"); + try recorder.expectResponse("ok state cursor_byte=7 cursor_cell=5 bytes_len=17 panel_depth=0 active_panel=- panel_path=-"); 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 + \\expect-response ok state cursor_byte=3 cursor_cell=2 bytes_len=5 panel_depth=0 active_panel=- panel_path=- \\save aébc \\ ; diff --git a/src/session.zig b/src/session.zig index 7acc1f5..c96ac9d 100644 --- a/src/session.zig +++ b/src/session.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const panel_mod = @import("panel.zig"); // Headless editor state for the protocol-first core. // req: coding/001, session/003 @@ -35,6 +36,9 @@ pub const Snapshot = struct { cursor_byte: usize, cursor_cell: usize, selection: ?Selection, + panel_depth: usize, + active_panel_index: ?usize, + active_panel_title: ?[]const u8, }; pub const Buffer = struct { @@ -60,6 +64,9 @@ pub const Buffer = struct { .cursor_byte = self.cursor.byte, .cursor_cell = self.cursor.cell, .selection = self.selection, + .panel_depth = 0, + .active_panel_index = null, + .active_panel_title = null, }; } @@ -118,13 +125,15 @@ pub const Buffer = struct { pub const Session = struct { allocator: std.mem.Allocator, buffer: ?Buffer = null, + panels: panel_mod.Stack, pub fn init(allocator: std.mem.Allocator) Session { - return .{ .allocator = allocator }; + return .{ .allocator = allocator, .panels = panel_mod.Stack.init(allocator) }; } pub fn deinit(self: *Session) void { if (self.buffer) |*buffer| buffer.deinit(); + self.panels.deinit(); self.* = undefined; } @@ -138,9 +147,44 @@ pub const Session = struct { return error.NoBufferOpen; } + pub fn openPanel(self: *Session, title: []const u8) !void { + try self.panels.open(title); + } + + pub fn closePanel(self: *Session) !void { + try self.panels.closeActive(); + } + + pub fn nextPanel(self: *Session) !void { + try self.panels.next(); + } + + pub fn previousPanel(self: *Session) !void { + try self.panels.previous(); + } + + pub fn panelPathAlloc(self: *const Session, allocator: std.mem.Allocator) ![]u8 { + return self.panels.pathAlloc(allocator); + } + pub fn snapshot(self: *const Session) !Snapshot { - if (self.buffer) |*buffer| return buffer.snapshot(); - return error.NoBufferOpen; + const panel_state = self.panels.state(); + if (self.buffer) |*buffer| { + var snap = buffer.snapshot(); + snap.panel_depth = panel_state.depth; + snap.active_panel_index = panel_state.active_index; + snap.active_panel_title = panel_state.active_title; + return snap; + } + return .{ + .bytes = "", + .cursor_byte = 0, + .cursor_cell = 0, + .selection = null, + .panel_depth = panel_state.depth, + .active_panel_index = panel_state.active_index, + .active_panel_title = panel_state.active_title, + }; } }; @@ -275,12 +319,14 @@ test "adversarial: invalid inserted UTF-8 is rejected and existing bytes are pre try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte); } -test "adversarial: dispatch requires an open buffer" { +test "adversarial: dispatch requires an open buffer but state remains inspectable" { var session = Session.init(std.testing.allocator); defer session.deinit(); try std.testing.expectError(error.NoBufferOpen, session.dispatch(.move_right)); - try std.testing.expectError(error.NoBufferOpen, session.snapshot()); + const snap = try session.snapshot(); + try std.testing.expectEqual(@as(usize, 0), snap.bytes.len); + try std.testing.expectEqual(@as(usize, 0), snap.panel_depth); } test "regular: pair insertion places cursor between delimiters" { @@ -308,3 +354,35 @@ test "adversarial: invalid UTF-8 pair insertion is rejected and existing bytes a try std.testing.expectEqualStrings("safe", buffer.snapshot().bytes); try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte); } + +test "regular: session opens closes and switches generic panel stack" { + var session = Session.init(std.testing.allocator); + defer session.deinit(); + + try session.openPanel("files"); + try session.openPanel("diagnostics"); + try session.previousPanel(); + var snap = try session.snapshot(); + try std.testing.expectEqual(@as(usize, 2), snap.panel_depth); + try std.testing.expectEqualStrings("files", snap.active_panel_title.?); + + try session.closePanel(); + snap = try session.snapshot(); + try std.testing.expectEqual(@as(usize, 1), snap.panel_depth); + try std.testing.expectEqualStrings("diagnostics", snap.active_panel_title.?); + + const path = try session.panelPathAlloc(std.testing.allocator); + defer std.testing.allocator.free(path); + try std.testing.expectEqualStrings("[diagnostics]", path); +} + +test "adversarial: session panel operations fail clearly on empty or invalid state" { + var session = Session.init(std.testing.allocator); + defer session.deinit(); + + try std.testing.expectError(error.NoPanelOpen, session.closePanel()); + try std.testing.expectError(error.NoPanelOpen, session.nextPanel()); + try std.testing.expectError(error.InvalidPanelTitle, session.openPanel("bad title")); + const snap = try session.snapshot(); + try std.testing.expectEqual(@as(usize, 0), snap.panel_depth); +} diff --git a/src/socket.zig b/src/socket.zig index b4aa35c..a8d289c 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\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=3 panel_depth=0 active_panel=- panel_path=-\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\n", response); + try std.testing.expectEqualStrings("ok state cursor_byte=3 cursor_cell=2 bytes_len=5 panel_depth=0 active_panel=- panel_path=-\n", response); } test "adversarial: socket transport returns explicit protocol errors" { @@ -223,3 +223,24 @@ test "adversarial: socket path rejects overlong Unix socket paths" { const long_path = "/tmp/this-mim-socket-path-is-intentionally-far-too-long-for-a-portable-unix-domain-socket-address-and-must-fail.sock"; try std.testing.expectError(SocketError.PathTooLong, Server.listen(std.testing.allocator, long_path, &session)); } + +test "regular: local socket panel command exposes inspectable panel state" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + const path = "/tmp/mim-test-panel.sock"; + var server = try Server.listen(std.testing.allocator, path, &session); + defer server.deinit(); + + var thread = try std.Thread.spawn(.{}, Server.acceptOnce, .{&server}); + 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); + + 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); +}