summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p.zig8
-rw-r--r--src/9p_io.zig51
-rw-r--r--src/CHANGELOG.md53
-rw-r--r--src/File.zig236
-rw-r--r--src/Output.zig25
-rw-r--r--src/ShaderBuild.zig8
-rw-r--r--src/body_layer.zig1
-rw-r--r--src/builtins.zig15
-rw-r--r--src/config.zig62
-rw-r--r--src/detached/server.zig10
-rw-r--r--src/diff.zig498
-rw-r--r--src/dump.zig7
-rw-r--r--src/exec.zig89
-rw-r--r--src/fs.zig57
-rw-r--r--src/host_io.zig8
-rw-r--r--src/layout.zig20
-rw-r--r--src/look.zig422
-rw-r--r--src/main.zig86
-rw-r--r--src/ninep/addr.zig15
-rw-r--r--src/ninep/cols.zig70
-rw-r--r--src/ninep/ctl.zig120
-rw-r--r--src/ninep/events.zig23
-rw-r--r--src/ninep/pane.zig25
-rw-r--r--src/ninep/pty.zig34
-rw-r--r--src/ninep/tree.zig26
-rw-r--r--src/pardes.zig80
-rw-r--r--src/regexp.zig38
-rw-r--r--src/syntax.zig499
-rw-r--r--src/tagline.zig3
-rw-r--r--src/terminal.zig9
-rw-r--r--src/tty/tty.zig36
31 files changed, 2438 insertions, 196 deletions
diff --git a/src/9p.zig b/src/9p.zig
index 837525cb..67b3b39a 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -61,7 +61,13 @@ pub const msize: u32 = 65536;
/// (src/9p_io.zig, `serve`), and a write it cannot keep would fail instead.
/// The largest write is msize less the Twrite header, 23 bytes (the test
/// below asks a client), one byte more than `iohdrsz` allows for.
-pub const editor: cloud9.fs.Options = .{ .fid_capacity = max_fids, .name_capacity = 255, .park_data_max = msize - 23 };
+/// Up to 128 reads held on one connection (a follower each of the log or
+/// an event file); the next is refused `too many reads waiting: 128`. A
+/// mount (9ns) keeps a wider window, 256, so held reads never take every
+/// one of its workers.
+pub const editor: cloud9.fs.Options = .{ .fid_capacity = max_fids, .name_capacity = 255, .park_data_max = msize - 23, .slot_capacity = max_held };
+
+pub const max_held: usize = 128;
/// The board's engine: the GPIO firmware's budget (src/esp32p4_9p.zig).
pub const board: cloud9.fs.Options = .{ .fid_capacity = board_fids, .name_capacity = board_name_capacity };
diff --git a/src/9p_io.zig b/src/9p_io.zig
index d1b2099c..e475416e 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -874,7 +874,29 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [
.greet_timeout_ms = Listener.greet_deadline_ms,
});
const p = socketPath(&l.path_buf, dir, entry_name).?;
- _ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: {
+ // Listened on under a name of its own, then renamed into place: a client
+ // that waits for the socket to appear finds it listening already. Bound
+ // at its own name, it was there a moment before listen(2), and a connect
+ // then was refused. (No room for the longer name: bound in place.)
+ var tmp_buf: [sun_path_len]u8 = undefined;
+ if (std.fmt.bufPrintSentinel(&tmp_buf, "{s}.{d}", .{ p, libc.getpid() }, 0) catch null) |tmp| {
+ const existing = @import("fs.zig").statPath(io, p, .{ .follow_symlinks = false }) catch null;
+ if (existing != null and (existing.?.kind != .unix_domain_socket or alive(p))) {
+ log.warn("something is already listening on {s}", .{p});
+ l.deinit(gpa);
+ return null;
+ }
+ _ = libc.unlink(tmp);
+ _ = l.runner.listen(.{ .unix = tmp }, max_conns) catch {
+ l.deinit(gpa);
+ return null;
+ };
+ if (libc.chmod(tmp, 0o600) != 0 or libc.rename(tmp, p) != 0) {
+ _ = libc.unlink(tmp);
+ l.deinit(gpa);
+ return null;
+ }
+ } else _ = 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)) {
log.warn("something is already listening on {s}", .{p});
@@ -1338,6 +1360,11 @@ test "a listening editor posts itself into the 9P registry and unposts on stop"
// The socket stays exactly where pardes has always bound it:
// adopting the registry moves nothing, it only advertises.
try testing.expect(statNoFollow(sock) != null);
+ // It appeared already listening, renamed into place from a name of
+ // its own, which is gone.
+ try testing.expect(alive(sock));
+ var tmp_buf: [sun_path_len:0]u8 = undefined;
+ try testing.expect(statNoFollow(try std.fmt.bufPrintSentinel(&tmp_buf, "{s}.{d}", .{ sock, libc.getpid() }, 0)) == null);
// And the registry holds a symlink to it one directory down, so
// several editors group under /mnt/9p/pardes/ instead of
// crowding the registry root — the layout zmx posts its
@@ -1542,6 +1569,11 @@ test "a change waits while the editor is out mid-step, a read does not, and the
try testing.expectEqualStrings("after\n", pane.file.?.content);
}
+test "a connection holds up to 128 reads, and the next is refused in words" {
+ try testing.expectEqual(@as(usize, 128), ninep.max_held);
+ if (comptime supported) try testing.expectEqualStrings("too many reads waiting: 128", Runner.Engine.e_waiting);
+}
+
test "a held read is answered when the log has news, and a flushed one spends nothing" {
if (comptime !supported) return error.SkipZigTest;
const gpa = testing.allocator;
@@ -1808,7 +1840,17 @@ pub const Client = struct {
var sock_buf: [sun_path_len]u8 = undefined;
const sock = try resolve(&sock_buf, dial);
var remote: RemoteError = .{};
- return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes));
+ return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes)) catch |err| {
+ if (err == error.Remote) said = remote;
+ return err;
+ };
+ }
+
+ /// The peer's own words for the last error.Remote a read or write here
+ /// got (its Rerror), for a caller that says why.
+ var said: RemoteError = .{};
+ pub fn lastSaid() []const u8 {
+ return said.buf[0..said.len];
}
/// Whether a peer answers at `dial`: a version and attach, and its root
@@ -1828,7 +1870,10 @@ pub const Client = struct {
var sock_buf: [sun_path_len]u8 = undefined;
const sock = try resolve(&sock_buf, dial);
var remote: RemoteError = .{};
- const result = try fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes);
+ const result = fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes) catch |err| {
+ if (err == error.Remote) said = remote;
+ return err;
+ };
gpa.free(result);
}
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 3dd7c996..72607fd2 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,58 @@
# Changelog
+## 0.0.9
+
+- A terminal's program that tracks the mouse gets its clicks, drags and
+ motion as well as its wheel, in the format it asked for; hold Shift for
+ pardes's own select, execute, look and scrollback. Mouse reports carry
+ Shift, Alt and Ctrl.
+- A program that asked for focus reports hears when its pane or the window
+ gains or loses focus. Every paste into a terminal is bracketed when the
+ program asked, with control bytes turned to spaces. The GUI's F1-F24,
+ Insert, Print Screen, Scroll Lock, Pause and Menu reach terminals.
+- A diff reads as the code it changes: a `.diff` or `.patch` file, and a
+ command's output that is a diff (`git diff`), colour each hunk in its
+ file's language, its old and new side each parsed whole, and tint added
+ and removed rows to the pane's edge with their `+`/`-` in the tint's hue.
+ Only the hunks in view are parsed, once each.
+- A right click on a diff's `+++`/`---`/`diff --git` line opens its file,
+ on a `@@` line the new file at the hunk, and on a hunk line's `+`, `-` or
+ space the new file at that line (a removed line: where it was). Paths are
+ the repository's; a file not here says so and opens nothing.
+- A write through a mount that fills a writer's buffer (a multiple of 4096
+ bytes) keeps its cut last line for the next write, so `seq … > exec`
+ runs every line once.
+
+## 0.0.8
+
+- `~` and `~/` mean the home directory wherever a path is taken: look
+ (`~/x:12`, `~/x:/re/`), `name`, `Save`, `ThemeFile`, DumpDir, Restore.
+- `pardes --wait` on a new file waits on the pane it made, however fast
+ that pane is saved and closed. A session's socket appears only once it is
+ listening, so an early client is never refused.
+- A wheel over a terminal whose program asked for the mouse goes to the
+ program, as a report in the format it chose, or as arrow keys on the
+ alternate screen with alternate scroll: codex, htop, less and vim scroll.
+- `pardes FILE` in a pane whose session answers never starts a nested
+ editor: a refusal is printed and it exits 1. A tty pardes killed by
+ SIGTERM or SIGHUP puts its terminal back first.
+- A 9P connection holds up to 128 waiting reads, and refuses the next in
+ words; 9ns (cloud9 705be665) no longer deadlocks a mount under many held
+ reads.
+- A backward search along one long line takes time in its length, not its
+ square. A detached session no client watches draws no frames: 50 MB of
+ output through `pty/run` takes 11 s, not over 30.
+- Save makes its file's missing directories, for its own name and `Save
+ path` alike. `pane/new` takes rows from another column before refusing.
+- With no pane at all, the root `look` opens its file and the root `exec`
+ runs in the session's directory. A refused `Newcol` logs only its error.
+- `Lspwhy` explains the last query in its own file and place; every `+Lsp`
+ answer shares one pane per directory.
+- DumpConfig's information lines start with `# `, and a line starting with
+ `#` runs as nothing. `winsize` refuses fewer than 2 rows. `look file:3:0`
+ is refused. `Kill X` with nothing running is a message, not a failure.
+ An event write-back needs no trailing newline.
+
## 0.0.7
- `lapis` draws its cells unblended and each plaque pixel once: about a
diff --git a/src/File.zig b/src/File.zig
index 43b921e3..2046a7cd 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -10,10 +10,12 @@ const Surface = pardes.Surface;
const modal = @import("modal.zig");
const filesystem = @import("fs.zig");
const syntax = @import("syntax.zig");
+const diff = @import("diff.zig");
const tracy = @import("tracy.zig");
const dump = @import("dump.zig");
const limits = @import("memory.zig").limits;
const locations = @import("locations.zig");
+const Colors = @import("colors.zig");
const Pane = panes.Pane;
const Output = panes.Output;
const Mini = panes.mini;
@@ -61,10 +63,6 @@ pub const State = struct {
/// edit to another length cannot be back to it, and is not hashed.
saved_len: ?usize = null,
watch_after_save: bool = false,
- /// Save makes the file's directory, and any above it, first: `Config`'s
- /// pane for an init file that is not there yet, whose directory may not
- /// be either.
- make_dirs: bool = false,
/// The file changed on disk under unsaved edits, which were kept (see
/// `changed`): its hash, so the same change is said once, until a Save
/// or get settles it. A Save warns once before overwriting it, as acme's
@@ -1305,6 +1303,59 @@ pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16,
const SynStyle = struct { fg: [3]u8, bold: bool };
+/// How added and removed diff lines are told apart in every theme: a row
+/// ground a little way from the page toward green or red (the theme's own
+/// ANSI green and red where it has a palette), and the prefix column in
+/// that hue, pushed toward the ink until it reads on that ground. The
+/// syntax colours are drawn over the ground unchanged.
+pub const DiffTints = struct {
+ added_bg: [3]u8,
+ removed_bg: [3]u8,
+ added_fg: [3]u8,
+ removed_fg: [3]u8,
+
+ pub fn of(p: *Pardes) DiffTints {
+ const th = p.theme();
+ const page = th.bg orelse th.tag_bg;
+ const ink = th.fg orelse th.tag_fg;
+ const green: [3]u8 = if (th.palette) |pal| pal[2] else .{ 0x2e, 0xa0, 0x43 };
+ const red: [3]u8 = if (th.palette) |pal| pal[1] else .{ 0xcf, 0x22, 0x2e };
+ const added_bg = Colors.mixOklab(page, green, 0.18);
+ const removed_bg = Colors.mixOklab(page, red, 0.18);
+ return .{
+ .added_bg = added_bg,
+ .removed_bg = removed_bg,
+ .added_fg = legible(green, ink, added_bg),
+ .removed_fg = legible(red, ink, removed_bg),
+ };
+ }
+
+ fn legible(hue: [3]u8, ink: [3]u8, ground: [3]u8) [3]u8 {
+ var fg = hue;
+ var step: usize = 0;
+ while (step < 4 and Colors.contrast(fg, ground) < 3) : (step += 1) fg = Colors.mixOklab(fg, ink, 0.4);
+ return fg;
+ }
+
+ /// The row ground and prefix ink for a line's style byte, if it is an
+ /// added or removed line.
+ pub fn forStyle(t: *const DiffTints, style: u8) ?struct { bg: [3]u8, fg: [3]u8 } {
+ if (style & syntax.diff_added != 0) return .{ .bg = t.added_bg, .fg = t.added_fg };
+ if (style & syntax.diff_removed != 0) return .{ .bg = t.removed_bg, .fg = t.removed_fg };
+ return null;
+ }
+};
+
+/// A diff row's tint: its ground from `x` to the pane's edge.
+fn tintRow(s: *Surface, x: u16, y: u16, end: u16, bg: [3]u8) void {
+ var col = x;
+ while (col < end) : (col += 1) {
+ const cell = s.at(col, y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = bg };
+ }
+}
+
fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
return switch (sy) {
.none => null,
@@ -1324,6 +1375,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
const prefix_width = gutterWidth(pane);
const tz_recolor = tracy.zone(@src(), "synRecolor");
defer tz_recolor.end();
+ const tints: ?DiffTints = if (pane.colorAlgo() == .diff and highlights.len > 0) DiffTints.of(p) else null;
// indexed start, same as bodyText — an empty tail simply paints nothing
var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
const total = nlines(p.gpa, f);
@@ -1356,6 +1408,12 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null;
const decoration = Output.decorateRow(f.output, line, metadata);
const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome();
+ // An added or removed line's row, every wrapped row of it, is tinted.
+ const tint = if (tints) |*t| if (base >= highlight_start and base - highlight_start < highlights.len)
+ t.forStyle(highlights[base - highlight_start])
+ else
+ null else null;
+ if (tint) |t| if (prefix_width < tw) tintRow(s, tx + @as(u16, @intCast(prefix_width)), body_y + vr, tx + tw, t.bg);
var c: usize = 0;
var screen_c: usize = 0;
while (hs + c < limit and prefix_width + screen_c < tw) {
@@ -1365,7 +1423,11 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
if (idx >= highlight_start) {
const hidx = idx - highlight_start;
if (hidx < highlights.len) {
- if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| {
+ if (if (tint != null and hs + c == 0)
+ SynStyle{ .fg = tint.?.fg, .bold = true }
+ else
+ synStyle(p, @enumFromInt(highlights[hidx] & syntax.syn_bits))) |ss|
+ {
var fill: usize = 0;
while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) {
const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr);
@@ -1394,6 +1456,170 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
}
}
+/// What finished command panes printed, kept while it cannot change: a
+/// copy of the terminal's rows, and whether they are a diff. Taken once
+/// when a finished pane is first drawn, so neither a frame nor a look dumps
+/// the whole scrollback again; a still running command is shown as it
+/// printed. Keyed by the pane and its width (a resize rewraps every row);
+/// a rerun in the pane drops it (exec.runCommand).
+pub const DiffOutput = struct {
+ const Entry = struct {
+ serial: u32,
+ cols: u16,
+ is_diff: bool,
+ /// The rows joined by '\n'; `rows` are slices of it.
+ text: []u8,
+ rows: [][]const u8,
+ used: u64,
+ };
+ slots: [4]?Entry = @splat(null),
+ clock: u64 = 0,
+
+ pub fn reset(o: *DiffOutput, gpa: std.mem.Allocator) void {
+ for (&o.slots) |*slot| {
+ if (slot.*) |e| {
+ gpa.free(e.text);
+ gpa.free(e.rows);
+ }
+ slot.* = null;
+ }
+ }
+
+ /// A pane's command runs again: what it printed before is gone.
+ pub fn drop(o: *DiffOutput, gpa: std.mem.Allocator, serial: u32) void {
+ for (&o.slots) |*slot| if (slot.*) |e| if (e.serial == serial) {
+ gpa.free(e.text);
+ gpa.free(e.rows);
+ slot.* = null;
+ };
+ }
+
+ /// A finished command pane's rows when they are a diff, else null.
+ pub fn lines(o: *DiffOutput, p: *Pardes, pane: *Pane) ?[]const []const u8 {
+ if (pane.command == null or !pane.command_done or pane.command_pty or pane.terminal == null) return null;
+ o.clock += 1;
+ for (&o.slots) |*slot| if (slot.*) |*e| if (e.serial == pane.serial and e.cols == pane.cols) {
+ e.used = o.clock;
+ return if (e.is_diff) e.rows else null;
+ };
+ const shown = terminal.shellRows(p, pane) catch return null;
+ const is_diff = diff.looksLikeDiff(shown);
+ var total: usize = 0;
+ if (is_diff) for (shown) |row| {
+ total += row.len + 1;
+ };
+ const text = p.gpa.alloc(u8, total) catch return null;
+ const rows = p.gpa.alloc([]const u8, if (is_diff) shown.len else 0) catch {
+ p.gpa.free(text);
+ return null;
+ };
+ var at: usize = 0;
+ for (rows, 0..) |*row, i| {
+ @memcpy(text[at..][0..shown[i].len], shown[i]);
+ row.* = text[at..][0..shown[i].len];
+ at += shown[i].len;
+ text[at] = '\n';
+ at += 1;
+ }
+ var oldest: usize = 0;
+ for (&o.slots, 0..) |*slot, i| {
+ const e = slot.* orelse {
+ oldest = i;
+ break;
+ };
+ if (o.slots[oldest]) |old| if (e.used < old.used) {
+ oldest = i;
+ };
+ }
+ if (o.slots[oldest]) |old| {
+ p.gpa.free(old.text);
+ p.gpa.free(old.rows);
+ }
+ o.slots[oldest] = .{ .serial = pane.serial, .cols = pane.cols, .is_diff = is_diff, .text = text, .rows = rows, .used = o.clock };
+ return if (is_diff) rows else null;
+ }
+};
+
+/// A command pane whose output is a diff (`git diff`, `diff -u`): its
+/// rows coloured as a .diff file's are, over what the program printed.
+/// The rows are the terminal's own, so a line it wrapped goes on in the
+/// row below (syntax.highlightDiffRows).
+pub fn recolorDiffOutput(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
+ if (pane.command == null or !p.settings.colors) return;
+ const tz = tracy.zone(@src(), "diffRecolor");
+ defer tz.end();
+ const rows = p.diff_output.lines(p, pane) orelse return;
+ // The copy's rows are one text, joined as a file's lines are.
+ const flat_start = @intFromPtr(rows[0].ptr);
+ const last = rows[rows.len - 1];
+ const flat = rows[0].ptr[0 .. @intFromPtr(last.ptr) + last.len - flat_start];
+ const raw = pane.body.mode == .tty;
+ const View = struct { row: usize, at: usize };
+ const arena = p.scratch.allocator();
+ const views = arena.alloc(?View, body_h) catch return;
+ var lo: usize = std.math.maxInt(usize);
+ var hi: usize = 0;
+ var shown = std.mem.splitScalar(u8, body, '\n');
+ for (views, 0..) |*view, vi| {
+ view.* = null;
+ const line = shown.next() orelse continue;
+ const vr: i32 = @intCast(vi);
+ const w = pane.wrapAt(vr);
+ const row_i: i32 = if (raw) terminal.gridOffset(pane) + vr else w.line;
+ if (row_i < 0 or row_i >= @as(i64, @intCast(rows.len))) continue;
+ const row: usize = @intCast(row_i);
+ const from: usize = if (raw) 0 else @intCast(@max(0, w.at));
+ if (from > rows[row].len or !std.mem.startsWith(u8, rows[row][from..], line)) continue;
+ view.* = .{ .row = row, .at = from };
+ const start = @intFromPtr(rows[row].ptr) - flat_start;
+ lo = @min(lo, start);
+ hi = @max(hi, start + rows[row].len);
+ }
+ if (lo > hi) return;
+ const styles = syntax.highlightDiffRows(arena, flat, lo, hi, true) catch return;
+ const tints = DiffTints.of(p);
+ const ink = p.theme().fg orelse p.theme().tag_fg;
+ const body_y = p.bodyTop(pane, r);
+ shown = std.mem.splitScalar(u8, body, '\n');
+ for (views, 0..) |maybe, vr| {
+ const line = shown.next() orelse break;
+ const view = maybe orelse continue;
+ const base = @intFromPtr(rows[view.row].ptr) - flat_start - lo;
+ const tint = if (base < styles.len) tints.forStyle(styles[base]) else null;
+ if (tint) |t| tintRow(s, tx, body_y + @as(u16, @intCast(vr)), tx + tw, t.bg);
+ var c: usize = 0;
+ var screen_c: usize = 0;
+ while (c < line.len and screen_c < tw) {
+ const grapheme_end = modal.nextGrapheme(line, c);
+ if (grapheme_end <= c) break;
+ const cells = graphemeDisplayWidth(line[c..grapheme_end]);
+ const style = styles[base + view.at + c];
+ const prefix = view.at + c == 0 and (line[0] == '+' or line[0] == '-');
+ // On an added or removed line, what the program coloured red or
+ // green is the tint's to say now: its code takes the page's ink.
+ const paint: ?SynStyle = if (tint != null and prefix)
+ .{ .fg = tint.?.fg, .bold = true }
+ else if (synStyle(p, @enumFromInt(style & syntax.syn_bits))) |ss|
+ ss
+ else if (tint != null)
+ .{ .fg = ink, .bold = false }
+ else
+ null;
+ if (paint) |ss| {
+ var fill: usize = 0;
+ while (fill < cells and screen_c + fill < tw) : (fill += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(screen_c + fill)), body_y + @as(u16, @intCast(vr)));
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+ screen_c += cells;
+ c = grapheme_end;
+ }
+ }
+}
+
pub fn drawWrapMarkers(
p: *Pardes,
s: *Surface,
diff --git a/src/Output.zig b/src/Output.zig
index 545637b3..3541fa57 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -164,6 +164,23 @@ test "stacked empty previews distinguish separators from trailing newlines" {
try std.testing.expect(!locations.sameInput("x.c:1:1 \n", "x.c:1:1\n", &rows, .stacked));
}
+test "Lspinfo and Lspwhy fill one +Lsp in a directory" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ _ = try p.setTestFile("x\n");
+ try fillResults(p, 0, "/work", .{ .query = .status }, "", try p.gpa.dupe(u8, "servers\n"), null);
+ try fillResults(p, 0, "/work", .{ .query = .explain }, "", try p.gpa.dupe(u8, "why\n"), null);
+ var lsp_panes: usize = 0;
+ for (p.panes) |slot| {
+ const f = (slot orelse continue).file orelse continue;
+ if (std.mem.eql(u8, f.path, "/work/+Lsp")) {
+ lsp_panes += 1;
+ try std.testing.expectEqualStrings("why\n", f.content);
+ }
+ }
+ try std.testing.expectEqual(@as(usize, 1), lsp_panes);
+}
+
test "plain output does not opt into location decoration" {
const report = rowDecoration(.{ .from = .{ .cmd = .LocationsConfig } }, "LocationsConfig context:5 tscontext:on");
try std.testing.expectEqualDeep(RowDecoration{}, report);
@@ -439,6 +456,12 @@ fn fileSearch(from: Origin) bool {
return from != .search and std.mem.eql(u8, traits(from).name, config.search_buffer);
}
+/// A language server's prose (Lspinfo, Lspwhy, a code action list, a
+/// format diff): one `+Lsp` a directory holds for all of them.
+fn lspProse(from: Origin) bool {
+ return from == .query and std.mem.eql(u8, traits(from).name, config.lsp_buffer);
+}
+
pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void {
var result_content = content;
errdefer p.gpa.free(result_content);
@@ -483,7 +506,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
const rp = slot orelse continue;
const rf = if (rp.file) |*f| f else continue;
const o = if (rf.output) |*x| x else continue;
- const shared = fileSearch(from) and fileSearch(o.from);
+ const shared = (fileSearch(from) and fileSearch(o.from)) or (lspProse(from) and lspProse(o.from));
if (i == id and !shared) continue;
if (!shared and !std.meta.eql(o.from, from)) continue;
if (!shared and by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
diff --git a/src/ShaderBuild.zig b/src/ShaderBuild.zig
index c2610fd7..b50999a5 100644
--- a/src/ShaderBuild.zig
+++ b/src/ShaderBuild.zig
@@ -236,11 +236,9 @@ fn collect(b: *Build, gpa: std.mem.Allocator, core: *pardes.Pardes, job: *Job) v
}
}
-/// `~/` is the home directory.
-fn expand(buf: []u8, path: []const u8) []const u8 {
- if (!std.mem.startsWith(u8, path, "~/")) return path;
- const home = libc.getenv("HOME") orelse return path;
- return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path;
+/// `~` is the home directory (the one rule, filesystem.expandHome).
+fn expand(buf: *[4096]u8, path: []const u8) []const u8 {
+ return pardes.filesystem.expandHome(path, buf);
}
/// The compile thread: each file behind the prefix, through glslc with the
diff --git a/src/body_layer.zig b/src/body_layer.zig
index cb26c1f2..8e7cd030 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -292,6 +292,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg);
} else if (pane.isTerminal() and p.settings.colors) {
panes.terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body);
+ panes.File.recolorDiffOutput(p, s, pane, r, tx, tw, body_h, body);
}
tz_color.end();
diff --git a/src/builtins.zig b/src/builtins.zig
index 8fe49649..6aabba8d 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -609,11 +609,13 @@ pub const Kill = struct {
const names = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
var stopped = false;
var jobless = false;
+ var running = false;
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
// A command pane's child is the command: the whole of its
// process group goes, as acme's Kill ends the process.
if (pane.command) |line| if (!pane.command_done) {
+ running = true;
const said = std.mem.trim(u8, line, " \t");
const first = said[0 .. std.mem.indexOfAny(u8, said, " \t") orelse said.len];
if (names.len > 0) {
@@ -627,6 +629,7 @@ pub const Kill = struct {
continue;
};
if (!panes.terminal.commandRunning(pane)) continue;
+ running = true;
const sent = pane.sent_command.?;
if (names.len > 0) {
var words = std.mem.tokenizeAny(u8, names, " \t");
@@ -640,9 +643,11 @@ pub const Kill = struct {
}
if (stopped) return;
if (jobless) return c.p.reportFailure(c.id, "Kill: no job to signal (its shell runs it without job control)");
- if (names.len > 0)
+ // Said, but no failure: a script's Kill, named or not, has nothing
+ // left to do. Only commands running, none of them the one named, is.
+ if (names.len > 0 and running)
c.p.reportFailure(c.id, "Kill: no running command has that first word")
- else // said, but no failure: a script's Kill has nothing left to do
+ else
c.p.setMessage(c.id, "Kill: nothing running");
}
};
@@ -813,7 +818,8 @@ pub const ThemeFile = struct {
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime !enabled) unreachable;
- const arg = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
+ var home_buf: [4096]u8 = undefined;
+ const arg = pardes.filesystem.expandHome(std.mem.trim(u8, c.arg orelse return, " \t\r\n"), &home_buf);
// Said with why, not "invalid theme path".
if (!std.mem.endsWith(u8, arg, ".zon")) {
var said: [320]u8 = undefined;
@@ -1304,9 +1310,8 @@ pub const Config = struct {
// Not there yet: a pane named for it, which Save makes.
const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Config", error.NoPaneSlots);
c.p.newScratchFor(c.id, .scratch);
- const pane = c.p.panes[free] orelse return;
+ if (c.p.panes[free] == null) return;
@import("ninep/pane.zig").nameBuffer(c.p, free, path, false) catch |err| return c.p.reportError(c.id, "Config", err);
- pane.file.?.make_dirs = true;
c.p.active = free;
}
};
diff --git a/src/config.zig b/src/config.zig
index 94b32b88..592281d3 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1386,35 +1386,36 @@ pub const Runtime = struct {
};
if (context.locations.len > 0) try out.print("LocationsConfig {s}\n", .{context.locations});
- // In effect, and set by no word.
+ // In effect, and set by no word: each a `#` line, which runs as
+ // nothing (exec.executeFrom), so the report can be written back.
try out.writeByte('\n');
- try out.print("Last shell spawned: {s}\n", .{shown(state.shell.effective.get())});
- try out.print("Shell change pending: {s}\n", .{onOff(state.shell.pending)});
+ try out.print("# Last shell spawned: {s}\n", .{shown(state.shell.effective.get())});
+ try out.print("# Shell change pending: {s}\n", .{onOff(state.shell.pending)});
if (find("Font").?.enabled(context.capabilities)) {
const f = &state.font;
- try out.print("Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) });
- try out.print("Font change pending: {s}\n", .{onOff(f.pending)});
+ try out.print("# Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) });
+ try out.print("# Font change pending: {s}\n", .{onOff(f.pending)});
}
if (find("WindowOpacity").?.enabled(context.capabilities))
- try out.print("Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)});
+ try out.print("# Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)});
if (context.capabilities.tagline_font_size and !find("TaglineSize").?.enabled(context.capabilities))
- try out.writeAll("Tagline size: build-time only\n");
+ try out.writeAll("# Tagline size: build-time only\n");
if (context.startup_config_path) |path|
- try out.print("Startup config: {s}\n", .{path})
+ try out.print("# Startup config: {s}\n", .{path})
else
- try out.writeAll("Startup config: no per-user config path\n");
+ try out.writeAll("# Startup config: no per-user config path\n");
try out.print(
- "Platform: {s}\n" ++
- "Default shell ($SHELL, else /bin/sh): {s}\n",
+ "# Platform: {s}\n" ++
+ "# Default shell ($SHELL, else /bin/sh): {s}\n",
.{ context.platform, context.compiled_default_shell },
);
if (context.hover_delay_frames) |frames|
- try out.print("Look hover delay: {d} frames\n", .{frames})
+ try out.print("# Look hover delay: {d} frames\n", .{frames})
else
- try out.writeAll("Look hover delay: off\n");
- try out.print("Native images: {s}\n", .{onOff(context.native_images)});
+ try out.writeAll("# Look hover delay: off\n");
+ try out.print("# Native images: {s}\n", .{onOff(context.native_images)});
if (std.mem.eql(u8, context.platform, "gui"))
- try out.print("Pet: {s}\n", .{@tagName(state.pet)});
+ try out.print("# Pet: {s}\n", .{@tagName(state.pet)});
}
test "setting names are unique" {
@@ -1761,15 +1762,15 @@ pub const Runtime = struct {
"Theme acme\n",
"WindowOpacity 85\n",
"Font /fonts/wanted.ttf:18.50\n",
- "Font in use: Effective Mono 13.75 points\n",
- "Font change pending: on\n",
- "Window opacity change pending: off\n",
+ "# Font in use: Effective Mono 13.75 points\n",
+ "# Font change pending: on\n",
+ "# Window opacity change pending: off\n",
"TaglineSize 82\n",
- "Startup config: /tmp/pardes/init\n",
- "Platform: gui\n",
- "Default shell ($SHELL, else /bin/sh): /bin/sh\n",
- "Look hover delay: 18 frames\n",
- "Native images: on\n",
+ "# Startup config: /tmp/pardes/init\n",
+ "# Platform: gui\n",
+ "# Default shell ($SHELL, else /bin/sh): /bin/sh\n",
+ "# Look hover delay: 18 frames\n",
+ "# Native images: on\n",
}) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
var defaults: Runtime = .{};
@@ -1794,22 +1795,25 @@ pub const Runtime = struct {
try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities));
try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities));
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell /bin/sh\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Last shell spawned: (none)\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell change pending: on\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Last shell spawned: (none)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Shell change pending: on\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Startup config: no per-user config path\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font in use") == null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "PanelSlide off\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Look hover delay: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Native images: off\n") != null);
// A setting this shell does not have reads so, as a line to write.
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures unsupported\n") != null);
// Every line before the blank one is a ctl word and its value.
const words = defaults_report[0..std.mem.indexOf(u8, defaults_report, "\n\n").?];
var lines = std.mem.splitScalar(u8, words, '\n');
while (lines.next()) |line| try std.testing.expect(find(line[0..std.mem.indexOfScalar(u8, line, ' ').?]) != null);
+ // ...and every one after it a `#` line, which runs as nothing.
+ var info = std.mem.splitScalar(u8, std.mem.trimEnd(u8, defaults_report[words.len + 2 ..], "\n"), '\n');
+ while (info.next()) |line| try std.testing.expect(std.mem.startsWith(u8, line, "# "));
defaults_context.platform = "web";
defaults_context.capabilities.panel_transitions = false;
@@ -1819,7 +1823,7 @@ pub const Runtime = struct {
const web_report = storage[0..out.end];
try std.testing.expect(std.mem.indexOf(u8, web_report, "PanelSlide unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, web_report, "Tagline size: build-time only\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "# Tagline size: build-time only\n") != null);
}
};
diff --git a/src/detached/server.zig b/src/detached/server.zig
index d8379793..001acc47 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -376,6 +376,7 @@ pub const Session = struct {
.now = clockNow,
.present = present,
.poll_frame = pollFrame,
+ .watched = watched,
.spawn = spawn,
.pty_write = ptyWrite,
.pty_resize = ptyResize,
@@ -641,6 +642,12 @@ pub const Session = struct {
s.mailbox.signal();
}
+ fn watched(ctx: ?*anyopaque) bool {
+ const s = of(ctx);
+ for (&s.clients) |*c| if (c.attached) return true;
+ return false;
+ }
+
fn pollFrame(ctx: ?*anyopaque) void {
const s = of(ctx);
if (s.ninep) |l| s.ninep_pending = l.tick().pending;
@@ -1052,6 +1059,9 @@ pub const Session = struct {
c.attached = true;
c.need_full = true;
c.greet = true;
+ // Unwatched until now, it may have drawn nothing: a frame
+ // for the one attaching (watched).
+ s.core.needs_frame = true;
},
.bye => s.close(c, .bye),
.event => |ev| {
diff --git a/src/diff.zig b/src/diff.zig
new file mode 100644
index 00000000..d129c222
--- /dev/null
+++ b/src/diff.zig
@@ -0,0 +1,498 @@
+//! A unified diff (`git diff`, `diff -u`), read a line at a time: which
+//! file each section is about, what each line is, and where a hunk line
+//! stands in the old and the new file. The syntax painter (syntax.zig
+//! highlightDiff) and a look on a diff line (look.zig) both walk it.
+//!
+//! A hunk's extent comes from the counts in its `@@` header, as patch
+//! reads it, so a removed line that itself starts `--` (`--- x`) is not
+//! taken for a file header.
+const std = @import("std");
+
+pub const Kind = enum {
+ /// Anything outside a file header and a hunk: a commit message,
+ /// `Only in ...`, a mail's headers.
+ other,
+ /// `diff ...`, `index ...`, `new file mode ...`, `rename from ...`,
+ /// `Binary files ... differ`: said of the section, naming no line.
+ meta,
+ /// `--- <old path>`
+ old_path,
+ /// `+++ <new path>`
+ new_path,
+ /// `@@ -a,b +c,d @@`
+ hunk,
+ context,
+ added,
+ removed,
+ /// `\ No newline at end of file`
+ no_newline,
+};
+
+/// One line, walked.
+pub const Line = struct {
+ kind: Kind,
+ /// Where it is in the old and the new file, from 1. A hunk header has
+ /// its first lines; a removed line the new line now standing where it
+ /// was (the one after it); an added line the old line after it.
+ old: usize = 0,
+ new: usize = 0,
+};
+
+pub const Walk = struct {
+ /// The section's paths, as written, borrowed from the lines walked:
+ /// `a/` and `b/` kept, timestamps cut.
+ old_path: []const u8 = "",
+ new_path: []const u8 = "",
+ /// `diff --git a/x b/y`'s b side, for a section with no `+++` yet
+ /// (a mode change, a rename, a binary file).
+ git_path: []const u8 = "",
+ old_left: usize = 0,
+ new_left: usize = 0,
+ old_at: usize = 0,
+ new_at: usize = 0,
+ /// The hunk's header numbers, which a look past its end clamps to.
+ new_start: usize = 0,
+ new_count: usize = 0,
+ /// Rows a terminal wrapped: inside a hunk, a row that starts with no
+ /// prefix at all continues the line above instead of ending the hunk.
+ continued_rows: bool = false,
+ last: Kind = .other,
+
+ pub fn inHunk(w: *const Walk) bool {
+ return w.old_left > 0 or w.new_left > 0;
+ }
+
+ /// The file the section is about: the new one, else (deleted) the old
+ /// one, else what its `diff --git` line names.
+ pub fn path(w: *const Walk) []const u8 {
+ if (w.new_path.len > 0 and !isDevNull(w.new_path)) return w.new_path;
+ if (w.old_path.len > 0 and !isDevNull(w.old_path)) return w.old_path;
+ return w.git_path;
+ }
+
+ /// Whether the section deletes its file (`+++ /dev/null`).
+ pub fn deleted(w: *const Walk) bool {
+ return isDevNull(w.new_path);
+ }
+
+ pub fn step(w: *Walk, line_raw: []const u8) Line {
+ const line = std.mem.trimEnd(u8, line_raw, "\r");
+ const got = w.stepInner(line);
+ w.last = got.kind;
+ return got;
+ }
+
+ fn stepInner(w: *Walk, line: []const u8) Line {
+ if (w.inHunk()) {
+ // A blank context line, its space stripped by a mailer or an
+ // editor, is still context.
+ const c: u8 = if (line.len == 0) ' ' else line[0];
+ switch (c) {
+ ' ' => if (w.old_left > 0 and w.new_left > 0) {
+ const at: Line = .{ .kind = .context, .old = w.old_at, .new = w.new_at };
+ w.old_left -= 1;
+ w.new_left -= 1;
+ w.old_at += 1;
+ w.new_at += 1;
+ return at;
+ },
+ '-' => if (w.old_left > 0) {
+ const at: Line = .{ .kind = .removed, .old = w.old_at, .new = w.new_at };
+ w.old_left -= 1;
+ w.old_at += 1;
+ return at;
+ },
+ '+' => if (w.new_left > 0) {
+ const at: Line = .{ .kind = .added, .old = w.old_at, .new = w.new_at };
+ w.new_left -= 1;
+ w.new_at += 1;
+ return at;
+ },
+ '\\' => return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at },
+ else => if (w.continued_rows and w.last != .hunk and w.last != .other)
+ return .{ .kind = w.last, .old = w.old_at, .new = w.new_at },
+ }
+ // Counts run out, or a line no hunk has: the hunk was cut short.
+ w.old_left = 0;
+ w.new_left = 0;
+ }
+ if (line.len > 0 and line[0] == '\\' and w.last != .other and w.last != .meta)
+ return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at };
+ if (std.mem.startsWith(u8, line, "diff ")) {
+ w.* = .{ .continued_rows = w.continued_rows };
+ w.git_path = gitPath(line);
+ return .{ .kind = .meta };
+ }
+ if (std.mem.startsWith(u8, line, "--- ")) {
+ // A second `---` with no `+++` between starts another section
+ // (plain `diff -u` output has no `diff` line of its own).
+ w.* = .{ .continued_rows = w.continued_rows, .git_path = w.git_path };
+ w.old_path = cutPath(line[4..]);
+ return .{ .kind = .old_path };
+ }
+ if (std.mem.startsWith(u8, line, "+++ ")) {
+ w.new_path = cutPath(line[4..]);
+ return .{ .kind = .new_path };
+ }
+ if (parseHunk(line)) |h| {
+ w.old_left = h.old_count;
+ w.new_left = h.new_count;
+ w.old_at = h.old_start;
+ w.new_at = h.new_start;
+ w.new_start = h.new_start;
+ w.new_count = h.new_count;
+ return .{ .kind = .hunk, .old = h.old_start, .new = h.new_start };
+ }
+ for (meta_words) |word| if (std.mem.startsWith(u8, line, word)) return .{ .kind = .meta };
+ return .{ .kind = .other };
+ }
+};
+
+const meta_words = [_][]const u8{
+ "index ", "new file mode ", "deleted file mode ", "old mode ", "new mode ",
+ "similarity index ", "rename from ", "rename to ", "copy from ", "copy to ",
+ "Binary files ", "dissimilarity ",
+};
+
+fn isDevNull(path: []const u8) bool {
+ return std.mem.eql(u8, path, "/dev/null");
+}
+
+/// A `---`/`+++` path with what follows it cut: `diff -u`'s tab and
+/// timestamp, and git's quotes round a name it had to escape.
+fn cutPath(rest: []const u8) []const u8 {
+ var name = rest[0 .. std.mem.indexOfScalar(u8, rest, '\t') orelse rest.len];
+ name = std.mem.trimEnd(u8, name, " \r");
+ if (name.len >= 2 and name[0] == '"' and name[name.len - 1] == '"') name = name[1 .. name.len - 1];
+ return name;
+}
+
+/// `diff --git a/x b/y`: the b side. A name with a space in it is split at
+/// the last ` b/`, which is right unless the old name has one too.
+fn gitPath(line: []const u8) []const u8 {
+ if (!std.mem.startsWith(u8, line, "diff --git ")) return "";
+ const rest = line["diff --git ".len..];
+ const at = std.mem.lastIndexOf(u8, rest, " b/") orelse return "";
+ return std.mem.trimEnd(u8, rest[at + 1 ..], " \r");
+}
+
+pub const HunkHeader = struct { old_start: usize, old_count: usize, new_start: usize, new_count: usize };
+
+/// `@@ -a[,b] +c[,d] @@ ...`, a count left out meaning one.
+pub fn parseHunk(line: []const u8) ?HunkHeader {
+ if (!std.mem.startsWith(u8, line, "@@ -")) return null;
+ var at: usize = "@@ -".len;
+ const old = range(line, &at) orelse return null;
+ if (at + 1 >= line.len or line[at] != ' ' or line[at + 1] != '+') return null;
+ at += 2;
+ const new = range(line, &at) orelse return null;
+ if (!std.mem.startsWith(u8, line[at..], " @@")) return null;
+ return .{ .old_start = old[0], .old_count = old[1], .new_start = new[0], .new_count = new[1] };
+}
+
+fn range(line: []const u8, at: *usize) ?[2]usize {
+ const start = number(line, at) orelse return null;
+ if (at.* < line.len and line[at.*] == ',') {
+ at.* += 1;
+ return .{ start, number(line, at) orelse return null };
+ }
+ return .{ start, 1 };
+}
+
+fn number(line: []const u8, at: *usize) ?usize {
+ const from = at.*;
+ var n: usize = 0;
+ while (at.* < line.len and std.ascii.isDigit(line[at.*])) : (at.* += 1)
+ n = std.math.add(usize, std.math.mul(usize, n, 10) catch return null, line[at.*] - '0') catch return null;
+ return if (at.* > from) n else null;
+}
+
+/// Where a walk that ends up at byte `pos` of `content` may safely start:
+/// the start of the nearest `diff ` line before it (no hunk line starts
+/// with a `d`), else the top.
+pub fn anchorBefore(content: []const u8, pos: usize) usize {
+ var end = @min(pos, content.len);
+ while (end > 0) {
+ const nl = std.mem.lastIndexOfScalar(u8, content[0..end], '\n') orelse break;
+ if (std.mem.startsWith(u8, content[nl + 1 ..], "diff ") and nl + 1 <= pos) return nl + 1;
+ end = nl;
+ }
+ return 0;
+}
+
+/// The same for a list of lines: the nearest `diff ` line at or above `row`.
+pub fn anchorRow(lines: []const []const u8, row: usize) usize {
+ var r = @min(row, lines.len);
+ while (r > 0) {
+ r -= 1;
+ if (std.mem.startsWith(u8, lines[r], "diff ")) return r;
+ }
+ return 0;
+}
+
+/// Whether text a command printed is a diff: a `diff --git` line, or a
+/// `--- ` line with a `+++ ` line under it, in its first rows (after
+/// `git show`'s commit header, say).
+pub fn looksLikeDiff(lines: []const []const u8) bool {
+ const n = @min(lines.len, 200);
+ for (lines[0..n], 0..) |line, i| {
+ if (std.mem.startsWith(u8, line, "diff --git ")) return true;
+ if (std.mem.startsWith(u8, line, "--- ") and i + 1 < lines.len and std.mem.startsWith(u8, lines[i + 1], "+++ ") and
+ i + 2 < lines.len and parseHunk(lines[i + 2]) != null) return true;
+ }
+ return false;
+}
+
+/// A look on one line of a diff: the file and line it names.
+pub const Target = struct {
+ /// The line's own kind.
+ kind: Kind,
+ /// As written in the diff, `a/`/`b/` kept.
+ path: []const u8,
+ /// From 1; 0 for the file alone (a `---`/`+++` line).
+ line: usize,
+ /// The section deletes the file, and this is its old side.
+ old_side: bool = false,
+};
+
+/// What a look at `lines[row]` opens, or null for a line that is no part
+/// of a file section (a commit message, `index ...`). The whole line is
+/// the look's: a click on a `+++`, `---` or `@@` marker is one on its path
+/// or its numbers.
+///
+/// `diff --git a/x b/y` opens y, and `+++ y` opens y. `--- x` opens x,
+/// unless the `+++` under it names another file (a rename, a new file,
+/// `diff -u old/x new/x`): then that one, the old name being the one
+/// likely gone.
+///
+/// A removed line opens the new file at the line now standing where it
+/// was: the new line after it, or the hunk's last new line when it went
+/// from the end of the file. In a section that deletes its file there is
+/// no new file, so a removed or context line opens the old one at its old
+/// line (it is there while the diff is not applied).
+pub fn targetAt(lines: []const []const u8, row: usize, continued_rows: bool) ?Target {
+ if (row >= lines.len) return null;
+ var w: Walk = .{ .continued_rows = continued_rows };
+ var r = anchorRow(lines, row);
+ var got: Line = .{ .kind = .other };
+ while (r <= row) : (r += 1) got = w.step(lines[r]);
+ const line = std.mem.trimEnd(u8, lines[row], "\r");
+ switch (got.kind) {
+ .other, .meta, .no_newline => {
+ // `diff --git a/x b/y`: the file, as its `+++` would.
+ if (got.kind == .meta and std.mem.startsWith(u8, line, "diff ") and w.git_path.len > 0)
+ return .{ .kind = got.kind, .path = w.git_path, .line = 0 };
+ return null;
+ },
+ .old_path, .new_path => {
+ const own = cutPath(line[4..]);
+ // `---` names the old file, but the new one on the `+++` under
+ // it wins when they differ: a new file (`--- /dev/null`), a
+ // rename, `diff -u old/x new/x`. The old name is the one
+ // likely gone.
+ if (got.kind == .old_path and row + 1 < lines.len and std.mem.startsWith(u8, lines[row + 1], "+++ ")) {
+ const new = cutPath(std.mem.trimEnd(u8, lines[row + 1], "\r")[4..]);
+ if (!isDevNull(new) and (isDevNull(own) or !std.mem.eql(u8, stripSide(new), stripSide(own))))
+ return .{ .kind = got.kind, .path = new, .line = 0 };
+ }
+ if (!isDevNull(own)) return .{ .kind = got.kind, .path = own, .line = 0, .old_side = got.kind == .old_path and w.deleted() };
+ if (w.path().len == 0) return null;
+ return .{ .kind = got.kind, .path = w.path(), .line = 0, .old_side = w.deleted() };
+ },
+ .hunk => {
+ if (w.path().len == 0) return null;
+ if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true };
+ return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.new) };
+ },
+ .context, .added, .removed => {
+ if (w.path().len == 0) return null;
+ if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true };
+ var at = got.new;
+ // Gone from the end of the file: nothing stands after it.
+ if (got.kind == .removed and w.new_count > 0 and at >= w.new_start + w.new_count)
+ at = w.new_start + w.new_count - 1;
+ return .{ .kind = got.kind, .path = w.path(), .line = @max(1, at) };
+ },
+ }
+}
+
+/// `a/x` and `b/x` as git writes them: the name in the repository.
+pub fn stripSide(path: []const u8) []const u8 {
+ if (path.len > 2 and (path[0] == 'a' or path[0] == 'b') and path[1] == '/') return path[2..];
+ return path;
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+
+fn splitLines(comptime text: []const u8) [std.mem.count(u8, text, "\n")][]const u8 {
+ @setEvalBranchQuota(100_000);
+ var out: [std.mem.count(u8, text, "\n")][]const u8 = undefined;
+ var it = std.mem.splitScalar(u8, text, '\n');
+ for (&out) |*line| line.* = it.next().?;
+ return out;
+}
+
+const git_diff =
+ \\diff --git a/src/a.zig b/src/a.zig
+ \\index 1111111..2222222 100644
+ \\--- a/src/a.zig
+ \\+++ b/src/a.zig
+ \\@@ -10,4 +10,4 @@ pub fn main() void {
+ \\ const a = 1;
+ \\-const b = 2;
+ \\+const b = 3;
+ \\+const c = 4;
+ \\ const d = 5;
+ \\--- x
+ \\@@ -40,2 +41,2 @@
+ \\ keep();
+ \\-gone();
+ \\+added();
+ \\diff --git a/old.py b/old.py
+ \\deleted file mode 100644
+ \\--- a/old.py
+ \\+++ /dev/null
+ \\@@ -1,2 +0,0 @@
+ \\-def f():
+ \\- return 1
+ \\
+;
+
+test "diff a walk follows the hunk counts, so a removed `--` line is no header" {
+ const lines = splitLines(git_diff);
+ var w: Walk = .{};
+ var kinds: [lines.len]Kind = undefined;
+ for (lines, 0..) |line, i| kinds[i] = w.step(line).kind;
+ try testing.expectEqualSlices(Kind, &.{
+ .meta, .meta, .old_path, .new_path, .hunk,
+ .context, .removed, .added, .added, .context,
+ .removed, .hunk, .context, .removed, .added,
+ .meta, .meta, .old_path, .new_path, .hunk,
+ .removed, .removed,
+ }, &kinds);
+}
+
+test "diff a look on each kind of line names the new file and its line" {
+ const lines = splitLines(git_diff);
+ const expect = struct {
+ fn at(ls: []const []const u8, row: usize, path: []const u8, line: usize) !void {
+ const t = targetAt(ls, row, false) orelse return error.NoTarget;
+ try testing.expectEqualStrings(path, t.path);
+ try testing.expectEqual(line, t.line);
+ }
+ };
+ try expect.at(&lines, 0, "b/src/a.zig", 0); // diff --git
+ try testing.expect(targetAt(&lines, 1, false) == null); // index
+ try expect.at(&lines, 2, "a/src/a.zig", 0); // ---
+ try expect.at(&lines, 3, "b/src/a.zig", 0); // +++
+ try expect.at(&lines, 4, "b/src/a.zig", 10); // @@
+ try expect.at(&lines, 5, "b/src/a.zig", 10); // context
+ try expect.at(&lines, 6, "b/src/a.zig", 11); // removed: the line now there
+ try expect.at(&lines, 7, "b/src/a.zig", 11); // added
+ try expect.at(&lines, 8, "b/src/a.zig", 12);
+ try expect.at(&lines, 9, "b/src/a.zig", 13);
+ // `--- x` is a removed line, gone from past the hunk's new lines.
+ try expect.at(&lines, 10, "b/src/a.zig", 13);
+ try expect.at(&lines, 11, "b/src/a.zig", 41);
+ try expect.at(&lines, 12, "b/src/a.zig", 41);
+ try expect.at(&lines, 13, "b/src/a.zig", 42);
+ try expect.at(&lines, 14, "b/src/a.zig", 42);
+ // A removed line of a deleted file: its old line in the old file.
+ const gone = targetAt(&lines, 21, false).?;
+ try testing.expectEqualStrings("a/old.py", gone.path);
+ try testing.expectEqual(@as(usize, 2), gone.line);
+ try testing.expect(gone.old_side);
+ try testing.expect(targetAt(&lines, 1, false) == null);
+}
+
+test "diff a removed line at the end of a file opens the hunk's last new line" {
+ const lines = splitLines(
+ \\--- a/f.c
+ \\+++ b/f.c
+ \\@@ -1,3 +1,2 @@
+ \\ one
+ \\ two
+ \\-three
+ \\
+ );
+ try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, false).?.line);
+}
+
+test "diff plain diff -u output: no a/ b/, timestamps after the names" {
+ const lines = splitLines("--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++
+ "+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++
+ "@@ -3 +3,2 @@\n" ++
+ "-const a = 1;\n" ++
+ "+const a = 2;\n" ++
+ "+const b = 3;\n");
+ try testing.expect(looksLikeDiff(&lines));
+ const t = targetAt(&lines, 5, false).?;
+ try testing.expectEqualStrings("new/x.zig", t.path);
+ try testing.expectEqual(@as(usize, 4), t.line);
+ // `--- old/x.zig` with `+++ new/x.zig` under it: the new file.
+ try testing.expectEqualStrings("new/x.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqualStrings("new/x.zig", stripSide("new/x.zig"));
+ try testing.expectEqualStrings("x.zig", stripSide("b/x.zig"));
+}
+
+test "diff a --- line opens the new name when the +++ under it names another" {
+ const lines = splitLines(
+ \\diff --git a/old name.zig b/new name.zig
+ \\similarity index 90%
+ \\rename from old name.zig
+ \\rename to new name.zig
+ \\--- a/old name.zig
+ \\+++ b/new name.zig
+ \\@@ -1 +1 @@
+ \\-a
+ \\+b
+ \\
+ );
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 4, false).?.path);
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 5, false).?.path);
+ try testing.expect(targetAt(&lines, 2, false) == null);
+}
+
+test "diff a new file's --- /dev/null opens the file it makes" {
+ const lines = splitLines(
+ \\--- /dev/null
+ \\+++ b/made.zig
+ \\@@ -0,0 +1 @@
+ \\+const x = 1;
+ \\
+ );
+ try testing.expectEqualStrings("b/made.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqual(@as(usize, 1), targetAt(&lines, 2, false).?.line);
+ try testing.expectEqual(@as(usize, 1), targetAt(&lines, 3, false).?.line);
+}
+
+test "diff output is told from other text by its first rows" {
+ const git = [_][]const u8{ "$ git diff", "diff --git a/x b/x", "index 1..2" };
+ try testing.expect(looksLikeDiff(&git));
+ const show = [_][]const u8{ "commit abc", "Author: a", "", " msg", "", "diff --git a/x b/x" };
+ try testing.expect(looksLikeDiff(&show));
+ const prose = [_][]const u8{ "--- a heading", "+++ not a path", "text" };
+ try testing.expect(!looksLikeDiff(&prose));
+ const nothing = [_][]const u8{ "ls", "a.zig b.zig" };
+ try testing.expect(!looksLikeDiff(&nothing));
+}
+
+test "diff hunk headers: counts left out are one, a broken one is none" {
+ try testing.expectEqual(HunkHeader{ .old_start = 3, .old_count = 1, .new_start = 4, .new_count = 1 }, parseHunk("@@ -3 +4 @@").?);
+ try testing.expectEqual(HunkHeader{ .old_start = 1, .old_count = 0, .new_start = 0, .new_count = 0 }, parseHunk("@@ -1,0 +0,0 @@ fn x").?);
+ try testing.expect(parseHunk("@@ -a +1 @@") == null);
+ try testing.expect(parseHunk("@@ -1 +1") == null);
+ try testing.expect(parseHunk("@@@ -1 -1 +1 @@@") == null);
+}
+
+test "diff a terminal's wrapped row stays in its line" {
+ const lines = [_][]const u8{ "--- a/x.c", "+++ b/x.c", "@@ -1,2 +1,2 @@", "+int a = 1; /* a long", "comment */", " int b;", "-int c;" };
+ try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, true).?.line);
+ var w: Walk = .{ .continued_rows = true };
+ var last: Kind = .other;
+ for (lines[0..5]) |line| last = w.step(line).kind;
+ try testing.expectEqual(Kind.added, last);
+}
diff --git a/src/dump.zig b/src/dump.zig
index 1cc115be..8e767240 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -100,13 +100,10 @@ fn hostEnv(name: [*:0]const u8) ?[*:0]u8 {
/// setting when set (a leading `~/` is the home directory), else the default.
pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
if (dir_setting.len == 0) return defaultDirectory(buf);
- var joined: [2048]u8 = undefined;
+ var joined: [4096]u8 = undefined;
// `~/x`, else a relative one from the directory pardes started in: the
// same place however it was written, and shown so.
- const raw = if (std.mem.startsWith(u8, dir_setting, "~/")) (if (hostEnv("HOME")) |h|
- std.fmt.bufPrint(&joined, "{s}/{s}", .{ std.mem.span(h), dir_setting[2..] }) catch return null
- else
- dir_setting) else dir_setting;
+ const raw = pardes.filesystem.expandHome(dir_setting, &joined);
var fba_buf: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&fba_buf);
var cwd_buf: [1024]u8 = undefined;
diff --git a/src/exec.zig b/src/exec.zig
index eed9297a..5a421f19 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -169,10 +169,54 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
result.text = edit.currentSelText(p, pane);
return result;
}
+ // In a diff, a file or hunk header, and a hunk line pointed at on its
+ // `+`/`-`/` ` prefix, are looked at whole: the place the line names
+ // (look.diffLook). A hunk line's code is words, looked at as ever:
+ // the code's, the prefix never one of them.
+ var word_from: usize = 0;
+ if (clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: {
+ const lines = look.diffLines(p, pane) orelse break :diff;
+ const at: usize = @intCast(row);
+ if (at >= lines.len) break :diff;
+ const line = lines[at];
+ const target = @import("diff.zig").targetAt(lines, at, pane.file == null) orelse break :diff;
+ const on_prefix = col == 0 and line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null;
+ switch (target.kind) {
+ .context, .added, .removed => if (!on_prefix) {
+ word_from = @intFromBool(line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null);
+ break :diff;
+ },
+ else => {},
+ }
+ result.text = line;
+ p.diff_look = .{ .serial = pane.serial, .row = at };
+ const hi = std.math.cast(i32, line.len) orelse return result;
+ if (hi == 0) return result;
+ var expanded = clicked;
+ if (pane.file != null) {
+ result.file_word = .{ .row = row, .lo = 0, .hi = hi };
+ const first = pane.wrapRow(row, 0);
+ const last = pane.wrapRow(row, hi - 1);
+ if (first.row < 0 or last.row < 0) return result;
+ expanded.r0 = first.row + @as(i32, pane.tag_rows);
+ expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, 0);
+ expanded.r1 = last.row + @as(i32, pane.tag_rows);
+ expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1);
+ } else {
+ const shown = if (raw) raw_line else line;
+ expanded.c0 = 0;
+ expanded.c1 = @as(i32, @intCast(panes.File.rawDisplayCol(shown, shown.len))) - 1;
+ }
+ result.preview = expanded;
+ result.expanded = expanded;
+ return result;
+ }
if (pane.file != null and clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
const line = panes.File.sourceLine(pane, row);
const source_col: usize = @intCast(@max(0, col));
- const b = look.expandedWord(line, source_col) orelse return result;
+ if (source_col < word_from or word_from > line.len) return result;
+ const word = look.expandedWord(line[word_from..], source_col - word_from) orelse return result;
+ const b: look.ExpandedWord = .{ .lo = word.lo + word_from, .hi = word.hi + word_from };
const lo = std.math.cast(i32, b.lo) orelse return result;
const hi = std.math.cast(i32, b.hi) orelse return result;
result.text = line[b.lo..b.hi];
@@ -295,9 +339,10 @@ pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void {
if (path.len == 0 or path[path.len - 1] == '/' or
std.mem.indexOfScalar(u8, path, '\n') != null)
return p.reportError(id, "save", error.NoFilename);
+ var home_buf: [4096]u8 = undefined;
const full = std.fs.path.resolvePosix(
p.scratch.allocator(),
- &.{ Pardes.paneDir(pane), path },
+ &.{ Pardes.paneDir(pane), @import("fs.zig").expandHome(path, &home_buf) },
) catch return;
if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory);
if (full.len > Pardes.SavePath.cap) return p.reportError(id, "save", error.PathTooLong);
@@ -643,12 +688,22 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize {
/// `execute` for text a gesture took from `id`'s body when `from_body`:
/// such text may be a bound REPL's. Run by name (`Exec ...`, whose line
/// comes back through `execute`) or written as a command word, never.
+/// A line that starts with `#` is a comment, as in a shell: it runs as
+/// nothing, silently, wherever a line runs (exec, a ctl, the init file),
+/// so DumpConfig's report reads back as configuration whole.
+pub fn isComment(line: []const u8) bool {
+ const t = std.mem.trimStart(u8, line, " \t");
+ return t.len > 0 and t[0] == '#';
+}
+
pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usize {
- // An empty window runs its session words, and nothing else: no pane to
- // run a command for.
+ if (isComment(commandText(txt))) return null;
+ // An empty window runs its session words, and a command line in the
+ // session's directory (execDir), as its workspace tag would.
const pane = p.panes[id] orelse {
- _ = executeBuiltinLine(p, id, commandText(txt));
- return null;
+ const cmd = commandText(txt);
+ if (cmd.len == 0 or executeBuiltinLine(p, id, cmd)) return null;
+ return runCommand(p, id, cmd);
};
const gesture = from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == null;
const cmd = commandText(txt);
@@ -699,7 +754,9 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
p.reportFailure(from, "exec: a command line is at most 1024 bytes");
return null;
}
- const src = p.panes[from] orelse return null;
+ // None asking (an empty window): the session's directory, no pane of
+ // its own to reuse.
+ const src = p.panes[from];
const dir = p.execDir(from);
// A directory that is not there runs nothing, as Tty makes nothing
// there: no pane, and the write fails ENOENT.
@@ -712,7 +769,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
// would hang that job up.
// From a column's tag, only a finished pane in that column is reused,
// as the new one goes in that column (placeNew).
- const reuse: ?usize = if (p.exec_column == null and src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| {
+ const reuse: ?usize = if (p.exec_column == null and src != null and src.?.command != null and src.?.command_done and !src.?.command_pty) from else for (p.panes, 0..) |slot, i| {
const other = slot orelse continue;
if (p.exec_column) |c| if (if (layout.findPane(p, i)) |f| f.col != c else true) continue;
if (other.command != null and other.command_done and !other.command_pty and std.mem.eql(u8, other.cwdSlice(), dir)) break i;
@@ -725,6 +782,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
};
p.gpa.free(pane.command.?);
pane.command = owned;
+ p.diff_output.drop(p.gpa, pane.serial);
pane.command_done = false;
pane.command_status = null;
pane.command_pty = true;
@@ -838,6 +896,7 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint:
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
const raw = commandText(txt);
if (raw.len == 0 or p.exec_depth >= max_exec_depth) return false;
+ if (isComment(raw)) return true;
const word_end = std.mem.indexOfAny(u8, raw, " \t") orelse raw.len;
var renamed: [1024 + 16]u8 = undefined;
const cmd = for (acme_words) |w| {
@@ -1032,9 +1091,12 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
// No pane to halve, but the column holds every pane's minimum
// with the new one's: the rows are shared out again, so what is
// refused is only what the documented arithmetic cannot hold.
- // +Errors goes wherever there is room, last column first; with
- // none, its maker says the text some other way (appendErrors).
- if (layout.shareColumn(p, f.col)) {} else if (kind == .errors) {
+ // +Errors and 9P's pane/new go wherever there is room, last
+ // column first, as acme takes rows from a window that has them
+ // before saying there are none; with none, +Errors's maker says
+ // the text some other way (appendErrors), and pane/new is
+ // refused.
+ if (layout.shareColumn(p, f.col)) {} else if (kind == .errors or kind == .fs) {
layout.removePane(p, id);
var c = p.ncol;
while (c > 0) {
@@ -1051,6 +1113,11 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
} else {
layout.insert(p, f.col, p.col_n[f.col], id);
p.unplaced.set(id);
+ // No pane anywhere can give it rows: pane/new is refused.
+ if (kind == .fs) {
+ p.panes[id].?.vweight = 0;
+ p.reportError(from, "placement", error.NoPaneRoom);
+ }
return;
}
} else {
diff --git a/src/fs.zig b/src/fs.zig
index 30ad9c43..7695d497 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -732,6 +732,59 @@ pub fn dotRelative(word: []const u8) bool {
/// Where a path really is on the host, and whether it is a directory. The
/// path may lie in a mount this editor serves, so the turn is given up for
/// the syscalls: another thread answers them.
+/// A path's leading `~` -- alone, or before `/` or `:` (`~/x:12`) -- is the
+/// home directory: $HOME, else the passwd entry's. `~user/...` is that
+/// user's. As in a shell, `~` is home even beside a file named `~` in the
+/// current directory. Every entry point that takes a path the user typed
+/// runs it through here: look (B3 and 9P), name, Save, ThemeFile, DumpDir,
+/// Restore, a forwarded `pardes ~/x`. The path itself when there is no
+/// `~` to expand, or no home for it.
+pub fn expandHome(path: []const u8, out: *[4096]u8) []const u8 {
+ if (comptime !pardes.hosted) return path;
+ return expandHomeFrom(path, out, std.c.getenv("HOME"));
+}
+
+/// expandHome with HOME given (null: unset), so a test need not change the
+/// process's environment, which a child spawned later reads.
+fn expandHomeFrom(path: []const u8, out: *[4096]u8, env_home: ?[*:0]const u8) []const u8 {
+ if (path.len == 0 or path[0] != '~') return path;
+ const end = std.mem.indexOfAny(u8, path, "/:") orelse path.len;
+ const home = (if (end == 1) homeDir(env_home) else userHome(path[1..end])) orelse return path;
+ const trimmed = std.mem.trimEnd(u8, home, "/");
+ const rest = path[end..];
+ if (trimmed.len == 0 and (rest.len == 0 or rest[0] != '/')) return std.fmt.bufPrint(out, "/{s}", .{rest}) catch path;
+ return std.fmt.bufPrint(out, "{s}{s}", .{ trimmed, rest }) catch path;
+}
+
+fn homeDir(env_home: ?[*:0]const u8) ?[]const u8 {
+ if (env_home) |h| if (h[0] != 0) return std.mem.span(h);
+ const pw = std.c.getpwuid(std.c.getuid()) orelse return null;
+ return std.mem.span(pw.dir orelse return null);
+}
+
+fn userHome(name: []const u8) ?[]const u8 {
+ var z: [256]u8 = undefined;
+ const name_z = std.fmt.bufPrintSentinel(&z, "{s}", .{name}, 0) catch return null;
+ const pw = std.c.getpwnam(name_z) orelse return null;
+ return std.mem.span(pw.dir orelse return null);
+}
+
+test "a leading ~ is home, from HOME or else the passwd entry" {
+ var out: [4096]u8 = undefined;
+ const home = homeDir(std.c.getenv("HOME")) orelse return error.SkipZigTest;
+ var want: [4096]u8 = undefined;
+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{s}/x:12", .{std.mem.trimEnd(u8, home, "/")}), expandHome("~/x:12", &out));
+ try std.testing.expectEqualStrings(std.mem.trimEnd(u8, home, "/"), expandHome("~", &out));
+ try std.testing.expectEqualStrings("a/~/b", expandHome("a/~/b", &out));
+ try std.testing.expectEqualStrings("~no-such-user-pardes/x", expandHome("~no-such-user-pardes/x", &out));
+ try std.testing.expectEqualStrings("/", expandHome("~root", &out)[0..1]);
+
+ // HOME unset: the passwd entry's home.
+ const pw = std.c.getpwuid(std.c.getuid()) orelse return error.SkipZigTest;
+ var from_passwd: [4096]u8 = undefined;
+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&from_passwd, "{s}/y", .{std.mem.trimEnd(u8, std.mem.span(pw.dir.?), "/")}), expandHomeFrom("~/y", &out, null));
+}
+
pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved {
if (comptime !platform_has_fs) return null;
var z: [4096]u8 = undefined;
@@ -1171,7 +1224,9 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
/// Restore first tries a relative argument under the default dump directory,
/// then preserves the ordinary file lookup. Absolute paths remain verbatim.
-pub fn readRestore(gpa: std.mem.Allocator, path: []const u8, dir_setting: []const u8) ![]u8 {
+pub fn readRestore(gpa: std.mem.Allocator, typed: []const u8, dir_setting: []const u8) ![]u8 {
+ var home_buf: [4096]u8 = undefined;
+ const path = expandHome(typed, &home_buf);
var dir_buf: [4096]u8 = undefined;
const dir = @import("dump.zig").directory(&dir_buf, dir_setting);
return readRestoreIn(gpa, path, dir);
diff --git a/src/host_io.zig b/src/host_io.zig
index ac25e63c..8cbdd2d4 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -19,6 +19,10 @@ pub const Host = struct {
present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null,
post_present: ?*const fn (ctx: ?*anyopaque) void = null,
poll_frame: ?*const fn (ctx: ?*anyopaque) void = null,
+ /// Whether anyone sees a frame drawn now. A detached session with no
+ /// client attached draws none (a /screen read draws its own), and
+ /// the frame owed is drawn when one attaches. Absent: always.
+ watched: ?*const fn (ctx: ?*anyopaque) bool = null,
detach: ?*const fn (ctx: ?*anyopaque) void = null,
spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null,
pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null,
@@ -1601,6 +1605,10 @@ pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool {
const exe = procExe(node.pid, &probe.exe) orelse {
if (offTty(node.pid, &probe.blob)) continue;
+ // Exiting (its exe gone, not a zombie yet), or not ours
+ // to read: outside the foreground group it holds
+ // nothing, as a killed background job does for a moment.
+ if (pgrp) |g| if (g != fg) continue;
return true;
};
if (!std.mem.eql(u8, exe, self_exe)) {
diff --git a/src/layout.zig b/src/layout.zig
index 28f3d270..4aebcf28 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -325,8 +325,14 @@ pub fn removePane(p: *Pardes, id: usize) void {
/// Close column `c` once its panes are gone, its width to the one left of
/// it (the first column's to its right), as acme's rowclose gives it.
pub fn dropColumn(p: *Pardes, c: usize) void {
- std.debug.assert(p.col_n[c] == 0);
const serial = columnSerial(p, c);
+ dropColumnUnannounced(p, c);
+ noteColumn(p, "delcol", serial);
+}
+
+/// dropColumn with no `delcol`, for a column never announced.
+pub fn dropColumnUnannounced(p: *Pardes, c: usize) void {
+ std.debug.assert(p.col_n[c] == 0);
tagline.exitHeader(p);
if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
p.col_tags[c].deinit(p.gpa);
@@ -341,12 +347,11 @@ pub fn dropColumn(p: *Pardes, c: usize) void {
p.ncol -= 1;
p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[p.ncol] = 0;
- noteColumn(p, "delcol", serial);
}
/// `newcol <serial>` or `delcol <serial>` in the log: the column's serial,
/// as /layout and /col name it.
-fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void {
+pub fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void {
var buf: [16]u8 = undefined;
pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch return);
}
@@ -649,6 +654,14 @@ pub fn firstColumn(p: *Pardes) ?usize {
}
pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize {
+ const c = insertColumnUnannounced(p, col, before) orelse return null;
+ noteColumn(p, "newcol", columnSerial(p, c));
+ return c;
+}
+
+/// insertColumn with no serial given and no `newcol`: a column that may
+/// yet be taken back (Newcol's trial), which then leaves no trace.
+pub fn insertColumnUnannounced(p: *Pardes, col: usize, before: bool) ?usize {
if (!canSplitCol(p, col)) return null;
var old_weight = p.col_weight[col];
if (old_weight < 2 or old_weight % 2 != 0) {
@@ -672,7 +685,6 @@ pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize {
p.col_serial[c] = 0;
p.col_n[c] = 0;
p.ncol += 1;
- noteColumn(p, "newcol", columnSerial(p, c));
return c;
}
diff --git a/src/look.zig b/src/look.zig
index 36eae75c..76b58fbe 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -6,6 +6,7 @@ const exec = @import("exec.zig");
const config = @import("config.zig");
const pdf_enabled = @import("pardes_config").mupdf;
const fs = @import("fs.zig");
+const diff = @import("diff.zig");
const platform_has_fs = fs.platform_has_fs;
const layout = @import("layout.zig");
const animation = @import("animation.zig");
@@ -457,7 +458,7 @@ pub fn invalidateLookHover(p: *Pardes, id: usize) void {
cancelLookHover(p);
}
-const ExpandedWord = struct { lo: usize, hi: usize };
+pub const ExpandedWord = struct { lo: usize, hi: usize };
pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
if (col >= line.len) return null;
@@ -1233,6 +1234,25 @@ fn missedLine(p: *Pardes, id: usize, path: []const u8, line: anytype) void {
missed(p, id, "{s} has no line {d}", .{ missText(path, &cut), line });
}
+/// Whether `:line:col`, `:line:col-col` or `:line:col-line:col` after a
+/// path names a column 0, which parsePathLine reads as no column.
+fn zeroColumn(rest: []const u8) bool {
+ var fields = std.mem.splitScalar(u8, rest, ':');
+ _ = fields.next(); // before the first ':'
+ _ = fields.next() orelse return false; // the line
+ const col = fields.next() orelse return false;
+ var ends = std.mem.splitScalar(u8, col, '-');
+ if (std.mem.eql(u8, ends.next().?, "0")) return true;
+ if (ends.next()) |end| if (std.mem.eql(u8, end, "0")) return true;
+ if (fields.next()) |end_col| if (std.mem.eql(u8, end_col, "0")) return true;
+ return false;
+}
+
+test "a zero column after a path is found, in every spelling a look takes" {
+ for ([_][]const u8{ ":3:0", ":3:0-4", ":3:2-0", ":3:2-5:0" }) |rest| try std.testing.expect(zeroColumn(rest));
+ for ([_][]const u8{ "", ":3", ":3:1", ":3:2-5", ":3:2-5:7", ":30:10" }) |rest| try std.testing.expect(!zeroColumn(rest));
+}
+
fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void {
p.look_missed = true;
var said: [miss_path_cap + 64]u8 = undefined;
@@ -1480,8 +1500,106 @@ fn isRecentList(pane: *const Pane) bool {
return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }) or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist });
}
-pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
+// ---- a look on a diff line ----
+
+/// The lines of a pane showing a diff, a .diff or .patch file or a
+/// finished command pane whose output is one (File.DiffOutput); null for
+/// any other pane. Good until the next step.
+pub fn diffLines(p: *Pardes, pane: *Pane) ?[]const []const u8 {
+ if (pane.file) |*f| {
+ if (pane.colorAlgo() != .diff) return null;
+ return panes.File.cursorLines(p.scratch.allocator(), pane, f) catch null;
+ }
+ if (!pane.isTerminal()) return null;
+ return p.diff_output.lines(p, pane);
+}
+
+const DiffLook = union(enum) { none, done, at: []const u8 };
+
+/// A look in a diff pane at one of its lines opens the file and line that
+/// line names (diff.targetAt): the new file at a `@@` line's first new
+/// line, at a context or added line's own line, and at a removed line's
+/// place, the new line now standing where it was. `---`/`+++` open their
+/// file. The path is the repository's (walking up from the diff's
+/// directory to a `.git` or `.jj`), `a/` and `b/` dropped, else the
+/// directory's own. A file that is not there opens nothing and says so.
+///
+/// Which line: the one a click just gave (`clicked`: exec.pointerOperand
+/// expanded to it and kept its row), else, for a look written over 9P, a
+/// line the text is the whole of, the cursor's first, then the first after
+/// it. A word on a hunk line is looked at as ever.
+fn diffLook(p: *Pardes, id: usize, pane: *Pane, operand: []const u8, clicked: bool, buf: []u8) DiffLook {
+ const text = std.mem.trimEnd(u8, operand, "\r\n");
+ if (std.mem.trim(u8, text, " \t").len == 0) return .none;
+ const lines = diffLines(p, pane) orelse return .none;
+ const row: usize = row: {
+ if (p.diff_look) |at| if (at.serial == pane.serial and at.row < lines.len and
+ std.mem.eql(u8, std.mem.trimEnd(u8, lines[at.row], "\r"), text))
+ {
+ p.diff_look = null;
+ break :row at.row;
+ };
+ if (clicked) return .none;
+ const cur: usize = @intCast(@max(0, pane.body.cur_row));
+ for (0..lines.len) |k| {
+ const i = (cur + k) % lines.len;
+ if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text)) break :row i;
+ }
+ return .none;
+ };
+ const target = diff.targetAt(lines, row, pane.file == null) orelse return .none;
+ const base = if (pane.file) |f| std.fs.path.dirname(f.path) orelse "/" else pane.cwdSlice();
+ var realbuf: [4096]u8 = undefined;
+ const found = diffFile(base, target.path, &realbuf) orelse {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ if (target.old_side)
+ missed(p, id, "{s}: deleted by this diff", .{missText(target.path, &cut)})
+ else
+ missed(p, id, "{s}: no such file here", .{missText(target.path, &cut)});
+ return .done;
+ };
+ const place = if (target.line > 0)
+ std.fmt.bufPrint(buf, "{s}:{d}", .{ found, target.line }) catch return .none
+ else
+ std.fmt.bufPrint(buf, "{s}", .{found}) catch return .none;
+ return .{ .at = place };
+}
+
+/// The file a diff names, from `base` (the diff's directory): tried in the
+/// repository `base` is in, then in `base`, as git writes it (`b/x`, the
+/// repository's `x`) and as written.
+fn diffFile(base: []const u8, path: []const u8, out: *[4096]u8) ?[]const u8 {
+ if (comptime !platform_has_fs) return null;
+ if (path.len == 0) return null;
+ if (path[0] == '/') {
+ const found = fs.resolveOs(path, out) orelse return null;
+ return if (found.dir) null else found.path;
+ }
+ var root_buf: [4096]u8 = undefined;
+ var dir: ?[]const u8 = base;
+ const root: ?[]const u8 = while (dir) |d| : (dir = std.fs.path.dirname(d)) {
+ var probe: [4096]u8 = undefined;
+ const git = std.fmt.bufPrint(&probe, "{s}/.git", .{d}) catch break null;
+ if (fs.resolveOs(git, &root_buf) != null) break d;
+ const jj = std.fmt.bufPrint(&probe, "{s}/.jj", .{d}) catch break null;
+ if (fs.resolveOs(jj, &root_buf) != null) break d;
+ } else null;
+ for ([_]?[]const u8{ root, base }) |maybe| {
+ const at = maybe orelse continue;
+ for ([_][]const u8{ diff.stripSide(path), path }) |name| {
+ var joined: [4096]u8 = undefined;
+ const full = std.fmt.bufPrint(&joined, "{s}/{s}", .{ at, name }) catch continue;
+ if (fs.resolveOs(full, out)) |found| if (!found.dir) return found.path;
+ }
+ }
+ return null;
+}
+
+pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void {
const pane = p.panes[id] orelse return;
+ // `~/x`, `~/x:12`, `~/x:/re/`: home, before anything reads the text.
+ var home_buf: [4096]u8 = undefined;
+ const operand = filesystem.expandHome(std.mem.trim(u8, operand_raw, " \t\r\n"), &home_buf);
// A +Recent row is a remembered place: reopened and clamped to the file
// as it is now, never a miss for a line the file no longer has.
if (isRecentList(pane)) {
@@ -1493,17 +1611,35 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
p.noteHaptic(.look);
noteLookSource(p, id);
const command = std.mem.trim(u8, operand, " \t\r\n");
- if (config.commandWord(command)) |cmd| {
- if (exec.execute(p, id, cmd)) |dst| p.active = dst;
- return;
- }
+ // A click on a diff line (a context line ` Del` included) is a look at
+ // the place it names, never the word run.
+ var diff_buf: [4096 + 32]u8 = undefined;
+ const looked: []const u8 = looked: {
+ switch (diffLook(p, id, pane, operand_raw, true, &diff_buf)) {
+ .none => {},
+ .done => return,
+ .at => |place| break :looked place,
+ }
+ if (config.commandWord(command)) |cmd| {
+ if (exec.execute(p, id, cmd)) |dst| p.active = dst;
+ return;
+ }
+ switch (diffLook(p, id, pane, operand_raw, false, &diff_buf)) {
+ .none => break :looked operand,
+ .done => return,
+ .at => |place| break :looked place,
+ }
+ };
// The operand may borrow selected terminal/PDF text released below.
- const txt = p.scratch.allocator().dupe(u8, operand) catch return;
+ const txt = p.scratch.allocator().dupe(u8, looked) catch return;
const trimmed = std.mem.trim(u8, txt, " \t\r\n");
// acme's `file:addr` and `:addr` (look.c:450-543): an address after
// the colon, evaluated in that file from its dot (openfile, look.c:675).
if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr);
const pl = parsePathLine(trimmed);
+ // `file:3:0`: columns count from 1, as addr's do, and a zero is refused
+ // in addr's words rather than taken as no column.
+ if (pl.at.line != 0 and zeroColumn(trimmed[pl.path.len..])) return p.reportFailure(id, @import("ninep/addr.zig").e_col_zero);
if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane);
if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return;
var realbuf: [4096]u8 = undefined;
@@ -1884,3 +2020,275 @@ test "a pointer resting where a look-hover found nothing does not wake the core
try std.testing.expect(frames < 20);
try std.testing.expect(p.look_hover_wait == null and p.look_hover_preview == null);
}
+
+// A diff pane on `diff_text`, saved as change.diff in a fresh directory
+// that holds `files` (path, content) and, if `repo`, a `.git`.
+const DiffFixture = struct {
+ tmp: std.testing.TmpDir,
+ dir: []const u8,
+ dir_buf: [4096]u8 = undefined,
+
+ fn init(f: *DiffFixture, repo: bool, files: []const [2][]const u8, diff_text: []const u8) !void {
+ const io = std.testing.io;
+ f.tmp = std.testing.tmpDir(.{});
+ errdefer f.tmp.cleanup();
+ if (repo) try f.tmp.dir.createDir(io, ".git", .default_dir);
+ for (files) |file| {
+ if (std.fs.path.dirname(file[0])) |sub| try f.tmp.dir.createDirPath(io, sub);
+ try f.tmp.dir.writeFile(io, .{ .sub_path = file[0], .data = file[1] });
+ }
+ try f.tmp.dir.writeFile(io, .{ .sub_path = "change.diff", .data = diff_text });
+ const len = try f.tmp.dir.realPathFile(io, "change.diff", &f.dir_buf);
+ f.dir = std.fs.path.dirname(f.dir_buf[0..len]).?;
+ }
+
+ fn open(f: *DiffFixture, p: *Pardes, diff_text: []const u8) !*Pane {
+ const pane = try p.setTestFile(diff_text);
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try std.fs.path.join(p.gpa, &.{ f.dir, "change.diff" });
+ return pane;
+ }
+
+ /// A right click's look at `row` of the diff pane (slot 0).
+ fn click(p: *Pardes, diff_pane: *Pane, row: usize) void {
+ p.active = 0;
+ const lines = diffLines(p, diff_pane).?;
+ p.diff_look = .{ .serial = diff_pane.serial, .row = row };
+ p.look_missed = false;
+ lookAt(p, 0, lines[row]);
+ }
+};
+
+fn expectAt(p: *Pardes, suffix: []const u8, line: usize) !void {
+ const pane = p.panes[p.active].?;
+ const f = pane.file orelse return error.NoFileOpened;
+ if (!std.mem.endsWith(u8, f.path, suffix)) {
+ std.debug.print("opened {s}, not {s}\n", .{ f.path, suffix });
+ return error.WrongFile;
+ }
+ try std.testing.expectEqual(@as(i32, @intCast(line)) - 1, pane.body.cur_row);
+}
+
+test "Look on each kind of diff line opens the new file at the line it names" {
+ if (!platform_has_fs) return;
+ const numbered = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\n" ++
+ "line 10\nline 11\nline 12\nline 13\nline 14\nline 15\n";
+ const diff_text =
+ \\diff --git a/src/a.zig b/src/a.zig
+ \\index 1111111..2222222 100644
+ \\--- a/src/a.zig
+ \\+++ b/src/a.zig
+ \\@@ -10,3 +10,4 @@ fn x() void {
+ \\ line 10
+ \\-Del
+ \\+line 11
+ \\+line 12
+ \\ line 13
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{.{ "src/a.zig", numbered }}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+
+ DiffFixture.click(p, pane, 4); // @@: the hunk's first new line
+ try expectAt(p, "/src/a.zig", 10);
+ DiffFixture.click(p, pane, 5); // a context line
+ try expectAt(p, "/src/a.zig", 10);
+ // A removed line (whose text is a builtin: no Del runs) opens the new
+ // line now standing where it was.
+ DiffFixture.click(p, pane, 6);
+ try expectAt(p, "/src/a.zig", 11);
+ try std.testing.expect(p.panes[0] != null);
+ DiffFixture.click(p, pane, 7); // added lines
+ try expectAt(p, "/src/a.zig", 11);
+ DiffFixture.click(p, pane, 8);
+ try expectAt(p, "/src/a.zig", 12);
+ DiffFixture.click(p, pane, 9);
+ try expectAt(p, "/src/a.zig", 13);
+ DiffFixture.click(p, pane, 3); // +++ b/path: the file, a/ b/ dropped
+ try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/src/a.zig"));
+ DiffFixture.click(p, pane, 2); // --- a/path
+ try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/src/a.zig"));
+ try std.testing.expect(!p.look_missed);
+
+ // No click: a 9P look writes a line, found after the cursor, or the
+ // text is on the cursor's line.
+ p.active = 0;
+ pane.body.cur_row = 0;
+ p.diff_look = null;
+ lookAt(p, 0, "+line 12");
+ try expectAt(p, "/src/a.zig", 12);
+ // A word on a hunk line is a word, looked for in the diff as ever.
+ p.active = 0;
+ pane.body.cur_row = 9;
+ lookAt(p, 0, "line");
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ // A line that is no part of a hunk is looked at as ever.
+ p.active = 0;
+ pane.body.cur_row = 1;
+ lookAt(p, 0, "1111111");
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+}
+
+test "Look on a diff line of a file that is not here says so and opens nothing" {
+ if (!platform_has_fs) return;
+ const diff_text =
+ \\diff --git a/src/elsewhere.zig b/src/elsewhere.zig
+ \\--- a/src/elsewhere.zig
+ \\+++ b/src/elsewhere.zig
+ \\@@ -1 +1 @@
+ \\-const a = 1;
+ \\+const a = 2;
+ \\diff --git a/old.zig b/old.zig
+ \\deleted file mode 100644
+ \\--- a/old.zig
+ \\+++ /dev/null
+ \\@@ -1 +0,0 @@
+ \\-const gone = 1;
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+ var before: usize = 0;
+ for (p.panes) |slot| before += @intFromBool(slot != null);
+ DiffFixture.click(p, pane, 5);
+ try std.testing.expect(p.look_missed);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try std.testing.expectEqual(before, after);
+ try std.testing.expectEqualStrings("Look: b/src/elsewhere.zig: no such file here", pane.msg[0..pane.msg_len]);
+ DiffFixture.click(p, pane, 11);
+ try std.testing.expect(p.look_missed);
+ try std.testing.expectEqualStrings("Look: a/old.zig: deleted by this diff", pane.msg[0..pane.msg_len]);
+}
+
+test "Look on plain diff -u output: names as written, timestamps cut, next to the diff" {
+ if (!platform_has_fs) return;
+ const diff_text = "--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++
+ "+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++
+ "@@ -1,2 +1,3 @@\n" ++
+ " const a = 1;\n" ++
+ "+const b = 2;\n" ++
+ " const c = 3;\n";
+ var fx: DiffFixture = undefined;
+ try fx.init(false, &.{.{ "new/x.zig", "const a = 1;\nconst b = 2;\nconst c = 3;\n" }}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+ DiffFixture.click(p, pane, 4);
+ try expectAt(p, "/new/x.zig", 2);
+ DiffFixture.click(p, pane, 5);
+ try expectAt(p, "/new/x.zig", 3);
+}
+
+test "Look on a diff: headers and a hunk line's prefix are the whole line, hover and click alike" {
+ if (!platform_has_fs) return;
+ const numbered = "one\ntwo\nthree\nfour\nfive\nsix\n";
+ const diff_text =
+ \\diff --git a/src/old.zig b/src/new.zig
+ \\similarity index 80%
+ \\rename from src/old.zig
+ \\rename to src/new.zig
+ \\--- a/src/old.zig
+ \\+++ b/src/new.zig
+ \\@@ -2,2 +2,2 @@ fn x() void {
+ \\ two
+ \\-drei
+ \\+three
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{.{ "src/new.zig", numbered }}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+ pane.tag_expanded = false;
+ // `word`: what the pointer there expands to when it is not the whole
+ // line; `line` 0: that word is looked at in the diff, nothing opened.
+ const Point = struct { row: u16, col: u16, line: usize, word: ?[]const u8 = null };
+ const points = [_]Point{
+ .{ .row = 0, .col = 0, .line = 1 }, // `diff`: the new side
+ .{ .row = 0, .col = 35, .line = 1 }, // on b/src/new.zig
+ .{ .row = 4, .col = 1, .line = 1 }, // `---`: renamed, so the new name
+ .{ .row = 4, .col = 5, .line = 1 }, // the `a/` of the old name
+ .{ .row = 5, .col = 0, .line = 1 }, // `+++`
+ .{ .row = 5, .col = 4, .line = 1 }, // its `b/`
+ .{ .row = 5, .col = 10, .line = 1 }, // its path
+ .{ .row = 6, .col = 0, .line = 2 }, // the first `@@`
+ .{ .row = 6, .col = 9, .line = 2 }, // `+2,2`
+ .{ .row = 6, .col = 13, .line = 2 }, // the second `@@`
+ .{ .row = 6, .col = 20, .line = 2 }, // the function after it
+ .{ .row = 7, .col = 0, .line = 2 }, // a context line's ` `
+ .{ .row = 8, .col = 0, .line = 3 }, // a removed line's `-`: where it was
+ .{ .row = 9, .col = 0, .line = 3 }, // an added line's `+`
+ .{ .row = 9, .col = 3, .line = 0, .word = "three" }, // its code: a word
+ .{ .row = 7, .col = 2, .line = 0, .word = "two" },
+ };
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ for (points) |at| {
+ // Back to the diff, scrolled to its top again.
+ p.active = 0;
+ pane.file.?.scroll = 0;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + at.col;
+ const y = p.bodyTop(pane, rect) + at.row;
+ // The hover's span is the click's operand.
+ const pointed = edit.pointerTextSelection(p, 0, x, y, null, null) orelse return error.NothingPointed;
+ const operand = exec.pointerOperand(p, pane, pointed.sel);
+ const lines = diffLines(p, pane).?; // scratch: good until the next update
+ const span = operand.file_word orelse return error.NoHoverSpan;
+ try std.testing.expectEqual(@as(i32, at.row), span.row);
+ if (at.word) |word| {
+ try std.testing.expectEqualStrings(word, operand.text.?);
+ try std.testing.expectEqualStrings(word, lines[at.row][@intCast(span.lo)..@intCast(span.hi)]);
+ } else {
+ try std.testing.expectEqualStrings(lines[at.row], operand.text.?);
+ try std.testing.expectEqual(@as(i32, 0), span.lo);
+ try std.testing.expectEqual(@as(i32, @intCast(lines[at.row].len)), span.hi);
+ }
+ p.look_missed = false;
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x, .row = y } });
+ if (at.line == 0) {
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ continue;
+ }
+ try std.testing.expect(!p.look_missed);
+ if (p.active == 0) {
+ std.debug.print("a look at row {d} col {d} stayed in the diff\n", .{ at.row, at.col });
+ return error.LookStayed;
+ }
+ try expectAt(p, "/src/new.zig", at.line);
+ }
+}
+
+test "Look outside a diff pane expands and looks as ever" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("+++ b/x.zig\n@@ -1 +1 @@\n+const a = 1;\n");
+ pane.tag_expanded = false;
+ try std.testing.expect(diffLines(p, pane) == null);
+ p.sync();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + 0;
+ const pointed = edit.pointerTextSelection(p, 0, x, p.bodyTop(pane, rect) + 2, null, null).?;
+ const operand = exec.pointerOperand(p, pane, pointed.sel);
+ try std.testing.expect(operand.text == null or !std.mem.eql(u8, operand.text.?, "+const a = 1;"));
+}
diff --git a/src/main.zig b/src/main.zig
index 6bdf2bfd..101a5be3 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -102,13 +102,18 @@ const help_text =
/// pane `path` landed in (the one it already had, if open), then /log
/// followed on one connection until that pane's `del` -- exit 0 -- or the
/// connection ends with the session -- exit 1.
-fn waitForDel(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, path: []const u8) noreturn {
- const first = ninep_io.Client.read(gpa, dial, "/index", "/index") catch std.process.exit(1);
- const serial = paneShowing(first, path) orelse {
- std.Io.File.stderr().writeStreamingAll(io, "pardes: --wait: no pane shows that file\n") catch {};
- std.process.exit(1);
+fn waitForDel(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, path: []const u8, known: ?u32) noreturn {
+ _ = io;
+ // The pane this launch made (a name not there yet) is known by its
+ // serial; one a look opened or went to is found by its file. Found by
+ // none after the look went there, it was closed already: that is the
+ // end being waited for, not a failure (a pane closed between the look
+ // and this read made `--wait` exit 1 before).
+ const serial = known orelse found: {
+ const first = ninep_io.Client.read(gpa, dial, "/index", "/index") catch std.process.exit(1);
+ defer gpa.free(first);
+ break :found paneShowing(first, path) orelse std.process.exit(0);
};
- gpa.free(first);
const Wait = struct {
gpa: std.mem.Allocator,
dial: []const u8,
@@ -390,22 +395,35 @@ fn nativeMain(init: std.process.Init) !void {
// leave a stale environment variable able to lock someone out of their
// own editor.
const parent = found orelse break :forwarding;
+ // Does it answer? One that answers takes the file or says why not,
+ // as acme's B does: from here on never a nested editor, whose
+ // screen would be drawn inside the pane that asked.
+ ninep_io.Client.probe(arena, parent.dial) catch break :forwarding;
+ const Refuse = struct {
+ fn with(io: std.Io, what: []const u8, err: anyerror) noreturn {
+ const why = if (err == error.Remote) ninep_io.Client.lastSaid() else @errorName(err);
+ var buf: [8192]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, "pardes: {s}: {s}\n", .{ what[0..@min(what.len, 4096)], why }) catch "pardes: refused\n";
+ std.Io.File.stderr().writeStreamingAll(io, text) catch {};
+ std.process.exit(1);
+ }
+ };
// The pane's `look` file: one line, and the line is the clicked text
// itself, which is what a right click in that pane would have been.
var look_buf: [64]u8 = undefined;
const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial});
- const word = positional orelse {
+ const typed_word = positional orelse {
var tag_buf: [64]u8 = undefined;
const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial});
- const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch break :forwarding;
+ const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch |err| Refuse.with(init.io, tag, err);
arena.free(contents);
try std.Io.File.stderr().writeStreamingAll(init.io, nested_text);
std.process.exit(1);
};
- // A word this launch cannot turn into a path is an argument problem
- // rather than a nesting one: fall through and let the session that
- // starts report it, the way `pardes typo` outside pardes does.
- if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding;
+ if (std.mem.indexOfAny(u8, typed_word, "\r\n") != null) Refuse.with(init.io, "a file name", error.NotOneLine);
+ // `pardes '~/x'`, quoted so the shell left it: home, as a look's.
+ var home_buf: [4096]u8 = undefined;
+ const word = pardes.filesystem.expandHome(typed_word, &home_buf);
const target = @import("look.zig").parsePathLine(word);
var realbuf: [4096]u8 = undefined;
var newbuf: [4096]u8 = undefined;
@@ -415,32 +433,50 @@ fn nativeMain(init: std.process.Init) !void {
// resolved and the name kept, a pane made for it that Save
// creates the file from.
const base = std.fs.path.basename(target.path);
- if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding;
+ if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) Refuse.with(init.io, word, error.NotAFileName);
const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse {
// Its directory not there either: the name made absolute as
- // written, and the pane's Save makes the directories (a
- // name into a missing one says so, pane.zig writeName).
+ // written, and the pane's Save makes the directories.
var cwd_buf: [4096]u8 = undefined;
- if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) break :forwarding;
+ if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) Refuse.with(init.io, word, error.NoWorkingDirectory);
const cwd = std.mem.sliceTo(&cwd_buf, 0);
- break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch break :forwarding;
+ break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch |err| Refuse.with(init.io, word, err);
};
- break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding;
+ break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch |err| Refuse.with(init.io, word, err);
};
+ var made_serial: ?u32 = null;
if (found_path != null) {
var command_buf: [8192]u8 = undefined;
- const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch |err| Refuse.with(init.io, word, err);
+ ninep_io.Client.write(arena, parent.dial, look, command) catch |err| Refuse.with(init.io, word, err);
} else {
- const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding;
- const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding;
+ const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, word, err);
+ const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, word, err);
+ made_serial = serial;
var name_buf: [64]u8 = undefined;
const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial});
var line_buf: [4200]u8 = undefined;
- const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding;
+ const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch |err| Refuse.with(init.io, word, err);
+ ninep_io.Client.write(arena, parent.dial, name, line) catch |err| {
+ // The name refused: the pane made for it goes too, no
+ // stray +New left behind.
+ var ctl_buf: [64]u8 = undefined;
+ const ctl = std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}) catch unreachable;
+ var why_buf: [256]u8 = undefined;
+ const said = ninep_io.Client.lastSaid();
+ const why = why_buf[0..@min(said.len, why_buf.len)];
+ @memcpy(why, said[0..why.len]);
+ ninep_io.Client.write(arena, parent.dial, ctl, "delete\n") catch {};
+ if (err == error.Remote) {
+ var buf: [8192]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, "pardes: {s}: {s}\n", .{ word[0..@min(word.len, 4096)], why }) catch "pardes: refused\n";
+ std.Io.File.stderr().writeStreamingAll(init.io, text) catch {};
+ std.process.exit(1);
+ }
+ Refuse.with(init.io, word, err);
+ };
}
- if (wait) waitForDel(init.io, arena, parent.dial, path);
+ if (wait) waitForDel(init.io, arena, parent.dial, path, made_serial);
return;
}
if (positional) |a| {
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 9674a323..df2cfaf0 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -384,6 +384,21 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "a backward search along one long line takes time in its length, not its square" {
+ const gpa = testing.allocator;
+ const line = try gpa.alloc(u8, 7 * 16800);
+ defer gpa.free(line);
+ for (0..16800) |i| @memcpy(line[i * 7 ..][0..7], "answer ");
+ const p = try th.withFile(gpa, line);
+ defer p.deinit();
+ const t0 = std.Io.Timestamp.now(testing.io, .awake);
+ try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .addr), "?a?").reply.status);
+ // Quadratic, it took seconds (6.4 s through 9P in Debug); linear, a
+ // few milliseconds.
+ const took = t0.durationTo(std.Io.Timestamp.now(testing.io, .awake)).toNanoseconds();
+ try testing.expect(took < std.time.ns_per_s);
+}
+
test "an alternation anchoring some branches and not others is refused with why, a $ not counting" {
const p = try th.withFile(testing.allocator, "def\nfoo\n");
defer p.deinit();
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index 454cce4a..c4ee83eb 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -163,7 +163,7 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
- if (line.len == 0) continue;
+ if (line.len == 0 or pardes.exec.isComment(line)) continue;
const word = line[0 .. std.mem.indexOfAny(u8, line, " \t") orelse line.len];
const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
if (std.mem.eql(u8, w, word)) break true;
@@ -228,6 +228,69 @@ test "New written to a column's exec makes its scratch in the session's director
try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
}
+test "pane/new takes rows from a pane in another column before refusing ENOSPC" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 16 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const first = p.panes[p.active].?.serial;
+ // A second column holding one tall pane.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status);
+ const tall = try th.newPane(p);
+ const tall_col = layout.findPane(p, p.paneBySerial(tall).?).?.col;
+ const focus = @intFromEnum(tree.TopFile.focus);
+ var digits: [16]u8 = undefined;
+ // From the first column, until pane/new is refused.
+ var moved = false;
+ for (0..pardes.MAX_PANES) |_| {
+ _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{first}));
+ const made = th.newPane(p) catch break;
+ if (layout.findPane(p, p.paneBySerial(made).?).?.col == tall_col) moved = true;
+ }
+ try testing.expect(moved);
+}
+
+test "a Newcol refused because the tags would not fit logs its err alone and spends no column serial" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ // Long tags wrap onto more rows as their column narrows: the boot's
+ // two panes share one, and each keeps its tag and two rows.
+ const made = try th.newPane(p);
+ const pane = p.panes[p.paneBySerial(made).?].?;
+ const col = layout.findPane(p, p.paneBySerial(made).?).?.col;
+ for (p.col_panes[col][0..p.col_n[col]]) |k| {
+ const each = p.panes[k].?;
+ if (each.tag.own) |old| p.gpa.free(old);
+ each.tag.own = try p.gpa.dupe(u8, "word " ** 60);
+ }
+ try testing.expect(p.col_n[col] >= 2);
+ p.sync();
+ const ctl = @intFromEnum(tree.TopFile.ctl);
+ const focus = @intFromEnum(tree.TopFile.focus);
+ var digits: [16]u8 = undefined;
+ const own = try std.fmt.bufPrint(&digits, "{d}\n", .{pane.serial});
+ var refused = false;
+ for (0..pardes.MAX_COLS) |_| {
+ // From the long tag's column, so it is the one split narrower.
+ _ = th.wr(p, focus, own);
+ const serial_before = p.next_column_serial;
+ const log_before = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_before.bytes);
+ const r = th.wr(p, ctl, "Newcol\n");
+ if (r.reply.status == .ok) continue;
+ if (std.mem.indexOf(u8, r.reply.ename, "tags would not fit") == null) continue;
+ refused = true;
+ try testing.expectEqual(serial_before, p.next_column_serial);
+ const log_after = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_after.bytes);
+ const added = log_after.bytes[log_before.bytes.len..];
+ try testing.expect(std.mem.startsWith(u8, added, "err "));
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, added, "\n"));
+ break;
+ }
+ try testing.expect(refused);
+}
+
test "a tagexec word that makes, acts on and focuses no pane reads back nothing" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -458,7 +521,10 @@ test "a refused write after a truncation leaves every kind of tag as it was, and
const h = th.call(p, .{ .tag = 2, .op = .open, .node = node, .omode = 1 }).reply.handle;
const r = th.call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .data = big });
try testing.expectEqual(E.NOSPC, r.errno());
- try testing.expectEqualStrings("tag: no space: over 4096 bytes", r.reply.ename);
+ try testing.expectEqualStrings("no space: over 4096 bytes", r.reply.ename);
+ // Its err record names the file once: `tag: no space`, never `tag: tag:`.
+ try testing.expect(th.logHas(p, "tag: no space: over 4096 bytes"));
+ try testing.expect(!th.logHas(p, "tag: tag:"));
_ = th.call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h, .opened = true });
try testing.expectEqualStrings(before, th.rd(p, node, 0, 1 << 16).bytes);
// A control character is refused the same way on every kind.
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 0bbc0319..9a95a10f 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -192,8 +192,30 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep
for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control);
if (exec) if (tooLong(req, text)) |refusal| return refusal;
if (!apply) continue;
- const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
+ var id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
+ // No pane at all: the root's look and exec act in the session's
+ // directory. An exec runs as the workspace tag's; a look from a
+ // scratch made as New makes one (and its column), dropped
+ // unannounced when the look went elsewhere.
+ var scratch: ?usize = null;
+ if (p.panes[id] == null and serial == null and p.anyPane() == null) {
+ if (exec) {
+ p.exec_header = true;
+ defer p.exec_header = false;
+ if (captured(p, req, id, exec, text, true)) |refusal| return refusal;
+ continue;
+ }
+ const free = p.freeSlot() orelse return Reply.fail(req.tag, E.NOSPC);
+ p.newScratchFor(id, .scratch);
+ if (p.panes[free] == null) return Reply.fail(req.tag, E.NOSPC);
+ p.active = free;
+ id = free;
+ scratch = free;
+ }
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
+ defer if (scratch) |s| if (p.active != s) if (p.panes[s]) |sp| if (sp.file) |f| {
+ if (f.content.len == 0) p.removePane(s, null) catch {};
+ };
p.fs.ran_at = p.panes[id].?.serial;
p.fs.no_pane_slot = false;
if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal;
@@ -267,7 +289,8 @@ fn failureErrno(failure: []const u8) u16 {
if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT;
// A pattern refused (Edit's too) is malformed input, as 9ns reads it;
// so is a word this build has not (`invalid: acme's Put ...`).
- if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid")) return E.INVAL;
+ if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid") or
+ std.mem.startsWith(u8, failure, "address out of range")) return E.INVAL;
return E.IO;
}
@@ -586,7 +609,7 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
while (it.next()) |line| {
- if (line.len == 0) continue;
+ if (line.len == 0 or exec_line.isComment(line)) continue;
if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) {
const size = sizeOf(line) orelse return refuse(p, req, e_size, line);
// A frontend's window is the size; only a session none is
@@ -812,7 +835,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
var held = !other and pane.fs.lock != null;
var it = Messages.init(p, req.data);
while (it.next()) |line_in| {
- if (line_in.len == 0) continue;
+ if (line_in.len == 0 or exec_line.isComment(line_in)) continue;
// A line before this one may have closed the pane (`Del`).
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
// acme's ctl words: done by the file that replaces each, or the
@@ -1929,12 +1952,12 @@ test "a builtin that fails a ctl write logs only its err, so the same failure ag
p.update(.tick); // the pane's `new` is logged first
while (p.nextEffect()) |_| {}
const ctl_node = @intFromEnum(tree.TopFile.ctl);
- for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status);
+ for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Unmount zzz\n").reply.status);
const log = @intFromEnum(tree.TopFile.log);
const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
- try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word (x3)\n"));
- try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null);
+ try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Unmount: no such mount zzz (x3)\n"));
+ try testing.expect(std.mem.indexOf(u8, text, "msg - Unmount") == null);
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
@@ -2445,6 +2468,81 @@ test "a look that misses file:/re/ names the file, and is logged as its pane's"
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir })));
}
+test "with no pane at all, the root look opens its file and the root exec runs in the session's directory" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" });
+ var buf: [4096]u8 = undefined;
+ const session = buf[0..try tmp.dir.realPath(testing.io, &buf)];
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = @intCast(session.len);
+ const Empty = struct {
+ fn all(q: *Pardes) !void {
+ while (q.anyPane()) |k| _ = wr(q, Node.of(q.panes[k].?.serial, .ctl), "delete\n");
+ }
+ };
+ try Empty.all(p);
+ const look_node = @intFromEnum(tree.TopFile.look);
+ try testing.expectEqual(Status.ok, wr(p, look_node, "f.txt\n").reply.status);
+ var want: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&want, "{s}/f.txt", .{session});
+ var opened: usize = 0;
+ var scratches: usize = 0;
+ for (p.panes) |slot| {
+ const f = (slot orelse continue).file orelse continue;
+ opened += @intFromBool(std.mem.eql(u8, f.path, path));
+ scratches += @intFromBool(std.mem.endsWith(u8, f.path, "/+New"));
+ }
+ try testing.expectEqual(@as(usize, 1), opened);
+ try testing.expectEqual(@as(usize, 0), scratches);
+
+ try Empty.all(p);
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.exec), "echo hi\n").reply.status);
+ const made = p.panes[p.anyPane().?].?;
+ try testing.expect(made.command != null);
+ try testing.expectEqualStrings(session, made.cwdSlice());
+}
+
+test "a look at file:3:0 is refused as addr 3:0 is, a column counting from 1" {
+ const p = try withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const by_addr = wr(p, Node.of(serial, .addr), "3:0");
+ const by_look = wr(p, Node.of(serial, .look), "/test.txt:3:0\n");
+ try testing.expectEqual(E.INVAL, by_addr.errno());
+ try testing.expectEqual(E.INVAL, by_look.errno());
+ try testing.expectEqualStrings(by_addr.reply.ename, by_look.reply.ename);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "/test.txt:3:1\n").reply.status);
+}
+
+test "Kill with a name and nothing running says so and does not fail, as bare Kill does" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const r = wr(p, @intFromEnum(tree.TopFile.ctl), "Kill zzz\n");
+ try testing.expectEqual(Status.ok, r.reply.status);
+ try testing.expect(th.logHas(p, "Kill: nothing running"));
+}
+
+test "a line starting with # runs as nothing at an exec or a ctl, silently" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ // The setup's own announcements are made by the first write's update.
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "# settle\n");
+ const before = p.next_serial;
+ const log_before = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_before.bytes);
+ for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec), @intFromEnum(tree.TopFile.ctl), Node.of(serial, .ctl), Node.ofCol(layout.columnSerial(p, 0), .ctl) }) |node| {
+ try testing.expectEqual(Status.ok, wr(p, node, "# Platform: tty\n # indented too\n").reply.status);
+ }
+ try testing.expectEqual(before, p.next_serial);
+ const log_after = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_after.bytes);
+ try testing.expectEqualStrings(log_before.bytes, log_after.bytes);
+}
+
test "the ctl word name refuses a leading blank, as the name file does" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -3225,9 +3323,9 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr
while (p.nextEffect()) |_| {}
const serial = serialOf(p);
for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| {
- const failed = wr(p, node, "Kill zzz\n");
+ const failed = wr(p, node, "Unmount zzz\n");
try testing.expectEqual(Status.err, failed.reply.status);
- try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Kill: no running command has that first word") != null);
+ try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Unmount: no such mount zzz") != null);
}
const log = @intFromEnum(tree.TopFile.log);
const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
@@ -3235,8 +3333,8 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr
try testing.expect(std.mem.indexOf(u8, text, "msg ") == null);
// The root's exec ran at the pane, so its err and the pane's exec's are
// the same record, counted.
- try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Kill: no running command has that first word"));
- try testing.expect(std.mem.indexOf(u8, text, "first word (x2)") != null);
+ try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Unmount: no such mount zzz"));
+ try testing.expect(std.mem.indexOf(u8, text, "mount zzz (x2)") != null);
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
// A control character is refused with its reason.
try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename);
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index c6505f52..63405781 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -688,8 +688,17 @@ fn scanNumber(data: []const u8, i: *usize) ?u32 {
}
/// Writing a Look or Exec record back performs the action it names.
-pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
- const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT);
+pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply {
+ const pane0 = p.panes[id] orelse return Reply.fail(asked.tag, E.NOENT);
+ // Its last record needs no newline, as every other line file's last
+ // line does not (`printf 'MX0 10' > event`).
+ var req = asked;
+ if (req.data.len > 0 and req.data[req.data.len - 1] != '\n') {
+ const whole = p.scratch.allocator().alloc(u8, req.data.len + 1) catch return Reply.fail(req.tag, E.NOMEM);
+ @memcpy(whole[0..req.data.len], req.data);
+ whole[req.data.len] = '\n';
+ req.data = whole;
+ }
const serial = pane0.serial;
{
const body = pane_files.bodyOf(pane0);
@@ -783,7 +792,7 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
else => unreachable,
}
}
- return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ return .{ .tag = req.tag, .written = @intCast(asked.data.len) };
}
// ---- tests ----
@@ -984,11 +993,17 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status);
try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+ // The last record needs no newline, as any line file's last line.
+ pane.msg_len = 0;
+ const bare = wr(p, event, "FX0 10");
+ try testing.expectEqual(Status.ok, bare.reply.status);
+ try testing.expectEqual(@as(u32, 6), bare.reply.written);
+ try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+
pane.msg_len = 0;
for ([_][]const u8{
"FX0 10\nFQ0 1\n", // unknown type character
"FX0 999\n", // out of range
- "FX0 10", // no newline
"FX5 1\n", // q0 > q1
"FD0 3\n", // a report, not a request
"F\n",
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 6a0fbba5..7526075b 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -639,7 +639,7 @@ pub fn tagFault(data: []const u8) ?[]const u8 {
/// A tag write's limit, the same for a pane's, a column's and the
/// workspace's: whole or not at all.
-pub const e_tag_over = std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail});
+pub const e_tag_over = std.fmt.comptimePrint("no space: over {d} bytes", .{limits.max_tag_tail});
fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
@@ -860,18 +860,14 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
"rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
else
"rename not allowed: an image or PDF is named by the file it shows");
- const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), fs.expandHome(name, &home_buf) }) catch
return Reply.fail(req.tag, E.NOMEM);
if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return tree.failText(req.tag, E.INVAL, "invalid file name: longer than a path may be");
nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) {
error.OutOfMemory => E.NOMEM,
else => E.INVAL,
});
- // A name into a directory not there yet asks for it, as Config's pane
- // does: its Save makes the directories (`pardes new/dir/f` forwarded).
- if (comptime pardes.hosted) if (fs.localPath(std.fs.path.dirname(full) orelse "/")) |local| {
- if (!exec.isDirectory(local)) fileOf(pane).?.make_dirs = true;
- };
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -1319,7 +1315,7 @@ test "a tag write past the limit is refused whole, naming the limit" {
try testing.expectEqual(E.NOSPC, r.errno());
// `no space` is what 9ns maps to ENOSPC, so a shell through a mount
// sees the errno the tree gives.
- try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over "));
+ try testing.expect(std.mem.startsWith(u8, r.reply.ename, "no space: over "));
try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes);
}
@@ -1392,19 +1388,6 @@ test "a name with a component over 255 bytes is refused, and a long path's faile
try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory"));
}
-test "a name into a directory not there has Save make it; one into a directory there does not" {
- if (comptime !pardes.hosted) return error.SkipZigTest;
- const p = try withFile(testing.allocator, "x\n");
- defer p.deinit();
- const serial = serialOf(p);
- const f = fileOf(p.panes[p.paneBySerial(serial).?].?).?;
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-mk/sub/f.txt\n").reply.status);
- try testing.expect(f.make_dirs);
- f.make_dirs = false;
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/f.txt\n").reply.status);
- try testing.expect(!f.make_dirs);
-}
-
test "a name cut across writes is one name, applied once at its newline or its close" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 0f5202cc..2245255b 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -33,7 +33,17 @@ pub const pty_rows_min = 2;
/// As the root ctl's `size` (4096 a side).
const winsize_max = 4096;
-const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max });
+const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x{d} to {d}x{d}", .{ pty_rows_min, winsize_max, winsize_max });
+const e_winsize_rows = std.fmt.comptimePrint("invalid winsize: at least {d} rows", .{pty_rows_min});
+
+/// `winsize C R` whose R is a size but fewer rows than a pty takes.
+fn tooFewRows(line: []const u8) bool {
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false;
+ _ = dimension(words.next() orelse return false) orelse return false;
+ const rows = dimension(words.next() orelse return false) orelse return false;
+ return words.next() == null and rows < pty_rows_min;
+}
/// A winsize whose numbers are numbers but not sizes: said as such.
fn outOfRange(line: []const u8) bool {
@@ -79,7 +89,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
if (@import("../host_io.zig").Shell.refusal(bin[0..@min(bin.len, 200)], &why)) |refused|
return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}", .{refused}) catch "exec: no such shell");
};
- if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (tooFewRows(line)) e_winsize_rows else if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -93,8 +103,10 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
.winsize => {
const cols = dimension(words.next() orelse return false) orelse return false;
// Two rows at least, as a pane is laid out: a one-row pty
- // loses its prompt's input mark and reads busy for ever.
- const rows = @max(pty_rows_min, dimension(words.next() orelse return false) orelse return false);
+ // loses its prompt's input mark and reads busy for ever. Fewer
+ // is refused, never quietly raised.
+ const rows = dimension(words.next() orelse return false) orelse return false;
+ if (rows < pty_rows_min) return false;
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
@@ -619,6 +631,13 @@ test "every pty/ctl verb, and every refusal" {
try testing.expect(a.winsize == null);
}
+ // One row is a size, but fewer than a pty takes: refused, and said so.
+ const one = wr(p, ctl, "winsize 80 1");
+ try testing.expectEqual(E.INVAL, one.errno());
+ try testing.expectEqualStrings("invalid winsize: at least 2 rows", one.reply.ename);
+ try testing.expect(one.winsize == null);
+ try testing.expectEqualStrings("invalid winsize: out of range, 1x2 to 4096x4096", e_winsize_range);
+
const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
try testing.expectEqual(Status.ok, spaced.reply.status);
try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols);
@@ -960,6 +979,7 @@ test "a run's answer says cut when its output's start is gone, and reads a bound
var got = sh.answered(p, node, h, into);
try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
+
// A resize between its start and end reflows the pins with the text.
h = sh.send(p, node);
sh.put(p, c ++ "one\r\ntwo\r\n");
@@ -1022,6 +1042,12 @@ test "a run's answer says cut when its output's start is gone, and reads a bound
sh.put(p, d ++ prompt);
p.gpa = gpa;
try testing.expectEqualStrings("error out of memory\n", sh.answered(p, node, h, into));
+ // An end drawn above the start (the prompt redrawn over what was
+ // printed, as in a window two rows high): cut, never an empty whole.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "printed\r\nmore\r\n\x1b[4A" ++ d ++ prompt);
+ got = sh.answered(p, node, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
}
test "a terminal's ctl counts its body, and a new directory is a rename in the log" {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 17941e10..322728da 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -1169,7 +1169,12 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
// and its failure is this write's, as acme takes each write whole. Held
// only when it is not whole yet -- an open `{` block, an `a`/`c`/`i`
// text awaiting its `.` -- or the write filled its Twrite and may go on.
- const cut = p.fs.write_room != 0 and req.data.len >= p.fs.write_room;
+ // So may one cut where a writer's buffer filled: stdio flushes, and a
+ // mount's page cache writes back, in multiples of 4096 bytes. `seq ...
+ // > exec` through a mount arrived in 4096-byte writes, and the line cut
+ // at each end ran as two commands (the half `Msg` refused, failing the
+ // write and the rest of the burst).
+ const cut = (p.fs.write_room != 0 and req.data.len >= p.fs.write_room) or req.data.len % 4096 == 0;
if (end < o.pending.items.len and !cut) {
o.pending.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
const whole = ctl.completeEnd(p, o.pending.items);
@@ -1826,6 +1831,25 @@ test "look and exec read back what their own open's write touched; a fresh open
for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
}
+test "a write cut where a writer's 4096-byte buffer filled holds its partial line for the next write" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec_node = Node.of(serialOf(p), .exec);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = exec_node, .omode = 1 }).reply.handle;
+ // 4096 bytes: whole `Msg first` lines, then a line cut after `Msg`.
+ var buf: [4096]u8 = undefined;
+ const line = "Msg first\n";
+ var n: usize = 0;
+ while (n + line.len <= buf.len - 3) : (n += line.len) @memcpy(buf[n..][0..line.len], line);
+ @memset(buf[n..], ' ');
+ @memcpy(buf[buf.len - 3 ..], "Msg");
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec_node, .handle = h, .data = &buf }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = exec_node, .handle = h, .data = " last\n" }).reply.status);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = exec_node, .handle = h, .opened = true });
+ try testing.expect(th.logHas(p, "last"));
+ try testing.expect(!th.logHas(p, "wrong #args"));
+}
+
test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/pardes.zig b/src/pardes.zig
index 1009d13d..402b3cd8 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1112,7 +1112,33 @@ test "first document fallback splits like Tty from the originating pane" {
};
}
-test "Lspwhy after a Hover asks to explain that hover, at its place" {
+test "a frame no one watches is not drawn, and is drawn once someone does" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const Watch = struct {
+ var presents: usize = 0;
+ var seen = false;
+ const vtable: Host.VTable = .{ .present = present, .watched = watched };
+ fn present(_: ?*anyopaque, _: *const Surface) void {
+ presents += 1;
+ }
+ fn watched(_: ?*anyopaque) bool {
+ return seen;
+ }
+ };
+ Watch.presents = 0;
+ Watch.seen = false;
+ p.needs_frame = true;
+ try p.pump(.{ .vtable = &Watch.vtable });
+ try std.testing.expectEqual(@as(usize, 0), Watch.presents);
+ try std.testing.expect(p.needs_frame); // still owed
+ Watch.seen = true;
+ try p.pump(.{ .vtable = &Watch.vtable });
+ try std.testing.expectEqual(@as(usize, 1), Watch.presents);
+}
+
+test "Lspwhy after a Hover asks to explain that hover, in its file at its place, run from any pane" {
if (!lsp.supports.contains(.hover)) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
@@ -1131,12 +1157,16 @@ test "Lspwhy after a Hover asks to explain that hover, at its place" {
pane.body.cur_row = 0;
pane.body.cur_col = 0;
p.lsp_wait = null;
- p.lspRequest(0, .explain, "");
+ // Run from another file's pane: still the hover's file and place.
+ const other = p.freeSlot().?;
+ _ = try p.openTutorView(other);
+ p.lspRequest(other, .explain, "");
var seen = false;
while (p.nextEffect()) |e| if (e == .lsp) {
try std.testing.expectEqual(lsp.Kind.explain, e.lsp.kind);
try std.testing.expectEqualStrings("hover ", e.lsp.arg.slice());
try std.testing.expectEqual(hover_at, e.lsp.offset);
+ try std.testing.expectEqual(@as(u8, 0), e.lsp.pane);
seen = true;
};
try std.testing.expect(seen);
@@ -4343,6 +4373,10 @@ pub const Pardes = struct {
chord_hold: bool = false,
/// The look just run found nothing (look.zig missed).
look_missed: bool = false,
+ /// The diff line a right click just expanded to, by row: a look at its
+ /// text goes to the place that row names, not the first equal line
+ /// (look.zig diffLook).
+ diff_look: ?struct { serial: u32, row: usize } = null,
look_hover_wait: ?look.LookHoverWait = null,
/// The place a look-hover last found nothing to open: the pointer
/// resting there does not ask again every frame (look.noteLookHover).
@@ -4484,6 +4518,9 @@ pub const Pardes = struct {
/// the terminal motion surface, memoized against the pane it was built
/// for — see panes.terminal.RowsCache for the lifetime rule
shell_rows: panes.terminal.RowsCache = .{},
+ /// Finished command panes' output, and whether it is a diff
+ /// (File.DiffOutput).
+ diff_output: panes.File.DiffOutput = .{},
/// The message row's entry points, called from everywhere as
/// `p.setMessage(...)`; they live with the rest of it in Messages.zig.
@@ -4734,6 +4771,7 @@ pub const Pardes = struct {
p.locations_cache.deinit(p.tree_sitter_gpa);
p.fs.deinit(gpa);
p.shell_rows.reset(gpa);
+ p.diff_output.reset(gpa);
p.scratch.deinit();
p.frame_arena.deinit();
gpa.free(p.surface.cells);
@@ -4983,8 +5021,13 @@ pub const Pardes = struct {
// An empty window (Delcol took the last column): its first column.
if (layout.firstColumn(p)) |c| return tagline.enterHeader(p, c);
const col = p.keyboardColumn(from_id) orelse return;
+ // A trial: laying it out gives it a serial (layout.compute), which a
+ // refusal takes back. Every other column has its own already, so
+ // the counter as it is now is the trial's to return to.
+ for (0..p.ncol) |k| _ = layout.columnSerial(p, k);
+ const unspent = p.next_column_serial;
// Said, not silently nothing: "no space" is 9ns's word for ENOSPC.
- const c = layout.insertColumn(p, col, false) orelse {
+ const c = layout.insertColumnUnannounced(p, col, false) orelse {
var said: [96]u8 = undefined;
return p.reportFailure(from_id, if (p.ncol >= MAX_COLS)
std.fmt.bufPrint(&said, "Newcol: no space for a column: {d} max", .{MAX_COLS}) catch "Newcol: no space for a column"
@@ -4996,10 +5039,13 @@ pub const Pardes = struct {
// out again, and where they cannot hold every minimum there is no
// new column, as a size too small is refused.
for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) {
- layout.dropColumn(p, c);
+ layout.dropColumnUnannounced(p, c);
+ p.next_column_serial = unspent;
layout.compute(p);
return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit");
};
+ // Kept: now it has a serial, and the log hears of it.
+ layout.noteColumn(p, "newcol", layout.columnSerial(p, c));
tagline.enterHeader(p, c);
}
@@ -5420,6 +5466,15 @@ pub const Pardes = struct {
p.noteLateFailure(id);
}
+ /// A Save makes the directories its file goes in, and any above them,
+ /// first: one rule, for the pane's own name and `Save path` alike. A
+ /// directory it cannot make leaves the write to fail and say why.
+ fn makeParents(path: []const u8) void {
+ if (comptime !hosted) return;
+ const local = filesystem.localPath(path) orelse return;
+ filesystem.makeDirs(std.fs.path.dirname(local) orelse "/");
+ }
+
pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
if (p.panes[id]) |pane| if (pane.file) |*f| {
// The `-%` spelling fs.zig already uses for "make this dirty".
@@ -5509,8 +5564,7 @@ pub const Pardes = struct {
const was = f.saved_revision;
const failures = p.fs.failures;
f.saved_revision = f.revision;
- if (f.make_dirs) if (filesystem.localPath(f.path)) |local|
- filesystem.makeDirs(std.fs.path.dirname(local) orelse "/");
+ makeParents(f.path);
p.hostWriteFile(sf.pane, f.path, f.content);
const saved_pane = p.panes[sf.pane] orelse return;
if (saved_pane.serial != serial) return;
@@ -5539,6 +5593,7 @@ pub const Pardes = struct {
if (pane.file) |*f| {
const was = f.saved_revision;
const failures = p.fs.failures;
+ makeParents(st.path.slice());
p.hostWriteFile(st.pane, st.path.slice(), f.content);
const after = p.panes[st.pane] orelse return;
if (after.serial != st.serial) return;
@@ -5554,6 +5609,7 @@ pub const Pardes = struct {
if (!pane.isTerminal()) return;
const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return;
defer p.gpa.free(text);
+ makeParents(st.path.slice());
p.hostWriteFile(st.pane, st.path.slice(), text);
},
.write_dump => {
@@ -5634,6 +5690,10 @@ pub const Pardes = struct {
// A shell with a clock draws what `advance` stepped; one without
// steps its own `.tick`s and draws while anything animates.
if (!p.needs_frame and !p.continuous() and (v.now != null or !p.animationActive())) return;
+ // No one to see it: the frame stays owed (needs_frame), not drawn.
+ // A pty streaming into an unwatched session spent most of its time
+ // drawing what nobody looked at.
+ if (v.watched) |f| if (!f(h.ctx)) return;
_ = p.frame_arena.reset(.retain_capacity);
const surface = try p.render(p.frame_arena.allocator());
if (v.present) |f| f(h.ctx, surface);
@@ -6340,8 +6400,12 @@ pub const Pardes = struct {
pub const SavePath = Effect.Buf(effect_path_cap);
- pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void {
+ pub fn lspRequest(p: *Pardes, asked: usize, kind: lsp.Kind, arg: []const u8) void {
if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary
+ // Lspwhy narrates the last query where it was asked -- that pane's
+ // file, place and argument -- from wherever it is run.
+ const last_pane: ?usize = if (kind == .explain) (if (p.lsp_last) |last| p.paneBySerial(last.serial) else null) else null;
+ const id = last_pane orelse asked;
var said: [128]u8 = undefined;
if (!lsp.supports.contains(kind))
return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: no language server here answers it", .{lspWord(kind)}) catch "no language server answers it");
@@ -6371,7 +6435,7 @@ pub const Pardes = struct {
if (kind == .explain) {
if (p.lsp_last) |last| {
sent = std.fmt.bufPrint(&explained, "{s} {s}", .{ @tagName(last.kind), last.arg.slice() }) catch "";
- if (last.serial == pane.serial) at = last.offset;
+ if (last_pane != null) at = last.offset;
}
} else if (kind != .status) p.lsp_last = .{ .kind = kind, .arg = .from(arg), .serial = pane.serial, .offset = @intCast(off) };
p.lsp_seq +%= 1;
diff --git a/src/regexp.zig b/src/regexp.zig
index 575e6861..86132f60 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -47,6 +47,14 @@ pub const Regex = struct {
/// mvzr steps left to every search with this compiled pattern together,
/// so that addr's backward scan and s/S's many calls share one bound.
steps: u64 = budget,
+ /// Where `find` last learned a line's start in a text (by its address
+ /// and length): the line holding `at` starts at `start`. A backward
+ /// search asks again at each match along a line, and looking back to
+ /// the line's start from the top each time was quadratic on a long one.
+ line_hint: struct { ptr: usize = 0, len: usize = 0, at: usize = 0, start: usize = 0 } = .{},
+ /// ...and where a line learned to end: the line from `start` ends at
+ /// `end` (its newline, or `hi`), asked again at each match along it.
+ end_hint: struct { ptr: usize = 0, len: usize = 0, start: usize = 0, hi: usize = 0, end: usize = 0 } = .{},
/// Timed on this machine's ReleaseSafe build: 10M steps of the worst
/// patterns take 45-80 ms, and a Debug build is ten times slower.
@@ -324,6 +332,28 @@ pub const Regex = struct {
pub const Match = struct { start: usize, end: usize };
+ /// Where the line from `start` ends (its newline, else `hi`), `at` on
+ /// it: known when asked before of this line of this text.
+ fn lineEnd(rx: *Regex, text: []const u8, start: usize, at: usize, hi: usize) usize {
+ const h = rx.end_hint;
+ if (h.ptr == @intFromPtr(text.ptr) and h.len == text.len and h.start == start and h.hi == hi and at <= h.end) return h.end;
+ // No newline between the line's start and `at`: the look starts there.
+ const end = std.mem.indexOfScalarPos(u8, text[0..hi], at, '\n') orelse hi;
+ rx.end_hint = .{ .ptr = @intFromPtr(text.ptr), .len = text.len, .start = start, .hi = hi, .end = end };
+ return end;
+ }
+
+ /// Where the line holding `from` starts, looking back only to where
+ /// the last call learned one in this text.
+ fn lineStart(rx: *Regex, text: []const u8, from: usize) usize {
+ const h = rx.line_hint;
+ const known = h.ptr == @intFromPtr(text.ptr) and h.len == text.len and h.at <= from;
+ const lo = if (known) h.at else 0;
+ const s = if (std.mem.lastIndexOfScalar(u8, text[lo..from], '\n')) |nl| lo + nl + 1 else if (known) h.start else 0;
+ rx.line_hint = .{ .ptr = @intFromPtr(text.ptr), .len = text.len, .at = from, .start = s };
+ return s;
+ }
+
/// The first match that starts in `from..=last` and ends by `hi`, as
/// offsets into `text`. The text before `from` still says where lines
/// begin.
@@ -345,7 +375,7 @@ pub const Regex = struct {
}
return null;
}
- var start: usize = if (rx.spans) 0 else if (std.mem.lastIndexOfScalar(u8, text[0..from], '\n')) |nl| nl + 1 else 0;
+ var start: usize = if (rx.spans) 0 else rx.lineStart(text, from);
var at = from - start;
// `^` cannot match in the middle of a line.
if (!rx.spans and rx.bol and at > 0) {
@@ -359,7 +389,9 @@ pub const Regex = struct {
const q = std.mem.indexOfPos(u8, text[0..hi], start + at, rx.lit[0..rx.lit_len]) orelse return null;
if (q > last) return null;
if (q > start + at) {
- const line_start = if (std.mem.lastIndexOfScalar(u8, text[0..q], '\n')) |nl| nl + 1 else 0;
+ // `start` begins a line and `start + at` is on it: the
+ // look back ends there.
+ const line_start = if (std.mem.lastIndexOfScalar(u8, text[start + at .. q], '\n')) |nl| start + at + nl + 1 else start;
if (line_start > start) {
start = line_start;
at = q - line_start;
@@ -371,7 +403,7 @@ pub const Regex = struct {
}
}
}
- const end = if (rx.spans) hi else std.mem.indexOfScalarPos(u8, text[0..hi], start, '\n') orelse hi;
+ const end = if (rx.spans) hi else rx.lineEnd(text, start, start + at, hi);
const line = text[start..end];
// matchPos finds nothing at a haystack's very end, where `$` or
// an empty match still can.
diff --git a/src/syntax.zig b/src/syntax.zig
index 8ed00bfa..b7406b04 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -5,6 +5,11 @@ const grammar_manifest = @import("grammar_manifest.zig");
const look = @import("look.zig");
const regexp = @import("regexp.zig");
const c_heap = @import("c_heap");
+const diff = @import("diff.zig");
+
+test {
+ _ = diff;
+}
pub const enabled = config.syntax_highlighting;
const zig_grammar = config.syntax_zig_grammar;
@@ -183,6 +188,7 @@ pub fn stop() void {
spec.objects = null;
spec.objects_failed = false;
}
+ dropDiffPieces();
ts_set_allocator(null, null, null, null);
syntax_started = false;
syntax_allocator = undefined;
@@ -1029,35 +1035,269 @@ fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node {
return null;
}
-pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
- const start_byte = @min(start_byte_raw, content.len);
- const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
- const source = content[start_byte..end_byte];
- const styles = try gpa.alloc(u8, source.len);
+/// A diff line's style byte carries whether the line was added or removed
+/// over its syntax style (the low bits), so the painter can tint its row
+/// and mark its prefix.
+pub const diff_added: u8 = 0x80;
+pub const diff_removed: u8 = 0x40;
+pub const syn_bits: u8 = 0x3f;
+
+/// A file section's hunks are parsed together, each side as one text, in
+/// pieces: a piece takes whole hunks until it holds `diff_piece_lines`
+/// lines, and a hunk longer than `diff_piece_cut` is cut there, counted
+/// from the section's first hunk. So a view parses about what a file's
+/// view does (its rows and a margin), however many small hunks it shows,
+/// and a 20k-line new file is parsed where it is shown, not whole. A string
+/// or comment across a cut colours as a fragment would.
+const diff_piece_lines = 40;
+const diff_piece_cut = 80;
+
+/// Painted hunk pieces, keyed by their bytes and language: scrolling back
+/// over a hunk, or a terminal repainting the same output every frame,
+/// parses nothing again. Freed by `stop`.
+const DiffPiece = struct { hash: u64, styles: []u8, used: u64 };
+var diff_pieces: [96]?DiffPiece = @splat(null);
+var diff_clock: u64 = 0;
+
+fn dropDiffPieces() void {
+ for (&diff_pieces) |*slot| {
+ if (slot.*) |piece| syntax_allocator.free(piece.styles);
+ slot.* = null;
+ }
+ diff_clock = 0;
+}
+
+/// The styles of `content[start..end)` read as a unified diff: headers
+/// muted, `@@` lines as keywords, and each hunk's code in the language of
+/// the file its section names. A hunk's old side (context and removed
+/// lines) and new side (context and added lines) are each parsed whole, so
+/// a string or comment that spans lines colours as it does in the file.
+/// Added and removed lines carry `diff_added`/`diff_removed`. A file in no
+/// language pardes knows keeps the plain colours: added lines as strings,
+/// removed ones as numbers.
+pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize) ![]u8 {
+ return highlightDiffRows(gpa, content, start_raw, end_raw, false);
+}
+
+/// The same for a terminal's rows, where a row the terminal wrapped
+/// continues the diff line above it.
+pub fn highlightDiffRows(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize, continued_rows: bool) ![]u8 {
+ const tz = tracy.zone(@src(), "highlightDiff");
+ defer tz.end();
+ const start_byte = @min(start_raw, content.len);
+ const end_byte = @max(start_byte, @min(end_raw, content.len));
+ const styles = try gpa.alloc(u8, end_byte - start_byte);
errdefer gpa.free(styles);
@memset(styles, 0);
+ const Window = struct {
+ styles: []u8,
+ start: usize,
+ fn put(w: @This(), at: usize, bytes: []const u8) void {
+ const lo = @max(at, w.start);
+ const hi = @min(at + bytes.len, w.start + w.styles.len);
+ if (lo < hi) @memcpy(w.styles[lo - w.start .. hi - w.start], bytes[lo - at .. hi - at]);
+ }
+ fn fill(w: @This(), at: usize, len: usize, style: u8) void {
+ const lo = @max(at, w.start);
+ const hi = @min(at + len, w.start + w.styles.len);
+ if (lo < hi) @memset(w.styles[lo - w.start .. hi - w.start], style);
+ }
+ };
+ const window: Window = .{ .styles = styles, .start = start_byte };
+
+ var walk: diff.Walk = .{ .continued_rows = continued_rows };
+ // The run of hunk lines not painted yet, with each line's kind.
+ var piece_start: ?usize = null;
+ var piece_end: usize = 0;
+ var piece_path: []const u8 = "";
+ var kinds: [diff_piece_cut]diff.Kind = undefined;
+ var nkinds: usize = 0;
+ var at = diff.anchorBefore(content, start_byte);
+ while (true) {
+ const nl = std.mem.indexOfScalarPos(u8, content, at, '\n') orelse content.len;
+ const got = walk.step(content[at..nl]);
+ const body = switch (got.kind) {
+ .context, .added, .removed, .no_newline => true,
+ // The next hunk of the section goes on in the same piece.
+ .hunk => piece_start != null and nkinds < diff_piece_lines,
+ else => false,
+ };
+ if (piece_start) |ps| if (!body or nkinds == diff_piece_cut) {
+ if (piece_end >= start_byte) try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path);
+ piece_start = null;
+ nkinds = 0;
+ };
+ if (at >= end_byte and piece_start == null) break;
+ if (body and (piece_start != null or got.kind != .hunk)) {
+ if (piece_start == null) {
+ piece_start = at;
+ piece_path = walk.path();
+ }
+ kinds[nkinds] = got.kind;
+ nkinds += 1;
+ piece_end = nl;
+ } else if (nl >= start_byte) {
+ const style: Syn = switch (got.kind) {
+ .hunk => .keyword,
+ .meta, .old_path, .new_path => .comment,
+ else => .none,
+ };
+ if (style != .none) window.fill(at, nl - at, @intFromEnum(style));
+ }
+ if (nl >= content.len) break;
+ at = nl + 1;
+ }
+ if (piece_start) |ps| if (piece_end >= start_byte)
+ try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path);
+ return styles;
+}
+fn paintDiffPiece(gpa: std.mem.Allocator, window: anytype, bytes: []const u8, at: usize, kinds: []const diff.Kind, path: []const u8) !void {
+ const selected: ?Selected = if (enabled and syntax_started) (forExt(std.fs.path.extension(path)) catch null) else null;
+ if (selected) |sel| {
+ const hash = std.hash.Wyhash.hash(std.hash.Wyhash.hash(0, sel.name), bytes);
+ diff_clock += 1;
+ for (&diff_pieces) |*slot| if (slot.*) |*piece| if (piece.hash == hash and piece.styles.len == bytes.len) {
+ piece.used = diff_clock;
+ return window.put(at, piece.styles);
+ };
+ const colored = try syntax_allocator.alloc(u8, bytes.len);
+ errdefer syntax_allocator.free(colored);
+ try paintHunk(gpa, colored, bytes, kinds, sel);
+ window.put(at, colored);
+ var oldest: usize = 0;
+ for (&diff_pieces, 0..) |*slot, i| {
+ const piece = slot.* orelse {
+ oldest = i;
+ break;
+ };
+ if (diff_pieces[oldest]) |o| if (piece.used < o.used) {
+ oldest = i;
+ };
+ }
+ if (diff_pieces[oldest]) |old| syntax_allocator.free(old.styles);
+ diff_pieces[oldest] = .{ .hash = hash, .styles = colored, .used = diff_clock };
+ return;
+ }
+ // No language: each line in one colour, as diffs were always shown.
+ var lines = std.mem.splitScalar(u8, bytes, '\n');
var offset: usize = 0;
- var lines = std.mem.splitScalar(u8, source, '\n');
- while (lines.next()) |line| {
- const syn = diffLineSyn(line);
- if (syn != .none) @memset(styles[offset .. offset + line.len], @intFromEnum(syn));
+ for (kinds) |kind| {
+ const line = lines.next() orelse break;
+ const style: u8 = switch (kind) {
+ .added => @intFromEnum(Syn.string) | diff_added,
+ .removed => @intFromEnum(Syn.number) | diff_removed,
+ .no_newline => @intFromEnum(Syn.comment),
+ .hunk => @intFromEnum(Syn.keyword),
+ else => 0,
+ };
+ if (style != 0) window.fill(at + offset, line.len, style);
offset += line.len + 1;
}
- return styles;
}
-fn diffLineSyn(line: []const u8) Syn {
- if (std.mem.startsWith(u8, line, "@@")) return .keyword;
- if (std.mem.startsWith(u8, line, "+++") or
- std.mem.startsWith(u8, line, "---") or
- std.mem.startsWith(u8, line, "diff ") or
- std.mem.startsWith(u8, line, "index ") or
- std.mem.startsWith(u8, line, "\\ No newline")) return .comment;
- if (line.len == 0) return .none;
- if (line[0] == '+') return .string;
- if (line[0] == '-') return .number;
- return .none;
+/// One piece's styles: its old side and its new side each parsed as one
+/// text, and every line's code coloured from its side, behind its
+/// one-character prefix. A `@@` line between its hunks is in neither.
+fn paintHunk(gpa: std.mem.Allocator, out: []u8, bytes: []const u8, kinds: []const diff.Kind, sel: Selected) !void {
+ @memset(out, 0);
+ const old_src = try gpa.alloc(u8, bytes.len);
+ defer gpa.free(old_src);
+ const new_src = try gpa.alloc(u8, bytes.len);
+ defer gpa.free(new_src);
+ var old_len: usize = 0;
+ var new_len: usize = 0;
+ var lines = std.mem.splitScalar(u8, bytes, '\n');
+ for (kinds) |kind| {
+ const line = lines.next() orelse break;
+ const code = line[codeStart(line, kind)..];
+ // A row a terminal wrapped goes on the line above, as it was printed.
+ const joined = continuesRow(line, kind);
+ if (kind == .context or kind == .removed) {
+ if (joined and old_len > 0) old_len -= 1;
+ @memcpy(old_src[old_len..][0..code.len], code);
+ old_len += code.len;
+ old_src[old_len] = '\n';
+ old_len += 1;
+ }
+ if (kind == .context or kind == .added) {
+ if (joined and new_len > 0) new_len -= 1;
+ @memcpy(new_src[new_len..][0..code.len], code);
+ new_len += code.len;
+ new_src[new_len] = '\n';
+ new_len += 1;
+ }
+ }
+ // A side is parsed only when a line takes its colours from it: the old
+ // side for removed lines, the new side for added lines, and context
+ // from the new side, else from the old. A piece that only adds or only
+ // removes is one parse.
+ var removes = false;
+ var adds = false;
+ for (kinds) |kind| {
+ removes = removes or kind == .removed;
+ adds = adds or kind == .added;
+ }
+ const context_new = adds or !removes;
+ const old_colors = try gpa.alloc(u8, old_len);
+ defer gpa.free(old_colors);
+ @memset(old_colors, 0);
+ if (!context_new or removes) paint(old_colors, old_src[0..old_len], sel);
+ const new_colors = try gpa.alloc(u8, new_len);
+ defer gpa.free(new_colors);
+ @memset(new_colors, 0);
+ if (context_new) paint(new_colors, new_src[0..new_len], sel);
+
+ lines = std.mem.splitScalar(u8, bytes, '\n');
+ var offset: usize = 0;
+ var old_at: usize = 0;
+ var new_at: usize = 0;
+ for (kinds) |kind| {
+ const line = lines.next() orelse break;
+ defer offset += line.len + 1;
+ const skip = codeStart(line, kind);
+ const code_len = line.len - skip;
+ const dst = out[offset + skip ..][0..code_len];
+ if (continuesRow(line, kind)) {
+ if ((kind == .context or kind == .removed) and old_at > 0) old_at -= 1;
+ if ((kind == .context or kind == .added) and new_at > 0) new_at -= 1;
+ }
+ switch (kind) {
+ .removed => @memcpy(dst, old_colors[old_at..][0..code_len]),
+ .added => @memcpy(dst, new_colors[new_at..][0..code_len]),
+ .context => @memcpy(dst, if (context_new) new_colors[new_at..][0..code_len] else old_colors[old_at..][0..code_len]),
+ .no_newline => @memset(out[offset..][0..line.len], @intFromEnum(Syn.comment)),
+ .hunk => @memset(out[offset..][0..line.len], @intFromEnum(Syn.keyword)),
+ else => {},
+ }
+ if (kind == .context or kind == .removed) old_at += code_len + 1;
+ if (kind == .context or kind == .added) new_at += code_len + 1;
+ const flag: u8 = switch (kind) {
+ .added => diff_added,
+ .removed => diff_removed,
+ else => 0,
+ };
+ if (flag != 0) for (out[offset..][0..line.len]) |*b| {
+ b.* |= flag;
+ };
+ }
+}
+
+fn continuesRow(line: []const u8, kind: diff.Kind) bool {
+ return line.len > 0 and codeStart(line, kind) == 0 and
+ (kind == .context or kind == .added or kind == .removed);
+}
+
+/// Where a hunk line's code starts: past its prefix, which a blank context
+/// line (its space stripped) and a terminal's wrapped row have none of.
+fn codeStart(line: []const u8, kind: diff.Kind) usize {
+ if (line.len == 0) return 0;
+ return switch (kind) {
+ .context => @intFromBool(line[0] == ' '),
+ .added => @intFromBool(line[0] == '+'),
+ .removed => @intFromBool(line[0] == '-'),
+ else => 0,
+ };
}
test "syntax a results row is coloured by the file its location names" {
@@ -1268,31 +1508,215 @@ test "syntax plain Markdown keeps block styles without starting the inline parse
}
test "syntax highlightDiff colors unified diff lines by prefix" {
- const diff =
+ const diff_text =
"diff --git a/x b/x\n" ++
"--- a/x\n" ++
"+++ b/x\n" ++
- "@@ -1,3 +1,3 @@\n" ++
+ "@@ -1,2 +1,2 @@\n" ++
" context\n" ++
"-old line\n" ++
"+new line\n";
- const styles = try highlightDiff(std.testing.allocator, diff, 0, diff.len);
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "diff --git", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "--- a/x", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "+++ b/x", 0));
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -1,2", 0));
+ try std.testing.expectEqual(Syn.none, diffSynAt(styles, diff_text, " context", 0));
+ // A file in no known language: each line in one colour, and tinted.
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "-old line", 0));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+new line", 0));
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "-old line").?] & diff_removed != 0);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "+new line").? + 4] & diff_added != 0);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, " context").?] & (diff_added | diff_removed) == 0);
+}
- const byteSyn = struct {
- fn at(s: []const u8, src: []const u8, needle: []const u8) Syn {
- const i = std.mem.indexOf(u8, src, needle).?;
- return @enumFromInt(s[i]);
- }
- }.at;
+fn diffSynAt(styles: []const u8, text: []const u8, needle: []const u8, offset: usize) Syn {
+ return @enumFromInt(styles[std.mem.indexOf(u8, text, needle).? + offset] & syn_bits);
+}
- try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "diff --git"));
- try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "--- a/x"));
- try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "+++ b/x"));
- try std.testing.expectEqual(Syn.keyword, byteSyn(styles, diff, "@@ -1,3"));
- try std.testing.expectEqual(Syn.none, byteSyn(styles, diff, " context"));
- try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line"));
- try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line"));
+test "syntax a Zig hunk is coloured as Zig, each side parsed whole" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "diff --git a/src/shape.zig b/src/shape.zig\n" ++
+ "index 1111111..2222222 100644\n" ++
+ "--- a/src/shape.zig\n" ++
+ "+++ b/src/shape.zig\n" ++
+ "@@ -1,6 +1,6 @@\n" ++
+ " const Shape = struct {\n" ++
+ "- side: u8 = 7,\n" ++
+ "+ side: u16 = 42,\n" ++
+ " const note =\n" ++
+ "- \\\\old words defer\n" ++
+ "+ \\\\new words defer\n" ++
+ " ;\n" ++
+ " };\n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "struct", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "7,", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "42", 0));
+ // A multiline string, told apart only by parsing the side it is on:
+ // `defer` inside it is no keyword.
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "old words defer", 10));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "new words defer", 10));
+ // The prefix is no code: it carries the tint alone.
+ const added = std.mem.indexOf(u8, diff_text, "+ side").?;
+ try std.testing.expectEqual(diff_added, styles[added]);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "42").?] & diff_added != 0);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "7,").?] & diff_removed != 0);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "index 111", 0));
+
+ // A window in the middle is coloured as the whole is.
+ const from = std.mem.indexOf(u8, diff_text, " const note").?;
+ const part = try highlightDiff(std.testing.allocator, diff_text, from, diff_text.len);
+ defer std.testing.allocator.free(part);
+ try std.testing.expectEqualSlices(u8, styles[from..], part);
+}
+
+test "syntax a Python hunk is coloured as Python" {
+ if (!enabled or !full_grammars) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "--- a/tool.py\n" ++
+ "+++ b/tool.py\n" ++
+ "@@ -1,3 +1,4 @@\n" ++
+ " def run(n):\n" ++
+ "- return n\n" ++
+ "+ # doubled now\n" ++
+ "+ return n * 2\n" ++
+ " \n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "def", 0));
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "return n\n", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "# doubled", 2));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2\n", 0));
+}
+
+test "syntax a deleted file's lines are coloured in its language" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "diff --git a/gone.zig b/gone.zig\n" ++
+ "deleted file mode 100644\n" ++
+ "--- a/gone.zig\n" ++
+ "+++ /dev/null\n" ++
+ "@@ -1,2 +0,0 @@\n" ++
+ "-const Gone = enum { a };\n" ++
+ "-// the end\n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "enum", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "// the end", 3));
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "enum").?] & diff_removed != 0);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "deleted file", 0));
+}
+
+test "syntax a hunk in no known language keeps the line colours" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "--- a/notes.unknownext\n" ++
+ "+++ b/notes.unknownext\n" ++
+ "@@ -1 +1 @@\n" ++
+ "-const struct 1\n" ++
+ "+const struct 2\n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "struct 1", 0));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "struct 2", 0));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+const struct 2", 0));
+}
+
+test "syntax git diff output in a command pane is told and coloured by its rows" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ // A command pane's rows: its command line, then git's output, with one
+ // long added line the terminal wrapped onto a second row.
+ const rows = [_][]const u8{
+ "git diff",
+ "diff --git a/main.zig b/main.zig",
+ "--- a/main.zig",
+ "+++ b/main.zig",
+ "@@ -1,2 +1,2 @@",
+ " pub fn main() void {",
+ "+ const answer: u32 = 42; // the ",
+ "wrapped rest",
+ "- return;",
+ "",
+ };
+ try std.testing.expect(diff.looksLikeDiff(&rows));
+ try std.testing.expect(!diff.looksLikeDiff(rows[5..]));
+ const text = try std.mem.join(std.testing.allocator, "\n", &rows);
+ defer std.testing.allocator.free(text);
+ const styles = try highlightDiffRows(std.testing.allocator, text, 0, text.len, true);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.none, diffSynAt(styles, text, "git diff", 0));
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, text, "fn main", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, text, "42", 0));
+ // The wrapped row stays an added line, and inside the comment.
+ try std.testing.expect(styles[std.mem.indexOf(u8, text, "wrapped rest").?] & diff_added != 0);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, text, "wrapped rest", 0));
+ try std.testing.expect(styles[std.mem.indexOf(u8, text, "return").?] & diff_removed != 0);
+}
+
+test "syntax a section's small hunks share one parse" {
+ if (!enabled) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const diff_text =
+ "--- a/x.zig\n+++ b/x.zig\n" ++
+ "@@ -1 +1 @@\n-const a = 1;\n+const a = 2;\n" ++
+ "@@ -10 +10 @@\n-const b = 1;\n+const b = 2;\n" ++
+ "@@ -20 +20 @@\n-const c = 1;\n+const c = 2;\n" ++
+ "--- a/y.zig\n+++ b/y.zig\n" ++
+ "@@ -1 +1 @@\n-const d = 1;\n+const d = 2;\n";
+ const styles = try highlightDiff(gpa, diff_text, 0, diff_text.len);
+ defer gpa.free(styles);
+ var cached: usize = 0;
+ for (diff_pieces) |slot| cached += @intFromBool(slot != null);
+ try std.testing.expectEqual(@as(usize, 2), cached); // x.zig's three hunks, and y.zig's
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -10", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2;\n@@ -20", 0));
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "@@ -10").?] & (diff_added | diff_removed) == 0);
+}
+
+test "syntax a hunk painted once is kept, and a long one is parsed in pieces" {
+ if (!enabled) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ var text: std.Io.Writer.Allocating = .init(gpa);
+ defer text.deinit();
+ const n = diff_piece_cut * 20 + 10;
+ try text.writer.print("--- /dev/null\n+++ b/big.zig\n@@ -0,0 +1,{d} @@\n", .{n});
+ for (0..n) |i| try text.writer.print("+const v{d} = {d};\n", .{ i, i });
+ const content = text.written();
+ const at = std.mem.indexOf(u8, content, "+const v1000 ").?;
+ const window = try highlightDiff(gpa, content, at, at + 200);
+ defer gpa.free(window);
+ try std.testing.expectEqual(Syn.number, @as(Syn, @enumFromInt(window["+const v1000 = ".len] & syn_bits)));
+ var cached: usize = 0;
+ for (diff_pieces) |slot| if (slot) |piece| {
+ cached += 1;
+ // One piece of many, not the whole hunk.
+ try std.testing.expect(piece.styles.len < content.len / 4);
+ };
+ try std.testing.expectEqual(@as(usize, 1), cached);
+ const again = try highlightDiff(gpa, content, at, at + 200);
+ defer gpa.free(again);
+ try std.testing.expectEqualSlices(u8, window, again);
+ cached = 0;
+ for (diff_pieces) |slot| cached += @intFromBool(slot != null);
+ try std.testing.expectEqual(@as(usize, 1), cached);
}
test "syntax result fragments preserve source indentation and inline markup" {
@@ -1526,3 +1950,4 @@ test "syntax source analysis preserves declarations and injected colors" {
try std.testing.expectEqual(@as(usize, 0), unsupported.declarations.len);
try std.testing.expectEqual(@as(usize, 0), unsupported.colors.len);
}
+
diff --git a/src/tagline.zig b/src/tagline.zig
index 1923871a..dc7d10d4 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -344,7 +344,8 @@ pub fn commitTagName(p: *Pardes, pane: *Pane) bool {
p.reportError(id, "name", error.InvalidName);
return false;
};
- const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), name }) catch |err| {
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), pardes.filesystem.expandHome(name, &home_buf) }) catch |err| {
p.reportError(id, "name", err);
return false;
};
diff --git a/src/terminal.zig b/src/terminal.zig
index 4cdfa52d..c0341d00 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -1152,9 +1152,14 @@ pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !Comm
const screen = state.vt.screens.get(.primary) orelse break :printed false;
if (end.garbage) break :printed false;
var whole = !marks.lost and !start.garbage;
+ // Nothing printed: the whole of it.
+ if (start.eql(end.*)) break :printed whole;
// D is where the next prompt begins: the output ends a cell before.
- const last = end.leftWrap(1) orelse break :printed whole;
- if (start.eql(end.*) or last.before(start.*)) break :printed whole;
+ // An end at the very top, or before the start, is output a redraw
+ // moved (a prompt drawn over it in a window two rows high): how
+ // much is not known, so it is not called whole.
+ const last = end.leftWrap(1) orelse break :printed false;
+ if (last.before(start.*)) break :printed false;
var from = start.*;
if (last.up(max_rows)) |floor| if (from.before(floor)) {
from = floor;
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 0a9d65c4..eabbbe22 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1083,6 +1083,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
defer vx.exitAltScreen(tty.writer()) catch {};
try vx.setMouseMode(tty.writer(), true);
try vx.setBracketedPaste(tty.writer(), true);
+ Killed.arm(tty.fd.handle, tty.termios);
+ defer Killed.disarm();
if (attach != null) {
attach_end = attachSession(init, attach_name, &tty, &vx);
@@ -1119,6 +1121,40 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
}
}
+/// SIGTERM or SIGHUP: the terminal put back as a clean exit leaves it --
+/// the main screen, no mouse, paste or keyboard modes, the cursor shown,
+/// cooked termios -- and then the signal's own death. A nested editor
+/// killed in a pane must not leave that pane's terminal wedged.
+/// Async-signal-safe: write, tcsetattr, sigaction, raise.
+const Killed = struct {
+ var fd: posix.fd_t = -1;
+ var cooked: posix.termios = undefined;
+ const reset = "\x1b[<u\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l";
+ const signals = [_]posix.SIG{ .TERM, .HUP };
+
+ fn arm(tty_fd: posix.fd_t, termios: posix.termios) void {
+ cooked = termios;
+ fd = tty_fd;
+ const on: posix.Sigaction = .{ .handler = .{ .handler = handle }, .mask = posix.sigemptyset(), .flags = posix.SA.RESETHAND };
+ for (signals) |sig| posix.sigaction(sig, &on, null);
+ }
+
+ fn disarm() void {
+ fd = -1;
+ const default: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 };
+ for (signals) |sig| posix.sigaction(sig, &default, null);
+ }
+
+ fn handle(sig: posix.SIG) callconv(.c) void {
+ if (fd >= 0) {
+ _ = std.c.write(fd, reset, reset.len);
+ _ = std.c.tcsetattr(fd, .FLUSH, &cooked);
+ }
+ // SA_RESETHAND put the default back: the same signal, now fatal.
+ _ = std.c.raise(sig);
+ }
+};
+
fn localSession(
init: std.process.Init,
opts: pardes.Options,