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

+8 -8
View File
@@ -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}
+56 -14
View File
@@ -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>
+1 -1
View File
@@ -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],
]
+10 -6
View File
@@ -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>
+4 -5
View File
@@ -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),
+2
View File
@@ -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
View File
@@ -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)),
},
},
+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: ['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 },
+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 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
}
+11 -5
View File
@@ -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)
}