From 45775ce500d3d6f793e3e5cdb0e9fc8533ff730a Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 10:50:04 +0800 Subject: [PATCH 1/8] fix(muya): preserve code fence length on serialize (#4755) A fenced code block written with more than three backticks (required when the block body itself contains a ``` line) was always re-serialized with exactly three backticks, so the inner fence closed the block early and the saved markdown was corrupt. Capture the opening fence length on parse (stored in meta only when it exceeds the default 3) and emit a fence long enough for both the original length and any all-backtick line in the body. Fixes #1841 Co-authored-by: Claude Opus 4.8 (1M context) --- .../state/__tests__/codeFenceLength.spec.ts | 55 +++++++++++++++++++ packages/muya/src/state/markdownToState.ts | 7 ++- packages/muya/src/state/stateToMarkdown.ts | 19 ++++++- packages/muya/src/state/types.ts | 1 + 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 packages/muya/src/state/__tests__/codeFenceLength.spec.ts diff --git a/packages/muya/src/state/__tests__/codeFenceLength.spec.ts b/packages/muya/src/state/__tests__/codeFenceLength.spec.ts new file mode 100644 index 0000000000..7731a62edf --- /dev/null +++ b/packages/muya/src/state/__tests__/codeFenceLength.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { MarkdownToState } from '../markdownToState'; +import ExportMarkdown from '../stateToMarkdown'; + +// #1841 — a fenced code block whose info-string fence is longer than 3 +// backticks (needed when the block's content itself contains a ``` line) was +// always re-serialized with exactly 3 backticks, so the first ``` inside the +// content prematurely closed the block and the saved markdown was corrupt. + +function gen(markdown: string): Parameters[0] { + return new MarkdownToState({ + footnote: false, + math: false, + isGitlabCompatibilityEnabled: false, + trimUnnecessaryCodeBlockEmptyLines: false, + frontMatter: false, + }).generate(markdown) as unknown as Parameters[0]; +} + +function openingFence(md: string): string { + return md.split('\n')[0]; +} + +describe('code fence length (#1841)', () => { + it('keeps a fence long enough to wrap content that contains ```', () => { + // A 4-backtick fence wrapping a body that contains a 3-backtick line. + const input = '````\n```\nfoo\n```\n````\n'; + + const md = new ExportMarkdown().generate(gen(input)); + + // The opening fence must be at least 4 backticks, otherwise the inner + // ``` closes the block and the markdown is broken. + expect(openingFence(md)).toMatch(/^`{4,}$/); + + // Re-parsing must still yield a single code block whose body keeps the + // inner ``` — i.e. the round trip did not corrupt the document. + const reparsed = gen(md) as Array<{ name: string; text?: string }>; + const codeBlocks = reparsed.filter(b => b.name === 'code-block'); + expect(codeBlocks).toHaveLength(1); + expect(codeBlocks[0].text).toContain('```'); + }); + + it('round-trips a long-fenced block byte-stably', () => { + const input = '````js\n```\nconst a = 1\n```\n````\n'; + const once = new ExportMarkdown().generate(gen(input)); + const twice = new ExportMarkdown().generate(gen(once)); + expect(twice).toBe(once); + }); + + it('still uses a plain 3-backtick fence for ordinary blocks', () => { + const input = '```js\nconst a = 1\n```\n'; + const md = new ExportMarkdown().generate(gen(input)); + expect(openingFence(md)).toBe('```js'); + }); +}); diff --git a/packages/muya/src/state/markdownToState.ts b/packages/muya/src/state/markdownToState.ts index 9e367005ba..2b3f5efdec 100644 --- a/packages/muya/src/state/markdownToState.ts +++ b/packages/muya/src/state/markdownToState.ts @@ -281,12 +281,13 @@ export class MarkdownToState { } case 'code': { - const { codeBlockStyle, text, lang: infoString = '' } = token; + const { codeBlockStyle, text, lang: infoString = '', raw = '' } = token; // marked >=17 appends a trailing newline to indented code text // (fenced text has none); strip it so indented blocks round-trip. const codeText = codeBlockStyle === 'indented' ? text.replace(/\n$/, '') : text; + const fenceLength = /^ {0,3}([`~]{3,})/.exec(raw)?.[1].length; parentList[0].push( - this._buildCodeState(codeText, infoString, codeBlockStyle, trimUnnecessaryCodeBlockEmptyLines), + this._buildCodeState(codeText, infoString, codeBlockStyle, trimUnnecessaryCodeBlockEmptyLines, fenceLength), ); break; } @@ -412,6 +413,7 @@ export class MarkdownToState { infoString: string, codeBlockStyle: 'indented' | undefined, trimUnnecessaryCodeBlockEmptyLines: boolean, + fenceLength?: number, ): TState { // GH#697, markedjs#1387 — strip everything past the first // whitespace; `\S*` matches the empty string so this is @@ -452,6 +454,7 @@ export class MarkdownToState { meta: { type: isFenced ? 'fenced' : 'indented', lang, + ...(isFenced && fenceLength && fenceLength > 3 ? { fenceLength } : {}), }, text: value, }; diff --git a/packages/muya/src/state/stateToMarkdown.ts b/packages/muya/src/state/stateToMarkdown.ts index 97bd88cec5..1945ccf9a0 100644 --- a/packages/muya/src/state/stateToMarkdown.ts +++ b/packages/muya/src/state/stateToMarkdown.ts @@ -356,11 +356,12 @@ export default class ExportMarkdown { const { type, lang } = meta; if (type === 'fenced') { - result.push(`${indent}${lang ? `\`\`\`${lang}\n` : '```\n'}`); + const fence = '`'.repeat(this._codeFenceLength(text, meta.fenceLength)); + result.push(`${indent}${lang ? `${fence}${lang}\n` : `${fence}\n`}`); textList.forEach((text) => { result.push(`${indent}${text}\n`); }); - result.push(`${indent}\`\`\`\n`); + result.push(`${indent}${fence}\n`); } else { textList.forEach((text) => { @@ -371,6 +372,20 @@ export default class ExportMarkdown { return result.join(''); } + // The opening fence must be longer than any all-backtick line in the body + // (else that line closes the block early), at least as long as the original + // fence, and never shorter than the markdown minimum of 3. + private _codeFenceLength(text: string, stored?: number): number { + let longestInterior = 0; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (/^`+$/.test(trimmed)) + longestInterior = Math.max(longestInterior, trimmed.length); + } + + return Math.max(3, stored ?? 3, longestInterior + 1); + } + private _serializeHtmlBlock(state: IHtmlBlockState, indent: string) { const result = []; const { text } = state; diff --git a/packages/muya/src/state/types.ts b/packages/muya/src/state/types.ts index 9e7b41e9f2..ef0c7c6f63 100644 --- a/packages/muya/src/state/types.ts +++ b/packages/muya/src/state/types.ts @@ -30,6 +30,7 @@ export interface ICodeBlockState { meta: { type: string; // "indented" | "fenced"; lang: string; + fenceLength?: number; }; text: string; } From dabc523a2d480c9b8f83a3073704d353efeb30b7 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 10:54:00 +0800 Subject: [PATCH 2/8] fix(muya): refresh math/diagram preview on undo (#4756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A math/diagram/html block's rendered preview was only refreshed from inputHandler/backspaceHandler, never from update(). Undo and redo reach the block through editor.applyTextEdit -> update(), so undoing an edit left a stale formula/diagram in the preview while the source text changed. Call _updatePreviewIfHave from update() too, guarded so it (a) bails out during the initial create pass before the block is attached, and (b) only re-renders when the text actually changed — otherwise update()'s render races the preview's own one-shot render on append (DiagramPreview.update is async), leaving the diagram SVG unmounted. Fixes #1632 Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/previewUpdateOnRender.spec.ts | 55 +++++++++++++++++++ .../block/content/codeBlockContent/index.ts | 20 +++++++ 2 files changed, 75 insertions(+) create mode 100644 packages/muya/src/block/content/codeBlockContent/__tests__/previewUpdateOnRender.spec.ts diff --git a/packages/muya/src/block/content/codeBlockContent/__tests__/previewUpdateOnRender.spec.ts b/packages/muya/src/block/content/codeBlockContent/__tests__/previewUpdateOnRender.spec.ts new file mode 100644 index 0000000000..06f5bb106b --- /dev/null +++ b/packages/muya/src/block/content/codeBlockContent/__tests__/previewUpdateOnRender.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Muya } from '../../../../muya'; + +// #1632 — re-rendering a math/diagram/html block via update() (the path taken +// by undo/redo: editor.applyTextEdit -> sd.update()) must refresh the rendered +// preview, not only the source text. inputHandler/backspaceHandler called +// _updatePreviewIfHave explicitly, but update() did not, so undoing an edit to +// a math block left a stale formula in the preview. + +const bootedHosts: HTMLElement[] = []; + +beforeEach(() => { + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) { + const host = bootedHosts.pop()!; + host.remove(); + } +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +describe('math block preview refresh on update() (#1632)', () => { + it('update() re-renders the preview after the text changes', () => { + const muya = bootMuya('$$\nalpha\n$$\n'); + const preview = muya.domNode.querySelector('.mu-math-preview')!; + expect(preview).not.toBeNull(); + + // The math content block (editable source inside the math container). + const content = muya.editor.scrollPage!.firstContentInDescendant() as unknown as { + text: string; + update: () => void; + }; + + // Simulate what undo does: mutate the text and re-render via update() + // WITHOUT going through inputHandler/backspaceHandler. + content.text = 'omega'; + content.update(); + + // The preview must reflect the new formula, not the stale one. + expect(preview.textContent).toContain('omega'); + expect(preview.textContent).not.toContain('alpha'); + }); +}); diff --git a/packages/muya/src/block/content/codeBlockContent/index.ts b/packages/muya/src/block/content/codeBlockContent/index.ts index 8ebf9fd767..b40cbb189f 100644 --- a/packages/muya/src/block/content/codeBlockContent/index.ts +++ b/packages/muya/src/block/content/codeBlockContent/index.ts @@ -114,8 +114,14 @@ class CodeBlockContent extends Content { : codeContainer!.parent; } + // The text the preview was last rendered from. Seeded with the initial + // text so the create-pass update() does not re-trigger a render that races + // the preview's own one-shot render on append (async for diagrams). + private _lastPreviewText: string; + constructor(muya: Muya, state: CodeContentState) { super(muya, state.text); + this._lastPreviewText = state.text; if (hasStateMeta(state)) this._initialLang = state.meta.lang; else @@ -134,6 +140,17 @@ class CodeBlockContent extends Content { // Some block has a preview container, like math, diagram, html, should update the preview if the text changed. private _updatePreviewIfHave(text: string) { + // update() runs during the initial create pass before this block is + // attached, when outContainer cannot resolve its parent chain. + if (!this._codeContainer) + return; + // Only re-render when the text actually changed. update() is called on + // every render pass; without this guard a diagram's create-pass render + // and update()'s render race (DiagramPreview.update is async), leaving + // the SVG unmounted. + if (text === this._lastPreviewText) + return; + this._lastPreviewText = text; if (this.outContainer?.attachments?.length) (this.outContainer?.attachments?.head as HTMLPreview).update(text); } @@ -166,6 +183,9 @@ class CodeBlockContent extends Content { } this._updateLineNumbers(text); + // Re-render the math/diagram/html preview too; undo/redo reaches this + // block only through update(), not inputHandler (#1632). + this._updatePreviewIfHave(text); } private _lastLineCount = -1; From c6f62c897b3524923ca07355a65ee452ff40c844 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 11:05:31 +0800 Subject: [PATCH 3/8] fix(desktop): resolve relative local links on export (#4757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relative local link such as [doc](./my_file.pdf) was exported to HTML / PDF verbatim, so the saved document's link pointed into the app resource directory instead of the file next to the source markdown. Images were already rewritten to absolute file:// URLs on export; do the same for via a sibling resolveLocalLinkHref helper. In-page fragments, URL schemes (http/https/mailto/data…) and absolute paths are left as-is. Fixes #1688 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/renderer/src/util/exportHtml.ts | 21 ++++++++++++++++ .../src/renderer/src/util/resolveLinkHref.ts | 20 ++++++++++++++++ .../test/unit/specs/exportHtml.spec.ts | 24 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 packages/desktop/src/renderer/src/util/resolveLinkHref.ts diff --git a/packages/desktop/src/renderer/src/util/exportHtml.ts b/packages/desktop/src/renderer/src/util/exportHtml.ts index f74d59e50d..053aefa30b 100644 --- a/packages/desktop/src/renderer/src/util/exportHtml.ts +++ b/packages/desktop/src/renderer/src/util/exportHtml.ts @@ -13,6 +13,7 @@ import type { Muya } from '@muyajs/core' import { MarkdownToHtml } from '@muyajs/core' import { sanitize, EXPORT_DOMPURIFY_CONFIG } from './dompurify' import { resolveLocalImageSrc } from './resolveImageSrc' +import { resolveLocalLinkHref } from './resolveLinkHref' export interface HeaderFooterPart { type?: number @@ -134,6 +135,23 @@ const rewriteImageSrcs = (html: string): string => return resolved === src ? match : `${pre}${resolved}${post}` }) +// Match the `href="…"` of an tag in the (already sanitized, double-quoted) +// engine output, so relative local links are rewritten to absolute `file://` +// URLs the same way images are. +const ANCHOR_HREF_REG = /(]*?\shref=")([^"]*)(")/gi + +/** + * Rewrite relative / absolute-local `` to absolute `file://` URLs so a + * link to a local file still resolves after the saved document is moved out of + * the source folder (#1688). Remote URLs, `mailto:`/`data:` schemes and in-page + * fragment anchors are left untouched. + */ +const rewriteAnchorHrefs = (html: string): string => + html.replace(ANCHOR_HREF_REG, (match, pre: string, href: string, post: string) => { + const resolved = resolveLocalLinkHref(href) + return resolved === href ? match : `${pre}${resolved}${post}` + }) + /** * Build a styled, standalone HTML document equivalent to legacy muyajs * `exportStyledHTML`. Renders markdown through the new engine, injects the TOC @@ -170,6 +188,9 @@ export const exportStyledHTML = async( // Resolve relative image paths to absolute file:// URLs so the saved document // still shows its images when opened from a different folder (issue 230). article = rewriteImageSrcs(article) + // Same for relative local links so they still resolve after the document is + // moved out of the source folder (#1688). + article = rewriteAnchorHrefs(article) // Inject the TOC at the `[TOC]` marker (legacy behaviour: only appears when // the document explicitly contains `[TOC]`). The marker is rendered as a diff --git a/packages/desktop/src/renderer/src/util/resolveLinkHref.ts b/packages/desktop/src/renderer/src/util/resolveLinkHref.ts new file mode 100644 index 0000000000..ba395dd9ee --- /dev/null +++ b/packages/desktop/src/renderer/src/util/resolveLinkHref.ts @@ -0,0 +1,20 @@ +// Resolve an 's href for export / static print (#1688): a relative local +// path is resolved to an absolute `file://` URL against the current document +// directory so a link to a local file still works after the exported HTML / PDF +// is moved out of the source folder. In-page fragments, any URL scheme, and +// already-absolute paths are left untouched. +export function resolveLocalLinkHref(href: string): string { + if (!href) return href + // In-page fragment anchor (#heading) — never a filesystem path. + if (href.startsWith('#')) return href + // Windows drive-absolute path (C:\… / C:/…) → file://. Checked before the + // scheme test, since `C:` otherwise reads as a URL scheme. + if (/^[a-z]:[\\/]/i.test(href)) return `file://${href}` + // POSIX / UNC absolute path → file://. + if (/^(?:\/|\\\\)/.test(href)) return `file://${href}` + // Any URL scheme (http:, https:, file:, mailto:, tel:, data:…) — leave as-is. + if (/^[a-z][a-z\d+.-]*:/i.test(href)) return href + // Relative local path — resolve against the document directory. + if (window.DIRNAME) return `file://${window.path.resolve(window.DIRNAME, href)}` + return href +} diff --git a/packages/desktop/test/unit/specs/exportHtml.spec.ts b/packages/desktop/test/unit/specs/exportHtml.spec.ts index 67b2800c30..2677508b96 100644 --- a/packages/desktop/test/unit/specs/exportHtml.spec.ts +++ b/packages/desktop/test/unit/specs/exportHtml.spec.ts @@ -255,3 +255,27 @@ describe('exportStyledHTML — relative image paths', () => { expect(out).not.toContain('file://') }) }) + +describe('exportStyledHTML — relative link paths (#1688)', () => { + it('rewrites a relative to an absolute file:// URL', async() => { + // window.DIRNAME is stubbed to '/docs', so `./my_file.pdf` resolves against it. + const out = await exportStyledHTML(NO_MUYA, '[doc](./my_file.pdf)', {}) + + expect(out).toMatch(/]+href="file:\/\/\/docs\/my_file\.pdf"/) + expect(out).not.toContain('href="./my_file.pdf"') + }) + + it('leaves a remote http(s) link untouched', async() => { + const out = await exportStyledHTML(NO_MUYA, '[site](https://example.com/p)', {}) + + expect(out).toMatch(/]+href="https:\/\/example\.com\/p"/) + expect(out).not.toContain('file://') + }) + + it('leaves an in-page fragment anchor untouched', async() => { + const out = await exportStyledHTML(NO_MUYA, '# Heading\n\n[jump](#heading)', {}) + + expect(out).toContain('href="#heading"') + expect(out).not.toContain('file://') + }) +}) From 13e702e6257c33120407999b3699ba9c5cb10700 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 11:11:18 +0800 Subject: [PATCH 4/8] fix(muya): require a word boundary before an emoji shortcode (#4758) The emoji rule matched any ':name:' run, so the colons inside a timestamp range such as '12:00-14:00' were tokenized as an (invalid) emoji and shown with the red mu-warn styling. Reject an emoji opener when the ':' is glued to a preceding letter or digit; emoji at the start of the text, after whitespace, or after punctuation are unaffected. Fixes #1677 Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/emojiWordBoundary.spec.ts | 32 +++++++++++++++++++ packages/muya/src/inlineRenderer/lexer.ts | 16 +++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 packages/muya/src/inlineRenderer/__tests__/emojiWordBoundary.spec.ts diff --git a/packages/muya/src/inlineRenderer/__tests__/emojiWordBoundary.spec.ts b/packages/muya/src/inlineRenderer/__tests__/emojiWordBoundary.spec.ts new file mode 100644 index 0000000000..e58db2ddc9 --- /dev/null +++ b/packages/muya/src/inlineRenderer/__tests__/emojiWordBoundary.spec.ts @@ -0,0 +1,32 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest'; +import { tokenizer } from '../lexer'; + +// #1677 — the emoji rule (/^(:)([a-z_\d+-]+)\1/) matched the colons inside a +// timestamp range like "12:00-14:00", so ":00-14:" was tokenized as an +// (invalid) emoji and rendered with the red mu-warn styling. An emoji opener +// must sit at a word boundary: a ":" glued to a preceding letter/digit is not +// the start of an emoji shortcode. + +function emojiTokens(src: string) { + return tokenizer(src).filter(t => t.type === 'emoji'); +} + +describe('emoji detection — word boundary (#1677)', () => { + it('does not treat the colons in a timestamp range as an emoji', () => { + expect(emojiTokens('12:00-14:00')).toHaveLength(0); + }); + + it('does not treat a colon glued to a word as an emoji', () => { + expect(emojiTokens('hello:smile:')).toHaveLength(0); + }); + + it('still recognises an emoji at the start of the text', () => { + expect(emojiTokens(':smile:').length).toBeGreaterThan(0); + }); + + it('still recognises an emoji after whitespace', () => { + expect(emojiTokens('lunch :100: today').length).toBeGreaterThan(0); + }); +}); diff --git a/packages/muya/src/inlineRenderer/lexer.ts b/packages/muya/src/inlineRenderer/lexer.ts index 1babf51b03..ef460320bb 100644 --- a/packages/muya/src/inlineRenderer/lexer.ts +++ b/packages/muya/src/inlineRenderer/lexer.ts @@ -196,11 +196,17 @@ function tryChunks(state: ILexState): boolean { for (const rule of chunks) { const to = state.inlineRules[rule].exec(state.src); if (to && isLengthEven(to[3])) { - if ( - rule === 'emoji' - && !lowerPriority(state.src, to[0].length, validateRules) - ) { - return false; + if (rule === 'emoji') { + // An emoji opener must sit at a word boundary: a ":" glued to a + // preceding letter/digit (e.g. the colons in "12:00-14:00") is + // not the start of a shortcode (#1677). + const prevChar = state.originSrc[state.pos - 1]; + if ( + (prevChar && /\w/.test(prevChar)) + || !lowerPriority(state.src, to[0].length, validateRules) + ) { + return false; + } } pushPending(state); const range = { From 0168cf113b6a36ea7b4a05d652177b0e37275457 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 11:14:09 +0800 Subject: [PATCH 5/8] fix(desktop): don't overwrite an existing file when creating from the sidebar (#4759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a sidebar file/folder with the same name as an existing one called outputFile, which truncates the target — silently destroying the existing file's content with no warning (rename already guards this). Check the path first and abort with a notice instead of overwriting. Fixes #1946 Co-authored-by: Claude Opus 4.8 (1M context) --- .../desktop/src/renderer/src/store/project.ts | 14 +++- .../specs/sidebar-create-conflict.spec.ts | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/test/unit/specs/sidebar-create-conflict.spec.ts diff --git a/packages/desktop/src/renderer/src/store/project.ts b/packages/desktop/src/renderer/src/store/project.ts index d1c68363a1..ff12dcde17 100644 --- a/packages/desktop/src/renderer/src/store/project.ts +++ b/packages/desktop/src/renderer/src/store/project.ts @@ -269,7 +269,7 @@ export const useProjectStore = defineStore('project', () => { }) } - function CREATE_FILE_DIRECTORY(name: string): void { + async function CREATE_FILE_DIRECTORY(name: string): Promise { const cache = createCache.value as CreateCacheEntry const { dirname, type } = cache @@ -279,6 +279,18 @@ export const useProjectStore = defineStore('project', () => { const fullName = `${dirname}/${name}` + // Creating over an existing path would silently overwrite it (outputFile + // truncates). Refuse instead of destroying the existing file (#1946). + if (await window.fileUtils.pathExists(fullName)) { + createCache.value = {} + notice.notify({ + title: 'Error in Side Bar', + type: 'error', + message: `A ${type} named "${name}" already exists in this folder.` + }) + return + } + create(fullName, type as FileCreateType) .then(() => { createCache.value = {} diff --git a/packages/desktop/test/unit/specs/sidebar-create-conflict.spec.ts b/packages/desktop/test/unit/specs/sidebar-create-conflict.spec.ts new file mode 100644 index 0000000000..fdf45f0bf5 --- /dev/null +++ b/packages/desktop/test/unit/specs/sidebar-create-conflict.spec.ts @@ -0,0 +1,65 @@ +import type * as FileSystemModule from '@/util/fileSystem' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +// `@/store/project` reaches window.path (via @/config) and window.fileUtils at +// runtime. Stub the surfaces before the hoisted imports run. +vi.hoisted(() => { + const w = globalThis as unknown as { + window?: { + path?: { sep: string; normalize: (p: string) => string; basename: (p: string) => string; dirname: (p: string) => string } + fileUtils?: { hasMarkdownExtension: (n: string) => boolean; pathExists: (p: string) => Promise } + electron?: { ipcRenderer: { send: (...a: unknown[]) => void; on: (...a: unknown[]) => void } } + } + } + w.window ??= {} + w.window.path ??= { sep: '/', normalize: (p) => p, basename: (p) => p, dirname: (p) => p } + w.window.fileUtils ??= { + hasMarkdownExtension: (n: string) => n.endsWith('.md'), + pathExists: () => Promise.resolve(false) + } + w.window.electron ??= { ipcRenderer: { send: () => {}, on: () => {} } } +}) + +vi.mock('@/services/notification', () => ({ + default: { notify: vi.fn(), name: 'notify' } +})) + +// Spy on the actual filesystem create so we can assert it never runs on a conflict. +vi.mock('@/util/fileSystem', async(orig) => { + const actual = await orig() + return { ...actual, create: vi.fn(() => Promise.resolve()) } +}) + +import { useProjectStore } from '@/store/project' +import { create } from '@/util/fileSystem' +import notice from '@/services/notification' + +describe('CREATE_FILE_DIRECTORY — name conflict guard (#1946)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('does not create (overwrite) when a file with the same name exists; notifies instead', async() => { + window.fileUtils.pathExists = vi.fn(() => Promise.resolve(true)) + const store = useProjectStore() + store.createCache = { dirname: '/docs', type: 'file' } + + await store.CREATE_FILE_DIRECTORY('notes') + + expect(create).not.toHaveBeenCalled() + expect(notice.notify).toHaveBeenCalledTimes(1) + }) + + it('creates the file when there is no conflict', async() => { + window.fileUtils.pathExists = vi.fn(() => Promise.resolve(false)) + const store = useProjectStore() + store.createCache = { dirname: '/docs', type: 'file' } + + await store.CREATE_FILE_DIRECTORY('fresh') + + expect(create).toHaveBeenCalledWith('/docs/fresh.md', 'file') + expect(notice.notify).not.toHaveBeenCalled() + }) +}) From af6d792f56e25bc941a9ecdc78d7fd7ae57b58a2 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 12:37:00 +0800 Subject: [PATCH 6/8] fix(desktop): ignore content-identical file-change events (#4760) * fix(desktop): ignore content-identical file-change events A watcher 'change' event fires whenever a file's mtime changes, even when the content is byte-identical (e.g. a git checkout that touches the file without changing it). The handler then marked the tab unsaved and showed a spurious 'file changed on disk' prompt. Skip the change when the new on-disk content equals the tab's current content. Fixes #1861 Co-Authored-By: Claude Opus 4.8 (1M context) * test(desktop): add e2e for the content-identical file-change guard End-to-end coverage that drives the REAL watcher: it writes the actual file with byte-identical content and lets the main-process chokidar watcher -> loadMarkdownFile -> renderer handler run, asserting the tab stays clean; a genuinely different write still marks it unsaved. (An earlier draft hand-crafted the IPC payload, which matched tautologically and proved nothing.) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../desktop/src/renderer/src/store/editor.ts | 8 ++ .../test/e2e/issue-1861-filechange.spec.ts | 40 ++++++++++ .../specs/file-change-content-check.spec.ts | 76 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 packages/desktop/test/e2e/issue-1861-filechange.spec.ts create mode 100644 packages/desktop/test/unit/specs/file-change-content-check.spec.ts diff --git a/packages/desktop/src/renderer/src/store/editor.ts b/packages/desktop/src/renderer/src/store/editor.ts index 4189a33618..ec7b4a2cf2 100644 --- a/packages/desktop/src/renderer/src/store/editor.ts +++ b/packages/desktop/src/renderer/src/store/editor.ts @@ -1658,6 +1658,14 @@ export const useEditorStore = defineStore('editor', { } case 'add': case 'change': { + // Only the file's metadata changed on disk (e.g. a git checkout + // that left the content byte-identical) — there is nothing to + // reload and no reason to warn the user (#1861). + const newMarkdown = (change as unknown as FileChangePayload).data?.markdown + if (typeof newMarkdown === 'string' && newMarkdown === tab.markdown) { + break + } + const { autoSave } = preferencesStore if (autoSave) { if (autoSaveTimers.has(id)) { diff --git a/packages/desktop/test/e2e/issue-1861-filechange.spec.ts b/packages/desktop/test/e2e/issue-1861-filechange.spec.ts new file mode 100644 index 0000000000..43a38e8d27 --- /dev/null +++ b/packages/desktop/test/e2e/issue-1861-filechange.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' +import fs from 'fs' +import { launchWithMarkdown, waitForMenuReady } from './helpers' + +// #1861 — rewriting the open file on disk with byte-identical content (e.g. a +// git checkout that left it unchanged) fires a watcher 'change', but must NOT +// mark the tab unsaved or show the "file changed on disk" banner. A genuinely +// different on-disk content must still warn. +// +// This drives the REAL watcher: it writes the actual file and lets the +// main-process chokidar watcher -> loadMarkdownFile -> renderer handler run, +// rather than hand-crafting the IPC payload (which would tautologically match). + +const isDirty = (page: Page) => + page.evaluate(() => !!document.querySelector('.editor-tabs li.unsaved')) + +// macOS uses polling + awaitWriteFinish (stabilityThreshold 1000ms), so a disk +// write surfaces ~1–2s later. +const WATCH_SETTLE = 2500 + +test.describe('Issue #1861 — content-identical file change', () => { + test('an identical on-disk rewrite stays clean; a real change warns', async() => { + const { app, page, filePath } = await launchWithMarkdown('hello\nworld\n') + await waitForMenuReady(app) + await page.waitForTimeout(500) + expect(await isDirty(page)).toBe(false) + + // Identical bytes — the watcher fires, but the tab must stay clean. + fs.writeFileSync(filePath, 'hello\nworld\n', 'utf-8') + await page.waitForTimeout(WATCH_SETTLE) + expect(await isDirty(page)).toBe(false) + + // A genuine content change still marks the tab unsaved. + fs.writeFileSync(filePath, 'hello\nworld\nchanged\n', 'utf-8') + await expect.poll(() => isDirty(page), { timeout: 8000 }).toBe(true) + + await app.close() + }) +}) diff --git a/packages/desktop/test/unit/specs/file-change-content-check.spec.ts b/packages/desktop/test/unit/specs/file-change-content-check.spec.ts new file mode 100644 index 0000000000..a1c13ed50d --- /dev/null +++ b/packages/desktop/test/unit/specs/file-change-content-check.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +vi.hoisted(() => { + const w = globalThis as unknown as { + window?: { + path?: { sep: string; dirname: (p: string) => string } + fileUtils?: { isSamePathSync: (a: string, b: string) => boolean } + electron?: { ipcRenderer: { send: (...a: unknown[]) => void; on: Mock } } + } + } + w.window ??= {} + w.window.path ??= { sep: '/', dirname: (p: string) => p } + w.window.fileUtils ??= { isSamePathSync: (a, b) => a === b } + w.window.electron ??= { ipcRenderer: { send: () => {}, on: vi.fn() } } +}) + +vi.mock('@/services/notification', () => ({ + default: { notify: vi.fn(), name: 'notify' } +})) +vi.mock('@/store/bufferedState', () => ({ debouncedSendBufferedState: vi.fn() })) + +import { useEditorStore } from '@/store/editor' + +// #1861: a watcher 'change' event fires even when only the file's mtime changed +// (e.g. a git checkout that left the content byte-identical). The handler then +// marked the tab unsaved and showed a "file changed on disk" prompt for a +// no-op change. Skip the handling when the new on-disk content equals the +// tab's current content. +describe('useEditorStore LISTEN_FOR_FILE_CHANGE — content-identical change (#1861)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + ;(window.electron.ipcRenderer.on as Mock).mockReset() + }) + + const makeSavedTab = (store: ReturnType) => { + const tab = { id: 'tab-1', filename: 'a.md', pathname: '/x/a.md', markdown: 'hello', isSaved: true } + store.tabs = [tab] as unknown as typeof store.tabs + store.tabIdToIndex = { 'tab-1': 0 } + return tab + } + + const captureHandler = () => { + const onMock = window.electron.ipcRenderer.on as Mock + const call = onMock.mock.calls.find((c) => c[0] === 'mt::update-file')! + return call[1] as (e: unknown, payload: unknown) => void + } + + const fire = (handler: ReturnType, markdown: string) => + handler(null, { type: 'change', change: { pathname: '/x/a.md', data: { markdown } } }) + + it('ignores a change whose content matches the tab (mtime-only change)', () => { + const store = useEditorStore() + const tab = makeSavedTab(store) + const notifySpy = vi.spyOn(store, 'pushTabNotification').mockImplementation(() => {}) + store.LISTEN_FOR_FILE_CHANGE() + + fire(captureHandler(), 'hello') + + expect(notifySpy).not.toHaveBeenCalled() + expect(tab.isSaved).toBe(true) + }) + + it('still warns when the on-disk content actually changed', () => { + const store = useEditorStore() + const tab = makeSavedTab(store) + const notifySpy = vi.spyOn(store, 'pushTabNotification').mockImplementation(() => {}) + store.LISTEN_FOR_FILE_CHANGE() + + fire(captureHandler(), 'hello world') + + expect(notifySpy).toHaveBeenCalledTimes(1) + expect(tab.isSaved).toBe(false) + }) +}) From 825793ae966d802d9c23bb3da9bda74967fcd7e9 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 16:11:52 +0800 Subject: [PATCH 7/8] fix(desktop): make the source-mode selection visible on dark themes (#4771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In source-code mode the bundled railscasts theme (used by every dark theme) painted the selection at #272935 — nearly identical to its #2b2b2b background — so a selection was effectively invisible. Override the CodeMirror selection background to the editor's --selection-color so it matches the WYSIWYG editor and stays visible across themes. Fixes #2372 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/renderer/src/codeMirror/index.css | 8 +++ .../issue-2372-source-selection-color.spec.ts | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 packages/desktop/test/e2e/issue-2372-source-selection-color.spec.ts diff --git a/packages/desktop/src/renderer/src/codeMirror/index.css b/packages/desktop/src/renderer/src/codeMirror/index.css index a56a4efe14..bf56b29778 100644 --- a/packages/desktop/src/renderer/src/codeMirror/index.css +++ b/packages/desktop/src/renderer/src/codeMirror/index.css @@ -7,3 +7,11 @@ .CodeMirror-line > span > span::selection { background: rgb(178, 215, 254); } +/* #2372 — make the source-mode selection use the same visible colour as the + WYSIWYG editor. The bundled railscasts theme paints the selection at #272935, + almost identical to its #2b2b2b background, so it was invisible on dark + themes. The higher specificity (+ !important) beats both railscasts' own + !important rule and the one-dark theme. */ +.source-code .CodeMirror div.CodeMirror-selected { + background: var(--selection-color, rgb(45 170 219 / 30%)) !important; +} diff --git a/packages/desktop/test/e2e/issue-2372-source-selection-color.spec.ts b/packages/desktop/test/e2e/issue-2372-source-selection-color.spec.ts new file mode 100644 index 0000000000..e0881ef161 --- /dev/null +++ b/packages/desktop/test/e2e/issue-2372-source-selection-color.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { launchWithMarkdown, clickMenuById, enterSourceMode } from './helpers' + +// #2372 — in source-code mode the dark themes (railscasts) rendered the +// selection at #272935, almost identical to the #2b2b2b editor background, so a +// selection was effectively invisible. The selection should use the same +// visible colour as the WYSIWYG editor (--selection-color). Drives the real +// built app: switch to the "dark" theme, enter source mode, select all, and +// read the rendered selection background. + +test.describe('#2372 source-mode selection colour', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const launched = await launchWithMarkdown('# Selection\n\nalpha bravo charlie\n\ndelta echo foxtrot\n') + app = launched.app + page = launched.page + await clickMenuById(app, 'dark') // a railscasts dark theme + await page.waitForFunction(() => document.body.classList.contains('dark'), null, { timeout: 5000 }) + await enterSourceMode(page, app) + await page.waitForFunction(() => !!document.querySelector('.source-code .CodeMirror.cm-s-railscasts'), null, { + timeout: 5000 + }) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test('selection background is the visible editor selection colour, not near-background', async() => { + // Select all via the real CodeMirror instance so it renders .CodeMirror-selected. + await page.evaluate(() => { + const cm = (document.querySelector('.source-code .CodeMirror') as Element & { CodeMirror?: { focus: () => void; execCommand: (c: string) => void } }).CodeMirror + cm!.focus() + cm!.execCommand('selectAll') + }) + await page.waitForSelector('.source-code .CodeMirror-selected', { state: 'attached', timeout: 5000 }) + + const { selBg, selectionColor } = await page.evaluate(() => { + const sel = document.querySelector('.source-code .CodeMirror-selected') as HTMLElement + // Resolve --selection-color (what the WYSIWYG editor uses) to its computed + // rgb form so we can compare against the rendered selection background. + const probe = document.createElement('div') + probe.style.background = 'var(--selection-color)' + document.body.appendChild(probe) + const selectionColor = getComputedStyle(probe).backgroundColor + probe.remove() + return { selBg: getComputedStyle(sel).backgroundColor, selectionColor } + }) + + // Source-mode selection now matches the editor's --selection-color + // (consistent with WYSIWYG mode), and is no longer the near-invisible + // railscasts colour rgb(39, 41, 53) (#272935). + expect(selBg).toBe(selectionColor) + expect(selBg).not.toBe('rgb(39, 41, 53)') + }) +}) From d99937057b293d6507bedb1faed1de5691ea61d0 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 16:40:24 +0800 Subject: [PATCH 8/8] fix(desktop): persist export dialog options across sessions (#4768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export dialog's options (page size, margins, font, theme, header/footer, TOC, …) were component-local refs with hardcoded defaults, so they reset to default on every dialog open and every app restart. Persist them to localStorage — the same renderer-side store the sidebar width uses — saving on change and restoring on mount via a small tested helper. Fixes #2287 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/exportSettings/index.vue | 53 ++++++++++++++++++- .../components/exportSettings/persistence.ts | 27 ++++++++++ .../specs/export-settings-persist.spec.ts | 39 ++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/src/renderer/src/components/exportSettings/persistence.ts create mode 100644 packages/desktop/test/unit/specs/export-settings-persist.spec.ts diff --git a/packages/desktop/src/renderer/src/components/exportSettings/index.vue b/packages/desktop/src/renderer/src/components/exportSettings/index.vue index b28e87fa56..9bb218e557 100644 --- a/packages/desktop/src/renderer/src/components/exportSettings/index.vue +++ b/packages/desktop/src/renderer/src/components/exportSettings/index.vue @@ -288,8 +288,9 @@