diff options
Diffstat (limited to 'src/ninep/ctl.zig')
| -rw-r--r-- | src/ninep/ctl.zig | 142 |
1 files changed, 121 insertions, 21 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index b4144fd2..2fdc47cc 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -51,7 +51,7 @@ fn noteResult(p: *Pardes, serial: u32) void { /// Runs one look or exec from the pane `id`, then records the panes it /// created; failing that, a look records the pane it focused and an exec /// the pane it acted on (even one it closed). -fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { +pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { const context = p.panes[id].?.serial; // A pane an event reader holds hears its clicks instead of acting on // them (editors/acme/look.c:53 and exec.c:173 send them to winevent), @@ -138,7 +138,14 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply { if (!apply) continue; const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); + p.fs.no_pane_slot = false; run(p, id, exec, text); + // A pane it would have opened had no slot: the write fails, and + // a look reads back nothing. + if (p.fs.no_pane_slot) { + p.fs.results_len = 0; + return tree.failText(req.tag, E.IO, p.fs.ename[0..p.fs.no_pane_slot_len]); + } } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; @@ -259,10 +266,23 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? const sp = std.mem.indexOfAny(u8, line, " \t"); const word = line[0 .. sp orelse line.len]; const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else ""; - const b = std.meta.stringToEnum(Builtin, word) orelse return refuse(p, req, "unknown control message", line); + const b = std.meta.stringToEnum(Builtin, word) orelse { + // A setting this build's frontend cannot show (Lift, GripWidth on a + // terminal) is known, and says so rather than sound misspelt. + if (config.Runtime.find(word) != null) { + var why: [64]u8 = undefined; + return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only", .{word}) catch "GUI-only", line); + } + return refuse(p, req, "unknown control message", line); + }; // Edit is the one pane word the root takes too: at the active pane. if (builtins.registry.scope(b) != scope and b != .Edit) - return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else "pane/<n>/ctl"); + return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) + "/ctl" + else if (b == .Delcol or b == .Joincol) + "col/<serial>/ctl" // a column's own ctl, the one /layout names + else + "pane/<n>/ctl"); if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line); if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line); const setting = config.Runtime.find(word) orelse return null; @@ -281,14 +301,19 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? break :probe probe.apply(setting, if (arg.len > 0) arg else null); }, }; - return if (takes) null else refuse(p, req, "bad value in control message", line); + if (takes) return null; + var why: [104]u8 = undefined; + return refuse(p, req, if (config.Runtime.choices(setting.action)) |values| + std.fmt.bufPrint(&why, "bad value in control message; takes {s}", .{values}) catch "bad value in control message" + else + "bad value in control message", line); } /// Runs a checked control message at pane `id`, as a click on its word /// would. Answers the refusal when it would have opened a prompt for an /// argument, which nobody writing to a file is there to type, or when it /// reported an error: `Mount: AlreadyMounted "Mount peer /tmp/s"`. -fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { +pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { p.fs.no_prompt = true; p.fs.refused = false; p.fs.failure_len = 0; @@ -307,7 +332,10 @@ fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { if (!created) noteResult(p, context); if (p.fs.refused) return refuse(p, req, e_prompt, line); if (p.fs.failure_len == 0) return null; - const refusal = refuse(p, req, p.fs.failure[0..p.fs.failure_len], line); + // An Edit's failure names what failed (the pattern that missed): its + // line, a program, is not quoted after it. + const edit = std.mem.startsWith(u8, line, "Edit ") or std.mem.eql(u8, line, "Edit"); + const refusal = if (edit) tree.failText(req.tag, E.IO, p.fs.failure[0..p.fs.failure_len]) else refuse(p, req, p.fs.failure[0..p.fs.failure_len], line); return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename }; } @@ -320,13 +348,22 @@ const e_prompt = "control message needs its argument"; /// What is not a setting -- platform, themes, where the config came from -- /// is /status's or the Config window's. fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { - const s = &p.settings; + try writeSettingsOf(p, &p.settings, w); + try w.writeAll("LocationsConfig "); + try p.locations_config.write(w); + try w.writeByte('\n'); + if (comptime builtins.Pet.enabled) try w.print("Pet {s}\n", .{@tagName(p.settings.pet)}); +} + +/// The setting words of `s`, one a line, as the root ctl reads them; a Dump +/// keeps those that differ from a fresh session's (dump.zig). +pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer) !void { inline for (config.Runtime.settings) |setting| { if (comptime !setting.enabled(builtins.capabilities)) continue; const word = setting.word; switch (setting.action) { .toggle => |field| try w.print("{s} {s}\n", .{ word, if (@field(s, @tagName(field))) "on" else "off" }), - .theme => try w.print("{s} {s}\n", .{ word, p.theme().name }), + .theme => try w.print("{s} {s}\n", .{ word, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }), .shell => { const chosen = s.shell.requested.get(); try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell }); @@ -347,8 +384,12 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { inline else => |field| try w.print("{s} {s}\n", .{ word, @tagName(@field(s, @tagName(field))) }), }, .message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }), - // Bare, it names the default directory. - .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}), + // The directory in effect, as Config reports it; bare only when + // there is none (no home to put the default in). + .dump_dir => { + var buf: [900]u8 = undefined; + if (pardes.dump.directory(&buf, s.dump_dir.get())) |dir| try w.print("{s} {s}\n", .{ word, dir }) else try w.print("{s}\n", .{word}); + }, .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }), .scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }), .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), @@ -359,10 +400,6 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }), } } - try w.writeAll("LocationsConfig "); - try p.locations_config.write(w); - try w.writeByte('\n'); - if (comptime builtins.Pet.enabled) try w.print("Pet {s}\n", .{@tagName(s.pet)}); } pub fn rootLen(p: *Pardes) u64 { @@ -407,11 +444,20 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { /// itself, so a new builtin is listed the moment it exists. fn writeCommands(w: *std.Io.Writer) !void { for (std.enums.values(Builtin)) |b| { - try w.print("{s}{s} {s}\n", .{ + try w.print("{s}{s} {s}", .{ @tagName(b), if (builtins.registry.takesArg(b)) " arg" else "", if (builtins.registry.scope(b) == .session) "root" else "pane", }); + // A setting that chooses among words: the words, comma-joined. Bare, + // it steps to the next of them (a two-valued one flips). + if (config.Runtime.find(@tagName(b))) |setting| if (config.Runtime.choices(setting.action)) |values| { + try w.writeByte(' '); + var parts = std.mem.splitSequence(u8, values, ", "); + var first = true; + while (parts.next()) |v| : (first = false) try w.print("{s}{s}", .{ if (first) "" else ",", v }); + }; + try w.writeByte('\n'); } } @@ -448,9 +494,12 @@ pub fn writeFocus(p: *Pardes, req: Req) Reply { const text = std.mem.trim(u8, req.data, " \t\r\n"); const serial = std.fmt.parseInt(u32, text, 10) catch return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); const id = p.paneBySerial(serial) orelse return tree.failText(req.tag, E.NOENT, e_no_pane); - // The keyboard goes to the pane itself, off any header that had it. + // The keyboard goes to the pane itself, off any header that had it, and + // its column becomes the active one, as the user's click there would + // make it: pane/new and a look place there next, whatever Placement. tagline.exitHeader(p); p.active = id; + if (pardes.layout.findPane(p, id)) |at| p.setActiveColumn(at.col); return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -516,10 +565,15 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { } else if (std.mem.startsWith(u8, line, "answer ")) { // The pane's question (its `ask` in the log), answered. const choice = std.mem.trim(u8, line["answer ".len..], " "); - if (choice.len != 1) return refuse(p, req, "answer takes one choice, or -", line); const id = p.paneBySerial(serial).?; - if (p.panes[id].?.prompt != .del_side and p.panes[id].?.prompt != .repl_choice) - return refuse(p, req, "no question asked", line); + var offered: [64]u8 = undefined; + const choices = p.questionChoices(id, &offered) orelse return refuse(p, req, "no question asked", line); + // Only a choice the question offers answers it; anything + // else leaves it standing, and says what it takes. + if (!std.mem.eql(u8, choice, "-") and (choice.len != 1 or std.mem.indexOfScalar(u8, choices, choice[0]) == null)) { + var why: [96]u8 = undefined; + return refuse(p, req, std.fmt.bufPrint(&why, "answer takes {s} or -", .{choices}) catch "answer takes a choice or -", line); + } if (apply) _ = p.answerQuestion(id, if (choice[0] == '-') null else choice[0]); } else if (!apply) { if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal; @@ -618,10 +672,11 @@ test "commands lists every builtin with its argument and the ctl that takes it" const b = std.meta.stringToEnum(Builtin, words.next().?).?; if (builtins.registry.takesArg(b)) try testing.expectEqualStrings("arg", words.next().?); try testing.expectEqualStrings(if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?); + if (config.Runtime.find(@tagName(b))) |s| if (config.Runtime.choices(s.action) != null) try testing.expect(words.next() != null); try testing.expect(words.next() == null); } try testing.expectEqual(std.enums.values(Builtin).len, lines); - for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root\n", "\nUndo pane\n", "\nRedo pane\n" }) |row| + for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root on,off\n", "\nPlacement arg root acme,pardes\n", "\nUndo pane\n", "\nRedo pane\n" }) |row| try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null); } @@ -747,7 +802,8 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi try testing.expectEqualStrings("not a session control message \"Del\": write it to pane/<n>/ctl", wr(p, root_ctl, "Del").reply.ename); try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename); - try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename); const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename; try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n"); try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\"")); @@ -1216,3 +1272,47 @@ test "a look after Newcol fills the empty column the keyboard is on" { try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]); try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]); } + +test "a Restore of a file that is no dump fails the write before any warning, and a failed Dump fails its write" { + if (comptime !pardes.hosted) return error.SkipZigTest; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const f = pane_files.fileOf(p.panes[0].?).?; + f.content = try p.gpa.realloc(f.content, 3); + @memcpy(f.content, "yy\n"); + f.revision +%= 1; // unsaved: a Restore would warn about it first + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "notes.txt", .data = "just notes\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line_buf: [4200]u8 = undefined; + const line = try std.fmt.bufPrint(&line_buf, "Restore {s}/notes.txt\n", .{dir}); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + const refused = wr(p, root_ctl, line); + try testing.expectEqual(E.IO, refused.errno()); + try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "not a pardes dump") != null); + try testing.expect(p.restore_req == null); + try testing.expect(p.panes[0].?.discard_warned == null); + + p.fs.late_failure_len = 0; + p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound); + try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: FileNotFound", p.fs.late_failure[0..p.fs.late_failure_len]); +} + +test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + if (!builtins.capabilities.lift) { + try testing.expectEqualStrings("Lift is GUI-only \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename); + try testing.expectEqualStrings("GripWidth is GUI-only \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); + } + try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename); + var buf: [900]u8 = undefined; + if (pardes.dump.directory(&buf, "")) |dir| { + var want: [920]u8 = undefined; + const line = try std.fmt.bufPrint(&want, "\nDumpDir {s}\n", .{dir}); + try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, line) != null); + } +} |
