diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Messages.zig | 9 | ||||
| -rw-r--r-- | src/Output.zig | 43 | ||||
| -rw-r--r-- | src/builtins.zig | 19 | ||||
| -rw-r--r-- | src/colors.zig | 52 | ||||
| -rw-r--r-- | src/detached/server.zig | 18 | ||||
| -rw-r--r-- | src/exec.zig | 63 | ||||
| -rw-r--r-- | src/fs.zig | 7 | ||||
| -rw-r--r-- | src/gui/gui.zig | 14 | ||||
| -rw-r--r-- | src/host_io.zig | 49 | ||||
| -rw-r--r-- | src/look.zig | 59 | ||||
| -rw-r--r-- | src/macos.zig | 8 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 11 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 269 | ||||
| -rw-r--r-- | src/ninep/events.zig | 76 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 33 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 8 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 4 | ||||
| -rw-r--r-- | src/pardes.zig | 99 | ||||
| -rw-r--r-- | src/recent.zig | 77 | ||||
| -rw-r--r-- | src/tty/tty.zig | 10 |
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 => { @@ -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; |
