Add generic session panel stack

This commit is contained in:
slhx agent
2026-06-21 02:32:46 +02:00
parent ce00452a9d
commit 230f8f9129
6 changed files with 369 additions and 21 deletions
+2
View File
@@ -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;
+165
View File
@@ -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);
}
+93 -11
View File
@@ -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);
}
}
+3 -3
View File
@@ -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
\\
;
+83 -5
View File
@@ -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);
}
+23 -2
View File
@@ -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);
}