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 |
| `]]` / `[[` | 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`) |
| `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 |
| `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 |
@@ -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
`: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
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;
- 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;
- 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;
- certificates, keys and keystores — `.pem`, `.crt`, `.cer`, `.der`, `.csr`,
`.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
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`
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
+55 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { hooks, runCommand } from './lib/commands'
import { followLink, hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown'
import Agenda from './lib/Agenda.svelte'
import Bar from './lib/Bar.svelte'
@@ -13,7 +13,7 @@
import Verify from './lib/Verify.svelte'
import Login from './lib/Login.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 Prompt from './lib/Prompt.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. */
function focusPane(pane: 'list' | 'document') {
if (pane === 'document' && !workspace.open) return
@@ -448,6 +490,12 @@
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 (outlineKey(event)) {
event.preventDefault()
@@ -586,6 +634,11 @@
void runCommand('outline')
return
}
if (previous === 'g' && key === 'x') {
event.preventDefault()
openInTab()
return
}
if (previous === 'd' && key === 'd') {
event.preventDefault()
void runCommand('d')
+7
View File
@@ -86,13 +86,20 @@
return cert?.act() ?? false
}
const hints = () => reader ?? null
const flip = () => {
if (kind !== 'markdown' || text === null) return false
source = !source
return true
}
hooks.scrollReader = scroll
hooks.fileAction = act
hooks.hintRoot = hints
hooks.toggleSource = flip
return () => {
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
if (hooks.fileAction === act) hooks.fileAction = () => false
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'],
['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'],
['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'],
+68 -23
View File
@@ -1,36 +1,64 @@
<script lang="ts">
import { api } from './api'
<script lang="ts" module>
import { api, type MediaFile } from './api'
import { hooks } from './commands'
import { embedSnippet } from './markdown'
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'
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 embeds = nodeIds.map((id) => `#${id}`).join(' ')
if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree'
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>
<div class="sheet">
<div class="sheet" bind:this={sheet}>
<header>
<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>
</header>
<ul>
{#each workspace.media as file (file.id)}
<li>
{#each workspace.media as file, index (file.id)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.mediaCursor} data-index={index} onpointerenter={() => (workspace.mediaCursor = index)}>
<div class="thumb">
{#if file.encrypted}
<span class="label">sealed</span>
@@ -51,16 +79,11 @@
<div class="actions">
{#if workspace.editing}
<button
onclick={() => {
hooks.insertText(embedSnippet(file))
workspace.overlay = 'none'
}}>insert</button
>
<button onclick={() => insertMedia(file)}>insert</button>
{/if}
<button onclick={() => workspace.copy(location.origin + file.url)}>copy url</button>
<button onclick={() => copyMedia(file)}>copy url</button>
{#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}
</div>
</li>
@@ -68,6 +91,20 @@
<li class="muted">nothing uploaded yet — attach a file from the editor.</li>
{/each}
</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>
<style>
@@ -101,6 +138,14 @@
transition: background-color var(--t-base) var(--ease-glide);
}
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 {
width: 52px;
+32 -1
View File
@@ -24,6 +24,9 @@ export const hooks = {
hintRoot: (): HTMLElement | null => null,
/* Filled in by the file pane: what Enter does there, false when nothing. */
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. */
jumpHeading: (_direction: 1 | -1): boolean => false,
/* `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',
aliases: ['up'],
@@ -777,9 +807,10 @@ export const commands: CommandSpec[] = [
{
name: 'media',
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 () => {
await workspace.loadMedia()
workspace.mediaCursor = 0
workspace.overlay = 'media'
}),
},
+1
View File
@@ -133,6 +133,7 @@ class Workspace {
/** The vault, as last heard from the server; null until asked. */
vault = $state<VaultStatus | null>(null)
media = $state<MediaFile[]>([])
mediaCursor = $state(0)
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)