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
+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);
}