diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/9p.zig | 8 | ||||
| -rw-r--r-- | src/9p_io.zig | 22 | ||||
| -rw-r--r-- | src/CHANGELOG.md | 25 | ||||
| -rw-r--r-- | src/File.zig | 4 | ||||
| -rw-r--r-- | src/Output.zig | 25 | ||||
| -rw-r--r-- | src/builtins.zig | 12 | ||||
| -rw-r--r-- | src/config.zig | 62 | ||||
| -rw-r--r-- | src/detached/server.zig | 10 | ||||
| -rw-r--r-- | src/exec.zig | 39 | ||||
| -rw-r--r-- | src/host_io.zig | 4 | ||||
| -rw-r--r-- | src/layout.zig | 20 | ||||
| -rw-r--r-- | src/look.zig | 22 | ||||
| -rw-r--r-- | src/main.zig | 60 | ||||
| -rw-r--r-- | src/ninep/addr.zig | 15 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 70 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 120 | ||||
| -rw-r--r-- | src/ninep/events.zig | 23 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 22 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 34 | ||||
| -rw-r--r-- | src/pardes.zig | 72 | ||||
| -rw-r--r-- | src/regexp.zig | 38 | ||||
| -rw-r--r-- | src/terminal.zig | 9 | ||||
| -rw-r--r-- | src/tty/tty.zig | 36 |
23 files changed, 627 insertions, 125 deletions
@@ -61,7 +61,13 @@ pub const msize: u32 = 65536; /// (src/9p_io.zig, `serve`), and a write it cannot keep would fail instead. /// The largest write is msize less the Twrite header, 23 bytes (the test /// below asks a client), one byte more than `iohdrsz` allows for. -pub const editor: cloud9.fs.Options = .{ .fid_capacity = max_fids, .name_capacity = 255, .park_data_max = msize - 23 }; +/// Up to 128 reads held on one connection (a follower each of the log or +/// an event file); the next is refused `too many reads waiting: 128`. A +/// mount (9ns) keeps a wider window, 256, so held reads never take every +/// one of its workers. +pub const editor: cloud9.fs.Options = .{ .fid_capacity = max_fids, .name_capacity = 255, .park_data_max = msize - 23, .slot_capacity = max_held }; + +pub const max_held: usize = 128; /// The board's engine: the GPIO firmware's budget (src/esp32p4_9p.zig). pub const board: cloud9.fs.Options = .{ .fid_capacity = board_fids, .name_capacity = board_name_capacity }; diff --git a/src/9p_io.zig b/src/9p_io.zig index d1b2099c..0eaa2d7e 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -1542,6 +1542,11 @@ test "a change waits while the editor is out mid-step, a read does not, and the try testing.expectEqualStrings("after\n", pane.file.?.content); } +test "a connection holds up to 128 reads, and the next is refused in words" { + try testing.expectEqual(@as(usize, 128), ninep.max_held); + if (comptime supported) try testing.expectEqualStrings("too many reads waiting: 128", Runner.Engine.e_waiting); +} + test "a held read is answered when the log has news, and a flushed one spends nothing" { if (comptime !supported) return error.SkipZigTest; const gpa = testing.allocator; @@ -1808,7 +1813,17 @@ pub const Client = struct { var sock_buf: [sun_path_len]u8 = undefined; const sock = try resolve(&sock_buf, dial); var remote: RemoteError = .{}; - return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes)); + return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes)) catch |err| { + if (err == error.Remote) said = remote; + return err; + }; + } + + /// The peer's own words for the last error.Remote a read or write here + /// got (its Rerror), for a caller that says why. + var said: RemoteError = .{}; + pub fn lastSaid() []const u8 { + return said.buf[0..said.len]; } /// Whether a peer answers at `dial`: a version and attach, and its root @@ -1828,7 +1843,10 @@ pub const Client = struct { var sock_buf: [sun_path_len]u8 = undefined; const sock = try resolve(&sock_buf, dial); var remote: RemoteError = .{}; - const result = try fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes); + const result = fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes) catch |err| { + if (err == error.Remote) said = remote; + return err; + }; gpa.free(result); } diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 3dd7c996..baff96b0 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.0.8 + +- A wheel over a terminal whose program asked for the mouse goes to the + program, as a report in the format it chose, or as arrow keys on the + alternate screen with alternate scroll: codex, htop, less and vim scroll. +- `pardes FILE` in a pane whose session answers never starts a nested + editor: a refusal is printed and it exits 1. A tty pardes killed by + SIGTERM or SIGHUP puts its terminal back first. +- A 9P connection holds up to 128 waiting reads, and refuses the next in + words; 9ns (cloud9 705be665) no longer deadlocks a mount under many held + reads. +- A backward search along one long line takes time in its length, not its + square. A detached session no client watches draws no frames: 50 MB of + output through `pty/run` takes 11 s, not over 30. +- Save makes its file's missing directories, for its own name and `Save + path` alike. `pane/new` takes rows from another column before refusing. +- With no pane at all, the root `look` opens its file and the root `exec` + runs in the session's directory. A refused `Newcol` logs only its error. +- `Lspwhy` explains the last query in its own file and place; every `+Lsp` + answer shares one pane per directory. +- DumpConfig's information lines start with `# `, and a line starting with + `#` runs as nothing. `winsize` refuses fewer than 2 rows. `look file:3:0` + is refused. `Kill X` with nothing running is a message, not a failure. + An event write-back needs no trailing newline. + ## 0.0.7 - `lapis` draws its cells unblended and each plaque pixel once: about a diff --git a/src/File.zig b/src/File.zig index 43b921e3..84cec879 100644 --- a/src/File.zig +++ b/src/File.zig @@ -61,10 +61,6 @@ pub const State = struct { /// edit to another length cannot be back to it, and is not hashed. saved_len: ?usize = null, watch_after_save: bool = false, - /// Save makes the file's directory, and any above it, first: `Config`'s - /// pane for an init file that is not there yet, whose directory may not - /// be either. - make_dirs: bool = false, /// The file changed on disk under unsaved edits, which were kept (see /// `changed`): its hash, so the same change is said once, until a Save /// or get settles it. A Save warns once before overwriting it, as acme's diff --git a/src/Output.zig b/src/Output.zig index 545637b3..3541fa57 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -164,6 +164,23 @@ test "stacked empty previews distinguish separators from trailing newlines" { try std.testing.expect(!locations.sameInput("x.c:1:1 \n", "x.c:1:1\n", &rows, .stacked)); } +test "Lspinfo and Lspwhy fill one +Lsp in a directory" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("x\n"); + try fillResults(p, 0, "/work", .{ .query = .status }, "", try p.gpa.dupe(u8, "servers\n"), null); + try fillResults(p, 0, "/work", .{ .query = .explain }, "", try p.gpa.dupe(u8, "why\n"), null); + var lsp_panes: usize = 0; + for (p.panes) |slot| { + const f = (slot orelse continue).file orelse continue; + if (std.mem.eql(u8, f.path, "/work/+Lsp")) { + lsp_panes += 1; + try std.testing.expectEqualStrings("why\n", f.content); + } + } + try std.testing.expectEqual(@as(usize, 1), lsp_panes); +} + test "plain output does not opt into location decoration" { const report = rowDecoration(.{ .from = .{ .cmd = .LocationsConfig } }, "LocationsConfig context:5 tscontext:on"); try std.testing.expectEqualDeep(RowDecoration{}, report); @@ -439,6 +456,12 @@ fn fileSearch(from: Origin) bool { return from != .search and std.mem.eql(u8, traits(from).name, config.search_buffer); } +/// A language server's prose (Lspinfo, Lspwhy, a code action list, a +/// format diff): one `+Lsp` a directory holds for all of them. +fn lspProse(from: Origin) bool { + return from == .query and std.mem.eql(u8, traits(from).name, config.lsp_buffer); +} + pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void { var result_content = content; errdefer p.gpa.free(result_content); @@ -483,7 +506,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: [] const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const o = if (rf.output) |*x| x else continue; - const shared = fileSearch(from) and fileSearch(o.from); + const shared = (fileSearch(from) and fileSearch(o.from)) or (lspProse(from) and lspProse(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; diff --git a/src/builtins.zig b/src/builtins.zig index 8fe49649..ba67766d 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -609,11 +609,13 @@ pub const Kill = struct { const names = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); var stopped = false; var jobless = false; + var running = false; for (c.p.panes, 0..) |slot, id| { const pane = slot orelse continue; // A command pane's child is the command: the whole of its // process group goes, as acme's Kill ends the process. if (pane.command) |line| if (!pane.command_done) { + running = true; const said = std.mem.trim(u8, line, " \t"); const first = said[0 .. std.mem.indexOfAny(u8, said, " \t") orelse said.len]; if (names.len > 0) { @@ -627,6 +629,7 @@ pub const Kill = struct { continue; }; if (!panes.terminal.commandRunning(pane)) continue; + running = true; const sent = pane.sent_command.?; if (names.len > 0) { var words = std.mem.tokenizeAny(u8, names, " \t"); @@ -640,9 +643,11 @@ pub const Kill = struct { } if (stopped) return; if (jobless) return c.p.reportFailure(c.id, "Kill: no job to signal (its shell runs it without job control)"); - if (names.len > 0) + // Said, but no failure: a script's Kill, named or not, has nothing + // left to do. Only commands running, none of them the one named, is. + if (names.len > 0 and running) c.p.reportFailure(c.id, "Kill: no running command has that first word") - else // said, but no failure: a script's Kill has nothing left to do + else c.p.setMessage(c.id, "Kill: nothing running"); } }; @@ -1304,9 +1309,8 @@ pub const Config = struct { // Not there yet: a pane named for it, which Save makes. const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Config", error.NoPaneSlots); c.p.newScratchFor(c.id, .scratch); - const pane = c.p.panes[free] orelse return; + if (c.p.panes[free] == null) return; @import("ninep/pane.zig").nameBuffer(c.p, free, path, false) catch |err| return c.p.reportError(c.id, "Config", err); - pane.file.?.make_dirs = true; c.p.active = free; } }; diff --git a/src/config.zig b/src/config.zig index 94b32b88..592281d3 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1386,35 +1386,36 @@ pub const Runtime = struct { }; if (context.locations.len > 0) try out.print("LocationsConfig {s}\n", .{context.locations}); - // In effect, and set by no word. + // In effect, and set by no word: each a `#` line, which runs as + // nothing (exec.executeFrom), so the report can be written back. try out.writeByte('\n'); - try out.print("Last shell spawned: {s}\n", .{shown(state.shell.effective.get())}); - try out.print("Shell change pending: {s}\n", .{onOff(state.shell.pending)}); + try out.print("# Last shell spawned: {s}\n", .{shown(state.shell.effective.get())}); + try out.print("# Shell change pending: {s}\n", .{onOff(state.shell.pending)}); if (find("Font").?.enabled(context.capabilities)) { const f = &state.font; - try out.print("Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) }); - try out.print("Font change pending: {s}\n", .{onOff(f.pending)}); + try out.print("# Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) }); + try out.print("# Font change pending: {s}\n", .{onOff(f.pending)}); } if (find("WindowOpacity").?.enabled(context.capabilities)) - try out.print("Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)}); + try out.print("# Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)}); if (context.capabilities.tagline_font_size and !find("TaglineSize").?.enabled(context.capabilities)) - try out.writeAll("Tagline size: build-time only\n"); + try out.writeAll("# Tagline size: build-time only\n"); if (context.startup_config_path) |path| - try out.print("Startup config: {s}\n", .{path}) + try out.print("# Startup config: {s}\n", .{path}) else - try out.writeAll("Startup config: no per-user config path\n"); + try out.writeAll("# Startup config: no per-user config path\n"); try out.print( - "Platform: {s}\n" ++ - "Default shell ($SHELL, else /bin/sh): {s}\n", + "# Platform: {s}\n" ++ + "# Default shell ($SHELL, else /bin/sh): {s}\n", .{ context.platform, context.compiled_default_shell }, ); if (context.hover_delay_frames) |frames| - try out.print("Look hover delay: {d} frames\n", .{frames}) + try out.print("# Look hover delay: {d} frames\n", .{frames}) else - try out.writeAll("Look hover delay: off\n"); - try out.print("Native images: {s}\n", .{onOff(context.native_images)}); + try out.writeAll("# Look hover delay: off\n"); + try out.print("# Native images: {s}\n", .{onOff(context.native_images)}); if (std.mem.eql(u8, context.platform, "gui")) - try out.print("Pet: {s}\n", .{@tagName(state.pet)}); + try out.print("# Pet: {s}\n", .{@tagName(state.pet)}); } test "setting names are unique" { @@ -1761,15 +1762,15 @@ pub const Runtime = struct { "Theme acme\n", "WindowOpacity 85\n", "Font /fonts/wanted.ttf:18.50\n", - "Font in use: Effective Mono 13.75 points\n", - "Font change pending: on\n", - "Window opacity change pending: off\n", + "# Font in use: Effective Mono 13.75 points\n", + "# Font change pending: on\n", + "# Window opacity change pending: off\n", "TaglineSize 82\n", - "Startup config: /tmp/pardes/init\n", - "Platform: gui\n", - "Default shell ($SHELL, else /bin/sh): /bin/sh\n", - "Look hover delay: 18 frames\n", - "Native images: on\n", + "# Startup config: /tmp/pardes/init\n", + "# Platform: gui\n", + "# Default shell ($SHELL, else /bin/sh): /bin/sh\n", + "# Look hover delay: 18 frames\n", + "# Native images: on\n", }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); var defaults: Runtime = .{}; @@ -1794,22 +1795,25 @@ pub const Runtime = struct { try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities)); try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities)); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell /bin/sh\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Last shell spawned: (none)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell change pending: on\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Last shell spawned: (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Shell change pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Startup config: no per-user config path\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font in use") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "PanelSlide off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Look hover delay: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Native images: off\n") != null); // A setting this shell does not have reads so, as a line to write. try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures unsupported\n") != null); // Every line before the blank one is a ctl word and its value. const words = defaults_report[0..std.mem.indexOf(u8, defaults_report, "\n\n").?]; var lines = std.mem.splitScalar(u8, words, '\n'); while (lines.next()) |line| try std.testing.expect(find(line[0..std.mem.indexOfScalar(u8, line, ' ').?]) != null); + // ...and every one after it a `#` line, which runs as nothing. + var info = std.mem.splitScalar(u8, std.mem.trimEnd(u8, defaults_report[words.len + 2 ..], "\n"), '\n'); + while (info.next()) |line| try std.testing.expect(std.mem.startsWith(u8, line, "# ")); defaults_context.platform = "web"; defaults_context.capabilities.panel_transitions = false; @@ -1819,7 +1823,7 @@ pub const Runtime = struct { const web_report = storage[0..out.end]; try std.testing.expect(std.mem.indexOf(u8, web_report, "PanelSlide unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100\n") != null); - try std.testing.expect(std.mem.indexOf(u8, web_report, "Tagline size: build-time only\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "# Tagline size: build-time only\n") != null); } }; diff --git a/src/detached/server.zig b/src/detached/server.zig index d8379793..001acc47 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -376,6 +376,7 @@ pub const Session = struct { .now = clockNow, .present = present, .poll_frame = pollFrame, + .watched = watched, .spawn = spawn, .pty_write = ptyWrite, .pty_resize = ptyResize, @@ -641,6 +642,12 @@ pub const Session = struct { s.mailbox.signal(); } + fn watched(ctx: ?*anyopaque) bool { + const s = of(ctx); + for (&s.clients) |*c| if (c.attached) return true; + return false; + } + fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); if (s.ninep) |l| s.ninep_pending = l.tick().pending; @@ -1052,6 +1059,9 @@ pub const Session = struct { c.attached = true; c.need_full = true; c.greet = true; + // Unwatched until now, it may have drawn nothing: a frame + // for the one attaching (watched). + s.core.needs_frame = true; }, .bye => s.close(c, .bye), .event => |ev| { diff --git a/src/exec.zig b/src/exec.zig index eed9297a..7d682d74 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -643,12 +643,22 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { /// `execute` for text a gesture took from `id`'s body when `from_body`: /// such text may be a bound REPL's. Run by name (`Exec ...`, whose line /// comes back through `execute`) or written as a command word, never. +/// A line that starts with `#` is a comment, as in a shell: it runs as +/// nothing, silently, wherever a line runs (exec, a ctl, the init file), +/// so DumpConfig's report reads back as configuration whole. +pub fn isComment(line: []const u8) bool { + const t = std.mem.trimStart(u8, line, " \t"); + return t.len > 0 and t[0] == '#'; +} + pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usize { - // An empty window runs its session words, and nothing else: no pane to - // run a command for. + if (isComment(commandText(txt))) return null; + // An empty window runs its session words, and a command line in the + // session's directory (execDir), as its workspace tag would. const pane = p.panes[id] orelse { - _ = executeBuiltinLine(p, id, commandText(txt)); - return null; + const cmd = commandText(txt); + if (cmd.len == 0 or executeBuiltinLine(p, id, cmd)) return null; + return runCommand(p, id, cmd); }; const gesture = from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == null; const cmd = commandText(txt); @@ -699,7 +709,9 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { p.reportFailure(from, "exec: a command line is at most 1024 bytes"); return null; } - const src = p.panes[from] orelse return null; + // None asking (an empty window): the session's directory, no pane of + // its own to reuse. + const src = p.panes[from]; const dir = p.execDir(from); // A directory that is not there runs nothing, as Tty makes nothing // there: no pane, and the write fails ENOENT. @@ -712,7 +724,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { // would hang that job up. // From a column's tag, only a finished pane in that column is reused, // as the new one goes in that column (placeNew). - const reuse: ?usize = if (p.exec_column == null and src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| { + const reuse: ?usize = if (p.exec_column == null and src != null and src.?.command != null and src.?.command_done and !src.?.command_pty) from else for (p.panes, 0..) |slot, i| { const other = slot orelse continue; if (p.exec_column) |c| if (if (layout.findPane(p, i)) |f| f.col != c else true) continue; if (other.command != null and other.command_done and !other.command_pty and std.mem.eql(u8, other.cwdSlice(), dir)) break i; @@ -838,6 +850,7 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint: pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { const raw = commandText(txt); if (raw.len == 0 or p.exec_depth >= max_exec_depth) return false; + if (isComment(raw)) return true; const word_end = std.mem.indexOfAny(u8, raw, " \t") orelse raw.len; var renamed: [1024 + 16]u8 = undefined; const cmd = for (acme_words) |w| { @@ -1032,9 +1045,12 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan // No pane to halve, but the column holds every pane's minimum // with the new one's: the rows are shared out again, so what is // refused is only what the documented arithmetic cannot hold. - // +Errors goes wherever there is room, last column first; with - // none, its maker says the text some other way (appendErrors). - if (layout.shareColumn(p, f.col)) {} else if (kind == .errors) { + // +Errors and 9P's pane/new go wherever there is room, last + // column first, as acme takes rows from a window that has them + // before saying there are none; with none, +Errors's maker says + // the text some other way (appendErrors), and pane/new is + // refused. + if (layout.shareColumn(p, f.col)) {} else if (kind == .errors or kind == .fs) { layout.removePane(p, id); var c = p.ncol; while (c > 0) { @@ -1051,6 +1067,11 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan } else { layout.insert(p, f.col, p.col_n[f.col], id); p.unplaced.set(id); + // No pane anywhere can give it rows: pane/new is refused. + if (kind == .fs) { + p.panes[id].?.vweight = 0; + p.reportError(from, "placement", error.NoPaneRoom); + } return; } } else { diff --git a/src/host_io.zig b/src/host_io.zig index ac25e63c..c8c537b9 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -19,6 +19,10 @@ pub const Host = struct { present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, post_present: ?*const fn (ctx: ?*anyopaque) void = null, poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, + /// Whether anyone sees a frame drawn now. A detached session with no + /// client attached draws none (a /screen read draws its own), and + /// the frame owed is drawn when one attaches. Absent: always. + watched: ?*const fn (ctx: ?*anyopaque) bool = null, detach: ?*const fn (ctx: ?*anyopaque) void = null, spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, diff --git a/src/layout.zig b/src/layout.zig index 28f3d270..4aebcf28 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -325,8 +325,14 @@ pub fn removePane(p: *Pardes, id: usize) void { /// Close column `c` once its panes are gone, its width to the one left of /// it (the first column's to its right), as acme's rowclose gives it. pub fn dropColumn(p: *Pardes, c: usize) void { - std.debug.assert(p.col_n[c] == 0); const serial = columnSerial(p, c); + dropColumnUnannounced(p, c); + noteColumn(p, "delcol", serial); +} + +/// dropColumn with no `delcol`, for a column never announced. +pub fn dropColumnUnannounced(p: *Pardes, c: usize) void { + std.debug.assert(p.col_n[c] == 0); tagline.exitHeader(p); if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; p.col_tags[c].deinit(p.gpa); @@ -341,12 +347,11 @@ pub fn dropColumn(p: *Pardes, c: usize) void { p.ncol -= 1; p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; - noteColumn(p, "delcol", serial); } /// `newcol <serial>` or `delcol <serial>` in the log: the column's serial, /// as /layout and /col name it. -fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void { +pub fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void { var buf: [16]u8 = undefined; pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch return); } @@ -649,6 +654,14 @@ pub fn firstColumn(p: *Pardes) ?usize { } pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize { + const c = insertColumnUnannounced(p, col, before) orelse return null; + noteColumn(p, "newcol", columnSerial(p, c)); + return c; +} + +/// insertColumn with no serial given and no `newcol`: a column that may +/// yet be taken back (Newcol's trial), which then leaves no trace. +pub fn insertColumnUnannounced(p: *Pardes, col: usize, before: bool) ?usize { if (!canSplitCol(p, col)) return null; var old_weight = p.col_weight[col]; if (old_weight < 2 or old_weight % 2 != 0) { @@ -672,7 +685,6 @@ pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize { p.col_serial[c] = 0; p.col_n[c] = 0; p.ncol += 1; - noteColumn(p, "newcol", columnSerial(p, c)); return c; } diff --git a/src/look.zig b/src/look.zig index 36eae75c..de51804d 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1233,6 +1233,25 @@ fn missedLine(p: *Pardes, id: usize, path: []const u8, line: anytype) void { missed(p, id, "{s} has no line {d}", .{ missText(path, &cut), line }); } +/// Whether `:line:col`, `:line:col-col` or `:line:col-line:col` after a +/// path names a column 0, which parsePathLine reads as no column. +fn zeroColumn(rest: []const u8) bool { + var fields = std.mem.splitScalar(u8, rest, ':'); + _ = fields.next(); // before the first ':' + _ = fields.next() orelse return false; // the line + const col = fields.next() orelse return false; + var ends = std.mem.splitScalar(u8, col, '-'); + if (std.mem.eql(u8, ends.next().?, "0")) return true; + if (ends.next()) |end| if (std.mem.eql(u8, end, "0")) return true; + if (fields.next()) |end_col| if (std.mem.eql(u8, end_col, "0")) return true; + return false; +} + +test "a zero column after a path is found, in every spelling a look takes" { + for ([_][]const u8{ ":3:0", ":3:0-4", ":3:2-0", ":3:2-5:0" }) |rest| try std.testing.expect(zeroColumn(rest)); + for ([_][]const u8{ "", ":3", ":3:1", ":3:2-5", ":3:2-5:7", ":30:10" }) |rest| try std.testing.expect(!zeroColumn(rest)); +} + fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void { p.look_missed = true; var said: [miss_path_cap + 64]u8 = undefined; @@ -1504,6 +1523,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { // the colon, evaluated in that file from its dot (openfile, look.c:675). if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr); const pl = parsePathLine(trimmed); + // `file:3:0`: columns count from 1, as addr's do, and a zero is refused + // in addr's words rather than taken as no column. + if (pl.at.line != 0 and zeroColumn(trimmed[pl.path.len..])) return p.reportFailure(id, @import("ninep/addr.zig").e_col_zero); if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane); if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; var realbuf: [4096]u8 = undefined; diff --git a/src/main.zig b/src/main.zig index 6bdf2bfd..576efc8e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -390,6 +390,19 @@ fn nativeMain(init: std.process.Init) !void { // leave a stale environment variable able to lock someone out of their // own editor. const parent = found orelse break :forwarding; + // Does it answer? One that answers takes the file or says why not, + // as acme's B does: from here on never a nested editor, whose + // screen would be drawn inside the pane that asked. + ninep_io.Client.probe(arena, parent.dial) catch break :forwarding; + const Refuse = struct { + fn with(io: std.Io, what: []const u8, err: anyerror) noreturn { + const why = if (err == error.Remote) ninep_io.Client.lastSaid() else @errorName(err); + var buf: [8192]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "pardes: {s}: {s}\n", .{ what[0..@min(what.len, 4096)], why }) catch "pardes: refused\n"; + std.Io.File.stderr().writeStreamingAll(io, text) catch {}; + std.process.exit(1); + } + }; // The pane's `look` file: one line, and the line is the clicked text // itself, which is what a right click in that pane would have been. var look_buf: [64]u8 = undefined; @@ -397,15 +410,12 @@ fn nativeMain(init: std.process.Init) !void { const word = positional orelse { var tag_buf: [64]u8 = undefined; const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial}); - const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch break :forwarding; + const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch |err| Refuse.with(init.io, tag, err); arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; - // A word this launch cannot turn into a path is an argument problem - // rather than a nesting one: fall through and let the session that - // starts report it, the way `pardes typo` outside pardes does. - if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; + if (std.mem.indexOfAny(u8, word, "\r\n") != null) Refuse.with(init.io, "a file name", error.NotOneLine); const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; var newbuf: [4096]u8 = undefined; @@ -415,30 +425,46 @@ fn nativeMain(init: std.process.Init) !void { // resolved and the name kept, a pane made for it that Save // creates the file from. const base = std.fs.path.basename(target.path); - if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding; + if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) Refuse.with(init.io, word, error.NotAFileName); const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse { // Its directory not there either: the name made absolute as - // written, and the pane's Save makes the directories (a - // name into a missing one says so, pane.zig writeName). + // written, and the pane's Save makes the directories. var cwd_buf: [4096]u8 = undefined; - if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) break :forwarding; + if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) Refuse.with(init.io, word, error.NoWorkingDirectory); const cwd = std.mem.sliceTo(&cwd_buf, 0); - break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch break :forwarding; + break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch |err| Refuse.with(init.io, word, err); }; - break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding; + break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch |err| Refuse.with(init.io, word, err); }; if (found_path != null) { var command_buf: [8192]u8 = undefined; - const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; - ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding; + const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch |err| Refuse.with(init.io, word, err); + ninep_io.Client.write(arena, parent.dial, look, command) catch |err| Refuse.with(init.io, word, err); } else { - const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding; - const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding; + const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, word, err); + const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, word, err); var name_buf: [64]u8 = undefined; const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial}); var line_buf: [4200]u8 = undefined; - const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding; - ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding; + const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch |err| Refuse.with(init.io, word, err); + ninep_io.Client.write(arena, parent.dial, name, line) catch |err| { + // The name refused: the pane made for it goes too, no + // stray +New left behind. + var ctl_buf: [64]u8 = undefined; + const ctl = std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}) catch unreachable; + var why_buf: [256]u8 = undefined; + const said = ninep_io.Client.lastSaid(); + const why = why_buf[0..@min(said.len, why_buf.len)]; + @memcpy(why, said[0..why.len]); + ninep_io.Client.write(arena, parent.dial, ctl, "delete\n") catch {}; + if (err == error.Remote) { + var buf: [8192]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "pardes: {s}: {s}\n", .{ word[0..@min(word.len, 4096)], why }) catch "pardes: refused\n"; + std.Io.File.stderr().writeStreamingAll(init.io, text) catch {}; + std.process.exit(1); + } + Refuse.with(init.io, word, err); + }; } if (wait) waitForDel(init.io, arena, parent.dial, path); return; diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index 9674a323..df2cfaf0 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -384,6 +384,21 @@ test "an address is evaluated from the current one, as acme's are" { try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename); } +test "a backward search along one long line takes time in its length, not its square" { + const gpa = testing.allocator; + const line = try gpa.alloc(u8, 7 * 16800); + defer gpa.free(line); + for (0..16800) |i| @memcpy(line[i * 7 ..][0..7], "answer "); + const p = try th.withFile(gpa, line); + defer p.deinit(); + const t0 = std.Io.Timestamp.now(testing.io, .awake); + try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .addr), "?a?").reply.status); + // Quadratic, it took seconds (6.4 s through 9P in Debug); linear, a + // few milliseconds. + const took = t0.durationTo(std.Io.Timestamp.now(testing.io, .awake)).toNanoseconds(); + try testing.expect(took < std.time.ns_per_s); +} + test "an alternation anchoring some branches and not others is refused with why, a $ not counting" { const p = try th.withFile(testing.allocator, "def\nfoo\n"); defer p.deinit(); diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index 454cce4a..c4ee83eb 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -163,7 +163,7 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { var lines = std.mem.splitScalar(u8, req.data, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; + if (line.len == 0 or pardes.exec.isComment(line)) continue; const word = line[0 .. std.mem.indexOfAny(u8, line, " \t") orelse line.len]; const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| { if (std.mem.eql(u8, w, word)) break true; @@ -228,6 +228,69 @@ test "New written to a column's exec makes its scratch in the session's director try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path); } +test "pane/new takes rows from a pane in another column before refusing ENOSPC" { + const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 16 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const first = p.panes[p.active].?.serial; + // A second column holding one tall pane. + try testing.expectEqual(tree.Status.ok, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status); + const tall = try th.newPane(p); + const tall_col = layout.findPane(p, p.paneBySerial(tall).?).?.col; + const focus = @intFromEnum(tree.TopFile.focus); + var digits: [16]u8 = undefined; + // From the first column, until pane/new is refused. + var moved = false; + for (0..pardes.MAX_PANES) |_| { + _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{first})); + const made = th.newPane(p) catch break; + if (layout.findPane(p, p.paneBySerial(made).?).?.col == tall_col) moved = true; + } + try testing.expect(moved); +} + +test "a Newcol refused because the tags would not fit logs its err alone and spends no column serial" { + const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + // Long tags wrap onto more rows as their column narrows: the boot's + // two panes share one, and each keeps its tag and two rows. + const made = try th.newPane(p); + const pane = p.panes[p.paneBySerial(made).?].?; + const col = layout.findPane(p, p.paneBySerial(made).?).?.col; + for (p.col_panes[col][0..p.col_n[col]]) |k| { + const each = p.panes[k].?; + if (each.tag.own) |old| p.gpa.free(old); + each.tag.own = try p.gpa.dupe(u8, "word " ** 60); + } + try testing.expect(p.col_n[col] >= 2); + p.sync(); + const ctl = @intFromEnum(tree.TopFile.ctl); + const focus = @intFromEnum(tree.TopFile.focus); + var digits: [16]u8 = undefined; + const own = try std.fmt.bufPrint(&digits, "{d}\n", .{pane.serial}); + var refused = false; + for (0..pardes.MAX_COLS) |_| { + // From the long tag's column, so it is the one split narrower. + _ = th.wr(p, focus, own); + const serial_before = p.next_column_serial; + const log_before = try pardes.ctlfs.events.freezeLog(p); + defer p.gpa.free(log_before.bytes); + const r = th.wr(p, ctl, "Newcol\n"); + if (r.reply.status == .ok) continue; + if (std.mem.indexOf(u8, r.reply.ename, "tags would not fit") == null) continue; + refused = true; + try testing.expectEqual(serial_before, p.next_column_serial); + const log_after = try pardes.ctlfs.events.freezeLog(p); + defer p.gpa.free(log_after.bytes); + const added = log_after.bytes[log_before.bytes.len..]; + try testing.expect(std.mem.startsWith(u8, added, "err ")); + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, added, "\n")); + break; + } + try testing.expect(refused); +} + test "a tagexec word that makes, acts on and focuses no pane reads back nothing" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -458,7 +521,10 @@ test "a refused write after a truncation leaves every kind of tag as it was, and const h = th.call(p, .{ .tag = 2, .op = .open, .node = node, .omode = 1 }).reply.handle; const r = th.call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .data = big }); try testing.expectEqual(E.NOSPC, r.errno()); - try testing.expectEqualStrings("tag: no space: over 4096 bytes", r.reply.ename); + try testing.expectEqualStrings("no space: over 4096 bytes", r.reply.ename); + // Its err record names the file once: `tag: no space`, never `tag: tag:`. + try testing.expect(th.logHas(p, "tag: no space: over 4096 bytes")); + try testing.expect(!th.logHas(p, "tag: tag:")); _ = th.call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h, .opened = true }); try testing.expectEqualStrings(before, th.rd(p, node, 0, 1 << 16).bytes); // A control character is refused the same way on every kind. diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 0bbc0319..9a95a10f 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -192,8 +192,30 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control); if (exec) if (tooLong(req, text)) |refusal| return refusal; if (!apply) continue; - const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; + var id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; + // No pane at all: the root's look and exec act in the session's + // directory. An exec runs as the workspace tag's; a look from a + // scratch made as New makes one (and its column), dropped + // unannounced when the look went elsewhere. + var scratch: ?usize = null; + if (p.panes[id] == null and serial == null and p.anyPane() == null) { + if (exec) { + p.exec_header = true; + defer p.exec_header = false; + if (captured(p, req, id, exec, text, true)) |refusal| return refusal; + continue; + } + const free = p.freeSlot() orelse return Reply.fail(req.tag, E.NOSPC); + p.newScratchFor(id, .scratch); + if (p.panes[free] == null) return Reply.fail(req.tag, E.NOSPC); + p.active = free; + id = free; + scratch = free; + } if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); + defer if (scratch) |s| if (p.active != s) if (p.panes[s]) |sp| if (sp.file) |f| { + if (f.content.len == 0) p.removePane(s, null) catch {}; + }; p.fs.ran_at = p.panes[id].?.serial; p.fs.no_pane_slot = false; if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal; @@ -267,7 +289,8 @@ fn failureErrno(failure: []const u8) u16 { if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT; // A pattern refused (Edit's too) is malformed input, as 9ns reads it; // so is a word this build has not (`invalid: acme's Put ...`). - if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid")) return E.INVAL; + if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid") or + std.mem.startsWith(u8, failure, "address out of range")) return E.INVAL; return E.IO; } @@ -586,7 +609,7 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { if (apply) p.fs.results_len = 0; var it = Messages.init(p, req.data); while (it.next()) |line| { - if (line.len == 0) continue; + if (line.len == 0 or exec_line.isComment(line)) continue; if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) { const size = sizeOf(line) orelse return refuse(p, req, e_size, line); // A frontend's window is the size; only a session none is @@ -812,7 +835,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { var held = !other and pane.fs.lock != null; var it = Messages.init(p, req.data); while (it.next()) |line_in| { - if (line_in.len == 0) continue; + if (line_in.len == 0 or exec_line.isComment(line_in)) continue; // A line before this one may have closed the pane (`Del`). if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT); // acme's ctl words: done by the file that replaces each, or the @@ -1929,12 +1952,12 @@ test "a builtin that fails a ctl write logs only its err, so the same failure ag p.update(.tick); // the pane's `new` is logged first while (p.nextEffect()) |_| {} const ctl_node = @intFromEnum(tree.TopFile.ctl); - for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status); + for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Unmount zzz\n").reply.status); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; - try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word (x3)\n")); - try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null); + try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Unmount: no such mount zzz (x3)\n")); + try testing.expect(std.mem.indexOf(u8, text, "msg - Unmount") == null); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } @@ -2445,6 +2468,81 @@ test "a look that misses file:/re/ names the file, and is logged as its pane's" try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir }))); } +test "with no pane at all, the root look opens its file and the root exec runs in the session's directory" { + 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 = "f.txt", .data = "file\n" }); + var buf: [4096]u8 = undefined; + const session = buf[0..try tmp.dir.realPath(testing.io, &buf)]; + @memcpy(p.session_dir[0..session.len], session); + p.session_dir_len = @intCast(session.len); + const Empty = struct { + fn all(q: *Pardes) !void { + while (q.anyPane()) |k| _ = wr(q, Node.of(q.panes[k].?.serial, .ctl), "delete\n"); + } + }; + try Empty.all(p); + const look_node = @intFromEnum(tree.TopFile.look); + try testing.expectEqual(Status.ok, wr(p, look_node, "f.txt\n").reply.status); + var want: [4200]u8 = undefined; + const path = try std.fmt.bufPrint(&want, "{s}/f.txt", .{session}); + var opened: usize = 0; + var scratches: usize = 0; + for (p.panes) |slot| { + const f = (slot orelse continue).file orelse continue; + opened += @intFromBool(std.mem.eql(u8, f.path, path)); + scratches += @intFromBool(std.mem.endsWith(u8, f.path, "/+New")); + } + try testing.expectEqual(@as(usize, 1), opened); + try testing.expectEqual(@as(usize, 0), scratches); + + try Empty.all(p); + try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.exec), "echo hi\n").reply.status); + const made = p.panes[p.anyPane().?].?; + try testing.expect(made.command != null); + try testing.expectEqualStrings(session, made.cwdSlice()); +} + +test "a look at file:3:0 is refused as addr 3:0 is, a column counting from 1" { + const p = try withFile(testing.allocator, "one\ntwo\nthree\n"); + defer p.deinit(); + const serial = serialOf(p); + const by_addr = wr(p, Node.of(serial, .addr), "3:0"); + const by_look = wr(p, Node.of(serial, .look), "/test.txt:3:0\n"); + try testing.expectEqual(E.INVAL, by_addr.errno()); + try testing.expectEqual(E.INVAL, by_look.errno()); + try testing.expectEqualStrings(by_addr.reply.ename, by_look.reply.ename); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "/test.txt:3:1\n").reply.status); +} + +test "Kill with a name and nothing running says so and does not fail, as bare Kill does" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const r = wr(p, @intFromEnum(tree.TopFile.ctl), "Kill zzz\n"); + try testing.expectEqual(Status.ok, r.reply.status); + try testing.expect(th.logHas(p, "Kill: nothing running")); +} + +test "a line starting with # runs as nothing at an exec or a ctl, silently" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + // The setup's own announcements are made by the first write's update. + _ = wr(p, @intFromEnum(tree.TopFile.ctl), "# settle\n"); + const before = p.next_serial; + const log_before = try pardes.ctlfs.events.freezeLog(p); + defer p.gpa.free(log_before.bytes); + for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec), @intFromEnum(tree.TopFile.ctl), Node.of(serial, .ctl), Node.ofCol(layout.columnSerial(p, 0), .ctl) }) |node| { + try testing.expectEqual(Status.ok, wr(p, node, "# Platform: tty\n # indented too\n").reply.status); + } + try testing.expectEqual(before, p.next_serial); + const log_after = try pardes.ctlfs.events.freezeLog(p); + defer p.gpa.free(log_after.bytes); + try testing.expectEqualStrings(log_before.bytes, log_after.bytes); +} + test "the ctl word name refuses a leading blank, as the name file does" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -3225,9 +3323,9 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr while (p.nextEffect()) |_| {} const serial = serialOf(p); for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| { - const failed = wr(p, node, "Kill zzz\n"); + const failed = wr(p, node, "Unmount zzz\n"); try testing.expectEqual(Status.err, failed.reply.status); - try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Kill: no running command has that first word") != null); + try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Unmount: no such mount zzz") != null); } const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; @@ -3235,8 +3333,8 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr try testing.expect(std.mem.indexOf(u8, text, "msg ") == null); // The root's exec ran at the pane, so its err and the pane's exec's are // the same record, counted. - try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Kill: no running command has that first word")); - try testing.expect(std.mem.indexOf(u8, text, "first word (x2)") != null); + try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Unmount: no such mount zzz")); + try testing.expect(std.mem.indexOf(u8, text, "mount zzz (x2)") != null); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); // A control character is refused with its reason. try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index c6505f52..63405781 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -688,8 +688,17 @@ fn scanNumber(data: []const u8, i: *usize) ?u32 { } /// Writing a Look or Exec record back performs the action it names. -pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { - const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); +pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { + const pane0 = p.panes[id] orelse return Reply.fail(asked.tag, E.NOENT); + // Its last record needs no newline, as every other line file's last + // line does not (`printf 'MX0 10' > event`). + var req = asked; + if (req.data.len > 0 and req.data[req.data.len - 1] != '\n') { + const whole = p.scratch.allocator().alloc(u8, req.data.len + 1) catch return Reply.fail(req.tag, E.NOMEM); + @memcpy(whole[0..req.data.len], req.data); + whole[req.data.len] = '\n'; + req.data = whole; + } const serial = pane0.serial; { const body = pane_files.bodyOf(pane0); @@ -783,7 +792,7 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { else => unreachable, } } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; + return .{ .tag = req.tag, .written = @intCast(asked.data.len) }; } // ---- tests ---- @@ -984,11 +993,17 @@ test "writing an event record back performs the action it names" { try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + // The last record needs no newline, as any line file's last line. + pane.msg_len = 0; + const bare = wr(p, event, "FX0 10"); + try testing.expectEqual(Status.ok, bare.reply.status); + try testing.expectEqual(@as(u32, 6), bare.reply.written); + try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + pane.msg_len = 0; for ([_][]const u8{ "FX0 10\nFQ0 1\n", // unknown type character "FX0 999\n", // out of range - "FX0 10", // no newline "FX5 1\n", // q0 > q1 "FD0 3\n", // a report, not a request "F\n", diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 6a0fbba5..dc8b4b10 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -639,7 +639,7 @@ pub fn tagFault(data: []const u8) ?[]const u8 { /// A tag write's limit, the same for a pane's, a column's and the /// workspace's: whole or not at all. -pub const e_tag_over = std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}); +pub const e_tag_over = std.fmt.comptimePrint("no space: over {d} bytes", .{limits.max_tag_tail}); fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; @@ -867,11 +867,6 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { error.OutOfMemory => E.NOMEM, else => E.INVAL, }); - // A name into a directory not there yet asks for it, as Config's pane - // does: its Save makes the directories (`pardes new/dir/f` forwarded). - if (comptime pardes.hosted) if (fs.localPath(std.fs.path.dirname(full) orelse "/")) |local| { - if (!exec.isDirectory(local)) fileOf(pane).?.make_dirs = true; - }; return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -1319,7 +1314,7 @@ test "a tag write past the limit is refused whole, naming the limit" { try testing.expectEqual(E.NOSPC, r.errno()); // `no space` is what 9ns maps to ENOSPC, so a shell through a mount // sees the errno the tree gives. - try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over ")); + try testing.expect(std.mem.startsWith(u8, r.reply.ename, "no space: over ")); try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes); } @@ -1392,19 +1387,6 @@ test "a name with a component over 255 bytes is refused, and a long path's faile try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory")); } -test "a name into a directory not there has Save make it; one into a directory there does not" { - if (comptime !pardes.hosted) return error.SkipZigTest; - const p = try withFile(testing.allocator, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - const f = fileOf(p.panes[p.paneBySerial(serial).?].?).?; - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-mk/sub/f.txt\n").reply.status); - try testing.expect(f.make_dirs); - f.make_dirs = false; - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/f.txt\n").reply.status); - try testing.expect(!f.make_dirs); -} - test "a name cut across writes is one name, applied once at its newline or its close" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 0f5202cc..2245255b 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -33,7 +33,17 @@ pub const pty_rows_min = 2; /// As the root ctl's `size` (4096 a side). const winsize_max = 4096; -const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max }); +const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x{d} to {d}x{d}", .{ pty_rows_min, winsize_max, winsize_max }); +const e_winsize_rows = std.fmt.comptimePrint("invalid winsize: at least {d} rows", .{pty_rows_min}); + +/// `winsize C R` whose R is a size but fewer rows than a pty takes. +fn tooFewRows(line: []const u8) bool { + var words = std.mem.tokenizeAny(u8, line, " \t"); + if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false; + _ = dimension(words.next() orelse return false) orelse return false; + const rows = dimension(words.next() orelse return false) orelse return false; + return words.next() == null and rows < pty_rows_min; +} /// A winsize whose numbers are numbers but not sizes: said as such. fn outOfRange(line: []const u8) bool { @@ -79,7 +89,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply { 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); + if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (tooFewRows(line)) e_winsize_rows else if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; @@ -93,8 +103,10 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { .winsize => { const cols = dimension(words.next() orelse return false) orelse return false; // Two rows at least, as a pane is laid out: a one-row pty - // loses its prompt's input mark and reads busy for ever. - const rows = @max(pty_rows_min, dimension(words.next() orelse return false) orelse return false); + // loses its prompt's input mark and reads busy for ever. Fewer + // is refused, never quietly raised. + const rows = dimension(words.next() orelse return false) orelse return false; + if (rows < pty_rows_min) return false; if (words.next() != null) return false; if (!apply) return true; p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); @@ -619,6 +631,13 @@ test "every pty/ctl verb, and every refusal" { try testing.expect(a.winsize == null); } + // One row is a size, but fewer than a pty takes: refused, and said so. + const one = wr(p, ctl, "winsize 80 1"); + try testing.expectEqual(E.INVAL, one.errno()); + try testing.expectEqualStrings("invalid winsize: at least 2 rows", one.reply.ename); + try testing.expect(one.winsize == null); + try testing.expectEqualStrings("invalid winsize: out of range, 1x2 to 4096x4096", e_winsize_range); + const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); try testing.expectEqual(Status.ok, spaced.reply.status); try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); @@ -960,6 +979,7 @@ test "a run's answer says cut when its output's start is gone, and reads a bound var got = sh.answered(p, node, h, into); try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n")); + // A resize between its start and end reflows the pins with the text. h = sh.send(p, node); sh.put(p, c ++ "one\r\ntwo\r\n"); @@ -1022,6 +1042,12 @@ test "a run's answer says cut when its output's start is gone, and reads a bound sh.put(p, d ++ prompt); p.gpa = gpa; try testing.expectEqualStrings("error out of memory\n", sh.answered(p, node, h, into)); + // An end drawn above the start (the prompt redrawn over what was + // printed, as in a window two rows high): cut, never an empty whole. + h = sh.send(p, node); + sh.put(p, c ++ "printed\r\nmore\r\n\x1b[4A" ++ d ++ prompt); + got = sh.answered(p, node, h, into); + try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n")); } test "a terminal's ctl counts its body, and a new directory is a rename in the log" { diff --git a/src/pardes.zig b/src/pardes.zig index f0d22d57..b16d39b7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1112,7 +1112,33 @@ test "first document fallback splits like Tty from the originating pane" { }; } -test "Lspwhy after a Hover asks to explain that hover, at its place" { +test "a frame no one watches is not drawn, and is drawn once someone does" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const Watch = struct { + var presents: usize = 0; + var seen = false; + const vtable: Host.VTable = .{ .present = present, .watched = watched }; + fn present(_: ?*anyopaque, _: *const Surface) void { + presents += 1; + } + fn watched(_: ?*anyopaque) bool { + return seen; + } + }; + Watch.presents = 0; + Watch.seen = false; + p.needs_frame = true; + try p.pump(.{ .vtable = &Watch.vtable }); + try std.testing.expectEqual(@as(usize, 0), Watch.presents); + try std.testing.expect(p.needs_frame); // still owed + Watch.seen = true; + try p.pump(.{ .vtable = &Watch.vtable }); + try std.testing.expectEqual(@as(usize, 1), Watch.presents); +} + +test "Lspwhy after a Hover asks to explain that hover, in its file at its place, run from any pane" { if (!lsp.supports.contains(.hover)) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -1131,12 +1157,16 @@ test "Lspwhy after a Hover asks to explain that hover, at its place" { pane.body.cur_row = 0; pane.body.cur_col = 0; p.lsp_wait = null; - p.lspRequest(0, .explain, ""); + // Run from another file's pane: still the hover's file and place. + const other = p.freeSlot().?; + _ = try p.openTutorView(other); + p.lspRequest(other, .explain, ""); var seen = false; while (p.nextEffect()) |e| if (e == .lsp) { try std.testing.expectEqual(lsp.Kind.explain, e.lsp.kind); try std.testing.expectEqualStrings("hover ", e.lsp.arg.slice()); try std.testing.expectEqual(hover_at, e.lsp.offset); + try std.testing.expectEqual(@as(u8, 0), e.lsp.pane); seen = true; }; try std.testing.expect(seen); @@ -4971,8 +5001,13 @@ pub const Pardes = struct { // An empty window (Delcol took the last column): its first column. if (layout.firstColumn(p)) |c| return tagline.enterHeader(p, c); const col = p.keyboardColumn(from_id) orelse return; + // A trial: laying it out gives it a serial (layout.compute), which a + // refusal takes back. Every other column has its own already, so + // the counter as it is now is the trial's to return to. + for (0..p.ncol) |k| _ = layout.columnSerial(p, k); + const unspent = p.next_column_serial; // Said, not silently nothing: "no space" is 9ns's word for ENOSPC. - const c = layout.insertColumn(p, col, false) orelse { + const c = layout.insertColumnUnannounced(p, col, false) orelse { var said: [96]u8 = undefined; return p.reportFailure(from_id, if (p.ncol >= MAX_COLS) std.fmt.bufPrint(&said, "Newcol: no space for a column: {d} max", .{MAX_COLS}) catch "Newcol: no space for a column" @@ -4984,10 +5019,13 @@ pub const Pardes = struct { // out again, and where they cannot hold every minimum there is no // new column, as a size too small is refused. for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) { - layout.dropColumn(p, c); + layout.dropColumnUnannounced(p, c); + p.next_column_serial = unspent; layout.compute(p); return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit"); }; + // Kept: now it has a serial, and the log hears of it. + layout.noteColumn(p, "newcol", layout.columnSerial(p, c)); tagline.enterHeader(p, c); } @@ -5389,6 +5427,15 @@ pub const Pardes = struct { p.noteLateFailure(id); } + /// A Save makes the directories its file goes in, and any above them, + /// first: one rule, for the pane's own name and `Save path` alike. A + /// directory it cannot make leaves the write to fail and say why. + fn makeParents(path: []const u8) void { + if (comptime !hosted) return; + const local = filesystem.localPath(path) orelse return; + filesystem.makeDirs(std.fs.path.dirname(local) orelse "/"); + } + 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". @@ -5478,8 +5525,7 @@ pub const Pardes = struct { const was = f.saved_revision; const failures = p.fs.failures; f.saved_revision = f.revision; - if (f.make_dirs) if (filesystem.localPath(f.path)) |local| - filesystem.makeDirs(std.fs.path.dirname(local) orelse "/"); + makeParents(f.path); p.hostWriteFile(sf.pane, f.path, f.content); const saved_pane = p.panes[sf.pane] orelse return; if (saved_pane.serial != serial) return; @@ -5508,6 +5554,7 @@ pub const Pardes = struct { if (pane.file) |*f| { const was = f.saved_revision; const failures = p.fs.failures; + makeParents(st.path.slice()); p.hostWriteFile(st.pane, st.path.slice(), f.content); const after = p.panes[st.pane] orelse return; if (after.serial != st.serial) return; @@ -5523,6 +5570,7 @@ pub const Pardes = struct { if (!pane.isTerminal()) return; const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); + makeParents(st.path.slice()); p.hostWriteFile(st.pane, st.path.slice(), text); }, .write_dump => { @@ -5603,6 +5651,10 @@ pub const Pardes = struct { // A shell with a clock draws what `advance` stepped; one without // steps its own `.tick`s and draws while anything animates. if (!p.needs_frame and !p.continuous() and (v.now != null or !p.animationActive())) return; + // No one to see it: the frame stays owed (needs_frame), not drawn. + // A pty streaming into an unwatched session spent most of its time + // drawing what nobody looked at. + if (v.watched) |f| if (!f(h.ctx)) return; _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); @@ -6307,8 +6359,12 @@ pub const Pardes = struct { pub const SavePath = Effect.Buf(effect_path_cap); - pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { + pub fn lspRequest(p: *Pardes, asked: usize, kind: lsp.Kind, arg: []const u8) void { if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary + // Lspwhy narrates the last query where it was asked -- that pane's + // file, place and argument -- from wherever it is run. + const last_pane: ?usize = if (kind == .explain) (if (p.lsp_last) |last| p.paneBySerial(last.serial) else null) else null; + const id = last_pane orelse asked; var said: [128]u8 = undefined; if (!lsp.supports.contains(kind)) return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: no language server here answers it", .{lspWord(kind)}) catch "no language server answers it"); @@ -6338,7 +6394,7 @@ pub const Pardes = struct { if (kind == .explain) { if (p.lsp_last) |last| { sent = std.fmt.bufPrint(&explained, "{s} {s}", .{ @tagName(last.kind), last.arg.slice() }) catch ""; - if (last.serial == pane.serial) at = last.offset; + if (last_pane != null) at = last.offset; } } else if (kind != .status) p.lsp_last = .{ .kind = kind, .arg = .from(arg), .serial = pane.serial, .offset = @intCast(off) }; p.lsp_seq +%= 1; diff --git a/src/regexp.zig b/src/regexp.zig index 575e6861..86132f60 100644 --- a/src/regexp.zig +++ b/src/regexp.zig @@ -47,6 +47,14 @@ pub const Regex = struct { /// mvzr steps left to every search with this compiled pattern together, /// so that addr's backward scan and s/S's many calls share one bound. steps: u64 = budget, + /// Where `find` last learned a line's start in a text (by its address + /// and length): the line holding `at` starts at `start`. A backward + /// search asks again at each match along a line, and looking back to + /// the line's start from the top each time was quadratic on a long one. + line_hint: struct { ptr: usize = 0, len: usize = 0, at: usize = 0, start: usize = 0 } = .{}, + /// ...and where a line learned to end: the line from `start` ends at + /// `end` (its newline, or `hi`), asked again at each match along it. + end_hint: struct { ptr: usize = 0, len: usize = 0, start: usize = 0, hi: usize = 0, end: usize = 0 } = .{}, /// Timed on this machine's ReleaseSafe build: 10M steps of the worst /// patterns take 45-80 ms, and a Debug build is ten times slower. @@ -324,6 +332,28 @@ pub const Regex = struct { pub const Match = struct { start: usize, end: usize }; + /// Where the line from `start` ends (its newline, else `hi`), `at` on + /// it: known when asked before of this line of this text. + fn lineEnd(rx: *Regex, text: []const u8, start: usize, at: usize, hi: usize) usize { + const h = rx.end_hint; + if (h.ptr == @intFromPtr(text.ptr) and h.len == text.len and h.start == start and h.hi == hi and at <= h.end) return h.end; + // No newline between the line's start and `at`: the look starts there. + const end = std.mem.indexOfScalarPos(u8, text[0..hi], at, '\n') orelse hi; + rx.end_hint = .{ .ptr = @intFromPtr(text.ptr), .len = text.len, .start = start, .hi = hi, .end = end }; + return end; + } + + /// Where the line holding `from` starts, looking back only to where + /// the last call learned one in this text. + fn lineStart(rx: *Regex, text: []const u8, from: usize) usize { + const h = rx.line_hint; + const known = h.ptr == @intFromPtr(text.ptr) and h.len == text.len and h.at <= from; + const lo = if (known) h.at else 0; + const s = if (std.mem.lastIndexOfScalar(u8, text[lo..from], '\n')) |nl| lo + nl + 1 else if (known) h.start else 0; + rx.line_hint = .{ .ptr = @intFromPtr(text.ptr), .len = text.len, .at = from, .start = s }; + return s; + } + /// The first match that starts in `from..=last` and ends by `hi`, as /// offsets into `text`. The text before `from` still says where lines /// begin. @@ -345,7 +375,7 @@ pub const Regex = struct { } return null; } - var start: usize = if (rx.spans) 0 else if (std.mem.lastIndexOfScalar(u8, text[0..from], '\n')) |nl| nl + 1 else 0; + var start: usize = if (rx.spans) 0 else rx.lineStart(text, from); var at = from - start; // `^` cannot match in the middle of a line. if (!rx.spans and rx.bol and at > 0) { @@ -359,7 +389,9 @@ pub const Regex = struct { const q = std.mem.indexOfPos(u8, text[0..hi], start + at, rx.lit[0..rx.lit_len]) orelse return null; if (q > last) return null; if (q > start + at) { - const line_start = if (std.mem.lastIndexOfScalar(u8, text[0..q], '\n')) |nl| nl + 1 else 0; + // `start` begins a line and `start + at` is on it: the + // look back ends there. + const line_start = if (std.mem.lastIndexOfScalar(u8, text[start + at .. q], '\n')) |nl| start + at + nl + 1 else start; if (line_start > start) { start = line_start; at = q - line_start; @@ -371,7 +403,7 @@ pub const Regex = struct { } } } - const end = if (rx.spans) hi else std.mem.indexOfScalarPos(u8, text[0..hi], start, '\n') orelse hi; + const end = if (rx.spans) hi else rx.lineEnd(text, start, start + at, hi); const line = text[start..end]; // matchPos finds nothing at a haystack's very end, where `$` or // an empty match still can. diff --git a/src/terminal.zig b/src/terminal.zig index 4751a09f..7874ca89 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -1093,9 +1093,14 @@ pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !Comm const screen = state.vt.screens.get(.primary) orelse break :printed false; if (end.garbage) break :printed false; var whole = !marks.lost and !start.garbage; + // Nothing printed: the whole of it. + if (start.eql(end.*)) break :printed whole; // D is where the next prompt begins: the output ends a cell before. - const last = end.leftWrap(1) orelse break :printed whole; - if (start.eql(end.*) or last.before(start.*)) break :printed whole; + // An end at the very top, or before the start, is output a redraw + // moved (a prompt drawn over it in a window two rows high): how + // much is not known, so it is not called whole. + const last = end.leftWrap(1) orelse break :printed false; + if (last.before(start.*)) break :printed false; var from = start.*; if (last.up(max_rows)) |floor| if (from.before(floor)) { from = floor; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index ef7f2761..1d7ff415 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1083,6 +1083,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v defer vx.exitAltScreen(tty.writer()) catch {}; try vx.setMouseMode(tty.writer(), true); try vx.setBracketedPaste(tty.writer(), true); + Killed.arm(tty.fd.handle, tty.termios); + defer Killed.disarm(); if (attach != null) { attach_end = attachSession(init, attach_name, &tty, &vx); @@ -1119,6 +1121,40 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v } } +/// SIGTERM or SIGHUP: the terminal put back as a clean exit leaves it -- +/// the main screen, no mouse, paste or keyboard modes, the cursor shown, +/// cooked termios -- and then the signal's own death. A nested editor +/// killed in a pane must not leave that pane's terminal wedged. +/// Async-signal-safe: write, tcsetattr, sigaction, raise. +const Killed = struct { + var fd: posix.fd_t = -1; + var cooked: posix.termios = undefined; + const reset = "\x1b[<u\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l"; + const signals = [_]posix.SIG{ .TERM, .HUP }; + + fn arm(tty_fd: posix.fd_t, termios: posix.termios) void { + cooked = termios; + fd = tty_fd; + const on: posix.Sigaction = .{ .handler = .{ .handler = handle }, .mask = posix.sigemptyset(), .flags = posix.SA.RESETHAND }; + for (signals) |sig| posix.sigaction(sig, &on, null); + } + + fn disarm() void { + fd = -1; + const default: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 }; + for (signals) |sig| posix.sigaction(sig, &default, null); + } + + fn handle(sig: posix.SIG) callconv(.c) void { + if (fd >= 0) { + _ = std.c.write(fd, reset, reset.len); + _ = std.c.tcsetattr(fd, .FLUSH, &cooked); + } + // SA_RESETHAND put the default back: the same signal, now fatal. + _ = std.c.raise(sig); + } +}; + fn localSession( init: std.process.Init, opts: pardes.Options, |
