keys: files and uploads answer the keyboard

`gx` opens the file under the cursor in a tab of its own, as the browser
shows it. `:download` (`:dl`) downloads it, after asking for the vault when
the file is sealed; on the open document it is `:export`. `:source` turns a
markdown file between rendered and as written, which before had only a
button. A sealed file asks for the vault before `gx` instead of after, so
the browser does not block the late tab as a popup.

The media list gets a cursor: `j`/`k` walk it, `i` or `Enter` embeds the
highlighted upload at the cursor while writing, `y` copies its address and
`x` deletes it. Deleting asks through the `[y/N]` prompt, which names what
would break, in place of the browser's confirm(): that one was the last
modal on the page and needed the pointer to dismiss. A footer lists the
keys, as the agenda's does.
This commit is contained in:
nvrl committed 2026-10-20 13:59:09 +02:00
1 parent 54bc333be2
commit 37443cf083
7 files changed
+172 -28

No files matched your search

+8 -2
View File
@@ -79,6 +79,7 @@ it; in the document pane it is that document. In the browse pane:
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document | | `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
| `]]` / `[[` | the next / previous heading of the open document, in the editor too | | `]]` / `[[` | the next / previous heading of the open document, in the editor too |
| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) | | `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) |
| `gx` | open the file under the cursor in a tab of its 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 | | `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 |
@@ -247,6 +248,10 @@ or a crash leaves in `uploads/`. That runs at start and once a day. A file in
the tree is never one of them: it is deleted from its folder, not from the tree is never one of them: it is deleted from its folder, not from
`:media`. `:media`.
`:media` lists every upload: `j`/`k` walk it, `i` or `Enter` embeds the
highlighted one at the cursor while writing, `y` copies its address and `x`
deletes it, asking first.
## Files ## Files
A file is a node like a document, only with bytes instead of text: it sits in A file is a node like a document, only with bytes instead of text: it sits in
@@ -257,7 +262,7 @@ place:
- PDFs, in the browser's own viewer; - PDFs, in the browser's own viewer;
- text and source code of any kind, recognised by its name or, failing that, by - text and source code of any kind, recognised by its name or, failing that, by
its first few kilobytes, with line numbers and the first 256 KiB of it; its first few kilobytes, with line numbers and the first 256 KiB of it;
- markdown, rendered, with `source` to see it as written; - markdown, rendered, with `source` (or `:source`) to see it as written;
- CSV and TSV, as a table of the first 500 rows; - CSV and TSV, as a table of the first 500 rows;
- certificates, keys and keystores — `.pem`, `.crt`, `.cer`, `.der`, `.csr`, - certificates, keys and keystores — `.pem`, `.crt`, `.cer`, `.der`, `.csr`,
`.key`, `.p8`, `.p12`, `.pfx` — read on the server: subject, issuer, `.key`, `.p8`, `.p12`, `.pfx` — read on the server: subject, issuer,
@@ -266,7 +271,8 @@ place:
it for that one look and is never stored. Nothing of a private key is ever it for that one look and is never stored. Nothing of a private key is ever
sent back but its algorithm and size. sent back but its algorithm and size.
Anything else is a download. `:name` Anything else is a download: `:download` (`:dl`) fetches the file under the
cursor, and `gx` opens one in a tab of its own as the browser shows it. `:name`
renames it, and the download is called whatever it is called now; `:mv`, `m` renames it, and the download is called whatever it is called now; `:mv`, `m`
and `p` move it; `x` deletes it. `:upload` picks files for the folder being and `p` move it; `x` deletes it. `:upload` picks files for the folder being
browsed, or drop them onto the list. Each upload is written to disk as it browsed, or drop them onto the list. Each upload is written to disk as it
+55 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { hooks, runCommand } from './lib/commands' import { followLink, hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown' import { embedSnippet } from './lib/markdown'
import Agenda from './lib/Agenda.svelte' import Agenda from './lib/Agenda.svelte'
import Bar from './lib/Bar.svelte' import Bar from './lib/Bar.svelte'
@@ -13,7 +13,7 @@
import Verify from './lib/Verify.svelte' import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte' import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte' import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
import Actions from './lib/Actions.svelte' import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte' import Prompt from './lib/Prompt.svelte'
import SecretPrompt from './lib/SecretPrompt.svelte' import SecretPrompt from './lib/SecretPrompt.svelte'
@@ -329,6 +329,48 @@
} }
} }
/** The media list's keys: j/k down the files, i inserts, y copies, x deletes. */
function mediaKey(event: KeyboardEvent): boolean {
const files = workspace.media
const last = Math.max(files.length - 1, 0)
const file = files[workspace.mediaCursor]
switch (event.key) {
case 'j':
case 'ArrowDown':
workspace.mediaCursor = Math.min(workspace.mediaCursor + 1, last)
return true
case 'k':
case 'ArrowUp':
workspace.mediaCursor = Math.max(workspace.mediaCursor - 1, 0)
return true
case 'G':
workspace.mediaCursor = last
return true
case 'Enter':
case 'i':
if (file) insertMedia(file)
return true
case 'y':
if (file) void copyMedia(file)
return true
case 'x':
if (file) void removeMedia(file)
return true
default:
return false
}
}
/** `gx`: the file as the browser shows it, in a tab of its own. */
function openInTab() {
const file = workspace.target?.file
if (!file) return workspace.say('gx opens a file — put the cursor on one', 'error')
// A tab opened after the vault's question would be stopped as a popup, so
// a sealed file asks for the vault to be opened first instead.
if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :unlock first, then gx', 'error')
followLink(file.url, true)
}
/** Alt+h / Alt+l: focus moves between the panes, out of the editor too. */ /** Alt+h / Alt+l: focus moves between the panes, out of the editor too. */
function focusPane(pane: 'list' | 'document') { function focusPane(pane: 'list' | 'document') {
if (pane === 'document' && !workspace.open) return if (pane === 'document' && !workspace.open) return
@@ -448,6 +490,12 @@
return return
} }
} }
if (workspace.overlay === 'media' && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (mediaKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'outline' && !event.ctrlKey && !event.metaKey && !event.altKey) { if (workspace.overlay === 'outline' && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (outlineKey(event)) { if (outlineKey(event)) {
event.preventDefault() event.preventDefault()
@@ -586,6 +634,11 @@
void runCommand('outline') void runCommand('outline')
return return
} }
if (previous === 'g' && key === 'x') {
event.preventDefault()
openInTab()
return
}
if (previous === 'd' && key === 'd') { if (previous === 'd' && key === 'd') {
event.preventDefault() event.preventDefault()
void runCommand('d') void runCommand('d')
+7
View File
@@ -86,13 +86,20 @@
return cert?.act() ?? false return cert?.act() ?? false
} }
const hints = () => reader ?? null const hints = () => reader ?? null
const flip = () => {
if (kind !== 'markdown' || text === null) return false
source = !source
return true
}
hooks.scrollReader = scroll hooks.scrollReader = scroll
hooks.fileAction = act hooks.fileAction = act
hooks.hintRoot = hints hooks.hintRoot = hints
hooks.toggleSource = flip
return () => { return () => {
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
if (hooks.fileAction === act) hooks.fileAction = () => false if (hooks.fileAction === act) hooks.fileAction = () => false
if (hooks.hintRoot === hints) hooks.hintRoot = () => null if (hooks.hintRoot === hints) hooks.hintRoot = () => null
if (hooks.toggleSource === flip) hooks.toggleSource = () => false
} }
}) })
+1
View File
@@ -16,6 +16,7 @@
['gO', 'the headings as a list: j/k, Enter jumps there'], ['gO', 'the headings as a list: j/k, Enter jumps there'],
['F', 'hints over the links and buttons in view — type one to follow it'], ['F', 'hints over the links and buttons in view — type one to follow it'],
['gf / gx', 'in the editor: follow the link under the cursor / open it in a tab'], ['gf / gx', 'in the editor: follow the link under the cursor / open it in a tab'],
['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'],
+68 -23
View File
@@ -1,36 +1,64 @@
<script lang="ts"> <script lang="ts" module>
import { api } from './api' import { api, type MediaFile } from './api'
import { hooks } from './commands' import { hooks } from './commands'
import { embedSnippet } from './markdown' import { embedSnippet } from './markdown'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
/*
* What a row's buttons do, out here so the keys in App.svelte do the same:
* i inserts, y copies, x deletes.
*/
export function insertMedia(file: MediaFile) {
if (!workspace.editing) return workspace.say('insert works while writing — i on a document first', 'error')
hooks.insertText(embedSnippet(file))
workspace.overlay = 'none'
}
export async function copyMedia(file: MediaFile) {
const copied = await workspace.copy(location.origin + file.url)
workspace.say(copied ? `copied the address of ${file.filename}` : 'the clipboard said no', copied ? 'info' : 'error')
}
export async function removeMedia(file: MediaFile) {
if (file.file_node !== null) return workspace.say(`${file.filename} is a file in the tree — delete it there`, 'error')
const used = file.node_ids.length === 1 ? '1 document' : `${file.node_ids.length} documents`
const warning = file.node_ids.length > 0 ? ` it is embedded in ${used}, which will break` : ''
if (!(await workspace.ask(`delete ${file.filename}?${warning}`))) return
await workspace.guard(async () => {
await api.deleteMedia(file.id)
await workspace.loadMedia()
workspace.mediaCursor = Math.min(workspace.mediaCursor, Math.max(workspace.media.length - 1, 0))
})
}
</script>
<script lang="ts">
import { formatBytes, formatStamp } from './util' import { formatBytes, formatStamp } from './util'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.mediaCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
const usage = (nodeIds: number[], fileNode: number | null) => { const usage = (nodeIds: number[], fileNode: number | null) => {
const embeds = nodeIds.map((id) => `#${id}`).join(' ') const embeds = nodeIds.map((id) => `#${id}`).join(' ')
if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree' if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree'
return embeds || 'unattached' return embeds || 'unattached'
} }
async function remove(id: string, filename: string, nodeIds: number[]) {
const used = nodeIds.length === 1 ? '1 document' : `${nodeIds.length} documents`
const warning = nodeIds.length > 0 ? ` it is embedded in ${used}, which will break.` : ''
if (!confirm(`delete ${filename}?${warning}`)) return
await workspace.guard(async () => {
await api.deleteMedia(id)
await workspace.loadMedia()
})
}
</script> </script>
<div class="sheet"> <div class="sheet" bind:this={sheet}>
<header> <header>
<span class="label rule"><span class="marker"></span><span class="title">media</span> {workspace.media.length} files</span> <span class="label rule"><span class="marker"></span><span class="title">media</span> {workspace.media.length} files</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button> <button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header> </header>
<ul> <ul>
{#each workspace.media as file (file.id)} {#each workspace.media as file, index (file.id)}
<li> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.mediaCursor} data-index={index} onpointerenter={() => (workspace.mediaCursor = index)}>
<div class="thumb"> <div class="thumb">
{#if file.encrypted} {#if file.encrypted}
<span class="label">sealed</span> <span class="label">sealed</span>
@@ -51,16 +79,11 @@
<div class="actions"> <div class="actions">
{#if workspace.editing} {#if workspace.editing}
<button <button onclick={() => insertMedia(file)}>insert</button>
onclick={() => {
hooks.insertText(embedSnippet(file))
workspace.overlay = 'none'
}}>insert</button
>
{/if} {/if}
<button onclick={() => workspace.copy(location.origin + file.url)}>copy url</button> <button onclick={() => copyMedia(file)}>copy url</button>
{#if file.file_node === null} {#if file.file_node === null}
<button class="danger" onclick={() => remove(file.id, file.filename, file.node_ids)}>rm</button> <button class="danger" onclick={() => removeMedia(file)}>rm</button>
{/if} {/if}
</div> </div>
</li> </li>
@@ -68,6 +91,20 @@
<li class="muted">nothing uploaded yet — attach a file from the editor.</li> <li class="muted">nothing uploaded yet — attach a file from the editor.</li>
{/each} {/each}
</ul> </ul>
<footer class="label">
<span class="kbd">j</span><span class="kbd">k</span> move
<span class="gap"></span>
{#if workspace.editing}
<span class="kbd">i</span> insert
<span class="gap"></span>
{/if}
<span class="kbd">y</span> copy url
<span class="gap"></span>
<span class="kbd">x</span> delete
<span class="gap"></span>
<span class="kbd">q</span><span class="kbd">esc</span> close
</footer>
</div> </div>
<style> <style>
@@ -101,6 +138,14 @@
transition: background-color var(--t-base) var(--ease-glide); transition: background-color var(--t-base) var(--ease-glide);
} }
li:hover { background: var(--surface); } li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
.thumb { .thumb {
width: 52px; width: 52px;
+32 -1
View File
@@ -24,6 +24,9 @@ export const hooks = {
hintRoot: (): HTMLElement | null => null, hintRoot: (): HTMLElement | null => null,
/* Filled in by the file pane: what Enter does there, false when nothing. */ /* Filled in by the file pane: what Enter does there, false when nothing. */
fileAction: (): boolean => false, fileAction: (): boolean => false,
/* `:source`: a markdown file between rendered and raw; false when the pane
shows no markdown. */
toggleSource: (): boolean => false,
/* `]]` / `[[` in whatever is being read; false when it has no headings. */ /* `]]` / `[[` in whatever is being read; false when it has no headings. */
jumpHeading: (_direction: 1 | -1): boolean => false, jumpHeading: (_direction: 1 | -1): boolean => false,
/* `gO`: the headings of what is on screen, read or being written, and the /* `gO`: the headings of what is on screen, read or being written, and the
@@ -760,6 +763,33 @@ export const commands: CommandSpec[] = [
}, },
}, },
}, },
{
name: 'download',
aliases: ['dl'],
group: 'sharing',
help: 'download the file under the cursor, or the open one — the open document goes as :export writes it',
run: () =>
workspace.guard(async () => {
const node = workspace.target
if (!node) return workspace.say('nothing selected', 'error')
if (node.kind === 'document' && node.id === workspace.open?.id) return runCommand('export')
if (!node.file) return workspace.say(`a ${node.kind} is no file — :export! downloads everything`, 'error')
if (node.file.encrypted && !(await workspace.ensureUnlocked())) return
const anchor = document.createElement('a')
anchor.href = node.file.url
anchor.download = node.name
anchor.click()
workspace.say(`downloading ${node.name}`)
}),
},
{
name: 'source',
group: 'documents',
help: 'show an open markdown file as written, or rendered again',
run: () => {
if (!hooks.toggleSource()) workspace.say('only an open markdown file has a source to show', 'error')
},
},
{ {
name: 'upload', name: 'upload',
aliases: ['up'], aliases: ['up'],
@@ -777,9 +807,10 @@ export const commands: CommandSpec[] = [
{ {
name: 'media', name: 'media',
group: 'editing', group: 'editing',
help: 'browse everything you have uploaded', help: 'browse everything you have uploaded — j/k, i inserts it while writing, y copies its address, x deletes',
run: () => workspace.guard(async () => { run: () => workspace.guard(async () => {
await workspace.loadMedia() await workspace.loadMedia()
workspace.mediaCursor = 0
workspace.overlay = 'media' workspace.overlay = 'media'
}), }),
}, },
+1
View File
@@ -133,6 +133,7 @@ class Workspace {
/** The vault, as last heard from the server; null until asked. */ /** The vault, as last heard from the server; null until asked. */
vault = $state<VaultStatus | null>(null) vault = $state<VaultStatus | null>(null)
media = $state<MediaFile[]>([]) media = $state<MediaFile[]>([])
mediaCursor = $state(0)
overview = $state<SpaceOverview[]>([]) overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0) overviewCursor = $state(0)