diff --git a/README.md b/README.md index 84a5398..eed878c 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,14 @@ the space itself. The board has `:board`, `:card ` (`:card!` links the open document to it), `:due <when|->`, `:done` and `:list <name>` (`:list!` deletes one). -On a touch device modal editing is turned off and the same actions are buttons. +On a touch device modal editing is off by default (`:set vim` turns it on) and +the same actions are buttons: the `⋯` on a row — or a long press — opens what +the keys do to it: rename, move, share, seal, delete. A move is picked up +there and dropped with the button the dock then shows, in whichever folder +is open. Inside a folder the list starts with `..`, the way up. The dock also +has today's entry, an upload into the folder, a search across every space, +and on the board the card moves, renames and deletes; the agenda has a done +button per card. ## Boards @@ -157,7 +164,16 @@ something is late or due, so nothing has to be remembered to be looked at. Every change is written to `localStorage` as it is typed and to the server a couple of seconds after typing stops, so `:w` is a habit rather than a -necessity. A document made with `o` and left with neither a name nor a word in +necessity. When the server cannot be reached the draft stays in the browser +and the save is tried again, less often the longer it fails, and at once when +the browser says it is back online. A session that runs out mid-sentence +shows the login, and logging in again carries on where it stopped, draft and +all. + +A document saved somewhere else since it was opened here — another tab, the +phone, the SFTP mount — is not written over: `:w` says so, `:e!` takes the +other version, and `:w!` writes this one anyway. Search results say which +space and folder each match is in. A document made with `o` and left with neither a name nor a word in it is removed again on the way out, so abandoned starts do not pile up. If a tab dies mid-document, opening it again restores the draft and says so — `:e!` throws it away, the way vim handles a swap file. diff --git a/src/error.rs b/src/error.rs index 3148c19..de581c5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,8 @@ pub enum AppError { TooManyRequests(i64), /// The file is sealed and the vault is closed. Locked, + /// Written over something newer than the writer had seen. + Conflict(String), Internal(anyhow::Error), } @@ -25,6 +27,7 @@ impl IntoResponse for AppError { AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()), AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + AppError::Conflict(m) => (StatusCode::CONFLICT, m), AppError::Locked => ( StatusCode::LOCKED, "the vault is locked — :unlock opens it".to_string(), diff --git a/src/routes/media.rs b/src/routes/media.rs index b463e3a..f124fe1 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -367,15 +367,31 @@ pub(crate) async fn insert_file( Ok(id) } -pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> { +#[derive(Deserialize)] +pub struct ListQuery { + #[serde(default)] + pub limit: Option<i64>, + #[serde(default)] + pub offset: Option<i64>, +} + +/// A page of the media list, newest first; the client asks for pages until +/// one comes back short. +pub async fn list( + _: Session, + State(state): State<AppState>, + Query(page): Query<ListQuery>, +) -> AppResult<Json<Vec<Media>>> { let rows = sqlx::query( "SELECT m.id, m.filename, m.mime, m.size, m.created_at, m.encrypted, (SELECT f.id FROM nodes f WHERE f.media_id = m.id) AS file_node, (SELECT group_concat(nm.node_id) FROM node_media nm JOIN nodes d ON d.id = nm.node_id WHERE nm.media_id = m.id AND d.kind <> 'file') AS node_ids - FROM media m ORDER BY m.created_at DESC LIMIT 500", + FROM media m ORDER BY m.created_at DESC, m.id LIMIT ?1 OFFSET ?2", ) + .bind(page.limit.unwrap_or(500).clamp(1, 2000)) + .bind(page.offset.unwrap_or(0).max(0)) .fetch_all(&state.db) .await?; Ok(Json(rows.iter().map(row_to_media).collect())) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index ec96372..654eb3f 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -63,6 +63,10 @@ pub struct NodeSummary { pub share_token: Option<String>, /// Files only. pub file: Option<FileInfo>, + /// Search results only: where the match sits, `work/notes`, so two + /// matches with the same name can be told apart. + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option<String>, } #[derive(Serialize)] @@ -140,6 +144,12 @@ pub struct UpdateInput { /// Spaces only, and only when it is sent at all. #[serde(default)] pub has_board: Option<bool>, + /// The `updated_at` the writer last saw. When the node has been saved + /// since — from another tab, another device, the SFTP mount — the write + /// is refused rather than silently undoing that save. Left out, the + /// write goes through whatever happened in between. + #[serde(default)] + pub base_updated_at: Option<i64>, } #[derive(Deserialize)] @@ -315,6 +325,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { shared: share_token.is_some(), share_token, file, + location: None, } } @@ -366,7 +377,7 @@ pub async fn recent_documents(db: &SqlitePool, root: i64, limit: i64) -> AppResu ) SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head FROM nodes n - WHERE n.kind = 'document' AND n.id IN (SELECT id FROM sub) + WHERE n.kind IN ('document', 'file') AND n.id IN (SELECT id FROM sub) ORDER BY n.updated_at DESC, n.id DESC LIMIT ?2" )) @@ -434,6 +445,7 @@ pub async fn list( let offset = params.offset.unwrap_or(0).max(0); let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); + let searching = search.and_then(fts_query).is_some(); let rows = match search.and_then(fts_query) { // A search reaches through the whole tree, optionally confined to one // space: the recursive term collects that space and everything under it. @@ -479,14 +491,21 @@ pub async fn list( } }; - Ok(Json( - rows.iter() - .map(|row| { - let head: String = row.try_get("head").unwrap_or_default(); - row_to_summary(row, &head) - }) - .collect(), - )) + let mut found: Vec<NodeSummary> = rows + .iter() + .map(|row| { + let head: String = row.try_get("head").unwrap_or_default(); + row_to_summary(row, &head) + }) + .collect(); + if searching { + for node in &mut found { + let path = path_of(&state.db, node.id).await?; + let parents = &path[..path.len().saturating_sub(1)]; + node.location = Some(parents.iter().map(|c| c.slug.as_str()).collect::<Vec<_>>().join("/")); + } + } + Ok(Json(found)) } pub async fn get( @@ -576,12 +595,18 @@ pub async fn update( // here is still free when it is written, and a file's name and its // download name never disagree. let mut tx = crate::db::begin_write(&state.db).await?; - let row = sqlx::query("SELECT parent_id, kind, name, slug FROM nodes WHERE id = ?1") + let row = sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1") .bind(id) .fetch_optional(&mut *tx) .await? .ok_or(AppError::NotFound)?; + if input.base_updated_at.is_some_and(|base| row.get::<i64, _>("updated_at") > base) { + return Err(AppError::Conflict( + "changed somewhere else since it was opened — :e! takes that version, :w! writes over it".into(), + )); + } + let parent_id: Option<i64> = row.get("parent_id"); let kind: String = row.get("kind"); let previous_name: String = row.get("name"); diff --git a/web/src/App.svelte b/web/src/App.svelte index 6be9df7..60a7ac5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -11,6 +11,7 @@ import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' import MediaBrowser from './lib/MediaBrowser.svelte' + import Actions from './lib/Actions.svelte' import Prompt from './lib/Prompt.svelte' import SecretPrompt from './lib/SecretPrompt.svelte' import Rail from './lib/Rail.svelte' @@ -65,6 +66,10 @@ cmdline = initial } hooks.openOverview = () => void openOverview() + hooks.openGlobalSearch = () => { + cmdGlobal = true + cmdline = '/' + } }) /* @@ -167,6 +172,18 @@ await workspace.guard(() => workspace.importFolder(files, root)) } + /* + * Files let go anywhere but the list or the editor would make the browser + * open them in place of the app. The places that take files say so + * themselves; everywhere else refuses the drop, and says where it goes. + */ + function strayDrop(event: DragEvent) { + if (event.defaultPrevented || !event.dataTransfer?.types.includes('Files')) return + event.preventDefault() + event.dataTransfer.dropEffect = 'none' + if (event.type === 'drop') workspace.say('drop files on the list to put them in this folder, or into the editor to embed them', 'error') + } + // What the empty pane offers, in the key/value shape of a status readout. // Keys are keycaps, so `o` and `O` cannot be read as the same key. const offers: [string[], string][] = [ @@ -636,7 +653,7 @@ } </script> -<svelte:window {onkeydown} /> +<svelte:window {onkeydown} ondragover={strayDrop} ondrop={strayDrop} /> {#if shareToken} <Shared token={shareToken} shareKey={shareKey} /> @@ -718,7 +735,7 @@ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex --> <div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else}<MediaBrowser />{/if} + {#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else}<MediaBrowser />{/if} </div> {/if} </div> diff --git a/web/src/lib/Actions.svelte b/web/src/lib/Actions.svelte new file mode 100644 index 0000000..d37e6b9 --- /dev/null +++ b/web/src/lib/Actions.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + /* + * What the keyboard does to the row under the cursor, as buttons: the way a + * phone renames, moves, shares, seals or deletes a node. Every button runs + * the same command a key would, on the same target, so the two can never + * drift apart. + */ + import { isContainer } from './api' + import { hooks, runCommand } from './commands' + import { workspace } from './store.svelte' + + const node = $derived(workspace.selected) + + type Action = { label: string; run: () => unknown; danger?: boolean } + + const actions = $derived.by((): Action[] => { + if (!node) return [] + const sealed = node.file?.encrypted ?? false + const holdsFiles = isContainer(node) || node.kind === 'file' + const list: Action[] = [ + { label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) }, + { label: 'rename', run: () => hooks.openCommandLine(`:name ${node.name}`) }, + workspace.marked?.id === node.id + ? { label: 'put it back — not moving', run: () => workspace.mark() } + : { label: 'move — then open a folder and drop it there', run: () => workspace.mark() }, + ] + if (node.kind === 'document') { + list.push( + node.shared + ? { label: 'stop sharing', run: () => runCommand('unshare') } + : { label: 'share — copies the link', run: () => runCommand('share') }, + ) + if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') }) + } + if (holdsFiles && node.kind !== 'space') { + list.push( + sealed + ? { label: 'take out of the vault', run: () => runCommand('decrypt') } + : { label: node.kind === 'file' ? 'seal in the vault' : 'seal every file in it', run: () => runCommand('encrypt') }, + ) + } + list.push({ label: 'delete', run: () => runCommand('delete'), danger: true }) + return list + }) + + function act(action: Action) { + workspace.overlay = 'none' + // The list is what the commands act on while it has the focus. + workspace.pane = 'list' + void action.run() + } +</script> + +<div class="sheet"> + <header> + <span class="label rule"><span class="marker"></span><span class="title">{node ? node.name.trim() || node.slug : 'nothing'}</span></span> + <button onclick={() => (workspace.overlay = 'none')}>esc</button> + </header> + <ul> + {#each actions as action (action.label)} + <li><button class:danger={action.danger} onclick={() => act(action)}>{action.label}</button></li> + {/each} + </ul> +</div> + +<style> + .sheet { + display: flex; + flex-direction: column; + gap: 14px; + width: min(100%, 48ch); + max-height: 100%; + background: color-mix(in oklab, var(--hull) 97%, transparent); + border: 1px solid var(--surface-alt); + border-radius: var(--frame-radius); + padding: 18px 20px; + animation: resolve var(--t-slow) var(--ease-snap) both; + } + header { display: flex; align-items: center; gap: 12px; } + header .label { flex: 1; min-width: 0; } + .title { color: var(--light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; } + li button { + width: 100%; + padding: 12px 14px; + text-align: left; + text-transform: none; + letter-spacing: 0; + font-family: var(--font-sans); + font-size: 15px; + } + .danger { color: var(--alarm); } +</style> diff --git a/web/src/lib/Agenda.svelte b/web/src/lib/Agenda.svelte index a71aef2..57efc46 100644 --- a/web/src/lib/Agenda.svelte +++ b/web/src/lib/Agenda.svelte @@ -7,7 +7,9 @@ */ import type { AgendaCard } from './api' import { workspace } from './store.svelte' - import { formatDay, formatWeekday } from './util' + import { runCommand } from './commands' + import Icon from './Icon.svelte' + import { formatDay, formatWeekday, isTouchDevice } from './util' let sheet = $state<HTMLDivElement>() @@ -36,7 +38,11 @@ {#if workspace.agenda.diary_today === false} <p class="diary"> <span class="dot"></span> nothing in the diary today - <span class="kbd">T</span> writes it + {#if isTouchDevice} + <button class="write" onclick={() => runCommand('today')}>write it</button> + {:else} + <span class="kbd">T</span> writes it + {/if} </p> {/if} @@ -60,6 +66,12 @@ <span class="due">{formatWeekday(card.due_at)} {formatDay(card.due_at).slice(5)}</span> {/if} </button> + {#if isTouchDevice} + <!-- Space, for a thumb. --> + <button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.finishCard(card))}> + <Icon name="check" size={18} /> + </button> + {/if} </li> {/each} </ul> @@ -82,6 +94,9 @@ </div> <style> + li:has(.finish) { display: flex; align-items: center; } + .finish { flex: none; display: grid; place-items: center; width: 44px; height: 40px; padding: 0; border: 0; background: none; color: var(--muted); } + .sheet { display: flex; flex-direction: column; diff --git a/web/src/lib/Icon.svelte b/web/src/lib/Icon.svelte index 01efa77..b40be82 100644 --- a/web/src/lib/Icon.svelte +++ b/web/src/lib/Icon.svelte @@ -32,6 +32,15 @@ doc: ['M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z', 'M14 3v5h5', 'M9 13h6', 'M9 17h4'], agenda: ['M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z', 'M12 7v5l3 2'], capture: ['M12 5v14', 'M5 12h14'], + upload: ['M12 15V4', 'M7 9l5-5 5 5', 'M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3'], + 'search-all': ['M10 4a6 6 0 1 0 0 12a6 6 0 1 0 0-12z', 'M19 19l-4.5-4.5', 'M17 4h4', 'M19 2v4'], + today: ['M5 6h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z', 'M4 10h16', 'M12 13v4', 'M10 15h4'], + up: ['M12 19V5', 'M6 11l6-6 6 6'], + down: ['M12 5v14', 'M6 13l6 6 6-6'], + drop: ['M12 4v11', 'M7 10l5 5 5-5', 'M5 20h14'], + rename: ['M4 20h4L19 9l-4-4L4 16z', 'M13 7l4 4'], + trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'], + more: ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'], } satisfies Record<string, string[]> export type IconName = keyof typeof PATHS diff --git a/web/src/lib/Login.svelte b/web/src/lib/Login.svelte index d3631ca..7041b43 100644 --- a/web/src/lib/Login.svelte +++ b/web/src/lib/Login.svelte @@ -42,6 +42,7 @@ </div> <p class="label rule">a private workspace · one user</p> + {#if workspace.notice}<p class="notice">{workspace.notice}</p>{/if} <label> <span class="label">login</span> @@ -64,6 +65,12 @@ </main> <style> + .notice { + margin: 0; + font-size: 13px; + color: var(--light-soft); + } + main { display: grid; place-items: center; diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 027ef8b..f5f4958 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -25,7 +25,7 @@ const groups = $derived.by((): Door[][] => { const cmd: Door = { icon: 'prompt', label: 'Command line', keys: [':'], run: () => hooks.openCommandLine(':') } - const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help'), desk: true } + const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help') } if (workspace.view === 'board') { // The card verbs are doors too, so a phone — which has no H, L or @@ -34,7 +34,7 @@ return [ [ { icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') }, - { icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list '), desk: true }, + { icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') }, { icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true }, ], [ @@ -44,6 +44,22 @@ { icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card }, { icon: 'doc', label: "The card's notes", keys: ['Enter'], run: () => workspace.guard(() => workspace.openCard()), dim: !card }, ], + [ + { icon: 'up', label: 'Card up its list', keys: ['K'], run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card }, + { icon: 'down', label: 'Card down its list', keys: ['J'], run: () => workspace.guard(() => workspace.nudgeCard(1)), dim: !card }, + { + icon: 'rename', + label: card ? 'Rename the card' : 'Rename the list', + keys: ['R'], + run: () => hooks.openCommandLine(card ? ':name ' : ':name! '), + }, + { + icon: 'trash', + label: card ? 'Delete the card' : 'Delete the list', + keys: card ? ['x'] : [':list!'], + run: () => runCommand(card ? 'delete' : 'list!'), + }, + ], [cmd, help], ] } @@ -59,8 +75,15 @@ ], [ { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => runCommand('agenda') }, + { icon: 'today', label: "Today's diary entry", keys: ['T'], run: () => runCommand('today') }, { icon: 'capture', label: 'Capture a card to the inbox', keys: ['c'], run: () => hooks.openCommandLine(':card ') }, ], + [ + { icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) }, + ...(workspace.marked + ? [{ icon: 'drop' as const, label: `Drop ${workspace.marked.name.trim() || workspace.marked.slug} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }] + : []), + ], [ { icon: 'grid', label: 'Every space', keys: ['w'], run: () => hooks.openOverview() }, { @@ -72,6 +95,7 @@ desk: true, }, { icon: 'search', label: 'Search this space', keys: ['/'], run: () => hooks.openCommandLine('/') }, + { icon: 'search-all', label: 'Search every space', keys: ['Ctrl-P'], run: () => hooks.openGlobalSearch() }, ], [cmd, help], ] @@ -230,6 +254,9 @@ flex-direction: row; justify-content: space-around; } + /* More doors than a narrow phone is wide: the row scrolls rather than squeezes. */ + .dock { overflow-x: auto; scrollbar-width: none; } + .dock .door { flex: none; } .dock .door { width: 44px; height: 40px; border-radius: var(--radius-s); } .dock .door::before { display: none; } .dock .door:active { background: var(--surface-alt); } diff --git a/web/src/lib/Shared.svelte b/web/src/lib/Shared.svelte index c6e51c1..9b90562 100644 --- a/web/src/lib/Shared.svelte +++ b/web/src/lib/Shared.svelte @@ -5,7 +5,7 @@ * in it as two windows, the band along the bottom. No rail — a reader has * no tools — but the reading keys work: j/k, gg/G, Ctrl-d/u. */ - import { api, type SharedDocument } from './api' + import { api, unreachable, type SharedDocument } from './api' import Clock from './Clock.svelte' import { renderMarkdown } from './markdown' import Outline from './Outline.svelte' @@ -18,7 +18,18 @@ let scroller = $state<HTMLElement>() let article = $state<HTMLElement>() let position = $state('All') - const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '') + /* + * A link to another document is an address inside the private workspace, + * and leads a reader to its login page. Those links are kept as their text. + */ + const html = $derived( + doc + ? renderMarkdown(doc.body, { token, key: shareKey }).replace( + /<a href="\/n\/[^"]*"[^>]*>(.*?)<\/a>/g, + '<span class="private-link" title="a page of the private workspace">$1</span>', + ) + : '', + ) const title = $derived(doc?.name.trim() ?? '') const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0) // The outline gets a window of its own only when there is something to outline. @@ -35,7 +46,11 @@ doc = data document.title = data.name.trim() || `~/workspace ${formatStamp(data.created_at)}` }) - .catch(() => (error = 'this link is not valid, or the document is no longer shared.')) + .catch((e) => + (error = unreachable(e) + ? 'the server cannot be reached — try again in a moment.' + : 'this link is not valid, or the document is no longer shared.'), + ) }) /** Where the view is, the way vim's ruler says it: Top, Bot, All or a percentage. */ diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 2594c41..196696d 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -2,7 +2,7 @@ import { isContainer, type NodeSummary } from './api' import Icon from './Icon.svelte' import { workspace } from './store.svelte' - import { formatBytes, formatDay, formatTime, formatWeekday } from './util' + import { formatBytes, formatDay, formatTime, formatWeekday, isTouchDevice } from './util' let list = $state<HTMLUListElement>() /** Files are being dragged over the list, which takes them into this folder. */ @@ -41,6 +41,15 @@ workspace.cursor = index void workspace.guard(() => workspace.openSelected()) } + + /* A tap opens a row, so on a phone the other verbs need a door of their own. */ + function actions(index: number) { + workspace.cursor = index + workspace.overlay = 'actions' + } + + /* Inside a folder, a phone has no h key and no crumbs on the bar: this is the way up. */ + const nested = $derived(isTouchDevice && !workspace.query && workspace.path.length > 1) </script> <!-- svelte-ignore a11y_no_static_element_interactions --> @@ -72,6 +81,15 @@ {/if} <ul bind:this={list}> + {#if nested} + <li> + <button class="row up" onclick={() => workspace.guard(() => workspace.up())}> + <span class="dot"></span> + <span class="title">..<span class="slash">/</span></span> + <span class="aside">up</span> + </button> + </li> + {/if} {#each workspace.nodes as node, index (node.id)} {@const previous = workspace.nodes[index - 1]} <!-- Day headings only make sense in a list that is a journal: documents, @@ -81,21 +99,33 @@ <span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span> </li> {/if} - <li> + <li class="item"> <button class="row" + oncontextmenu={(event) => { + event.preventDefault() + actions(index) + }} class:active={index === workspace.cursor} class:open={workspace.open?.id === node.id} data-index={index} onclick={() => pick(index)} > <span class="dot"></span> - <span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span> + <span class="title"> + {label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if} + {#if workspace.query && node.location}<span class="where">{node.location}</span>{/if} + </span> {#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if} {#if node.shared}<span class="shared" title="shared"></span>{/if} {#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if} <span class="aside">{aside(node)}</span> </button> + {#if isTouchDevice} + <button class="more" aria-label="actions for {label(node)}" onclick={() => actions(index)}> + <Icon name="more" size={18} /> + </button> + {/if} </li> {:else} <li class="empty muted"> @@ -208,6 +238,9 @@ font-size: 14px; } .slash { color: var(--muted); margin-left: 1px; } + /* Where a search match sits, so two of the same name can be told apart. */ + .where { margin-left: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); } + .row.active .where { color: var(--ink); } .aside { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } .badge { flex: none; font-size: 11px; font-weight: 700; color: var(--light-dim); } .shared { @@ -220,6 +253,9 @@ .sealed { flex: none; display: grid; color: var(--muted); } .row.active .sealed { color: var(--ink); } .empty { padding: 12px 10px; font-size: 12px; } + .item { display: flex; align-items: center; } + .more { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; background: none; color: var(--muted); } + .up .title { font-family: var(--font-mono); } /* Roomier tap targets where there is no keyboard. */ @media (pointer: coarse) { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a9a338a..2d6c0cc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -32,6 +32,8 @@ export interface NodeSummary { share_token: string | null /** Files only. */ file: FileInfo | null + /** Search results only: where the match sits, `work/notes`. */ + location?: string } /** One step of a node's path, the space first and the node itself last. */ @@ -170,15 +172,25 @@ export class ApiError extends Error { } } +/** No answer at all: offline, or the server is down. Status 0, as XHR has it. */ +export const unreachable = (error: unknown) => + error instanceof ApiError && (error.status === 0 || error.status === 502 || error.status === 503 || error.status === 504) + async function request<T>(path: string, init: RequestInit = {}): Promise<T> { - const response = await fetch(`/api${path}`, { - credentials: 'same-origin', - ...init, - headers: - init.body instanceof FormData - ? init.headers - : { 'content-type': 'application/json', ...init.headers }, - }) + let response: Response + try { + response = await fetch(`/api${path}`, { + credentials: 'same-origin', + ...init, + headers: + init.body instanceof FormData + ? init.headers + : { 'content-type': 'application/json', ...init.headers }, + }) + } catch { + // fetch says "Failed to fetch" and nothing else; this says what it means. + throw new ApiError(0, 'the server cannot be reached') + } if (!response.ok) { let message = response.statusText @@ -195,6 +207,42 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> { const body = (data: unknown) => JSON.stringify(data) +/** The size of a page of a long list, the most the server hands out at once. */ +const PAGE = 2000 + +/** Every page of a list, read until one comes back short. */ +async function pages<T>(read: (limit: number, offset: number) => Promise<T[]>): Promise<T[]> { + const all: T[] = [] + for (;;) { + const page = await read(PAGE, all.length) + all.push(...page) + if (page.length < PAGE) return all + } +} + +/** + * Files up as multipart, by XHR rather than fetch, because a gigabyte going + * up needs to say how far along it is. + */ +function send<T>(url: string, files: File[], progress?: (fraction: number) => void) { + return new Promise<T>((resolve, reject) => { + const form = new FormData() + for (const file of files) form.append('file', file) + const xhr = new XMLHttpRequest() + xhr.open('POST', url) + xhr.responseType = 'json' + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) progress?.(event.loaded / event.total) + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as T) + else reject(new ApiError(xhr.status, xhr.response?.error ?? xhr.statusText)) + } + xhr.onerror = () => reject(new ApiError(0, 'the upload did not reach the server')) + xhr.send(form) + }) +} + export const api = { me: () => request<{ user: string }>('/me'), @@ -212,13 +260,23 @@ export const api = { createSpace: (name: string, has_board = true) => request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }), - /** The children of a node, or — with `q` — a search across the whole tree. */ - listNodes: (params: { parent?: number; q?: string; space?: number }) => { + /** + * The children of a node, or — with `q` — a search across the whole tree. + * A folder is read a page at a time until a page comes back short, so one + * that grows past a page is listed whole rather than cut off; a search + * stops at its first page, the best matches. + */ + listNodes: async (params: { parent?: number; q?: string; space?: number }) => { const query = new URLSearchParams() if (params.q) query.set('q', params.q) else if (params.parent !== undefined) query.set('parent', String(params.parent)) if (params.q && params.space !== undefined) query.set('space', String(params.space)) - return request<NodeSummary[]>(`/nodes?${query}`) + if (params.q) return request<NodeSummary[]>(`/nodes?${query}`) + return pages((limit, offset) => { + query.set('limit', String(limit)) + query.set('offset', String(offset)) + return request<NodeSummary[]>(`/nodes?${query}`) + }) }, getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`), @@ -252,9 +310,10 @@ export const api = { created_at?: number }) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }), + /** `base_updated_at`: refused with 409 if the node was saved since then. */ updateNode: ( id: number, - input: { name: string; body: string; created_at?: number; has_board?: boolean }, + input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number }, ) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }), deleteNode: (id: number) => @@ -310,35 +369,18 @@ export const api = { moveCard: (id: number, list_id: number, position?: number) => request<Card>(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }), - listMedia: () => request<MediaFile[]>('/media'), + listMedia: () => pages((limit, offset) => request<MediaFile[]>(`/media?limit=${limit}&offset=${offset}`)), /** * Upload files into a space or folder as file nodes. XHR rather than fetch, * because a gigabyte going up needs to say how far along it is. */ putFiles: (parentId: number, files: File[], encrypt: boolean, progress?: (fraction: number) => void) => - new Promise<WorkspaceNode[]>((resolve, reject) => { - const form = new FormData() - for (const file of files) form.append('file', file) - const xhr = new XMLHttpRequest() - xhr.open('POST', `/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`) - xhr.responseType = 'json' - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) progress?.(event.loaded / event.total) - } - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as WorkspaceNode[]) - else reject(new ApiError(xhr.status, xhr.response?.error ?? xhr.statusText)) - } - xhr.onerror = () => reject(new ApiError(0, 'the upload did not reach the server')) - xhr.send(form) - }), + send<WorkspaceNode[]>(`/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`, files, progress), - upload: (files: File[]) => { - const form = new FormData() - for (const file of files) form.append('file', file) - return request<MediaFile[]>('/media', { method: 'POST', body: form }) - }, + /** Files to embed in a document, with the same progress as `putFiles`. */ + upload: (files: File[], progress?: (fraction: number) => void) => + send<MediaFile[]>('/api/media', files, progress), deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index d4374c9..cdc680d 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -22,6 +22,8 @@ export const hooks = { overviewColumns: (): number => 1, openOverview: () => {}, openCommandLine: (_initial: string) => {}, + /** Ctrl-P: a search across every space. */ + openGlobalSearch: () => {}, } /** Help groups the command list under these headings, in this order. */ @@ -121,7 +123,10 @@ export const commands: CommandSpec[] = [ group: 'editing', help: 'save the open document', run: () => workspace.guard(() => workspace.save()), - bang: { help: 'same as :w — the bang is accepted out of habit', run: () => workspace.guard(() => workspace.save()) }, + bang: { + help: 'save even over a newer version saved somewhere else since this one was opened', + run: () => workspace.guard(() => workspace.save({ force: true })), + }, }, { name: 'wq', @@ -134,9 +139,9 @@ export const commands: CommandSpec[] = [ hooks.focusList() }, bang: { - help: 'same as :wq — the bang is accepted out of habit', + help: 'save over a newer version, as :w! does, and leave the editor', run: async () => { - await workspace.guard(() => workspace.save()) + await workspace.guard(() => workspace.save({ force: true })) workspace.editing = false hooks.focusList() }, @@ -335,8 +340,14 @@ export const commands: CommandSpec[] = [ const target = arg.trim() if (!target) return workspace.say('usage: :mv <folder|..>', 'error') - const destination = - target === '..' ? workspace.path.at(-2)?.id : findFolder(target)?.id + // `..` is up from where the node is, which in a search result is not + // the folder being browsed. + const up = async () => { + if (node.parent_id === null) return undefined + 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 if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error') if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error') await workspace.guard(() => workspace.moveNode(node.id, destination)) diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index dc51bb7..0b6d0ca 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -2,6 +2,7 @@ import { api, ApiError, isContainer, + unreachable, type Agenda, type AgendaCard, type Board, @@ -13,7 +14,7 @@ import { type SpaceOverview, type VaultStatus, } from './api' -import { formatBytes, sealedNote } from './util' +import { formatBytes, isTouchDevice, sealedNote } from './util' export type Pane = 'list' | 'document' /** The tree of documents, or the board of the space being browsed. */ @@ -97,7 +98,7 @@ class Workspace { vimMode = $state('normal') flash = $state<Flash>(null) - overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions'>('none') /* * What is due or in hand on every board, kept loaded all the time: the bar @@ -140,7 +141,14 @@ class Workspace { */ #spots: Record<number, Spot> = JSON.parse(localStorage.getItem('diary:spots') ?? '{}') - vimEnabled = $state(localStorage.getItem('diary:vim') !== 'off') + /** Modal editing on a keyboard; a phone's editor is a plain text box. */ + vimEnabled = $state((localStorage.getItem('diary:vim') ?? (isTouchDevice ? 'off' : 'on')) !== 'off') + + /** What the login screen has to say: why it is showing, if not for a first visit. */ + notice = $state('') + + /** Saves that failed for want of a server wait this long before trying again. */ + #retryDelay = 0 /** Mirror vim yanks and deletes into the system clipboard, like `set clipboard=unnamed`. */ clipboard = $state(localStorage.getItem('diary:clipboard') !== 'off') @@ -332,7 +340,30 @@ class Workspace { async #writeBack() { this.#autosave = null if (!this.open || !this.dirty) return - await this.guard(() => this.save({ quiet: true })) + try { + await this.save({ quiet: true }) + if (this.#retryDelay > 0) this.say('saved — the server is back') + this.#retryDelay = 0 + } catch (error) { + if (!unreachable(error)) return this.guard(() => Promise.reject(error)) + // Nothing is lost — the draft is in localStorage — but it is not on the + // server either, so it is tried again, less often the longer it fails. + this.#retryDelay = Math.min(Math.max(this.#retryDelay * 2, 2000), 60_000) + this.say(`not saved: the server cannot be reached — kept here, trying again in ${this.#retryDelay / 1000}s`, 'error') + this.#autosave = setTimeout(() => void this.#writeBack(), this.#retryDelay) + } + } + + /** Back online: what is waiting goes now rather than at the next retry. */ + #watchConnection() { + window.addEventListener('online', () => { + if (!this.dirty) return + if (this.#autosave) clearTimeout(this.#autosave) + void this.#writeBack() + }) + window.addEventListener('offline', () => { + this.say('offline — what is typed is kept here, and saved once the server answers', 'error') + }) } /** @@ -376,16 +407,28 @@ class Workspace { return true } + #watching = false + async boot() { + if (!this.#watching) { + this.#watching = true + this.#watchConnection() + } try { const { user } = await api.me() this.user = user + this.notice = '' await this.loadSpaces(Workspace.addressedPath()) await this.loadAgenda() await this.loadVault() await this.openAgendaIfDue() - } catch { + } catch (error) { this.user = null + // Down is not the same as logged out: say so, and keep asking. + if (unreachable(error)) { + this.notice = 'the server cannot be reached — trying again' + setTimeout(() => void this.boot(), 5000) + } } finally { this.booting = false } @@ -394,6 +437,14 @@ class Workspace { async login(username: string, password: string) { const { user } = await api.login(username, password) this.user = user + this.notice = '' + // The session ran out under an open workspace: everything is still here, + // the draft included, so pick up where it stopped rather than start over. + if (this.spaces.length > 0) { + await this.refresh() + if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true })) + return + } await this.loadSpaces(Workspace.addressedPath()) await this.loadAgenda() await this.loadVault() @@ -637,12 +688,20 @@ class Workspace { this.say(`new space ${space.slug}`) } - async save({ quiet = false } = {}) { + /** + * Write the draft. Refused when the document was saved somewhere else + * since it was opened here — another tab, the phone, the SFTP mount — + * unless `force`, which is `:w!`. + */ + async save({ quiet = false, force = false } = {}) { if (!this.open) return // Snapshot what goes over the wire: an autosave races the typing that // triggered it, and anything typed since must stay marked unsaved. const sent = { ...this.draft, name: this.draft.name.trim() } - const saved = await api.updateNode(this.open.id, sent) + const saved = await api.updateNode(this.open.id, { + ...sent, + base_updated_at: force ? undefined : this.open.updated_at, + }) this.open = saved this.dirty = @@ -1098,6 +1157,7 @@ class Workspace { async toggleShare(id: number) { const node = this.open?.id === id ? this.open : await api.getNode(id) + if (node.kind !== 'document') return this.say('only a document can be shared', 'error') if (node.shared) { await api.unshare(id) this.say(`#${id} is private again`) @@ -1115,7 +1175,7 @@ class Workspace { * There is nothing to re-copy later — asking again mints a new link and * retires the old one. */ - async mintShareLink(id: number) { + async mintShareLink(id: number): Promise<{ url: string; sealed: number }> { const { path, sealed } = await api.share(id) const url = `${location.origin}${path}` await this.copy(url) @@ -1178,7 +1238,12 @@ class Workspace { } async upload(files: File[]): Promise<MediaFile[]> { - const uploaded = await api.upload(files) + const what = files.length === 1 ? files[0].name : `${files.length} files` + const total = files.reduce((sum, file) => sum + file.size, 0) + this.say(`uploading ${what} (${formatBytes(total)}) …`) + const uploaded = await api.upload(files, (fraction) => + this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`), + ) this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`) return uploaded } @@ -1204,6 +1269,7 @@ class Workspace { } catch (error) { if (error instanceof ApiError && error.status === 401) { this.user = null + this.notice = 'the session ran out — log in again, and carry on where you were' this.say('session expired — log in again', 'error') } else if (error instanceof ApiError && error.status === 423) { // It locked itself while nobody was looking.