mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
keys: the command line remembers and completes
`↑` / `↓` (and `Ctrl-p` / `Ctrl-n`) walk the lines run before, newest first, narrowed by what is already typed: `:se` then `↑` finds the last `:set …`. Commands and `/` searches keep separate histories in localStorage, a hundred lines each. Passwords never get here: they are typed into the vault prompt, not the line. Tab moves to the next match and Shift-Tab to the one before, with the match marked in the list. Once a name is complete, Tab completes the argument: the folders listed here plus `..` and `/` for `:cd` and `:mv`, space names for `:space` and `:rmspace`, the options of `:set`, `:due`'s usual dates and `:backup`'s subcommands. `:mv /` now moves to the root of the space, as `:cd /` goes there. While a search is live, `Ctrl-n` / `Ctrl-p` (and Tab) move the list cursor through the results without leaving the line, and Enter opens the one lit rather than only closing the line. Ctrl-P's search does the same. After a command the focus goes back where it was, or to the editor while writing, instead of being left on <body> for the window handler to catch.
This commit is contained in:
1 parent
37443cf083
commit
b3a60aa361
4 files changed
+220
-25
No files matched your search
+175
-15
@@ -1,6 +1,34 @@
|
||||
<script lang="ts" module>
|
||||
/*
|
||||
* What was run before, oldest first, kept across reloads. Commands and
|
||||
* searches are walked separately, as in vim: `/` then ↑ finds searches.
|
||||
*/
|
||||
const HISTORY_CAP = 100
|
||||
|
||||
function readHistory(key: string): string[] {
|
||||
try {
|
||||
const lines = JSON.parse(localStorage.getItem(key) ?? '[]')
|
||||
return Array.isArray(lines) ? lines.filter((line) => typeof line === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** The line goes to the end, and any earlier copy of it goes. */
|
||||
function remember(key: string, line: string) {
|
||||
if (!line.replace(/^[:/]/, '').trim()) return
|
||||
const lines = [...readHistory(key).filter((old) => old !== line), line].slice(-HISTORY_CAP)
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(lines))
|
||||
} catch {
|
||||
/* a full localStorage costs the history, not the command */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { completions, runCommand, hooks } from './commands'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { argCompletions, completions, runCommand, hooks } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -10,17 +38,63 @@
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** One line of the completion list, and what picking it makes of the line. */
|
||||
interface Item {
|
||||
value: string
|
||||
label: string
|
||||
args?: string
|
||||
help?: string
|
||||
}
|
||||
|
||||
const { initial, global = false, close }: Props = $props()
|
||||
|
||||
let value = $state(untrack(() => initial))
|
||||
let input = $state<HTMLInputElement>()
|
||||
let list = $state<HTMLUListElement>()
|
||||
const isSearch = $derived(value.startsWith('/'))
|
||||
const matches = $derived(isSearch ? [] : completions(value).slice(0, 8))
|
||||
const historyKey = $derived(isSearch ? 'diary:history:search' : 'diary:history:cmd')
|
||||
|
||||
/*
|
||||
* Where the keys were before the line took them: the line goes away under
|
||||
* the focus, which would otherwise be left on nothing at all.
|
||||
*/
|
||||
const returnTo = document.activeElement as HTMLElement | null
|
||||
|
||||
/** Tab's walk through the matches, frozen as they were when it started. */
|
||||
let cycle = $state<{ items: Item[]; at: number } | null>(null)
|
||||
/** ↑'s walk back through the history, narrowed by what was typed first. */
|
||||
let walk: { stem: string; lines: string[]; at: number } | null = null
|
||||
|
||||
function candidates(line: string): Item[] {
|
||||
const args = argCompletions(line)
|
||||
if (args !== null) {
|
||||
const head = line.replace(/^:/, '').split(/\s/)[0]
|
||||
return args.map((arg) => ({ value: `:${head} ${arg}`, label: arg }))
|
||||
}
|
||||
return completions(line).map((spec) => ({
|
||||
value: `:${spec.name} `,
|
||||
label: `:${spec.name}`,
|
||||
args: spec.args,
|
||||
help: spec.help,
|
||||
}))
|
||||
}
|
||||
|
||||
const shown = $derived.by((): Item[] => {
|
||||
if (cycle) return cycle.items
|
||||
if (isSearch || value.replace(/^:/, '').length === 0) return []
|
||||
return candidates(value).slice(0, 8)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
input?.focus()
|
||||
})
|
||||
|
||||
// The match Tab is on stays in sight in a list longer than the box.
|
||||
$effect(() => {
|
||||
if (cycle?.at === undefined) return
|
||||
void tick().then(() => list?.querySelector('.current')?.scrollIntoView({ block: 'nearest' }))
|
||||
})
|
||||
|
||||
// A search is live: the list filters as you type, like `incsearch`.
|
||||
$effect(() => {
|
||||
if (!isSearch) return
|
||||
@@ -29,39 +103,122 @@
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
/**
|
||||
* Hand the keys back once the line is gone, unless what ran took them
|
||||
* somewhere itself — into the editor, a prompt, an overlay.
|
||||
*/
|
||||
async function giveBackFocus() {
|
||||
// The line is taken off the page on the next update; until then it is
|
||||
// still what has the focus.
|
||||
await tick()
|
||||
const active = document.activeElement
|
||||
if (active && active !== document.body && active.isConnected) return
|
||||
if (returnTo?.isConnected && returnTo !== document.body) returnTo.focus()
|
||||
else if (workspace.editing && workspace.pane === 'document') hooks.focusEditor()
|
||||
else hooks.focusList()
|
||||
}
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
const line = value
|
||||
remember(historyKey, line)
|
||||
close()
|
||||
if (isSearch) {
|
||||
// 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)
|
||||
await workspace.guard(async () => {
|
||||
if (query && workspace.query !== query) await workspace.search(query, global)
|
||||
if (query && workspace.selected) await workspace.openSelected()
|
||||
})
|
||||
hooks.focusList()
|
||||
return
|
||||
}
|
||||
await runCommand(line)
|
||||
await giveBackFocus()
|
||||
}
|
||||
|
||||
/** Tab and Shift-Tab: the next or previous match, round and round. */
|
||||
function complete(delta: 1 | -1) {
|
||||
if (cycle) {
|
||||
const count = cycle.items.length
|
||||
cycle.at = (cycle.at + delta + count) % count
|
||||
value = cycle.items[cycle.at].value
|
||||
return
|
||||
}
|
||||
const items = candidates(value)
|
||||
if (items.length === 0) return
|
||||
// A single match is simply taken, so the next Tab moves on to what follows.
|
||||
if (items.length === 1) {
|
||||
value = items[0].value
|
||||
return
|
||||
}
|
||||
cycle = { items, at: delta > 0 ? 0 : items.length - 1 }
|
||||
value = items[cycle.at].value
|
||||
}
|
||||
|
||||
/** ↑ and ↓: the lines run before, newest first, that start as this one does. */
|
||||
function recall(delta: 1 | -1) {
|
||||
if (!walk) {
|
||||
const stem = value
|
||||
const lines = readHistory(historyKey)
|
||||
.reverse()
|
||||
.filter((line) => line.startsWith(stem) && line !== stem)
|
||||
walk = { stem, lines, at: -1 }
|
||||
}
|
||||
const at = Math.min(Math.max(walk.at + delta, -1), walk.lines.length - 1)
|
||||
if (at === walk.at) return
|
||||
walk.at = at
|
||||
value = at < 0 ? walk.stem : walk.lines[at]
|
||||
cycle = null
|
||||
void tick().then(() => input?.setSelectionRange(value.length, value.length))
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
hooks.focusList()
|
||||
}
|
||||
if (event.key === 'Tab' && matches.length > 0) {
|
||||
void giveBackFocus()
|
||||
} else if (event.key === 'Tab' && isSearch) {
|
||||
// Nothing to complete in a search: Tab walks the results instead.
|
||||
event.preventDefault()
|
||||
value = `:${matches[0].name} `
|
||||
workspace.move(event.shiftKey ? -1 : 1)
|
||||
} else if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
complete(event.shiftKey ? -1 : 1)
|
||||
} else if (ctrl && isSearch && (event.key === 'n' || event.key === 'p')) {
|
||||
// The results, walked without leaving the line; Enter opens the one lit.
|
||||
event.preventDefault()
|
||||
workspace.move(event.key === 'n' ? 1 : -1)
|
||||
} else if (event.key === 'ArrowUp' || (ctrl && event.key === 'p')) {
|
||||
event.preventDefault()
|
||||
recall(1)
|
||||
} else if (event.key === 'ArrowDown' || (ctrl && event.key === 'n')) {
|
||||
event.preventDefault()
|
||||
recall(-1)
|
||||
}
|
||||
}
|
||||
|
||||
function oninput() {
|
||||
cycle = null
|
||||
walk = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="cmdline">
|
||||
{#if matches.length > 0 && value.replace(/^:/, '').length > 0}
|
||||
<ul class="completions">
|
||||
{#each matches as spec (spec.name)}
|
||||
{#if shown.length > 0}
|
||||
<ul class="completions" bind:this={list}>
|
||||
{#each shown as item, index (item.value)}
|
||||
<li>
|
||||
<button onclick={() => (value = `:${spec.name} `, input?.focus())}>
|
||||
<span class="name">:{spec.name}</span>
|
||||
{#if spec.args}<span class="muted">{spec.args}</span>{/if}
|
||||
<span class="help">{spec.help}</span>
|
||||
<button
|
||||
class:current={cycle?.at === index}
|
||||
onclick={() => ((value = item.value), (cycle = null), input?.focus())}
|
||||
>
|
||||
<span class="name">{item.label}</span>
|
||||
{#if item.args}<span class="muted">{item.args}</span>{/if}
|
||||
{#if item.help}<span class="help">{item.help}</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -70,7 +227,7 @@
|
||||
|
||||
<form onsubmit={submit}>
|
||||
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.space?.slug ?? '')}</span>{/if}
|
||||
<input bind:this={input} bind:value {onkeydown} spellcheck="false" autocomplete="off" />
|
||||
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -130,6 +287,9 @@
|
||||
text-transform: none;
|
||||
}
|
||||
.completions button:hover { background: var(--surface-alt); }
|
||||
.completions button.current { background: var(--light); color: var(--ink); }
|
||||
.completions button.current .name,
|
||||
.completions button.current .help { color: var(--ink); }
|
||||
.name { color: var(--light); }
|
||||
.help { color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -28,16 +28,16 @@
|
||||
['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'],
|
||||
['t', 'agenda — late, due this week, in hand, on every board'],
|
||||
['T', "today's diary entry, started if there is none"],
|
||||
['Ctrl-P', 'find anything, in every space'],
|
||||
['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'],
|
||||
['b', "open this space's board"],
|
||||
['c', "capture a card into this space's inbox, without leaving here"],
|
||||
['Esc · q', 'close the document'],
|
||||
['/', 'full-text search, inside the current space'],
|
||||
['/', 'full-text search, inside the current space — Tab or Ctrl-N/P walk the results, Enter opens one'],
|
||||
['n', 'clear the search'],
|
||||
['s', 'toggle sharing for the document — asks before taking it back'],
|
||||
['y', 'mint a new share link and copy it — asks, the old one stops working'],
|
||||
['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'],
|
||||
[':', 'command line'],
|
||||
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
|
||||
['Ctrl-S', 'save, from anywhere'],
|
||||
['?', 'this screen — in the editor too'],
|
||||
]
|
||||
|
||||
+39
-4
@@ -51,6 +51,8 @@ export interface CommandSpec {
|
||||
group: Group
|
||||
help: string
|
||||
run: (arg: string) => void | Promise<void>
|
||||
/** What the argument can be, for Tab on the command line. */
|
||||
complete?: (arg: string) => string[]
|
||||
/** The forcing variant, `:name!` — vim hands the bang over as an argument. */
|
||||
bang?: { help: string; run: (arg: string) => void | Promise<void> }
|
||||
}
|
||||
@@ -93,6 +95,15 @@ const findFolder = (target: string) => {
|
||||
)
|
||||
}
|
||||
|
||||
/** The folders listed here, by slug, then the two ways out — for `:cd` and `:mv`. */
|
||||
const folderNames = () => [
|
||||
...workspace.nodes.filter(isContainer).map((node) => node.slug),
|
||||
'..',
|
||||
'/',
|
||||
]
|
||||
|
||||
const spaceNames = () => workspace.spaces.map((space) => space.slug)
|
||||
|
||||
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
|
||||
const sealTarget = (encrypt: boolean) =>
|
||||
workspace.guard(async () => {
|
||||
@@ -287,6 +298,7 @@ export const commands: CommandSpec[] = [
|
||||
args: '[name]',
|
||||
group: 'tree',
|
||||
help: 'switch to a space, or list them',
|
||||
complete: spaceNames,
|
||||
run: async (arg) => {
|
||||
const wanted = arg.trim().toLowerCase()
|
||||
if (!wanted) {
|
||||
@@ -321,6 +333,7 @@ export const commands: CommandSpec[] = [
|
||||
args: '<name|..|/>',
|
||||
group: 'tree',
|
||||
help: 'enter a folder, .. goes up, / back to the space',
|
||||
complete: folderNames,
|
||||
run: async (arg) => {
|
||||
const target = arg.trim()
|
||||
await workspace.guard(async () => {
|
||||
@@ -382,6 +395,7 @@ export const commands: CommandSpec[] = [
|
||||
args: '<when|->',
|
||||
group: 'board',
|
||||
help: 'due date of the card: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
|
||||
complete: () => ['today', 'tm', 'fri', '+3d'],
|
||||
run: async (arg) => {
|
||||
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
|
||||
const target = arg.trim()
|
||||
@@ -428,14 +442,15 @@ export const commands: CommandSpec[] = [
|
||||
{
|
||||
name: 'move',
|
||||
aliases: ['mv'],
|
||||
args: '<folder|..>',
|
||||
args: '<folder|..|/>',
|
||||
group: 'tree',
|
||||
help: 'move the marked nodes, or else the selected one, into a folder here, or up one level',
|
||||
help: 'move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space',
|
||||
complete: folderNames,
|
||||
run: async (arg) => {
|
||||
const node = workspace.marked[0] ?? workspace.target
|
||||
if (!node) return workspace.say('nothing selected', 'error')
|
||||
const target = arg.trim()
|
||||
if (!target) return workspace.say('usage: :mv <folder|..>', 'error')
|
||||
if (!target) return workspace.say('usage: :mv <folder|..|/>', 'error')
|
||||
|
||||
// `..` is up from where the node is, which in a search result is not
|
||||
// the folder being browsed.
|
||||
@@ -444,7 +459,8 @@ export const commands: CommandSpec[] = [
|
||||
const parent = await api.getNode(node.parent_id)
|
||||
return parent.kind === 'space' ? undefined : (parent.parent_id ?? undefined)
|
||||
}
|
||||
const destination = target === '..' ? await up() : findFolder(target)?.id
|
||||
const destination =
|
||||
target === '..' ? await up() : target === '/' ? (workspace.spaceId ?? undefined) : findFolder(target)?.id
|
||||
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
|
||||
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
|
||||
if (workspace.marked.length) return workspace.guard(() => workspace.moveNodes(workspace.marked, destination))
|
||||
@@ -651,6 +667,7 @@ export const commands: CommandSpec[] = [
|
||||
args: '[name]',
|
||||
group: 'tree',
|
||||
help: 'delete a space and everything in it (asks first)',
|
||||
complete: spaceNames,
|
||||
run: async (arg) => {
|
||||
const wanted = arg.trim().toLowerCase()
|
||||
const space = wanted
|
||||
@@ -878,6 +895,7 @@ export const commands: CommandSpec[] = [
|
||||
args: '<option>',
|
||||
group: 'view',
|
||||
help: 'vim, novim, clipboard, noclipboard, board, noboard',
|
||||
complete: () => ['vim', 'novim', 'clipboard', 'noclipboard', 'board', 'noboard'],
|
||||
run: (arg) => {
|
||||
const option = arg.trim()
|
||||
if (option === 'vim') return workspace.setVim(true), workspace.say('vim keys on')
|
||||
@@ -898,6 +916,7 @@ export const commands: CommandSpec[] = [
|
||||
group: 'view',
|
||||
args: '[login <proton-username> | logout]',
|
||||
help: 'when the workspace was last mirrored to proton drive — login sets the mirror up, logout forgets its session',
|
||||
complete: () => ['login ', 'logout'],
|
||||
run: (arg) =>
|
||||
workspace.guard(async () => {
|
||||
const trimmed = arg.trim()
|
||||
@@ -1147,6 +1166,22 @@ export async function runCommand(line: string): Promise<void> {
|
||||
await spec.run(arg)
|
||||
}
|
||||
|
||||
/**
|
||||
* What Tab offers for the argument of `line`, a command name and the start of
|
||||
* an argument: whatever the command's `complete` lists that begins that way.
|
||||
* Null when the name is not finished yet, or names no command.
|
||||
*/
|
||||
export function argCompletions(line: string): string[] | null {
|
||||
const match = /^:?(\S+)\s(.*)$/.exec(line)
|
||||
if (!match) return null
|
||||
const [, head, arg] = match
|
||||
if (head.endsWith('!')) return []
|
||||
const spec = lookup.get(head)
|
||||
if (!spec) return null
|
||||
const wanted = arg.toLowerCase()
|
||||
return (spec.complete?.(arg) ?? []).filter((candidate) => candidate.toLowerCase().startsWith(wanted) && candidate !== arg)
|
||||
}
|
||||
|
||||
/** Command names for the `:` completion menu. */
|
||||
export function completions(prefix: string): CommandSpec[] {
|
||||
const head = prefix.trim().replace(/^:/, '').split(/\s+/)[0] ?? ''
|
||||
|
||||
Reference in new issue
Block a user