aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.h
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/app.h
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/app.h')
-rw-r--r--src/app.h122
1 files changed, 117 insertions, 5 deletions
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);