a73x

ac859fba

feat: the layout file seats every leaf it names, and a bad file is refused with its line

a73x   2026-09-03 05:20

Commit message
feat: the layout file seats every leaf it names, and a bad file is refused with its line

src/client/layout.zig
Old New
@@ -527,8 +527,32 @@ pub const ParsedLayout = struct {
527 /// degrades to the default cut rather than refusing startup — the deliberate 527 /// degrades to the default cut rather than refusing startup — the deliberate
528 /// opposite of the hosts file's strictness about lines the user authored. 528 /// opposite of the hosts file's strictness about lines the user authored.
529 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout { 529 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout {
530 var ignored: []const u8 = "";
531 return parseReporting(alloc, bytes, &ignored);
532 }
533
534 /// The first line with anything but blanks on it, or empty for a file with
535 /// nothing on any line. What to print when the file as a whole is not a
536 /// layout: a leading blank line names nothing a reader could go and fix.
537 pub fn firstNonBlankLine(bytes: []const u8) []const u8 {
538 var it = std.mem.splitScalar(u8, bytes, '\n');
539 while (it.next()) |line| {
540 if (std.mem.trim(u8, line, " \t\r").len != 0) return line;
541 }
542 return bytes[0..0];
543 }
544
545 /// `parse`, and the line it gave up on. `failed` is set to a slice of
546 /// `bytes` — the raw line, indentation and all — so a caller that refuses a
547 /// file can say WHICH line to fix. Malformation is found at four depths
548 /// (the header, the node grammar, a leaf's spelling, and the `focus` index
549 /// checked after the walk), and a caller naming line 1 for all four sends
550 /// the reader to the wrong place.
551 pub fn parseReporting(alloc: std.mem.Allocator, bytes: []const u8, failed: *[]const u8) ?ParsedLayout {
530 var line_iter = std.mem.splitScalar(u8, bytes, '\n'); 552 var line_iter = std.mem.splitScalar(u8, bytes, '\n');
531 553
554 // Until a body line is read, the whole file is what failed.
555 failed.* = firstNonBlankLine(bytes);
532 const header = line_iter.next() orelse return null; 556 const header = line_iter.next() orelse return null;
533 if (!std.mem.eql(u8, header, "mux-layout 1")) return null; 557 if (!std.mem.eql(u8, header, "mux-layout 1")) return null;
534 558
@@ -547,6 +571,9 @@ pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout {
547 571
548 while (line_iter.next()) |raw_line| { 572 while (line_iter.next()) |raw_line| {
549 if (raw_line.len == 0) continue; 573 if (raw_line.len == 0) continue;
574 // Every refusal from here down is this line's, including the ones
575 // raised after the walk: `focus` is the last line a file carries.
576 failed.* = raw_line;
550 577
551 // After a focus line the tree is complete: any further non-empty 578 // After a focus line the tree is complete: any further non-empty
552 // line — node or otherwise — is malformed. 579 // line — node or otherwise — is malformed.
@@ -1246,6 +1273,39 @@ test "parse: every malformation degrades to null, never an error" {
1246 for (bad) |b| try std.testing.expect(parse(alloc, b) == null); 1273 for (bad) |b| try std.testing.expect(parse(alloc, b) == null);
1247 } 1274 }
1248 1275
1276 test "parseReporting: the line it gave up on, at every depth the parse can fail" {
1277 const alloc = std.testing.allocator;
1278 var failed: []const u8 = undefined;
1279
1280 // A leaf whose spelling is empty: the raw line, indentation kept, so a
1281 // reader can find it among siblings that differ only by indent.
1282 const empty_spelling = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 \n";
1283 try std.testing.expect(parseReporting(alloc, empty_spelling, &failed) == null);
1284 try std.testing.expectEqualStrings(" leaf 1 ", failed);
1285
1286 // The 256th leaf, which is the first the id byte cannot number.
1287 var buf = std.ArrayListUnmanaged(u8){};
1288 defer buf.deinit(alloc);
1289 try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
1290 for (0..256) |i| try buf.writer(alloc).print(" leaf 1 --sock /a#s{d}\n", .{i});
1291 try std.testing.expect(parseReporting(alloc, buf.items, &failed) == null);
1292 try std.testing.expectEqualStrings(" leaf 1 --sock /a#s255", failed);
1293
1294 // A focus index past the last leaf is caught after the walk, and the
1295 // focus line is still the line to fix.
1296 try std.testing.expect(parseReporting(alloc, "mux-layout 1\nleaf 0 x\nfocus 9\n", &failed) == null);
1297 try std.testing.expectEqualStrings("focus 9", failed);
1298
1299 // Not a layout at all: the first line with anything on it, so a file
1300 // that opens with a blank line still names something.
1301 try std.testing.expect(parseReporting(alloc, "\nnot a layout\n", &failed) == null);
1302 try std.testing.expectEqualStrings("not a layout", failed);
1303
1304 // Nothing on any line: there is no line to name.
1305 try std.testing.expect(parseReporting(alloc, "", &failed) == null);
1306 try std.testing.expectEqual(@as(usize, 0), failed.len);
1307 }
1308
1249 test "remapLeaves: null removes, containers collapse, ids rewrite in one pass" { 1309 test "remapLeaves: null removes, containers collapse, ids rewrite in one pass" {
1250 const alloc = std.testing.allocator; 1310 const alloc = std.testing.allocator;
1251 var t = Tree.init(alloc); 1311 var t = Tree.init(alloc);
src/tui/wall_layout.zig
Old New
@@ -1,7 +1,7 @@
1 //! The pane tree's operations and the layout sidecar. `relayout` is the 1 //! The pane tree's operations and the layout sidecar. `relayout` is the
2 //! single flatten point that turns the tree into tile rects and paints the 2 //! single flatten point that turns the tree into tile rects and paints the
3 //! rails; the sidecar saves that tree on the last detach and heals it back 3 //! rails; the sidecar saves that tree on the last detach and `seedLayout`
4 //! per leaf against the hosts' live lists. 4 //! seats it back verbatim, refusing a file it cannot seat whole.
5 const std = @import("std"); 5 const std = @import("std");
6 const proto = @import("term").protocol; 6 const proto = @import("term").protocol;
7 const hosts = @import("client").hosts; 7 const hosts = @import("client").hosts;
@@ -193,7 +193,17 @@ pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Share
193 defer alloc.free(path); 193 defer alloc.free(path);
194 const bytes = loadLayout(alloc, path) orelse return null; 194 const bytes = loadLayout(alloc, path) orelse return null;
195 defer alloc.free(bytes); 195 defer alloc.free(bytes);
196 return seedLayout(alloc, table, shared, bytes, entry_spelling); 196 return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) {
197 .plan => |p| p,
198 .refused => |line| blk: {
199 // Said once, on stderr, before the alternate screen: the wall
200 // then starts as if the file were missing, and the line is the
201 // thing to fix or delete.
202 std.debug.print("mux: layout ignored ({s}): {s}\n", .{ path, line });
203 break :blk null;
204 },
205 .none => null,
206 };
197 } 207 }
198 208
199 /// Every failure is the same null: a caller degrades the same way whatever 209 /// Every failure is the same null: a caller degrades the same way whatever
@@ -254,6 +264,10 @@ const SeedKeep = struct { saved: usize, host: usize };
254 pub const SeedPlan = struct { 264 pub const SeedPlan = struct {
255 panes: []?SeedPane, 265 panes: []?SeedPane,
256 focus: ?usize, 266 focus: ?usize,
267 /// Leaves the file named that this wall does not seat: the session
268 /// this `mux` runs inside, and whatever the terminal was too small to
269 /// cut. Every OTHER disagreement with the file is a refusal, so this
270 /// counts only what the wall chose to leave out.
257 dropped: usize, 271 dropped: usize,
258 272
259 pub fn deinit(self: *SeedPlan, alloc: std.mem.Allocator) void { 273 pub fn deinit(self: *SeedPlan, alloc: std.mem.Allocator) void {
@@ -267,17 +281,39 @@ pub const SeedPlan = struct {
267 } 281 }
268 }; 282 };
269 283
270 /// The sidecar's cut before any host answers; every failure degrades 284 pub const SeedResult = union(enum) {
271 /// silently, leaf by leaf or file whole, to the default cut. 285 plan: SeedPlan,
286 /// The file is not a wall: the first offending line, borrowed from
287 /// `bytes`, for the caller to print. The wall then starts as if the
288 /// file were missing.
289 refused: []const u8,
290 /// No file, or a file with no leaves this wall can seat at this size.
291 none,
292 };
293
294 /// The layout is authored: every leaf is a pane, in the saved tree. The
295 /// only things that keep a leaf off the wall are the session this `mux`
296 /// runs inside (a wall may not attach to itself) and a terminal too small
297 /// for the whole tree, which trims from the end. Anything else wrong with
298 /// the file — a host the hosts file does not list, a leaf with no session
299 /// or a bad name, a repeated leaf, more leaves than the wall seats, or
300 /// text that is not a layout — refuses the FILE, with the first bad line,
301 /// because silently seating part of a wall is how a user loses one.
272 pub fn seedLayout( 302 pub fn seedLayout(
273 alloc: std.mem.Allocator, 303 alloc: std.mem.Allocator,
274 table: []const Host, 304 table: []const Host,
275 shared: *Shared, 305 shared: *Shared,
276 bytes: []const u8, 306 bytes: []const u8,
277 entry_spelling: ?[]const u8, 307 entry_spelling: ?[]const u8,
278 ) ?SeedPlan { 308 ) SeedResult {
279 // Pass 1: read the spellings once and give each leaf its fate. 309 // An empty file is not a bad file: nothing was authored, so there is
280 var probe = layout.parse(alloc, bytes) orelse return null; 310 // nothing to name in a refusal and nothing for the user to fix.
311 if (std.mem.trim(u8, bytes, " \t\r\n").len == 0) return .none;
312 // The line the parse gave up on, not the header: a file can be a wall
313 // down to its last line and still be refused by it.
314 var bad_line: []const u8 = "";
315 var probe = layout.parseReporting(alloc, bytes, &bad_line) orelse return .{ .refused = bad_line };
316 defer probe.deinit(alloc);
281 var keeps = std.ArrayListUnmanaged(SeedKeep){}; 317 var keeps = std.ArrayListUnmanaged(SeedKeep){};
282 defer keeps.deinit(alloc); 318 defer keeps.deinit(alloc);
283 var entry_at: ?usize = null; 319 var entry_at: ?usize = null;
@@ -288,24 +324,18 @@ pub fn seedLayout(
288 entry_at = i; 324 entry_at = i;
289 continue; 325 continue;
290 } 326 }
327 // A second leaf spelling the entry is a repeated leaf like any
328 // other: only the first can be the tile the user is typing in.
329 if (std.mem.eql(u8, sp, es)) return .{ .refused = inFile(bytes, sp) };
291 } 330 }
292 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse { 331 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return .{ .refused = inFile(bytes, sp) };
293 dropped += 1;
294 continue;
295 };
296 const sess = sp[cut + 1 ..]; 332 const sess = sp[cut + 1 ..];
297 if (!proto.validSessionName(sess)) { 333 if (!proto.validSessionName(sess)) return .{ .refused = inFile(bytes, sp) };
298 dropped += 1;
299 continue;
300 }
301 // The host part must be ON the wall, byte for byte: the file is a 334 // The host part must be ON the wall, byte for byte: the file is a
302 // match key, never an address, so nothing here can be resurrected. 335 // match key, never an address, so nothing here can be resurrected.
303 const hi = for (table, 0..) |*h, j| { 336 const hi = for (table, 0..) |*h, j| {
304 if (std.mem.eql(u8, h.spec.spelling, sp[0..cut])) break j; 337 if (std.mem.eql(u8, h.spec.spelling, sp[0..cut])) break j;
305 } else { 338 } else return .{ .refused = inFile(bytes, sp) };
306 dropped += 1;
307 continue;
308 };
309 // The shell's own session would wait forever: never birthed 339 // The shell's own session would wait forever: never birthed
310 // (`showsSelf`) and named by every list, so never collapsed. 340 // (`showsSelf`) and named by every list, so never collapsed.
311 if (table[hi].self_name) |self| { 341 if (table[hi].self_name) |self| {
@@ -316,43 +346,39 @@ pub fn seedLayout(
316 } 346 }
317 // A host's list names a session once; a second pane on the same 347 // A host's list names a session once; a second pane on the same
318 // (host, session) could never bind. 348 // (host, session) could never bind.
319 const dup = blk: { 349 for (keeps.items) |k| {
320 if (entry_spelling) |es| { 350 if (std.mem.eql(u8, probe.spellings.items[k.saved], sp)) return .{ .refused = inFile(bytes, sp) };
321 if (std.mem.eql(u8, sp, es)) break :blk true;
322 }
323 for (keeps.items) |k| {
324 if (std.mem.eql(u8, probe.spellings.items[k.saved], sp)) break :blk true;
325 }
326 break :blk false;
327 };
328 if (dup) {
329 dropped += 1;
330 continue;
331 } 351 }
332 keeps.append(alloc, .{ .saved = i, .host = hi }) catch { 352 keeps.append(alloc, .{ .saved = i, .host = hi }) catch return .none;
333 probe.deinit(alloc);
334 return null;
335 };
336 } 353 }
337 const base: usize = if (entry_spelling != null) 1 else 0; 354 const base: usize = if (entry_spelling != null) 1 else 0;
338 while (base + keeps.items.len > wv.max_tiles) { 355 if (base + keeps.items.len > wv.max_tiles)
339 _ = keeps.pop(); 356 return .{ .refused = inFile(bytes, probe.spellings.items[keeps.items[wv.max_tiles - base].saved]) };
340 dropped += 1; 357 if (keeps.items.len == 0 and entry_at == null) return .none;
341 }
342 probe.deinit(alloc);
343 if (keeps.items.len == 0) return null;
344 // A tree cut on a big screen must not refuse the boot on a laptop: 358 // A tree cut on a big screen must not refuse the boot on a laptop:
345 // drop the last pane and retry until the terminal can hold what is 359 // drop the last pane and retry until the terminal can hold what is
346 // left, or nothing is left and today's default cut runs. 360 // left. The floor is a wall of ONE — the entry tile's leaf when the
361 // file named it, the first saved pane otherwise — because an empty
362 // tree is not a wall, and seating none of them is the default cut.
363 const floor: usize = if (entry_at != null) 0 else 1;
347 var n = keeps.items.len; 364 var n = keeps.items.len;
348 while (n >= 1) : (n -= 1) { 365 while (n >= floor) : (n -= 1) {
349 if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| { 366 if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| {
350 var out = plan; 367 var out = plan;
351 out.dropped = dropped + (keeps.items.len - n); 368 out.dropped = dropped + (keeps.items.len - n);
352 return out; 369 return .{ .plan = out };
353 } 370 }
371 if (n == 0) break;
354 } 372 }
355 return null; 373 return .none;
374 }
375
376 /// The same text, in the FILE's own bytes. A refusal outlives the parse
377 /// that found it — `probe` owns its spellings and frees them on the way
378 /// out — while `bytes` belongs to the caller that is about to print.
379 fn inFile(bytes: []const u8, spelling: []const u8) []const u8 {
380 const at = std.mem.indexOf(u8, bytes, spelling) orelse return layout.firstNonBlankLine(bytes);
381 return bytes[at..][0..spelling.len];
356 } 382 }
357 383
358 /// One sizing attempt over the first `keeps` panes; the tree is 384 /// One sizing attempt over the first `keeps` panes; the tree is
src/tui/wall_test_layout.zig
Old New
@@ -8,6 +8,7 @@ const fixture = @import("wall_test_harness.zig");
8 const wall_host = @import("wall_host.zig"); 8 const wall_host = @import("wall_host.zig");
9 const wall_layout = @import("wall_layout.zig"); 9 const wall_layout = @import("wall_layout.zig");
10 const wv = @import("wallview.zig"); 10 const wv = @import("wallview.zig");
11 const Host = wall_host.Host;
11 const ResizeWitness = fixture.ResizeWitness; 12 const ResizeWitness = fixture.ResizeWitness;
12 const Resolved = wall_host.Resolved; 13 const Resolved = wall_host.Resolved;
13 const Shared = wv.Shared; 14 const Shared = wv.Shared;
@@ -795,9 +796,10 @@ test "seed: the saved cut is the tree before any host has answered - orientation
795 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"), 796 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
796 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"), 797 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
797 }; 798 };
798 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse 799 var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
799 return error.SeedRefusedAValidSidecar; 800 defer if (res == .plan) res.plan.deinit(alloc);
800 defer plan.deinit(alloc); 801 try std.testing.expect(res == .plan);
802 const plan = res.plan;
801 try std.testing.expectEqual(@as(usize, 3), plan.panes.len); 803 try std.testing.expectEqual(@as(usize, 3), plan.panes.len);
802 try std.testing.expectEqual(@as(usize, 0), plan.dropped); 804 try std.testing.expectEqual(@as(usize, 0), plan.dropped);
803 try std.testing.expectEqual(@as(usize, 0), plan.panes[0].?.host); 805 try std.testing.expectEqual(@as(usize, 0), plan.panes[0].?.host);
@@ -816,56 +818,6 @@ test "seed: the saved cut is the tree before any host has answered - orientation
816 try std.testing.expect(r0.cols > 2 * r1.cols); 818 try std.testing.expect(r0.cols > 2 * r1.cols);
817 } 819 }
818 820
819 test "seed: a leaf whose host is not on the wall is dropped and the rest keep their cut" {
820 const alloc = std.testing.allocator;
821 var shared: Shared = undefined;
822 seedShared(alloc, &shared);
823 defer shared.tree.deinit();
824 var table = [_]wall_host.Host{
825 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
826 };
827 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse
828 return error.SeedRefusedAValidSidecar;
829 defer plan.deinit(alloc);
830 try std.testing.expectEqual(@as(usize, 2), plan.panes.len);
831 try std.testing.expectEqual(@as(usize, 1), plan.dropped);
832 try std.testing.expectEqualStrings("--sock /tmp/h0.sock#a", plan.panes[0].?.label);
833 try std.testing.expectEqualStrings("--sock /tmp/h0.sock#b", plan.panes[1].?.label);
834 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
835 }
836
837 test "seed: a leaf naming no session, or a name the wire grammar refuses, is dropped" {
838 const alloc = std.testing.allocator;
839 var shared: Shared = undefined;
840 seedShared(alloc, &shared);
841 defer shared.tree.deinit();
842 var table = [_]wall_host.Host{
843 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
844 };
845 const bytes = "mux-layout 1\nstacked 0\n leaf 10 --sock /tmp/h0.sock#a\n leaf 10 no-hash-at-all\n leaf 10 --sock /tmp/h0.sock#ha sh\n";
846 var plan = wall_layout.seedLayout(alloc, &table, &shared, bytes, null) orelse
847 return error.SeedRefusedAValidSidecar;
848 defer plan.deinit(alloc);
849 try std.testing.expectEqual(@as(usize, 1), plan.panes.len);
850 try std.testing.expectEqual(@as(usize, 2), plan.dropped);
851 }
852
853 test "seed: two leaves naming one host's one session become one pane" {
854 const alloc = std.testing.allocator;
855 var shared: Shared = undefined;
856 seedShared(alloc, &shared);
857 defer shared.tree.deinit();
858 var table = [_]wall_host.Host{
859 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
860 };
861 const bytes = "mux-layout 1\nstacked 0\n leaf 10 --sock /tmp/h0.sock#a\n leaf 10 --sock /tmp/h0.sock#a\n";
862 var plan = wall_layout.seedLayout(alloc, &table, &shared, bytes, null) orelse
863 return error.SeedRefusedAValidSidecar;
864 defer plan.deinit(alloc);
865 try std.testing.expectEqual(@as(usize, 1), plan.panes.len);
866 try std.testing.expectEqual(@as(usize, 1), plan.dropped);
867 }
868
869 test "seed: the session this shell is standing in is never seeded" { 821 test "seed: the session this shell is standing in is never seeded" {
870 const alloc = std.testing.allocator; 822 const alloc = std.testing.allocator;
871 var shared: Shared = undefined; 823 var shared: Shared = undefined;
@@ -876,9 +828,12 @@ test "seed: the session this shell is standing in is never seeded" {
876 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"), 828 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
877 }; 829 };
878 table[0].self_name = "a"; 830 table[0].self_name = "a";
879 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse 831 // The one leaf a good file can name and this wall still not seat: it
880 return error.SeedRefusedAValidSidecar; 832 // is a drop, counted, and not a refusal of the file around it.
881 defer plan.deinit(alloc); 833 var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
834 defer if (res == .plan) res.plan.deinit(alloc);
835 try std.testing.expect(res == .plan);
836 const plan = res.plan;
882 try std.testing.expectEqual(@as(usize, 2), plan.panes.len); 837 try std.testing.expectEqual(@as(usize, 2), plan.panes.len);
883 try std.testing.expectEqual(@as(usize, 1), plan.dropped); 838 try std.testing.expectEqual(@as(usize, 1), plan.dropped);
884 // Host 1's aliasing "a" is another host's session and stays. 839 // Host 1's aliasing "a" is another host's session and stays.
@@ -886,7 +841,7 @@ test "seed: the session this shell is standing in is never seeded" {
886 try std.testing.expectEqualStrings("--sock /tmp/h1.sock#a", plan.panes[1].?.label); 841 try std.testing.expectEqualStrings("--sock /tmp/h1.sock#a", plan.panes[1].?.label);
887 } 842 }
888 843
889 test "seed: garbage, an empty file and a wall with no matching host all leave the tree exactly as it was" { 844 test "seed: garbage and a leaf no host on this wall can seat refuse the file; an empty file is silence - and the tree survives all three" {
890 const alloc = std.testing.allocator; 845 const alloc = std.testing.allocator;
891 var shared: Shared = undefined; 846 var shared: Shared = undefined;
892 seedShared(alloc, &shared); 847 seedShared(alloc, &shared);
@@ -895,14 +850,18 @@ test "seed: garbage, an empty file and a wall with no matching host all leave th
895 var table = [_]wall_host.Host{ 850 var table = [_]wall_host.Host{
896 fixture.testHost(&shared, "--sock /tmp/other.sock", "/tmp/other.sock"), 851 fixture.testHost(&shared, "--sock /tmp/other.sock", "/tmp/other.sock"),
897 }; 852 };
898 const cases = [_][]const u8{ "not a layout at all", "", seed_bytes }; 853 // Text that is not a layout is named by its first line; a layout whose
899 for (cases) |bytes| { 854 // first leaf names a host this wall does not have is named by that leaf.
900 try std.testing.expectEqual( 855 const junk = wall_layout.seedLayout(alloc, &table, &shared, "not a layout at all", null);
901 @as(?wall_layout.SeedPlan, null), 856 try std.testing.expect(junk == .refused);
902 wall_layout.seedLayout(alloc, &table, &shared, bytes, null), 857 try std.testing.expectEqualStrings("not a layout at all", junk.refused);
903 ); 858 const unseatable = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
904 try std.testing.expectEqual(@as(usize, 1), shared.tree.count()); 859 try std.testing.expect(unseatable == .refused);
905 } 860 try std.testing.expectEqualStrings("--sock /tmp/h0.sock#a", unseatable.refused);
861 // An empty file was never authored: there is no line to print and
862 // nothing for the user to fix, so it degrades in silence.
863 try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, "", null) == .none);
864 try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
906 } 865 }
907 866
908 test "seed: more leaves than the terminal can cut seeds what fits and counts the rest" { 867 test "seed: more leaves than the terminal can cut seeds what fits and counts the rest" {
@@ -924,19 +883,19 @@ test "seed: more leaves than the terminal can cut seeds what fits and counts the
924 defer buf.deinit(alloc); 883 defer buf.deinit(alloc);
925 try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n"); 884 try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
926 for (0..9) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i}); 885 for (0..9) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
927 var plan = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null) orelse 886 var res = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null);
928 return error.SeedRefusedAFittableSidecar; 887 defer if (res == .plan) res.plan.deinit(alloc);
929 defer plan.deinit(alloc); 888 try std.testing.expect(res == .plan);
930 try std.testing.expectEqual(@as(usize, 4), plan.panes.len); 889 try std.testing.expectEqual(@as(usize, 4), res.plan.panes.len);
931 try std.testing.expectEqual(@as(usize, 5), plan.dropped); 890 try std.testing.expectEqual(@as(usize, 5), res.plan.dropped);
932 try std.testing.expectEqual(@as(usize, 4), shared.tree.count()); 891 try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
933 } 892 }
934 893
935 test "seed: more leaves than max_tiles seeds max_tiles" { 894 test "seed: more leaves than the wall seats refuses the file, naming the first leaf past the cap" {
936 const alloc = std.testing.allocator; 895 const alloc = std.testing.allocator;
937 var shared: Shared = undefined; 896 var shared: Shared = undefined;
938 seedShared(alloc, &shared); 897 seedShared(alloc, &shared);
939 // Tall enough that every kept pane fits: the cap under test is the 898 // Tall enough that every leaf would fit: the cap under test is the
940 // wall's, not the terminal's. 899 // wall's, not the terminal's.
941 shared.size = .{ .cols = 100, .rows = 500 }; 900 shared.size = .{ .cols = 100, .rows = 500 };
942 defer shared.tree.deinit(); 901 defer shared.tree.deinit();
@@ -947,11 +906,14 @@ test "seed: more leaves than max_tiles seeds max_tiles" {
947 defer buf.deinit(alloc); 906 defer buf.deinit(alloc);
948 try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n"); 907 try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
949 for (0..40) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i}); 908 for (0..40) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
950 var plan = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null) orelse 909 const res = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null);
951 return error.SeedRefusedAFittableSidecar; 910 try std.testing.expect(res == .refused);
952 defer plan.deinit(alloc); 911 var want_buf: [64]u8 = undefined;
953 try std.testing.expectEqual(wv.max_tiles, plan.panes.len); 912 const want = try std.fmt.bufPrint(&want_buf, "--sock /tmp/h0.sock#s{d}", .{wv.max_tiles});
954 try std.testing.expectEqual(@as(usize, 8), plan.dropped); 913 try std.testing.expectEqualStrings(want, res.refused);
914 // Seating the first 32 of a 40-pane wall is not the wall that was
915 // saved, so nothing is seated at all.
916 try std.testing.expectEqual(@as(usize, 0), shared.tree.count());
955 } 917 }
956 918
957 test "seed: the saved focus comes back as the pane that carries it, and is null when that leaf was dropped" { 919 test "seed: the saved focus comes back as the pane that carries it, and is null when that leaf was dropped" {
@@ -963,21 +925,19 @@ test "seed: the saved focus comes back as the pane that carries it, and is null
963 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"), 925 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
964 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"), 926 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
965 }; 927 };
966 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse 928 var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
967 return error.SeedRefusedAValidSidecar; 929 try std.testing.expect(res == .plan);
968 try std.testing.expectEqual(@as(?usize, 1), plan.focus); 930 try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
969 plan.deinit(alloc); 931 res.plan.deinit(alloc);
970 // The focused leaf (saved index 1, host 0's "b") dropped with its host 932 // The focused leaf (saved index 1, host 0's "b") is the session this
971 // gone from the wall: nothing carries the focus record. 933 // shell is standing in, so it is dropped: nothing carries the record.
972 shared.tree.deinit(); 934 shared.tree.deinit();
973 shared.tree = layout.Tree.init(alloc); 935 shared.tree = layout.Tree.init(alloc);
974 var one = [_]wall_host.Host{ 936 table[0].self_name = "b";
975 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"), 937 var res2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
976 }; 938 defer if (res2 == .plan) res2.plan.deinit(alloc);
977 var plan2 = wall_layout.seedLayout(alloc, &one, &shared, seed_bytes, null) orelse 939 try std.testing.expect(res2 == .plan);
978 return error.SeedRefusedAValidSidecar; 940 try std.testing.expectEqual(@as(?usize, null), res2.plan.focus);
979 defer plan2.deinit(alloc);
980 try std.testing.expectEqual(@as(?usize, null), plan2.focus);
981 } 941 }
982 942
983 test "seed: the entry tile's own pane is not pending; a sidecar that does not know it inserts it" { 943 test "seed: the entry tile's own pane is not pending; a sidecar that does not know it inserts it" {
@@ -991,22 +951,22 @@ test "seed: the entry tile's own pane is not pending; a sidecar that does not kn
991 }; 951 };
992 // Matched: the entry stands where its saved pane was, id 0, not 952 // Matched: the entry stands where its saved pane was, id 0, not
993 // pending, and no second pane claims its (host, session). 953 // pending, and no second pane claims its (host, session).
994 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h0.sock#a") orelse 954 var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h0.sock#a");
995 return error.SeedRefusedAValidSidecar; 955 try std.testing.expect(res == .plan);
996 try std.testing.expectEqual(@as(usize, 3), plan.panes.len); 956 try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
997 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan.panes[0]); 957 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), res.plan.panes[0]);
998 try std.testing.expect(plan.panes[1] != null and plan.panes[2] != null); 958 try std.testing.expect(res.plan.panes[1] != null and res.plan.panes[2] != null);
999 try std.testing.expectEqual(@as(usize, 3), shared.tree.count()); 959 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
1000 plan.deinit(alloc); 960 res.plan.deinit(alloc);
1001 // Unmatched: the sidecar predates this entry; its pane is inserted 961 // Unmatched: the sidecar predates this entry; its pane is inserted
1002 // beside the saved focus, and every saved leaf is still a pane. 962 // beside the saved focus, and every saved leaf is still a pane.
1003 shared.tree.deinit(); 963 shared.tree.deinit();
1004 shared.tree = layout.Tree.init(alloc); 964 shared.tree = layout.Tree.init(alloc);
1005 var plan2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h9.sock#z") orelse 965 var res2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h9.sock#z");
1006 return error.SeedRefusedAValidSidecar; 966 defer if (res2 == .plan) res2.plan.deinit(alloc);
1007 defer plan2.deinit(alloc); 967 try std.testing.expect(res2 == .plan);
1008 try std.testing.expectEqual(@as(usize, 4), plan2.panes.len); 968 try std.testing.expectEqual(@as(usize, 4), res2.plan.panes.len);
1009 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan2.panes[0]); 969 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), res2.plan.panes[0]);
1010 try std.testing.expectEqual(@as(usize, 4), shared.tree.count()); 970 try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
1011 } 971 }
1012 972
@@ -1047,3 +1007,160 @@ test "a wall left before its hosts answered saves the shape it was given" {
1047 try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#a") != null); 1007 try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#a") != null);
1048 try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#b") != null); 1008 try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#b") != null);
1049 } 1009 }
1010
1011 test "seedLayout: every leaf of a good file is a pane, in the file's tree, and nothing else is consulted" {
1012 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1013 defer shared.tree.deinit();
1014 var table = [_]Host{
1015 fixture.testHost(&shared, "--sock /a", "/a"),
1016 fixture.testHost(&shared, "box", "/b"),
1017 };
1018 // No poll answer on either host: the file alone decides.
1019 const file =
1020 \\mux-layout 1
1021 \\beside 0
1022 \\ leaf 1 --sock /a#0
1023 \\ leaf 1 box#work
1024 \\ leaf 1 --sock /a#2
1025 \\focus 1
1026 \\
1027 ;
1028 var res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
1029 defer if (res == .plan) res.plan.deinit(std.testing.allocator);
1030 try std.testing.expect(res == .plan);
1031 try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
1032 try std.testing.expectEqualStrings("0", res.plan.panes[0].?.session);
1033 try std.testing.expectEqual(@as(usize, 0), res.plan.panes[0].?.host);
1034 try std.testing.expectEqualStrings("work", res.plan.panes[1].?.session);
1035 try std.testing.expectEqual(@as(usize, 1), res.plan.panes[1].?.host);
1036 try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
1037 try std.testing.expectEqual(@as(usize, 0), res.plan.dropped);
1038 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
1039 }
1040
1041 test "seedLayout: a leaf whose host is not in the hosts file refuses the whole file and names the line" {
1042 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1043 defer shared.tree.deinit();
1044 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1045 const file =
1046 \\mux-layout 1
1047 \\beside 0
1048 \\ leaf 1 --sock /a#0
1049 \\ leaf 1 nowhere#0
1050 \\
1051 ;
1052 const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
1053 try std.testing.expect(res == .refused);
1054 try std.testing.expectEqualStrings("nowhere#0", res.refused);
1055 // Nothing was seated: the caller starts as if the file were missing.
1056 try std.testing.expect(shared.tree.root == null);
1057 }
1058
1059 test "seedLayout: a leaf with no session, a bad name, or a repeat refuses; garbage refuses with its first line" {
1060 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1061 defer shared.tree.deinit();
1062 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1063 const no_session = "mux-layout 1\nleaf 0 --sock /a\n";
1064 const bad_name = "mux-layout 1\nleaf 0 --sock /a#no space\n";
1065 const repeat = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#0\n";
1066 const garbage = "not a layout\n";
1067 for ([_][]const u8{ no_session, bad_name, repeat, garbage }) |file| {
1068 const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
1069 try std.testing.expect(res == .refused);
1070 try std.testing.expect(res.refused.len > 0);
1071 }
1072 const on_repeat = wall_layout.seedLayout(std.testing.allocator, &table, &shared, repeat, null);
1073 try std.testing.expect(on_repeat == .refused);
1074 try std.testing.expectEqualStrings("--sock /a#0", on_repeat.refused);
1075 const on_garbage = wall_layout.seedLayout(std.testing.allocator, &table, &shared, garbage, null);
1076 try std.testing.expect(on_garbage == .refused);
1077 try std.testing.expectEqualStrings("not a layout", on_garbage.refused);
1078 }
1079
1080 test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" {
1081 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1082 defer shared.tree.deinit();
1083 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1084 const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\nfocus 1\n";
1085 var has = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
1086 defer if (has == .plan) has.plan.deinit(std.testing.allocator);
1087 try std.testing.expect(has == .plan);
1088 // The entry is pane 0 by contract; the other leaf follows.
1089 try std.testing.expect(has.plan.panes[0] == null); // the entry tile is the caller's
1090 try std.testing.expectEqualStrings("0", has.plan.panes[1].?.session);
1091 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
1092
1093 shared.tree.deinit();
1094 shared.tree = layout.Tree.init(std.testing.allocator);
1095 var not = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#9");
1096 defer if (not == .plan) not.plan.deinit(std.testing.allocator);
1097 try std.testing.expect(not == .plan);
1098 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
1099 }
1100
1101 test "seedLayout: a file that spells the entry tile twice is a repeat like any other, and refuses" {
1102 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1103 defer shared.tree.deinit();
1104 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1105 // Only the FIRST leaf can be the tile the user is already typing in;
1106 // seating the second would put two panes on one session.
1107 const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#1\n leaf 1 --sock /a#1\n";
1108 const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
1109 try std.testing.expect(res == .refused);
1110 try std.testing.expectEqualStrings("--sock /a#1", res.refused);
1111 try std.testing.expect(shared.tree.root == null);
1112 }
1113
1114 test "seedLayout: a file good down to its last line is refused BY that line, not by its header" {
1115 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1116 defer shared.tree.deinit();
1117 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1118 // `focus 9` names a leaf this file does not have, and the parse only
1119 // finds it after the walk. A reader told "mux-layout 1" would go and
1120 // fix the one line that is correct.
1121 const file = "mux-layout 1\nleaf 0 --sock /a#0\nfocus 9\n";
1122 const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
1123 try std.testing.expect(res == .refused);
1124 try std.testing.expectEqualStrings("focus 9", res.refused);
1125 }
1126
1127 test "seedLayout: a terminal too small for even one saved pane leaves the wall's own tree alone" {
1128 const alloc = std.testing.allocator;
1129 var shared: Shared = undefined;
1130 seedShared(alloc, &shared);
1131 // A tty, as every restoring wall is, so the label row counts toward
1132 // each pane's floor: two rows cannot hold ONE pane, let alone two.
1133 shared.is_tty = true;
1134 shared.size = .{ .cols = 100, .rows = 2 };
1135 defer shared.tree.deinit();
1136 // Something for the seed to lose. A trim that ran out of panes and
1137 // installed the empty remainder would leave a count of 0 here - which
1138 // an untouched fresh wall also has, so the pin needs a tree first.
1139 try shared.tree.addFirst(7);
1140 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1141 const file = "mux-layout 1\nstacked 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\n";
1142 const res = wall_layout.seedLayout(alloc, &table, &shared, file, null);
1143 try std.testing.expect(res == .none);
1144 try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
1145 }
1146
1147 test "seedLayout: a terminal that holds one pane keeps the entry tile when the file's other leaves will not fit" {
1148 const alloc = std.testing.allocator;
1149 var shared: Shared = undefined;
1150 seedShared(alloc, &shared);
1151 // Three rows under a label bar: one pane clears the daemon's floor,
1152 // two do not, so the trim runs down to the entry tile's own leaf.
1153 shared.is_tty = true;
1154 shared.size = .{ .cols = 100, .rows = 3 };
1155 defer shared.tree.deinit();
1156 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1157 const file = "mux-layout 1\nstacked 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\n";
1158 var res = wall_layout.seedLayout(alloc, &table, &shared, file, "--sock /a#0");
1159 defer if (res == .plan) res.plan.deinit(alloc);
1160 try std.testing.expect(res == .plan);
1161 try std.testing.expectEqual(@as(usize, 1), res.plan.panes.len);
1162 // The one pane is the entry tile's slot, which the caller fills.
1163 try std.testing.expect(res.plan.panes[0] == null);
1164 try std.testing.expectEqual(@as(usize, 1), res.plan.dropped);
1165 try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
1166 }