diff options
| author | user <user@clank> | 2026-08-15 16:13:46 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 16:13:46 +0200 |
| commit | ee6288a7eadeb43c83a5ab78f6c65cffa4748a53 (patch) | |
| tree | 8fc137687f47a7f20f0a2766bca17cd53e0400dc | |
| parent | make coverage: a number, cheaply (diff) | |
| download | slosh-ee6288a7eadeb43c83a5ab78f6c65cffa4748a53.tar.gz slosh-ee6288a7eadeb43c83a5ab78f6c65cffa4748a53.tar.xz slosh-ee6288a7eadeb43c83a5ab78f6c65cffa4748a53.zip | |
sl0ppty: C-a e opens the config, in a pane, in your editor
The config watcher's whole point is the loop -- edit, save, watch the session
change under you, edit again -- and the loop had a hole in the middle of it:
you had to leave the session, or remember where the file lives, to start.
So: one keystroke opens `$EDITOR <config>` in a new pane, down rather than
across because a config file is lines and half the height beats half the
width for reading them. `editor` in the config beats $EDITOR, and vi is the
fallback, being the one editor a POSIX system must have -- a wrong guess you
can see and change beats a pane that opens empty.
The interesting part is what kind of pane it is. Panes given a command keep
their corpse (D14), which is right for a dev server and wrong for this: quit
the editor and you would be left dismissing a dead pane you opened thirty
seconds ago. Rather than gaming that policy by giving the pane no command --
which would be a lie in the one field dump-layout and re-run read -- panes can
now be *ephemeral*: a task rather than part of the session's shape. An
ephemeral pane keeps no corpse and is not written into a dumped layout, so a
restart does not reopen somebody's editor on a file they had finished with.
That concept is the reusable half of this commit; the keybinding is the part
you notice.
| -rw-r--r-- | config/config.kdl | 10 | ||||
| -rw-r--r-- | src/app.c | 67 | ||||
| -rw-r--r-- | src/app.h | 3 | ||||
| -rw-r--r-- | src/cmd.c | 4 | ||||
| -rw-r--r-- | src/config.c | 9 | ||||
| -rw-r--r-- | src/config.h | 3 | ||||
| -rw-r--r-- | src/pane.c | 3 | ||||
| -rw-r--r-- | src/sl0ppty.h | 6 | ||||
| -rw-r--r-- | tests/test_edit_config.py | 130 |
9 files changed, 230 insertions, 5 deletions
diff --git a/config/config.kdl b/config/config.kdl index 8008051..15ef8b0 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -472,6 +472,16 @@ theme { // help detach quit // literal-prefix none +// What C-a e opens this file with. Empty means $EDITOR, and then vi -- the +// one editor a POSIX system is required to have, so a wrong guess you can see +// beats a pane that opens empty. +// +// The pane it opens is *ephemeral*: quit the editor and the pane goes, with +// no corpse to dismiss and no trace in a dumped layout. You asked for an +// editor, not for a record of having had one. +// +// editor "nvim" + keys { // The leader. Every binding below is this key, then the key on the line. // ctrl+a is a default and not a decision -- it is start-of-line to half @@ -1294,10 +1294,22 @@ static void replace_child(node_t *parent, node_t *old, node_t *new_) { } } +/* The pane a split makes. NULL argv means "whatever this session runs", which + * is the ordinary case; a command makes it a task pane instead. */ +static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, + bool before, const char *const *argv, + const char *label); + static void split_node(app_t *a, node_t *leaf, split_dir_t dir, bool before) { - if (!leaf) return; - node_t *fresh = leaf_new(a); - if (!fresh) return; + split_node_with(a, leaf, dir, before, NULL, NULL); +} + +static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, + bool before, const char *const *argv, + const char *label) { + if (!leaf) return NULL; + node_t *fresh = argv ? leaf_new_ex(a, argv, NULL, false, label) : leaf_new(a); + if (!fresh) return NULL; /* Growing an existing split in the same direction keeps the tree flat, so * three vertical splits are three equal columns rather than 1/2 + 1/4 + 1/4. */ @@ -1332,6 +1344,7 @@ static void split_node(app_t *a, node_t *leaf, split_dir_t dir, bool before) { cur(a)->focus = fresh; layout(a); + return fresh; } /* Would splitting this pane produce two panes worth having? @@ -1448,6 +1461,10 @@ struct reap { * empty for a pane that is just a shell -- which is the whole distinction, and * it happens to already be recorded. */ static bool keep_corpse(const pane_t *p) { + /* A pane opened to do one thing is done when that thing ends, whatever the + * policy says about commands: you asked for an editor, not for a record of + * having had one. */ + if (pane_ephemeral(p)) return false; switch (CFG.keep_dead) { case KEEP_DEAD_ALL: return true; case KEEP_DEAD_NONE: return false; @@ -1690,6 +1707,40 @@ bool app_close_pane(app_t *a, uint32_t id) { * the run that ended stays above the new one in its scrollback. A suspended * pane is started rather than restarted, because "run the thing this pane is * for" is the same request either way. */ +/* Open the config in $EDITOR, in a pane of its own. + * + * The whole point is the loop: edit, save, watch the session change under you, + * edit again. Anything that makes you leave the session to do it -- another + * terminal, or remembering where the file lives -- is friction in the middle + * of the one workflow the config watcher exists for. + * + * The pane is ephemeral: quit the editor and it is gone. You asked for an + * editor, not for a record of having had one. */ +bool app_edit_config(app_t *a) { + node_t *n = a->ntabs ? cur(a)->focus : NULL; + if (!n) return false; + + const char *editor = CFG.editor && *CFG.editor ? CFG.editor : getenv("EDITOR"); + /* vi is the one editor a POSIX system is required to have. Better a wrong + * guess you can see and change than a pane that opens empty. */ + if (!editor || !*editor) editor = "vi"; + + char cmd[1024]; + snprintf(cmd, sizeof cmd, "%s '%s'", editor, config_default_path()); + const char *argv[] = {"/bin/sh", "-c", cmd, NULL}; + + /* Down rather than across: a config file is lines, and half the width of a + * pane is a worse place to read them than half the height. */ + if (!split_fits(n, SPLIT_ROWS)) { + app_toast(a, "no room to split down"); + return false; + } + node_t *fresh = split_node_with(a, n, SPLIT_ROWS, false, argv, cmd); + if (!fresh) return false; + pane_set_ephemeral(fresh->pane, true); + return true; +} + bool app_rerun_pane(app_t *a, uint32_t id) { node_t *n = id ? pane_by_id(a, id) : (a->ntabs ? cur(a)->focus : NULL); if (!n) return false; @@ -4013,6 +4064,9 @@ static bool prefix_command(app_t *a, const input_event_t *ev) { a->query[0] = 0; a->sel = 0; return true; + case ACT_EDIT_CONFIG: + app_edit_config(a); + return true; case ACT_HELP: a->help = !a->help; return true; @@ -4336,8 +4390,11 @@ static void dump_node(app_t *a, node_t *n, strbuf_t *b, int depth) { char cwdbuf[4096]; sb_quoted(b, "cwd", live_cwd(n->pane, cwdbuf, sizeof cwdbuf)); /* The label is the command a layout gave it; a pane running the session's - * default shell has none, and gets none back. */ - sb_quoted(b, "command", pane_label(n->pane)); + * default shell has none, and gets none back. An ephemeral pane is a task + * that happened to be open when the dump was taken — restoring it would + * reopen somebody's editor on a file they finished with. */ + if (!pane_ephemeral(n->pane)) + sb_quoted(b, "command", pane_label(n->pane)); sb_quoted(b, "purpose", n->purpose); if (pane_suspended(n->pane)) sb_add(b, " suspended=true"); if (n == cur(a)->focus) sb_add(b, " focus=true"); @@ -114,6 +114,9 @@ bool app_close_pane(app_t *a, uint32_t id); /* Run a dead (or not-yet-started) pane's command again, in the same pane and * on top of the same scrollback. 0 means the focused one. */ bool app_rerun_pane(app_t *a, uint32_t id); +/* Open the config file in $EDITOR, in a pane of its own, so the edit/save/ + * watch loop happens without leaving the session. The pane is ephemeral. */ +bool app_edit_config(app_t *a); uint32_t app_focused_pane_id(app_t *a); uint32_t app_current_tab_id(app_t *a); @@ -146,6 +146,10 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, return jerr("no such pane"); return jok_int("id", app_focused_pane_id(a)); } + if (strcmp(cmd, "edit-config") == 0) { + return app_edit_config(a) ? jok_int("id", app_focused_pane_id(a)) + : jerr("no room for another pane"); + } if (strcmp(cmd, "rerun") == 0) { if (!app_rerun_pane(a, (uint32_t)jv_geti(req, "id", 0))) return jerr("cannot run it again"); diff --git a/src/config.c b/src/config.c index f4076b7..877b225 100644 --- a/src/config.c +++ b/src/config.c @@ -30,6 +30,7 @@ static const struct { {"detach", ACT_DETACH}, {"quit", ACT_QUIT}, {"literal-prefix", ACT_LITERAL_PREFIX}, {"help", ACT_HELP}, + {"edit-config", ACT_EDIT_CONFIG}, }; static const struct { @@ -393,6 +394,7 @@ void config_defaults(config_t *c) { bind_add(c, GHOSTTY_KEY_SLASH, 0, ACT_HELP, false); bind_add(c, GHOSTTY_KEY_R, 0, ACT_RERUN, false); bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM, false); + bind_add(c, GHOSTTY_KEY_E, 0, ACT_EDIT_CONFIG, false); bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE, false); bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT, false); bind_add(c, GHOSTTY_KEY_L, 0, ACT_FOCUS_RIGHT, false); @@ -431,6 +433,7 @@ void config_free(config_t *c) { free(c->exprs); free(c->binds); free(c->shell); + free(c->editor); free(c->shader_dir); memset(c, 0, sizeof *c); } @@ -478,6 +481,7 @@ static const struct { {ACT_SCROLL_BOTTOM, "scroll", "back to the present"}, {ACT_HELP, "session", "this list"}, + {ACT_EDIT_CONFIG, "session", "edit the config"}, {ACT_DETACH, "session", "detach, leave it running"}, {ACT_QUIT, "session", "quit the session"}, {ACT_LITERAL_PREFIX, "session", "send the prefix itself"}, @@ -709,6 +713,11 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { free(c->shell); c->shell = strdup(sh); } + const char *ed = kdl_arg(kdl_child(root, "editor"), 0, NULL); + if (ed) { + free(c->editor); + c->editor = strdup(ed); + } /* Shader plugins, before any shader is named below: a `shaders` block may * use what one of these adds, and a name is looked up as it is parsed. The diff --git a/src/config.h b/src/config.h index 882b8b0..33ece43 100644 --- a/src/config.h +++ b/src/config.h @@ -64,6 +64,7 @@ typedef enum { ACT_DETACH, ACT_QUIT, ACT_HELP, + ACT_EDIT_CONFIG, ACT_LITERAL_PREFIX, ACT_SELECT_TAB_1, /* .. +8, so digits stay one entry each */ } action_t; @@ -282,6 +283,8 @@ typedef struct { /* what a new pane runs; NULL means $SHELL */ char *shell; + /* What `edit-config` opens the file with; NULL means $EDITOR, then vi. */ + char *editor; /* Where to look for shader plugins (`*.so`). NULL means the default: a * `shaders` directory beside the config file. Read before the `shaders` and @@ -58,6 +58,7 @@ struct pane { * a dead pane needs exactly what it was started with, and by then the pane * is the only thing that still knows. */ bool suspended; + bool ephemeral; char **argv; char *cwd; char label[128]; @@ -224,6 +225,8 @@ static void on_title_changed(GhosttyTerminal t, void *ud) { } bool pane_suspended(const pane_t *p) { return p->suspended; } +void pane_set_ephemeral(pane_t *p, bool yes) { p->ephemeral = yes; } +bool pane_ephemeral(const pane_t *p) { return p->ephemeral; } const char *pane_label(const pane_t *p) { return p->label; } /* Spawn what a suspended pane was created to run. */ diff --git a/src/sl0ppty.h b/src/sl0ppty.h index e117908..38d5f18 100644 --- a/src/sl0ppty.h +++ b/src/sl0ppty.h @@ -119,6 +119,12 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows, const char *cwd, bool suspended, const char *label); bool pane_suspended(const pane_t *p); +/* A pane opened to do one thing, rather than one the session is made of: an + * editor you popped open, not the dev server a layout declared. It runs a + * command like any other, but when that command ends it is finished — so it + * keeps no corpse and is not written into a dumped layout. */ +void pane_set_ephemeral(pane_t *p, bool yes); +bool pane_ephemeral(const pane_t *p); const char *pane_label(const pane_t *p); /* The pid of the program in the pane, or -1. Used to ask the kernel where a * shell has got to, which is the only honest answer to "what is this pane's diff --git a/tests/test_edit_config.py b/tests/test_edit_config.py new file mode 100644 index 0000000..516e5da --- /dev/null +++ b/tests/test_edit_config.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""C-a e opens the config in $EDITOR, in a pane of its own. + +The point is the loop: edit, save, watch the session change under you, edit +again. Anything that makes you leave the session to do it — another terminal, +or remembering where the file lives — is friction in the middle of the one +workflow the config watcher exists for. + +The pane is *ephemeral*: quit the editor and it is gone. You asked for an +editor, not for a record of having had one. +""" +import os +import sys +import tempfile + +from harness import Session, check, report + +SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; read x'] + +# An "editor" that shows the file and stays up, and one that quits when told. +SHOWS = "tail -f" +QUITS = "sh -c 'read x; exit' --" + + +def conf(text='shell "x"\n'): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +def test_it_opens_the_config_in_a_pane(): + c = conf('shell "a-marker-only-this-test-writes"\n') + with Session(SH, cols=70, rows=24, config=c, + env={"EDITOR": SHOWS}) as s: + s.settle() + check("one pane to start", len(s.panes()) == 1) + s.key("e") + snap = s.until_text("a-marker-only-this-test-writes") + check("a second pane opened", len(s.panes()) == 2, str(s.panes())) + check("with the config file in it", + "a-marker-only-this-test-writes" in snap.screen(), snap.screen()) + os.unlink(c) + + +def test_the_editor_pane_goes_when_the_editor_does(): + c = conf() + with Session(SH, cols=70, rows=24, config=c, env={"EDITOR": QUITS}) as s: + s.settle() + s.key("e") + s.settle(60) + check("the editor is up", len(s.panes()) == 2, str(s.panes())) + s.raw("q\\n") + s.until(lambda _: len(s.panes()) == 1) + check("quitting it closes the pane", len(s.panes()) == 1, str(s.panes())) + check("and leaves no corpse to dismiss", + "[process exited" not in s.snapshot().screen(), + s.snapshot().screen()) + os.unlink(c) + + +def test_the_config_can_name_the_editor(): + """`editor` beats $EDITOR, for the person whose $EDITOR is something they + do not want a terminal pane to run.""" + c = conf('editor "tail -f"\nshell "picked-by-the-config"\n') + with Session(SH, cols=70, rows=24, config=c, + env={"EDITOR": "definitely-not-an-editor"}) as s: + s.settle() + s.key("e") + snap = s.until_text("picked-by-the-config") + check("the config's editor is used", + "picked-by-the-config" in snap.screen(), snap.screen()) + os.unlink(c) + + +def test_it_is_not_written_into_a_dumped_layout(): + """A dump is the session's shape. An editor someone had open while it was + taken is not part of that shape — restoring it would reopen their editor + on a file they had finished with.""" + c = conf() + with Session(SH, cols=70, rows=24, config=c, env={"EDITOR": SHOWS}) as s: + s.settle() + s.key("e") + s.settle(60) + check("the editor is up", len(s.panes()) == 2, str(s.panes())) + dumped = s.api("dump-layout")["kdl"] + check("the dump does not carry the editor command", + "tail -f" not in dumped, dumped) + os.unlink(c) + + +def test_no_room_says_so_rather_than_splitting_anyway(): + c = conf() + with Session(SH, cols=70, rows=10, config=c, env={"EDITOR": SHOWS}) as s: + s.settle() + s.key("e") + s.settle(40) + check("still one pane", len(s.panes()) == 1, str(s.panes())) + check("and it said why", "no room" in s.snapshot().screen(), + s.snapshot().screen()) + os.unlink(c) + + +def test_the_cheatsheet_lists_it(): + c = conf() + with Session(SH, cols=92, rows=28, config=c) as s: + s.settle() + s.send(r"\x01?") + s.settle() + screen = s.snapshot().screen() + check("it is on the sheet", "edit the config" in screen, screen) + check("under a key", "e " in screen, screen) + os.unlink(c) + + +def test_the_control_api_can_do_it_too(): + c = conf('shell "from-the-api"\n') + with Session(SH, cols=70, rows=24, config=c, env={"EDITOR": SHOWS}) as s: + s.settle() + r = s.api("edit-config") + check("the api reports ok", r.get("ok") is True, str(r)) + snap = s.until_text("from-the-api") + check("and opened it", "from-the-api" in snap.screen(), snap.screen()) + os.unlink(c) + + +for name, fn in sorted(list(globals().items())): + if name.startswith("test_"): + fn() +sys.exit(report()) |
