mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
store: one workspace, made of parts
The Workspace class held every piece of client state in one 2500-line file. It is now a root that composes nine parts, each in web/src/lib/state/: - tree: spaces, folders, the list, search, marks, and what is done to nodes - tiles: the tiles on screen, the focus trail, and the views among them - drafts: document drafts and autosave, and text written in place - cards: the board, the agenda, the card back, and the card verbs - diary: today's entry, the calendar, stepping between days - scratch: the scratchpad drawer and its ways out - sheets: what floats over the tiles, and what each sheet lists - vault: the vault status and unlocking - prompts: the [y/N] question, the password box, Proton's check The root keeps what belongs to no one part: the session, the status line, guard, what has the focus, and the focused tile's open/draft/ editing. The keyboard layer still sees one `workspace`; call sites read `workspace.cards.card`, `workspace.tree.nodes` and so on. Behaviour is unchanged.
This commit is contained in:
1 parent
7f8d625a8d
commit
b1107013ee
40 files changed
+3077
-2924
No files matched your search
+165
-165
@@ -90,8 +90,8 @@ function verb(arg: string, verbs: readonly string[]): [string, string] {
|
||||
|
||||
/** Close the focused tile; the index of the one that has the focus after. */
|
||||
const closeFocused = () => {
|
||||
workspace.closeTile()
|
||||
return workspace.focused
|
||||
workspace.tiles.close()
|
||||
return workspace.tiles.focused
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,18 +103,18 @@ const closeFocused = () => {
|
||||
export async function openInfo(section?: InfoSection) {
|
||||
if (!requireOpen()) return
|
||||
const { headings, current } = hooks.headings()
|
||||
workspace.outline = headings
|
||||
workspace.outlineCursor = current
|
||||
workspace.sheets.outline = headings
|
||||
workspace.sheets.outlineCursor = current
|
||||
await workspace.guard(async () => {
|
||||
// One after the other: each writes what is typed first, and two saves
|
||||
// of the same text at once would race.
|
||||
await workspace.loadHistory()
|
||||
await workspace.loadBacklinks()
|
||||
await workspace.sheets.loadHistory()
|
||||
await workspace.sheets.loadBacklinks()
|
||||
// Picked afresh each time, from the first: the open card leads.
|
||||
workspace.cardsCursor = 0
|
||||
workspace.filesCursor = 0
|
||||
if (section) workspace.infoSection = section
|
||||
workspace.openSheet('info')
|
||||
workspace.sheets.cardsCursor = 0
|
||||
workspace.sheets.filesCursor = 0
|
||||
if (section) workspace.sheets.infoSection = section
|
||||
workspace.sheets.open('info')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ const requireOpen = () => {
|
||||
|
||||
/** A card about `node`, titled after it unless named. */
|
||||
const cardAbout = (node: NodeSummary, title: string) =>
|
||||
workspace.guard(() => workspace.createCard(title.trim() || node.name.trim() || node.slug, node.id))
|
||||
workspace.guard(() => workspace.cards.createCard(title.trim() || node.name.trim() || node.slug, node.id))
|
||||
|
||||
const TITLE_HREF = titleHref('')
|
||||
|
||||
@@ -153,11 +153,11 @@ export function followLink(href: string, tab = false) {
|
||||
if (tab) {
|
||||
if (!path) return workspace.say(`no document called "${title}" yet`, 'error')
|
||||
href = `/n/${path}`
|
||||
} else return void workspace.guard(() => workspace.followTitle(title))
|
||||
} else return void workspace.guard(() => workspace.tree.followTitle(title))
|
||||
}
|
||||
if (href.startsWith('/n/') && !tab) {
|
||||
const path = href.slice(3).split('#')[0]
|
||||
return void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
|
||||
return void workspace.guard(() => workspace.tree.openPath(decodeURIComponent(path)))
|
||||
}
|
||||
window.open(new URL(href, location.href).href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -167,8 +167,8 @@ export function followLink(href: string, tab = false) {
|
||||
* otherwise the shell, whose keys then act on that tile.
|
||||
*/
|
||||
export async function focusTile(index: number) {
|
||||
if (!workspace.tiles[index]) return
|
||||
workspace.focusTile(index)
|
||||
if (!workspace.tiles.all[index]) return
|
||||
workspace.tiles.focusOn(index)
|
||||
// The tile's editor fills the hooks once it has seen it has the focus.
|
||||
await tick()
|
||||
if (workspace.editing && workspace.pane === 'document') hooks.focusEditor()
|
||||
@@ -177,38 +177,38 @@ export async function focusTile(index: number) {
|
||||
|
||||
/** `gt` / `gT`, `:bn` / `:bp`: the next or previous tile, round and round. */
|
||||
export function cycleTile(delta: 1 | -1) {
|
||||
const count = workspace.tiles.length
|
||||
const count = workspace.tiles.all.length
|
||||
if (count < 2) return workspace.say('only one tile — L on a row opens another', 'error')
|
||||
return focusTile((workspace.focused + delta + count) % count)
|
||||
return focusTile((workspace.tiles.focused + delta + count) % count)
|
||||
}
|
||||
|
||||
/** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */
|
||||
const describeTile = (index: number) => {
|
||||
const tile = workspace.tiles[index]
|
||||
const tile = workspace.tiles.all[index]
|
||||
const name = tile.kind !== 'node' ? tile.kind : tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)'
|
||||
return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
|
||||
return `${index + 1}${index === workspace.tiles.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
|
||||
}
|
||||
|
||||
/** `:space rm`: a space, the one you are in unless named, into the trash. */
|
||||
async function removeSpace(name: string) {
|
||||
const wanted = name.toLowerCase()
|
||||
const space = wanted ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.space
|
||||
const space = wanted ? workspace.tree.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.tree.space
|
||||
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error')
|
||||
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) return
|
||||
await workspace.guard(() => workspace.deleteSpace(space.id))
|
||||
if (!(await workspace.prompts.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) return
|
||||
await workspace.guard(() => workspace.tree.deleteSpace(space.id))
|
||||
}
|
||||
|
||||
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
|
||||
const jumpTo = async (index: number) => {
|
||||
if (workspace.rows === 0) return workspace.say('nothing here', 'error')
|
||||
workspace.cursor = Math.min(Math.max(index, 0), workspace.rows - 1)
|
||||
await workspace.guard(() => workspace.openSelected())
|
||||
if (workspace.tree.rows === 0) return workspace.say('nothing here', 'error')
|
||||
workspace.tree.cursor = Math.min(Math.max(index, 0), workspace.tree.rows - 1)
|
||||
await workspace.guard(() => workspace.tree.openSelected())
|
||||
}
|
||||
|
||||
/** A folder among the children currently listed, by slug or by name. */
|
||||
const findFolder = (target: string) => {
|
||||
const wanted = target.toLowerCase()
|
||||
return workspace.nodes.find(
|
||||
return workspace.tree.nodes.find(
|
||||
(node) =>
|
||||
isContainer(node) && (node.slug === wanted || node.name.toLowerCase() === wanted),
|
||||
)
|
||||
@@ -216,12 +216,12 @@ const findFolder = (target: string) => {
|
||||
|
||||
/** The folders listed here, by slug, then the two ways out — for `:cd` and `:mv`. */
|
||||
const folderNames = () => [
|
||||
...workspace.nodes.filter(isContainer).map((node) => node.slug),
|
||||
...workspace.tree.nodes.filter(isContainer).map((node) => node.slug),
|
||||
'..',
|
||||
'/',
|
||||
]
|
||||
|
||||
const spaceNames = () => workspace.spaces.map((space) => space.slug)
|
||||
const spaceNames = () => workspace.tree.spaces.map((space) => space.slug)
|
||||
|
||||
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
|
||||
let tokenNames: string[] | null = null
|
||||
@@ -250,7 +250,7 @@ function documentTitles(arg: string): string[] {
|
||||
|
||||
/** The document in a tile, the focused one first: what a note goes to the end of unless told otherwise. */
|
||||
function documentOnScreen(): string | undefined {
|
||||
const tile = [workspace.tile, ...workspace.tiles].find((t) => t.open?.kind === 'document')
|
||||
const tile = [workspace.tile, ...workspace.tiles.all].find((t) => t.open?.kind === 'document')
|
||||
return tile?.open ? `/n/${tile.open.path.map((crumb) => crumb.slug).join('/')}` : undefined
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ let templateCache: { space: number; names: string[] } | null = null
|
||||
|
||||
/** Tab cannot wait for the server: the names come on the press after the first. */
|
||||
function templateNames(): string[] {
|
||||
const here = workspace.spaceId
|
||||
const here = workspace.tree.spaceId
|
||||
if (here === null) return []
|
||||
if (templateCache?.space !== here) {
|
||||
templateCache = { space: here, names: [] }
|
||||
@@ -281,7 +281,7 @@ const sealTarget = (encrypt: boolean) =>
|
||||
if (node.file && node.file.encrypted === encrypt) {
|
||||
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
|
||||
}
|
||||
if (!encrypt && workspace.parentSealed) {
|
||||
if (!encrypt && workspace.tree.parentSealed) {
|
||||
return workspace.say(`this ${node.kind} is inside a sealed folder — decrypt that folder instead`, 'error')
|
||||
}
|
||||
const name = node.name || node.slug
|
||||
@@ -289,13 +289,13 @@ const sealTarget = (encrypt: boolean) =>
|
||||
const question = encrypt
|
||||
? `seal ${node.kind} "${name}" — every file in it now, and every file added later? documents stay readable`
|
||||
: `unseal ${node.kind} "${name}" and take every file in it out of the vault?`
|
||||
if (!(await workspace.ask(question))) return
|
||||
if (!(await workspace.prompts.ask(question))) return
|
||||
} else if (!encrypt) {
|
||||
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
|
||||
if (!(await workspace.prompts.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
|
||||
}
|
||||
if (!(await workspace.ensureUnlocked())) return
|
||||
if (!(await workspace.vault.ensureUnlocked())) return
|
||||
workspace.say(encrypt ? 'encrypting …' : 'decrypting …')
|
||||
await workspace.seal(node.id, encrypt)
|
||||
await workspace.vault.seal(node.id, encrypt)
|
||||
})
|
||||
|
||||
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
|
||||
@@ -346,7 +346,7 @@ export function describeBackup(status: BackupStatus): string {
|
||||
|
||||
/** The vault in a line: open or locked, and what it is busy with. */
|
||||
export function describeVault(): string {
|
||||
const vault = workspace.vault
|
||||
const vault = workspace.vault.status
|
||||
if (!vault?.configured) return 'no vault yet'
|
||||
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : ''
|
||||
const rekey = vault.rekey
|
||||
@@ -359,27 +359,27 @@ export function describeVault(): string {
|
||||
|
||||
/** The first time: a password for the vault, asked twice. */
|
||||
async function setupVault() {
|
||||
const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
|
||||
const password = await workspace.prompts.askSecret('new vault password, 10+ characters — it cannot be recovered')
|
||||
if (password === null) return
|
||||
const again = await workspace.askSecret('the same again')
|
||||
const again = await workspace.prompts.askSecret('the same again')
|
||||
if (again === null) return
|
||||
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
|
||||
workspace.vault = await api.setupVault(password)
|
||||
workspace.vault.status = await api.setupVault(password)
|
||||
workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload seal seals new files')
|
||||
}
|
||||
|
||||
async function changeVaultPassword() {
|
||||
const current = await workspace.askSecret('current vault password')
|
||||
const current = await workspace.prompts.askSecret('current vault password')
|
||||
if (current === null) return
|
||||
const password = await workspace.askSecret('new vault password, 10+ characters')
|
||||
const password = await workspace.prompts.askSecret('new vault password, 10+ characters')
|
||||
if (password === null) return
|
||||
const again = await workspace.askSecret('the same again')
|
||||
const again = await workspace.prompts.askSecret('the same again')
|
||||
if (again === null) return
|
||||
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
|
||||
workspace.vault = await api.changeVaultPassword(current, password)
|
||||
workspace.vault.status = await api.changeVaultPassword(current, password)
|
||||
workspace.say(
|
||||
workspace.vault.rekey
|
||||
? `vault password changed — re-sealing ${workspace.vault.sealed} file(s) under the new key; leave the vault open, :vault shows how far`
|
||||
workspace.vault.status.rekey
|
||||
? `vault password changed — re-sealing ${workspace.vault.status.sealed} file(s) under the new key; leave the vault open, :vault shows how far`
|
||||
: 'vault password changed',
|
||||
)
|
||||
}
|
||||
@@ -407,15 +407,15 @@ async function protonLogin(username: string) {
|
||||
const unsealed = status.sealed_session
|
||||
? ''
|
||||
: ' The session will be stored unsealed on the data volume — set WORKSPACE_SESSION_KEY_FILE to seal it.'
|
||||
if (!(await workspace.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return
|
||||
const password = await workspace.askSecret(`proton password for ${username}`)
|
||||
if (!(await workspace.prompts.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return
|
||||
const password = await workspace.prompts.askSecret(`proton password for ${username}`)
|
||||
if (password === null) return
|
||||
workspace.say('logging in to proton …')
|
||||
let answer: BackupLogin = await api.backupLogin(username, password)
|
||||
for (let round = 0; answer.next !== 'done'; round++) {
|
||||
if (round === 5) return workspace.say('proton keeps asking — try again later', 'error')
|
||||
if (answer.next === 'totp') {
|
||||
const code = await workspace.askSecret('proton two-factor code')
|
||||
const code = await workspace.prompts.askSecret('proton two-factor code')
|
||||
if (code === null) return workspace.say('proton login abandoned')
|
||||
try {
|
||||
answer = await api.backupTotp(code)
|
||||
@@ -425,7 +425,7 @@ async function protonLogin(username: string) {
|
||||
workspace.say(error.message, 'error')
|
||||
}
|
||||
} else {
|
||||
const token = await workspace.askVerification(answer.url ?? '')
|
||||
const token = await workspace.prompts.askVerification(answer.url ?? '')
|
||||
if (token === null) return workspace.say('proton login abandoned')
|
||||
workspace.say('logging in to proton …')
|
||||
answer = await api.backupCaptcha(token)
|
||||
@@ -447,9 +447,9 @@ const exportName = (title: string, at: number) => {
|
||||
* `:q!`. A document `o` made and nothing was written in is not kept.
|
||||
*/
|
||||
async function leaveEditor() {
|
||||
if (workspace.writing) return void workspace.leaveWriting()
|
||||
if (await workspace.abandonIfBlank()) return hooks.focusList()
|
||||
if (workspace.dirty && !(await workspace.guard(() => workspace.save()))) return
|
||||
if (workspace.drafts.writing) return void workspace.drafts.leaveWriting()
|
||||
if (await workspace.drafts.abandonIfBlank()) return hooks.focusList()
|
||||
if (workspace.dirty && !(await workspace.guard(() => workspace.drafts.save()))) return
|
||||
workspace.editing = false
|
||||
hooks.focusList()
|
||||
}
|
||||
@@ -461,10 +461,10 @@ export const commands: CommandSpec[] = [
|
||||
group: 'editing',
|
||||
essential: true,
|
||||
help: "save what is being written — the open document, a card's description, a note — rather than in a moment",
|
||||
run: () => workspace.guard(() => (workspace.writing ? workspace.saveWriting() : workspace.save())),
|
||||
run: () => workspace.guard(() => (workspace.drafts.writing ? workspace.drafts.saveWriting() : workspace.drafts.save())),
|
||||
bang: {
|
||||
help: 'save even over a newer version saved somewhere else since this one was opened',
|
||||
run: () => workspace.guard(() => workspace.save({ force: true })),
|
||||
run: () => workspace.guard(() => workspace.drafts.save({ force: true })),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -476,7 +476,7 @@ export const commands: CommandSpec[] = [
|
||||
bang: {
|
||||
help: 'save over a newer version, as :w! does, and leave the editor',
|
||||
run: async () => {
|
||||
if (await workspace.guard(() => workspace.save({ force: true }))) await leaveEditor()
|
||||
if (await workspace.guard(() => workspace.drafts.save({ force: true }))) await leaveEditor()
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -487,27 +487,27 @@ export const commands: CommandSpec[] = [
|
||||
essential: true,
|
||||
help: 'leave the editor with what is waiting written first, or close the document when already reading',
|
||||
run: async () => {
|
||||
if (workspace.writing || workspace.editing) return leaveEditor()
|
||||
if (workspace.drafts.writing || workspace.editing) return leaveEditor()
|
||||
// A document `o` made and nothing was written in is not kept.
|
||||
if (await workspace.abandonIfBlank()) return hooks.focusList()
|
||||
if (await workspace.drafts.abandonIfBlank()) return hooks.focusList()
|
||||
if (workspace.dirty) {
|
||||
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
|
||||
return
|
||||
}
|
||||
if (workspace.tiles.length > 1) return focusTile(closeFocused())
|
||||
workspace.closeTile()
|
||||
if (workspace.tiles.all.length > 1) return focusTile(closeFocused())
|
||||
workspace.tiles.close()
|
||||
hooks.focusList()
|
||||
},
|
||||
bang: {
|
||||
help: 'leave the editor, letting go of what is not saved yet',
|
||||
run: async () => {
|
||||
if (workspace.writing) return void workspace.leaveWriting(true)
|
||||
if (workspace.drafts.writing) return void workspace.drafts.leaveWriting(true)
|
||||
const open = workspace.open
|
||||
workspace.editing = false
|
||||
workspace.dirty = false
|
||||
if (open) {
|
||||
workspace.dropStash(open.id)
|
||||
await workspace.guard(() => workspace.openNode(open.id))
|
||||
workspace.drafts.dropStash(open.id)
|
||||
await workspace.guard(() => workspace.tree.openNode(open.id))
|
||||
}
|
||||
hooks.focusList()
|
||||
},
|
||||
@@ -524,24 +524,24 @@ export const commands: CommandSpec[] = [
|
||||
const [what, rest] = verb(arg, ['new', 'name', 'rm'])
|
||||
if (what === 'new') {
|
||||
if (!rest) return workspace.say('usage: :space new <name>', 'error')
|
||||
return workspace.guard(() => workspace.createSpace(rest))
|
||||
return workspace.guard(() => workspace.tree.createSpace(rest))
|
||||
}
|
||||
if (what === 'name') {
|
||||
const space = workspace.space
|
||||
const space = workspace.tree.space
|
||||
if (!space) return workspace.say('no space open', 'error')
|
||||
if (!rest) return workspace.say('usage: :space name <text>', 'error')
|
||||
return workspace.guard(() => workspace.renameNode(space.id, rest))
|
||||
return workspace.guard(() => workspace.tree.renameNode(space.id, rest))
|
||||
}
|
||||
if (what === 'rm') return removeSpace(rest)
|
||||
const wanted = rest.toLowerCase()
|
||||
if (!wanted) {
|
||||
return workspace.say(workspace.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
|
||||
return workspace.say(workspace.tree.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
|
||||
}
|
||||
const space = workspace.spaces.find(
|
||||
const space = workspace.tree.spaces.find(
|
||||
(candidate) => candidate.slug === wanted || candidate.name.toLowerCase() === wanted,
|
||||
)
|
||||
if (!space) return workspace.say(`no such space: ${wanted} — :space new ${wanted} makes it`, 'error')
|
||||
await workspace.guard(() => workspace.enterSpace(space.id))
|
||||
await workspace.guard(() => workspace.tree.enterSpace(space.id))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -551,7 +551,7 @@ export const commands: CommandSpec[] = [
|
||||
help: 'make a folder here',
|
||||
run: async (arg) => {
|
||||
if (!arg.trim()) return workspace.say('usage: :mkdir <name>', 'error')
|
||||
await workspace.guard(() => workspace.createFolder(arg.trim()))
|
||||
await workspace.guard(() => workspace.tree.createFolder(arg.trim()))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -563,14 +563,14 @@ export const commands: CommandSpec[] = [
|
||||
run: async (arg) => {
|
||||
const target = arg.trim()
|
||||
await workspace.guard(async () => {
|
||||
if (!target || target === '..') return workspace.up()
|
||||
if (!target || target === '..') return workspace.tree.up()
|
||||
if (target === '/') {
|
||||
const space = workspace.spaceId
|
||||
return space === null ? undefined : workspace.goTo(space)
|
||||
const space = workspace.tree.spaceId
|
||||
return space === null ? undefined : workspace.tree.goTo(space)
|
||||
}
|
||||
const folder = findFolder(target)
|
||||
if (!folder) return workspace.say(`no such folder here: ${target}`, 'error')
|
||||
await workspace.goTo(folder.id)
|
||||
await workspace.tree.goTo(folder.id)
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -592,14 +592,14 @@ export const commands: CommandSpec[] = [
|
||||
name: 'board',
|
||||
group: 'board',
|
||||
help: "this space's board, in a tile — the last one shown when this space has none",
|
||||
run: () => workspace.guard(() => workspace.showView('board')),
|
||||
run: () => workspace.guard(() => workspace.tiles.show('board')),
|
||||
},
|
||||
{
|
||||
name: 'plan',
|
||||
aliases: ['special'],
|
||||
group: 'board',
|
||||
help: 'the board, the agenda and the calendar, tiled ahead of what is open — S puts them up, and every view away again; 1 … 9 on the board picks the board',
|
||||
run: () => workspace.guard(() => workspace.togglePlan()),
|
||||
run: () => workspace.guard(() => workspace.tiles.togglePlan()),
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
@@ -611,7 +611,7 @@ export const commands: CommandSpec[] = [
|
||||
const open = workspace.open
|
||||
if (readingDocument() && open) return cardAbout(open, arg)
|
||||
if (!arg.trim()) return workspace.say('usage: :card <title>', 'error')
|
||||
await workspace.guard(() => workspace.createCard(arg.trim()))
|
||||
await workspace.guard(() => workspace.cards.createCard(arg.trim()))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -622,11 +622,11 @@ export const commands: CommandSpec[] = [
|
||||
complete: () => ['today', 'tm', 'fri', '+3d'],
|
||||
run: async (arg) => {
|
||||
const target = arg.trim()
|
||||
if (target === '-' || target === '') return workspace.guard(() => workspace.patchCard({ due_at: null }))
|
||||
if (target === '-' || target === '') return workspace.guard(() => workspace.cards.patchCard({ due_at: null }))
|
||||
const at = parseDue(target)
|
||||
if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error')
|
||||
await workspace.guard(async () => {
|
||||
if (await workspace.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`)
|
||||
if (await workspace.cards.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -640,7 +640,7 @@ export const commands: CommandSpec[] = [
|
||||
const rule = arg.trim()
|
||||
const repeat = rule === '-' || rule === '' ? null : rule
|
||||
await workspace.guard(async () => {
|
||||
const card = await workspace.patchCard({ repeat })
|
||||
const card = await workspace.cards.patchCard({ repeat })
|
||||
// The server's spelling of the rule: `+14d` is kept as `+2w`.
|
||||
if (card) workspace.say(card.repeat === null ? 'repeats no more' : `repeats ${card.repeat} once done`)
|
||||
})
|
||||
@@ -650,7 +650,7 @@ export const commands: CommandSpec[] = [
|
||||
name: 'done',
|
||||
group: 'board',
|
||||
help: 'move the card in focus into the done list, or back out to the inbox',
|
||||
run: () => workspace.guard(() => workspace.toggleDone()),
|
||||
run: () => workspace.guard(() => workspace.cards.toggleDone()),
|
||||
},
|
||||
{
|
||||
name: 'scratch',
|
||||
@@ -659,8 +659,8 @@ export const commands: CommandSpec[] = [
|
||||
group: 'documents',
|
||||
help: 'the scratchpad, lowered over whatever is on screen — Q does the same; with text, a note on it without leaving',
|
||||
run: async (arg) => {
|
||||
if (!arg.trim()) return workspace.guard(() => workspace.toggleScratch())
|
||||
await workspace.guard(() => workspace.scratch(arg.trim()))
|
||||
if (!arg.trim()) return workspace.guard(() => workspace.scratch.toggle())
|
||||
await workspace.guard(() => workspace.scratch.add(arg.trim()))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -670,10 +670,10 @@ export const commands: CommandSpec[] = [
|
||||
help: 'put the scratch note at the end of a document, by its title — Tab completes it — or the one on screen, and take it off the scratchpad',
|
||||
complete: documentTitles,
|
||||
run: async (arg) => {
|
||||
if (!workspace.scratchOpen) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
|
||||
if (!workspace.scratch.open) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
|
||||
const target = arg.trim() || documentOnScreen()
|
||||
if (!target) return workspace.say('usage: :append <document> — or open the one it goes to', 'error')
|
||||
await workspace.guard(() => workspace.appendScratchNote(target))
|
||||
await workspace.guard(() => workspace.scratch.appendNote(target))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -682,8 +682,8 @@ export const commands: CommandSpec[] = [
|
||||
group: 'documents',
|
||||
help: 'make the scratch note a document in the folder being browsed — titled by its first line unless given one — and take it off the scratchpad',
|
||||
run: async (arg) => {
|
||||
if (!workspace.scratchOpen) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
|
||||
await workspace.guard(() => workspace.keepScratchNote(arg))
|
||||
if (!workspace.scratch.open) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
|
||||
await workspace.guard(() => workspace.scratch.keepNote(arg))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -696,7 +696,7 @@ export const commands: CommandSpec[] = [
|
||||
if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error')
|
||||
const [what, text] = verb(arg, ['add', 'edit'])
|
||||
if (!text) return workspace.say(`usage: :task ${what === 'edit' ? 'edit' : '[add]'} <text>`, 'error')
|
||||
await workspace.guard(() => (what === 'edit' ? workspace.renameTask(text) : workspace.addTask(text)))
|
||||
await workspace.guard(() => (what === 'edit' ? workspace.cards.renameTask(text) : workspace.cards.addTask(text)))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -704,11 +704,11 @@ export const commands: CommandSpec[] = [
|
||||
args: '<name>',
|
||||
group: 'board',
|
||||
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
|
||||
complete: () => workspace.boardLabels,
|
||||
complete: () => workspace.cards.boardLabels,
|
||||
run: async (arg) => {
|
||||
const card = workspace.focusedCard
|
||||
const card = workspace.cards.focusedCard
|
||||
if (!arg.trim() && card) return workspace.say(card.labels.join(' · ') || 'no labels — :label <name> adds one')
|
||||
await workspace.guard(() => workspace.toggleLabel(arg))
|
||||
await workspace.guard(() => workspace.cards.toggleLabel(arg))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -718,22 +718,22 @@ export const commands: CommandSpec[] = [
|
||||
help: 'add a column to the board — name renames the one under the cursor, rm deletes it and every card on it (asks first)',
|
||||
complete: () => ['name ', 'rm'],
|
||||
run: async (arg) => {
|
||||
if (!workspace.view('board')) return workspace.say('no board open — :board', 'error')
|
||||
if (!workspace.tiles.view('board')) return workspace.say('no board open — :board', 'error')
|
||||
const [what, rest] = verb(arg, ['name', 'rm'])
|
||||
const list = workspace.list
|
||||
const list = workspace.cards.list
|
||||
if (what === 'name') {
|
||||
if (!list) return workspace.say('no list selected', 'error')
|
||||
if (!rest) return workspace.say('usage: :list name <text>', 'error')
|
||||
return workspace.guard(() => workspace.renameList(rest))
|
||||
return workspace.guard(() => workspace.cards.renameList(rest))
|
||||
}
|
||||
if (what === 'rm') {
|
||||
if (!list) return workspace.say('no list selected', 'error')
|
||||
const cards = list.cards.length
|
||||
if (!(await workspace.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return
|
||||
return workspace.guard(() => workspace.deleteList())
|
||||
if (!(await workspace.prompts.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return
|
||||
return workspace.guard(() => workspace.cards.deleteList())
|
||||
}
|
||||
if (!rest) return workspace.say('usage: :list <name>', 'error')
|
||||
await workspace.guard(() => workspace.createList(rest))
|
||||
await workspace.guard(() => workspace.cards.createList(rest))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -745,7 +745,7 @@ export const commands: CommandSpec[] = [
|
||||
help: 'move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space',
|
||||
complete: folderNames,
|
||||
run: async (arg) => {
|
||||
const node = workspace.marked[0] ?? workspace.target
|
||||
const node = workspace.tree.marked[0] ?? workspace.target
|
||||
if (!node) return workspace.say('nothing selected', 'error')
|
||||
const target = arg.trim()
|
||||
if (!target) return workspace.say('usage: :mv <folder|..|/>', 'error')
|
||||
@@ -758,11 +758,11 @@ export const commands: CommandSpec[] = [
|
||||
return parent.kind === 'space' ? undefined : (parent.parent_id ?? undefined)
|
||||
}
|
||||
const destination =
|
||||
target === '..' ? await up() : target === '/' ? (workspace.spaceId ?? undefined) : findFolder(target)?.id
|
||||
target === '..' ? await up() : target === '/' ? (workspace.tree.spaceId ?? undefined) : findFolder(target)?.id
|
||||
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
|
||||
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
|
||||
if (workspace.marked.length) return workspace.guard(() => workspace.moveNodes(workspace.marked, destination))
|
||||
await workspace.guard(() => workspace.moveNode(node.id, destination))
|
||||
if (workspace.tree.marked.length) return workspace.guard(() => workspace.tree.moveNodes(workspace.tree.marked, destination))
|
||||
await workspace.guard(() => workspace.tree.moveNode(node.id, destination))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -776,7 +776,7 @@ export const commands: CommandSpec[] = [
|
||||
run: async (arg) => {
|
||||
await workspace.guard(async () => {
|
||||
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||
const here = workspace.spaceId
|
||||
const here = workspace.tree.spaceId
|
||||
const templates = words.length && here !== null ? await api.templates(here) : []
|
||||
if (words.length && here !== null) templateCache = { space: here, names: templates.map((t) => t.slug) }
|
||||
const wanted = words[0]?.toLowerCase()
|
||||
@@ -789,7 +789,7 @@ export const commands: CommandSpec[] = [
|
||||
created_at = at
|
||||
words.shift()
|
||||
}
|
||||
await workspace.createDocument(words.join(' '), { created_at, template_id: template?.id })
|
||||
await workspace.tree.createDocument(words.join(' '), { created_at, template_id: template?.id })
|
||||
})
|
||||
hooks.focusEditor()
|
||||
},
|
||||
@@ -801,8 +801,8 @@ export const commands: CommandSpec[] = [
|
||||
help: 'the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
if (workspace.view('calendar')) return workspace.showView('calendar')
|
||||
await workspace.openCalendar()
|
||||
if (workspace.tiles.view('calendar')) return workspace.tiles.show('calendar')
|
||||
await workspace.diary.openCalendar()
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -812,8 +812,8 @@ export const commands: CommandSpec[] = [
|
||||
help: 'what is late, due this week, or in hand — across every board',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
if (!workspace.view('agenda')) workspace.agendaCursor = 0
|
||||
await workspace.showView('agenda')
|
||||
if (!workspace.tiles.view('agenda')) workspace.cards.agendaCursor = 0
|
||||
await workspace.tiles.show('agenda')
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -822,8 +822,8 @@ export const commands: CommandSpec[] = [
|
||||
group: 'documents',
|
||||
help: "open today's diary entry, starting one if there is none yet; with text, a timed line at its end without going there",
|
||||
run: async (arg) => {
|
||||
if (arg.trim()) return workspace.guard(() => workspace.logToday(arg.trim()))
|
||||
await workspace.guard(() => workspace.openDay())
|
||||
if (arg.trim()) return workspace.guard(() => workspace.diary.logToday(arg.trim()))
|
||||
await workspace.guard(() => workspace.diary.openDay())
|
||||
hooks.focusEditor()
|
||||
},
|
||||
},
|
||||
@@ -843,7 +843,7 @@ export const commands: CommandSpec[] = [
|
||||
}
|
||||
const id = arg ? Number(arg) : node?.id
|
||||
if (!id || Number.isNaN(id)) return workspace.say('usage: :e <id>', 'error')
|
||||
await workspace.guard(() => workspace.openNode(id, true))
|
||||
await workspace.guard(() => workspace.tree.openNode(id, true))
|
||||
hooks.focusEditor()
|
||||
},
|
||||
bang: {
|
||||
@@ -852,8 +852,8 @@ export const commands: CommandSpec[] = [
|
||||
const open = requireOpen()
|
||||
if (!open) return
|
||||
workspace.dirty = false
|
||||
workspace.dropStash(open.id)
|
||||
await workspace.guard(() => workspace.openNode(open.id, workspace.editing))
|
||||
workspace.drafts.dropStash(open.id)
|
||||
await workspace.guard(() => workspace.tree.openNode(open.id, workspace.editing))
|
||||
workspace.say(`${open.slug} reloaded`)
|
||||
},
|
||||
},
|
||||
@@ -862,13 +862,13 @@ export const commands: CommandSpec[] = [
|
||||
name: 'next',
|
||||
group: 'documents',
|
||||
help: 'open the next node down the list',
|
||||
run: () => jumpTo(workspace.cursor + 1),
|
||||
run: () => jumpTo(workspace.tree.cursor + 1),
|
||||
},
|
||||
{
|
||||
name: 'prev',
|
||||
group: 'documents',
|
||||
help: 'open the previous node',
|
||||
run: () => jumpTo(workspace.cursor - 1),
|
||||
run: () => jumpTo(workspace.tree.cursor - 1),
|
||||
},
|
||||
{
|
||||
name: 'close',
|
||||
@@ -877,10 +877,10 @@ export const commands: CommandSpec[] = [
|
||||
help: 'close the focused tile — Alt-q; asks when its draft could not be written',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
await workspace.flushTile()
|
||||
await workspace.drafts.flushTile()
|
||||
if (workspace.dirty) {
|
||||
const name = workspace.open?.slug ?? 'the draft'
|
||||
if (!(await workspace.ask(`${name} could not be written — close anyway? the draft stays in this browser`))) return
|
||||
if (!(await workspace.prompts.ask(`${name} could not be written — close anyway? the draft stays in this browser`))) return
|
||||
}
|
||||
await focusTile(closeFocused())
|
||||
}),
|
||||
@@ -890,14 +890,14 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['on'],
|
||||
group: 'tiles',
|
||||
help: 'close every tile but the focused one',
|
||||
run: () => workspace.guard(() => workspace.onlyTile()),
|
||||
run: () => workspace.guard(() => workspace.tiles.only()),
|
||||
},
|
||||
{
|
||||
name: 'ls',
|
||||
aliases: ['buffers', 'files'],
|
||||
group: 'tiles',
|
||||
help: 'list the open tiles, % on the focused one',
|
||||
run: () => workspace.say(workspace.tiles.map((_, index) => describeTile(index)).join(' · ')),
|
||||
run: () => workspace.say(workspace.tiles.all.map((_, index) => describeTile(index)).join(' · ')),
|
||||
},
|
||||
{
|
||||
name: 'buffer',
|
||||
@@ -905,18 +905,18 @@ export const commands: CommandSpec[] = [
|
||||
args: '<n|name>',
|
||||
group: 'tiles',
|
||||
help: 'focus a tile by its number in :ls, or by the start of its name',
|
||||
complete: () => workspace.tiles.flatMap((tile) => (tile.kind !== 'node' ? [tile.kind] : tile.open ? [tile.open.slug] : [])),
|
||||
complete: () => workspace.tiles.all.flatMap((tile) => (tile.kind !== 'node' ? [tile.kind] : tile.open ? [tile.open.slug] : [])),
|
||||
run: (arg) => {
|
||||
const wanted = arg.trim().toLowerCase()
|
||||
if (!wanted) return workspace.say(describeTile(workspace.focused))
|
||||
if (!wanted) return workspace.say(describeTile(workspace.tiles.focused))
|
||||
const index = /^\d+$/.test(wanted)
|
||||
? Number(wanted) - 1
|
||||
: workspace.tiles.findIndex((tile) =>
|
||||
: workspace.tiles.all.findIndex((tile) =>
|
||||
tile.kind !== 'node'
|
||||
? tile.kind.startsWith(wanted)
|
||||
: tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)),
|
||||
)
|
||||
if (!workspace.tiles[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
|
||||
if (!workspace.tiles.all[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
|
||||
return focusTile(index)
|
||||
},
|
||||
},
|
||||
@@ -944,13 +944,13 @@ export const commands: CommandSpec[] = [
|
||||
name: 'last',
|
||||
group: 'documents',
|
||||
help: 'open the last node in the list',
|
||||
run: () => jumpTo(workspace.rows - 1),
|
||||
run: () => jumpTo(workspace.tree.rows - 1),
|
||||
},
|
||||
{
|
||||
name: 'random',
|
||||
group: 'documents',
|
||||
help: 'open something here at random — good for re-reading',
|
||||
run: () => jumpTo(Math.floor(Math.random() * workspace.nodes.length)),
|
||||
run: () => jumpTo(Math.floor(Math.random() * workspace.tree.nodes.length)),
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
@@ -961,23 +961,23 @@ export const commands: CommandSpec[] = [
|
||||
run: async (arg) => {
|
||||
// On the board the thing under the cursor is a card, not a node.
|
||||
if (onBoard() && !arg) {
|
||||
const card = workspace.card
|
||||
const card = workspace.cards.card
|
||||
if (!card) return workspace.say('no card selected', 'error')
|
||||
if (!(await workspace.ask(`delete the card "${card.title}"?`))) return
|
||||
return workspace.guard(() => workspace.deleteCard())
|
||||
if (!(await workspace.prompts.ask(`delete the card "${card.title}"?`))) return
|
||||
return workspace.guard(() => workspace.cards.deleteCard())
|
||||
}
|
||||
// Whatever has focus: the row under the cursor in the list, the open
|
||||
// document only while the document pane is the one being looked at.
|
||||
const id = arg ? Number(arg) : workspace.target?.id
|
||||
if (!id || Number.isNaN(id)) return workspace.say('nothing to delete', 'error')
|
||||
const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id)
|
||||
const node = workspace.open?.id === id ? workspace.open : workspace.tree.nodes.find((n) => n.id === id)
|
||||
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
|
||||
const what =
|
||||
node && isContainer(node)
|
||||
? `delete the ${node.kind} ${name} and everything in it?`
|
||||
: `delete ${name}?`
|
||||
if (!(await workspace.ask(`${what} it goes to the trash.`))) return
|
||||
await workspace.guard(() => workspace.deleteNode(id))
|
||||
if (!(await workspace.prompts.ask(`${what} it goes to the trash.`))) return
|
||||
await workspace.guard(() => workspace.tree.deleteNode(id))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -989,9 +989,9 @@ export const commands: CommandSpec[] = [
|
||||
run: async (arg) => {
|
||||
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle()
|
||||
if (onBoard()) {
|
||||
if (!workspace.card) return workspace.say('no card here — :list name renames the list', 'error')
|
||||
if (!workspace.cards.card) return workspace.say('no card here — :list name renames the list', 'error')
|
||||
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
|
||||
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
|
||||
return workspace.guard(() => workspace.cards.patchCard({ title: arg.trim() }))
|
||||
}
|
||||
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
|
||||
|
||||
@@ -1001,11 +1001,11 @@ export const commands: CommandSpec[] = [
|
||||
if (node && node.id === workspace.open?.id) {
|
||||
workspace.draft.name = arg
|
||||
workspace.dirty = true
|
||||
return workspace.guard(() => workspace.save())
|
||||
return workspace.guard(() => workspace.drafts.save())
|
||||
}
|
||||
|
||||
if (!node) return workspace.say('nothing selected — :space name renames the space', 'error')
|
||||
await workspace.guard(() => workspace.renameNode(node.id, arg))
|
||||
await workspace.guard(() => workspace.tree.renameNode(node.id, arg))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1043,9 +1043,9 @@ export const commands: CommandSpec[] = [
|
||||
if (arg.trim() === 'purge') return emptyTrash()
|
||||
if (arg.trim()) return workspace.say('usage: :trash [purge]', 'error')
|
||||
await workspace.guard(async () => {
|
||||
await workspace.loadTrash()
|
||||
workspace.trashCursor = 0
|
||||
workspace.openSheet('trash')
|
||||
await workspace.sheets.loadTrash()
|
||||
workspace.sheets.trashCursor = 0
|
||||
workspace.sheets.open('trash')
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -1060,7 +1060,7 @@ export const commands: CommandSpec[] = [
|
||||
if (at === null) return workspace.say('usage: :date 2026-09-05', 'error')
|
||||
workspace.draft.created_at = at
|
||||
workspace.dirty = true
|
||||
await workspace.guard(() => workspace.save())
|
||||
await workspace.guard(() => workspace.drafts.save())
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1078,7 +1078,7 @@ export const commands: CommandSpec[] = [
|
||||
if (what === 'new') return renewLink(node)
|
||||
if (what) return workspace.say('usage: :share [off|new]', 'error')
|
||||
if (node.shared) return workspace.say('already shared — :share new for a fresh link, :share off to take it back')
|
||||
await workspace.guard(() => workspace.toggleShare(node.id))
|
||||
await workspace.guard(() => workspace.tree.toggleShare(node.id))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1105,7 +1105,7 @@ export const commands: CommandSpec[] = [
|
||||
if (!node) return workspace.say('nothing selected — :export! downloads everything', 'error')
|
||||
if (node.kind === 'document') return exportDocument(node)
|
||||
if (!node.file) return workspace.say(`a ${node.kind} goes with :export!, the whole workspace`, 'error')
|
||||
if (node.file.encrypted && !(await workspace.ensureUnlocked())) return
|
||||
if (node.file.encrypted && !(await workspace.vault.ensureUnlocked())) return
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = node.file.url
|
||||
anchor.download = node.name
|
||||
@@ -1117,12 +1117,12 @@ export const commands: CommandSpec[] = [
|
||||
run: async () => {
|
||||
// A plain navigation, so the browser streams the archive straight to
|
||||
// disk instead of the tab holding all of it in memory first.
|
||||
await workspace.flush()
|
||||
await workspace.drafts.flush()
|
||||
// Sealed files go into the archive opened, which needs the vault:
|
||||
// asked here, because a refusal after the navigation would replace
|
||||
// the whole app with an error page.
|
||||
await workspace.loadVault()
|
||||
if ((workspace.vault?.sealed ?? 0) > 0 && !(await workspace.ensureUnlocked())) {
|
||||
await workspace.vault.load()
|
||||
if ((workspace.vault.status?.sealed ?? 0) > 0 && !(await workspace.vault.ensureUnlocked())) {
|
||||
return workspace.say('the export holds sealed files — it needs the vault open', 'error')
|
||||
}
|
||||
workspace.say('building the archive — the download starts on its own')
|
||||
@@ -1150,7 +1150,7 @@ export const commands: CommandSpec[] = [
|
||||
const what = arg.trim()
|
||||
if (what === 'seal') {
|
||||
return workspace.guard(async () => {
|
||||
if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true)
|
||||
if (await workspace.vault.ensureUnlocked()) hooks.pickTreeFiles(true)
|
||||
})
|
||||
}
|
||||
if (what) return workspace.say('usage: :upload [seal]', 'error')
|
||||
@@ -1182,10 +1182,10 @@ export const commands: CommandSpec[] = [
|
||||
args: '[text]',
|
||||
group: 'documents',
|
||||
help: 'full-text search in this space (empty clears)',
|
||||
run: (arg) => workspace.guard(() => workspace.search(arg, false)),
|
||||
run: (arg) => workspace.guard(() => workspace.tree.search(arg, false)),
|
||||
bang: {
|
||||
help: 'full-text search across every space — Ctrl-P',
|
||||
run: (arg) => workspace.guard(() => workspace.search(arg, true)),
|
||||
run: (arg) => workspace.guard(() => workspace.tree.search(arg, true)),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1193,7 +1193,7 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['nohlsearch'],
|
||||
group: 'documents',
|
||||
help: 'clear the search filter',
|
||||
run: () => workspace.guard(() => workspace.search('')),
|
||||
run: () => workspace.guard(() => workspace.tree.search('')),
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
@@ -1202,8 +1202,8 @@ export const commands: CommandSpec[] = [
|
||||
help: 're-read the current folder from the server',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
await workspace.refresh()
|
||||
workspace.say(`${workspace.nodes.length} here`)
|
||||
await workspace.tree.refresh()
|
||||
workspace.say(`${workspace.tree.nodes.length} here`)
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -1215,8 +1215,8 @@ export const commands: CommandSpec[] = [
|
||||
const words = body.split(/\s+/).filter(Boolean).length
|
||||
const open = workspace.open ? `${workspace.open.slug} ${words}w ${body.split('\n').length}L · ` : ''
|
||||
workspace.say(
|
||||
`${open}${workspace.here || '/'} · ${workspace.nodes.length} node${workspace.nodes.length === 1 ? '' : 's'}${
|
||||
workspace.query ? ` matching "${workspace.query}"` : ''
|
||||
`${open}${workspace.tree.here || '/'} · ${workspace.tree.nodes.length} node${workspace.tree.nodes.length === 1 ? '' : 's'}${
|
||||
workspace.tree.query ? ` matching "${workspace.tree.query}"` : ''
|
||||
}`,
|
||||
)
|
||||
},
|
||||
@@ -1238,8 +1238,8 @@ export const commands: CommandSpec[] = [
|
||||
return workspace.setClipboard(false), workspace.say('yanks stay in vim registers')
|
||||
// A board is a property of the space, not of this browser, so unlike the
|
||||
// options above this one goes to the server.
|
||||
if (option === 'board') return workspace.guard(() => workspace.setBoard(true))
|
||||
if (option === 'noboard') return workspace.guard(() => workspace.setBoard(false))
|
||||
if (option === 'board') return workspace.guard(() => workspace.cards.setBoard(true))
|
||||
if (option === 'noboard') return workspace.guard(() => workspace.cards.setBoard(false))
|
||||
workspace.say(`unknown option: ${option}`, 'error')
|
||||
},
|
||||
},
|
||||
@@ -1256,7 +1256,7 @@ export const commands: CommandSpec[] = [
|
||||
const loginMatch = /^login\s+(\S+)$/.exec(trimmed)
|
||||
if (loginMatch) return protonLogin(loginMatch[1])
|
||||
if (trimmed === 'logout') {
|
||||
if (!(await workspace.ask('Forget the Proton Drive session? What is mirrored stays on Drive.'))) return
|
||||
if (!(await workspace.prompts.ask('Forget the Proton Drive session? What is mirrored stays on Drive.'))) return
|
||||
await api.backupLogout()
|
||||
return workspace.say('proton drive session forgotten — backups are off')
|
||||
}
|
||||
@@ -1268,7 +1268,7 @@ export const commands: CommandSpec[] = [
|
||||
workspace.guard(async () => {
|
||||
// Whatever is in the editor belongs in the backup that was just
|
||||
// asked for, so it goes to the server before the mirror runs.
|
||||
await workspace.flush()
|
||||
await workspace.drafts.flush()
|
||||
workspace.say('backing up …')
|
||||
const status = await api.backupNow()
|
||||
if (status.last_error) return workspace.say(`backup failed: ${status.last_error}`, 'error')
|
||||
@@ -1298,7 +1298,7 @@ export const commands: CommandSpec[] = [
|
||||
if (!target) return workspace.say(`no session ${rmMatch[1]} — :sessions lists them`, 'error')
|
||||
if (target.current) return workspace.say('that is this browser — :logout ends it', 'error')
|
||||
await api.revokeSession(target.id)
|
||||
await workspace.loadVault()
|
||||
await workspace.vault.load()
|
||||
workspace.say(`signed out ${browserOf(target.user_agent)}, seen ${relative(target.last_seen)} — the vault is locked`)
|
||||
}),
|
||||
},
|
||||
@@ -1393,19 +1393,19 @@ export const commands: CommandSpec[] = [
|
||||
run: (arg) =>
|
||||
workspace.guard(async () => {
|
||||
const what = arg.trim()
|
||||
await workspace.loadVault()
|
||||
await workspace.vault.load()
|
||||
if (what === 'unlock') {
|
||||
if (workspace.vault?.unlocked) return workspace.say('the vault is already open')
|
||||
return workspace.ensureUnlocked()
|
||||
if (workspace.vault.status?.unlocked) return workspace.say('the vault is already open')
|
||||
return workspace.vault.ensureUnlocked()
|
||||
}
|
||||
if (what === 'lock') {
|
||||
workspace.vault = await api.lockVault()
|
||||
workspace.vault.status = await api.lockVault()
|
||||
if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id)
|
||||
return workspace.say('vault locked')
|
||||
}
|
||||
if (what === 'passwd') return changeVaultPassword()
|
||||
if (what) return workspace.say('usage: :vault [unlock|lock|passwd]', 'error')
|
||||
if (!workspace.vault?.configured) return setupVault()
|
||||
if (!workspace.vault.status?.configured) return setupVault()
|
||||
await openSettings('vault')
|
||||
}),
|
||||
},
|
||||
@@ -1433,7 +1433,7 @@ export const commands: CommandSpec[] = [
|
||||
complete: () => ['all'],
|
||||
run: (arg) => {
|
||||
const all = arg.trim() === 'all'
|
||||
if (workspace.overlay === 'help' && !all) return workspace.closeSheet()
|
||||
if (workspace.sheets.shown === 'help' && !all) return workspace.sheets.close()
|
||||
openHelp(all)
|
||||
},
|
||||
},
|
||||
@@ -1493,7 +1493,7 @@ const renamed: Record<string, string | ((arg: string) => unknown)> = {
|
||||
if (node?.kind !== 'document') return workspace.say('no document to link — open one first', 'error')
|
||||
return cardAbout(node, arg)
|
||||
},
|
||||
unmark: () => workspace.unmarkAll(),
|
||||
unmark: () => workspace.tree.unmarkAll(),
|
||||
}
|
||||
|
||||
const lookup = new Map<string, CommandSpec>()
|
||||
@@ -1508,7 +1508,7 @@ export async function runCommand(line: string): Promise<void> {
|
||||
if (!input) return
|
||||
|
||||
if (input.startsWith('/')) {
|
||||
await workspace.guard(() => workspace.search(input.slice(1)))
|
||||
await workspace.guard(() => workspace.tree.search(input.slice(1)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1523,7 +1523,7 @@ export async function runCommand(line: string): Promise<void> {
|
||||
|
||||
// `:12` jumps to a node by id, the way `:12` jumps to a line in vim.
|
||||
if (/^\d+$/.test(head)) {
|
||||
await workspace.guard(() => workspace.openNode(Number(head)))
|
||||
await workspace.guard(() => workspace.tree.openNode(Number(head)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1539,7 +1539,7 @@ export async function runCommand(line: string): Promise<void> {
|
||||
await spec.bang.run(arg)
|
||||
} else await spec.run(arg)
|
||||
// What a line changed shows in the settings sheet it was typed over.
|
||||
if (workspace.overlay === 'settings') await workspace.guard(() => loadSettings())
|
||||
if (workspace.sheets.shown === 'settings') await workspace.guard(() => loadSettings())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user