From 60905b68dd3f0a144ad4f7cbf6f8649b1e1024ae Mon Sep 17 00:00:00 2001 From: ShiroKSH Date: Fri, 10 Jul 2026 00:37:53 +0300 Subject: [PATCH 1/3] fix: harden editor and sync edge cases --- CHANGELOG.md | 1 + apps/desktop/src/lib/localStoragePersist.ts | 7 +++- apps/desktop/src/store/persistence.ts | 4 +-- packages/cli/package.json | 1 - packages/editor/src/ImageMarkdown.test.ts | 12 +++++++ packages/editor/src/Link.test.ts | 24 +++++++++++++ packages/editor/src/Link.ts | 35 ++++++++++++------- packages/editor/src/List.ts | 2 +- packages/editor/src/markdownToProsemirror.ts | 2 +- packages/sync/src/sync.ts | 6 ++++ packages/ui/src/editor/EditorView.tsx | 1 + .../ui/src/editor/MarkdownSourceEditor.tsx | 1 + packages/ui/src/editor/taskListSplit.test.ts | 21 +++++++++++ 13 files changed, 98 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daaf8125..a6770ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Fixed - Copying linked rich text now preserves hyperlinks when pasted into other rich text editors. Thanks [@snvtac](https://github.com/snvtac)! [#149](https://github.com/bholmesdev/hubble.md/issues/149) +- Cloud Sync no longer records failed asset transfers as successfully synced files. ## [0.1.18] - 2026-07-07 diff --git a/apps/desktop/src/lib/localStoragePersist.ts b/apps/desktop/src/lib/localStoragePersist.ts index a67e3dfe..cd4bdfe9 100644 --- a/apps/desktop/src/lib/localStoragePersist.ts +++ b/apps/desktop/src/lib/localStoragePersist.ts @@ -14,7 +14,12 @@ export function localStoragePersist( const nextState = typeof setter === "function" ? setter(current) : setter; const toStore = serialize ? serialize(nextState) : nextState; - localStorage.setItem(key, JSON.stringify(toStore)); + try { + localStorage.setItem(key, JSON.stringify(toStore)); + } catch { + // Persistence is best-effort; a storage quota/security error + // must not prevent the in-memory state update. + } return nextState; }); }, diff --git a/apps/desktop/src/store/persistence.ts b/apps/desktop/src/store/persistence.ts index ab2149c4..069d941b 100644 --- a/apps/desktop/src/store/persistence.ts +++ b/apps/desktop/src/store/persistence.ts @@ -54,10 +54,10 @@ export const STORAGE_KEY = "hubble-desktop-app"; function readStorage(key: string): T | null { if (typeof localStorage === "undefined") return null; - const raw = localStorage.getItem(key); - if (!raw) return null; try { + const raw = localStorage.getItem(key); + if (!raw) return null; return JSON.parse(raw) as T; } catch { return null; diff --git a/packages/cli/package.json b/packages/cli/package.json index 51fa5305..7bd64e40 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,6 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "postbuild": "chmod +x dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/packages/editor/src/ImageMarkdown.test.ts b/packages/editor/src/ImageMarkdown.test.ts index af7c064f..e5bdd23c 100644 --- a/packages/editor/src/ImageMarkdown.test.ts +++ b/packages/editor/src/ImageMarkdown.test.ts @@ -34,4 +34,16 @@ describe("image markdown conversion", () => { const doc = markdownToTiptapDoc("before\n\n![]()\n\nafter"); expect(doc.content?.some((node) => node.type === "image")).toBe(false); }); + + it("keeps text that follows an inline image", () => { + const doc = markdownToTiptapDoc("![diagram](example.png) trailing"); + + expect(doc.content?.[0]).toMatchObject({ + type: "paragraph", + content: [ + { type: "text", text: "diagram" }, + { type: "text", text: " trailing" }, + ], + }); + }); }); diff --git a/packages/editor/src/Link.test.ts b/packages/editor/src/Link.test.ts index 2dedc647..10f4fc25 100644 --- a/packages/editor/src/Link.test.ts +++ b/packages/editor/src/Link.test.ts @@ -46,4 +46,28 @@ describe("getActiveLinkRange", () => { target: null, }); }); + + it("does not merge adjacent links with different attributes", () => { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("a", [ + schema.marks.link.create({ href: "https://a.example" }), + ]), + schema.text("b", [ + schema.marks.link.create({ href: "https://b.example" }), + ]), + ]), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, 1), + }); + + expect(getActiveLinkRange(state)).toMatchObject({ + from: 1, + to: 2, + href: "https://a.example", + }); + }); }); diff --git a/packages/editor/src/Link.ts b/packages/editor/src/Link.ts index 52e3ca69..d81c5346 100644 --- a/packages/editor/src/Link.ts +++ b/packages/editor/src/Link.ts @@ -78,6 +78,14 @@ export type LinkAttrs = { target: string | null; }; +function sameLinkAttrs(left: LinkAttrs, right: LinkAttrs): boolean { + return ( + left.href === right.href && + left.kind === right.kind && + left.target === right.target + ); +} + export function createLinkMark( href = "", attrs?: Partial>, @@ -136,6 +144,10 @@ export function getActiveLinkRange(state: EditorState): { return { from: selection.from, to: selection.from, ...attrs }; } + const mark = markType.isInSet(parent.child(index).marks); + const attrs = mark ? getLinkAttrs(mark.attrs) : null; + if (attrs === null) return null; + let startIndex = index; let endIndex = index; @@ -145,26 +157,23 @@ export function getActiveLinkRange(state: EditorState): { } let to = from + parent.child(index).nodeSize; - while ( - startIndex > 0 && - !!markType.isInSet(parent.child(startIndex - 1).marks) - ) { + while (startIndex > 0) { + const previousMark = markType.isInSet(parent.child(startIndex - 1).marks); + const previousAttrs = previousMark + ? getLinkAttrs(previousMark.attrs) + : null; + if (!previousAttrs || !sameLinkAttrs(previousAttrs, attrs)) break; startIndex -= 1; from -= parent.child(startIndex).nodeSize; } - while ( - endIndex + 1 < parent.childCount && - !!markType.isInSet(parent.child(endIndex + 1).marks) - ) { + while (endIndex + 1 < parent.childCount) { + const nextMark = markType.isInSet(parent.child(endIndex + 1).marks); + const nextAttrs = nextMark ? getLinkAttrs(nextMark.attrs) : null; + if (!nextAttrs || !sameLinkAttrs(nextAttrs, attrs)) break; endIndex += 1; to += parent.child(endIndex).nodeSize; } - const mark = - markType.isInSet(parent.child(index).marks) ?? - markType.create({ href: "" }); - const attrs = getLinkAttrs(mark.attrs); - if (attrs === null) return null; return { from, to, ...attrs }; } diff --git a/packages/editor/src/List.ts b/packages/editor/src/List.ts index 689a0853..36aaffc5 100644 --- a/packages/editor/src/List.ts +++ b/packages/editor/src/List.ts @@ -419,7 +419,7 @@ function setListItemType( (node, pos) => { const $pos = tr.doc.resolve(pos); if ($pos.depth <= $nearestListPos.depth) return true; - hasChangedAny = itemType(node) !== type; + hasChangedAny ||= itemType(node) !== type; tr.setNodeMarkup(pos, undefined, { checked: type === "task" ? false : null, }); diff --git a/packages/editor/src/markdownToProsemirror.ts b/packages/editor/src/markdownToProsemirror.ts index 7e40fee0..3c62a178 100644 --- a/packages/editor/src/markdownToProsemirror.ts +++ b/packages/editor/src/markdownToProsemirror.ts @@ -36,7 +36,7 @@ function blockToPM(node: Content): JSONContent[] { switch (node.type) { case "paragraph": { const [maybeImage] = node.children; - if (maybeImage?.type === "image") { + if (node.children.length === 1 && maybeImage?.type === "image") { return imageToPM(maybeImage); } const paragraphHtml = node.children.every( diff --git a/packages/sync/src/sync.ts b/packages/sync/src/sync.ts index aa8cba80..87b42920 100644 --- a/packages/sync/src/sync.ts +++ b/packages/sync/src/sync.ts @@ -212,6 +212,9 @@ export async function sync( headers: { "Content-Type": "application/octet-stream" }, body: data, }); + if (!res.ok) { + throw new Error(`Asset upload failed: ${res.status}`); + } const { storageId } = (await res.json()) as { storageId: string }; await backend.pushAsset({ workspaceId, @@ -228,6 +231,9 @@ export async function sync( const url = await backend.getAssetDownloadUrl(remote.storageId); if (!url) return; const res = await fetch(url); + if (!res.ok) { + throw new Error(`Asset download failed: ${res.status}`); + } const buf = new Uint8Array(await res.arrayBuffer()); await ensureParentDir(remote.path); await fs.writeBinaryFile(`${workspacePath}/${remote.path}`, buf); diff --git a/packages/ui/src/editor/EditorView.tsx b/packages/ui/src/editor/EditorView.tsx index b5ea64ee..80c96917 100644 --- a/packages/ui/src/editor/EditorView.tsx +++ b/packages/ui/src/editor/EditorView.tsx @@ -142,6 +142,7 @@ export function EditorView({ window.clearTimeout(saveTimerRef.current); } saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = null; void onSave(savePath, latestMarkdownRef.current); }, saveDebounceMs); }, [onSave, saveDebounceMs]); diff --git a/packages/ui/src/editor/MarkdownSourceEditor.tsx b/packages/ui/src/editor/MarkdownSourceEditor.tsx index f1ab830a..24ec3e83 100644 --- a/packages/ui/src/editor/MarkdownSourceEditor.tsx +++ b/packages/ui/src/editor/MarkdownSourceEditor.tsx @@ -44,6 +44,7 @@ export function MarkdownSourceEditor({ window.clearTimeout(saveTimerRef.current); } saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = null; void onSave(savePath, latestMarkdownRef.current); }, saveDebounceMs); }, [onSave, saveDebounceMs]); diff --git a/packages/ui/src/editor/taskListSplit.test.ts b/packages/ui/src/editor/taskListSplit.test.ts index 57fa9d1d..e11d9e94 100644 --- a/packages/ui/src/editor/taskListSplit.test.ts +++ b/packages/ui/src/editor/taskListSplit.test.ts @@ -25,6 +25,27 @@ describe("task list splitting", () => { taskItem({ checked: false }), ]); }); + + it("converts every item when toggling a mixed list to bullets", () => { + const editor = createEditor({ + type: "doc", + content: [ + { + type: "bulletList", + content: [ + taskItem({ checked: true, text: "done" }), + taskItem({ checked: null, text: "plain" }), + ], + }, + ], + }); + + expect(editor.commands.toggleParentBulletList()).toBe(true); + expect(listItems(editor)).toMatchObject([ + taskItem({ checked: null, text: "done" }), + taskItem({ checked: null, text: "plain" }), + ]); + }); }); function createEditor(content: JSONContent) { From fed659c5bc93828b7ce2b90ff92cd558e72b86da Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 10:48:44 -0400 Subject: [PATCH 2/3] Preserve images when paragraphs mix images with text Splitting the paragraph around image children keeps the image block and the surrounding text, instead of dropping the image to alt text on round-trip. Also warn when localStorage persistence fails. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/lib/localStoragePersist.ts | 3 +- packages/editor/src/ImageMarkdown.test.ts | 31 ++++++++++---- packages/editor/src/markdownToProsemirror.ts | 44 ++++++++++++++++++-- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/localStoragePersist.ts b/apps/desktop/src/lib/localStoragePersist.ts index cd4bdfe9..cafa1943 100644 --- a/apps/desktop/src/lib/localStoragePersist.ts +++ b/apps/desktop/src/lib/localStoragePersist.ts @@ -16,9 +16,10 @@ export function localStoragePersist( const toStore = serialize ? serialize(nextState) : nextState; try { localStorage.setItem(key, JSON.stringify(toStore)); - } catch { + } catch (error) { // Persistence is best-effort; a storage quota/security error // must not prevent the in-memory state update. + console.warn(`Failed to persist "${key}" to localStorage`, error); } return nextState; }); diff --git a/packages/editor/src/ImageMarkdown.test.ts b/packages/editor/src/ImageMarkdown.test.ts index e5bdd23c..44a4dcb0 100644 --- a/packages/editor/src/ImageMarkdown.test.ts +++ b/packages/editor/src/ImageMarkdown.test.ts @@ -35,15 +35,30 @@ describe("image markdown conversion", () => { expect(doc.content?.some((node) => node.type === "image")).toBe(false); }); - it("keeps text that follows an inline image", () => { + it("keeps both the image and text that follows it", () => { const doc = markdownToTiptapDoc("![diagram](example.png) trailing"); - expect(doc.content?.[0]).toMatchObject({ - type: "paragraph", - content: [ - { type: "text", text: "diagram" }, - { type: "text", text: " trailing" }, - ], - }); + expect(doc.content).toMatchObject([ + { type: "image", attrs: { src: "example.png", alt: "diagram" } }, + { type: "paragraph", content: [{ type: "text", text: "trailing" }] }, + ]); + }); + + it("keeps images mixed with surrounding text", () => { + const doc = markdownToTiptapDoc("before ![diagram](example.png) after"); + + expect(doc.content).toMatchObject([ + { type: "paragraph", content: [{ type: "text", text: "before" }] }, + { type: "image", attrs: { src: "example.png", alt: "diagram" } }, + { type: "paragraph", content: [{ type: "text", text: "after" }] }, + ]); + }); + + it("round-trips an image with trailing text without losing the image", () => { + const doc = markdownToTiptapDoc("![diagram](example.png) trailing"); + const markdown = tiptapDocToMarkdown(doc); + + expect(markdown).toContain("![diagram](example.png)"); + expect(markdown).toContain("trailing"); }); }); diff --git a/packages/editor/src/markdownToProsemirror.ts b/packages/editor/src/markdownToProsemirror.ts index 3c62a178..4b6fb158 100644 --- a/packages/editor/src/markdownToProsemirror.ts +++ b/packages/editor/src/markdownToProsemirror.ts @@ -35,9 +35,8 @@ function normalizeBlockContent(children: Content[]): Content[] { function blockToPM(node: Content): JSONContent[] { switch (node.type) { case "paragraph": { - const [maybeImage] = node.children; - if (node.children.length === 1 && maybeImage?.type === "image") { - return imageToPM(maybeImage); + if (node.children.some((child) => child.type === "image")) { + return splitParagraphAroundImages(node.children); } const paragraphHtml = node.children.every( (child) => child.type === "html", @@ -241,6 +240,45 @@ function listItemToPM(li: ListItem, allowChecked: boolean): JSONContent[] { ]; } +// Images are block nodes in the editor schema, so a paragraph mixing images +// with other inline content becomes image blocks with paragraphs between them. +function splitParagraphAroundImages(children: Content[]): JSONContent[] { + const blocks: JSONContent[] = []; + let run: Content[] = []; + const flushRun = () => { + const content = inlineToPM(trimInlineRun(run)); + run = []; + if (content.length > 0) blocks.push({ type: "paragraph", content }); + }; + for (const child of children) { + if (child.type === "image") { + flushRun(); + blocks.push(...imageToPM(child)); + } else { + run.push(child); + } + } + flushRun(); + return blocks; +} + +function trimInlineRun(run: Content[]): Content[] { + const trimmed = [...run]; + const first = trimmed[0]; + if (first?.type === "text") { + const value = first.value.trimStart(); + if (value) trimmed[0] = { ...first, value }; + else trimmed.shift(); + } + const last = trimmed[trimmed.length - 1]; + if (last?.type === "text") { + const value = last.value.trimEnd(); + if (value) trimmed[trimmed.length - 1] = { ...last, value }; + else trimmed.pop(); + } + return trimmed; +} + function imageToPM(imageNode: Image): JSONContent[] { if (!imageNode.url) return []; return [ From 5b4b5ddf2d8fdf9b8736685aa9e031f5607bf3f3 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 10:52:12 -0400 Subject: [PATCH 3/3] Avoid noting cloud sync in the changelog. This isn't publicly released yet --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6770ff3..daaf8125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,6 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Fixed - Copying linked rich text now preserves hyperlinks when pasted into other rich text editors. Thanks [@snvtac](https://github.com/snvtac)! [#149](https://github.com/bholmesdev/hubble.md/issues/149) -- Cloud Sync no longer records failed asset transfers as successfully synced files. ## [0.1.18] - 2026-07-07