mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
ui: touch parity, saves that survive outages, edits that do not collide
PUT /api/nodes/{id} takes base_updated_at and answers 409 when the node
was saved since; the editor sends it, so a save from another tab, the
phone or the SFTP mount is no longer overwritten in silence. :w! and
:wq! now force the write.
An autosave the server could not take is kept and retried with backoff,
and at once on the online event; network failures say "the server
cannot be reached" rather than "Failed to fetch". At boot that is no
longer shown as a login screen. A session that expires mid-edit shows
the login with a note, and logging back in resumes the open draft.
Folder listings and the media list are read page by page, so more than
500 children are no longer cut off. Search results carry the space and
folder they sit in, and the overview's recent list includes files.
Embedding uploads report progress, like uploads into a folder.
On touch devices vim is off by default. Each row has an actions sheet
(rename, move, share, seal, delete), folders start with a .. row, and
the dock gains today's entry, upload, search all spaces, drop for a
marked node, help, and the board's list, reorder, rename and delete.
The agenda gets a done button per card and a button for today's entry.
Sharing a folder or a file is refused in the client. :mv .. in a search
moves up from the match's own folder. Files dropped outside the list or
editor no longer navigate the tab away. The public share view renders
/n/ links as plain text, and tells a network error from a dead link.
This commit is contained in:
1 parent
ee13ae44fa
commit
2733ac1a3a
15 files changed
+472
-74
No files matched your search
@@ -99,7 +99,14 @@ the space itself. The board has
|
||||
`:board`, `:card <title>` (`: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.
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+18
-2
@@ -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()))
|
||||
|
||||
+35
-10
@@ -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");
|
||||
|
||||
+19
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+29
-2
@@ -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); }
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+76
-34
@@ -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' }),
|
||||
|
||||
|
||||
+16
-5
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user