From 7f8d625a8d43bc6187013a8d6972433bb496bde2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 22 Oct 2026 15:35:36 +0200 Subject: [PATCH] commands: one grammar for !, merged commands, a settings sheet, two-level help A `!` now means force or everything and nothing else (`:w!`, `:search!`, `:export!`, `:logout!`). A command that did a second thing behind its bang takes a word for it instead: `:space new|name|rm`, `:list name|rm`, `:task [add]|edit`, `:upload seal`, `:import zip [/]`. `:card` while reading a document makes a card about it, so `:card!` is gone. Merged: `:export` downloads what has focus, a file as it is or a document as markdown (`:download` folded in); `:share [off|new]` replaces `:unshare` and `:link`, and the list's `s` and `y` give way to it and the info sheet; `:noh` replaces `:clear`; `:vault [unlock|lock|passwd]` replaces `:unlock`, `:lock` and `:passwd`; Esc in the list puts down what `m` picked up before it closes the document, replacing `:unmark`. The old spellings run as their new lines for one release through a table in commands.ts; help and Tab leave them out, and the wiki lists them. A settings sheet (`,` or `:settings`) holds the options, the vault, the backup, the signed-in browsers, ssh keys and capture tokens. Each row runs the command a line would. Bare `:set`, `:vault`, `:backup`, `:sessions`, `:sshkey` and `:token` open it at their part. `?` shows the keys and commands to start with, flagged in the keymap and the command table; a second `?` (or `:help all`) shows all of them, and a filter always searches all of them. Help rows are keyed by key and description, as two contexts can share a label. --- README.md | 69 ++-- docs/wiki/Backup-and-Mail.md | 2 +- docs/wiki/Boards-and-Agenda.md | 10 +- docs/wiki/Capture.md | 2 +- docs/wiki/Commands.md | 93 ++--- docs/wiki/Deploying.md | 2 +- docs/wiki/Files-and-Search.md | 4 +- docs/wiki/Guide.md | 6 +- docs/wiki/Not-Losing-Things.md | 6 +- docs/wiki/SFTP.md | 2 +- docs/wiki/Sharing.md | 10 +- docs/wiki/Spaces-and-Documents.md | 4 +- docs/wiki/Vault.md | 10 +- docs/wiki/Writing.md | 2 +- scripts/wiki.ts | 30 +- src/error.rs | 2 +- src/routes/trash.rs | 2 +- src/routes/vault.rs | 2 +- web/src/App.svelte | 6 +- web/src/lib/Actions.svelte | 4 +- web/src/lib/Bar.svelte | 2 +- web/src/lib/CommandLine.svelte | 15 +- web/src/lib/Help.svelte | 48 ++- web/src/lib/Icon.svelte | 1 + web/src/lib/Info.svelte | 6 +- web/src/lib/Rail.svelte | 11 +- web/src/lib/Settings.svelte | 217 ++++++++++++ web/src/lib/Sheet.svelte | 5 + web/src/lib/StatusBar.svelte | 2 +- web/src/lib/actions.ts | 39 ++- web/src/lib/commands.ts | 563 +++++++++++++++--------------- web/src/lib/keymap.ts | 69 ++-- web/src/lib/modes.ts | 4 +- web/src/lib/store.svelte.ts | 6 +- 34 files changed, 799 insertions(+), 457 deletions(-) create mode 100644 web/src/lib/Settings.svelte diff --git a/README.md b/README.md index 28c82aa..b49ca60 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ keeps running untouched. `openssl rand -base64 32`. Changing it logs every device out, and so does changing `WORKSPACE_PASSWORD` or `:logout!`, which ends every session but needs no restart. Every signed-in browser is a row on the server: `:sessions` -lists them and `:sessions rm ` signs one out — a lost phone, say — without +(or `,`, the settings sheet) lists them and `:sessions rm ` signs one out — a lost phone, say — without touching the rest, and locks the vault as well. A setting that is set but cannot be read — a number out of range, a switch that is neither on nor off — stops the start with the reason, rather than being taken for its default. The database and @@ -139,7 +139,7 @@ it; in the document pane it is that document. In the browse pane: | `o` | a new document here, straight into insert mode | | `O` | a new folder here | | `i`, `a` | edit the open entry | -| `R` | rename what is highlighted — a document, a folder or a file — starting from its name (`:name!` renames the space) | +| `R` | rename what is highlighted — a document, a folder or a file — starting from its name (`:space name` renames the space) | | `m` / `p` | pick nodes up — `m` again puts one back, `M` takes the whole folder — then drop them all into the folder you walk to | | `za` | fold a document's own files open under it in the list, or away again | | `Ctrl`/`Shift`-click | pick a row up / every row from the cursor to it; drag picked-up rows onto a folder, a crumb or a space | @@ -151,21 +151,21 @@ it; in the document pane it is that document. In the browse pane: | `[d` / `]d` | the diary entry before / after the one open, by date | | `c` | capture a card into this space's inbox without leaving where you are — reading a document, a card about it; `Tab` on the line makes it a note, or a line in today's entry | | `Q` | the scratchpad, lowered over whatever is on screen — see [The scratchpad](docs/wiki/Writing.md#the-scratchpad) | -| `Esc`, `q` | close the document, and its tile when there are several | +| `Esc`, `q` | close the document, and its tile when there are several; `Esc` first puts down what `m` picked up | | `h` | up one level in the tree | | `/` | full-text search in this space (`:noh` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit | | `Ctrl-P` | full-text search across every space; `Enter` opens the match beside the results | | `n` / `N` | the next / previous search result, opened beside the results; past the last it starts again at the first | -| `s` / `y` | toggle sharing / mint a new share link — either asks first when it would cut readers off | | `x`, `dd` | delete the entry under the cursor into the trash (asks `y`/`N`) | | `u` | undo the last delete: it comes back out of the trash to where it was | | `:` | command line — `Tab` / `Shift-Tab` cycle the matching commands, then the argument (folders for `:cd` and `:mv`, spaces for `:space`, options for `:set` …); `↑` / `↓` recall the lines run before that start as this one does | | `Ctrl-S` | save from anywhere | -| `?` | help | +| `?` | help: the keys to start with, and every key and command on a second `?` | +| `,` | settings: vim and the clipboard, the vault, the backup, signed-in browsers, ssh keys and capture tokens | Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are -`:w`, `:wq`, `:q!`, `:new [template] [yyyy-mm-dd] [name]`, `:date`, `:name`, `:share`, `:link`, +`:w`, `:wq`, `:q!`, `:new [template] [yyyy-mm-dd] [name]`, `:date`, `:name`, `:share [off|new]`, `:upload`, `:search`, `:backup` and `:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓` there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the @@ -174,21 +174,28 @@ first, and `gx` opens it in a new tab. `[[` in insert mode offers the titles of documents to link to. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the list (`:bn` and `:bp` walk the tiles), `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, -`:clear` drops the search, `:trash` lists what was deleted, `:reload` re-reads the folder, `:stats` counts words +`:noh` drops the search, `:trash` lists what was deleted, `:reload` re-reads the folder, `:stats` counts words and lines, `:search!` searches every space, `:set clipboard` / `:set noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the session, `:logout!` every session and `:sessions rm ` one other, and -`:token` manages the tokens that capture from outside (see below). The tree has its own: `:space [name]` switches (`:space! ` -makes one, `:rmspace [name]` puts one in the trash with everything in it), `:mkdir ` +`:token` manages the tokens that capture from outside (see below). The tree has its own: `:space [name]` switches (`:space new ` +makes one, `:space rm [name]` puts one in the trash with everything in it), `:mkdir ` makes a folder, `:cd ` walks it and `:mv ` moves what is selected, and `:import` uploads a folder into the one being browsed -(`:import!` takes a `.zip` instead). `:upload` outside the editor puts files +(`:import zip` takes a `.zip` instead). `:upload` outside the editor puts files into the folder being browsed, and so does dropping them on the list. `:name ` renames whatever is under -the cursor — a document, a folder, or a card on a board — and `:name!` renames -the space itself. Two spaces cannot share a name, in any case, and no name is +the cursor — a document, a folder, or a card on a board — and `:space name ` +renames the space itself. Two spaces cannot share a name, in any case, and no name is longer than 200 characters. The board has -`:board`, `:card ` (`:card!` links the open document to it), -`:due <when|->`, `:repeat <rule|->`, `:done` and `:list <name>` (`:list!` deletes one). +`:board`, `:card <title>` (reading a document, the card is about it), +`:due <when|->`, `:repeat <rule|->`, `:done` and `:list <name>` (`:list name` renames one, `:list rm` deletes one). + +A `!` means force, or everything, and nothing else: `:w!`, `:q!`, `:search!`, +`:export!`, `:logout!`. A command that does a second thing takes a word for it +instead — `:space new`, `:share off`, `:upload seal`, `:import zip`, `:vault +unlock`. The old spellings (`:space!`, `:unshare`, `:unlock` …) still work for +one release; the [command reference](docs/wiki/Commands.md#old-spellings) lists +them. A single line break in a document is a line break, the way it was typed. A paragraph an editor wrapped at a fixed width — an import, a save over the mount @@ -233,7 +240,7 @@ keys get in, and only the ones registered from inside the workspace: ```text :sshkey add laptop ssh-ed25519 AAAA… you@laptop -:sshkey lists them, with their fingerprints +:sshkey the settings sheet's ssh keys, with their fingerprints :sshkey rm laptop and this one is out, mounted or not :sshkey vault laptop on this key may read sealed files while the vault is open ``` @@ -260,7 +267,7 @@ inbox new cards land in, the last is done. A card is finished exactly when it sits in the last column — moving it there stamps it, moving it out reopens it — so a tick and a column can never disagree; marking a card done through the API moves it there too, and deleting the last column makes the one before it done. -`:list <name>` adds a column in front of the last one, `:name!` on the board renames the column under the cursor, and +`:list <name>` adds a column in front of the last one, `:list name` renames the column under the cursor, and done cards older than a week fold away until `z` shows them. `c` captures from anywhere: `:card <title>` outside the board puts the card in @@ -291,7 +298,7 @@ saving it to the card; `a` puts a label on. Code in the description is coloured as nvim colours it. See the [wiki](docs/wiki/Boards-and-Agenda.md#the-back-of-a-card) for every key. -A card can point at a document: `:card!` while reading one makes a card for it, +A card can point at a document: `c` or `:card` while reading one makes a card for it, and `gf` on its back opens it again. `gf` on a card without one makes a document named after the card in the space's root and opens it to write in, and closing that document returns to the board on the same card. The link is deliberately loose — @@ -363,7 +370,7 @@ token in place of a session: ```text :token add phone makes one, shows it once and copies it -:token lists them, with when each was last used +:token the settings sheet's tokens, each with when it was last used — x there retires one :token rm phone and that one captures nothing from the next request on ``` @@ -424,7 +431,7 @@ the old version — sealing deletes the plaintext on purpose. Versions are not in the export, the mount or the readable view on Proton Drive; they are in `diary.db`, and so in its mirror. -A delete is not the end of anything. `x`, `dd`, `:delete` and `:rmspace` +A delete is not the end of anything. `x`, `dd`, `:delete` and `:space rm` put what they delete in the trash, with everything inside it, and `u` puts the last of them back where it was — the same folder, the same name, unless a new one took the name meanwhile. `:trash` lists everything in it, the last @@ -515,7 +522,7 @@ place: it for that one look and is never stored. Nothing of a private key is ever sent back but its algorithm and size. -Anything else is a download: `:download` (`:dl`) fetches the file under the +Anything else is a download: `:export` fetches the file under the cursor, and `gx` opens one in a tab of its own as the browser shows it. `:name` renames it, and the download is called whatever it is called now; `:mv`, `m` and `p` move it; `x` deletes it. `:upload` picks files for the folder being @@ -577,24 +584,24 @@ account the mirror writes to, and to a logged-in browser while the vault is shut — to anyone, in short, who does not know the vault password. `:vault` sets the password the first time (ten characters at least) and says -whether the vault is open afterwards. `:upload!` puts files into the folder +whether the vault is open afterwards. `:upload seal` puts files into the folder being browsed sealed — encrypted as they stream in, so the plaintext never touches the disk. `:encrypt` seals the file under the cursor; `:decrypt` takes -it back out. `:unlock` and `:lock` open and close it, `:passwd` changes the +it back out. `:vault unlock` and `:vault lock` open and close it, `:vault passwd` changes the password. A sealed file carries a lock in the list, and the bar says `vault open` while it is. -`:passwd` makes a new master key as well as a new password. The old password +`:vault passwd` makes a new master key as well as a new password. The old password stops opening anything at once; every sealed file is then re-sealed under the new key in the background, under a new name, while the vault is open — `:vault` shows how far it has come, and locking halfway only pauses it until the next -`:unlock`. The old copies are deleted from `uploads/` and, on the next mirror +`:vault unlock`. The old copies are deleted from `uploads/` and, on the next mirror run, from Proton Drive for good. After that, a copy of `diary.db` from before the change (a `diary.db.pre-*` snapshot, an old revision in Proton Drive) and the old password open nothing that is still kept anywhere. What they cannot take back is a copy someone already made of the files themselves. Re-sealing rewrites only each file's header, but every sealed file is uploaded to Proton -Drive once more. A second `:passwd` waits until the first has finished. +Drive once more. A second `:vault passwd` waits until the first has finished. `:encrypt` on a folder or space seals it for good, not just once: every file in it now, and every file that lands in it later. An upload, an import, an SFTP @@ -654,8 +661,8 @@ Proton cannot read either; only the vault password can read the sealed one. `:import` picks a folder and uploads it whole — every file carries the path it had inside, and the tree is rebuilt on the other side: directories become folders, `.md` and `.markdown` files become documents (titled by their first -heading), anything else becomes a file in the tree. `:import!` does the same with -a zip, which is the shape `:export!` writes. `:import! /` pours an `:export!` +heading), anything else becomes a file in the tree. `:import zip` does the same with +a zip, which is the shape `:export!` writes. `:import zip /` pours an `:export!` back in at the root: each directory becomes a space again — or goes into the space already there by that name — and with `.workspace.json` beside it the dates, names, boards and cards come back as they were, a document's files go @@ -766,7 +773,7 @@ back into an empty data directory — no tool in between, which is the point. | `narl-workspace backup-now` | mirror once and exit — for cron, or for nerves | | `narl-workspace proton-logout` | forget the session; the mirror stays where it is | -From inside the workspace, `:backup` says when the last one finished and `:backup!` +From inside the workspace, `:backup` (the settings sheet, `,`) says when the last one finished and `:backup!` runs one now. A failed backup is loud: it is reported by `:backup`, and the next tick retries it. @@ -848,13 +855,13 @@ The page sends the key on to the server once, in the body of the request that reads the entry, never in a URL. That answer gives the browser a cookie scoped to this one link's path, good for 12 hours, and the entry's pictures and videos are fetched with it — so not even this server's reverse proxy logs the -key. A fresh key from `:link`, or `:unshare`, voids the cookies handed out +key. A fresh key from `:share new`, or `:share off`, voids the cookies handed out under the old one. Because only the hash is kept, a link exists in full exactly once: in the moment -`:share` copies it. It cannot be re-read from the entry afterwards. `:link` +`:share` copies it. It cannot be re-read from the entry afterwards. `:share new` mints a fresh key for the same token — which retires the previous link, making -it the way to cut off a reader without unpublishing — and `:unshare` destroys +it the way to cut off a reader without unpublishing — and `:share off` destroys both halves. ## API diff --git a/docs/wiki/Backup-and-Mail.md b/docs/wiki/Backup-and-Mail.md index f072f38..d67b3a7 100644 --- a/docs/wiki/Backup-and-Mail.md +++ b/docs/wiki/Backup-and-Mail.md @@ -61,7 +61,7 @@ narl-workspace/ the device | command | what it does | | --- | --- | -| `:backup` | when the last one finished, and whether it failed | +| `:backup` | the backup in the settings sheet (`,`): when the last one finished, and whether it failed; `Enter` there runs one | | `:backup!` | run one now | | `:backup logout` | forget the session; the mirror stays where it is | | `narl-workspace proton-status` | account, device, schedule, how much is mirrored | diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md index f169dc5..bc1e4c4 100644 --- a/docs/wiki/Boards-and-Agenda.md +++ b/docs/wiki/Boards-and-Agenda.md @@ -51,8 +51,8 @@ something by where they stand, not by their names: list: moving it there stamps it, moving it out reopens it; - the lists between are **work in hand**, which the agenda shows. -`:list <name>` adds a list in front of the last one, `:name!` renames the list -under the cursor, and `:list!` deletes it with its cards. Done cards older than +`:list <name>` adds a list in front of the last one, `:list name` renames the list +under the cursor, and `:list rm` deletes it with its cards. Done cards older than a week fold away until `z` shows them. | key | what it does | @@ -84,7 +84,7 @@ as they do on the board. | `j` / `k`, `gg` / `G` | down / up the checklist, its first / last step | | `Space`, `x` | tick the step, or untick it | | `o` | a new step below the cursor (`:task <text>`) | -| `r` | reword the step (`:task! <text>`) | +| `r` | reword the step (`:task edit <text>`) | | `J` / `K` | move the step down / up | | `dd` | drop the step | | `e`, `i`, `Enter` | write the description | @@ -112,8 +112,8 @@ step open again. `c` in the tree opens `:card ` on the command line. The card goes into the inbox of the space you are in, or of the first space with a board, and -you stay where you were. Reading a document, `c` opens `:card! ` instead: the -card is about that document, and an empty title takes the document's. From +you stay where you were. Reading a document, `:card` makes the card about that +document, and an empty title takes the document's. From outside the browser, see [Capture](Capture.md). ### Notes diff --git a/docs/wiki/Capture.md b/docs/wiki/Capture.md index 532cf0c..eedac4f 100644 --- a/docs/wiki/Capture.md +++ b/docs/wiki/Capture.md @@ -8,7 +8,7 @@ token in place of a login. ```text :token add phone makes one, shows it once and copies it -:token lists them, with when each was last used +:token the settings sheet's tokens, each with when it was last used — x there retires one :token rm phone and that one captures nothing from the next request on ``` diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 4f1de26..ff59215 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -29,7 +29,7 @@ arguments and `↑` / `↓` recall earlier lines. | `Ctrl-D / Ctrl-U` | half a page down / up the open document | | `zl / zh · zL / zH` | the wide table or code block at the top of the view, sideways — a little, or half its width | | `]] / \[\[` | next / previous heading of the document — in the editor too | -| `gi` | the info sheet: the open document's headings, earlier versions, backlinks, cards and sharing — Tab between them | +| `gi` | the info sheet: the open document's headings, earlier versions, backlinks, cards, files and sharing — :share there, Tab between them | | `gO` | the headings as a list: j/k, Enter jumps there | | `F` | hints over the links and buttons in view — type one to follow it | | `gf / gx` | in the editor: follow the link under the cursor / open it in a tab | @@ -52,11 +52,9 @@ arguments and `↑` / `↓` recall earlier lines. | `Ctrl-P` | find anything, in every space — Enter opens the result lit beside them, n goes on | | `b` | the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's | | `c` | capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry | -| `Esc · q` | close the document, and its tile when there are several | +| `Esc · q` | close the document, and its tile when there are several — Esc first puts down what m picked up | | `/` | full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one | | `n / N` | next / previous search result, opened beside the results — :noh clears the search | -| `s` | toggle sharing for the document — asks before taking it back | -| `y` | mint a new share link and copy it — asks, the old one stops working | | `x · dd` | delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N) | | `u` | undo the last delete: it comes back out of the trash — :trash has the rest | | `gh` | the open document's earlier versions, as diffs — Enter brings one back | @@ -64,7 +62,8 @@ arguments and `↑` / `↓` recall earlier lines. | `gc` | the cards about the open document — j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board | | `:` | command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before | | `Ctrl-S` | save, from anywhere | -| `?` | this screen, from the editor too — h/l a column, / filters it down | +| `?` | this screen — the keys to start with, then on a second ? every key and command; h/l a column, / filters it down | +| `,` | the settings sheet: vim and the clipboard, the vault, the backup, signed-in browsers, ssh keys, capture tokens | | `q · Esc` | in a view — the board, the agenda, the calendar, a card's back — close its tile; the focus goes back to the tile before | ### On a board @@ -135,22 +134,20 @@ The first list is the inbox, the last list is done. ## Commands -A command takes a `!` for its forcing variant where one exists, the way vim's -do: `:w!`, `:space! <name>`, `:import!`. +A `!` means force, or everything, and nothing else: `:w!` writes over a +newer version, `:search!` looks in every space, `:export!` downloads the +whole workspace. A command that does a second thing takes a word for it: +`:space new <name>`, `:share off`, `:vault unlock`. ### Spaces & folders | command | what it does | | --- | --- | -| `:space, :sp [name]` | switch to a space, or list them | -| `:space!` | create a space with that name and switch to it | +| `:space, :sp [name] \| new <name> \| name <text> \| rm [name]` | switch to a space, or list them — new makes one, name renames the one you are in, rm puts one in the trash with everything in it (asks first) | | `:mkdir <name>` | make a folder here | | `:cd <name\|..\|/>` | enter a folder, .. goes up, / back to the space | -| `:import, :imp` | upload a folder into this one — markdown becomes documents, links follow | -| `:import!` | import loose files, or a .zip of a folder; :import! / pours an :export! back in, spaces and boards and all | +| `:import, :imp [zip [/]]` | upload a folder into this one — markdown becomes documents, links follow; zip takes loose files or a .zip instead, and zip / pours an :export! back in, spaces and boards and all | | `:move, :mv <folder\|..\|/>` | move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space | -| `:unmark` | put down everything picked up with m | -| `:rmspace, :rms [name]` | delete a space and everything in it, into the trash (asks first) | | `:trash [purge]` | what was deleted — j/k, Enter brings it back, x deletes it for good; :trash purge empties it | ### Boards @@ -159,16 +156,13 @@ do: `:w!`, `:space! <name>`, `:import!`. | --- | --- | | `:board` | this space's board, in a tile — the last one shown when this space has none | | `:plan, :special` | the board, the agenda and the calendar, tiled ahead of what is open — S puts them up, and every view away again; 1 … 9 on the board picks the board | -| `:card, :c <title>` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox | -| `:card!` | add a card linked to the open document — gf on it opens the document | +| `:card, :c [title]` | add a card — reading a document, one about it, titled after it unless named, and gf on it comes back; on the board to the list under the cursor, elsewhere to this space's inbox | | `:due <when\|->` | due date of the card in focus — on the board, its back, in the agenda, or the open document's: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears | | `:repeat <rule\|->` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it | | `:done` | move the card in focus into the done list, or back out to the inbox | -| `:task, :step <text>` | add a step to the open card's checklist, below the cursor | -| `:task!` | reword the step under the cursor | +| `:task, :step [add] <text> \| edit <text>` | add a step to the open card's checklist, below the cursor — edit rewords the step under it; add is only needed for a step that starts with edit | | `:label <name>` | put a label on the card, or take it off when it has it — each name keeps its colour everywhere | -| `:list <name>` | add a column to the board | -| `:list!` | delete the column under the cursor and every card on it | +| `:list <name> \| name <text> \| rm` | add a column to the board — name renames the one under the cursor, rm deletes it and every card on it (asks first) | | `:agenda, :ag` | what is late, due this week, or in hand — across every board | ### Documents @@ -195,7 +189,7 @@ do: `:w!`, `:space! <name>`, `:import!`. | `:info [outline\|history\|backlinks\|cards\|files\|share]` | what is known about the open document: its headings, earlier versions, backlinks, cards, files and sharing — Tab between them (gi) | | `:search, :se [text]` | full-text search in this space (empty clears) | | `:search!` | full-text search across every space — Ctrl-P | -| `:clear, :noh` | clear the search filter | +| `:noh, :nohlsearch` | clear the search filter | | `:reload, :r` | re-read the current folder from the server | ### Tiles @@ -221,46 +215,65 @@ do: `:w!`, `:space! <name>`, `:import!`. | `:quit!` | leave the editor, letting go of what is not saved yet | | `:edit, :e [id]` | edit the open document, or open a node by id | | `:edit!` | throw away the draft and re-read the document from the server | -| `:name, :title, :rename <text>` | rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title | -| `:name!` | rename the space you are in — on the board, the list under the cursor | +| `:name, :title, :rename <text>` | rename what has focus — document, folder, file, or card on a board; bare, while writing, into the title — :space name and :list name rename those | | `:date <yyyy-mm-dd>` | re-date the open document | -| `:upload, :up` | files for the open document — while writing, embedded at the cursor — or, from the list, into this folder | -| `:upload!` | put files into this folder sealed in the vault — encrypted before they touch the disk | +| `:upload, :up [seal]` | files for the open document — while writing, embedded at the cursor — or, from the list, into this folder; seal puts them into this folder sealed in the vault, encrypted before they touch the disk | ### Sharing & export | command | what it does | | --- | --- | -| `:share` | publish the document behind an unguessable link and copy it | -| `:unshare` | revoke the share link | -| `:link` | mint a new share link for the document and copy it | +| `:share [off\|new]` | publish the document behind an unguessable link and copy it — off takes it back, new mints a fresh link and the old one stops working; both ask first, and :info share shows where it stands | | `:copy, :yank` | copy the whole document to the clipboard as markdown | -| `:export, :exp` | download the open document as a .md file | +| `:export, :exp` | download what has focus — the file under the cursor as it is, a document as a .md file with what is typed so far | | `:export!` | download the whole workspace — every space, document and file — as a zip | -| `:download, :dl` | download the file under the cursor, or the open one — the open document goes as :export writes it | ### Vault | command | what it does | | --- | --- | -| `:vault` | whether the vault is open — or, the first time, set its password | -| `:unlock` | open the vault with its password, so sealed files can be read | -| `:lock` | close the vault now — it also closes itself when left alone, and on logout | -| `:passwd` | change the vault's password — every sealed file is then re-sealed under a new key, while the vault is open | +| `:vault [unlock\|lock\|passwd]` | the vault in the settings sheet, or, the first time, set its password — unlock opens it, lock closes it now, passwd changes the password and re-seals every file under a new key | | `:encrypt, :seal` | seal the file under the cursor in the vault — a folder stays sealed: every file in it, and every file added later | | `:decrypt, :unseal` | take the file under the cursor back out of the vault — on a sealed folder, unseal it and every file inside | -### Session +### Settings & session | command | what it does | | --- | --- | | `:stats` | word, line and node counts | -| `:set <option>` | vim, novim, clipboard, noclipboard, board, noboard | -| `:backup [login <proton-username> \| logout]` | when the workspace was last mirrored to proton drive — login sets the mirror up, logout forgets its session | +| `:set [option]` | vim, novim, clipboard, noclipboard, board, noboard — bare, the settings sheet | +| `:backup [login <proton-username> \| logout]` | the proton drive mirror, in the settings sheet — login sets it up, logout forgets its session | | `:backup!` | back up to proton drive now, rather than when the workspace falls quiet | -| `:sessions [rm <n>]` | every browser signed in, numbered — rm signs one of them out, and locks the vault | -| `:sshkey [add <name> <key> \| rm <name> \| vault <name> on\|off]` | list, add or remove keys allowed to mount the workspace over sftp, and say which may read sealed files | -| `:token [add <name> \| rm <name>]` | list, add or remove tokens that capture into the workspace from outside it — add shows the token once and copies it | -| `:help, :h` | show the key and command reference | +| `:sessions [rm <n>]` | every browser signed in, in the settings sheet — rm signs the n-th out, and locks the vault | +| `:sshkey [add <name> <key> \| rm <name> \| vault <name> on\|off]` | the keys allowed to mount the workspace over sftp, in the settings sheet — add, rm, and vault on\|off for which may read sealed files | +| `:token [add <name> \| rm <name>]` | the tokens that capture into the workspace from outside it, in the settings sheet — add shows a new one once and copies it, rm retires one | +| `:help, :h [all]` | the keys to start with — ? again, or :help all, for every key and command | +| `:settings [options\|vault\|backup\|sessions\|keys\|tokens]` | the settings sheet: vim and the clipboard, the vault, the backup, the browsers signed in, ssh keys and capture tokens — Tab between them (,) | | `:logout` | end the session | | `:logout!` | end every session, on every device — for a lost phone or a shared computer | + +### Old spellings + +These still work for one release, and run as the line beside them. Help and +`Tab` no longer offer them. + +| was | now | +| --- | --- | +| `:space!` | `:space new` | +| `:rmspace` | `:space rm` | +| `:rms` | `:space rm` | +| `:list!` | `:list rm` | +| `:task!` | `:task edit` | +| `:upload!` | `:upload seal` | +| `:import!` | `:import zip` | +| `:unshare` | `:share off` | +| `:link` | `:share new` | +| `:clear` | `:noh` | +| `:download` | `:export` | +| `:dl` | `:export` | +| `:unlock` | `:vault unlock` | +| `:lock` | `:vault lock` | +| `:passwd` | `:vault passwd` | +| `:name!` | `:list name` on the board, `:space name` elsewhere | +| `:card!` | `:card` while reading the document | +| `:unmark` | `Esc` in the list | diff --git a/docs/wiki/Deploying.md b/docs/wiki/Deploying.md index 4a1a354..0bae0a4 100644 --- a/docs/wiki/Deploying.md +++ b/docs/wiki/Deploying.md @@ -45,7 +45,7 @@ It joins the external `narl` network, drops the published port, and sets ## Sessions and security -- Every signed-in browser is a row on the server. `:sessions` lists them, +- Every signed-in browser is a row on the server. `:sessions` lists them in the settings sheet (`,`), `:sessions rm <n>` signs one out and locks the vault, and `:logout!` signs out every one. - Changing `WORKSPACE_SECRET` or `WORKSPACE_PASSWORD` also signs everyone out. diff --git a/docs/wiki/Files-and-Search.md b/docs/wiki/Files-and-Search.md index 4af587b..7aa6420 100644 --- a/docs/wiki/Files-and-Search.md +++ b/docs/wiki/Files-and-Search.md @@ -17,7 +17,7 @@ Put files in: - drop them onto the list; - `:upload` picks files for the folder you are in, or for the document you are reading; -- `:import` uploads a whole folder, and `:import!` a zip +- `:import` uploads a whole folder, and `:import zip` a zip (see [Not losing things](Not-Losing-Things.md#import)); - copy them in over the [SFTP mount](SFTP.md). @@ -44,7 +44,7 @@ not stored. Nothing of a private key is sent back but its algorithm and size. ![A certificate read on the server](../images/file-certificate.png) -Anything else is a download: `:download` (`:dl`) fetches the file under the +Anything else is a download: `:export` fetches the file under the cursor, and `gx` opens it in a browser tab of its own. Files are renamed with `:name`, moved like documents, and deleted into the trash with `x`. diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md index 86d1010..90ed347 100644 --- a/docs/wiki/Guide.md +++ b/docs/wiki/Guide.md @@ -42,7 +42,7 @@ counts while either is above zero. More in [Boards and the agenda](Boards-and-Ag Press `2` for the `work` space. A **space** is one area of your life, with its own folders, its own board and its own search. Make more with -`:space! <name>`; `w` shows all of them at once, with what was last written in +`:space new <name>`; `w` shows all of them at once, with what was last written in each. ![Every space at a glance](../images/overview.png) @@ -124,12 +124,12 @@ LaTeX between dollar signs and drawn by KaTeX: The slides came as a PDF. Drop it onto the list, or `:upload`, and it is a file in the folder, beside the notes about it. `Enter` opens a PDF, an image, a video, a CSV table, a text or source file, or a certificate in place. Anything -else downloads with `:dl`. +else downloads with `:export`. ![A CSV file as a table](../images/file-table.png) A scan of something private, like an ID or a contract, goes in with -`:upload!` instead. That seals it in the **vault**, under a password that is +`:upload seal` instead. That seals it in the **vault**, under a password that is never stored. See [The vault](Vault.md). ## On the way home: capture diff --git a/docs/wiki/Not-Losing-Things.md b/docs/wiki/Not-Losing-Things.md index 5e6c923..730e85e 100644 --- a/docs/wiki/Not-Losing-Things.md +++ b/docs/wiki/Not-Losing-Things.md @@ -36,7 +36,7 @@ restore can be undone the same way. ## The trash -A delete is not final. `x`, `dd`, `:delete`, `:rmspace` and an `rm` over the +A delete is not final. `x`, `dd`, `:delete`, `:space rm` and an `rm` over the mount put what they delete in the trash, with everything inside it. - `u` puts the last delete back where it was: the same folder, the same name, @@ -75,8 +75,8 @@ sealed files opened. - `:import` picks a folder and uploads it into the one you are in. Directories become folders, `.md` and `.markdown` files become documents (named by their first heading), and anything else becomes a file. -- `:import!` does the same with a zip. -- `:import! /` pours an `:export!` back in at the root. Each directory becomes a +- `:import zip` does the same with a zip. +- `:import zip /` pours an `:export!` back in at the root. Each directory becomes a space again, or goes into the space already there by that name, and with `.workspace.json` the dates, names, boards and cards come back as they were. diff --git a/docs/wiki/SFTP.md b/docs/wiki/SFTP.md index 7fce1a0..7b50ba7 100644 --- a/docs/wiki/SFTP.md +++ b/docs/wiki/SFTP.md @@ -11,7 +11,7 @@ in, and only the ones registered from inside the workspace: ```text :sshkey add laptop ssh-ed25519 AAAA… you@laptop -:sshkey lists them, with their fingerprints +:sshkey the settings sheet's ssh keys, with their fingerprints :sshkey rm laptop and this one is out, mounted or not :sshkey vault laptop on this key may read sealed files while the vault is open ``` diff --git a/docs/wiki/Sharing.md b/docs/wiki/Sharing.md index da60434..5051e9c 100644 --- a/docs/wiki/Sharing.md +++ b/docs/wiki/Sharing.md @@ -5,10 +5,10 @@ reader needs no account. | key or command | what it does | | --- | --- | -| `s`, `:share` | share the document and copy its link; on a shared one, `s` asks before taking it back | -| `y`, `:link` | make a new link for the same document; the old one stops working | -| `:unshare` | stop sharing | -| `:info share` | the share part of the info sheet: whether the document is public, the shape of its link, and the same `s` and `y` | +| `:share` | share the document and copy its link | +| `:share new` | make a new link for the same document; the old one stops working (asks first) | +| `:share off` | stop sharing (asks first) | +| `:info share` | the share part of the info sheet: whether the document is public and the shape of its link; `s` there shares or stops, `y` makes a new link | The link looks like `https://your-host/s/<token>#<key>`. The page renders the document read-only and serves only the pictures and videos it embeds right now: @@ -27,7 +27,7 @@ kept only the path holds a link that answers `404`. The server keeps only a SHA-256 of the key and compares it in constant time, and a wrong key is answered exactly like an unknown token. That also means the full link exists only once, when `:share` copies it. It cannot be read back from the -document later; `:link` makes a new one instead, which is also the way to cut a +document later; `:share new` makes a new one instead, which is also the way to cut a reader off without unpublishing. A document in the trash answers like an unknown link, and works again with the diff --git a/docs/wiki/Spaces-and-Documents.md b/docs/wiki/Spaces-and-Documents.md index a8e1778..e967f87 100644 --- a/docs/wiki/Spaces-and-Documents.md +++ b/docs/wiki/Spaces-and-Documents.md @@ -10,8 +10,8 @@ uni do not get mixed up. `Shift-Tab` step through them. - `w` shows every space at once, with what was last written in each. `hjkl` move, `Enter` goes there. -- `:space! <name>` makes a space, `:name! <name>` renames the one you are in, - and `:rmspace` puts it in the trash with everything in it. Two spaces cannot +- `:space new <name>` makes a space, `:space name <name>` renames the one you are in, + and `:space rm` puts it in the trash with everything in it. Two spaces cannot share a name, whatever the case. - `:set noboard` turns the board off for a space, `:set board` back on. diff --git a/docs/wiki/Vault.md b/docs/wiki/Vault.md index b52cfdd..5440c0e 100644 --- a/docs/wiki/Vault.md +++ b/docs/wiki/Vault.md @@ -14,12 +14,12 @@ the vault is shut. | command | what it does | | --- | --- | -| `:vault` | set the password the first time (ten characters at least); afterwards, say whether the vault is open | -| `:unlock` / `:lock` | open / close the vault | -| `:upload!` | upload files sealed, encrypted as they stream in, so the plaintext never touches the disk | +| `:vault` | set the password the first time (ten characters at least); afterwards, the vault in the settings sheet (`,`), where `Enter` opens or locks it | +| `:vault unlock` / `:vault lock` | open / close the vault | +| `:upload seal` | upload files sealed, encrypted as they stream in, so the plaintext never touches the disk | | `:encrypt` | seal the file under the cursor; on a folder or space, seal it for good | | `:decrypt` | take the file, or the sealed folder, back out | -| `:passwd` | change the password | +| `:vault passwd` | change the password | A sealed file carries a lock in the list, and the bar says `vault open` while it is. While the vault is shut, `Enter` on a sealed file asks for the password. @@ -51,7 +51,7 @@ copy once the vault shuts. A sealed file is never indexed for search. ## Changing the password -`:passwd` makes a new master key as well as a new password. The old password +`:vault passwd` makes a new master key as well as a new password. The old password stops opening anything at once. Every sealed file is then sealed again under the new key in the background while the vault is open; `:vault` shows how far it has come. After that, an old copy of `diary.db` together with the old password diff --git a/docs/wiki/Writing.md b/docs/wiki/Writing.md index c0a1228..51dc9dc 100644 --- a/docs/wiki/Writing.md +++ b/docs/wiki/Writing.md @@ -95,7 +95,7 @@ in its history. Either way the note leaves the scratchpad, as it does with `K`. `Enter` on one lowers the scratchpad with it under the cursor. The notes are in the backup, and an export of the whole workspace carries -them in `.workspace.json`, so `:import! /` puts them back. +them in `.workspace.json`, so `:import zip /` puts them back. ## Markdown diff --git a/scripts/wiki.ts b/scripts/wiki.ts index 148dfd7..a895e3d 100644 --- a/scripts/wiki.ts +++ b/scripts/wiki.ts @@ -88,6 +88,21 @@ function commandSpecs(source: string): Spec[] { }) } +/** The `renamed` table in commands.ts: an old spelling and the line it runs as now. */ +function renamedRows(source: string): string[] { + const start = source.indexOf('const renamed: Record<') + const block = source.slice(start, source.indexOf('\n}\n', start)) + const rows = [...block.matchAll(/^ '?([\w!]+)'?: '([^']+)',$/gm)].map(([, old, now]) => `| ${code(`:${old}`)} | ${code(`:${now}`)} |`) + if (rows.length === 0) throw new Error('commands.ts: no renamed commands found') + // The two that depend on where they are typed say so in their comment. + return [ + ...rows, + `| ${code(':name!')} | ${code(':list name')} on the board, ${code(':space name')} elsewhere |`, + `| ${code(':card!')} | ${code(':card')} while reading the document |`, + `| ${code(':unmark')} | ${code('Esc')} in the list |`, + ] +} + function commandsPage(): string { const help = read('web/src/lib/Help.svelte') const source = read('web/src/lib/commands.ts') @@ -144,10 +159,21 @@ ${keyTable('scratch')} ## Commands -A command takes a \`!\` for its forcing variant where one exists, the way vim's -do: \`:w!\`, \`:space! <name>\`, \`:import!\`. +A \`!\` means force, or everything, and nothing else: \`:w!\` writes over a +newer version, \`:search!\` looks in every space, \`:export!\` downloads the +whole workspace. A command that does a second thing takes a word for it: +\`:space new <name>\`, \`:share off\`, \`:vault unlock\`. ${sections.join('\n\n')} + +### Old spellings + +These still work for one release, and run as the line beside them. Help and +\`Tab\` no longer offer them. + +| was | now | +| --- | --- | +${renamedRows(source).join('\n')} ` ) } diff --git a/src/error.rs b/src/error.rs index 7884d13..bbb13ea 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,7 +30,7 @@ impl IntoResponse for AppError { AppError::Conflict(m) => (StatusCode::CONFLICT, m), AppError::Locked => ( StatusCode::LOCKED, - "the vault is locked — :unlock opens it".to_string(), + "the vault is locked — :vault unlock opens it".to_string(), ), AppError::TooManyRequests(wait) => ( StatusCode::TOO_MANY_REQUESTS, diff --git a/src/routes/trash.rs b/src/routes/trash.rs index f66386a..620a607 100644 --- a/src/routes/trash.rs +++ b/src/routes/trash.rs @@ -310,7 +310,7 @@ async fn fallback(db: &mut SqliteConnection, space: Option<i64>) -> AppResult<i6 .fetch_optional(&mut *db) .await? .ok_or_else(|| { - AppError::BadRequest("there is no space to restore it into — :space! makes one".into()) + AppError::BadRequest("there is no space to restore it into — :space new makes one".into()) }) } diff --git a/src/routes/vault.rs b/src/routes/vault.rs index 8c95ee2..321d9bd 100644 --- a/src/routes/vault.rs +++ b/src/routes/vault.rs @@ -44,7 +44,7 @@ pub async fn setup( strong_enough(&input.password)?; if !state.vault.setup(input.password).await? { return Err(AppError::BadRequest( - "there is a vault already — :passwd changes its password".into(), + "there is a vault already — :vault passwd changes its password".into(), )); } tracing::info!("vault set up"); diff --git a/web/src/App.svelte b/web/src/App.svelte index 30a8276..3329ac1 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -18,6 +18,7 @@ import Verify from './lib/Verify.svelte' import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' + import Settings from './lib/Settings.svelte' import Trash from './lib/Trash.svelte' import CardTile from './lib/CardTile.svelte' import Scratch from './lib/Scratch.svelte' @@ -44,11 +45,11 @@ let cmdGlobal = $state(false) let fileInput = $state<HTMLInputElement>() let treeInput = $state<HTMLInputElement>() - /** Whether the files being picked go into the vault — `:upload!`. */ + /** Whether the files being picked go into the vault — `:upload seal`. */ let sealUpload = false let folderInput = $state<HTMLInputElement>() let importInput = $state<HTMLInputElement>() - // Whether the next archive picked is poured in at the root, `:import! /`. + // Whether the next archive picked is poured in at the root, `:import zip /`. let importRoot = false let shell = $state<HTMLDivElement>() let overlayEl = $state<HTMLDivElement>() @@ -61,6 +62,7 @@ verify: Verify, info: Info, trash: Trash, + settings: Settings, } let boardEl = $state<HTMLDivElement>() /** The first half of `gg`, `dd`, `]]` …, shown until the second comes. */ diff --git a/web/src/lib/Actions.svelte b/web/src/lib/Actions.svelte index 72f7140..52cf834 100644 --- a/web/src/lib/Actions.svelte +++ b/web/src/lib/Actions.svelte @@ -35,10 +35,10 @@ if (node.kind === 'document') { list.push( node.shared - ? { label: 'stop sharing', run: () => runCommand('unshare') } + ? { label: 'stop sharing', run: () => runCommand('share off') } : { label: 'share — copies the link', run: () => runCommand('share') }, ) - if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') }) + if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('share new') }) } // Inside a sealed folder only that folder opens, as a whole. if (holdsFiles && node.kind !== 'space' && !(sealed && workspace.parentSealed)) { diff --git a/web/src/lib/Bar.svelte b/web/src/lib/Bar.svelte index 6f80a03..cd3f5bc 100644 --- a/web/src/lib/Bar.svelte +++ b/web/src/lib/Bar.svelte @@ -32,7 +32,7 @@ {#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}<span class="slug">{space.slug}</span> </button> {/each} - <button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}> + <button class="pill add" title="new space (:space new <name>)" onclick={() => hooks.openCommandLine(':space new ')}> + </button> </nav> diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index 913f266..97eb78b 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -28,7 +28,7 @@ <script lang="ts"> import { tick, untrack } from 'svelte' - import { argCompletions, completions, runCommand, hooks } from './commands' + import { argCompletions, completions, readingDocument, runCommand, hooks } from './commands' import { workspace } from './store.svelte' interface Props { @@ -56,17 +56,16 @@ /* * A capture line — `c` opens one — writes a thought down somewhere without * going there: a card, a note, a line in today's entry. Tab walks the three - * with the text kept. A card stays linked to the document being read when - * the line began as one that was. + * with the text kept. Reading a document, `:card` makes one about it. */ - const card = untrack(() => (initial.startsWith(':card! ') ? 'card!' : 'card')) - const targets = [card, 'scratch', 'today'] - const targetNames: Record<string, string> = { card: 'card', 'card!': 'card about this', scratch: 'note', today: 'today' } - const capture = $derived(/^:(card!?|scratch|today)\s/.exec(value)?.[1]) + const about = untrack(readingDocument) + const targets = ['card', 'scratch', 'today'] + const targetNames: Record<string, string> = { card: about ? 'card about this' : 'card', scratch: 'note', today: 'today' } + const capture = $derived(/^:(card|scratch|today)\s/.exec(value)?.[1]) function retarget(delta: 1 | -1): boolean { if (!capture) return false - const at = targets.indexOf(capture === 'card!' || capture === 'card' ? card : capture) + const at = targets.indexOf(capture) const next = targets[(at + delta + targets.length) % targets.length] value = value.replace(/^:\S+/, `:${next}`) return true diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 44e6a12..6e3f535 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -2,13 +2,30 @@ /* * The help is a sheet the size of the porthole, laid out in newspaper * columns that run on to the right, so all of it is in view at once on a - * wide screen and a key away on a narrow one. Its keys live in keymap.ts - * with the others; these are what they move. + * wide screen and a key away on a narrow one. It comes in two depths: the + * keys and commands to start with, then, on a second `?`, every one of + * them. Its keys live in keymap.ts with the others; these are what they + * move. */ + import { workspace } from './store.svelte' + let pages: HTMLElement | undefined = $state() let field: HTMLInputElement | undefined = $state() /** `/` in the help: only the rows whose key or words hold this. */ export const helpFilter = $state({ text: '' }) + /** Every key and command, rather than the ones to start with. */ + export const helpDepth = $state({ all: false }) + + export function openHelp(all = false) { + helpDepth.all = all + workspace.openSheet('help') + } + + /** `?` in the help: everything, and the help away once it is all on show. */ + export function deeperHelp() { + if (helpDepth.all) workspace.closeSheet() + else helpDepth.all = true + } /** A column on or back, a screen on or back, or to either end. */ export function scrollHelp(by: 1 | -1 | 'pagedown' | 'pageup' | 'start' | 'end') { @@ -40,7 +57,7 @@ <script lang="ts"> import { commands, groups, type CommandSpec, type Group } from './commands' import Sheet from './Sheet.svelte' - import { rows, sections as keySections } from './keymap' + import { essentials, rows, sections as keySections } from './keymap' const titles: Record<Group, string> = { tree: 'spaces & folders', @@ -50,7 +67,7 @@ editing: 'writing', sharing: 'sharing & export', vault: 'vault', - view: 'session', + view: 'settings & session', } type Row = { key: string; args?: string; description: string } @@ -59,10 +76,10 @@ const signature = (spec: CommandSpec) => `:${spec.name}${spec.aliases?.length ? `(${spec.aliases.join(' ')})` : ''}` - const fromSpecs = (specs: CommandSpec[]): Row[] => + const fromSpecs = (specs: CommandSpec[], bangs = true): Row[] => specs.flatMap((spec) => [ { key: signature(spec), args: spec.args, description: spec.help }, - ...(spec.bang ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []), + ...(spec.bang && bangs ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []), ]) /* The keys come out of the keymap that dispatches them, so the help @@ -72,9 +89,16 @@ ...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]), ] + /* The first page: what a day in the workspace needs, and where the rest is. */ + const first: [string, Row[]][] = [ + ['to start with', essentials()], + ['commands to start with', fromSpecs(commands.filter((spec) => spec.essential), false)], + ] + const shown = $derived.by(() => { const words = helpFilter.text.trim().toLowerCase().split(/\s+/).filter(Boolean) - if (words.length === 0) return sections + // A filter looks through all of it: what is looked for is rarely on the first page. + if (words.length === 0) return helpDepth.all ? sections : first const hit = (row: Row) => { const text = `${row.key} ${row.args ?? ''} ${row.description}`.toLowerCase() return words.every((word) => text.includes(word)) @@ -105,7 +129,13 @@ } </script> -<Sheet title="help" detail="{count} keys and commands" width={0} fill keys={['help', 'sheet']}> +<Sheet + title="help" + detail={helpDepth.all || filtering ? `${count} keys and commands` : `the ${count} to start with — ? again for all of them`} + width={0} + fill + keys={['help', 'sheet']} +> {#snippet actions()} <label class="filter" class:on={helpFilter.text !== ''}> <span class="slash">/</span> @@ -125,7 +155,7 @@ <section style="--key: {keyWidth(rows)}ch"> <h2>{title}</h2> <dl> - {#each rows as row (row.key)} + {#each rows as row (row.key + row.description)} {@const short = filtering ? row.description : brief(row.description)} <div class="row"> <dt>{row.key}{#if row.args}<span class="args">{row.args}</span>{/if}</dt> diff --git a/web/src/lib/Icon.svelte b/web/src/lib/Icon.svelte index 22e8879..06e0393 100644 --- a/web/src/lib/Icon.svelte +++ b/web/src/lib/Icon.svelte @@ -42,6 +42,7 @@ repeat: ['M17 3l3 3-3 3', 'M4 11V9a3 3 0 0 1 3-3h13', 'M7 21l-3-3 3-3', 'M20 13v2a3 3 0 0 1-3 3H4'], trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'], more: ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'], + settings: ['M4 6h9', 'M17 6h3', 'M15 4v4', 'M4 12h3', 'M11 12h9', 'M9 10v4', 'M4 18h11', 'M19 18h1', 'M17 16v4'], } satisfies Record<string, string[]> export type IconName = keyof typeof PATHS diff --git a/web/src/lib/Info.svelte b/web/src/lib/Info.svelte index 871d3a0..9077fa9 100644 --- a/web/src/lib/Info.svelte +++ b/web/src/lib/Info.svelte @@ -81,7 +81,7 @@ // A new link retires the one readers have, so it asks first. export async function renewLink(node: NodeSummary | null | undefined) { if (!node) return workspace.say('nothing selected', 'error') - if (!node.shared) return workspace.say('not shared — s shares it', 'error') + if (!node.shared) return workspace.say('not shared — :share shares it', 'error') if (!(await workspace.ask(`new share link for "${node.name.trim() || node.slug}"? the current one stops working`))) return await workspace.guard(() => workspace.renewShareLink(node.id)) } @@ -301,10 +301,6 @@ </Sheet> <style> - .tabs { display: flex; flex-wrap: wrap; gap: 4px; flex: none; } - .tabs button { text-transform: none; letter-spacing: 0; } - .tabs .on { background: var(--light); border-color: var(--light); color: var(--ink); } - .split { display: flex; gap: 18px; min-height: 0; flex: 1; } .versions { width: 34ch; flex: none; } .when { color: var(--fg); font-size: 13px; } diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index acf23cd..2ece825 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -30,6 +30,7 @@ const groups = $derived.by((): Door[][] => { const cmd: Door = { icon: 'prompt', label: 'Command line', keys: keysOf('global', 'line'), run: () => hooks.openCommandLine(':') } const help: Door = { icon: 'help', label: 'Help', keys: keysOf('global', 'help'), run: () => runCommand('help') } + const settings: Door = { icon: 'settings', label: 'Settings', keys: keysOf('global', 'settings'), run: () => runCommand('settings') } const focus = workspace.focus const board: Door = workspace.space?.has_board || focus === 'board' @@ -66,16 +67,16 @@ icon: 'rename', label: card ? 'Rename the card' : 'Rename the list', keys: keysOf('board', 'rename'), - run: () => hooks.openCommandLine(card ? ':name ' : ':name! '), + run: () => hooks.openCommandLine(card ? ':name ' : ':list name '), }, { icon: 'trash', label: card ? 'Delete the card' : 'Delete the list', - keys: card ? keysOf('board', 'delete') : [':list!'], - run: () => runCommand(card ? 'delete' : 'list!'), + keys: card ? keysOf('board', 'delete') : [':list rm'], + run: () => runCommand(card ? 'delete' : 'list rm'), }, ], - [cmd, help], + [cmd, settings, help], ] } @@ -112,7 +113,7 @@ { icon: 'search', label: 'Search this space', keys: keysOf('tree', 'search'), run: () => hooks.openCommandLine('/') }, { icon: 'search-all', label: 'Search every space', keys: keysOf('global', 'find'), run: () => hooks.openGlobalSearch() }, ], - [cmd, help], + [cmd, settings, help], ] }) diff --git a/web/src/lib/Settings.svelte b/web/src/lib/Settings.svelte new file mode 100644 index 0000000..89f3a51 --- /dev/null +++ b/web/src/lib/Settings.svelte @@ -0,0 +1,217 @@ +<script lang="ts" module> + import { api, type ApiToken, type BackupStatus, type SignedIn, type SshKey } from './api' + import { browserOf, describeBackup, describeVault, hooks, runCommand } from './commands' + import { workspace } from './store.svelte' + import { relative } from './util' + + /* + * Everything about the workspace that is not in it: how the editor + * behaves, the vault, the backup, who is signed in, and what may reach in + * from outside. Each row runs the command a line would, so the sheet and + * the line cannot drift apart, and the line still does all of it. + */ + export type SettingsSection = 'options' | 'vault' | 'backup' | 'sessions' | 'keys' | 'tokens' + export const settingsSections: SettingsSection[] = ['options', 'vault', 'backup', 'sessions', 'keys', 'tokens'] + + /** What the sheet shows, as last read; the part on show stays for the next time. */ + export const settings = $state({ + section: 'options' as SettingsSection, + cursor: 0, + sessions: [] as SignedIn[], + keys: [] as SshKey[], + tokens: [] as ApiToken[], + backup: null as BackupStatus | null, + }) + + export async function loadSettings() { + const [sessions, keys, tokens, backup] = await Promise.all([ + api.sessions(), + api.listSshKeys(), + api.listTokens(), + api.backupStatus(), + workspace.loadVault(), + ]) + Object.assign(settings, { sessions, keys, tokens, backup }) + settings.cursor = Math.min(settings.cursor, Math.max(settingRows().length - 1, 0)) + } + + /** The sheet, at `section` or where it was last left, read afresh. */ + export function openSettings(section?: SettingsSection) { + return workspace.guard(async () => { + if (section && section !== settings.section) { + settings.section = section + settings.cursor = 0 + } + await loadSettings() + workspace.openSheet('settings') + }) + } + + export interface Setting { + name: string + detail: string + /** Enter, or a click on the row. */ + act?: () => unknown + /** `x`, after a question. */ + remove?: () => unknown + } + + const run = (line: string) => () => runCommand(line) + const type = (line: string) => () => hooks.openCommandLine(line) + const confirm = (question: string, line: string) => async () => { + if (await workspace.ask(question)) await runCommand(line) + } + const used = (at: number | null) => (at === null ? 'never used' : `used ${relative(at)}`) + + /** The rows of a part of the sheet, from what was last read. */ + export function settingRows(section = settings.section): Setting[] { + switch (section) { + case 'options': { + const space = workspace.space + return [ + { + name: 'vim keys', + detail: workspace.vimEnabled ? 'on — the editor reads vim' : 'off — a plain editor', + act: run(workspace.vimEnabled ? 'set novim' : 'set vim'), + }, + { + name: 'clipboard', + detail: workspace.clipboard ? 'yanks go to the system clipboard' : 'yanks stay in vim registers', + act: run(workspace.clipboard ? 'set noclipboard' : 'set clipboard'), + }, + ...(space + ? [ + { + name: 'board', + detail: space.has_board ? `${space.slug} has a board` : `${space.slug} has none`, + act: run(space.has_board ? 'set noboard' : 'set board'), + }, + ] + : []), + ] + } + case 'vault': { + const vault = workspace.vault + if (!vault?.configured) { + return [{ name: 'set a password', detail: 'files sealed under it open only with it — it cannot be recovered', act: run('vault') }] + } + return [ + { + name: vault.unlocked ? 'lock' : 'unlock', + detail: `${describeVault()} · ${vault.sealed} sealed`, + act: run(vault.unlocked ? 'vault lock' : 'vault unlock'), + }, + { name: 'change the password', detail: 'every sealed file is re-sealed under a new key', act: run('vault passwd') }, + ] + } + case 'backup': { + const status = settings.backup + if (!status?.configured) { + return [{ name: 'log in to proton drive', detail: 'mirror the workspace there once it falls quiet', act: type(':backup login ') }] + } + return [ + { name: 'back up now', detail: describeBackup(status), act: run('backup!') }, + { name: 'log out', detail: 'forget the session — what is mirrored stays on drive', act: run('backup logout') }, + ] + } + case 'sessions': + return settings.sessions.map((session, index) => ({ + name: session.current ? 'this browser' : browserOf(session.user_agent), + detail: `seen ${relative(session.last_seen)} · signed in ${relative(session.created_at)}`, + remove: session.current + ? undefined + : confirm(`sign out ${browserOf(session.user_agent)}? the vault locks too`, `sessions rm ${index + 1}`), + })) + case 'keys': + return [ + ...settings.keys.map((key) => ({ + name: key.name, + detail: `${key.fingerprint} · ${key.vault ? 'reads sealed files' : 'no sealed files'} · ${used(key.last_used_at)}`, + act: run(`sshkey vault ${key.name} ${key.vault ? 'off' : 'on'}`), + remove: confirm(`remove the key ${key.name}? it mounts nothing from now on`, `sshkey rm ${key.name}`), + })), + { name: 'add a key', detail: ':sshkey add <name> <public key>', act: type(':sshkey add ') }, + ] + case 'tokens': + return [ + ...settings.tokens.map((token) => ({ + name: token.name, + detail: `made ${relative(token.created_at)} · ${used(token.last_used_at)}`, + remove: confirm(`retire the token ${token.name}? it captures nothing from now on`, `token rm ${token.name}`), + })), + { name: 'add a token', detail: 'shown once and copied — the server keeps only its hash', act: type(':token add ') }, + ] + } + } + + /** Tab and Shift-Tab: the next part along, round the ends. */ + export function turnSettings(by: number) { + const at = settingsSections.indexOf(settings.section) + settings.section = settingsSections[(at + by + settingsSections.length) % settingsSections.length] + settings.cursor = 0 + } + + export function moveSettings(by: number) { + settings.cursor = Math.min(Math.max(settings.cursor + by, 0), Math.max(settingRows().length - 1, 0)) + } + + export const actSetting = (index = settings.cursor) => void settingRows()[index]?.act?.() + export const removeSetting = (index = settings.cursor) => void settingRows()[index]?.remove?.() +</script> + +<script lang="ts"> + import Sheet from './Sheet.svelte' + + const rows = $derived(settingRows()) + + const tabs = $derived<[SettingsSection, string][]>([ + ['options', 'options'], + ['vault', workspace.vault?.configured ? `vault ${workspace.vault.unlocked ? 'open' : 'locked'}` : 'vault'], + ['backup', settings.backup?.configured ? `backup${settings.backup.last_error ? ' failing' : ''}` : 'backup off'], + ['sessions', `sessions ${settings.sessions.length}`], + ['keys', `ssh keys ${settings.keys.length}`], + ['tokens', `tokens ${settings.tokens.length}`], + ]) + + const turn = (section: SettingsSection) => { + settings.section = section + settings.cursor = 0 + } +</script> + +<Sheet title="settings" width={100} cursor={settings.cursor} keys={['settings', 'sheet']} hide={rows[settings.cursor]?.remove ? [] : ['remove']}> + <nav class="tabs"> + {#each tabs as [name, text] (name)} + <button class:on={name === settings.section} onclick={() => turn(name)}>{text}</button> + {/each} + </nav> + + <ul class="items"> + {#each rows as row, index (index)} + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions --> + <li + class="item" + class:at={index === settings.cursor} + data-index={index} + onpointerenter={() => (settings.cursor = index)} + onclick={() => actSetting(index)} + > + <span class="text"> + <span class="name">{row.name}</span> + <span class="muted">{row.detail}</span> + </span> + {#if row.remove} + <button class="danger" onclick={(event) => (event.stopPropagation(), removeSetting(index))}>remove</button> + {/if} + </li> + {:else} + <li class="muted">nothing here.</li> + {/each} + </ul> +</Sheet> + +<style> + /* A row with a button beside its two lines. */ + .items .item { flex-direction: row; align-items: center; gap: 12px; } + .text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } +</style> diff --git a/web/src/lib/Sheet.svelte b/web/src/lib/Sheet.svelte index 28a2665..0c88c2f 100644 --- a/web/src/lib/Sheet.svelte +++ b/web/src/lib/Sheet.svelte @@ -120,6 +120,11 @@ .sheet :global(.item .muted) { font-size: 11px; } .sheet :global(.danger:hover) { background: var(--alarm); border-color: var(--alarm); color: var(--ink); } + /* The parts of a sheet with several, one button each — info, settings. */ + .sheet :global(.tabs) { display: flex; flex-wrap: wrap; gap: 4px; flex: none; } + .sheet :global(.tabs button) { text-transform: none; letter-spacing: 0; } + .sheet :global(.tabs .on) { background: var(--light); border-color: var(--light); color: var(--ink); } + @media (max-width: 767px) { .sheet { padding: 14px; gap: 14px; } } diff --git a/web/src/lib/StatusBar.svelte b/web/src/lib/StatusBar.svelte index 038e31c..78b251d 100644 --- a/web/src/lib/StatusBar.svelte +++ b/web/src/lib/StatusBar.svelte @@ -34,7 +34,7 @@ {#if pending}<span class="pending" aria-live="polite">{pending}</span>{/if} {#if workspace.tiles.length > 1}<span class="flag" title=":ls lists the tiles">tile {workspace.focused + 1}/{workspace.tiles.length}</span>{/if} {#if workspace.fullscreen}<span class="flag">full</span>{/if} - {#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :lock closes it">vault open</span>{/if} + {#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :vault lock closes it">vault open</span>{/if} {#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if} <span class="hint"> {isTouchDevice ? 'tap : for commands' : '? help · : command · / search'} diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts index fdf2620..9668333 100644 --- a/web/src/lib/actions.ts +++ b/web/src/lib/actions.ts @@ -10,7 +10,8 @@ */ import { cycleTile, focusTile, followLink, hooks, openInfo, runCommand } from './commands' import { bindings, type Context } from './keymap' -import { focusHelpFilter, scrollHelp } from './Help.svelte' +import { deeperHelp, focusHelpFilter, scrollHelp } from './Help.svelte' +import { actSetting, moveSettings, removeSetting, turnSettings, openSettings } from './Settings.svelte' import { purgeTrashed, restoreTrashed } from './Trash.svelte' import { copyEmbed, embedFile, goHeading, moveHistory, openBacklink, openFile, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte' import { workspace } from './store.svelte' @@ -68,7 +69,7 @@ function openInTab() { if (!file) return workspace.say('gx opens a file — put the cursor on one', 'error') // A tab opened after the vault's question would be stopped as a popup, so // a sealed file asks for the vault to be opened first instead. - if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :unlock first, then gx', 'error') + if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :vault unlock first, then gx', 'error') followLink(file.url, true) } @@ -134,6 +135,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { start: () => scrollHelp('start'), end: () => scrollHelp('end'), filter: () => focusHelpFilter(), + deeper: () => deeperHelp(), }, /** The overview: hjkl across the grid, a digit or Enter to go. */ @@ -227,7 +229,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { due: () => { if (workspace.focusedCard) line(':due ') }, - new: () => line(':card! '), + new: () => line(':card '), label: () => { if (workspace.focusedCard) line(':label ') }, @@ -248,6 +250,17 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { link: () => void renewLink(workspace.open), }, + /** The settings sheet: Tab from part to part, j/k down it, Enter changes, x removes. */ + settings: { + next: () => turnSettings(1), + previous: () => turnSettings(-1), + down: () => moveSettings(1), + up: () => moveSettings(-1), + bottom: () => moveSettings(Infinity), + act: () => actSetting(), + remove: () => removeSetting(), + }, + /* * A card's back reads the list keys over its checklist, and the board's * card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do @@ -260,10 +273,11 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { top: () => void workspace.moveTask(-Infinity), bottom: () => void workspace.moveTask(Infinity), tick: () => guard(() => workspace.toggleTask()), - add: () => line(':task '), + // `add` spelled out, so a step that starts with "edit" is added too. + add: () => line(':task add '), reword: () => { const task = workspace.card?.checklist[workspace.taskCursor] - if (task) line(`:task! ${task.text}`) + if (task) line(`:task edit ${task.text}`) }, nudgeDown: () => guard(() => workspace.nudgeTask(1)), nudgeUp: () => guard(() => workspace.nudgeTask(-1)), @@ -389,8 +403,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { drop: () => guard(() => workspace.drop()), new: () => void runCommand('new'), // Capture: a thought goes on this space's inbox without leaving here. - // Reading a document, the card is about it. - capture: () => line(workspace.focus === 'node' && workspace.open?.kind === 'document' ? ':card! ' : ':card '), + // Reading a document, `:card` makes it about that document. + capture: () => line(':card '), // The name has to come from somewhere, and the command line is where // names are typed — this only saves typing `:mkdir `. mkdir: () => line(':mkdir '), @@ -405,12 +419,14 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { if (workspace.open) void runCommand('q') else workspace.unmarkAll() }, + // Esc lets go of one thing at a time: what is carried, then the document. + escape: () => { + if (workspace.marked.length) return workspace.unmarkAll() + if (workspace.open) void runCommand('q') + }, delete: () => void runCommand('d'), // A delete goes to the trash, so it can be undone the way vim undoes. undo: () => guard(() => workspace.undoDelete()), - share: () => void toggleSharing(workspace.target), - // A vim hand presses y to yank; here it retires the link readers have. - link: () => void renewLink(workspace.target), // Through the results, as vim's `n` goes through the matches; the // search itself stays until `:noh`. next: () => guard(() => workspace.nextResult(1)), @@ -447,7 +463,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { due: () => line(':due '), // On an empty list there is no card to rename, so it is the list. The // name is there to be edited, not typed again from nothing. - rename: () => line(workspace.card ? `:name ${workspace.card.title}` : `:name! ${workspace.list?.name ?? ''}`), + rename: () => line(workspace.card ? `:name ${workspace.card.title}` : `:list name ${workspace.list?.name ?? ''}`), old: () => void (workspace.showOldDone = !workspace.showOldDone), // A letter over every card in view: typing it puts the cursor there. hints: () => { @@ -539,6 +555,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = { plan: () => void runCommand('plan'), line: () => line(':'), help: () => void runCommand('help'), + settings: () => void openSettings(), }, editor: {}, diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 7bf9cb2..694b636 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,6 +1,8 @@ import { tick } from 'svelte' -import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api' -import { infoSections } from './Info.svelte' +import { api, ApiError, isContainer, type BackupLogin, type BackupStatus, type NodeSummary } from './api' +import { openHelp } from './Help.svelte' +import { infoSections, renewLink, toggleSharing } from './Info.svelte' +import { loadSettings, openSettings, settingsSections, type SettingsSection } from './Settings.svelte' import { workspace, type InfoSection } from './store.svelte' import { emptyTrash } from './Trash.svelte' import { formatDay, parseDay, parseDue, relative, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util' @@ -65,8 +67,25 @@ export interface CommandSpec { run: (arg: string) => unknown /** What the argument can be, for Tab on the command line. */ complete?: (arg: string) => string[] - /** The forcing variant, `:name!` — vim hands the bang over as an argument. */ + /** + * The forcing or everything variant, `:w!`, `:search!` — vim hands the + * bang over as an argument. A `!` means nothing else: a command that does + * a second thing takes a word for it, `:space new`. + */ bang?: { help: string; run: (arg: string) => unknown } + /** Among the few the first page of the help lists. */ + essential?: true +} + +/** + * The first word of `arg` when it is one of `verbs`, and what follows it: + * `rm work` is `['rm', 'work']`. Anything else is `['', arg]`, so a name + * that is not a verb is the plain command's argument. + */ +function verb(arg: string, verbs: readonly string[]): [string, string] { + const match = /^\s*(\S+)(?:\s+([\s\S]*))?$/.exec(arg) + if (match && verbs.includes(match[1])) return [match[1], (match[2] ?? '').trim()] + return ['', arg.trim()] } /** Close the focused tile; the index of the one that has the focus after. */ @@ -100,7 +119,7 @@ export async function openInfo(section?: InfoSection) { } /** A document is open and in focus — being written or read — rather than the list. */ -const readingDocument = () => +export const readingDocument = () => workspace.editing || (workspace.focus === 'node' && workspace.open?.kind === 'document') /** The board's cursor, or the card turned over beside it, has the keys. */ @@ -114,6 +133,10 @@ const requireOpen = () => { return workspace.open } +/** A card about `node`, titled after it unless named. */ +const cardAbout = (node: NodeSummary, title: string) => + workspace.guard(() => workspace.createCard(title.trim() || node.name.trim() || node.slug, node.id)) + const TITLE_HREF = titleHref('') /** @@ -166,6 +189,15 @@ const describeTile = (index: number) => { return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}` } +/** `:space rm`: a space, the one you are in unless named, into the trash. */ +async function removeSpace(name: string) { + const wanted = name.toLowerCase() + const space = wanted ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.space + if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error') + if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) return + await workspace.guard(() => workspace.deleteSpace(space.id)) +} + /** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */ const jumpTo = async (index: number) => { if (workspace.rows === 0) return workspace.say('nothing here', 'error') @@ -277,7 +309,7 @@ const ago = (at: number | null) => { } /** "firefox on linux", out of a user-agent string, as far as it says. */ -function browserOf(agent: string): string { +export function browserOf(agent: string): string { const browser = /Edg\//.test(agent) ? 'edge' : /Firefox\//.test(agent) @@ -303,16 +335,66 @@ function browserOf(agent: string): string { return system ? `${browser} on ${system}` : browser } -const describeBackup = (status: BackupStatus) => { - if (!status.configured) { - return workspace.say('proton drive backups are off — :backup login <proton-username> sets them up', 'error') - } - if (status.last_error) { - return workspace.say(`backup failed: ${status.last_error}`, 'error') - } +/** The mirror in a line. */ +export function describeBackup(status: BackupStatus): string { + if (!status.configured) return 'proton drive backups are off — :backup login <proton-username> sets them up' + if (status.last_error) return `backup failed: ${status.last_error}` const where = status.device ? `${status.device} · ` : '' const state = status.running ? 'running now' : status.pending ? 'changes waiting' : 'up to date' - workspace.say(`${where}${state} · last ${ago(status.last_success_at)}`) + return `${where}${state} · last ${ago(status.last_success_at)}` +} + +/** The vault in a line: open or locked, and what it is busy with. */ +export function describeVault(): string { + const vault = workspace.vault + if (!vault?.configured) return 'no vault yet' + const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : '' + const rekey = vault.rekey + ? vault.unlocked + ? ` · re-sealing after the password change, ${vault.rekey.total ? `${vault.rekey.done}/${vault.rekey.total}` : 'counting'}` + : ' · re-sealing after the password change waits for :vault unlock' + : '' + return vault.unlocked ? `open${left}${rekey}` : `locked${rekey}` +} + +/** The first time: a password for the vault, asked twice. */ +async function setupVault() { + const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered') + if (password === null) return + const again = await workspace.askSecret('the same again') + if (again === null) return + if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error') + workspace.vault = await api.setupVault(password) + workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload seal seals new files') +} + +async function changeVaultPassword() { + const current = await workspace.askSecret('current vault password') + if (current === null) return + const password = await workspace.askSecret('new vault password, 10+ characters') + if (password === null) return + const again = await workspace.askSecret('the same again') + if (again === null) return + if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error') + workspace.vault = await api.changeVaultPassword(current, password) + workspace.say( + workspace.vault.rekey + ? `vault password changed — re-sealing ${workspace.vault.sealed} file(s) under the new key; leave the vault open, :vault shows how far` + : 'vault password changed', + ) +} + +/** The open document, or the one under the cursor, as a `.md` file in the downloads. */ +async function exportDocument(node: NodeSummary) { + const { name, body, created_at } = node.id === workspace.open?.id ? workspace.draft : await api.getNode(node.id) + const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body + const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = exportName(name.trim(), created_at) + anchor.click() + URL.revokeObjectURL(url) + workspace.say(`wrote ${anchor.download}`) } /** @@ -377,6 +459,7 @@ export const commands: CommandSpec[] = [ name: 'write', aliases: ['w'], group: 'editing', + essential: true, help: "save what is being written — the open document, a card's description, a note — rather than in a moment", run: () => workspace.guard(() => (workspace.writing ? workspace.saveWriting() : workspace.save())), bang: { @@ -401,6 +484,7 @@ export const commands: CommandSpec[] = [ name: 'quit', aliases: ['q'], group: 'editing', + essential: true, help: 'leave the editor with what is waiting written first, or close the document when already reading', run: async () => { if (workspace.writing || workspace.editing) return leaveEditor() @@ -432,28 +516,33 @@ export const commands: CommandSpec[] = [ { name: 'space', aliases: ['sp'], - args: '[name]', + args: '[name] | new <name> | name <text> | rm [name]', group: 'tree', - help: 'switch to a space, or list them', - complete: spaceNames, + help: 'switch to a space, or list them — new makes one, name renames the one you are in, rm puts one in the trash with everything in it (asks first)', + complete: (arg) => (arg.startsWith('rm ') ? spaceNames().map((name) => `rm ${name}`) : ['new ', 'name ', 'rm ', ...spaceNames()]), run: async (arg) => { - const wanted = arg.trim().toLowerCase() + const [what, rest] = verb(arg, ['new', 'name', 'rm']) + if (what === 'new') { + if (!rest) return workspace.say('usage: :space new <name>', 'error') + return workspace.guard(() => workspace.createSpace(rest)) + } + if (what === 'name') { + const space = workspace.space + if (!space) return workspace.say('no space open', 'error') + if (!rest) return workspace.say('usage: :space name <text>', 'error') + return workspace.guard(() => workspace.renameNode(space.id, rest)) + } + if (what === 'rm') return removeSpace(rest) + const wanted = rest.toLowerCase() if (!wanted) { return workspace.say(workspace.spaces.map((space) => space.slug).join(' · ') || 'no spaces') } const space = workspace.spaces.find( (candidate) => candidate.slug === wanted || candidate.name.toLowerCase() === wanted, ) - if (!space) return workspace.say(`no such space: ${wanted} — :space! ${wanted} makes it`, 'error') + if (!space) return workspace.say(`no such space: ${wanted} — :space new ${wanted} makes it`, 'error') await workspace.guard(() => workspace.enterSpace(space.id)) }, - bang: { - help: 'create a space with that name and switch to it', - run: async (arg) => { - if (!arg.trim()) return workspace.say('usage: :space! <name>', 'error') - await workspace.guard(() => workspace.createSpace(arg.trim())) - }, - }, }, { name: 'mkdir', @@ -488,12 +577,15 @@ export const commands: CommandSpec[] = [ { name: 'import', aliases: ['imp'], + args: '[zip [/]]', group: 'tree', - help: 'upload a folder into this one — markdown becomes documents, links follow', - run: () => hooks.pickFolder(), - bang: { - help: 'import loose files, or a .zip of a folder; :import! / pours an :export! back in, spaces and boards and all', - run: (arg) => hooks.pickArchive(arg.trim() === '/'), + help: 'upload a folder into this one — markdown becomes documents, links follow; zip takes loose files or a .zip instead, and zip / pours an :export! back in, spaces and boards and all', + complete: () => ['zip', 'zip /'], + run: (arg) => { + const [what, rest] = verb(arg, ['zip']) + if (what === 'zip' && (rest === '' || rest === '/')) return hooks.pickArchive(rest === '/') + if (arg.trim()) return workspace.say('usage: :import [zip [/]]', 'error') + hooks.pickFolder() }, }, { @@ -512,24 +604,15 @@ export const commands: CommandSpec[] = [ { name: 'card', aliases: ['c'], - args: '<title>', + args: '[title]', group: 'board', - help: "add a card — on the board to the list under the cursor, elsewhere to this space's inbox", + help: "add a card — reading a document, one about it, titled after it unless named, and gf on it comes back; on the board to the list under the cursor, elsewhere to this space's inbox", run: async (arg) => { + const open = workspace.open + if (readingDocument() && open) return cardAbout(open, arg) if (!arg.trim()) return workspace.say('usage: :card <title>', 'error') await workspace.guard(() => workspace.createCard(arg.trim())) }, - bang: { - help: 'add a card linked to the open document — gf on it opens the document', - run: async (arg) => { - const node = workspace.target - if (!node || node.kind !== 'document') { - return workspace.say('no document to link — open one first', 'error') - } - const title = arg.trim() || node.name.trim() || node.slug - await workspace.guard(() => workspace.createCard(title, node.id)) - }, - }, }, { name: 'due', @@ -606,21 +689,14 @@ export const commands: CommandSpec[] = [ { name: 'task', aliases: ['step'], - args: '<text>', + args: '[add] <text> | edit <text>', group: 'board', - help: "add a step to the open card's checklist, below the cursor", + help: "add a step to the open card's checklist, below the cursor — edit rewords the step under it; add is only needed for a step that starts with edit", run: async (arg) => { if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error') - if (!arg.trim()) return workspace.say('usage: :task <text>', 'error') - await workspace.guard(() => workspace.addTask(arg.trim())) - }, - bang: { - help: 'reword the step under the cursor', - run: async (arg) => { - if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error') - if (!arg.trim()) return workspace.say('usage: :task! <text>', 'error') - await workspace.guard(() => workspace.renameTask(arg.trim())) - }, + const [what, text] = verb(arg, ['add', 'edit']) + if (!text) return workspace.say(`usage: :task ${what === 'edit' ? 'edit' : '[add]'} <text>`, 'error') + await workspace.guard(() => (what === 'edit' ? workspace.renameTask(text) : workspace.addTask(text))) }, }, { @@ -637,24 +713,27 @@ export const commands: CommandSpec[] = [ }, { name: 'list', - args: '<name>', + args: '<name> | name <text> | rm', group: 'board', - help: 'add a column to the board', + help: 'add a column to the board — name renames the one under the cursor, rm deletes it and every card on it (asks first)', + complete: () => ['name ', 'rm'], run: async (arg) => { if (!workspace.view('board')) return workspace.say('no board open — :board', 'error') - if (!arg.trim()) return workspace.say('usage: :list <name>', 'error') - await workspace.guard(() => workspace.createList(arg.trim())) - }, - bang: { - help: 'delete the column under the cursor and every card on it', - run: async () => { - if (!workspace.view('board')) return workspace.say('no board open — :board', 'error') - const list = workspace.list + const [what, rest] = verb(arg, ['name', 'rm']) + const list = workspace.list + if (what === 'name') { + if (!list) return workspace.say('no list selected', 'error') + if (!rest) return workspace.say('usage: :list name <text>', 'error') + return workspace.guard(() => workspace.renameList(rest)) + } + if (what === 'rm') { if (!list) return workspace.say('no list selected', 'error') const cards = list.cards.length if (!(await workspace.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return - await workspace.guard(() => workspace.deleteList()) - }, + return workspace.guard(() => workspace.deleteList()) + } + if (!rest) return workspace.say('usage: :list <name>', 'error') + await workspace.guard(() => workspace.createList(rest)) }, }, { @@ -662,6 +741,7 @@ export const commands: CommandSpec[] = [ aliases: ['mv'], args: '<folder|..|/>', group: 'tree', + essential: true, help: 'move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space', complete: folderNames, run: async (arg) => { @@ -685,17 +765,12 @@ export const commands: CommandSpec[] = [ await workspace.guard(() => workspace.moveNode(node.id, destination)) }, }, - { - name: 'unmark', - group: 'tree', - help: 'put down everything picked up with m', - run: () => workspace.unmarkAll(), - }, { name: 'new', aliases: ['n', 'o'], args: '[template] [yyyy-mm-dd] [name]', group: 'documents', + essential: true, help: "start a new document here — from one of the space's templates, dated, named; in the diary from templates/day", complete: (arg) => (arg.includes(' ') ? [] : templateNames()), run: async (arg) => { @@ -910,10 +985,11 @@ export const commands: CommandSpec[] = [ aliases: ['title', 'rename'], args: '<text>', group: 'editing', - help: 'rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title', + help: 'rename what has focus — document, folder, file, or card on a board; bare, while writing, into the title — :space name and :list name rename those', run: async (arg) => { if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle() if (onBoard()) { + if (!workspace.card) return workspace.say('no card here — :list name renames the list', 'error') if (!arg.trim()) return workspace.say('usage: :name <text>', 'error') return workspace.guard(() => workspace.patchCard({ title: arg.trim() })) } @@ -928,41 +1004,9 @@ export const commands: CommandSpec[] = [ return workspace.guard(() => workspace.save()) } - if (!node) return workspace.say('nothing selected — :name! renames the space', 'error') + if (!node) return workspace.say('nothing selected — :space name renames the space', 'error') await workspace.guard(() => workspace.renameNode(node.id, arg)) }, - bang: { - help: 'rename the space you are in — on the board, the list under the cursor', - run: async (arg) => { - if (onBoard()) { - if (!arg.trim()) return workspace.say('usage: :name! <list name>', 'error') - return workspace.guard(() => workspace.renameList(arg.trim())) - } - const space = workspace.space - if (!space) return workspace.say('no space open', 'error') - if (!arg.trim()) return workspace.say('usage: :name! <text>', 'error') - await workspace.guard(() => workspace.renameNode(space.id, arg)) - }, - }, - }, - { - name: 'rmspace', - aliases: ['rms'], - args: '[name]', - group: 'tree', - help: 'delete a space and everything in it, into the trash (asks first)', - complete: spaceNames, - run: async (arg) => { - const wanted = arg.trim().toLowerCase() - const space = wanted - ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) - : workspace.space - if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error') - if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) { - return - } - await workspace.guard(() => workspace.deleteSpace(space.id)) - }, }, { name: 'history', @@ -992,6 +1036,7 @@ export const commands: CommandSpec[] = [ name: 'trash', args: '[purge]', group: 'tree', + essential: true, help: 'what was deleted — j/k, Enter brings it back, x deletes it for good; :trash purge empties it', complete: () => ['purge'], run: async (arg) => { @@ -1020,38 +1065,20 @@ export const commands: CommandSpec[] = [ }, { name: 'share', + args: '[off|new]', group: 'sharing', - help: 'publish the document behind an unguessable link and copy it', - run: async () => { + essential: true, + help: 'publish the document behind an unguessable link and copy it — off takes it back, new mints a fresh link and the old one stops working; both ask first, and :info share shows where it stands', + complete: () => ['off', 'new'], + run: async (arg) => { const node = workspace.target - const id = node?.id - if (!id) return workspace.say('nothing selected', 'error') - if (node?.shared) return workspace.say('already shared — :link for a new link, :unshare to revoke') - await workspace.guard(() => workspace.toggleShare(id)) - }, - }, - { - name: 'unshare', - group: 'sharing', - help: 'revoke the share link', - run: async () => { - const node = workspace.target - const id = node?.id - if (!id) return workspace.say('nothing selected', 'error') - if (!node?.shared) return workspace.say('not shared') - await workspace.guard(() => workspace.toggleShare(id)) - }, - }, - { - name: 'link', - group: 'sharing', - help: 'mint a new share link for the document and copy it', - run: async () => { - const node = workspace.target - const id = node?.id - if (!id) return workspace.say('nothing selected', 'error') - if (!node?.shared) return workspace.say('not shared — :share first', 'error') - await workspace.guard(() => workspace.renewShareLink(id)) + if (!node) return workspace.say('nothing selected', 'error') + const what = arg.trim() + if (what === 'off') return node.shared ? toggleSharing(node) : workspace.say('not shared') + if (what === 'new') return renewLink(node) + if (what) return workspace.say('usage: :share [off|new]', 'error') + if (node.shared) return workspace.say('already shared — :share new for a fresh link, :share off to take it back') + await workspace.guard(() => workspace.toggleShare(node.id)) }, }, { @@ -1070,19 +1097,21 @@ export const commands: CommandSpec[] = [ name: 'export', aliases: ['exp'], group: 'sharing', - help: 'download the open document as a .md file', - run: () => { - if (!requireOpen()) return - const { name, body, created_at } = workspace.draft - const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body - const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = exportName(name.trim(), created_at) - anchor.click() - URL.revokeObjectURL(url) - workspace.say(`wrote ${anchor.download}`) - }, + essential: true, + help: 'download what has focus — the file under the cursor as it is, a document as a .md file with what is typed so far', + run: () => + workspace.guard(async () => { + const node = workspace.target + if (!node) return workspace.say('nothing selected — :export! downloads everything', 'error') + if (node.kind === 'document') return exportDocument(node) + if (!node.file) return workspace.say(`a ${node.kind} goes with :export!, the whole workspace`, 'error') + if (node.file.encrypted && !(await workspace.ensureUnlocked())) return + const anchor = document.createElement('a') + anchor.href = node.file.url + anchor.download = node.name + anchor.click() + workspace.say(`downloading ${node.name}`) + }), bang: { help: 'download the whole workspace — every space, document and file — as a zip', run: async () => { @@ -1101,25 +1130,6 @@ export const commands: CommandSpec[] = [ }, }, }, - { - name: 'download', - aliases: ['dl'], - group: 'sharing', - help: 'download the file under the cursor, or the open one — the open document goes as :export writes it', - run: () => - workspace.guard(async () => { - const node = workspace.target - if (!node) return workspace.say('nothing selected', 'error') - if (node.kind === 'document' && node.id === workspace.open?.id) return runCommand('export') - if (!node.file) return workspace.say(`a ${node.kind} is no file — :export! downloads everything`, 'error') - if (node.file.encrypted && !(await workspace.ensureUnlocked())) return - const anchor = document.createElement('a') - anchor.href = node.file.url - anchor.download = node.name - anchor.click() - workspace.say(`downloading ${node.name}`) - }), - }, { name: 'source', group: 'documents', @@ -1131,15 +1141,20 @@ export const commands: CommandSpec[] = [ { name: 'upload', aliases: ['up'], + args: '[seal]', group: 'editing', - help: "files for the open document — while writing, embedded at the cursor — or, from the list, into this folder", - run: () => (readingDocument() ? hooks.pickFiles() : hooks.pickTreeFiles(false)), - bang: { - help: 'put files into this folder sealed in the vault — encrypted before they touch the disk', - run: () => - workspace.guard(async () => { + essential: true, + help: 'files for the open document — while writing, embedded at the cursor — or, from the list, into this folder; seal puts them into this folder sealed in the vault, encrypted before they touch the disk', + complete: () => ['seal'], + run: (arg) => { + const what = arg.trim() + if (what === 'seal') { + return workspace.guard(async () => { if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true) - }), + }) + } + if (what) return workspace.say('usage: :upload [seal]', 'error') + return readingDocument() ? hooks.pickFiles() : hooks.pickTreeFiles(false) }, }, { @@ -1174,8 +1189,8 @@ export const commands: CommandSpec[] = [ }, }, { - name: 'clear', - aliases: ['noh'], + name: 'noh', + aliases: ['nohlsearch'], group: 'documents', help: 'clear the search filter', run: () => workspace.guard(() => workspace.search('')), @@ -1208,12 +1223,13 @@ export const commands: CommandSpec[] = [ }, { name: 'set', - args: '<option>', + args: '[option]', group: 'view', - help: 'vim, novim, clipboard, noclipboard, board, noboard', + help: 'vim, novim, clipboard, noclipboard, board, noboard — bare, the settings sheet', complete: () => ['vim', 'novim', 'clipboard', 'noclipboard', 'board', 'noboard'], run: (arg) => { const option = arg.trim() + if (!option) return openSettings('options') if (option === 'vim') return workspace.setVim(true), workspace.say('vim keys on') if (option === 'novim') return workspace.setVim(false), workspace.say('vim keys off') if (option === 'clipboard') @@ -1231,12 +1247,12 @@ export const commands: CommandSpec[] = [ name: 'backup', group: 'view', args: '[login <proton-username> | logout]', - help: 'when the workspace was last mirrored to proton drive — login sets the mirror up, logout forgets its session', + help: 'the proton drive mirror, in the settings sheet — login sets it up, logout forgets its session', complete: () => ['login ', 'logout'], run: (arg) => workspace.guard(async () => { const trimmed = arg.trim() - if (trimmed === '') return describeBackup(await api.backupStatus()) + if (trimmed === '') return openSettings('backup') const loginMatch = /^login\s+(\S+)$/.exec(trimmed) if (loginMatch) return protonLogin(loginMatch[1]) if (trimmed === 'logout') { @@ -1270,18 +1286,12 @@ export const commands: CommandSpec[] = [ name: 'sessions', args: '[rm <n>]', group: 'view', - help: 'every browser signed in, numbered — rm signs one of them out, and locks the vault', + help: 'every browser signed in, in the settings sheet — rm signs the n-th out, and locks the vault', run: (arg) => workspace.guard(async () => { const trimmed = arg.trim() + if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') return openSettings('sessions') const sessions = await api.sessions() - if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') { - return workspace.say( - sessions - .map((s, i) => `${i + 1} ${s.current ? 'this one' : browserOf(s.user_agent)}, seen ${relative(s.last_seen)}`) - .join(' · '), - ) - } const rmMatch = /^rm\s+(\d+)$/.exec(trimmed) if (!rmMatch) return workspace.say('usage: :sessions [rm <n>]', 'error') const target = sessions[Number(rmMatch[1]) - 1] @@ -1296,15 +1306,12 @@ export const commands: CommandSpec[] = [ name: 'sshkey', args: '[add <name> <key> | rm <name> | vault <name> on|off]', group: 'view', - help: 'list, add or remove keys allowed to mount the workspace over sftp, and say which may read sealed files', + help: 'the keys allowed to mount the workspace over sftp, in the settings sheet — add, rm, and vault on|off for which may read sealed files', + complete: () => ['add ', 'rm ', 'vault '], run: (arg) => workspace.guard(async () => { const trimmed = arg.trim() - if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') { - const keys = await api.listSshKeys() - if (keys.length === 0) return workspace.say('no keys registered — :sshkey add <name> <key>') - return workspace.say(keys.map((k) => `${k.name}${k.vault ? ' [vault]' : ''} ${k.fingerprint}`).join(' · ')) - } + if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') return openSettings('keys') const addMatch = /^add\s+(\S+)\s+(.+)$/.exec(trimmed) if (addMatch) { const [, name, key] = addMatch @@ -1340,7 +1347,7 @@ export const commands: CommandSpec[] = [ name: 'token', args: '[add <name> | rm <name>]', group: 'view', - help: 'list, add or remove tokens that capture into the workspace from outside it — add shows the token once and copies it', + help: 'the tokens that capture into the workspace from outside it, in the settings sheet — add shows a new one once and copies it, rm retires one', complete: (arg) => { // Tab cannot wait for the server, so the names are fetched the first // time it is pressed here and offered from then on. @@ -1353,16 +1360,7 @@ export const commands: CommandSpec[] = [ run: (arg) => workspace.guard(async () => { const trimmed = arg.trim() - if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') { - const tokens = await api.listTokens() - tokenNames = tokens.map((t) => t.name) - if (tokens.length === 0) return workspace.say('no tokens — :token add <name> makes one') - return workspace.say( - tokens - .map((t) => `${t.name}, ${t.last_used_at === null ? 'never used' : `used ${relative(t.last_used_at)}`}`) - .join(' · '), - ) - } + if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') return openSettings('tokens') const addMatch = /^add\s+(.+)$/.exec(trimmed) if (addMatch) { const added = await api.addToken(addMatch[1].trim()) @@ -1388,73 +1386,27 @@ export const commands: CommandSpec[] = [ }, { name: 'vault', + args: '[unlock|lock|passwd]', group: 'vault', - help: 'whether the vault is open — or, the first time, set its password', - run: () => + help: 'the vault in the settings sheet, or, the first time, set its password — unlock opens it, lock closes it now, passwd changes the password and re-seals every file under a new key', + complete: () => ['unlock', 'lock', 'passwd'], + run: (arg) => workspace.guard(async () => { + const what = arg.trim() await workspace.loadVault() - const vault = workspace.vault - if (vault?.configured) { - const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : '' - const rekey = vault.rekey - ? vault.unlocked - ? ` · re-sealing after the password change, ${vault.rekey.total ? `${vault.rekey.done}/${vault.rekey.total}` : 'counting'}` - : ' · re-sealing after the password change waits for :unlock' - : '' - return workspace.say( - vault.unlocked ? `vault open${left}${rekey} — :lock closes it` : `vault locked${rekey} — :unlock opens it`, - ) + if (what === 'unlock') { + if (workspace.vault?.unlocked) return workspace.say('the vault is already open') + return workspace.ensureUnlocked() } - const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered') - if (password === null) return - const again = await workspace.askSecret('the same again') - if (again === null) return - if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error') - workspace.vault = await api.setupVault(password) - workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload! seals new files') - }), - }, - { - name: 'unlock', - group: 'vault', - help: 'open the vault with its password, so sealed files can be read', - run: () => - workspace.guard(async () => { - await workspace.loadVault() - if (workspace.vault?.unlocked) return workspace.say('the vault is already open') - await workspace.ensureUnlocked() - }), - }, - { - name: 'lock', - group: 'vault', - help: 'close the vault now — it also closes itself when left alone, and on logout', - run: () => - workspace.guard(async () => { - workspace.vault = await api.lockVault() - if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id) - workspace.say('vault locked') - }), - }, - { - name: 'passwd', - group: 'vault', - help: "change the vault's password — every sealed file is then re-sealed under a new key, while the vault is open", - run: () => - workspace.guard(async () => { - const current = await workspace.askSecret('current vault password') - if (current === null) return - const password = await workspace.askSecret('new vault password, 10+ characters') - if (password === null) return - const again = await workspace.askSecret('the same again') - if (again === null) return - if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error') - workspace.vault = await api.changeVaultPassword(current, password) - workspace.say( - workspace.vault.rekey - ? `vault password changed — re-sealing ${workspace.vault.sealed} file(s) under the new key; leave the vault open, :vault shows how far` - : 'vault password changed', - ) + if (what === 'lock') { + workspace.vault = await api.lockVault() + if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id) + return workspace.say('vault locked') + } + if (what === 'passwd') return changeVaultPassword() + if (what) return workspace.say('usage: :vault [unlock|lock|passwd]', 'error') + if (!workspace.vault?.configured) return setupVault() + await openSettings('vault') }), }, { @@ -1474,11 +1426,30 @@ export const commands: CommandSpec[] = [ { name: 'help', aliases: ['h'], + args: '[all]', group: 'view', - help: 'show the key and command reference', - run: () => { - if (workspace.overlay === 'help') workspace.closeSheet() - else workspace.openSheet('help') + essential: true, + help: 'the keys to start with — ? again, or :help all, for every key and command', + complete: () => ['all'], + run: (arg) => { + const all = arg.trim() === 'all' + if (workspace.overlay === 'help' && !all) return workspace.closeSheet() + openHelp(all) + }, + }, + { + name: 'settings', + args: '[options|vault|backup|sessions|keys|tokens]', + group: 'view', + essential: true, + help: 'the settings sheet: vim and the clipboard, the vault, the backup, the browsers signed in, ssh keys and capture tokens — Tab between them (,)', + complete: () => settingsSections, + run: (arg) => { + const section = arg.trim() + if (section && !settingsSections.includes(section as SettingsSection)) { + return workspace.say(`usage: :settings [${settingsSections.join('|')}]`, 'error') + } + return openSettings((section || undefined) as SettingsSection | undefined) }, }, { @@ -1493,6 +1464,38 @@ export const commands: CommandSpec[] = [ }, ] +/** + * Old spellings, kept for one release so the fingers and notes that know + * them still work: each runs as the line it is now. Help and Tab do not + * offer them. + */ +const renamed: Record<string, string | ((arg: string) => unknown)> = { + 'space!': 'space new', + rmspace: 'space rm', + rms: 'space rm', + 'list!': 'list rm', + 'task!': 'task edit', + 'upload!': 'upload seal', + 'import!': 'import zip', + unshare: 'share off', + link: 'share new', + clear: 'noh', + download: 'export', + dl: 'export', + unlock: 'vault unlock', + lock: 'vault lock', + passwd: 'vault passwd', + // On the board it renamed the list, elsewhere the space. + 'name!': (arg) => runCommand(`${onBoard() ? 'list' : 'space'} name ${arg}`), + // It linked the card to the document under the cursor, read or not. + 'card!': (arg) => { + const node = workspace.target + if (node?.kind !== 'document') return workspace.say('no document to link — open one first', 'error') + return cardAbout(node, arg) + }, + unmark: () => workspace.unmarkAll(), +} + const lookup = new Map<string, CommandSpec>() for (const spec of commands) { lookup.set(spec.name, spec) @@ -1512,6 +1515,12 @@ export async function runCommand(line: string): Promise<void> { const [head, ...rest] = input.split(/\s+/) const arg = rest.join(' ') + const old = renamed[head] + if (old !== undefined) { + await (typeof old === 'string' ? runCommand(`${old} ${arg}`) : old(arg)) + return + } + // `:12` jumps to a node by id, the way `:12` jumps to a line in vim. if (/^\d+$/.test(head)) { await workspace.guard(() => workspace.openNode(Number(head))) @@ -1528,9 +1537,9 @@ export async function runCommand(line: string): Promise<void> { if (forced) { if (!spec.bang) return workspace.say(`E477: no ! allowed: ${head}`, 'error') await spec.bang.run(arg) - return - } - await spec.run(arg) + } else await spec.run(arg) + // What a line changed shows in the settings sheet it was typed over. + if (workspace.overlay === 'settings') await workspace.guard(() => loadSettings()) } /** diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts index d6b2f8b..23cfc9e 100644 --- a/web/src/lib/keymap.ts +++ b/web/src/lib/keymap.ts @@ -26,6 +26,7 @@ export type Context = | 'cards' | 'history' | 'share' + | 'settings' | 'card' | 'drawer' | 'workspace' @@ -64,24 +65,26 @@ export interface Binding { hint?: string /** The keycaps the footer and the rail show; from `keys` when not given. */ caps?: readonly string[] + /** On the help's first page, among the keys to start with. */ + essential?: boolean } const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'] as const export const bindings = [ // --- browsing and reading; `always` is heard in the editor too --------------- - { context: 'global', keys: digits, action: 'space', caps: ['1…9'], label: '1 … 9 · 0', help: 'switch to that space, like a workspace' }, + { context: 'global', keys: digits, action: 'space', caps: ['1…9'], label: '1 … 9 · 0', help: 'switch to that space, like a workspace', essential: true }, { context: 'global', keys: digits.map((digit) => `Shift-${digit}`), action: 'send', label: 'Shift 1 … 9', help: 'send the marked nodes, or the selected one, to that space' }, { context: 'global', keys: ['Tab', 'Shift-Tab'], action: 'cycle', label: 'Tab · Shift-Tab', help: 'next / previous space' }, { context: 'global', keys: ['w'], action: 'overview', help: 'overview of every space — hjkl, then Enter' }, { context: 'global', keys: ['f'], action: 'fullscreen', help: 'fullscreen the focused pane or tile; again or Esc to leave' }, - { context: 'always', keys: ['Alt-h', 'Alt-j', 'Alt-k', 'Alt-l'], action: 'focus', typing: true, label: 'Alt-h/j/k/l', help: 'focus the tile that way, from the editor too — left of the leftmost is the list' }, + { context: 'always', keys: ['Alt-h', 'Alt-j', 'Alt-k', 'Alt-l'], action: 'focus', typing: true, label: 'Alt-h/j/k/l', help: 'focus the tile that way, from the editor too — left of the leftmost is the list', essential: true }, { context: 'always', keys: ['Alt-Shift-h', 'Alt-Shift-j', 'Alt-Shift-k', 'Alt-Shift-l'], action: 'swap', typing: true, label: 'Alt-Shift-h/j/k/l', help: 'move the focused tile that way' }, { context: 'tree', keys: ['L'], action: 'beside', label: 'L · Shift-Enter', help: 'open the row in a new tile beside the others' }, { context: 'global', keys: ['gt'], action: 'nextTile', label: 'gt / gT', help: 'next / previous tile — a view too' }, { context: 'global', keys: ['gT'], action: 'previousTile' }, { context: 'always', keys: ['Alt-q'], action: 'close', typing: true, help: 'close the focused tile — :close; :only closes the others' }, - { context: 'tree', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k · ↓ ↑', help: 'move down / up the list — scroll the document when it has focus' }, + { context: 'tree', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k · ↓ ↑', help: 'move down / up the list — scroll the document when it has focus', essential: true }, { context: 'tree', keys: ['k', 'ArrowUp'], action: 'up' }, { context: 'tree', keys: ['gg'], action: 'top', label: 'gg / G', help: 'first / last row, or top / bottom of the document — a first g, d, ] or [ waits a second in the status line' }, { context: 'tree', keys: ['G'], action: 'bottom' }, @@ -90,49 +93,49 @@ export const bindings = [ { context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' }, { context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' }, { context: 'tree', keys: ['[['], action: 'previousHeading' }, - { context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks, cards and sharing — Tab between them" }, + { context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks, cards, files and sharing — :share there, Tab between them", essential: true }, { context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' }, { context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' }, { context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' }, { context: 'tree', keys: ['gx'], action: 'inTab', help: 'open the file under the cursor in a tab of its own' }, - { context: 'tree', keys: ['Enter', 'Shift-Enter', 'l', 'ArrowRight'], action: 'open', label: 'Enter · l', help: 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password' }, - { context: 'tree', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'from the document to the list, from the list up a level' }, - { context: 'tree', keys: ['o'], action: 'new', help: 'new document here, straight into insert mode' }, + { context: 'tree', keys: ['Enter', 'Shift-Enter', 'l', 'ArrowRight'], action: 'open', label: 'Enter · l', help: 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password', essential: true }, + { context: 'tree', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'from the document to the list, from the list up a level', essential: true }, + { context: 'tree', keys: ['o'], action: 'new', help: 'new document here, straight into insert mode', essential: true }, { context: 'tree', keys: ['O'], action: 'mkdir', caps: ['⇧ O'], help: 'new folder here' }, - { context: 'tree', keys: ['i', 'a'], action: 'edit', help: 'edit the open document' }, + { context: 'tree', keys: ['i', 'a'], action: 'edit', help: 'edit the open document', essential: true }, { context: 'tree', keys: ['R'], action: 'rename', help: 'rename what is under the cursor — document, folder or card — starting from its name' }, - { context: 'tree', keys: ['m'], action: 'mark', label: 'm / p', help: 'pick nodes up, then drop them all into the folder you walk to' }, + { context: 'tree', keys: ['m'], action: 'mark', label: 'm / p', help: 'pick nodes up, then drop them all into the folder you walk to', essential: true }, { context: 'tree', keys: ['p'], action: 'drop' }, { context: 'tree', keys: ['za'], action: 'files', help: "a document's own files, folded open under it in the list — again folds them away; p drops marked files in there" }, { context: 'tree', keys: ['M'], action: 'markAll', help: 'pick up the whole folder — again puts it all back' }, { context: 'tree', label: 'Ctrl / Shift-click', help: 'pick a row up · every row up to it — drag them onto a folder' }, - { context: 'global', keys: ['S'], action: 'plan', help: 'the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away' }, - { context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in a tile of its own' }, - { context: 'global', keys: ['T'], action: 'today', help: "today's diary entry, started if there is none — from templates/day when the diary has one" }, + { context: 'global', keys: ['S'], action: 'plan', help: 'the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away', essential: true }, + { context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in a tile of its own', essential: true }, + { context: 'global', keys: ['T'], action: 'today', help: "today's diary entry, started if there is none — from templates/day when the diary has one", essential: true }, { context: 'global', keys: ['C'], action: 'calendar', help: 'the diary by month, in a tile: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day beside it' }, { context: 'tree', keys: ['[d'], action: 'previousEntry', label: '[d / ]d', help: 'the diary entry before / after the one open, by date' }, { context: 'tree', keys: [']d'], action: 'nextEntry' }, - { context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on' }, + { context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on', essential: true }, { context: 'global', keys: ['b'], action: 'board', help: "the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's" }, - { context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry" }, - { context: 'tree', keys: ['q', 'Escape'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several' }, - { context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one' }, + { context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry", essential: true }, + { context: 'tree', keys: ['q'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several — Esc first puts down what m picked up', essential: true }, + { context: 'tree', keys: ['Escape'], action: 'escape' }, + { context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one', essential: true }, { context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' }, { context: 'tree', keys: ['N'], action: 'previous' }, - { context: 'tree', keys: ['s'], action: 'share', help: 'toggle sharing for the document — asks before taking it back' }, - { context: 'tree', keys: ['y'], action: 'link', help: 'mint a new share link and copy it — asks, the old one stops working' }, - { context: 'tree', keys: ['x', 'dd'], action: 'delete', help: 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)' }, - { context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest' }, + { context: 'tree', keys: ['x', 'dd'], action: 'delete', help: 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)', essential: true }, + { context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest', essential: true }, { context: 'tree', keys: ['gh'], action: 'history', help: "the open document's earlier versions, as diffs — Enter brings one back" }, { context: 'tree', keys: ['gb'], action: 'backlinks', help: 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing' }, { context: 'tree', keys: ['gc'], action: 'cards', help: 'the cards about the open document — j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board' }, - { context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before' }, + { context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before', essential: true }, { context: 'always', keys: ['Ctrl-s', 'Meta-s'], action: 'save', typing: true, label: 'Ctrl-S', help: 'save, from anywhere' }, - { context: 'global', keys: ['?'], action: 'help', help: 'this screen, from the editor too — h/l a column, / filters it down' }, + { context: 'global', keys: ['?'], action: 'help', help: 'this screen — the keys to start with, then on a second ? every key and command; h/l a column, / filters it down', essential: true }, + { context: 'global', keys: [','], action: 'settings', help: 'the settings sheet: vim and the clipboard, the vault, the backup, signed-in browsers, ssh keys, capture tokens', essential: true }, // --- the workspace around the panes ------------------------------------------ { context: 'workspace', keys: ['Escape'], action: 'escape', typing: true }, - { context: 'workspace', keys: ['Q'], action: 'scratch', section: 'scratch', help: 'lower the scratchpad over whatever is on screen, or raise it again — :scratch <text> takes a note without it' }, + { context: 'workspace', keys: ['Q'], action: 'scratch', section: 'scratch', help: 'lower the scratchpad over whatever is on screen, or raise it again — :scratch <text> takes a note without it', essential: true }, // While a document is being written, a key that lands outside the editor // hands the focus back to it instead of walking the list unseen. { context: 'writing', keys: ['*'], action: 'refocus' }, @@ -232,6 +235,7 @@ export const bindings = [ { context: 'help', keys: ['g'], action: 'start', caps: ['g', 'G'], hint: 'start · end' }, { context: 'help', keys: ['G'], action: 'end' }, { context: 'help', keys: ['/'], action: 'filter', hint: 'filter, and read the lines whole' }, + { context: 'help', keys: ['?'], action: 'deeper', hint: 'all of it' }, { context: 'overview', keys: ['h', 'ArrowLeft', 'l', 'ArrowRight', 'k', 'ArrowUp', 'j', 'ArrowDown'], action: 'step', caps: ['h', 'j', 'k', 'l'], hint: 'move' }, { context: 'overview', keys: digits, action: 'go', caps: ['1…9', '↵'], hint: 'go' }, @@ -285,10 +289,18 @@ export const bindings = [ { context: 'share', keys: ['s', 'Enter'], action: 'toggle', caps: ['s'], hint: 'share · stop' }, { context: 'share', keys: ['y'], action: 'link', hint: 'new link' }, + { context: 'settings', keys: ['Tab'], action: 'next', caps: ['tab'], hint: 'next part' }, + { context: 'settings', keys: ['Shift-Tab'], action: 'previous' }, + { context: 'settings', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' }, + { context: 'settings', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'settings', keys: ['G'], action: 'bottom' }, + { context: 'settings', keys: ['Enter', 'l', 'Space'], action: 'act', caps: ['↵'], hint: 'change' }, + { context: 'settings', keys: ['x'], action: 'remove', hint: 'remove' }, + // --- the editor's own keymap, described only --------------------------------------- { context: 'editor', label: 'vim', help: 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros' }, - { context: 'editor', label: 'autosave', help: 'a document, a description or a note saves itself as the typing stops; :w saves sooner' }, - { context: 'editor', label: ':q · Esc', help: 'leave the editor, what is waiting written first — Esc in normal mode; :q! lets it go' }, + { context: 'editor', label: 'autosave', help: 'a document, a description or a note saves itself as the typing stops; :w saves sooner', essential: true }, + { context: 'editor', label: ':q · Esc', help: 'leave the editor, what is waiting written first — Esc in normal mode; :q! lets it go', essential: true }, { context: 'editor', label: '[[', help: 'link a document by its title, offered as you type' }, { context: 'editor', label: 'drop · paste', help: 'an image, video or audio file attaches it' }, { context: 'editor', label: '$x^2$ · $$…$$', help: 'set as math' }, @@ -314,6 +326,13 @@ const named: Record<string, string> = { Escape: 'Esc' } /** The keys as the help writes them. */ export const label = (binding: Binding) => binding.label ?? (binding.keys ?? []).map((key) => named[key] ?? key).join(' · ') +/** The help's first page: the keys marked to start with, in the table's order. */ +export function essentials(): { key: string; description: string }[] { + return (bindings as readonly Binding[]) + .filter((binding) => binding.essential && binding.help) + .map((binding) => ({ key: label(binding), description: binding.help! })) +} + /** The rows of one help column, in the table's order. */ export function rows(section: Section): { key: string; description: string }[] { const contexts = sections.find((entry) => entry.section === section)?.contexts ?? [] diff --git a/web/src/lib/modes.ts b/web/src/lib/modes.ts index a7cc877..c003091 100644 --- a/web/src/lib/modes.ts +++ b/web/src/lib/modes.ts @@ -11,7 +11,7 @@ * tile what has the focus: the tree, or a view — board, agenda, * calendar, a card's back * views what the views share: q, gt, b - * global spaces, t, T, C, S, :, ? + * global spaces, t, T, C, S, :, ?, , * * The `[y/N]` prompt sits over all of it, in App.svelte, and the command * line keeps its own keys while it has them. @@ -21,7 +21,7 @@ import { bindings, type Binding, type Context } from './keymap' import { workspace } from './store.svelte' /** The sheets with keys of their own, under the same name as the sheet. */ -const sheets = new Set<string>(['help', 'overview', 'trash']) +const sheets = new Set<string>(['help', 'overview', 'trash', 'settings']) /** The stack, the topmost first. */ export function stack(): Context[] { diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 7ffeee6..d42b009 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -34,7 +34,7 @@ export type Pane = 'list' | 'document' export type TileKind = 'node' | 'board' | 'agenda' | 'calendar' | 'card' export type View = Exclude<TileKind, 'node'> /** What floats over the tiles; one at a time. */ -export type Sheet = 'help' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' +export type Sheet = 'help' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' | 'settings' /** The info sheet's parts: what the open document holds, and what is around it. */ export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'files' | 'share' export type Flash = { text: string; kind: 'info' | 'error' } | null @@ -1403,7 +1403,7 @@ class Workspace { /** * Delete a space and everything in it. The app has to land somewhere * afterwards, so it falls into the next space — or, if that was the last - * one, into an empty workspace that `:space! <name>` fills again. + * one, into an empty workspace that `:space new <name>` fills again. */ async deleteSpace(id: number) { const { removed } = await api.deleteNode(id) @@ -2384,7 +2384,7 @@ class Workspace { return { url, sealed } } - /** `:link`: a new link for a shared document, copied; the old one stops working. */ + /** `:share new`: a new link for a shared document, copied; the old one stops working. */ async renewShareLink(id: number) { const { url, sealed } = await this.mintShareLink(id) this.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)