summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig19
-rw-r--r--src/File.zig53
-rw-r--r--src/Messages.zig53
-rw-r--r--src/Output.zig4
-rw-r--r--src/builtins.zig265
-rw-r--r--src/config.zig125
-rw-r--r--src/detached/server.zig22
-rw-r--r--src/dump.zig36
-rw-r--r--src/exec.zig168
-rw-r--r--src/file_watch.zig58
-rw-r--r--src/fs-help.txt38
-rw-r--r--src/fs.zig23
-rw-r--r--src/gui/gui.zig8
-rw-r--r--src/host_io.zig76
-rw-r--r--src/layout.zig201
-rw-r--r--src/look.zig174
-rw-r--r--src/modal.zig52
-rw-r--r--src/mouse.zig87
-rw-r--r--src/ninep/addr.zig188
-rw-r--r--src/ninep/cols.zig124
-rw-r--r--src/ninep/ctl.zig847
-rw-r--r--src/ninep/events.zig322
-rw-r--r--src/ninep/pane.zig146
-rw-r--r--src/ninep/pty.zig49
-rw-r--r--src/ninep/tree.zig309
-rw-r--r--src/pardes.zig256
-rw-r--r--src/regexp.zig17
-rw-r--r--src/sam_edit.zig59
-rw-r--r--src/tty/tty.zig7
29 files changed, 3343 insertions, 443 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 0f5e0a77..2130db27 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -255,7 +255,7 @@ pub const Listener = struct {
const refused = pardes.ctlfs.Reply.fail(req.tag, pardes.ctlfs.E.IO);
return conn.reply(&refused, "");
}
- if (!quiet and pardes.ctlfs.needsQuiet(req)) {
+ if (!quiet and pardes.ctlfs.needsQuiet(l.core, req)) {
pardes.turn.parked = true;
const later: pardes.ctlfs.Reply = .{ .tag = req.tag, .status = .again };
return conn.reply(&later, "");
@@ -263,6 +263,8 @@ pub const Listener = struct {
const core = l.core;
const epoch = pardes.turn.epoch;
const restores = pardes.turn.restores;
+ // Asked first: a release that runs a held line has none after.
+ const changes = pardes.ctlfs.changesPane(core, req);
const reply = core.serveFs(req);
if (req.op == .release and quiet) l.collectOs();
// A read with nothing yet stays parked in the engine, and the core
@@ -289,7 +291,7 @@ pub const Listener = struct {
o.held = .{ .asker = conn, .req = req, .ticket = ticket };
return;
}
- if (!pardes.ctlfs.changesPane(req)) return conn.reply(&reply, core.fsPayload(reply));
+ if (!changes) return conn.reply(&reply, core.fsPayload(reply));
// The editor draws the change and performs what it asked for; when
// it asked for something -- a save, a shell, a watch -- the answer
// waits until that is done, so `echo Save > exec` returns with the
@@ -314,6 +316,9 @@ pub const Listener = struct {
// settle in the same wait is told to this write too.
if (l.core == core and core.fs.late_failure_len != 0) {
const failed = pardes.ctlfs.failText(req.tag, pardes.ctlfs.E.IO, core.fs.late_failure[0..core.fs.late_failure_len]);
+ // Its err record says it (the path in it); the msg the failure
+ // was also said as goes, as a builtin's failing a write does.
+ pardes.ctlfs.events.dropMessage(core, core.fs.late_failure[0..core.fs.late_failure_len]);
pardes.ctlfs.events.noteError(core, req, failed);
return conn.reply(&failed, "");
}
@@ -1756,6 +1761,16 @@ pub const Client = struct {
return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes));
}
+ /// Whether a peer answers at `dial`: a version and attach, and its root
+ /// read. Any answer from the peer, an error of its own included, is one.
+ pub fn probe(gpa: std.mem.Allocator, dial: []const u8) !void {
+ const got = readLimit(gpa, dial, "", "", 0) catch |err| switch (err) {
+ error.IsDirectory, error.NotFound, error.Remote, error.FileTooLarge => return,
+ else => return err,
+ };
+ gpa.free(got);
+ }
+
pub fn write(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, bytes: []const u8) !void {
if (comptime !supported) return error.Unsupported;
var names: [max_depth][]const u8 = undefined;
diff --git a/src/File.zig b/src/File.zig
index 6a1ac319..5f6d7097 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -58,6 +58,15 @@ pub const State = struct {
/// as an undo to the save point is in acme.
saved_hash: ?u64 = null,
watch_after_save: bool = false,
+ /// The file changed on disk under unsaved edits, which were kept (see
+ /// `changed`): its hash, so the same change is said once, until a Save
+ /// or get settles it. A Save warns once before overwriting it, as acme's
+ /// Put does (exec.c:577, "modified since last read").
+ disk_newer: ?u64 = null,
+ disk_newer_warned: bool = false,
+ /// Its file was deleted on disk: said once (`deleted`) until it is
+ /// written or found again.
+ disk_gone: bool = false,
/// Seconds of the last content change, for the filesystem's stat; zero until edited.
mtime: u32 = 0,
/// Non-null for a generated output buffer rather than an on-disk file.
@@ -809,6 +818,10 @@ pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
.location_rows = location_rows,
.scroll = src.scroll,
.revision = @intFromBool(saved.dirty),
+ // Restored with unsaved text, it has text to lose as an edited pane
+ // has: a hash its text does not match, so Exit, Restore, Del and
+ // get ask first (builtins.warnModifiedIn reads only edited panes).
+ .saved_hash = if (saved.dirty) std.hash.Wyhash.hash(0, content) +% 1 else null,
.history = history,
};
pane.file.?.tree_context = saved.tree_context and supportsContext(pane);
@@ -960,12 +973,48 @@ pub fn redo(p: *Pardes, pane: *Pane) void {
restoreSnap(pane, f, next);
}
-/// Commit an externally rewritten file onto the same undo history as typed
-/// edits. Unsaved work remains one `u` away; there is no third merge state.
+/// A pane's file deleted on disk: the text stays, and it says so once,
+/// `<file> deleted on disk (Save writes it again)`, logged `changed
+/// <serial> deleted`.
+pub fn deleted(p: *Pardes, id: u8) void {
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ if (f.disk_gone) return;
+ f.disk_gone = true;
+ var said: [limits.host_path_cap + 64]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&said, "{s} deleted on disk (Save writes it again)", .{f.path}) catch "deleted on disk (Save writes it again)");
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} deleted", .{pane.serial}) catch "");
+}
+
+/// A file rewritten on disk. A clean buffer takes the new text, committed
+/// onto the same undo history as typed edits. A buffer with unsaved edits
+/// is never reloaded under them: it keeps its text and stays dirty, and
+/// says so once (`<file> changed on disk (get reloads it, Save overwrites
+/// it)`, logged `changed <serial>`); get then asks before throwing
+/// the edits away and Save before overwriting the newer file. acme reloads
+/// nothing by itself: its get asks (winclean) and its Put refuses a file
+/// modified since it was read (exec.c:577).
pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
const pane = p.panes[id] orelse return;
const f = if (pane.file) |*file| file else return;
+ f.disk_gone = false;
if (std.mem.eql(u8, f.content, bytes)) return;
+ if (f.saved_hash) |saved| if (f.revision != f.saved_revision and std.hash.Wyhash.hash(0, f.content) != saved) {
+ const disk = std.hash.Wyhash.hash(0, bytes);
+ if (f.disk_newer == disk) return;
+ f.disk_newer = disk;
+ f.disk_newer_warned = false;
+ var said: [limits.host_path_cap + 64]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&said, "{s} changed on disk (get reloads it, Save overwrites it)", .{f.path}) catch "changed on disk (get reloads it, Save overwrites it)");
+ var rec: [16]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d}", .{pane.serial}) catch "");
+ return;
+ };
+ f.disk_newer = null;
+ // Logged as the dirty case is, with what became of it.
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch "");
const new = p.gpa.dupe(u8, bytes) catch return;
pushUndo(p, pane);
setContent(p, f, new);
diff --git a/src/Messages.zig b/src/Messages.zig
index 36cd44da..ce7a2a8c 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -412,9 +412,8 @@ fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
if (text.len == 0) return;
const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
const kept = text[0..@min(text.len, LoggedMessage.cap)];
- // /log hears every one: a client that retried and failed the same way
- // is waiting on that second line. Only the +Messages view collapses.
- ctlfs.events.noteMessage(p, serial, kept);
+ // /log hears every one (a failure of a ctl write as its err record).
+ if (!p.fs.unlogged) ctlfs.events.noteMessage(p, serial, text);
if (p.messages.len > 0) {
const last = &p.messages.log[(p.messages.head + limits.message_log - 1) % limits.message_log];
if (last.serial == serial and
@@ -444,17 +443,40 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
return &m.log[(first + i) % limits.message_log];
}
-/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
/// What a route that would open a pane says when every slot is taken.
-pub const no_pane_slots = "no pane slots: {d} max";
+/// "no space": 9ns's word for ENOSPC (it has none for ENFILE).
+pub const no_pane_slots = "no space for a pane: {d} max";
+/// ...and when the column it would go in has no rows for one.
+pub const no_pane_room = "no space for a pane in that column: each keeps its tag and {d} rows";
+
+/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
+/// Why a peer could not be reached, in words, for an error of a 9P dial
+/// (src/9p_io.zig, Client); null for any other error.
+pub fn dialReason(err: anyerror) ?[]const u8 {
+ return switch (err) {
+ error.Dial => "no answer",
+ error.Timeout => "timed out",
+ error.Hangup => "hung up",
+ error.Botch => "protocol botch",
+ else => null,
+ };
+}
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
w.print("{s}: ", .{operation}) catch {};
+ // A peer out of reach says so, not the error's name (`dial`).
+ if (dialReason(err)) |why| {
+ w.print("dial failed: {s}", .{why}) catch {};
+ return reportFailure(p, id, w.buffered());
+ }
// Every route that opens a pane says the cap the same way.
- if (err == error.NoPaneSlots) {
- w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {};
+ if (err == error.NoPaneSlots or err == error.NoPaneRoom) {
+ if (err == error.NoPaneSlots)
+ w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {}
+ else
+ w.print(no_pane_room, .{pardes.layout.min_body_rows}) catch {};
// A 9P look or exec that would open the pane fails with it too.
p.fs.no_pane_slot = true;
@memcpy(p.fs.ename[0..w.buffered().len], w.buffered());
@@ -480,9 +502,14 @@ pub fn clip(text: []const u8, max: usize) []const u8 {
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
+ p.fs.failures +%= 1;
+ // A failure said later, when the host could not do the work (a Save's
+ // write): that builtin's announcement goes from the log too.
+ ctlfs.events.dropFailedAnnouncement(p, text, false);
// A builtin a ctl write runs: its first error is also the write's, cut
// between words.
- if (p.fs.no_prompt and p.fs.failure_len == 0) {
+ const failing_write = p.fs.no_prompt or p.fs.capturing;
+ if (failing_write and p.fs.failure_len == 0) {
const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text;
@memcpy(p.fs.failure[0..kept.len], kept);
var n = kept.len;
@@ -492,6 +519,16 @@ pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
}
p.fs.failure_len = @intCast(n);
}
+ // The write fails with it, and its err record says it: no msg for it,
+ // and no Verbose announcement before that, so the same failure again is
+ // the same record again, counted (events.pushCounting).
+ if (failing_write) {
+ const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
+ ctlfs.events.dropAnnouncement(p, serial, text);
+ p.fs.unlogged = true;
+ defer p.fs.unlogged = false;
+ return setMessage(p, id, text);
+ }
setMessage(p, id, text);
}
diff --git a/src/Output.zig b/src/Output.zig
index 2f462fd1..b32a40a2 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -499,7 +499,7 @@ pub fn openJumps(p: *Pardes, id: usize) !void {
const loc: []const u8 = if (has_path)
(if (jp.file) |f| f.path else pdf_path.?)
else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable;
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{jp.serial}) catch unreachable;
const what: []const u8 = if (jp.file) |f|
std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
else if (jp.image) |iv|
@@ -665,7 +665,7 @@ pub fn openConfig(p: *Pardes, id: usize) !void {
.startup_config_path = p.opts.startup_config_path,
.platform = @tagName(pardes.platform),
.theme_name = p.theme().name,
- .compiled_default_shell = config.default_shell,
+ .compiled_default_shell = config.defaultShell(),
.gui_shader_source_mode = if (gui_shader_source_mode) |mode|
mode.label()
else
diff --git a/src/builtins.zig b/src/builtins.zig
index 32e3cd84..3f4a680c 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -170,9 +170,10 @@ pub const registry = struct {
if (@intFromEnum(b) == i) return @hasDecl(T, "requires_arg") and T.requires_arg;
inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
if (@intFromEnum(b) == i) return switch (setting.action) {
- // a switch flips bare, a choice steps, and DumpDir bare is the default
- .toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion => false,
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true,
+ // a switch flips bare, a choice steps, and DumpDir and Shell bare
+ // are the default
+ .toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion, .shell => false,
+ .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true,
};
unreachable;
}
@@ -234,6 +235,7 @@ test "Exit asks once about unsaved text, and quits when asked again" {
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
try std.testing.expect(!p.quit);
try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)"));
@@ -242,26 +244,41 @@ test "Exit asks once about unsaved text, and quits when asked again" {
try std.testing.expect(p.quit);
}
-test "Exit over a ctl write names every pane it asks about, and the next Exit discards them all" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
- defer p.deinit();
- const first = try p.setTestFile("saved\n");
- first.file.?.saved_revision = first.file.?.revision -% 1;
- p.newScratchBelow(p.active);
- const second = p.panes[p.active].?;
- panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "x" ** 120));
- try std.testing.expect(second.file.?.revision != second.file.?.saved_revision);
- p.fs.no_prompt = true;
- defer p.fs.no_prompt = false;
- try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
- try std.testing.expect(!p.quit);
- const said = p.fs.failure[0..p.fs.failure_len];
- try std.testing.expect(std.mem.indexOf(u8, said, first.file.?.path) != null);
- try std.testing.expect(std.mem.indexOf(u8, said, second.file.?.path) != null);
- try std.testing.expect(std.mem.endsWith(u8, said, ": Modified (Exit again to discard)"));
- p.fs.failure_len = 0;
- try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
- try std.testing.expect(p.quit);
+test "Exit over a ctl write or a click names every pane it asks about, a second only those edited since, and the next discards them all" {
+ for ([_]bool{ false, true }) |clicked| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const first = try p.setTestFile("saved\n");
+ first.file.?.saved_revision = first.file.?.revision -% 1;
+ first.file.?.saved_hash = 0; // edited: what it had is not what it has
+ p.newScratchBelow(p.active);
+ const second = p.panes[p.active].?;
+ panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "x" ** 120));
+ try std.testing.expect(second.file.?.revision != second.file.?.saved_revision);
+ // A ctl write, or a click through exec (ctl.captured).
+ if (clicked) p.fs.capturing = true else p.fs.no_prompt = true;
+ defer p.fs.capturing = false;
+ defer p.fs.no_prompt = false;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(!p.quit);
+ // Two: counted, never a list cut short; `unsaved` records name them.
+ try std.testing.expectEqualStrings("2 unsaved panes: Modified (Exit again to discard)", p.fs.failure[0..p.fs.failure_len]);
+ const log = p.fs.log.buf.items;
+ var rec: [64]u8 = undefined;
+ for ([_]*const Pane{ first, second }) |pane|
+ try std.testing.expect(std.mem.indexOf(u8, log, try std.fmt.bufPrint(&rec, "unsaved {d} ", .{pane.serial})) != null);
+ // Edited since: only that one is named again (acme's rule).
+ p.fs.failure_len = 0;
+ panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "y" ** 120));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(!p.quit);
+ const again = p.fs.failure[0..p.fs.failure_len];
+ try std.testing.expect(std.mem.indexOf(u8, again, first.file.?.path) == null);
+ try std.testing.expect(std.mem.startsWith(u8, again, second.file.?.path));
+ p.fs.failure_len = 0;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(p.quit);
+ }
}
/// A dump file that exists, for a Restore to get past reading it.
@@ -282,6 +299,7 @@ test "Restore asks about unsaved text as Exit does, and restores when asked agai
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
var line_buf: [4200]u8 = undefined;
const line = try testDump(&line_buf);
try std.testing.expect(p.executeBuiltinLine(p.active, line));
@@ -296,6 +314,7 @@ test "a Restore of a dump that cannot be read says so, before the unsaved-text w
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
p.fs.no_prompt = true;
defer p.fs.no_prompt = false;
try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /nope/missing.zon"));
@@ -310,6 +329,7 @@ test "a Restore's warning is not an Exit's: each word is warned on its own" {
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
var line_buf: [4200]u8 = undefined;
try std.testing.expect(p.executeBuiltinLine(p.active, try testDump(&line_buf)));
try std.testing.expect(p.restore_req == null);
@@ -394,7 +414,7 @@ test "Ligatures is a setting only in the shell that shapes text" {
.startup_config_path = null,
.platform = @tagName(pardes.platform),
.theme_name = p.theme().name,
- .compiled_default_shell = config.default_shell,
+ .compiled_default_shell = config.defaultShell(),
.hover_delay_frames = null,
.native_images = false,
.capabilities = capabilities,
@@ -413,6 +433,7 @@ comptime {
// ---- the two acme verbs ----
+/// Look at its argument: open the file or address it names, else find the word, as a right click does.
pub const Look = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -424,6 +445,7 @@ pub const Look = struct {
}
};
+/// Execute its argument as a middle click on it would, a builtin or a shell command.
pub const Exec = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -454,11 +476,12 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
const Asked = union(enum) { all, pane: usize, column: usize };
fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
- var modified = false;
- // Every pane asked about is named in the one refusal a ctl write gets,
- // as acme's rowclean names them all (wind.c:511).
- var all_buf: [256]u8 = undefined;
- var all = std.Io.Writer.fixed(&all_buf);
+ // Every pane asked about is an `unsaved <serial> <name>` record, as
+ // acme's rowclean names them all (wind.c:511); the refusal a write
+ // gets names the one, or counts them, never a list cut short.
+ var count: usize = 0;
+ var one_buf: [limits.host_path_cap + 24]u8 = undefined;
+ var one: []const u8 = "";
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
switch (which) {
@@ -474,28 +497,39 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
if (f.revision == f.saved_revision or !(panes.Output.fileTraits(f.output).saves or scratch)) continue;
if (f.output != null and f.content.len < 100) continue;
// Dirty by a rename alone is no text to lose, as get says (ctl.zig).
- // Nor is an empty buffer that never held a file.
- if (f.saved_hash) |saved| {
- if (std.hash.Wyhash.hash(0, f.content) == saved) continue;
- } else if (f.content.len == 0) continue;
+ // Nor is a buffer never edited at all: its first edit is what keeps
+ // the text it had (File.zig), so with none there is nothing to lose.
+ const saved = f.saved_hash orelse continue;
+ if (std.hash.Wyhash.hash(0, f.content) == saved) continue;
if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue;
pane.discard_warned = .{ .revision = f.revision, .by = asking };
- var buf: [limits.host_path_cap + 48]u8 = undefined;
- c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ f.path, @tagName(asking) }) catch f.path);
- all.print("{s}{s}", .{ if (modified) ", " else "", f.path }) catch {};
- modified = true;
+ // Scratches share a name: each is named with its pane's serial too,
+ // `/dir/+New (pane 12)`, the same in every refusal (Exit, Restore,
+ // Del, Delcol).
+ var name_buf: [limits.host_path_cap + 24]u8 = undefined;
+ const name = if (scratch) std.fmt.bufPrint(&name_buf, "{s} (pane {d})", .{ f.path, pane.serial }) catch f.path else f.path;
+ var buf: [limits.host_path_cap + 72]u8 = undefined;
+ pardes.ctlfs.events.noteLog(c.p, .unsaved, pane);
+ c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ name, @tagName(asking) }) catch name);
+ if (count == 0) one = std.fmt.bufPrint(&one_buf, "{s}", .{name}) catch name;
+ count += 1;
}
- if (modified and c.p.fs.no_prompt) {
- all.print(": Modified ({s} again to discard)", .{@tagName(asking)}) catch {};
- const said = @import("Messages.zig").clip(all.buffered(), c.p.fs.failure.len);
- @memcpy(c.p.fs.failure[0..said.len], said);
- c.p.fs.failure_len = @intCast(said.len);
+ if (count > 0 and (c.p.fs.no_prompt or c.p.fs.capturing)) {
+ var said_buf: [limits.host_path_cap + 96]u8 = undefined;
+ const said = (if (count == 1)
+ std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) })
+ else
+ std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified";
+ const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len);
+ @memcpy(c.p.fs.failure[0..kept.len], kept);
+ c.p.fs.failure_len = @intCast(kept.len);
}
- return modified;
+ return count > 0;
}
-/// acme's Exit (editors/acme/exec.c:892): quit, once warnModified has
-/// nothing to say.
+/// Quit the editor, refusing once while a pane holds unsaved text: an Exit
+/// with nothing edited since discards it (acme's Exit, editors/acme/exec.c:892,
+/// asking warnModified first).
pub const Exit = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -557,6 +591,7 @@ pub const Kill = struct {
}
};
+/// Write the session's panes and layout to a dump file, for Restore.
pub const Dump = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -564,6 +599,7 @@ pub const Dump = struct {
}
};
+/// Replace every pane with those of a dump file, the last one Dump wrote when bare.
pub const Restore = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -599,6 +635,7 @@ pub const Restore = struct {
}
};
+/// Attach this terminal to a detached pardes session by name.
pub const Attach = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -614,6 +651,7 @@ pub const Attach = struct {
}
};
+/// Detach this pardes from its terminal, leaving the session running.
pub const Detach = struct {
pub const scope: Scope = .session;
pub const enabled = pardes.can_attach;
@@ -625,6 +663,7 @@ pub const Detach = struct {
}
};
+/// Mount another pardes's 9P tree under /n/<name>, given its dial address.
pub const Mount = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -637,9 +676,25 @@ pub const Mount = struct {
const dial = std.mem.trim(u8, args.rest(), " \t");
if (dial.len == 0) return c.p.reportError(c.id, "Mount name dial", error.MissingArgument);
c.p.fs.mount(c.p.gpa, name, dial) catch |err| return c.p.reportError(c.id, "Mount", err);
+ // A mount that cannot be reached is said now, and taken back, not
+ // found out by the first look through it.
+ const reached = reached: {
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ break :reached @import("9p_io.zig").Client.probe(c.p.gpa, dial);
+ };
+ reached catch |err| {
+ if (c.p.fs.mounts.pop()) |gone| {
+ c.p.gpa.free(gone.name);
+ c.p.gpa.free(gone.dial);
+ }
+ var what: [160]u8 = undefined;
+ return c.p.reportError(c.id, std.fmt.bufPrint(&what, "Mount {s} {s}", .{ name, dial[0..@min(dial.len, 100)] }) catch "Mount", err);
+ };
}
};
+/// Unmount the 9P tree mounted under /n/<name>.
pub const Unmount = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -656,6 +711,7 @@ pub const Unmount = struct {
// ---- the message row ----
+/// Show its argument on this pane's message row.
pub const Msg = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -668,6 +724,7 @@ pub const Msg = struct {
}
};
+/// Switch to the next bundled theme.
pub const NextColor = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -675,6 +732,7 @@ pub const NextColor = struct {
}
};
+/// List the themes in a pane, each a word to click.
pub const ThemeSel = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true };
@@ -683,6 +741,7 @@ pub const ThemeSel = struct {
}
};
+/// Load a theme from a file.
pub const ThemeFile = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -696,6 +755,7 @@ pub const ThemeFile = struct {
}
};
+/// Write the bundled themes into the config directory's themes, to edit.
pub const DumpThemes = struct {
pub const scope: Scope = .session;
pub const enabled = pardes.hosted;
@@ -710,6 +770,7 @@ pub const DumpThemes = struct {
}
};
+/// List the fonts in a pane, each a word to click.
pub const FontSel = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true };
@@ -722,6 +783,7 @@ pub const FontSel = struct {
}
};
+/// Show the declarations enclosing the top row at the pane's head, on or off.
pub const TreeContext = struct {
pub const enabled = pardes.syntax.enabled;
pub const takes_arg = true;
@@ -746,6 +808,7 @@ pub const TreeContext = struct {
}
};
+/// Fit the PDF page to the pane by width or by height, flipping between them.
pub const PdfFit = struct {
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
@@ -756,6 +819,7 @@ pub const PdfFit = struct {
}
};
+/// Tint the PDF page to the theme, or show it as printed, flipping between them.
pub const PdfTint = struct {
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
@@ -766,6 +830,7 @@ pub const PdfTint = struct {
}
};
+/// List the PDF's sections in a pane, each a place to go.
pub const PdfSections = struct {
pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true, .locations = true };
pub const enabled = pardes.pdf_enabled;
@@ -800,6 +865,7 @@ pub const Ascii = struct {
// ---- the system clipboard ----
+/// Copy the selection to the system clipboard.
pub const ClipYank = struct {
pub fn run(c: Ctx) void {
edit.clipYank(c.p, c.pane, false);
@@ -814,18 +880,21 @@ pub const ClipYankMain = struct {
}
};
+/// Paste the system clipboard after the selection.
pub const ClipPaste = struct {
pub fn run(c: Ctx) void {
edit.clipRequest(c.p, c.id, .after);
}
};
+/// Paste the system clipboard before the selection.
pub const ClipPasteBefore = struct {
pub fn run(c: Ctx) void {
edit.clipRequest(c.p, c.id, .before);
}
};
+/// Replace the selection with the system clipboard.
pub const ClipReplace = struct {
pub fn run(c: Ctx) void {
edit.clipRequest(c.p, c.id, .replace);
@@ -834,6 +903,7 @@ pub const ClipReplace = struct {
// ---- panes and columns ----
+/// Write the pane's text to its file, or to the file its argument names.
pub const Save = struct {
pub const takes_arg = true;
pub fn run(c: Ctx) void {
@@ -844,6 +914,7 @@ pub const Save = struct {
}
};
+/// Open an empty scratch pane, <dir>/+New, in this pane's directory.
pub const New = struct {
pub const output: OutputTraits = .{ .name = config.scratch_buffer, .doc = true, .saves = true };
pub fn run(c: Ctx) void {
@@ -900,6 +971,7 @@ pub const Del = struct {
}
};
+/// Recolour a terminal's program colours to the theme, or show them as sent, flipping between them.
pub const Filter = struct {
pub fn run(c: Ctx) void {
if (!c.pane.isTerminal()) return;
@@ -954,14 +1026,20 @@ pub const Edit = struct {
/// acme's Undo and Redo (exec.c `undo`): step the pane's body back or
/// forward through its edits, from a tag, a pane ctl or exec.
+/// With no step left, Undo and Redo say so and change nothing; a ctl write
+/// of them still succeeds, as acme's Undo is silent there. A file keeps
+/// limits.undo_max steps (256).
pub const Undo = struct {
pub fn run(c: Ctx) void {
+ if (c.pane.file) |f| if (f.history.undo_len == 0) return c.p.setMessage(c.id, "Undo: nothing to undo");
edit.doUndo(c.p, &c.pane.body);
}
};
+/// Redo the last undone edit.
pub const Redo = struct {
pub fn run(c: Ctx) void {
+ if (c.pane.file) |f| if (f.history.redo_len == 0) return c.p.setMessage(c.id, "Redo: nothing to redo");
edit.doRedo(c.p, &c.pane.body);
}
};
@@ -973,6 +1051,7 @@ pub const Collapse = struct {
}
};
+/// Close this column and its panes, refusing once over unsaved text.
pub const Delcol = struct {
pub fn run(c: Ctx) void {
// The column of the tag it came from, else the pane's: a pane ctl's
@@ -985,12 +1064,14 @@ pub const Delcol = struct {
}
};
+/// Close the pane, giving its rows to the pane above it.
pub const DelAbove = struct {
pub fn run(c: Ctx) void {
Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "k" });
}
};
+/// Close the pane, giving its rows to the pane below it.
pub const DelBelow = struct {
pub fn run(c: Ctx) void {
Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "j" });
@@ -1009,10 +1090,10 @@ pub const Tty = struct {
// A shell that is not there is said, where the host would quietly
// start its fallback in its place.
if (comptime pardes.hosted) if (arg.len > 0) {
- var buf: [std.fs.max_path_bytes]u8 = undefined;
- if (@import("host_io.zig").Shell.find(arg, &buf) == null) {
- var said: [320]u8 = undefined;
- return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Tty: no shell \"{s}\" (a name on the usual paths, or a path to one)", .{arg[0..@min(arg.len, 200)]}) catch "Tty: no such shell");
+ var why: [320]u8 = undefined;
+ if (@import("host_io.zig").Shell.refusal(arg[0..@min(arg.len, 200)], &why)) |refused| {
+ var said: [340]u8 = undefined;
+ return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Tty: {s}", .{refused}) catch "Tty: no such shell");
}
};
const pane = exec.spawnTty(c.p, c.id) orelse return;
@@ -1028,8 +1109,8 @@ pub const Tty9p = struct {
}
};
-/// Fold the active pane's column into the one on its right, keeping its panes.
-/// Binds this terminal as a language's REPL, by the language's name as the
+/// Binds this terminal as a language's REPL, so an exec on that language's
+/// files is typed into it instead of run. The language is named as the
/// syntax table has it: an exec made on the body of a file in that
/// language -- a middle click, the execute key, a single word like `ls`
/// as much as a selection -- and one made on this terminal's own body, is
@@ -1064,10 +1145,25 @@ pub const Repl = struct {
// Named as a code fence names it: `py`, `Python`, `sh`.
const named: ?u8 = if (manifest.byName(arg)) |i| if (grammars[i].exts.len > 0) @intCast(i) else null else null;
const lang: u8 = named orelse {
+ // A few, whole, and where the rest are: a list cut to fit the
+ // row would end mid-word.
var w = std.Io.Writer.fixed(&buf);
- w.print("Repl: no language \"{s}\"; - or one of:", .{@import("Messages.zig").clip(arg, 24)}) catch {};
- for (grammars) |g| if (g.exts.len > 0) w.print(" {s}", .{g.name}) catch break;
- return c.p.reportFailure(c.id, @import("Messages.zig").clip(w.buffered(), 256));
+ w.print("Repl: no language \"{s}\"; - or one like", .{@import("Messages.zig").clip(arg, 24)}) catch {};
+ // Those that start as the word does first, a misspelling's
+ // likeliest meaning (pithon: python).
+ var shown: usize = 0;
+ var known: usize = 0;
+ for ([2]bool{ true, false }) |alike| for (grammars) |g| if (g.exts.len > 0) {
+ const starts = arg.len > 0 and g.name[0] == std.ascii.toLower(arg[0]);
+ if (starts != alike) continue;
+ known += 1;
+ if (shown < 6) {
+ w.print(" {s}", .{g.name}) catch {};
+ shown += 1;
+ }
+ };
+ w.print(" ({d} more: docs/tags.md, Repl)", .{known - shown}) catch {};
+ return c.p.reportFailure(c.id, w.buffered());
};
if (pane.repl) |r| if (r.lang == lang)
return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl");
@@ -1088,6 +1184,7 @@ pub const Repl = struct {
}
};
+/// Fold the active pane's column into the one on its right, keeping its panes.
/// The horizontal mirror of the vertical stacking `New` does.
pub const Joincol = struct {
pub const scope: Scope = .session;
@@ -1099,6 +1196,7 @@ pub const Joincol = struct {
}
};
+/// Open the tutor, a document to work through.
pub const Tutor = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -1108,6 +1206,7 @@ pub const Tutor = struct {
}
};
+/// Open the help: every key and every builtin.
pub const Help = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.help_buffer };
@@ -1116,6 +1215,7 @@ pub const Help = struct {
}
};
+/// Open the settings in effect, each a word to click.
pub const Config = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.config_buffer };
@@ -1124,6 +1224,7 @@ pub const Config = struct {
}
};
+/// Set how location rows are drawn, or show how they are when bare.
pub const LocationsConfig = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1154,6 +1255,7 @@ pub const LocationsConfig = struct {
}
};
+/// Open the messages shown so far.
pub const Messages = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.messages_buffer };
@@ -1172,6 +1274,7 @@ pub const Changelog = struct {
}
};
+/// Open the source of a panel transition or post shader, by name.
pub const EffectCode = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1199,6 +1302,7 @@ pub const Pet = struct {
}
};
+/// Open a minimap of the file in braille dots.
pub const Mini = struct {
pub const takes_arg = true;
pub const output: OutputTraits = .{ .name = "Mini", .doc = true };
@@ -1211,6 +1315,7 @@ pub const Mini = struct {
// ---- search ----
+/// List every place a pattern matches in the pane, in a +Search.
pub const Find = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -1247,6 +1352,7 @@ pub const Grep = struct {
// nothing above it reaches its column's tag, then the workspace's, and Down
// comes back the same way. Left and Right walk the column tags.
+/// Move the keyboard to the pane, or column tag, to the left.
pub const Left = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1257,6 +1363,7 @@ pub const Left = struct {
}
};
+/// Move the keyboard to the pane below, off a tag onto the pane under it.
pub const Down = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1275,6 +1382,7 @@ pub const Down = struct {
}
};
+/// Move the keyboard to the pane above, and from a column's top pane to its tag.
pub const Up = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1288,6 +1396,7 @@ pub const Up = struct {
}
};
+/// Move the keyboard to the pane, or column tag, to the right.
pub const Right = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1316,6 +1425,7 @@ pub const Forward = struct {
}
};
+/// Go back to the last place jumped from in another pane.
pub const Last = struct {
pub fn run(c: Ctx) void {
var i = c.p.njumps;
@@ -1331,6 +1441,7 @@ pub const Last = struct {
}
};
+/// List the places jumped from, each a place to go.
pub const Jumplist = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true, .locations = true };
@@ -1341,66 +1452,77 @@ pub const Jumplist = struct {
// ---- the language group ----
+/// Ask the language server about the symbol under the cursor.
pub const Hover = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .hover, "");
}
};
+/// List the language server's code actions for the cursor.
pub const CodeAction = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .code_action, "");
}
};
+/// Select every reference to the symbol under the cursor.
pub const SelectRefs = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .select_refs, "");
}
};
+/// List the file's symbols from the language server.
pub const Symbols = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .document_symbols, "");
}
};
+/// List the file's diagnostics from the language server.
pub const Diagnostics = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .diagnostics, "");
}
};
+/// List the workspace's diagnostics from the language server.
pub const WsDiagnostics = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .workspace_diagnostics, "");
}
};
+/// List the callers of the function under the cursor.
pub const Callers = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .incoming_calls, "");
}
};
+/// List the functions the function under the cursor calls.
pub const Callees = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .outgoing_calls, "");
}
};
+/// List the supertypes of the type under the cursor.
pub const Supertypes = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .supertypes, "");
}
};
+/// List the subtypes of the type under the cursor.
pub const Subtypes = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .subtypes, "");
}
};
+/// Rename the symbol under the cursor everywhere, to its argument.
pub const Rename = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -1411,6 +1533,7 @@ pub const Rename = struct {
}
};
+/// Search the workspace's symbols for its argument.
pub const WsSymbols = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -1421,18 +1544,21 @@ pub const WsSymbols = struct {
}
};
+/// Show which language servers serve this file and their state.
pub const Lspinfo = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .status, "");
}
};
+/// Explain how the last language server request was answered, step by step.
pub const Lspwhy = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .explain, "");
}
};
+/// Read the board memory at an address, into a pane.
pub const Peek = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1447,6 +1573,7 @@ pub const Peek = struct {
}
};
+/// Write a value to the board memory at an address.
pub const Poke = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1460,6 +1587,7 @@ pub const Poke = struct {
}
};
+/// Dump a range of the board memory in hex, into a pane.
pub const Hexdump = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1474,6 +1602,7 @@ pub const Hexdump = struct {
}
};
+/// Read or set the board GPIO pins, into a pane.
pub const Gpio = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1767,18 +1896,28 @@ test "Exit and Restore name a modified +New scratch as well as files" {
defer p.deinit();
const file = try p.setTestFile("saved\n");
file.file.?.saved_revision = file.file.?.revision -% 1; // modified
+ file.file.?.saved_hash = 0; // edited: what it had is not what it has
p.newScratchBelow(p.active);
const scratch = &p.panes[p.active].?.file.?;
scratch.content = try p.gpa.realloc(scratch.content, 120);
@memset(scratch.content, 'n'); // past acme's 100 bytes, where it whines
scratch.revision +%= 1; // typed into, never saved
+ scratch.saved_hash = 0; // edited: what it had is not what it has
p.fs.no_prompt = true;
defer p.fs.no_prompt = false;
try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
try std.testing.expect(!p.quit);
- const said = p.fs.failure[0..p.fs.failure_len];
- try std.testing.expect(std.mem.indexOf(u8, said, "test.txt") != null);
- try std.testing.expect(std.mem.indexOf(u8, said, config.scratch_buffer) != null);
+ // Each pane's message row names it; the write's refusal counts them.
+ try std.testing.expectEqualStrings("2 unsaved panes: Modified (Exit again to discard)", p.fs.failure[0..p.fs.failure_len]);
+ try std.testing.expect(std.mem.indexOf(u8, file.msg[0..file.msg_len], "test.txt") != null);
+ const scratch_pane = p.panes[p.active].?;
+ var named: [64]u8 = undefined;
+ try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], try std.fmt.bufPrint(&named, "{s} (pane {d})", .{ config.scratch_buffer, scratch_pane.serial })) != null);
+ // Delcol, Restore and Exit name it the same way.
+ p.fs.failure_len = 0;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol"));
+ try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], "(Delcol again") != null);
+ try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], named[0 .. config.scratch_buffer.len + 7]) != null);
}
test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and close on the second" {
@@ -1786,6 +1925,7 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
const serial = pane.serial;
try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol"));
try std.testing.expect(p.paneBySerial(serial) != null);
@@ -1797,3 +1937,14 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo
try std.testing.expect(p.executeBuiltinLine(p.active, "Del"));
try std.testing.expect(p.paneBySerial(serial) == null);
}
+
+test "a pane renamed but never edited has no text to lose: Del closes it at once" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ p.newScratchBelow(p.active);
+ const pane = try p.setTestFile("loaded\n");
+ pane.file.?.saved_revision = pane.file.?.revision -% 1; // dirty by the rename alone
+ const serial = pane.serial;
+ try std.testing.expect(p.executeBuiltinLine(0, "Del"));
+ try std.testing.expect(p.paneBySerial(serial) == null);
+}
diff --git a/src/config.zig b/src/config.zig
index d4efed0e..229c70ea 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -26,7 +26,7 @@ const undecided: []const u8 = "<undecided>";
// null means word/chord-only. Every enabled builtin must explicitly choose a path.
pub const leader_path = paths: {
- @setEvalBranchQuota(2000);
+ @setEvalBranchQuota(10_000); // a check per builtin, and they grow
var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{
.Help = &[_]u8{leader_help},
.Hover = "lk",
@@ -76,6 +76,7 @@ pub const leader_path = paths: {
.Joincol = "cj",
.Placement = "cp",
.BootShell = null,
+ .LookWord = null,
.Debug = "td",
.Msg = null,
.Colors = "tc",
@@ -350,7 +351,16 @@ test "wheel drift guard" {
try std.testing.expect(wheelTick(&g, false));
}
-pub const default_shell = "fish";
+/// The shell when no `Shell` is set: the user's login shell ($SHELL), as a
+/// terminal would start, else /bin/sh. An explicit `Shell` overrides it.
+pub fn defaultShell() []const u8 {
+ if (comptime pardes.hosted) if (std.c.getenv("SHELL")) |s| {
+ const shell = std.mem.span(s);
+ // One that is not there, or not executable, is no shell: /bin/sh.
+ if (shell.len > 0 and std.c.access(s, std.c.X_OK) == 0) return shell;
+ };
+ return "/bin/sh";
+}
// Minimum file gutter width, including the space after the line number.
pub const PREFIX_W: u16 = 5;
@@ -708,12 +718,17 @@ pub const Runtime = struct {
/// has typed into stands: `keep` leaves the shell, `replace` closes it
/// (exec.evictLonePristineTty).
boot_shell: BootShell = .keep,
+ /// A plain word looked at in a pane: `search`, acme's look3, selects its
+ /// next place in that pane, wrapping; `list`, what pardes did before,
+ /// lists every place in a +Search pane (look.zig, lookWord).
+ look_word: LookWord = .search,
pub const ShaderAnimation = enum { off, on, always };
pub const Placement = enum { acme, pardes };
pub const BootShell = enum { keep, replace };
+ pub const LookWord = enum { search, list };
/// A setting that is one of a few words, each an enum field of Runtime.
- pub const Choice = enum { placement, boot_shell };
+ pub const Choice = enum { placement, boot_shell, look_word };
/// shadow: a soft drop shadow. rim: a light hairline along the top of
/// the focused pane's tag rows. auto: a shadow on a light page; on a
@@ -912,6 +927,8 @@ pub const Runtime = struct {
pub const Setting = struct {
word: []const u8,
action: Action,
+ /// What it does, one sentence: /commands lists it after the word.
+ doc: []const u8,
availability: ?Capability = null,
pub fn enabled(setting: Setting, capabilities: Capabilities) bool {
@@ -924,51 +941,52 @@ pub const Runtime = struct {
// The builtin registry and Config report share this command table.
pub const settings = [_]Setting{
- .{ .word = "Colors", .action = .{ .toggle = .colors } },
- .{ .word = "Wrap", .action = .{ .toggle = .wrap } },
- .{ .word = "TreeContextTagStyle", .action = .{ .toggle = .tree_context_tag_style } },
- .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } },
- .{ .word = "Debug", .action = .{ .toggle = .debug } },
- .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } },
- .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } },
- .{ .word = "Verbose", .action = .{ .toggle = .verbose } },
- .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
- .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures },
- .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift },
- .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift },
- .{ .word = "Lift", .action = .lift, .availability = .lift },
- .{ .word = "Motion", .action = .motion },
- .{ .word = "InactiveDim", .action = .inactive_dim },
- .{ .word = "GripWidth", .action = .grip_width, .availability = .lift },
- .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } },
- .{ .word = "MessageFall", .action = .{ .message_ms = .fall } },
- .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } },
- .{ .word = "DumpDir", .action = .dump_dir },
- .{ .word = "Theme", .action = .theme },
- .{ .word = "Shell", .action = .shell },
- .{ .word = "Font", .action = .font, .availability = .font_picker },
- .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker },
- .{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity },
- .{ .word = "WindowBlur", .action = .window_blur, .availability = .window_blur },
- .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions },
- .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions },
- .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions },
- .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions },
- .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions },
- .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions },
- .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions },
- .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions },
- .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions },
- .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions },
- .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions },
- .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
- .{ .word = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders },
- .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders },
- .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders },
- .{ .word = "Shader", .action = .shader, .availability = .scene_shaders },
- .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders },
- .{ .word = "Placement", .action = .{ .choice = .placement } },
- .{ .word = "BootShell", .action = .{ .choice = .boot_shell } },
+ .{ .word = "Colors", .action = .{ .toggle = .colors }, .doc = "Syntax colours in text panes, on or off." },
+ .{ .word = "Wrap", .action = .{ .toggle = .wrap }, .doc = "Long lines wrap onto the next row, on or off." },
+ .{ .word = "TreeContextTagStyle", .action = .{ .toggle = .tree_context_tag_style }, .doc = "A pane's TreeContext rows draw in the tag's style, on or off." },
+ .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom }, .doc = "Each pane's tag goes at its foot rather than its head, on or off." },
+ .{ .word = "Debug", .action = .{ .toggle = .debug }, .doc = "A small box of frame statistics at the top right, on or off." },
+ .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint }, .doc = "The focused pane's and column's tags are tinted, on or off." },
+ .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold }, .doc = "Keywords and the like are drawn bold, on or off." },
+ .{ .word = "Verbose", .action = .{ .toggle = .verbose }, .doc = "A builtin says its own name on the message row as it runs, on or off." },
+ .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation }, .doc = "A message falls into its row and dissolves out of it, on or off." },
+ .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures, .doc = "A font's programming ligatures draw across their cells, on or off." },
+ .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift, .doc = "The cursor blinks, solid while typing, on or off." },
+ .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift, .doc = "A soft halo of the selection's colour round it in the body, on or off." },
+ .{ .word = "Lift", .action = .lift, .availability = .lift, .doc = "How the focused pane stands off the page: off, shadow, rim or auto." },
+ .{ .word = "Motion", .action = .motion, .doc = "How animations move: off, crisp, smooth, bouncy or playful." },
+ .{ .word = "InactiveDim", .action = .inactive_dim, .doc = "Fade the unfocused panes' text by a percentage, 0 through 100, 0 off." },
+ .{ .word = "GripWidth", .action = .grip_width, .availability = .lift, .doc = "The grip and scrollbar width, as a percent of the theme's rail." },
+ .{ .word = "MessageLinger", .action = .{ .message_ms = .linger }, .doc = "How long, in milliseconds, a message stays after what dismisses it." },
+ .{ .word = "MessageFall", .action = .{ .message_ms = .fall }, .doc = "How long, in milliseconds, a message takes to fall into its row." },
+ .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve }, .doc = "How long, in milliseconds, a message takes to dissolve." },
+ .{ .word = "DumpDir", .action = .dump_dir, .doc = "The directory Dump writes to and a relative Restore reads from; bare, the default." },
+ .{ .word = "Theme", .action = .theme, .doc = "Switch to the named theme." },
+ .{ .word = "Shell", .action = .shell, .doc = "The shell new terminals run; bare, the login shell." },
+ .{ .word = "Font", .action = .font, .availability = .font_picker, .doc = "The body font, by name or path, and its size." },
+ .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker, .doc = "Scale the tag font, a percentage." },
+ .{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity, .doc = "The window's opacity, a percentage." },
+ .{ .word = "WindowBlur", .action = .window_blur, .availability = .window_blur, .doc = "The blur behind a translucent window, a radius." },
+ .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions, .doc = "Panes slide in and out as they open and close, on or off." },
+ .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions, .doc = "Panes zoom in and out as they open and close, on or off." },
+ .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions, .doc = "Panes dissolve in and out as they open and close, on or off." },
+ .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions, .doc = "Panes open and close through a scatter of ASCII, on or off." },
+ .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions, .doc = "Panes open and close with a vertical wipe, on or off." },
+ .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions, .doc = "Panes open and close from their edges, on or off." },
+ .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions, .doc = "Panes fall into place as they open, on or off." },
+ .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions, .doc = "Panes open and close in a wave, on or off." },
+ .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions, .doc = "Panes open and close like a curtain, on or off." },
+ .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions, .doc = "Panes open and close through scrambled text, on or off." },
+ .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions, .doc = "Panes type themselves in as they open, on or off." },
+ .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders, .doc = "The bundled CRT post pass over the window, on or off." },
+ .{ .word = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders, .doc = "The brightest ink glows a little: a level, 0 (off) through 3." },
+ .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders, .doc = "The window's corners fall into shade: a level, 0 (off) through 3." },
+ .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders, .doc = "The page takes a fine, still grain, like paper: a level, 0 (off) through 3." },
+ .{ .word = "Shader", .action = .shader, .availability = .scene_shaders, .doc = "A Shadertoy file into the post chain, or out of it; off empties the chain." },
+ .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders, .doc = "When the post chain animates on its own: off, on (while focused) or always." },
+ .{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." },
+ .{ .word = "BootShell", .action = .{ .choice = .boot_shell }, .doc = "A document dropped where a lone untouched shell stands: keep the shell, or replace it." },
+ .{ .word = "LookWord", .action = .{ .choice = .look_word }, .doc = "A plain word looked at: search selects its next place in the pane, list lists every place in a +Search." },
};
pub fn find(name: []const u8) ?Setting {
@@ -1052,8 +1070,9 @@ pub const Runtime = struct {
}
},
.shell => {
- const value = std.mem.trim(u8, argument orelse return false, " \t\r\n");
- if (value.len == 0 or !state.shell.requested.set(value)) return false;
+ // Bare, the default again ($SHELL, else /bin/sh).
+ const value = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ if (!state.shell.requested.set(value)) return false;
state.shell.pending = true;
},
.tagline_size => {
@@ -1379,7 +1398,7 @@ pub const Runtime = struct {
try out.writeAll("Startup config: no per-user config path\n");
try out.print(
"Platform: {s}\n" ++
- "Compiled default shell: {s}\n",
+ "Default shell ($SHELL, else /bin/sh): {s}\n",
.{ context.platform, context.compiled_default_shell },
);
if (context.gui_shader_source_mode) |mode|
@@ -1641,6 +1660,7 @@ pub const Runtime = struct {
.choice => |which| switch (which) {
.placement => "pardes",
.boot_shell => "replace",
+ .look_word => "list",
},
.lift => "rim",
.motion => "playful",
@@ -1703,6 +1723,7 @@ pub const Runtime = struct {
.choice => |which| switch (which) {
.placement => "Placement pardes\n",
.boot_shell => "BootShell replace\n",
+ .look_word => "LookWord list\n",
},
.lift => "Lift rim\n",
.motion => "Motion playful\n",
@@ -1738,7 +1759,7 @@ pub const Runtime = struct {
"TaglineSize 82\n",
"Startup config: /tmp/pardes/init\n",
"Platform: gui\n",
- "Compiled default shell: /bin/sh\n",
+ "Default shell ($SHELL, else /bin/sh): /bin/sh\n",
"GUI shader source: live GLSL compiled during this build\n",
"Look hover delay: 18 frames\n",
"Native images: on\n",
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 8f45135a..13cf4488 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -373,6 +373,7 @@ pub const Session = struct {
.pty_signal = ptySignal,
.close_pty = closePaneShell,
.tty_taken = ttyTaken,
+ .fg_name = fgName,
.kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
@@ -544,6 +545,14 @@ pub const Session = struct {
return host_io.ttyTaken(pt.pid, pt.fd);
}
+ fn fgName(ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 {
+ const s = of(ctx);
+ if (pane >= s.ptys.len) return null;
+ const pt = s.ptys[pane];
+ if (pt.fd < 0) return null;
+ return host_io.foregroundName(pt.pid, pt.fd, buf);
+ }
+
fn killJob(ctx: ?*anyopaque, pane: u8) bool {
const s = of(ctx);
if (pane >= s.ptys.len) return false;
@@ -1076,7 +1085,9 @@ pub const Session = struct {
rows = if (rows == 0) c.rows else @min(rows, c.rows);
}
var regridded = false;
- if (cols != 0 and (cols != s.cols or rows != s.rows)) {
+ // Against the core's size too: a root ctl `size` may have set it
+ // while nobody was attached.
+ if (cols != 0 and (cols != s.cols or rows != s.rows or cols != s.core.screen_w or rows != s.core.screen_h)) {
s.cols = cols;
s.rows = rows;
for (&s.clients) |*c| c.need_full = true;
@@ -1190,11 +1201,20 @@ fn onQuitSignal(_: std.posix.SIG) callconv(.c) void {
if (quit_wake >= 0) _ = libc.send(quit_wake, "q", 1, nosignal);
}
+/// The screen of a detached session no frontend has sized.
+pub const detached_cols = 160;
+pub const detached_rows = 50;
+
pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void {
const gpa = init.gpa;
const allocs = pardes.memory.init(gpa);
defer pardes.memory.deinit();
var options = opts;
+ // No frontend yet, so no size to take: one roomy enough for a script's
+ // panes (80x24 left pane/new refused after a few); `size` sets another,
+ // and an attaching frontend its own.
+ options.cols = detached_cols;
+ options.rows = detached_rows;
options.image_allocator = allocs.image;
options.pdf_allocator = allocs.pdf;
options.tree_sitter_allocator = allocs.tree_sitter;
diff --git a/src/dump.zig b/src/dump.zig
index 88a95994..7b27876e 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -848,7 +848,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
restored.command_status = t.status;
}
if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty;
- if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .serial = restored.serial, .cwd = .from(t.cwd) } });
+ if (revive) p.emitSpawn(i, restored.serial, t.cwd);
break :terminal restored;
},
.file => try pardes.panes.File.restore(p, i, src),
@@ -949,3 +949,37 @@ test "a restored pane keeps its dot, as acme's dump keeps a window's" {
try std.testing.expectEqual(@as(u32, 4), dot.q0);
try std.testing.expectEqual(@as(u32, 7), dot.q1);
}
+
+test "a pane restored with unsaved text is asked about by Exit, Restore, Del and get" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("saved\n");
+ _ = ctlfs.pane.spliceBody(p, pane, 0, 0, "unsaved ").?;
+ try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision);
+ try dumpState(p);
+ // Restore reads its dump before it warns: one it can read.
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "d.zon", .data = p.dump_out.? });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ var restore_line: [4200]u8 = undefined;
+ for ([_][]const u8{ "Exit", "Restore", "Del", "get" }) |word| {
+ const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ const id = for (restored.panes, 0..) |slot, i| {
+ if (slot) |candidate| if (candidate.file != null and candidate.file.?.output == null) break i;
+ } else return error.FileLost;
+ const th = @import("ninep/testing.zig");
+ const node = if (std.mem.eql(u8, word, "Del") or std.mem.eql(u8, word, "get"))
+ ctlfs.Node.of(restored.panes[id].?.serial, .ctl)
+ else
+ @intFromEnum(ctlfs.TopFile.ctl);
+ const line = if (std.mem.eql(u8, word, "Restore")) try std.fmt.bufPrint(&restore_line, "Restore {s}/d.zon\n", .{dir}) else if (std.mem.eql(u8, word, "Exit")) "Exit\n" else if (std.mem.eql(u8, word, "Del")) "Del\n" else "get\n";
+ const answer = th.wr(restored, node, line);
+ try std.testing.expectEqual(ctlfs.Status.err, answer.reply.status);
+ try std.testing.expect(std.mem.indexOf(u8, answer.reply.ename, "Modified") != null);
+ try std.testing.expect(!restored.quit and restored.panes[id] != null);
+ }
+}
diff --git a/src/exec.zig b/src/exec.zig
index d4480e00..2b0c90cb 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -217,6 +217,28 @@ pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 {
return null;
}
+/// Where the selection `heldSelection` would give is, as acme's argument
+/// location (exec.c:162, getbytearg): `path:#q0,#q1` of a file pane's dot.
+pub fn heldSelectionPlace(p: *Pardes, first: usize, buf: []u8) ?[]const u8 {
+ var k: usize = 0;
+ while (k < p.panes.len + 2) : (k += 1) {
+ const i = switch (k) {
+ 0 => first,
+ 1 => p.active,
+ else => k - 2,
+ };
+ if (k > 0 and (i == first or (k > 1 and i == p.active))) continue;
+ const t = p.panes[i] orelse continue;
+ if (t.sel[sel_slot].state == .done) return null; // a pointer selection: no byte range kept
+ if ((t.body.vsel.active and t.body.vsel.explicit) or t.body.msel.active) {
+ const f = t.file orelse return null;
+ const d = @import("ninep/pane.zig").dotOf(t);
+ return std.fmt.bufPrint(buf, "{s}:#{d},#{d}", .{ f.path, d.q0, d.q1 }) catch null;
+ }
+ }
+ return null;
+}
+
/// A clicked word and the selection held for it, as one command line.
pub fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 {
const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n");
@@ -249,6 +271,16 @@ pub fn saveFile(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
const f = if (pane.file) |*file| file else return;
if (f.output != null) return; // nothing behind it yet: saveTo, with a path
+ // Its file changed on disk under these edits: once, as acme's Put says
+ // "modified since last read" (exec.c:577), and the next Save writes.
+ if (f.disk_newer != null) {
+ if (!f.disk_newer_warned) {
+ f.disk_newer_warned = true;
+ var said: [Pardes.SavePath.cap + 64]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "{s} modified on disk since read (Save again to overwrite)", .{f.path}) catch "modified on disk since read (Save again to overwrite)");
+ }
+ f.disk_newer = null;
+ }
p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
}
@@ -310,12 +342,16 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void {
}
fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
- const src = p.panes[from] orelse return null;
+ if (p.panes[from] == null) return null;
const free = p.freeSlot() orelse {
p.reportError(from, "Tty", error.NoPaneSlots);
return null;
};
- const nt = p.newShell(free, Pardes.paneDir(src)) catch |err| {
+ const nt = p.newShell(free, p.execDir(from)) catch |err| {
+ if (err == error.PathTooLong) {
+ p.reportFailure(from, std.fmt.comptimePrint("Tty: its directory is longer than a path may be ({d} bytes)", .{pardes.memory.limits.host_path_cap}));
+ return null;
+ }
p.reportError(from, "Tty", err);
return null;
};
@@ -444,13 +480,29 @@ fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed {
}
const repl_enter_wait_ns: u64 = 250 * std.time.ns_per_ms;
+/// Python echoes a long paste over several reads: its Enter waits for the
+/// echo to go quiet this long, not for the first of it.
+const repl_quiet_ns: u64 = 100 * std.time.ns_per_ms;
+
+/// The host's clock now; core time (`now_ns`, `stepped_ns`) is as old as
+/// the last frame, seconds in an idle session.
+fn clockNow(p: *Pardes) u64 {
+ return if (p.host.vtable.now) |clock| clock(p.host.ctx) else p.now_ns;
+}
/// Sends the Enters a paste into pane `id`'s REPL is owed: when the REPL
-/// has answered it (`answered`), or once the wait is up.
+/// has answered it (`answered`), or once the wait is up. Python's answer
+/// only restarts a short wait, so the Enter goes once its echo of the
+/// whole paste is in (a multi-line paste read in part would leave it at
+/// `...`).
pub fn flushReplEnter(p: *Pardes, id: usize, answered: bool) void {
const pane = p.panes[id] orelse return;
if (pane.repl_enter == 0) return;
- if (!answered and p.stepped_ns < pane.repl_enter_due_ns) return;
+ if (answered and (if (pane.repl) |r| r.lang == python_lang else false)) {
+ pane.repl_enter_due_ns = clockNow(p) + repl_quiet_ns;
+ return;
+ }
+ if (!answered and p.now_ns < pane.repl_enter_due_ns) return;
for (0..pane.repl_enter) |_| p.emitWrite(id, "\r");
pane.repl_enter = 0;
}
@@ -492,14 +544,22 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
// A Python block whose last line is indented, or a bare header, is not
// over at one Enter, pasted into 3.13's REPL or typed into the old one:
// the blank line a second Enter gives ends it.
- const enters: u8 = if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n])) 2 else 1;
+ // So is a paste of more than one line: 3.13's REPL takes it as one
+ // input, which one Enter leaves at `...`.
+ const python = if (dst.repl) |r| r.lang == python_lang else false;
+ const lines = std.mem.count(u8, std.mem.trim(u8, clean[0..n], " \t\n"), "\n") + 1;
+ const enters: u8 = if (python and (pythonBlockOpen(clean[0..n]) or (pasted and lines > 1))) 2 else 1;
if (pasted) {
// An Enter read with the paste is part of it: 3.13's REPL takes it
// as a newline in the pasted text, even a one-line one, and waits.
// It goes once the REPL has answered the paste (its next output),
// or after a moment if nothing comes.
dst.repl_enter = enters;
- dst.repl_enter_due_ns = p.stepped_ns + repl_enter_wait_ns;
+ // From the clock now, not core time: idle (a detached session
+ // nobody watches), `stepped_ns` and `now_ns` are as old as the last
+ // frame, and a wait counted from there would be over already, the
+ // Enter going out with the paste.
+ dst.repl_enter_due_ns = clockNow(p) + repl_enter_wait_ns;
} else for (0..enters) |_| p.emitWrite(to, "\r");
// Line by line, a blank line inside a Python block ends the block.
if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and
@@ -577,11 +637,14 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
return null;
}
const src = p.panes[from] orelse return null;
- const dir = Pardes.paneDir(src);
+ const dir = p.execDir(from);
// Not one whose pty a job it left behind still prints to: reusing it
// would hang that job up.
- const reuse: ?usize = if (src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| {
+ // From a column's tag, only a finished pane in that column is reused,
+ // as the new one goes in that column (placeNew).
+ const reuse: ?usize = if (p.exec_column == null and src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| {
const other = slot orelse continue;
+ if (p.exec_column) |c| if (if (layout.findPane(p, i)) |f| f.col != c else true) continue;
if (other.command != null and other.command_done and !other.command_pty and std.mem.eql(u8, other.cwdSlice(), dir)) break i;
} else null;
if (reuse) |id| {
@@ -604,7 +667,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
if (panes.Terminal.onAlternateScreen(pane)) panes.Terminal.feedOutput(p, pane, "\x1b[?1049l");
panes.Terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m");
echoCommand(p, pane, line);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
+ p.emitSpawn(id, pane.serial, pane.cwdSlice());
noteRun(p, pane, "run", line);
return id;
}
@@ -670,6 +733,15 @@ pub fn commandText(txt: []const u8) []const u8 {
return cmd;
}
+/// The builtin a command line's first word names, as acme's lookup reads
+/// only the first word (editors/acme/exec.c, lookup): what an event's
+/// flag 1 says.
+pub fn namesBuiltin(txt: []const u8) bool {
+ const cmd = commandText(txt);
+ const end = std.mem.indexOfAny(u8, cmd, " \t") orelse cmd.len;
+ return std.meta.stringToEnum(Builtin, cmd[0..end]) != null;
+}
+
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
const cmd = commandText(txt);
if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false;
@@ -726,12 +798,22 @@ pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?
// A builtin says its own name before it runs, so a tagline click is
// legible even when what it did is invisible. Whatever the builtin
// then says for itself replaces the announcement.
+ // Its log record stands only if it does not fail (events.dropFailedAnnouncement).
+ var announced: ?u64 = null;
if (p.announce and p.settings.verbose and builtins.registry.announces(b)) {
p.setMessage(id, @tagName(b));
pane.msg_announcement = true;
+ announced = pardes.ctlfs.events.newestSaying(p, @tagName(b));
+ p.fs.announced_seq = announced;
+ const word = @tagName(b)[0..@min(@tagName(b).len, p.fs.announced_word.len)];
+ @memcpy(p.fs.announced_word[0..word.len], word);
+ p.fs.announced_word_len = @intCast(word.len);
}
+ const failures = p.fs.failures;
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg, .from_body = from_body };
builtins.registry.dispatch(b, c);
+ if (announced != null and p.fs.failures != failures and p.fs.announced_seq == announced)
+ pardes.ctlfs.events.dropFailedAnnouncement(p, "", true);
}
pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void {
@@ -761,6 +843,26 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return;
p.font_request_taken = false;
},
+ // A shell that is no executable is refused, not taken and quietly
+ // replaced at the next spawn; bare, it goes back to the default.
+ .shell => {
+ const want = std.mem.trim(u8, arg orelse "", " \t\r\n");
+ if (want.len == 0) {
+ p.settings.shell.requested.clear();
+ p.settings.shell.pending = true;
+ if (!p.announce) return;
+ var said: [320]u8 = undefined;
+ return p.setMessage(p.active, std.fmt.bufPrint(&said, "Shell: the default again, {s} ($SHELL, else /bin/sh)", .{config.defaultShell()}) catch "Shell: the default again");
+ }
+ if (comptime pardes.hosted) {
+ var why: [320]u8 = undefined;
+ if (@import("host_io.zig").Shell.refusal(want[0..@min(want.len, 200)], &why)) |refused| {
+ var text: [340]u8 = undefined;
+ return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Shell: {s}", .{refused}) catch "Shell: no such shell");
+ }
+ }
+ if (!p.settings.apply(setting, want) and p.announce) p.reportFailure(p.active, "Shell: does not take that value");
+ },
else => if (!p.settings.apply(setting, arg) and p.announce) {
var text: [96]u8 = undefined;
const takes = if (setting.action == .toggle) "takes on or off" else "does not take that value";
@@ -808,19 +910,61 @@ pub const NewPane = enum { scratch, fs, shell, doc, command, errors };
/// t->w == nil).
pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void {
const asked = if (p.exec_column != null) null else origin;
+ layout.compute(p);
+ const before = p.rects;
switch (p.settings.placement) {
.acme => switch (kind) {
// New: the bottom half of the last pane in its own column
// (look.c:921-923, et->col).
.scratch => layout.acmePlace(p, p.exec_column orelse columnOf(p, from), null, id),
- // +Errors, a command's output: the last column (util.c:94-98).
- .command, .errors => layout.acmePlace(p, p.ncol - 1, null, id),
+ // +Errors, a command's output: the last column (util.c:94-98);
+ // a command run from a column's tag, that column.
+ .command => layout.acmePlace(p, p.exec_column orelse p.ncol - 1, null, id),
+ .errors => layout.acmePlace(p, p.ncol - 1, null, id),
// makenewwindow(nil) for 9P's new (acme.c:877), else (t).
.fs => layout.acmePlace(p, acmeColumn(p, from), null, id),
.shell, .doc => layout.acmePlace(p, acmeColumn(p, from), asked, id),
},
.pardes => pardesPlace(p, from, id, kind),
}
+ // No pane is made shorter than its tag and two rows: where the place
+ // chosen has not the room, the column's tallest pane gives it, and
+ // where none can, the pane is refused, and closed at the step's end
+ // (Pardes.unplaced) once whatever made it is done with it.
+ if (!layout.placedRoomy(p, id, &before)) if (layout.findPane(p, id)) |f| {
+ layout.removePane(p, id);
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| p.rects[k] = before[k];
+ layout.snapColWeights(p, f.col);
+ if (!layout.splitTallest(p, f.col, id) or !layout.placedRoomy(p, id, &before)) {
+ if (layout.findPane(p, id) == null) layout.insert(p, f.col, p.col_n[f.col], id);
+ // +Errors goes wherever there is room, last column first; with
+ // none, its maker says the text some other way (appendErrors).
+ if (kind == .errors) {
+ layout.removePane(p, id);
+ var c = p.ncol;
+ while (c > 0) {
+ c -= 1;
+ if (c == f.col) continue;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| p.rects[k] = before[k];
+ layout.snapColWeights(p, c);
+ if (p.col_n[c] == 0) {
+ layout.insert(p, c, 0, id);
+ break;
+ }
+ if (layout.splitTallest(p, c, id) and layout.placedRoomy(p, id, &before)) break;
+ if (layout.findPane(p, id) != null) layout.removePane(p, id);
+ } else {
+ layout.insert(p, f.col, p.col_n[f.col], id);
+ p.unplaced.set(id);
+ return;
+ }
+ } else {
+ p.unplaced.set(id);
+ p.reportError(from, "placement", error.NoPaneRoom);
+ return;
+ }
+ }
+ };
if (kind == .command) return;
tagline.exitHeader(p);
p.active = id;
@@ -865,7 +1009,7 @@ fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void {
.shell => belowParent(p, from, id),
.doc, .errors => pardesDoc(p, from, id),
.command => {
- const rc = p.ncol - 1;
+ const rc = p.exec_column orelse p.ncol - 1;
if (p.col_n[rc] > 0) belowParent(p, p.col_panes[rc][p.col_n[rc] - 1], id) else layout.insert(p, rc, 0, id);
},
}
diff --git a/src/file_watch.zig b/src/file_watch.zig
index 5d998470..d028488e 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -60,6 +60,10 @@ pub const Watch = struct {
kq: c_int = -1,
serial: u32,
generation: Generation,
+ /// The path last read while watched (its hash): its going away later is
+ /// a deletion to say, where a path never there (a pane renamed to a new
+ /// name) is not.
+ existed: ?u64 = null,
};
/// The final slot is the one non-pane watched payload: the active ThemeFile.
@@ -221,8 +225,10 @@ fn markDir(fd: c_int, dir_z: [:0]const u8, watches: *const Table) c_int {
switch (builtin.os.tag) {
.linux => {
// CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE
- // cover rename-over and delete-then-recreate saves.
- const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
+ // cover rename-over and delete-then-recreate saves; DELETE and
+ // MOVED_FROM a file going away, said at once (File.deleted), not
+ // when something else next changes in the directory.
+ const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.DELETE | linux.IN.MOVED_FROM | linux.IN.ONLYDIR;
return libc.inotify_add_watch(fd, dir_z, mask);
},
.macos => {
@@ -440,7 +446,12 @@ pub fn reloadPane(
}
if (pane.file) |file| {
- const bytes = filesystem.read(core, file.path) catch return false;
+ const bytes = filesystem.read(core, file.path) catch |err| {
+ // Gone from under the pane: said once, the text kept.
+ if (err == error.FileNotFound and watched.existed == std.hash.Wyhash.hash(0, file.path)) pardes.panes.File.deleted(core, @intCast(id));
+ return false;
+ };
+ if (watches[id]) |*live| live.existed = std.hash.Wyhash.hash(0, file.path);
defer core.gpa.free(bytes);
const hash = std.hash.Wyhash.hash(0, bytes);
switch (watched.generation) {
@@ -517,6 +528,7 @@ pub fn applyEffect(
const bytes = filesystem.read(core, file.path) catch return false;
defer core.gpa.free(bytes);
watched.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) };
+ watched.existed = std.hash.Wyhash.hash(0, file.path);
return false;
};
return on and watches[id] != null and reloadPane(core, io, watches, id, false);
@@ -665,6 +677,13 @@ test "restored file watches preserve snapshots and accept later disk changes" {
try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" });
_ = reloadChanged(core, io, gpa, &watches);
+ // A snapshot restored with unsaved text keeps it: a later disk
+ // change is said, never loaded over it (File.changed).
+ if (case.dirty) {
+ try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
+ try std.testing.expect(pane.file.?.disk_newer != null);
+ continue;
+ }
try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content);
pardes.panes.File.undo(core, pane);
try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
@@ -821,3 +840,36 @@ test "theme watch reloads valid ZON and keeps the last theme across a bad save"
try std.testing.expect(!reloadTheme(core, gpa, &watches, false));
try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg);
}
+
+test "a watched file deleted wakes the watch at once, and its pane says so" {
+ if (comptime !supported or !filesystem.platform_has_fs or builtin.os.tag != .linux) return;
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "here\n" });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ while (core.nextEffect()) |_| {}
+ const id: u8 = @intCast(core.freeSlot().?);
+ const pane = try pardes.panes.File.open(core, id, path, 0);
+ const fd = init(true);
+ if (fd < 0) return error.NoWatcher;
+ defer _ = libc.close(fd);
+ var watches: Table = @splat(null);
+ defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 });
+ while (core.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == id and watch.on) {
+ _ = applyEffect(core, io, fd, &watches, id, watch.on, watch.mode);
+ },
+ else => {},
+ };
+ _ = drain(fd);
+ try tmp.dir.deleteFile(io, "watched");
+ var pfd = [_]libc.pollfd{.{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }};
+ try std.testing.expect(libc.poll(&pfd, 1, 1000) > 0);
+ _ = reloadChanged(core, io, gpa, &watches);
+ try std.testing.expect(pane.file.?.disk_gone);
+}
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 73a49e9b..bd822b60 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -1,28 +1,24 @@
Pardes control filesystem
=========================
README this guide
-index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed), column serial
+index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (<dir>/+New if unnamed), column serial; rsplit(maxsplit=1) first, names hold spaces
status pid, version and pane count
-look write a line: a right click on it at the active pane; read: the serials it touched
-exec write a line: a middle click, an editor command word or a shell line; read the same
-log events: new/del/rename/save <serial> <name>, newcol/delcol <column serial>, run/exit/send <serial> ...,
- ask <serial> <what> <choices>, dump/restore <path>, restored/restoredcol <old> <new>, msg, err
+look write a line: a right click on it at the active pane; read: the serials it touched (this open's)
+exec write a line: a middle click, an editor command word or a shell line; read the same (every exec file)
+log events: new del rename save newcol delcol run exit send ask answer changed dump restore restored restoredcol msg err
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard
-ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word)
-commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it
-layout one line per column: serial index x width current|notcurrent empty|full pane-serials...; then active <serial>
-tag the workspace tag: read it, > replaces it, >> appends (one line)
-col/<n>/tag the tag of the column with serial n, the same way (a serial stays while the column lives)
-col/<n>/ctl Delcol, Joincol, New or Tty on that column; col/<n>/exec a word as a click in its tag; rmdir col/<n> closes an empty one
-pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial
+ctl settings a line; or Newcol, Dump, Restore [f], Exit (asks once), Kill [w], Placement acme|pardes, size C R (--detach)
+commands every builtin: word, arg if it takes one, root|pane|both (the ctl that takes it), -- what it does
+layout a line per column: serial index x width current|notcurrent empty|full pane-serials; active <serial>
+tag the workspace tag, tagexec a word as a click in it; col/<serial>/ a column's tag, ctl (Delcol Joincol New Tty), exec; rmdir: empty only
+pane/new open it to make a pane (<session dir>/+New) in the active column, filling it if empty; the read answers its serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only:
- appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes
+ appended to its directory's +Errors pane) event look exec tagexec, pty/ on terminals; rmdir closes
os/, src/ the host filesystem; the editor's own sources
-Below, $m is the mount: $NINE_MOUNT/pardes/<pid, or NAME of --detach=NAME>; PARDES_MOUNT in a Tty9p shell;
-$NINE_MOUNT itself under `9ns --unix SOCKET -- cmd`, which mounts one session as its root.
+$m below: $NINE_MOUNT/pardes/<pid|NAME>; $NINE_MOUNT under 9ns --unix; PARDES_MOUNT in a Tty9p shell.
cat $m/index which panes exist
n=$(cat $m/pane/new) make a pane, take its serial
@@ -31,21 +27,19 @@ $NINE_MOUNT itself under `9ns --unix SOCKET -- cmd`, which mounts one session as
echo notes.txt > $m/pane/$n/name; echo Save > $m/pane/$n/ctl rename, then save it
echo 'Msg hello' > $m/exec show text in the editor
echo 'Edit ,x/foo/c/bar/' > $m/pane/$n/ctl rename every foo, one undo step (sam's commands)
- cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it
exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more
- cat $m/pane/$n/event hold it open to own that pane's clicks
echo Tty > $m/pane/$n/ctl; cat $m/index a terminal in that pane's directory
- exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N then its output; or busy, or error
+ exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N then its output; or busy: <program> is running, or error
printf 'ls\r' > $m/pane/$n/pty/data type into a terminal; pty/status: cols rows busy
echo 'sig INT' > $m/pane/$n/pty/ctl interrupt it; also winsize C R, and exec to restart
Pitfalls, one each:
Each open of pane/new makes another pane (ls, stat, find never do); rmdir closes even a dirty one.
- addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line); > never resets it, 0 does; a miss refuses data.
+ addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line), bytes snapped to runes; 0 resets; a miss refuses data.
data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all.
tag reads the path, then its own text; > replaces that text (default words too), >> appends.
A terminal's body is a history snapshot frozen per open, wrapped rows joined into lines; pty/data is the live stream.
- exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in.
- Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why.
- lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane leaves it empty; the last pane of all quits.
+ exec: a non-builtin line (at most 1024 bytes) runs as a command pane (Shell -c), a shell's typed in; once whole.
+ Through a mount a malformed write is EINVAL, a failed one EIO or an errno that fits (ENOENT): log's err says why.
+ lock/unlock need a held ctl fd (exec 3>ctl); a held lock fails: retry. fs.md has the rest.
Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands.
diff --git a/src/fs.zig b/src/fs.zig
index 8be728a6..4414c150 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1271,6 +1271,16 @@ pub const Namespace = struct {
/// The root ctl is running a session builtin: its messages are the
/// session's, logged `msg -`, though shown on the active pane.
session_write: bool = false,
+ /// A ctl write's failure is being said: its err record logs it, so
+ /// no msg does (Messages.reportFailure).
+ unlogged: bool = false,
+ /// A 9P write of clicks (look, exec, tagexec, a column's exec) is running
+ /// one: a builtin's failure is the write's, as a ctl line's is (no_prompt,
+ /// which also refuses prompts, which a click may open).
+ capturing: bool = false,
+ /// A 9P request is being served (Pardes.serveFs): a prompt it opens is
+ /// a question for the log (`ask`), answered with `answer`.
+ serving: bool = false,
refused: bool = false,
failure: [256]u8 = undefined,
failure_len: u16 = 0,
@@ -1288,6 +1298,19 @@ pub const Namespace = struct {
header_held: bool = false,
late_failure: [256]u8 = undefined,
late_failure_len: u16 = 0,
+ /// The log record of the last Verbose announcement (`msg <serial>
+ /// <Word>`) and its word: a failure of that builtin, now or when its
+ /// host work fails (a Save, a Dump), takes it back out of the log, so
+ /// an announcement stands only for a builtin that did what it said.
+ announced_seq: ?u64 = null,
+ /// The serial of the pane the root's exec or look last ran a line at
+ /// (the keyboard's), 0 before one: its err record names that pane.
+ ran_at: u32 = 0,
+ /// Every failure said (Messages.reportFailure), counted: a builtin
+ /// that said one while it ran failed.
+ failures: u32 = 0,
+ announced_word: [32]u8 = undefined,
+ announced_word_len: u8 = 0,
/// A refusal that quotes the message it refuses, as Plan 9's cmderror
/// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
ename: [320]u8 = undefined,
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index f74d5a0a..bd2dca69 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4097,6 +4097,7 @@ const Shell = struct {
.pty_signal = ptySignal,
.close_pty = closePaneShell,
.tty_taken = ttyTaken,
+ .fg_name = fgName,
.kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
@@ -4559,6 +4560,13 @@ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
return host_io.ttyTaken(pt.pid, pt.fd);
}
+fn fgName(ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 {
+ const s = shellOf(ctx);
+ const pt = s.ptys[pane] orelse return null;
+ if (pt.fd < 0) return null;
+ return host_io.foregroundName(pt.pid, pt.fd, buf);
+}
+
fn killJob(ctx: ?*anyopaque, pane: u8) bool {
const s = shellOf(ctx);
const pt = s.ptys[pane] orelse return false;
diff --git a/src/host_io.zig b/src/host_io.zig
index df78f73c..1db0dac9 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -25,6 +25,9 @@ pub const Host = struct {
pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null,
close_pty: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null,
tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null,
+ /// The name of the program holding the tty, when it is not the
+ /// shell (host_io.foregroundName), in `buf`.
+ fg_name: ?*const fn (ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 = null,
/// Answered at once, not queued as an effect: Kill asks while the
/// job it saw running is still the one in the tty (host_io.killJob).
kill_job: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null,
@@ -834,7 +837,7 @@ pub const Shell = struct {
@memcpy(buf[0..bin.len], bin);
buf[bin.len] = 0;
const p: [*:0]const u8 = @ptrCast(buf);
- return if (libc.access(p, X_OK) == 0) p else null;
+ return if (libc.access(p, X_OK) == 0 and !isDirectory(p)) p else null;
}
for (bin_dirs) |dir| {
if (dir.len + bin.len + 1 > buf.len) continue;
@@ -847,6 +850,26 @@ pub const Shell = struct {
return null;
}
+ /// A directory passes access(X_OK) (it can be searched): no shell.
+ pub fn isDirectory(path: [*:0]const u8) bool {
+ const d = libc.opendir(path) orelse return false;
+ _ = libc.closedir(d);
+ return true;
+ }
+
+ /// Why `bin` is no shell, for the words that name one (Tty, Shell);
+ /// null when it is one.
+ pub fn refusal(bin: []const u8, said: []u8) ?[]const u8 {
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ if (find(bin, &buf) != null) return null;
+ if (std.mem.indexOfScalar(u8, bin, '/') != null and bin.len < buf.len) {
+ @memcpy(buf[0..bin.len], bin);
+ buf[bin.len] = 0;
+ if (isDirectory(@ptrCast(&buf))) return std.fmt.bufPrint(said, "not a shell: {s} is a directory", .{bin}) catch "not a shell: a directory";
+ }
+ return std.fmt.bufPrint(said, "no shell \"{s}\" (a name on the usual paths, or a path to one)", .{bin}) catch "no such shell";
+ }
+
fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 {
for (fallbacks) |f| {
@memcpy(buf[0..f.len], f);
@@ -1673,7 +1696,9 @@ fn reapShell(pid: libc.pid_t, grace_ms: u32) void {
/// The tty's foreground job is signalled. A command pane's shell (`command`)
/// is its command too: its line runs with job control on, so the job
/// running is in a group of its own, and the shell's group goes as well, or
-/// the rest of the line would run on after the job it was on.
+/// the rest of the line would run on after the job it was on. A stop (TERM
+/// or KILL) of a command reaches its `&` jobs too, each in a group of its
+/// own in the shell's session: Kill leaves nothing the line started.
pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal, command: bool) void {
const sig = switch (which) {
.int => libc.SIG.INT,
@@ -1683,11 +1708,42 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna
.kill => libc.SIG.KILL,
};
const fg = tcgetpgrp(master_fd);
+ if (command and shell_pid > 0 and (which == .term or which == .kill)) signalSession(shell_pid, sig);
if (fg > 0) _ = libc.kill(-fg, sig);
if (command and shell_pid > 0 and fg != shell_pid) _ = libc.kill(-shell_pid, sig);
if (fg <= 0 and !command and shell_pid > 0) _ = libc.kill(shell_pid, sig);
}
+/// Every process group in the session `sid` leads, but its own (which the
+/// caller signals last). forkpty made the shell the session's leader, and
+/// job control moves a job to a new group, never out of the session.
+// ponytail: Linux only (a /proc scan); elsewhere a `&` job outlives Kill,
+// add sysctl KERN_PROC_SESSION on macOS if that matters.
+fn signalSession(sid: libc.pid_t, sig: libc.SIG) void {
+ if (comptime builtin.os.tag != .linux) return;
+ const d = libc.opendir("/proc") orelse return;
+ defer _ = libc.closedir(d);
+ var buf: [4096]u8 = undefined;
+ while (libc.readdir(d)) |ent| {
+ const name = std.mem.sliceTo(&ent.name, 0);
+ const pid = std.fmt.parseInt(libc.pid_t, name, 10) catch continue;
+ var path_buf: [64:0]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&path_buf, "/proc/{d}/stat", .{pid}, 0) catch continue;
+ const ids = parseGroupSession(readProc(path, &buf) orelse continue) orelse continue;
+ if (ids.session == sid and ids.pgrp != sid) _ = libc.kill(-ids.pgrp, sig);
+ }
+}
+
+fn parseGroupSession(stat: []const u8) ?struct { pgrp: libc.pid_t, session: libc.pid_t } {
+ const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null;
+ var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n");
+ _ = fields.next() orelse return null; // 3: state
+ _ = fields.next() orelse return null; // 4: ppid
+ const pgrp = std.fmt.parseInt(libc.pid_t, fields.next() orelse return null, 10) catch return null;
+ const session = std.fmt.parseInt(libc.pid_t, fields.next() orelse return null, 10) catch return null;
+ return .{ .pgrp = pgrp, .session = session };
+}
+
/// Kill: SIGTERM to the job in the tty's foreground, never to the shell.
/// The shell leads its own group, and a job it runs has another; with job
/// control off (`set +m`, a shell run as `sh -c`) the job shares the shell's
@@ -1698,6 +1754,18 @@ pub fn killJob(shell_pid: libc.pid_t, master_fd: c_int) bool {
return libc.kill(-fg, libc.SIG.TERM) == 0;
}
+/// The command name of the tty's foreground job, when that is not the
+/// shell: what `pty/run` says is running. Linux only (/proc/<pgrp>/comm).
+pub fn foregroundName(shell_pid: libc.pid_t, master_fd: c_int, buf: []u8) ?[]const u8 {
+ if (comptime builtin.os.tag != .linux) return null;
+ const fg = tcgetpgrp(master_fd);
+ if (fg <= 0 or fg == shell_pid) return null;
+ var name: [64:0]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/comm", .{@as(u32, @intCast(fg))}, 0) catch return null;
+ const comm = std.mem.trimEnd(u8, readProc(path, buf) orelse return null, "\n");
+ return if (comm.len > 0) comm else null;
+}
+
fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 {
const fd = libc.open(path, .{ .ACCMODE = .RDONLY });
if (fd < 0) return null;
@@ -2076,7 +2144,7 @@ test "a command's exit is its process's, told by its watcher, whether or not its
}
}
-test "a command's background job outlives it and its pty, and Kill stops its whole line" {
+test "a command's background job outlives it and its pty, and Kill stops its whole line, its & jobs too" {
if (comptime !tty_probe_platform) return error.SkipZigTest;
if (!haveFile("/bin/sh")) return error.SkipZigTest;
const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
@@ -2094,6 +2162,8 @@ test "a command's background job outlives it and its pty, and Kill stops its who
.{ .line = "(sleep 0.4; touch left) & exit 0", .kill = false, .made = true },
// The job running is in its own group; the shell goes too.
.{ .line = "sleep 0.4; touch killed", .kill = true, .made = false },
+ // Kill stops the whole command, its `&` jobs as well.
+ .{ .line = "(sleep 0.4; touch killed) & sleep 5", .kill = true, .made = false },
}) |c| {
const id = core.freeSlot().?;
_ = try core.newCommand(id, dir, c.line);
diff --git a/src/layout.zig b/src/layout.zig
index b9b24e79..09cf23b3 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -274,7 +274,11 @@ pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void {
const rb = p.rects[p.col_panes[cc][k + 1]];
const combined: f32 = @floatFromInt(ra.h + rb.h);
var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y);
- nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H));
+ // A terminal keeps its tag and two body rows (floorRows); a text pane
+ // gives up all but its tag, as acme's do.
+ const lo: f32 = @floatFromInt(@max(BOX_H, floorRows(p, p.col_panes[cc][k])));
+ const hi = combined - @as(f32, @floatFromInt(@max(BOX_H, floorRows(p, p.col_panes[cc][k + 1]))));
+ nt = std.math.clamp(nt, @min(lo, @max(@as(f32, BOX_H), hi)), @max(@as(f32, BOX_H), hi));
if (combined <= 0) return;
// Dragging a folded tag open is an explicit resize, so switch this pair
// back to visible-height weights without consuming other folded weights.
@@ -408,6 +412,115 @@ pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void {
splitAt(p, c, id, y);
}
+/// The body rows a placement leaves every pane it makes or shrinks, under
+/// its tag (acme's minht keeps one).
+pub const min_body_rows = 2;
+
+/// The rows a placement, and a `size`, leave a pane: its tag at its width
+/// and `min_body_rows`. The one minimum both ask.
+pub fn minRows(p: *Pardes, id: usize) u16 {
+ return p.wantedTagRows(id) + min_body_rows;
+}
+
+/// Whether every column has the rows for each of its unfolded panes'
+/// `minRows` (a folded one its tag row): the panes' own minima, not how
+/// the weights happen to share the rows, so a size that fits keeps fitting
+/// when it grows. One pane alone in its column always fits.
+pub fn minimaFit(p: *Pardes) bool {
+ compute(p);
+ const avail = p.screen_h -| (p.topBarHeight() + p.columnBarHeight());
+ for (0..p.ncol) |c| {
+ if (p.col_n[c] < 2) continue;
+ var need: u32 = 0;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ need += if (pane.collapsed) BOX_H else minRows(p, k);
+ }
+ if (need > avail) return false;
+ }
+ return true;
+}
+
+/// After a resize the minima fit: each unfolded pane the resize took below
+/// its `minRows` (it had them in `before`) gets them back, a row at a time
+/// from whichever pane of its column has the most above its own. A pane
+/// dragged short before keeps what it had.
+pub fn restoreMinima(p: *Pardes, before: *const [MAX_PANES]Rect) void {
+ compute(p);
+ for (0..p.ncol) |c| {
+ const n = p.col_n[c];
+ if (n < 2) continue;
+ var hs: [MAX_PANES]u16 = undefined;
+ var mins: [MAX_PANES]u16 = undefined;
+ var short = false;
+ for (p.col_panes[c][0..n], 0..) |k, i| {
+ const pane = p.panes[k] orelse return;
+ if (pane.collapsed) break;
+ hs[i] = p.rects[k].h;
+ mins[i] = minRows(p, k);
+ if (hs[i] < mins[i] and before[k].h >= mins[i]) short = true;
+ } else {
+ if (!short) continue;
+ for (p.col_panes[c][0..n], 0..) |k, i| {
+ if (hs[i] >= mins[i] or before[k].h < mins[i]) continue;
+ while (hs[i] < mins[i]) {
+ var giver: ?usize = null;
+ var spare: u16 = 0;
+ for (0..n) |j| if (j != i and hs[j] -| mins[j] > spare) {
+ spare = hs[j] - mins[j];
+ giver = j;
+ };
+ const g = giver orelse break;
+ hs[g] -= 1;
+ hs[i] += 1;
+ }
+ }
+ for (p.col_panes[c][0..n], 0..) |k, i| p.panes[k].?.vweight = @floatFromInt(@max(1, hs[i]));
+ }
+ }
+ compute(p);
+}
+
+/// After placing `id`: whether it, and every pane of its column that the
+/// placement shrank, still has its tag and `min_body_rows` (from `before`,
+/// the rects as they were). A pane alone in its column always has room: the
+/// window can be no taller.
+pub fn placedRoomy(p: *Pardes, id: usize, before: *const [MAX_PANES]Rect) bool {
+ compute(p);
+ const f = findPane(p, id) orelse return true;
+ if (p.col_n[f.col] == 1) return true;
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ if (pane.collapsed) continue;
+ if (k != id and p.rects[k].h >= before[k].h) continue;
+ if (p.rects[k].h < minRows(p, k)) return false;
+ }
+ return true;
+}
+
+/// Places `id` last in column `c`, where acme's coladd puts a new window,
+/// with half the rows of the column's tallest unfolded pane, when that
+/// leaves both their rows; false, and `id` in no column, when not.
+pub fn splitTallest(p: *Pardes, c: usize, id: usize) bool {
+ compute(p);
+ var tall: ?usize = null;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (k == id or p.panes[k].?.collapsed) continue;
+ if (tall == null or p.rects[k].h > p.rects[tall.?].h) tall = k;
+ }
+ const v = tall orelse return false;
+ const h = p.rects[v].h;
+ // Its tag wraps at the column's width: measured there, not at none.
+ p.rects[id].w = p.col_w[c];
+ if (h < minRows(p, v) + minRows(p, id)) return false;
+ snapColWeights(p, c);
+ insert(p, c, p.col_n[c], id);
+ const given = std.math.clamp(h / 2, minRows(p, id), h - minRows(p, v));
+ p.panes[v].?.vweight = @floatFromInt(h - given);
+ p.panes[id].?.vweight = @floatFromInt(given);
+ return true;
+}
+
/// acme's coladd at row `y` (cols.c:66-126): the new pane goes under the
/// pane that `y` falls in, which keeps its rows above `y`.
fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
@@ -419,9 +532,9 @@ fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
snapColWeights(p, c);
insert(p, c, k + 1, id);
const pane = p.panes[id].?;
- // Each keeps a tag and a body row, as acme's minht does.
- const lo = r.y +| BOX_H + 1;
- const hi = (r.y + r.h) -| (BOX_H + 1);
+ // Each keeps a tag and min_body_rows, where acme's minht keeps one.
+ const lo = r.y +| minRows(p, v);
+ const hi = (r.y + r.h) -| minRows(p, id);
const row = splitRowForExtent(r.y, r.h, if (lo <= hi) std.math.clamp(y, lo, hi) else r.y + r.h / 2) orelse {
pane.vweight = BOX_H + 1;
return;
@@ -550,7 +663,7 @@ pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) ?usize {
}
pub fn splitParent(p: *Pardes, want: usize) usize {
- const need = 2 * BOX_H + 3;
+ const need = 2 * (BOX_H + min_body_rows);
if (p.rects[want].h >= need) return want;
if (findPane(p, want)) |f| for (0..p.col_n[f.col]) |k| {
if (p.rects[p.col_panes[f.col][k]].h >= need) return p.col_panes[f.col][k];
@@ -567,23 +680,28 @@ pub fn splitParent(p: *Pardes, want: usize) usize {
pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
const src = p.panes[src_id] orelse return;
const src_h = p.rects[src_id].h;
- const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ // Its tag may wrap to more than one row: the rows under it are its body.
+ const tag_h = @max(BOX_H, src.tag_rows);
+ const body: u16 = if (src_h > tag_h) src_h - tag_h else 1;
const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1;
// cap keep so a content-full source still leaves the new pane a tag +
// a few body rows (an Alt-n from a full shell was born 0 rows tall)
- const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3)));
+ // ...and the source min_body_rows of its own (exec.placeNew), when it
+ // has them: a boot layout splits before any row is laid out.
+ const least = @min(min_body_rows, body);
+ const keep = std.math.clamp(cur, least, @max(least, body -| (BOX_H + 3)));
if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| {
const pid = p.col_panes[f.col][k];
if (p.panes[pid]) |pp| if (pp != nw and !pp.collapsed) {
pp.vweight = @floatFromInt(@max(1, p.rects[pid].h));
};
};
- if (!src.collapsed) src.vweight = @floatFromInt(BOX_H + keep);
- nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep)));
+ if (!src.collapsed) src.vweight = @floatFromInt(tag_h + keep);
+ nw.vweight = @floatFromInt(@max(1, src_h -| (tag_h + keep)));
if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) {
// trimmed: every row ends in a newline, and the empty line after
// the last one is not a result
- const want: f32 = @floatFromInt(BOX_H + panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n")));
+ const want: f32 = @floatFromInt(BOX_H + @max(min_body_rows, panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n"))));
if (want < nw.vweight) {
src.vweight += nw.vweight - want;
nw.vweight = want;
@@ -727,16 +845,62 @@ pub fn compute(p: *Pardes) void {
if (vsum <= 0) vsum = 1;
var y: u16 = p.topBarHeight() + p.columnBarHeight();
+ const top = y;
const avail_h = p.screen_h -| y;
- for (0..p.col_n[c]) |k| {
+ var hs: [MAX_PANES]u16 = undefined;
+ const n = p.col_n[c];
+ for (0..n) |k| {
const id = p.col_panes[c][k];
- const pane = p.panes[id] orelse continue;
- const lastk = k + 1 == p.col_n[c];
+ const pane = p.panes[id] orelse {
+ hs[k] = 0;
+ continue;
+ };
+ const lastk = k + 1 == n;
const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum;
const room = p.screen_h -| y;
- const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh)))));
- p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
- y +|= ch;
+ hs[k] = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh)))));
+ y +|= hs[k];
+ }
+ keepTerminalFloors(p, c, hs[0..n]);
+ y = top;
+ for (0..n) |k| {
+ const id = p.col_panes[c][k];
+ if (p.panes[id] == null) continue;
+ p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = hs[k] };
+ y +|= hs[k];
+ }
+ }
+}
+
+/// The fewest rows a pane is laid out with: a terminal keeps its tag and
+/// `min_body_rows` (a shorter one loses its prompt and reads busy for
+/// ever), a text pane may shrink to its tag, as acme's windows do.
+fn floorRows(p: *Pardes, id: usize) u16 {
+ const pane = p.panes[id] orelse return 0;
+ return if (pane.isTerminal()) @max(1, pane.tag_rows) + min_body_rows else 1;
+}
+
+/// Raises each terminal in column `c` below its floor to it, a row at a
+/// time from whichever pane has the most rows above its own floor. Where
+/// the column has not the rows, the terminal keeps what it could get.
+// ponytail: a row per step, O(panes x rows); a column holds few of both.
+fn keepTerminalFloors(p: *Pardes, c: usize, hs: []u16) void {
+ for (hs, 0..) |*h, k| {
+ const floor = floorRows(p, p.col_panes[c][k]);
+ while (h.* < floor) {
+ var giver: ?usize = null;
+ var spare: u16 = 0;
+ for (hs, 0..) |other, j| {
+ if (j == k) continue;
+ const extra = other -| floorRows(p, p.col_panes[c][j]);
+ if (extra > spare) {
+ spare = extra;
+ giver = j;
+ }
+ }
+ const g = giver orelse break;
+ hs[g] -= 1;
+ h.* += 1;
}
}
}
@@ -844,9 +1008,10 @@ test "collapse local transfers remain bounded through repeated tiny window resiz
test "collapse expansion is limited to space available in one neighbor" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 42 });
defer p.deinit();
+ // Text panes: a terminal would keep its tag and two rows (floorRows).
for (1..4) |id| {
- _ = try p.newShell(id, "");
- insert(p, 0, id, id);
+ p.newScratchBelow(0);
+ try std.testing.expectEqual(id, p.col_panes[0][id]);
}
compute(p);
toggleCollapse(p, 2);
diff --git a/src/look.zig b/src/look.zig
index 17746cd1..4ee5a638 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -360,7 +360,12 @@ pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbu
for (word[config.pane_addr.len..]) |c| {
if (!std.ascii.isDigit(c)) break;
id = id *| 10 +| (c - '0');
- } else return .{ .pane = .{ .id = id, .at = pl.at } };
+ } else {
+ // `@p<serial>`: the pane's serial, as /index and the log name
+ // it, not its slot; one that is not open names none.
+ const slot = if (p) |pp| (pp.paneBySerial(@intCast(@min(id, std.math.maxInt(u32)))) orelse MAX_PANES) else id;
+ return .{ .pane = .{ .id = slot, .at = pl.at } };
+ }
}
for (config.url_schemes) |scheme| {
@@ -635,7 +640,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
const loc: []const u8 = if (has_path)
std.fs.path.basename(pane.file.?.path)
else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong;
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{pane.serial}) catch return error.PathTooLong;
const cl: usize = @intCast(@max(0, pane.body.cur_row));
const cc: usize = @intCast(@max(0, pane.body.cur_col));
for (lines, 0..) |ln, i| {
@@ -748,6 +753,12 @@ fn lookEdge(delta: i32) LookFrom {
fn lookStand(pane: *Pane) LookFrom {
if (pane.look_at) |s| if (s.row == pane.body.cur_row and s.col0 == pane.body.cur_col)
return .{ .row = s.row, .col = s.col0, .strict = true };
+ // A selection on one row (a Restore brings the dot back as one, with no
+ // walk remembered) is where the walk stands: from its start, past it
+ // either way, as from a spot the walk landed on.
+ const v = pane.body.vsel;
+ if (v.active and v.explicit and v.row == pane.body.cur_row and v.col != pane.body.cur_col)
+ return .{ .row = v.row, .col = @min(v.col, pane.body.cur_col), .strict = true };
return .{ .row = pane.body.cur_row, .col = pane.body.cur_col };
}
@@ -1127,7 +1138,7 @@ fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 {
if (comptime pdf_enabled) if (pane.pdf) |doc| return locationText(p, doc.path, target.at);
if (pane.image) |iv| return locationText(p, iv.path, target.at);
var path: [32]u8 = undefined;
- return locationText(p, std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at);
+ return locationText(p, std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{pane.serial}) catch return null, target.at);
},
}
}
@@ -1175,14 +1186,35 @@ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) v
/// A look that found nothing: said on the message row, logged as an `err`
/// record, and marked so that /look reads back nothing (ninep/ctl.zig run).
+/// The most of a looked-at path or text a miss repeats; past it, cut on a
+/// character boundary and ended with `…`.
+const miss_path_cap = 256;
+
+fn missText(text: []const u8, buf: *[miss_path_cap + "…".len]u8) []const u8 {
+ if (text.len <= miss_path_cap) return text;
+ var n: usize = miss_path_cap;
+ while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
+ @memcpy(buf[0..n], text[0..n]);
+ @memcpy(buf[n..][0.."…".len], "…");
+ return buf[0 .. n + "…".len];
+}
+
+fn missedLine(p: *Pardes, id: usize, path: []const u8, line: anytype) void {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ missed(p, id, "{s} has no line {d}", .{ missText(path, &cut), line });
+}
+
fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void {
p.look_missed = true;
- var said: [160]u8 = undefined;
+ var said: [miss_path_cap + 64]u8 = undefined;
const why = std.fmt.bufPrint(&said, fmt, args) catch "no match";
- var msg: [176]u8 = undefined;
+ var msg: [said.len + 8]u8 = undefined;
+ // Shown on the pane; logged once, as the err below, not a msg too.
+ p.fs.unlogged = true;
p.setMessage(id, std.fmt.bufPrint(&msg, "Look: {s}", .{why}) catch "Look: no match");
+ p.fs.unlogged = false;
const serial = if (p.panes[id]) |pane| pane.serial else 0;
- var rec: [200]u8 = undefined;
+ var rec: [said.len + 32]u8 = undefined;
pardes.ctlfs.events.notePath(p, "err", std.fmt.bufPrint(&rec, "{d} look: {s}", .{ serial, why }) catch return);
}
@@ -1195,7 +1227,7 @@ fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize,
if (at.line > panes.File.nlines(p.gpa, f)) {
if (opened) |free| p.removePane(free, null) catch {};
if (p.panes[before] != null) p.active = before;
- return missed(p, id, "{s} has no line {d}", .{ path, at.line });
+ return missedLine(p, id, path, at.line);
}
if (at.line > 0 and at.col == 0 and at.end_line == 0) {
const start = modal.lineStartOffset(f.content, at.line - 1);
@@ -1205,6 +1237,24 @@ fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize,
}
}
+/// A plain word looked at in a pane, as acme's look3 does with one that
+/// names no file (look.c:210, search): its next place in that pane after
+/// the dot, wrapping to the top, is selected there. No +Search pane
+/// (`LookWord list` lists every place in one instead).
+fn lookWord(p: *Pardes, id: usize, pane: *Pane, word: []const u8) void {
+ const ninep_pane = @import("ninep/pane.zig");
+ const f = pane.file orelse return;
+ const text = f.content;
+ const from: usize = @min(ninep_pane.dotOf(pane).q1, text.len);
+ const at = std.mem.indexOfPos(u8, text, from, word) orelse (std.mem.indexOf(u8, text[0..@min(text.len, from + word.len -| 1)], word) orelse {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return missed(p, id, "no match for \"{s}\"", .{missText(word, &cut)});
+ });
+ clearNavigationSelection(p, pane);
+ ninep_pane.setDot(pane, .{ .q0 = @intCast(at), .q1 = @intCast(at + word.len) });
+ p.active = id;
+}
+
/// `file:addr` or `:addr`, acme's look.c:450-543: a colon followed by an
/// address character (addr.c:28, isaddrc) that is not a line spot this
/// parser already reads (`file:12`, `file:12:5`). Not a URL.
@@ -1216,7 +1266,7 @@ fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } {
// pattern in the address may (`calc.py:/return a/`).
if (std.mem.indexOfAny(u8, tok[0..colon], " \t") != null) return null;
if (std.mem.indexOfAny(u8, expr, " \t") != null and std.mem.indexOfAny(u8, expr, "/?") == null) return null;
- if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/$.#,;", expr[0]) == null) return null;
+ if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/?$.#,;", expr[0]) == null) return null;
if (std.ascii.isDigit(expr[0])) {
const pl = parsePathLine(tok);
if (pl.at.line != 0 and pl.end == tok.len) return null;
@@ -1231,7 +1281,18 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
const ninep_pane = @import("ninep/pane.zig");
const Addr = @import("ninep/addr.zig").Addr;
var target = id;
- if (path.len > 0) {
+ // A pane already open on the path is evaluated where it stands: Look's
+ // own clean start would collapse its dot (and the looker's) before the
+ // address ever read it, so `f:/re/` again would find the same match.
+ var realbuf: [4096]u8 = undefined;
+ const open: ?usize = if (path.len == 0) null else switch (resolveLookTarget(p, id, path, &realbuf)) {
+ .pane => |t| if (t.id < MAX_PANES and p.panes[t.id] != null) t.id else null,
+ else => null,
+ };
+ // `@p<serial>:<address>`: that pane, or, not open, a miss said as one.
+ const by_serial = std.mem.startsWith(u8, path, config.pane_addr);
+ if (by_serial and open == null) return missed(p, id, "no pane {s}", .{path[0..@min(path.len, 32)]});
+ if (open) |o| target = o else if (path.len > 0) {
const before = p.active;
lookAt(p, id, path);
target = p.active;
@@ -1239,8 +1300,11 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
if (target == before and target != id) return;
}
const pane = p.panes[target] orelse return;
- const f = pane.file orelse return;
- if (path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
+ const f = pane.file orelse {
+ if (by_serial and pane.isTerminal()) return lookTerminalAddress(p, id, target, expr);
+ return missed(p, id, "{s} is no text to address", .{path[0..@min(path.len, 64)]});
+ };
+ if (!by_serial and path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
var a: Addr = .{ .text = f.content, .lim = null, .expr = expr };
const r = a.address(ninep_pane.dotOf(pane)) orelse {
return missed(p, id, "{s}", .{a.err});
@@ -1253,6 +1317,46 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
p.active = target;
}
+/// `@p<terminal>:<address>`: any address over the terminal's body as its
+/// logical lines read (the lines a +Search lists as `@p<serial>:<line>`),
+/// from its cursor; the match is selected there, as a +Search row's is.
+fn lookTerminalAddress(p: *Pardes, id: usize, target: usize, expr: []const u8) void {
+ const Addr = @import("ninep/addr.zig").Addr;
+ const pane = p.panes[target].?;
+ const lines = edit.paneCursorLines(p, &pane.body) catch return missed(p, id, "{s}", .{"out of memory"});
+ var text: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ const row: usize = @intCast(@max(0, pane.body.cur_row));
+ var dot: u32 = 0;
+ for (lines, 0..) |ln, i| {
+ if (i == row) dot = @intCast(text.items.len + @min(ln.len, @as(usize, @intCast(@max(0, pane.body.cur_col)))));
+ text.appendSlice(arena, ln) catch return;
+ text.append(arena, '\n') catch return;
+ }
+ var a: Addr = .{ .text = text.items, .lim = null, .expr = expr };
+ const r = a.address(.{ .q0 = dot, .q1 = dot }) orelse return missed(p, id, "{s}", .{a.err});
+ if (a.i < expr.len or r.q0 > r.q1)
+ return missed(p, id, "{s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"});
+ const from = spotAt(text.items, r.q0);
+ var at: Spot = .{ .line = from.line, .col = from.col };
+ if (r.q1 > r.q0) {
+ // The last byte of the match, 1-based and inclusive, as a row's range.
+ const to = spotAt(text.items, r.q1 - 1);
+ at.end_line = to.line;
+ at.end_col = to.col;
+ }
+ clearNavigationSelection(p, pane);
+ focusPaneLine(p, target, at, .center);
+}
+
+/// The 1-based line and column of byte `q` of `text`.
+fn spotAt(text: []const u8, q: u32) struct { line: usize, col: usize } {
+ const upto = text[0..@min(q, text.len)];
+ const line = std.mem.count(u8, upto, "\n") + 1;
+ const start = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |nl| nl + 1 else 0;
+ return .{ .line = line, .col = upto.len - start + 1 };
+}
+
pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const pane = p.panes[id] orelse return;
p.noteHaptic(.look);
@@ -1276,18 +1380,31 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
// A line past the end of an open pane is a miss (the target's line is
// already clamped, so the one written is asked).
if (found == .pane and found.pane.id < MAX_PANES) if (p.panes[found.pane.id]) |shown| if (shown.file) |*f| if (pl.at.line > panes.File.nlines(p.gpa, f))
- return missed(p, id, "{s} has no line {d}", .{ trimmed, pl.at.line });
- // A miss changes nothing, the selection included; anything else starts
- // from a clean one.
- if (found != .none) clearNavigationSelection(p, pane);
+ return missedLine(p, id, trimmed, pl.at.line);
+ // A miss changes nothing, the selection included; a look that stays in
+ // this pane starts from a clean one. One that goes to another pane
+ // leaves this one's dot alone, as acme's does: only the click's own
+ // highlight goes.
+ if (found == .pane and found.pane.id == id)
+ clearNavigationSelection(p, pane)
+ else if (found != .none) {
+ for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot);
+ pane.sel = @splat(.{});
+ }
switch (found) {
.none => {
- if (pane.image != null or pane.body.mode == .tty) return;
+ // A raw terminal's clicks are its program's; a look written over
+ // 9P is no keyboard input, and searches whatever the key mode.
+ if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return;
+ if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed);
const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| {
p.reportError(id, "search", err);
return;
};
- if (rows == 0) return missed(p, id, "no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)});
+ if (rows == 0) {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return missed(p, id, "no match for \"{s}\"", .{missText(trimmed, &cut)});
+ }
clearNavigationSelection(p, pane);
const at = pane.search_row;
_ = searchStep(p, id, 1);
@@ -1298,8 +1415,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
},
.pane => |t| {
- if (t.id >= MAX_PANES) return;
- const target = p.panes[t.id] orelse return;
+ // `@p<serial>` names no open pane: a miss, said.
+ if (t.id >= MAX_PANES or p.panes[t.id] == null) return missed(p, id, "no pane {s}", .{pl.path[0..@min(pl.path.len, 32)]});
+ const target = p.panes[t.id].?;
const before = p.active;
clearNavigationSelection(p, target);
focusPaneLine(p, t.id, t.at, .center);
@@ -1333,14 +1451,14 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
// touches nothing.
if (openPaneTarget(p, target.path, target.at)) |open| if (p.panes[open.pane.id].?.file) |*f|
if (target.at.line > panes.File.nlines(p.gpa, f))
- return missed(p, id, "{s} has no line {d}", .{ target.path, target.at.line });
+ return missedLine(p, id, target.path, target.at.line);
// focus an existing pane on this path (rescrolled), else open
const before = p.active;
var opened: ?usize = null;
if (!focusPaneByPath(p, target.path, target.at)) {
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
- return p.reportError(id, "look", err);
+ return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err);
if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
exec.placeNew(p, id, id, free, .doc);
layout.compute(p);
@@ -1358,7 +1476,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
if (focusPaneByPath(p, target.path, .{})) return;
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
_ = panes.Image.create(p, free, target.path, &.{}) catch |err|
- return p.reportError(id, "look", err);
+ return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err);
exec.placeNew(p, id, id, free, .doc);
},
}
@@ -1587,6 +1705,16 @@ test "Look copies its operand before releasing pointer selection storage" {
try std.testing.expectEqual(.none, p.drag);
}
+test "@p names a pane by its serial, as /index does" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("alpha\nbravo\n");
+ var realbuf: [4096]u8 = undefined;
+ var word: [32]u8 = undefined;
+ const target = resolve(p, try std.fmt.bufPrint(&word, config.pane_addr ++ "{d}:2", .{pane.serial}), "/", &realbuf);
+ try std.testing.expectEqual(p.paneBySerial(pane.serial).?, target.pane.id);
+}
+
test "Look ignores missing and out of bounds pane addresses" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
defer p.deinit();
@@ -1596,6 +1724,6 @@ test "Look ignores missing and out of bounds pane addresses" {
lookAt(p, 0, operand);
try std.testing.expectEqual(@as(usize, 0), p.active);
try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row);
- try std.testing.expect(!pane.body.vsel.active);
+ try std.testing.expect(pane.body.vsel.active); // a miss changes nothing, the selection included
}
}
diff --git a/src/modal.zig b/src/modal.zig
index cc7f1ebd..11eda7b9 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -1322,6 +1322,58 @@ pub fn lineEndOffset(text: []const u8, line: usize) usize {
}
/// gap offset -> (row, col) cell
+/// The start of the UTF-8 rune `off` falls in. Addresses (the 9P `addr`,
+/// `dot`, `#n`, `line:col`, a search's match) count runes, as sam's and
+/// acme's do, not grapheme clusters: a lone combining mark or a `\r` is a
+/// place of its own, whatever a terminal draws in one cell.
+pub fn runeStart(text: []const u8, off: usize) usize {
+ const o = @min(off, text.len);
+ if (o == text.len or text[o] & 0xC0 != 0x80) return o;
+ var s = o;
+ var steps: u8 = 0;
+ while (s > 0 and text[s] & 0xC0 == 0x80 and steps < 3) : (steps += 1) s -= 1;
+ // Only a lead byte whose sequence reaches `o`, its bytes all
+ // continuations, makes `o` the inside of a rune; a stray continuation
+ // byte (invalid UTF-8) is a place of its own.
+ const n = std.unicode.utf8ByteSequenceLength(text[s]) catch return o;
+ if (s + n <= o or s + n > text.len) return o;
+ for (text[s + 1 .. s + n]) |c| if (c & 0xC0 != 0x80) return o;
+ return s;
+}
+
+/// `off`, or the end of the rune it falls inside.
+pub fn runeEnd(text: []const u8, off: usize) usize {
+ const o = @min(off, text.len);
+ const s = runeStart(text, o);
+ if (s == o) return o;
+ return s + (std.unicode.utf8ByteSequenceLength(text[s]) catch 1);
+}
+
+/// The rune boundary after the one at `off`.
+pub fn nextRune(text: []const u8, off: usize) usize {
+ return if (off >= text.len) text.len else runeEnd(text, off + 1);
+}
+
+/// The rune boundary before `off`.
+pub fn prevRune(text: []const u8, off: usize) usize {
+ return if (off == 0) 0 else runeStart(text, @min(off, text.len) - 1);
+}
+
+/// `positionAt` snapped to a rune rather than a grapheme cluster.
+pub fn runePositionAt(text: []const u8, off: usize) Cursor {
+ const bounded = runeStart(text, off);
+ const s = if (std.mem.lastIndexOfScalar(u8, text[0..bounded], '\n')) |nl| nl + 1 else 0;
+ return .{ .row = lineAtOffset(text, bounded), .col = bounded - s };
+}
+
+/// `offsetAt` snapped to a rune rather than a grapheme cluster.
+pub fn runeOffsetAt(text: []const u8, c: Cursor) usize {
+ const row = @min(c.row, cursorLineCount(text) - 1);
+ const s = lineStartOffset(text, row);
+ const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
+ return s + runeStart(text[s..e], @min(c.col, e - s));
+}
+
pub fn positionAt(text: []const u8, off: usize) Cursor {
const bounded = @min(off, text.len);
// Find the line start backward without a second scan of the document prefix.
diff --git a/src/mouse.zig b/src/mouse.zig
index 1ca53017..5bfd92ee 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -109,20 +109,23 @@ test "a pane's last row of text is no resize handle; the rule over the next pane
try std.testing.expect(nudgeRightSeam(p, 2) == seam + 2);
}
-test "a pane's grip dragged up or down its own column moves its top, down to a tag alone above" {
+test "a pane's grip dragged up or down its own column moves its top, down to the floor of the pane above" {
if (platform == .web) return;
const p = try cornerFixture(std.testing.allocator);
defer p.deinit();
const upper = p.col_panes[1][0];
const lower = p.col_panes[1][1];
const grip_x = p.rects[lower].x;
- // Up, past the pane above's first row: it keeps one row, its tag.
+ // Up, past the pane above's first row: a text pane keeps its tag, a
+ // terminal its tag and two rows (layout.floorRows).
const top = p.rects[upper].y;
+ const above = p.panes[upper].?;
+ const floor: u16 = if (above.isTerminal()) above.tag_rows + layout.min_body_rows else 1;
p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } });
p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = top } });
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = top } });
p.sync();
- try std.testing.expectEqual(@as(u16, 1), p.rects[upper].h);
+ try std.testing.expectEqual(floor, p.rects[upper].h);
try std.testing.expectEqual(lower, p.col_panes[1][1]);
// Down, inside itself: its top follows, the pane above taking the rows.
const to = p.rects[lower].y + 6;
@@ -385,6 +388,8 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
// A column tag's command acts on that column, empty or not.
p.exec_column = column;
defer p.exec_column = null;
+ p.exec_header = true;
+ defer p.exec_header = false;
if (word.len > 0) {
if (m.button == config.exec_button) {
const named = std.meta.stringToEnum(Builtin, word);
@@ -709,7 +714,7 @@ fn reportGesture(
text: []const u8,
on_tag: bool,
operand: exec.PointerOperand,
- chorded: bool,
+ chord_arg: ?[]const u8,
) bool {
const scripted = p.panes[id] orelse return false;
if (scripted.fs.readers == 0) return false;
@@ -718,14 +723,29 @@ fn reportGesture(
(if (on_tag) .tag_look else .body_look)
else
(if (on_tag) .tag_exec else .body_exec);
- const named = std.meta.stringToEnum(Builtin, exec.commandText(text)) != null;
+ const named = exec.namesBuiltin(text);
const range = gestureRange(p, id, text, on_tag, operand);
+ // No flag 2: the record already carries the word a click took, its
+ // range and text, and in acme flag 2 promises a second record with them
+ // (editors/acme/exec.c:154-183, look.c:42-68), which none follows here.
var flag: u32 = if (named) ctlfs.events.flag_builtin else 0;
- if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion;
if (is_look) {
if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename;
- } else if (chorded) flag |= ctlfs.events.flag_chorded;
- return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text);
+ } else if (chord_arg != null) flag |= ctlfs.events.flag_chorded;
+ if (!ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text)) return false;
+ // A chorded exec's argument follows as two records, its text and where
+ // it came from (`file:#q0,#q1`, empty when that is not known), as acme
+ // sends them (exec.c:182-188); the pane keeps it for the write-back.
+ if (chord_arg) |a| {
+ ctlfs.events.noteFollow(p, id, action, a);
+ var where: [512]u8 = undefined;
+ ctlfs.events.noteFollow(p, id, action, exec.heldSelectionPlace(p, id, &where) orelse "");
+ const pf = &scripted.fs;
+ if (pf.chord_arg) |old| p.gpa.free(old);
+ pf.chord_arg = p.gpa.dupe(u8, a) catch null;
+ pf.chord_at = range;
+ }
+ return true;
}
fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: exec.PointerOperand) ctlfs.pane.State.Range {
@@ -788,7 +808,7 @@ fn dispatchPointerBuiltin(
// A click that takes no text does nothing, and tells an event reader
// nothing, as acme's look3 and execute return on an empty expansion.
const operand = text orelse return;
- if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return;
+ if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg)) return;
// A middle click on a body is the exec a bound REPL may take.
exec.runBuiltinFrom(p, cmd, id, "", exec.withArg(p, operand, arg), gesture != null and !gesture.?.on_tag);
}
@@ -1017,7 +1037,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
if (line >= 0) {
pane.sel[b].state = .none;
var buf: [48]u8 = undefined;
- const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return;
+ const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ pane.serial, @as(usize, @intCast(line)) + 1 }) catch return;
exec.runBuiltin(p, config.look_cmd, s.id, "", target);
return;
}
@@ -1633,3 +1653,50 @@ test "mouse thumb buttons navigate once and clear selection gestures" {
try std.testing.expect(!pane.body.vsel.active);
try std.testing.expectEqual(.none, p.drag);
}
+
+test "a chorded exec an event reader holds is told with its argument, and written back runs with it" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "Msg said\narg-text\n");
+ defer p.deinit();
+ const serial = p.panes[0].?.serial;
+ const event = ctlfs.Node.of(serial, .event);
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle;
+ // The argument: a selection held in the pane (its second line).
+ ctlfs.pane.setDot(p.panes[0].?, .{ .q0 = 9, .q1 = 17 });
+ p.chord_arg = try p.gpa.dupe(u8, "arg-text");
+ dispatchPointerBuiltin(p, 0, config.exec_cmd, "Msg", .{ .on_tag = false, .operand = .{ .row = 0, .col = 1, .text = "Msg" } });
+ var records: [3][]const u8 = undefined;
+ var got: [3][64]u8 = undefined;
+ for (&records, &got) |*r, *g| {
+ const bytes = th.call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 64 }).bytes;
+ @memcpy(g[0..bytes.len], bytes);
+ r.* = g[0..bytes.len];
+ }
+ try std.testing.expect(std.mem.endsWith(u8, records[0], " 9 3 Msg\n")); // builtin|chorded, no expansion record
+ try std.testing.expectEqualStrings("X0 0 0 8 arg-text\n", records[1][1..]);
+ try std.testing.expect(std.mem.endsWith(u8, records[2], "test.txt:#9,#17\n"));
+ // Written back whole, it runs with its argument.
+ var back: [64]u8 = undefined;
+ const line = try std.fmt.bufPrint(&back, "M{s}", .{records[0][1..]});
+ try std.testing.expectEqual(ctlfs.Status.ok, th.call(p, .{ .tag = 3, .op = .write, .node = event, .handle = h, .data = line }).reply.status);
+ const pane = p.panes[0].?;
+ try std.testing.expectEqualStrings("arg-text", pane.msg[0..pane.msg_len]);
+ // All three records written back as read, in one write or in three:
+ // the two after the first are its argument and where it came from,
+ // never commands of their own.
+ for ([_]bool{ true, false }) |together| {
+ pane.msg_len = 0;
+ var all: [256]u8 = undefined;
+ var w = std.Io.Writer.fixed(&all);
+ for (records) |r| try w.print("M{s}", .{r[1..]});
+ if (together) {
+ _ = th.call(p, .{ .tag = 5, .op = .write, .node = event, .handle = h, .data = w.buffered() });
+ } else for (records) |r| {
+ var one: [80]u8 = undefined;
+ _ = th.call(p, .{ .tag = 6, .op = .write, .node = event, .handle = h, .data = try std.fmt.bufPrint(&one, "M{s}", .{r[1..]}) });
+ }
+ try std.testing.expectEqualStrings("arg-text", pane.msg[0..pane.msg_len]);
+ try std.testing.expect(!th.logHas(p, "run "));
+ }
+ _ = th.call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
+}
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 1e47183b..9498df19 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -13,6 +13,7 @@ fn clip(n: usize) u32 {
pub const e_no_match = "no match for regexp";
pub const e_range = "address out of range";
+pub const e_col_zero = "address out of range: a column counts from 1";
pub const e_regexp = "bad regular expression";
/// Not "took too long": 9ns reads errors by their words, and that would be
/// ENAMETOOLONG.
@@ -94,7 +95,7 @@ pub const Addr = struct {
// pardes's `line:col`, Look's `file:12:5` spelling (look.zig
// parsePathLine): the point at that column of that line, the
// column in bytes from 1, clamped to the line's end and snapped
- // to a character's start as Look's cursor is. Not sam's.
+ // to a rune's start, as every address is. Not sam's.
if (size == .line and dir == 0 and a.i + 1 < a.expr.len and a.expr[a.i] == ':' and
a.expr[a.i + 1] >= '0' and a.expr[a.i + 1] <= '9')
{
@@ -104,10 +105,10 @@ pub const Addr = struct {
col = @min(col * 10 + (a.expr[a.i] - '0'), std.math.maxInt(u32));
const lines = modal.cursorLineCount(a.text);
if (n == 0 or n > lines or col == 0) {
- a.err = e_range;
+ a.err = if (col == 0) e_col_zero else e_range;
return null;
}
- const q = clip(modal.offsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
+ const q = clip(modal.runeOffsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
r = .{ .q0 = q, .q1 = q };
continue;
}
@@ -162,7 +163,7 @@ pub const Addr = struct {
a.err = e_range;
return null;
}
- const g = clip(modal.graphemeStart(a.text, @intCast(off)));
+ const g = clip(modal.runeStart(a.text, @intCast(off)));
return .{ .q0 = g, .q1 = g };
}
var line: i64 = n;
@@ -188,35 +189,81 @@ pub const Addr = struct {
while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1;
return .{ .q0 = clip(q0), .q1 = clip(q1) };
},
- '+' => {
- if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') {
- q1 += 1;
- };
- q0 = q1;
- },
- else => {
- q0 = 0;
- q1 = 0;
- },
+ else => {},
}
- while (line > 0 and q1 < a.text.len) {
- const ch = a.text[q1];
- q1 += 1;
- if (ch == '\n' or q1 == a.text.len) {
- line -= 1;
- if (line > 0) q0 = q1;
+ // Forward, sam's lineaddr (editors/sam/address.c:180-215), not acme's
+ // number(): line n is found by counting newlines, and a line just
+ // past the last newline is an empty one there, so `1` of an empty
+ // text is #0,#0 and `2` of "a\n" is #2,#2; only past that is it out
+ // of range.
+ const nc = a.text.len;
+ var p: usize = 0;
+ var p1: usize = 0;
+ if (n == 0) {
+ if (dir == 0 or q1 == 0) return .{ .q0 = 0, .q1 = 0 };
+ p1 = q1;
+ p = q1 - 1;
+ } else {
+ var k: u32 = 1;
+ if (dir != 0 and q1 > 0) {
+ p = q1 - 1;
+ k = @intFromBool(a.text[p] == '\n');
+ p += 1;
+ }
+ while (k < n) {
+ if (p >= nc) {
+ a.err = e_range;
+ return null;
+ }
+ if (a.text[p] == '\n') k += 1;
+ p += 1;
}
+ p1 = p;
}
- if (line > 0) {
- a.err = e_range;
- return null;
+ while (p < nc) {
+ const ch = a.text[p];
+ p += 1;
+ if (ch == '\n') break;
}
- return .{ .q0 = clip(q0), .q1 = clip(q1) };
+ return .{ .q0 = clip(p1), .q1 = clip(p) };
}
/// acme's regexp(): forward from the end of `r` to the limit, wrapping
/// to the start of the text when there is none; backward, the last
/// match ending by the start of `r`, else the last one in the text.
+ /// The first match at or after `from`, else, with no limit, the first
+ /// from the top (a search wraps); null (the outer) when too slow.
+ fn forward(a: *Addr, rx: *regexp_.Regex, from: usize, hi: usize) ??Range {
+ const ahead = rx.find(a.text, from, hi, hi) catch {
+ a.err = e_slow;
+ return null;
+ };
+ const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse return @as(?Range, null);
+ return @as(?Range, .{ .q0 = clip(m.start), .q1 = clip(m.end) });
+ }
+
+ /// The last match ending at or before `limit`, else the last of all
+ /// (a search wraps); null (the outer) when too slow.
+ fn backward(a: *Addr, rx: *regexp_.Regex, limit: u32) ??Range {
+ var last: ?Range = null;
+ var before: ?Range = null;
+ var at: usize = 0;
+ while (at <= a.text.len) {
+ const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse break;
+ const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ if (found_range.q1 <= limit) before = found_range;
+ last = found_range;
+ at = if (m.end > m.start) m.end else m.end + 1;
+ }
+ return @as(?Range, before orelse last);
+ }
+
fn regexp(a: *Addr, r: Range, pat: []const u8, back: bool) ?Range {
if (pat.len == 0) {
a.err = "no previous regular expression";
@@ -226,34 +273,29 @@ pub const Addr = struct {
a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp;
return null;
};
+ // sam's nextmatch (editors/sam/address.c:97-119): an empty match
+ // right at the search's start is no progress, so the search is made
+ // again a character on (back: a character before), wrapping, and
+ // repeated `/^/` or `/$/` walks the lines.
const found = if (back) found: {
- var last: ?Range = null;
- var before: ?Range = null;
- var at: usize = 0;
- while (at <= a.text.len) {
- const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
- a.err = e_slow;
- return null;
- }) orelse break;
- const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
- if (found_range.q1 <= r.q0) before = found_range;
- last = found_range;
- at = if (m.end > m.start) m.end else m.end + 1;
- }
- break :found before orelse last;
+ var f = a.backward(&rx, r.q0) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q1 == r.q0) {
+ f = a.backward(&rx, if (r.q0 == 0) clip(a.text.len) else r.q0 - 1) orelse return null;
+ };
+ break :found f;
} else found: {
const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len;
const from = @min(@as(usize, r.q1), hi);
- const ahead = rx.find(a.text, from, hi, hi) catch {
- a.err = e_slow;
- return null;
+ var f = a.forward(&rx, from, hi) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q0 == from) {
+ const next = if (from + 1 > hi) (if (a.lim != null) break :found null else 0) else from + 1;
+ f = a.forward(&rx, next, hi) orelse return null;
};
- const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
- a.err = e_slow;
- return null;
- }) orelse break :found null;
- break :found Range{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ break :found f;
};
+ // A byte pattern may match inside a rune: the match covers the
+ // runes it touches.
+ if (found) |f| return .{ .q0 = clip(modal.runeStart(a.text, f.q0)), .q1 = clip(modal.runeEnd(a.text, f.q1)) };
return found orelse {
a.err = e_no_match;
return null;
@@ -306,6 +348,27 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "an empty match at the search's start is passed over, as sam's nextmatch does" {
+ const p = try th.withFile(testing.allocator, "ab\ncd\nef\n");
+ defer p.deinit();
+ const addr = Node.of(th.serialOf(p), .addr);
+ const Case = struct { start: []const u8, expr: []const u8, want: []const u32 };
+ for ([_]Case{
+ // From #3 (the start of line 2), /^/ goes on to line 3.
+ .{ .start = "#3", .expr = "/^/", .want = &.{6} },
+ .{ .start = "#2", .expr = "/$/", .want = &.{ 5, 8 } },
+ .{ .start = "#3", .expr = "/x*/", .want = &.{ 4, 5, 6 } },
+ .{ .start = "#3", .expr = "?^?", .want = &.{0} },
+ }) |c| {
+ _ = th.wr(p, addr, c.start);
+ for (c.want) |q| {
+ try testing.expectEqual(Status.ok, th.wr(p, addr, c.expr).reply.status);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q1);
+ }
+ }
+}
+
test "regular expressions search lines as sam's do, and a search wraps" {
const p = try th.withFile(testing.allocator, "alpha beta\nbeta gamma\ngamma\n");
defer p.deinit();
@@ -357,7 +420,8 @@ test "a failed address leaves none, so data refuses rather than act at the last
try testing.expectEqualStrings(e_no_match, th.wr(p, addr, "/zzz/").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.wr(p, data, "ONE").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, data, 0, 64).reply.ename);
- try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, addr, 0, 64).reply.ename);
+ try testing.expectEqual(Status.ok, th.rd(p, addr, 0, 64).reply.status);
+ try testing.expectEqualStrings("", th.rd(p, addr, 0, 64).bytes);
try testing.expectEqual(E.INVAL, th.call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .xdata), .truncate = true }).errno());
try testing.expectEqualStrings("one\ntwo\n", p.panes[0].?.file.?.content);
// A good address gives it one again; truncating addr does not.
@@ -466,7 +530,7 @@ test "the address language, form by form" {
for ([_][2][]const u8{
.{ "zzz", e_syntax }, .{ "/nomatch/", e_no_match }, .{ "99", e_range },
.{ "#999", e_range }, .{ "/a[/", e_regexp }, .{ "/(a/", e_regexp },
- .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_range },
+ .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_col_zero },
}) |c| {
_ = th.wr(p, addr, "#0");
try testing.expectEqualStrings(c[1], th.wr(p, addr, c[0]).reply.ename);
@@ -476,3 +540,31 @@ test "the address language, form by form" {
_ = th.wr(p, addr, "#0");
try testing.expectEqual(E.INVAL, th.wr(p, addr, nested).errno());
}
+
+test "a line just past the last newline is an empty one there, as sam's lineaddr has it" {
+ const Case = struct { text: []const u8, expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .text = "", .expr = "1", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\n", .expr = "2", .q0 = 2, .q1 = 2 },
+ .{ .text = "a\nb", .expr = "2", .q0 = 2, .q1 = 3 },
+ .{ .text = "a\nb\n", .expr = "1", .q0 = 0, .q1 = 2 },
+ .{ .text = "a\nb\n", .expr = "0", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\nb\n", .expr = "1+1", .q0 = 2, .q1 = 4 },
+ .{ .text = "a\nb\n", .expr = "$", .q0 = 4, .q1 = 4 },
+ }) |c| {
+ var a: Addr = .{ .text = c.text, .lim = null, .expr = c.expr };
+ const r = a.address(.{ .q0 = 0, .q1 = 0 }).?;
+ try testing.expectEqual(c.q0, r.q0);
+ try testing.expectEqual(c.q1, r.q1);
+ }
+ for ([_][2][]const u8{ .{ "", "2" }, .{ "a\n", "3" } }) |c| {
+ var a: Addr = .{ .text = c[0], .lim = null, .expr = c[1] };
+ try testing.expectEqual(null, a.address(.{ .q0 = 0, .q1 = 0 }));
+ try testing.expectEqualStrings(e_range, a.err);
+ }
+ // Edit's addresses are these: 1i on an empty file inserts.
+ const p = try th.withFile(testing.allocator, "");
+ defer p.deinit();
+ try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .ctl), "Edit 1i/header/\n").reply.status);
+ try testing.expectEqualStrings("header", p.panes[0].?.file.?.content);
+}
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index b6dc7d26..b00ecdba 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -123,8 +123,11 @@ pub fn released(p: *Pardes, serial: ?u32) void {
}
/// A column's ctl: the words that act on a column, as its tag's clicks do --
-/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag.
+/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag. What
+/// they say is the session's in the log (`msg -`), not the focused pane's.
pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
+ p.fs.session_write = true;
+ defer p.fs.session_write = false;
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
@@ -133,31 +136,59 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
if (std.mem.eql(u8, w, word)) break true;
} else false;
- if (!takes) return tree.failText(req.tag, E.INVAL, "a column's ctl takes Delcol, Joincol, New or Tty");
+ // Plan 9's words for it, so a mount's errno is EINVAL as the root's
+ // and a pane's ctl give (9ns reads "control message").
+ if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{line[0..@min(line.len, 64)]}) catch "unknown control message");
+ // Only Tty takes a word (its shell); `New extra` is no New.
+ if (!std.mem.eql(u8, word, "Tty") and word.len != line.len)
+ return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{line[0..@min(line.len, 64)]}) catch "wrong #args in control message");
const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
p.exec_column = col;
defer p.exec_column = null;
+ p.exec_header = true;
+ defer p.exec_header = false;
if (pardes.ctlfs.ctl.runBuiltin(p, req, p.active, line)) |refusal| return refusal;
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-/// A column's exec: each line run as a middle click on it in that column's
-/// tag, acting on that column.
-pub fn writeExec(p: *Pardes, req: Req, serial: u32) Reply {
+/// A column's exec (`serial`), or the workspace tag's (/tagexec, null):
+/// each line run as a middle click on it in that tag, a column's acting on
+/// that column. A read answers the panes the last write touched, as /exec's
+/// does; what the words say is the session's in the log (`msg -`).
+pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply {
+ p.fs.results_len = 0;
+ p.fs.session_write = true;
+ defer p.fs.session_write = false;
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
- const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control);
+ if (pardes.ctlfs.ctl.tooLong(req, line)) |refusal| return refusal;
+ const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null;
+ if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
p.exec_column = col;
defer p.exec_column = null;
- pardes.ctlfs.ctl.run(p, p.active, true, line);
+ p.exec_header = true;
+ defer p.exec_header = false;
+ if (pardes.ctlfs.ctl.captured(p, req, p.active, true, line, true)) |refusal| return refusal;
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+test "New written to a column's exec makes its scratch in the session's directory, as tagexec's does" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
+ const serial = layout.columnSerial(p, 0);
+ const exec_node = tree.Node.ofCol(serial, .exec);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, exec_node, "New\n").reply.status);
+ try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
+}
+
/// rmdir of a column closes it when it is empty; one with panes is
/// refused, saying so (close them, or Delcol on its ctl, which asks first).
pub fn remove(p: *Pardes, req: Req, serial: u32, file: tree.ColFile) Reply {
@@ -257,10 +288,12 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .ctl), "New\n").reply.status);
try testing.expectEqual(@as(usize, 1), p.col_n[1]);
try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno());
+ var rec: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}/ctl: unknown control message \"Exit\"", .{empty})));
// rmdir refuses a column with a pane, and says why.
const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true });
try testing.expectEqual(E.NOTEMPTY, full.errno());
- try testing.expect(th.logHas(p, "column not empty"));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty})));
// exec runs a word as a click in that column's tag: Delcol closes it.
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status);
try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty));
@@ -282,3 +315,76 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on
// The root's ctl points a column word at the column's own.
try testing.expect(std.mem.endsWith(u8, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Delcol\n").reply.ename, "write it to col/<serial>/ctl"));
}
+
+test "tagexec runs a word as a click in the workspace tag; exec files read back what they touched; their messages are the session's" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const tagexec = @intFromEnum(tree.TopFile.tagexec);
+ try testing.expectEqual(tree.Status.ok, th.look_up(p, tree.root, "tagexec").reply.status);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status);
+ try testing.expectEqual(@as(usize, 2), p.ncol);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Msg from the top\n").reply.status);
+ try testing.expect(th.logHas(p, "msg - from the top\n"));
+ // A column's exec makes a pane there, reads it back, and its Msg is
+ // the session's too.
+ const right = layout.columnSerial(p, 1);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "New\n").reply.status);
+ const made = p.panes[p.col_panes[1][0]].?.serial;
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "Msg from a column\n").reply.status);
+ try testing.expect(th.logHas(p, "msg - from a column\n"));
+}
+
+test "a word run from the workspace's or a column's tag runs in the session's directory, a pane's in its own" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const session = p.sessionDir();
+ try testing.expect(!std.mem.eql(u8, session, pardes.Pardes.paneDir(p.panes[0].?))); // the file is elsewhere
+ const dirs = [_]u64{ @intFromEnum(tree.TopFile.tagexec), Node.ofCol(layout.columnSerial(p, 0), .exec) };
+ for (dirs) |node| {
+ try testing.expectEqual(tree.Status.ok, th.wr(p, node, "true\n").reply.status);
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqualStrings(session, pardes.Pardes.paneDir(p.panes[made].?));
+ try p.removePane(made, null);
+ }
+ const from_pane = p.panes[0].?.serial;
+ _ = th.wr(p, Node.of(from_pane, .exec), "true\n");
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqualStrings(pardes.Pardes.paneDir(p.panes[0].?), pardes.Pardes.paneDir(p.panes[made].?));
+}
+
+test "a command run from a column's tag gets its pane in that column, not the last" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.newColumn(p.active);
+ p.newScratchFor(p.active, .fs); // fills the new, right column
+ p.sync();
+ const left = layout.columnSerial(p, 0);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(left, .exec), "true\n").reply.status);
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqual(@as(usize, 0), layout.findPane(p, made).?.col);
+ // Finished, it is reused only by its own column's commands.
+ p.panes[made].?.command_done = true;
+ p.panes[made].?.command_pty = false;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(layout.columnSerial(p, 1), .exec), "true\n").reply.status);
+ const other = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expect(other != made);
+ try testing.expectEqual(@as(usize, 1), layout.findPane(p, other).?.col);
+}
+
+test "a column's ctl refuses an unknown word and an argument as the other ctls do" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const ctl_node = Node.ofCol(layout.columnSerial(p, 0), .ctl);
+ const unknown = th.wr(p, ctl_node, "Bogus\n");
+ try testing.expectEqual(E.INVAL, unknown.errno());
+ try testing.expect(std.mem.startsWith(u8, unknown.reply.ename, "unknown control message \"Bogus\""));
+ const extra = th.wr(p, ctl_node, "New extra\n");
+ try testing.expectEqual(E.INVAL, extra.errno());
+ try testing.expect(std.mem.startsWith(u8, extra.reply.ename, "wrong #args"));
+ try testing.expectEqual(@as(usize, 1), p.col_n[0]);
+ // A pane's bare answer and a winsize of no size say what they take.
+ try testing.expectEqualStrings("answer takes a choice or -", th.wr(p, Node.of(p.panes[0].?.serial, .ctl), "answer\n").reply.ename);
+}
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 2fdc47cc..4f52d661 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -51,16 +51,19 @@ 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).
-pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
+pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) 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),
// and a line written to a look or exec is a click: on that pane, or for
// the root's, on the pane with the keyboard. The reader writes the
// record back to have it done.
- if (p.panes[id].?.fs.readers > 0) {
- const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null;
- var flag: u32 = pardes.ctlfs.events.flag_expansion;
+ // A click in a column's or the workspace's tag is not the pane's.
+ if (!in_tag and p.panes[id].?.fs.readers > 0) {
+ const named = exec_line.namesBuiltin(text);
+ // No flag 2: a written line was never expanded from a click, and no
+ // expansion record follows (editors/acme/look.c:42-43, exec.c:154-157).
+ var flag: u32 = 0;
if (named) flag |= pardes.ctlfs.events.flag_builtin;
if (!exec and !named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= pardes.ctlfs.events.flag_filename;
_ = pardes.ctlfs.events.noteAction(p, id, if (exec) .body_exec else .body_look, 0, 0, flag, text);
@@ -79,9 +82,8 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
- // A look's hits buffer is how it found the text, not where: it
- // answers the pane the hit is selected in.
- if (!exec) if (p.paneBySerial(serial)) |made| if (p.panes[made].?.file) |f| if (f.output) |o| if (o.from == .search) continue;
+ // A look that made a +Search pane (LookWord list, a terminal)
+ // answers it: that is where the places it found are.
noteResult(p, serial);
created = true;
};
@@ -112,34 +114,77 @@ const Messages = struct {
const edit = "Edit";
if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t'))
return first;
- const sam = @import("../sam_edit.zig");
- while (m.at < m.data.len and sam.needsMore(m.p.scratch.allocator(), std.mem.trim(u8, m.data[start..end], " \t\r")[edit.len..])) {
- end = std.mem.indexOfScalarPos(u8, m.data, m.at, '\n') orelse m.data.len;
- m.at = end + 1;
- }
+ end = editEnd(m.p, m.data, start, end, true).?;
+ m.at = end + 1;
return std.mem.trim(u8, m.data[start..end], " \t\r");
}
};
+/// Where the Edit block whose first line is `data[start..first_end]` ends:
+/// the end of its last line, taking the lines after it while its `{`
+/// group or a/c/i text block is open. Out of lines, the end of `data` when
+/// `to_end` (a write run whole), else null (wait for more). Only a line
+/// that could end what is open is parsed again with all before it: within
+/// a text block, just a `.` line. The arena copy of each parse is freed.
+fn editEnd(p: *Pardes, data: []const u8, start: usize, first_end: usize, to_end: bool) ?usize {
+ const sam = @import("../sam_edit.zig");
+ const edit = "Edit";
+ const arena = p.scratch.allocator();
+ var end = first_end;
+ var wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]);
+ while (wait != .none) {
+ if (end >= data.len) return if (to_end) data.len else null;
+ const next = std.mem.indexOfScalarPos(u8, data, end + 1, '\n') orelse
+ (if (to_end) data.len else return null);
+ const line = std.mem.trim(u8, data[end + 1 .. next], " \t\r");
+ end = next;
+ if (wait == .text and !std.mem.eql(u8, line, ".")) continue;
+ wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]);
+ }
+ return end;
+}
+
+/// How much of `data` is whole messages, as `Messages` reads them: lines
+/// ended by a newline, an Edit block only once it closes. The rest waits
+/// for more (tree.Open.pending).
+pub fn completeEnd(p: *Pardes, data: []const u8) usize {
+ const edit = "Edit";
+ var whole: usize = 0;
+ var at: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, data, at, '\n')) |nl| {
+ const start = at;
+ var end = nl;
+ const first = std.mem.trim(u8, data[start..end], " \t\r");
+ if (std.mem.startsWith(u8, first, edit) and (first.len == edit.len or first[edit.len] == ' ' or first[edit.len] == '\t'))
+ end = editEnd(p, data, start, end, false) orelse return whole;
+ at = end + 1;
+ whole = at;
+ }
+ return whole;
+}
+
// ---- look and exec ----
/// One command per line, checked whole before any line runs: a line is the
/// clicked text itself, so it may not be empty or carry control characters.
/// A null `serial` clicks at whichever pane is active as each line runs,
/// which is what the root's own look and exec mean; a serial stays put.
-pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
+pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Reply {
+ p.fs.ran_at = 0;
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
while (it.next()) |text| {
if (text.len == 0) continue;
// Only an Edit block holds newlines (Messages).
- for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return Reply.fail(req.tag, E.INVAL);
+ for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return tree.failText(req.tag, E.INVAL, e_control);
+ if (exec) if (tooLong(req, text)) |refusal| return refusal;
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.ran_at = p.panes[id].?.serial;
p.fs.no_pane_slot = false;
- run(p, id, exec, text);
+ if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal;
// A pane it would have opened had no slot: the write fails, and
// a look reads back nothing.
if (p.fs.no_pane_slot) {
@@ -151,6 +196,38 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+pub const e_too_long = std.fmt.comptimePrint("a command line is at most {d} bytes", .{pardes.exec.command_max});
+
+/// A line an exec would run as a command, over the most a command line may
+/// be: refused whole, before anything runs, rather than said on the message
+/// row after the write succeeded. A builtin's line (Msg, an Edit block) may
+/// be longer.
+pub fn tooLong(req: Req, line: []const u8) ?Reply {
+ if (line.len <= pardes.exec.command_max) return null;
+ const cmd = exec_line.commandText(line);
+ const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len];
+ if (std.meta.stringToEnum(Builtin, word) != null) return null;
+ return tree.failText(req.tag, E.INVAL, e_too_long);
+}
+
+pub const e_control = "a command line holds no control character but a tab";
+
+/// Runs one click (`run`) as a 9P write's: a builtin that fails there fails
+/// the write, with its words and an err record, and no msg, as a ctl line
+/// does (runBuiltin). The refusal, or null.
+pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, in_tag: bool) ?Reply {
+ p.fs.capturing = true;
+ p.fs.failure_len = 0;
+ defer p.fs.capturing = false;
+ run(p, id, exec, text, in_tag);
+ if (p.fs.failure_len == 0) return null;
+ // A failed click answers nothing: look reads back empty.
+ p.fs.results_len = 0;
+ const failure = p.fs.failure[0..p.fs.failure_len];
+ // A slot or a place refused keeps its errno words (no space: ENOSPC).
+ return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure);
+}
+
pub fn resultsLen(p: *Pardes) u64 {
var n: u64 = 0;
for (p.fs.results[0..p.fs.results_len]) |serial| {
@@ -160,11 +237,28 @@ pub fn resultsLen(p: *Pardes) u64 {
return n;
}
+/// The panes the open's last write touched (tree.Open.results); with no
+/// open, the session's last.
pub fn readResults(p: *Pardes, req: Req) Reply {
const out = p.fs.stage(p.gpa);
- for (p.fs.results[0..p.fs.results_len]) |serial|
+ const o = tree.openOf(p, req);
+ const kept = if (o) |open| open.results.list[0..open.results.len] else p.fs.results[0..p.fs.results_len];
+ // A look answers panes to go to: never one closed since (an exec may
+ // answer the pane it closed, which is what it acted on).
+ const looking = if (tree.Node.target(req.node)) |t| switch (t) {
+ .top => |f| f == .look,
+ .pane => |pt| pt.file == .look,
+ .col => false,
+ } else false;
+ for (kept) |serial| if (!looking or p.paneBySerial(serial) != null)
out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM);
- return tree.stagedReply(p, req);
+ const open = o orelse return tree.stagedReply(p, req);
+ if (!open.results.wrote) return tree.stagedReply(p, req);
+ var at = req;
+ at.off = open.results.read;
+ const reply = tree.stagedReply(p, at);
+ open.results.read += reply.payload.staged;
+ return reply;
}
// ---- /status ----
@@ -366,7 +460,7 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
.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 });
+ try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.defaultShell() });
},
.font => {
const requested = s.font.requested_name.get();
@@ -424,6 +518,30 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
var it = Messages.init(p, req.data);
while (it.next()) |line| {
if (line.len == 0) continue;
+ if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) {
+ const size = sizeOf(line) orelse return refuse(p, req, e_size, line);
+ // A frontend's window is the size; only a session none is
+ // attached to (--detach, unattended) takes one written.
+ if (!p.unattended) return refuse(p, req, "size: a frontend is attached and owns the size", line);
+ if (apply) {
+ const was: [2]u16 = .{ p.screen_w, p.screen_h };
+ const before = p.rects;
+ p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } });
+ p.sync();
+ // Every pane keeps its tag and two rows, as placement
+ // leaves them (layout.minRows, the one minimum): a size
+ // too small for the panes' minima is refused, and one
+ // they fit gives back rows the resize took under them.
+ if (!pardes.layout.minimaFit(p)) {
+ p.update(.{ .resize = .{ .cols = was[0], .rows = was[1] } });
+ p.sync();
+ return refuse(p, req, "size: too small for the panes, each its tag and 2 rows", line);
+ }
+ pardes.layout.restoreMinima(p, &before);
+ p.sync();
+ }
+ continue;
+ }
if (!apply) {
if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
continue;
@@ -437,17 +555,88 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+const size_min = [2]u16{ 20, 6 };
+const size_max = [2]u16{ 4096, 4096 };
+// "invalid": the word 9ns reads as EINVAL (enameToErrno), as winsize's.
+const e_size = std.fmt.comptimePrint("invalid size: takes <cols> <rows>, at least {d}x{d}, at most {d}x{d}", .{ size_min[0], size_min[1], size_max[0], size_max[1] });
+
+/// `size <cols> <rows>`, within size_min and size_max.
+fn sizeOf(line: []const u8) ?[2]u16 {
+ var it = std.mem.tokenizeAny(u8, line["size".len..], " \t");
+ const cols = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
+ const rows = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
+ if (it.next() != null or cols < size_min[0] or rows < size_min[1] or cols > size_max[0] or rows > size_max[1]) return null;
+ return .{ cols, rows };
+}
+
// ---- /commands ----
/// Every builtin the registry holds, one a line: its word, `arg` when it
/// takes one, and which ctl takes it, `root` or `pane` -- the registry
/// itself, so a new builtin is listed the moment it exists.
+/// A builtin's doc comment, its first sentence, from the source itself: a
+/// line of /commands says what each word does without a second copy of it.
+fn summaryOf(word: []const u8, buf: []u8) []const u8 {
+ const source = @embedFile("../builtins.zig");
+ var needle_buf: [64]u8 = undefined;
+ const needle = std.fmt.bufPrint(&needle_buf, "\npub const {s} = struct", .{word}) catch return "";
+ const at = std.mem.indexOf(u8, source, needle) orelse return "";
+ // Back over the `///` lines just above it.
+ var start = at + 1;
+ while (start > 0) {
+ const prev_end = start - 1;
+ const prev_start = if (std.mem.lastIndexOfScalar(u8, source[0..prev_end], '\n')) |nl| nl + 1 else 0;
+ if (!std.mem.startsWith(u8, std.mem.trimStart(u8, source[prev_start..prev_end], " "), "///")) break;
+ start = prev_start;
+ }
+ var w = std.Io.Writer.fixed(buf);
+ var lines = std.mem.splitScalar(u8, source[start .. at + 1], '\n');
+ while (lines.next()) |line| {
+ const text = std.mem.trim(u8, std.mem.trimStart(u8, std.mem.trimStart(u8, line, " "), "/"), " ");
+ if (text.len == 0) continue;
+ if (w.buffered().len > 0) w.writeByte(' ') catch break;
+ w.writeAll(text) catch break;
+ }
+ const all = stripCitations(w.buffered());
+ // The first sentence, whole: to a `.` that ends one.
+ var i: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, all, i, '.')) |dot| : (i = dot + 1) {
+ if (dot + 1 == all.len or all[dot + 1] == ' ') return all[0 .. dot + 1];
+ }
+ return all;
+}
+
+/// Drops a parenthesis that cites source (`(editors/acme/exec.c:892)`,
+/// `(exec.c, \`kill\`; acme.c's WKill)`): a reader of /commands wants what
+/// the word does, not where acme has it. In place; answers the rest.
+fn stripCitations(text: []u8) []u8 {
+ var out: usize = 0;
+ var i: usize = 0;
+ while (i < text.len) {
+ if (text[i] == '(') if (std.mem.indexOfScalarPos(u8, text, i, ')')) |close| {
+ const inside = text[i + 1 .. close];
+ if (std.mem.indexOf(u8, inside, ".c") != null or std.mem.indexOf(u8, inside, ".zig") != null) {
+ // With the space before it, and a colon after it kept.
+ if (out > 0 and text[out - 1] == ' ') out -= 1;
+ i = close + 1;
+ continue;
+ }
+ };
+ text[out] = text[i];
+ out += 1;
+ i += 1;
+ }
+ return text[0..out];
+}
+
fn writeCommands(w: *std.Io.Writer) !void {
for (std.enums.values(Builtin)) |b| {
+ // Edit is a pane's word the root's ctl takes too, at the active pane.
+ const scope: []const u8 = if (b == .Edit) "both" else if (builtins.registry.scope(b) == .session) "root" else "pane";
try w.print("{s}{s} {s}", .{
@tagName(b),
if (builtins.registry.takesArg(b)) " arg" else "",
- if (builtins.registry.scope(b) == .session) "root" else "pane",
+ scope,
});
// A setting that chooses among words: the words, comma-joined. Bare,
// it steps to the next of them (a two-valued one flips).
@@ -457,6 +646,14 @@ fn writeCommands(w: *std.Io.Writer) !void {
var first = true;
while (parts.next()) |v| : (first = false) try w.print("{s}{s}", .{ if (first) "" else ",", v });
};
+ // Then ` -- ` and what it does: a setting's doc, else its source's.
+ var summary_buf: [2048]u8 = undefined;
+ const summary = if (config.Runtime.find(@tagName(b))) |setting| setting.doc else summaryOf(@tagName(b), &summary_buf);
+ if (summary.len > 0) {
+ // The sentence whole unless it is long, then cut between words.
+ const kept = @import("../Messages.zig").clip(summary, 240);
+ try w.print(" -- {s}{s}", .{ kept, if (kept.len < summary.len) "…" else "" });
+ }
try w.writeByte('\n');
}
}
@@ -550,9 +747,21 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
if (std.mem.eql(u8, line, "get")) {
if (!apply) continue;
- if (getRefused(p, pane)) |said| return tree.failText(req.tag, E.IO, said);
+ // Quoting the line, as a builtin's refusal (Save's) does.
+ if (getRefused(p, pane)) |said_in_ename| {
+ // refuse writes ename, where the words are: copy them out.
+ var said_buf: [320]u8 = undefined;
+ const said = said_buf[0..said_in_ename.len];
+ @memcpy(said, said_in_ename);
+ const r = refuse(p, req, said, line);
+ return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = r.ename };
+ }
const errno = get(p, pane);
- if (errno != 0) return Reply.fail(req.tag, errno);
+ // Naming the path, in words 9ns reads as the errno.
+ if (errno != 0) {
+ const path = if (pane_files.fileOf(pane)) |f| f.path else "";
+ return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], if (errno == E.NOENT) "no such file" else "cannot be read" }) catch "get: no such file");
+ }
} else if (std.mem.eql(u8, line, "lock")) {
if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
if (other) return tree.failText(req.tag, E.BUSY, e_locked);
@@ -562,6 +771,24 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (!held) return tree.failText(req.tag, E.INVAL, e_not_locked);
held = false;
if (apply) pane.fs.lock = null;
+ } else if (std.mem.eql(u8, line, "answer")) {
+ // With a question up, what it offers.
+ var offered: [64]u8 = undefined;
+ if (p.questionChoices(p.paneBySerial(serial).?, &offered)) |choices| {
+ var why: [96]u8 = undefined;
+ return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "answer takes {s} or -", .{choices}) catch std.fmt.bufPrint(&why, "answer takes a choice or -", .{}) catch "answer takes a choice or -");
+ }
+ return tree.failText(req.tag, E.INVAL, "answer takes a choice or -");
+ } else if (std.mem.startsWith(u8, line, "answer ") and p.panes[p.paneBySerial(serial).?].?.prompt == .save) {
+ // Save's path, asked over 9P (`ask <serial> save path`).
+ if (!apply) continue;
+ const id = p.paneBySerial(serial).?;
+ const asked = p.panes[id].?;
+ const path = std.mem.trim(u8, line["answer ".len..], " ");
+ var rec: [300]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {s}", .{ serial, path[0..@min(path.len, 256)] }) catch "");
+ if (!std.mem.eql(u8, path, "-")) exec_line.saveTo(p, id, path);
+ asked.closePrompt();
} 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..], " ");
@@ -585,7 +812,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-const e_not_locked = "window not locked by this open";
+const e_not_locked = "pane not locked by this open";
/// Who holds it is another open of this ctl, whose client pardes cannot name.
const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock";
@@ -600,6 +827,9 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null;
pane.discard_warned = .{ .revision = f.revision, .by = .get };
const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
+ // Shown; the write's err record logs it, not a msg too.
+ p.fs.unlogged = true;
+ defer p.fs.unlogged = false;
p.setMessage(p.paneBySerial(pane.serial).?, said);
return said;
}
@@ -611,10 +841,19 @@ fn get(p: *Pardes, pane: *Pane) u16 {
error.FileNotFound => E.NOENT,
else => E.IO,
};
+ // Throwing unsaved edits away for it is a reload the log hears of, as a
+ // clean one from the watch is.
+ const discarded = f.revision != f.saved_revision;
panes.File.pushUndo(p, pane);
panes.File.setContent(p, f, bytes);
f.saved_revision = f.revision;
f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.disk_newer = null;
+ f.disk_gone = false;
+ if (discarded) {
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch "");
+ }
return 0;
}
@@ -671,12 +910,13 @@ test "commands lists every builtin with its argument and the ctl that takes it"
var words = std.mem.tokenizeScalar(u8, line, ' ');
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().?);
+ try testing.expectEqualStrings(if (b == .Edit) "both" else 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);
+ // Then, where the source has one, ` -- ` and what it does.
+ if (words.next()) |dashes| try testing.expectEqualStrings("--", dashes);
}
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 on,off\n", "\nPlacement arg root acme,pardes\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
+ for ([_][]const u8{ "\nDel arg pane -- ", "\nNewcol root -- ", "\nSave arg pane", "\nVerbose arg root on,off", "\nPlacement arg root acme,pardes", "\nUndo pane -- ", "\nEdit arg both -- acme's Edit" }) |row|
try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null);
}
@@ -691,6 +931,15 @@ test "Undo and Redo on a pane's ctl step its body through its edits" {
try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes);
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes);
+ // Nothing left: said, and the write still succeeds, as acme's is silent.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
+ try testing.expect(th.logHas(p, "Redo: nothing to redo"));
+ _ = wr(p, Node.of(serial, .ctl), "Undo");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expect(th.logHas(p, "Undo: nothing to undo"));
+ // Said again word for word, it is that line counted, as err's are.
+ for (0..3) |_| _ = wr(p, Node.of(serial, .ctl), "Undo");
+ try testing.expect(th.logHas(p, "Undo: nothing to undo (x4)\n"));
}
test "focus reads the pane with the keyboard, and a serial written to it moves the keyboard there" {
@@ -932,8 +1181,8 @@ test "a message from a root ctl write is the session's in the log" {
const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len;
_ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Kill\n").reply.status);
- // The builtin's name, then what it said: both the session's.
- try testing.expectEqualStrings("msg - Kill\n", call(p, .{ .tag = 4, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
+ // What it said, the session's; its own words name it, so its Verbose
+ // announcement gives way to them.
try testing.expectEqualStrings("msg - Kill: nothing running\n", call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
}
@@ -980,6 +1229,37 @@ test "a look takes acme's :addr and file:addr, and a miss changes nothing" {
try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: ");
}
+test "the root look's file:/re/ goes on from the dot, and a miss leaves the dot as it was" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "foo x foo y\nfoo\n");
+ defer p.deinit();
+ const dot = Node.of(serialOf(p), .dot);
+ const root = @intFromEnum(tree.TopFile.look);
+ var line: [256]u8 = undefined;
+ const path = p.panes[0].?.file.?.path;
+ const again = try std.fmt.bufPrint(&line, "{s}:/foo/\n", .{path});
+ for ([_][]const u8{ " 0 3 ", " 6 9 ", " 12 15 ", " 0 3 " }) |want| {
+ try testing.expectEqual(Status.ok, wr(p, root, again).reply.status);
+ try testing.expectEqualStrings(want, rd(p, dot, 0, 64).bytes);
+ }
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "{s}:/zzqq/\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes);
+}
+
+test "a look's miss repeats the path whole up to 256 bytes, then cuts it with an ellipsis" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root = @intFromEnum(tree.TopFile.look);
+ const long = "some/deeply/nested/directory/that/goes/on/and/on/with/a_rather_long_file_name.txt";
+ var line: [512]u8 = undefined;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "{s}\n", .{long}));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&line, "look: no match for \"{s}\"\n", .{long})));
+ const huge = "é" ** 150; // 300 bytes: cut at 256, between characters
+ _ = wr(p, root, huge ++ "\n");
+ try testing.expect(th.logHas(p, "look: no match for \"" ++ "é" ** 128 ++ "…\"\n"));
+}
+
test "exec runs a builtin at the pane and records the pane it acted on" {
const gpa = testing.allocator;
const p = try withFile(gpa, "Msg fs-ran\n");
@@ -1133,13 +1413,16 @@ test "get reloads the pane from disk and reports a missing file without losing d
try testing.expectEqualStrings("from disk\n", pane.file.?.content);
try testing.expect(!pane_files.dirtyOf(pane));
try testing.expect(pane.file.?.history.undo_len > 0);
+ // What it had (renamed, so dirty) was thrown away for it: logged.
+ var reloaded: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
_ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory}));
_ = wr(p, Node.of(serial, .body), "unsaved\n");
// Unsaved text is asked about once, as acme's get asks winclean.
const asked = wr(p, ctl_node, "get\n");
try testing.expectEqual(E.IO, asked.errno());
- try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)"));
+ try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard) \"get\""));
try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno());
try testing.expect(p.paneBySerial(serial) != null);
try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
@@ -1174,16 +1457,17 @@ test "a pane's own look and exec go to the event reader holding it, which writes
const pane = p.panes[0].?;
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") == null);
const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes;
- try testing.expectEqualStrings("FX0 0 2 10 Msg routed\n", record);
+ // Flag 1: its first word is a builtin; no 2, no expansion follows.
+ try testing.expectEqualStrings("FX0 0 1 10 Msg routed\n", record);
// Written back, it is done.
try testing.expectEqual(tree.Status.ok, wr(p, event, record).reply.status);
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") != null);
try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .look), "one.txt:3\n").reply.status);
- try testing.expectEqualStrings("FL0 0 6 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ try testing.expectEqualStrings("FL0 0 4 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
// The root's is a click at the pane with the keyboard, this one.
try testing.expectEqual(tree.Status.ok, wr(p, root_exec, "Msg rooted\n").reply.status);
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "rooted") == null);
- try testing.expectEqualStrings("FX0 0 2 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ try testing.expectEqualStrings("FX0 0 1 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
_ = call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
// With no reader, the root's and the pane's act at once.
try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg direct\n").reply.status);
@@ -1207,6 +1491,40 @@ test "look and exec read back what the last command touched, a ctl write's too"
// A ctl write that makes nothing names the pane it ran at.
try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status);
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes);
+ // Closed, it is no pane to go to: a fresh read of look is empty.
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .ctl), "Del\n").reply.status);
+ try testing.expect(p.paneBySerial(made) == null);
+ try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
+}
+
+test "a builtin's Verbose announcement is logged only when it succeeds, its failure said now or by the host later" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var want: [64]u8 = undefined;
+ const said_exit = try std.fmt.bufPrint(&want, "msg {d} Exit\n", .{serial});
+ // Refused as it runs, through a click: the write fails, and no Exit.
+ const f = &p.panes[0].?.file.?;
+ f.saved_revision = f.revision -% 1;
+ f.saved_hash = 0;
+ try testing.expect(wr(p, Node.of(serial, .exec), "Exit\n").errno() != 0);
+ try testing.expect(!th.logHas(p, said_exit));
+ // Refused at the keyboard (no write to fail), edited since: the same.
+ f.revision +%= 1;
+ _ = p.executeBuiltinLine(p.active, "Exit");
+ try testing.expect(!th.logHas(p, said_exit));
+ try testing.expect(!p.quit);
+ // A Save the host could not do, told a step later: the Save goes too.
+ var save_buf: [64]u8 = undefined;
+ const said_save = try std.fmt.bufPrint(&save_buf, "msg {d} Save\n", .{serial});
+ _ = wr(p, Node.of(serial, .exec), "Save\n");
+ p.saveFailed(@intCast(p.paneBySerial(serial).?), "/test.txt", error.AccessDenied);
+ try testing.expect(!th.logHas(p, said_save));
+ // One that does what it says keeps its announcement.
+ _ = wr(p, Node.of(serial, .exec), "Undo\n");
+ var undo_buf: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&undo_buf, "msg {d} Undo", .{serial})));
}
test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" {
@@ -1297,7 +1615,7 @@ test "a Restore of a file that is no dump fails the write before any warning, an
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]);
+ try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: file not found", 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" {
@@ -1316,3 +1634,468 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back
try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, line) != null);
}
}
+
+test "Shell refuses a path that is no executable, and bare it goes back to the default" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const refused = wr(p, root_ctl, "Shell /nonexistent/zzsh\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: no shell \"/nonexistent/zzsh\"") != null);
+ try testing.expect(th.logHas(p, "no shell \"/nonexistent/zzsh\""));
+ try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno());
+ try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory"));
+ try testing.expectEqualStrings("", p.settings.shell.requested.get());
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell /bin/sh\n").reply.status);
+ try testing.expectEqualStrings("/bin/sh", p.settings.shell.requested.get());
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell\n").reply.status);
+ try testing.expectEqualStrings("", p.settings.shell.requested.get());
+ try testing.expect(th.logHas(p, "Shell: the default again, "));
+ var want: [300]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 8192).bytes, try std.fmt.bufPrint(&want, "Shell {s}\n", .{config.defaultShell()})) != null);
+}
+
+test "a builtin that fails a ctl write logs only its err, so the same failure again is counted" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ const ctl_node = @intFromEnum(tree.TopFile.ctl);
+ for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status);
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word \"Kill zzz\" (x3)\n"));
+ try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "an exec line over the command limit fails the write and says the limit; a builtin's may be longer" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const long = "echo " ++ "y" ** pardes.exec.command_max ++ "\n";
+ const refused = wr(p, root_exec, long);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectEqualStrings(e_too_long, refused.reply.ename);
+ try testing.expect(th.logHas(p, "a command line is at most 1024 bytes"));
+ try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg " ++ "z" ** 1100 ++ "\n").reply.status);
+}
+
+test "Edit's p and = never fail for want of room for +Errors: the text is logged instead" {
+ const p = try withFile(testing.allocator, "one\ntwo\n");
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 6 } });
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,p\n").reply.status);
+ try testing.expect(th.logHas(p, " one\n") and th.logHas(p, " two\n"));
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(@as(usize, 1), count); // the +Errors that had no room is gone
+}
+
+test "size sets the screen of a session no frontend is attached to, and only of one" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120 40\n").errno()); // a frontend's
+ p.unattended = true;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "size 120 40\n").reply.status);
+ try testing.expectEqual(@as(u16, 120), p.screen_w);
+ try testing.expectEqual(@as(u16, 40), p.screen_h);
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120\n").errno());
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size\n").errno());
+ try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 5 2\n").reply.ename, "at least 20x6, at most 4096x4096") != null);
+ // Too small for the panes it has, each its tag and two rows: refused,
+ // the size as it was.
+ for (0..4) |_| p.newScratchBelow(p.active);
+ p.sync();
+ try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 80 8\n").reply.ename, "too small for the panes") != null);
+ try testing.expectEqual(@as(u16, 40), p.screen_h);
+}
+
+test "Mount dials its peer and fails, mounting nothing, when none answers; a look through a peer gone says dial failed" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const dead = "/tmp/pardes-test-no-such-peer.sock";
+ const refused = wr(p, root_ctl, "Mount dead " ++ dead ++ "\n");
+ try testing.expectEqual(E.IO, refused.errno());
+ try testing.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer \"Mount dead " ++ dead ++ "\"", refused.reply.ename);
+ try testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
+ // Mounted when it answered, gone since: a look through it says so.
+ try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "gone"), .dial = try p.gpa.dupe(u8, dead) });
+ const looked = wr(p, @intFromEnum(tree.TopFile.look), "/n/gone/f.txt\n");
+ try testing.expect(looked.errno() != 0);
+ try testing.expect(th.logHas(p, "look: /n/gone/f.txt: dial failed: no answer"));
+}
+
+test "a root exec that fails logs its err against the pane it ran at" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const f = &p.panes[0].?.file.?;
+ f.saved_revision = f.revision -% 1;
+ f.saved_hash = 0;
+ try testing.expect(wr(p, root_exec, "Exit\n").errno() != 0);
+ var want: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: ", .{serialOf(p)})));
+}
+
+test "size is monotonic: growing is never refused, and a size once taken is taken again" {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ p.unattended = true;
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const new = @intFromEnum(tree.TopFile.new);
+ // The dogfood's repro: Newcol, three pane/new, a Tty, then pane/new
+ // until a column has no room (ENOSPC).
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ for (0..3) |_| try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .ctl), "Tty\n").reply.status);
+ p.sync();
+ while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync();
+ p.sync();
+ var line: [32]u8 = undefined;
+ // Down a row at a time (59, 58, 57, 56 and on) until one is refused:
+ // once refused, every smaller one is too.
+ var taken: [60]bool = @splat(false);
+ var rows: u16 = 59;
+ var refused = false;
+ while (rows >= 20) : (rows -= 1) {
+ taken[rows] = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ if (refused) try testing.expect(!taken[rows]);
+ if (!taken[rows]) refused = true;
+ }
+ try testing.expect(refused); // the columns are full: some size is too small
+ // Back up: each size is taken again exactly when it was on the way down.
+ rows = 20;
+ while (rows <= 59) : (rows += 1) {
+ const ok = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ try testing.expectEqual(taken[rows], ok);
+ }
+ for ([_]u16{ 60, 61, 64, 70, 100 }) |grown| {
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{grown})).reply.status);
+ try testing.expectEqual(grown, p.screen_h);
+ }
+ // Out of range is EINVAL, in words 9ns reads as that.
+ const bad = wr(p, root_ctl, "size 5 2\n");
+ try testing.expectEqual(E.INVAL, bad.errno());
+ try testing.expectStringStartsWith(bad.reply.ename, "invalid size");
+}
+
+test "Repl with no such language names a few whole, and where the rest are" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const refused = wr(p, Node.of(serialOf(p), .ctl), "Repl zzlang\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Repl: no language \"zzlang\"; - or one like zig ada bash c c_sharp clojure (") != null);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, " more: docs/tags.md, Repl)") != null);
+}
+
+test "a 10k-line Edit text block is taken in linear time and memory" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ var block: std.ArrayList(u8) = .empty;
+ defer block.deinit(gpa);
+ try block.appendSlice(gpa, "Edit $a\n");
+ for (0..10_000) |i| try block.print(gpa, "line {d} of the block {{ with a brace }}\n", .{i});
+ try block.appendSlice(gpa, ".\n");
+ const started = std.Io.Clock.awake.now(std.testing.io);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), block.items).reply.status);
+ const took = started.durationTo(std.Io.Clock.awake.now(std.testing.io)).toNanoseconds();
+ try testing.expect(took < std.time.ns_per_s);
+ try testing.expect(p.scratch.queryCapacity() < 50 * 1024 * 1024);
+ try testing.expectEqual(@as(usize, 10_001), std.mem.count(u8, p.panes[0].?.file.?.content, "\n"));
+}
+
+test "a file changed on disk under unsaved edits is not reloaded: it says so, get asks, Save warns once" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const f = &p.panes[0].?.file.?;
+ // Clean, it takes the new text.
+ pardes.panes.File.changed(p, 0, "two\n");
+ try testing.expectEqualStrings("two\n", f.content);
+ var reloaded: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
+ // Edited, it keeps its text and stays dirty.
+ _ = wr(p, Node.of(serial, .body), "mine\n");
+ pardes.panes.File.changed(p, 0, "three\n");
+ try testing.expectEqualStrings("two\nmine\n", f.content);
+ try testing.expect(f.revision != f.saved_revision);
+ try testing.expect(th.logHas(p, "changed on disk (get reloads it, Save overwrites it)"));
+ var want: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "changed {d}\n", .{serial})));
+ // Exit and get still ask about the edits.
+ try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, wr(p, Node.of(serial, .ctl), "get\n").reply.ename, "Modified") != null);
+ // Save warns once before overwriting the newer file, then writes.
+ const first = wr(p, Node.of(serial, .ctl), "Save\n");
+ try testing.expectEqual(Status.err, first.reply.status);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "modified on disk since read") != null);
+ try testing.expect(wr(p, Node.of(serial, .ctl), "Save\n").saved);
+}
+
+test "a looked word is found in its own pane, next place after the dot, wrapping, and opens no pane" {
+ const p = try withFile(testing.allocator, "foo x foo y\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const look_node = Node.of(serial, .look);
+ const dot = Node.of(serial, .dot);
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ for ([_][]const u8{ " 0 3 ", " 6 9 ", " 0 3 " }) |want| {
+ try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
+ try testing.expectEqualStrings(want, rd(p, dot, 0, 64).bytes);
+ }
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // LookWord list: the +Search listing, as before.
+ try testing.expect(p.executeBuiltinLine(0, "LookWord list"));
+ try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
+ after = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expect(after > count);
+ // ...and look reads back that +Search pane, where the places are.
+ var listed = false;
+ var rows = std.mem.tokenizeScalar(u8, rd(p, look_node, 0, 256).bytes, '\n');
+ while (rows.next()) |row| {
+ const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, row, 10)).?].?;
+ if (made.file) |f| if (f.output) |o| if (o.from == .search) {
+ listed = true;
+ };
+ }
+ try testing.expect(listed);
+}
+
+test "every word's /commands description is its own doc comment's first sentence, no citation" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const listed = rd(p, @intFromEnum(tree.TopFile.commands), 0, 1 << 16).bytes;
+ for ([_][]const u8{
+ "\nRepl arg pane -- Binds this terminal as a language's REPL, so an exec on that language's files is typed into it instead of run.\n",
+ "\nJoincol root -- Fold the active pane's column into the one on its right, keeping its panes.\n",
+ "\nExit root -- Quit the editor, refusing once while a pane holds unsaved text: an Exit with nothing edited since discards it.\n",
+ "\nColors arg root on,off -- Syntax colours in text panes, on or off.\n",
+ // fs.md's examples, as they read.
+ "\nNewcol root -- An empty column right of the keyboard's, its tag taking the keyboard.\n",
+ "\nSave arg pane -- Write the pane's text to its file, or to the file its argument names.\n",
+ "\nVerbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off.\n",
+ "\nPlacement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules.\n",
+ "\nKill arg root -- acme's Kill: stop the commands pardes started",
+ }) |row| try testing.expect(std.mem.indexOf(u8, listed, row) != null);
+ try testing.expect(std.mem.indexOf(u8, listed, ".c:") == null);
+ // Every description is the first sentence of the doc comment just
+ // above its word's declaration in builtins.zig.
+ const source = @embedFile("../builtins.zig");
+ var it = std.mem.tokenizeScalar(u8, listed, '\n');
+ var undescribed: usize = 0;
+ while (it.next()) |line| {
+ // Every builtin has one: a word nobody can look up is no use.
+ const dashes = std.mem.indexOf(u8, line, " -- ") orelse {
+ std.debug.print("no doc comment: {s}\n", .{line});
+ undescribed += 1;
+ continue;
+ };
+ const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ').?];
+ if (config.Runtime.find(word)) |setting| {
+ try testing.expect(std.mem.endsWith(u8, line, setting.doc));
+ continue;
+ }
+ var needle: [64]u8 = undefined;
+ const at = std.mem.indexOf(u8, source, try std.fmt.bufPrint(&needle, "\npub const {s} = struct", .{word})).?;
+ const said = std.mem.trimEnd(u8, line[dashes + 4 ..], "…");
+ // Its opening words are in the comment block right above.
+ const head = said[0..@min(said.len, 10)];
+ const block_start = std.mem.lastIndexOf(u8, source[0..at], "\n\n") orelse 0;
+ try testing.expect(std.mem.indexOf(u8, source[block_start..at], head) != null);
+ }
+ try testing.expectEqual(0, undescribed);
+}
+
+test "Newcol at the column cap fails the write and says the cap" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 4000, .rows = 30 } });
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ while (p.ncol < pardes.MAX_COLS) {
+ // From the widest column's tag, so no column gets too narrow first.
+ p.sync();
+ var widest: usize = 0;
+ for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) {
+ widest = c;
+ };
+ pardes.tagline.enterHeader(p, widest);
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ }
+ const refused = wr(p, root_ctl, "Newcol\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ var want: [64]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, try std.fmt.bufPrint(&want, "no space for a column: {d} max", .{pardes.MAX_COLS})) != null);
+ try testing.expect(th.logHas(p, "no space for a column"));
+}
+
+test "Save on a terminal asked over 9P is an ask in the log, answered with a path, and not asked twice" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Save\n").reply.status);
+ var want: [48]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} save path\n", .{serial})));
+ try testing.expect(p.panes[id].?.prompt == .save);
+ // Again: the one open stands, one ask in the log.
+ _ = wr(p, Node.of(serial, .tagexec), "Save\n");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, "save path\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ // Answered: the path is written to, the prompt goes.
+ const answered = wr(p, Node.of(serial, .ctl), "answer /tmp/pardes-ask-save.txt\n");
+ try testing.expectEqual(Status.ok, answered.reply.status);
+ try testing.expect(p.panes[id].?.prompt == .none);
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} /tmp/pardes-ask-save.txt\n", .{serial})));
+}
+
+test "get names the path it could not read, logs the edits it threw away, and a deleted file is said once" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const f = &p.panes[0].?.file.?;
+ const path = f.path;
+ // Its file is not on disk: get names it.
+ const missing = wr(p, Node.of(serial, .ctl), "get\n");
+ try testing.expectEqual(E.NOENT, missing.errno());
+ var want: [512]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "get: {s}: no such file", .{path}), missing.reply.ename);
+ // Deleted on disk: said once.
+ pardes.panes.File.deleted(p, 0);
+ pardes.panes.File.deleted(p, 0);
+ var rec: [32]u8 = undefined;
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, try std.fmt.bufPrint(&rec, "changed {d} deleted\n", .{serial})));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "a builtin that fails through exec, tagexec or a column's exec fails the write with one err and no msg" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ const serial = serialOf(p);
+ for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| {
+ const failed = wr(p, node, "Kill zzz\n");
+ try testing.expectEqual(Status.err, failed.reply.status);
+ try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Kill: no running command has that first word") != null);
+ }
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ try testing.expect(std.mem.indexOf(u8, text, "msg ") == null);
+ // The root's exec ran at the pane, so its err and the pane's exec's are
+ // the same record, counted.
+ try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Kill: no running command has that first word"));
+ try testing.expect(std.mem.indexOf(u8, text, "first word (x2)") != null);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ // A control character is refused with its reason.
+ try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename);
+ // A look miss: one err, no msg.
+ _ = wr(p, @intFromEnum(tree.TopFile.look), "zzqqnotthere\n");
+ try testing.expect(th.logHas(p, "look: no match for \"zzqqnotthere\""));
+ try testing.expect(!th.logHas(p, "msg 1 Look: no match"));
+}
+
+test "a word written to a raw terminal's look is listed as in normal mode" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one MARK two\r\n" } });
+ p.panes[id].?.body.mode = .tty;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "MARK\n").reply.status);
+ const said = std.mem.trimEnd(u8, rd(p, Node.of(serial, .look), 0, 64).bytes, "\n");
+ const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, said, 10)).?].?;
+ try testing.expect(made.file.?.output.?.from == .search);
+}
+
+test "@p<serial>:<address> takes any sam address; a miss and an unknown serial are errs" {
+ const p = try withFile(testing.allocator, "foo x foo y\nbar\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const root = @intFromEnum(tree.TopFile.look);
+ const dot = Node.of(serial, .dot);
+ const Case = struct { addr: []const u8, want: []const u8 };
+ for ([_]Case{
+ .{ .addr = "0/foo/", .want = " 0 3 " },
+ .{ .addr = "/foo/", .want = " 6 9 " },
+ .{ .addr = "#12,#15", .want = " 12 15 " },
+ .{ .addr = "$", .want = " 16 16 " },
+ .{ .addr = "1,2", .want = " 0 16 " },
+ // Backward, as in addr: from the dot's start, wrapping to the last.
+ .{ .addr = "?foo?", .want = " 6 9 " },
+ .{ .addr = "$-?y?", .want = " 10 11 " },
+ }) |c| {
+ var line: [64]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:{s}\n", .{ serial, c.addr })).reply.status);
+ try testing.expectEqualStrings(c.want, rd(p, dot, 0, 64).bytes);
+ }
+ var line: [64]u8 = undefined;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial}));
+ try testing.expect(th.logHas(p, "look: no match for regexp"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+ _ = wr(p, root, "@p999:/foo/\n");
+ try testing.expect(th.logHas(p, "look: no pane @p999"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+ // A line address to no pane is a miss too, not the active pane.
+ _ = wr(p, root, "@p77:1\n");
+ try testing.expect(th.logHas(p, "look: no pane @p77"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+}
+
+test "a look that goes to another pane leaves the looking pane's dot alone" {
+ const p = try withFile(testing.allocator, "alpha bravo\ncharlie\n");
+ defer p.deinit();
+ const source = serialOf(p);
+ const other = try th.newPane(p);
+ _ = wr(p, Node.of(other, .body), "one\ntwo\nthree\n");
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f one\nf two\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ for ([_][]const u8{ "@p{d}:2", "@p{d}:/three/", "{s}/f.txt:2" }) |form| {
+ @import("pane.zig").setDot(p.panes[p.paneBySerial(source).?].?, .{ .q0 = 6, .q1 = 11 });
+ const text = if (form[0] == '@')
+ (if (std.mem.eql(u8, form, "@p{d}:2")) try std.fmt.bufPrint(&line, "@p{d}:2\n", .{other}) else try std.fmt.bufPrint(&line, "@p{d}:/three/\n", .{other}))
+ else
+ try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{dir});
+ try testing.expectEqual(Status.ok, wr(p, Node.of(source, .look), text).reply.status);
+ try testing.expect(p.active != p.paneBySerial(source).?);
+ try testing.expectEqualStrings(" 6 11 ", rd(p, Node.of(source, .dot), 0, 64).bytes);
+ }
+}
+
+test "@p<terminal>:<address> takes any address over the terminal's lines, selecting the match" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one\r\ntwo MARK three\r\n" } });
+ const pane = p.panes[id].?;
+ const root = @intFromEnum(tree.TopFile.look);
+ var line: [64]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:0/MARK/\n", .{serial})).reply.status);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_row);
+ try testing.expect(pane.body.vsel.active);
+ // #n lands on that byte: #5 is the `w` of `two`.
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:#5\n", .{serial})).reply.status);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_row);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_col);
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial}));
+ try testing.expect(th.logHas(p, "look: no match for regexp"));
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index ccc61352..98799774 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -110,7 +110,9 @@ pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply {
// ---- the editor-wide log ----
-pub const LogKind = enum { new, del, rename, save };
+/// `unsaved`: a pane a refusal (Exit, Restore, Del, Delcol) is about,
+/// logged before the write's err so a script reads which, whole.
+pub const LogKind = enum { new, del, rename, save, unsaved };
/// A pane leaving, before its state goes with it; one never announced
/// leaves silently.
@@ -122,6 +124,12 @@ pub fn noteRetire(p: *Pardes, pane: *Pane) void {
pane.fs.unannounced = false;
return;
}
+ // A question still standing ends with it, so a follower of its `ask`
+ // hears the end.
+ if (pane.prompt == .repl_choice or pane.prompt == .del_side or pane.prompt == .save) {
+ var rec: [32]u8 = undefined;
+ notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} -", .{pane.serial}) catch "");
+ }
noteLog(p, .del, pane);
}
@@ -143,12 +151,20 @@ pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
}
/// Records `msg <serial> <text>` for what the editor said, `-` for no pane.
-pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void {
+/// Text past LoggedMessage.cap bytes is cut between words with an ellipsis,
+/// as an err record's reason is.
+pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
var buf: [pardes.Messages.LoggedMessage.cap + 32]u8 = undefined;
- pushLog(p, (if (serial == 0)
- std.fmt.bufPrint(&buf, "msg - {s}\n", .{text})
+ const kept = pardes.Messages.clip(said, pardes.Messages.LoggedMessage.cap);
+ const cut = if (kept.len < said.len) "…" else "";
+ // A builtin's own word for what it did stands in for its Verbose
+ // announcement just before (`Undo` then `Undo: nothing to undo`), so
+ // that 1000 Undos with nothing left are one line, counted, as err's are.
+ dropAnnouncement(p, serial, kept);
+ pushCounting(p, (if (serial == 0)
+ std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut })
else
- std.fmt.bufPrint(&buf, "msg {d} {s}\n", .{ serial, text })) catch return);
+ std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return);
}
/// Records `err <serial|-> <file>: <why>` for a write or truncation that was
@@ -165,16 +181,25 @@ pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void {
pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno);
- var name: [16]u8 = undefined;
+ var name: [32]u8 = undefined;
var serial: []const u8 = "-";
var file: []const u8 = "os";
if (req.node & tree.sources.archive_node != 0) file = "src";
if (req.node != pardes.filesystem.os_root and req.node & pardes.filesystem.os_node == 0 and req.node & tree.sources.archive_node == 0) {
if (tree.Node.target(req.node)) |t| switch (t) {
- .top => |f| file = f.fileName(),
+ .top => |f| {
+ file = f.fileName();
+ // The root's exec or look ran at the keyboard's pane: its
+ // failure is that pane's, as a click there would be.
+ if ((f == .exec or f == .look) and p.fs.ran_at != 0)
+ serial = std.fmt.bufPrint(&name, "{d}", .{p.fs.ran_at}) catch "-";
+ },
.col => |c| {
serial = "-";
- file = std.fmt.bufPrint(&name, "col/{d}/tag", .{c.serial}) catch "col";
+ file = (if (c.file == .dir)
+ std.fmt.bufPrint(&name, "col/{d}", .{c.serial})
+ else
+ std.fmt.bufPrint(&name, "col/{d}/{s}", .{ c.serial, @tagName(c.file) })) catch "col";
},
.pane => |pt| {
serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-";
@@ -189,26 +214,91 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
};
}
var buf: [256]u8 = undefined;
- const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}\n", .{ serial, file, why[0..@min(why.len, 200)] }) catch return;
+ // A long reason is cut between words, with an ellipsis, never mid-word.
+ const kept = pardes.Messages.clip(why, 200);
+ const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}{s}\n", .{ serial, file, kept, if (kept.len < why.len) "…" else "" }) catch return;
for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') {
c.* = ' ';
};
+ pushCounting(p, record);
+}
+
+/// Drops the newest record when it is the Verbose announcement (`msg
+/// <serial> <Word>`) of the builtin whose words `text` are (`Word: ...`):
+/// what the builtin said, or the err its failure logs, takes its place.
+/// Not one a follower has read.
+pub fn dropAnnouncement(p: *Pardes, serial: u32, text: []const u8) void {
+ const last = newest(p) orelse return;
+ if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
+ const rest = last.text["msg ".len..];
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
+ const word = rest[@min(sp + 1, rest.len)..];
+ var who: [16]u8 = undefined;
+ const mine = if (serial == 0) "-" else std.fmt.bufPrint(&who, "{d}", .{serial}) catch "";
+ if (std.mem.eql(u8, rest[0..sp], mine) and word.len > 0 and std.mem.indexOfScalar(u8, word, ' ') == null and
+ text.len > word.len and std.mem.startsWith(u8, text, word) and text[word.len] == ':')
+ p.fs.log.buf.shrinkRetainingCapacity(last.at);
+}
+
+/// The sequence number of the newest log record when it is a msg that
+/// ends in `word` alone: not one counted (`(x2)`) over an earlier say.
+pub fn newestSaying(p: *Pardes, word: []const u8) ?u64 {
+ const last = newest(p) orelse return null;
+ if (!std.mem.startsWith(u8, last.text, "msg ") or !std.mem.endsWith(u8, last.text, word)) return null;
+ if (last.text[last.text.len - word.len - 1] != ' ') return null;
+ return last.seq;
+}
+
+/// Takes record `seq` back out of the log, unless a follower has read it.
+pub fn dropRecord(p: *Pardes, seq: u64) void {
+ if (followerRead(p, seq)) return;
+ const q = &p.fs.log;
+ var at = q.head;
+ var s = q.dropped;
+ while (at + 4 <= q.buf.items.len) : (s += 1) {
+ const size = 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ if (s == seq) {
+ const items = q.buf.items;
+ std.mem.copyForwards(u8, items[at..], items[at + size ..]);
+ q.buf.shrinkRetainingCapacity(items.len - size);
+ return;
+ }
+ at += size;
+ }
+}
+
+/// A builtin's failure: its Verbose announcement goes from the log, when
+/// `text` is that builtin's (it starts with its word) or `any`, as for a
+/// failure said while the builtin itself runs.
+pub fn dropFailedAnnouncement(p: *Pardes, text: []const u8, any: bool) void {
+ const seq = p.fs.announced_seq orelse return;
+ const word = p.fs.announced_word[0..p.fs.announced_word_len];
+ if (!any and !(std.mem.startsWith(u8, text, word) and text.len > word.len and (text[word.len] == ' ' or text[word.len] == ':'))) return;
+ p.fs.announced_seq = null;
+ dropRecord(p, seq);
+}
+
+/// Drops the newest record when it is a msg saying `text` (of any pane)
+/// that no follower has read: a failure its write's err record will say.
+pub fn dropMessage(p: *Pardes, text: []const u8) void {
+ const last = newest(p) orelse return;
+ if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
+ const rest = last.text["msg ".len..];
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse return;
+ if (std.mem.eql(u8, rest[sp + 1 ..], text)) p.fs.log.buf.shrinkRetainingCapacity(last.at);
+}
+
+/// Pushes `record`, or, when it repeats the newest record word for word,
+/// counts that one instead: `<record> (x2)`, the count being every time it
+/// was said. One a follower has already read is not rewritten: the repeat
+/// is a new line carrying the running count, `(x3)`.
+fn pushCounting(p: *Pardes, record: []u8) void {
// A client retrying a write that fails the same way would fill the ring
// with one line, so a repeat of the newest record is that record counted,
// `(x3)`, as +Messages counts its repeats; unless a follower has read it
// already and so waits on the repeat as a line of its own.
- const q = &p.fs.log;
- var at = q.head;
- var last: ?usize = null;
- var seq = q.dropped;
- while (at + 4 <= q.buf.items.len) : (seq += 1) {
- last = at;
- at += 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
- }
- const last_at = last orelse return pushLog(p, record);
- const last_seq = seq - 1;
- var said = q.buf.items[last_at + 4 ..];
- said = said[0 .. said.len - 1];
+ const last = newest(p) orelse return pushLog(p, record);
+ var said = last.text;
var times: u32 = 1;
if (std.mem.endsWith(u8, said, ")")) if (std.mem.lastIndexOf(u8, said, " (x")) |x| {
if (std.fmt.parseInt(u32, said[x + 3 .. said.len - 1], 10)) |n| {
@@ -217,14 +307,35 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
} else |_| {}
};
if (!std.mem.eql(u8, said, record[0 .. record.len - 1])) return pushLog(p, record);
+ var counted: [pardes.Messages.LoggedMessage.cap + 64]u8 = undefined;
+ const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return;
+ if (!followerRead(p, last.seq)) p.fs.log.buf.shrinkRetainingCapacity(last.at);
+ pushLog(p, again);
+}
+
+/// The newest record in the ring: where it starts, its sequence number and
+/// its text without the newline.
+fn newest(p: *Pardes) ?struct { at: usize, seq: u64, text: []const u8 } {
+ const q = &p.fs.log;
+ var at = q.head;
+ var last: ?usize = null;
+ var seq = q.dropped;
+ while (at + 4 <= q.buf.items.len) : (seq += 1) {
+ last = at;
+ at += 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ }
+ const last_at = last orelse return null;
+ const text = q.buf.items[last_at + 4 ..];
+ return .{ .at = last_at, .seq = seq - 1, .text = text[0 .. text.len - 1] };
+}
+
+/// Whether a follower has read the record `seq`, or is part way through it.
+fn followerRead(p: *Pardes, seq: u64) bool {
for (p.fs.opens) |o| if (o.node != 0 and o.what == .log) {
const l = o.what.log;
- if (l.follow and (l.next > last_seq or (l.next == last_seq and l.part > 0))) return pushLog(p, record);
+ if (l.follow and (l.next > seq or (l.next == seq and l.part > 0))) return true;
};
- var counted: [280]u8 = undefined;
- const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return;
- q.buf.shrinkRetainingCapacity(last_at);
- pushLog(p, again);
+ return false;
}
/// The log is one ring that records whether or not anyone reads it. A record
@@ -322,7 +433,12 @@ pub fn readLog(p: *Pardes, req: Req) Reply {
/// records instead of ending; closing it is the way back.
pub fn writeLog(p: *Pardes, req: Req) Reply {
const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.log;
- if (!std.mem.eql(u8, std.mem.trim(u8, req.data, " \t\r\n"), "follow")) return Reply.fail(req.tag, E.INVAL);
+ const word = std.mem.trim(u8, req.data, " \t\r\n");
+ // `follow` reads what the open froze, then waits; `follow new` skips
+ // what was there and waits for what comes after, as tail -n0 -f does.
+ if (std.mem.eql(u8, word, "follow new")) {
+ slot.pos = slot.bytes.len;
+ } else if (!std.mem.eql(u8, word, "follow")) return Reply.fail(req.tag, E.INVAL);
slot.follow = true;
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -459,6 +575,16 @@ pub fn noteAction(
return true;
}
+/// A record that follows another with no place of its own, `<c>0 0 0 <n>
+/// <text>`: a chorded exec's argument and its location (acme exec.c:182).
+pub fn noteFollow(p: *Pardes, id: usize, action: Action, text: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.fs.readers == 0) return;
+ var buf: [max_record_text + 64]u8 = undefined;
+ pane.fs.events.push(p.gpa, formatRecord(&buf, p.fs.origin, action, 0, 0, 0, text));
+ p.fs.news = true;
+}
+
pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
if (id >= MAX_PANES or bytes.len == 0) return;
const pf = &(p.panes[id] orelse return).fs;
@@ -472,7 +598,7 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
p.fs.news = true;
}
-const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, text: ?[]const u8 = null };
+const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, flag: u32 = 0, text: ?[]const u8 = null };
const EventReader = struct {
data: []const u8,
@@ -493,8 +619,9 @@ const EventReader = struct {
// is taken too, so a click with no place in the text (one written
// to a pane's own look or exec) can be written back and done.
var text: ?[]const u8 = null;
+ var flag: u32 = 0;
if (i < er.data.len and er.data[i] != '\n') {
- _ = scanNumber(er.data, &i) orelse return null;
+ flag = scanNumber(er.data, &i) orelse return null;
const n = scanNumber(er.data, &i) orelse return null;
if (i >= er.data.len or er.data[i] != ' ' or er.data.len - (i + 1) < n) return null;
text = er.data[i + 1 ..][0..n];
@@ -502,7 +629,7 @@ const EventReader = struct {
}
if (i >= er.data.len or er.data[i] != '\n') return null;
er.i = i + 1;
- return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .text = text };
+ return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .flag = flag, .text = text };
}
};
@@ -530,7 +657,9 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event),
}
const n = if (r.action.onTag()) tag.len else body.len;
- if (r.q0 > r.q1 or r.q1 > n) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
+ if (r.q0 > r.q1) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
+ // Well formed, but not a range of this text: say which.
+ if (r.q1 > n) return tree.failText(req.tag, E.INVAL, if (r.action.onTag()) "range past end of tag" else "range past end of body");
}
if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
}
@@ -548,9 +677,65 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
// been (acme's xfideventwrite, xfid.c:842-844): a REPL bound for
// it takes it. One that came from a write to exec (`F`) was
// never a click, and runs as the command it was.
- .body_exec => _ = exec.executeFrom(p, live, text, r.origin != 'F'),
- .tag_exec => _ = exec.execute(p, live, text),
- .body_look, .tag_look => look.lookAt(p, live, text),
+ // The chorded exec a reader was told of runs with its argument
+ // (mouse.reportGesture), when it is that range written back.
+ .body_exec, .tag_exec => {
+ const pf = &pane.fs;
+ // The two records that follow a chorded exec (its argument
+ // and where it came from), written back after it, are its:
+ // consumed, never run as commands of their own.
+ if (pf.chord_follow > 0 and r.q0 == 0 and r.q1 == 0 and r.flag == 0) {
+ pf.chord_follow -= 1;
+ // The argument a command written back alone waited for.
+ if (pf.chord_pending) |command| {
+ pf.chord_pending = null;
+ defer p.gpa.free(command);
+ const line = exec.withArg(p, command, r.text orelse "");
+ if (pf.chord_pending_tag) _ = exec.execute(p, live, line) else _ = exec.executeFrom(p, live, line, true);
+ }
+ continue;
+ }
+ var arg = if (pf.chord_arg) |a| (if (pf.chord_at.q0 == r.q0 and pf.chord_at.q1 == r.q1) a else null) else null;
+ if (r.flag & flag_chorded != 0) {
+ // Its argument is the next record when it came in the
+ // same write; the rest that follow are consumed.
+ pf.chord_follow = 2;
+ const save = run.i;
+ if (run.next()) |following| if (following.q0 == 0 and following.q1 == 0 and following.flag == 0) {
+ if (following.text) |t| arg = p.scratch.allocator().dupe(u8, t) catch arg;
+ pf.chord_follow = 1;
+ } else {
+ run.i = save;
+ };
+ // Alone, with no argument kept: it waits for its own.
+ if (arg == null) {
+ if (pf.chord_pending) |old| p.gpa.free(old);
+ pf.chord_pending = p.gpa.dupe(u8, text) catch null;
+ pf.chord_pending_tag = r.action == .tag_exec;
+ continue;
+ }
+ }
+ if (r.action == .tag_exec)
+ _ = exec.execute(p, live, exec.withArg(p, text, arg))
+ else
+ _ = exec.executeFrom(p, live, exec.withArg(p, text, arg), r.origin != 'F');
+ if (arg != null) if (p.paneBySerial(serial)) |still| {
+ const spf = &p.panes[still].?.fs;
+ if (spf.chord_arg) |a| p.gpa.free(a);
+ spf.chord_arg = null;
+ };
+ },
+ // What it found is what the look file reads back next, as a
+ // look written there would leave (ctl.run).
+ .body_look, .tag_look => {
+ p.look_missed = false;
+ look.lookAt(p, live, text);
+ p.fs.results_len = 0;
+ if (!p.look_missed) if (p.panes[p.active]) |found| {
+ p.fs.results[0] = found.serial;
+ p.fs.results_len = 1;
+ };
+ },
else => unreachable,
}
}
@@ -726,7 +911,7 @@ test "a pane deleted while its event file is open leaves no suppression behind"
try testing.expectEqual(@as(u16, 0), p.fs.listeners);
// The reader's next read hears what acme says of a window gone under it.
const shut = call(p, .{ .tag = 19, .op = .read, .node = event, .handle = a.reply.handle, .size = 64 });
- try testing.expectEqual(E.IO, shut.errno());
+ try testing.expectEqual(E.NOENT, shut.errno());
try testing.expectEqualStrings(tree.e_shut_down, shut.reply.ename);
_ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle });
@@ -767,6 +952,12 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(E.INVAL, wr(p, event, bad).errno());
try testing.expectEqual(@as(usize, 0), pane.msg_len);
}
+ // A look written back sets what look reads back.
+ _ = wr(p, event, "FL0 3\n");
+ var want_serial: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want_serial, "{d}\n", .{serial}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
+ // Well formed but past the text: said so, not "bad event syntax".
+ try testing.expectEqualStrings("range past end of body", wr(p, event, "MX0 999\n").reply.ename);
_ = call(p, .{ .tag = 23, .op = .open, .node = event });
p.fs.origin = 'K';
@@ -848,10 +1039,10 @@ test "the log records whether or not anyone reads, and an open that follows wait
try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status);
// A shell's `read` asks for a few bytes at a time: the record comes in
- // pieces rather than failing. Every repeat is its own line.
+ // pieces rather than failing. A repeat the follower waits on, having
+ // read the first, is its own line.
const said_by = p.panes[0].?.serial;
p.setMessage(0, "same failure");
- p.setMessage(0, "same failure");
var pieced: [64]u8 = undefined;
var got: usize = 0;
while (got == 0 or pieced[got - 1] != '\n') {
@@ -860,7 +1051,8 @@ test "the log records whether or not anyone reads, and an open that follows wait
got += piece.len;
}
try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure\n", .{said_by}), pieced[0..got]);
- try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure\n", .{said_by}), rdf.next(p, log, fh, frozen).bytes);
+ p.setMessage(0, "same failure");
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure (x2)\n", .{said_by}), rdf.next(p, log, fh, frozen).bytes);
// A follower the ring outran hears how much it missed, then carries on.
var filler: [200]u8 = @splat('x');
@@ -911,19 +1103,20 @@ test "a refused or failed write is an err record in the log, saying which file a
// A write that succeeds says nothing.
_ = wr(p, Node.of(serial, .addr), "#0");
try testing.expectEqual(Status.again, call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).reply.status);
- // A follower that has read an err waits on its repeat: a line of its own.
+ // A follower that has read an err waits on its repeat: a line of its
+ // own, with the running count.
_ = wr(p, Node.of(serial, .addr), "/nowhere/");
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen));
_ = wr(p, Node.of(serial, .addr), "/nowhere/");
- try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen));
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp (x2)\n", .{serial}), next.record(p, f, frozen));
_ = call(p, .{ .tag = 6, .op = .release, .node = log, .handle = f });
- // With the follower gone, repeats are the newest record counted: the
- // one it read, and three more.
+ // With the follower gone, repeats are the newest record counted on:
+ // its two, and three more.
for (0..3) |_| _ = wr(p, Node.of(serial, .addr), "/elsewhere/");
const g = call(p, .{ .tag = 7, .op = .open, .node = log }).reply.handle;
const text = call(p, .{ .tag = 8, .op = .read, .node = log, .handle = g, .size = 1 << 16 }).bytes;
- try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "regexp\nerr {d} addr: no match for regexp (x4)\n", .{serial})));
+ try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "regexp\nerr {d} addr: no match for regexp (x5)\n", .{serial})));
_ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g });
}
@@ -1020,3 +1213,46 @@ test "a click in the body reports the offsets of the word it takes, so the recor
_ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h });
}
}
+
+test "follow new skips what the log held and waits for what comes after" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ p.setMessage(0, "before the open");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = log, .handle = h, .data = "follow new\n" }).reply.status);
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = log, .handle = h, .size = 4096 }).reply.status);
+ p.setMessage(0, "after it");
+ try testing.expect(std.mem.endsWith(u8, call(p, .{ .tag = 4, .op = .read, .node = log, .handle = h, .size = 4096 }).bytes, " after it\n"));
+ _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h });
+}
+
+test "a long msg record is cut between words at its cap, with an ellipsis" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const long = "word " ** 60; // 300 bytes
+ _ = wr(p, Node.of(serialOf(p), .ctl), "Msg " ++ long ++ "\n");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ const at = std.mem.lastIndexOf(u8, text, "msg ").?;
+ const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
+ try testing.expect(std.mem.endsWith(u8, record, " word…\n"));
+ try testing.expect(record.len <= "msg 1 ".len + pardes.Messages.LoggedMessage.cap + "…\n".len);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "a long err record is cut between words, with an ellipsis" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?;
+ const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
+ try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index c594c7a3..d8329ccc 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -41,6 +41,9 @@ pub const State = struct {
/// rather than act at the address before it, which acme would do.
addr_failed: bool = false,
limit: ?Range = null,
+ /// The limit a truncation cleared, kept until the write that follows it
+ /// on that open (a shell's `>`): a write that fails puts it back.
+ limit_truncated: ?Range = null,
/// Opens of `event`, which hold the pane scripted.
readers: u16 = 0,
/// One of `readers` reads `event`; a second reading open is refused.
@@ -74,10 +77,23 @@ pub const State = struct {
/// Installed during this update; /log hears about it once the update ends
/// and the pane has its file, name and place.
unannounced: bool = false,
+ /// A chorded exec's argument an event reader was told of (flag 8), kept
+ /// for the record's write-back at `chord_at`, which runs with it.
+ chord_arg: ?[]u8 = null,
+ chord_at: Range = .{},
+ /// A chorded record written back: how many of its two follow-up records
+ /// (argument, location) are still to be consumed rather than run.
+ chord_follow: u8 = 0,
+ /// A chorded record written back alone with no argument kept: the
+ /// command, run when its argument record comes.
+ chord_pending: ?[]u8 = null,
+ chord_pending_tag: bool = false,
pub const Range = struct { q0: u32 = 0, q1: u32 = 0 };
pub fn deinit(pf: *State, gpa: std.mem.Allocator) void {
+ if (pf.chord_arg) |a| gpa.free(a);
+ if (pf.chord_pending) |c| gpa.free(c);
pf.events.deinit(gpa);
pf.pty_out.deinit(gpa);
pf.tag_snap.deinit(gpa);
@@ -128,11 +144,12 @@ pub fn clip(n: usize) u32 {
pub fn dotOf(pane: *Pane) State.Range {
const text = bodyOf(pane);
- const head = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
+ // In runes, as every address is (modal.runeStart).
+ const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
- const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
+ const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
var hi = @max(head, anchor);
- if (hi < text.len) hi = modal.nextGrapheme(text, hi);
+ if (hi < text.len) hi = modal.nextRune(text, hi);
return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) };
}
@@ -140,9 +157,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void {
const text = bodyOf(pane);
const q0 = @min(@as(usize, r.q0), text.len);
const q1 = @max(q0, @min(@as(usize, r.q1), text.len));
- const a = modal.positionAt(text, q0);
+ const a = modal.runePositionAt(text, q0);
pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
- const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0);
+ const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0);
pane.body.cur_row = @intCast(h.row);
pane.body.cur_col = @intCast(h.col);
pane.body.cur_pinned = true;
@@ -246,10 +263,11 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
const d = dotOf(pane);
break :sel @max(d.q0, d.q1) - d.q0;
},
- .addr, .dot => range_len,
+ .addr => if (pf.addr_failed) 0 else range_len,
+ .dot => range_len,
.limit => if (pf.limit == null) 0 else range_len,
.dirty, .mark, .scroll => 2,
- .look, .exec => ctl.resultsLen(p),
+ .look, .exec, .tagexec => ctl.resultsLen(p),
// What the next read would answer, which a client can see without
// parking on it.
.event => if (pf.events.peek()) |record| record.len else 0,
@@ -304,11 +322,12 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
break :tag tree.stagedReply(p, req);
},
.ctl => ctl.readPane(p, req, pane),
- .addr => addr: {
- if (pf.addr_failed) break :addr tree.failText(req.tag, E.INVAL, e_addr_failed);
+ // A failed address reads empty, as an unset limit does: there is
+ // none. data and xdata are what refuse.
+ .addr => readRange(p, req, if (pf.addr_failed) null else addr: {
clampAddr(pf, bodyOf(pane).len);
- break :addr readRange(p, req, pf.addr);
- },
+ break :addr pf.addr;
+ }),
.dot => readRange(p, req, dotOf(pane)),
.limit => readRange(p, req, pf.limit),
.data => readData(req, id, pane, pf, false),
@@ -317,7 +336,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
.dirty => readFlag(p, req, dirtyOf(pane)),
.mark => readFlag(p, req, !pf.nomark),
.scroll => readFlag(p, req, !pf.noscroll),
- .look, .exec => ctl.readResults(p, req),
+ .look, .exec, .tagexec => ctl.readResults(p, req),
.event => events.readQueue(p, req, &pf.events),
.pty_status => pty.readStatus(p, req, id, pane),
.pty_data => pty.readData(p, req, pf),
@@ -369,7 +388,7 @@ fn readData(req: Req, id: usize, pane: *Pane, pf: *State, stop_at_end: bool) Rep
const q0: usize = pf.addr.q0;
const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len;
var end = @min(hi, q0 +| req.size);
- end = @max(q0, modal.graphemeStart(text, end));
+ end = @max(q0, modal.runeStart(text, end));
pf.addr.q0 = clip(end);
if (!stop_at_end) pf.addr.q1 = clip(end);
if (pane.file == null) return .{ .tag = req.tag };
@@ -409,7 +428,9 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
.data, .xdata => writeData(p, req, pane),
.sel => writeSel(p, req, pane),
.dirty, .mark, .scroll => writeFlag(p, req, pane, file),
- .look, .exec => ctl.command(p, req, pane.serial, file == .exec),
+ .look, .exec => ctl.command(p, req, pane.serial, file == .exec, false),
+ // A click in its tag: never an event reader's to hear (cols.writeExec).
+ .tagexec => ctl.command(p, req, pane.serial, true, true),
.event => events.writeEvent(p, req, id),
.errors => if (appendErrors(p, id, req.data)) |took|
.{ .tag = req.tag, .written = @intCast(took) }
@@ -522,8 +543,13 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
};
const range = r orelse {
if (file == .addr) pf.addr_failed = true;
+ if (file == .limit) if (pf.limit_truncated) |kept| {
+ pf.limit = kept;
+ pf.limit_truncated = null;
+ };
return tree.failText(req.tag, E.INVAL, a.err);
};
+ if (file == .limit) pf.limit_truncated = null;
switch (file) {
.addr => {
pf.addr = range;
@@ -565,10 +591,18 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
/// Writing `name` renames the buffer: a relative name resolves against the
/// pane's directory, as the tag's name field does.
+/// acme's refusal of a name with a blank or a control character in it
+/// (editors/acme/xfid.c:650-652). A blank inside a name is taken here, as
+/// pardes names files with spaces; one at either end is refused, not
+/// quietly cut off, so the name a script wrote is the name it gets.
+const e_name_char = "bad character in file name";
+
fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- const name = std.mem.trim(u8, req.data, " \t\r\n");
+ // One line: its newline ends it, as `echo` writes it.
+ const name = if (std.mem.endsWith(u8, req.data, "\n")) req.data[0 .. req.data.len - 1] else req.data;
if (name.len == 0) return Reply.fail(req.tag, E.INVAL);
- for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL);
+ for (name) |c| if (c < ' ') return tree.failText(req.tag, E.INVAL, e_name_char);
+ if (name[0] == ' ' or name[name.len - 1] == ' ') return tree.failText(req.tag, E.INVAL, e_name_char);
if (fileOf(pane) == null) return tree.failText(req.tag, E.PERM, if (pane.isTerminal())
"rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
else
@@ -638,6 +672,12 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize {
return null;
};
exec.placeNew(p, id, id, free, .errors);
+ // No column had room for it (it closes at the step's end): the text is
+ // logged instead, a msg a line, so it is not lost and nothing fails.
+ if (p.unplaced.isSet(free)) {
+ var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n');
+ while (lines.next()) |line| events.noteMessage(p, pane.serial, line[0..@min(line.len, pardes.Messages.LoggedMessage.cap)]);
+ }
return text.len;
}
@@ -683,7 +723,10 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
// is evaluated from where the last one left off, as with `>>`.
// `0` (or `,`) is how to start over. See State.addr.
.addr => {},
- .limit => pf.limit = null,
+ .limit => {
+ pf.limit_truncated = pf.limit;
+ pf.limit = null;
+ },
.dot => if (fileOf(pane) != null) setDot(pane, .{}),
else => {},
}
@@ -858,7 +901,7 @@ test "data and xdata read from addr, move it, and write through it" {
try testing.expectEqual(@as(u32, 4), p.panes[0].?.fs.addr.q0);
}
-test "data never splits a grapheme, in either direction" {
+test "data never splits a rune, in either direction" {
const gpa = testing.allocator;
const p = try withFile(gpa, "\u{00e9}x\n");
defer p.deinit();
@@ -1060,6 +1103,9 @@ test "name reads the file name and writing it promotes a scratch without touchin
try testing.expectEqualStrings("existing target\n", target);
for ([_][]const u8{ "", "\n", "bad\x01name\n" }) |bad|
try testing.expectEqual(E.INVAL, wr(p, name, bad).errno());
+ // A blank at either end is refused as acme refuses one, not cut off.
+ for ([_][]const u8{ "trailing.zig \n", " leading.zig\n", "tab\t.zig\n" }) |bad|
+ try testing.expectEqualStrings("bad character in file name", wr(p, name, bad).reply.ename);
try testing.expectEqualStrings(path, pane.file.?.path);
try testing.expectEqual(Status.ok, wr(p, name, "two words.zig\n").reply.status);
const spaced = try std.fmt.bufPrint(&path_buffer, "{s}/two words.zig", .{directory});
@@ -1108,11 +1154,15 @@ test "relative names use the file directory and can name a new target" {
try testing.expect(dirtyOf(pane));
}
-test "relative names follow inherited scratch and virtual directories" {
+test "relative names follow a pane/new scratch's session directory and virtual directories" {
const p = try withFile(testing.allocator, "source body\n");
defer p.deinit();
const source = p.panes[0].?;
- try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/project/src/source.zig\n").reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/elsewhere/source.zig\n").reply.status);
+ // pane/new's scratch is the session's, not the active pane's.
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
const serial = try th.newPane(p);
const scratch = p.panes[p.paneBySerial(serial).?].?;
const name = Node.of(serial, .name);
@@ -1205,3 +1255,59 @@ test "setattr truncation empties the body and answers fresh attributes" {
const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) });
try testing.expectEqual(@as(u64, 9), noop.reply.attr.size);
}
+
+test "addresses are in runes: a combining mark and a lone \\r are places of their own" {
+ const gpa = testing.allocator;
+ // e, U+0301 (two bytes, 1-2), x, \r, \n
+ const p = try withFile(gpa, "e\u{0301}x\r\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const addr_node = Node.of(serial, .addr);
+ const dot = Node.of(serial, .dot);
+ const Case = struct { expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .expr = "#1,#3", .q0 = 1, .q1 = 3 }, // the mark alone
+ .{ .expr = "#2", .q0 = 1, .q1 = 1 }, // inside it: back to its start
+ .{ .expr = "#4,#5", .q0 = 4, .q1 = 5 }, // the \r alone
+ .{ .expr = "1:2", .q0 = 1, .q1 = 1 }, // line:col the same
+ .{ .expr = "1:3", .q0 = 1, .q1 = 1 },
+ .{ .expr = "0/\xCC/", .q0 = 1, .q1 = 3 }, // a match inside a rune covers it
+ }) |c| {
+ try testing.expectEqual(Status.ok, wr(p, addr_node, c.expr).reply.status);
+ try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1);
+ }
+ // Copied to dot, it reads back the same.
+ _ = wr(p, addr_node, "#1,#3");
+ try testing.expectEqual(Status.ok, wr(p, dot, " 1 3 ").reply.status);
+ p.sync();
+ try testing.expectEqualStrings(" 1 3 ", rd(p, dot, 0, 100).bytes);
+ _ = wr(p, addr_node, "#4,#5");
+ try testing.expectEqualStrings("\r", rd(p, Node.of(serial, .xdata), 0, 100).bytes);
+}
+
+test "a limit write that fails keeps the limit a truncation cleared before it" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one two\n");
+ defer p.deinit();
+ const limit = Node.of(serialOf(p), .limit);
+ _ = wr(p, limit, "#0,#3");
+ _ = call(p, .{ .tag = 1, .op = .setattr, .node = limit, .truncate = true });
+ try testing.expectEqual(Status.err, wr(p, limit, "/nomatch/").reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, limit, 0, 100).bytes);
+ // A truncation with no write after it clears it.
+ _ = call(p, .{ .tag = 2, .op = .setattr, .node = limit, .truncate = true });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = limit });
+ try testing.expectEqualStrings("", rd(p, limit, 0, 100).bytes);
+}
+
+test "a pane's tagexec runs a word as a click in its tag, and reads back what it touched" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Msg from the tag\n").reply.status);
+ try testing.expect(th.logHas(p, "from the tag"));
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, Node.of(serial, .tagexec), 0, 64).bytes);
+}
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 393516ce..4009af49 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -25,7 +25,25 @@ fn dimension(word: []const u8) ?u16 {
if (word.len == 0 or word.len > 5) return null;
for (word) |c| if (c < '0' or c > '9') return null;
const n = std.fmt.parseInt(u16, word, 10) catch return null;
- return if (n == 0) null else n;
+ return if (n == 0 or n > winsize_max) null else n;
+}
+
+/// The fewest rows a winsize write gives a terminal's pty.
+pub const pty_rows_min = 2;
+
+/// As the root ctl's `size` (4096 a side).
+const winsize_max = 4096;
+const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max });
+
+/// A winsize whose numbers are numbers but not sizes: said as such.
+fn outOfRange(line: []const u8) bool {
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false;
+ var n: u8 = 0;
+ while (words.next()) |w| : (n += 1) {
+ _ = std.fmt.parseInt(u32, w, 10) catch return false;
+ }
+ return n == 2;
}
fn signalNamed(word: []const u8) ?pardes.PtySignal {
@@ -43,7 +61,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, e_bad_pty_ctl);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -56,7 +74,9 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
switch (v) {
.winsize => {
const cols = dimension(words.next() orelse return false) orelse return false;
- const rows = dimension(words.next() orelse return false) orelse return false;
+ // Two rows at least, as a pane is laid out: a one-row pty
+ // loses its prompt's input mark and reads busy for ever.
+ const rows = @max(pty_rows_min, dimension(words.next() orelse return false) orelse return false);
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
@@ -72,7 +92,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
if (words.next() != null) return false;
if (pane.cwdSlice().len > pardes.effect_path_cap) return false;
if (!apply) return true;
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
+ p.emitSpawn(id, pane.serial, pane.cwdSlice());
},
}
return true;
@@ -189,7 +209,12 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
// marks: a nested shell's prompt (ssh, a shell with its own
// integration) looks like an empty prompt to ghostty but is not the
// shell this run knows.
- answer(p, slot, "busy", .{});
+ // Naming what runs, where the host can tell (Linux).
+ var name: [64]u8 = undefined;
+ if (p.hostForegroundName(id, &name)) |program|
+ answer(p, slot, "busy: {s} is running", .{program})
+ else
+ answer(p, slot, "busy", .{});
} else {
// Sent now if the shell is at its prompt, or at its first prompt
// when it has not drawn one yet (noteMarks).
@@ -433,7 +458,7 @@ test "a pane that is not a terminal has no pty/ at all" {
var buf: [32]th.Dirent = undefined;
const files = th.dirents(rdir(p, dir, 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 17), files.len);
+ try testing.expectEqual(@as(usize, 18), files.len);
try testing.expect(th.nameAt(files, "pty") == null);
try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno());
@@ -454,7 +479,7 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" {
var buf: [32]th.Dirent = undefined;
const files = th.dirents(rdir(p, dir, 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 18), files.len);
+ try testing.expectEqual(@as(usize, 19), files.len);
try testing.expect(th.nameAt(files, "pty").?.dir);
const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf);
@@ -528,10 +553,7 @@ test "every pty/ctl verb, and every refusal" {
"winsize", // no arguments
"winsize 80", // one argument
"winsize 80 24 extra", // three
- "winsize 0 24", // zero is "unknown", never a width
- "winsize 80 0",
"winsize -1 24", // not a decimal
- "winsize 999999 24", // wider than a u16
"sig", // no name
"sig INT TERM", // two
"sig SIGINT",
@@ -553,6 +575,13 @@ test "every pty/ctl verb, and every refusal" {
try testing.expect(a.signal == null);
try testing.expect(!a.spawned);
}
+ // Numbers, but no size: said as out of range, the bound size's.
+ for ([_][]const u8{ "winsize 0 24", "winsize 80 0", "winsize 0 0", "winsize 4097 24", "winsize 999999 24" }) |bad| {
+ const a = wr(p, ctl, bad);
+ try testing.expectEqual(E.INVAL, a.errno());
+ try testing.expectEqualStrings(e_winsize_range, a.reply.ename);
+ try testing.expect(a.winsize == null);
+ }
const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
try testing.expectEqual(Status.ok, spaced.reply.status);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index baf8c772..ed0e0352 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -66,7 +66,10 @@ pub const e_bad_event = "bad event syntax";
/// A second open reading a file whose reads consume: rio's word for it
/// (windows/rio/xfid.c:25), which 9ns turns into EBUSY.
pub const e_in_use = "file in use";
-pub const e_shut_down = "window shut down";
+/// A read held on a pane's event or pty/data that the pane closed under.
+/// acme says "window shut down" (xfid.c:1005), which 9ns reads as EIO;
+/// these words read as ENOENT, what every other file of a gone pane gives.
+pub const e_shut_down = "no such pane: its window shut";
/// Tremove closes a pane; nothing else in the tree is created or destroyed by
/// the protocol, and wstat stays a truncation. Making a pane is an open of
@@ -82,21 +85,21 @@ pub const features: cloud9.fs.Features = .{ .remove = true, .create = true };
/// the mutation of its own step, so the layout and the surface are whole
/// under it -- and a step out there is never waiting on a write of its own,
/// so what parks here is never the syscall's own request.
-pub fn needsQuiet(req: Req) bool {
+pub fn needsQuiet(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
else => false,
};
}
/// `needsQuiet`, plus the open that makes a pane: what costs a frame and a
/// wake of the editor.
-pub fn changesPane(req: Req) bool {
+pub fn changesPane(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
.open => req.node == @intFromEnum(TopFile.new),
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
.lookup, .getattr, .read, .readdir => false,
};
}
@@ -125,7 +128,20 @@ pub const Open = struct {
pty_data,
/// An open that may write a pane's ctl, and so hold its lock.
ctl,
+ /// An open of a file that takes lines of commands (look, exec, a
+ /// ctl, tagexec): its `pending` and `results`.
+ lines,
} = .{ .snapshot = null },
+ /// What a write to a file of command lines left unfinished: a last line
+ /// with no newline yet, or an Edit block still open. A write through a
+ /// mount is cut at the 9P message size, so one `echo` of many lines may
+ /// come as several writes, cut anywhere; the rest waits for the open's
+ /// next write, or its release, and never runs as a fragment.
+ pending: std.ArrayList(u8) = .empty,
+ /// The panes this open's last write touched, which a read of look or
+ /// exec on it answers, as /net/tcp/clone answers on the open that
+ /// asked; a fresh open holds the session's last, from before it opened.
+ results: Results = .{},
/// A read that found nothing yet (`.again`), kept to be answered when
/// this open's file has something, the way factotum keeps its log's
/// waiting reads and answers them on append (security/auth/factotum/
@@ -142,6 +158,16 @@ pub const Open = struct {
pub const Replay = struct { off: u64, bytes: []u8 };
+ pub const Results = struct {
+ list: [pardes.MAX_PANES]u32 = undefined,
+ len: u8 = 0,
+ /// Written on: reads are a stream from the write on, whatever
+ /// offset they come at, since a shell's `exec 3<>look` shares one
+ /// offset between the write and the read (as pty/run does).
+ wrote: bool = false,
+ read: usize = 0,
+ };
+
/// `asker` is the connection the read came on, which only the listener
/// knows.
pub const Held = struct { asker: *anyopaque, req: Req, ticket: cloud9.fs.Ticket };
@@ -154,8 +180,9 @@ pub const Open = struct {
gpa.free(run.output);
gpa.free(run.line);
},
- .event, .pty_data, .ctl => {},
+ .event, .pty_data, .ctl, .lines => {},
}
+ o.pending.deinit(gpa);
if (o.replay) |r| gpa.free(r.bytes);
o.* = .{};
}
@@ -191,6 +218,7 @@ pub const TopFile = enum(u5) {
layout,
tag,
col,
+ tagexec,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -199,7 +227,7 @@ pub const TopFile = enum(u5) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane, .col => 0o755,
- .look, .exec, .log, .focus, .ctl, .tag => 0o666,
+ .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666,
.README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444,
};
}
@@ -231,6 +259,9 @@ pub const PaneFile = enum(u5) {
event,
look,
exec,
+ /// A word run as a click in the pane's tag, as /tagexec is in the
+ /// workspace's and col/<n>/exec in a column's.
+ tagexec,
pty,
pty_ctl,
pty_status,
@@ -468,10 +499,14 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
.dir = c.file == .dir,
.mode = switch (c.file) {
.dir => 0o755,
- .ctl, .exec => 0o222,
- .tag => 0o666,
+ .ctl => 0o222,
+ .tag, .exec => 0o666,
+ },
+ .size = switch (c.file) {
+ .tag => cols.headerText(p, c.serial).?.len + 1,
+ .exec => ctl.resultsLen(p),
+ else => 0,
},
- .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0,
.mtime = p.fs.started,
.append = c.file == .tag,
};
@@ -513,7 +548,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.index => pane.indexLen(p),
.README => fs.help.len,
.status => ctl.statusLen(p),
- .look, .exec => ctl.resultsLen(p),
+ .look, .exec, .tagexec => ctl.resultsLen(p),
.log => events.logLen(&p.fs.log),
.listeners => listeners: {
var buf: [screen.listeners_cap]u8 = undefined;
@@ -618,7 +653,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
.root => {
// The guide, then what describes the session, then the
// files that act, then the directories.
- inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |t| {
+ inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| {
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
}
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os");
@@ -680,16 +715,26 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// pane it made, so reading it twice answers the same one and
// closing it leaves the pane alone.
.new => {
- const slot = p.freeSlot() orelse return failText(req.tag, E.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots");
+ const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane");
+ p.fs.no_pane_slot = false;
+ // Refused, the open's err is the one record of it (the
+ // one rule): no msg besides.
+ p.fs.capturing = true;
p.newScratchFor(p.active, .fs);
+ p.fs.capturing = false;
+ p.fs.failure_len = 0;
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
+ // No room in its column: it closes at the step's end.
+ if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]);
return .{ .tag = req.tag, .handle = made.serial };
},
.screen => .{ .snapshot = null },
.log => .{ .log = .{} },
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
},
- .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT),
+ .col => |c| if (layout.columnBySerial(p, c.serial) == null)
+ return Reply.fail(req.tag, E.NOENT)
+ else if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
.pane => |t| what: {
const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
@@ -706,7 +751,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
1, 2 => .ctl, // OWRITE, ORDWR
else => return .{ .tag = req.tag },
},
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
};
},
};
@@ -715,6 +760,10 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
} else return Reply.fail(req.tag, E.NFILE);
const o = &p.fs.opens[i];
o.* = .{ .node = req.node, .what = what };
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results.len = p.fs.results_len;
+ }
switch (o.what) {
// A screen freezes at open; a terminal body at its first read.
.snapshot => |*bytes| if (pane_of == null) {
@@ -734,7 +783,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
pn.fs.event_reader = pn.fs.event_reader or reader;
},
.pty_data => pane_of.?.fs.pty_reader = true,
- .run, .ctl => {},
+ .run, .ctl, .lines => {},
}
return .{ .tag = req.tag, .handle = @intCast(i + 1) };
}
@@ -771,9 +820,21 @@ fn releaseHandle(p: *Pardes, req: Req) void {
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};
+ // `: > limit` clears it for good once its open goes.
+ if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
+ p.panes[id].?.fs.limit_truncated = null;
+ };
if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
if (t == .top and t.top == .tag) cols.released(p, null);
const o = openOf(p, req) orelse return;
+ // The last line an open wrote with no newline after it runs now.
+ if (o.pending.items.len > 0) {
+ const rest = p.scratch.allocator().dupe(u8, o.pending.items) catch "";
+ o.pending.clearRetainingCapacity();
+ const w: Req = .{ .tag = req.tag, .op = .write, .node = req.node, .handle = req.handle, .data = rest };
+ const reply = writeNow(p, w, t);
+ if (reply.status == .err) events.noteError(p, w, reply);
+ }
const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null;
if (of) |pn| switch (o.what) {
.event => |reader| {
@@ -795,7 +856,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
.ctl => if (pn.fs.lock == req.handle) {
pn.fs.lock = null;
},
- .snapshot, .log => {},
+ .snapshot, .log, .lines => {},
};
o.deinit(p.gpa);
}
@@ -861,7 +922,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
break :index stagedReply(p, req);
},
.status => ctl.readStatus(p, req),
- .look, .exec => ctl.readResults(p, req),
+ .look, .exec, .tagexec => ctl.readResults(p, req),
.log => events.readLog(p, req),
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
@@ -892,25 +953,92 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
// An event or pty/data read the pane closed under: acme's
// answer to the same (editors/acme/xfid.c:1005).
if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data)
- return failText(req.tag, E.IO, e_shut_down);
+ return failText(req.tag, E.NOENT, e_shut_down);
return Reply.fail(req.tag, E.NOENT);
};
const pn = p.panes[id].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return pane.read(p, req, id, pn, t.file);
},
- .col => |c| return if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM),
+ .col => |c| return switch (c.file) {
+ .tag => cols.read(p, req, c.serial),
+ .exec => ctl.readResults(p, req),
+ else => Reply.fail(req.tag, E.PERM),
+ },
}
}
+/// The files whose writes are lines of commands, run as each line is whole.
+fn linesFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec,
+ .col => |c| c.file == .ctl or c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec,
+ };
+}
+
+/// The ones of those a read answers with the panes a write touched.
+fn resultsFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .tagexec,
+ .col => |c| c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec or t.file == .tagexec,
+ };
+}
+
+/// An open that keeps lines or results: any that may write one of those
+/// files, and any that may read a look or exec.
+fn linesOpen(target: Target, req: Req) bool {
+ if (!linesFile(target)) return false;
+ return req.omode & 3 != 0 or resultsFile(target);
+}
+
+/// A release that will run a line its open held back.
+fn holdsLines(p: *Pardes, req: Req) bool {
+ const o = openOf(p, req) orelse return false;
+ return o.pending.items.len > 0;
+}
+
+/// A line held back longer than this is refused rather than kept growing.
+const pending_cap = 1 << 20;
+
fn write(p: *Pardes, req: Req, target: Target) Reply {
+ const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target);
+ if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target);
+ // A clicked line holds no control character: refused at once, not held
+ // to fail unseen at the close.
+ if (resultsFile(target)) for (req.data) |c| if (c < ' ' and c != '\t' and c != '\n' and c != '\r') return Reply.fail(req.tag, E.INVAL);
+ o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
+ const end = ctl.completeEnd(p, o.pending.items);
+ if (end == 0) {
+ if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ o.pending.clearRetainingCapacity();
+ return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB");
+ }
+ const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM);
+ const rest = o.pending.items.len - end;
+ std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]);
+ o.pending.shrinkRetainingCapacity(rest);
+ var w = req;
+ w.data = whole;
+ var reply = writeNow(p, w, target);
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results = .{ .list = o.results.list, .len = p.fs.results_len, .wrote = true };
+ }
+ if (reply.status == .ok) reply.written = @intCast(req.data.len);
+ return reply;
+}
+
+fn writeNow(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| return switch (f) {
- .look, .exec => ctl.command(p, req, null, f == .exec),
+ .look, .exec => ctl.command(p, req, null, f == .exec, false),
.log => events.writeLog(p, req),
.focus => ctl.writeFocus(p, req),
.ctl => ctl.writeRoot(p, req),
.tag => cols.write(p, req, null),
+ .tagexec => cols.writeExec(p, req, null),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -1069,13 +1197,13 @@ test "readdir lists the root and a pane directory without creating anything" {
@memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes);
const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf);
try testing.expect(top.len >= 10);
- for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| {
+ for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| {
try testing.expectEqualStrings(name, top[i].name);
- try testing.expectEqual(i >= 13, top[i].dir);
+ try testing.expectEqual(i >= 14, top[i].dir);
}
- try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node);
- try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node);
- try testing.expectEqual(fs.os_root, top[15].node);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node);
+ try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node);
+ try testing.expectEqual(fs.os_root, top[16].node);
var idbuf: [16]u8 = undefined;
try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null);
try testing.expect(th.nameAt(top, "self") == null);
@@ -1114,10 +1242,10 @@ test "readdir lists the root and a pane directory without creating anything" {
const dir = rdir(p, Node.of(serial, .dir), 0);
const files = th.dirents(dir.bytes, &buf);
- try testing.expectEqual(@as(usize, 17), files.len);
+ try testing.expectEqual(@as(usize, 18), files.len);
for ([_][]const u8{
"name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata",
- "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec",
+ "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", "tagexec",
}, 0..) |name, i|
try testing.expectEqualStrings(name, files[i].name);
try testing.expect(th.nameAt(files, ".") == null);
@@ -1203,6 +1331,10 @@ test "opening /pane/new makes a pane and removing one closes it" {
const id = p.paneBySerial(serial).?;
try testing.expectEqualStrings("", p.panes[id].?.file.?.content);
+ // Its directory is the session's, not that of /test.txt, the pane
+ // that happened to have the keyboard.
+ var named: [4096]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&named, "{s}/+New", .{p.sessionDir()}), p.panes[id].?.file.?.path);
_ = wr(p, Node.of(serial, .body), "hi");
// A second open is a second pane, and releasing a clone fid leaves the
@@ -1310,14 +1442,19 @@ test "editor paths resolve to the same nodes the wire serves" {
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
+ // Tall enough that the column holds every pane at its tag and two rows,
+ // and wide enough that each tag, the session directory's +New, is one.
+ p.update(.{ .resize = .{ .cols = 400, .rows = pardes.MAX_PANES * 4 } });
const new = @intFromEnum(TopFile.new);
while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
var want: [64]u8 = undefined;
- const said = try std.fmt.bufPrint(&want, "no pane slots: {d} max", .{pardes.MAX_PANES});
+ const said = try std.fmt.bufPrint(&want, "no space for a pane: {d} max", .{pardes.MAX_PANES});
const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
- try testing.expectEqual(E.NFILE, refused.errno());
+ try testing.expectEqual(E.NOSPC, refused.errno());
try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
try testing.expect(th.logHas(p, said));
+ // Its err alone says it: no msg besides (the one rule).
+ try testing.expect(!th.logHas(p, "New: no space"));
// A look that would open a pane fails its write, and says the same.
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1335,3 +1472,113 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
const made = th.wr(p, ctl_node, "New\n");
try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null);
}
+
+test "a command line cut across writes runs once whole, and the last runs at release; an Edit block waits for its end" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec = @intFromEnum(TopFile.exec);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ try testing.expect(h != 0);
+ for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status);
+ try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n"));
+ try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true });
+ try testing.expect(th.logHas(p, " tail\n"));
+ // An Edit block's text lines come in later writes on the same open.
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const c = call(p, .{ .tag = 4, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ for ([_][]const u8{ "Edit ,a\n", "one\ntw", "o\n", ".\n" }) |piece|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = piece }).reply.status);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
+}
+
+test "look and exec read back what their own open's write touched; a fresh open reads the session's last" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec = @intFromEnum(TopFile.exec);
+ const first = p.panes[0].?.serial;
+ const mine = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ const theirs = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = mine, .data = "Tty\n" });
+ const made = p.panes[p.active].?.serial;
+ try testing.expect(made != first);
+ _ = call(p, .{ .tag = 4, .op = .write, .node = exec, .handle = theirs, .data = "Msg hi\n" });
+ var want: [16]u8 = undefined;
+ // Read at the offset the write left, as a shell's `exec 3<>` does.
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), call(p, .{ .tag = 5, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ try testing.expectEqualStrings("", call(p, .{ .tag = 6, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ const said = p.panes[p.active].?.serial;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 7, .op = .read, .node = exec, .handle = theirs, .size = 64 }).bytes);
+ const fresh = call(p, .{ .tag = 8, .op = .open, .node = exec }).reply.handle;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes);
+ for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
+}
+
+test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ var made: usize = 1;
+ const refused = while (made < pardes.MAX_PANES) : (made += 1) {
+ const a = call(p, .{ .tag = 1, .op = .open, .node = new });
+ if (a.reply.status == .err) break a;
+ } else return error.NeverRefused;
+ try testing.expectEqual(E.NOSPC, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null);
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(made, count); // the refused one is gone
+ for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (p.col_n[c] > 1 and !p.panes[k].?.collapsed)
+ try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows);
+ };
+}
+
+test "an Edit block still open when its open closes fails there with an err and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,x/b/{\ni/[/\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "unmatched"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text still open when its open closes fails there too and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a\nhalf of a block\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "not ended by a . line"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text cut short through exec is an err in the log, not a msg" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const exec = Node.of(serialOf(p), .exec);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = c, .data = "Edit $a\nhalf\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = c, .opened = true });
+ var want: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: Edit: a, c or i text not ended", .{serialOf(p)})));
+ try testing.expect(!th.logHas(p, "msg 1 Edit: a, c or i"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "a pane made from pane/new goes last in its column, even when the last pane has no room to halve" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for (0..3) |_| {
+ const made = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }).reply.handle;
+ p.sync();
+ try testing.expectEqual(made, p.panes[p.col_panes[0][p.col_n[0] - 1]].?.serial);
+ }
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 1f2c3175..72a90244 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -267,7 +267,9 @@ pub const MAX_TAG_ROWS = 8;
pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max;
pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1;
pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px;
-pub const MAX_COLS = 6;
+/// Columns a session holds: 16 on a desktop (each costs about 8 KB of state,
+/// a column tag being most of it), 6 on the board.
+pub const MAX_COLS = if (platform == .esp32p4) 6 else 16;
const column_weight_unit = layout.column_weight_unit;
const max_column_weight = layout.max_column_weight;
pub const MAX_JUMPS = 64;
@@ -471,6 +473,7 @@ test {
_ = @import("body_layer.zig");
_ = @import("draw.zig");
_ = @import("tag_layer.zig");
+ _ = @import("dump.zig"); // its tests ran nowhere before
_ = @import("Presentation.zig");
_ = @import("animation.zig");
}
@@ -1653,13 +1656,30 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
try std.testing.expectEqualStrings("\x1b[200~print(1)\x1b[201~", Sent.said(1));
p.update(.{ .output = .{ .pane = 1, .bytes = "print(1)" } });
Sent.drain(p);
+ // Python's echo only restarts a short wait: the Enter goes once the
+ // echo of the whole paste has gone quiet.
+ try std.testing.expectEqualStrings("", Sent.said(1));
+ p.now_ns = p.panes[1].?.repl_enter_due_ns;
+ p.update(.tick);
+ Sent.drain(p);
try std.testing.expectEqualStrings("\r", Sent.said(1));
+ // A paste of more than one line gets the blank line a block gets.
+ _ = exec.executeFrom(p, 0, "print(11)\nprint(22)", true);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("\x1b[200~print(11)\nprint(22)\x1b[201~", Sent.said(1));
+ p.now_ns = p.panes[1].?.repl_enter_due_ns;
+ p.update(.tick);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("\r\r", Sent.said(1));
// Nothing answering, it goes when its wait is up.
_ = exec.executeFrom(p, 0, "print(2)", true);
- p.stepped_ns += 300 * std.time.ns_per_ms;
+ p.update(.tick); // on the clock: not yet, however old core time is
+ Sent.drain(p);
+ try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~"));
+ p.now_ns = p.panes[1].?.repl_enter_due_ns; // the clock reaches its wait
p.update(.tick);
Sent.drain(p);
- try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~\r"));
+ try std.testing.expectEqualStrings("\r", Sent.said(1));
p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004l" } });
Sent.len[1] = 0;
// A block left open by its indented last line gets the blank line that
@@ -1714,6 +1734,7 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n");
try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status);
try std.testing.expectEqualStrings("print(5)\r", answered.pty());
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} a\n", .{file.serial})));
}
_ = exec.executeFrom(p, 0, "print(3)", true);
p.update(.{ .key = .{ .cp = Key.escape } });
@@ -1804,6 +1825,10 @@ test "Tty+fish, one word a tag can hold, opens a terminal on that shell" {
try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null);
try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell"));
try std.testing.expect(caller.shell == null);
+ // A directory passes access(X_OK); it is no shell either.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tty /etc"));
+ try std.testing.expect(p.active == before);
+ try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "Tty: not a shell: /etc is a directory") != null);
// Only a word that says so splits at `+`: `Dump+x.zon` is no Dump.
try std.testing.expect(!p.executeBuiltinLine(0, "Dump+x.zon"));
try std.testing.expect(!p.executeBuiltinLine(0, "Msg+hello"));
@@ -2116,10 +2141,19 @@ test "owned cwd preserves long paths aliases and failed updates" {
const original = pane.cwdSlice();
try pane.setOwnedCwd(original);
try std.testing.expectEqualStrings(&path, pane.cwdSlice());
- const effects = p.effects_len;
+ // A terminal's directory may be as long as a path is (PATH_MAX): one
+ // past the effect's buffer goes out of line (spawn_dirs); only one past
+ // the host's path limit is too long.
const free = p.freeSlot().?;
- try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1]));
+ _ = try p.newShell(free, path[0..]);
+ try std.testing.expectEqualStrings(&path, p.spawn_dirs[free].?);
+ try p.removePane(free, null);
+ while (p.nextEffect()) |_| {}
+ var too_long: [limits.host_path_cap + 1]u8 = @splat('d');
+ too_long[0] = '/';
+ try std.testing.expectError(error.PathTooLong, p.newShell(free, &too_long));
try std.testing.expect(p.panes[free] == null);
+ const effects = p.effects_len;
exec.saveTo(p, 0, "file.txt");
try std.testing.expectEqual(effects, p.effects_len);
try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "path too long") != null);
@@ -2680,7 +2714,7 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu
};
const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin;
exec.runBuiltin(p, help, 0, "", null);
- try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no pane slots") != null);
+ try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no space for a pane") != null);
}
test "Font keeps requested and acknowledged faces as separate plain state" {
@@ -4272,6 +4306,19 @@ pub const Pardes = struct {
/// The column whose tag the running command came from, while it runs:
/// the column acme's commands take from their Text's col.
exec_column: ?usize = null,
+ /// Panes made this step that found no room in their column
+ /// (exec.placeNew): closed at the step's end, once whatever made them
+ /// is done with them.
+ unplaced: std.StaticBitSet(MAX_PANES) = .initEmpty(),
+ /// The running word came from the workspace's or a column's tag, which
+ /// have no directory of their own: it runs in the session's
+ /// (`sessionDir`), as acme's row and column tags do.
+ exec_header: bool = false,
+ session_dir: [limits.host_path_cap]u8 = undefined,
+ /// A spawn's directory too long for the effect's inline buffer, by pane
+ /// slot: the effect then carries none, and perform gives the host this.
+ spawn_dirs: [MAX_PANES]?[]u8 = @splat(null),
+ session_dir_len: ?u16 = null,
/// acme's activecol (dat.c:37), by column serial, 0 for none: the
/// column last typed in (acme.c:487), clicked in with the select button
/// (acme.c:659), dropped into by a grip (acme.c:640), or given a new
@@ -4547,7 +4594,7 @@ pub const Pardes = struct {
// text under the shell. The shell keeps the keyboard. A boot
// layout, like .classic's, not a placement.
if (p.boot == .tty) {
- const np = try p.newScratch(0, 1);
+ const np = try p.newScratch(0, 1, .scratch);
layout.insert(p, 0, 1, 1);
layout.splitBelow(p, 0, np);
}
@@ -4618,6 +4665,7 @@ pub const Pardes = struct {
if (p.last_dump) |d| gpa.free(d);
if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value);
if (p.chord_arg) |a| gpa.free(a);
+ for (p.spawn_dirs) |d| if (d) |dir| gpa.free(dir);
if (p.pipe.wait) |*wait| wait.deinit(gpa);
p.locations_cache.deinit(p.tree_sitter_gpa);
p.fs.deinit(gpa);
@@ -4760,20 +4808,29 @@ pub const Pardes = struct {
pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
std.debug.assert(p.panes[id] == null);
- if (cwd.len > effect_path_cap) return error.PathTooLong;
+ if (cwd.len > limits.host_path_cap) return error.PathTooLong;
const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
panes.Terminal.armShellSpawn(pane);
p.installPane(id, pane);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } });
+ p.emitSpawn(id, pane.serial, cwd);
return pane;
}
+ /// A child for pane `id` in `cwd`: a directory up to PATH_MAX, the ones
+ /// past the effect's inline buffer kept here for perform to give.
+ pub fn emitSpawn(p: *Pardes, id: usize, serial: u32, cwd: []const u8) void {
+ if (p.spawn_dirs[id]) |old| p.gpa.free(old);
+ p.spawn_dirs[id] = null;
+ if (cwd.len > effect_path_cap) p.spawn_dirs[id] = p.gpa.dupe(u8, cwd) catch null;
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = serial, .cwd = .from(if (cwd.len > effect_path_cap) "" else cwd) } });
+ }
+
/// A command pane: a terminal whose child is `$SHELL -c line` rather
/// than an interactive shell, left showing its output and `exit N` when
/// it is done (exec.zig runCommand).
pub fn newCommand(p: *Pardes, id: usize, cwd: []const u8, line: []const u8) !*Pane {
std.debug.assert(p.panes[id] == null);
- if (cwd.len > effect_path_cap) return error.PathTooLong;
+ if (cwd.len > limits.host_path_cap) return error.PathTooLong;
const owned = try p.gpa.dupe(u8, line);
errdefer p.gpa.free(owned);
const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
@@ -4784,7 +4841,7 @@ pub const Pardes = struct {
// directory's commands reuse it; the child's own cd does not move it.
pane.setOwnedCwd(cwd) catch {};
p.installPane(id, pane);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } });
+ p.emitSpawn(id, pane.serial, cwd);
return pane;
}
@@ -4795,12 +4852,19 @@ pub const Pardes = struct {
return pane;
}
- fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane {
+ fn newScratch(p: *Pardes, from_id: usize, free: usize, kind: exec.NewPane) !*Pane {
const src = p.panes[from_id] orelse return error.MissingPane;
const content = try p.gpa.dupe(u8, "");
errdefer p.gpa.free(content);
- const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content);
- np.cwd = .{ .inherited = src };
+ // A terminal that has not said where it is yet (the boot layout's)
+ // has no directory: the scratch is the session's, <cwd>/+New. So is
+ // 9P's pane/new, and a New from a column's or the workspace's tag:
+ // no pane asked for it, whichever last had the keyboard (acme's new
+ // window has acme's directory).
+ const own = kind != .fs and !p.exec_header;
+ const dir = if (own) paneDir(src) else "";
+ const np = try panes.Output.open(p, free, if (dir.len == 0) p.sessionDir() else dir, .{ .cmd = .New }, "", content);
+ if (own) np.cwd = .{ .inherited = src };
np.body.cur_pinned = true;
return np;
}
@@ -4814,7 +4878,7 @@ pub const Pardes = struct {
/// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`).
pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void {
const free = p.freeSlot() orelse return p.reportError(from_id, "New", error.NoPaneSlots);
- _ = p.newScratch(from_id, free) catch return;
+ _ = p.newScratch(from_id, free, kind) catch return;
exec.placeNew(p, from_id, from_id, free, kind);
}
@@ -4822,7 +4886,14 @@ pub const Pardes = struct {
/// on its tag, for the next pane made to fill.
pub fn newColumn(p: *Pardes, from_id: usize) void {
const col = p.keyboardColumn(from_id) orelse return;
- const c = layout.insertColumn(p, col, false) orelse return;
+ // Said, not silently nothing: "no space" is 9ns's word for ENOSPC.
+ const c = layout.insertColumn(p, col, false) orelse {
+ var said: [96]u8 = undefined;
+ return p.reportFailure(from_id, if (p.ncol >= MAX_COLS)
+ std.fmt.bufPrint(&said, "Newcol: no space for a column: {d} max", .{MAX_COLS}) catch "Newcol: no space for a column"
+ else
+ "Newcol: no space for a column: this one is too narrow to split");
+ };
tagline.enterHeader(p, c);
}
@@ -4930,7 +5001,7 @@ pub const Pardes = struct {
pub fn shellBin(p: *const Pardes) []const u8 {
const selected = p.settings.shell.requested.get();
- return if (selected.len == 0) config.default_shell else selected;
+ return if (selected.len == 0) config.defaultShell() else selected;
}
pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void {
@@ -5111,6 +5182,11 @@ pub const Pardes = struct {
return ev;
}
+ pub fn hostForegroundName(p: *const Pardes, id: usize, buf: []u8) ?[]const u8 {
+ const f = p.host.vtable.fg_name orelse return null;
+ return f(p.host.ctx, @intCast(id), buf);
+ }
+
pub fn hostTtyTaken(p: *const Pardes, id: usize) bool {
const f = p.host.vtable.tty_taken orelse return false;
return f(p.host.ctx, @intCast(id));
@@ -5151,8 +5227,9 @@ pub const Pardes = struct {
var what: [limits.host_path_cap + 8]u8 = undefined;
const operation = std.fmt.bufPrint(&what, "Dump {s}", .{path}) catch "Dump";
p.reportError(p.active, operation, err);
- var buf: [256]u8 = undefined;
- const said = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch "Dump failed";
+ // In the words the message row has, so the write's err is the one
+ // record of it (its msg goes: 9p_io, dropMessage).
+ const said = if (p.panes[p.active]) |pane| pane.msg[0..pane.msg_len] else "Dump failed";
const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
@memcpy(p.fs.late_failure[0..kept.len], kept);
p.fs.late_failure_len = @intCast(kept.len);
@@ -5184,7 +5261,7 @@ pub const Pardes = struct {
switch (e) {
// The host reads the pane (its command, its shell) as it forks,
// so the slot must still hold the pane the spawn was for.
- .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else {
+ .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, if (s.cwd.len == 0) (p.spawn_dirs[s.pane] orelse "") else s.cwd.slice()) else {
p.fallback.spawned[s.pane] = true;
},
// A pane with no child is silent: nothing invents output on its
@@ -5207,9 +5284,13 @@ pub const Pardes = struct {
p.hostWriteFile(sf.pane, f.path, f.content);
const saved_pane = p.panes[sf.pane] orelse return;
if (saved_pane.serial != serial) return;
- ctlfs.events.noteLog(p, .save, saved_pane);
const saved = if (saved_pane.file) |*file| file else return;
- if (saved.saved_revision != saved.revision or !saved.watch_after_save) return;
+ // A save the host could not do (saveFailed) is its err
+ // record alone, not a `save`.
+ if (saved.saved_revision != saved.revision) return;
+ saved.disk_gone = false;
+ ctlfs.events.noteLog(p, .save, saved_pane);
+ if (!saved.watch_after_save) return;
saved.watch_after_save = false;
if (filesystem.localPath(saved.path) != null)
p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } });
@@ -5316,12 +5397,14 @@ pub const Pardes = struct {
/// that changes a pane costs a frame, which is why a round trip on a
/// local socket costs microseconds and not a vsync.
pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply {
- if (!ctlfs.changesPane(req)) return ctlfs.handle(p, req);
+ if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req);
p.needs_frame = true;
p.raw_hover_intent = false;
look.cancelLookHover(p);
+ p.fs.serving = true;
const reply = ctlfs.handle(p, req);
- if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NFILE) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply);
+ p.fs.serving = false;
+ if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NOSPC) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
// told, and the panes it made announced to /log now rather than at
@@ -5566,6 +5649,8 @@ pub const Pardes = struct {
const asked = p.panes[id] orelse return null;
switch (asked.prompt) {
.del_side => return "k j",
+ // Any path; `-` takes it back.
+ .save => return "<path>",
.repl_choice => {
const ask = p.repl_ask orelse return null;
var w = std.Io.Writer.fixed(buf);
@@ -5579,8 +5664,17 @@ pub const Pardes = struct {
}
}
+ /// Answers the pane's question: a choice it offers, else (null, or a
+ /// key it does not offer) takes it back. The log says which, as
+ /// `answer <serial> <choice|->`, after the `ask` it answers.
pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool {
const asked = p.panes[id] orelse return false;
+ var offered: [64]u8 = undefined;
+ if (p.questionChoices(id, &offered)) |choices| {
+ const chosen: ?u8 = if (choice) |c| (if (std.mem.indexOfScalar(u8, choices, c) != null and c != ' ') c else null) else null;
+ var rec: [32]u8 = undefined;
+ ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {c}", .{ asked.serial, chosen orelse '-' }) catch "");
+ }
switch (asked.prompt) {
.repl_choice => {
asked.prompt = .none;
@@ -5768,6 +5862,8 @@ pub const Pardes = struct {
// A column tag's command acts on that column, empty or not.
p.exec_column = if (header != null) p.header_column else null;
defer p.exec_column = null;
+ p.exec_header = header != null;
+ defer p.exec_header = false;
// Out of the tag or header FIRST: Del frees the pane, Kill
// and Restore the whole session.
if (header != null) tagline.exitHeader(p) else pane.focus = .body;
@@ -5870,6 +5966,23 @@ pub const Pardes = struct {
// ---- search (`/`, Find, n/N): results into an output buffer, look the rows ----
+ /// Where pardes started: the directory of what runs from a header.
+ pub fn sessionDir(p: *Pardes) []const u8 {
+ if (p.session_dir_len) |n| return p.session_dir[0..n];
+ if (comptime !hosted) return "/";
+ if (std.c.getcwd(&p.session_dir, p.session_dir.len) == null) return "/";
+ const n: u16 = @intCast(std.mem.indexOfScalar(u8, &p.session_dir, 0) orelse return "/");
+ p.session_dir_len = n;
+ return p.session_dir[0..n];
+ }
+
+ /// The directory a word run from pane `id`'s text or tag runs in, or,
+ /// from a header, the session's.
+ pub fn execDir(p: *Pardes, id: usize) []const u8 {
+ if (p.exec_header) return p.sessionDir();
+ return paneDir(p.panes[id] orelse return p.sessionDir());
+ }
+
pub fn paneDir(pane: *const Pane) []const u8 {
switch (pane.cwd) {
.inherited => |src| return paneDir(src),
@@ -5896,6 +6009,15 @@ pub const Pardes = struct {
p.fs.refused = true;
return;
}
+ // Asked for over 9P (Save from an exec on a terminal): a question
+ // the log says, `ask <serial> save path`, answered on the pane's
+ // ctl with `answer <path>` or `answer -`; asked again, the one
+ // already open stands, not a second on top of it.
+ if (request == .save and p.fs.serving) {
+ if (pane.prompt == .save) return;
+ var rec: [32]u8 = undefined;
+ ctlfs.events.notePath(p, "ask", std.fmt.bufPrint(&rec, "{d} save path", .{pane.serial}) catch "");
+ }
const marker = switch (request) {
.search => |marker| marker,
.save => config.save_marker,
@@ -6384,7 +6506,26 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
+ /// The rows pane `id`'s tag takes at its width, wrapped as `sync` wraps
+ /// it: what a placement must leave it above its body rows.
+ pub fn wantedTagRows(p: *Pardes, id: usize) u16 {
+ const pane = p.panes[id] orelse return BOX_H;
+ if (pane.collapsed or !pane.tag_expanded) return BOX_H;
+ if (p.rects[id].w == 0) return pane.tag_rows; // never laid out
+ var wrap_room: [32 * 1024]u8 = undefined;
+ var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
+ const wa = wrap_fba.allocator();
+ const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows;
+ const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows;
+ const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows;
+ return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS)));
+ }
+
pub fn sync(p: *Pardes) void {
+ while (p.unplaced.findFirstSet()) |id| {
+ p.unplaced.unset(id);
+ if (p.panes[id] != null) p.removePane(id, null) catch {};
+ }
tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
@@ -6466,8 +6607,14 @@ pub const Pardes = struct {
pane.tag_top = @intCast(@min(top, lines - pane.tag_rows));
} else pane.tag_top = 0;
const cols = @max(1, r.w -| config.GUTTER);
- const rows = @max(1, r.h -| pane.tag_rows); // the tag steals the top rows
- if ((cols != pane.cols or rows != pane.rows) and r.h > pane.tag_rows) {
+ // The tag steals the top rows. The pty is what the pane shows
+ // (layout keeps a terminal two body rows where the window has
+ // them): a taller pty would put its cursor below the pane.
+ const rows = @max(1, r.h -| pane.tag_rows);
+ // A terminal's pty follows its pane down to a row, whatever the
+ // resize; only a folded one keeps the size it had, and one not
+ // placed yet the grid it was made with.
+ if ((cols != pane.cols or rows != pane.rows) and (r.h > pane.tag_rows or (pane.isTerminal() and !pane.collapsed and r.h > 0))) {
// doc panes have no pty/emulator grid to reflow; just record
// the size so bodyText renders the right number of rows
if (pane.isTerminal()) {
@@ -6518,9 +6665,10 @@ pub const Pardes = struct {
const wait = Messages.messageWake(p, pane) orelse continue;
frames = if (frames) |soonest| @min(soonest, wait) else wait;
};
- // A paste's Enter waiting on its REPL goes at the end of its wait.
+ // A paste's Enter waiting on its REPL goes at the end of its wait,
+ // a time on the clock: the frames from `stepped_ns` that reach it.
for (p.panes) |slot| if (slot) |pane| if (pane.repl_enter > 0) {
- const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns) / animation.frame_ns);
+ const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns + animation.frame_ns - 1) / animation.frame_ns);
frames = if (frames) |soonest| @min(soonest, wait) else wait;
};
return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null;
@@ -7038,7 +7186,10 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(low, p.active); // focus follows the rows
// Esc takes the question back and the pane stays; so does any other key,
- // and so does a click.
+ // and so does a click. (Top gets rows back first: split at its cursor,
+ // it kept its tag and two, too few to give a pane below its own.)
+ p.panes[top].?.vweight = p.panes[low].?.vweight;
+ p.sync();
p.newScratchBelow(top);
const again = p.active;
p.sync();
@@ -7091,10 +7242,12 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[asked].?.prompt);
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\n", .{serial})));
p.update(.{ .key = .{ .cp = ' ' } });
p.update(.{ .key = .{ .cp = 'd' } });
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status);
try std.testing.expect(p.paneBySerial(serial) == null);
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} k\n", .{serial})));
}
// Bare Del from anything but a key closes at once and gives the rows up,
@@ -7874,3 +8027,48 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
p.advance(p.stepped_ns + frame);
try std.testing.expectEqual(@as(?u64, null), p.nextWake());
}
+
+test "a pane closed while its question stands ends the question in the log" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "x\n");
+ defer p.deinit();
+ p.settings.placement = .pardes;
+ p.newScratchBelow(0);
+ const closing = p.active;
+ p.newScratchBelow(closing);
+ p.sync();
+ p.active = closing; // a middle pane: Del from a key asks which side
+ const serial = p.panes[closing].?.serial;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt);
+ // A bare answer says what this question takes.
+ try std.testing.expectEqualStrings("answer takes k j or -", th.wr(p, ctlfs.Node.of(serial, .ctl), "answer\n").reply.ename);
+ try p.removePane(closing, null);
+ var want: [64]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ serial, serial })));
+}
+
+test "a Save the host could not do logs no save record" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "x\n");
+ defer p.deinit();
+ const Failing = struct {
+ core: *Pardes,
+ const vtable: Host.VTable = .{ .write_file = write };
+ fn write(ctx: ?*anyopaque, pane: u8, path: []const u8, _: []const u8) void {
+ const f: *@This() = @ptrCast(@alignCast(ctx.?));
+ f.core.saveFailed(pane, path, error.AccessDenied);
+ }
+ };
+ var host: Failing = .{ .core = p };
+ p.host = .{ .ctx = &host, .vtable = &Failing.vtable };
+ _ = th.wr(p, ctlfs.Node.of(p.panes[0].?.serial, .body), "more\n");
+ p.perform(.{ .save_file = .{ .pane = 0 } });
+ try std.testing.expect(!th.logHas(p, "\nsave "));
+ try std.testing.expect(p.panes[0].?.file.?.saved_revision != p.panes[0].?.file.?.revision);
+ // With the host's write done, it is logged.
+ p.host = .{};
+ p.perform(.{ .save_file = .{ .pane = 0 } });
+ try std.testing.expect(th.logHas(p, "\nsave "));
+}
diff --git a/src/regexp.zig b/src/regexp.zig
index 97e3dc91..64e1d75a 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -258,3 +258,20 @@ test "a repeat inside a group gives back to what follows the group" {
try std.testing.expectEqual(c.end, m.end);
}
}
+
+test "`$` may end any alternative, not only the last" {
+ const Case = struct { pat: []const u8, text: []const u8, start: usize, end: usize };
+ for ([_]Case{
+ .{ .pat = "h$|zz", .text = "xh", .start = 1, .end = 2 },
+ .{ .pat = "zz|h$", .text = "xh", .start = 1, .end = 2 },
+ .{ .pat = "h$|zz", .text = "hx zz", .start = 3, .end = 5 },
+ .{ .pat = "(h$|zz)", .text = "xh", .start = 1, .end = 2 },
+ }) |c| {
+ var rx = try Regex.compile(c.pat);
+ const m = (try rx.find(c.text, 0, c.text.len, c.text.len)).?;
+ try std.testing.expectEqual(c.start, m.start);
+ try std.testing.expectEqual(c.end, m.end);
+ }
+ var only = try Regex.compile("h$|zz");
+ try std.testing.expectEqual(null, try only.find("hx", 0, 2, 2));
+}
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index 29dec726..cd2998ef 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -16,6 +16,13 @@
const std = @import("std");
const regexp = @import("regexp.zig");
const addr_lang = @import("ninep/addr.zig");
+const modal = @import("modal.zig");
+
+/// The next place after `p` a search may start: a rune on, and past the
+/// text's end once there, which ends a loop over `p <= end`.
+fn stepRune(text: []const u8, p: usize) usize {
+ return if (p >= text.len) p + 1 else modal.nextRune(text, p);
+}
pub const Range = addr_lang.Range;
@@ -68,6 +75,11 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const
if (last) |l| l.next = c else first = c;
last = c;
}
+ // An a, c or i text that never met its `.` line is refused, as an
+ // unclosed `{` is: sam takes the end of input for one, but a block that
+ // reaches Edit unfinished was cut short (a write, an open closed), and
+ // half of it inserted is worse than none.
+ if (ps.open_text) return fail(why, "a, c or i text not ended by a . line", .{});
const cmds = first orelse return .{ .ops = &.{}, .dot = dot, .printed = "" };
// acme's u runs at once (ecmd.c:532), outside the edit log; here it is
// an Edit of its own.
@@ -85,6 +97,25 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const
/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text
/// block still waiting for its `.` line: a writer of lines (a ctl, exec)
/// hands an Edit the lines after it until it does not.
+/// What an Edit command still waits for: nothing, the `.` line that ends
+/// an a, c or i text block, or the `}` of a group.
+pub const Wait = enum { none, text, group };
+
+/// As `needsMore`, saying which: while a text block is open only a `.`
+/// line can end it, so a caller need not parse the block again for every
+/// line of text in it (ninep/ctl.zig, editEnd), which was quadratic.
+pub fn waitsFor(arena: std.mem.Allocator, command: []const u8) Wait {
+ var why: Why = .{};
+ const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return .none;
+ defer arena.free(src);
+ var ps: Parser = .{ .arena = arena, .src = src, .why = &why };
+ while (true) {
+ const c = ps.parse(0) catch
+ return if (ps.open_text) .text else if (std.mem.eql(u8, why.text(), "unmatched `{'")) .group else .none;
+ if (c == null) return if (ps.open_text) .text else .none;
+ }
+}
+
pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool {
var why: Why = .{};
const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false;
@@ -418,9 +449,16 @@ const Exec = struct {
};
}
+ /// A match covers the runes it touches, as an address's does
+ /// (addr.zig): mvzr matches bytes, so `.` would take one byte of `é`
+ /// and x, y and s would split it. From `from` on only, so a match never
+ /// reaches back into what the last one took.
fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match {
if (from > hi) return null;
- return rx.find(ex.text, from, hi, hi) catch fail(ex.why, "{s}", .{addr_lang.e_slow});
+ const m = (rx.find(ex.text, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null;
+ var start = modal.runeStart(ex.text, m.start);
+ if (start < from) start = modal.runeEnd(ex.text, m.start);
+ return .{ .start = start, .end = @max(start, modal.runeEnd(ex.text, m.end)) };
}
/// ecmd.c:62, cmdexec: runs `c` from `dot`, answering the dot it leaves.
@@ -516,10 +554,10 @@ const Exec = struct {
const m = (try ex.find(&rx, p1, r.q1)) orelse break;
if (m.start == m.end) {
if (op != null and m.start == op.?) {
- p1 += 1;
+ p1 = stepRune(ex.text, p1);
continue;
}
- p1 = m.end + 1;
+ p1 = stepRune(ex.text, m.end);
} else p1 = m.end;
op = m.end;
n -= 1;
@@ -560,10 +598,10 @@ const Exec = struct {
if (try ex.find(&rx, p, r.q1)) |m| {
if (m.start == m.end) {
if (op != null and m.start == op.?) {
- p += 1;
+ p = stepRune(ex.text, p);
continue;
}
- p = m.end + 1;
+ p = stepRune(ex.text, m.end);
} else p = m.end;
tr = if (xy) .{ .q0 = clip(m.start), .q1 = clip(m.end) } else .{ .q0 = clip(op.?), .q1 = clip(m.start) };
sel_end = m.end;
@@ -723,3 +761,14 @@ test "the dot an Edit leaves selects what a change put where it stood" {
try std.testing.expectEqual(Range{ .q0 = 2, .q1 = 5 }, moveDot(.{ .q0 = 2, .q1 = 2 }, &ops));
try std.testing.expectEqual(Range{ .q0 = 6, .q1 = 7 }, moveDot(.{ .q0 = 5, .q1 = 6 }, &ops));
}
+
+test "x, y and s match whole runes: a byte pattern never splits one" {
+ // é is c3 a9: `.` takes it whole, as it takes a lone combining mark.
+ try expectEdit("\u{e9}", ",x/./a/|/", "\u{e9}|");
+ try expectEdit("e\u{301}t", ",s/./X/g", "XXX");
+ try expectEdit("a\u{e9}b", ",y/\u{e9}/c/-/", "-\u{e9}-");
+ try expectEdit("\u{e9}\u{e9}", ",x/\\xC3/c/E/", "EE");
+ // An invalid byte is a place of its own.
+ try expectEdit("a\x81b", ",x/./a/|/", "a|\x81|b|");
+ try expectEdit("\u{e9}", ",s/$/!/", "\u{e9}!");
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 754b4b96..e7eddec4 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -946,6 +946,7 @@ const Shell = struct {
.pty_signal = ptySignal,
.close_pty = closePty,
.tty_taken = ttyTaken,
+ .fg_name = fgName,
.kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
@@ -1295,6 +1296,12 @@ const Shell = struct {
return host_io.ttyTaken(pt.pid, pt.file.handle);
}
+ fn fgName(ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 {
+ const s = of(ctx);
+ const pt = s.ptys[pane] orelse return null;
+ return host_io.foregroundName(pt.pid, pt.file.handle, buf);
+ }
+
/// The command panes' exits, told once their output is in (host_io).
fn takeExits(s: *@This()) void {
host_io.takeExits(s.core, &s.ptys, s, closeWatched);