diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 560dc0ff..120038a1 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
- "version": "2.76.0",
+ "version": "2.77.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts
index 46d99b40..c20c78e4 100644
--- a/apps/desktop/src/main/app-config.ts
+++ b/apps/desktop/src/main/app-config.ts
@@ -96,6 +96,11 @@ const SCALAR_FIELDS: Partial> = {
tomlKey: 'fzf_path',
comment: 'absolute path to fzf; empty = look on $PATH'
},
+ searchTaggedBlocks: {
+ section: 'search',
+ tomlKey: 'tagged_blocks',
+ comment: 'with a #tag in Search notes, list the tagged blocks under each note'
+ },
// editor
livePreview: {
section: 'editor',
@@ -179,6 +184,11 @@ const SCALAR_FIELDS: Partial> = {
tomlKey: 'list_indent_guides',
comment: 'vertical guide lines at each nested list level'
},
+ linkPopoverOnHover: {
+ section: 'editor',
+ tomlKey: 'link_popover_on_hover',
+ comment: 'resting the pointer on a [[wikilink]] opens the link popover'
+ },
editorScrollOff: {
section: 'editor',
tomlKey: 'scroll_off',
diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts
index cb636443..6aa98e2e 100644
--- a/apps/desktop/src/main/vault.test.ts
+++ b/apps/desktop/src/main/vault.test.ts
@@ -1010,6 +1010,22 @@ describe('listNotes metadata parsing', () => {
expect(note?.tags).toEqual(['realtag'])
})
+ it('does not index #tags or [[links]] inside a fence in a CRLF note', async () => {
+ const root = await makeTempDir('zennotes-meta-fence-crlf-')
+ await ensureVaultLayout(root)
+ const rel = 'inbox/windows.md'
+ await writeFile(
+ path.join(root, rel),
+ '# Notes\r\n\r\n```c\r\n#include \r\n// [[Secret Note]]\r\n```\r\n\r\n#realtag [[Real Link]]\r\n',
+ 'utf8'
+ )
+
+ const notes = await listNotes(root)
+ const note = notes.find((n) => n.path === rel)
+ expect(note?.tags).toEqual(['realtag'])
+ expect(note?.wikilinks).toEqual(['Real Link'])
+ })
+
it('indexes frontmatter tags as first-class note tags', async () => {
const root = await makeTempDir('zennotes-meta-frontmatter-tags-')
await ensureVaultLayout(root)
diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts
index a1b403d4..e89a34c7 100644
--- a/apps/desktop/src/main/vault.ts
+++ b/apps/desktop/src/main/vault.ts
@@ -1979,8 +1979,9 @@ async function folderOf(root: string, absPath: string): Promise {
})
})
+describe('list_notes metadata skips fenced code', () => {
+ it('does not index #tags or [[links]] inside a fence in a CRLF note', async () => {
+ await writeFile(
+ path.join(root, 'inbox', 'Windows.md'),
+ '# Notes\r\n\r\n```c\r\n#include \r\n// [[Secret Note]]\r\n```\r\n\r\n#realtag [[Real Link]]\r\n'
+ )
+
+ const notes = await listNotes(root)
+ const note = notes.find((n) => n.path === 'inbox/Windows.md')
+ expect(note?.tags).toEqual(['realtag'])
+ expect(note?.wikilinks).toEqual(['Real Link'])
+ })
+})
+
describe('remapped system folders (#398)', () => {
it('classifies a remapped trash directory as trash and keeps its tasks out', async () => {
await mkdir(path.join(root, '.zennotes'), { recursive: true })
diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts
index 59a66085..07473f4d 100644
--- a/apps/desktop/src/mcp/vault-ops.ts
+++ b/apps/desktop/src/mcp/vault-ops.ts
@@ -658,7 +658,7 @@ async function folderOf(root: string, abs: string): Promise {
* never read code as content. Line-based and indentation-tolerant: a fence
* nested under a list item is still a code block (#293). Mirrors
* `stripCodeContent` in apps/desktop/src/main/vault.ts,
- * packages/app-core/src/lib/{tags,wikilinks}.ts, and
+ * packages/shared-domain/src/tag-scan.ts, packages/app-core/src/lib/wikilinks.ts, and
* internal/vault/parse.go in ZenNotes/znserver — keep all five in sync.
*/
function stripCodeContent(body: string): string {
@@ -669,7 +669,8 @@ function stripCodeContent(body: string): string {
let fenceLen = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i] as string
- const m = /^[ \t]*(`{3,}|~{3,})(.*)$/.exec(line)
+ // `.` never matches `\r`, so the `\r?` is what lets a CRLF-saved fence line match.
+ const m = /^[ \t]*(`{3,}|~{3,})(.*)\r?$/.exec(line)
if (m) {
const marker = m[1] as string
const char = marker[0] as string
diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json
index 24ae2d8e..182f2dd7 100644
--- a/apps/quicklook/package.json
+++ b/apps/quicklook/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/quicklook",
"private": true,
- "version": "2.76.0",
+ "version": "2.77.0",
"type": "module",
"description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it",
"homepage": "https://zennotes.org",
diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json
index 94297305..ab493ac0 100644
--- a/apps/share-viewer/package.json
+++ b/apps/share-viewer/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/share-viewer",
"private": true,
- "version": "2.76.0",
+ "version": "2.77.0",
"type": "module",
"description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website",
"homepage": "https://zennotes.org",
diff --git a/apps/web/package.json b/apps/web/package.json
index 9ee0f6b8..c0789489 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
- "version": "2.76.0",
+ "version": "2.77.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
diff --git a/package-lock.json b/package-lock.json
index 84d3910f..1dc724e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "zennotes-monorepo",
- "version": "2.76.0",
+ "version": "2.77.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zennotes-monorepo",
- "version": "2.76.0",
+ "version": "2.77.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -23,7 +23,7 @@
},
"apps/desktop": {
"name": "@zennotes/desktop",
- "version": "2.76.0",
+ "version": "2.77.0",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
@@ -915,7 +915,7 @@
},
"apps/quicklook": {
"name": "@zennotes/quicklook",
- "version": "2.76.0",
+ "version": "2.77.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -989,7 +989,7 @@
},
"apps/share-viewer": {
"name": "@zennotes/share-viewer",
- "version": "2.76.0",
+ "version": "2.77.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -1061,7 +1061,7 @@
},
"apps/web": {
"name": "@zennotes/web",
- "version": "2.76.0",
+ "version": "2.77.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16409,7 +16409,7 @@
},
"packages/app-core": {
"name": "@zennotes/app-core",
- "version": "2.76.0",
+ "version": "2.77.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16498,14 +16498,14 @@
},
"packages/bridge-contract": {
"name": "@zennotes/bridge-contract",
- "version": "2.76.0",
+ "version": "2.77.0",
"devDependencies": {
"typescript": "^5.7.2"
}
},
"packages/shared-domain": {
"name": "@zennotes/shared-domain",
- "version": "2.76.0",
+ "version": "2.77.0",
"dependencies": {
"@scure/bip39": "2.4.0",
"@zennotes/bridge-contract": "*",
@@ -16519,7 +16519,7 @@
},
"packages/shared-ui": {
"name": "@zennotes/shared-ui",
- "version": "2.76.0"
+ "version": "2.77.0"
}
}
}
diff --git a/package.json b/package.json
index 2fd6ffa7..53204304 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
- "version": "2.76.0",
+ "version": "2.77.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 2f2aa29b..c55eb663 100644
--- a/packages/app-core/package.json
+++ b/packages/app-core/package.json
@@ -1,11 +1,12 @@
{
"name": "@zennotes/app-core",
"private": true,
- "version": "2.76.0",
+ "version": "2.77.0",
"type": "module",
"exports": {
"./main": "./src/main.tsx",
"./navigation": "./src/navigation.ts",
+ "./calendar": "./src/calendar.tsx",
"./notes": "./src/notes.ts",
"./shell": "./src/shell.ts",
"./browse": "./src/browse.ts",
diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx
index 899a8d48..eb309a44 100644
--- a/packages/app-core/src/App.tsx
+++ b/packages/app-core/src/App.tsx
@@ -174,6 +174,10 @@ const Editor = lazy(async () => {
return { default: module.Editor }
})
+const LinkPopover = lazy(async () => {
+ const module = await import('./components/LinkPopover')
+ return { default: module.LinkPopover }
+})
const PinnedReferencePane = lazy(async () => {
const module = await import('./components/PinnedReferencePane')
return { default: module.PinnedReferencePane }
@@ -370,6 +374,7 @@ function App(): JSX.Element {
const init = useStore((s) => s.init)
const workspaceRestored = useStore((s) => s.workspaceRestored && !s.workspaceTransitioning)
const searchOpen = useStore((s) => s.searchOpen)
+ const linkPopoverOpen = useStore((s) => s.linkPopover !== null)
const setSearchOpen = useStore((s) => s.setSearchOpen)
const vaultTextSearchOpen = useStore((s) => s.vaultTextSearchOpen)
const setVaultTextSearchOpen = useStore((s) => s.setVaultTextSearchOpen)
@@ -1340,6 +1345,11 @@ function App(): JSX.Element {
)}
+ {linkPopoverOpen && (
+
+
+
+ )}
diff --git a/packages/app-core/src/calendar.tsx b/packages/app-core/src/calendar.tsx
new file mode 100644
index 00000000..8b1d90a2
--- /dev/null
+++ b/packages/app-core/src/calendar.tsx
@@ -0,0 +1,15 @@
+import { CalendarPanel } from './components/CalendarPanel'
+import { useStore } from './store'
+
+/**
+ * The date calendar as a surface of its own, for hosts that open it from a
+ * main screen instead of beside a note (the phone shells' Calendar entry),
+ * so it works with no note open. It orients on the open note when that is a
+ * daily or weekly note, else on today. `onOpen` runs once it has opened a
+ * day, a week or a task's line; a host that shows it as a sheet closes it
+ * there.
+ */
+export function DateCalendar({ onOpen }: { onOpen?: () => void }): JSX.Element {
+ const note = useStore((s) => s.activeNote)
+ return
+}
diff --git a/packages/app-core/src/components/CalendarPanel.test.ts b/packages/app-core/src/components/CalendarPanel.test.ts
new file mode 100644
index 00000000..3e590fed
--- /dev/null
+++ b/packages/app-core/src/components/CalendarPanel.test.ts
@@ -0,0 +1,140 @@
+// @vitest-environment jsdom
+
+import { act, createElement } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { NoteMeta } from '@shared/ipc'
+import { useStore } from '../store'
+import { dailyNoteLocationForDate } from '../lib/vault-layout'
+import { CalendarPanel } from './CalendarPanel'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const confirmApp = vi.hoisted(() => vi.fn(async () => true))
+vi.mock('../lib/confirm-requests', () => ({ confirmApp }))
+
+function isoDate(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+function dailyNote(date: Date): NoteMeta {
+ const { title, subpath } = dailyNoteLocationForDate(date, useStore.getState().vaultSettings)
+ return {
+ path: `inbox/${subpath ? `${subpath}/` : ''}${title}.md`,
+ title,
+ folder: 'inbox',
+ siblingOrder: 0,
+ createdAt: 0,
+ updatedAt: 0,
+ size: 0,
+ tags: [],
+ wikilinks: [],
+ hasAttachments: false,
+ assetEmbeds: [],
+ excerpt: ''
+ }
+}
+
+/** A press then its click, the order a browser fires them in. */
+function tap(el: Element, pointerType: 'touch' | 'mouse'): void {
+ const down = new Event('pointerdown', { bubbles: true })
+ Object.defineProperty(down, 'pointerType', { value: pointerType })
+ act(() => {
+ el.dispatchEvent(down)
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+}
+
+describe('CalendarPanel: a day on a touch screen', () => {
+ let host: HTMLDivElement
+ let root: Root
+ let originalState: ReturnType
+ const openDailyNoteForDate = vi.fn(async (_date: Date) => {})
+ const today = new Date()
+ const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1)
+
+ beforeEach(() => {
+ originalState = useStore.getState()
+ Object.defineProperty(Element.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: vi.fn()
+ })
+ const settings = originalState.vaultSettings
+ useStore.setState({
+ vaultSettings: { ...settings, dailyNotes: { ...settings.dailyNotes, enabled: true } },
+ notes: [],
+ vaultTasks: [],
+ tasksLoading: true,
+ openDailyNoteForDate
+ })
+ confirmApp.mockClear()
+ openDailyNoteForDate.mockClear()
+ host = document.createElement('div')
+ document.body.append(host)
+ root = createRoot(host)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ host.remove()
+ useStore.setState(originalState, true)
+ })
+
+ function day(date: Date): Element {
+ const cell = host.querySelector(`[data-cal-day="${isoDate(date)}"]`)
+ if (!cell) throw new Error(`no cell for ${isoDate(date)}`)
+ return cell
+ }
+
+ it('stands on its own with no note, oriented on today', () => {
+ act(() => root.render(createElement(CalendarPanel, { note: null })))
+ expect(day(today).className).toContain('ring-accent/60')
+ })
+
+ it('selects with the first tap and offers to create the day with a second', async () => {
+ act(() => root.render(createElement(CalendarPanel, { note: null })))
+ tap(day(tomorrow), 'touch')
+ expect(day(tomorrow).className).toContain('ring-accent/60')
+ expect(confirmApp).not.toHaveBeenCalled()
+
+ tap(day(tomorrow), 'touch')
+ await act(async () => {})
+ expect(confirmApp).toHaveBeenCalledWith(expect.objectContaining({ title: 'New daily note' }))
+ expect(openDailyNoteForDate).toHaveBeenCalledTimes(1)
+ expect(isoDate(openDailyNoteForDate.mock.calls[0][0])).toBe(isoDate(tomorrow))
+ })
+
+ it("opens an existing day's note on the tap that lands on the selected day", async () => {
+ useStore.setState({ notes: [dailyNote(today)] })
+ const onOpen = vi.fn()
+ act(() => root.render(createElement(CalendarPanel, { note: null, onOpen })))
+ tap(day(today), 'touch')
+ await act(async () => {})
+ expect(confirmApp).not.toHaveBeenCalled()
+ expect(openDailyNoteForDate).toHaveBeenCalledTimes(1)
+ expect(onOpen).toHaveBeenCalledTimes(1)
+ })
+
+ it('stays open when the new day is declined', async () => {
+ confirmApp.mockResolvedValueOnce(false)
+ const onOpen = vi.fn()
+ act(() => root.render(createElement(CalendarPanel, { note: null, onOpen })))
+ tap(day(today), 'touch')
+ await act(async () => {})
+ expect(confirmApp).toHaveBeenCalledTimes(1)
+ expect(openDailyNoteForDate).not.toHaveBeenCalled()
+ expect(onOpen).not.toHaveBeenCalled()
+ })
+
+ it('keeps a mouse click to select only', async () => {
+ useStore.setState({ notes: [dailyNote(today)] })
+ act(() => root.render(createElement(CalendarPanel, { note: null })))
+ tap(day(today), 'mouse')
+ tap(day(tomorrow), 'mouse')
+ tap(day(tomorrow), 'mouse')
+ await act(async () => {})
+ expect(day(tomorrow).className).toContain('ring-accent/60')
+ expect(confirmApp).not.toHaveBeenCalled()
+ expect(openDailyNoteForDate).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx
index 4817952e..bdc1a7f8 100644
--- a/packages/app-core/src/components/CalendarPanel.tsx
+++ b/packages/app-core/src/components/CalendarPanel.tsx
@@ -114,25 +114,56 @@ function dotsFor(stats: NoteStats | undefined): { count: number; faint: boolean
export function CalendarPanel({
note,
- fitWidth
+ fitWidth,
+ onOpen
}: {
- note: NoteContent
+ /** The note beside the calendar, or null where the calendar stands on its
+ * own with no note open (the phones' Calendar entry); it then orients on
+ * today. */
+ note: NoteContent | null
/** Width to render at when the pane has less room than the width the user
* chose; see lib/side-panel-fit. (#805) */
fitWidth?: number
+ /** Called once the calendar has opened a day, a week or a task's line, even
+ * a note that was already open, so a host that shows it as a sheet can put
+ * it away. Beside a note it stays open, so the pane passes none. */
+ onOpen?: () => void
}): JSX.Element {
const notes = useStore((s) => s.notes)
const vaultSettings = useStore((s) => s.vaultSettings)
const vimMode = useStore((s) => s.vimMode)
- const openDailyNoteForDate = useStore((s) => s.openDailyNoteForDate)
- const openWeeklyNoteForDate = useStore((s) => s.openWeeklyNoteForDate)
+ const storeOpenDailyNote = useStore((s) => s.openDailyNoteForDate)
+ const storeOpenWeeklyNote = useStore((s) => s.openWeeklyNoteForDate)
const vaultTasks = useStore((s) => s.vaultTasks)
const showArchivedTasks = useStore((s) => s.showArchivedTasks)
const tasksLoading = useStore((s) => s.tasksLoading)
const refreshTasks = useStore((s) => s.refreshTasks)
const addTaskForDate = useStore((s) => s.addTaskForDate)
const toggleTaskFromList = useStore((s) => s.toggleTaskFromList)
- const openTaskAt = useStore((s) => s.openTaskAt)
+ const storeOpenTask = useStore((s) => s.openTaskAt)
+ const onOpenRef = useRef(onOpen)
+ onOpenRef.current = onOpen
+ const openDailyNoteForDate = useCallback(
+ async (date: Date) => {
+ await storeOpenDailyNote(date)
+ onOpenRef.current?.()
+ },
+ [storeOpenDailyNote]
+ )
+ const openWeeklyNoteForDate = useCallback(
+ async (date: Date) => {
+ await storeOpenWeeklyNote(date)
+ onOpenRef.current?.()
+ },
+ [storeOpenWeeklyNote]
+ )
+ const openTaskAt = useCallback(
+ async (task: VaultTask) => {
+ await storeOpenTask(task)
+ onOpenRef.current?.()
+ },
+ [storeOpenTask]
+ )
const applyTaskMutation = useStore((s) => s.applyTaskMutation)
const moveTaskToDate = useStore((s) => s.moveTaskToDate)
const deleteTaskFromList = useStore((s) => s.deleteTaskFromList)
@@ -153,7 +184,10 @@ export function CalendarPanel({
)
// The date the active note represents — what the calendar orients around.
- const active = useMemo(() => classifyDateNote(note, vaultSettings), [note, vaultSettings])
+ const active = useMemo(
+ () => (note ? classifyDateNote(note, vaultSettings) : null),
+ [note, vaultSettings]
+ )
const refDate = active?.date ?? new Date()
const activeDayIso = active?.kind === 'daily' ? isoDateStr(active.date) : null
const activeWeekIso = active?.kind === 'weekly' ? isoWeekStr(active.date) : null
@@ -172,7 +206,7 @@ export function CalendarPanel({
useEffect(() => {
setAnchor(new Date(refDate.getFullYear(), refDate.getMonth(), 1))
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [note.path])
+ }, [note?.path])
// Calendar key -> NoteMeta for the daily/weekly notes that exist on disk.
const { dailyByDate, weeklyByWeek } = useMemo(
@@ -187,8 +221,13 @@ export function CalendarPanel({
useEffect(() => {
setSelectedIso(activeDayIso ?? todayIso)
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [note.path])
+ }, [note?.path])
const selectedDate = useMemo(() => parseIsoDate(selectedIso), [selectedIso])
+ // A finger has no double-click, so on a touch screen a second tap on the
+ // selected day opens it, the way a phone's own calendar opens the day it
+ // shows. The first tap still only selects, which keeps that week's tasks
+ // below a tap away; a mouse keeps click to select, double-click to open.
+ const touchPressRef = useRef(false)
const selectedWeekIso = useMemo(() => isoWeekStr(selectedDate), [selectedDate])
// Tasks scheduled for the selected day. Tasks written in a daily note inherit
@@ -1020,7 +1059,16 @@ export function CalendarPanel({
key={iso}
type="button"
data-cal-day={iso}
- onClick={() => dailyEnabled && setSelectedIso(iso)}
+ onPointerDown={(e) => {
+ touchPressRef.current = e.pointerType === 'touch'
+ }}
+ onClick={() => {
+ const touch = touchPressRef.current
+ touchPressRef.current = false
+ if (!dailyEnabled) return
+ if (touch && iso === selectedIso) void openOrCreateDay(day, iso)
+ else setSelectedIso(iso)
+ }}
onDoubleClick={() => void openOrCreateDay(day, iso)}
onContextMenu={(e) => openDayMenu(e, day, iso)}
onMouseEnter={(e) => armHover(e.currentTarget, dayMeta)}
diff --git a/packages/app-core/src/components/CloudPendingConflictResolver.test.ts b/packages/app-core/src/components/CloudPendingConflictResolver.test.ts
index 087e35fb..5425a82b 100644
--- a/packages/app-core/src/components/CloudPendingConflictResolver.test.ts
+++ b/packages/app-core/src/components/CloudPendingConflictResolver.test.ts
@@ -8,7 +8,10 @@ import type {
CloudSyncPendingConflictDetails,
CloudSyncRunSummary,
} from "@zennotes/bridge-contract/cloud-sync";
-import { CloudPendingConflictResolver } from "./CloudPendingConflictResolver";
+import {
+ CLOUD_CONFLICT_LOAD_TIMEOUT_MS,
+ CloudPendingConflictResolver,
+} from "./CloudPendingConflictResolver";
import {
clearCloudSyncStatus,
syncCloudVaultWithStatus,
@@ -462,6 +465,91 @@ describe("CloudPendingConflictResolver", () => {
view.unmount();
});
+ // Discord, 2026-10-26: on a phone whose sync was stuck, the sheet showed
+ // "Loading the versions kept for you…" with Finish later greyed out, and
+ // closing the app was the only way out.
+ it("ends a load that never answers in an error with Retry and Details", async () => {
+ vi.useFakeTimers();
+ try {
+ bridge.getCloudConflict.mockReturnValueOnce(new Promise(() => {}));
+ useCloudSyncStatusStore.setState({ phase: "syncing" });
+ const view = mount({});
+ await act(async () => Promise.resolve());
+ expect(view.host.textContent).toContain("Loading the versions kept for you");
+ expect(button(view.host, "Finish later").disabled).toBe(false);
+
+ await act(async () => {
+ vi.advanceTimersByTime(CLOUD_CONFLICT_LOAD_TIMEOUT_MS);
+ });
+ const alert = view.host.querySelector('[role="alert"]');
+ expect(alert?.textContent).toContain("taking too long to open the two versions");
+ expect(alert?.querySelector("[data-cloud-error-details]")?.textContent).toContain(
+ "Loading conflict item-1 (Plans/Trip.md) did not finish within 20 seconds.",
+ );
+ expect(view.host.textContent).not.toContain("Loading the versions kept for you");
+
+ await act(async () => button(view.host, "Retry").click());
+ expect(bridge.getCloudConflict).toHaveBeenCalledTimes(2);
+ expect(view.host.querySelector('[role="alert"]')).toBeNull();
+ expect(view.host.textContent).toContain("Combined note");
+ view.unmount();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("shows a load that fails with Retry instead of loading forever", async () => {
+ bridge.getCloudConflict.mockRejectedValueOnce(new Error("Disk is full"));
+ const view = mount({});
+ await act(async () => Promise.resolve());
+
+ expect(view.host.querySelector('[role="alert"]')?.textContent).toContain("Disk is full");
+ expect(view.host.textContent).not.toContain("Loading the versions kept for you");
+
+ await act(async () => button(view.host, "Retry").click());
+ expect(bridge.getCloudConflict).toHaveBeenCalledTimes(2);
+ expect(view.host.querySelector('[role="alert"]')).toBeNull();
+ expect(view.host.textContent).toContain("Combined note");
+ view.unmount();
+ });
+
+ it("shows both versions when a slow load answers after the deadline", async () => {
+ vi.useFakeTimers();
+ try {
+ let answer!: (value: CloudSyncPendingConflictDetails) => void;
+ bridge.getCloudConflict.mockReturnValueOnce(
+ new Promise((resolve) => { answer = resolve; }),
+ );
+ const view = mount({});
+ await act(async () => {
+ vi.advanceTimersByTime(CLOUD_CONFLICT_LOAD_TIMEOUT_MS);
+ });
+ expect(view.host.querySelector('[role="alert"]')).not.toBeNull();
+
+ await act(async () => answer(details));
+ expect(view.host.querySelector('[role="alert"]')).toBeNull();
+ expect(view.host.textContent).toContain("Combined note");
+ expect(bridge.getCloudConflict).toHaveBeenCalledOnce();
+ view.unmount();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("can be left with Finish later while a sync is running", async () => {
+ useCloudSyncStatusStore.setState({ phase: "syncing" });
+ const onClose = vi.fn();
+ const view = mount({ onClose });
+ await act(async () => Promise.resolve());
+ expect(button(view.host, "Use other device").disabled).toBe(true);
+
+ await act(async () => button(view.host, "Finish later").click());
+ await act(async () => button(view.host, "Save & finish later").click());
+ expect(onClose).toHaveBeenCalledOnce();
+ expect(bridge.saveCloudConflictDraft).not.toHaveBeenCalled();
+ view.unmount();
+ });
+
it("explains a delete conflict as a human choice", async () => {
bridge.getCloudConflict.mockResolvedValue({
...details,
diff --git a/packages/app-core/src/components/CloudPendingConflictResolver.tsx b/packages/app-core/src/components/CloudPendingConflictResolver.tsx
index 48330482..115cada8 100644
--- a/packages/app-core/src/components/CloudPendingConflictResolver.tsx
+++ b/packages/app-core/src/components/CloudPendingConflictResolver.tsx
@@ -21,6 +21,11 @@ import { randomUUID } from "@zennotes/shared-domain/random-uuid";
type ChangeChoice = "local" | "cloud" | "both";
type WholeVersionChoice = "local" | "cloud";
+/** Both versions are one local file and at most one small download. A load
+ * still going after this is waiting on something else: the desktop, for
+ * one, still lets a running sync finish first. */
+export const CLOUD_CONFLICT_LOAD_TIMEOUT_MS = 20_000;
+
export function CloudPendingConflictResolver({
conflict,
summary,
@@ -73,6 +78,7 @@ export function CloudPendingConflictResolver({
useEffect(() => {
let cancelled = false;
+ let overdue = false;
setDetails(null);
setChoices({});
if (!reload.keepError) setError(null);
@@ -81,10 +87,21 @@ export function CloudPendingConflictResolver({
setFinishLaterOpen(false);
setCombineOpen(false);
setWholeVersionChoice(null);
+ // A load that never answered kept this sheet on its loading line, and the
+ // only way out was closing the app. Past the deadline it says so and
+ // offers Retry; an answer that still arrives replaces the error.
+ const deadline = window.setTimeout(() => {
+ if (cancelled) return;
+ overdue = true;
+ setReloading(false);
+ setError(loadTooSlow(conflict));
+ }, CLOUD_CONFLICT_LOAD_TIMEOUT_MS);
void bridge
.getCloudConflict(conflict.id, reviewId.current)
.then((next) => {
if (cancelled) return;
+ window.clearTimeout(deadline);
+ if (overdue) setError(null);
setReloading(false);
const initialDraft =
next.draft_text ??
@@ -104,11 +121,13 @@ export function CloudPendingConflictResolver({
})
.catch((cause) => {
if (cancelled) return;
+ window.clearTimeout(deadline);
setReloading(false);
setError(message(cause));
});
return () => {
cancelled = true;
+ window.clearTimeout(deadline);
};
}, [bridge, conflict.id, reload]);
@@ -268,10 +287,12 @@ export function CloudPendingConflictResolver({
{conflictExplanation(conflict)}
+ {/* Not held by a running sync: a draft is saved before every run and
+ cannot change during one, so leaving never waits on that run. */}