diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c68646ad..f5c8353c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.44.0", + "version": "2.45.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 19cbc263..2cb126de 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1835,6 +1835,16 @@ function stopRemoteWatch(): void { } } +/** Tell every remote window to re-pull the vault: its list is behind the server. */ +function sendRemoteResync(): void { + windowVaults.sendRemoteVaultChange({ + kind: "change", + path: "", + folder: "inbox", + scope: "resync", + }); +} + function startRemoteWatch( client: RemoteServerClient, capabilities: ServerCapabilities, @@ -1854,14 +1864,7 @@ function startRemoteWatch( windowVaults.sendRemoteVaultChange(ev); }, { - onReconnect: () => { - windowVaults.sendRemoteVaultChange({ - kind: "change", - path: "", - folder: "inbox", - scope: "resync", - }); - }, + onReconnect: () => sendRemoteResync(), }, ); } @@ -2017,7 +2020,11 @@ async function setRemoteWorkspace( vaultPath?: string | null; } = {}, ): Promise<{ vault: VaultInfo | null; capabilities: ServerCapabilities }> { - const client = new RemoteServerClient({ baseUrl, authToken }); + const client = new RemoteServerClient({ + baseUrl, + authToken, + onStalePath: () => sendRemoteResync(), + }); let capabilities = await client.getCapabilities(); remoteWorkspaceBootError = null; let vault = await client.getCurrentVault(); diff --git a/apps/desktop/src/main/remote/server-client.test.ts b/apps/desktop/src/main/remote/server-client.test.ts index e28b9191..faa6a7a8 100644 --- a/apps/desktop/src/main/remote/server-client.test.ts +++ b/apps/desktop/src/main/remote/server-client.test.ts @@ -1,12 +1,12 @@ import http from 'node:http' import type { AddressInfo } from 'node:net' import { WebSocketServer } from 'ws' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - connectionErrorMessage, RemoteConnectionError, RemoteRequestError, - RemoteServerClient + RemoteServerClient, + connectionErrorMessage } from './server-client' describe('connectionErrorMessage (#481)', () => { @@ -214,6 +214,41 @@ describe('watchVaultChanges reconnect', () => { } }, 15_000) + it('a proxy that refuses the upgrade falls back to polling, and stop ends the polling (#734)', async () => { + // A reverse proxy without WebSocket support answers the handshake with a + // plain HTTP error every time. The feed is not briefly down, it is + // unavailable, and the old client left the vault frozen at connect time. + const server = http.createServer((_req, res) => { + res.writeHead(404) + res.end('not found') + }) + server.on('upgrade', (_req, socket) => { + socket.end('HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const client = new RemoteServerClient({ baseUrl: `http://127.0.0.1:${port}` }) + + let resyncs = 0 + const stop = client.watchVaultChanges(() => {}, { + onReconnect: () => (resyncs += 1), + pollWhileDownMs: 100 + }) + try { + await waitFor(() => resyncs >= 3, 5_000, 'polling resyncs') + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0][0])).toContain('/api/watch') + } finally { + stop() + warn.mockRestore() + } + const afterStop = resyncs + await new Promise((resolve) => setTimeout(resolve, 350)) + expect(resyncs).toBe(afterStop) + await new Promise((resolve) => server.close(resolve)) + }, 10_000) + it('an unreachable server neither throws nor crashes, and stop cancels the retry loop', async () => { // Port 1 is never listening. The connection error must stay inside the // client (an unhandled ws 'error' event would crash the process, which @@ -225,3 +260,54 @@ describe('watchVaultChanges reconnect', () => { await new Promise((resolve) => setTimeout(resolve, 100)) }, 10_000) }) + + +describe('a 404 for a path this app asked to change (#734)', () => { + async function serverAnswering(status: number, body: string): Promise<{ port: number; close: () => Promise; requests: string[] }> { + const requests: string[] = [] + const server = http.createServer((req, res) => { + requests.push(`${req.method} ${req.url}`) + res.writeHead(status) + res.end(body) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + return { port, requests, close: () => new Promise((resolve) => server.close(() => resolve())) } + } + + it('names the path, keeps the 404 status, and asks the host to re-pull the list', async () => { + const { port, close } = await serverAnswering(404, 'not found') + const stale: string[] = [] + const client = new RemoteServerClient({ + baseUrl: `http://127.0.0.1:${port}`, + onStalePath: (path) => stale.push(path) + }) + try { + const error = await client.moveToTrash('inbox/Renamed elsewhere.md').catch((e: unknown) => e) + expect(error).toBeInstanceOf(RemoteRequestError) + expect((error as RemoteRequestError).status).toBe(404) + expect((error as Error).message).toContain('nothing at inbox/Renamed elsewhere.md any more') + expect((error as Error).message).toContain('refreshed') + expect(stale).toEqual(['inbox/Renamed elsewhere.md']) + } finally { + await close() + } + }) + + it('leaves a 404 on a read alone: absent is a valid answer there (#556)', async () => { + const { port, close } = await serverAnswering(404, 'not found') + const stale: string[] = [] + const client = new RemoteServerClient({ + baseUrl: `http://127.0.0.1:${port}`, + onStalePath: (path) => stale.push(path) + }) + try { + const error = await client.readNote('inbox/Absent.md').catch((e: unknown) => e) + expect((error as RemoteRequestError).status).toBe(404) + expect((error as Error).message).toContain('404') + expect(stale).toEqual([]) + } finally { + await close() + } + }) +}) diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index 9fd43d12..eea39912 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -30,6 +30,12 @@ import { export interface RemoteServerClientOptions { baseUrl: string authToken?: string | null + /** + * Called with the vault-relative path when the server answers 404 to a + * request about it. The list this app shows is behind the server, so the + * host re-pulls it (see `stalePathMessage`). + */ + onStalePath?: (path: string) => void } type JsonRequestInit = Omit & { body?: unknown } @@ -48,6 +54,7 @@ import type { WriteWorkflowInput } from '@zennotes/bridge-contract/workflows' import { prepareWorkflowRun } from '@shared/workflows/prepare-run' +import { REMOTE_CHANGE_POLL_MS, stalePathMessage } from '@shared/remote-workspace-messages' export class RemoteConnectionError extends Error {} @@ -65,10 +72,12 @@ export class RemoteRequestError extends Error { export class RemoteServerClient { readonly baseUrl: string readonly authToken: string | null + private readonly onStalePath: ((path: string) => void) | null constructor(options: RemoteServerClientOptions) { this.baseUrl = normalizeBaseUrl(options.baseUrl) this.authToken = options.authToken?.trim() || null + this.onStalePath = options.onStalePath ?? null } async getCapabilities(): Promise { @@ -452,7 +461,7 @@ export class RemoteServerClient { watchVaultChanges( onEvent: (event: VaultChangeEvent) => void, - options: { onReconnect?: () => void; stableAfterMs?: number } = {} + options: { onReconnect?: () => void; stableAfterMs?: number; pollWhileDownMs?: number } = {} ): () => void { const url = new URL('/api/watch', `${this.baseUrl}/`) const headers: Record = {} @@ -473,6 +482,35 @@ export class RemoteServerClient { let failedAttempts = 0 // How long a socket must stay up before it counts as a real session. const stableAfterMs = options.stableAfterMs ?? 15_000 + // Some hosts never let the socket through at all: a reverse proxy that + // does not forward the Upgrade handshake answers every attempt with a + // plain HTTP error, so the feed is not "briefly down", it is unavailable. + // Left alone, this app then shows a vault frozen at connect time, and a + // note another device renamed or trashed still lists under its old path + // until an operation on it comes back 404 (#734). While the socket is + // down, re-pull the vault on a timer instead; each tick is a gap the + // caller closes the same way it closes a reconnect. + const pollWhileDownMs = options.pollWhileDownMs ?? REMOTE_CHANGE_POLL_MS + let pollTimer: NodeJS.Timeout | null = null + let warnedAboutPolling = false + const startPolling = (): void => { + if (pollTimer || stopped) return + if (!warnedAboutPolling) { + warnedAboutPolling = true + console.warn( + `[remote] ${this.baseUrl}: the change feed at /api/watch is not staying connected (a proxy without WebSocket support?); refreshing every ${Math.round(pollWhileDownMs / 1000)}s instead` + ) + } + pollTimer = setInterval(() => { + if (!stopped) options.onReconnect?.() + }, pollWhileDownMs) + } + const stopPolling = (): void => { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } const connect = (): void => { if (stopped) return @@ -484,6 +522,7 @@ export class RemoteServerClient { // caller re-pulls everything instead of trusting the resumed feed. // Only the very first attempt connecting cleanly has no gap. const hadGap = failedAttempts > 0 + stopPolling() // The failure counter resets only after the socket has stayed up for // a while, not on the handshake: a peer that accepts the upgrade and // immediately drops it (a misconfigured proxy, a crash-looping @@ -527,6 +566,7 @@ export class RemoteServerClient { ws = null const delay = Math.min(30_000, 1_000 * 2 ** failedAttempts) failedAttempts += 1 + startPolling() reconnectTimer = setTimeout(connect, delay) }) } @@ -535,6 +575,7 @@ export class RemoteServerClient { return () => { stopped = true + stopPolling() if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null @@ -573,6 +614,17 @@ export class RemoteServerClient { } if (!response.ok) { const text = await response.text().catch(() => '') + // A 404 for a path this app asked to change means the list is behind + // the server, not that the server is broken: another device moved, + // renamed, or trashed the note and the change never arrived here + // (#734). Say which path is gone and have the host re-pull the list. + // Reads keep the plain answer: a 404 on `?path=` is how remote + // databases learn a file is absent (#556), and that is not staleness. + const stalePath = response.status === 404 ? requestedPath(init?.body) : null + if (stalePath !== null) { + this.onStalePath?.(stalePath) + throw new RemoteRequestError(stalePathMessage(stalePath), response.status) + } throw new RemoteRequestError( requestErrorMessage(this.baseUrl, path, response, text), response.status @@ -582,3 +634,10 @@ export class RemoteServerClient { return (await response.json()) as T } } + +/** The vault-relative path a JSON request body names, when it names one. */ +function requestedPath(body: unknown): string | null { + if (!body || typeof body !== 'object') return null + const path = (body as { path?: unknown }).path + return typeof path === 'string' && path.length > 0 ? path : null +} diff --git a/apps/server/package.json b/apps/server/package.json index ed94d4fd..0ee99611 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.44.0", + "version": "2.45.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 3ea6aa8d..8a975cac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.44.0", + "version": "2.45.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 58eda022..d6ff36f7 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -34,6 +34,7 @@ import type { WriteWorkflowInput } from '@zennotes/bridge-contract/workflows' import { prepareWorkflowRun } from '@shared/workflows/prepare-run' +import { REMOTE_CHANGE_POLL_MS, stalePathMessage } from '@shared/remote-workspace-messages' import type { AppUpdateState, AssetMeta, @@ -195,6 +196,15 @@ async function jsonRequest( ) } const text = await res.text().catch(() => '') + // A 404 for a path this app asked to change means the list is behind the + // server: another device moved, renamed, or trashed the note and the + // change feed did not carry it here (#734). Name the path and re-pull. + // Reads keep the plain answer; a 404 on `?path=` is a legitimate "absent". + const stalePath = res.status === 404 ? requestedPath(init?.body) : null + if (stalePath !== null) { + dispatchVaultChange(RESYNC_EVENT) + throw new HttpRequestError(res.status, path, stalePathMessage(stalePath), text) + } throw new HttpRequestError( res.status, path, @@ -214,6 +224,13 @@ function notImplemented(name: string): never { throw new Error(`zen.${name} is not available in the web build`) } +/** The vault-relative path a JSON request body names, when it names one. */ +function requestedPath(body: unknown): string | null { + if (!body || typeof body !== 'object') return null + const path = (body as { path?: unknown }).path + return typeof path === 'string' && path.length > 0 ? path : null +} + // -------------------------------------------------------------------- // Platform / system // -------------------------------------------------------------------- @@ -1032,6 +1049,38 @@ let watchReconnectTimer: number | null = null // connection opens, tell listeners to re-pull everything rather than // resuming the stream as if nothing happened. let watchHadGap = false +// Some hosts never let the socket through at all: a reverse proxy that does +// not forward the Upgrade handshake fails every attempt. Left alone, the page +// shows a vault frozen at load time and a note another device renamed or +// trashed keeps its old path until an operation on it comes back 404 (#734). +// While the socket is down, re-pull the vault on a timer instead. +let watchPollTimer: number | null = null +let warnedAboutPolling = false + +const RESYNC_EVENT: VaultChangeEvent = { kind: 'change', path: '', folder: 'inbox', scope: 'resync' } + +function dispatchVaultChange(ev: VaultChangeEvent): void { + for (const cb of vaultChangeListeners) cb(ev) +} + +function startWatchPolling(): void { + if (watchPollTimer !== null) return + if (!warnedAboutPolling) { + warnedAboutPolling = true + console.warn( + `[zen] the change feed at ${API_BASE}/watch is not staying connected (a proxy without WebSocket support?); refreshing every ${Math.round(REMOTE_CHANGE_POLL_MS / 1000)}s instead` + ) + } + watchPollTimer = window.setInterval(() => { + if (vaultChangeListeners.size > 0) dispatchVaultChange(RESYNC_EVENT) + }, REMOTE_CHANGE_POLL_MS) +} + +function stopWatchPolling(): void { + if (watchPollTimer === null) return + window.clearInterval(watchPollTimer) + watchPollTimer = null +} function ensureWatchSocket(): void { if (watchSocket && watchSocket.readyState <= 1) return @@ -1040,15 +1089,14 @@ function ensureWatchSocket(): void { const ws = new WebSocket(url) watchSocket = ws ws.addEventListener('open', () => { + stopWatchPolling() if (!watchHadGap) return watchHadGap = false - const resync: VaultChangeEvent = { kind: 'change', path: '', folder: 'inbox', scope: 'resync' } - for (const cb of vaultChangeListeners) cb(resync) + dispatchVaultChange(RESYNC_EVENT) }) ws.addEventListener('message', e => { try { - const ev = JSON.parse(String(e.data)) as VaultChangeEvent - for (const cb of vaultChangeListeners) cb(ev) + dispatchVaultChange(JSON.parse(String(e.data)) as VaultChangeEvent) } catch { // ignore malformed frames } @@ -1057,6 +1105,7 @@ function ensureWatchSocket(): void { watchSocket = null if (vaultChangeListeners.size > 0) { watchHadGap = true + startWatchPolling() if (watchReconnectTimer === null) { watchReconnectTimer = window.setTimeout(() => { watchReconnectTimer = null @@ -1075,9 +1124,12 @@ function onVaultChange(cb: VaultChangeListener): () => void { ensureWatchSocket() return () => { vaultChangeListeners.delete(cb) - if (vaultChangeListeners.size === 0 && watchSocket) { - watchSocket.close() - watchSocket = null + if (vaultChangeListeners.size === 0) { + stopWatchPolling() + if (watchSocket) { + watchSocket.close() + watchSocket = null + } } } } diff --git a/docs/releases/v2.45.0/RELEASE_NOTES.md b/docs/releases/v2.45.0/RELEASE_NOTES.md new file mode 100644 index 00000000..7fc9c1c7 --- /dev/null +++ b/docs/releases/v2.45.0/RELEASE_NOTES.md @@ -0,0 +1,23 @@ +ZenNotes 2.45.0: the arrow keys work in the [[ picker again, and a remote vault stays current behind any proxy + +> Two fixes so far. In the main editor, โ†‘/โ†“ closed the `[[`, `@` and `/` menus instead of moving through them (only the Ctrl chords worked), and a self-hosted vault reached through a proxy that does not pass WebSockets froze at connect time, so Move to Trash failed with "404 Not Found" for a note another device had already renamed. + +## ๐Ÿ› Fixes + +- **The arrow keys navigate the `[[`, `@` and `/` menus again.** (#707 by @ArditZubaku, closes #739 by @OmnivorousKumquat) In the main editor and the pinned reference pane, โ†“ and โ†‘ moved the caret while a completion menu was open, and the caret move closed the menu, so the only way to pick an item was Ctrl+N/Ctrl+P or Ctrl+J/Ctrl+K. Quick Capture and the template editor happened to work, which is why the bug looked random. The completion keymap was spread last into the editor's general keymap at default precedence, behind the default keymap's own ArrowUp/ArrowDown; it is now mounted at highest precedence next to the Ctrl navigation, the way the autocomplete package mounts its own keymap. Verified in the built app with Vim on and off: the arrows move the highlight, the menu stays open, Enter inserts, and Esc still closes the picker without leaving insert mode. Alongside it, both docs surfaces now call the thing "the [[ wikilink picker" and list the movement keys, and the picker's own footer leads with them (โ†‘/โ†“ or Ctrl+J/K to move ยท Enter inserts ยท Type | to change display text ยท /path/to/note for exact links), so the answer is on screen the moment it opens. + + How to test locally: `npm run build` then launch `apps/desktop/out/main/index.js` with `ZENNOTES_USER_DATA_PATH` and `ZENNOTES_CONFIG_DIR` on scratch folders, open a note, type `[[`, press โ†“. Before: the menu closes and the caret moves down a line. After: the highlight moves to the second note, the menu stays open, Enter inserts it. Same for `@` and `/`. + +- **A remote vault behind a proxy without WebSockets stays current, and a stale note says so.** (#734 by @mptpro, Linux desktop against a Docker server) Move to Trash on a remote vault failed from the desktop with "Remote server request failed (404 Not Found) for /api/notes/trash: not found", while the web and Android apps trashed fine against the same server. Every client sends the same request; the 404 is the server's "no such file", so the desktop was asking about a note that no longer lived at the path it showed. A remote vault hears about changes made elsewhere through one WebSocket, `/api/watch`; a reverse proxy or tunnel that does not pass the Upgrade handshake fails every attempt, and the desktop kept a list frozen at connect time. Two changes, mirrored in the desktop and web clients: while the change feed cannot stay connected, the app re-pulls the vault every 30 seconds (and warns once in the console); and a 404 for a path the app asked to change now names it, says it was moved, renamed, or deleted from another device, and refreshes the list, so the stale row is gone by the time the toast is read. A 404 on a read keeps its plain answer, which is how remote databases learn a file is absent. The self-hosting docs say what a proxy needs for instant updates. + + How to test locally: `cd apps/server && go build -o /tmp/zennotes-server ./cmd/zennotes-server`, start it with `ZENNOTES_BIND=127.0.0.1:7878 ZENNOTES_VAULT_PATH=/path/to/vault ZENNOTES_CONFIG_PATH=/tmp/zs.json ZENNOTES_AUTH_TOKEN=tok /tmp/zennotes-server`, and put a proxy in front that forwards HTTP but ends upgrades (a 12-line Node `http.createServer` with an `upgrade` handler that writes `HTTP/1.1 404` is enough). Connect the desktop to the proxy URL, then rename a note from outside: `curl -X POST -H 'Authorization: Bearer tok' -H 'Content-Type: application/json' -d '{"path":"inbox/Note.md","title":"Renamed"}' http://127.0.0.1:7878/api/notes/rename`. Before: the desktop keeps "Note" forever, and Move to Trash on it fails with the 404 toast and the row stays. After: trashing the old row right away says "The server has nothing at inbox/Note.md any more: it was moved, renamed, or deleted from another device. The list has been refreshed." and the sidebar shows "Renamed"; leave it alone instead and the rename appears on its own within 30 seconds. + +## ๐Ÿงฐ For contributors + +- `completionKeymapExtension` in `packages/app-core/src/lib/cm-completion-nav.ts` is the filtered completion keymap at `Prec.highest`; mount it next to `completionNavKeymap` instead of spreading `completionKeymapForEditor` into a `keymap.of([...])`. `cm-completion-nav-arrows.test.ts` fails on the old wiring. +- `packages/shared-domain/src/remote-workspace-messages.ts` holds `stalePathMessage(path)` and `REMOTE_CHANGE_POLL_MS`, shared by the desktop `RemoteServerClient` (new `onStalePath` option, `watchVaultChanges` gains `pollWhileDownMs`) and the web `http-bridge` (`RESYNC_EVENT`, `startWatchPolling`). Desktop tests cover the polling fallback, the stale 404 message, and the untouched read 404 (#556). +- The [[ picker footer text lives in `packages/app-core/src/styles/index.css` as a `::after` content string. + +--- + +Local-first and keyboard-first, as always. diff --git a/docs/releases/v2.45.0/twitter-post.md b/docs/releases/v2.45.0/twitter-post.md new file mode 100644 index 00000000..aa5aec9c --- /dev/null +++ b/docs/releases/v2.45.0/twitter-post.md @@ -0,0 +1,22 @@ +# Twitter/X thread for ZenNotes 2.45.0 + +## Tweet 1 + +ZenNotes 2.45.0 is out. Two fixes from the community this week. + +โŒจ๏ธ The arrow keys move through the [[ , @ and / menus again in the main editor. They used to close the menu and move the caret, so only Ctrl+N/P worked. Thanks @ArditZubaku for the fix and OmnivorousKumquat for the report that led to it. + +https://github.com/ZenNotes/zennotes/releases/tag/v2.45.0 + +## Tweet 2 + +๐ŸŒ Self-hosting behind a reverse proxy that does not pass WebSockets? The desktop used to freeze its note list at connect time, so a note renamed on your phone failed with "404 not found" when you trashed it on the laptop. It now refreshes every 30 seconds on its own, and a stale note says exactly what happened and refreshes the list. Thanks mptpro for the report. + +## Tweet 3 + +๐Ÿ“ Also new: the docs and the [[ picker itself now say how to move through it (โ†‘/โ†“, Ctrl+J/K, Enter, Tab, Esc). + +Free, open source, local-first Markdown notes. +https://zennotes.org + +Issues closed: #739, #734. Pull request merged: #707. diff --git a/package-lock.json b/package-lock.json index 39b8d375..cd85b377 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.44.0", + "version": "2.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.44.0", + "version": "2.45.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.44.0", + "version": "2.45.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,11 +874,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.44.0" + "version": "2.45.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.44.0", + "version": "2.45.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16286,7 +16286,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.44.0", + "version": "2.45.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16363,11 +16363,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.44.0" + "version": "2.45.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.44.0", + "version": "2.45.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16378,7 +16378,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.44.0" + "version": "2.45.0" } } } diff --git a/package.json b/package.json index 51585696..95f6d3c8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.44.0", + "version": "2.45.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 7ec7a49b..9ffe9115 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.44.0", + "version": "2.45.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 68feca79..5b3d6425 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -62,7 +62,7 @@ import { forwardOnCheckboxArrow } from '../lib/cm-forward-task' import { markerHopCommands } from '../lib/cm-marker-hop' import { isInMarkdownCode } from '../lib/cm-auto-pairs' import { toggleCheckbox } from '../lib/cm-toggle-checkbox' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { isVimAwaitingArgument } from '../lib/vim-nav' import { toCodeMirrorKey, vimHalfPageKeymap } from '../lib/vim-half-page-keymap' @@ -406,8 +406,7 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens indentWithTab, ...vimAwareDefaultKeymap(vimMode), ...historyKeymap, - ...vimAwareSearchKeymap(vimMode), - ...completionKeymapForEditor + ...vimAwareSearchKeymap(vimMode) ]) } @@ -1799,7 +1798,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // Don't install @codemirror/autocomplete's stock keymap โ€” it binds // mac-only `Alt-`` / `Alt-i` to completion and swallows the char // those combos type on AltGr-style layouts (#429). Our filtered - // `completionKeymapForEditor` (in buildEditorKeymap) covers the rest. + // `completionKeymapExtension` (mounted below) covers the rest. defaultKeymap: false, override: [ slashCommandSource, @@ -1824,6 +1823,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } }), completionNavKeymap, + completionKeymapExtension, editorKeymapCompartment.of(buildEditorKeymap(s0.vimMode, s0.keymapOverrides)), EditorView.domEventHandlers({ mousedown: (event, view) => { diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index c261e678..b3ace04f 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -55,7 +55,7 @@ import { } from '../lib/cm-wikilinks' import { hashtagSource } from '../lib/cm-hashtag-complete' import { frontmatterTagSource } from '../lib/cm-frontmatter-tag-complete' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { classifyLocalAssetHref, hrefFragment, type LocalAssetKind } from '../lib/local-assets' import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, PanelLeftIcon, PinIcon } from './icons' @@ -261,6 +261,7 @@ export function PinnedReferencePane(): JSX.Element | null { } }), completionNavKeymap, + completionKeymapExtension, keymap.of([ { key: 'Mod-f', @@ -274,8 +275,7 @@ export function PinnedReferencePane(): JSX.Element | null { indentWithTab, ...vimAwareDefaultKeymap(s0.vimMode), ...historyKeymap, - ...vimAwareSearchKeymap(s0.vimMode), - ...completionKeymapForEditor + ...vimAwareSearchKeymap(s0.vimMode) ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 9ea86bfc..27f285d9 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -60,7 +60,7 @@ import { closeCompletion, completionStatus } from '@codemirror/autocomplete' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { slashCommandRender, templateSlashCommandSource } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' import type { NoteMeta } from '@shared/ipc' @@ -499,6 +499,7 @@ export function QuickCaptureApp(): JSX.Element { : 'slash-cmd-option' }), completionNavKeymap, + completionKeymapExtension, // Esc closes an open slash menu instead of bubbling to the window-level // Esc that saves + hides the capture window. Runs before everything, // and only when a completion is actually open. @@ -516,7 +517,6 @@ export function QuickCaptureApp(): JSX.Element { ), keymap.of([ indentWithTab, - ...completionKeymapForEditor, ...vimAwareDefaultKeymap(prefs.vimMode), ...historyKeymap, ...vimAwareSearchKeymap(prefs.vimMode) diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index 2684ceb1..3fdd7144 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -29,7 +29,7 @@ import { editorTabSize } from '../lib/editor-tab-size' import { templateVariableSource, TEMPLATE_VARIABLES } from '../lib/cm-template-variables' import { templateSlashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -158,9 +158,9 @@ export function TemplateEditorModal({ : 'slash-cmd-option' }), completionNavKeymap, + completionKeymapExtension, keymap.of([ indentWithTab, - ...completionKeymapForEditor, ...vimAwareDefaultKeymap(vimModeRef.current), ...historyKeymap ]), diff --git a/packages/app-core/src/lib/cm-auto-pairs.test.ts b/packages/app-core/src/lib/cm-auto-pairs.test.ts index cbe354fa..2fc250c2 100644 --- a/packages/app-core/src/lib/cm-auto-pairs.test.ts +++ b/packages/app-core/src/lib/cm-auto-pairs.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { ensureSyntaxTree } from '@codemirror/language' import { EditorSelection, EditorState } from '@codemirror/state' import { EditorView, keymap } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' @@ -97,10 +98,15 @@ describe('autoPairBackspaceTransaction', () => { describe('isInMarkdownCode', () => { it('identifies fenced and inline code but not Markdown prose', () => { const doc = 'Prose\n\n```ts\nconst fenced = \n```\n\nInline `const inline = `' - const current = EditorState.create({ + const fresh = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage, addKeymap: false })] }) + // A new state parses only within a small time budget; a slow runner can + // hand `syntaxTree` a tree that stops before the fence. Finish the parse, + // then take the state that carries the finished tree. + ensureSyntaxTree(fresh, doc.length, 5_000) + const current = fresh.update({}).state expect(isInMarkdownCode(current, 2)).toBe(false) expect(isInMarkdownCode(current, doc.indexOf('const fenced') + 'const fenced = '.length)).toBe(true) diff --git a/packages/app-core/src/lib/cm-completion-nav-arrows.test.ts b/packages/app-core/src/lib/cm-completion-nav-arrows.test.ts new file mode 100644 index 00000000..27b75a19 --- /dev/null +++ b/packages/app-core/src/lib/cm-completion-nav-arrows.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom + +import { + autocompletion, + currentCompletions, + selectedCompletionIndex, + startCompletion, + type CompletionContext, + type CompletionResult +} from '@codemirror/autocomplete' +import { defaultKeymap } from '@codemirror/commands' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { completionKeymapExtension, completionNavKeymap } from './cm-completion-nav' + +/** Stands in for the `@` date/note sources: three options, no filtering. */ +function source(context: CompletionContext): CompletionResult | null { + const match = context.matchBefore(/@\w*/) + if (!match) return null + return { + from: match.from + 1, + options: [{ label: 'Today' }, { label: 'Tomorrow' }, { label: 'Yesterday' }], + filter: false + } +} + +function mount(): EditorView { + return new EditorView({ + state: EditorState.create({ + doc: 'line one\nline two\n@\nline four', + selection: { anchor: 19 }, + extensions: [ + autocompletion({ defaultKeymap: false, override: [source] }), + completionNavKeymap, + completionKeymapExtension, + keymap.of([...defaultKeymap]) + ] + }), + parent: document.body + }) +} + +function press(view: EditorView, key: string): void { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + ) +} + +/** + * Wait for the popup to open, then past `interactionDelay` (75ms, counted + * from the moment it opened), before which the popup ignores navigation. A + * fixed sleep from `startCompletion` is not enough on a slow runner: the + * popup can open late in that window and the first press lands inside the + * delay, which reads as "the arrow did nothing". + */ +async function settle(view: EditorView): Promise { + const deadline = Date.now() + 5_000 + while (currentCompletions(view.state).length === 0) { + if (Date.now() > deadline) throw new Error('completion never opened') + await new Promise((resolve) => setTimeout(resolve, 20)) + } + await new Promise((resolve) => setTimeout(resolve, 200)) +} + +describe('completion arrow navigation', () => { + it('moves the highlighted option instead of the caret', async () => { + const view = mount() + startCompletion(view) + await settle(view) + expect(currentCompletions(view.state).length).toBe(3) + expect(selectedCompletionIndex(view.state)).toBe(0) + const caret = view.state.selection.main.head + + press(view, 'ArrowDown') + expect(selectedCompletionIndex(view.state)).toBe(1) + press(view, 'ArrowDown') + expect(selectedCompletionIndex(view.state)).toBe(2) + press(view, 'ArrowUp') + expect(selectedCompletionIndex(view.state)).toBe(1) + + // Mounted below `defaultKeymap` instead, its ArrowUp/ArrowDown caret motions + // win and the caret move closes the menu โ€” the regression this guards. + expect(currentCompletions(view.state).length).toBe(3) + expect(view.state.selection.main.head).toBe(caret) + + view.destroy() + }) + + it('lets the arrows through when no completion is open', () => { + let reached = 0 + const view = new EditorView({ + state: EditorState.create({ + doc: 'line one', + extensions: [ + autocompletion({ defaultKeymap: false, override: [source] }), + completionNavKeymap, + completionKeymapExtension, + keymap.of([ + { + key: 'ArrowDown', + run: () => { + reached += 1 + return true + } + } + ]) + ] + }), + parent: document.body + }) + + press(view, 'ArrowDown') + expect(reached).toBe(1) + + view.destroy() + }) +}) diff --git a/packages/app-core/src/lib/cm-completion-nav.ts b/packages/app-core/src/lib/cm-completion-nav.ts index 853e48b9..b4f56625 100644 --- a/packages/app-core/src/lib/cm-completion-nav.ts +++ b/packages/app-core/src/lib/cm-completion-nav.ts @@ -6,7 +6,7 @@ import { selectedCompletion } from '@codemirror/autocomplete' import { Prec } from '@codemirror/state' -import { EditorView, type KeyBinding } from '@codemirror/view' +import { EditorView, keymap, type KeyBinding } from '@codemirror/view' /** * macOS AltGr-style keyboard layouts (custom Ukelele `.keylayout` files, a @@ -26,6 +26,16 @@ export const completionKeymapForEditor: readonly KeyBinding[] = completionKeymap (binding) => !(typeof binding.mac === 'string' && MAC_TEXT_ENTRY_CHORDS.has(binding.mac)) ) +/** + * Mount this instead of spreading the bindings into an editor's general + * `keymap.of([...])`, where they lose to anything listed earlier โ€” the arrows + * then move the caret, which closes the popup. `Prec.highest` is what + * `@codemirror/autocomplete` gives its own keymap. + */ +export const completionKeymapExtension = Prec.highest( + keymap.of([...completionKeymapForEditor]) +) + /** * Direction a Ctrl-based chord should move the autocomplete selection, * or `null` when the event isn't one of our nav chords. diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 451b1da5..31c5d7c5 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -174,7 +174,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Connect the desktop app to a self-hosted server', body: - 'Settings โ†’ Vault โ†’ Remote workspace takes the server URL and its token. **On macOS**, a server on your own network also needs the system Local Network permission: macOS asks the first time ZenNotes reaches a local address, and if you dismiss that prompt the connection fails with no packets sent and no further warning โ€” it looks exactly like a server that is down. Turn it back on under System Settings โ†’ Privacy & Security โ†’ Local Network. A server reached over the public internet is unaffected.' + 'Settings โ†’ Vault โ†’ Remote workspace takes the server URL and its token. **On macOS**, a server on your own network also needs the system Local Network permission: macOS asks the first time ZenNotes reaches a local address, and if you dismiss that prompt the connection fails with no packets sent and no further warning โ€” it looks exactly like a server that is down. Turn it back on under System Settings โ†’ Privacy & Security โ†’ Local Network. A server reached over the public internet is unaffected. Live updates travel over a WebSocket at /api/watch. If a reverse proxy in front of the server does not pass WebSocket upgrades, the app refreshes on its own every 30 seconds instead, so a note renamed or trashed on another device can show its old name for up to half a minute; passing the upgrade through gives instant updates.' }, { title: 'Customize the look: themes vs. overrides', @@ -374,6 +374,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'Typing `@` in normal text opens suggestions: the date shortcuts (Today, Yesterday, Tomorrow), the current time (Now โ€” type `@time` or `@now`), plus any notes matching what you type. Choosing a date inserts an ISO date like `2026-04-15`; choosing Now inserts the current time in your configured 12-hour or 24-hour format (Settings โ†’ Editor โ†’ Time format); choosing a note inserts a `[[wikilink]]`, so `@` is a quick alternative to `[[`. A bare `@` leads with the dates and Now โ€” start typing letters and matching notes appear.' }, + { + title: '[[ opens the wikilink picker', + body: + 'Type `[[` and the wikilink picker lists matching notes, images, PDFs, SVGs, and CSV databases; keep typing to narrow it. โ†‘/โ†“ or Ctrl+J/K (Ctrl+N/P) move through the suggestions, Enter inserts the link, Tab inserts it and keeps the caret inside the brackets so you can add a `#heading`, and Esc closes the picker. Type `|` after the target to set the display text, or `/path/to/note` for an exact link; picking a database drops a `[[Database]]` link that opens its grid.' + }, { title: 'Templates scaffold new notes', body: @@ -558,7 +563,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ id: 'palettes-and-pickers', title: 'Palettes and pickers', description: - 'These apply once a palette, search overlay, or picker already has focus โ€” the command palette, note search, vault text search, outline, buffer switcher, the [[ reference picker, the / slash menu, and the date and template pickers.', + 'These apply once a palette, search overlay, or picker already has focus โ€” the command palette, note search, vault text search, outline, buffer switcher, the [[ wikilink picker, the / slash menu, and the date and template pickers.', items: [ { keys: 'ArrowDown / Ctrl+N / Ctrl+J', action: 'Next result', detail: 'Move the selection down. Ctrl+J / Ctrl+K behave the same in every picker, so they no longer collide with the global Search-notes shortcut on Windows and Linux.' }, { keys: 'ArrowUp / Ctrl+P / Ctrl+K', action: 'Previous result', detail: 'Move the selection up.' }, diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index a372cbb9..972a8485 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -4573,7 +4573,7 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite } .cm-tooltip-autocomplete:has(.wikilink-cmd-option)::after { - content: "Type | to change display text ยท Use /path/to/note for exact links"; + content: "โ†‘/โ†“ or Ctrl+J/K to move ยท Enter inserts ยท Type | to change display text ยท /path/to/note for exact links"; display: block; padding: 10px 12px 8px; border-top: 1px solid rgb(var(--z-bg-3) / 0.7); diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index d864d682..bf11939c 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.44.0", + "version": "2.45.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index ee48b7a6..cd5961b3 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.44.0", + "version": "2.45.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/remote-workspace-messages.ts b/packages/shared-domain/src/remote-workspace-messages.ts new file mode 100644 index 00000000..7176c4c0 --- /dev/null +++ b/packages/shared-domain/src/remote-workspace-messages.ts @@ -0,0 +1,15 @@ +/** + * Wording shared by the desktop and web remote clients for the one failure + * a self-hosted vault produces on its own: the server answers 404 for a + * note the app still lists. That is not a broken server; it is a list that + * fell behind because another device moved, renamed, or trashed the note + * and the change feed did not reach this app (a reverse proxy without + * WebSocket support does exactly that, #734). Both clients refresh the list + * when they raise this, so the sentence can promise it. + */ +export function stalePathMessage(path: string): string { + return `The server has nothing at ${path} any more: it was moved, renamed, or deleted from another device. The list has been refreshed.` +} + +/** How often a remote client re-pulls the vault while its change feed is down. */ +export const REMOTE_CHANGE_POLL_MS = 30_000 diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index f6524ecd..a6122ee8 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.44.0", + "version": "2.45.0", "type": "module", "exports": { ".": "./src/index.ts"