summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Terminal.zig23
-rw-r--r--src/builtins.zig99
-rw-r--r--src/config.zig9
-rw-r--r--src/exec.zig1
-rw-r--r--src/fs-help.txt2
-rw-r--r--src/host_io.zig25
-rw-r--r--src/ninep/pty.zig1
-rw-r--r--src/panes.zig6
-rw-r--r--src/pardes.zig12
-rw-r--r--src/tutor.txt7
10 files changed, 173 insertions, 12 deletions
diff --git a/src/Terminal.zig b/src/Terminal.zig
index 0bec3ce8..cd292e5f 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -214,6 +214,29 @@ pub fn armShellSpawn(pane: *Pane) void {
pane.pending_command.wait = .spawn;
}
+/// Remembers a line pardes types into this terminal as the command it
+/// started, for Kill: it runs from the shell's next C mark to its D.
+/// ponytail: of several lines queued for a shell not up yet, only the last
+/// is remembered, as the first of them.
+pub fn noteCommand(pane: *Pane, line: []const u8) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ const word = std.mem.trim(u8, line, " \t")[0 .. std.mem.indexOfAny(u8, std.mem.trim(u8, line, " \t"), " \t") orelse std.mem.trim(u8, line, " \t").len];
+ var sent: @typeInfo(@FieldType(Pane, "sent_command")).optional.child = .{ .seq = state.stream.handler.started +% 1 };
+ sent.len = @intCast(@min(word.len, sent.word.len));
+ @memcpy(sent.word[0..sent.len], word[0..sent.len]);
+ pane.sent_command = sent;
+}
+
+/// Whether the command pardes last typed here is running now.
+pub fn commandRunning(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ const sent = pane.sent_command orelse return false;
+ const marks = &state.stream.handler;
+ return marks.phase == .running and marks.started == sent.seq;
+}
+
pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool {
if (pane.pending_command.wait == .none) return false;
const old_len = pane.pending_command.bytes.len;
diff --git a/src/builtins.zig b/src/builtins.zig
index 2dac8a59..0154e707 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -213,6 +213,56 @@ test "a builtin's scope is the pane unless it says session, and every setting is
try std.testing.expectEqual(Scope.session, registry.scope(std.meta.stringToEnum(Builtin, setting.word).?));
}
+test "Exit asks once about unsaved text, and quits when asked again" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("saved\n");
+ pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(!p.quit);
+ try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified"));
+ // Told once, and nothing edited since: the second Exit quits.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(p.quit);
+}
+
+test "Kill stops the commands pardes typed, bare all and by name those that match, and never quits" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const term = p.active;
+ try std.testing.expect(p.panes[term].?.isTerminal());
+ const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = prompt } });
+ while (p.nextEffect()) |_| {}
+ const killed = struct {
+ fn count(pp: *Pardes, line: []const u8) usize {
+ _ = pp.executeBuiltinLine(pp.active, line);
+ var n: usize = 0;
+ while (pp.nextEffect()) |e| if (e == .signal_pty and e.signal_pty.sig == .command) {
+ n += 1;
+ };
+ return n;
+ }
+ };
+ // Nothing running: nothing to stop, and never the shell.
+ try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill"));
+ _ = exec.execute(p, term, "make -j8");
+ while (p.nextEffect()) |_| {}
+ // Typed but not started yet (no C mark): not running.
+ try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill"));
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;C;aid=pardes\x07" } });
+ while (p.nextEffect()) |_| {}
+ try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill ls"));
+ try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill ls make"));
+ try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill"));
+ try std.testing.expect(!p.quit);
+ // Ended (a D mark): nothing to stop again.
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;D;143;aid=pardes\x07" ++ prompt } });
+ while (p.nextEffect()) |_| {}
+ try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill"));
+}
+
test "Look does not announce itself on the message row; Del still does" {
const Builtin = registry.Builtin();
try std.testing.expect(!registry.announces(Builtin.Look));
@@ -274,14 +324,61 @@ pub const Exec = struct {
// ---- session ----
-pub const Kill = struct {
+/// acme's Exit (editors/acme/exec.c, `exit` and rowclean): quit, unless a
+/// pane holds unsaved text nobody has been told about. Each such pane says
+/// so, `<name>: Modified`, and the Exit goes no further; an Exit after that,
+/// with nothing edited since, quits. A scratch of under 100 bytes is no
+/// loss worth the question, as acme's winclean does not ask for one.
+pub const Exit = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
+ var modified = false;
+ for (c.p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ if (f.revision == f.saved_revision or !panes.Output.fileTraits(f.output).saves) continue;
+ if (f.output != null and f.content.len < 100) continue;
+ if (pane.exit_warned == f.revision) continue;
+ pane.exit_warned = f.revision;
+ c.p.reportError(id, f.path, error.Modified);
+ modified = true;
+ }
+ if (modified) return;
c.p.quit = true;
c.p.emit(.quit);
}
};
+/// acme's Kill (exec.c, `kill`; acme.c's WKill): stop the commands pardes
+/// started -- the lines it typed into a terminal, from an exec, a middle
+/// click or a pty/run, while their shell says they run (C mark to D).
+/// Bare, every one; `Kill make ls`, those whose line starts with one of
+/// the words. acme posts the "kill" note, which ends a process; here that
+/// is SIGTERM to the terminal's foreground job, and never to the shell,
+/// which is its own job and stays.
+pub const Kill = struct {
+ pub const scope: Scope = .session;
+ pub const takes_arg = true;
+ pub fn run(c: Ctx) void {
+ const names = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ var stopped = false;
+ for (c.p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ if (!panes.Terminal.commandRunning(pane)) continue;
+ const sent = pane.sent_command.?;
+ if (names.len > 0) {
+ var words = std.mem.tokenizeAny(u8, names, " \t");
+ while (words.next()) |w| {
+ if (std.mem.eql(u8, w, sent.word[0..sent.len])) break;
+ } else continue;
+ }
+ c.p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = .command } });
+ stopped = true;
+ }
+ if (!stopped and names.len > 0) c.p.reportError(c.id, "Kill", error.NoSuchCommand);
+ }
+};
+
pub const Dump = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
diff --git a/src/config.zig b/src/config.zig
index f898df11..6d0cf0a6 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -49,6 +49,7 @@ pub const leader_path = paths: {
.Togglettymode = null,
.Collapse = null,
.Kill = null,
+ .Exit = null,
.ClipYank = "y",
.ClipYankMain = "Y",
.ClipPaste = "p",
@@ -160,8 +161,9 @@ pub const leader_path = paths: {
break :paths table;
};
-test "Space-k is unbound while Kill and other k chords remain available" {
+test "Space-k is unbound while Exit, Kill and other k chords remain available" {
try std.testing.expect(leader_path.get(.Kill) == null);
+ try std.testing.expect(leader_path.get(.Exit) == null);
try std.testing.expectEqualStrings("wk", leader_path.get(.Up).?);
try std.testing.expectEqualStrings("lk", leader_path.get(.Hover).?);
try std.testing.expectEqualStrings("d", leader_path.get(.Del).?);
@@ -183,7 +185,10 @@ test "Space-k is unbound while Kill and other k chords remain available" {
while (p.nextEffect()) |effect| try std.testing.expect(effect != .quit);
p.update(.{ .key = .{ .cp = 'k' } });
try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row);
+ // Kill stops commands and never quits; Exit quits.
try std.testing.expect(p.executeBuiltinLine(p.active, "Kill"));
+ try std.testing.expect(!p.quit);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
try std.testing.expect(p.quit);
}
@@ -237,7 +242,7 @@ pub const child_term = "xterm-256color";
pub const child_colorterm = "truecolor";
pub const child_term_program = "pardes";
-pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill";
+pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit";
pub const pane_builtins_str = "Tty Collapse Del";
pub const pdf_pane_builtins_str = "Tty PdfSections PdfTint Collapse Del";
diff --git a/src/exec.zig b/src/exec.zig
index 46b4f57b..b16c600c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -399,6 +399,7 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize {
defer p.exec_depth -= 1;
const dst = (if (takesCommandLine(p, id)) id else ttyForDir(p, Pardes.paneDir(pane))) orelse return null;
panes.Terminal.padOutputBelowEdits(p, dst);
+ panes.Terminal.noteCommand(p.panes[dst].?, cmd);
if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| {
p.reportError(id, "queue command", err);
return dst;
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 8db1e288..bbff6f13 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -9,7 +9,7 @@ log events: new/del/rename/save <serial> <name>, msg <serial|-> <text>,
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
focus the serial of the pane with the keyboard; write a serial to give it the keyboard
-ctl the settings, one a line as a write takes them; write one, or Newcol, Dump; Kill QUITS
+ctl settings, one a line as a write takes them; or Newcol, Dump, Exit (quits), Kill (stops cmds)
commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it
pane/new open it to make a pane (named +New); the read answers that pane's serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only:
diff --git a/src/host_io.zig b/src/host_io.zig
index a5efc7d1..5d9dd413 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -1337,8 +1337,14 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna
.hup => libc.SIG.HUP,
.quit => libc.SIG.QUIT,
.kill => libc.SIG.KILL,
+ .command => libc.SIG.TERM,
};
const fg = tcgetpgrp(master_fd);
+ // The shell leads its own group: a job it runs has another.
+ if (which == .command) {
+ if (fg > 0 and fg != shell_pid) _ = libc.kill(-fg, sig);
+ return;
+ }
if (fg > 0) {
_ = libc.kill(-fg, sig);
return;
@@ -1640,6 +1646,25 @@ test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands i
try std.testing.expect(sh.waitText(test_prompt, 10_000));
}
+test "Kill's signal stops the foreground job and never the shell" {
+ if (comptime !tty_probe_platform) return error.SkipZigTest;
+ var sh = TestShell.start() orelse return error.SkipZigTest;
+ defer sh.stop();
+
+ // At the prompt the shell is the foreground: there is nothing to stop.
+ signalTty(sh.pid, sh.master, .command);
+ sleepMs(100);
+ try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG));
+
+ sh.send("sleep 30\n");
+ try std.testing.expect(sh.waitTaken(true, 10_000));
+ sh.forget();
+ signalTty(sh.pid, sh.master, .command);
+ try std.testing.expect(sh.waitTaken(false, 10_000));
+ try std.testing.expect(sh.waitText(test_prompt, 10_000));
+ try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG));
+}
+
test "a background job is not the tty's owner" {
if (comptime !tty_probe_platform) return error.SkipZigTest;
var sh = TestShell.start() orelse return error.SkipZigTest;
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 50d14624..dcf9a8a7 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -233,6 +233,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
slot.declined = marks.declined;
p.emitWrite(id, slot.line);
p.emitWrite(id, "\r");
+ pardes.panes.Terminal.noteCommand(pane, slot.line);
slot.phase = .sent;
return;
}
diff --git a/src/panes.zig b/src/panes.zig
index ab4f5072..70b461eb 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -95,6 +95,12 @@ pub const Pane = struct {
rows: u16,
greet: bool = false,
pending_command: Terminal.PendingCommand = .{},
+ /// The last command line pardes typed into this terminal (an exec, a
+ /// middle click, a pty/run): its first word, which Kill matches, and
+ /// the shell's command count its start (C mark) reaches.
+ sent_command: ?struct { word: [64]u8 = undefined, len: u8 = 0, seq: u32 = 0 } = null,
+ /// The revision an Exit last warned this pane was modified at.
+ exit_warned: ?u32 = null,
/// Native host queues the Linux v9fs launcher in this pane's initial shell.
v9fs_on_spawn: bool = false,
file: ?File.State = null,
diff --git a/src/pardes.zig b/src/pardes.zig
index 05f6710a..f3348ea5 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -561,7 +561,7 @@ test "startup config runs builtin lines in order and isolates bad lines" {
.startup_config =
\\Theme dark
\\DefinitelyNotABuiltin
- \\Kill trailing-garbage
+ \\Exit trailing-garbage
\\Theme no-such-theme
\\Theme acme
,
@@ -575,7 +575,7 @@ test "startup config runs builtin lines in order and isolates bad lines" {
for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) {
.write => |w| {
try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "DefinitelyNotABuiltin") == null);
- try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Kill") == null);
+ try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Exit") == null);
},
else => {},
};
@@ -668,10 +668,10 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" {
else => {},
};
- // Fixed-name builtins remain exact: a tail does not turn New or Kill into
+ // Fixed-name builtins remain exact: a tail does not turn New or Exit into
// a match (especially important for the destructive one).
try std.testing.expect(!p.executeBuiltinLine(0, "New argument"));
- try std.testing.expect(!p.executeBuiltinLine(0, "Kill trailing-garbage"));
+ try std.testing.expect(!p.executeBuiltinLine(0, "Exit trailing-garbage"));
try std.testing.expect(!p.quit);
// A word absent from the builtin vocabulary takes the fallback, including
@@ -3083,7 +3083,9 @@ pub const AttachRequest = struct { pane: u8, name: []const u8 };
pub const PendingWrite = struct { bytes: []u8, off: usize = 0 };
-pub const PtySignal = enum(u8) { int, term, hup, quit, kill };
+/// `command` is Kill's: SIGTERM to the terminal's foreground job, and never
+/// to the shell itself when the shell is in the foreground.
+pub const PtySignal = enum(u8) { int, term, hup, quit, kill, command };
/// IO the core wants done. Payloads are inline (fixed buffers): effects are
/// queued values with no lifetime ties back into the core.
diff --git a/src/tutor.txt b/src/tutor.txt
index 2f5b521c..507ce40e 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -66,7 +66,7 @@
The top bar is:
Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor
- Debug Kill
+ Debug Exit
Reach it from the keyboard with `k` off the topmost tagline; `j` comes
back down.
@@ -361,7 +361,7 @@ typed
abandons it, and so does any key that leads nowhere.
SPC ? list every path
- SPC d Del Kill remains available in the topbar.
+ SPC d Del Exit (quit) remains available in the topbar.
SPC f s Save SPC f f Find SPC f n New
SPC y Y p P R the system clipboard
SPC w h/j/k/l focus a neighbouring pane
@@ -488,4 +488,5 @@ typed
Spawn this tutor again: middle-click "Tutor" in the top bar, or SPC h t.
Quit: this is a file pane — `:q` is not wired. Close it with Del in its
- tag, "Kill" in the top bar to quit everything, or Ctrl-c the app.
+ tag, "Exit" in the top bar to quit everything, or Ctrl-c the app.
+ "Kill" does not quit: it stops the commands pardes started, as acme's does.