summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig9
-rw-r--r--src/Output.zig43
-rw-r--r--src/builtins.zig19
-rw-r--r--src/colors.zig52
-rw-r--r--src/detached/server.zig18
-rw-r--r--src/exec.zig63
-rw-r--r--src/fs.zig7
-rw-r--r--src/gui/gui.zig14
-rw-r--r--src/host_io.zig49
-rw-r--r--src/look.zig59
-rw-r--r--src/macos.zig8
-rw-r--r--src/ninep/cols.zig11
-rw-r--r--src/ninep/ctl.zig269
-rw-r--r--src/ninep/events.zig76
-rw-r--r--src/ninep/pane.zig33
-rw-r--r--src/ninep/pty.zig8
-rw-r--r--src/ninep/tree.zig4
-rw-r--r--src/pardes.zig99
-rw-r--r--src/recent.zig77
-rw-r--r--src/tty/tty.zig10
20 files changed, 744 insertions, 184 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index ce7a2a8c..aebaff0c 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -503,9 +503,6 @@ pub fn clip(text: []const u8, max: usize) []const u8 {
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
p.fs.failures +%= 1;
- // A failure said later, when the host could not do the work (a Save's
- // write): that builtin's announcement goes from the log too.
- ctlfs.events.dropFailedAnnouncement(p, text, false);
// A builtin a ctl write runs: its first error is also the write's, cut
// between words.
const failing_write = p.fs.no_prompt or p.fs.capturing;
@@ -520,11 +517,9 @@ pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
p.fs.failure_len = @intCast(n);
}
// The write fails with it, and its err record says it: no msg for it,
- // and no Verbose announcement before that, so the same failure again is
- // the same record again, counted (events.pushCounting).
+ // so the same failure again is the same record again, counted
+ // (events.pushCounting).
if (failing_write) {
- const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
- ctlfs.events.dropAnnouncement(p, serial, text);
p.fs.unlogged = true;
defer p.fs.unlogged = false;
return setMessage(p, id, text);
diff --git a/src/Output.zig b/src/Output.zig
index 3b8e6a7a..1461757f 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -418,6 +418,16 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
/// 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.
+/// Whether `dir` has its Find/Grep +Search open.
+pub fn hasFileSearch(p: *Pardes, dir: []const u8) bool {
+ for (p.panes) |slot| {
+ const f = (slot orelse continue).file orelse continue;
+ const o = f.output orelse continue;
+ if (fileSearch(o.from) and std.mem.eql(u8, std.fs.path.dirname(f.path) orelse "", dir)) return true;
+ }
+ return false;
+}
+
fn fileSearch(from: Origin) bool {
return switch (from) {
.cmd => |c| c == .Find or c == .Grep,
@@ -436,7 +446,9 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
const by_arg = std.meta.activeTag(from) != .query;
if (p.locations_config.context == 0 and !p.locations_config.tscontext) {
for (p.panes, 0..) |slot, i| {
- if (i == id) continue;
+ // A directory's +Search is found by its directory, whatever
+ // pane ran the search: that +Search itself too.
+ if (i == id and !fileSearch(from)) continue;
const rp = slot orelse continue;
const rf = if (rp.file) |*f| f else continue;
const output = if (rf.output) |*o| o else continue;
@@ -446,6 +458,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
if (!locations.sameInput(content, rf.content, rf.location_rows, p.locations_config.layout)) continue;
try setArg(output, arg);
p.gpa.free(content);
+ p.fs.refilled = rp.serial;
p.active = id;
if (traits(from).steps) {
pane.search_pane = i;
@@ -463,11 +476,11 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
anchor = formatted.anchor;
}
for (p.panes, 0..) |slot, i| {
- if (i == id) continue;
const rp = slot orelse continue;
const rf = if (rp.file) |*f| f else continue;
const o = if (rf.output) |*x| x else continue;
const shared = fileSearch(from) and fileSearch(o.from);
+ if (i == id and !shared) continue;
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;
@@ -555,13 +568,22 @@ pub fn recentText(p: *Pardes, w: *std.Io.Writer, look_rows: bool) !void {
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
+ const state: []const u8 = if (is_open) "open" else "(closed)";
+ if (!look_rows) {
+ // A name as /index shows it: one line, a newline in it `\n`.
+ var name_buf: [4 * 4096]u8 = undefined;
+ try w.print("{s} {s}\n", .{ if (is_open) "open" else "closed", pardes.ctlfs.events.shown(e.path, &name_buf) });
+ } else if (e.line == 0) {
+ try w.print("{s} {s}\n", .{ e.path, state });
+ } else if (e.aline != 0 and (e.aline != e.line or e.acol != e.col)) {
+ // A selection: both its ends, in order, `path:L:C-L2:C2`.
+ const a_first = e.aline < e.line or (e.aline == e.line and e.acol < e.col);
+ const s = if (a_first) [2]u32{ e.aline, e.acol } else [2]u32{ e.line, e.col };
+ const t = if (a_first) [2]u32{ e.line, e.col } else [2]u32{ e.aline, e.acol };
+ try w.print("{s}:{d}:{d}-{d}:{d} {s}\n", .{ e.path, s[0], s[1], t[0], t[1], state });
+ } else {
try w.print("{s}:{d}:{d} {s}\n", .{ e.path, e.line, e.col, state });
+ }
}
}
@@ -647,7 +669,10 @@ pub fn openFonts(p: *Pardes, id: usize) !void {
fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
errdefer p.gpa.free(content);
const pane = p.panes[id] orelse return error.MissingPane;
- const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const own = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ // A terminal whose shell has not said where it is yet (an init file's
+ // word at boot): the session's directory, not `/`.
+ const dir = if (own.len == 0) p.sessionDir() else own;
const free = p.freeSlot() orelse return error.NoPaneSlots;
_ = try open(p, free, dir, from, "", content);
exec.placeNew(p, id, id, free, .doc);
diff --git a/src/builtins.zig b/src/builtins.zig
index 9025da6d..b0499dc0 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -56,6 +56,22 @@ fn isEnabled(comptime T: type) bool {
return !@hasDecl(T, "enabled") or T.enabled;
}
+/// Why `word`, a builtin some build has, is none in this one: `GUI-only`
+/// for the GUI's own (`gui_only`), else `not in this build`; null for a
+/// word that is one here, or in no build.
+pub fn absentWhy(name: []const u8) ?[]const u8 {
+ @setEvalBranchQuota(20000);
+ inline for (@typeInfo(@This()).@"struct".decls) |d| {
+ if (comptime @TypeOf(@field(@This(), d.name)) == type) {
+ const T = @field(@This(), d.name);
+ if (comptime @typeInfo(T) == .@"struct" and @hasDecl(T, "run") and !isEnabled(T)) {
+ if (std.mem.eql(u8, d.name, name)) return if (@hasDecl(T, "gui_only")) "GUI-only" else "not in this build";
+ }
+ }
+ }
+ return null;
+}
+
fn manualBuiltinCount() comptime_int {
comptime {
var count = 0;
@@ -814,6 +830,7 @@ 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;
+ pub const gui_only = true;
pub fn run(c: Ctx) void {
if (comptime enabled) apply(c) else unreachable;
}
@@ -1319,6 +1336,7 @@ pub const EffectCode = struct {
pub const takes_arg = true;
pub const requires_arg = true;
pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders;
+ pub const gui_only = true;
pub const output: OutputTraits = .{ .name = config.effect_code_buffer };
pub fn run(c: Ctx) void {
if (comptime enabled)
@@ -1335,6 +1353,7 @@ pub const Pet = struct {
pub const takes_arg = true;
pub const requires_arg = true;
pub const enabled = pardes.platform == .gui;
+ pub const gui_only = true;
pub fn run(c: Ctx) void {
const name = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
c.p.settings.pet = std.meta.stringToEnum(@TypeOf(c.p.settings.pet), name) orelse return;
diff --git a/src/colors.zig b/src/colors.zig
index da074765..aa69fe6d 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -711,19 +711,63 @@ 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 (Themes) are too many
-/// for a message or a refused write.
+/// Themes near `name`, a line of them, for a Theme given a name there is
+/// none of (all of them, Themes, are too many for a refused write): every
+/// one sharing its first letter when they fit in `buf`, else the 8 nearest.
pub fn themesNear(buf: []u8, name: []const u8) []const u8 {
var w = std.Io.Writer.fixed(buf);
for (themes) |t| {
if (name.len > 0 and std.ascii.toLower(t.name[0]) != std.ascii.toLower(name[0])) continue;
- if (w.buffered().len + t.name.len + 1 > buf.len) break;
w.print("{s}{s}", .{ if (w.buffered().len > 0) " " else "", t.name }) catch break;
+ } else if (w.buffered().len > 0) return w.buffered();
+ // The nearest, most alike first: ponytail, a plain score (shared
+ // prefix, the name inside, its letters in order), no edit distance.
+ var best: [8]usize = undefined;
+ var scores: [8]usize = undefined;
+ var n: usize = 0;
+ for (themes, 0..) |t, i| {
+ const s = likeness(t.name, name);
+ var at = n;
+ while (at > 0 and scores[at - 1] < s) at -= 1;
+ if (at == best.len) continue;
+ const last = @min(n, best.len - 1);
+ var k = last;
+ while (k > at) : (k -= 1) {
+ best[k] = best[k - 1];
+ scores[k] = scores[k - 1];
+ }
+ best[at] = i;
+ scores[at] = s;
+ n = @min(n + 1, best.len);
}
+ w = std.Io.Writer.fixed(buf);
+ for (best[0..n]) |i| w.print("{s}{s}", .{ if (w.buffered().len > 0) " " else "", themes[i].name }) catch break;
return w.buffered();
}
+fn likeness(theme_name: []const u8, name: []const u8) usize {
+ var prefix: usize = 0;
+ while (prefix < @min(theme_name.len, name.len) and std.ascii.toLower(theme_name[prefix]) == std.ascii.toLower(name[prefix])) prefix += 1;
+ // Its letters found in order, one not there passed over.
+ var in_order: usize = 0;
+ var pos: usize = 0;
+ for (name) |c| {
+ const at = for (theme_name[pos..], pos..) |t, i| {
+ if (std.ascii.toLower(t) == std.ascii.toLower(c)) break i;
+ } else continue;
+ in_order += 1;
+ pos = at + 1;
+ }
+ const inside: usize = if (name.len > 0 and std.ascii.indexOfIgnoreCase(theme_name, name) != null) name.len * 4 else 0;
+ return prefix * 3 + in_order + inside;
+}
+
+/// The theme named `name`, whatever its case.
+pub fn themeIndex(name: []const u8) ?usize {
+ for (themes, 0..) |t, i| if (std.ascii.eqlIgnoreCase(t.name, name)) return i;
+ return null;
+}
+
pub fn setThemeIndex(p: *Pardes, index: usize) void {
if (index >= themes.len or
(p.custom_theme == null and p.theme_file_path.get().len == 0 and
diff --git a/src/detached/server.zig b/src/detached/server.zig
index a08a0342..676b5b95 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -485,9 +485,11 @@ pub const Session = struct {
fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
const s = of(ctx);
if (pane >= s.ptys.len) return; // the core indexes its own panes
- s.closePty(pane);
s.harvest();
- if (s.ptys[pane].pid != 0) return s.core.reportError(pane, "shell", error.ShellClosing);
+ // The new shell starts before a running one goes: one that cannot
+ // start leaves the running one be (pty/ctl's exec).
+ const running = s.ptys[pane].fd >= 0;
+ if (!running and s.ptys[pane].pid != 0) return s.core.reportError(pane, "shell", error.ShellClosing);
for (s.retired_shells) |shell| {
if (shell.pid == 0) break;
} else return s.core.reportError(pane, "shell", error.ShellClosing);
@@ -500,7 +502,17 @@ pub const Session = struct {
s.core.screen_h,
s.core.screen_w,
s.ninep,
- ) catch |err| return s.core.shellFailed(pane, err);
+ ) catch |err| {
+ if (running) return s.core.restartFailed(pane, err);
+ return s.core.shellFailed(pane, err);
+ };
+ s.closePty(pane);
+ s.harvest();
+ if (s.ptys[pane].pid != 0) {
+ _ = libc.close(child.file.handle);
+ host_io.retireShell(child.pid);
+ return s.core.reportError(pane, "shell", error.ShellClosing);
+ }
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);
diff --git a/src/exec.zig b/src/exec.zig
index 1fc38b9c..cf4e01ab 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -395,6 +395,22 @@ pub fn isDirectory(path: []const u8) bool {
return stat.kind == .directory;
}
+/// Whether `path` is a plain file on this host, the turn out while it asks.
+pub fn isFile(path: []const u8) bool {
+ return kindOf(path) == .file;
+}
+
+/// What `path` is on this host, null when it is not there; the turn out
+/// while it asks.
+pub fn kindOf(path: []const u8) ?std.Io.File.Kind {
+ var buf: [4096]u8 = undefined;
+ const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return null;
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return null;
+ return stat.kind;
+}
+
/// 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.
@@ -641,6 +657,14 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi
.sent => |to| return to,
.asked => return null,
};
+ // A builtin or setting this build lacks (Fonts on a terminal) is said,
+ // never run as a shell command.
+ const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t") orelse cmd.len];
+ if (builtins.absentWhy(word) orelse if (config.Runtime.find(word) != null) @as([]const u8, "GUI-only") else null) |why| {
+ var said: [96]u8 = undefined;
+ p.reportFailure(id, std.fmt.bufPrint(&said, "{s} is {s}, invalid here", .{ word, why }) catch "GUI-only, invalid here");
+ return null;
+ }
if (!takesCommandLine(p, id)) return runCommand(p, id, cmd);
panes.Terminal.padOutputBelowEdits(p, id);
panes.Terminal.noteCommand(pane, cmd);
@@ -824,20 +848,29 @@ pub fn applyStartupConfig(p: *Pardes) void {
}
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 });
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24, .startup_config = "ThemeSel\n" });
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);
+ // In the session's directory, not `/`.
+ for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output != null)
+ try std.testing.expectEqualStrings(p.sessionDir(), std.fs.path.dirname(f.path).?);
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);
+ // The panes there at boot are the log's first records, before what the
+ // init file said.
+ const th = @import("ninep/testing.zig");
+ const log = @intFromEnum(pardes.ctlfs.TopFile.log);
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ defer _ = th.call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ const text = th.call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ try std.testing.expectStringStartsWith(text, "new ");
}
test "an init file's retired ColumnTags line is said and ignored" {
@@ -861,23 +894,16 @@ pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?
const pane = p.panes[id] orelse return;
// A builtin says its own name before it runs, so a tagline click is
// legible even when what it did is invisible. Whatever the builtin
- // then says for itself replaces the announcement.
- // Its log record stands only if it does not fail (events.dropFailedAnnouncement).
- var announced: ?u64 = null;
+ // then says for itself replaces the announcement. It is the message
+ // row's only, never /log's: a msg record is something said.
if (p.announce and p.settings.verbose and builtins.registry.announces(b)) {
+ p.fs.unlogged = true;
p.setMessage(id, @tagName(b));
+ p.fs.unlogged = false;
pane.msg_announcement = true;
- announced = pardes.ctlfs.events.newestSaying(p, @tagName(b));
- p.fs.announced_seq = announced;
- const word = @tagName(b)[0..@min(@tagName(b).len, p.fs.announced_word.len)];
- @memcpy(p.fs.announced_word[0..word.len], word);
- p.fs.announced_word_len = @intCast(word.len);
}
- const failures = p.fs.failures;
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg, .from_body = from_body };
builtins.registry.dispatch(b, c);
- if (announced != null and p.fs.failures != failures and p.fs.announced_seq == announced)
- pardes.ctlfs.events.dropFailedAnnouncement(p, "", true);
}
pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void {
@@ -888,15 +914,12 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
switch (setting.action) {
.theme => {
const want = std.mem.trim(u8, arg orelse return, " \t\r\n");
- for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) {
- colors.setThemeIndex(p, i);
- return;
- };
+ if (colors.themeIndex(want)) |i| return colors.setThemeIndex(p, i);
// A config line is not answered on screen (applyStartupConfig
// runs with announce off); a click or a ctl write is.
if (!p.announce) return;
- var near: [96]u8 = undefined;
- var text: [200]u8 = undefined;
+ var near: [170]u8 = undefined;
+ var text: [256]u8 = undefined;
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 => {
diff --git a/src/fs.zig b/src/fs.zig
index 6feaa972..5d7d68e8 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1308,11 +1308,6 @@ pub const Namespace = struct {
header_held: bool = false,
late_failure: [256]u8 = undefined,
late_failure_len: u16 = 0,
- /// The log record of the last Verbose announcement (`msg <serial>
- /// <Word>`) and its word: a failure of that builtin, now or when its
- /// host work fails (a Save, a Dump), takes it back out of the log, so
- /// an announcement stands only for a builtin that did what it said.
- announced_seq: ?u64 = null,
/// The serial of the pane the root's exec or look last ran a line at
/// (the keyboard's), 0 before one: its err record names that pane.
ran_at: u32 = 0,
@@ -1322,8 +1317,6 @@ pub const Namespace = struct {
/// Every failure said (Messages.reportFailure), counted: a builtin
/// that said one while it ran failed.
failures: u32 = 0,
- announced_word: [32]u8 = undefined,
- announced_word_len: u8 = 0,
/// A refusal that quotes the message it refuses, as Plan 9's cmderror
/// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
ename: [320]u8 = undefined,
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index ac4443ab..d935b5fd 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4551,10 +4551,20 @@ fn gridPostPresent(ctx: ?*anyopaque) void {
fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
const s = shellOf(ctx);
s.reap();
+ // The new shell starts before a running one goes: one that cannot
+ // start leaves the running one be (pty/ctl's exec).
+ const running = s.ptys[pane] != null;
+ 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| {
+ if (running) return s.core.restartFailed(pane, err);
+ return s.core.shellFailed(pane, err);
+ };
s.closePty(pane);
- if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy);
+ if (s.ptys[pane] != null) {
+ _ = libc.close(child.file.handle);
+ host_io.retireShell(child.pid);
+ 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.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;
diff --git a/src/host_io.zig b/src/host_io.zig
index 913930ac..85ba94f4 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -861,7 +861,13 @@ pub const Shell = struct {
/// null when it is one.
pub fn refusal(bin: []const u8, said: []u8) ?[]const u8 {
var buf: [std.fs.max_path_bytes]u8 = undefined;
- if (find(bin, &buf) != null) return null;
+ if (find(bin, &buf)) |found| {
+ // A script whose `#!` names a program that is not there: its
+ // exec fails ENOENT as a missing shell's does, so told apart.
+ var interp: [256]u8 = undefined;
+ const missing = missingInterpreter(found, &interp) orelse return null;
+ return std.fmt.bufPrint(said, "interpreter {s} not found", .{missing}) catch "interpreter not found";
+ }
if (std.mem.indexOfScalar(u8, bin, '/') != null and bin.len < buf.len) {
@memcpy(buf[0..bin.len], bin);
buf[bin.len] = 0;
@@ -871,6 +877,43 @@ pub const Shell = struct {
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";
}
+ /// The program a script's `#!` line names, when it is not there to
+ /// run; null for a script whose interpreter is there, or no script.
+ pub fn missingInterpreter(path: [*:0]const u8, out: []u8) ?[]const u8 {
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0));
+ if (fd < 0) return null;
+ defer _ = libc.close(fd);
+ var head: [256]u8 = undefined;
+ const n = libc.read(fd, &head, head.len);
+ if (n < 2) return null;
+ const got = head[0..@intCast(n)];
+ if (!std.mem.startsWith(u8, got, "#!")) return null;
+ const line = got[2 .. std.mem.indexOfScalar(u8, got, '\n') orelse got.len];
+ var words = std.mem.tokenizeAny(u8, line, " \t\r");
+ const interp = words.next() orelse return null;
+ if (interp.len + 1 > out.len) return null;
+ @memcpy(out[0..interp.len], interp);
+ out[interp.len] = 0;
+ if (libc.access(@ptrCast(out.ptr), X_OK) == 0) return null;
+ return out[0..interp.len];
+ }
+
+ test "a script whose #! names a program not there is told apart from a missing shell" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "bad", .data = "#!/no/such/interp -x\n", .flags = .{ .permissions = .executable_file } });
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "good", .data = "#!/bin/sh\n", .flags = .{ .permissions = .executable_file } });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ var path: [4200]u8 = undefined;
+ var said: [320]u8 = undefined;
+ try std.testing.expectEqualStrings("interpreter /no/such/interp not found", refusal(try std.fmt.bufPrint(&path, "{s}/bad", .{dir}), &said).?);
+ try std.testing.expect(refusal(try std.fmt.bufPrint(&path, "{s}/good", .{dir}), &said) == null);
+ try std.testing.expect(std.mem.startsWith(u8, refusal(try std.fmt.bufPrint(&path, "{s}/none", .{dir}), &said).?, "no shell "));
+ }
+
fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 {
for (fallbacks) |f| {
@memcpy(buf[0..f.len], f);
@@ -1203,7 +1246,9 @@ pub fn forkShell(
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
+ // Itself, or its script's interpreter: told apart by whether it
+ // is there.
+ .NOENT => if (libc.access(spawn.path, 0) == 0) error.InterpreterNotFound else error.ShellNotFound,
.ACCES, .PERM => error.ShellNotExecutable,
.NOEXEC => error.ShellNotExecutable,
else => error.ShellDidNotStart,
diff --git a/src/look.zig b/src/look.zig
index ca9aa80a..236f0687 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -667,7 +667,10 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
nrows += 1;
}
}
- if (!open_empty and nrows == 0) return 0;
+ // A Find or Grep that finds nothing empties its directory's +Search, if
+ // there is one, rather than leave the last search's rows looking like
+ // this one's.
+ if (!open_empty and nrows == 0 and (kind == .text or !panes.Output.hasFileSearch(p, dir))) 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
@@ -1381,8 +1384,52 @@ fn spotAt(text: []const u8, q: u32) struct { line: usize, col: usize } {
return .{ .line = line, .col = upto.len - start + 1 };
}
+/// A remembered place in `path` (Recent, the jumplist): the file opened or
+/// focused, then `at` clamped to it as it is now, a line past the end its
+/// last line, never a miss; a selection's two ends restored where they
+/// still fit.
+pub fn reopenAt(p: *Pardes, from: usize, path: []const u8, at: Spot) void {
+ lookAt(p, from, path);
+ const id = p.active;
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ if (!std.mem.eql(u8, f.path, path)) return;
+ const n = @max(1, panes.File.nlines(p.gpa, f));
+ const clamp = struct {
+ fn spot(text: []const u8, lines: usize, line: usize, col: usize) [2]usize {
+ const l = std.math.clamp(line, 1, lines);
+ const width = modal.lineSlice(text, l - 1).len + 1;
+ return .{ l, std.math.clamp(col, 1, width) };
+ }
+ };
+ const head = clamp.spot(f.content, n, at.line, at.col);
+ var to: Spot = .{ .line = head[0], .col = head[1] };
+ if (at.end_line != 0) {
+ const end = clamp.spot(f.content, n, at.end_line, at.end_col);
+ to.end_line = end[0];
+ to.end_col = end[1];
+ }
+ clearNavigationSelection(p, pane);
+ focusPaneLine(p, id, to, .center);
+}
+
+/// Whether `pane` is the +Recent list, whose rows are remembered places.
+fn isRecentList(pane: *const Pane) bool {
+ const f = pane.file orelse return false;
+ const o = f.output orelse return false;
+ return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent });
+}
+
pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const pane = p.panes[id] orelse return;
+ // A +Recent row is a remembered place: reopened and clamped to the file
+ // as it is now, never a miss for a line the file no longer has.
+ if (isRecentList(pane)) {
+ const row = std.mem.trim(u8, operand, " \t\r\n");
+ const loc = row[0 .. std.mem.indexOfScalar(u8, row, ' ') orelse row.len];
+ const pl = parsePathLine(loc);
+ if (pl.path.len > 0 and pl.path[0] == '/' and pl.at.line != 0) return reopenAt(p, id, pl.path, pl.at);
+ }
p.noteHaptic(.look);
noteLookSource(p, id);
const command = std.mem.trim(u8, operand, " \t\r\n");
@@ -1423,12 +1470,16 @@ 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.
+ // A `./` or `../` name opened nothing: said, ENOENT, not searched
+ // for as a word. What is missing is the pane's directory when
+ // that is gone, else the file named.
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");
+ const gone = if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| !exec.isDirectory(local) else false else true;
+ if (gone) return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory");
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such file", .{missText(trimmed, &cut)}) catch "look: no such file");
}
// A raw terminal's clicks are its program's; a look written over
// 9P is no keyboard input, and searches whatever the key mode.
diff --git a/src/macos.zig b/src/macos.zig
index 9b17803b..e839816c 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -2437,11 +2437,17 @@ fn hostState(ctx: ?*anyopaque) *State {
fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
const st = hostState(ctx);
const core = st.core;
+ // The new shell starts before a running one goes: one that cannot
+ // start leaves the running one be (pty/ctl's exec).
+ const running = st.ptys[pane] != null;
+ const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd, core.screen_h, core.screen_w, st.ninep) catch |err| {
+ if (running) return core.restartFailed(pane, err);
+ return core.shellFailed(pane, err);
+ };
reap(st, pane);
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.shellFailed(pane, err);
st.ptys[pane] = .{
.file = child.file,
.pid = child.pid,
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index aae6d364..6b59f9e5 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -401,19 +401,18 @@ 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);
- // 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"));
+ // Its announcement is the message row's, never logged; 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.
+ // A column's exec makes a pane there and reads it back.
const right = layout.columnSerial(p, 1);
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "New\n").reply.status);
const made = p.panes[p.col_panes[1][0]].?.serial;
var want: [16]u8 = undefined;
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes);
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes);
- try testing.expect(th.logHas(p, "msg - New\n"));
+ try testing.expect(!th.logHas(p, "msg - New\n")); // announced on the message row only
}
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 477a91fe..c2a78fb8 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -388,11 +388,11 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const b = std.meta.stringToEnum(Builtin, word) orelse {
// A setting this build's frontend cannot show (Lift, GripWidth on a
// terminal) is known, and says so rather than sound misspelt.
- if (config.Runtime.find(word) != null) {
+ if (builtins.absentWhy(word) orelse if (config.Runtime.find(word) != null) @as([]const u8, "GUI-only") else null) |absent| {
var why: [64]u8 = undefined;
// 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, std.fmt.bufPrint(&why, "{s} is {s}, invalid here", .{ word, absent }) catch "GUI-only, invalid here", line);
}
return refuse(p, req, "unknown control message", line);
};
@@ -408,13 +408,15 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line);
const setting = config.Runtime.find(word) orelse return null;
const takes = switch (setting.action) {
- .theme => for (pardes.themes) |t| {
- if (std.mem.eql(u8, t.name, arg)) break true;
- } else {
- // `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}; Themes lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
+ .theme => if (pardes.colors.themeIndex(arg) != null) true else {
+ // `refuse` quotes the line after it, all in the 128 bytes an
+ // Rerror carries: the names get what is left.
+ const head = "bad value in control message; like it: ";
+ const tail = "; Themes lists all";
+ var near: [128]u8 = undefined;
+ const room = @import("cloud9").fs.errmax -| (head.len + tail.len + line.len + 3);
+ var why: [256]u8 = undefined;
+ return refuse(p, req, std.fmt.bufPrint(&why, head ++ "{s}" ++ tail, .{pardes.colors.themesNear(near[0..@min(room, near.len)], arg)}) catch "bad value in control message", line);
},
.font => config.Runtime.FontSpec.parse(arg) != null,
else => probe: {
@@ -864,6 +866,8 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
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.
+ // And logged `unsaved <serial> <name>`, as the other refusals are.
+ tree.events.noteLog(p, .unsaved, pane);
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;
@@ -1465,6 +1469,8 @@ test "get reloads the pane from disk and reports a missing file without losing d
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)"));
+ var unsaved: [4200]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&unsaved, "unsaved {d} {s}/missing.txt\n", .{ serial, directory })));
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);
@@ -1610,12 +1616,34 @@ test "Joincol with no column to the right and Theme with no such theme say so" {
const themed = wr(p, root_ctl, "Theme drak\n");
try testing.expectEqual(E.INVAL, themed.errno());
try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: ");
- try testing.expect(std.mem.indexOf(u8, themed.reply.ename, " dark") != null or std.mem.indexOf(u8, themed.reply.ename, ": dark") != null);
+ try testing.expect(std.mem.indexOf(u8, themed.reply.ename, ": dr") != null);
try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\""));
// A click on the word says it on the message row.
_ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n");
const pane = p.panes[p.active].?;
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null);
+ // Within the 128 bytes an Rerror carries, the quoted line kept.
+ try testing.expect(themed.reply.ename.len <= 128);
+ // A name in any case is the theme.
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Theme DRACULA\n").reply.status);
+ try testing.expectEqualStrings("dracula", pardes.themes[p.settings.theme].name);
+ // A misspelling far from any first letter's list still finds its like.
+ var near: [170]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, pardes.colors.themesNear(&near, "9dracula"), "dracula") != null);
+ // Every theme sharing the first letter, when they fit.
+ var tested = false;
+ for ("abcdefghijklmnopqrstuvwxyz") |letter| {
+ var count: usize = 0;
+ var len: usize = 0;
+ for (pardes.themes) |t| if (t.name[0] == letter) {
+ count += 1;
+ len += t.name.len + 1;
+ };
+ if (count < 2 or len > near.len) continue;
+ try testing.expectEqual(count, std.mem.count(u8, pardes.colors.themesNear(&near, &.{letter}), " ") + 1);
+ tested = true;
+ }
+ try testing.expect(tested);
}
test "a look after Newcol fills the empty column the keyboard is on" {
@@ -1663,6 +1691,10 @@ 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: no such directory", p.fs.late_failure[0..p.fs.late_failure_len]);
+ // A DumpDir that is there but no directory says that.
+ p.fs.late_failure_len = 0;
+ p.dumpFailed("/dev/null/pardes.dump.zon", error.NotDir);
+ try testing.expectEqualStrings("Dump /dev/null/pardes.dump.zon: /dev/null is not a 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" {
@@ -1672,6 +1704,14 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back
if (!builtins.capabilities.lift) {
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);
+ // A builtin the GUI alone has, written to a ctl or an exec, never
+ // runs as a shell command.
+ if (comptime !pardes.font_picker) {
+ try testing.expectEqualStrings("Fonts is GUI-only, invalid here \"Fonts\"", wr(p, root_ctl, "Fonts\n").reply.ename);
+ const ran = wr(p, root_exec, "Fonts\n");
+ try testing.expectEqual(E.IO, ran.errno());
+ try testing.expectEqualStrings("Fonts is GUI-only, invalid here", ran.reply.ename);
+ }
}
try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename);
var buf: [900]u8 = undefined;
@@ -1860,6 +1900,18 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir
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);
+ // The same Find again reads back the +Search, not the pane it ran in.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes);
+ // A Grep run from the +Search itself fills that one, never a second.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(found.serial, .exec), "Grep bravo\n").reply.status);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes);
+ var searches: usize = 0;
+ for (p.panes) |slot| if (slot) |q| if (q.file) |f| if (f.output != null) {
+ searches += @intFromBool(std.mem.endsWith(u8, f.path, "/+Search"));
+ };
+ try testing.expectEqual(@as(usize, 1), searches);
+ 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");
@@ -1874,6 +1926,8 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir
var after: usize = 0;
for (p.panes) |slot| after += @intFromBool(slot != null);
try testing.expectEqual(count, after);
+ // The +Search the miss would have filled is emptied, not left stale.
+ try testing.expectEqualStrings("", found.file.?.content);
}
test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" {
@@ -1982,6 +2036,49 @@ test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot
try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]);
}
+test "a script whose interpreter is not there: Tty refuses it up front, only an err logged, and pty/ctl exec keeps the running shell" {
+ 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 = .{};
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad", .data = "#!/no/such/interp\n", .flags = .{ .permissions = .executable_file } });
+ 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 serial = serialOf(p);
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const refused = wr(p, Node.of(serial, .ctl), try std.fmt.bufPrint(&line, "Tty {s}/bad\n", .{dir}));
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectEqualStrings("Tty: interpreter /no/such/interp not found", refused.reply.ename);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // The log gained the err and nothing else: no new, msg or del.
+ try testing.expect(th.logHas(p, "Tty: interpreter /no/such/interp not found\n"));
+ try testing.expect(!th.logHas(p, "\nmsg "));
+ try testing.expect(!th.logHas(p, "\ndel "));
+ // The shell to start again is that script: refused before the running
+ // one goes.
+ const id = p.paneBySerial(serial).?;
+ p.panes[id].?.shell = try p.gpa.dupe(u8, try std.fmt.bufPrint(&line, "{s}/bad", .{dir}));
+ p.setCwd(id, dir);
+ const exec_refused = wr(p, Node.of(serial, .pty_ctl), "exec\n");
+ try testing.expectEqual(E.NOENT, exec_refused.errno());
+ try testing.expectEqualStrings("exec: interpreter /no/such/interp not found", exec_refused.reply.ename);
+ // One the host found could not start (the script changed after): said
+ // with the interpreter's name, and the running shell is not gone.
+ p.fs.late_failure_len = 0;
+ p.restartFailed(@intCast(id), error.InterpreterNotFound);
+ try testing.expectEqualStrings("shell: interpreter /no/such/interp not found", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expect(!p.panes[id].?.shell_failed);
+}
+
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();
@@ -2022,6 +2119,26 @@ test "/index shows a name as the log does: a newline in it is \\n, controls spac
try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, "/tmp/back\\\\nslash") != null);
}
+test "a look at a ./ or ../ name that is not there names the file, ENOENT; a plain one is looked for as text" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(testing.io, "many");
+ 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}/many/a.txt\n", .{dir}));
+ for ([_][]const u8{ "./zz.txt", "../zz.txt" }) |name| {
+ const r = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}\n", .{name}));
+ try testing.expectEqual(E.NOENT, r.errno());
+ var want: [64]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "look: {s}: no such file", .{name}), r.reply.ename);
+ }
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .look), "zz.txt\n").reply.status);
+ try testing.expect(th.logHas(p, "look: no match for \"zz.txt\""));
+}
+
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();
@@ -2141,8 +2258,8 @@ test "a closed file is found again: Recent and /recent list it closed, a look at
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);
+ 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);
@@ -2163,6 +2280,134 @@ test "a closed file is found again: Recent and /recent list it closed, a look at
try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path);
}
+test "Back and Forward over a closed file's entries: one reopen is that file's pane in every entry, so the next step moves" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var long: [400]u8 = undefined;
+ var n: usize = 0;
+ for (0..100) |_| {
+ @memcpy(long[n..][0..4], "row\n");
+ n += 4;
+ }
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = long[0..n] });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "sea\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, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir}));
+ p.sync();
+ const b_serial = p.panes[p.active].?.serial;
+ // Two entries for a.txt, far apart in it.
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:1\n", .{dir}));
+ p.sync();
+ const a = p.active;
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:90\n", .{dir}));
+ p.sync();
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt\n", .{dir}));
+ p.sync();
+ const c_serial = p.panes[p.active].?.serial;
+ _ = wr(p, Node.of(p.panes[a].?.serial, .ctl), "Del\n");
+ p.sync();
+ var closed: usize = 0;
+ for (p.jumps[0..p.njumps]) |j| closed += @intFromBool(j.serial == 0);
+ try testing.expectEqual(@as(usize, 2), closed);
+ // Back reopens it at line 90: both its entries are that pane now.
+ p.jumpBy(-1);
+ p.sync();
+ const re = p.panes[p.active].?;
+ try testing.expect(std.mem.endsWith(u8, re.file.?.path, "/a.txt"));
+ try testing.expectEqual(@as(i32, 89), re.body.cur_row);
+ for (p.jumps[0..p.njumps]) |j| try testing.expect(j.serial != 0);
+ // Back again goes to its line 1, in the same pane, then to b.
+ p.jumpBy(-1);
+ p.sync();
+ try testing.expectEqual(re.serial, p.panes[p.active].?.serial);
+ try testing.expectEqual(@as(i32, 0), re.body.cur_row);
+ p.jumpBy(-1);
+ p.sync();
+ try testing.expectEqual(b_serial, p.panes[p.active].?.serial);
+ // And Forward walks it all again, to c.
+ p.jumpBy(1);
+ p.sync();
+ p.jumpBy(1);
+ p.sync();
+ try testing.expectEqual(@as(i32, 89), re.body.cur_row);
+ p.jumpBy(1);
+ p.sync();
+ try testing.expectEqual(c_serial, p.panes[p.active].?.serial);
+}
+
+test "Recent keeps only files on disk: not a name never saved, nor the served tree's" {
+ 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 = "real.txt", .data = "real\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 names = [_][]const u8{ try std.fmt.allocPrint(testing.allocator, "{s}/c", .{dir}), try testing.allocator.dupe(u8, "/virtual/zz"), try std.fmt.allocPrint(testing.allocator, "{s}/real.txt", .{dir}) };
+ defer for (names) |n| testing.allocator.free(n);
+ for (names) |name| {
+ const made = try th.newPane(p);
+ _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&line, "{s}\n", .{name}));
+ p.sync();
+ _ = wr(p, Node.of(made, .ctl), "Del\n");
+ _ = wr(p, Node.of(made, .ctl), "Del\n");
+ p.sync();
+ }
+ try testing.expect(p.recent.get(names[0]) == null);
+ try testing.expect(p.recent.get(names[1]) == null);
+ try testing.expect(p.recent.get(names[2]) != null);
+}
+
+test "/recent shows a name as /index does, a newline in it escaped" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.recent.opened(p.recentGpa(), "/tmp/a\nb\\c");
+ const text = rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes;
+ try testing.expect(std.mem.indexOf(u8, text, "closed /tmp/a\\nb\\\\c\n") != null);
+}
+
+test "a remembered place is clamped to the file as it is now, its selection's two ends kept where they fit" {
+ 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 = "c.txt", .data = "one\ntwo\nthree\nfour\nfive\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, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt:2\n", .{dir}));
+ p.sync();
+ const c = p.panes[p.active].?;
+ // A selection over lines 2 to 4, then closed.
+ @import("pane.zig").setDot(c, .{ .q0 = 4, .q1 = 19 });
+ try testing.expect(c.body.vsel.active);
+ _ = wr(p, Node.of(c.serial, .ctl), "Del\n");
+ p.sync();
+ // The file shrinks to two lines while it is closed.
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\n" });
+ _ = wr(p, root_exec, "Recent\n");
+ 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, "c.txt:").?;
+ const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?];
+ // Both ends are in the row, a range.
+ try testing.expect(std.mem.indexOfScalar(u8, row, '-') != null);
+ var full: [4400]u8 = undefined;
+ const loc = try std.fmt.bufPrint(&full, "{s}/{s}\n", .{ dir, row[0 .. std.mem.indexOfScalar(u8, row, ' ').?] });
+ // A look at it opens the file, never a miss, clamped to its last line.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), loc).reply.status);
+ p.sync();
+ const back = p.panes[p.active].?;
+ try testing.expect(std.mem.endsWith(u8, back.file.?.path, "/c.txt"));
+ try testing.expect(back.body.cur_row <= 1);
+ try testing.expect(back.body.vsel.active);
+}
+
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();
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 690efbe5..add0d750 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -141,10 +141,16 @@ pub fn announce(p: *Pardes) void {
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);
+ if (pane.file) |f| if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path);
}
}
+/// The panes present at boot are the log's first records: something said
+/// before the first update ends (an init file's notice) waits for them.
+fn bootFirst(p: *Pardes) void {
+ if (newest(p) == null) announce(p);
+}
+
/// Records `<kind> <serial> <name>`.
pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
var buf: [4 * 4096 + 64]u8 = undefined;
@@ -161,10 +167,7 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
var buf: [pardes.Messages.LoggedMessage.cap + 32]u8 = undefined;
const kept = pardes.Messages.clip(said, pardes.Messages.LoggedMessage.cap);
const cut = if (kept.len < said.len) "…" else "";
- // A builtin's own word for what it did stands in for its Verbose
- // announcement just before (`Undo` then `Undo: nothing to undo`), so
- // that 1000 Undos with nothing left are one line, counted, as err's are.
- dropAnnouncement(p, serial, kept);
+ bootFirst(p);
pushCounting(p, (if (serial == 0)
std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut })
else
@@ -179,6 +182,7 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
/// `dump <path>` when a Dump is written, `restore <path>` first in a
/// Restore's replacement.
pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void {
+ bootFirst(p);
var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined;
pushLog(p, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ what, path }) catch return);
}
@@ -230,61 +234,6 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
pushCounting(p, record);
}
-/// Drops the newest record when it is the Verbose announcement (`msg
-/// <serial> <Word>`) of the builtin whose words `text` are (`Word: ...`):
-/// what the builtin said, or the err its failure logs, takes its place.
-/// Not one a follower has read.
-pub fn dropAnnouncement(p: *Pardes, serial: u32, text: []const u8) void {
- const last = newest(p) orelse return;
- if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
- const rest = last.text["msg ".len..];
- const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
- const word = rest[@min(sp + 1, rest.len)..];
- var who: [16]u8 = undefined;
- const mine = if (serial == 0) "-" else std.fmt.bufPrint(&who, "{d}", .{serial}) catch "";
- if (std.mem.eql(u8, rest[0..sp], mine) and word.len > 0 and std.mem.indexOfScalar(u8, word, ' ') == null and
- text.len > word.len and std.mem.startsWith(u8, text, word) and text[word.len] == ':')
- p.fs.log.buf.shrinkRetainingCapacity(last.at);
-}
-
-/// The sequence number of the newest log record when it is a msg that
-/// ends in `word` alone: not one counted (`(x2)`) over an earlier say.
-pub fn newestSaying(p: *Pardes, word: []const u8) ?u64 {
- const last = newest(p) orelse return null;
- if (!std.mem.startsWith(u8, last.text, "msg ") or !std.mem.endsWith(u8, last.text, word)) return null;
- if (last.text[last.text.len - word.len - 1] != ' ') return null;
- return last.seq;
-}
-
-/// Takes record `seq` back out of the log, unless a follower has read it.
-pub fn dropRecord(p: *Pardes, seq: u64) void {
- if (followerRead(p, seq)) return;
- const q = &p.fs.log;
- var at = q.head;
- var s = q.dropped;
- while (at + 4 <= q.buf.items.len) : (s += 1) {
- const size = 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
- if (s == seq) {
- const items = q.buf.items;
- std.mem.copyForwards(u8, items[at..], items[at + size ..]);
- q.buf.shrinkRetainingCapacity(items.len - size);
- return;
- }
- at += size;
- }
-}
-
-/// A builtin's failure: its Verbose announcement goes from the log, when
-/// `text` is that builtin's (it starts with its word) or `any`, as for a
-/// failure said while the builtin itself runs.
-pub fn dropFailedAnnouncement(p: *Pardes, text: []const u8, any: bool) void {
- const seq = p.fs.announced_seq orelse return;
- const word = p.fs.announced_word[0..p.fs.announced_word_len];
- if (!any and !(std.mem.startsWith(u8, text, word) and text.len > word.len and (text[word.len] == ' ' or text[word.len] == ':'))) return;
- p.fs.announced_seq = null;
- dropRecord(p, seq);
-}
-
/// Drops the newest record when it is a msg saying `text` (of any pane)
/// that no follower has read: a failure its write's err record will say.
pub fn dropMessage(p: *Pardes, text: []const u8) void {
@@ -1100,13 +1049,10 @@ test "the log records whether or not anyone reads, and an open that follows wait
try std.fmt.bufPrint(&expected, "msg {d} save: two lines\n", .{serial}),
rdf.next(p, log, fh, frozen).bytes,
);
- // A builtin announces itself on the message row, so the log says which ran.
+ // A builtin announces itself on the message row only: the log has
+ // what it did.
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status);
try testing.expectEqualStrings(
- try std.fmt.bufPrint(&expected, "msg {d} Del\n", .{serial}),
- rdf.next(p, log, fh, frozen).bytes,
- );
- try testing.expectEqualStrings(
try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}),
rdf.next(p, log, fh, frozen).bytes,
);
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 4370d401..5ed3f6f4 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -50,9 +50,11 @@ pub const State = struct {
event_reader: bool = false,
events: events.Queue = .{},
nomark: bool = false,
- /// The revision a truncation of `data`, `xdata` or `body` left: the
- /// write that follows it at once joins its undo step, so a shell's
- /// `printf x > data` is one step, as acme's replace is.
+ /// The revision a truncation or a write of `data`, `xdata` or `body`
+ /// left: the write that follows it at once joins its undo step, until
+ /// that open is released (tree.zig releaseHandle). So a shell's
+ /// `printf 'x\ny\n' > data` is one step, as acme's replace is, though
+ /// bash writes it a line at a time; any other edit between breaks it.
joined: ?u32 = null,
noscroll: bool = false,
tag_snap: std.ArrayList(u8) = .empty,
@@ -454,6 +456,7 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const at = bodyOf(pane).len;
const take = spliceBody(p, pane, at, at, req.data) orelse
return Reply.fail(req.tag, E.NOMEM);
+ pane.fs.joined = fileOf(pane).?.revision;
if (!pane.fs.noscroll) showOffset(pane, at + take);
return .{ .tag = req.tag, .written = @intCast(take) };
}
@@ -508,6 +511,7 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply {
const before = dotOf(pane);
const take = spliceBody(p, pane, q0, q1, req.data) orelse
return Reply.fail(req.tag, E.NOMEM);
+ pf.joined = fileOf(pane).?.revision;
setDot(pane, .{
.q0 = shiftOne(before.q0, clip(q0), clip(q1 - q0), clip(take)),
.q1 = shiftOne(before.q1, clip(q0), clip(q1 - q0), clip(take)),
@@ -683,7 +687,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);
+ if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path);
}
/// Diagnostics go to the +Errors buffer of the pane's directory.
@@ -725,6 +729,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
const pf = &pane.fs;
switch (file) {
.body => if (fileOf(pane) != null) {
+ pf.joined = null;
_ = spliceBody(p, pane, 0, bodyOf(pane).len, "") orelse return .err;
pf.joined = fileOf(pane).?.revision;
pf.addr = .{};
@@ -735,6 +740,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
const q0 = pf.addr.q0;
const q1 = @max(q0, pf.addr.q1);
const before = dotOf(pane);
+ pf.joined = null;
_ = spliceBody(p, pane, q0, q1, "") orelse return .err;
pf.joined = fileOf(pane).?.revision;
setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) });
@@ -1030,6 +1036,25 @@ test "the flag files read and write the state acme kept behind ctl verbs" {
try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno());
}
+test "the writes of one open are one undo step, as bash's line-buffered printf makes them; the next open is another" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ for ([_]u64{ Node.of(serial, .data), Node.of(serial, .body), Node.of(serial, .xdata) }) |node| {
+ _ = wr(p, Node.of(serial, .addr), "$");
+ for ([_][]const u8{ "x\n", "y\n", "z\n" }) |piece| try testing.expectEqual(Status.ok, wr(p, node, piece).reply.status);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = node });
+ // A second open: its own step.
+ _ = wr(p, Node.of(serial, .addr), "$");
+ try testing.expectEqual(Status.ok, wr(p, node, "w\n").reply.status);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = node });
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\nx\ny\nz\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+ }
+}
+
test "a replace through data, truncate then write, is one undo step" {
const p = try withFile(testing.allocator, "one two\n");
defer p.deinit();
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index aa0f499a..bf5f432c 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -67,6 +67,14 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
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");
};
+ // Nor is a shell that is not there, or a script whose
+ // interpreter is not: refused, and the running shell kept.
+ if (comptime pardes.hosted) if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| {
+ const bin = pane.shell orelse p.shellBin();
+ var why: [320]u8 = undefined;
+ if (@import("../host_io.zig").Shell.refusal(bin[0..@min(bin.len, 200)], &why)) |refused|
+ return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}", .{refused}) catch "exec: no such shell");
+ };
if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 64089c01..a8b86a96 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -831,6 +831,10 @@ fn releaseHandle(p: *Pardes, req: Req) void {
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};
+ // The writes of one open are one undo step (pane.zig `joined`).
+ if (t == .pane and (t.pane.file == .body or t.pane.file == .data or t.pane.file == .xdata)) if (p.paneBySerial(t.pane.serial)) |id| {
+ p.panes[id].?.fs.joined = null;
+ };
// `: > limit` clears it for good once its open goes.
if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
p.panes[id].?.fs.limit_truncated = null;
diff --git a/src/pardes.zig b/src/pardes.zig
index 0950d13a..f6032ac5 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4700,10 +4700,33 @@ pub const Pardes = struct {
}
/// 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 } {
+ /// Whether `path` is one Recent keeps: a file on this host's disk, not
+ /// the served tree's (`/virtual/`) nor a name never saved.
+ pub fn recentKeeps(path: []const u8) bool {
+ const local = filesystem.localPath(path) orelse return false;
+ if (comptime !hosted) return true;
+ return exec.isFile(local);
+ }
+
+ /// A file pane closing: its place noted in Recent, when Recent keeps it.
+ fn noteClosed(p: *Pardes, pane: *const Pane) void {
+ const d = recentDot(pane) orelse return;
+ if (p.recent.get(d.path) == null and !recentKeeps(d.path)) return;
+ p.recent.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol);
+ }
+
+ fn recentDot(pane: *const Pane) ?struct { path: []const u8, line: u32, col: u32, aline: u32 = 0, acol: u32 = 0 } {
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 selection's anchor too, so a reopen restores both its ends.
+ const sel = pane.body.vsel.active;
+ return .{
+ .path = f.path,
+ .line = @intCast(@max(0, pane.body.cur_row) + 1),
+ .col = @intCast(@max(0, pane.body.cur_col) + 1),
+ .aline = if (sel) @intCast(@max(0, pane.body.vsel.row) + 1) else 0,
+ .acol = if (sel) @intCast(@max(0, pane.body.vsel.col) + 1) else 0,
+ };
}
/// The recent entry a closed jump names, by its path's hash.
@@ -4714,7 +4737,7 @@ pub const Pardes = struct {
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);
+ for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane);
p.saveRecent();
p.recent.deinit(p.recentGpa());
look.cancelLookHover(p);
@@ -4827,7 +4850,7 @@ 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);
+ p.noteClosed(pane);
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 } });
@@ -5330,16 +5353,7 @@ pub const Pardes = struct {
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);
+ p.sayShellFailure(id, err);
// 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.
@@ -5354,6 +5368,36 @@ pub const Pardes = struct {
}
}
+ /// A shell started again in a terminal whose shell runs (pty/ctl's
+ /// exec) did not start: the host kept the running one, so only said.
+ pub fn restartFailed(p: *Pardes, id: u8, err: anyerror) void {
+ if (p.panes[id] == null) return;
+ p.sayShellFailure(id, err);
+ }
+
+ fn sayShellFailure(p: *Pardes, id: u8, err: anyerror) void {
+ const pane = p.panes[id] orelse return;
+ if (err == error.FileNotFound or err == error.NotDir) {
+ p.reportFailure(id, "shell: directory missing (no such directory)");
+ } else if (err == error.InterpreterNotFound) {
+ // The script is there; the program its `#!` names is not.
+ var said: [320]u8 = undefined;
+ var interp: [256]u8 = undefined;
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const Shell = if (comptime hosted) @import("host_io.zig").Shell else void;
+ const name: ?[]const u8 = if (comptime hosted) (if (Shell.find(pane.shell orelse p.shellBin(), &buf)) |f| Shell.missingInterpreter(f, &interp) else null) else null;
+ p.reportFailure(id, if (name) |n| std.fmt.bufPrint(&said, "shell: interpreter {s} not found", .{n}) catch "shell: interpreter not found" else "shell: interpreter not found");
+ } 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);
+ }
+
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".
@@ -5381,11 +5425,15 @@ 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";
- // Into a DumpDir that is not there: said so, ENOENT, as a Save is.
+ // Into a DumpDir that is not there: said so, ENOENT, as a Save is;
+ // one that is there but no directory (`/dev/null`) says that.
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) {
+ const kind: ?std.Io.File.Kind = if (comptime hosted) (if (filesystem.localPath(parent)) |local| exec.kindOf(local) else .directory) else .directory;
+ const no_dir = err == error.FileNotFound or err == error.NotDir or kind != .directory;
+ if (kind != null and kind != .directory) {
+ var said: [limits.host_path_cap + 48]u8 = undefined;
+ p.reportFailure(p.active, std.fmt.bufPrint(&said, "{s}: {s} is not a directory", .{ operation, parent }) catch "Dump: not a directory");
+ } else 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);
@@ -6694,14 +6742,17 @@ pub const Pardes = struct {
// 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);
+ look.reopenAt(p, from, e.path, .{ .line = @max(1, j.line), .col = @max(1, j.col) });
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;
+ const f = opened.file orelse return;
+ if (!std.mem.eql(u8, f.path, e.path)) return; // it did not open
+ // Every entry of that file is the reopened pane from here on, so
+ // a step onto another of them moves in it rather than reopening.
+ for (p.jumps[0..p.njumps]) |*k| if (k.serial == 0 and k.path_hash == j.path_hash) {
+ k.pane = @intCast(p.active);
+ k.serial = opened.serial;
+ };
p.jcur = @intCast(next);
return;
}
diff --git a/src/recent.zig b/src/recent.zig
index b6a1a66a..91c0afeb 100644
--- a/src/recent.zig
+++ b/src/recent.zig
@@ -15,6 +15,13 @@ pub const Entry = struct {
/// Where its dot was when it last closed (1-based; 0 none).
line: u32 = 0,
col: u32 = 0,
+ /// The selection's other end then (its anchor), 0 when there was none.
+ aline: u32 = 0,
+ acol: u32 = 0,
+ /// Open in a pane now (opened, not closed since): never the one the
+ /// cap drops. ponytail: a pane renamed away leaves its old path marked
+ /// open until the session ends; kept rather than tracked by pane.
+ open: bool = false,
};
pub const List = struct {
@@ -38,9 +45,14 @@ pub const List = struct {
var entry: Entry = if (l.find(path)) |i| l.entries.orderedRemove(i) else .{
.path = gpa.dupe(u8, path) catch return,
};
+ entry.open = true;
+ // The oldest closed file goes for it; an open one never does.
if (l.entries.items.len >= cap) {
- const last = l.entries.pop().?;
- gpa.free(last.path);
+ var i = l.entries.items.len;
+ while (i > 0) : (i -= 1) if (!l.entries.items[i - 1].open) {
+ gpa.free(l.entries.orderedRemove(i - 1).path);
+ break;
+ };
}
l.entries.insert(gpa, 0, entry) catch {
gpa.free(entry.path);
@@ -53,6 +65,11 @@ pub const List = struct {
/// `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 {
+ l.closedSel(gpa, path, line, col, 0, 0);
+ }
+
+ /// `closed`, with the selection's anchor too (0 0 for none).
+ pub fn closedSel(l: *List, gpa: std.mem.Allocator, path: []const u8, line: u32, col: u32, aline: u32, acol: u32) void {
const i = l.find(path) orelse found: {
l.opened(gpa, path);
// Out of memory for it: nothing to note the dot on.
@@ -60,6 +77,9 @@ pub const List = struct {
};
l.entries.items[i].line = line;
l.entries.items[i].col = col;
+ l.entries.items[i].aline = aline;
+ l.entries.items[i].acol = acol;
+ l.entries.items[i].open = false;
l.dirty = true;
}
@@ -67,9 +87,12 @@ pub const List = struct {
return l.entries.items[l.find(path) orelse return null];
}
- /// One line a file, `<line> <col> <path>`, most recent first.
+ /// One line a file, `<line> <col> <aline> <acol> <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 });
+ // A name with a newline in it would read back as two lines: not kept.
+ for (l.entries.items) |e| if (std.mem.indexOfScalar(u8, e.path, '\n') == null)
+ try w.print("{d} {d} {d} {d} {s}\n", .{ e.line, e.col, e.aline, e.acol, e.path });
}
/// The lines `write` wrote; a line that is not one is passed over.
@@ -77,13 +100,21 @@ pub const List = struct {
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();
+ // Numbers, then the path (which starts with `/`): two of them in
+ // a list written before the anchor was kept, four since.
+ var nums: [4]u32 = @splat(0);
+ var n: usize = 0;
+ var rest = line;
+ while (rest.len > 0 and rest[0] != '/' and n < nums.len) : (n += 1) {
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse break;
+ nums[n] = std.fmt.parseInt(u32, rest[0..sp], 10) catch break;
+ rest = rest[sp + 1 ..];
+ }
+ if (n != 2 and n != 4) continue;
+ const path = 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 {
+ l.entries.append(gpa, .{ .path = owned, .line = nums[0], .col = nums[1], .aline = nums[2], .acol = nums[3] }) catch {
gpa.free(owned);
return;
};
@@ -105,15 +136,37 @@ test "the list keeps a path once, most recent first, its dot from closing, cappe
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());
+ try std.testing.expectEqualStrings("7 3 0 0 /a\n0 0 0 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);
+ // A list written before the anchor was kept still reads.
+ again.read(gpa, "4 2 /old\n");
+ try std.testing.expectEqual(@as(u32, 4), again.get("/old").?.line);
+ try std.testing.expectEqual(@as(usize, 3), 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);
+ const path = std.fmt.bufPrint(&name, "/f{d}", .{i}) catch unreachable;
+ l.opened(gpa, path);
+ l.closed(gpa, path, 1, 1);
}
try std.testing.expectEqual(@as(usize, cap), l.entries.items.len);
}
+
+test "the cap drops the oldest closed file, never an open one" {
+ const gpa = std.testing.allocator;
+ var l: List = .{};
+ defer l.deinit(gpa);
+ var name: [16]u8 = undefined;
+ // The oldest is open; the next is closed.
+ l.opened(gpa, "/open");
+ l.opened(gpa, "/shut");
+ l.closed(gpa, "/shut", 1, 1);
+ for (2..cap) |i| l.opened(gpa, std.fmt.bufPrint(&name, "/f{d}", .{i}) catch unreachable);
+ try std.testing.expectEqual(@as(usize, cap), l.entries.items.len);
+ l.opened(gpa, "/new");
+ try std.testing.expectEqual(@as(usize, cap), l.entries.items.len);
+ try std.testing.expect(l.get("/open") != null);
+ try std.testing.expect(l.get("/shut") == null);
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 3c8c90bc..a6f6bdfc 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1253,9 +1253,15 @@ const Shell = struct {
fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
const s = of(ctx);
- closePty(ctx, pane); // a shell still in the slot goes first, reaped
+ // The new shell starts before a running one goes: one that cannot
+ // start leaves the running one be (pty/ctl's exec).
+ const running = s.ptys[pane] != null;
+ 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| {
+ if (running) return s.core.restartFailed(pane, err);
+ return s.core.shellFailed(pane, err);
+ };
+ closePty(ctx, pane); // a shell still in the slot goes, 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.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;