mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
navigation: the list by name outside the diary, Ctrl-P goes to anything by its name, new things land beside what they belong to
The list is folders first and then by name in every space but the diary, which stays a journal by day; a document's row elsewhere shows when it was last edited. Ctrl-P is a jump by name across every space, served by GET /api/jump: the last word is looked for in the name, the ones before along the path. / is the one full-text search, and Tab on its line takes it to every space and back. A card's notes are made in the folder being browsed when that is in the card's space, and a missing [[link]]'s document beside the one linking to it. o in a space with templates opens the line as :new with them listed.
This commit is contained in:
1 parent
c70bfdd7be
commit
9363a0024d
21 files changed
+297
-96
No files matched your search
@@ -31,17 +31,21 @@ short actionable summary of work, a **note** a thought not sorted yet, and a
|
|||||||
its document. The documents and the plan each have a screen, and the
|
its document. The documents and the plan each have a screen, and the
|
||||||
scratchpad comes down over either.
|
scratchpad comes down over either.
|
||||||
|
|
||||||
### Left over from the plan, not started
|
### The navigation fixes
|
||||||
|
|
||||||
These were in the plan's navigation fixes, outside the numbered slices:
|
The plan's navigation fixes sat outside the numbered slices, and landed after
|
||||||
|
them, in one commit:
|
||||||
|
|
||||||
- The list groups documents by day in every space; the plan had that only in
|
- The list is by name, folders first, everywhere but the diary, which stays a
|
||||||
the diary, and folders first then by name elsewhere.
|
journal by day; a document's row there shows when it was last edited.
|
||||||
- `Ctrl-P` is full-text search across every space; the plan had it become a
|
- `Ctrl-P` goes to anything by its name, in every space (`GET /api/jump`): the
|
||||||
jump to a node by its name and path, with `Tab` in `/` reaching every space.
|
last word is looked for in the name, the ones before along the path. `/` is
|
||||||
- A card's notes are made in the space's root, not in the folder being
|
the one full-text search, and `Tab` on its line takes it to every space.
|
||||||
browsed.
|
- A card's notes are made in the folder being browsed when that is in the
|
||||||
- `o` does not offer the space's templates; `:new <template>` does.
|
card's space, and a `[[missing]]` link's document beside the one linking to
|
||||||
|
it.
|
||||||
|
- `o` in a space with templates offers them on the line, `:new `; `Enter` on
|
||||||
|
nothing is still a blank document.
|
||||||
|
|
||||||
## Fixed before, on 2026-10-21
|
## Fixed before, on 2026-10-21
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ it; in the document pane it is that document. In the browse pane:
|
|||||||
| `gc` | the info sheet at the cards about the open document — `j`/`k` picks one for `Space`, `H`/`L`, `:due` and `:label`, `Enter` turns it over on its board (`:cards`) |
|
| `gc` | the info sheet at the cards about the open document — `j`/`k` picks one for `Space`, `H`/`L`, `:due` and `:label`, `Enter` turns it over on its board (`:cards`) |
|
||||||
| `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own |
|
| `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own |
|
||||||
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
|
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
|
||||||
| `o` | a new document here, straight into insert mode |
|
| `o` | a new document here, straight into insert mode; when the space has templates, the line offers them first |
|
||||||
| `O` | a new folder here |
|
| `O` | a new folder here |
|
||||||
| `i`, `a` | edit the open entry |
|
| `i`, `a` | edit the open entry |
|
||||||
| `R` | rename what is highlighted — a document, a folder or a file — starting from its name (`:space 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) |
|
||||||
@@ -164,8 +164,8 @@ it; in the document pane it is that document. In the browse pane:
|
|||||||
| `Ctrl-Q` | from anywhere, the editor too: the scratchpad down with the last note being written, and again back to where you were, still writing (Firefox quits on it instead) |
|
| `Ctrl-Q` | from anywhere, the editor too: the scratchpad down with the last note being written, and again back to where you were, still writing (Firefox quits on it instead) |
|
||||||
| `Esc`, `q` | close the document, and its tile when there are several; `Esc` first puts down what `m` picked up |
|
| `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 |
|
| `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 |
|
| `/` | full-text search in this space (`:noh` clears it); `Tab` on the line takes it to every space and back, `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 |
|
| `Ctrl-P` | go to anything by its name, in every space: the last word is looked for in the name, the ones before along the path (`proj ingest`); `Tab` or `Ctrl-N`/`Ctrl-P` walk, `Enter` goes |
|
||||||
| `n` / `N` | the next / previous search result, opened beside the results; past the last it starts again at the first |
|
| `n` / `N` | the next / previous search result, opened beside the results; past the last it starts again at the first |
|
||||||
| `x`, `dd` | delete the entry under the cursor into the trash (asks `y`/`N`) |
|
| `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 |
|
| `u` | undo the last delete: it comes back out of the trash to where it was |
|
||||||
@@ -319,7 +319,7 @@ coloured as nvim colours it. See the
|
|||||||
|
|
||||||
A card can point at a document: `c` or `: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
|
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
|
document named after the card — in the folder being browsed when that is in the card's space, else in its root — and opens it to write in, and
|
||||||
closing that document returns to the board on the same card. The link is deliberately loose —
|
closing that document returns to the board on the same card. The link is deliberately loose —
|
||||||
deleting the document leaves the card standing, because the work is still open
|
deleting the document leaves the card standing, because the work is still open
|
||||||
once its notes are gone. Only a document can be a card's notes; a folder or a
|
once its notes are gone. Only a document can be a card's notes; a folder or a
|
||||||
@@ -552,7 +552,7 @@ limit at least that large too.
|
|||||||
|
|
||||||
### Searching inside files
|
### Searching inside files
|
||||||
|
|
||||||
`/` and `Ctrl-P` find a file by what it says as well as by its name: the text
|
`/` finds a file by what it says as well as by its name: the text
|
||||||
of every file that is text — by its type, or by its first 8 KiB reading as
|
of every file that is text — by its type, or by its first 8 KiB reading as
|
||||||
UTF-8 without a NUL — is indexed, up to its first 256 KiB, the same part the
|
UTF-8 without a NUL — is indexed, up to its first 256 KiB, the same part the
|
||||||
viewer shows. A file found that way lists the words around the match under its
|
viewer shows. A file found that way lists the words around the match under its
|
||||||
@@ -924,6 +924,7 @@ sealed or not.
|
|||||||
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
|
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
|
||||||
| `POST` | `/api/import` | import an export at the root: directories become spaces |
|
| `POST` | `/api/import` | import an export at the root: directories become spaces |
|
||||||
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
|
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
|
||||||
|
| `GET` | `/api/jump?q=` | `Ctrl-P`: the nodes in every space whose name holds the last word and whose path holds the ones before, best first, each with its `location` |
|
||||||
| `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first |
|
| `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first |
|
||||||
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
||||||
| `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on |
|
| `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on |
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ sealed or not.
|
|||||||
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
|
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
|
||||||
| `POST` | `/api/import` | import an export at the root: directories become spaces |
|
| `POST` | `/api/import` | import an export at the root: directories become spaces |
|
||||||
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
|
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
|
||||||
|
| `GET` | `/api/jump?q=` | `Ctrl-P`: the nodes in every space whose name holds the last word and whose path holds the ones before, best first, each with its `location` |
|
||||||
| `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first |
|
| `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first |
|
||||||
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
||||||
| `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on |
|
| `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on |
|
||||||
|
|||||||
@@ -115,8 +115,10 @@ outside the browser, see [Capture](Capture.md).
|
|||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
`gf` on a card, or on its back, opens its notes: a document named after the card, made in the
|
`gf` on a card, or on its back, opens its notes: a document named after the card,
|
||||||
root of the space the first time, among the documents. `S` goes back to the
|
among the documents. The first time, it is made in the folder you are browsing
|
||||||
|
when that is in the card's space, so the notes land beside what they are about,
|
||||||
|
and in the root of the space when it is not. `S` goes back to the
|
||||||
board, on the same card.
|
board, on the same card.
|
||||||
|
|
||||||
The link is loose on purpose: deleting the document leaves the card, because the
|
The link is loose on purpose: deleting the document leaves the card, because the
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ arguments and `↑` / `↓` recall earlier lines.
|
|||||||
| `gx` | open the file under the cursor in a tab of its own |
|
| `gx` | open the file under the cursor in a tab of its own |
|
||||||
| `Enter · l` | open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password |
|
| `Enter · l` | open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password |
|
||||||
| `h` | from the document to the list, from the list up a level |
|
| `h` | from the document to the list, from the list up a level |
|
||||||
| `o` | new document here, straight into insert mode |
|
| `o` | new document here, straight into insert mode — when the space has templates, the line offers them first |
|
||||||
| `O` | new folder here |
|
| `O` | new folder here |
|
||||||
| `i · a` | edit the open document |
|
| `i · a` | edit the open document |
|
||||||
| `R` | rename what is under the cursor — document, folder or card — starting from its name |
|
| `R` | rename what is under the cursor — document, folder or card — starting from its name |
|
||||||
@@ -48,11 +48,11 @@ arguments and `↑` / `↓` recall earlier lines.
|
|||||||
| `T` | today's diary entry, started if there is none — from templates/day when the diary has one |
|
| `T` | today's diary entry, started if there is none — from templates/day when the diary has one |
|
||||||
| `C` | the diary by month, on the plan screen: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day among the documents |
|
| `C` | the diary by month, on the plan screen: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day among the documents |
|
||||||
| `[d / ]d` | the diary entry before / after the one open, by date |
|
| `[d / ]d` | the diary entry before / after the one open, by date |
|
||||||
| `Ctrl-P` | find anything, in every space — Enter opens the result lit beside them, n goes on |
|
| `Ctrl-P` | go to anything by its name, in every space — words before the last narrow the path, as in proj/ingest; Tab or Ctrl-N/P walk, Enter goes |
|
||||||
| `b` | the board, on the plan screen: this space's, or the last one shown — 1 … 9 there shows another's |
|
| `b` | the board, on the plan screen: 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 |
|
| `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 first puts down what m picked up |
|
| `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 |
|
| `/` | full-text search, inside the current space and its text files — Tab takes it to every space, Ctrl-N/P walk the results, Enter opens one |
|
||||||
| `n / N` | next / previous search result, opened beside the results — :noh clears the search |
|
| `n / N` | next / previous search result, opened beside the results — :noh clears the search |
|
||||||
| `x · dd` | delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N) |
|
| `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 |
|
| `u` | undo the last delete: it comes back out of the trash — :trash has the rest |
|
||||||
@@ -188,7 +188,7 @@ whole workspace. A command that does a second thing takes a word for it:
|
|||||||
| `:outline, :toc` | the open document's headings to pick from — gO, in the editor too |
|
| `:outline, :toc` | the open document's headings to pick from — gO, in the editor too |
|
||||||
| `: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) |
|
| `: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, :se [text]` | full-text search in this space (empty clears) |
|
||||||
| `:search!` | full-text search across every space — Ctrl-P |
|
| `:search!` | full-text search across every space — `/` then Tab |
|
||||||
| `:noh, :nohlsearch` | clear the search filter |
|
| `:noh, :nohlsearch` | clear the search filter |
|
||||||
| `:reload, :r` | re-read the current folder from the server |
|
| `:reload, :r` | re-read the current folder from the server |
|
||||||
|
|
||||||
|
|||||||
@@ -53,13 +53,11 @@ file or an SVG, for example, downloads instead of opening.
|
|||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
| key | searches |
|
`/` searches the space you are in; `Tab` on the line takes the search to every
|
||||||
| --- | --- |
|
space, and back (`:search!` does the same from the command line).
|
||||||
| `/` | the space you are in |
|
|
||||||
| `Ctrl-P` | every space |
|
|
||||||
|
|
||||||
Results appear as you type, ranked by relevance, each with the space and
|
Results appear as you type, ranked by relevance, each with the space and
|
||||||
folder it is in. `Tab` or `Ctrl-N` / `Ctrl-P` walk them without leaving the
|
folder it is in. `Ctrl-N` / `Ctrl-P` walk them without leaving the
|
||||||
line, and `Enter` opens the one lit. Afterwards `n` and `N` open the next and
|
line, and `Enter` opens the one lit. Afterwards `n` and `N` open the next and
|
||||||
previous result beside the list, and `:noh` clears the search.
|
previous result beside the list, and `:noh` clears the search.
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -68,7 +68,7 @@ each.
|
|||||||
|
|
||||||
The list on the left shows the folder you are in. `j` and `k` move, `l` or
|
The list on the left shows the folder you are in. `j` and `k` move, `l` or
|
||||||
`Enter` opens what is under the cursor, `h` goes up a level. Folders come first,
|
`Enter` opens what is under the cursor, `h` goes up a level. Folders come first,
|
||||||
then documents grouped by the day they were written.
|
then documents by name; only the diary lists them by day.
|
||||||
|
|
||||||
Open the project notes with `Enter`. To read a second document beside it, move
|
Open the project notes with `Enter`. To read a second document beside it, move
|
||||||
to it and press `L` instead: it opens in a **tile** of its own.
|
to it and press `L` instead: it opens in a **tile** of its own.
|
||||||
@@ -159,15 +159,18 @@ going to the board.
|
|||||||
|
|
||||||
### Finding things
|
### Finding things
|
||||||
|
|
||||||
`/` searches the space you are in, as you type, and `Ctrl-P` every space.
|
`/` searches the space you are in, as you type, and `Tab` on the line takes
|
||||||
The search reads the documents and the text files in the tree, then the cards
|
it to every space. The search reads the documents and the text files in the tree, then the cards
|
||||||
and the notes: a log file is found by a line inside it, and the result shows
|
and the notes: a log file is found by a line inside it, and the result shows
|
||||||
the words around the match.
|
the words around the match.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
`Tab` walks the results without leaving the line, `Enter` opens one, and
|
`Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens
|
||||||
`n`/`N` step through them afterwards. More in [Files and search](Files-and-Search.md).
|
one, and `n`/`N` step through them afterwards. When you know what it is called,
|
||||||
|
`Ctrl-P` goes straight there from anywhere: type part of the name, with a word
|
||||||
|
of the folder before it to narrow it, `proj ingest`. More in
|
||||||
|
[Files and search](Files-and-Search.md).
|
||||||
|
|
||||||
## Afternoon: a lecture
|
## Afternoon: a lecture
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,17 @@ deleted. See [The diary](Diary.md).
|
|||||||
## The tree
|
## The tree
|
||||||
|
|
||||||
Inside a space, folders nest to any depth. The list shows the folder you are
|
Inside a space, folders nest to any depth. The list shows the folder you are
|
||||||
in: folders first, then documents and files, grouped by the day they were
|
in: folders first, then documents and files by name. The diary is the
|
||||||
written.
|
exception: a journal, its entries are newest first and grouped by the day they
|
||||||
|
are about, and each shows the time it was written where elsewhere a document
|
||||||
|
shows when it was last edited.
|
||||||
|
|
||||||
| key | what it does |
|
| key | what it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `j` / `k`, `gg` / `G` | move, first, last |
|
| `j` / `k`, `gg` / `G` | move, first, last |
|
||||||
| `l`, `Enter` | open the document or file, or go into the folder |
|
| `l`, `Enter` | open the document or file, or go into the folder |
|
||||||
| `h` | up one level |
|
| `h` | up one level |
|
||||||
| `o` | a new document here, straight into insert mode |
|
| `o` | a new document here, straight into insert mode; when the space has [templates](Writing.md#templates), the line offers them first |
|
||||||
| `O` | a new folder here |
|
| `O` | a new folder here |
|
||||||
| `R` | rename what is under the cursor |
|
| `R` | rename what is under the cursor |
|
||||||
| `x`, `dd` | delete it, into the trash |
|
| `x`, `dd` | delete it, into the trash |
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ A title is looked for in the space the link is written in first, then in every
|
|||||||
other. It matches a name in any case, or the slug of that name. Typing `[[` in
|
other. It matches a name in any case, or the slug of that name. Typing `[[` in
|
||||||
the editor offers the titles of documents as the search finds them. A title
|
the editor offers the titles of documents as the search finds them. A title
|
||||||
that no document has yet is drawn dimmed, and following it offers to write that
|
that no document has yet is drawn dimmed, and following it offers to write that
|
||||||
document.
|
document, in the folder of the one linking to it.
|
||||||
|
|
||||||
Links keep working when what they point at moves. Renaming a document, or a
|
Links keep working when what they point at moves. Renaming a document, or a
|
||||||
folder above it, or moving either, rewrites the links to it in every document
|
folder above it, or moving either, rewrites the links to it in every document
|
||||||
@@ -175,6 +175,9 @@ it is a template.
|
|||||||
|
|
||||||
- `:new meeting` starts a document from `templates/meeting`. `Tab` completes the
|
- `:new meeting` starts a document from `templates/meeting`. `Tab` completes the
|
||||||
template names.
|
template names.
|
||||||
|
- `o` in a space with templates opens the line as `:new ` with them listed:
|
||||||
|
`Tab` picks one, and `Enter` on nothing makes a blank document. In the diary
|
||||||
|
`o` starts from `templates/day` without asking.
|
||||||
- `:new meeting 2026-10-20 Budget` dates it and names it as well.
|
- `:new meeting 2026-10-20 Budget` dates it and names it as well.
|
||||||
|
|
||||||
In a template these placeholders are filled in, on the clock of
|
In a template these placeholders are filled in, on the clock of
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|||||||
.route("/cards/{id}/move", post(boards::move_card))
|
.route("/cards/{id}/move", post(boards::move_card))
|
||||||
.route("/nodes", get(nodes::list).post(nodes::create))
|
.route("/nodes", get(nodes::list).post(nodes::create))
|
||||||
.route("/resolve", get(nodes::resolve))
|
.route("/resolve", get(nodes::resolve))
|
||||||
|
.route("/jump", get(nodes::jump))
|
||||||
.route("/nodes/{id}", get(nodes::get))
|
.route("/nodes/{id}", get(nodes::get))
|
||||||
.route("/nodes/{id}", put(nodes::update))
|
.route("/nodes/{id}", put(nodes::update))
|
||||||
.route("/nodes/{id}", delete(nodes::remove))
|
.route("/nodes/{id}", delete(nodes::remove))
|
||||||
|
|||||||
+122
-5
@@ -626,15 +626,24 @@ pub async fn list(
|
|||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
// Browsing: the children of one node, folders before documents.
|
// Browsing: the children of one node, folders before documents. The
|
||||||
|
// diary is a journal, newest first; anywhere else it is by name.
|
||||||
None => {
|
None => {
|
||||||
let parent = params.parent;
|
let parent = params.parent;
|
||||||
sqlx::query(&format!(
|
sqlx::query(&format!(
|
||||||
"SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
|
"WITH RECURSIVE up (id, parent_id, role) AS (
|
||||||
FROM nodes n
|
SELECT id, parent_id, role FROM nodes WHERE id IS ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id, n.parent_id, n.role FROM nodes n JOIN up ON n.id = up.parent_id
|
||||||
|
),
|
||||||
|
journal (yes) AS (SELECT EXISTS (SELECT 1 FROM up WHERE role = '{DIARY}'))
|
||||||
|
SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
|
||||||
|
FROM nodes n, journal
|
||||||
WHERE n.parent_id IS ?1 AND n.role IS NOT 'trash'
|
WHERE n.parent_id IS ?1 AND n.role IS NOT 'trash'
|
||||||
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END,
|
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END,
|
||||||
n.position, n.created_at DESC, n.id DESC
|
n.position,
|
||||||
|
CASE WHEN journal.yes THEN n.created_at END DESC,
|
||||||
|
n.name = '', n.name COLLATE NOCASE, n.id DESC
|
||||||
LIMIT ?2 OFFSET ?3"
|
LIMIT ?2 OFFSET ?3"
|
||||||
))
|
))
|
||||||
.bind(parent)
|
.bind(parent)
|
||||||
@@ -690,6 +699,97 @@ pub struct ResolveQuery {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct JumpQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many places `Ctrl-P` offers at once.
|
||||||
|
const JUMP_LIMIT: usize = 30;
|
||||||
|
|
||||||
|
/// `Ctrl-P`: the nodes, in every space, called what was typed. The last word
|
||||||
|
/// is looked for in a node's own name or slug, and the words before it
|
||||||
|
/// anywhere along its path, so `proj ingest` and `work/ingest` both find
|
||||||
|
/// `work/projects/ingest-pipeline`. A name that starts with the last word
|
||||||
|
/// comes first, then one with a word that does, then one that merely holds
|
||||||
|
/// it; after that the shorter path, then the one edited last.
|
||||||
|
pub async fn jump(
|
||||||
|
_: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<JumpQuery>,
|
||||||
|
) -> AppResult<Json<Vec<NodeSummary>>> {
|
||||||
|
let words: Vec<String> = params
|
||||||
|
.q
|
||||||
|
.split(|c: char| c.is_whitespace() || c == '/')
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.collect();
|
||||||
|
if words.is_empty() {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
let rows = sqlx::query(&format!(
|
||||||
|
"WITH RECURSIVE walk (id, trail, names) AS (
|
||||||
|
SELECT id, slug, name FROM nodes WHERE parent_id IS NULL AND kind = 'space'
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id, walk.trail || '/' || n.slug, walk.names || '/' || n.name
|
||||||
|
FROM nodes n JOIN walk ON n.parent_id = walk.id
|
||||||
|
)
|
||||||
|
SELECT {SUMMARY_COLUMNS}, walk.trail, walk.names
|
||||||
|
FROM walk JOIN nodes n ON n.id = walk.id
|
||||||
|
WHERE n.kind <> 'space'"
|
||||||
|
))
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut found: Vec<(u8, usize, i64, NodeSummary)> = rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| {
|
||||||
|
let name: String = row.get("name");
|
||||||
|
let slug: String = row.get("slug");
|
||||||
|
let trail: String = row.get("trail");
|
||||||
|
let names: String = row.get("names");
|
||||||
|
let own = format!("{} {slug}", name.to_lowercase());
|
||||||
|
let along = format!("{} {trail}", names.to_lowercase());
|
||||||
|
let rank = jump_rank(&words, &own, &along)?;
|
||||||
|
let mut node = row_to_summary(row, "");
|
||||||
|
node.location = trail
|
||||||
|
.rsplit_once('/')
|
||||||
|
.map(|(parents, _)| parents.to_string());
|
||||||
|
Some((rank, trail.matches('/').count(), -node.updated_at, node))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
found.sort_by_key(|(rank, depth, edited, _)| (*rank, *depth, *edited));
|
||||||
|
Ok(Json(
|
||||||
|
found
|
||||||
|
.into_iter()
|
||||||
|
.take(JUMP_LIMIT)
|
||||||
|
.map(|(.., node)| node)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a node stands for the words typed, lower first, or `None` when it
|
||||||
|
/// is not what they name: `own` is its name and slug, `along` its path.
|
||||||
|
fn jump_rank(words: &[String], own: &str, along: &str) -> Option<u8> {
|
||||||
|
let (last, before) = words.split_last()?;
|
||||||
|
if !before.iter().all(|word| along.contains(word.as_str())) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if own.starts_with(last.as_str()) {
|
||||||
|
Some(0)
|
||||||
|
} else if own
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.any(|part| part.starts_with(last.as_str()))
|
||||||
|
{
|
||||||
|
Some(1)
|
||||||
|
} else if own.contains(last.as_str()) {
|
||||||
|
Some(2)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a slug path to the node it names. This is the other half of the
|
/// Resolve a slug path to the node it names. This is the other half of the
|
||||||
/// `/n/<space>/<slug>/...` addresses the client puts in the URL bar and the
|
/// `/n/<space>/<slug>/...` addresses the client puts in the URL bar and the
|
||||||
/// importer writes into rewritten links: they stay readable because they are
|
/// importer writes into rewritten links: they stay readable because they are
|
||||||
@@ -1229,7 +1329,7 @@ mod tests {
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
excerpt, fts_query, load, move_nodes, next_slug, place_for, purge_subtree,
|
excerpt, fts_query, jump_rank, load, move_nodes, next_slug, place_for, purge_subtree,
|
||||||
recent_documents, slugify, DOCUMENT, EXCERPT_CHARS, FILE, FOLDER, HEAD_CHARS,
|
recent_documents, slugify, DOCUMENT, EXCERPT_CHARS, FILE, FOLDER, HEAD_CHARS,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -1747,4 +1847,21 @@ mod tests {
|
|||||||
Err(AppError::BadRequest(_))
|
Err(AppError::BadRequest(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Ctrl-P` reads the last word as the name and the ones before as the
|
||||||
|
/// path, and ranks a name that starts with it above one that holds it.
|
||||||
|
#[test]
|
||||||
|
fn a_jump_names_the_node_and_walks_its_path() {
|
||||||
|
let words = |q: &str| q.split([' ', '/']).map(str::to_string).collect::<Vec<_>>();
|
||||||
|
let own = "ingest pipeline ingest-pipeline";
|
||||||
|
let along = "work/projects/ingest pipeline work/projects/ingest-pipeline";
|
||||||
|
assert_eq!(jump_rank(&words("ingest"), own, along), Some(0));
|
||||||
|
assert_eq!(jump_rank(&words("pipe"), own, along), Some(1));
|
||||||
|
assert_eq!(jump_rank(&words("line"), own, along), Some(2));
|
||||||
|
assert_eq!(jump_rank(&words("proj ingest"), own, along), Some(0));
|
||||||
|
assert_eq!(jump_rank(&words("work/pipe"), own, along), Some(1));
|
||||||
|
assert_eq!(jump_rank(&words("uni ingest"), own, along), None);
|
||||||
|
// A folder's name names the folder, not everything in it.
|
||||||
|
assert_eq!(jump_rank(&words("projects"), own, along), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+8
-8
@@ -42,7 +42,7 @@
|
|||||||
const shareKey = shareToken ? decodeURIComponent(location.hash.slice(1)) : ''
|
const shareKey = shareToken ? decodeURIComponent(location.hash.slice(1)) : ''
|
||||||
|
|
||||||
let cmdline = $state<string | null>(null)
|
let cmdline = $state<string | null>(null)
|
||||||
let cmdGlobal = $state(false)
|
let cmdJump = $state(false)
|
||||||
let fileInput = $state<HTMLInputElement>()
|
let fileInput = $state<HTMLInputElement>()
|
||||||
let treeInput = $state<HTMLInputElement>()
|
let treeInput = $state<HTMLInputElement>()
|
||||||
/** Whether the files being picked go into the vault — `:upload seal`. */
|
/** Whether the files being picked go into the vault — `:upload seal`. */
|
||||||
@@ -98,13 +98,13 @@
|
|||||||
}
|
}
|
||||||
hooks.focusList = () => shell?.focus()
|
hooks.focusList = () => shell?.focus()
|
||||||
hooks.openCommandLine = (initial) => {
|
hooks.openCommandLine = (initial) => {
|
||||||
cmdGlobal = false
|
cmdJump = false
|
||||||
cmdline = initial
|
cmdline = initial
|
||||||
}
|
}
|
||||||
hooks.openOverview = () => void openOverview()
|
hooks.openOverview = () => void openOverview()
|
||||||
hooks.openGlobalSearch = () => {
|
hooks.openJump = () => {
|
||||||
cmdGlobal = true
|
cmdJump = true
|
||||||
cmdline = '/'
|
cmdline = ''
|
||||||
}
|
}
|
||||||
hooks.closeCommandLine = () => {
|
hooks.closeCommandLine = () => {
|
||||||
if (cmdline === null) return false
|
if (cmdline === null) return false
|
||||||
@@ -249,8 +249,8 @@
|
|||||||
[keysOf('global', 'plan'), 'the plan: board, agenda, calendar'],
|
[keysOf('global', 'plan'), 'the plan: board, agenda, calendar'],
|
||||||
[[...keysOf('global', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'one of them, on the plan'],
|
[[...keysOf('global', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'one of them, on the plan'],
|
||||||
[keysOf('tree', 'capture'), 'capture a card to the inbox'],
|
[keysOf('tree', 'capture'), 'capture a card to the inbox'],
|
||||||
[keysOf('global', 'find'), 'find in every space'],
|
[keysOf('global', 'find'), 'go to anything by its name'],
|
||||||
[keysOf('tree', 'search'), 'search this space'],
|
[keysOf('tree', 'search'), 'search this space, Tab every space'],
|
||||||
[keysOf('global', 'help'), 'help'],
|
[keysOf('global', 'help'), 'help'],
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -355,7 +355,7 @@
|
|||||||
<StatusBar {pending} />
|
<StatusBar {pending} />
|
||||||
|
|
||||||
{#if cmdline !== null}
|
{#if cmdline !== null}
|
||||||
<CommandLine initial={cmdline} global={cmdGlobal} close={() => ((cmdline = null), (cmdGlobal = false))} />
|
<CommandLine initial={cmdline} jump={cmdJump} close={() => ((cmdline = null), (cmdJump = false))} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if workspace.scratch.open}
|
{#if workspace.scratch.open}
|
||||||
|
|||||||
@@ -28,13 +28,14 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick, untrack } from 'svelte'
|
import { tick, untrack } from 'svelte'
|
||||||
|
import { api, type NodeSummary } from './api'
|
||||||
import { argCompletions, completions, readingDocument, runCommand, hooks } from './commands'
|
import { argCompletions, completions, readingDocument, runCommand, hooks } from './commands'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initial: string
|
initial: string
|
||||||
/** A `/` search across every space rather than this one — Ctrl-P. */
|
/** Ctrl-P: a jump to a node by its name, in every space, rather than a command. */
|
||||||
global?: boolean
|
jump?: boolean
|
||||||
close: () => void
|
close: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +47,18 @@
|
|||||||
help?: string
|
help?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { initial, global = false, close }: Props = $props()
|
const { initial, jump = false, close }: Props = $props()
|
||||||
|
|
||||||
let value = $state(untrack(() => initial))
|
let value = $state(untrack(() => initial))
|
||||||
let input = $state<HTMLInputElement>()
|
let input = $state<HTMLInputElement>()
|
||||||
let list = $state<HTMLUListElement>()
|
let list = $state<HTMLUListElement>()
|
||||||
const isSearch = $derived(value.startsWith('/'))
|
const isSearch = $derived(!jump && value.startsWith('/'))
|
||||||
|
/** A search across every space rather than this one; Tab switches. */
|
||||||
|
let global = $state(false)
|
||||||
|
|
||||||
|
/* What a jump offers for what is typed, and the one lit. */
|
||||||
|
let places = $state<NodeSummary[]>([])
|
||||||
|
let lit = $state(0)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* A capture line — `c` opens one — writes a thought down somewhere without
|
* A capture line — `c` opens one — writes a thought down somewhere without
|
||||||
@@ -70,7 +77,7 @@
|
|||||||
value = value.replace(/^:\S+/, `:${next}`)
|
value = value.replace(/^:\S+/, `:${next}`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const historyKey = $derived(isSearch ? 'diary:history:search' : 'diary:history:cmd')
|
const historyKey = $derived(jump ? 'diary:history:jump' : isSearch ? 'diary:history:search' : 'diary:history:cmd')
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Where the keys were before the line took them: the line goes away under
|
* Where the keys were before the line took them: the line goes away under
|
||||||
@@ -98,6 +105,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shown = $derived.by((): Item[] => {
|
const shown = $derived.by((): Item[] => {
|
||||||
|
if (jump) return places.map((node) => ({ value: String(node.id), label: node.name || node.slug, args: node.location }))
|
||||||
if (cycle) return cycle.items
|
if (cycle) return cycle.items
|
||||||
if (isSearch || value.replace(/^:/, '').length === 0) return []
|
if (isSearch || value.replace(/^:/, '').length === 0) return []
|
||||||
return candidates(value).slice(0, 8)
|
return candidates(value).slice(0, 8)
|
||||||
@@ -109,7 +117,7 @@
|
|||||||
|
|
||||||
// The match Tab is on stays in sight in a list longer than the box.
|
// The match Tab is on stays in sight in a list longer than the box.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (cycle?.at === undefined) return
|
if ((jump ? lit : cycle?.at) === undefined) return
|
||||||
void tick().then(() => list?.querySelector('.current')?.scrollIntoView({ block: 'nearest' }))
|
void tick().then(() => list?.querySelector('.current')?.scrollIntoView({ block: 'nearest' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -117,10 +125,35 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isSearch) return
|
if (!isSearch) return
|
||||||
const query = value.slice(1)
|
const query = value.slice(1)
|
||||||
const timer = setTimeout(() => void workspace.guard(() => workspace.tree.search(query, global)), 120)
|
const everywhere = global
|
||||||
|
const timer = setTimeout(() => void workspace.guard(() => workspace.tree.search(query, everywhere)), 120)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// So is a jump, with the places it offers under the line.
|
||||||
|
$effect(() => {
|
||||||
|
if (!jump) return
|
||||||
|
const query = value
|
||||||
|
const timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
void workspace.guard(async () => {
|
||||||
|
const found = await api.jump(query)
|
||||||
|
if (value !== query) return
|
||||||
|
places = found
|
||||||
|
lit = 0
|
||||||
|
}),
|
||||||
|
120,
|
||||||
|
)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Enter or a click on a jump: the place opened, a folder by going in. */
|
||||||
|
async function go(node: NodeSummary | undefined) {
|
||||||
|
if (!node) return
|
||||||
|
await workspace.guard(() => workspace.tree.openPath(node.location ? `${node.location}/${node.slug}` : node.slug))
|
||||||
|
await giveBackFocus()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hand the keys back once the line is gone, unless what ran took them
|
* Hand the keys back once the line is gone, unless what ran took them
|
||||||
* somewhere itself — into the editor, a prompt, an overlay.
|
* somewhere itself — into the editor, a prompt, an overlay.
|
||||||
@@ -139,9 +172,12 @@
|
|||||||
async function submit(event: SubmitEvent) {
|
async function submit(event: SubmitEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const line = value
|
const line = value
|
||||||
|
// Closing takes the line's props with it, so what it was is read first.
|
||||||
|
const [jumping, searching, place] = [jump, isSearch, places[lit]]
|
||||||
remember(historyKey, line)
|
remember(historyKey, line)
|
||||||
close()
|
close()
|
||||||
if (isSearch) {
|
if (jumping) return go(place)
|
||||||
|
if (searching) {
|
||||||
// Enter goes to the highlighted result, once the results are for what
|
// Enter goes to the highlighted result, once the results are for what
|
||||||
// was typed rather than for the line a keystroke ago.
|
// was typed rather than for the line a keystroke ago.
|
||||||
const query = line.slice(1)
|
const query = line.slice(1)
|
||||||
@@ -199,10 +235,15 @@
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
close()
|
close()
|
||||||
void giveBackFocus()
|
void giveBackFocus()
|
||||||
} else if (event.key === 'Tab' && isSearch) {
|
} else if (jump && (event.key === 'Tab' || (ctrl && (event.key === 'n' || event.key === 'p')))) {
|
||||||
// Nothing to complete in a search: Tab walks the results instead.
|
// The places, walked without leaving the line; Enter goes to the one lit.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
workspace.tree.move(event.shiftKey ? -1 : 1)
|
const count = places.length
|
||||||
|
if (count) lit = (lit + (event.shiftKey || event.key === 'p' ? -1 : 1) + count) % count
|
||||||
|
} else if (event.key === 'Tab' && isSearch) {
|
||||||
|
// Nothing to complete in a search: Tab takes it to every space, or back.
|
||||||
|
event.preventDefault()
|
||||||
|
global = !global
|
||||||
} else if (event.key === 'Tab') {
|
} else if (event.key === 'Tab') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!retarget(event.shiftKey ? -1 : 1)) complete(event.shiftKey ? -1 : 1)
|
if (!retarget(event.shiftKey ? -1 : 1)) complete(event.shiftKey ? -1 : 1)
|
||||||
@@ -231,8 +272,8 @@
|
|||||||
{#each shown as item, index (item.value)}
|
{#each shown as item, index (item.value)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
class:current={cycle?.at === index}
|
class:current={jump ? lit === index : cycle?.at === index}
|
||||||
onclick={() => ((value = item.value), (cycle = null), input?.focus())}
|
onclick={() => (jump ? (close(), void go(places[index])) : ((value = item.value), (cycle = null), input?.focus()))}
|
||||||
>
|
>
|
||||||
<span class="name">{item.label}</span>
|
<span class="name">{item.label}</span>
|
||||||
{#if item.args}<span class="muted">{item.args}</span>{/if}
|
{#if item.args}<span class="muted">{item.args}</span>{/if}
|
||||||
@@ -244,7 +285,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={submit}>
|
<form onsubmit={submit}>
|
||||||
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.tree.space?.slug ?? '')}</span>{/if}
|
{#if isSearch}<span class="scope label" title="Tab: this space · every space">{global ? 'all spaces' : (workspace.tree.space?.slug ?? '')} <span class="kbd">tab</span></span>{/if}
|
||||||
|
{#if jump}<span class="scope label">go to</span>{/if}
|
||||||
{#if capture}<span class="scope label" title="Tab: card · note · today">→ {targetNames[capture]} <span class="kbd">tab</span></span>{/if}
|
{#if capture}<span class="scope label" title="Tab: card · note · today">→ {targetNames[capture]} <span class="kbd">tab</span></span>{/if}
|
||||||
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
|
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
desk: true,
|
desk: true,
|
||||||
},
|
},
|
||||||
{ icon: 'search', label: 'Search this space', keys: keysOf('tree', 'search'), run: () => hooks.openCommandLine('/') },
|
{ 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() },
|
{ icon: 'search-all', label: 'Go to anything by its name', keys: keysOf('global', 'find'), run: () => hooks.openJump() },
|
||||||
],
|
],
|
||||||
[cmd, settings, help],
|
[cmd, settings, help],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,15 +20,19 @@
|
|||||||
return node.name.trim() || node.excerpt.trim() || node.slug
|
return node.name.trim() || node.excerpt.trim() || node.slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The diary is a journal, listed by day; anywhere else the list is by name. */
|
||||||
|
const journal = $derived(workspace.tree.space?.role === 'diary')
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* A folder is listed by what is in it, a document by when it was written —
|
* A folder is listed by what is in it, a document by when it was written in
|
||||||
* and how many files it has, folded or not — a file by its size.
|
* the diary and when it was last edited elsewhere — and how many files it
|
||||||
|
* has, folded or not — a file by its size.
|
||||||
*/
|
*/
|
||||||
const aside = (node: NodeSummary) =>
|
const aside = (node: NodeSummary) =>
|
||||||
node.file
|
node.file
|
||||||
? formatBytes(node.file.size)
|
? formatBytes(node.file.size)
|
||||||
: node.kind === 'document'
|
: node.kind === 'document'
|
||||||
? `${node.child_count > 0 ? `+${node.child_count} · ` : ''}${formatTime(node.created_at)}`
|
? `${node.child_count > 0 ? `+${node.child_count} · ` : ''}${journal ? formatTime(node.created_at) : relative(node.updated_at)}`
|
||||||
: `${node.child_count}`
|
: `${node.child_count}`
|
||||||
|
|
||||||
/* The row a day heading compares with: the one above, past a document's files. */
|
/* The row a day heading compares with: the one above, past a document's files. */
|
||||||
@@ -114,9 +118,9 @@
|
|||||||
{#each workspace.tree.nodes as node, index (node.id)}
|
{#each workspace.tree.nodes as node, index (node.id)}
|
||||||
{@const previous = above(index)}
|
{@const previous = above(index)}
|
||||||
{@const nested = workspace.tree.isNested(node)}
|
{@const nested = workspace.tree.isNested(node)}
|
||||||
<!-- Day headings only make sense in a list that is a journal: documents,
|
<!-- Day headings only make sense in a list that is a journal: the
|
||||||
in date order, not a search and not a folder listing. -->
|
diary's documents, in date order, not a search. -->
|
||||||
{#if !workspace.tree.query && !nested && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
|
{#if journal && !workspace.tree.query && !nested && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
|
||||||
<li class="daybreak label rule">
|
<li class="daybreak label rule">
|
||||||
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
|
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* share an action and differ in direction. Returning `false` lets the key
|
* share an action and differ in direction. Returning `false` lets the key
|
||||||
* on to the next context down, as if it had not been bound here.
|
* on to the next context down, as if it had not been bound here.
|
||||||
*/
|
*/
|
||||||
import { cycleTile, focusTile, followLink, hooks, openInfo, runCommand } from './commands'
|
import { cycleTile, focusTile, followLink, hooks, newDocument, openInfo, runCommand } from './commands'
|
||||||
import { bindings, type Context } from './keymap'
|
import { bindings, type Context } from './keymap'
|
||||||
import { deeperHelp, focusHelpFilter, scrollHelp } from './Help.svelte'
|
import { deeperHelp, focusHelpFilter, scrollHelp } from './Help.svelte'
|
||||||
import { actSetting, moveSettings, removeSetting, turnSettings, openSettings } from './Settings.svelte'
|
import { actSetting, moveSettings, removeSetting, turnSettings, openSettings } from './Settings.svelte'
|
||||||
@@ -413,7 +413,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
|||||||
markAll: () => workspace.tree.markAll(),
|
markAll: () => workspace.tree.markAll(),
|
||||||
files: () => guard(() => workspace.tree.toggleFiles()),
|
files: () => guard(() => workspace.tree.toggleFiles()),
|
||||||
drop: () => guard(() => workspace.tree.drop()),
|
drop: () => guard(() => workspace.tree.drop()),
|
||||||
new: () => void runCommand('new'),
|
new: () => guard(newDocument),
|
||||||
// Capture: a thought goes on this space's inbox without leaving here.
|
// Capture: a thought goes on this space's inbox without leaving here.
|
||||||
// Reading a document, `:card` makes it about that document.
|
// Reading a document, `:card` makes it about that document.
|
||||||
capture: () => line(':card '),
|
capture: () => line(':card '),
|
||||||
@@ -534,9 +534,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
global: {
|
global: {
|
||||||
// Ctrl-P: find anything, in any space — the one search that is not
|
// Ctrl-P: go to anything by its name, in any space.
|
||||||
// confined to where you are.
|
find: () => hooks.openJump(),
|
||||||
find: () => hooks.openGlobalSearch(),
|
|
||||||
// Ctrl-D / Ctrl-U page the open document, as they do in a pager.
|
// Ctrl-D / Ctrl-U page the open document, as they do in a pager.
|
||||||
halfDown: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfdown') : false),
|
halfDown: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfdown') : false),
|
||||||
halfUp: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfup') : false),
|
halfUp: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfup') : false),
|
||||||
|
|||||||
@@ -458,6 +458,8 @@ export const api = {
|
|||||||
|
|
||||||
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
|
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
|
||||||
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
|
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
|
||||||
|
/** Ctrl-P: the nodes called what was typed, in every space, with where each is. */
|
||||||
|
jump: (q: string) => request<NodeSummary[]>(`/jump?q=${encodeURIComponent(q)}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a folder into a node. The relative path is passed as each part's
|
* Upload a folder into a node. The relative path is passed as each part's
|
||||||
|
|||||||
+17
-3
@@ -44,8 +44,8 @@ export const hooks = {
|
|||||||
overviewColumns: (): number => 1,
|
overviewColumns: (): number => 1,
|
||||||
openOverview: () => {},
|
openOverview: () => {},
|
||||||
openCommandLine: (_initial: string) => {},
|
openCommandLine: (_initial: string) => {},
|
||||||
/** Ctrl-P: a search across every space. */
|
/** Ctrl-P: a jump to a node by its name, in every space. */
|
||||||
openGlobalSearch: () => {},
|
openJump: () => {},
|
||||||
/** Esc with the line open but the keyboard elsewhere: the line away; false when it is not open. */
|
/** Esc with the line open but the keyboard elsewhere: the line away; false when it is not open. */
|
||||||
closeCommandLine: (): boolean => false,
|
closeCommandLine: (): boolean => false,
|
||||||
/** `F`: letters over the links and buttons inside `root`. */
|
/** `F`: letters over the links and buttons inside `root`. */
|
||||||
@@ -271,6 +271,20 @@ function templateNames(): string[] {
|
|||||||
return templateCache.names
|
return templateCache.names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `o`: a new document here. When the space has templates the line comes up
|
||||||
|
* as `:new ` with them listed — Tab picks one, Enter on nothing is a blank
|
||||||
|
* document. The diary starts from `templates/day` by itself, so it is not
|
||||||
|
* asked.
|
||||||
|
*/
|
||||||
|
export async function newDocument() {
|
||||||
|
const space = workspace.tree.space
|
||||||
|
const templates = space && space.role !== 'diary' ? await api.templates(space.id) : []
|
||||||
|
if (!space || templates.length === 0) return runCommand('new')
|
||||||
|
templateCache = { space: space.id, names: templates.map((t) => t.slug) }
|
||||||
|
hooks.openCommandLine(':new ')
|
||||||
|
}
|
||||||
|
|
||||||
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
|
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
|
||||||
const sealTarget = (encrypt: boolean) =>
|
const sealTarget = (encrypt: boolean) =>
|
||||||
workspace.guard(async () => {
|
workspace.guard(async () => {
|
||||||
@@ -1184,7 +1198,7 @@ export const commands: CommandSpec[] = [
|
|||||||
help: 'full-text search in this space (empty clears)',
|
help: 'full-text search in this space (empty clears)',
|
||||||
run: (arg) => workspace.guard(() => workspace.tree.search(arg, false)),
|
run: (arg) => workspace.guard(() => workspace.tree.search(arg, false)),
|
||||||
bang: {
|
bang: {
|
||||||
help: 'full-text search across every space — Ctrl-P',
|
help: 'full-text search across every space — `/` then Tab',
|
||||||
run: (arg) => workspace.guard(() => workspace.tree.search(arg, true)),
|
run: (arg) => workspace.guard(() => workspace.tree.search(arg, true)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const bindings = [
|
|||||||
{ context: 'tree', keys: ['gx'], action: 'inTab', help: 'open the file under the cursor in a tab of its own' },
|
{ 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', essential: true },
|
{ 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: ['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: 'new', help: 'new document here, straight into insert mode — when the space has templates, the line offers them first', essential: true },
|
||||||
{ context: 'tree', keys: ['O'], action: 'mkdir', caps: ['⇧ O'], help: 'new folder here' },
|
{ context: 'tree', keys: ['O'], action: 'mkdir', caps: ['⇧ O'], help: 'new folder here' },
|
||||||
{ context: 'tree', keys: ['i', 'a'], action: 'edit', help: 'edit the open document', essential: true },
|
{ 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: ['R'], action: 'rename', help: 'rename what is under the cursor — document, folder or card — starting from its name' },
|
||||||
@@ -114,12 +114,12 @@ export const bindings = [
|
|||||||
{ context: 'global', keys: ['C'], action: 'calendar', help: 'the diary by month, on the plan screen: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day among the documents' },
|
{ context: 'global', keys: ['C'], action: 'calendar', help: 'the diary by month, on the plan screen: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day among the documents' },
|
||||||
{ 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: 'previousEntry', label: '[d / ]d', help: 'the diary entry before / after the one open, by date' },
|
||||||
{ context: 'tree', keys: [']d'], action: 'nextEntry' },
|
{ 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', essential: true },
|
{ context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'go to anything by its name, in every space — words before the last narrow the path, as in proj/ingest; Tab or Ctrl-N/P walk, Enter goes', essential: true },
|
||||||
{ context: 'global', keys: ['b'], action: 'board', help: "the board, on the plan screen: this space's, or the last one shown — 1 … 9 there shows another's" },
|
{ context: 'global', keys: ['b'], action: 'board', help: "the board, on the plan screen: 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", essential: true },
|
{ 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: ['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: ['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: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab takes it to every space, 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: '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: ['N'], action: 'previous' },
|
||||||
{ 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: ['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 },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api, ApiError, type Agenda, type AgendaCard, type Board, type BoardList, type Card, type Moved, type NodeSummary, type Task } from '../api'
|
import { api, ApiError, type Agenda, type AgendaCard, type Board, type BoardList, type Card, type Moved, type NodeSummary, type Task, type WorkspaceNode } from '../api'
|
||||||
import { formatDay, formatWeekday } from '../util'
|
import { formatDay, formatWeekday } from '../util'
|
||||||
import type { Workspace } from '../store.svelte'
|
import type { Workspace } from '../store.svelte'
|
||||||
|
|
||||||
@@ -543,19 +543,20 @@ export class Cards {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the document a card points at. A card without one gets one, named
|
* Open the document a card points at. A card without one gets one, named
|
||||||
* after it, in the space's root — so Enter always leads to a place to write
|
* after it, in the folder being browsed when that is in the card's space,
|
||||||
* the notes down. `q` on that document comes back here.
|
* and in the space's root when it is not — so Enter always leads to a
|
||||||
|
* place to write the notes down. `q` on that document comes back here.
|
||||||
*/
|
*/
|
||||||
async openCard() {
|
async openCard() {
|
||||||
const card = this.card
|
const card = this.card
|
||||||
const space = this.boardSpaceId
|
const space = this.boardSpaceId
|
||||||
if (!card || space === null) return
|
if (!card || space === null) return
|
||||||
let nodeId = card.node_id
|
let notes: WorkspaceNode | null = null
|
||||||
if (nodeId !== null) {
|
if (card.node_id !== null) {
|
||||||
// Deleted notes read as gone, but the card still points at them, so
|
// Deleted notes read as gone, but the card still points at them, so
|
||||||
// the way back to them is named rather than a bare "not found".
|
// the way back to them is named rather than a bare "not found".
|
||||||
try {
|
try {
|
||||||
await api.getNode(nodeId)
|
notes = await api.getNode(card.node_id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.status === 404) {
|
if (error instanceof ApiError && error.status === 404) {
|
||||||
return this.ws.say(`the notes of "${card.title}" are gone — :trash may still have them`, 'error')
|
return this.ws.say(`the notes of "${card.title}" are gone — :trash may still have them`, 'error')
|
||||||
@@ -563,16 +564,17 @@ export class Cards {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (nodeId === null) {
|
if (!notes) {
|
||||||
const node = await api.createNode({ parent_id: space, name: card.title, body: '' })
|
const parent = this.ws.tree.spaceId === space ? (this.ws.tree.parentId ?? space) : space
|
||||||
await api.updateCard(card.id, { node_id: node.id })
|
notes = await api.createNode({ parent_id: parent, name: card.title, body: '' })
|
||||||
nodeId = node.id
|
await api.updateCard(card.id, { node_id: notes.id })
|
||||||
}
|
}
|
||||||
if (this.ws.tree.parentId !== space) await this.ws.tree.goTo(space)
|
const folder = notes.parent_id ?? space
|
||||||
|
if (this.ws.tree.parentId !== folder) await this.ws.tree.goTo(folder)
|
||||||
else await this.ws.tree.refresh()
|
else await this.ws.tree.refresh()
|
||||||
// The notes open beside the board, and `q` takes their tile away again
|
// The notes open beside the board, and `q` takes their tile away again
|
||||||
// on the way back to it.
|
// on the way back to it.
|
||||||
await this.ws.tree.openNode(nodeId, card.node_id === null)
|
await this.ws.tree.openNode(notes.id, card.node_id === null)
|
||||||
if (card.node_id === null) this.ws.enterInsert = true
|
if (card.node_id === null) this.ws.enterInsert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class Tree {
|
|||||||
*/
|
*/
|
||||||
marked = $state.raw<NodeSummary[]>([])
|
marked = $state.raw<NodeSummary[]>([])
|
||||||
|
|
||||||
/** A search across every space rather than the one being browsed — Ctrl-P. */
|
/** A search across every space rather than the one being browsed — `/` then Tab, or `:search!`. */
|
||||||
globalSearch = $state(false)
|
globalSearch = $state(false)
|
||||||
|
|
||||||
/** The last thing deleted here, which `u` puts back. */
|
/** The last thing deleted here, which `u` puts back. */
|
||||||
@@ -508,14 +508,20 @@ export class Tree {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Follow a `[[title]]`: to the document it names, or — when there is none
|
* Follow a `[[title]]`: to the document it names, or — when there is none
|
||||||
* — to a new one called that, here, after asking. Which document a title
|
* — to a new one called that, beside the document linking to it, after
|
||||||
* names the server says when the text is written, so it is written first.
|
* asking. Which document a title names the server says when the text is
|
||||||
|
* written, so it is written first.
|
||||||
*/
|
*/
|
||||||
async followTitle(title: string) {
|
async followTitle(title: string) {
|
||||||
await this.ws.drafts.flushTile()
|
await this.ws.drafts.flushTile()
|
||||||
const path = this.ws.open?.wikilinks[title]
|
const open = this.ws.open
|
||||||
|
const path = open?.wikilinks[title]
|
||||||
if (path) return this.openPath(path)
|
if (path) return this.openPath(path)
|
||||||
if (!(await this.ws.prompts.ask(`no document called "${title}" yet — write it in ${this.here}?`))) return
|
const crumbs = open?.path.slice(0, -1) ?? []
|
||||||
|
const folder = crumbs.at(-1)
|
||||||
|
const where = folder ? crumbs.map((crumb) => crumb.slug).join('/') : this.here
|
||||||
|
if (!(await this.ws.prompts.ask(`no document called "${title}" yet — write it in ${where}?`))) return
|
||||||
|
if (folder && folder.id !== this.parentId) await this.goTo(folder.id)
|
||||||
await this.createDocument(title)
|
await this.createDocument(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user