aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-17 12:56:42 +0200
committeruser <user@clank>2026-08-17 12:56:42 +0200
commit6a27f7b365d71ab853931fd5ab3c0f2f7ac83d57 (patch)
treeaf17513ab62107059d8044eb416b5353e66ab528 /src
parentsl0ppty: `C-a Enter` splits the longer way (diff)
downloadslosh-6a27f7b365d71ab853931fd5ab3c0f2f7ac83d57.tar.gz
slosh-6a27f7b365d71ab853931fd5ab3c0f2f7ac83d57.tar.xz
slosh-6a27f7b365d71ab853931fd5ab3c0f2f7ac83d57.zip
sl0ppty: workspaces, and layouts that write themselves
A **project** is a directory on disk. A **workspace** is the tab it occupies in a session. Two words because they are two things, and conflating them is how a session grows a second source of truth about what it is looking at. `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `sl0ppty.layout.kdl` -- or a `.git`, which is the project that has not said what it needs yet. Both markers, because a `~/dev` of forty checkouts and three layout files is a picker with three rows, and `C-a W` is how a project stops being the second kind. A directory that *is* a project is never descended into, so the walk stays off `node_modules` without anyone writing a rule about `node_modules`. `C-a w` is the third subject of the one picker (D19): the finder searches what this session has, this lists what is on disk, including the projects with no workspace open yet, which is most of them and the whole point. **Identity is the tab's `purpose`** -- `project:<name>.<hash8>` of the resolved path, the shape D3 already published -- so there is no second field to disagree with it, membership is a string comparison, and a dumped session restores it for free because a dump already writes tab purposes and applying one already locks them (D8). Hashed on the *path* rather than the name, so two worktrees of one repo are two workspaces, which is what they are. A tab whose layout gave it a purpose of its own keeps it and is simply not a member: overwriting a declared purpose is the one thing D8 forbids, and the reply counts the ones it left alone. **Nothing watches the filesystem.** The list is derived when it is asked for -- one `readdir` per root and two `stat`s per entry -- and an answer nobody remembered cannot be stale, which is a promise no watcher can make on a bind mount. The picker scans once as it opens, not per frame, because `draw_picker` asks for its rows every frame and a `readdir` per repaint is a filesystem walk at 120Hz. Opening is idempotent (`sl0ppi up`'s property): the same request twice is one workspace, focused, and the answer says which branch it took. Four things had to be true before `save-workspace` could exist, and none of them were. **A relative `cwd=` in a layout file now means that file's own directory** -- the rule `include` already followed for configs, applied to the other half of the same syntax. A project's layout could previously only name absolute directories, which are right on exactly one machine and wrong in a worktree of the same repo. **A dump can be asked for one tab, against a base.** `dump-layout` takes `tab`, `relative_to` and `suspend`. Writing it also turned up a bug: `dump_node` asked `cur(a)->focus` whichever tab it was writing, so a dump of three tabs restored the focus of one. **A dump records what a pane is *running*.** `label` only ever knew what a layout said, so a pane you split and typed `npm run dev` into had nothing to say for itself and came back as a bare shell -- which made arranging a project and writing it down two jobs instead of one. The kernel knows what the label does not: a pty has a foreground process group, and that group is by definition the job that owns the terminal, so the answer is read the way `live_cwd` already reads `/proc/PID/cwd`. Three things are deliberately not commands: a shell at a prompt (its group is the pane's own, and `command="zsh"` on every idle pane restores a session where each shell runs inside a shell), a background job (it does not own the terminal), and an ephemeral pane. A declared label still outranks a live process, because it survives the program exiting (D14) and re-saving must not degrade `npm run dev` into whatever the process tree looks like this minute. argv is joined with shell quoting, since `command=` goes through `/bin/sh -c`. **A pane from a split starts where the pane it came out of stands**, not where the server was launched. Wrong on its own, and it had been quietly writing an absolute path to somebody else's directory into every saved project. A saved project defaults to `suspend="commands"`: the pane running this morning's dev server is written asleep, because a *project's* layout is a statement of shape and a *session's* dump is a statement of fact. Over the API, replacing a layout that is already there is refused without `force`; from the keyboard `C-a W` replaces and says `replaced`, because a key cannot pass a flag and the file is committed text whose diff is the review. `--check` now holds a layout to the layout schema instead of answering with the name of the flag that reads one. `unknown pane property: cmd`, `split is cols or rows`, `purpose is ignored on a pane with panes in it` -- the mistakes a layout can make that still parses. The lists live beside `build_pane` and a test greps for every prop the loader reads, the arrangement that keeps `KNOWN_TOP` honest. **`C-a P` sets a pane's purpose**, a third subject on the editor the pane title and the tab label already share. A purpose is what tooling finds a pane by, and it could only be declared by a layout or set over the socket -- so every pane anybody arranged by hand had none, and a layout dumped from one was a shape with no tags in it. Typed by an operator it counts as declared (D8: "a layout or an operator"). Clearing one now *unlocks* it: a lock held over an empty string was the one state nothing could escape. `set-purpose` also reads `id: 0` as the focused pane now, which every sibling verb already did. Two gaps found on the way, both reachable only over the socket before: **`C-a X` closes a tab.** It used to be a side effect -- press `x` once per pane and the tab evaporates when the last one goes -- so four panes meant four presses and counting. It says how many panes went, since most of them were not the one you were looking at, and on the last tab it refuses and points at `q`: a key that closes a tab four times and ends your session the fifth is one you cannot press without counting first. No `✕` on the tabs, for the arithmetic that took the per-pane `+` away. **`scrollback` is a number we choose.** D4's "free from lib-vt" was true of the machinery and quietly false of the size: handed no limit, lib-vt picks its own -- 10,000 *bytes*, which measured at 622 lines of an 80-column pane, less than one `make` run. `scrollback` is lines, defaulting to the 10,000 every other multiplexer settled on, and `scrollback_bytes` is the ceiling it runs into, because either limit can be reached first and neither can see what the other depends on. Measured at 10,000 lines of styled output: ~6 MB at 80 columns (the ceiling never bites), ~15 MB at 200 (2% of the lines), ~32 MB at 400 (half of them). No `unlimited`, deliberately: the ceiling is per pane, and a negative is refused rather than wrapped into eighteen quintillion lines. Read when a pane is created, so it applies to the next pane you open -- same rule as `shell`, because shrinking a pane's history retroactively throws away output somebody is reading. `--project-root` was considered and dropped: the config is the one place projects are declared, and tests pin a config like every other config-driven feature. Verified: 1752 checks, all green. 90 in `tests/test_workspaces.py` (the two markers, depth without descending into a project, two worktrees as two workspaces, idempotent open, the honoured-purpose count, the save/close/open round trip byte-for-byte, the set-up-by-hand-and-write-it-down flow, the picker, and every verb's bare form); 53 in `tests/test_portable_layouts.py` (relative and absolute `cwd=`, text having no base, per-tab focus, the four `suspend` words, captured commands, an idle shell and a background job captured as nothing, spaces surviving the round trip, label precedence, and the checker's lists not going stale); 21 in `tests/test_purpose_editor.py`; 14 in `tests/test_scrollback.py`; 5 more in `tests/test_tabs.py`. `tests/test_osc5577.py` had a vacuous check: `\033` through the driver's unescaper is NUL + "33", so "a pane cannot relabel a declared purpose" passed without the sequence ever arriving. It uses `emit_cmd` now and has a positive control that the same sequence lands once the purpose is cleared. Three cheatsheet tests were pinned to terminal sizes that exactly fitted the old sheet, which four new bindings pushed past. They now ask for room; truncation has its own test.
Diffstat (limited to 'src')
-rw-r--r--src/app.c926
-rw-r--r--src/app.h122
-rw-r--r--src/cmd.c184
-rw-r--r--src/config.c114
-rw-r--r--src/config.h37
-rw-r--r--src/main.c43
-rw-r--r--src/pane.c102
-rw-r--r--src/path.c57
-rw-r--r--src/project.c155
-rw-r--r--src/project.h64
-rw-r--r--src/sl0ppty.h27
11 files changed, 1740 insertions, 91 deletions
diff --git a/src/app.c b/src/app.c
index c527532..a792ade 100644
--- a/src/app.c
+++ b/src/app.c
@@ -324,7 +324,18 @@ struct app {
* rather than two that would drift apart, for the same reason the rename
* editor is. Only one can be open, which is what makes sharing the query
* and the selection honest rather than a saving. */
- enum { PICK_NONE = 0, PICK_FINDER, PICK_PALETTE } picker;
+ enum {
+ PICK_NONE = 0,
+ PICK_FINDER,
+ PICK_PALETTE,
+ PICK_WORKSPACES
+ } picker;
+ /* What the project picker is listing: scanned once when it opens, because
+ * draw_picker asks for its rows every frame and a readdir per repaint is a
+ * filesystem walk at 120Hz. Once per opening is when the answer has to be
+ * right, which is why nothing is kept for longer. */
+ project_t *projects;
+ size_t nprojects;
char query[64];
size_t sel;
@@ -332,7 +343,7 @@ struct app {
* where it will live rather than in a dialog somewhere else. A pane's title
* and a tab's label are the same gesture on two different things, so this is
* one editor with a subject rather than two machines that would drift. */
- enum { RENAME_NONE = 0, RENAME_PANE, RENAME_TAB } renaming;
+ enum { RENAME_NONE = 0, RENAME_PANE, RENAME_TAB, RENAME_PURPOSE } renaming;
uint32_t rename_id;
/* As wide as a pane title, so seeding the editor never truncates one (and so
* never truncates one mid-UTF-8). */
@@ -351,6 +362,14 @@ struct app {
/* Which tab a layout said was active, resolved after every tab exists --
* the index is only meaningful once the array has stopped growing. */
size_t restore_tab;
+
+ /* Set for the duration of one app_apply_layout: every pane it builds starts
+ * suspended whatever the file said. `open-workspace suspended:true` is the
+ * "open ten projects, run zero processes" case, which is a different question
+ * from the one a project's layout answered about which of *its* panes are
+ * expensive -- and it has to be decided before anything spawns, because a pane
+ * is created suspended or created running and there is no un-running one. */
+ bool force_suspend;
const char *const *argv;
/* What this session is called. The server knows it because it opened the
* socket under that name; the app only knows it because it is worth saying
@@ -622,6 +641,7 @@ static const char *hint_for(app_t *a, const char *action) {
if (strncmp(action, "corner:", 7) == 0) return "drag to resize both ways";
if (strncmp(action, "focus:", 6) == 0) return "open this pane";
if (strncmp(action, "find:", 5) == 0) return "go to this pane";
+ if (strncmp(action, "open:", 5) == 0) return "go to this project";
/* Not "click to switch": that is the one of the three nobody needs telling.
* Same rule as a pane's title, which advertises the rename and the drag. */
if (strncmp(action, "tab:", 4) == 0)
@@ -895,6 +915,10 @@ static node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd,
pane_set_clipboard_handler(p, on_pane_clipboard, a);
pane_set_notify_handler(p, on_pane_notify, a);
pane_set_cell_px(p, a->cell_w, a->cell_h);
+ /* Every pane is made here, so this is the one place history has to be sized.
+ * lib-vt's own default is 10,000 bytes -- 622 lines of an 80-column pane --
+ * which is not a number anybody chose. */
+ pane_set_scrollback(p, CFG.scrollback, CFG.scrollback_bytes);
return n;
}
@@ -914,19 +938,17 @@ static const char *const *default_argv(app_t *a) {
return argv;
}
+/* Where the program in a pane *is*, which after any amount of `cd` is not where
+ * it was started. Defined with the dumper, declared here because a split wants
+ * it too: a new pane opens where the one it came out of stands. */
+static const char *live_cwd(const pane_t *p, char *buf, size_t cap);
+
+/* A plain pane running the session's shell. One line, because everything a pane
+ * needs setting up is in leaf_new_ex and a second copy of that list is a second
+ * place to forget something -- which is exactly how panes came to have every
+ * handler attached and no history limit. */
static node_t *leaf_new(app_t *a) {
- pane_t *p = pane_new(default_argv(a), 1, 1, NULL);
- if (!p) return NULL;
- node_t *n = calloc(1, sizeof *n);
- n->kind = NODE_LEAF;
- n->weight = WEIGHT_UNIT;
- n->pane = p;
- n->id = ++a->next_id;
- pane_set_osc_handler(p, on_pane_osc, a);
- pane_set_clipboard_handler(p, on_pane_clipboard, a);
- pane_set_notify_handler(p, on_pane_notify, a);
- pane_set_cell_px(p, a->cell_w, a->cell_h);
- return n;
+ return leaf_new_ex(a, default_argv(a), NULL, false, "");
}
/* Empty a chain, freeing the programs it owned. */
@@ -1005,6 +1027,7 @@ void app_free(app_t *a) {
gfx_free(a->gfx);
free(a->clipboard);
free(a->clipboard_pending);
+ free(a->projects);
for (size_t i = 0; i < a->ntabs; i++) node_free(a->tabs[i].root);
free(a->tabs);
free(a);
@@ -1556,7 +1579,14 @@ 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);
+ /* A new pane opens where the pane it came out of *is*, not where the session
+ * was started. Splitting inside a project and landing in whatever directory
+ * the server happened to be launched from is wrong on its own, and it made a
+ * saved project layout carry an absolute path to somewhere else entirely. */
+ char cwdbuf[4096];
+ const char *cwd = live_cwd(leaf->pane, cwdbuf, sizeof cwdbuf);
+ node_t *fresh = argv ? leaf_new_ex(a, argv, cwd, false, label)
+ : leaf_new_ex(a, default_argv(a), cwd, false, "");
if (!fresh) return NULL;
place_beside(a, cur(a), leaf, fresh, dir, before);
@@ -1811,29 +1841,50 @@ bool app_close_tab(app_t *a, uint32_t id) {
/* D8's trust model: a purpose declared by a layout outranks an in-band one and
* cannot be overridden, so `cat hostile.txt` in a pane cannot relabel a
- * project tab. `declared` is only ever true on the control path. */
+ * project tab. `declared` is only ever true on the control path.
+ *
+ * Clearing a declared purpose *unlocks* it. Setting it to nothing means "this
+ * pane has no purpose of mine", and a lock held over an empty string would be
+ * the one state nobody can get out of -- the program can no longer label
+ * itself and there is nothing there to have outranked it. Same shape as
+ * clearing a pane's name, which hands the title back to the program. */
+static void set_purpose(char *slot, size_t cap, bool *locked,
+ const char *purpose, bool declared) {
+ sanitise_purpose(purpose, slot, cap);
+ if (declared) *locked = slot[0] != 0;
+}
+
+/* `id` 0 is the focused pane, and the tab you are looking at, the way it is
+ * everywhere else a verb takes one (`close`, `rerun`, `clear-shaders`). It was
+ * the one addressable thing that refused it, which read as "that purpose is
+ * not allowed" rather than "say which pane". */
bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose,
bool declared) {
- struct byid b = {id, NULL};
- walk_all(a, byid_cb, &b);
- if (!b.found) return false;
- if (b.found->purpose_locked && !declared) return false;
- sanitise_purpose(purpose, b.found->purpose, sizeof b.found->purpose);
- if (declared) b.found->purpose_locked = true;
+ node_t *found = NULL;
+ if (!id) {
+ found = cur(a)->focus;
+ } else {
+ struct byid b = {id, NULL};
+ walk_all(a, byid_cb, &b);
+ found = b.found;
+ }
+ if (!found) return false;
+ if (found->purpose_locked && !declared) return false;
+ set_purpose(found->purpose, sizeof found->purpose, &found->purpose_locked,
+ purpose, declared);
return true;
}
bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose,
bool declared) {
- for (size_t i = 0; i < a->ntabs; i++) {
- tab_t *t = &a->tabs[i];
- if (t->id != id) continue;
- if (t->purpose_locked && !declared) return false;
- sanitise_purpose(purpose, t->purpose, sizeof t->purpose);
- if (declared) t->purpose_locked = true;
- return true;
- }
- return false;
+ tab_t *t = id ? NULL : cur(a);
+ for (size_t i = 0; !t && i < a->ntabs; i++)
+ if (a->tabs[i].id == id) t = &a->tabs[i];
+ if (!t) return false;
+ if (t->purpose_locked && !declared) return false;
+ set_purpose(t->purpose, sizeof t->purpose, &t->purpose_locked, purpose,
+ declared);
+ return true;
}
static tab_t *tab_by_id(app_t *a, uint32_t id) {
@@ -2195,15 +2246,24 @@ uint32_t app_current_tab_id(app_t *a) { return a->ntabs ? cur(a)->id : 0; }
* A `pane` with children is a split; `split` on it picks the direction. Every
* purpose a layout declares is locked, which is the point: identity comes from
* the layout, not from whatever the program inside decides to print (D8).
+ *
+ * `base` is the directory the file came from, and a relative `cwd=` resolves
+ * against it, so a layout can be checked in beside the project it describes
+ * (path_resolve). A layout that arrived as text has no base and keeps the old
+ * meaning: relative to wherever the session is.
*/
-static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd) {
- /* Expanded once here, so both the pane spawned from this node and every
+static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd,
+ const char *base) {
+ /* Resolved once here, so both the pane spawned from this node and every
* child that inherits the value get the same real directory. The buffer
- * outlives the recursion below it: children finish before we return. */
+ * outlives the recursion below it: children finish before we return. An
+ * inherited value is already resolved, so it is passed through untouched --
+ * resolving it twice would re-root a path against itself. */
char cwdbuf[1024];
- const char *node_cwd =
- path_expand(kdl_prop(node, "cwd", cwd), cwdbuf, sizeof cwdbuf);
+ const char *own = kdl_prop(node, "cwd", NULL);
+ const char *node_cwd = own ? path_resolve(own, base, cwdbuf, sizeof cwdbuf)
+ : cwd;
/* a split: children, in order, in one direction */
size_t kids = 0;
@@ -2222,7 +2282,7 @@ static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd) {
: SPLIT_COLS;
for (size_t i = 0; i < node->nkids; i++) {
if (strcmp(node->kids[i]->name, "pane") != 0) continue;
- node_t *kid = build_pane(a, node->kids[i], node_cwd);
+ node_t *kid = build_pane(a, node->kids[i], node_cwd, base);
if (!kid) continue;
sp->kids = realloc(sp->kids, (sp->nkids + 1) * sizeof *sp->kids);
sp->kids[sp->nkids++] = kid;
@@ -2243,7 +2303,7 @@ static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd) {
}
const char *command = kdl_prop(node, "command", NULL);
- bool suspended = kdl_prop_bool(node, "suspended", false);
+ bool suspended = a->force_suspend || kdl_prop_bool(node, "suspended", false);
const char *argv[4];
if (command) {
argv[0] = "/bin/sh";
@@ -2251,10 +2311,10 @@ static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd) {
argv[2] = command;
argv[3] = NULL;
} else {
- const char *const *base = default_argv(a);
- argv[0] = base[0];
+ const char *const *shell = default_argv(a);
+ argv[0] = shell[0];
argv[1] = NULL;
- for (size_t i = 1; base[i] && i < 3; i++) argv[i] = base[i];
+ for (size_t i = 1; shell[i] && i < 3; i++) argv[i] = shell[i];
}
node_t *leaf = leaf_new_ex(a, command ? argv : default_argv(a), node_cwd,
@@ -2274,8 +2334,8 @@ static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd) {
return leaf;
}
-bool app_apply_layout(app_t *a, const kdl_node_t *root, bool replace, char *err,
- size_t errcap) {
+bool app_apply_layout(app_t *a, const kdl_node_t *root, bool replace,
+ const char *base, char *err, size_t errcap) {
a->restore_tab = (size_t)-1;
const kdl_node_t *lay = kdl_child(root, "layout");
if (!lay) lay = root; /* allow a bare list of tabs */
@@ -2294,11 +2354,14 @@ bool app_apply_layout(app_t *a, const kdl_node_t *root, bool replace, char *err,
tab->purpose_locked = true;
}
- /* the tab body is a split of its pane children */
+ /* The tab body is a split of its pane children, and the tab's own props
+ * are its root's -- including `cwd`, which build_pane reads off the body
+ * for itself. `base` as the inherited value is what makes a layout with no
+ * `cwd` anywhere in it start in the directory it was checked into. */
kdl_node_t body = {.name = (char *)"pane", .kids = t->kids,
.nkids = t->nkids, .props = t->props,
.nprops = t->nprops};
- node_t *tree = build_pane(a, &body, kdl_prop(t, "cwd", NULL));
+ node_t *tree = build_pane(a, &body, base, base);
if (!tree) tree = leaf_new(a);
if (!tree) {
a->ntabs--;
@@ -2357,24 +2420,475 @@ bool app_apply_layout(app_t *a, const kdl_node_t *root, bool replace, char *err,
return true;
}
-bool app_apply_layout_text(app_t *a, const char *text, bool replace, char *err,
- size_t errcap) {
+bool app_apply_layout_text_at(app_t *a, const char *text, bool replace,
+ const char *base, char *err, size_t errcap) {
kdl_node_t *root = kdl_parse(text, err, errcap);
if (!root) return false;
- bool ok = app_apply_layout(a, root, replace, err, errcap);
+ bool ok = app_apply_layout(a, root, replace, base, err, errcap);
kdl_free(root);
return ok;
}
+bool app_apply_layout_text(app_t *a, const char *text, bool replace, char *err,
+ size_t errcap) {
+ /* No base: text has no directory it came from, so a relative `cwd=` in it
+ * keeps meaning what it meant before there was a base to be relative to. */
+ return app_apply_layout_text_at(a, text, replace, NULL, err, errcap);
+}
+
bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err,
size_t errcap) {
- kdl_node_t *root = kdl_parse_file(path, err, errcap);
+ /* Expanded here rather than left to a shell: `--layout "~/x.layout.kdl"` in
+ * quotes, and every path arriving over the control socket, reach fopen with
+ * the tilde still on them. */
+ char pathbuf[1024], dirbuf[1024];
+ const char *file = path_expand(path, pathbuf, sizeof pathbuf);
+ kdl_node_t *root = kdl_parse_file(file, err, errcap);
if (!root) return false;
- bool ok = app_apply_layout(a, root, replace, err, errcap);
+ bool ok = app_apply_layout(a, root, replace,
+ path_dir(file, dirbuf, sizeof dirbuf), err, errcap);
kdl_free(root);
return ok;
}
+/* ---- checking one -------------------------------------------------------- *
+ *
+ * The other half of D2's promise: a document says which one it is, and a name
+ * the loader does not read is reported rather than skipped. build_pane() above
+ * asks for props by name and ignores the rest, which is right for loading --
+ * a session should start -- and useless for the person who wrote `cmd=` where
+ * `command=` was meant and got a shell.
+ *
+ * These lists sit next to the code that reads them on purpose, and
+ * `tests/test_layout_check.py` greps this file for every `kdl_prop*(node, ...)`
+ * the layout section asks for and fails if one is missing here -- the same
+ * arrangement that keeps KNOWN_TOP honest in config.c.
+ */
+
+/* Read by build_pane, on a `pane` node or on a tab acting as its own root. */
+static const char *const PANE_PROPS[] = {"split", "weight", "cwd",
+ "command", "focus", "purpose",
+ "suspended"};
+/* Read by app_apply_layout off the tab itself. */
+static const char *const TAB_PROPS[] = {"name", "active"};
+
+static bool in_list(const char *const *list, size_t n, const char *name) {
+ for (size_t i = 0; i < n; i++)
+ if (strcmp(list[i], name) == 0) return true;
+ return false;
+}
+
+#define NELEM(x) (sizeof(x) / sizeof(*(x)))
+
+typedef struct {
+ const char *file;
+ layout_msg_t *msgs;
+ size_t max, n, dropped;
+} lcheck_t;
+
+static void lc_say(lcheck_t *c, int line, const char *fmt, ...) {
+ if (c->n >= c->max) {
+ c->dropped++;
+ return;
+ }
+ char text[160];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(text, sizeof text, fmt, ap);
+ va_end(ap);
+ snprintf(c->msgs[c->n++], sizeof *c->msgs, "%s:%d: %s", c->file, line, text);
+}
+
+/* `true`/`false` and nothing else: kdl_prop_bool falls back silently, so
+ * `suspended=yes` is a pane that quietly starts. */
+static void lc_bool(lcheck_t *c, const kdl_node_t *n, const char *key) {
+ const char *v = kdl_prop(n, key, NULL);
+ if (v && strcmp(v, "true") != 0 && strcmp(v, "false") != 0)
+ lc_say(c, n->line, "%s takes true or false, not `%s`", key, v);
+}
+
+static void lc_props(lcheck_t *c, const kdl_node_t *n, bool is_tab) {
+ size_t kids = 0;
+ for (size_t i = 0; i < n->nkids; i++)
+ if (strcmp(n->kids[i]->name, "pane") == 0) kids++;
+
+ for (size_t i = 0; i < n->nprops; i++) {
+ const char *k = n->props[i].key;
+ bool known = in_list(PANE_PROPS, NELEM(PANE_PROPS), k) ||
+ (is_tab && in_list(TAB_PROPS, NELEM(TAB_PROPS), k));
+ if (!known) {
+ lc_say(c, n->line, "unknown %s property: %s", is_tab ? "tab" : "pane", k);
+ continue;
+ }
+ /* A split reads `split=` and its children; a leaf reads what it runs. The
+ * wrong half is dropped without a word, which is how `purpose=` on a split
+ * comes to tag nothing at all. A tab reads its own name and purpose either
+ * way, so those are never the ignored ones. */
+ bool leaf_only = strcmp(k, "command") == 0 || strcmp(k, "suspended") == 0 ||
+ strcmp(k, "focus") == 0 ||
+ (!is_tab && strcmp(k, "purpose") == 0);
+ if (kids && leaf_only)
+ lc_say(c, n->line, "%s is ignored on a %s with panes in it", k,
+ is_tab ? "tab" : "pane");
+ if (!kids && strcmp(k, "split") == 0)
+ lc_say(c, n->line, "split is ignored on a pane with nothing to split");
+ }
+
+ const char *dir = kdl_prop(n, "split", NULL);
+ if (dir && strcmp(dir, "cols") != 0 && strcmp(dir, "rows") != 0)
+ lc_say(c, n->line, "split is cols or rows, not `%s`", dir);
+
+ const char *w = kdl_prop(n, "weight", NULL);
+ if (w) {
+ char *end = NULL;
+ long v = strtol(w, &end, 10);
+ if (!end || *end || v < WEIGHT_MIN)
+ lc_say(c, n->line, "weight is a number of %d or more, not `%s`",
+ WEIGHT_MIN, w);
+ }
+
+ lc_bool(c, n, "suspended");
+ lc_bool(c, n, "focus");
+ if (is_tab) lc_bool(c, n, "active");
+
+ for (size_t i = 0; i < n->nkids; i++) {
+ const kdl_node_t *k = n->kids[i];
+ if (strcmp(k->name, "pane") != 0) {
+ lc_say(c, k->line, "a %s holds panes, not `%s`", is_tab ? "tab" : "pane",
+ k->name);
+ continue;
+ }
+ lc_props(c, k, false);
+ }
+}
+
+size_t layout_check(const kdl_node_t *root, const char *file,
+ layout_msg_t *msgs, size_t max, size_t *dropped) {
+ lcheck_t c = {.file = file, .msgs = msgs, .max = max};
+ const kdl_node_t *lay = kdl_child(root, "layout");
+ size_t tabs = 0;
+
+ for (size_t i = 0; i < root->nkids; i++) {
+ const kdl_node_t *n = root->kids[i];
+ if (!n || !n->name) continue;
+ if (lay && n == lay) continue;
+ if (strcmp(n->name, "tab") == 0) continue; /* a bare list of tabs is legal */
+ if (config_is_setting(n->name)) {
+ lc_say(&c, n->line, "this is a config, not a layout: `%s` is a setting",
+ n->name);
+ continue;
+ }
+ lc_say(&c, n->line, "unknown node: %s", n->name);
+ }
+
+ if (!lay) lay = root;
+ for (size_t i = 0; i < lay->nkids; i++) {
+ const kdl_node_t *n = lay->kids[i];
+ if (!n || !n->name) continue;
+ if (strcmp(n->name, "tab") != 0) {
+ if (lay != root) lc_say(&c, n->line, "a layout holds tabs, not `%s`", n->name);
+ continue;
+ }
+ tabs++;
+ lc_props(&c, n, true);
+ }
+
+ /* Last, so it reads as the summary it is rather than the first surprise. */
+ if (!tabs) lc_say(&c, lay->line, "this layout declares no tabs");
+ if (dropped) *dropped = c.dropped;
+ return c.n;
+}
+
+size_t layout_check_file(const char *path, layout_msg_t *msgs, size_t max,
+ size_t *dropped) {
+ char pathbuf[1024];
+ const char *file = path_expand(path, pathbuf, sizeof pathbuf);
+ const char *base = strrchr(file, '/');
+ base = base ? base + 1 : file;
+
+ char err[256] = {0};
+ kdl_node_t *root = kdl_parse_file(file, err, sizeof err);
+ if (!root) {
+ /* kdl reports `line N: what`, with no filename because it never saw one,
+ * and `cannot open X` for the other kind of failure -- which carries its
+ * own path and no line. Same `file:line: text` shape as everything else
+ * either way, so an editor's compile step reads all of them. */
+ if (max) {
+ int line = 0;
+ if (sscanf(err, "line %d:", &line) == 1)
+ snprintf(msgs[0], sizeof *msgs, "%s:%d:%s", base, line,
+ strchr(err, ':') + 1);
+ else
+ snprintf(msgs[0], sizeof *msgs, "%s", err);
+ }
+ if (dropped) *dropped = 0;
+ return max ? 1 : 0;
+ }
+ size_t n = layout_check(root, base, msgs, max, dropped);
+ kdl_free(root);
+ return n;
+}
+
+/* ---- workspaces ---------------------------------------------------------- *
+ *
+ * A project is a directory (project.c). A workspace is the tab it occupies here.
+ * Membership is the tab's *purpose*, in the `project:` namespace -- the shape
+ * this repo has published as a project tab's identity since D3 -- so there is no
+ * second answer to "what is this tab" and a dumped layout restores membership
+ * for free, because a dump already writes tab purposes and apply-layout already
+ * locks them (D8).
+ *
+ * Opening is idempotent, which is `sl0ppi up`'s property carried over: the same
+ * request twice is one workspace, focused. Everything else follows from that --
+ * a script can drive it in a loop without asking first, and so can a keystroke.
+ */
+
+/* Which roots to scan and how deep, from the config in force. Read fresh each
+ * time rather than cached, because saving the config is allowed to change where
+ * projects live and a cache would answer with yesterday's ~/dev. */
+static const char *const *workspace_roots(int *depth) {
+ static const char *roots[PROJECT_ROOTS_MAX + 1];
+ size_t n = 0;
+ for (; n < CFG.nproject_roots && n < PROJECT_ROOTS_MAX; n++)
+ roots[n] = CFG.project_roots[n];
+ roots[n] = NULL;
+ if (depth) *depth = CFG.project_depth;
+ return roots;
+}
+
+bool app_project_roots_set(void) { return CFG.nproject_roots > 0; }
+
+size_t app_projects(project_t *out, size_t max) {
+ int depth = 2;
+ const char *const *roots = workspace_roots(&depth);
+ return project_scan(roots, depth, out, max);
+}
+
+/* The first tab holding this workspace, or NULL: a workspace whose layout
+ * declared several tabs has several, and the first is the one to land in. */
+static tab_t *workspace_tab(app_t *a, const char *slug) {
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (strcmp(a->tabs[i].purpose, slug) == 0) return &a->tabs[i];
+ return NULL;
+}
+
+uint32_t app_workspace_tab(app_t *a, const char *slug) {
+ tab_t *t = workspace_tab(a, slug);
+ return t ? t->id : 0;
+}
+
+/* Make every tab this apply just built a member of the workspace.
+ *
+ * A tab the layout gave a purpose of its own keeps it and is not a member --
+ * honoured rather than overwritten, because overwriting a declared purpose is
+ * the one thing D8 forbids. The count comes back so the caller can say so
+ * instead of it being a silent surprise. */
+static void adopt_tabs(app_t *a, size_t from, const project_t *p,
+ app_workspace_open_t *out) {
+ for (size_t i = from; i < a->ntabs; i++) {
+ tab_t *t = &a->tabs[i];
+ if (t->purpose[0] && strncmp(t->purpose, "project:", 8) != 0) {
+ out->honoured++;
+ continue;
+ }
+ sanitise_purpose(p->slug, t->purpose, sizeof t->purpose);
+ t->purpose_locked = true;
+ /* A tab with no name of its own takes the project's: a strip reading
+ * `1 2 3` is not navigation, which is the whole complaint workspaces
+ * answer. */
+ if (!t->name[0]) snprintf(t->name, sizeof t->name, "%s", p->name);
+ if (!out->tab) out->tab = t->id;
+ out->tabs++;
+ }
+}
+
+bool app_workspace_find(const char *name, project_t *out) {
+ int depth = 2;
+ const char *const *roots = workspace_roots(&depth);
+ return project_find(roots, depth, name, out);
+}
+
+bool app_workspace_open(app_t *a, const char *name, bool suspended,
+ app_workspace_open_t *out, char *err, size_t errcap) {
+ memset(out, 0, sizeof *out);
+ project_t p;
+ if (!app_workspace_find(name, &p)) {
+ snprintf(err, errcap,
+ CFG.nproject_roots ? "no project called %s"
+ : "no project roots: set project_roots in your "
+ "config (asked for %s)",
+ name);
+ return false;
+ }
+ snprintf(out->purpose, sizeof out->purpose, "%s", p.slug);
+ snprintf(out->path, sizeof out->path, "%s", p.path);
+
+ /* Already open: focus it. The same request twice is one workspace, and the
+ * answer says which branch it took so a caller never has to ask first. */
+ tab_t *have = workspace_tab(a, p.slug);
+ if (have) {
+ out->tab = have->id;
+ app_select_tab_id(a, have->id);
+ return true;
+ }
+
+ /* `suspended` has to be decided before anything spawns: a pane is created
+ * suspended or it is created running, and there is no un-running a process.
+ * Carried on the app for the duration of one apply, the way restore_focus and
+ * restore_tab already are, rather than widening two signatures for it. */
+ a->force_suspend = suspended;
+ size_t before = a->ntabs;
+ bool ok;
+ if (p.layout[0]) {
+ ok = app_apply_layout_file(a, p.layout, false, err, errcap);
+ } else if (CFG.project_layout) {
+ /* The base is the *project*, not the directory this file came from: the
+ * point of a shared project layout is that `cwd="."` means whichever
+ * project is being opened. Relative to itself it would open every project
+ * in ~/.config. */
+ char buf[1024];
+ kdl_node_t *root = kdl_parse_file(
+ path_expand(CFG.project_layout, buf, sizeof buf), err, errcap);
+ ok = root && app_apply_layout(a, root, false, p.path, err, errcap);
+ kdl_free(root);
+ } else {
+ /* No file anywhere: one pane, your shell, in the project. Which is the
+ * least a `.git` with nothing else in it can honestly be opened as. */
+ char kdl[256];
+ snprintf(kdl, sizeof kdl, "layout { tab { pane } }");
+ ok = app_apply_layout_text_at(a, kdl, false, p.path, err, errcap);
+ }
+ a->force_suspend = false;
+ if (!ok) return false;
+
+ adopt_tabs(a, before, &p, out);
+ out->created = true;
+ if (out->tab) app_select_tab_id(a, out->tab);
+ layout(a);
+ return true;
+}
+
+size_t app_workspace_close(app_t *a, const char *slug) {
+ size_t closed = 0;
+ /* Re-found each time rather than collected first: closing a tab moves every
+ * index after it, and a list of pointers taken beforehand would be stale by
+ * the second one. */
+ for (;;) {
+ tab_t *t = workspace_tab(a, slug);
+ if (!t || !app_close_tab(a, t->id)) break;
+ closed++;
+ }
+ return closed;
+}
+
+/* Which project a tab belongs to, or is standing in.
+ *
+ * Three answers in order: the path asked for, the project whose slug this tab
+ * already carries, and the directory the focused pane is in. All three are
+ * resolved by *scanning*, which is also the containment check -- a directory no
+ * root holds is one this session will not write a layout into, and that falls
+ * out of asking the same question the picker asks rather than being a rule of
+ * its own. */
+static bool tab_project(app_t *a, const tab_t *t, const char *path,
+ project_t *out) {
+ int depth = 2;
+ const char *const *roots = workspace_roots(&depth);
+ if (path && *path) return project_find(roots, depth, path, out);
+
+ if (strncmp(t->purpose, "project:", 8) == 0) {
+ project_t all[PROJECTS_MAX];
+ size_t n = project_scan(roots, depth, all, PROJECTS_MAX);
+ for (size_t i = 0; i < n; i++)
+ if (strcmp(all[i].slug, t->purpose) == 0) {
+ *out = all[i];
+ return true;
+ }
+ }
+
+ if (!t->focus) return false;
+ char cwdbuf[4096];
+ return project_find(roots, depth, live_cwd(t->focus->pane, cwdbuf, sizeof cwdbuf),
+ out);
+}
+
+bool app_workspace_save(app_t *a, uint32_t tab, const char *path, int suspend,
+ bool force, app_workspace_save_t *out, char *err,
+ size_t errcap) {
+ memset(out, 0, sizeof *out);
+ tab_t *t = tab ? tab_by_id(a, tab) : cur(a);
+ if (!t) {
+ snprintf(err, errcap, "no such tab");
+ return false;
+ }
+
+ project_t p;
+ if (!tab_project(a, t, path, &p)) {
+ snprintf(err, errcap, "this tab is not in a project root");
+ return false;
+ }
+ /* A tab that is already *a* workspace may only be saved into its own project.
+ * Saving it elsewhere would leave one tab claiming two projects -- named for
+ * one, carrying the other's purpose -- which is the second source of truth
+ * this whole design exists to avoid. Almost always it is a `path` typed while
+ * the wrong tab was focused, so it is said rather than obeyed. */
+ if (strncmp(t->purpose, "project:", 8) == 0 &&
+ strcmp(t->purpose, p.slug) != 0) {
+ snprintf(err, errcap,
+ "this tab is another project's workspace (%s): save it from its own"
+ " tab, or pass that tab's id",
+ t->purpose);
+ return false;
+ }
+ snprintf(out->path, sizeof out->path, "%s/%s", p.path, PROJECT_LAYOUT_FILE);
+ out->replaced = p.layout[0] != 0;
+ if (out->replaced && !force) {
+ snprintf(err, errcap, "%s already has a layout: pass force to replace it",
+ p.name);
+ return false;
+ }
+
+ dump_layout_t o = {.tab = t->id, .base = p.path, .suspend = suspend,
+ .for_project = true};
+ char *kdl = app_dump_layout(a, &o);
+ if (!kdl || !o.tabs) {
+ free(kdl);
+ snprintf(err, errcap, "nothing to write");
+ return false;
+ }
+ FILE *f = fopen(out->path, "w");
+ if (!f) {
+ free(kdl);
+ snprintf(err, errcap, "cannot write %s", out->path);
+ return false;
+ }
+ /* One line of provenance, and the thing to run when it stops working. No
+ * timestamp, for the same reason the dump has none: a file that differs every
+ * time is a bad diff, and this one is meant to be committed. */
+ fputs("// What this project needs open, written by `save-workspace`.\n"
+ "// Checked by `sl0ppty --check`; opened by `open-workspace`.\n",
+ f);
+ fputs(kdl, f);
+ bool wrote = fclose(f) == 0;
+ free(kdl);
+ if (!wrote) {
+ snprintf(err, errcap, "cannot write %s", out->path);
+ return false;
+ }
+
+ out->panes = o.panes;
+ out->suspended = o.suspended;
+ /* Saving is also adopting: the tab that wrote the file is that project's
+ * workspace from now on, so `C-a W` in a tab you happened to build in a
+ * checkout is the whole of onboarding one. */
+ if (!t->purpose[0] || strncmp(t->purpose, "project:", 8) == 0) {
+ sanitise_purpose(p.slug, t->purpose, sizeof t->purpose);
+ t->purpose_locked = true;
+ if (!t->name[0]) snprintf(t->name, sizeof t->name, "%s", p.name);
+ }
+ snprintf(out->purpose, sizeof out->purpose, "%s", t->purpose);
+ return true;
+}
+
/* ---- resizing and reordering -------------------------------------------- */
static size_t index_in_parent(node_t *n) {
@@ -2960,11 +3474,18 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
}
const char *title = pane_title(leaf->pane);
- bool editing = a->renaming == RENAME_PANE && a->rename_id == leaf->id;
+ bool naming = a->renaming == RENAME_PANE && a->rename_id == leaf->id;
+ bool tagging = a->renaming == RENAME_PURPOSE && a->rename_id == leaf->id;
+ bool editing = naming || tagging;
if ((editing || (title && *title)) && avail >= 3) {
char buf[320]; /* a full-length name, plus the caret and its spaces */
- int len = editing ? snprintf(buf, sizeof buf, " %s\u2588 ", a->rename_buf)
- : snprintf(buf, sizeof buf, " %s ", title);
+ /* The purpose editor says which label it is: typed into the same cell as a
+ * rename, an unlabelled caret would leave you guessing which one you are
+ * changing -- and the two are edited from keys one shift apart. */
+ int len = tagging
+ ? snprintf(buf, sizeof buf, " purpose %s\u2588 ", a->rename_buf)
+ : naming ? snprintf(buf, sizeof buf, " %s\u2588 ", a->rename_buf)
+ : snprintf(buf, sizeof buf, " %s ", title);
/* snprintf reports what it *would* have written; clamp to what it did, or
* the scroll below would move bytes that are not there. */
if (len < 0) len = 0;
@@ -3991,8 +4512,9 @@ typedef struct {
} help_row_t;
static size_t help_rows(help_row_t *out, size_t cap) {
- static const char *const GROUPS[] = {"panes", "focus", "size", "tabs",
- "scroll", "session"};
+ static const char *const GROUPS[] = {"panes", "focus", "size",
+ "tabs", "projects", "scroll",
+ "session"};
size_t n = 0;
for (size_t g = 0; g < sizeof GROUPS / sizeof *GROUPS && n < cap; g++) {
bool titled = false;
@@ -4386,8 +4908,61 @@ static size_t palette_rows(app_t *a, pick_row_t *out, size_t max) {
return n;
}
+/* ---- the project picker --------------------------------------------------
+ *
+ * The third subject, and the one that answers a different question from the
+ * other two: the finder searches what *exists* in this session, while this
+ * lists what exists on *disk* -- including the projects with no workspace open
+ * yet, which is most of them and the whole point.
+ *
+ * The scan happens once, when the picker opens, into a->projects. Not per frame:
+ * draw_picker asks for its rows every frame, and a readdir of ~/dev at 120Hz
+ * would be a filesystem walk per repaint. Once per opening is also exactly when
+ * the answer has to be right, so nothing is remembered for longer than that.
+ */
+static size_t workspace_rows(app_t *a, pick_row_t *out, size_t max) {
+ size_t n = 0;
+ for (size_t i = 0; i < a->nprojects && n < max; i++) {
+ const project_t *p = &a->projects[i];
+ uint32_t tab = app_workspace_tab(a, p->slug);
+ /* Matched on the name, the path and what kind it is, so `.git` narrows to
+ * the ones with no layout yet -- which is the list you want when you are
+ * about to write one. */
+ char hay[640];
+ snprintf(hay, sizeof hay, "%s %s %s", p->name, p->path,
+ p->layout[0] ? PROJECT_LAYOUT_FILE : ".git");
+ if (!ci_contains(hay, a->query)) continue;
+
+ pick_row_t *r = &out[n++];
+ memset(r, 0, sizeof *r);
+ /* The root it came from rather than the whole path: which of `~/dev` and
+ * `~/work` a project is in is the part that distinguishes two of the same
+ * name, and the rest is the name again. */
+ const char *slash = strrchr(p->path, '/');
+ size_t keep = slash ? (size_t)(slash - p->path) : strlen(p->path);
+ const char *root_end = p->path + keep;
+ const char *root_start = root_end;
+ while (root_start > p->path && root_start[-1] != '/') root_start--;
+ snprintf(r->left, sizeof r->left, "%.*s",
+ (int)(root_end - root_start), root_start);
+ snprintf(r->mid, sizeof r->mid, "%s", p->name);
+ /* What it is, or what it already is: an open workspace says how many panes
+ * it has, and a project with no layout says so, which reads as the
+ * invitation it is. */
+ if (tab)
+ snprintf(r->right, sizeof r->right, "%s", p->slug);
+ else
+ snprintf(r->right, sizeof r->right, "%s",
+ p->layout[0] ? PROJECT_LAYOUT_FILE : ".git \u00b7 no layout");
+ r->here = tab != 0 && tab == cur(a)->id;
+ snprintf(r->action, sizeof r->action, "open:%zu", i);
+ }
+ return n;
+}
+
static size_t picker_rows(app_t *a, pick_row_t *out, size_t max) {
if (a->picker == PICK_PALETTE) return palette_rows(a, out, max);
+ if (a->picker == PICK_WORKSPACES) return workspace_rows(a, out, max);
return finder_rows(a, out, max);
}
@@ -4412,8 +4987,10 @@ static void draw_picker(app_t *a, screen_t *s) {
if (a->sel >= n) a->sel = n ? n - 1 : 0;
bool palette = a->picker == PICK_PALETTE;
- const char *title = palette ? "commands" : "find";
- const char *close = palette ? "closepalette" : "closefind";
+ bool projects = a->picker == PICK_WORKSPACES;
+ const char *title = palette ? "commands" : projects ? "projects" : "find";
+ const char *close = palette ? "closepalette"
+ : projects ? "closeprojects" : "closefind";
/* The window of results on screen. Kept around the selection rather than
* anchored at the top, so arrowing past the bottom scrolls instead of
@@ -4587,6 +5164,20 @@ static void picker_accept(app_t *a, const char *action) {
app_focus_pane(a, (uint32_t)strtoul(action + 5, NULL, 10));
else if (strncmp(action, "run:", 4) == 0)
run_action(a, (action_t)strtol(action + 4, NULL, 10));
+ else if (strncmp(action, "open:", 5) == 0) {
+ /* By index into the snapshot the picker is showing, so the row that was
+ * clicked is the project that opens even if the disk has changed since --
+ * a scan between the paint and the click would otherwise open its
+ * neighbour. */
+ size_t i = (size_t)strtoul(action + 5, NULL, 10);
+ if (i >= a->nprojects) return;
+ app_workspace_open_t w;
+ char err[256] = {0};
+ if (app_workspace_open(a, a->projects[i].path, false, &w, err, sizeof err))
+ app_toast(a, w.created ? "opened" : "already open");
+ else
+ app_toast(a, err[0] ? err : "cannot open that");
+ }
}
/* Move the selection by `d`, wrapping.
@@ -4720,6 +5311,25 @@ static void rename_begin(app_t *a, uint32_t id) {
app_focus_pane(a, id);
}
+/* The same editor, on the pane's other label. A purpose is what tooling finds a
+ * pane by (D8), and until now it could only be declared by a layout or set over
+ * the socket -- so every pane anybody arranged by hand had none, and a layout
+ * dumped from one was a shape with no tags in it. Typed here it counts as
+ * *declared*, because D8's `declared` means "from a layout or an operator" and
+ * a person at the keyboard is the operator. */
+static void purpose_begin(app_t *a, uint32_t id) {
+ node_t *n = pane_by_id(a, id);
+ if (!n) return;
+ a->renaming = RENAME_PURPOSE;
+ a->rename_id = id;
+ /* Seeded, unlike a tab's name: here the purpose *is* the thing being edited,
+ * and the usual edit is adding `:2` to the end of one. */
+ snprintf(a->rename_buf, sizeof a->rename_buf, "%s", n->purpose);
+ a->name_click_ms = 0;
+ a->name_click_id = 0;
+ app_focus_pane(a, id);
+}
+
static void rename_tab_begin(app_t *a, uint32_t id) {
tab_t *t = tab_by_id(a, id);
if (!t) return;
@@ -4740,7 +5350,11 @@ static void rename_end(app_t *a, bool keep) {
if (!a->renaming) return;
if (keep) {
bool done = false;
- if (a->renaming == RENAME_PANE) {
+ const char *what = "name";
+ if (a->renaming == RENAME_PURPOSE) {
+ what = "purpose";
+ done = app_set_pane_purpose(a, a->rename_id, a->rename_buf, true);
+ } else if (a->renaming == RENAME_PANE) {
node_t *n = pane_by_id(a, a->rename_id);
if (n) {
pane_set_name(n->pane, a->rename_buf);
@@ -4749,7 +5363,17 @@ static void rename_end(app_t *a, bool keep) {
} else {
done = app_set_tab_name(a, a->rename_id, a->rename_buf);
}
- if (done) app_toast(a, a->rename_buf[0] ? "renamed" : "name cleared");
+ if (done) {
+ char said[40];
+ snprintf(said, sizeof said, "%s %s", what,
+ a->rename_buf[0] ? "set" : "cleared");
+ /* "renamed" for a name, because that is the word for it; "purpose set"
+ * for the other, because "renamed" would be a lie about which label
+ * moved. */
+ app_toast(a, a->renaming == RENAME_PURPOSE
+ ? said
+ : (a->rename_buf[0] ? "renamed" : "name cleared"));
+ }
}
a->renaming = RENAME_NONE;
a->rename_id = 0;
@@ -5104,7 +5728,8 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
if (ev->maction == MOUSE_PRESS) app_new_tab(a, "");
return;
}
- if (strncmp(action, "find:", 5) == 0 || strncmp(action, "run:", 4) == 0) {
+ if (strncmp(action, "find:", 5) == 0 || strncmp(action, "run:", 4) == 0 ||
+ strncmp(action, "open:", 5) == 0) {
if (ev->maction != MOUSE_PRESS) return;
/* Closed first, so an action that opens something else -- the cheatsheet,
* or the palette again -- is not shut by the picker it came from. */
@@ -5112,7 +5737,8 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
picker_accept(a, action);
return;
}
- if (strcmp(action, "closefind") == 0 || strcmp(action, "closepalette") == 0) {
+ if (strcmp(action, "closefind") == 0 || strcmp(action, "closepalette") == 0 ||
+ strcmp(action, "closeprojects") == 0) {
if (ev->maction == MOUSE_PRESS) a->picker = PICK_NONE;
return;
}
@@ -5251,6 +5877,11 @@ static bool run_action(app_t *a, action_t act) {
case ACT_CLOSE_PANE:
if (cur(a)->focus) close_leaf(a, cur(a)->focus);
return true;
+ case ACT_SET_PURPOSE:
+ /* Nothing to tag in an empty tab, and a purpose belongs to a pane rather
+ * than to the space one would occupy. */
+ if (cur(a)->focus) purpose_begin(a, cur(a)->focus->id);
+ return true;
case ACT_RERUN:
/* The keyboard's half of a dead pane's [re-run] button. Refused on a
* live pane rather than restarting it: "run it again" would mean
@@ -5268,6 +5899,26 @@ static bool run_action(app_t *a, action_t act) {
case ACT_FOCUS_DOWN: focus_dir(a, 0, 1); return true;
case ACT_FOCUS_NEXT: focus_next(a); return true;
case ACT_NEW_TAB: app_new_tab(a, ""); return true;
+ case ACT_CLOSE_TAB: {
+ /* The last tab is refused rather than obeyed. `app_close_tab` ends the
+ * session when nothing is left, which is right for a *request* -- a script
+ * asking to close the only tab means it -- and a trap for a key: one that
+ * closes a tab four times and ends your session the fifth is a key you
+ * cannot press without counting first. `quit` is how you mean that, and it
+ * is one letter away. */
+ if (a->ntabs < 2) {
+ app_toast(a, "last tab: quit the session instead");
+ return true;
+ }
+ /* Said out loud with the count, because everything in it goes with it and
+ * the panes it kills are the ones you were not looking at. */
+ size_t n = count_leaves(cur(a)->root);
+ char said[64];
+ snprintf(said, sizeof said, "closed tab \u00b7 %zu pane%s", n,
+ n == 1 ? "" : "s");
+ if (app_close_tab(a, cur(a)->id)) app_toast(a, said);
+ return true;
+ }
case ACT_NEXT_TAB: app_cycle_tab(a, 1); return true;
case ACT_PREV_TAB: app_cycle_tab(a, -1); return true;
case ACT_RESIZE_LEFT: resize_focus(a, -1, 0); return true;
@@ -5310,6 +5961,44 @@ static bool run_action(app_t *a, action_t act) {
a->query[0] = 0;
a->sel = 0;
return true;
+ case ACT_WORKSPACES: {
+ /* Scanned here, once, and kept only while the picker is up: draw_picker
+ * asks for its rows every frame. Opening is also exactly when the list
+ * has to be right, which is why nothing older than this keystroke is
+ * ever shown. */
+ if (!app_project_roots_set()) {
+ app_toast(a, "no project roots: set project_roots in your config");
+ return true;
+ }
+ free(a->projects);
+ a->projects = calloc(PROJECTS_MAX, sizeof *a->projects);
+ a->nprojects = a->projects ? app_projects(a->projects, PROJECTS_MAX) : 0;
+ if (!a->nprojects) {
+ app_toast(a, "no projects under your project_roots");
+ return true;
+ }
+ a->picker = PICK_WORKSPACES;
+ a->query[0] = 0;
+ a->sel = 0;
+ return true;
+ }
+ case ACT_SAVE_WORKSPACE: {
+ app_workspace_save_t w;
+ char err[256] = {0};
+ /* `commands` rather than as-is: the pane running this morning's dev server
+ * should be in the file asleep, not started on every open. */
+ if (!app_workspace_save(a, 0, NULL, DUMP_SUSPEND_COMMANDS, true, &w, err,
+ sizeof err)) {
+ app_toast(a, err[0] ? err : "cannot save that");
+ return true;
+ }
+ char said[96];
+ snprintf(said, sizeof said, "%s %s \u00b7 %zu pane%s, %zu suspended",
+ w.replaced ? "replaced" : "wrote", PROJECT_LAYOUT_FILE, w.panes,
+ w.panes == 1 ? "" : "s", w.suspended);
+ app_toast(a, said);
+ return true;
+ }
case ACT_EDIT_CONFIG:
app_edit_config(a);
return true;
@@ -5570,8 +6259,10 @@ void app_event(app_t *a, const input_event_t *ev) {
if (a->picker && ev->maction != MOUSE_MOTION) {
bool own = action && (strncmp(action, "find:", 5) == 0 ||
strncmp(action, "run:", 4) == 0 ||
+ strncmp(action, "open:", 5) == 0 ||
strcmp(action, "closefind") == 0 ||
- strcmp(action, "closepalette") == 0);
+ strcmp(action, "closepalette") == 0 ||
+ strcmp(action, "closeprojects") == 0);
if (!own) {
if (ev->maction == MOUSE_PRESS) a->picker = PICK_NONE;
break;
@@ -5654,11 +6345,63 @@ static const char *live_cwd(const pane_t *p, char *buf, size_t cap) {
return pane_start_cwd(p); /* not running, or not Linux: what it was given */
}
+/* Everything one dump needs to know, so dump_node does not have to ask the app
+ * which tab it is in the middle of. The focused pane belongs to *its* tab, not
+ * to the one you happen to be looking at -- reading `cur(a)->focus` here meant
+ * a dump of three tabs restored the focus of one. */
+typedef struct {
+ const tab_t *tab;
+ const char *base;
+ int suspend;
+ size_t panes, suspended;
+} dumpctx_t;
+
+/* The command this pane is written back out with, or NULL for a plain shell.
+ *
+ * Two sources, in this order. The **label** is what a layout told this pane to
+ * run, and it outranks everything: it survives the program exiting (D14), and
+ * re-saving a project must not degrade `npm run dev` into whatever node's argv
+ * happens to look like this minute. Failing that, **what the pane's terminal is
+ * actually running** -- because a pane you split and typed a command into had
+ * nothing to say for itself and came back as a bare shell, which made setting a
+ * project up by hand and writing it down two different jobs instead of one.
+ *
+ * An ephemeral pane is a task that happened to be open when the dump was taken,
+ * and is written with no command either way: restoring it would reopen somebody's
+ * editor on a file they finished with. */
+static const char *dump_command(const node_t *n, char *buf, size_t cap) {
+ if (pane_ephemeral(n->pane)) return NULL;
+ const char *label = pane_label(n->pane);
+ if (label && *label) return label;
+ return pane_foreground(n->pane, buf, cap);
+}
+
+/* Whether this pane is written as one that has not started yet.
+ *
+ * `as-is` is the honest answer for a session dump: what is suspended now is
+ * suspended in the file. It is the wrong answer for a project's layout, where
+ * the pane running the dev server you started this morning would start one on
+ * every open -- which is the thing `suspended` exists to prevent. So a saved
+ * project defaults to `commands`: a pane that has a command is written asleep, a
+ * shell is not. Same distinction, and the same word, as `keep_dead`.
+ *
+ * "Has a command" is whatever the file is about to say, captured or declared, so
+ * the two cannot disagree -- a pane written with a `command=` and no
+ * `suspended=true` under this policy would start a dev server on every open. */
+static bool dump_suspended(const char *command, const node_t *n, int policy) {
+ switch (policy) {
+ case DUMP_SUSPEND_NONE: return false;
+ case DUMP_SUSPEND_ALL: return true;
+ case DUMP_SUSPEND_COMMANDS: return command && *command;
+ default: return pane_suspended(n->pane);
+ }
+}
+
/* `split=` belongs on the node that *has* the children -- that is where
* build_pane() reads it -- and a tab's own props are the props of its root,
* so a root split says so on the tab. Getting this backwards produces a file
* that loads without complaint and rebuilds the wrong tree. */
-static void dump_node(app_t *a, node_t *n, strbuf_t *b, int depth) {
+static void dump_node(node_t *n, strbuf_t *b, int depth, dumpctx_t *ctx) {
char pad[64];
int p = depth * 4 < 60 ? depth * 4 : 60;
memset(pad, ' ', (size_t)p);
@@ -5670,36 +6413,54 @@ static void dump_node(app_t *a, node_t *n, strbuf_t *b, int depth) {
if (n->kind == NODE_SPLIT) {
sb_add(b, " split=\"%s\" {\n", n->dir == SPLIT_ROWS ? "rows" : "cols");
- for (size_t i = 0; i < n->nkids; i++) dump_node(a, n->kids[i], b, depth + 1);
+ for (size_t i = 0; i < n->nkids; i++)
+ dump_node(n->kids[i], b, depth + 1, ctx);
sb_add(b, "%s}\n", pad);
return;
}
+ ctx->panes++;
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. 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));
+ /* Relative to the project when there is one, so the file is the same file on
+ * another machine. A directory outside the base stays absolute: it is not a
+ * fact about the project. */
+ sb_quoted(b, "cwd",
+ path_relative(live_cwd(n->pane, cwdbuf, sizeof cwdbuf), ctx->base));
+ char cmdbuf[4096];
+ const char *command = dump_command(n, cmdbuf, sizeof cmdbuf);
+ sb_quoted(b, "command", command);
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");
+ if (dump_suspended(command, n, ctx->suspend)) {
+ sb_add(b, " suspended=true");
+ ctx->suspended++;
+ }
+ if (n == ctx->tab->focus) sb_add(b, " focus=true");
sb_add(b, "\n");
}
-char *app_dump_layout(app_t *a) {
+char *app_dump_layout(app_t *a, dump_layout_t *o) {
+ dump_layout_t all = {0};
+ if (!o) o = &all;
+ o->tabs = o->panes = o->suspended = 0;
+
strbuf_t b = {0};
- sb_add(&b, "// sl0ppty session, dumped %s", "");
- b.len = 0; /* no timestamp: a layout that differs every time is a bad diff */
- sb_add(&b, "layout {\n");
+ sb_add(&b, "layout {\n"); /* no timestamp: a layout that differs every time
+ * is a bad diff */
for (size_t i = 0; i < a->ntabs; i++) {
tab_t *t = &a->tabs[i];
+ if (o->tab && t->id != o->tab) continue;
+ dumpctx_t ctx = {.tab = t, .base = o->base, .suspend = o->suspend};
sb_add(&b, " tab");
sb_quoted(&b, "name", t->name);
- sb_quoted(&b, "purpose", t->purpose);
- if (i == a->cur) sb_add(&b, " active=true");
+ /* The workspace's own purpose is derived from where the project is, so a
+ * file that lives there does not repeat it -- and a copy of the project
+ * elsewhere becomes its own workspace rather than claiming this one's. */
+ if (!(o->for_project && strncmp(t->purpose, "project:", 8) == 0))
+ sb_quoted(&b, "purpose", t->purpose);
+ /* Which tab you were looking at is a fact about a session. Asked for one
+ * tab, the answer is that tab, and `active` would be noise in a file
+ * checked in beside a project. */
+ if (!o->tab && i == a->cur) sb_add(&b, " active=true");
/* The tab's props are its root's props, so a root that is a split says
* which way it goes here rather than on a `pane` node of its own. */
if (t->root && t->root->kind == NODE_SPLIT)
@@ -5708,11 +6469,14 @@ char *app_dump_layout(app_t *a) {
if (t->root) {
if (t->root->kind == NODE_SPLIT)
for (size_t k = 0; k < t->root->nkids; k++)
- dump_node(a, t->root->kids[k], &b, 2);
+ dump_node(t->root->kids[k], &b, 2, &ctx);
else
- dump_node(a, t->root, &b, 2);
+ dump_node(t->root, &b, 2, &ctx);
}
sb_add(&b, " }\n");
+ o->tabs++;
+ o->panes += ctx.panes;
+ o->suspended += ctx.suspended;
}
sb_add(&b, "}\n");
return b.buf;
diff --git a/src/app.h b/src/app.h
index 5295750..4559a55 100644
--- a/src/app.h
+++ b/src/app.h
@@ -5,6 +5,8 @@
#ifndef SL0PPTY_APP_H
#define SL0PPTY_APP_H
+#include "kdl.h"
+#include "project.h"
#include "shader.h"
#include "sl0ppty.h"
@@ -85,7 +87,10 @@ bool app_set_tab_name(app_t *a, uint32_t id, const char *name);
bool app_move_tab(app_t *a, uint32_t id, size_t index);
/* Purposes (D8). `declared` means "from a layout/control API": it outranks an
- * in-band purpose and locks it, so a pane cannot relabel itself afterwards.
+ * in-band purpose and locks it, so a pane cannot relabel itself afterwards --
+ * except by being cleared, which unlocks the slot and hands the label back,
+ * because a lock over an empty string is a state nothing can escape.
+ * `id` 0 is the focused pane and the current tab, as everywhere else.
* Returns false when a locked purpose refuses an in-band change. */
bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose,
bool declared);
@@ -93,11 +98,87 @@ bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose,
bool declared);
/* Build tabs and panes from a KDL layout. `replace` drops what was there.
- * Purposes a layout declares are locked (D8). */
+ * Purposes a layout declares are locked (D8). A relative `cwd=` in a *file*
+ * resolves against that file's directory, so a project's layout can be checked
+ * in beside the project; text has no directory and keeps meaning what it said. */
bool app_apply_layout_text(app_t *a, const char *text, bool replace, char *err,
size_t errcap);
bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err,
size_t errcap);
+/* The same, with an explicit base -- for a layout whose text came from one place
+ * and whose relative paths mean another: a shared project layout in ~/.config
+ * opening a project in ~/dev. */
+bool app_apply_layout_text_at(app_t *a, const char *text, bool replace,
+ const char *base, char *err, size_t errcap);
+
+/* What a session would not honour in a layout document, one problem per entry,
+ * `file:line: text` -- the same shape `--check` prints for a config, because a
+ * project's layout is a file people edit by hand and `cmd=` where `command=`
+ * was meant is silently a shell. Returns how many it filled in; `dropped` is
+ * how many more there were, so a summary can say so. Neither allocates. */
+#define LAYOUT_MSGS_MAX 32
+typedef char layout_msg_t[192];
+size_t layout_check(const kdl_node_t *root, const char *file,
+ layout_msg_t *msgs, size_t max, size_t *dropped);
+size_t layout_check_file(const char *path, layout_msg_t *msgs, size_t max,
+ size_t *dropped);
+
+/* ---- workspaces ---------------------------------------------------------- *
+ *
+ * A project is a directory (project.h). A workspace is the tab it occupies in
+ * this session, and membership is that tab's `purpose` in the `project:`
+ * namespace -- so there is no second answer to "what is this tab", and a dumped
+ * layout restores membership for free because a dump already writes tab purposes
+ * and applying one already locks them (D8).
+ */
+
+/* Whether the config names anywhere to look. False means the feature is dormant
+ * and every verb below says so rather than answering with nothing. */
+bool app_project_roots_set(void);
+/* One project by name, or by a path under a root. False when no root holds it,
+ * which is also the containment check: a path this session will not write to. */
+bool app_workspace_find(const char *name, project_t *out);
+/* Every project the configured roots hold, sorted by name. */
+size_t app_projects(project_t *out, size_t max);
+/* The tab holding a workspace, or 0. */
+uint32_t app_workspace_tab(app_t *a, const char *slug);
+
+typedef struct {
+ uint32_t tab; /* the tab to land in */
+ char purpose[64]; /* the workspace's identity */
+ char path[512]; /* the project on disk */
+ size_t tabs; /* tabs it adopted */
+ size_t honoured; /* tabs whose own declared purpose was left alone */
+ bool created; /* false when it was already open and was focused */
+} app_workspace_open_t;
+
+/* Open a project's layout as a workspace, or focus the one already open --
+ * idempotent, so the same request twice is one workspace and a caller never has
+ * to ask first. `name` is a project name or a path under a root. `suspended`
+ * starts every pane asleep whatever the layout said, which is the "open ten
+ * projects, run zero processes" case. */
+bool app_workspace_open(app_t *a, const char *name, bool suspended,
+ app_workspace_open_t *out, char *err, size_t errcap);
+
+/* Close every tab carrying this workspace's purpose; returns how many. */
+size_t app_workspace_close(app_t *a, const char *slug);
+
+typedef struct {
+ char path[512]; /* the file written */
+ char purpose[64]; /* the workspace the tab now carries */
+ size_t panes, suspended;
+ bool replaced; /* there was a layout there before */
+} app_workspace_save_t;
+
+/* Write one tab out as a project's layout: the same dump `dump-layout` answers
+ * with, relative to the project, into `sl0ppty.layout.kdl` beside it. `tab` 0 is
+ * the current one; `path` names the project for a tab that is not yet a
+ * workspace (and saving adopts it, which is the whole of onboarding one).
+ * Refused without `force` when the project already has a layout: that file is
+ * checked in. */
+bool app_workspace_save(app_t *a, uint32_t tab, const char *path, int suspend,
+ bool force, app_workspace_save_t *out, char *err,
+ size_t errcap);
/* What this session is called, for the status line. The server knows it
* because it opened the socket under that name. */
@@ -191,13 +272,44 @@ bool app_load_pane_shaders(app_t *a, uint32_t pane_id, const char *path,
* this pane's doing. */
bool app_clear_pane_shaders(app_t *a, uint32_t pane_id);
+/* What to write when a session is written back out. `tab` 0 is every tab;
+ * `base` relativises every `cwd=` under it, which is what makes a project's
+ * layout the same file on another machine; `suspend` decides which panes are
+ * written as not-yet-started. `panes` and `suspended` come back filled in, so
+ * a caller can say what it wrote. NULL means all of it, verbatim, absolute. */
+typedef enum {
+ DUMP_SUSPEND_ASIS = 0, /* what is suspended now */
+ DUMP_SUSPEND_NONE,
+ DUMP_SUSPEND_COMMANDS, /* every pane that was given a command */
+ DUMP_SUSPEND_ALL
+} dump_suspend_t;
+
+typedef struct {
+ uint32_t tab;
+ const char *base;
+ int suspend;
+ /* This dump *is* a project's own layout file, so leave out what the project's
+ * location already says: a tab's `project:` purpose is derived from the path,
+ * and a checked-in file carrying one would hand another checkout of the same
+ * repo the original's identity. Every other purpose is somebody's label and is
+ * written as usual. */
+ bool for_project;
+ /* out. `tabs` is how a caller tells "that tab has no panes" from "there is no
+ * such tab", which are the same empty document. */
+ size_t tabs, panes, suspended;
+} dump_layout_t;
+
/* The session written back out as a layout file: tabs, splits, proportions,
* directories and commands. The inverse of app_apply_layout_text(), and what
- * makes "quit, rebuild, come back to the same screen" possible. Caller frees.
+ * `dump-layout` and `save-workspace` both answer with -- one of them writes it
+ * to a file, which is the only difference between them.
*
- * The shape is restorable; what is running inside a program is not, and this
+ * What a dump can honestly restore is the shape. Not the state inside a
+ * program: a shell's history, a running editor. A pane running the session's
+ * default shell is dumped as a pane with no command, so restoring it gives a
+ * fresh one, and a pane whose program has exited is dumped as what it ran. It
* does not pretend otherwise. */
-char *app_dump_layout(app_t *a);
+char *app_dump_layout(app_t *a, dump_layout_t *o);
/* State as JSON, for the control API and the harness. Caller frees. */
char *app_panes_json(app_t *a);
diff --git a/src/cmd.c b/src/cmd.c
index a8af4a7..32d6f4b 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -81,6 +81,18 @@ static char *jok_raw(const char *key, char *raw) {
return out;
}
+/* `suspend` on a dump: which panes are written as not-yet-started. A word
+ * rather than a flag because there are four honest answers and three of them
+ * are wanted -- see dump_suspended() for which is the default where. */
+static int suspend_policy(const char *word, int fallback) {
+ if (!word) return fallback;
+ if (strcmp(word, "as-is") == 0) return DUMP_SUSPEND_ASIS;
+ if (strcmp(word, "none") == 0) return DUMP_SUSPEND_NONE;
+ if (strcmp(word, "commands") == 0) return DUMP_SUSPEND_COMMANDS;
+ if (strcmp(word, "all") == 0) return DUMP_SUSPEND_ALL;
+ return -1;
+}
+
static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
const jv_t *req, bool *quit) {
const char *cmd = jv_gets(req, "cmd", NULL);
@@ -235,12 +247,22 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
return ok ? jok_int(NULL, 0) : jerr("refused");
}
if (strcmp(cmd, "dump-layout") == 0) {
- char *kdl = app_dump_layout(a);
+ dump_layout_t o = {.tab = (uint32_t)jv_geti(req, "tab", 0),
+ .base = jv_gets(req, "relative_to", NULL)};
+ o.suspend = suspend_policy(jv_gets(req, "suspend", NULL), DUMP_SUSPEND_ASIS);
+ if (o.suspend < 0) return jerr("suspend is as-is, none, commands or all");
+ char *kdl = app_dump_layout(a, &o);
+ if (o.tab && !o.tabs) {
+ free(kdl);
+ return jerr("no such tab");
+ }
json_t j;
json_init(&j);
json_obj_open(&j, NULL);
json_bool(&j, "ok", true);
json_str(&j, "kdl", kdl, strlen(kdl));
+ json_int(&j, "panes", (long long)o.panes);
+ json_int(&j, "suspended", (long long)o.suspended);
json_obj_close(&j);
free(kdl);
return j.buf;
@@ -258,6 +280,107 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
s->force_full = true;
return jok_raw("tabs", app_tabs_json(a));
}
+ /* ---- workspaces ------------------------------------------------------- *
+ *
+ * `workspaces` lists what is on disk and which of it is open; the other three
+ * are open, close and write one. Everything a tool needs to drive a project
+ * without knowing anything about that project: open it, ask `panes` what is
+ * in it, and act on the purposes the project's own layout declared. */
+ if (strcmp(cmd, "workspaces") == 0) {
+ project_t all[PROJECTS_MAX];
+ size_t n = app_projects(all, PROJECTS_MAX);
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ /* Said out loud rather than answered with an empty list: "you have no
+ * projects" and "you never said where they are" are different facts. */
+ json_bool(&j, "roots", app_project_roots_set());
+ json_arr_open(&j, "workspaces");
+ for (size_t i = 0; i < n; i++) {
+ json_obj_open(&j, NULL);
+ json_str(&j, "name", all[i].name, strlen(all[i].name));
+ json_str(&j, "path", all[i].path, strlen(all[i].path));
+ json_str(&j, "purpose", all[i].slug, strlen(all[i].slug));
+ json_str(&j, "layout", all[i].layout, strlen(all[i].layout));
+ /* The file's mtime, so a tool can compare it against a pane's `since` and
+ * decide the open workspace has drifted -- without this session storing a
+ * byte to remember, or pretending it could re-apply a layout over running
+ * processes. */
+ json_int(&j, "mtime", (long long)all[i].mtime);
+ json_int(&j, "tab", (long long)app_workspace_tab(a, all[i].slug));
+ json_obj_close(&j);
+ }
+ json_arr_close(&j);
+ json_obj_close(&j);
+ return j.buf;
+ }
+ if (strcmp(cmd, "open-workspace") == 0) {
+ const char *name = jv_gets(req, "name", jv_gets(req, "path", NULL));
+ if (!name) return jerr("need a name or a path");
+ app_workspace_open_t w;
+ char err[256] = {0};
+ if (!app_workspace_open(a, name, jv_getb(req, "suspended", false), &w, err,
+ sizeof err))
+ return jerr(err[0] ? err : "cannot open that");
+ app_resize(a, s->cols, s->rows);
+ s->force_full = true;
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ json_int(&j, "tab", (long long)w.tab);
+ json_str(&j, "purpose", w.purpose, strlen(w.purpose));
+ json_str(&j, "path", w.path, strlen(w.path));
+ json_bool(&j, "created", w.created);
+ json_int(&j, "tabs", (long long)w.tabs);
+ /* A layout tab that declared a purpose of its own keeps it and is not a
+ * member. Counted here rather than left silent, because a tab that is not
+ * in the workspace it looks like it is in would be a surprise later. */
+ json_int(&j, "honoured", (long long)w.honoured);
+ json_obj_close(&j);
+ return j.buf;
+ }
+ if (strcmp(cmd, "close-workspace") == 0) {
+ const char *purpose = jv_gets(req, "purpose", NULL);
+ char slug[64] = {0};
+ if (!purpose) {
+ const char *name = jv_gets(req, "name", NULL);
+ if (!name) return jerr("need a name or a purpose");
+ /* Resolved by the same function opening uses, so the two cannot disagree
+ * about which workspace a name means. */
+ project_t p;
+ if (!app_workspace_find(name, &p)) return jerr("no such project");
+ snprintf(slug, sizeof slug, "%s", p.slug);
+ purpose = slug;
+ }
+ size_t n = app_workspace_close(a, purpose);
+ app_resize(a, s->cols, s->rows);
+ s->force_full = true;
+ return jok_int("closed", (long long)n);
+ }
+ if (strcmp(cmd, "save-workspace") == 0) {
+ int suspend = suspend_policy(jv_gets(req, "suspend", NULL),
+ DUMP_SUSPEND_COMMANDS);
+ if (suspend < 0) return jerr("suspend is as-is, none, commands or all");
+ app_workspace_save_t w;
+ char err[256] = {0};
+ if (!app_workspace_save(a, (uint32_t)jv_geti(req, "tab", 0),
+ jv_gets(req, "path", NULL), suspend,
+ jv_getb(req, "force", false), &w, err, sizeof err))
+ return jerr(err[0] ? err : "cannot save that");
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ json_str(&j, "path", w.path, strlen(w.path));
+ json_str(&j, "purpose", w.purpose, strlen(w.purpose));
+ json_int(&j, "panes", (long long)w.panes);
+ json_int(&j, "suspended", (long long)w.suspended);
+ json_bool(&j, "replaced", w.replaced);
+ json_obj_close(&j);
+ return j.buf;
+ }
if (strcmp(cmd, "notify") == 0) {
const char *text = jv_gets(req, "text", NULL);
if (!text) return jerr("need text");
@@ -393,7 +516,64 @@ char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
}
if (strcmp(verb, "dump-layout") == 0) {
app_compose(a, s); /* the layout is a function of the frame: compose first */
- return app_dump_layout(a);
+ return app_dump_layout(a, NULL);
+ }
+ /* The bare forms the harness and a person at a shell use. Same functions, so
+ * a script and a test cannot drift from what the API does. */
+ if (strcmp(verb, "workspaces") == 0) {
+ project_t all[PROJECTS_MAX];
+ size_t n = app_projects(all, PROJECTS_MAX);
+ /* One row per project, each bounded by the fields it prints: name, marker
+ * and path are all fixed-size in project_t, so the whole answer is too. */
+ size_t cap = n * (sizeof all[0].name + sizeof all[0].path + 32) + 128;
+ char *out = malloc(cap);
+ size_t len = 0;
+ if (!app_project_roots_set())
+ len += (size_t)snprintf(out + len, cap - len,
+ "no project roots: set project_roots in your "
+ "config\n");
+ for (size_t i = 0; i < n && len < cap; i++) {
+ uint32_t tab = app_workspace_tab(a, all[i].slug);
+ len += (size_t)snprintf(out + len, cap - len, "%-24s %-9s %s%s\n",
+ all[i].name, all[i].layout[0] ? "layout" : ".git",
+ all[i].path, tab ? " (open)" : "");
+ }
+ return out;
+ }
+ if (strcmp(verb, "open-workspace") == 0) {
+ app_workspace_open_t w;
+ char err[256] = {0};
+ if (!app_workspace_open(a, arg, false, &w, err, sizeof err))
+ return strdup(err[0] ? err : "cannot open that");
+ app_resize(a, s->cols, s->rows);
+ s->force_full = true;
+ char out[128];
+ snprintf(out, sizeof out, "%s tab %u", w.created ? "opened" : "focused",
+ w.tab);
+ return strdup(out);
+ }
+ if (strcmp(verb, "close-workspace") == 0) {
+ project_t p;
+ if (!app_workspace_find(arg, &p)) return strdup("no such project");
+ size_t n = app_workspace_close(a, p.slug);
+ app_resize(a, s->cols, s->rows);
+ s->force_full = true;
+ char out[64];
+ snprintf(out, sizeof out, "closed %zu tab%s", n, n == 1 ? "" : "s");
+ return strdup(out);
+ }
+ if (strcmp(verb, "save-workspace") == 0) {
+ app_workspace_save_t w;
+ char err[256] = {0};
+ /* The bare form takes the path a tab is not yet a workspace for, since that
+ * is the one thing it cannot work out on its own. */
+ if (!app_workspace_save(a, 0, *arg ? arg : NULL, DUMP_SUSPEND_COMMANDS,
+ false, &w, err, sizeof err))
+ return strdup(err[0] ? err : "cannot save that");
+ char out[640];
+ snprintf(out, sizeof out, "%s (%zu panes, %zu suspended)", w.path, w.panes,
+ w.suspended);
+ return strdup(out);
}
if (strcmp(verb, "reload") == 0) {
char err[256] = {0};
diff --git a/src/config.c b/src/config.c
index 70d0fae..dba211d 100644
--- a/src/config.c
+++ b/src/config.c
@@ -19,7 +19,8 @@ static const struct {
{"split-cols", ACT_SPLIT_COLS}, {"split-rows", ACT_SPLIT_ROWS},
{"close-pane", ACT_CLOSE_PANE}, {"rerun", ACT_RERUN},
{"zoom", ACT_ZOOM},
- {"minimize", ACT_MINIMIZE}, {"focus-left", ACT_FOCUS_LEFT},
+ {"minimize", ACT_MINIMIZE}, {"set-purpose", ACT_SET_PURPOSE},
+ {"focus-left", ACT_FOCUS_LEFT},
{"focus-right", ACT_FOCUS_RIGHT}, {"focus-up", ACT_FOCUS_UP},
{"focus-down", ACT_FOCUS_DOWN}, {"focus-next", ACT_FOCUS_NEXT},
{"resize-left", ACT_RESIZE_LEFT}, {"resize-right", ACT_RESIZE_RIGHT},
@@ -35,8 +36,11 @@ static const struct {
{"scroll-page-down", ACT_SCROLL_PAGE_DOWN},
{"scroll-top", ACT_SCROLL_TOP}, {"scroll-bottom", ACT_SCROLL_BOTTOM},
{"new-tab", ACT_NEW_TAB}, {"next-tab", ACT_NEXT_TAB},
+ {"close-tab", ACT_CLOSE_TAB},
{"prev-tab", ACT_PREV_TAB}, {"finder", ACT_FINDER},
{"palette", ACT_PALETTE},
+ {"workspaces", ACT_WORKSPACES},
+ {"save-workspace", ACT_SAVE_WORKSPACE},
{"detach", ACT_DETACH}, {"quit", ACT_QUIT},
{"literal-prefix", ACT_LITERAL_PREFIX},
{"help", ACT_HELP},
@@ -685,6 +689,16 @@ void config_defaults(config_t *c) {
c->min_split_cols = 32;
c->min_split_rows = 8;
c->scroll_lines = 3;
+ /* What every other multiplexer settled on, and what lib-vt does not give you:
+ * its own default is 10,000 *bytes*, which measured at 622 lines of an
+ * 80-column pane. See config/config.kdl for the ceiling's arithmetic. */
+ c->scrollback = 10000;
+ c->scrollback_bytes = 16u * 1024 * 1024;
+ /* Two levels, because `~/dev/work/api` is as common as `~/dev/api` and a
+ * directory that is itself a project is never descended into -- so the second
+ * level costs one readdir per non-project directory and never walks a
+ * checkout. No roots by default: the feature is dormant until you say where. */
+ c->project_depth = 2;
c->toast_ms = 2500;
c->hover_delay_ms = 250;
c->double_click_ms = 400;
@@ -883,6 +897,11 @@ void config_defaults(config_t *c) {
/* `b` for break out, which is the word tmux taught everybody for this. */
bind_add(c, GHOSTTY_KEY_B, 0, ACT_PANE_TO_NEW_TAB, false);
bind_add(c, GHOSTTY_KEY_C, 0, ACT_NEW_TAB, false);
+ /* Shifted beside `x`, which closes a pane: the same verb on the bigger thing.
+ * A tab with four panes in it used to take four presses of `x` and the tab
+ * evaporating when the last one went, which is a side effect rather than a
+ * verb. */
+ bind_add(c, GHOSTTY_KEY_X, MOD_SHIFT, ACT_CLOSE_TAB, false);
/* Cycling tabs is on tab/shift+tab, not on n/p: `p` is the palette, which is
* pressed far more often than "the tab before this one" and had the only
* shifted letter in the defaults. Tab is the key every other tabbed thing
@@ -890,8 +909,16 @@ void config_defaults(config_t *c) {
* this costs nothing on a client without the kitty protocol. */
bind_add(c, GHOSTTY_KEY_TAB, 0, ACT_NEXT_TAB, false);
bind_add(c, GHOSTTY_KEY_TAB, MOD_SHIFT, ACT_PREV_TAB, false);
+ /* `w` for the projects picker and `W` to write this tab as one's layout: the
+ * same shifted-pair shape as `p`/`P`, on the letter people reach for. */
+ bind_add(c, GHOSTTY_KEY_W, 0, ACT_WORKSPACES, false);
+ bind_add(c, GHOSTTY_KEY_W, MOD_SHIFT, ACT_SAVE_WORKSPACE, false);
bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER, false);
bind_add(c, GHOSTTY_KEY_P, 0, ACT_PALETTE, false);
+ /* Shifted, beside the palette on the same letter: `p` runs a command, `P`
+ * tags this pane. A purpose was reachable only from a layout or the socket,
+ * which is why panes people arrange by hand have none. */
+ bind_add(c, GHOSTTY_KEY_P, MOD_SHIFT, ACT_SET_PURPOSE, false);
bind_add(c, GHOSTTY_KEY_PAGE_UP, 0, ACT_SCROLL_PAGE_UP, false);
bind_add(c, GHOSTTY_KEY_PAGE_DOWN, 0, ACT_SCROLL_PAGE_DOWN, false);
bind_add(c, GHOSTTY_KEY_HOME, 0, ACT_SCROLL_TOP, false);
@@ -910,6 +937,8 @@ void config_free(config_t *c) {
free(c->shell);
free(c->editor);
free(c->shader_dir);
+ free(c->project_layout);
+ for (size_t i = 0; i < c->nproject_roots; i++) free(c->project_roots[i]);
for (size_t i = 0; i < c->nfiles; i++) free(c->files[i]);
memset(c, 0, sizeof *c);
}
@@ -934,6 +963,10 @@ static const struct {
{ACT_RERUN, "panes", "run a finished pane again"},
{ACT_ZOOM, "panes", "fill the tab with it"},
{ACT_MINIMIZE, "panes", "put it away in the strip"},
+ /* Under `panes` because a purpose is a fact about this pane, and the label
+ * says "tag" rather than "set purpose": the word people arrive with is the
+ * one for what it is *for*, which is finding the pane again later. */
+ {ACT_SET_PURPOSE, "panes", "tag it with a purpose"},
{ACT_FOCUS_LEFT, "focus", "go left"},
{ACT_FOCUS_RIGHT, "focus", "go right"},
@@ -961,6 +994,7 @@ static const struct {
{ACT_ROTATE_LAYOUT, "size", "turn the layout a quarter"},
{ACT_NEW_TAB, "tabs", "new tab"},
+ {ACT_CLOSE_TAB, "tabs", "close this tab"},
{ACT_NEXT_TAB, "tabs", "next tab"},
{ACT_PREV_TAB, "tabs", "previous tab"},
{ACT_SELECT_TAB_1, "tabs", "go to that tab"},
@@ -972,6 +1006,11 @@ static const struct {
{ACT_SCROLL_TOP, "scroll", "to the oldest line"},
{ACT_SCROLL_BOTTOM, "scroll", "back to the present"},
+ /* Their own group: a project is neither a pane nor this session, and the
+ * two verbs are the same pair -- go to one, write one down. */
+ {ACT_WORKSPACES, "projects", "go to a project"},
+ {ACT_SAVE_WORKSPACE, "projects", "save this tab as a layout"},
+
{ACT_PALETTE, "session", "run a command"},
{ACT_HELP, "session", "this list"},
{ACT_EDIT_CONFIG, "session", "edit the config"},
@@ -1246,6 +1285,10 @@ char *config_render(const config_t *c) {
cb_add(&b, "focus_follows_mouse %s\n", yesno(c->focus_follows_mouse));
cb_add(&b, "in_band_shaders %s\n", yesno(c->in_band_shaders));
cb_add(&b, "scroll_lines %u\n", c->scroll_lines);
+ cb_add(&b, "scrollback %zu // lines of history per pane; 0 for none\n",
+ c->scrollback);
+ cb_add(&b, "scrollback_bytes %zu // the ceiling that count runs into; 0 for none\n",
+ c->scrollback_bytes);
cb_add(&b, "toast_ms %u\n", c->toast_ms);
cb_add(&b, "hover_delay_ms %u\n", c->hover_delay_ms);
cb_add(&b, "double_click_ms %u\n", c->double_click_ms);
@@ -1264,6 +1307,17 @@ char *config_render(const config_t *c) {
else cb_add(&b, "// editor \"nvim\" // unset: $EDITOR, then vi\n");
if (c->shader_dir) cb_add(&b, "shader_dir \"%s\"\n", c->shader_dir);
else cb_add(&b, "// shader_dir \"~/.config/sl0ppty/shaders\"\n");
+ if (c->nproject_roots) {
+ cb_add(&b, "project_roots");
+ for (size_t i = 0; i < c->nproject_roots; i++)
+ cb_add(&b, " \"%s\"", c->project_roots[i]);
+ cb_add(&b, " depth=%d\n", c->project_depth);
+ } else {
+ cb_add(&b, "// project_roots \"~/dev\" \"~/work\" depth=%d\n",
+ c->project_depth);
+ }
+ if (c->project_layout) cb_add(&b, "project_layout \"%s\"\n", c->project_layout);
+ else cb_add(&b, "// project_layout \"~/.config/sl0ppty/project.layout.kdl\"\n");
cb_add(&b, "\n// ---- colour ----\ntheme {\n");
for (size_t i = 0; i < sizeof THEME_COLORS / sizeof *THEME_COLORS; i++)
@@ -1426,8 +1480,12 @@ static const char *const KNOWN_TOP[] = {
"newtab_mark",
"padding",
"pane_buttons",
+ "project_layout",
+ "project_roots",
"rounded",
"scroll_lines",
+ "scrollback",
+ "scrollback_bytes",
"shader_dir",
"shaders",
"shell",
@@ -1702,6 +1760,25 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
}
c->scroll_lines =
(uint16_t)kdl_arg_int(kdl_child(root, "scroll_lines"), 0, c->scroll_lines);
+ /* Read as a `long` so a negative is *refused* rather than wrapping into a
+ * limit of eighteen quintillion lines, which is how "unlimited" gets into a
+ * program by accident. There is no unlimited on purpose: with 64 panes it is a
+ * memory leak with a friendly name. */
+ const kdl_node_t *sbn = kdl_child(root, "scrollback");
+ if (sbn) {
+ long v = kdl_arg_int(sbn, 0, (long)c->scrollback);
+ if (v < 0) complain(c, err, errcap, sbn->line,
+ "scrollback is a number of lines, 0 for none, not %ld", v);
+ else c->scrollback = (size_t)v;
+ }
+ const kdl_node_t *sbb = kdl_child(root, "scrollback_bytes");
+ if (sbb) {
+ long v = kdl_arg_int(sbb, 0, (long)c->scrollback_bytes);
+ if (v < 0) complain(c, err, errcap, sbb->line,
+ "scrollback_bytes is a byte ceiling, 0 for none, not %ld",
+ v);
+ else c->scrollback_bytes = (size_t)v;
+ }
c->toast_ms = (uint16_t)kdl_arg_int(kdl_child(root, "toast_ms"), 0, c->toast_ms);
c->hover_delay_ms = (uint16_t)kdl_arg_int(kdl_child(root, "hover_delay_ms"), 0,
c->hover_delay_ms);
@@ -1755,6 +1832,41 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
if (lerr[0]) complain(c, err, errcap, 0, "%s", lerr);
}
+ /* Where projects live. Several roots because people have `~/dev` and `~/work`,
+ * and a `depth` property rather than a second node because it is one small
+ * fixed record -- the same shape `min_pane cols=24 rows=6` uses.
+ *
+ * Naming the node at all replaces the roots it inherited, the way a `shaders`
+ * block does: a list you can only add to is a list you cannot correct. */
+ const kdl_node_t *proots = kdl_child(root, "project_roots");
+ if (proots) {
+ for (size_t i = 0; i < c->nproject_roots; i++) free(c->project_roots[i]);
+ c->nproject_roots = 0;
+ for (size_t i = 0; i < proots->nargs; i++) {
+ if (c->nproject_roots >= PROJECT_ROOTS_MAX) {
+ complain(c, err, errcap, proots->line,
+ "project_roots takes at most %d directories", PROJECT_ROOTS_MAX);
+ break;
+ }
+ c->project_roots[c->nproject_roots++] = strdup(proots->args[i]);
+ }
+ if (!proots->nargs)
+ complain(c, err, errcap, proots->line,
+ "project_roots takes one or more directories");
+ long d = kdl_prop_int(proots, "depth", c->project_depth);
+ if (d < 1 || d > 8)
+ complain(c, err, errcap, proots->line,
+ "depth is between 1 and 8, not %ld", d);
+ else
+ c->project_depth = (int)d;
+ }
+
+ const char *play = kdl_arg(kdl_child(root, "project_layout"), 0, NULL);
+ if (play) {
+ free(c->project_layout);
+ c->project_layout = strdup(play);
+ }
+
/* One node per pass, in the order written, because a chain is a sequence.
* Shared by `shaders { }` and by every state, so a state's chain is exactly
* as expressive as the global one and there is one parser to be wrong. */
diff --git a/src/config.h b/src/config.h
index 8ef4f9e..0376790 100644
--- a/src/config.h
+++ b/src/config.h
@@ -49,6 +49,7 @@ typedef enum {
ACT_RERUN,
ACT_ZOOM,
ACT_MINIMIZE,
+ ACT_SET_PURPOSE,
ACT_ROTATE_LAYOUT,
ACT_FOCUS_LEFT,
ACT_FOCUS_RIGHT,
@@ -56,10 +57,13 @@ typedef enum {
ACT_FOCUS_DOWN,
ACT_FOCUS_NEXT,
ACT_NEW_TAB,
+ ACT_CLOSE_TAB,
ACT_NEXT_TAB,
ACT_PREV_TAB,
ACT_FINDER,
ACT_PALETTE,
+ ACT_WORKSPACES,
+ ACT_SAVE_WORKSPACE,
ACT_SCROLL_UP,
ACT_SCROLL_DOWN,
ACT_SCROLL_PAGE_UP,
@@ -101,6 +105,11 @@ typedef enum { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } align_t;
* thing nobody can read. */
#define CONFIG_FILES_MAX 16
+/* How many project roots one config may name. `~/dev` and `~/work` is the case;
+ * a person with more than this many places they keep code has a search problem
+ * rather than a list problem. */
+#define PROJECT_ROOTS_MAX 8
+
/* How many complaints one load keeps. A config with more than this many
* problems in it has one problem, and the list is long enough to say so. */
#define CONFIG_MSGS_MAX 32
@@ -175,6 +184,16 @@ typedef struct {
* offer something the layout would collapse anyway is the floor. */
uint16_t min_split_cols, min_split_rows;
uint16_t scroll_lines;
+ /* How much history a pane keeps. `scrollback` is the number people mean --
+ * lines -- and `scrollback_bytes` is the ceiling that keeps one very wide,
+ * heavily styled pane from spending the machine to honour it: lib-vt applies
+ * whichever limit is reached first, and both depend on things a line count
+ * cannot see. 0 lines is no scrollback; 0 bytes is no ceiling.
+ *
+ * Not a `uint16_t`, because 65,535 is a number somebody will want to exceed
+ * and a silently wrapped limit is worse than a refused one. */
+ size_t scrollback;
+ size_t scrollback_bytes;
uint16_t toast_ms;
uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */
uint16_t double_click_ms; /* how close two clicks must be to be a double */
@@ -352,6 +371,24 @@ typedef struct {
* on what has been loaded. */
char *shader_dir;
+ /* Where projects live: `project_roots "~/dev" "~/work" depth=2`. A project is
+ * a subdirectory of one of these with a `sl0ppty.layout.kdl` in it -- or a
+ * `.git`, which gets `project_layout` instead. Empty means the feature is
+ * dormant and costs nothing.
+ *
+ * `depth` is levels below each root, and a directory that *is* a project is
+ * never descended into, so 2 covers `~/dev/work/api` without walking any
+ * checkout's insides. */
+ char *project_roots[PROJECT_ROOTS_MAX];
+ size_t nproject_roots;
+ int project_depth;
+
+ /* The layout a project with no file of its own opens as. Yours rather than a
+ * guess about your stack: relative paths in it bind to whichever project is
+ * being opened, so one file is the shape you start every project in. NULL is
+ * one pane running your shell. */
+ char *project_layout;
+
/* Every file this config was built from, in the order they were read: the one
* that was loaded and everything it included, whether or not each existed.
* Kept because the watcher needs it — a theme you can include is a theme you
diff --git a/src/main.c b/src/main.c
index 988d577..956c10b 100644
--- a/src/main.c
+++ b/src/main.c
@@ -18,6 +18,7 @@
#include <termios.h>
#include <unistd.h>
+#include "app.h"
#include "proto.h"
#include "server.h"
#include "config.h"
@@ -38,6 +39,42 @@ static void term_size(uint16_t *cols, uint16_t *rows) {
}
}
+/* Whether a file is a layout rather than a config, decided the same way both
+ * loaders decide it: by what its top-level names are. A file that will not
+ * parse has no top-level names, and then the extension is all there is -- which
+ * is exactly the job D2 gave it, and better than telling somebody their layout
+ * is a config whose "defaults would stand". */
+static bool looks_like_layout(const char *path) {
+ char buf[1024];
+ kdl_node_t *root = kdl_parse_file(path_expand(path, buf, sizeof buf), NULL, 0);
+ if (!root) return strstr(path, ".layout.") != NULL;
+ bool layout = false;
+ for (size_t i = 0; i < root->nkids && !layout; i++) {
+ const char *name = root->kids[i] ? root->kids[i]->name : NULL;
+ if (!name) continue;
+ if (strcmp(name, "layout") == 0 || strcmp(name, "tab") == 0) layout = true;
+ }
+ kdl_free(root);
+ return layout;
+}
+
+/* `sl0ppty --check FILE` on a layout. Telling somebody they handed a layout to
+ * the config checker was honest and unhelpful: the file they want checked is
+ * the one they were told to check. Same output shape, same exit status. */
+static int check_layout(const char *path) {
+ layout_msg_t msgs[LAYOUT_MSGS_MAX];
+ size_t dropped = 0;
+ size_t n = layout_check_file(path, msgs, LAYOUT_MSGS_MAX, &dropped);
+ for (size_t i = 0; i < n; i++) fprintf(stderr, " %s\n", msgs[i]);
+ if (dropped) fprintf(stderr, " ...and %zu more\n", dropped);
+ if (n)
+ fprintf(stderr, "%s: %zu problem%s\n", path, n + dropped,
+ n + dropped == 1 ? "" : "s");
+ else
+ printf("%s: ok, a layout\n", path);
+ return n ? 1 : 0;
+}
+
/* `sl0ppty --check [FILE]`: read a config the way a session would and say what
* it could not honour, one problem per line, `file:line: what`. Exits 1 when
* there is anything to say, so it drops into an editor's compile step or a
@@ -121,9 +158,11 @@ int main(int argc, char **argv) {
else if (strcmp(a, "--check") == 0) {
/* An optional path, so it lints a file you have not installed yet:
* `sl0ppty --check theme.kdl`. Without one it checks the config a session
- * would actually read. */
+ * would actually read. A layout is checked as a layout: one flag, and the
+ * file decides which schema it is held to. */
const char *path = (i + 1 < argc && argv[i + 1][0] != '-') ? argv[++i] : NULL;
- return check_config(path);
+ return path && looks_like_layout(path) ? check_layout(path)
+ : check_config(path);
}
else if (strcmp(a, "--dump-config") == 0) {
/* Every setting with the value it currently has, as a file you could
diff --git a/src/pane.c b/src/pane.c
index aaad325..4db47cf 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -397,6 +397,108 @@ void pane_free(pane_t *p) {
int pane_fd(const pane_t *p) { return p->pty.fd; }
pid_t pane_pid(const pane_t *p) { return p->pty.pid; }
const char *pane_start_cwd(const pane_t *p) { return p->cwd; }
+
+/* What this pane's terminal is *running*, as a command line, or NULL.
+ *
+ * `label` is only what a layout gave the pane, so a pane you split and then
+ * typed `npm run dev` into had nothing to say for itself and was written back
+ * out as a bare shell. The kernel knows what the label does not: the pty has a
+ * foreground process group, and that group is by definition the job that owns
+ * the terminal -- which is exactly what "what is running in this pane" means.
+ *
+ * The pane's own shell sitting at a prompt is *not* a command, and is reported
+ * as none: its process group is the pane's own, and writing `command="zsh"`
+ * into a layout would turn every idle pane into one that re-runs your shell
+ * inside your shell. A background job is not reported either -- it does not own
+ * the terminal, and a layout that resurrected `&` jobs in the foreground would
+ * be describing a session nobody had.
+ *
+ * argv is joined with shell quoting, because `command=` is handed to `/bin/sh
+ * -c` and an argument with a space in it would otherwise come back as two. */
+static void sq_append(char *out, size_t cap, size_t *len, const char *arg) {
+ bool safe = *arg != 0;
+ for (const char *c = arg; *c && safe; c++)
+ safe = (*c >= 'A' && *c <= 'Z') || (*c >= 'a' && *c <= 'z') ||
+ (*c >= '0' && *c <= '9') || strchr("_./:=@%+-,", *c) != NULL;
+ if (*len && *len + 1 < cap) out[(*len)++] = ' ';
+ if (safe) {
+ *len += (size_t)snprintf(out + *len, cap - *len, "%s", arg);
+ return;
+ }
+ if (*len + 1 < cap) out[(*len)++] = '\'';
+ for (const char *c = arg; *c && *len + 5 < cap; c++) {
+ if (*c != '\'') {
+ out[(*len)++] = *c;
+ continue;
+ }
+ /* A single quote cannot appear inside single quotes: close, escape, reopen. */
+ memcpy(out + *len, "'\\''", 4);
+ *len += 4;
+ }
+ if (*len + 1 < cap) out[(*len)++] = '\'';
+ out[*len < cap ? *len : cap - 1] = 0;
+}
+
+const char *pane_foreground(const pane_t *p, char *buf, size_t cap) {
+ if (!p || p->pty.fd < 0 || p->pty.pid <= 0) return NULL;
+ pid_t fg = tcgetpgrp(p->pty.fd);
+ if (fg <= 0 || fg == p->pty.pid) return NULL; /* the pane's own shell */
+
+ char path[64];
+ snprintf(path, sizeof path, "/proc/%d/cmdline", (int)fg);
+ FILE *f = fopen(path, "rb");
+ if (!f) return NULL; /* gone between asking and reading, or not Linux */
+ char raw[4096];
+ size_t n = fread(raw, 1, sizeof raw - 1, f);
+ fclose(f);
+ if (!n) return NULL; /* a zombie or a kernel thread has no command line */
+ raw[n] = 0;
+
+ size_t len = 0;
+ buf[0] = 0;
+ for (size_t i = 0; i < n; i += strlen(raw + i) + 1) {
+ if (!raw[i]) break;
+ sq_append(buf, cap, &len, raw + i);
+ if (len + 2 >= cap) break;
+ }
+ return buf[0] ? buf : NULL;
+}
+
+/* How much history this pane keeps.
+ *
+ * lib-vt's own default is 10,000 *bytes* and no line limit, which measured at
+ * 622 lines of an 80-column pane -- less than a screenful of `make` output, and
+ * not a number anybody chose. Both limits are set here because either can bite
+ * first and lib-vt applies whichever does: a line count is what a person means
+ * by "scrollback", and the byte ceiling is what stops one very wide, heavily
+ * styled pane from spending the machine's memory to honour it.
+ *
+ * Both are estimates. Pruning happens a page at a time (a page is ~400KB of
+ * grid), so what a pane actually keeps is a little more than it was told to,
+ * never less. Lowering either drops history immediately, which is what makes
+ * this safe to apply to running panes on a config reload rather than only to the
+ * next pane opened.
+ *
+ * `lines` 0 is no scrollback at all, and goes through the byte limit because
+ * that is the one lib-vt documents as erasing retained history. */
+void pane_set_scrollback(pane_t *p, size_t lines, size_t bytes) {
+ if (!p || !p->term) return;
+ if (!lines) {
+ size_t none = 0;
+ ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES,
+ &none);
+ ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES,
+ &none);
+ return;
+ }
+ ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES,
+ &lines);
+ /* A NULL value removes the byte limit, which is the honest way to say "no
+ * ceiling": a very large number would still be a number we invented. */
+ ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES,
+ bytes ? &bytes : NULL);
+}
+
bool pane_alive(const pane_t *p) { return p->alive; }
bool pane_dirty(pane_t *p) { return p->dirty; }
const char *pane_title(const pane_t *p) {
diff --git a/src/path.c b/src/path.c
index 79ca119..edacda2 100644
--- a/src/path.c
+++ b/src/path.c
@@ -27,6 +27,63 @@ const char *path_expand(const char *path, char *buf, size_t cap) {
return buf;
}
+/* A path as a layout file meant it, against the directory that file lives in.
+ *
+ * A project's layout is checked in with the project, so it cannot name absolute
+ * directories: `cwd="/home/user/dev/api"` is right on exactly one machine and
+ * wrong in a worktree of the same repo. A relative path therefore resolves
+ * against the file that wrote it -- the rule `include` already follows for
+ * configs, applied to the other half of the same syntax.
+ *
+ * `base` NULL is the old behaviour on purpose: a layout arriving as text over
+ * the control socket was written by whoever sent it and has no directory to be
+ * relative to, so it keeps meaning what it meant. */
+const char *path_resolve(const char *path, const char *base, char *buf,
+ size_t cap) {
+ if (!path) return NULL;
+ if (path[0] == '~') return path_expand(path, buf, cap);
+ if (path[0] == '/' || !base || !*base) return path;
+ /* `.` is the project directory itself, which is what a dump writes for the
+ * pane that started there -- joined naively it would be `/home/you/dev/api/.`,
+ * which works and reads like a bug. */
+ if (strcmp(path, ".") == 0) return base;
+ if (strncmp(path, "./", 2) == 0) path += 2;
+ snprintf(buf, cap, "%s/%s", base, path);
+ return buf;
+}
+
+/* The directory a file lives in: what `path_resolve` wants as its base. A path
+ * with no `/` in it is in the current one, which is `.` rather than `""` --
+ * an empty base means "no base" to everything downstream. */
+const char *path_dir(const char *path, char *buf, size_t cap) {
+ if (!path) return NULL;
+ const char *slash = strrchr(path, '/');
+ if (!slash) return ".";
+ if (slash == path) return "/";
+ size_t n = (size_t)(slash - path);
+ if (n >= cap) n = cap - 1;
+ memcpy(buf, path, n);
+ buf[n] = 0;
+ return buf;
+}
+
+/* The inverse, for writing a layout back out: a path under `base` becomes what
+ * it is relative to it, and anything else is left absolute. Left absolute
+ * rather than climbed out of with `../..`, because a pane whose directory is
+ * outside the project is not a fact about the project and a file full of `..`
+ * is a file nobody can move. Returns a pointer into `path` or a literal, so
+ * there is nothing to free and nothing to size. */
+const char *path_relative(const char *path, const char *base) {
+ if (!path || !base || !*base) return path;
+ size_t n = strlen(base);
+ while (n && base[n - 1] == '/') n--; /* a trailing slash is not a component */
+ if (strncmp(path, base, n) != 0) return path;
+ if (path[n] == 0) return ".";
+ if (path[n] != '/') return path; /* /dev/apiary is not under /dev/api */
+ const char *rest = path + n + 1;
+ return *rest ? rest : ".";
+}
+
/* Create a directory and everything above it. `mkdir` of one level is enough
* on a machine where ~/.config already exists, which is most of them and not
* all of them -- a fresh container has neither, and "could not write your
diff --git a/src/project.c b/src/project.c
new file mode 100644
index 0000000..4e2ba2d
--- /dev/null
+++ b/src/project.c
@@ -0,0 +1,155 @@
+/* Finding projects. See project.h for what one is and why nothing watches. */
+#define _GNU_SOURCE
+#include "project.h"
+
+#include <dirent.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+
+#include "sl0ppty.h"
+
+/* FNV-1a over the resolved path, printed as eight hex digits.
+ *
+ * Eight because a slug has to fit a purpose (64 bytes, `[A-Za-z0-9_.:/-]`)
+ * beside a name people will recognise, and because the thing being avoided is
+ * two projects with the same basename -- `~/dev/api` and `~/work/api` -- not an
+ * adversary. Cryptography is not what a tab label is for. */
+static void hash8(const char *s, char *out) {
+ uint32_t h = 2166136261u;
+ for (; *s; s++) {
+ h ^= (unsigned char)*s;
+ h *= 16777619u;
+ }
+ snprintf(out, 9, "%08x", h);
+}
+
+void project_slug(const char *path, const char *name, char *out, size_t cap) {
+ char h[9];
+ hash8(path, h);
+ /* The name is truncated rather than the hash: the hash is what makes the slug
+ * unique, and half a hash is a collision waiting. */
+ int room = (int)cap - (int)sizeof "project:" - 9;
+ if (room < 1) room = 1;
+ snprintf(out, cap, "project:%.*s.%s", room, name, h);
+}
+
+/* A directory's own name, without a trailing slash to confuse it. */
+static void basename_of(const char *path, char *out, size_t cap) {
+ const char *end = path + strlen(path);
+ while (end > path && end[-1] == '/') end--;
+ const char *start = end;
+ while (start > path && start[-1] != '/') start--;
+ size_t n = (size_t)(end - start);
+ if (n >= cap) n = cap - 1;
+ memcpy(out, start, n);
+ out[n] = 0;
+}
+
+static bool exists(const char *dir, const char *leaf, struct stat *st) {
+ char p[1024];
+ snprintf(p, sizeof p, "%s/%s", dir, leaf);
+ struct stat local;
+ return stat(p, st ? st : &local) == 0;
+}
+
+/* Fill in a project, or say this directory is not one.
+ *
+ * Two markers, in order of authority: a layout file means the project said what
+ * it needs, and a `.git` means it is a project that has not said yet. The second
+ * is why this is worth having at all -- a `~/dev` of forty checkouts and three
+ * layout files would otherwise be a picker with three rows. */
+static bool describe(const char *path, project_t *p) {
+ memset(p, 0, sizeof *p);
+ struct stat st;
+ if (stat(path, &st) != 0 || !S_ISDIR(st.st_mode)) return false;
+
+ char real[PATH_MAX];
+ const char *resolved = realpath(path, real) ? real : path;
+ snprintf(p->path, sizeof p->path, "%s", resolved);
+ basename_of(p->path, p->name, sizeof p->name);
+ if (!p->name[0] || p->name[0] == '.') return false; /* not `.`, `..`, dotdirs */
+ project_slug(p->path, p->name, p->slug, sizeof p->slug);
+
+ struct stat lst;
+ if (exists(p->path, PROJECT_LAYOUT_FILE, &lst)) {
+ snprintf(p->layout, sizeof p->layout, "%s/%s", p->path, PROJECT_LAYOUT_FILE);
+ p->mtime = (int64_t)lst.st_mtime;
+ return true;
+ }
+ return exists(p->path, ".git", NULL);
+}
+
+static int by_name(const void *a, const void *b) {
+ return strcmp(((const project_t *)a)->name, ((const project_t *)b)->name);
+}
+
+/* One level of one root. Recurses only into directories that are *not* projects,
+ * so a checkout's own subdirectories are never walked. */
+static void scan_dir(const char *dir, int depth, project_t *out, size_t max,
+ size_t *n) {
+ if (depth <= 0 || *n >= max) return;
+ DIR *d = opendir(dir);
+ if (!d) return; /* missing or unreadable is the normal case, not an error */
+ struct dirent *e;
+ while ((e = readdir(d)) && *n < max) {
+ if (e->d_name[0] == '.') continue; /* `.`, `..`, and dotdirs */
+ char path[1024];
+ snprintf(path, sizeof path, "%s/%s", dir, e->d_name);
+ project_t p;
+ if (describe(path, &p)) {
+ /* Deduplicated by path: two roots may overlap, and one project appearing
+ * twice in a picker is a picker that cannot be trusted. */
+ bool seen = false;
+ for (size_t i = 0; i < *n && !seen; i++)
+ seen = strcmp(out[i].path, p.path) == 0;
+ if (!seen) out[(*n)++] = p;
+ continue;
+ }
+ struct stat st;
+ if (stat(path, &st) == 0 && S_ISDIR(st.st_mode))
+ scan_dir(path, depth - 1, out, max, n);
+ }
+ closedir(d);
+}
+
+size_t project_scan(const char *const *roots, int depth, project_t *out,
+ size_t max) {
+ size_t n = 0;
+ if (!roots) return 0;
+ for (size_t i = 0; roots[i] && *roots[i]; i++) {
+ char buf[1024];
+ scan_dir(path_expand(roots[i], buf, sizeof buf), depth, out, max, &n);
+ }
+ qsort(out, n, sizeof *out, by_name);
+ return n;
+}
+
+bool project_find(const char *const *roots, int depth, const char *name,
+ project_t *out) {
+ if (!name || !*name) return false;
+ project_t all[PROJECTS_MAX];
+ size_t n = project_scan(roots, depth, all, PROJECTS_MAX);
+
+ /* A path is matched against what a scan found rather than described on its
+ * own, which is the containment check: a directory no root holds is one this
+ * session will not open and will not write a layout into. */
+ bool is_path = strchr(name, '/') != NULL;
+ char want[PATH_MAX];
+ if (is_path) {
+ char buf[1024];
+ const char *expanded = path_expand(name, buf, sizeof buf);
+ if (!realpath(expanded, want)) return false;
+ }
+
+ for (size_t i = 0; i < n; i++) {
+ bool hit = is_path ? strcmp(all[i].path, want) == 0
+ : strcmp(all[i].name, name) == 0;
+ if (!hit) continue;
+ *out = all[i];
+ return true;
+ }
+ return false;
+}
diff --git a/src/project.h b/src/project.h
new file mode 100644
index 0000000..1c2a103
--- /dev/null
+++ b/src/project.h
@@ -0,0 +1,64 @@
+/* Projects on disk, and the workspaces they open as.
+ *
+ * A *project* is a directory somebody works in. A *workspace* is the tab it
+ * currently occupies in a session. This file is about the first: finding them,
+ * naming them, and saying where each one's layout lives. The second lives in
+ * app.c, because a workspace is tabs.
+ *
+ * The list is derived when it is asked for rather than watched. One readdir per
+ * root plus two faccessat per entry is forty checkouts for eighty-one syscalls,
+ * which is nothing next to the keystroke that asked -- and an answer nobody
+ * remembered cannot be stale, which no watcher can promise on a bind mount.
+ */
+#ifndef SL0PPTY_PROJECT_H
+#define SL0PPTY_PROJECT_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+/* The file a project uses to say what it needs. One name, so there is no
+ * precedence question: a layout file by D2's rule, checked by `--check`, and
+ * highlighted by anything that knows KDL. */
+#define PROJECT_LAYOUT_FILE "sl0ppty.layout.kdl"
+
+/* How many projects one session will look at. A picker is not a filesystem
+ * browser: past this many the answer to "which project" is a search, not a
+ * list, and the list is what this is for. */
+#define PROJECTS_MAX 128
+
+typedef struct {
+ char name[64]; /* the directory's own name, which is what people call it */
+ char path[512]; /* absolute, resolved */
+ char slug[64]; /* `project:name.hash8` -- the purpose a workspace carries */
+ /* The project's own layout file, or "" for one inferred from a `.git`. The
+ * distinction is the whole of what "declared" means here, and the picker
+ * shows it: a project with no layout is an invitation to save one. */
+ char layout[512];
+ int64_t mtime; /* of `layout`, 0 when there is none. Drift is derivable from
+ * this and a pane's `since` without anything storing it. */
+} project_t;
+
+/* Every project under `roots`, sorted by name. `roots` is a NUL-terminated
+ * array of directories; `depth` is how many levels below each to look, and a
+ * directory that *is* a project is never descended into -- which is what keeps
+ * this off node_modules without a rule about node_modules.
+ *
+ * A root that does not exist is not an error. Neither is one with nothing in
+ * it: the setting is a statement of where you keep things, not a promise that
+ * you have any. */
+size_t project_scan(const char *const *roots, int depth, project_t *out,
+ size_t max);
+
+/* One project by name, or by path when `name` looks like one (it has a `/`).
+ * False when no root holds it -- which is also the containment check, since a
+ * path that no root holds is a path this session will not write to. */
+bool project_find(const char *const *roots, int depth, const char *name,
+ project_t *out);
+
+/* The purpose a workspace for this directory carries: `project:<name>.<hash>`
+ * of the resolved path. Hashed on the path rather than the name so that two
+ * worktrees of one repo are two workspaces, which is what they are. */
+void project_slug(const char *path, const char *name, char *out, size_t cap);
+
+#endif /* SL0PPTY_PROJECT_H */
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index 037a050..a6cdf6a 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -88,6 +88,19 @@ char *screen_dump_json(screen_t *s);
/* Expand a leading `~`, using `buf` when it has to. Returns `path` itself when
* there is nothing to do, so `buf` only has to outlive the result's use. */
const char *path_expand(const char *path, char *buf, size_t cap);
+/* `~`, then a relative path against `base` -- the directory the file that wrote
+ * it lives in, so a layout checked in with a project can say `cwd="."`. A NULL
+ * or empty `base` leaves a relative path alone, which is what it meant before
+ * there was a base to be relative to. */
+const char *path_resolve(const char *path, const char *base, char *buf,
+ size_t cap);
+/* The directory part of a path: the base `path_resolve` wants. `.` when there
+ * is no `/` in it. */
+const char *path_dir(const char *path, char *buf, size_t cap);
+/* The inverse of `path_resolve` for writing one back out: a path under `base`
+ * relative to it, `.` for `base` itself, and anything not under it unchanged.
+ * Points into `path` or a literal, so it needs no buffer. */
+const char *path_relative(const char *path, const char *base);
/* mkdir -p, for the directory a config or a session socket wants to live in. */
bool path_mkdirs(const char *dir);
@@ -146,6 +159,20 @@ const char *pane_label(const pane_t *p);
pid_t pane_pid(const pane_t *p);
/* Where it was started, which is not where it *is*: see pane_pid(). */
const char *pane_start_cwd(const pane_t *p);
+/* What the pane's terminal is running now, as a command line, or NULL when the
+ * answer is "your shell, at a prompt". Read from the pty's foreground process
+ * group, so it is the job that owns the terminal rather than a guess: `label`
+ * only knows what a layout said, which left a pane you split and typed a command
+ * into with nothing to write down. Shell-quoted, because `command=` is run
+ * through `/bin/sh -c`. */
+const char *pane_foreground(const pane_t *p, char *buf, size_t cap);
+/* How much history the pane keeps: a line count, and a byte ceiling that stops
+ * one wide, heavily styled pane from spending the machine to honour it. 0 lines
+ * is no scrollback; 0 bytes is no ceiling. Both are estimates -- lib-vt prunes a
+ * page at a time, so a pane keeps a little more than it was told, never less --
+ * and lowering either drops history at once, which is what makes this safe to
+ * apply to a running pane. */
+void pane_set_scrollback(pane_t *p, size_t lines, size_t bytes);
bool pane_start(pane_t *p);
void pane_free(pane_t *p);
int pane_fd(const pane_t *p);