mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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.
This commit is contained in:
1 parent
d8e72c688d
commit
7f8d625a8d
34 files changed
+794
-452
No files matched your search
@@ -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 <n>` signs one out — a lost phone, say — without
|
||||
(or `,`, the settings sheet) lists them and `:sessions rm <n>` 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 <n>` one other, and
|
||||
`:token` manages the tokens that capture from outside (see below). The tree has its own: `:space [name]` switches (`:space! <name>`
|
||||
makes one, `:rmspace [name]` puts one in the trash with everything in it), `:mkdir <name>`
|
||||
`:token` manages the tokens that capture from outside (see below). The tree has its own: `:space [name]` switches (`:space new <name>`
|
||||
makes one, `:space rm [name]` puts one in the trash with everything in it), `:mkdir <name>`
|
||||
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..|/>` 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 <text>` 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 <text>`
|
||||
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 <title>` (`: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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+53
-40
@@ -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 |
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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`.
|
||||
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||

|
||||
@@ -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 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+28
-2
@@ -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')}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+4
-2
@@ -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. */
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-9
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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],
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
+28
-11
@@ -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: {},
|
||||
|
||||
+281
-272
@@ -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 [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' }))
|
||||
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 = url
|
||||
anchor.download = exportName(name.trim(), created_at)
|
||||
anchor.href = node.file.url
|
||||
anchor.download = node.name
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
workspace.say(`wrote ${anchor.download}`)
|
||||
},
|
||||
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: () =>
|
||||
workspace.guard(async () => {
|
||||
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`,
|
||||
)
|
||||
}
|
||||
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: () =>
|
||||
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()
|
||||
if (what === 'unlock') {
|
||||
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 () => {
|
||||
return workspace.ensureUnlocked()
|
||||
}
|
||||
if (what === 'lock') {
|
||||
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',
|
||||
)
|
||||
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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+44
-25
@@ -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 ?? []
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user