diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Terminal.zig | 23 | ||||
| -rw-r--r-- | src/builtins.zig | 99 | ||||
| -rw-r--r-- | src/config.zig | 9 | ||||
| -rw-r--r-- | src/exec.zig | 1 | ||||
| -rw-r--r-- | src/fs-help.txt | 2 | ||||
| -rw-r--r-- | src/host_io.zig | 25 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 1 | ||||
| -rw-r--r-- | src/panes.zig | 6 | ||||
| -rw-r--r-- | src/pardes.zig | 12 | ||||
| -rw-r--r-- | src/tutor.txt | 7 |
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. |
