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:
nvrl committed 2026-10-22 17:39:24 +02:00
1 parent c70bfdd7be
commit 9363a0024d
21 files changed
+297 -96

No files matched your search

+13 -9
View File
@@ -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
+6 -5
View File
@@ -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 |
+1
View File
@@ -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 |
+4 -2
View File
@@ -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
+4 -4
View File
@@ -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 |
+3 -5
View File
@@ -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
View File
@@ -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.
![A search that found a log line](../images/search.png) ![A search that found a log line](../images/search.png)
`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
+5 -3
View File
@@ -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 |
+4 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+56 -14
View File
@@ -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>
+1 -1
View File
@@ -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],
] ]
+10 -6
View File
@@ -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>
+4 -5
View File
@@ -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),
+2
View File
@@ -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
View File
@@ -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)),
}, },
}, },
+3 -3
View File
@@ -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 },
+14 -12
View File
@@ -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
} }
+11 -5
View File
@@ -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)
} }