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
|
||||
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 diary, and folders first then by name elsewhere.
|
||||
- `Ctrl-P` is full-text search across every space; the plan had it become a
|
||||
jump to a node by its name and path, with `Tab` in `/` reaching every space.
|
||||
- A card's notes are made in the space's root, not in the folder being
|
||||
browsed.
|
||||
- `o` does not offer the space's templates; `:new <template>` does.
|
||||
- The list is by name, folders first, everywhere but the diary, which stays a
|
||||
journal by day; a document's row there shows when it was last edited.
|
||||
- `Ctrl-P` goes to anything by its name, in every space (`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.
|
||||
- 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 offers them on the line, `:new `; `Enter` on
|
||||
nothing is still a blank document.
|
||||
|
||||
## 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`) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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) |
|
||||
@@ -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) |
|
||||
| `Esc`, `q` | close the document, and its tile when there are several; `Esc` first puts down what `m` picked up |
|
||||
| `h` | up one level in the tree |
|
||||
| `/` | full-text search in this space (`:noh` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit |
|
||||
| `Ctrl-P` | full-text search across every space; `Enter` opens the match beside the results |
|
||||
| `/` | 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` | 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 |
|
||||
| `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 |
|
||||
@@ -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,
|
||||
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 —
|
||||
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
|
||||
@@ -552,7 +552,7 @@ limit at least that large too.
|
||||
|
||||
### 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
|
||||
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
|
||||
@@ -924,6 +924,7 @@ sealed or not.
|
||||
| `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 |
|
||||
| `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/{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 |
|
||||
|
||||
@@ -41,6 +41,7 @@ sealed or not.
|
||||
| `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 |
|
||||
| `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/{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 |
|
||||
|
||||
@@ -115,8 +115,10 @@ outside the browser, see [Capture](Capture.md).
|
||||
|
||||
### Notes
|
||||
|
||||
`gf` on a card, or on its back, opens its notes: a document named after the card, made in the
|
||||
root of the space the first time, among the documents. `S` goes back to the
|
||||
`gf` on a card, or on its back, opens its notes: a document named after the card,
|
||||
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.
|
||||
|
||||
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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `i · a` | edit the open document |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `/` | 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 |
|
||||
| `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 |
|
||||
@@ -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 |
|
||||
| `:info [outline\|history\|backlinks\|cards\|files\|share]` | what is known about the open document: its headings, earlier versions, backlinks, cards, files and sharing — Tab between them (gi) |
|
||||
| `:search, :se [text]` | full-text search in this space (empty clears) |
|
||||
| `:search!` | full-text search across every space — Ctrl-P |
|
||||
| `:search!` | full-text search across every space — `/` then Tab |
|
||||
| `:noh, :nohlsearch` | clear the search filter |
|
||||
| `: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
|
||||
|
||||
| key | searches |
|
||||
| --- | --- |
|
||||
| `/` | the space you are in |
|
||||
| `Ctrl-P` | every space |
|
||||
`/` 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).
|
||||
|
||||
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
|
||||
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
|
||||
`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
|
||||
to it and press `L` instead: it opens in a **tile** of its own.
|
||||
@@ -159,15 +159,18 @@ going to the board.
|
||||
|
||||
### Finding things
|
||||
|
||||
`/` searches the space you are in, as you type, and `Ctrl-P` every space.
|
||||
The search reads the documents and the text files in the tree, then the cards
|
||||
`/` searches the space you are in, as you type, and `Tab` on the line takes
|
||||
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
|
||||
the words around the match.
|
||||
|
||||

|
||||
|
||||
`Tab` walks the results without leaving the line, `Enter` opens one, and
|
||||
`n`/`N` step through them afterwards. More in [Files and search](Files-and-Search.md).
|
||||
`Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens
|
||||
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
|
||||
|
||||
|
||||
@@ -24,15 +24,17 @@ deleted. See [The diary](Diary.md).
|
||||
## The tree
|
||||
|
||||
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
|
||||
written.
|
||||
in: folders first, then documents and files by name. The diary is the
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `j` / `k`, `gg` / `G` | move, first, last |
|
||||
| `l`, `Enter` | open the document or file, or go into the folder |
|
||||
| `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 |
|
||||
| `R` | rename what is under the cursor |
|
||||
| `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
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
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("/nodes", get(nodes::list).post(nodes::create))
|
||||
.route("/resolve", get(nodes::resolve))
|
||||
.route("/jump", get(nodes::jump))
|
||||
.route("/nodes/{id}", get(nodes::get))
|
||||
.route("/nodes/{id}", put(nodes::update))
|
||||
.route("/nodes/{id}", delete(nodes::remove))
|
||||
|
||||
+122
-5
@@ -626,15 +626,24 @@ pub async fn list(
|
||||
.fetch_all(&state.db)
|
||||
.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 => {
|
||||
let parent = params.parent;
|
||||
sqlx::query(&format!(
|
||||
"SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
|
||||
FROM nodes n
|
||||
"WITH RECURSIVE up (id, parent_id, role) AS (
|
||||
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'
|
||||
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"
|
||||
))
|
||||
.bind(parent)
|
||||
@@ -690,6 +699,97 @@ pub struct ResolveQuery {
|
||||
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
|
||||
/// `/n/<space>/<slug>/...` addresses the client puts in the URL bar and the
|
||||
/// importer writes into rewritten links: they stay readable because they are
|
||||
@@ -1229,7 +1329,7 @@ mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
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,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
@@ -1747,4 +1847,21 @@ mod tests {
|
||||
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)) : ''
|
||||
|
||||
let cmdline = $state<string | null>(null)
|
||||
let cmdGlobal = $state(false)
|
||||
let cmdJump = $state(false)
|
||||
let fileInput = $state<HTMLInputElement>()
|
||||
let treeInput = $state<HTMLInputElement>()
|
||||
/** Whether the files being picked go into the vault — `:upload seal`. */
|
||||
@@ -98,13 +98,13 @@
|
||||
}
|
||||
hooks.focusList = () => shell?.focus()
|
||||
hooks.openCommandLine = (initial) => {
|
||||
cmdGlobal = false
|
||||
cmdJump = false
|
||||
cmdline = initial
|
||||
}
|
||||
hooks.openOverview = () => void openOverview()
|
||||
hooks.openGlobalSearch = () => {
|
||||
cmdGlobal = true
|
||||
cmdline = '/'
|
||||
hooks.openJump = () => {
|
||||
cmdJump = true
|
||||
cmdline = ''
|
||||
}
|
||||
hooks.closeCommandLine = () => {
|
||||
if (cmdline === null) return false
|
||||
@@ -249,8 +249,8 @@
|
||||
[keysOf('global', 'plan'), 'the plan: board, agenda, calendar'],
|
||||
[[...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('global', 'find'), 'find in every space'],
|
||||
[keysOf('tree', 'search'), 'search this space'],
|
||||
[keysOf('global', 'find'), 'go to anything by its name'],
|
||||
[keysOf('tree', 'search'), 'search this space, Tab every space'],
|
||||
[keysOf('global', 'help'), 'help'],
|
||||
]
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
<StatusBar {pending} />
|
||||
|
||||
{#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 workspace.scratch.open}
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { api, type NodeSummary } from './api'
|
||||
import { argCompletions, completions, readingDocument, runCommand, hooks } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
interface Props {
|
||||
initial: string
|
||||
/** A `/` search across every space rather than this one — Ctrl-P. */
|
||||
global?: boolean
|
||||
/** Ctrl-P: a jump to a node by its name, in every space, rather than a command. */
|
||||
jump?: boolean
|
||||
close: () => void
|
||||
}
|
||||
|
||||
@@ -46,12 +47,18 @@
|
||||
help?: string
|
||||
}
|
||||
|
||||
const { initial, global = false, close }: Props = $props()
|
||||
const { initial, jump = false, close }: Props = $props()
|
||||
|
||||
let value = $state(untrack(() => initial))
|
||||
let input = $state<HTMLInputElement>()
|
||||
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
|
||||
@@ -70,7 +77,7 @@
|
||||
value = value.replace(/^:\S+/, `:${next}`)
|
||||
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
|
||||
@@ -98,6 +105,7 @@
|
||||
}
|
||||
|
||||
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 (isSearch || value.replace(/^:/, '').length === 0) return []
|
||||
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.
|
||||
$effect(() => {
|
||||
if (cycle?.at === undefined) return
|
||||
if ((jump ? lit : cycle?.at) === undefined) return
|
||||
void tick().then(() => list?.querySelector('.current')?.scrollIntoView({ block: 'nearest' }))
|
||||
})
|
||||
|
||||
@@ -117,10 +125,35 @@
|
||||
$effect(() => {
|
||||
if (!isSearch) return
|
||||
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)
|
||||
})
|
||||
|
||||
// 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
|
||||
* somewhere itself — into the editor, a prompt, an overlay.
|
||||
@@ -139,9 +172,12 @@
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
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)
|
||||
close()
|
||||
if (isSearch) {
|
||||
if (jumping) return go(place)
|
||||
if (searching) {
|
||||
// Enter goes to the highlighted result, once the results are for what
|
||||
// was typed rather than for the line a keystroke ago.
|
||||
const query = line.slice(1)
|
||||
@@ -199,10 +235,15 @@
|
||||
event.stopPropagation()
|
||||
close()
|
||||
void giveBackFocus()
|
||||
} else if (event.key === 'Tab' && isSearch) {
|
||||
// Nothing to complete in a search: Tab walks the results instead.
|
||||
} else if (jump && (event.key === 'Tab' || (ctrl && (event.key === 'n' || event.key === 'p')))) {
|
||||
// The places, walked without leaving the line; Enter goes to the one lit.
|
||||
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') {
|
||||
event.preventDefault()
|
||||
if (!retarget(event.shiftKey ? -1 : 1)) complete(event.shiftKey ? -1 : 1)
|
||||
@@ -231,8 +272,8 @@
|
||||
{#each shown as item, index (item.value)}
|
||||
<li>
|
||||
<button
|
||||
class:current={cycle?.at === index}
|
||||
onclick={() => ((value = item.value), (cycle = null), input?.focus())}
|
||||
class:current={jump ? lit === index : cycle?.at === index}
|
||||
onclick={() => (jump ? (close(), void go(places[index])) : ((value = item.value), (cycle = null), input?.focus()))}
|
||||
>
|
||||
<span class="name">{item.label}</span>
|
||||
{#if item.args}<span class="muted">{item.args}</span>{/if}
|
||||
@@ -244,7 +285,8 @@
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
|
||||
</form>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
desk: true,
|
||||
},
|
||||
{ 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],
|
||||
]
|
||||
|
||||
@@ -20,15 +20,19 @@
|
||||
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 —
|
||||
* and how many files it has, folded or not — a file by its size.
|
||||
* A folder is listed by what is in it, a document by when it was written in
|
||||
* 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) =>
|
||||
node.file
|
||||
? formatBytes(node.file.size)
|
||||
: 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}`
|
||||
|
||||
/* 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)}
|
||||
{@const previous = above(index)}
|
||||
{@const nested = workspace.tree.isNested(node)}
|
||||
<!-- Day headings only make sense in a list that is a journal: documents,
|
||||
in date order, not a search and not a folder listing. -->
|
||||
{#if !workspace.tree.query && !nested && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
|
||||
<!-- Day headings only make sense in a list that is a journal: the
|
||||
diary's documents, in date order, not a search. -->
|
||||
{#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">
|
||||
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
|
||||
</li>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 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.
|
||||
*/
|
||||
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 { deeperHelp, focusHelpFilter, scrollHelp } from './Help.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(),
|
||||
files: () => guard(() => workspace.tree.toggleFiles()),
|
||||
drop: () => guard(() => workspace.tree.drop()),
|
||||
new: () => void runCommand('new'),
|
||||
new: () => guard(newDocument),
|
||||
// Capture: a thought goes on this space's inbox without leaving here.
|
||||
// Reading a document, `:card` makes it about that document.
|
||||
capture: () => line(':card '),
|
||||
@@ -534,9 +534,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
},
|
||||
|
||||
global: {
|
||||
// Ctrl-P: find anything, in any space — the one search that is not
|
||||
// confined to where you are.
|
||||
find: () => hooks.openGlobalSearch(),
|
||||
// Ctrl-P: go to anything by its name, in any space.
|
||||
find: () => hooks.openJump(),
|
||||
// Ctrl-D / Ctrl-U page the open document, as they do in a pager.
|
||||
halfDown: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfdown') : 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. */
|
||||
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
|
||||
|
||||
+17
-3
@@ -44,8 +44,8 @@ export const hooks = {
|
||||
overviewColumns: (): number => 1,
|
||||
openOverview: () => {},
|
||||
openCommandLine: (_initial: string) => {},
|
||||
/** Ctrl-P: a search across every space. */
|
||||
openGlobalSearch: () => {},
|
||||
/** Ctrl-P: a jump to a node by its name, in every space. */
|
||||
openJump: () => {},
|
||||
/** Esc with the line open but the keyboard elsewhere: the line away; false when it is not open. */
|
||||
closeCommandLine: (): boolean => false,
|
||||
/** `F`: letters over the links and buttons inside `root`. */
|
||||
@@ -271,6 +271,20 @@ function templateNames(): string[] {
|
||||
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. */
|
||||
const sealTarget = (encrypt: boolean) =>
|
||||
workspace.guard(async () => {
|
||||
@@ -1184,7 +1198,7 @@ export const commands: CommandSpec[] = [
|
||||
help: 'full-text search in this space (empty clears)',
|
||||
run: (arg) => workspace.guard(() => workspace.tree.search(arg, false)),
|
||||
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)),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: ['Enter', 'Shift-Enter', 'l', 'ArrowRight'], action: 'open', label: 'Enter · l', help: 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password', essential: true },
|
||||
{ context: 'tree', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'from the document to the list, from the list up a level', essential: true },
|
||||
{ context: 'tree', keys: ['o'], action: 'new', help: 'new document here, straight into insert mode', essential: true },
|
||||
{ context: 'tree', keys: ['o'], action: '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: ['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' },
|
||||
@@ -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: 'tree', keys: ['[d'], action: 'previousEntry', label: '[d / ]d', help: 'the diary entry before / after the one open, by date' },
|
||||
{ context: 'tree', keys: [']d'], action: 'nextEntry' },
|
||||
{ context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on', 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: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry", essential: true },
|
||||
{ context: 'tree', keys: ['q'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several — Esc first puts down what m picked up', essential: true },
|
||||
{ context: 'tree', keys: ['Escape'], action: 'escape' },
|
||||
{ context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one', essential: true },
|
||||
{ context: 'tree', keys: ['/'], 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: '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 },
|
||||
|
||||
@@ -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 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
|
||||
* after it, in the space's root — so Enter always leads to a place to write
|
||||
* the notes down. `q` on that document comes back here.
|
||||
* after it, in the folder being browsed when that is in the card's space,
|
||||
* 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() {
|
||||
const card = this.card
|
||||
const space = this.boardSpaceId
|
||||
if (!card || space === null) return
|
||||
let nodeId = card.node_id
|
||||
if (nodeId !== null) {
|
||||
let notes: WorkspaceNode | null = null
|
||||
if (card.node_id !== null) {
|
||||
// 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".
|
||||
try {
|
||||
await api.getNode(nodeId)
|
||||
notes = await api.getNode(card.node_id)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
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
|
||||
}
|
||||
}
|
||||
if (nodeId === null) {
|
||||
const node = await api.createNode({ parent_id: space, name: card.title, body: '' })
|
||||
await api.updateCard(card.id, { node_id: node.id })
|
||||
nodeId = node.id
|
||||
if (!notes) {
|
||||
const parent = this.ws.tree.spaceId === space ? (this.ws.tree.parentId ?? space) : space
|
||||
notes = await api.createNode({ parent_id: parent, name: card.title, body: '' })
|
||||
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()
|
||||
// The notes open beside the board, and `q` takes their tile away again
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export class Tree {
|
||||
*/
|
||||
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)
|
||||
|
||||
/** 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
|
||||
* — to a new one called that, here, after asking. Which document a title
|
||||
* names the server says when the text is written, so it is written first.
|
||||
* — to a new one called that, beside the document linking to it, after
|
||||
* asking. Which document a title names the server says when the text is
|
||||
* written, so it is written first.
|
||||
*/
|
||||
async followTitle(title: string) {
|
||||
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 (!(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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user