summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig6
-rw-r--r--src/Output.zig104
-rw-r--r--src/Terminal.zig21
-rw-r--r--src/animation.zig2
-rw-r--r--src/builtins.zig94
-rw-r--r--src/colors.zig4
-rw-r--r--src/config.zig7
-rw-r--r--src/detached/server.zig7
-rw-r--r--src/draw.zig231
-rw-r--r--src/exec.zig103
-rw-r--r--src/fs-help.txt4
-rw-r--r--src/fs.zig19
-rw-r--r--src/gui/gui.zig56
-rw-r--r--src/host_io.zig82
-rw-r--r--src/layout.zig46
-rw-r--r--src/look.zig44
-rw-r--r--src/macos.zig7
-rw-r--r--src/main.zig16
-rw-r--r--src/ninep/cols.zig89
-rw-r--r--src/ninep/ctl.zig506
-rw-r--r--src/ninep/events.zig115
-rw-r--r--src/ninep/pane.zig82
-rw-r--r--src/ninep/pty.zig11
-rw-r--r--src/ninep/tree.zig42
-rw-r--r--src/panes.zig10
-rw-r--r--src/pardes.zig268
-rw-r--r--src/recent.zig119
-rw-r--r--src/tagline.zig21
-rw-r--r--src/tty/tty.zig7
29 files changed, 1905 insertions, 218 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 2130db27..72897979 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -315,7 +315,11 @@ pub const Listener = struct {
// ponytail: one slot, so a failure of another client's effects that
// 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]);
+ const late = core.fs.late_failure[0..core.fs.late_failure_len];
+ // What is not there (`no such directory`) is ENOENT, as a
+ // builtin's failure saying so is (ctl.failureErrno).
+ const errno = if (std.mem.indexOf(u8, late, "no such") != null or std.mem.indexOf(u8, late, "not found") != null) pardes.ctlfs.E.NOENT else pardes.ctlfs.E.IO;
+ const failed = pardes.ctlfs.failText(req.tag, errno, late);
// 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]);
diff --git a/src/Output.zig b/src/Output.zig
index 553dcc95..3b8e6a7a 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -29,6 +29,9 @@ pub const Origin = union(enum) {
search,
errors,
pdf_links,
+ /// The panes a refusal (Exit, Restore, Del, Delcol, get) is over, kept
+ /// in +Unsaved rather than a notice that goes.
+ unsaved,
};
pub const max_arg = dump.max_origin_arg;
@@ -183,6 +186,7 @@ pub fn traits(o: Origin) Traits {
.search => .{ .name = config.search_buffer, .steps = true, .locations = true },
.errors => .{ .name = config.errors_buffer, .doc = true },
.pdf_links => .{ .name = config.pdf_links_buffer, .steps = true, .locations = true },
+ .unsaved => .{ .name = config.unsaved_buffer, .doc = true },
.cmd => |b| builtins.registry.outputTraits(b) orelse unreachable,
.query => |k| switch (k) {
.hover => .{ .name = config.hover_buffer },
@@ -242,6 +246,7 @@ pub fn word(o: Origin) []const u8 {
.search => "/",
.errors => config.errors_buffer,
.pdf_links => config.pdf_links_buffer,
+ .unsaved => config.unsaved_buffer,
};
}
@@ -250,6 +255,10 @@ pub fn fromWord(w: []const u8) ?Origin {
if (std.mem.eql(u8, w, "/")) return .search;
if (std.mem.eql(u8, w, config.errors_buffer)) return .errors;
if (std.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links;
+ if (std.mem.eql(u8, w, config.unsaved_buffer)) return .unsaved;
+ // A dump from before the rename names them by their old words.
+ if (std.mem.eql(u8, w, "ThemeSel")) return .{ .cmd = .Themes };
+ if (builtins.capabilities.font_picker) if (std.mem.eql(u8, w, "FontSel")) return .{ .cmd = .Fonts };
if (std.meta.stringToEnum(Builtin, w)) |b|
if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b };
if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k };
@@ -312,7 +321,7 @@ fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?u
=> {},
else => return anchor,
},
- .errors, .pdf_links => return anchor,
+ .errors, .pdf_links, .unsaved => return anchor,
}
if (content.len == 0) return anchor;
const path_only = std.meta.eql(from, Origin{ .cmd = .Find });
@@ -406,6 +415,16 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
return pane;
}
+/// Find and Grep share one +Search a directory, each run replacing what
+/// the last showed, as acme reuses a directory's +Errors; a pane's own
+/// search (LookWord list) keeps a pane a pattern.
+fn fileSearch(from: Origin) bool {
+ return switch (from) {
+ .cmd => |c| c == .Find or c == .Grep,
+ else => false,
+ };
+}
+
pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void {
var result_content = content;
errdefer p.gpa.free(result_content);
@@ -448,9 +467,12 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
const rp = slot orelse continue;
const rf = if (rp.file) |*f| f else continue;
const o = if (rf.output) |*x| x else continue;
- if (!std.meta.eql(o.from, from)) continue;
- if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
+ const shared = fileSearch(from) and fileSearch(o.from);
+ if (!shared and !std.meta.eql(o.from, from)) continue;
+ if (!shared and by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
+ o.from = from;
+ p.fs.refilled = rp.serial;
try setArg(o, arg);
if (std.mem.eql(u8, rf.content, result_content)) {
p.gpa.free(result_content);
@@ -492,6 +514,12 @@ pub fn openJumps(p: *Pardes, id: usize) !void {
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
for (p.jumps[0..p.njumps]) |j| {
+ // A closed file's entry: marked, and a look at it opens it again.
+ if (j.serial == 0) {
+ const e = p.recentByHash(j.path_hash) orelse continue;
+ try out.writer.print("{s}:{d}:{d} (closed)\n", .{ e.path, @max(1, j.line), @max(1, j.col) });
+ continue;
+ }
const jp = p.panes[j.pane] orelse continue;
var idbuf: [16]u8 = undefined;
const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null;
@@ -519,6 +547,70 @@ pub fn openJumps(p: *Pardes, id: usize) !void {
try openStepped(p, id, .{ .cmd = .Jumplist }, content);
}
+/// The recent list as rows: `<path>:<line>:<col> open|closed`, most recent
+/// first; a look at a row opens the file there, as a +Jumps row does.
+pub fn recentText(p: *Pardes, w: *std.Io.Writer, look_rows: bool) !void {
+ for (p.recent.entries.items) |e| {
+ var is_open = false;
+ for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output == null and std.mem.eql(u8, f.path, e.path)) {
+ is_open = true;
+ };
+ const state: []const u8 = if (is_open) "open" else "closed";
+ if (!look_rows)
+ try w.print("{s} {s}\n", .{ state, e.path })
+ else if (e.line == 0)
+ try w.print("{s} {s}\n", .{ e.path, state })
+ else
+ try w.print("{s}:{d}:{d} {s}\n", .{ e.path, e.line, e.col, state });
+ }
+}
+
+/// A refusal's list of unsaved panes, kept in the one +Unsaved: filled
+/// again when it is there (as +Search is), else opened below `id`. Best
+/// effort: with no room for it, the notice and the log still say it.
+pub fn openUnsaved(p: *Pardes, id: usize, content: []u8) void {
+ for (p.panes) |slot| {
+ const rp = slot orelse continue;
+ const rf = if (rp.file) |*f| f else continue;
+ const o = rf.output orelse continue;
+ if (o.from != .unsaved) continue;
+ File.setContent(p, rf, content);
+ resetBody(p, rp);
+ return;
+ }
+ const pane = p.panes[id] orelse return p.gpa.free(content);
+ const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const free = p.freeSlot() orelse return p.gpa.free(content);
+ _ = open(p, free, dir, .unsaved, "", content) catch return;
+ exec.placeNew(p, id, id, free, .doc);
+ p.active = id;
+}
+
+/// Recent: the one +Recent, filled again when it is there (as Find and
+/// Grep's +Search is), else opened below the asking pane.
+pub fn openRecent(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try recentText(p, &out.writer, true);
+ const content = try out.toOwnedSlice();
+ for (p.panes, 0..) |slot, i| {
+ const rp = slot orelse continue;
+ const rf = if (rp.file) |*f| f else continue;
+ const o = rf.output orelse continue;
+ if (!std.meta.eql(o.from, Origin{ .cmd = .Recent })) continue;
+ File.setContent(p, rf, content);
+ resetBody(p, rp);
+ p.fs.refilled = rp.serial;
+ if (p.panes[id]) |pane| {
+ pane.search_pane = i;
+ pane.search_row = null;
+ }
+ look.armLookWalk(p, i);
+ return;
+ }
+ try openStepped(p, id, .{ .cmd = .Recent }, content);
+}
+
pub fn openThemes(p: *Pardes, id: usize) !void {
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
@@ -528,13 +620,13 @@ pub fn openThemes(p: *Pardes, id: usize) !void {
try out.writer.print(comptime config.Runtime.findAction(.theme).?.word ++ " {s}\n", .{t.name});
}
const content = try out.toOwnedSlice();
- try openStepped(p, id, .{ .cmd = .ThemeSel }, content);
+ try openStepped(p, id, .{ .cmd = .Themes }, content);
}
pub fn isWalkRow(file: *const File.State, row: usize, line: []const u8) bool {
const state = file.output orelse return true;
if (usesLocationsConfig(state.from)) return isResultRow(file, row);
- if (!std.meta.eql(state.from, Origin{ .cmd = .ThemeSel })) return true;
+ if (!std.meta.eql(state.from, Origin{ .cmd = .Themes })) return true;
// Section labels are for reading, not stops in the command walk.
return std.mem.startsWith(u8, std.mem.trimStart(u8, line, " \t"), comptime config.Runtime.findAction(.theme).?.word ++ " ");
}
@@ -548,7 +640,7 @@ pub fn openFonts(p: *Pardes, id: usize) !void {
for (font_list) |f|
try out.writer.print(comptime config.Runtime.findAction(.font).?.word ++ " {s}\n", .{f.name});
const content = try out.toOwnedSlice();
- try openStepped(p, id, .{ .cmd = .FontSel }, content);
+ try openStepped(p, id, .{ .cmd = .Fonts }, content);
}
}
diff --git a/src/Terminal.zig b/src/Terminal.zig
index 4808298e..32595d25 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -1013,6 +1013,27 @@ pub fn onAlternateScreen(pane: *const Pane) bool {
return state.vt.screens.active_key == .alternate;
}
+/// Whether a new terminal's greeting may be typed now: only at the shell's
+/// first prompt (pardes's own marks: no command started; a prompt drawn
+/// again for a resize is the same first prompt), with nothing
+/// typed at it and no program on the alternate screen. Any later moment is `.never`, for good: something has run or
+/// been typed first, and an `ls` then would go into it. A shell pardes
+/// cannot mark has no first prompt to find, and is never greeted.
+pub fn greeting(pane: *const Pane) enum { wait, now, never } {
+ if (comptime !enabled) return .never;
+ if (pane.fs.unmarked) return .never;
+ // Text typed into the edit buffer that stands in for the prompt row.
+ if (pane.ovl) |o| if (o.text.len > 0) return .never;
+ const state = pane.terminal orelse return .wait;
+ const marks = &state.stream.handler;
+ // (Not `declined`: bash's first prompt comes after a D of its own.)
+ if (marks.started > 0) return .never;
+ // At a prompt's input (ghostty's semantic marks, pardes's or the
+ // shell's own), before any command pardes's marks saw run.
+ if (!promptInputReady(pane)) return .wait;
+ return if (promptInputEmpty(pane)) .now else .never;
+}
+
pub fn promptInputReady(pane: *const Pane) bool {
if (comptime !enabled) return false;
const state = pane.terminal orelse return false;
diff --git a/src/animation.zig b/src/animation.zig
index 840412f3..19f7ce0b 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -1002,7 +1002,7 @@ pub const CursorGlide = struct {
}
}
- fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 {
+ pub fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 {
var c: [2]f32 = .{ 0, 0 };
for (glide.corners) |corner| for (&c, corner) |*sum, axis| {
sum.* += axis.value(now_ns) / 4;
diff --git a/src/builtins.zig b/src/builtins.zig
index 3f4a680c..9025da6d 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -238,7 +238,7 @@ test "Exit asks once about unsaved text, and quits when asked again" {
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)"));
+ try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nExit again to discard\n"));
// Told once, and nothing edited since: the second Exit quits.
try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
try std.testing.expect(p.quit);
@@ -267,6 +267,14 @@ test "Exit over a ctl write or a click names every pane it asks about, a second
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);
+ // The whole list stays in +Unsaved; the notice is short.
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), first.file.?.path) != null);
+ try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), "\nExit again to discard\n"));
+ var noticed = false;
+ for (p.panes) |slot| if (slot) |pane| {
+ noticed = noticed or std.mem.eql(u8, pane.msg[0..pane.msg_len], "2 unsaved panes \u{2014} Exit again to discard");
+ };
+ try std.testing.expect(noticed);
// 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));
@@ -274,6 +282,13 @@ test "Exit over a ctl write or a click names every pane it asks about, a second
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);
+ // The one +Unsaved, filled again.
+ var kept: usize = 0;
+ for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| {
+ kept += @intFromBool(o.from == .unsaved);
+ };
+ try std.testing.expectEqual(@as(usize, 1), kept);
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), 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"));
@@ -281,6 +296,12 @@ test "Exit over a ctl write or a click names every pane it asks about, a second
}
}
+/// What +Unsaved holds: the list a refusal over unsaved panes keeps.
+fn unsavedList(p: *Pardes) []const u8 {
+ for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| if (o.from == .unsaved) return f.content;
+ return "";
+}
+
/// A dump file that exists, for a Restore to get past reading it.
fn testDump(buf: []u8) ![]const u8 {
var tmp = std.testing.tmpDir(.{});
@@ -304,7 +325,7 @@ test "Restore asks about unsaved text as Exit does, and restores when asked agai
const line = try testDump(&line_buf);
try std.testing.expect(p.executeBuiltinLine(p.active, line));
try std.testing.expect(p.restore_req == null);
- try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Restore again to discard)"));
+ try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nRestore again to discard\n"));
try std.testing.expect(p.executeBuiltinLine(p.active, line));
try std.testing.expectEqualStrings(line["Restore ".len..], p.restore_req.?);
}
@@ -336,7 +357,7 @@ test "a Restore's warning is not an Exit's: each word is warned on its own" {
// The Restore warned; an Exit after it has not been, and asks.
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)"));
+ try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nExit again to discard\n"));
try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
try std.testing.expect(p.quit);
}
@@ -460,7 +481,9 @@ pub const Exec = struct {
/// acme's rowclean (editors/acme/wind.c:511-529, winclean), asked before
/// what would throw unsaved text away: each pane holding some nobody has
-/// been told about says so, `<name>: Modified (Exit again to discard)`,
+/// been told about is listed in +Unsaved, `<name>: Modified`, under it
+/// `Exit again to discard`, with a short notice, `3 unsaved panes -- Exit
+/// again to discard`,
/// naming the word asking, since repeating it throws the text away, and is
/// then clean for the asking, as winclean clears `dirty`; so the same word
/// again, with nothing edited since, goes ahead. Only the same word: a
@@ -482,6 +505,9 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
var count: usize = 0;
var one_buf: [limits.host_path_cap + 24]u8 = undefined;
var one: []const u8 = "";
+ // The whole list goes into +Unsaved, where it stays; the notice is short.
+ var listed: std.ArrayList(u8) = .empty;
+ const arena = c.p.scratch.allocator();
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
switch (which) {
@@ -508,12 +534,17 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
// 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);
+ listed.print(arena, "{s}: Modified\n", .{name}) catch {};
if (count == 0) one = std.fmt.bufPrint(&one_buf, "{s}", .{name}) catch name;
count += 1;
}
+ if (count > 0) {
+ listed.print(arena, "\n{s} again to discard\n", .{@tagName(asking)}) catch {};
+ if (c.p.gpa.dupe(u8, listed.items)) |content| panes.Output.openUnsaved(c.p, c.id, content) else |_| {}
+ var short: [96]u8 = undefined;
+ c.p.reportFailure(c.id, std.fmt.bufPrint(&short, "{d} unsaved pane{s} \u{2014} {s} again to discard", .{ count, if (count == 1) "" else "s", @tagName(asking) }) catch "unsaved panes");
+ }
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)
@@ -595,7 +626,8 @@ pub const Kill = struct {
pub const Dump = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
- dump.dumpState(c.p) catch {};
+ // Never a Dump said done that wrote nothing: its failure is said.
+ dump.dumpState(c.p) catch |err| c.p.reportError(c.id, "Dump", err);
}
};
@@ -705,7 +737,14 @@ pub const Unmount = struct {
var args = std.mem.tokenizeAny(u8, c.arg orelse "", " \t");
const name = args.next() orelse return c.p.reportError(c.id, "Unmount name", error.MissingArgument);
if (args.next() != null) return c.p.reportError(c.id, "Unmount name", error.TooManyArguments);
- pardes.filesystem.unmount(c.p, name) catch |err| return c.p.reportError(c.id, "Unmount", err);
+ pardes.filesystem.unmount(c.p, name) catch |err| {
+ // "no such": the words 9ns reads as ENOENT.
+ if (err == error.NotMounted) {
+ var said: [96]u8 = undefined;
+ return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Unmount: no such mount {s}", .{name[0..@min(name.len, 32)]}) catch "Unmount: no such mount");
+ }
+ return c.p.reportError(c.id, "Unmount", err);
+ };
}
};
@@ -733,7 +772,7 @@ pub const NextColor = struct {
};
/// List the themes in a pane, each a word to click.
-pub const ThemeSel = struct {
+pub const Themes = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true };
pub fn run(c: Ctx) void {
@@ -771,7 +810,7 @@ pub const DumpThemes = struct {
};
/// List the fonts in a pane, each a word to click.
-pub const FontSel = struct {
+pub const Fonts = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true };
pub const enabled = capabilities.font_picker;
@@ -1315,7 +1354,12 @@ pub const Mini = struct {
// ---- search ----
-/// List every place a pattern matches in the pane, in a +Search.
+/// A Find or Grep that finds nothing fails as one, in words 9ns reads as
+/// ENOENT (`not found`), the same for both.
+const e_search_miss_find = "find: no match, not found";
+const e_search_miss_grep = "grep: no match, not found";
+
+/// List the files below this pane's directory whose names hold its argument, in a +Search.
pub const Find = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -1323,7 +1367,8 @@ pub const Find = struct {
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (pat.len > 0) {
- _ = look.runSearch(c.p, c.id, pat, .find, .top, true) catch |err| c.p.reportError(c.id, "find", err);
+ const rows = look.runSearch(c.p, c.id, pat, .find, .top, false) catch |err| return c.p.reportError(c.id, "find", err);
+ if (rows == 0) c.p.reportFailure(c.id, e_search_miss_find);
return;
}
c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.find_marker });
@@ -1339,7 +1384,8 @@ pub const Grep = struct {
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (pat.len > 0) {
- _ = look.runSearch(c.p, c.id, pat, .grep, .top, true) catch |err| c.p.reportError(c.id, "grep", err);
+ const rows = look.runSearch(c.p, c.id, pat, .grep, .top, false) catch |err| return c.p.reportError(c.id, "grep", err);
+ if (rows == 0) c.p.reportFailure(c.id, e_search_miss_grep);
return;
}
c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.grep_marker });
@@ -1432,6 +1478,7 @@ pub const Last = struct {
while (i > 0) {
i -= 1;
const j = c.p.jumps[i];
+ if (j.serial == 0) continue; // a closed file's: Back reopens it, Last goes to a pane
// Restore the cursor without recentering the pane's retained view.
if (j.pane != c.id) return look.focusPaneLine(c.p, j.pane, .{ .line = j.line, .col = j.col }, .keep);
}
@@ -1441,6 +1488,15 @@ pub const Last = struct {
}
};
+/// List the files opened lately, closed ones too, each a place to open again.
+pub const Recent = struct {
+ pub const scope: Scope = .session;
+ pub const output: OutputTraits = .{ .name = config.recent_buffer, .steps = true, .locations = true };
+ pub fn run(c: Ctx) void {
+ panes.Output.openRecent(c.p, c.id) catch |err| c.p.reportError(c.id, "recent", err);
+ }
+};
+
/// List the places jumped from, each a place to go.
pub const Jumplist = struct {
pub const scope: Scope = .session;
@@ -1909,15 +1965,15 @@ test "Exit and Restore name a modified +New scratch as well as files" {
try std.testing.expect(!p.quit);
// 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);
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "test.txt: Modified") != 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);
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), 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);
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol again to discard") != null);
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), 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" {
@@ -1929,11 +1985,11 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo
const serial = pane.serial;
try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol"));
try std.testing.expect(p.paneBySerial(serial) != null);
- try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Delcol again to discard)"));
+ try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nDelcol again to discard\n"));
// Each word is warned on its own: Del asks too, then closes.
try std.testing.expect(p.executeBuiltinLine(p.active, "Del"));
try std.testing.expect(p.paneBySerial(serial) != null);
- try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Del again to discard)"));
+ try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nDel again to discard\n"));
try std.testing.expect(p.executeBuiltinLine(p.active, "Del"));
try std.testing.expect(p.paneBySerial(serial) == null);
}
diff --git a/src/colors.zig b/src/colors.zig
index 71938929..da074765 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -442,7 +442,7 @@ pub const themes = fold(curated) ++ foldImports(fold(curated), @import("generate
/// The first `native_count` of `themes` are pardes's own; the rest imported.
pub const native_count = @typeInfo(curated).@"struct".decls.len;
-/// The picker's sections (`ThemeSel`), in ring order, each running from
+/// The picker's sections (`Themes`), in ring order, each running from
/// the theme it names to the next section's; `NextColor` and `Theme` still
/// see one flat ring. The current pardes themes, then the faithful ports a
/// family at a time, then the legacy pair (`helix`, captured from an old
@@ -712,7 +712,7 @@ fn invalidateThemeDependentRasters(p: *Pardes) void {
}
/// Themes whose names share `name`'s first letter, a line of them, for a
-/// Theme given a name there is none of: all of them (ThemeSel) are too many
+/// Theme given a name there is none of: all of them (Themes) are too many
/// for a message or a refused write.
pub fn themesNear(buf: []u8, name: []const u8) []const u8 {
var w = std.Io.Writer.fixed(buf);
diff --git a/src/config.zig b/src/config.zig
index 86f3e7eb..a703f79e 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -65,6 +65,7 @@ pub const leader_path = paths: {
.New = "fn",
.Tty = "nt",
.Find = "ff",
+ .Recent = "fr",
.Grep = "fg",
.Config = "fc",
.LocationsConfig = null,
@@ -87,7 +88,7 @@ pub const leader_path = paths: {
.Wrap = "tw",
.Tagbottom = "tb",
.NextColor = "tn",
- .ThemeSel = "tt",
+ .Themes = "tt",
.Theme = null,
.Shell = null,
.Petscii = "tp",
@@ -108,7 +109,7 @@ pub const leader_path = paths: {
.Exec = null,
});
if (builtins.capabilities.font_picker) {
- table.set(.FontSel, "tf");
+ table.set(.Fonts, "tf");
table.set(.Font, null);
table.set(.TaglineSize, null);
}
@@ -455,6 +456,8 @@ pub const help_buffer = "+Help";
pub const config_buffer = "+Config";
pub const effect_code_buffer = "+EffectCode";
pub const jumps_buffer = "+Jumps";
+pub const recent_buffer = "+Recent";
+pub const unsaved_buffer = "+Unsaved";
pub const themes_buffer = "+Themes";
pub const fonts_buffer = "+Fonts";
pub const pdf_sections_buffer = "+PdfSections";
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 99a2afc2..a08a0342 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -500,12 +500,15 @@ pub const Session = struct {
s.core.screen_h,
s.core.screen_w,
s.ninep,
- ) catch |err| return s.core.reportError(pane, "shell", err);
+ ) catch |err| return s.core.shellFailed(pane, err);
const command = if (s.core.panes[pane]) |pn| pn.command != null else false;
s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } };
setNonblock(child.file.handle);
var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined;
- if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
+ // Only a shell given no directory is asked where it is: one given a
+ // directory is named by it already (newShell), and asked now, before
+ // its chdir, it would answer this process's, a rename to undo.
+ if (cwd.len == 0) if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
}
fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
diff --git a/src/draw.zig b/src/draw.zig
index b0b16e12..15411770 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -212,6 +212,14 @@ fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box {
/// at the Motion flavour's pace, a jump gliding and a step or a keystroke in
/// insert mode landing at once; blinking unless typing. A pixel shell's: a
/// grid's cursor is the terminal's own.
+///
+/// The springs hold the cursor where the pane's final layout puts it, and
+/// the quad is drawn through the pane's frame (paneFrame): while the pane
+/// slides, zooms or is dragged, the cursor rides with its text. A pane that
+/// moved in the layout carries the springs with it (they keep their place
+/// in the pane), so with no transition the cursor lands with the text. A
+/// focus change glides from where the cursor is drawn to the new pane's
+/// cursor, wherever that pane is drawn now.
fn glideCursor(p: *Pardes, s: *Surface) void {
s.chrome.cursor_glide = 0;
s.chrome.cursor_alpha = 1;
@@ -220,26 +228,59 @@ fn glideCursor(p: *Pardes, s: *Surface) void {
const box = focusedCursorBox(p, s) orelse return;
const motion = animation.Motion.of(p.settings.motion);
var snap = if (s.cursor) |cursor| cursor.bar else false;
+ var frame: PaneFrame = .{};
+ // In a column's or the workspace's tag the cursor is not the pane's,
+ // and does not move with it.
+ var inside = false;
if (p.panes[p.active]) |pane| {
- // A focus change lands at once: a glide between panes would be
- // squashed against the new one's edge.
- if (p.cursor_view.serial != pane.serial) snap = true;
- // Typing never trails: insert mode lands every move.
- if (pane.body.mode == .insert) snap = true;
- // Scrolled (a file's view, a terminal's output): the quad moves with
- // the text and lands, never gliding against text that jumped (every
- // Enter at a prompt scrolls one and moves the other).
+ const rect = p.rects[p.active];
+ const cx = box.x + box.w / 2;
+ const cy = box.y + box.h / 2;
+ inside = cx >= @as(f32, @floatFromInt(rect.x)) and cx <= @as(f32, @floatFromInt(rect.x + rect.w)) and
+ cy >= @as(f32, @floatFromInt(rect.y)) and cy <= @as(f32, @floatFromInt(rect.y + rect.h));
+ if (inside) frame = paneFrame(s, pane.serial, rect);
+ const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) };
+ const view = p.cursor_view;
const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0;
- if (p.cursor_view.serial == pane.serial and line != p.cursor_view.line) {
- p.cursor_glide.shift(0, @floatFromInt(p.cursor_view.line - line));
- snap = true;
+ if (view.serial == pane.serial and view.inside == inside) {
+ // The pane moved in the layout: the springs move with it (its
+ // own cursor's; one in a column's tag stays).
+ if (inside) p.cursor_glide.shift(origin[0] - view.origin[0], origin[1] - view.origin[1]);
+ // Typing never trails: insert mode lands every move.
+ if (pane.body.mode == .insert) snap = true;
+ // Scrolled (a file's view, a terminal's output): the quad moves
+ // with the text and lands, never gliding against text that
+ // jumped (every Enter at a prompt scrolls one and moves the
+ // other).
+ if (inside and line != view.line) {
+ p.cursor_glide.shift(0, @floatFromInt(view.line - line));
+ snap = true;
+ }
+ } else if (view.serial != 0 and p.cursor_glide.placed) {
+ // Focus moved (to another pane, or between a pane and a
+ // column's or the workspace's tag): the glide starts where the
+ // cursor is drawn, in the old place's frame, and heads for the
+ // new cursor through the new one's.
+ const old: PaneFrame = if (!view.inside) .{} else for (p.panes, 0..) |slot, id| {
+ const other = slot orelse continue;
+ if (other.serial != view.serial) continue;
+ const r = p.rects[id];
+ p.cursor_glide.shift(@as(f32, @floatFromInt(r.x)) - view.origin[0], @as(f32, @floatFromInt(r.y)) - view.origin[1]);
+ break paneFrame(s, view.serial, r);
+ } else .{};
+ const at = p.cursor_glide.centreAt(p.now_ns);
+ const seen = frame.unplace(old.place(at));
+ p.cursor_glide.shift(seen[0] - at[0], seen[1] - at[1]);
+ p.cursor_crossing = true;
}
- p.cursor_view = .{ .serial = pane.serial, .line = line };
+ p.cursor_view = .{ .serial = pane.serial, .line = line, .origin = origin, .inside = inside };
}
p.cursor_glide.aim(box, p.now_ns, motion, snap);
_ = p.cursor_glide.step(p.now_ns);
const moving = p.cursor_glide.moving();
- s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving));
+ if (!moving) p.cursor_crossing = false;
+ // Drawn as the quad while it glides and while its pane moves.
+ s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving or frame.moving()));
// The shell blinks it, from how long ago the last key or click was.
if (p.settings.cursor_blink)
s.chrome.cursor_idle_ms = @intCast(@min(std.math.maxInt(u32) - 1, (p.now_ns -| p.input_ns) / std.time.ns_per_ms));
@@ -249,13 +290,51 @@ fn glideCursor(p: *Pardes, s: *Surface) void {
const see_through: f32 = if (motion.zeta < 1) 0.7 else 0.85;
s.chrome.cursor_alpha = if (moving) see_through + (1 - see_through) * std.math.clamp(1 - remaining / 0.5, 0, 1) else 1;
const quad = p.cursor_glide.sample(p.now_ns, motion);
- // Never outside the focused pane, however far a flavour overshoots or
- // a scroll carried it: it squashes against the pane's edges.
- const r = p.rects[p.active];
- for (quad, 0..) |point, i| {
- s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], @as(f32, @floatFromInt(r.x)), @as(f32, @floatFromInt(r.x + r.w)));
- s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], @as(f32, @floatFromInt(r.y)), @as(f32, @floatFromInt(r.y + r.h)));
+ // Never outside the focused pane as drawn, however far a flavour
+ // overshoots or a scroll carried it: it squashes against the pane's
+ // edges. Crossing to another pane, it may pass over any of the grid.
+ const bounds: animation.Box = if (p.cursor_crossing or !inside)
+ .{ .w = @floatFromInt(s.cols), .h = @floatFromInt(s.rows) }
+ else
+ frame.at;
+ for (quad, 0..) |corner, i| {
+ const point = frame.place(corner);
+ s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], bounds.x, bounds.x + bounds.w);
+ s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], bounds.y, bounds.y + bounds.h);
+ }
+}
+
+/// Where a pane is drawn against where the layout puts it: its transition
+/// track's presented box against the track's target, or the layout's box
+/// both ways when it is still. Maps grid points between the two.
+const PaneFrame = struct {
+ to: animation.Box = .{ .w = 1, .h = 1 },
+ at: animation.Box = .{ .w = 1, .h = 1 },
+
+ fn moving(f: PaneFrame) bool {
+ return !std.meta.eql(f.to, f.at);
+ }
+
+ fn place(f: PaneFrame, point: [2]f32) [2]f32 {
+ return .{ f.at.x + (point[0] - f.to.x) * scale(f.at.w, f.to.w), f.at.y + (point[1] - f.to.y) * scale(f.at.h, f.to.h) };
+ }
+
+ fn unplace(f: PaneFrame, point: [2]f32) [2]f32 {
+ return .{ f.to.x + (point[0] - f.at.x) * scale(f.to.w, f.at.w), f.to.y + (point[1] - f.at.y) * scale(f.to.h, f.at.h) };
+ }
+
+ fn scale(num: f32, den: f32) f32 {
+ return if (den > 0.001) num / den else 1;
+ }
+};
+
+fn paneFrame(s: *const Surface, serial: u32, rect: Rect) PaneFrame {
+ const still = layout.panelBox(rect);
+ for (s.panelTracks()) |track| {
+ if (track.serial != serial or track.phase == .closing or !track.active()) continue;
+ return .{ .to = track.to, .at = track.visualBox() };
}
+ return .{ .to = still, .at = still };
}
/// The Lift style as drawn: `auto` is a shadow on a light page, and on a
@@ -667,8 +746,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
}
}
};
- glideCursor(p, s);
+ // After the tracks are submitted: the cursor is drawn through its
+ // pane's.
try p.presentation.submit(p, s);
+ glideCursor(p, s);
return composeAsciiTransitions(p, arena, s);
}
@@ -1706,11 +1787,13 @@ test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixte
try std.testing.expect(reverse.bg.rgb[0] < 255);
}
-test "with no lift and no dim a focus switch asks for no frames" {
+test "with no lift, no dim and no motion a focus switch asks for no frames" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
defer p.deinit();
_ = try p.setTestFile("text\n");
p.newScratchBelow(0);
+ // With motion the cursor glides to the other pane (glideCursor).
+ p.settings.motion = .off;
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
p.advance(std.time.ns_per_s);
@@ -2059,3 +2142,109 @@ test "a body with context rows is painted once, and the grid's rows are what a p
try std.testing.expect(layer.rows > body.rect.h);
}
}
+
+/// Two columns, the file in the left one and a shell in the right, the
+/// right one focused with its cursor settled.
+fn twoColumns(p: *Pardes, arena: *std.heap.ArenaAllocator) !void {
+ _ = try p.setTestFile("some text on a line\n" ** 6);
+ _ = try p.newShell(1, "");
+ try std.testing.expect(layout.splitColumn(p, 0, 1, false));
+ p.sync();
+ p.advance(std.time.ns_per_s);
+ const boot = try p.render(arena.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.advance(2 * std.time.ns_per_s);
+ _ = arena.reset(.retain_capacity);
+ const settled = try p.render(arena.allocator());
+ p.acknowledgePanelPresentation(settled.panelTracks());
+ try std.testing.expect(!p.cursor_glide.moving());
+}
+
+test "while its pane slides the cursor rides with it, at the same cell of the pane every frame" {
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 });
+ defer p.deinit();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.settings.motion = .bouncy;
+ try twoColumns(p, &arena);
+ p.active = 1;
+ p.advance(3 * std.time.ns_per_s);
+ _ = arena.reset(.retain_capacity);
+ p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
+ p.advance(4 * std.time.ns_per_s);
+ p.settings.panel_transition = .slide;
+ // The right column's pane moves right.
+ p.col_weight[0] = layout.column_weight_unit * 2;
+ p.col_weight[1] = layout.column_weight_unit;
+ p.sync();
+ var now: u64 = 4 * std.time.ns_per_s;
+ var riding: usize = 0;
+ for (0..40) |_| {
+ now += 16 * std.time.ns_per_ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ p.acknowledgePanelPresentation(s.panelTracks());
+ const frame = paneFrame(s, p.panes[1].?.serial, p.rects[1]);
+ const box = focusedCursorBox(p, s).?;
+ const want = frame.place(.{ box.x, box.y });
+ try std.testing.expectApproxEqAbs(want[0], s.chrome.cursor_quad[0], 0.01);
+ try std.testing.expectApproxEqAbs(want[1], s.chrome.cursor_quad[1], 0.01);
+ if (frame.moving()) {
+ riding += 1;
+ // Drawn as the quad while the pane moves.
+ try std.testing.expect(s.chrome.cursor_glide & 1 != 0);
+ }
+ }
+ try std.testing.expect(riding >= 3);
+
+ // With no transition, the cursor lands with the text at once.
+ p.settings.panel_transition = .off;
+ p.col_weight[0] = layout.column_weight_unit;
+ p.col_weight[1] = layout.column_weight_unit * 2;
+ p.sync();
+ now += 16 * std.time.ns_per_ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ const landed = try p.render(arena.allocator());
+ const box = focusedCursorBox(p, landed).?;
+ try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01);
+ try std.testing.expect(!p.cursor_glide.moving());
+}
+
+test "focus moved to another column glides from where the cursor was drawn" {
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 });
+ defer p.deinit();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.settings.motion = .smooth;
+ try twoColumns(p, &arena);
+ p.active = 0;
+ p.advance(3 * std.time.ns_per_s);
+ _ = arena.reset(.retain_capacity);
+ const before = try p.render(arena.allocator());
+ try std.testing.expect(!p.cursor_glide.moving());
+ const was: [2]f32 = .{ before.chrome.cursor_quad[0], before.chrome.cursor_quad[1] };
+
+ p.active = 1;
+ _ = arena.reset(.retain_capacity);
+ const start = try p.render(arena.allocator());
+ // It sets off from where it was drawn, in the left column...
+ try std.testing.expectApproxEqAbs(was[0], start.chrome.cursor_quad[0], 0.01);
+ try std.testing.expectApproxEqAbs(was[1], start.chrome.cursor_quad[1], 0.01);
+ try std.testing.expect(p.cursor_glide.moving() and start.chrome.cursor_glide & 1 != 0);
+ // ...crosses the grid, not squashed against the right one's edge...
+ p.advance(3 * std.time.ns_per_s + 40 * std.time.ns_per_ms);
+ _ = arena.reset(.retain_capacity);
+ const mid = try p.render(arena.allocator());
+ try std.testing.expect(mid.chrome.cursor_quad[0] > was[0] and mid.chrome.cursor_quad[0] < @as(f32, @floatFromInt(p.rects[1].x)) + 1);
+ // ...and lands on the right one's cursor.
+ p.advance(5 * std.time.ns_per_s);
+ _ = arena.reset(.retain_capacity);
+ const landed = try p.render(arena.allocator());
+ const box = focusedCursorBox(p, landed).?;
+ try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01);
+ try std.testing.expect(!p.cursor_glide.moving() and !p.cursor_crossing);
+}
diff --git a/src/exec.zig b/src/exec.zig
index 2b0c90cb..1fc38b9c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -37,6 +37,12 @@ pub fn takesCommandLine(p: *const Pardes, id: usize) bool {
const pane = p.panes[id] orelse return false;
// A command pane's child is its command, never a shell to type at.
if (!pane.isTerminal() or pane.command != null) return false;
+ // Someone's line at the shell's prompt, typed and not sent: typing a
+ // command there would run it joined to theirs (`echo TYPEDls`). Only an
+ // idle, EMPTY prompt is typed at; otherwise a new pane runs the line.
+ // (Text in pardes's own edit buffer is not the shell's yet: an exec of
+ // it, or of anything, types only what it runs.)
+ if (panes.Terminal.promptInputReady(pane) and !panes.Terminal.promptInputEmpty(pane)) return false;
// A mounted shell sits behind sudo's process supervisor. Its OSC 133
// prompt marks identify input readiness; the launcher's executable is
// not the shell executable used by the ordinary process heuristic.
@@ -301,17 +307,15 @@ pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void {
if (pane.isTerminal()) askWrite(p, id, pane.serial, full);
return;
};
+ // A scratch takes the name once the write is done (promoteSaved): a
+ // Save that fails changes nothing, the name included.
if (f.output != null and panes.Output.fileTraits(f.output).saves) {
- const owned = p.gpa.dupe(u8, full) catch return;
- p.gpa.free(f.path);
- f.path = owned;
- f.output = null; // an ordinary file pane from here on
- f.watch_after_save = true;
- pane.clearCwd();
- // re-derive the tag as a plain file's
- if (pane.tag.own) |own| p.gpa.free(own);
- pane.tag.own = null;
- p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
+ p.emit(.{ .save_text = .{
+ .pane = @intCast(id),
+ .serial = pane.serial,
+ .path = Pardes.SavePath.from(full),
+ .promote = true,
+ } });
return;
}
// its own path, spelled out: the in-place write, so the pane comes clean
@@ -343,11 +347,20 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void {
fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
if (p.panes[from] == null) return null;
+ // A directory that is not there makes no terminal: its shell could not
+ // start in it, and a dead pane would answer no run. Asked only of a host
+ // that starts shells (none, as in the core's tests, starts none).
+ const dir = p.execDir(from);
+ if (missingDir(p, dir)) {
+ var said: [pardes.memory.limits.host_path_cap + 48]u8 = undefined;
+ p.reportFailure(from, std.fmt.bufPrint(&said, "Tty: {s}: no such directory", .{dir}) catch "Tty: no such directory");
+ return null;
+ }
const free = p.freeSlot() orelse {
p.reportError(from, "Tty", error.NoPaneSlots);
return null;
};
- const nt = p.newShell(free, p.execDir(from)) catch |err| {
+ const nt = p.newShell(free, dir) 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;
@@ -362,6 +375,26 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
return nt;
}
+/// Whether a shell would be started in `dir` and `dir` is not there: asked
+/// only of a host that starts shells, and only of a local path.
+pub fn missingDir(p: *Pardes, dir: []const u8) bool {
+ if (comptime !hosted) return false;
+ if (p.host.vtable.spawn == null) return false;
+ const local = @import("fs.zig").localPath(dir) orelse return false;
+ return !isDirectory(local);
+}
+
+/// Whether `path` is a directory on this host, the turn out while it asks
+/// (it may be inside a mount this editor serves).
+pub fn isDirectory(path: []const u8) bool {
+ var buf: [4096]u8 = undefined;
+ const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return false;
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return false;
+ return stat.kind == .directory;
+}
+
/// With `BootShell replace`, a doc landing in `col` closes a lone pristine
/// shell there, the boot's placeholder, and absorbVWeight hands its space to
/// the doc. With `keep`, the default, the shell stays like any other pane.
@@ -638,6 +671,13 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
}
const src = p.panes[from] orelse return null;
const dir = p.execDir(from);
+ // A directory that is not there runs nothing, as Tty makes nothing
+ // there: no pane, and the write fails ENOENT.
+ if (missingDir(p, dir)) {
+ var said: [pardes.memory.limits.host_path_cap + 48]u8 = undefined;
+ p.reportFailure(from, std.fmt.bufPrint(&said, "exec: {s}: no such directory", .{dir}) catch "exec: no such directory");
+ return null;
+ }
// Not one whose pty a job it left behind still prints to: reusing it
// would hang that job up.
// From a column's tag, only a finished pane in that column is reused,
@@ -771,11 +811,35 @@ pub fn applyStartupConfig(p: *Pardes) void {
if (executeBuiltinLine(p, p.active, line)) continue;
// A setting pardes no longer has is said, once, and changes nothing.
const word = commandText(line);
- if (std.mem.eql(u8, word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len], "ColumnTags"))
+ const head = word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len];
+ if (std.mem.eql(u8, head, "ColumnTags"))
p.setMessage(p.active, "ColumnTags: column tags are always shown now; the init line is ignored");
+ // A word renamed runs as its new name, and says so, once a line.
+ for ([_][2][]const u8{ .{ "ThemeSel", "Themes" }, .{ "FontSel", "Fonts" } }) |renamed| if (std.mem.eql(u8, head, renamed[0])) {
+ _ = executeBuiltinLine(p, p.active, renamed[1]);
+ var said: [64]u8 = undefined;
+ p.setMessage(p.active, std.fmt.bufPrint(&said, "{s}: renamed to {s}", .{ renamed[0], renamed[1] }) catch "renamed");
+ };
}
}
+test "an init file's ThemeSel runs as Themes and says it was renamed" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.opts.startup_config = "ThemeSel\n";
+ applyStartupConfig(p);
+ var listed = false;
+ for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| {
+ listed = listed or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Themes });
+ };
+ try std.testing.expect(listed);
+ var said = false;
+ for (p.panes) |slot| if (slot) |pane| {
+ said = said or std.mem.eql(u8, pane.msg[0..pane.msg_len], "ThemeSel: renamed to Themes");
+ };
+ try std.testing.expect(said);
+}
+
test "an init file's retired ColumnTags line is said and ignored" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -833,7 +897,7 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
if (!p.announce) return;
var near: [96]u8 = undefined;
var text: [200]u8 = undefined;
- return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Theme: no theme \"{s}\"; like it: {s}; ThemeSel lists all", .{ want[0..@min(want.len, 40)], colors.themesNear(&near, want) }) catch "Theme: no such theme");
+ return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Theme: no theme \"{s}\"; like it: {s}; Themes lists all", .{ want[0..@min(want.len, 40)], colors.themesNear(&near, want) }) catch "Theme: no such theme");
},
.font => {
if (comptime !font_picker) return;
@@ -937,9 +1001,12 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
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);
+ // No pane to halve, but the column holds every pane's minimum
+ // with the new one's: the rows are shared out again, so what is
+ // refused is only what the documented arithmetic cannot hold.
// +Errors goes wherever there is room, last column first; with
// none, its maker says the text some other way (appendErrors).
- if (kind == .errors) {
+ if (layout.shareColumn(p, f.col)) {} else if (kind == .errors) {
layout.removePane(p, id);
var c = p.ncol;
while (c > 0) {
@@ -959,12 +1026,20 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
return;
}
} else {
+ // Refused, it closes at the step's end holding no weight, so
+ // its column's rows come back as they were, none rounded off.
+ p.panes[id].?.vweight = 0;
p.unplaced.set(id);
p.reportError(from, "placement", error.NoPaneRoom);
return;
}
}
};
+ // Placed: no pane of its column left under its minimum by the rows
+ // rounding or an earlier placement gave it; shared out again if so.
+ if (layout.findPane(p, id)) |f| if (!layout.columnAtMinimums(p, f.col)) {
+ _ = layout.shareColumn(p, f.col);
+ };
if (kind == .command) return;
tagline.exitHeader(p);
p.active = id;
diff --git a/src/fs-help.txt b/src/fs-help.txt
index bd822b60..62d4ae48 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -11,11 +11,11 @@ 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 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
+recent files opened lately, closed ones too, most recent first: open|closed <path>
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 tagexec, pty/ on terminals; rmdir closes
+pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only, to +Errors) event look exec tagexec, pty/ on terminals; rmdir closes
os/, src/ the host filesystem; the editor's own sources
$m below: $NINE_MOUNT/pardes/<pid|NAME>; $NINE_MOUNT under 9ns --unix; PARDES_MOUNT in a Tty9p shell.
diff --git a/src/fs.zig b/src/fs.zig
index 4414c150..6feaa972 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -224,12 +224,12 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply {
if (fd < 0) return Reply.fail(req.tag, E.PERM);
defer _ = libc.close(fd);
if (req.op == .setattr) {
- if (!req.truncate or req.off != 0 or libc.ftruncate(fd, 0) != 0) return Reply.fail(req.tag, E.INVAL);
+ if (!req.truncate or req.off != 0 or libc.ftruncate(fd, 0) != 0) return tree.failText(req.tag, E.INVAL, "invalid truncate: only to zero");
var truncated = attr;
truncated.size = 0;
return .{ .tag = req.tag, .attr = truncated };
}
- if (req.off > std.math.maxInt(i64)) return Reply.fail(req.tag, E.INVAL);
+ if (req.off > std.math.maxInt(i64)) return tree.failText(req.tag, E.INVAL, "invalid offset: past what a file may hold");
if (libc.lseek(fd, @intCast(req.off), libc.SEEK.SET) < 0) return Reply.fail(req.tag, E.IO);
if (req.op == .write) {
const written = libc.write(fd, req.data.ptr, req.data.len);
@@ -677,7 +677,9 @@ pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[409
if (std.mem.startsWith(u8, joined, "/virtual/")) return resolveVirtual(p, joined[8..], out);
if (resolveOs(joined, out)) |found| return found;
if (resolveVirtual(p, joined, out)) |found| return found;
- if (resolveVirtual(p, word, out)) |found| return found;
+ // `.`, `..` and `./x` name the pane's directory, never the served tree:
+ // with that directory gone they name nothing (`.` would be /virtual/).
+ if (!dotRelative(word)) if (resolveVirtual(p, word, out)) |found| return found;
if (resolveEmbedded(word, cwd, out)) |source| {
const path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null;
return .{ .path = path };
@@ -685,6 +687,14 @@ pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[409
return null;
}
+/// Whether `word` is spelled relative to the directory it is looked from:
+/// `.`, `..`, `./x`, `../x`.
+pub fn dotRelative(word: []const u8) bool {
+ const w = std.mem.trimEnd(u8, word, "/");
+ return std.mem.eql(u8, w, ".") or std.mem.eql(u8, w, "..") or
+ std.mem.startsWith(u8, word, "./") or std.mem.startsWith(u8, word, "../");
+}
+
/// Where a path really is on the host, and whether it is a directory. The
/// path may lie in a mount this editor serves, so the turn is given up for
/// the syscalls: another thread answers them.
@@ -1306,6 +1316,9 @@ pub const Namespace = struct {
/// 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,
+ /// The output pane a search just filled again rather than made
+ /// (Output.fillResults): what an exec reads back.
+ refilled: ?u32 = null,
/// Every failure said (Messages.reportFailure), counted: a builtin
/// that said one while it ran failed.
failures: u32 = 0,
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 2d31416b..cb3bdd97 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4556,7 +4556,7 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
s.closePty(pane);
if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy);
s.gens[pane] +%= 1;
- const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err);
+ const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.shellFailed(pane, err);
const command = s.core.panes[pane].?.command != null;
const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } };
s.ptys[pane] = pt;
@@ -5882,9 +5882,10 @@ fn renderFrame(
}
}
var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff());
- // Cursors stand still while panes move, as they always have; while the
- // focused one glides or blinks, it is the overlay's quad instead. One
- // or the other every frame (none only at a blink's off half).
+ // While panes move the cells' cursors are not drawn; the focused one is
+ // the overlay's quad, riding with its pane (draw.glideCursor), as it is
+ // while it glides or blinks. One or the other every frame (none only at
+ // a blink's off half).
const blink_alpha: f32 = if (blinkNow(g, chrome)) |b| b.alpha else 1;
const quad_mode = chrome.cursor_glide & 2 != 0 and (chrome.cursor_glide & 1 != 0 or blink_alpha < 1);
g.cursor_quad_alpha = if (quad_mode) chrome.cursor_alpha * blink_alpha else 0;
@@ -6706,9 +6707,12 @@ fn emitSurfaceCell(
// this underlay at tagline height leaves page-colored gutters between
// adjacent workspace, column and pane tags at TaglineSize < 100.
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page);
- // Over a theme's plaque (not a grip's button) the underlay lets it
- // show (tagPlaques).
- if (cell_decor.nplaques != 0 and !here.grip and cell_decor.inPlaque((@as(f32, @floatFromInt(col)) + 0.5) * body_layout.w + body_layout.x_off, @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5))
+ // Over a theme's plaque the underlay lets it show (tagPlaques),
+ // in a grip's cell too where the plaque reaches into it past the
+ // button (which is drawn over the cells).
+ const cell_x = @as(f32, @floatFromInt(col)) * body_layout.w + body_layout.x_off;
+ const cell_y = @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5;
+ if (cell_decor.nplaques != 0 and cell_decor.inPlaque(if (here.grip) cell_x + body_layout.w - 0.5 else cell_x + 0.5, cell_y))
instances[next.*].effect |= clear_bg_bit;
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
@@ -7145,10 +7149,13 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu
const band = plaqueBox(&g, &s.chrome, s.regionList(), region, layout, 1280, 768) orelse continue;
if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - band.border - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region;
} else return error.PlaqueOutsideItsBand;
+ // Clear of every grip's button: a plaque starts flush against it,
+ // in the gap between it and the text.
for (s.regionList()) |grip| {
if (grip.kind != .grip and grip.kind != .column_grip) continue;
- const gx0 = @as(f32, @floatFromInt(grip.rect.x)) * 16;
- const gx1 = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * 16;
+ const column = scrollColumn(&g, &s.chrome, grip.rect.x, layout);
+ const gx0 = column[0];
+ const gx1 = column[1];
const gy0 = @as(f32, @floatFromInt(grip.rect.y)) * 32;
const gy1 = @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * 32;
try std.testing.expect(r[2] <= gx0 + 0.01 or r[0] >= gx1 - 0.01 or r[3] <= gy0 + 0.01 or r[1] >= gy1 - 0.01);
@@ -7173,6 +7180,11 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu
if (tag.kind != .tag or tag.rect.y != column.rect.y + column.rect.h or tag.rect.x + tag.rect.w != column.rect.x + column.rect.w) continue;
const lower = plaqueBox(&g, &s.chrome, s.regionList(), tag, layout, 1280, 768).?;
try std.testing.expectEqual(upper.box[0], lower.box[0]);
+ // Flush against the grips' buttons, which line up too.
+ const grip = for (s.regionList()) |part| {
+ if (part.kind == .grip and part.owner == tag.owner) break part;
+ } else unreachable;
+ try std.testing.expectEqual(scrollColumn(&g, &s.chrome, grip.rect.x, layout)[1], lower.box[0]);
try std.testing.expectEqual(upper.box[2], lower.box[2]);
try std.testing.expectEqual(upper.band[3], upper.box[3]);
try std.testing.expectEqual(lower.band[1] - lower.border, lower.box[1]);
@@ -9382,14 +9394,15 @@ fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const parde
const pane = tag.kind == .tag;
const first = tag.rect.y;
const last = tag.rect.y + tag.rect.h - 1;
- // From its start, right of its grip: the gap cell before its text is
- // the plaque's margin, so its frame is never under the first letter. A
- // column's text sits a cell further in than a pane's; its plaque starts
- // where the pane's under it does, at its grip's end.
- const start = if (tag.kind == .column_tag) for (regions) |grip| {
- if (grip.kind == .column_grip and grip.owner == tag.owner) break grip.rect.x + grip.rect.w;
- } else tag.rect.x else tag.rect.x;
- const x0 = @as(f32, @floatFromInt(@min(start, tag.rect.x))) * layout.w;
+ // Flush against its grip's button, so the two read as one piece: the
+ // gap between button and text (the scroll gap) is the plaque's, and so
+ // is the cell before its text, so its frame is never under the first
+ // letter. A column's plaque starts where the pane's under it does.
+ const grip_kind: pardes.Region.Kind = if (tag.kind == .column_tag) .column_grip else .grip;
+ const x0 = for (regions) |grip| {
+ if (tag.kind != .workspace_tag and grip.kind == grip_kind and grip.owner == tag.owner)
+ break scrollColumn(g, chrome, grip.rect.x, layout)[1];
+ } else @as(f32, @floatFromInt(tag.rect.x)) * layout.w;
const x1_cells = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w;
const window_edge = win_w - x1_cells < layout.w;
const x1 = if (window_edge) @max(x1_cells, win_w) else x1_cells;
@@ -9454,7 +9467,13 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce
const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w;
const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w;
try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h);
- try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h);
+ // Right of it, a theme's plaque starts flush against it (plaqueBox).
+ const plaqued = chrome.decor_box_border != null and !cell_decor.plaques_off;
+ if (!plaqued) try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h);
+ // Its cells over the plaque's reach are let through (emitInstance):
+ // under the button's row, the band goes on as far as the plaque.
+ if (plaqued and grip.rect.h > 1)
+ try addDecor(g, gpa, left, button[3], button[2], @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h, band, 1, track, win_w, win_h);
if (grip.kind != .grip) continue;
// acme's button ring, two pixels (ButtonBorder) inside the button:
// the focused one's in its own fill. An unfocused dirty one keeps a
@@ -10160,7 +10179,6 @@ fn buildOverlay(
fn appendCursorGlide(g: *Gui, surface: *const pardes.Surface, builder: *OverlayBuilder) void {
const chrome = &surface.chrome;
if (g.cursor_quad_alpha <= 0.001) return;
- if (surface.panelTracks().len != 0) return;
const layout = fixedCellLayout(g);
var px: [4][2]f32 = undefined;
var centre: [2]f32 = .{ 0, 0 };
diff --git a/src/host_io.zig b/src/host_io.zig
index 1db0dac9..913930ac 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -867,7 +867,8 @@ pub const Shell = struct {
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";
+ // "not found": the words a mount reads as ENOENT.
+ return std.fmt.bufPrint(said, "no shell \"{s}\", not found (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 {
@@ -1156,17 +1157,58 @@ pub fn forkShell(
var env_slots: ChildEnv.Slots = undefined;
const envp = ChildEnv.build(&env_slots);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ // The child says here why it could not become the shell (its chdir or
+ // its exec failed, as for a script whose interpreter is not there): the
+ // pipe closes on exec, so the parent reads nothing when the shell runs,
+ // and a reason when it did not, before anyone is told it started.
+ var told: [2]c_int = .{ -1, -1 };
+ // pipe and then CLOEXEC, not pipe2, which macOS has not.
+ if (libc.pipe(&told) != 0) return error.PipeFailed;
+ ninep_io.setCloexec(told[0]);
+ ninep_io.setCloexec(told[1]);
const pid = forkpty(&master, null, null, &ws);
- if (pid < 0) return error.ForkFailed;
+ if (pid < 0) {
+ _ = libc.close(told[0]);
+ _ = libc.close(told[1]);
+ return error.ForkFailed;
+ }
if (pid == 0) {
var set = posix.sigemptyset();
posix.sigaddset(&set, posix.SIG.WINCH);
posix.sigprocmask(posix.SIG.UNBLOCK, &set, null);
- if (cwd_z) |path| if (chdir(path.ptr) != 0) _exit(126);
+ if (cwd_z) |path| if (chdir(path.ptr) != 0) {
+ const why = [_]u8{ 'c', @truncate(@intFromEnum(libc.errno(@as(c_int, -1)))) };
+ _ = libc.write(told[1], &why, why.len);
+ _exit(126);
+ };
if (envp) |env| _ = execve(spawn.path, &spawn.argv, env);
_ = execv(spawn.path, &spawn.argv);
+ const why = [_]u8{ 'x', @truncate(@intFromEnum(libc.errno(@as(c_int, -1)))) };
+ _ = libc.write(told[1], &why, why.len);
_exit(127);
}
+ _ = libc.close(told[1]);
+ const failed = failed: {
+ var why: [2]u8 = undefined;
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ const n = libc.read(told[0], &why, why.len);
+ _ = libc.close(told[0]);
+ if (n != 2) break :failed null;
+ break :failed why;
+ };
+ if (failed) |why| {
+ _ = libc.close(master);
+ _ = libc.waitpid(pid, null, 0);
+ const e: libc.E = @enumFromInt(why[1]);
+ if (why[0] == 'c') return if (e == .NOENT or e == .NOTDIR) error.FileNotFound else error.AccessDenied;
+ return switch (e) {
+ .NOENT => error.ShellNotFound, // itself, or its script's interpreter
+ .ACCES, .PERM => error.ShellNotExecutable,
+ .NOEXEC => error.ShellNotExecutable,
+ else => error.ShellDidNotStart,
+ };
+ }
ninep_io.setCloexec(master);
if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), one_line == null and spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
@@ -1286,7 +1328,14 @@ pub fn shellCwd(pid: libc.pid_t, buf: []u8) ?[]const u8 {
const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null;
const n = libc.readlink(path, buf.ptr, buf.len);
if (n <= 0 or n >= buf.len) return null;
- return buf[0..@intCast(n)];
+ const link = buf[0..@intCast(n)];
+ // A directory removed under the shell reads `<path> (deleted)`:
+ // no directory to be named by, so the name it had stays, and a
+ // restart works there once it is back.
+ if (std.mem.endsWith(u8, link, " (deleted)")) {
+ _ = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), link, .{}) catch return null;
+ }
+ return link;
},
.macos, .ios, .tvos, .watchos, .visionos => {
var info: proc_vnodepathinfo = undefined;
@@ -1330,6 +1379,31 @@ test "shell cwd rejects truncation and preserves an owned child path longer than
try std.testing.expectEqualStrings(directory, shellCwd(child.pid, &result) orelse return error.MissingCwd);
}
+test "a shell whose directory was removed is not named `... (deleted)`" {
+ if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
+ if (!haveFile("/bin/sh")) return error.SkipZigTest;
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(io, "gone");
+ var path_buf: [4096]u8 = undefined;
+ const directory = path_buf[0..try tmp.dir.realPathFile(io, "gone", &path_buf)];
+ const rcs: Shell.PromptFiles = .{};
+ const child = try forkShell(null, 0, &rcs, "/bin/sh", directory, 24, 80, null);
+ defer {
+ _ = libc.kill(child.pid, libc.SIG.KILL);
+ _ = libc.waitpid(child.pid, null, 0);
+ _ = libc.close(child.file.handle);
+ }
+ var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid };
+ try std.testing.expect(writeFd(child.file.handle, "printf '\\160ardes-cwd-ready\\n'\n"));
+ try std.testing.expect(sh.waitText("pardes-cwd-ready", 5_000));
+ var result: [4096]u8 = undefined;
+ try std.testing.expectEqualStrings(directory, shellCwd(child.pid, &result) orelse return error.MissingCwd);
+ try tmp.dir.deleteDir(io, "gone");
+ try std.testing.expect(shellCwd(child.pid, &result) == null);
+}
+
extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t;
const occ_max_depth: u8 = 8;
diff --git a/src/layout.zig b/src/layout.zig
index 09cf23b3..16af1ec5 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -441,6 +441,52 @@ pub fn minimaFit(p: *Pardes) bool {
return true;
}
+/// Whether every unfolded pane of column `c` has its `minRows` (a pane
+/// alone in its column always does).
+pub fn columnAtMinimums(p: *Pardes, c: usize) bool {
+ compute(p);
+ if (p.col_n[c] < 2) return true;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ if (!pane.collapsed and p.rects[k].h < minRows(p, k)) return false;
+ }
+ return true;
+}
+
+/// Shares column `c`'s rows out again so every unfolded pane has its
+/// `minRows`, as evenly as that allows; false, changing nothing, when the
+/// column has not the rows for all of them (or holds a folded pane).
+pub fn shareColumn(p: *Pardes, c: usize) bool {
+ compute(p);
+ const n = p.col_n[c];
+ if (n == 0) return false;
+ const avail = p.screen_h -| (p.topBarHeight() + p.columnBarHeight());
+ var mins: [MAX_PANES]u16 = undefined;
+ var need: u32 = 0;
+ for (p.col_panes[c][0..n], 0..) |k, i| {
+ const pane = p.panes[k] orelse return false;
+ if (pane.collapsed) return false;
+ p.rects[k].w = p.col_w[c];
+ mins[i] = minRows(p, k);
+ need += mins[i];
+ }
+ if (need > avail) return false;
+ // Each its minimum, then what is left a row at a time to the smallest.
+ var hs: [MAX_PANES]u16 = undefined;
+ @memcpy(hs[0..n], mins[0..n]);
+ var left = avail - need;
+ while (left > 0) : (left -= 1) {
+ var low: usize = 0;
+ for (1..n) |i| if (hs[i] < hs[low]) {
+ low = i;
+ };
+ hs[low] += 1;
+ }
+ for (p.col_panes[c][0..n], 0..) |k, i| p.panes[k].?.vweight = @floatFromInt(hs[i]);
+ compute(p);
+ 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
diff --git a/src/look.zig b/src/look.zig
index 39f7420e..ca9aa80a 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -593,7 +593,11 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
var nroots: usize = 0;
for (p.panes) |slot| {
const pp = slot orelse continue;
- const d = Pardes.paneDir(pp);
+ // A pane of the served tree (/virtual/, /n/<peer>/) has no
+ // directory on this host to walk, and one that named `/` would
+ // swallow every other root: passed over.
+ const d = filesystem.localPath(Pardes.paneDir(pp)) orelse continue;
+ if (filesystem.isVirtual(if (pp.file) |f| f.path else d)) continue;
var covered = false;
var k: usize = 0;
while (k < nroots) {
@@ -612,10 +616,18 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
roots[nroots] = d;
nroots += 1;
}
+ // A pane's directory that is not there (a scratch named into one
+ // not made yet, a peer's) has nothing to grep: passed over, not the
+ // search's failure.
for (roots[0..nroots]) |r|
- out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]);
+ out_len += filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]) catch |err| switch (err) {
+ error.FileNotFound, error.NotDir, error.AccessDenied => continue,
+ else => return err,
+ };
+ nrows = std.mem.count(u8, out[0..out_len], "\n");
} else if (kind == .find) {
out_len = try filesystem.find(arena, dir, pat, out);
+ nrows = std.mem.count(u8, out[0..out_len], "\n");
} else if (pane.hasPdf()) {
if (comptime pdf_enabled) {
const found = try panes.Pdf.searchRows(
@@ -655,7 +667,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
nrows += 1;
}
}
- if (!open_empty and kind == .text and nrows == 0) return 0;
+ if (!open_empty and nrows == 0) return 0;
const content = try p.gpa.dupe(u8, out[0..out_len]);
// the buffer records WHICH search filled it, pattern and all: Find and
// Grep are builtins (words you can execute), the bare `/` is a key
@@ -1110,7 +1122,8 @@ fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8)
if (openPaneTarget(p, j, pl.at)) |target| return target;
}
found = resolve(p, txt, dir, realbuf);
- if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break;
+ // `.`, `..`, `./x` are this pane's directory's, not another's.
+ if (found != .none or pl.path.len == 0 or pl.path[0] == '/' or filesystem.dotRelative(pl.path)) break;
}
return found;
}
@@ -1390,8 +1403,14 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const found = resolveLookTarget(p, id, txt, &realbuf);
// 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 missedLine(p, id, trimmed, pl.at.line);
+ if (found == .pane and found.pane.id < MAX_PANES) if (p.panes[found.pane.id]) |shown| {
+ // A terminal's lines are its logical lines, as a +Search lists them.
+ const n = if (shown.file) |*f| panes.File.nlines(p.gpa, f) else if (shown.isTerminal())
+ if (edit.paneCursorLines(p, &shown.body)) |ls| ls.len else |_| std.math.maxInt(usize)
+ else
+ std.math.maxInt(usize);
+ if (pl.at.line > n) 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
@@ -1404,6 +1423,13 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
}
switch (found) {
.none => {
+ // `.` or `..` from a pane whose directory is gone: said, ENOENT,
+ // not searched for as a word.
+ if (filesystem.dotRelative(trimmed)) {
+ const dir = Pardes.paneDir(pane);
+ var said: [4200]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory");
+ }
// 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;
@@ -1436,6 +1462,12 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
},
.url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
.dir => |dir| {
+ // A directory that is not there (the pane's own, gone): said,
+ // ENOENT, no terminal made in it.
+ if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| if (!exec.isDirectory(local)) {
+ var said: [4200]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory");
+ };
for (p.panes, 0..) |slot, i| {
if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and exec.takesCommandLine(p, i)) {
clearNavigationSelection(p, tt);
diff --git a/src/macos.zig b/src/macos.zig
index 4ff13b23..9b17803b 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -2441,7 +2441,7 @@ fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
st.gens[pane] +%= 1;
const gen = st.gens[pane];
- const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd, core.screen_h, core.screen_w, st.ninep) catch |err| return core.reportError(pane, "shell", err);
+ const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd, core.screen_h, core.screen_w, st.ninep) catch |err| return core.shellFailed(pane, err);
st.ptys[pane] = .{
.file = child.file,
.pid = child.pid,
@@ -2450,7 +2450,10 @@ fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
.cmd = .{ .watched = (if (core.panes[pane]) |pn| pn.command != null else false) and host_io.watchExit(child.pid), .fd = child.file.handle },
};
var lbuf: [1024]u8 = undefined;
- if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd);
+ // Only a shell given no directory is asked where it is: one given a
+ // directory is named by it already (newShell), and asked now, before
+ // its chdir, it would answer this process's, a rename to undo.
+ if (cwd.len == 0) if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd);
if (st.started) if (st.ptys[pane]) |*pt| startReader(st, pt, pane);
}
diff --git a/src/main.zig b/src/main.zig
index c23f8183..aaa237f6 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -369,6 +369,7 @@ fn nativeMain(init: std.process.Init) !void {
opts.startup_config = found.bytes;
opts.startup_config_path = found.path;
opts.config_dir = found.dir;
+ opts.recent_path = recentPath(init.io, arena, init.environ_map);
if (found.dir) |d| @import("crash.zig").setDir(d);
if (detach) |name| {
const named = if (name.len != 0)
@@ -428,3 +429,18 @@ test {
if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig");
if (comptime pardes.font_picker) _ = @import("fonts.zig");
}
+
+/// Where the list of files opened lately is kept: $XDG_STATE_HOME/pardes
+/// /recent, else ~/.local/state/pardes/recent, the directory made if need
+/// be; null with no home to keep it in (the list then lasts the session).
+fn recentPath(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Environ.Map) ?[]const u8 {
+ const dir = dir: {
+ if (env.get("XDG_STATE_HOME")) |base| if (base.len != 0 and std.fs.path.isAbsolute(base))
+ break :dir std.fs.path.join(arena, &.{ base, "pardes" }) catch return null;
+ const home = env.get("HOME") orelse return null;
+ if (home.len == 0) return null;
+ break :dir std.fs.path.join(arena, &.{ home, ".local", "state", "pardes" }) catch return null;
+ };
+ std.Io.Dir.cwd().createDirPath(io, dir) catch return null;
+ return std.fs.path.join(arena, &.{ dir, "recent" }) catch null;
+}
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index b00ecdba..aae6d364 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -78,6 +78,7 @@ pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply {
const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text;
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ if (pardes.ctlfs.pane.tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why);
const was = headerText(p, serial).?;
var data = req.data;
const rewriting = p.fs.header_rewrite == rewriteKey(serial);
@@ -126,6 +127,8 @@ pub fn released(p: *Pardes, serial: ?u32) void {
/// 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 {
+ // What this write touched, alone, reads back (runBuiltin notes it).
+ 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');
@@ -164,8 +167,14 @@ pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply {
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 tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control);
+ for (line) |c| if ((c < ' ' and c != '\t') or c == 0x7f) return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control);
if (pardes.ctlfs.ctl.tooLong(req, line)) |refusal| return refusal;
+ // A pane's word (Undo, Msg, Save) is no word of a tag no pane owns:
+ // refused as the root's ctl refuses it, never done at whichever
+ // pane has the keyboard. The tag's own words (New, Tty, Find, Grep
+ // in a column's) are its to run.
+ if (paneWord(line, if (serial == null) pardes.config.topbar_str else tagline.column_tag_default))
+ return pardes.ctlfs.ctl.refuseTo(p, req, "not a session control message", line, "pane/<n>/ctl");
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;
@@ -189,6 +198,75 @@ test "New written to a column's exec makes its scratch in the session's director
try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
}
+test "New written to a column's ctl reads back the new pane alone, as its exec does" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_exec = @intFromEnum(tree.TopFile.exec);
+ _ = th.wr(p, root_exec, "Msg before\n");
+ const ctl_node = tree.Node.ofCol(layout.columnSerial(p, 0), .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl_node, "New\n").reply.status);
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{p.panes[p.active].?.serial}), th.rd(p, root_exec, 0, 64).bytes);
+}
+
+/// Whether `line` names a builtin that acts on a pane (every one of pane
+/// scope, Edit too, which the root's ctl takes at the active pane) and is
+/// not one of `own`, the words its tag holds by default: those a tag no
+/// pane owns refuses, never doing them at the pane with the keyboard.
+fn paneWord(line: []const u8, own: []const u8) bool {
+ const cmd = pardes.exec.commandText(line);
+ const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len];
+ const b = std.meta.stringToEnum(pardes.builtins.registry.Builtin(), word) orelse return false;
+ if (pardes.builtins.registry.scope(b) == .session) return false;
+ var words = std.mem.tokenizeScalar(u8, own, ' ');
+ while (words.next()) |w| if (std.mem.eql(u8, w, word)) return false;
+ return true;
+}
+
+test "a pane's word written to /tagexec or a column's exec is refused, and acts on no pane" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec);
+ for ([_]u64{ @intFromEnum(tree.TopFile.tagexec), col_exec }) |node| {
+ for ([_][]const u8{ "Undo\n", "Msg hi\n", "Redo\n" }) |line| {
+ const refused = th.wr(p, node, line);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "not a session control message");
+ }
+ }
+ // The column tag's own words still run there.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status);
+}
+
+test "every pane word a tag no pane owns does not hold is refused there, Edit included, and the pane is untouched" {
+ const p = try th.withFile(testing.allocator, "keep me\n");
+ defer p.deinit();
+ const Builtin = pardes.builtins.registry.Builtin();
+ const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec);
+ for ([_]struct { node: u64, own: []const u8 }{
+ .{ .node = @intFromEnum(tree.TopFile.tagexec), .own = pardes.config.topbar_str },
+ .{ .node = col_exec, .own = tagline.column_tag_default },
+ }) |tag_file| {
+ var checked: usize = 0;
+ for (std.enums.values(Builtin)) |b| {
+ if (pardes.builtins.registry.scope(b) == .session) continue;
+ var owned = false;
+ var words = std.mem.tokenizeScalar(u8, tag_file.own, ' ');
+ while (words.next()) |w| owned = owned or std.mem.eql(u8, w, @tagName(b));
+ if (owned) continue;
+ var line: [64]u8 = undefined;
+ const text = try std.fmt.bufPrint(&line, "{s}{s}\n", .{ @tagName(b), if (b == .Edit) " ,d" else if (pardes.builtins.registry.requiresArg(b)) " x" else "" });
+ const refused = th.wr(p, tag_file.node, text);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "not a session control message");
+ checked += 1;
+ }
+ try testing.expect(checked > 20);
+ }
+ // `Edit ,d` wiped the pane with the keyboard: nothing touched it.
+ try testing.expectEqualStrings("keep me\n", p.panes[0].?.file.?.content);
+}
+
/// 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 {
@@ -323,8 +401,10 @@ test "tagexec runs a word as a click in the workspace tag; exec files read back
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"));
+ // What it says is the session's (Newcol announcing itself); a pane's
+ // word such as Msg is refused there.
+ try testing.expect(th.logHas(p, "msg - Newcol\n"));
+ try testing.expectEqual(E.INVAL, th.wr(p, tagexec, "Msg from the top\n").errno());
// 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);
@@ -333,8 +413,7 @@ test "tagexec runs a word as a click in the workspace tag; exec files read back
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"));
+ try testing.expect(th.logHas(p, "msg - New\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" {
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 4f52d661..477a91fe 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -73,6 +73,7 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo
// An exec answers the pane it went to: a command pane it reused, or
// the terminal it typed into.
p.look_missed = false;
+ p.fs.refilled = null;
const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: {
look.lookAt(p, id, text);
break :blk null;
@@ -88,6 +89,9 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo
created = true;
};
if (created) return;
+ // A +Search it filled again (Find and Grep reuse one): that is what it
+ // touched.
+ if (p.fs.refilled) |serial| if (p.paneBySerial(serial) != null) return noteResult(p, serial);
if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial);
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
@@ -170,14 +174,16 @@ pub fn completeEnd(p: *Pardes, data: []const u8) usize {
/// 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, in_tag: bool) Reply {
- p.fs.ran_at = 0;
+ // The root's lines run at the keyboard's pane: a refusal before any runs
+ // is that pane's too, in its err record.
+ p.fs.ran_at = if (serial == null) (if (p.panes[p.active]) |pane| pane.serial else 0) else 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 tree.failText(req.tag, E.INVAL, e_control);
+ for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) 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;
@@ -196,7 +202,9 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep
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});
+// "invalid" is the word 9ns reads as EINVAL (enameToErrno): through a
+// mount the refusal is EINVAL, as the reply's errno is.
+pub const e_too_long = std.fmt.comptimePrint("invalid command line: 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
@@ -210,12 +218,22 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply {
return tree.failText(req.tag, E.INVAL, e_too_long);
}
-pub const e_control = "a command line holds no control character but a tab";
+pub const e_control = "invalid command line: it holds a control character (or DEL) other than 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 {
+ // A builtin that means nothing without its argument, written bare: the
+ // write is refused, EINVAL, as a ctl's is (checkBuiltin), not run to
+ // fail or to ask.
+ if (exec) {
+ const cmd = exec_line.commandText(text);
+ if (std.meta.stringToEnum(Builtin, cmd)) |b| if (builtins.registry.requiresArg(b)) {
+ p.fs.results_len = 0;
+ return refuse(p, req, "wrong #args in control message", cmd);
+ };
+ }
p.fs.capturing = true;
p.fs.failure_len = 0;
defer p.fs.capturing = false;
@@ -225,7 +243,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i
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);
+ return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure);
+}
+
+/// The errno a builtin's failure answers with: ENOENT for what is not
+/// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else
+/// EIO.
+fn failureErrno(failure: []const u8) u16 {
+ return if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) E.NOENT else E.IO;
}
pub fn resultsLen(p: *Pardes) u64 {
@@ -344,7 +369,7 @@ fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
}
/// `refuse`, saying which ctl takes the message instead.
-fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply {
+pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply {
const room = p.fs.ename.len -| (why.len + ctl.len + 18);
const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) catch why;
return tree.failText(req.tag, E.INVAL, text);
@@ -365,7 +390,9 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
// terminal) is known, and says so rather than sound misspelt.
if (config.Runtime.find(word) != null) {
var why: [64]u8 = undefined;
- return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only", .{word}) catch "GUI-only", line);
+ // EINVAL, a request this build cannot take, in words 9ns reads
+ // as that: `invalid`.
+ return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only, invalid here", .{word}) catch "GUI-only, invalid here", line);
}
return refuse(p, req, "unknown control message", line);
};
@@ -387,7 +414,7 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
// `refuse` quotes the line after it, in what is left of 128.
var near: [40]u8 = undefined;
var why: [104]u8 = undefined;
- return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; ThemeSel lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
+ return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; Themes lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
},
.font => config.Runtime.FontSpec.parse(arg) != null,
else => probe: {
@@ -426,11 +453,13 @@ pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
if (!created) noteResult(p, context);
if (p.fs.refused) return refuse(p, req, e_prompt, line);
if (p.fs.failure_len == 0) return null;
- // An Edit's failure names what failed (the pattern that missed): its
- // line, a program, is not quoted after it.
- const edit = std.mem.startsWith(u8, line, "Edit ") or std.mem.eql(u8, line, "Edit");
- const refusal = if (edit) tree.failText(req.tag, E.IO, p.fs.failure[0..p.fs.failure_len]) else refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
- return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename };
+ // A line that ran and failed says why in the builtin's own words, which
+ // name what failed (`Mount peer /tmp/s: dial failed: ...`): the line is
+ // not quoted after them, as an exec's is not. Only a line refused before
+ // it runs, as no message at all, quotes it (`unknown control message
+ // "X"`).
+ const failure = p.fs.failure[0..p.fs.failure_len];
+ return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure);
}
const e_prompt = "control message needs its argument";
@@ -753,14 +782,20 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
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 };
+ // It ran and was refused: its words, not the line quoted.
+ return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{said}) catch said);
}
- const errno = get(p, pane);
- // Naming the path, in words 9ns reads as the errno.
+ var failed: anyerror = error.Unexpected;
+ const errno = get(p, pane, &failed);
+ // Naming the path, in words 9ns reads as the errno; a peer
+ // out of reach as a look or a Save says it.
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");
+ var why_buf: [48]u8 = undefined;
+ const why = if (pardes.Messages.dialReason(failed)) |r|
+ std.fmt.bufPrint(&why_buf, "dial failed: {s}", .{r}) catch "dial failed"
+ else if (errno == E.NOENT) "no such file" else "cannot be read";
+ return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], why }) 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);
@@ -826,20 +861,26 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
if (std.hash.Wyhash.hash(0, f.content) == saved) return null;
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.
+ const id = p.paneBySerial(pane.serial).?;
+ // Kept in +Unsaved, as Exit's refusal is; the notice is short. The
+ // write's err record logs it, not a msg too.
+ const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null;
+ if (listed) |content| panes.Output.openUnsaved(p, id, content);
p.fs.unlogged = true;
- defer p.fs.unlogged = false;
- p.setMessage(p.paneBySerial(pane.serial).?, said);
- return said;
+ p.setMessage(id, "1 unsaved pane \u{2014} get again to discard");
+ p.fs.unlogged = false;
+ return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
}
-fn get(p: *Pardes, pane: *Pane) u16 {
+fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 {
const f = pane_files.fileOf(pane) orelse return 0;
if (!panes.Output.fileTraits(f.output).saves) return 0;
- const bytes = fs.read(p, f.path) catch |err| return switch (err) {
- error.FileNotFound => E.NOENT,
- else => E.IO,
+ const bytes = fs.read(p, f.path) catch |err| {
+ failed.* = err;
+ return switch (err) {
+ 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.
@@ -1055,7 +1096,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename);
const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n");
- try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\""));
+ try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme no-such-theme\""));
try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename);
try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename);
// A bare required word fails the check, so the line before never runs.
@@ -1065,7 +1106,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
// its line; the lines before it have taken effect, as in acme.
const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off");
try testing.expectEqual(E.IO, failed.errno());
- try testing.expectEqualStrings("Mount name dial: missing argument \"Mount x\"", failed.reply.ename);
+ try testing.expectEqualStrings("Mount name dial: missing argument", failed.reply.ename);
try testing.expect(p.settings.verbose);
try testing.expect(!p.fs.no_prompt);
try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status);
@@ -1413,16 +1454,17 @@ 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.
+ // Renamed but never edited, it was clean: nothing thrown away, nothing
+ // logged as reloaded over edits.
var reloaded: [32]u8 = undefined;
- try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
+ 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) \"get\""));
+ try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)"));
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);
@@ -1436,7 +1478,7 @@ test "exec Save writes the named file through the host" {
const serial = serialOf(p);
const pane = p.panes[0].?;
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status);
- try testing.expect(pane_files.dirtyOf(pane));
+ try testing.expect(!pane_files.dirtyOf(pane)); // a name alone is no edit; Save writes all the same
const saved = wr(p, Node.of(serial, .exec), "Save\n");
try testing.expectEqual(Status.ok, saved.reply.status);
try testing.expect(saved.saved);
@@ -1504,6 +1546,11 @@ test "a builtin's Verbose announcement is logged only when it succeeds, its fail
const serial = serialOf(p);
var want: [64]u8 = undefined;
const said_exit = try std.fmt.bufPrint(&want, "msg {d} Exit\n", .{serial});
+ // A Save a ctl write gives no name to, on a scratch: refused, no Save.
+ const scratch = try th.newPane(p);
+ var said_save_buf: [64]u8 = undefined;
+ try testing.expectEqual(E.INVAL, wr(p, Node.of(scratch, .ctl), "Save\n").errno());
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&said_save_buf, "msg {d} Save\n", .{scratch})));
// 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;
@@ -1615,7 +1662,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: file not found", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: no such directory", 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" {
@@ -1623,8 +1670,8 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back
defer p.deinit();
const root_ctl = @intFromEnum(tree.TopFile.ctl);
if (!builtins.capabilities.lift) {
- try testing.expectEqualStrings("Lift is GUI-only \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename);
- try testing.expectEqualStrings("GripWidth is GUI-only \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename);
+ try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename);
+ try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename);
}
try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename);
var buf: [900]u8 = undefined;
@@ -1665,7 +1712,7 @@ test "a builtin that fails a ctl write logs only its err, so the same failure ag
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.endsWith(u8, text, "err - ctl: Kill: no running command has that first word (x3)\n"));
try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null);
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
@@ -1677,7 +1724,10 @@ test "an exec line over the command limit fails the write and says the limit; a
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"));
+ var rec: [96]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: a command line is at most 1024 bytes", .{serialOf(p)})));
+ try testing.expectEqualStrings(e_control, wr(p, root_exec, "echo a\x01b\n").reply.ename);
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: it holds", .{serialOf(p)})));
try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg " ++ "z" ** 1100 ++ "\n").reply.status);
}
@@ -1721,8 +1771,13 @@ test "Mount dials its peer and fails, mounting nothing, when none answers; a loo
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.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer", refused.reply.ename);
try testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
+ // A pane of a peer gone since: get says so too.
+ try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "away"), .dial = try p.gpa.dupe(u8, dead) });
+ _ = wr(p, Node.of(serialOf(p), .name), "/n/away/f.txt\n");
+ const got = wr(p, Node.of(serialOf(p), .ctl), "get\n");
+ try testing.expectEqualStrings("get: /n/away/f.txt: dial failed: no answer", got.reply.ename);
// 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");
@@ -1741,6 +1796,373 @@ test "a root exec that fails logs its err against the pane it ran at" {
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: ", .{serialOf(p)})));
}
+test "Tty in a directory that is not there fails and makes no pane; a shell that never started answers a run" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n");
+ // A host that starts shells (the core alone starts none, and asks
+ // nothing of a directory).
+ const Starting = struct {
+ fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {}
+ };
+ p.host = .{ .vtable = &.{ .spawn = Starting.spawn } };
+ defer p.host = .{};
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const refused = wr(p, Node.of(serial, .ctl), "Tty\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "Tty: /tmp/pardes-no-such-dir-zz: no such directory");
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // A terminal whose shell the host could not start.
+ _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status);
+ const term = p.active;
+ p.shellFailed(@intCast(term), error.FileNotFound);
+ const run_node = Node.of(p.panes[term].?.serial, .pty_run);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = run_node, .omode = 2 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = run_node, .handle = h, .data = "true\n" });
+ try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 3, .op = .read, .node = run_node, .handle = h, .size = 64 }).bytes);
+}
+
+test "a builtin that needs its argument, written bare to an exec, is EINVAL as through ctl" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for ([_]u64{ root_exec, Node.of(serialOf(p), .exec), @intFromEnum(tree.TopFile.tagexec) }) |node| {
+ const refused = wr(p, node, "Mount\n");
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectEqualStrings("wrong #args in control message \"Mount\"", refused.reply.ename);
+ }
+}
+
+test "Grep with no hit says grep: no match and opens nothing, a missing pane directory passed over" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "alpha\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "one\nbravo here\nthree bravo\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;
+ _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/a.txt\n", .{dir}));
+ // A hit opens its +Search, a row a line: exec reads it back. Find and
+ // Grep share it, a directory's one +Search, each run replacing it.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep bravo\n").reply.status);
+ const grepped = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ try testing.expectEqual(@as(usize, 2), std.mem.count(u8, grepped.file.?.content, "other.txt:"));
+ try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:2") != null);
+ try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:3") != null);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status);
+ const found = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ try testing.expect(std.mem.indexOf(u8, found.file.?.content, "other.txt") != null);
+ try testing.expectEqual(grepped.serial, found.serial);
+ try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") == null);
+ // A second pane in a directory that is not there.
+ const other = try th.newPane(p);
+ _ = wr(p, Node.of(other, .name), "/tmp/pardes-no-such-dir-zz/b.txt\n");
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n");
+ try testing.expectEqual(E.NOENT, missed.errno());
+ try testing.expectEqualStrings("grep: no match, not found", missed.reply.ename);
+ const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n");
+ try testing.expectEqual(E.NOENT, unfound.errno());
+ try testing.expectEqualStrings("find: no match, not found", unfound.reply.ename);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+}
+
+test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const refused = wr(p, @intFromEnum(tree.TopFile.ctl), "Unmount nothere\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "Unmount: no such mount nothere");
+}
+
+test "a terminal told the directory it already goes by logs no rename" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ _ = wr(p, Node.of(serialOf(p), .name), "/tmp/f.txt\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), "Tty\n").reply.status);
+ p.update(.tick); // announced
+ const term = p.active;
+ // The host says where its shell started: where it was already named.
+ p.setCwd(term, "/tmp");
+ var rec: [32]u8 = undefined;
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial})));
+ // This process's directory, asked before the shell has said anything
+ // (so before its chdir): not taken.
+ const session = p.sessionDir();
+ p.setCwd(term, session);
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial})));
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "$ " } });
+ // Somewhere else is a rename.
+ p.setCwd(term, "/usr");
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /usr", .{p.panes[term].?.serial})));
+}
+
+test "a terminal with text typed at its prompt is never typed into: a look at its directory opens another" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const term = p.paneBySerial(serialOf(p)).?;
+ p.setCwd(term, "/tmp");
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07TYPED" } });
+ while (p.nextEffect()) |_| {}
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.look), "/tmp\n").reply.status);
+ var typed_into = false;
+ while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) {
+ typed_into = true;
+ };
+ try testing.expect(!typed_into);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count + 1, after);
+ // An exec at it runs in a pane of its own, not typed after TYPED.
+ _ = wr(p, Node.of(serialOf(p), .exec), "echo hi\n");
+ while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) {
+ typed_into = true;
+ };
+ try testing.expect(!typed_into);
+}
+
+test "a command line from a missing directory fails ENOENT and makes no pane; a command whose shell never started ends 127" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const Starting = struct {
+ fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {}
+ };
+ p.host = .{ .vtable = &.{ .spawn = Starting.spawn } };
+ defer p.host = .{};
+ const serial = serialOf(p);
+ _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n");
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const refused = wr(p, Node.of(serial, .exec), "echo hi\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory");
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // A command pane whose shell the host could not start.
+ _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "echo hi\n").reply.status);
+ const cmd = p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, Node.of(serial, .exec), 0, 64).bytes, "\n"), 10)).?;
+ p.shellFailed(@intCast(cmd), error.FileNotFound);
+ const pane = p.panes[cmd].?;
+ try testing.expect(pane.command_done);
+ try testing.expectEqual(@as(?u8, 127), pane.command_status);
+ var rec: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "exit {d} 127\n", .{pane.serial})));
+ try testing.expect(th.logHas(p, "shell: directory missing"));
+}
+
+test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot start fails the write" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const Starting = struct {
+ fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {}
+ };
+ p.host = .{ .vtable = &.{ .spawn = Starting.spawn } };
+ defer p.host = .{};
+ const serial = serialOf(p);
+ p.setCwd(p.paneBySerial(serial).?, "/tmp/pardes-no-such-dir-zz");
+ const refused = wr(p, Node.of(serial, .pty_ctl), "exec\n");
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory");
+ // The host's own failure is the waiting write's (late_failure).
+ p.fs.late_failure_len = 0;
+ p.shellFailed(@intCast(p.paneBySerial(serial).?), error.AccessDenied);
+ try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]);
+}
+
+test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log, .omode = 2 }).reply.handle;
+ const Case = struct { node: u64, data: []const u8, words: []const u8, handle: u32 = 0 };
+ for ([_]Case{
+ .{ .node = root_exec, .data = "echo a\x7fb\n", .words = "control character" },
+ .{ .node = root_look, .data = "a\x7fb\n", .words = "control character" },
+ .{ .node = Node.of(serial, .name), .data = "\n", .words = "an empty name" },
+ .{ .node = log, .data = "bogus\n", .words = "takes `follow`", .handle = h },
+ }) |c| {
+ const refused = call(p, .{ .tag = 2, .op = .write, .node = c.node, .handle = c.handle, .data = c.data });
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c.words) != null);
+ }
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ try testing.expect(!th.logHas(p, "Invalid argument"));
+ try testing.expect(th.logHas(p, "an empty name"));
+}
+
+test "/index shows a name as the log does: a newline in it is \\n, controls spaces, bytes not UTF-8 \\xNN" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const term = p.paneBySerial(serialOf(p)).?;
+ p.setCwd(term, "/tmp/two\nlines\x7f\xff");
+ const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes;
+ try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines \\xff ") != null);
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, index, "\n"));
+ try testing.expectEqual(@as(u64, index.len), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(tree.TopFile.index) }).reply.attr.size);
+ // Its tag never carries the raw newline either.
+ const tag = rd(p, Node.of(serialOf(p), .tag), 0, 4096).bytes;
+ try testing.expect(std.mem.indexOf(u8, tag, "/tmp/two\\nlines") != null);
+ // A backslash of the name's own is doubled: `\n` written in a name
+ // reads back unlike a newline.
+ p.setCwd(term, "/tmp/back\\nslash");
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, "/tmp/back\\\\nslash") != null);
+}
+
+test "a served-tree pane spoils no Grep, and Look . from a gone directory is ENOENT, not /virtual/" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "hit.txt", .data = "needle here\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;
+ _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/other.txt\n", .{dir}));
+ // A pane of the served tree.
+ const virtual = try th.newPane(p);
+ _ = wr(p, Node.of(virtual, .name), "/virtual/index\n");
+ // One named the served tree's root, whose directory is `/`.
+ const top = try th.newPane(p);
+ _ = wr(p, Node.of(top, .name), "/virtual\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep needle\n").reply.status);
+ // `.` from a pane whose directory is gone.
+ const gone = try th.newPane(p);
+ _ = wr(p, Node.of(gone, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n");
+ const looked = wr(p, Node.of(gone, .look), ".\n");
+ try testing.expectEqual(E.NOENT, looked.errno());
+ try testing.expectStringStartsWith(looked.reply.ename, "look: /tmp/pardes-no-such-dir-zz: no such directory");
+}
+
+test "a tag write refuses NUL and the other control characters" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for ([_]u64{ @intFromEnum(tree.TopFile.tag), tree.Node.ofCol(pardes.layout.columnSerial(p, 0), .tag), Node.of(serialOf(p), .tag) }) |node| {
+ const refused = wr(p, node, " a\x00b");
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "invalid tag text");
+ try testing.expectEqual(E.INVAL, wr(p, node, " \x7f").errno());
+ }
+}
+
+test "a malformed address is bad address syntax, on a terminal as on a text" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const t = try withFile(testing.allocator, "one\n");
+ defer t.deinit();
+ for ([_]*pardes.Pardes{ p, t }) |q| {
+ for ([_][]const u8{ "1x", "/(/", ",,x" }) |expr| {
+ const refused = wr(q, Node.of(serialOf(q), .addr), expr);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expect(refused.reply.ename.len > 0);
+ }
+ try testing.expectEqualStrings("bad address syntax", wr(q, Node.of(serialOf(q), .addr), "1x").reply.ename);
+ }
+}
+
+test "a bad event record says what is wrong with it" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const event = Node.of(serialOf(p), .event);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle;
+ for ([_][2][]const u8{
+ .{ "MI0 1 0 1 x\n", "only a look or exec" },
+ .{ "MX3 1 0 0 \n", "ends before it starts" },
+ .{ "MX9 9 0 0 \n", "range past end" },
+ .{ "garbage\n", "a record is" },
+ }) |c| {
+ const refused = call(p, .{ .tag = 2, .op = .write, .node = event, .handle = h, .data = c[0] });
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c[1]) != null);
+ }
+ _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h });
+}
+
+test "a column takes panes until their documented minimums fill it, not until none can be halved" {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 50 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const new = @intFromEnum(tree.TopFile.new);
+ while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync();
+ p.sync();
+ const avail = p.screen_h - (p.topBarHeight() + p.columnBarHeight());
+ for (0..p.ncol) |c| {
+ var need: u32 = 0;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| need += layout.minRows(p, k);
+ // Refused only when one more pane's tag and two rows would not fit.
+ if (p.col_n[c] > 1) try testing.expect(avail - need < 3);
+ // Every pane keeps its minimum.
+ for (p.col_panes[c][0..p.col_n[c]]) |k| try testing.expect(p.rects[k].h >= layout.minRows(p, k));
+ }
+}
+
+test "a closed file is found again: Recent and /recent list it closed, a look at its row reopens it at its dot, and a jump back does too" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\ntwo\nthree\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\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;
+ const a_path = try std.fmt.bufPrint(&line, "{s}/a.txt", .{dir});
+ var a_buf: [4200]u8 = undefined;
+ const a = a_buf[0..a_path.len];
+ @memcpy(a, a_path);
+ // Opened, its dot on line 3, then closed.
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}:3\n", .{a})).reply.status);
+ p.sync();
+ const opened = p.active;
+ try testing.expectEqualStrings(a, p.panes[opened].?.file.?.path);
+ var want: [4300]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{a})) != null);
+ // Another file, so the jumplist has somewhere to come back from.
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})).reply.status);
+ p.sync();
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[opened].?.serial, .ctl), "Del\n").reply.status);
+ p.sync();
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "closed {s}\n", .{a})) != null);
+ // Recent lists it, closed, at its dot; a look at the row opens it there.
+ try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status);
+ const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ const at = std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}:3:", .{a})).?;
+ const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?];
+ try testing.expect(std.mem.endsWith(u8, row, " closed"));
+ try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " closed".len]})).reply.status);
+ p.sync();
+ const back = p.panes[p.active].?;
+ try testing.expectEqualStrings(a, back.file.?.path);
+ try testing.expectEqual(@as(i32, 2), back.body.cur_row);
+ // Closed again; the jumplist keeps its entry, closed, and a jump back
+ // to it opens it again.
+ _ = wr(p, Node.of(back.serial, .ctl), "Del\n");
+ p.sync();
+ var closed_entry = false;
+ for (p.jumps[0..p.njumps]) |j| closed_entry = closed_entry or j.serial == 0;
+ try testing.expect(closed_entry);
+ var tries: usize = 0;
+ while (tries < p.njumps) : (tries += 1) {
+ p.jumpBy(-1);
+ p.sync();
+ if (p.panes[p.active].?.file) |f| if (std.mem.eql(u8, f.path, a)) break;
+ }
+ try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path);
+}
+
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();
@@ -2098,4 +2520,10 @@ test "@p<terminal>:<address> takes any address over the terminal's lines, select
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"));
+ // A line past its last is a miss, as in a file.
+ const row_before = pane.body.cur_row;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:999\n", .{serial}));
+ try testing.expect(th.logHas(p, "has no line 999"));
+ try testing.expectEqual(row_before, pane.body.cur_row);
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 98799774..690efbe5 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -101,7 +101,7 @@ pub const Queue = struct {
/// One record per read; `.again` holds the read until a record arrives.
pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply {
const record = q.peek() orelse return .{ .tag = req.tag, .status = .again };
- if (req.size < record.len) return Reply.fail(req.tag, E.INVAL);
+ if (req.size < record.len) return tree.failText(req.tag, E.INVAL, "invalid read: shorter than the record waiting");
const out = p.fs.stage(p.gpa);
out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM);
q.pop();
@@ -140,14 +140,18 @@ pub fn announce(p: *Pardes) void {
if (!pane.fs.unannounced) continue;
pane.fs.unannounced = false;
noteLog(p, .new, pane);
+ // A file opened (a look, a Restore, a boot): first in Recent.
+ if (pane.file) |f| if (f.output == null) p.recent.opened(p.recentGpa(), f.path);
}
}
/// Records `<kind> <serial> <name>`.
pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
- var buf: [4096 + 64]u8 = undefined;
- const name = pane_files.nameOf(pane);
- pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name[0..@min(name.len, 4096)] }) catch return);
+ var buf: [4 * 4096 + 64]u8 = undefined;
+ var name_buf: [4 * 4096]u8 = undefined;
+ // As /index shows it: a newline in the name is `\n`.
+ const name = shown(pane_files.nameOf(pane), &name_buf);
+ pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name }) catch return);
}
/// Records `msg <serial> <text>` for what the editor said, `-` for no pane.
@@ -214,9 +218,12 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
};
}
var buf: [256]u8 = undefined;
+ // A reason that names its file already (`look: no space...` from a
+ // look) is not prefixed twice: `err 3 look: no space...`.
+ const reason = if (why.len > file.len + 2 and std.ascii.startsWithIgnoreCase(why, file) and std.mem.startsWith(u8, why[file.len..], ": ")) why[file.len + 2 ..] else why;
// 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;
+ const kept = pardes.Messages.clip(reason, 200);
+ const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}{s}\n", .{ serial, file, kept, if (kept.len < reason.len) "…" else "" }) catch return;
for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') {
c.* = ' ';
};
@@ -292,11 +299,13 @@ pub fn dropMessage(p: *Pardes, text: []const u8) void {
/// 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 {
+fn pushCounting(p: *Pardes, raw: []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.
+ var clean: [4 * 4096 + 256]u8 = undefined;
+ const record = sanitize(raw, &clean);
const last = newest(p) orelse return pushLog(p, record);
var said = last.text;
var times: u32 = 1;
@@ -340,10 +349,75 @@ fn followerRead(p: *Pardes, seq: u64) bool {
/// The log is one ring that records whether or not anyone reads it. A record
/// is one line: a newline in a message or a name would read as two records.
-fn pushLog(p: *Pardes, record: []u8) void {
- for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') {
- c.* = ' ';
- };
+/// A record as one line of text (shown): the record's own newline, last,
+/// is kept.
+fn sanitize(record: []const u8, out: []u8) []u8 {
+ // A newline in a message is a space (its words go on); a name's own
+ // newline is escaped before it gets here (noteLog).
+ const w = shownAs(record[0 .. record.len - 1], out[0 .. out.len - 1], false).len;
+ out[w] = '\n';
+ return out[0 .. w + 1];
+}
+
+/// `text` as one line of UTF-8 a reader can split and decode, as /log and
+/// /index show names: a backslash is `\\`, a newline `\n` (a name with one
+/// stays readable as what it is, not run into the next), any other control
+/// character, DEL or C1 control (U+0080-U+009F) a space, and a byte that
+/// is not UTF-8 `\xNN`. `out` of 4 bytes a byte of `text` holds it all.
+pub fn shown(text: []const u8, out: []u8) []u8 {
+ return shownAs(text, out, true);
+}
+
+fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 {
+ var w: usize = 0;
+ var i: usize = 0;
+ const body = text;
+ while (i < body.len and w + 4 <= out.len) {
+ const c = body[i];
+ // A name's own backslash is `\\`, so `\n` and `\xNN` read back as
+ // the escapes they are, never as a name that held them.
+ if (c == '\\' and escape_newline) {
+ @memcpy(out[w..][0..2], "\\\\");
+ w += 2;
+ i += 1;
+ continue;
+ }
+ if (c == '\n' and escape_newline) {
+ @memcpy(out[w..][0..2], "\\n");
+ w += 2;
+ i += 1;
+ continue;
+ }
+ if (c < ' ' or c == 0x7f) {
+ out[w] = ' ';
+ w += 1;
+ i += 1;
+ continue;
+ }
+ const n = std.unicode.utf8ByteSequenceLength(c) catch 0;
+ if (n == 0 or i + n > body.len or !std.unicode.utf8ValidateSlice(body[i .. i + n])) {
+ _ = std.fmt.bufPrint(out[w..], "\\x{x:0>2}", .{c}) catch break;
+ w += 4;
+ i += 1;
+ continue;
+ }
+ if (n == 2 and c == 0xC2 and body[i + 1] <= 0x9F) {
+ out[w] = ' ';
+ w += 1;
+ i += 2;
+ continue;
+ }
+ if (w + n > out.len) break;
+ @memcpy(out[w..][0..n], body[i..][0..n]);
+ w += n;
+ i += n;
+ }
+ return out[0..w];
+}
+
+fn pushLog(p: *Pardes, raw: []u8) void {
+ var clean: [4 * 4096 + 256]u8 = undefined;
+ const record = sanitize(raw, &clean);
// One record larger than the ring would push every other out and then
// not fit itself; cut it to what fits instead, on a character boundary.
var end = @min(record.len, p.fs.log.cap - 4) - 1;
@@ -438,7 +512,7 @@ pub fn writeLog(p: *Pardes, req: Req) Reply {
// 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);
+ } else if (!std.mem.eql(u8, word, "follow")) return tree.failText(req.tag, E.INVAL, "invalid write to log: it takes `follow` or `follow new`");
slot.follow = true;
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -654,14 +728,14 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
while (check.next()) |r| {
switch (r.action) {
.body_look, .tag_look, .body_exec, .tag_exec => {},
- else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event),
+ else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": only a look or exec (L l X x) is written back"),
}
const n = if (r.action.onTag()) tag.len else body.len;
- if (r.q0 > r.q1) 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 ++ ": its range ends before it starts");
// 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);
+ if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": a record is <origin><action><q0> <q1> <flag> <count> <text>, a line each");
}
var run: EventReader = .{ .data = req.data };
while (run.next()) |r| {
@@ -1244,6 +1318,17 @@ test "a long msg record is cut between words at its cap, with an ellipsis" {
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
+test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are escaped" {
+ var out: [64]u8 = undefined;
+ var name: [64]u8 = undefined;
+ try testing.expectEqualStrings("/tmp/two\\nlines", shown("/tmp/two\nlines", &name));
+ // A backslash of the name's own is doubled: `\n` in a name differs from
+ // a newline in one.
+ try testing.expectEqualStrings("/tmp/a\\\\nb", shown("/tmp/a\\nb", &name));
+ try testing.expectEqualStrings("msg - a b c \\xff d\n", sanitize("msg - a\x7fb\xc2\x85c \xff d\n", &out));
+ try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out));
+}
+
test "a long err record is cut between words, with an ellipsis" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index d8329ccc..4370d401 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -298,7 +298,8 @@ pub fn indexLen(p: *Pardes) u64 {
const pane = p.panes[p.paneBySerial(serial).?].?;
var digits: [16]u8 = undefined;
n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len;
- n += 1 + kindOf(pane).len + 3 + nameOf(pane).len + 1;
+ var name_buf: [4 * 4096]u8 = undefined;
+ n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(pane), &name_buf).len + 1;
n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len;
}
return n;
@@ -457,8 +458,21 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(take) };
}
+/// Why `data` is no text a tag takes: a control character but a tab or a
+/// newline, DEL, a C1 control, or bytes that are not UTF-8 (a NUL in a tag
+/// made a Dump that wrote nothing).
+pub fn tagFault(data: []const u8) ?[]const u8 {
+ for (data) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return "invalid tag text: a control character";
+ if (!std.unicode.utf8ValidateSlice(data)) return "invalid tag text: not UTF-8";
+ var i: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, data, i, 0xC2)) |at| : (i = at + 1)
+ if (at + 1 < data.len and data[at + 1] <= 0x9F) return "invalid tag text: a control character";
+ return null;
+}
+
fn writeTag(req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ if (tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why);
// The tag's own text grows by what is written, newlines and all: a tag
// is a text like any other (acme's tag file appends the same way).
const pf = &pane.fs;
@@ -481,8 +495,11 @@ fn writeTag(req: Req, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(take) };
}
+/// A write only text takes, to a pane with none: said, as every EINVAL is.
+pub const e_no_text = "invalid write: this pane has no text (a terminal, an image or a PDF)";
+
fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply {
- if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
+ if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text);
const pf = &pane.fs;
if (pf.addr_failed) return tree.failText(req.tag, E.INVAL, e_addr_failed);
clampAddr(pf, bodyOf(pane).len);
@@ -501,7 +518,7 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply {
}
fn writeSel(p: *Pardes, req: Req, pane: *Pane) Reply {
- if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
+ if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text);
const d = dotOf(pane);
const q0: usize = d.q0;
const q1: usize = @max(q0, @as(usize, d.q1));
@@ -558,7 +575,7 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
.limit => pf.limit = range,
// Setting dot scrolls to it, which is the whole of acme's `show`.
.dot => {
- if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
+ if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text);
setDot(pane, range);
},
else => unreachable,
@@ -573,7 +590,7 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
const pf = &pane.fs;
switch (file) {
.dirty => {
- const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL);
+ const f = fileOf(pane) orelse return tree.failText(req.tag, E.INVAL, e_no_text);
f.saved_revision = if (on) f.revision -% 1 else f.revision;
},
// acme's nomark joins the writes after it into one undo step. The
@@ -597,19 +614,34 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
/// quietly cut off, so the name a script wrote is the name it gets.
const e_name_char = "bad character in file name";
+/// Why `name` is not one file name, with the reason, or null: a newline, a
+/// control byte, DEL or a C1 control (U+0080-U+009F), a blank at either
+/// end, or bytes that are not UTF-8.
+fn nameFault(name: []const u8) ?[]const u8 {
+ if (std.mem.indexOfScalar(u8, name, '\n') != null) return e_name_char ++ ": a newline (a name is one line)";
+ for (name) |c| if (c < ' ' or c == 0x7f) return e_name_char ++ ": a control character";
+ if (!std.unicode.utf8ValidateSlice(name)) return e_name_char ++ ": not UTF-8";
+ if (std.mem.indexOf(u8, name, "\xc2") != null) {
+ var i: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, name, i, 0xC2)) |at| : (i = at + 1)
+ if (at + 1 < name.len and name[at + 1] <= 0x9F) return e_name_char ++ ": a control character";
+ }
+ if (name[0] == ' ' or name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end";
+ return null;
+}
+
fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- // One line: its newline ends it, as `echo` writes it.
+ // One name: its newline ends it, as `echo` writes it, and it is one.
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 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 (name.len == 0) return tree.failText(req.tag, E.INVAL, e_name_char ++ ": an empty name");
+ if (nameFault(name)) |why| return tree.failText(req.tag, E.INVAL, why);
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
"rename not allowed: an image or PDF is named by the file it shows");
const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
return Reply.fail(req.tag, E.NOMEM);
- if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return Reply.fail(req.tag, E.INVAL);
+ if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return tree.failText(req.tag, E.INVAL, "invalid file name: longer than a path may be");
nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) {
error.OutOfMemory => E.NOMEM,
else => E.INVAL,
@@ -636,7 +668,9 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !
if (pane.tag.own) |own| p.gpa.free(own);
pane.tag.own = null;
}
- f.saved_revision = f.revision -% 1;
+ // A name alone is no edit: dirty stays what the text made it, as
+ // the guards ask only whether it was ever edited, so /index, Exit
+ // and Restore agree. Save writes it under the new name all the same.
f.watch_after_save = fs.localPath(full) != null;
}
if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
@@ -649,6 +683,7 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !
look.invalidateLookHover(p, id);
p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
events.noteLog(p, .rename, pane);
+ if (f.output == null) p.recent.opened(p.recentGpa(), f.path);
}
/// Diagnostics go to the +Errors buffer of the pane's directory.
@@ -1103,9 +1138,26 @@ 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);
+ // One name, in acme's words and why: a blank at either end (not cut
+ // off), a second line, a control byte, DEL, a C1 control, not UTF-8.
+ for ([_][2][]const u8{
+ .{ "trailing.zig \n", "a blank at its end" },
+ .{ " leading.zig\n", "a blank at its end" },
+ .{ "tab\t.zig\n", "a control character" },
+ .{ "two\nlines\n", "a newline" },
+ .{ "del\x7f.zig\n", "a control character" },
+ .{ "c1\xc2\x85.zig\n", "a control character" },
+ .{ "bad\xff.zig\n", "not UTF-8" },
+ }) |c| {
+ const refused = wr(p, name, c[0]);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectStringStartsWith(refused.reply.ename, "bad character in file name: ");
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c[1]) != null);
+ }
+ // A name that is one line and UTF-8 is taken, é and all.
+ try testing.expectEqual(Status.ok, wr(p, name, "caf\xc3\xa9.zig\n").reply.status);
+ try testing.expect(std.mem.endsWith(u8, pane.file.?.path, "/caf\xc3\xa9.zig"));
+ try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "{s}\n", .{path})).reply.status);
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});
@@ -1151,7 +1203,7 @@ test "relative names use the file directory and can name a new target" {
var expected_buf: [4096]u8 = undefined;
const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory});
try testing.expectEqualStrings(expected, pane.file.?.path);
- try testing.expect(dirtyOf(pane));
+ try testing.expect(!dirtyOf(pane)); // a name alone is no edit
}
test "relative names follow a pane/new scratch's session directory and virtual directories" {
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 4009af49..aa0f499a 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -61,6 +61,12 @@ 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;
+ // A shell restarted where the directory is gone would not start:
+ // refused before anything runs, as Tty there is.
+ if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pardes.exec.missingDir(p, pane.cwdSlice())) {
+ const dir = pane.cwdSlice();
+ return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}: no such directory", .{dir[0..@min(dir.len, 256)]}) catch "exec: no such directory");
+ };
if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
@@ -196,7 +202,10 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const pf = &pane.fs;
const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line);
const marks = &state.stream.handler;
- if (pane.command != null) {
+ if (pane.shell_failed) {
+ // Its shell never started: no prompt is coming to wait for.
+ answer(p, slot, "error shell gone", .{});
+ } else if (pane.command != null) {
// Its child is the command, and no shell is there to take a line.
answer(p, slot, "error {s}", .{if (pane.command_done) "command done; not a shell" else "a command runs here, not a shell"});
} else if (pf.unmarked) {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index ed0e0352..64089c01 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -219,6 +219,9 @@ pub const TopFile = enum(u5) {
tag,
col,
tagexec,
+ /// The files opened lately, closed ones too, a line each:
+ /// `open|closed <path>`, most recent first.
+ recent,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -228,7 +231,7 @@ pub const TopFile = enum(u5) {
return switch (f) {
.root, .pane, .col => 0o755,
.look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666,
- .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444,
+ .README, .index, .status, .screen, .listeners, .new, .commands, .layout, .recent => 0o444,
};
}
@@ -239,6 +242,13 @@ pub const TopFile = enum(u5) {
pub const root: u64 = @intFromEnum(TopFile.root);
+fn recentLen(p: *Pardes) u64 {
+ var buf: [64]u8 = undefined;
+ var counting: std.Io.Writer.Discarding = .init(&buf);
+ pardes.panes.Output.recentText(p, &counting.writer, false) catch return 0;
+ return counting.fullCount();
+}
+
/// Declaration order is readdir order.
pub const PaneFile = enum(u5) {
dir = 0,
@@ -556,6 +566,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
},
.ctl => ctl.rootLen(p),
.commands => ctl.commandsLen(),
+ .recent => recentLen(p),
.focus => focus: {
if (p.header_focus) break :focus 0;
const pn = p.panes[p.active] orelse break :focus 0;
@@ -653,7 +664,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.tagexec, TopFile.pane, TopFile.col }) |t| {
+ inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.recent, 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");
@@ -917,7 +928,11 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
last = serial;
const id = p.paneBySerial(serial).?;
const pn = p.panes[id].?;
- out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn), pane.columnOf(p, id) }) catch {};
+ // A name as /log shows it: one line of UTF-8, a
+ // newline in it `\n` (events.shown).
+ var name_buf: [4 * 4096]u8 = undefined;
+ const name = events.shown(pane.nameOf(pn), &name_buf);
+ out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), name, pane.columnOf(p, id) }) catch {};
}
break :index stagedReply(p, req);
},
@@ -929,6 +944,12 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
.focus => ctl.readFocus(p, req),
.ctl => ctl.readRoot(p, req),
.commands => ctl.readCommands(p, req),
+ .recent => recent: {
+ var text: std.Io.Writer.Allocating = .init(p.scratch.allocator());
+ pardes.panes.Output.recentText(p, &text.writer, false) catch break :recent Reply.fail(req.tag, E.NOMEM);
+ p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch break :recent Reply.fail(req.tag, E.NOMEM);
+ break :recent stagedReply(p, req);
+ },
.layout => layout: {
var text: std.Io.Writer.Allocating = .init(p.scratch.allocator());
cols.writeLayout(p, &text.writer) catch break :layout Reply.fail(req.tag, E.NOMEM);
@@ -1007,7 +1028,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
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);
+ if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control);
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) {
@@ -1197,13 +1218,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", "tagexec", "pane", "col", "os" }, 0..) |name, i| {
+ for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "recent", "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 >= 14, top[i].dir);
+ try testing.expectEqual(i >= 15, top[i].dir);
}
- 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);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[15].node);
+ try testing.expectEqual(@intFromEnum(TopFile.col), top[16].node);
+ try testing.expectEqual(fs.os_root, top[17].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);
@@ -1466,6 +1487,9 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
const looked = th.wr(p, look_node, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir}));
try testing.expect(looked.errno() != 0);
try testing.expect(std.mem.indexOf(u8, looked.reply.ename, said) != null);
+ // Its err record names the file once: `look: no space...`.
+ try testing.expect(th.logHas(p, "look: no space"));
+ try testing.expect(!th.logHas(p, "look: look:"));
try testing.expectEqualStrings("", th.rd(p, look_node, 0, 64).bytes);
// New from a pane's ctl fails as well.
const ctl_node = Node.of(p.panes[0].?.serial, .ctl);
diff --git a/src/panes.zig b/src/panes.zig
index 7aea5898..32ad7b6b 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -106,6 +106,16 @@ pub const Pane = struct {
cols: u16,
rows: u16,
greet: bool = false,
+ /// Its shell could not be started (Pardes.shellFailed): a run hears
+ /// `error shell gone` at once.
+ shell_failed: bool = false,
+ /// Its shell has printed something, so it has made its chdir: until
+ /// then /proc says this process's directory, not the shell's
+ /// (Pardes.setCwd keeps the one it was started in).
+ shell_spoke: bool = false,
+ /// A shell has run in it (Pardes.acknowledgeShell): a Tty whose first
+ /// shell fails to start closes, one started again keeps its pane.
+ had_shell: bool = false,
pending_command: Terminal.PendingCommand = .{},
/// The last command line pardes typed into this terminal (an exec, a
/// middle click, a pty/run): its first word, which Kill matches, and
diff --git a/src/pardes.zig b/src/pardes.zig
index d6385e6f..0950d13a 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -14,6 +14,7 @@ const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
pub const tagline = @import("tagline.zig");
pub const exec = @import("exec.zig");
+const recent_files = @import("recent.zig");
pub const mouse = @import("mouse.zig");
pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
@@ -447,11 +448,17 @@ test "a compact tagline anchors at its own pane, and both shells step from the s
try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w));
}
+const recent_path_cap = limits.host_path_cap;
+
pub const Loc = struct {
pane: u16,
+ /// 0 once its pane has closed: a file's entry stays, reopened by a
+ /// jump to it from `path_hash`'s recent entry.
serial: u32,
line: u32,
col: u32,
+ /// The file it is in (Wyhash of the path), 0 for a pane with none.
+ path_hash: u64 = 0,
};
test {
@@ -474,6 +481,7 @@ test {
_ = @import("draw.zig");
_ = @import("tag_layer.zig");
_ = @import("dump.zig"); // its tests ran nowhere before
+ _ = @import("recent.zig");
_ = @import("Presentation.zig");
_ = @import("animation.zig");
}
@@ -2321,6 +2329,9 @@ test "owned cwd Save promotion releases the former directory" {
const pane = p.panes[id].?;
try pane.setOwnedCwd("/old/directory");
exec.saveTo(p, id, "saved.txt");
+ // Named once the write is done (a failed one changes nothing).
+ try std.testing.expect(pane.file.?.output != null);
+ while (p.nextEffect()) |effect| p.perform(effect);
try std.testing.expect(pane.cwd == .none);
try std.testing.expect(pane.file.?.output == null);
try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path);
@@ -2348,16 +2359,16 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file
// typing the filename and submitting converts it into an ordinary file
edit.insertKey(p, &np.input, .{ .cp = 'n', .text = "note.txt" });
exec.submitSave(p, id);
- try std.testing.expect(np.file.?.output == null);
- try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path);
+ // The write asked for, the pane is the file once it is done.
var saved = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .save_file => |sf| if (@as(usize, sf.pane) == id) {
- saved = true;
- },
- else => {},
- };
+ while (p.nextEffect()) |effect| {
+ if (effect == .save_text and @as(usize, effect.save_text.pane) == id) saved = true;
+ p.perform(effect);
+ }
try std.testing.expect(saved);
+ try std.testing.expect(np.file.?.output == null);
+ try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path);
+ try std.testing.expectEqual(np.file.?.revision, np.file.?.saved_revision);
}
test "Save on a terminal writes its plaintext scrollback and stays a terminal" {
@@ -3640,7 +3651,7 @@ pub const Effect = union(enum) {
/// write this pane's file content to its path; the shell reads both off
/// the core (content is unbounded, effects are fixed-size values)
save_file: struct { pane: u8 },
- save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap) },
+ save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap), promote: bool = false },
write_dump,
set_clipboard,
read_clipboard,
@@ -4229,6 +4240,10 @@ pub const Options = struct {
ninep_quic: ?[]const u8 = null,
mounts: []const filesystem.Mount = &.{},
config_dir: ?[]const u8 = null,
+ /// Where the list of files opened lately is kept across sessions
+ /// (recent.zig), set by the host that has a state directory; none, it
+ /// lives as long as the session.
+ recent_path: ?[]const u8 = null,
startup_config_path: ?[]const u8 = null,
image_allocator: ?std.mem.Allocator = null,
pdf_allocator: ?std.mem.Allocator = null,
@@ -4295,6 +4310,12 @@ pub const Pardes = struct {
presentation: Presentation = .{},
active: usize = 0,
+ /// The files opened lately, closed ones too (recent.zig): `Recent`,
+ /// /recent, and the jumplist's way back to a closed pane.
+ recent: recent_files.List = .{},
+ /// The kept list was read (or there was none): only then is it written,
+ /// so a session never writes over one it did not read.
+ recent_loaded: bool = false,
jumps: [MAX_JUMPS]Loc = undefined,
njumps: usize = 0,
jcur: usize = 0,
@@ -4347,7 +4368,9 @@ pub const Pardes = struct {
/// scroll, G6): the core then keeps the continuous path running, so the
/// shell draws every loop and a virtual clock moves on, as for a lift.
shell_continuous: bool = false,
- cursor_view: struct { serial: u32 = 0, line: i64 = 0 } = .{},
+ cursor_view: struct { serial: u32 = 0, line: i64 = 0, origin: [2]f32 = .{ 0, 0 }, inside: bool = true } = .{},
+ /// The cursor glides from one pane to another: it may cross the grid.
+ cursor_crossing: bool = false,
input_ns: u64 = 0,
animate_theme_changes: bool = false,
native_images: bool = false,
@@ -4621,6 +4644,7 @@ pub const Pardes = struct {
},
}
p.sync();
+ p.loadRecent();
exec.applyStartupConfig(p);
colors.finishThemeInitialization(p);
p.sync();
@@ -4641,7 +4665,58 @@ pub const Pardes = struct {
p.col_panes[0][0] = 0;
}
+ /// The recent list's own allocator: it is kept best effort (a file not
+ /// remembered for want of memory is no failure of what opened it), so
+ /// on a host it takes from the process heap, not the editor's, whose
+ /// every failure must be answered.
+ // ponytail: a host heap for a best-effort list; the board's gpa there.
+ pub fn recentGpa(p: *const Pardes) std.mem.Allocator {
+ return if (comptime hosted) std.heap.smp_allocator else p.gpa;
+ }
+
+ /// The kept list of files opened lately, read once at start.
+ fn loadRecent(p: *Pardes) void {
+ const path = p.opts.recent_path orelse return;
+ if (comptime !hosted) return;
+ defer p.recent_loaded = true;
+ const bytes = filesystem.readFile(p.gpa, path) catch return;
+ defer p.gpa.free(bytes);
+ p.recent.read(p.recentGpa(), bytes);
+ }
+
+ /// Written out when it changed, at the end of a step and at the end.
+ pub fn saveRecent(p: *Pardes) void {
+ if (!p.recent.dirty) return;
+ p.recent.dirty = false;
+ const path = p.opts.recent_path orelse return;
+ if (comptime !hosted) return;
+ if (!p.recent_loaded) return;
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ defer out.deinit();
+ p.recent.write(&out.writer) catch return;
+ turn.yield();
+ defer turn.back();
+ std.Io.Dir.cwd().writeFile(std.Io.Threaded.global_single_threaded.io(), .{ .sub_path = path, .data = out.written() }) catch {};
+ }
+
+ /// A file pane's path and dot, for the recent list (0 when none).
+ fn recentDot(pane: *const Pane) ?struct { path: []const u8, line: u32, col: u32 } {
+ const f = pane.file orelse return null;
+ if (f.output != null) return null;
+ return .{ .path = f.path, .line = @intCast(@max(0, pane.body.cur_row) + 1), .col = @intCast(@max(0, pane.body.cur_col) + 1) };
+ }
+
+ /// The recent entry a closed jump names, by its path's hash.
+ pub fn recentByHash(p: *const Pardes, hash: u64) ?recent_files.Entry {
+ for (p.recent.entries.items) |e| if (std.hash.Wyhash.hash(0, e.path) == hash) return e;
+ return null;
+ }
+
pub fn deinit(p: *Pardes) void {
+ // Every file open now is closed with its dot, and the list kept.
+ for (p.panes) |slot| if (slot) |pane| if (recentDot(pane)) |d| p.recent.closed(p.recentGpa(), d.path, d.line, d.col);
+ p.saveRecent();
+ p.recent.deinit(p.recentGpa());
look.cancelLookHover(p);
if (p.repl_ask) |ask| p.gpa.free(ask.text);
for (0..MAX_PANES) |id| p.dropPendingWrite(id);
@@ -4750,6 +4825,9 @@ pub const Pardes = struct {
}
fn retirePane(p: *Pardes, pane: *Pane) void {
+ // Closed, a file is remembered with its dot: Recent and a jump back
+ // to it reopen it there.
+ if (recentDot(pane)) |d| p.recent.closed(p.recentGpa(), d.path, d.line, d.col);
const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf();
for (p.panes, 0..) |slot, id| if (slot == pane) {
if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
@@ -4813,6 +4891,10 @@ pub const Pardes = struct {
std.debug.assert(p.panes[id] == null);
if (cwd.len > limits.host_path_cap) return error.PathTooLong;
const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
+ // Named by the directory it is started in from the first, so the
+ // shell saying it is there is no rename (`new N /` then `rename`);
+ // out of memory for it, the shell names it when it says.
+ if (cwd.len > 0) pane.setOwnedCwd(cwd) catch {};
panes.Terminal.armShellSpawn(pane);
p.installPane(id, pane);
p.emitSpawn(id, pane.serial, cwd);
@@ -4822,6 +4904,9 @@ pub const Pardes = struct {
/// 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 {
+ // A new shell has not said anything yet (setCwd's guard), whatever
+ // the one it replaces said.
+ if (p.panes[id]) |pane| pane.shell_spoke = false;
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;
@@ -4995,6 +5080,11 @@ pub const Pardes = struct {
else => "",
};
if (std.mem.eql(u8, cur, cwd)) return;
+ // A shell started in a directory is asked where it is before it has
+ // said anything: its chdir may not have happened yet, and the answer
+ // is this process's directory, a rename to undo. That answer, then,
+ // is not taken.
+ if (pane.isTerminal() and !pane.shell_spoke and pane.cwd == .owned and std.mem.eql(u8, cwd, p.sessionDir())) return;
pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err);
if (look.lookHoverPane(p) == id) look.cancelLookHover(p);
// A terminal is named by its directory, so /log hears it renamed; one
@@ -5010,6 +5100,8 @@ pub const Pardes = struct {
pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void {
if (id < MAX_PANES) if (p.panes[id]) |pane| {
ctlfs.pty.shellGone(p, pane, true); // a respawn replaced whatever ran
+ pane.shell_failed = false;
+ pane.had_shell = true;
pane.fs.unmarked = !prompt_marks;
// The tag's Tty word names the shell the host ran, and a click
// on it (`Tty+fish`) opens another of the same.
@@ -5210,13 +5302,73 @@ pub const Pardes = struct {
/// A write of `path` the host could not do: the pane stays dirty, the
/// message row says `Save <path>: <why>`, and the 9P write that asked
/// for it, waiting on the save, fails with that (`fs.late_failure`).
+ /// A scratch written to `path` is that file from here on: named, an
+ /// ordinary file pane, clean, and watched (exec.saveTo).
+ fn promoteSaved(p: *Pardes, id: u8, path: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ const owned = p.gpa.dupe(u8, path) catch return;
+ p.gpa.free(f.path);
+ f.path = owned;
+ f.output = null;
+ pane.clearCwd();
+ // re-derive the tag as a plain file's
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = null;
+ f.saved_revision = f.revision;
+ f.disk_gone = false;
+ ctlfs.events.noteLog(p, .save, pane);
+ p.recent.opened(p.recentGpa(), f.path);
+ if (filesystem.localPath(f.path) != null)
+ p.emit(.{ .watch = .{ .pane = id, .on = true } });
+ }
+
+ /// A terminal whose shell could not be started: said, and a run waiting
+ /// on it, or written to it from now on, hears `error shell gone`
+ /// rather than wait for a prompt that never comes.
+ pub fn shellFailed(p: *Pardes, id: u8, err: anyerror) void {
+ const pane = p.panes[id] orelse return;
+ pane.shell_failed = true;
+ ctlfs.pty.shellGone(p, pane, false);
+ if (err == error.FileNotFound or err == error.NotDir)
+ p.reportFailure(id, "shell: directory missing (no such directory)")
+ else
+ p.reportError(id, "shell", err);
+ // A 9P write that asked for the shell (pty/ctl's exec), waiting on
+ // it, fails with what was said, as a failed Save's does.
+ const said = pane.msg[0..pane.msg_len];
+ 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);
+ // A command that never started ended: `exit 127`, as a shell says of
+ // a command it could not run, so a follower waiting on its exit
+ // hears one, its tag stops saying it runs, and Kill finds nothing.
+ if (pane.command != null and !pane.command_done) {
+ pane.command_pty = false;
+ exec.commandDone(p, id, 127);
+ } else if (pane.command == null and !pane.had_shell) {
+ // A Tty whose shell never ran: no dead pane left behind; the
+ // write that asked for it has failed with why. A terminal whose
+ // shell was started again (pty/ctl exec) keeps its pane.
+ p.unplaced.set(id);
+ }
+ }
+
pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
if (p.panes[id]) |pane| if (pane.file) |*f| {
// The `-%` spelling fs.zig already uses for "make this dirty".
f.saved_revision = f.revision -% 1;
};
- var what: [limits.host_path_cap + 8]u8 = undefined;
- p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err);
+ var what: [limits.host_path_cap + 32]u8 = undefined;
+ // A write into a directory that is not there: said so, in the words
+ // a mount reads as ENOENT.
+ const parent = std.fs.path.dirname(path) orelse "/";
+ const no_dir = err == error.FileNotFound or err == error.NotDir or
+ (if (comptime hosted) (if (filesystem.localPath(parent)) |local| !exec.isDirectory(local) else false) else false);
+ if (no_dir)
+ p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: no such directory", .{path}) catch "Save: no such directory")
+ else
+ p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err);
const said = if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else "Save failed";
const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
@memcpy(p.fs.late_failure[0..kept.len], kept);
@@ -5229,7 +5381,14 @@ pub const Pardes = struct {
pub fn dumpFailed(p: *Pardes, path: []const u8, err: anyerror) void {
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);
+ // Into a DumpDir that is not there: said so, ENOENT, as a Save is.
+ const parent = std.fs.path.dirname(path) orelse "/";
+ const no_dir = err == error.FileNotFound or err == error.NotDir or
+ (if (comptime hosted) (if (filesystem.localPath(parent)) |local| !exec.isDirectory(local) else false) else false);
+ if (no_dir) {
+ var said: [limits.host_path_cap + 48]u8 = undefined;
+ p.reportFailure(p.active, std.fmt.bufPrint(&said, "{s}: no such directory", .{operation}) catch "Dump: no such directory");
+ } else p.reportError(p.active, operation, err);
// 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";
@@ -5283,13 +5442,21 @@ pub const Pardes = struct {
const pane = p.panes[sf.pane] orelse return;
const f = if (pane.file) |*file| file else return;
const serial = pane.serial;
+ const was = f.saved_revision;
+ const failures = p.fs.failures;
f.saved_revision = f.revision;
p.hostWriteFile(sf.pane, f.path, f.content);
const saved_pane = p.panes[sf.pane] orelse return;
if (saved_pane.serial != serial) return;
const saved = if (saved_pane.file) |*file| file else return;
// A save the host could not do (saveFailed) is its err
- // record alone, not a `save`.
+ // record alone, not a `save`, and changes nothing: a clean
+ // file stays clean.
+ if (p.fs.failures != failures) {
+ saved.saved_revision = was;
+ return;
+ }
+ // Edited while it was written: what is on disk is not this.
if (saved.saved_revision != saved.revision) return;
saved.disk_gone = false;
ctlfs.events.noteLog(p, .save, saved_pane);
@@ -5301,7 +5468,21 @@ pub const Pardes = struct {
.save_text => |st| {
const pane = p.panes[st.pane] orelse return;
if (pane.serial != st.serial) return; // a recycled slot: not ours
- if (pane.file) |f| return p.hostWriteFile(st.pane, st.path.slice(), f.content);
+ if (pane.file) |*f| {
+ const was = f.saved_revision;
+ const failures = p.fs.failures;
+ p.hostWriteFile(st.pane, st.path.slice(), f.content);
+ const after = p.panes[st.pane] orelse return;
+ if (after.serial != st.serial) return;
+ const g = if (after.file) |*file| file else return;
+ // Failed, it changes nothing: not the name, not dirty.
+ if (p.fs.failures != failures) {
+ g.saved_revision = was;
+ return;
+ }
+ if (st.promote) p.promoteSaved(st.pane, st.path.slice());
+ return;
+ }
if (!pane.isTerminal()) return;
const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return;
defer p.gpa.free(text);
@@ -5503,6 +5684,7 @@ pub const Pardes = struct {
},
.output => |o| {
const pane = p.panes[o.pane] orelse return;
+ pane.shell_spoke = true;
ctlfs.events.notePtyOutput(p, o.pane, o.bytes);
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
@@ -6010,6 +6192,8 @@ pub const Pardes = struct {
// ctl file: the write fails instead (src/ninep/ctl.zig).
if (p.fs.no_prompt) {
p.fs.refused = true;
+ // A failure like any other: the builtin's announcement goes.
+ p.fs.failures +%= 1;
return;
}
// Asked for over 9P (Save from an exec on a terminal): a question
@@ -6457,9 +6641,15 @@ pub const Pardes = struct {
var cur: usize = 0;
for (p.jumps[0..p.njumps], 0..) |j, i| {
if (i == p.jcur) cur = w; // survives -> lands at w; dies -> the next one does
- const pane = p.panes[j.pane] orelse continue;
- if (pane.serial != j.serial) continue;
- p.jumps[w] = j;
+ var kept = j;
+ const live = if (j.serial == 0) false else if (p.panes[j.pane]) |pane| pane.serial == j.serial else false;
+ // A file's pane closed: its entry stays, closed, while the recent
+ // list knows the file, and a jump to it opens it again.
+ if (!live) {
+ if (j.path_hash == 0 or p.recentByHash(j.path_hash) == null) continue;
+ kept.serial = 0;
+ }
+ p.jumps[w] = kept;
w += 1;
}
p.njumps = w;
@@ -6471,6 +6661,7 @@ pub const Pardes = struct {
.serial = pane.serial,
.line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.body.cur_pinned) @intCast(@max(0, pane.body.cur_row) + 1) else 0,
.col = if (pane.pdfPage() != null) 0 else if (pane.body.cur_pinned) @intCast(@max(0, pane.body.cur_col) + 1) else 0,
+ .path_hash = if (recentDot(pane)) |d| std.hash.Wyhash.hash(0, d.path) else 0,
};
if (p.njumps > 0 and p.samePlace(p.jumps[p.jcur], now)) {
p.jumps[p.jcur] = now; // same visit; the entry IS where you are
@@ -6499,6 +6690,21 @@ pub const Pardes = struct {
if (p.njumps == 0 or next < 0 or next >= p.njumps) return;
const j = p.jumps[@intCast(next)];
if (j.pane >= MAX_PANES) return;
+ // A closed file's entry: opened again at its dot, placed as a look
+ // places it, and the entry is that pane from here on.
+ if (j.serial == 0) {
+ const e = p.recentByHash(j.path_hash) orelse return;
+ var where: [recent_path_cap + 32]u8 = undefined;
+ const loc = std.fmt.bufPrint(&where, "{s}:{d}:{d}", .{ e.path, @max(1, j.line), @max(1, j.col) }) catch return;
+ const from = p.active;
+ look.lookAt(p, from, loc);
+ const opened = p.panes[p.active] orelse return;
+ if (p.active == from) return;
+ p.jumps[@intCast(next)].pane = @intCast(p.active);
+ p.jumps[@intCast(next)].serial = opened.serial;
+ p.jcur = @intCast(next);
+ return;
+ }
const pane = p.panes[j.pane] orelse return;
if (pane.serial != j.serial) return;
if (p.panes[p.active]) |source| look.clearNavigationSelection(p, source);
@@ -6525,10 +6731,18 @@ pub const Pardes = struct {
}
pub fn sync(p: *Pardes) void {
+ var refused = false;
while (p.unplaced.findFirstSet()) |id| {
p.unplaced.unset(id);
if (p.panes[id] != null) p.removePane(id, null) catch {};
+ refused = true;
}
+ // A refused pane gone, no pane it squeezed stays under its minimum.
+ if (refused) for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) {
+ _ = layout.shareColumn(p, c);
+ };
+ // A file opened or closed this step: the kept list follows.
+ p.saveRecent();
tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
@@ -6631,11 +6845,19 @@ pub const Pardes = struct {
if (pane.file) |*file| file.syntax_dirty = true;
}
panes.Terminal.releasePendingCommandIfReady(p, id, pane);
- if (pane.greet and pane.isTerminal() and p.resize_count > 0 and panes.Terminal.promptInputReady(pane)) {
- p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } });
- p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } });
- pane.greet = false;
- }
+ // The greeting `ls` of a new terminal in a directory is typed at
+ // its shell's FIRST prompt, or never: typed later it would land
+ // in whatever runs then, or in the middle of a line someone
+ // typed (panes.Terminal.greeting).
+ if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.Terminal.greeting(pane)) {
+ .wait => {},
+ .never => pane.greet = false,
+ .now => {
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } });
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } });
+ pane.greet = false;
+ },
+ };
}
}
diff --git a/src/recent.zig b/src/recent.zig
new file mode 100644
index 00000000..b6a1a66a
--- /dev/null
+++ b/src/recent.zig
@@ -0,0 +1,119 @@
+//! The files opened lately, most recent first, those since closed too, as
+//! `Recent` lists them and a jump to a closed pane reopens them: a path
+//! and where the dot was when it closed. Kept across sessions in a small
+//! file under $XDG_STATE_HOME/pardes (Options.recent_path) when the host
+//! gives one. acme has nothing like it (its dump and Load are the nearest):
+//! a file closed by accident, its path forgotten, is found here again.
+
+const std = @import("std");
+
+/// At most this many files are remembered, the oldest dropped first.
+pub const cap = 200;
+
+pub const Entry = struct {
+ path: []u8,
+ /// Where its dot was when it last closed (1-based; 0 none).
+ line: u32 = 0,
+ col: u32 = 0,
+};
+
+pub const List = struct {
+ entries: std.ArrayList(Entry) = .empty,
+ /// Changed since it was last written out.
+ dirty: bool = false,
+
+ pub fn deinit(l: *List, gpa: std.mem.Allocator) void {
+ for (l.entries.items) |e| gpa.free(e.path);
+ l.entries.deinit(gpa);
+ }
+
+ fn find(l: *const List, path: []const u8) ?usize {
+ for (l.entries.items, 0..) |e, i| if (std.mem.eql(u8, e.path, path)) return i;
+ return null;
+ }
+
+ /// `path` opened: first in the list, once, keeping the dot it closed at.
+ pub fn opened(l: *List, gpa: std.mem.Allocator, path: []const u8) void {
+ if (path.len == 0) return;
+ var entry: Entry = if (l.find(path)) |i| l.entries.orderedRemove(i) else .{
+ .path = gpa.dupe(u8, path) catch return,
+ };
+ if (l.entries.items.len >= cap) {
+ const last = l.entries.pop().?;
+ gpa.free(last.path);
+ }
+ l.entries.insert(gpa, 0, entry) catch {
+ gpa.free(entry.path);
+ entry = undefined;
+ return;
+ };
+ l.dirty = true;
+ }
+
+ /// `path` closed with its dot at `line`:`col`: where a reopen lands.
+ /// Its place in the list is its opening's.
+ pub fn closed(l: *List, gpa: std.mem.Allocator, path: []const u8, line: u32, col: u32) void {
+ const i = l.find(path) orelse found: {
+ l.opened(gpa, path);
+ // Out of memory for it: nothing to note the dot on.
+ break :found l.find(path) orelse return;
+ };
+ l.entries.items[i].line = line;
+ l.entries.items[i].col = col;
+ l.dirty = true;
+ }
+
+ pub fn get(l: *const List, path: []const u8) ?Entry {
+ return l.entries.items[l.find(path) orelse return null];
+ }
+
+ /// One line a file, `<line> <col> <path>`, most recent first.
+ pub fn write(l: *const List, w: *std.Io.Writer) !void {
+ for (l.entries.items) |e| try w.print("{d} {d} {s}\n", .{ e.line, e.col, e.path });
+ }
+
+ /// The lines `write` wrote; a line that is not one is passed over.
+ pub fn read(l: *List, gpa: std.mem.Allocator, bytes: []const u8) void {
+ var lines = std.mem.splitScalar(u8, bytes, '\n');
+ while (lines.next()) |line| {
+ if (l.entries.items.len >= cap) break;
+ var it = std.mem.splitScalar(u8, line, ' ');
+ const ln = std.fmt.parseInt(u32, it.next() orelse continue, 10) catch continue;
+ const col = std.fmt.parseInt(u32, it.next() orelse continue, 10) catch continue;
+ const path = it.rest();
+ if (path.len == 0 or path[0] != '/' or l.find(path) != null) continue;
+ const owned = gpa.dupe(u8, path) catch return;
+ l.entries.append(gpa, .{ .path = owned, .line = ln, .col = col }) catch {
+ gpa.free(owned);
+ return;
+ };
+ }
+ }
+};
+
+test "the list keeps a path once, most recent first, its dot from closing, capped" {
+ const gpa = std.testing.allocator;
+ var l: List = .{};
+ defer l.deinit(gpa);
+ l.opened(gpa, "/a");
+ l.opened(gpa, "/b");
+ l.closed(gpa, "/a", 7, 3);
+ l.opened(gpa, "/a");
+ try std.testing.expectEqualStrings("/a", l.entries.items[0].path);
+ try std.testing.expectEqual(@as(usize, 2), l.entries.items.len);
+ try std.testing.expectEqual(@as(u32, 7), l.get("/a").?.line);
+ var buf: [256]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ try l.write(&w);
+ try std.testing.expectEqualStrings("7 3 /a\n0 0 /b\n", w.buffered());
+ var again: List = .{};
+ defer again.deinit(gpa);
+ again.read(gpa, w.buffered());
+ again.read(gpa, "junk\n");
+ try std.testing.expectEqual(@as(usize, 2), again.entries.items.len);
+ for (0..cap + 5) |i| {
+ var name: [16]u8 = undefined;
+ l.opened(gpa, std.fmt.bufPrint(&name, "/f{d}", .{i}) catch unreachable);
+ }
+ try std.testing.expectEqual(@as(usize, cap), l.entries.items.len);
+}
diff --git a/src/tagline.zig b/src/tagline.zig
index d024be4e..5f95b2a0 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -112,6 +112,17 @@ pub fn revealHeader(p: *Pardes) void {
}
}
+/// A terminal's directory as its tag shows it: one line, as /index shows
+/// it (a newline `\n`), never a raw newline breaking the tag.
+fn shownDir(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ const dir = pane.cwdSlice();
+ if (std.mem.indexOfAny(u8, dir, "\n\\\x7f") == null and for (dir) |c| {
+ if (c < ' ') break false;
+ } else true) return dir;
+ const out = try arena.alloc(u8, dir.len * 4 + 4);
+ return pardes.ctlfs.events.shown(dir, out);
+}
+
/// The computed start of a pane's tag -- its path and dirty marker, a
/// PDF's page, a terminal's directory -- without the space that parts it
/// from the tag's own text. It is never stored, and never edited.
@@ -133,19 +144,19 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
var idbuf: [48]u8 = undefined;
const id = exec.replId(&idbuf, pane);
const gap = if (id.len > 0) " " else "";
- if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id });
- if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ pane.cwdSlice(), shown, more, n, gap, id });
- return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id });
+ if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running{s}{s}", .{ (try shownDir(arena, pane)), shown, more, gap, id });
+ if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ (try shownDir(arena, pane)), shown, more, n, gap, id });
+ return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ (try shownDir(arena, pane)), shown, more, gap, id });
}
// A bound REPL says what it is bound as, beside its Tty word.
if (pane.repl != null) {
var idbuf: [48]u8 = undefined;
- return std.fmt.allocPrint(arena, "{s} {s}", .{ pane.cwdSlice(), exec.replId(&idbuf, pane) });
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ (try shownDir(arena, pane)), exec.replId(&idbuf, pane) });
}
// Unsaved text shows on the grip, as acme's modbutton does, not as a
// mark in the text: the prefix is the name alone.
if (pane.file) |f| return if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
- return pane.cwdSlice();
+ return (try shownDir(arena, pane));
}
pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index c5491a73..3c8c90bc 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1255,11 +1255,14 @@ const Shell = struct {
const s = of(ctx);
closePty(ctx, pane); // a shell still in the slot goes first, reaped
s.gens[pane] +%= 1;
- const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err);
+ const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.shellFailed(pane, err);
const command = if (s.core.panes[pane]) |pn| pn.command != null else false;
s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} }, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } };
var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined;
- if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
+ // Only a shell given no directory is asked where it is: one given a
+ // directory is named by it already (newShell), and asked now, before
+ // its chdir, it would answer this process's, a rename to undo.
+ if (cwd.len == 0) if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
if (s.threads_ok) {
if (s.ptys[pane]) |*pt| {
pt.reader = s.io.concurrent(readPty, .{ s.io, s.gpa, pt.file, @as(usize, pane), s.gens[pane], s.loop }) catch pt.reader;