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
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 | } | ||