summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig45
-rw-r--r--src/CHANGELOG.md26
-rw-r--r--src/File.zig46
-rw-r--r--src/Messages.zig10
-rw-r--r--src/Output.zig10
-rw-r--r--src/builtins.zig3
-rw-r--r--src/config.zig165
-rw-r--r--src/dump.zig3
-rw-r--r--src/exec.zig8
-rw-r--r--src/fs.zig9
-rw-r--r--src/look.zig5
-rw-r--r--src/lsp/lsp.zig26
-rw-r--r--src/lsp/lsp_client.zig14
-rw-r--r--src/main.zig10
-rw-r--r--src/ninep/addr.zig13
-rw-r--r--src/ninep/cols.zig128
-rw-r--r--src/ninep/ctl.zig72
-rw-r--r--src/ninep/events.zig52
-rw-r--r--src/ninep/pane.zig221
-rw-r--r--src/ninep/tree.zig22
-rw-r--r--src/pardes.zig93
-rw-r--r--src/regexp.zig10
-rw-r--r--src/sam_edit.zig1
-rw-r--r--src/terminal.zig17
24 files changed, 801 insertions, 208 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 78ff5d59..d1b2099c 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -851,6 +851,16 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [
return null;
};
if (!ensureSocketDir(dir)) return null;
+ const entry_name = if (named.len != 0) named else fallback;
+ // Checked before anything is made: the turn's hooks point at the
+ // listener from here on, and a path too long for sun_path used to free
+ // it with them still set, so the editor's next rest called into freed
+ // memory.
+ var path_check: [sun_path_len]u8 = undefined;
+ if (socketPath(&path_check, dir, entry_name) == null) {
+ log.warn("no socket for {s}: the path is too long for a unix socket", .{entry_name});
+ return null;
+ }
const l = gpa.create(Listener) catch return null;
l.* = .{ .io = io, .core = core };
pardes.turn.start(io);
@@ -863,11 +873,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [
.handler = .{ .ctx = l, .serve = Listener.onServe, .opened = Listener.onOpened, .refused = Listener.onRefused },
.greet_timeout_ms = Listener.greet_deadline_ms,
});
- const entry_name = if (named.len != 0) named else fallback;
- const p = socketPath(&l.path_buf, dir, entry_name) orelse {
- gpa.destroy(l);
- return null;
- };
+ const p = socketPath(&l.path_buf, dir, entry_name).?;
_ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: {
const existing = @import("fs.zig").statPath(io, p, .{ .follow_symlinks = false }) catch null;
if (err != error.AddressInUse or existing == null or existing.?.kind != .unix_domain_socket or alive(p)) {
@@ -1261,6 +1267,35 @@ test "TCP addresses are numeric and normalize mapped IPv4" {
extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8;
extern "c" fn rmdir(path: [*:0]const u8) c_int;
+test "a socket path too long for sun_path is no listener, and leaves the editor's turn as it was" {
+ if (comptime !supported) return error.SkipZigTest;
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ var directory: [64:0]u8 = undefined;
+ _ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-long-XXXXXX", .{}, 0);
+ if (mkdtemp(&directory) == null) return error.TempDirectoryFailed;
+ const dir = std.mem.span(@as([*:0]const u8, &directory));
+ defer _ = rmdir(&directory);
+ // A name that fits: the directory is one a listener takes.
+ var fits: [sun_path_len]u8 = undefined;
+ try testing.expect(socketPath(&fits, dir, "short") != null);
+ const old = libc.getenv("XDG_RUNTIME_DIR");
+ var old_buf: [4096]u8 = undefined;
+ const old_copy = if (old) |o| try std.fmt.bufPrintSentinel(&old_buf, "{s}", .{std.mem.span(o)}, 0) else null;
+ var z: [4096]u8 = undefined;
+ _ = setenv("XDG_RUNTIME_DIR", try std.fmt.bufPrintSentinel(&z, "{s}", .{dir}, 0), 1);
+ defer if (old_copy) |o| {
+ _ = setenv("XDG_RUNTIME_DIR", o, 1);
+ } else {
+ _ = unsetenv("XDG_RUNTIME_DIR");
+ };
+ const long = "n" ** 120;
+ try testing.expect(listen(testing.io, testing.allocator, p, long, "", null, null) == null);
+ try testing.expect(pardes.turn.answer_held == null);
+ try testing.expect(pardes.turn.wake_parked == null);
+ try testing.expect(pardes.turn.io == null);
+}
+
test "a listening editor posts itself into the 9P registry and unposts on stop" {
if (comptime !supported) return error.SkipZigTest;
const gpa = testing.allocator;
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 42f2eb6f..3dd7c996 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,31 @@
# Changelog
+## 0.0.7
+
+- `lapis` draws its cells unblended and each plaque pixel once: about a
+ third less GPU time a frame at 4K, pixel for pixel the same. `lapis_plain`
+ is lapis on flat grounds, keeping its gold frames, plaque shadows and
+ file-name shadow.
+- Fixed a crash at startup when the 9P socket path is longer than the
+ system allows, and a leak when a terminal pane closes.
+- Pane, column and workspace tags share one set of write checks: the 4096
+ byte limit, control characters, and a refused `>` write leaves the tag as
+ it was. Column and workspace tags read without a trailing newline.
+- A control character in a write to any ctl file refuses the whole write.
+- Restore never reuses a column serial; the log announces a pane's `new`
+ before anything else about it.
+- `DumpConfig` prints each setting as the ctl word that sets it, and
+ `unsupported` for one the frontend lacks.
+- `New` and `Tty` run from the workspace and column tags over 9P, with no
+ panes too; `Help` and `Find` there act in the session's directory.
+- `pardes NEWFILE` forwards into a directory that does not exist yet; Save
+ makes it. The Tutor pane is `<dir>/+Tutor`.
+- Long-path errors keep their reason; `name` refuses a component over 255
+ bytes; regex refusals of an unanchored branch say why; acme's `Tab`,
+ `Indent`, `Local`, `Incl` and `Abort` are refused, not run as commands.
+- `Lspwhy` explains the last language-server query, whatever its kind.
+- Bulk body writes over 9P are about 15x faster per 60 KB write.
+
## 0.0.6
- A middle or right sweep in a column tag or the workspace tag runs or looks
diff --git a/src/File.zig b/src/File.zig
index e8a9e2f8..43b921e3 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -57,6 +57,9 @@ pub const State = struct {
/// replaces it: an edit that brings the text back to it is clean again,
/// as an undo to the save point is in acme.
saved_hash: ?u64 = null,
+ /// The length of the text `saved_hash` is of, when it is of one: an
+ /// 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
@@ -617,14 +620,22 @@ pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize {
/// The line index of `new` from `old`'s: an edit replaces one span, so
/// line starts before it stand, starts after it shift, and only the span
/// is scanned. Rebuilding scanned the whole file on every keystroke.
-fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize {
+/// The bytes a splice left alone at either end, when its caller knows them.
+pub const Span = struct { head: usize, tail: usize };
+
+fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8, known: ?Span) ![]usize {
const n = @min(old.len, new.len);
var head: usize = 0;
- while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64;
- while (head < n and old[head] == new[head]) head += 1;
var tail: usize = 0;
- while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64;
- while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1;
+ if (known) |span| {
+ head = span.head;
+ tail = span.tail;
+ } else {
+ while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64;
+ while (head < n and old[head] == new[head]) head += 1;
+ while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64;
+ while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1;
+ }
// A start s follows the newline at s-1: kept while that newline is in
// the common head, shifted while it is in the common tail.
const kept = std.sort.upperBound(usize, starts, head, struct {
@@ -677,7 +688,12 @@ test "edited line index equals a rebuilt one" {
const old = try gpa.dupe(u8, text.items);
defer gpa.free(old);
try text.replaceRange(gpa, at, del, ins[0..ins_len]);
- const edited = try editedLineStarts(gpa, starts, old, text.items);
+ // As a splice that says what it kept, and as one that does not.
+ const known: Span = .{ .head = at, .tail = old.len - at - del };
+ const told = try editedLineStarts(gpa, starts, old, text.items, known);
+ defer gpa.free(told);
+ const edited = try editedLineStarts(gpa, starts, old, text.items, null);
+ try std.testing.expectEqualSlices(usize, edited, told);
defer gpa.free(edited);
var fresh_state: State = undefined;
fresh_state.content = text.items;
@@ -906,6 +922,12 @@ fn reportEdit(p: *Pardes, f: *State, new: []const u8) void {
}
pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
+ setContentSpan(p, f, new, null);
+}
+
+/// `setContent` for a splice that knows what it left alone at either end,
+/// so the line index is not found again by comparing the whole text.
+pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
locations.freeRows(p.gpa, f.location_rows);
f.location_rows = &.{};
for (p.panes) |slot| if (slot) |pane| {
@@ -919,13 +941,17 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
reportEdit(p, f, new);
if (f.mini) |*mini| mini.deinit(p.gpa);
f.mini = null;
- const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{};
- // ponytail: a whole-text hash per edit; a rolling hash when files grow large.
- if (f.revision == f.saved_revision or f.saved_hash == null) f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new, span) catch &.{} else &.{};
+ // ponytail: a whole-text hash per edit that keeps the saved length; a
+ // rolling hash when that too matters.
+ if (f.revision == f.saved_revision or f.saved_hash == null) {
+ f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.saved_len = f.content.len;
+ }
p.gpa.free(f.content);
f.content = new;
f.revision +%= 1;
- if (f.saved_hash) |h| if (std.hash.Wyhash.hash(0, new) == h) {
+ if (f.saved_hash) |h| if ((f.saved_len orelse new.len) == new.len and std.hash.Wyhash.hash(0, new) == h) {
f.saved_revision = f.revision;
};
f.mtime = pardes.ctlfs.events.now();
diff --git a/src/Messages.zig b/src/Messages.zig
index d1478ac7..3eeb8c92 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -463,7 +463,10 @@ pub fn dialReason(err: anyerror) ?[]const u8 {
}
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
- var buf: [256]u8 = undefined;
+ // Room for an operation naming the longest path and the reason after
+ // it: a shorter one cut the reason off (`Save /long…`), and the reason
+ // is what a failure is for. Where it must be shorter, the path gives.
+ var buf: [@max(256, limits.host_path_cap + 128)]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
w.print("{s}: ", .{operation}) catch {};
// A peer out of reach says so, not the error's name (`dial`).
@@ -503,6 +506,11 @@ 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;
+ var fitted: @TypeOf(p.fs.said) = undefined;
+ const said = pardes.ctlfs.fitErr(text, &fitted);
+ @memcpy(p.fs.said[0..said.len], said);
+ p.fs.said_len = @intCast(said.len);
+ p.fs.said_at = p.fs.failures;
// A builtin a ctl write runs: its first error is also the write's, its
// path shortened in the middle if it must be, never its reason.
const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits;
diff --git a/src/Output.zig b/src/Output.zig
index 0581be41..545637b3 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -815,7 +815,11 @@ pub fn openConfig(p: *Pardes, id: usize) !void {
errdefer out.deinit();
var dir_buf: [1024]u8 = undefined;
const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4);
+ var located: std.Io.Writer.Allocating = .init(p.gpa);
+ defer located.deinit();
+ try p.locations_config.write(&located.writer);
try config.Runtime.writeReport(&out.writer, .{
+ .locations = located.written(),
.default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "",
.startup_config_path = p.opts.startup_config_path,
.platform = @tagName(pardes.platform),
@@ -912,7 +916,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void {
fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void {
errdefer p.gpa.free(content);
- const pane = p.panes[id] orelse return error.MissingPane;
+ if (p.panes[id] == null) return error.MissingPane;
for (p.panes, 0..) |slot, i| {
const hp = slot orelse continue;
const hf = if (hp.file) |*f| f else continue;
@@ -924,7 +928,9 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8)
p.active = i;
return;
}
- const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ // Where the word ran: the pane's directory, or from a column's or the
+ // workspace's tag, the session's.
+ const dir = p.execDir(id);
const free = p.freeSlot() orelse return error.NoPaneSlots;
_ = try open(p, free, dir, from, arg, content);
exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc);
diff --git a/src/builtins.zig b/src/builtins.zig
index c40b2e00..8fe49649 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -458,7 +458,8 @@ test "Ligatures is a setting only in the shell that shapes text" {
.state = &p.settings,
});
try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures off\n") != null);
- try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures") != null);
+ // A GUI-only setting reads so elsewhere, as every one the shell lacks.
+ try std.testing.expectEqual(!exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures unsupported\n") != null);
}
comptime {
diff --git a/src/config.zig b/src/config.zig
index a70ae708..94b32b88 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1275,6 +1275,8 @@ pub const Runtime = struct {
native_images: bool,
capabilities: Capabilities,
state: *const Runtime,
+ /// The `LocationsConfig` argument in effect, as it would be typed.
+ locations: []const u8 = "",
};
fn onOff(value: bool) []const u8 {
@@ -1285,23 +1287,19 @@ pub const Runtime = struct {
return if (text.len == 0) "(none)" else text;
}
- fn transitionSettingName(transition: animation.Transition) []const u8 {
- if (transition == .off) return "off";
- return findAction(.{ .transition = transition }).?.word;
- }
-
- /// Each line reads as the command that would set it: the word, then the
- /// value you would type after it. No colons, no units — `WindowOpacity 70`
- /// is both what DumpConfig prints and what you write back.
+ /// Each setting line reads as the command that would set it: the word,
+ /// then the value you would type after it (`unsupported` where this
+ /// shell has no such setting). No colons, no units: `WindowOpacity 70`
+ /// is both what DumpConfig prints and what you write back. What is in
+ /// effect but set by no word follows, after a blank line.
pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void {
const state = context.state;
- var wrote_transition = false;
for (settings) |setting| switch (setting.action) {
.toggle => |field| {
- // A toggle for what this shell does not draw (Ligatures in a
- // terminal, whose text its emulator draws) is no setting here:
- // it is not a builtin, so the report does not list it either.
- if (!setting.enabled(context.capabilities)) continue;
+ if (!setting.enabled(context.capabilities)) {
+ try out.print("{s} unsupported\n", .{setting.word});
+ continue;
+ }
const value = switch (field) {
inline else => |f| @field(state, @tagName(f)),
};
@@ -1310,11 +1308,7 @@ pub const Runtime = struct {
.theme => try out.print("{s} {s}\n", .{ setting.word, context.theme_name }),
.window_opacity => {
if (setting.enabled(context.capabilities))
- try out.print("{s} {d}{s}\n", .{
- setting.word,
- state.window_opacity,
- if (state.window_opacity_pending) " (pending)" else "",
- })
+ try out.print("{s} {d}\n", .{ setting.word, state.window_opacity })
else
try out.print("{s} unsupported\n", .{setting.word});
},
@@ -1333,64 +1327,28 @@ pub const Runtime = struct {
} }),
.shell => {
const chosen = state.shell.requested.get();
- try out.print(
- "{s} requested (new panes) {s}{s}\n" ++
- "{s} effective (last spawn) {s}\n" ++
- "{s} pending {s}\n",
- .{
- setting.word,
- if (chosen.len == 0) context.compiled_default_shell else chosen,
- if (chosen.len == 0) " (default)" else "",
- setting.word,
- shown(state.shell.effective.get()),
- setting.word,
- onOff(state.shell.pending),
- },
- );
+ try out.print("{s} {s}\n", .{ setting.word, if (chosen.len == 0) context.compiled_default_shell else chosen });
},
.font => if (!setting.enabled(context.capabilities))
try out.print("{s} unsupported\n", .{setting.word})
else {
- if (state.font.requested_size_hundredths == 0)
- try out.print("{s} requested size unchanged\n", .{setting.word})
- else
- try out.print("{s} requested size {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 });
- try out.print(
- "{s} requested {s}\n" ++
- "{s} requested path {s}\n" ++
- "{s} effective {s}\n" ++
- "{s} pending {s}\n" ++
- "{s} effective size {d}.{d:0>2} {s}\n",
- .{
- setting.word,
- shown(state.font.requested_name.get()),
- setting.word,
- shown(state.font.requested_path.get()),
- setting.word,
- shown(state.font.effective_name.get()),
- setting.word,
- onOff(state.font.pending),
- setting.word,
- state.font.effective_size_hundredths / 100,
- state.font.effective_size_hundredths % 100,
- @tagName(state.font.effective_size_unit),
- },
- );
+ // As `Font` takes it, `name:size`: the one asked for, else
+ // the one in use.
+ const f = &state.font;
+ const name = if (f.requested_path.get().len > 0) f.requested_path.get() else if (f.requested_name.get().len > 0) f.requested_name.get() else f.effective_name.get();
+ try out.print("{s} {s}", .{ setting.word, name });
+ if (f.requested_size_hundredths != 0) try out.print(":{d}.{d:0>2}", .{ f.requested_size_hundredths / 100, f.requested_size_hundredths % 100 });
+ try out.writeByte('\n');
},
.tagline_size => if (!context.capabilities.tagline_font_size)
try out.print("{s} unsupported\n", .{setting.word})
- else if (!setting.enabled(context.capabilities))
- try out.print("{s} {d} (build-time only)\n", .{ setting.word, state.font.tagline_percent })
else
try out.print("{s} {d}\n", .{ setting.word, state.font.tagline_percent }),
- .transition => {
- if (wrote_transition) continue;
- wrote_transition = true;
- if (!setting.enabled(context.capabilities))
- try out.writeAll("Panel transition unsupported\n")
- else
- try out.print("Panel transition {s}\n", .{transitionSettingName(state.panel_transition)});
- },
+ // Each a switch of its own, the one in use on.
+ .transition => |transition| if (!setting.enabled(context.capabilities))
+ try out.print("{s} unsupported\n", .{setting.word})
+ else
+ try out.print("{s} {s}\n", .{ setting.word, onOff(state.panel_transition == transition) }),
.scene => |effect| {
if (!setting.enabled(context.capabilities)) {
try out.print("{s} unsupported\n", .{setting.word});
@@ -1426,7 +1384,21 @@ pub const Runtime = struct {
.inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }),
.grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }),
};
+ if (context.locations.len > 0) try out.print("LocationsConfig {s}\n", .{context.locations});
+ // In effect, and set by no word.
+ 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)});
+ 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)});
+ }
+ if (find("WindowOpacity").?.enabled(context.capabilities))
+ 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");
if (context.startup_config_path) |path|
try out.print("Startup config: {s}\n", .{path})
else
@@ -1726,9 +1698,9 @@ pub const Runtime = struct {
.chip_shadow => "ChipShadow on\n",
.thumb_flash => "ThumbFlash on\n",
},
- .shell => "Shell requested (new panes) fish\n",
+ .shell => "Shell fish\n",
.tagline_size => "TaglineSize 73\n",
- .window_opacity => "WindowOpacity 85 (pending)\n",
+ .window_opacity => "WindowOpacity 85\n",
.window_blur => "WindowBlur 40\n",
.dump_dir => "DumpDir ~/dumps\n",
.message_ms => |which| switch (which) {
@@ -1738,17 +1710,17 @@ pub const Runtime = struct {
},
.transition => |transition| switch (transition) {
.off => unreachable,
- .slide => "Panel transition PanelSlide\n",
- .zoom => "Panel transition PanelZoom\n",
- .dissolve => "Panel transition PanelDissolve\n",
- .ascii => "Panel transition PanelAscii\n",
- .vertical => "Panel transition PanelVertical\n",
- .edges => "Panel transition PanelEdges\n",
- .fall => "Panel transition PanelFall\n",
- .wave => "Panel transition PanelWave\n",
- .curtain => "Panel transition PanelCurtain\n",
- .scramble => "Panel transition PanelScramble\n",
- .typewriter => "Panel transition PanelType\n",
+ .slide => "PanelSlide on\n",
+ .zoom => "PanelZoom on\n",
+ .dissolve => "PanelDissolve on\n",
+ .ascii => "PanelAscii on\n",
+ .vertical => "PanelVertical on\n",
+ .edges => "PanelEdges on\n",
+ .fall => "PanelFall on\n",
+ .wave => "PanelWave on\n",
+ .curtain => "PanelCurtain on\n",
+ .scramble => "PanelScramble on\n",
+ .typewriter => "PanelType on\n",
},
.scene => |effect| switch (effect) {
.crt => "Crt 2\n",
@@ -1788,12 +1760,10 @@ pub const Runtime = struct {
for ([_][]const u8{
"Theme acme\n",
"WindowOpacity 85\n",
- "Font requested size 18.50\n",
- "Font requested Wanted Mono\n",
- "Font requested path /fonts/wanted.ttf\n",
- "Font effective Effective Mono\n",
- "Font pending on\n",
- "Font effective size 13.75 points\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",
"TaglineSize 82\n",
"Startup config: /tmp/pardes/init\n",
"Platform: gui\n",
@@ -1823,19 +1793,23 @@ pub const Runtime = struct {
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur unsupported\n") != null);
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 requested (new panes) /bin/sh (default)\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null);
+ 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, "Font unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\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);
- // A shell that does not shape text has no Ligatures setting to report.
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures") == 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);
defaults_context.platform = "web";
defaults_context.capabilities.panel_transitions = false;
@@ -1843,8 +1817,9 @@ pub const Runtime = struct {
out = .fixed(&storage);
try writeReport(&out, defaults_context);
const web_report = storage[0..out.end];
- try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100 (build-time only)\n") != null);
+ 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);
}
};
diff --git a/src/dump.zig b/src/dump.zig
index 2d3c2593..1cc115be 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -852,6 +852,9 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
errdefer p.deinit();
if (previous) |old| {
p.next_serial = old.next_serial;
+ // Columns as panes: a restored one takes a fresh serial, never one
+ // a column of the old session had, living or deleted.
+ p.next_column_serial = old.next_column_serial;
p.lsp_seq = old.lsp_seq;
p.pipe.seq = old.pipe.seq;
p.cell_pixels = old.cell_pixels;
diff --git a/src/exec.zig b/src/exec.zig
index 8c062f66..eed9297a 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -346,7 +346,8 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void {
}
fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
- if (p.panes[from] == null) return null;
+ // No pane asking (an empty window's tag): the session's directory, as
+ // New's scratch is.
// A directory that is not there makes no terminal: its shell could not
// start in it, and a dead pane would answer no run. Asked only of a host
// that starts shells (none, as in the core's tests, starts none).
@@ -827,6 +828,11 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint:
.{ .acme = "Load", .pardes = null, .hint = ": Restore loads a dump" },
.{ .acme = "ID", .pardes = null },
.{ .acme = "Send", .pardes = null },
+ .{ .acme = "Tab", .pardes = null },
+ .{ .acme = "Indent", .pardes = null },
+ .{ .acme = "Local", .pardes = null },
+ .{ .acme = "Incl", .pardes = null },
+ .{ .acme = "Abort", .pardes = null },
};
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
diff --git a/src/fs.zig b/src/fs.zig
index 1d0bbc22..30ad9c43 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1335,11 +1335,20 @@ pub const Namespace = struct {
/// 0 the workspace's, a column's its index + 1; and whether the newline
/// that ended its last write is held back.
header_rewrite: ?u32 = null,
+ /// A column's or the workspace's tag truncated, not yet done (as a
+ /// pane tag's `tag_trunc_pending`): its key, the workspace's 0.
+ header_trunc_pending: ?u32 = null,
/// A look or exec a 9P write made found every pane slot taken; the
/// write fails with what was said, kept at the head of `ename`.
no_pane_slot: bool = false,
no_pane_slot_len: u16 = 0,
header_held: bool = false,
+ /// The newest failure said (reportFailure), fitted as an err is: its
+ /// path cut in the middle, never its reason, which the message row's
+ /// first 256 bytes lose. `said_at` is the `failures` count it was.
+ said: [256]u8 = undefined,
+ said_len: u16 = 0,
+ said_at: u32 = 0,
late_failure: [256]u8 = undefined,
late_failure_len: u16 = 0,
/// A 9P write asked a language server something: the answer count it
diff --git a/src/look.zig b/src/look.zig
index 65d001a9..36eae75c 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -583,8 +583,9 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
if (pat.len == 0) return 0;
const arena = p.scratch.allocator();
// where the pane lives: a file's directory, a shell's cwd — the walk
- // root, and the directory the results buffer is named in.
- const dir = Pardes.paneDir(pane);
+ // root, and the directory the results buffer is named in. From a
+ // column's or the workspace's tag, the session's.
+ const dir = if (p.exec_header) p.sessionDir() else Pardes.paneDir(pane);
const out = try arena.alloc(u8, filesystem.search_max_output_bytes);
var out_len: usize = 0;
var nrows: usize = 0;
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 43f94f95..33e95570 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -31,8 +31,34 @@ pub const Req = struct {
offset: u32,
arg: []const u8 = "",
root: []const u8 = "",
+ /// For `explain`: the query it narrates, read from `arg` (`<kind>
+ /// <arg>`) by `explaining`; a definition when none was asked before.
+ explains: Kind = .definition,
};
+/// An `explain` request made the one it narrates: `explains` and `arg` set
+/// from its `<kind> <arg>`; any other request as it is.
+pub fn explaining(req: Req) Req {
+ if (req.kind != .explain) return req;
+ var r = req;
+ const sp = std.mem.indexOfScalar(u8, req.arg, ' ') orelse req.arg.len;
+ const named = std.meta.stringToEnum(Kind, req.arg[0..sp]) orelse .definition;
+ r.explains = if (named == .explain or named == .status) .definition else named;
+ r.arg = if (sp < req.arg.len) req.arg[sp + 1 ..] else "";
+ return r;
+}
+
+test "an explain request narrates the query named in its arg, a definition by default" {
+ const base: Req = .{ .kind = .explain, .path = "/x.zig", .source = "", .offset = 0 };
+ var r = base;
+ r.arg = "hover ";
+ try std.testing.expectEqual(Kind.hover, explaining(r).explains);
+ r.arg = "rename newName";
+ try std.testing.expectEqual(Kind.rename, explaining(r).explains);
+ try std.testing.expectEqualStrings("newName", explaining(r).arg);
+ try std.testing.expectEqual(Kind.definition, explaining(base).explains);
+}
+
/// Every language, Zig's (zls) included, through the protocol client, which
/// runs its server as a child process. None on the web or the board.
const backends = if (@import("pardes_config").platform == .web or @import("pardes_config").platform == .esp32p4)
diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig
index 7704d0ad..0c5e3b7e 100644
--- a/src/lsp/lsp_client.zig
+++ b/src/lsp/lsp_client.zig
@@ -405,8 +405,9 @@ const Trace = struct {
// ------------------------------------------------------------------- query
-pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
+pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, asked: lsp.Req, out: *std.Io.Writer) !void {
_ = gpa;
+ const req = lsp.explaining(asked);
if (req.kind == .status) return status(req, out);
var tr: Trace = .{ .on = req.kind == .explain };
@@ -443,8 +444,8 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out
fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) std.Io.Writer.Error!void {
if (req.kind != .explain) return;
- try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{
- req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
+ try out.print("lsp explain — the {s} query at byte {d} of {s}\n\n", .{
+ @tagName(req.explains), req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
});
try out.writeAll(tr.buf[0..tr.len]);
if (hideTime())
@@ -488,9 +489,10 @@ fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer
}
fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void {
- // `explain` narrates the definition query — dispatch below on the
- // effective kind so the trace follows the code `gd` really runs.
- const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind;
+ // `explain` narrates the last query asked (a definition, `gd`, when
+ // none was) — dispatch below on the effective kind so the trace
+ // follows the code that query really runs.
+ const kind: lsp.Kind = if (req.kind == .explain) req.explains else req.kind;
// Both arg-taking kinds are useless without one, and an empty
// workspace/symbol query means "every symbol in the project".
diff --git a/src/main.zig b/src/main.zig
index 0e2ba954..6bdf2bfd 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -416,7 +416,15 @@ fn nativeMain(init: std.process.Init) !void {
// 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;
- const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding;
+ 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).
+ var cwd_buf: [4096]u8 = undefined;
+ if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) break :forwarding;
+ const cwd = std.mem.sliceTo(&cwd_buf, 0);
+ break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch break :forwarding;
+ };
break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding;
};
if (found_path != null) {
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 33a3f646..9674a323 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -304,6 +304,7 @@ pub const Addr = struct {
error.TooLong => regexp_.Regex.e_long,
error.WideRange => regexp_.Regex.e_wide,
error.NegatedRunes => regexp_.Regex.e_negated,
+ error.Mixed => regexp_.Regex.e_mixed,
error.Bad => e_regexp,
};
return null;
@@ -383,6 +384,18 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "an 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();
+ const node = Node.of(th.serialOf(p), .addr);
+ for ([_][]const u8{ "/^def|x/", "/foo$|^zèbre/" }) |pat| {
+ const r = th.wr(p, node, pat);
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expectEqualStrings(regexp_.Regex.e_mixed, r.reply.ename);
+ }
+ try testing.expectEqual(Status.ok, th.wr(p, node, "/foo$|def/").reply.status);
+}
+
test "a pattern with a newline and an anchor mvzr would misread is refused EINVAL, by addr and by Edit" {
const p = try th.withFile(testing.allocator, "a\nfoo\n");
defer p.deinit();
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index cc583c93..454cce4a 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -7,6 +7,7 @@ const std = @import("std");
const pardes = @import("../pardes.zig");
const Pardes = pardes.Pardes;
const tree = @import("tree.zig");
+const limits = @import("../memory.zig").limits;
const tagline = pardes.tagline;
const layout = pardes.layout;
const Text = pardes.panes.Text;
@@ -65,10 +66,11 @@ pub fn headerText(p: *Pardes, serial: ?u32) ?[]const u8 {
}
pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
+ applyTruncation(p, serial);
const text = headerText(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
const out = p.fs.stage(p.gpa);
+ // As a pane tag reads: the text alone, no newline after it.
out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
- out.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
return tree.stagedReply(p, req);
}
@@ -78,7 +80,19 @@ pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply {
const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text;
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- if (pardes.ctlfs.pane.tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why);
+ const pending = p.fs.header_trunc_pending == rewriteKey(serial);
+ // The same checks as a pane tag's, whole or not at all; a refused
+ // write drops the truncation it would have come with.
+ if (pardes.ctlfs.pane.tagFault(req.data)) |why| {
+ if (pending) p.fs.header_trunc_pending = null;
+ return tree.failText(req.tag, E.INVAL, why);
+ }
+ const had = if (pending) 0 else headerText(p, serial).?.len;
+ if (req.data.len > limits.max_tag_tail -| had) {
+ if (pending) p.fs.header_trunc_pending = null;
+ return tree.failText(req.tag, E.NOSPC, pardes.ctlfs.pane.e_tag_over);
+ }
+ applyTruncation(p, serial);
const was = headerText(p, serial).?;
var data = req.data;
const rewriting = p.fs.header_rewrite == rewriteKey(serial);
@@ -99,6 +113,20 @@ pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply {
/// Truncating clears it, as a pane tag's `cleartag`: its default words go
/// too, and `u` in it brings them back.
pub fn truncate(p: *Pardes, serial: ?u32) tree.Status {
+ if (header(p, serial) == null) return .err;
+ // Done with the write after it, which may yet be refused.
+ p.fs.header_trunc_pending = rewriteKey(serial);
+ return .ok;
+}
+
+/// A pending truncation of this header, done.
+pub fn applyTruncation(p: *Pardes, serial: ?u32) void {
+ if (p.fs.header_trunc_pending != rewriteKey(serial)) return;
+ p.fs.header_trunc_pending = null;
+ _ = clear(p, serial);
+}
+
+fn clear(p: *Pardes, serial: ?u32) tree.Status {
const t = (header(p, serial) orelse return .err).text;
const empty = p.gpa.alloc(u8, 0) catch return .err;
t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null);
@@ -118,6 +146,7 @@ fn rewriteKey(serial: ?u32) u32 {
}
pub fn released(p: *Pardes, serial: ?u32) void {
+ applyTruncation(p, serial);
if (p.fs.header_rewrite != rewriteKey(serial)) return;
p.fs.header_rewrite = null;
p.fs.header_held = false;
@@ -176,7 +205,8 @@ pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply {
if (paneWord(line, if (serial == null) pardes.config.topbar_str else tagline.column_tag_default))
return pardes.ctlfs.ctl.refuseTo(p, req, "not a session control message", line, "pane/<n>/ctl");
const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null;
- if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
+ // With no pane at all, a word runs at the stand-in, as a click in
+ // the empty window's tag does (runBuiltinFrom).
p.exec_column = col;
defer p.exec_column = null;
p.exec_header = true;
@@ -198,6 +228,19 @@ 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 "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();
+ const tagexec = @intFromEnum(tree.TopFile.tagexec);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status);
+ try testing.expectEqualStrings("", th.rd(p, tagexec, 0, 64).bytes);
+ // A column's New makes one, and answers it.
+ const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status);
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{p.panes[p.active].?.serial}), th.rd(p, col_exec, 0, 64).bytes);
+}
+
test "New written to a column's ctl reads back the new pane alone, as its exec does" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -218,6 +261,8 @@ fn paneWord(line: []const u8, own: []const u8) bool {
const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len];
const b = std.meta.stringToEnum(pardes.builtins.registry.Builtin(), word) orelse return false;
if (pardes.builtins.registry.scope(b) == .session) return false;
+ // A new pane is every tag's to make, in the session's directory.
+ if (b == .New or b == .Tty) return false;
var words = std.mem.tokenizeScalar(u8, own, ' ');
while (words.next()) |w| if (std.mem.eql(u8, w, word)) return false;
return true;
@@ -238,6 +283,47 @@ test "a pane's word written to /tagexec or a column's exec is refused, and acts
try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status);
}
+test "Help and Find at /tagexec act in the session's directory, not the keyboard pane's" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "needle.txt", .data = "n\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 tagexec = @intFromEnum(tree.TopFile.tagexec);
+ for ([_][]const u8{ "Help\n", "Find needle\n" }) |line| {
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, line).reply.status);
+ const made = p.panes[p.active].?.file.?.path;
+ try testing.expectEqualStrings(session, std.fs.path.dirname(made).?);
+ }
+}
+
+test "New and Tty run at /tagexec and a column's exec, with no pane at all too" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const tagexec = @intFromEnum(tree.TopFile.tagexec);
+ const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec);
+ for ([_]u64{ tagexec, col_exec }) |node| for ([_][]const u8{ "New\n", "Tty\n" }) |line| {
+ const before = p.next_serial;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, node, line).reply.status);
+ try testing.expectEqual(before + 1, p.next_serial);
+ };
+ // Every pane gone: the workspace tag makes one all the same.
+ for ([_][]const u8{ "New\n", "Tty\n" }) |line| {
+ while (p.ncol > 0) {
+ const first = p.panes[p.col_panes[0][0]].?.serial;
+ _ = th.wr(p, tree.Node.of(first, .ctl), "delete\n");
+ if (p.anyPane() == null) break;
+ }
+ try testing.expect(p.anyPane() == null);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, line).reply.status);
+ try testing.expect(p.anyPane() != null);
+ }
+}
+
test "every pane word a tag no pane owns does not hold is refused there, Edit included, and the pane is untouched" {
const p = try th.withFile(testing.allocator, "keep me\n");
defer p.deinit();
@@ -250,6 +336,7 @@ test "every pane word a tag no pane owns does not hold is refused there, Edit in
var checked: usize = 0;
for (std.enums.values(Builtin)) |b| {
if (pardes.builtins.registry.scope(b) == .session) continue;
+ if (b == .New or b == .Tty) continue; // every tag's
var owned = false;
var words = std.mem.tokenizeScalar(u8, tag_file.own, ' ');
while (words.next()) |w| owned = owned or std.mem.eql(u8, w, @tagName(b));
@@ -321,15 +408,15 @@ test "a column's tag and the workspace's read, replace and append as a pane tag
try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "777").errno());
const tag = Node.ofCol(serial, .tag);
try testing.expectEqual(tree.Status.ok, th.look_up(p, Node.ofCol(serial, .dir), "tag").reply.status);
- try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol\n", th.rd(p, tag, 0, 4096).bytes);
+ try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", th.rd(p, tag, 0, 4096).bytes);
// `echo Make > tag`: cleared, then the newline ending the write dropped.
_ = th.call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true });
_ = th.wr(p, tag, "Make\n");
- try testing.expectEqualStrings("Make\n", th.rd(p, tag, 0, 4096).bytes);
+ try testing.expectEqualStrings("Make", th.rd(p, tag, 0, 4096).bytes);
_ = th.call(p, .{ .tag = 2, .op = .release, .node = tag });
// `>>` appends; a newline in a one-line tag is a space.
_ = th.wr(p, tag, " Lint\nNow");
- try testing.expectEqualStrings("Make Lint Now\n", th.rd(p, tag, 0, 4096).bytes);
+ try testing.expectEqualStrings("Make Lint Now", th.rd(p, tag, 0, 4096).bytes);
const top = @intFromEnum(tree.TopFile.tag);
_ = th.call(p, .{ .tag = 3, .op = .setattr, .node = top, .truncate = true });
_ = th.wr(p, top, "Newcol Exit\n");
@@ -356,6 +443,35 @@ test "a focus write makes its pane's column the active one: layout says so and p
}
}
+test "a refused write after a truncation leaves every kind of tag as it was, and Dump still works" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const col = layout.columnSerial(p, 0);
+ const big = "w" ** (limits.max_tag_tail + 1);
+ // Each tag: a truncating open, then a write too long: nothing changes.
+ for ([_]u64{ Node.of(serial, .tag), Node.ofCol(col, .tag), @intFromEnum(tree.TopFile.tag) }) |node| {
+ _ = th.wr(p, node, " Mine");
+ const before = try testing.allocator.dupe(u8, th.rd(p, node, 0, 1 << 16).bytes);
+ defer testing.allocator.free(before);
+ _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = node, .truncate = true });
+ 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);
+ _ = 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.
+ try testing.expectEqual(E.INVAL, th.wr(p, node, "a\x01b").errno());
+ // A truncation with no write after it clears at the close.
+ _ = th.call(p, .{ .tag = 5, .op = .setattr, .node = node, .truncate = true });
+ const e = th.call(p, .{ .tag = 6, .op = .open, .node = node, .omode = 1 }).reply.handle;
+ _ = th.call(p, .{ .tag = 7, .op = .release, .node = node, .handle = e, .opened = true });
+ try testing.expect(std.mem.indexOf(u8, th.rd(p, node, 0, 1 << 16).bytes, "Mine") == null);
+ }
+ try pardes.dump.dumpState(p);
+}
+
test "a column's ctl and exec act on it as its tag would, and rmdir closes it only empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 5ed03c58..0bbc0319 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -93,6 +93,12 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo
// touched.
if (p.fs.refilled) |serial| if (p.paneBySerial(serial) != null) return noteResult(p, serial);
if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial);
+ // A word in a column's or the workspace's tag ran at no pane: it
+ // answers one only if it focused one (Newcol answers nothing).
+ if (p.exec_header) {
+ if (p.active != id) if (p.panes[p.active]) |pane| noteResult(p, pane.serial);
+ return;
+ }
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
@@ -233,6 +239,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i
p.fs.results_len = 0;
return refuse(p, req, "wrong #args in control message", cmd);
};
+ // ...and one that takes none, written with one (`Config extra`):
+ // refused as a ctl's is, not run as a shell command of that name.
+ if (std.mem.indexOfAny(u8, cmd, " \t")) |sp| if (std.meta.stringToEnum(Builtin, cmd[0..sp])) |b| {
+ if (!builtins.registry.takesArg(b) and config.Runtime.find(cmd[0..sp]) == null) {
+ p.fs.results_len = 0;
+ return refuse(p, req, "wrong #args in control message", cmd);
+ }
+ };
}
p.fs.capturing = true;
p.fs.failure_len = 0;
@@ -372,17 +386,27 @@ const Builtin = builtins.registry.Builtin();
/// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`.
fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
const room = p.fs.ename.len -| (why.len + 3);
- const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why;
+ var shown: [320]u8 = undefined;
+ const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, blanked(line[0..@min(line.len, room)], &shown) }) catch why;
return tree.failText(req.tag, E.INVAL, text);
}
/// `refuse`, saying which ctl takes the message instead.
pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply {
const room = p.fs.ename.len -| (why.len + ctl.len + 18);
- const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) catch why;
+ var shown: [320]u8 = undefined;
+ const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(line[0..@min(line.len, room)], &shown), ctl }) catch why;
return tree.failText(req.tag, E.INVAL, text);
}
+/// A quoted line as a refusal shows it: a control byte (a tab, the one a
+/// line may hold) is a blank, so the reason reads as one line of words.
+fn blanked(line: []const u8, buf: *[320]u8) []const u8 {
+ const n = @min(line.len, buf.len);
+ for (line[0..n], buf[0..n]) |c, *o| o.* = if (c < ' ' or c == 0x7f) ' ' else c;
+ return buf[0..n];
+}
+
/// Checks one line written to a ctl as a builtin of `scope` before any line
/// of the write runs: a word the registry knows, of this ctl's scope, given
/// an argument if and only if it takes or requires one, and for a setting a
@@ -886,8 +910,10 @@ fn acmeCtl(p: *Pardes, req: Req, serial: u32, line: []const u8, apply: bool) Acm
const File = tree.PaneFile;
// A word written to one of the pane's own files, as acme's is to its.
const Into = struct { file: File, data: []const u8 };
+ // `name` takes all after its one blank, as the name file takes what is
+ // written: a blank at its start is refused there, not trimmed here.
const into: ?Into = if (std.mem.eql(u8, word, "name") and rest.len > 0)
- .{ .file = .name, .data = rest }
+ .{ .file = .name, .data = line[word.len + 1 ..] }
else if (std.mem.eql(u8, line, "clean"))
.{ .file = .dirty, .data = "0" }
else if (std.mem.eql(u8, line, "dirty"))
@@ -983,6 +1009,7 @@ fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 {
panes.File.setContent(p, f, bytes);
f.saved_revision = f.revision;
f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.saved_len = f.content.len;
f.disk_newer = null;
f.disk_gone = false;
if (discarded) {
@@ -1007,6 +1034,21 @@ const root_status = @intFromEnum(tree.TopFile.status);
const root_look = @intFromEnum(tree.TopFile.look);
const root_exec = @intFromEnum(tree.TopFile.exec);
+test "a control character in a write to any ctl refuses the whole write, and a quoted line shows a tab as a blank" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const col = layout.columnSerial(p, 0);
+ for ([_]u64{ @intFromEnum(tree.TopFile.ctl), Node.of(serialOf(p), .ctl), Node.ofCol(col, .ctl) }) |node| {
+ const r = wr(p, node, "Wrap off\nbo\x01gus\n");
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expectEqualStrings(e_control, r.reply.ename);
+ }
+ const quoted = wr(p, Node.of(serialOf(p), .ctl), "bogus\tword\n");
+ try testing.expectEqual(E.INVAL, quoted.errno());
+ try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "bogus word") != null);
+ try testing.expect(std.mem.indexOfScalar(u8, quoted.reply.ename, '\t') == null);
+}
+
test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then whether it is current" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
@@ -2403,11 +2445,33 @@ 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 "the ctl word name refuses a leading blank, as the name file does" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const r = wr(p, Node.of(serial, .ctl), "name /tmp/lead.txt\n");
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expectEqualStrings("bad character in file name: a blank at its start", r.reply.ename);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "name /tmp/ok.txt\n").reply.status);
+ try testing.expectEqualStrings("/tmp/ok.txt", pane_files.nameOf(p, p.panes[0].?));
+}
+
+test "a builtin that takes no argument, written with one to an exec, is refused wrong #args, as at a ctl" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec) }) |node| for ([_][]const u8{ "Config extra\n", "DumpConfig extra\n" }) |line| {
+ const r = wr(p, node, line);
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expectStringStartsWith(r.reply.ename, "wrong #args in control message");
+ };
+}
+
test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask) or are refused, never run as commands" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
const serial = serialOf(p);
- for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n" }) |line| {
+ for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n", "Tab\n", "Indent\n", "Local\n", "Incl\n", "Abort\n" }) |line| {
for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| {
const r = wr(p, node, line);
try testing.expectEqual(E.INVAL, r.errno());
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index fe7af77e..c6505f52 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -153,10 +153,17 @@ fn bootFirst(p: *Pardes) void {
/// Records `<kind> <serial> <name>`.
pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
+ // Nothing about a pane comes before its `new`: one not announced yet
+ // is announced first, and a rename of it is no record, as its `new`
+ // carries the name it has by then.
+ if (pane.fs.unannounced and kind != .new) {
+ if (kind == .rename) return;
+ announce(p);
+ }
var buf: [4 * 4096 + 64]u8 = undefined;
var name_buf: [4 * 4096]u8 = undefined;
// As /index shows it: a newline in the name is `\n`.
- const name = shown(pane_files.nameOf(pane), &name_buf);
+ const name = shown(pane_files.nameOf(p, pane), &name_buf);
pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name }) catch return);
}
@@ -1011,7 +1018,7 @@ test "the log records whether or not anyone reads, and an open that follows wait
// Nobody holds /log open, and the pane is recorded anyway.
const early = try th.newPane(p);
- const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p.panes[p.paneBySerial(early).?].?) });
+ const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p, p.panes[p.paneBySerial(early).?].?) });
const early_owned = try gpa.dupe(u8, early_line);
defer gpa.free(early_owned);
@@ -1048,7 +1055,7 @@ test "the log records whether or not anyone reads, and an open that follows wait
const serial = try th.newPane(p);
const id = p.paneBySerial(serial).?;
try testing.expectEqualStrings(
- try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p.panes[id].?) }),
+ try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p, p.panes[id].?) }),
rdf.next(p, log, fh, frozen).bytes,
);
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/logged.txt\n").reply.status);
@@ -1176,6 +1183,45 @@ test "a Dump written and a Restore made are in the log, with their files" {
try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restoredcol {d} {d}\n", .{ pardes.layout.columnSerial(p, 0), pardes.layout.columnSerial(restored, 0) })));
}
+test "a Restore gives columns fresh serials, past every serial the old session handed out" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const first = pardes.layout.columnSerial(p, 0);
+ // Serials spent on columns since deleted.
+ p.next_column_serial += 5;
+ const spent = p.next_column_serial;
+ try dump.dumpState(p);
+ const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ for (0..restored.ncol) |c| try testing.expect(pardes.layout.columnSerial(restored, c) >= spent);
+ try testing.expect(pardes.layout.columnSerial(restored, 0) != first);
+}
+
+test "a pane named before it is announced has its new under that name, and no rename before it" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ // As a pane Config makes and names in one update.
+ pane.fs.unannounced = true;
+ try pane_files.nameBuffer(p, 0, "/tmp/named-first.txt", false);
+ announce(p);
+ const log = try freezeLog(p);
+ defer p.gpa.free(log.bytes);
+ var want: [64]u8 = undefined;
+ try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} /tmp/named-first.txt\n", .{pane.serial})));
+ try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{pane.serial})) == null);
+ // A shell given no directory is named by the session's from the first,
+ // and saying it is there is no rename.
+ const free = p.freeSlot().?;
+ const shell = try p.newShell(free, "");
+ try testing.expectEqualStrings(p.sessionDir(), pane_files.nameOf(p, shell));
+ announce(p);
+ p.setCwd(free, p.sessionDir());
+ const after = try freezeLog(p);
+ defer p.gpa.free(after.bytes);
+ try testing.expect(std.mem.indexOf(u8, after.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{shell.serial})) == null);
+}
+
test "opens of the log share the open records, and a closed one frees its record" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 3fad1fae..6a0fbba5 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -77,6 +77,10 @@ pub const State = struct {
/// whole of it is dropped and draws no empty row.
tag_rewrite: bool = false,
tag_held_newline: bool = false,
+ /// A truncation of `tag` not yet done: the write after it does it, once
+ /// the write is known to fit, so a refused `>` leaves the tag as it was;
+ /// a read or a close with no write does it then.
+ tag_trunc_pending: bool = false,
/// The pty's size as pty/ctl's winsize last set it, until the pane's own
/// grid resizes and gives the pty its size again.
winsize: ?[2]u16 = null,
@@ -130,12 +134,14 @@ pub fn dirtyOf(pane: *const Pane) bool {
}
/// The pane's name: its file, its document, or a terminal's directory.
-pub fn nameOf(pane: *const Pane) []const u8 {
+pub fn nameOf(p: *Pardes, pane: *const Pane) []const u8 {
if (pane.file) |*f| return f.path;
if (pane.pdfPath()) |path| return path;
if (pane.image) |state| return state.path;
const cwd = pane.cwdSlice();
- return if (cwd.len > 0) cwd else "/";
+ // A shell given no directory was started in the session's, and is
+ // named by it until it says where it is (a `new` then, not a rename).
+ return if (cwd.len > 0) cwd else p.sessionDir();
}
pub fn kindOf(pane: *const Pane) []const u8 {
@@ -149,12 +155,62 @@ pub fn clip(n: usize) u32 {
return std.math.cast(u32, n) orelse std.math.maxInt(u32);
}
+/// The file's line index when it describes `text` (built by a render or a
+/// look), else null: with it a row is a binary search, not a count of every
+/// newline from the top, which a bulk write paid on each flush, twice over.
+fn lineIndex(pane: *const Pane, text: []const u8) ?[]const usize {
+ const f = if (pane.file) |*file| file else return null;
+ if (f.line_starts.len == 0 or f.content.ptr != text.ptr or f.content.len != text.len) return null;
+ return f.line_starts;
+}
+
+/// The line `off` is on: the last start at or before it.
+fn rowAt(starts: []const usize, off: usize) usize {
+ const after = std.sort.upperBound(usize, starts, off, struct {
+ fn order(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.order);
+ return after -| 1;
+}
+
+fn lineEnd(starts: []const usize, text: []const u8, row: usize) usize {
+ return if (row + 1 < starts.len) starts[row + 1] - 1 else text.len;
+}
+
+/// `modal.runeOffsetAt`, through the line index when there is one.
+fn runeOffsetAt(pane: *const Pane, text: []const u8, c: modal.Cursor) usize {
+ const starts = lineIndex(pane, text) orelse return modal.runeOffsetAt(text, c);
+ const row = @min(c.row, starts.len - 1);
+ const s = starts[row];
+ const e = lineEnd(starts, text, row);
+ return s + modal.runeStart(text[s..e], @min(c.col, e - s));
+}
+
+/// `modal.runePositionAt`, through the line index when there is one.
+fn runePositionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor {
+ const starts = lineIndex(pane, text) orelse return modal.runePositionAt(text, off);
+ const bounded = modal.runeStart(text, off);
+ const row = rowAt(starts, bounded);
+ return .{ .row = row, .col = bounded - starts[row] };
+}
+
+/// `modal.positionAt`, through the line index when there is one.
+fn positionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor {
+ const starts = lineIndex(pane, text) orelse return modal.positionAt(text, off);
+ const bounded = @min(off, text.len);
+ const row = rowAt(starts, bounded);
+ const s = starts[row];
+ const e = lineEnd(starts, text, row);
+ return .{ .row = row, .col = modal.graphemeStart(text[s..e], @min(bounded - s, e - s)) };
+}
+
pub fn dotOf(pane: *Pane) State.Range {
const text = bodyOf(pane);
// In runes, as every address is (modal.runeStart).
- const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
+ const head = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
- const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
+ const anchor = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
var hi = @max(head, anchor);
if (hi < text.len) hi = modal.nextRune(text, hi);
return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) };
@@ -164,9 +220,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void {
const text = bodyOf(pane);
const q0 = @min(@as(usize, r.q0), text.len);
const q1 = @max(q0, @min(@as(usize, r.q1), text.len));
- const a = modal.runePositionAt(text, q0);
+ const a = runePositionAt(pane, text, q0);
pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
- const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0);
+ const h = runePositionAt(pane, text, if (q1 > q0) modal.prevRune(text, q1) else q0);
pane.body.cur_row = @intCast(h.row);
pane.body.cur_col = @intCast(h.col);
pane.body.cur_pinned = true;
@@ -177,7 +233,7 @@ pub fn setDot(pane: *Pane, r: State.Range) void {
pub fn showOffset(pane: *Pane, off: usize) void {
const text = bodyOf(pane);
- const c = modal.positionAt(text, @min(off, text.len));
+ const c = positionAt(pane, text, @min(off, text.len));
pane.body.cur_row = @intCast(c.row);
pane.body.cur_col = @intCast(c.col);
pane.body.cur_pinned = true;
@@ -229,10 +285,28 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
const join = pane.fs.joined == f.revision;
pane.fs.joined = null;
if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane);
- panes.File.setContent(p, f, new);
+ panes.File.setContentSpan(p, f, new, .{ .head = lo, .tail = f.content.len - hi });
return take;
}
+test "rows through the line index are the ones counting newlines gives, at every offset" {
+ const p = try withFile(testing.allocator, "héllo\nwo\u{301}rld\n\n last line é\ntail");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const f = &pane.file.?;
+ _ = try panes.File.lineIndex(p.gpa, f);
+ const text = bodyOf(pane);
+ try testing.expect(lineIndex(pane, text) != null);
+ for (0..text.len + 2) |off| {
+ try testing.expectEqual(modal.positionAt(text, off), positionAt(pane, text, off));
+ try testing.expectEqual(modal.runePositionAt(text, off), runePositionAt(pane, text, off));
+ }
+ for (0..8) |row| for (0..16) |col| {
+ const c: modal.Cursor = .{ .row = row, .col = col };
+ try testing.expectEqual(modal.runeOffsetAt(text, c), runeOffsetAt(pane, text, c));
+ };
+}
+
test "a terminal's body stats as long as it reads" {
const gpa = testing.allocator;
const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
@@ -261,7 +335,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
.body => if (pane.file == null and pane.isTerminal()) panes.terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len,
.data, .xdata => bodyOf(pane).len,
.tag => tagOf(p, pane).len,
- .name => nameOf(pane).len + 1,
+ .name => nameOf(p, pane).len + 1,
.ctl => ctl: {
var buf: [ctl.pane_text_cap]u8 = undefined;
break :ctl ctl.paneText(p, pane, &buf).len;
@@ -308,7 +382,7 @@ pub fn indexLen(p: *Pardes) u64 {
// unreachable: a u32 serial is at most 10 digits
n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len;
var name_buf: [4 * 4096]u8 = undefined;
- n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(pane), &name_buf).len + 1;
+ n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(p, pane), &name_buf).len + 1;
// unreachable: a column index is under 16 digits
n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len;
}
@@ -322,12 +396,13 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
return switch (file) {
.name => name: {
const out = p.fs.stage(p.gpa);
- out.appendSlice(p.gpa, nameOf(pane)) catch {};
+ out.appendSlice(p.gpa, nameOf(p, pane)) catch {};
out.append(p.gpa, '\n') catch {};
break :name tree.stagedReply(p, req);
},
.body => readBody(p, req, id, pane),
.tag => tag: {
+ applyTagTruncation(p, pane);
const out = p.fs.stage(p.gpa);
out.appendSlice(p.gpa, tagOf(p, pane)) catch {};
break :tag tree.stagedReply(p, req);
@@ -448,7 +523,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
return switch (file) {
.name => writeName(p, req, id, pane),
.body => writeBody(p, req, id, pane),
- .tag => writeTag(req, pane),
+ .tag => writeTag(p, req, pane),
.ctl => ctl.writePane(p, req, pane),
.addr, .dot, .limit => writeRange(req, pane, file),
.data, .xdata => writeData(p, req, pane),
@@ -562,16 +637,28 @@ pub fn tagFault(data: []const u8) ?[]const u8 {
return null;
}
-fn writeTag(req: Req, pane: *Pane) Reply {
+/// 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});
+
+fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- if (tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why);
+ const pf = &pane.fs;
+ // A refused write after a truncation changes nothing: the truncation
+ // it would have come with is dropped too.
+ if (tagFault(req.data)) |why| {
+ pf.tag_trunc_pending = false;
+ return tree.failText(req.tag, E.INVAL, why);
+ }
// The tag's own text grows by what is written, newlines and all: a tag
// is a text like any other (acme's tag file appends the same way).
- const pf = &pane.fs;
- const had = tagline.curTail(pane).len;
- const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline);
- // Whole or not at all: a write that would pass the limit changes nothing.
- if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}));
+ const had = if (pf.tag_trunc_pending) 0 else tagline.curTail(pane).len;
+ const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline and !pf.tag_trunc_pending);
+ if (req.data.len > room) {
+ pf.tag_trunc_pending = false;
+ return tree.failText(req.tag, E.NOSPC, e_tag_over);
+ }
+ applyTagTruncation(p, pane);
const take = wholeUtf8(req.data);
// A truncating write drops ONE trailing newline, its whole text's: a
// newline held from a write before goes in once more text follows it.
@@ -588,6 +675,26 @@ fn writeTag(req: Req, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(take) };
}
+/// A pending truncation of the tag, done: its text cleared as `cleartag`
+/// clears it, the prefix left.
+pub fn applyTagTruncation(p: *Pardes, pane: *Pane) void {
+ const pf = &pane.fs;
+ if (!pf.tag_trunc_pending) return;
+ pf.tag_trunc_pending = false;
+ const empty = p.gpa.alloc(u8, 0) catch return;
+ // An edit like a keyboard one: `u` in the tag brings it back.
+ pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null);
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = empty;
+ // what is left is the prefix; the cursor goes after it
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
+ pane.tag.vsel.active = false;
+ pane.tag.nsel = 0;
+ pf.tag_rewrite = true;
+ pf.tag_held_newline = false;
+}
+
/// A write only text takes, to a pane with none: said, as every EINVAL is.
pub const e_no_text = "invalid write: this pane has no text (a terminal, an image or a PDF)";
@@ -738,6 +845,9 @@ fn nameFault(name: []const u8) ?[]const u8 {
}
if (name[0] == ' ') return e_name_char ++ ": a blank at its start";
if (name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end";
+ // No file system takes a longer one (NAME_MAX): a Save would only fail.
+ var parts = std.mem.splitScalar(u8, name, '/');
+ while (parts.next()) |part| if (part.len > 255) return "invalid file name: a component over 255 bytes";
return null;
}
@@ -757,6 +867,11 @@ 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) };
}
@@ -855,20 +970,8 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) });
pf.addr = .{ .q0 = q0, .q1 = q0 };
},
- .tag => {
- const empty = p.gpa.alloc(u8, 0) catch return .err;
- // An edit like a keyboard one: `u` in the tag brings it back.
- pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null);
- if (pane.tag.own) |own| p.gpa.free(own);
- pane.tag.own = empty;
- // what is left is the prefix; the cursor goes after it
- pane.tag.cur_row = 0;
- pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
- pane.tag.vsel.active = false;
- pane.tag.nsel = 0;
- pf.tag_rewrite = true;
- pf.tag_held_newline = false;
- },
+ // Done with the write after it, which may yet be refused.
+ .tag => pf.tag_trunc_pending = true,
// A shell's `>` truncates before it writes: the address written
// is evaluated from where the last one left off, as with `>>`.
// `0` (or `,`) is how to start over. See State.addr.
@@ -923,7 +1026,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" {
try testing.expectEqual(@as(u64, both.bytes.len), indexLen(p));
var expected: [4200]u8 = undefined;
const made = p.panes[p.paneBySerial(serial).?].?;
- try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(made), columnOf(p, p.paneBySerial(serial).?) })));
+ try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(p, made), columnOf(p, p.paneBySerial(serial).?) })));
}
test "a terminal is listed as term with its directory as name" {
@@ -1260,13 +1363,46 @@ test "a write of two lines to name is refused EINVAL, on a held open or not" {
const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle;
try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/a\n/tmp/b\n" }).errno());
_ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true });
- try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
// Line by line, as bash writes it: the second line is refused.
const h2 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle;
try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-a\n" }).reply.status);
try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-b\n" }).errno());
_ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h2, .opened = true });
- try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
+}
+
+test "a name with a component over 255 bytes is refused, and a long path's failed Save keeps its reason" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const refused = wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 256 ++ "/f.txt\n");
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectEqualStrings("invalid file name: a component over 255 bytes", refused.reply.ename);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 255 ++ "\n").reply.status);
+ // A Save of a 3000-byte path the host refuses: the waiting write's
+ // reason is at the end, the path giving up its middle.
+ const long = "/nonexistent-pardes-root/" ++ ("d" ** 200 ++ "/") ** 15 ++ "f.txt";
+ const id = p.paneBySerial(serial).?;
+ try nameBuffer(p, id, long, false);
+ p.fs.late_failure_len = 0;
+ p.saveFailed(@intCast(id), long, error.AccessDenied);
+ const late = p.fs.late_failure[0..p.fs.late_failure_len];
+ try testing.expect(std.mem.startsWith(u8, late, "Save /nonexistent-pardes-root/"));
+ 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" {
@@ -1280,16 +1416,16 @@ test "a name cut across writes is one name, applied once at its newline or its c
p.fs.write_room = "/tmp/pardes-na".len;
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" });
p.fs.write_room = 0;
- try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" });
- try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
_ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true });
// No newline, the whole write: applied at once, on its own open.
const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle;
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" });
- try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
_ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true });
- try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
}
test "a replace through data, truncate then write, is one undo step" {
@@ -1329,12 +1465,15 @@ test "truncating the tag clears its editable tail" {
try testing.expect(tagline.curTail(pane).len > 0);
const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true });
try testing.expectEqual(Status.ok, cleared.reply.status);
+ // Done at the first read or write after it (or the close).
+ _ = rd(p, tag, 0, 4096);
try testing.expectEqualStrings("", pane.tag.own.?);
try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null);
// The tag keeps its undo: undoing in it brings back what was cleared.
pardes.edit.doUndo(p, &pane.tag);
try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine"));
_ = call(p, .{ .tag = 2, .op = .setattr, .node = tag, .truncate = true });
+ _ = rd(p, tag, 0, 4096);
try testing.expectEqualStrings("", pane.tag.own.?);
// Written back without a leading space, the text still stands apart
@@ -1373,7 +1512,7 @@ test "name reads the file name and writing it promotes a scratch without touchin
const name = Node.of(serial, .name);
const body = Node.of(serial, .body);
var scratch_name: [4200]u8 = undefined;
- const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(pane)});
+ const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(p, pane)});
try testing.expectEqualStrings(scratch_line, rd(p, name, 0, 4096).bytes);
try testing.expectEqual(@as(u64, scratch_line.len), call(p, .{ .tag = 1, .op = .getattr, .node = name }).reply.attr.size);
_ = wr(p, body, "scratch ");
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 5f3ffc9c..17941e10 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -605,7 +605,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
.tag, .exec => 0o666,
},
.size = switch (c.file) {
- .tag => cols.headerText(p, c.serial).?.len + 1,
+ .tag => cols.headerText(p, c.serial).?.len,
.exec => ctl.resultsLen(p),
else => 0,
},
@@ -647,7 +647,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
// and /pane/new none until an open has a pane to name.
.root, .pane, .col, .screen, .new => 0,
.layout => cols.layoutLen(p),
- .tag => cols.headerText(p, null).?.len + 1,
+ .tag => cols.headerText(p, null).?.len,
.index => pane.indexLen(p),
.README => fs.help.len,
.status => ctl.statusLen(p),
@@ -935,6 +935,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
// A truncating write to `tag` is over when its open goes (pane.zig
// writeTag): a newline it held back is dropped.
if (t == .pane and t.pane.file == .tag) if (p.paneBySerial(t.pane.serial)) |id| {
+ pane.applyTagTruncation(p, p.panes[id].?);
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};
@@ -1052,7 +1053,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
// A name as /log shows it: one line of UTF-8, a
// newline in it `\n` (events.shown).
var name_buf: [4 * 4096]u8 = undefined;
- const name = events.shown(pane.nameOf(pn), &name_buf);
+ const name = events.shown(pane.nameOf(p, pn), &name_buf);
out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), name, pane.columnOf(p, id) }) catch {};
}
break :index stagedReply(p, req);
@@ -1146,12 +1147,21 @@ fn holdsLines(p: *Pardes, req: Req) bool {
/// A line held back longer than this is refused rather than kept growing.
const pending_cap = 1 << 20;
+/// The ctl files, whose lines are commands as look's and exec's are.
+fn ctlFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .ctl,
+ .col => |c| c.file == .ctl,
+ .pane => |t| t.file == .ctl,
+ };
+}
+
fn write(p: *Pardes, req: Req, target: Target) Reply {
+ // A command line holds no control character but a tab: the whole write
+ // is refused at once, not held to fail unseen at the close.
+ if (resultsFile(target) or ctlFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control);
const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target);
if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target);
- // A clicked line holds no control character: refused at once, not held
- // to fail unseen at the close.
- if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control);
o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
var end = ctl.completeEnd(p, o.pending.items);
// A tail with no newline is a whole line when the write is the whole of
diff --git a/src/pardes.zig b/src/pardes.zig
index a63568db..f0d22d57 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1112,6 +1112,46 @@ test "first document fallback splits like Tty from the originating pane" {
};
}
+test "Lspwhy after a Hover asks to explain that hover, at its place" {
+ if (!lsp.supports.contains(.hover)) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("const a = 1;\nconst b = a;\n");
+ const f = &pane.file.?;
+ p.gpa.free(f.path);
+ f.path = try p.gpa.dupe(u8, "/tmp/x.zig");
+ while (p.nextEffect()) |_| {}
+ pane.body.cur_row = 1;
+ pane.body.cur_col = 10;
+ p.lspRequest(0, .hover, "");
+ var hover_at: u32 = 0;
+ while (p.nextEffect()) |e| if (e == .lsp) {
+ hover_at = e.lsp.offset;
+ };
+ pane.body.cur_row = 0;
+ pane.body.cur_col = 0;
+ p.lsp_wait = null;
+ p.lspRequest(0, .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);
+ seen = true;
+ };
+ try std.testing.expect(seen);
+}
+
+test "the Tutor pane is named +Tutor in the session's directory, never a bare path at the root" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
+ const tutor = try p.openTutorView(1);
+ try std.testing.expectEqualStrings("/project/src/+Tutor", tutor.file.?.path);
+}
+
test "first document keeps its originating shell and only opens a column when it fits" {
for ([_]u16{ 199, 200 }) |width| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 });
@@ -3543,7 +3583,9 @@ pub const Effect = union(enum) {
write_dump,
set_clipboard,
read_clipboard,
- lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) },
+ /// `arg` up to 128 bytes, or for `explain` the query it explains,
+ /// `<kind> <arg>`.
+ lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(160) },
/// Snapshot the matching request with pipeRequest(id), then run it away
/// from the UI/event loop and answer with pipe_resp.
pipe: struct { id: u32 },
@@ -4341,6 +4383,9 @@ pub const Pardes = struct {
can_ask: bool = false,
lsp_seq: u32 = 0,
+ /// The last query asked (Lspwhy explains it): its kind, argument, and
+ /// the pane and place it was asked at.
+ lsp_last: ?struct { kind: lsp.Kind, arg: Effect.Buf(128), serial: u32, offset: u32 } = null,
lsp_wait: ?struct {
id: u32,
kind: lsp.Kind,
@@ -5046,11 +5091,14 @@ pub const Pardes = struct {
// is this process's directory, a rename to undo. That answer, then,
// is not taken.
if (pane.isTerminal() and !pane.shell_spoke and pane.cwd == .owned and std.mem.eql(u8, cwd, p.sessionDir())) return;
+ // One with no directory yet is named by the session's (nameOf): its
+ // shell saying it is there is no new name.
+ const same_name = cur.len == 0 and std.mem.eql(u8, cwd, p.sessionDir());
pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err);
if (look.lookHoverPane(p) == id) look.cancelLookHover(p);
// A terminal is named by its directory, so /log hears it renamed; one
// not announced yet is announced under the new name.
- if (pane.isTerminal() and !pane.fs.unannounced) ctlfs.events.noteLog(p, .rename, pane);
+ if (pane.isTerminal() and !same_name) ctlfs.events.noteLog(p, .rename, pane);
}
pub fn shellBin(p: *const Pardes) []const u8 {
@@ -5338,10 +5386,7 @@ pub const Pardes = struct {
}
// 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.noteLateFailure(id);
}
pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
@@ -5359,10 +5404,7 @@ pub const Pardes = struct {
p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: no such directory", .{path}) catch "Save: no such directory")
else
p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err);
- const said = if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else "Save failed";
- const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
- @memcpy(p.fs.late_failure[0..kept.len], kept);
- p.fs.late_failure_len = @intCast(kept.len);
+ p.noteLateFailure(id);
}
/// A Dump the host could not write: said on the message row and in the
@@ -5385,10 +5427,7 @@ pub const Pardes = struct {
} else p.reportError(p.active, operation, err);
// In the words the message row has, so the write's err is the one
// record of it (its msg goes: 9p_io, dropMessage).
- const said = if (p.panes[p.active]) |pane| pane.msg[0..pane.msg_len] else "Dump failed";
- 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.noteLateFailure(p.active);
}
/// The path a watch is about: a real file's, or a PDF's.
@@ -6291,6 +6330,17 @@ pub const Pardes = struct {
.row = @intCast(@max(0, pane.body.cur_row)),
.col = @intCast(@max(0, pane.body.cur_col)),
}) else 0;
+ // Lspwhy asks the last query again, narrated: its kind and argument,
+ // at its place when asked from its pane. None yet: a definition.
+ var explained: [160]u8 = undefined;
+ var sent = arg;
+ var at = off;
+ 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;
+ }
+ } else if (kind != .status) p.lsp_last = .{ .kind = kind, .arg = .from(arg), .serial = pane.serial, .offset = @intCast(off) };
p.lsp_seq +%= 1;
// A 9P write that asked it is answered once the answer is in.
if (p.fs.serving) p.fs.lsp_answer_at = turn.lsp_answers;
@@ -6308,8 +6358,8 @@ pub const Pardes = struct {
.id = p.lsp_seq,
.kind = kind,
.pane = @intCast(id),
- .offset = @intCast(off),
- .arg = .from(arg),
+ .offset = @intCast(at),
+ .arg = .from(sent),
} });
}
@@ -6457,8 +6507,11 @@ pub const Pardes = struct {
/// What was said of a failure just now on pane `id` is also the waiting
/// 9P write's (9p_io, `fs.late_failure`).
pub fn noteLateFailure(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- const said = pane.msg[0..pane.msg_len];
+ // Said by reportFailure just now: its words fitted whole, the
+ // reason kept; else what the message row has.
+ const said = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0)
+ p.fs.said[0..p.fs.said_len]
+ else if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else return;
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);
@@ -6576,7 +6629,9 @@ pub const Pardes = struct {
pub fn openTutorView(p: *Pardes, id: usize) !*Pane {
const content = try p.gpa.dupe(u8, tutor_text);
errdefer p.gpa.free(content);
- const path = try p.gpa.dupe(u8, "/Tutor");
+ // A generated pane, named as every one is: `+` in the session's
+ // directory, never a bare path Save could write at the root.
+ const path = try std.fs.path.join(p.gpa, &.{ p.sessionDir(), "+Tutor" });
errdefer p.gpa.free(path);
const history = try panes.File.History.create(p.gpa);
errdefer p.gpa.destroy(history);
diff --git a/src/regexp.zig b/src/regexp.zig
index ddb5eb28..575e6861 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -56,6 +56,7 @@ pub const Regex = struct {
pub const e_anchor = "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n";
pub const e_wide = std.fmt.comptimePrint("bad regular expression: a range of runes wider than {d} in [...] is not supported", .{max_range});
pub const e_negated = "bad regular expression: a [^...] with non-ASCII runes is not supported (mvzr's classes hold bytes)";
+ pub const e_mixed = "bad regular expression: an alternation anchors every branch with ^ or none (^a|^b, not ^a|b)";
/// The most runes a non-ASCII range in a class is spelled out as.
pub const max_range = 256;
@@ -79,7 +80,7 @@ pub const Regex = struct {
return @intCast(n);
}
- pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes };
+ pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes, Mixed };
pub fn compile(pat: []const u8) Error!Regex {
if (pat.len == 0) return error.Bad;
@@ -273,7 +274,7 @@ pub const Regex = struct {
/// `^a|^b` as `^(a|b)` in `out`, when the pattern is an alternation at
/// its top level and every branch starts with `^`; null when it is not
/// one, or no branch does.
- fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{Bad}!?[]const u8 {
+ fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{ Bad, Mixed }!?[]const u8 {
var bars: [16]usize = undefined;
var n: usize = 0;
var depth: usize = 0;
@@ -306,7 +307,8 @@ pub const Regex = struct {
from = to + 1;
}
if (anchored == 0) return null;
- if (anchored != n + 1) return error.Bad;
+ // Only `^` counts: `$` is mvzr's own at a branch's end (`a$|b`).
+ if (anchored != n + 1) return error.Mixed;
var w = std.Io.Writer.fixed(out);
w.writeAll("^(") catch return error.Bad;
from = 0;
@@ -481,7 +483,7 @@ test "a ^ after | anchors that branch: ^def|^ finds a line that starts either w
var both = try Regex.compile("^def|^ ");
try std.testing.expectEqual(@as(usize, 6), (try both.find(text, 0, text.len, text.len)).?.start);
try std.testing.expectEqual(@as(usize, 9), (try both.find(text, 7, text.len, text.len)).?.start);
- try std.testing.expectError(error.Bad, Regex.compile("^def|x"));
+ try std.testing.expectError(error.Mixed, Regex.compile("^def|x"));
var plain = try Regex.compile("a|b");
try std.testing.expectEqual(@as(usize, 7), (try plain.find(text, 0, text.len, text.len)).?.start);
}
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index 0f317a83..94cf6fe0 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -463,6 +463,7 @@ const Exec = struct {
error.TooLong => fail(ex.why, "{s}", .{regexp.Regex.e_long}),
error.WideRange => fail(ex.why, "{s}", .{regexp.Regex.e_wide}),
error.NegatedRunes => fail(ex.why, "{s}", .{regexp.Regex.e_negated}),
+ error.Mixed => fail(ex.why, "{s}", .{regexp.Regex.e_mixed}),
error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}),
};
}
diff --git a/src/terminal.zig b/src/terminal.zig
index 6bf46bef..1a3b6a21 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -1343,7 +1343,10 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
if (pane.terminal == null) return &empty_grid;
const c = &p.shell_rows;
if (!c.stale and c.pane == pane) return c.rows;
- if (c.pane != null) {
+ // Another pane's rows, or a dropped pane's still waiting for the step's
+ // sweep (dropPane keeps them, as they may still be borrowed): this
+ // step's rows are scratch, and nothing in the slot is written over.
+ if (c.pane != null or c.text_alloc.len > 0 or c.rows.len > 0) {
c.stale = true;
return (try buildRows(p.scratch.allocator(), p, pane)).rows;
}
@@ -1357,6 +1360,18 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
return c.rows;
}
+test "rows asked for after a pane's cached rows were dropped, before the sweep, leak nothing" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(pane.terminal != null);
+ _ = try shellRows(p, pane);
+ // A pane's teardown drops its entry; the rows stay until the sweep.
+ p.shell_rows.dropPane(pane);
+ _ = try shellRows(p, pane);
+}
+
/// The full modal motion surface: shell history with the live edit overlay
/// spliced into the rows it covers.
pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {