Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 4 additions & 27 deletions packages/desktop/src/renderer/src/components/sideBar/toc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import { computed, ref } from 'vue'
import { useEditorStore } from '@/store/editor'
import { usePreferencesStore } from '@/store/preferences'
import { deriveKeyedToc, type KeyedTocNode } from '@/util/tocKeys'
import bus from '../../bus'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
Expand All @@ -44,33 +45,9 @@ const defaultProps = {
const { toc } = storeToRefs(editorStore)
const { wordWrapInToc } = storeToRefs(preferencesStore)

interface KeyedTocNode {
key: string
label: unknown
slug: unknown
children: KeyedTocNode[]
}

// The TOC nodes carry no stable id, so el-tree (without a node-key) discarded
// the user's expand/collapse state on every content edit (#3028). Derive a
// stable key per node — its slug, deduplicated in document order so duplicate
// headings stay unique — and let el-tree preserve state by that key.
const keyedToc = computed<KeyedTocNode[]>(() => {
const seen = new Map<string, number>()
const assign = (nodes: Array<Record<string, unknown>>): KeyedTocNode[] =>
nodes.map((node) => {
const base = typeof node.slug === 'string' && node.slug ? node.slug : 'heading'
const count = seen.get(base) ?? 0
seen.set(base, count + 1)
return {
key: count === 0 ? base : `${base}-${count}`,
label: node.label,
slug: node.slug,
children: assign((node.children as Array<Record<string, unknown>>) ?? [])
}
})
return assign(toc.value as unknown as Array<Record<string, unknown>>)
})
// Stable per-node key so el-tree preserves the user's expand/collapse state
// across content edits (#3028) and tab switches (#3791). See deriveKeyedToc.
const keyedToc = computed<KeyedTocNode[]>(() => deriveKeyedToc(toc.value))

// Track which headings the user collapsed, by stable key (#3028). Headings are
// expanded by default; a collapse is remembered here.
Expand Down
15 changes: 14 additions & 1 deletion packages/desktop/src/renderer/src/store/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,18 @@ export const useEditorStore = defineStore('editor', {
}
},

// Flush any edit still queued in the engine's rAF batch into the active
// tab's `currentFile` before its markdown is read to persist — otherwise an
// edit made in the same frame as the read is silently dropped from the
// written file (#3803), the way tab switching already guards (#2938). Safe
// no-op when nothing is pending.
flushActiveEditor(): void {
bus.emit('flush-active-editor')
},

FILE_SAVE(): void {
if (!this.currentFile) return
this.flushActiveEditor()
const projectStore = useProjectStore()
const { id, filename, pathname, markdown } = this.currentFile
const options = getOptionsFromState(this.currentFile)
Expand Down Expand Up @@ -531,6 +541,7 @@ export const useEditorStore = defineStore('editor', {

FILE_SAVE_AS(): void {
if (!this.currentFile) return
this.flushActiveEditor()
const projectStore = useProjectStore()
const { id, filename, pathname, markdown } = this.currentFile
const options = getOptionsFromState(this.currentFile)
Expand Down Expand Up @@ -705,6 +716,7 @@ export const useEditorStore = defineStore('editor', {

MOVE_FILE_TO(): void {
if (!this.currentFile) return
this.flushActiveEditor()
const projectStore = useProjectStore()
const { id, filename, pathname, markdown } = this.currentFile
const options = getOptionsFromState(this.currentFile)
Expand Down Expand Up @@ -747,6 +759,7 @@ export const useEditorStore = defineStore('editor', {

RESPONSE_FOR_RENAME(): void {
if (!this.currentFile) return
this.flushActiveEditor()
const projectStore = useProjectStore()
const { id, filename, pathname, markdown } = this.currentFile
const options = getOptionsFromState(this.currentFile)
Expand Down Expand Up @@ -812,7 +825,7 @@ export const useEditorStore = defineStore('editor', {
// Must run while `currentFile` still points at the outgoing tab, so its
// flushed edit is attributed to that tab and not lost on switch (#2938).
if (oldCurrentFile) {
bus.emit('flush-active-editor')
this.flushActiveEditor()
}
window.DIRNAME = pathname ? window.path.dirname(pathname) : ''
this.currentFile = currentFile
Expand Down
8 changes: 7 additions & 1 deletion packages/desktop/src/renderer/src/util/listToTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface TreeNode<T extends ListItem = ListItem> {
lvl: number | null
label: unknown
slug: unknown
githubSlug: unknown
children: Array<TreeNode<T>>
}

Expand All @@ -23,19 +24,24 @@ class Node<T extends ListItem> implements TreeNode<T> {
lvl: number | null
label: unknown
slug: unknown
githubSlug: unknown
children: Array<TreeNode<T>>

constructor(item: {
parent: TreeNode<T> | null
lvl: number | null
content?: unknown
slug?: unknown
githubSlug?: unknown
}) {
const { parent, lvl, content, slug } = item
const { parent, lvl, content, slug, githubSlug } = item
this.parent = parent
this.lvl = lvl
this.label = content
this.slug = slug
// Carried through for the TOC: a content-derived id that, unlike `slug`
// (a per-render object id), survives a document reload / tab switch (#3791).
this.githubSlug = githubSlug
this.children = []
}

Expand Down
37 changes: 37 additions & 0 deletions packages/desktop/src/renderer/src/util/tocKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface KeyedTocNode {
key: string
label: unknown
slug: unknown
children: KeyedTocNode[]
}

interface TocLike {
label?: unknown
slug?: unknown
githubSlug?: unknown
children?: TocLike[]
}

// Give each TOC node a stable key so el-tree can preserve the user's
// expand/collapse state. The key is the heading's content-derived `githubSlug`,
// deduplicated in document order so duplicate headings stay distinct. This is
// stable across content edits (#3028) AND across a document reload / tab switch
// (#3791) — unlike the per-render object id `slug`, which every switch rebuilds.
// `slug` is still carried for the click-to-scroll anchor payload.
export function deriveKeyedToc(nodes: TocLike[]): KeyedTocNode[] {
const seen = new Map<string, number>()
const assign = (list: TocLike[]): KeyedTocNode[] =>
list.map((node) => {
const base =
typeof node.githubSlug === 'string' && node.githubSlug ? node.githubSlug : 'heading'
const count = seen.get(base) ?? 0
seen.set(base, count + 1)
return {
key: count === 0 ? base : `${base}-${count}`,
label: node.label,
slug: node.slug,
children: assign(node.children ?? [])
}
})
return assign(nodes)
}
169 changes: 169 additions & 0 deletions packages/desktop/test/unit/specs/flush-before-save.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'

// `@/store/editor` reads `window.path` at module load and `window.electron`
// at runtime; stub those surfaces before the hoisted imports run.
vi.hoisted(() => {
const w = globalThis as unknown as {
window?: {
path?: { sep: string; dirname: (p: string) => string }
electron?: {
clipboard: { writeText: (s: string) => void }
ipcRenderer: { send: (...a: unknown[]) => void; on: (...a: unknown[]) => void }
}
}
}
w.window ??= {}
w.window.path ??= { sep: '/', dirname: (p: string) => p }
w.window.electron ??= {
clipboard: { writeText: () => {} },
ipcRenderer: { send: () => {}, on: () => {} }
}
})

vi.mock('@/services/notification', () => ({
default: { notify: vi.fn(), name: 'notify' }
}))

import { useEditorStore } from '@/store/editor'
import bus from '@/bus'

// #3803: the store snapshots `currentFile.markdown` (refreshed only on the
// engine's deferred rAF `json-change`) to send to the main process. A keystroke
// typed in the same frame as Cmd+S was therefore dropped from the saved file.
// The save/move/rename paths now emit `flush-active-editor` first, which the
// editor synchronously commits into `currentFile.markdown` before it is read.
//
// The bug lives at the `const { …, markdown } = this.currentFile` READ, which
// sits between the flush and the send — so an emit-order assertion (flush < send)
// alone would still pass if a regression moved the flush past the read. These
// tests instead wire a real `flush-active-editor` listener that commits the
// pending keystroke (mirroring editor.vue → `editor.flush()` → `json-change` →
// LISTEN_FOR_CONTENT_CHANGE) and assert the SENT PAYLOAD carries it: a flush
// moved after the read would send the stale snapshot and fail here.

const STALE = 'hello' // what the pre-flush snapshot holds
const FLUSHED = 'hello world!' // the last keystroke the editor commits on flush
const MARKDOWN_ARG = 4 // send(channel, id, filename, pathname, markdown, …)

function seedCurrentFile(
store: ReturnType<typeof useEditorStore>,
overrides: Record<string, unknown> = {}
) {
store.currentFile = {
id: 'tab-1',
filename: 'note.md',
pathname: '/tmp/note.md',
markdown: STALE,
isSaved: false,
encoding: { encoding: 'utf8', isBom: false },
lineEnding: 'lf',
adjustLineEndingOnSave: false,
trimTrailingNewline: 2,
...overrides
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}

// Mirror editor.vue's listener: commit the pending keystroke into the store on
// flush. Returns a detach fn (the bus is a module singleton — listeners leak
// across tests otherwise).
function onFlushCommit(store: ReturnType<typeof useEditorStore>) {
const handler = () => {
if (store.currentFile) store.currentFile.markdown = FLUSHED
}
bus.on('flush-active-editor', handler)
return () => bus.off('flush-active-editor', handler)
}

// Global invocation order of a given emitted event, located by event name (not
// array position) so an unrelated earlier emit can't mask a moved flush.
function emitOrderOf(emitSpy: ReturnType<typeof vi.spyOn>, event: string): number | undefined {
const i = emitSpy.mock.calls.findIndex((c: unknown[]) => c[0] === event)
return i === -1 ? undefined : emitSpy.mock.invocationCallOrder[i]
}

describe('editor store — flush pending edits before saving (#3803)', () => {
let detach: (() => void) | undefined

beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})

afterEach(() => {
detach?.()
detach = undefined
})

it('FILE_SAVE sends the flushed markdown, not the stale pre-flush snapshot', () => {
const store = useEditorStore()
seedCurrentFile(store)
detach = onFlushCommit(store)
const sendSpy = vi.spyOn(window.electron.ipcRenderer, 'send')

store.FILE_SAVE()

const call = sendSpy.mock.calls.find((c) => c[0] === 'mt::response-file-save')
expect(call).toBeDefined()
expect(call?.[MARKDOWN_ARG]).toBe(FLUSHED)
})

it('FILE_SAVE_AS sends the flushed markdown, not the stale pre-flush snapshot', () => {
const store = useEditorStore()
seedCurrentFile(store)
detach = onFlushCommit(store)
const sendSpy = vi.spyOn(window.electron.ipcRenderer, 'send')

store.FILE_SAVE_AS()

const call = sendSpy.mock.calls.find((c) => c[0] === 'mt::response-file-save-as')
expect(call).toBeDefined()
expect(call?.[MARKDOWN_ARG]).toBe(FLUSHED)
})

// MOVE_FILE_TO / RESPONSE_FOR_RENAME only transmit `markdown` in their untitled
// (no-pathname) branch, which reuses `mt::response-file-save` — that is where
// the flush actually matters, so assert the payload there too.
it('MOVE_FILE_TO (untitled) sends the flushed markdown', () => {
const store = useEditorStore()
seedCurrentFile(store, { pathname: '' })
detach = onFlushCommit(store)
const sendSpy = vi.spyOn(window.electron.ipcRenderer, 'send')

store.MOVE_FILE_TO()

const call = sendSpy.mock.calls.find((c) => c[0] === 'mt::response-file-save')
expect(call).toBeDefined()
expect(call?.[MARKDOWN_ARG]).toBe(FLUSHED)
})

it('RESPONSE_FOR_RENAME (untitled) sends the flushed markdown', () => {
const store = useEditorStore()
seedCurrentFile(store, { pathname: '' })
detach = onFlushCommit(store)
const sendSpy = vi.spyOn(window.electron.ipcRenderer, 'send')

store.RESPONSE_FOR_RENAME()

const call = sendSpy.mock.calls.find((c) => c[0] === 'mt::response-file-save')
expect(call).toBeDefined()
expect(call?.[MARKDOWN_ARG]).toBe(FLUSHED)
})

// The existing-file rename branch emits 'rename' (no markdown payload); guard
// that the flush still precedes it so it can't be silently dropped later.
it('RESPONSE_FOR_RENAME (existing file) flushes before emitting rename', () => {
const store = useEditorStore()
seedCurrentFile(store, { pathname: '/tmp/note.md' })
const emitSpy = vi.spyOn(bus, 'emit')

store.RESPONSE_FOR_RENAME()

const flushOrder = emitOrderOf(emitSpy, 'flush-active-editor')
const renameOrder = emitOrderOf(emitSpy, 'rename')
expect(flushOrder).toBeDefined()
expect(renameOrder).toBeDefined()
expect(flushOrder as number).toBeLessThan(renameOrder as number)
})
})
58 changes: 58 additions & 0 deletions packages/desktop/test/unit/specs/toc-keys.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { deriveKeyedToc } from '@/util/tocKeys'

// #3791: the TOC's collapse state is remembered by these keys. They must be
// derived from the content-based `githubSlug` so they survive a tab switch
// (which rebuilds every heading block with a fresh object-identity `slug`),
// not only same-tab edits (#3028).

const flatKeys = (nodes: ReturnType<typeof deriveKeyedToc>): string[] =>
nodes.flatMap((n) => [n.key, ...flatKeys(n.children)])

describe('deriveKeyedToc', () => {
it('keys nodes by githubSlug, deduplicated in document order', () => {
const keyed = deriveKeyedToc([
{ label: 'Intro', slug: 'uid-1', githubSlug: 'intro', children: [] },
{ label: 'Intro', slug: 'uid-2', githubSlug: 'intro', children: [] }
])
expect(keyed.map((n) => n.key)).toEqual(['intro', 'intro-1'])
})

it('produces identical keys when the same document is rebuilt with fresh slugs (tab switch)', () => {
// Same headings/content, different per-render object-identity slugs — this
// is exactly what Editor.setContent does when you switch back to a tab.
const doc = (p: string) => [
{
label: 'A',
slug: `${p}-1`,
githubSlug: 'a',
children: [{ label: 'B', slug: `${p}-2`, githubSlug: 'b', children: [] }]
},
{ label: 'C', slug: `${p}-3`, githubSlug: 'c', children: [] }
]

const before = deriveKeyedToc(doc('old'))
const after = deriveKeyedToc(doc('new'))

// Keys are stable across the rebuild, so collapse state keyed by them
// survives the switch (they would differ if keyed by `slug`).
expect(flatKeys(after)).toEqual(flatKeys(before))
expect(flatKeys(after)).toEqual(['a', 'b', 'c'])
})

it('falls back to a stable placeholder for unsluggable headings', () => {
const keyed = deriveKeyedToc([
{ label: '🎉', slug: 'uid-1', githubSlug: '', children: [] },
{ label: '🎊', slug: 'uid-2', githubSlug: '', children: [] }
])
expect(keyed.map((n) => n.key)).toEqual(['heading', 'heading-1'])
})

it('preserves slug (for the scroll-to-heading payload) while keying by githubSlug', () => {
const keyed = deriveKeyedToc([
{ label: 'A', slug: 'uid-42', githubSlug: 'a', children: [] }
])
expect(keyed[0].slug).toBe('uid-42')
expect(keyed[0].key).toBe('a')
})
})
Loading
Loading