From 6f213920cd2406e1af076b226388fc79ac6b1557 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:34:51 +0800 Subject: [PATCH 001/118] test: group test files by feature Merge the ~200 per-fix test files into a few files per feature area. Source-reading cases move to .checks.* files, separate from behavior tests. No assertion changed: the same 1752 test names pass. --- scripts/anchorJumpUnderZoom.spec.ts | 100 - scripts/assetScopeDotDirs.test.ts | 16 - scripts/backgroundEditorPosition.test.ts | 121 - scripts/calloutMarker.test.ts | 50 - scripts/checkedReadMigration.spec.ts | 139 -- scripts/checkedReadMigration.test.ts | 124 - scripts/ciCacheBudget.test.ts | 56 - scripts/clipboardOwnershipX11.test.ts | 35 - scripts/codeFence.test.ts | 63 - scripts/coldStartDoesNotStealFocus.test.ts | 44 - scripts/copyLabelFlash.spec.ts | 33 - scripts/copyReferenceStyle.test.ts | 149 -- scripts/diagramCache.test.ts | 327 --- scripts/diagramThemeRefresh.spec.ts | 217 -- scripts/documentEncodingRoundTrip.spec.ts | 269 --- scripts/documentLoadFailure.spec.ts | 101 - scripts/documents.checks.spec.ts | 1071 +++++++++ scripts/documents.checks.test.ts | 1014 ++++++++ scripts/documents.load.spec.ts | 1416 +++++++++++ scripts/documents.save.spec.ts | 1473 ++++++++++++ scripts/documents.test.ts | 564 +++++ scripts/duplicateTabNames.test.ts | 52 - scripts/editing.checks.test.ts | 980 ++++++++ scripts/editing.spec.ts | 80 + scripts/editing.test.ts | 1549 ++++++++++++ scripts/editor.checks.spec.ts | 602 +++++ scripts/editor.checks.test.ts | 1652 +++++++++++++ ...pec.ts => editor.imageUndo.checks.spec.ts} | 0 scripts/editor.spec.ts | 184 ++ scripts/editor.test.ts | 1165 +++++++++ scripts/editor.vim.test.ts | 531 +++++ scripts/editorContextMenuI18n.test.ts | 208 -- scripts/editorLanguage.test.ts | 21 - scripts/editorLineEnding.test.ts | 95 - scripts/editorOptionWiring.test.ts | 809 ------- scripts/editorPositionFlush.test.ts | 108 - scripts/editorTheme.test.ts | 198 -- scripts/editorToolbar.test.ts | 577 ----- scripts/editorUndoStops.test.ts | 47 - scripts/editorWritesThroughTheStore.spec.ts | 169 -- scripts/explicitSaveCancelsAutoSave.spec.ts | 187 -- scripts/export.checks.test.ts | 1130 +++++++++ scripts/export.test.ts | 1275 ++++++++++ scripts/exportContentWidth.test.ts | 388 --- scripts/exportFoldParity.test.ts | 368 --- scripts/exportHtml.test.ts | 133 -- scripts/exportOpenPrompt.test.ts | 71 - scripts/exportPreviewAppearance.test.ts | 108 - scripts/exportRichContent.test.ts | 435 ---- scripts/exportSanitize.test.ts | 164 -- scripts/exportedThemeFidelity.test.ts | 202 -- scripts/externalChangeReload.spec.ts | 458 ---- scripts/fileAssociations.test.ts | 62 - scripts/fileDropRouting.test.ts | 124 - scripts/findSeed.test.ts | 38 - scripts/findText.spec.ts | 80 - scripts/findWidgetTooltipClip.test.ts | 50 - scripts/firstReadMeasuresItsOwnTab.spec.ts | 124 - scripts/focusParagraph.test.ts | 15 - scripts/foldKeys.test.ts | 140 -- scripts/foldLayout.test.ts | 111 - scripts/foldMeasurementBatching.test.ts | 231 -- scripts/foldStatePerDocument.spec.ts | 693 ------ scripts/folderSidebar.spec.ts | 178 -- scripts/fontFamily.test.ts | 80 - scripts/footnoteAnchorIds.test.ts | 28 - scripts/formatShortcutKeymap.test.ts | 273 --- scripts/frontMatter.test.ts | 123 - scripts/frontMatterFieldStateScope.test.ts | 86 - scripts/frontMatterProseBlock.test.ts | 170 -- scripts/headingInlineCode.test.ts | 37 - scripts/headingLinkCompletion.test.ts | 138 -- scripts/headingSymbols.test.ts | 50 - scripts/homeSentinelSnapshot.spec.ts | 99 - scripts/homeTabRender.spec.ts | 274 --- scripts/i18nCoverage.test.ts | 624 ----- scripts/imageEmbed.test.ts | 229 -- scripts/issue261EditorPdf.test.ts | 79 - scripts/jumpScrollMotion.test.ts | 19 - scripts/jumpToSelectedFragment.test.ts | 470 ---- scripts/katexContribFlavour.test.ts | 19 - scripts/largeFileLoadRevision.spec.ts | 167 -- scripts/layout.checks.spec.ts | 33 + scripts/layout.checks.test.ts | 348 +++ scripts/layout.scrollSync.test.ts | 2096 +++++++++++++++++ scripts/layout.spec.ts | 1024 ++++++++ scripts/layout.test.ts | 708 ++++++ scripts/lineCoordinates.test.ts | 167 -- scripts/lineSamplesCache.spec.ts | 66 - scripts/linkClickTarget.test.ts | 64 - scripts/linkHoverPreview.test.ts | 64 - scripts/listContinuation.test.ts | 673 ------ scripts/liveModeWatchedPath.test.ts | 45 - scripts/localFileLinks.test.ts | 338 --- scripts/localeLoading.spec.ts | 24 - scripts/lossyDecodeSaveGuard.test.ts | 255 -- scripts/lossySaveRefusalScope.spec.ts | 170 -- scripts/macosAppMenu.test.ts | 153 -- scripts/macosOpenDocumentPaths.test.ts | 32 - scripts/macosPdfExport.test.ts | 46 - scripts/markdownTokenColours.test.ts | 135 -- scripts/mathDelimiterContract.test.ts | 167 -- scripts/menuModalGuards.test.ts | 90 - scripts/mermaidDiagramLabels.test.ts | 317 --- scripts/mermaidPrintTheme.test.ts | 66 - scripts/monacoAltGraph.spec.ts | 132 -- scripts/monacoChordOwnership.spec.ts | 568 ----- scripts/monacoImePatch.test.ts | 45 - scripts/monacoStartupGraph.test.ts | 301 --- scripts/navigation.anchors.test.ts | 677 ++++++ scripts/navigation.checks.test.ts | 321 +++ scripts/navigation.spec.ts | 1442 ++++++++++++ scripts/navigation.test.ts | 153 ++ scripts/navigationHistory.test.ts | 154 -- scripts/nbspBlankLine.spec.ts | 71 - scripts/newTabLinkKeepsScrollHistory.spec.ts | 78 - scripts/newWindowClaim.spec.ts | 55 - scripts/nsisInstallerHooks.test.ts | 176 -- scripts/offMainThreadCommands.test.ts | 14 - scripts/oneWritePerTabInFlight.spec.ts | 328 --- scripts/openFileMode.test.ts | 138 -- scripts/openShortcutInPreview.test.ts | 70 - scripts/osTypeFallback.spec.ts | 70 - scripts/packageManagedUpdate.test.ts | 79 - scripts/paneSlide.test.ts | 28 - scripts/pasteUrlContext.test.ts | 235 -- scripts/pathIdentityCaseFolding.spec.ts | 346 --- scripts/pdfExportResult.test.ts | 87 - scripts/perWindowSession.spec.ts | 114 - scripts/pinnedTagFiles.spec.ts | 48 - scripts/pinnedTagSave.test.ts | 188 -- scripts/pinnedTagWindow.test.ts | 54 - scripts/platformSource.spec.ts | 134 -- scripts/preview.checks.spec.ts | 426 ++++ scripts/preview.checks.test.ts | 860 +++++++ scripts/preview.domShim.ts | 51 + scripts/preview.lazyLoad.spec.ts | 61 + scripts/preview.render.test.ts | 960 ++++++++ scripts/preview.renderedHtml.spec.ts | 94 + scripts/preview.richContent.test.ts | 808 +++++++ scripts/preview.spec.ts | 1324 +++++++++++ scripts/preview.test.ts | 695 ++++++ scripts/previewAnchorRestore.test.ts | 521 ---- scripts/previewAnnotations.spec.ts | 69 - scripts/previewCopy.test.ts | 92 - scripts/previewCopyHtml.spec.ts | 152 -- scripts/previewCursor.spec.ts | 95 - scripts/previewPatch.spec.ts | 340 --- scripts/previewPatchParity.spec.ts | 101 - scripts/previewSanitize.spec.ts | 144 -- scripts/previewSanitize.test.ts | 245 -- scripts/previewWidth.test.ts | 153 -- scripts/printOversizedMedia.test.ts | 100 - scripts/printsExportArticle.test.ts | 104 - scripts/readLandsInItsOwnTab.spec.ts | 279 --- scripts/recentFilesMultiWindow.test.ts | 278 --- scripts/recentFilesWriteRace.spec.ts | 136 -- scripts/release.checks.test.ts | 1469 ++++++++++++ scripts/release.spec.ts | 89 + scripts/release.test.ts | 163 ++ scripts/releaseWorkflow.test.ts | 619 ----- scripts/renameFileSession.spec.ts | 151 -- scripts/renderPipelineConvention.test.ts | 41 - scripts/renderProtocol.test.ts | 626 ----- scripts/renderProtocolDom.test.ts | 193 -- scripts/renderedHtmlField.spec.ts | 452 ---- scripts/reopenDirtyDocument.spec.ts | 268 --- scripts/richContentCache.test.ts | 185 -- scripts/richContentIdempotence.spec.ts | 92 - scripts/richContentLazyLoad.spec.ts | 59 - scripts/saveFromReadingMode.test.ts | 98 - scripts/saveImageAsAssetUrl.test.ts | 95 - scripts/scrollHistoryPerTab.spec.ts | 328 --- scripts/scrollSync.test.ts | 205 -- scripts/scrollSyncAcrossFolds.test.ts | 455 ---- scripts/scrollSyncBlockMapping.test.ts | 1512 ------------ scripts/scrollSyncInput.test.ts | 26 - scripts/semanticTokens.test.ts | 182 -- scripts/sessionRestoreResilience.spec.ts | 724 ------ scripts/settings.checks.spec.ts | 359 +++ scripts/settings.checks.test.ts | 645 +++++ scripts/settings.locale.spec.ts | 27 + scripts/settings.spec.ts | 993 ++++++++ scripts/settings.test.ts | 343 +++ scripts/settings.themeLoad.spec.ts | 93 + scripts/settingsFile.spec.ts | 114 - scripts/settingsPersistence.spec.ts | 1176 --------- scripts/settingsSemantics.test.ts | 132 -- scripts/shortcutRegistry.test.ts | 1285 ---------- scripts/shortcuts.checks.spec.ts | 134 ++ scripts/shortcuts.checks.test.ts | 1048 +++++++++ scripts/shortcuts.spec.ts | 437 ++++ scripts/shortcuts.test.ts | 924 ++++++++ scripts/shortcuts.toolbar.test.ts | 781 ++++++ scripts/silentSaveKeepsConflicts.spec.ts | 205 -- .../singleImplementationConvention.test.ts | 352 --- scripts/sourceTree.test.ts | 161 -- scripts/splitPanes.test.ts | 39 - scripts/splitScrollSyncSetting.spec.ts | 123 - scripts/staleThemeLoad.spec.ts | 91 - scripts/statusBarHeight.test.ts | 22 - scripts/tabAnchorAcrossPanes.spec.ts | 263 --- scripts/tabFileActions.test.ts | 30 - scripts/tabNavigationHistory.spec.ts | 100 - scripts/tabPathIdentity.spec.ts | 188 -- scripts/tabReadingPosition.spec.ts | 539 ----- scripts/tabReorder.test.ts | 32 - scripts/tabSwitchIsNotARender.test.ts | 98 - scripts/tabSwitchKeepsPreviewDom.spec.ts | 109 - scripts/tabTransfer.test.ts | 243 -- scripts/tabTransferHandoff.test.ts | 97 - scripts/tabTransferRaces.spec.ts | 216 -- scripts/tableEditing.test.ts | 734 ------ scripts/tabs.checks.spec.ts | 869 +++++++ scripts/tabs.checks.test.ts | 321 +++ scripts/tabs.spec.ts | 1300 ++++++++++ scripts/tabs.test.ts | 506 ++++ scripts/taskToggleLineEndings.spec.ts | 313 --- scripts/tauriCommandRegistration.test.ts | 67 - scripts/themeCssValidation.test.ts | 101 - scripts/titlebarToolbar.test.ts | 230 -- scripts/tocDocumentPasses.spec.ts | 168 -- scripts/tocFollowsEditor.test.ts | 228 -- scripts/tocJumpMotion.test.ts | 14 - scripts/tocOverlay.test.ts | 162 -- scripts/tocRenderSignal.spec.ts | 70 - scripts/truncatedBufferGuard.spec.ts | 500 ---- scripts/uncHostGuard.test.ts | 106 - scripts/undoHistoryPerTab.spec.ts | 857 ------- scripts/unsavedRecovery.test.ts | 252 -- scripts/untitledFirstSaveWrites.spec.ts | 131 -- scripts/untitledTitle.test.ts | 46 - scripts/updateDialogNotes.test.ts | 26 - scripts/updateSettlesBeforeInstall.spec.ts | 84 - scripts/versionSync.test.ts | 59 - scripts/viewModeWithoutSaving.spec.ts | 737 ------ scripts/viewerKeymap.spec.ts | 295 --- scripts/vimClipboardRegisters.test.ts | 117 - scripts/vimScrollCommands.test.ts | 456 ---- scripts/wikilinkFileTargets.test.ts | 129 - scripts/windowClosePerTab.test.ts | 187 -- scripts/windowGeometryWhileRunning.test.ts | 22 - scripts/windowOrganization.test.ts | 72 - scripts/windowStartupHandle.test.ts | 24 - scripts/windowStateRestore.spec.ts | 224 -- scripts/windowTagDismiss.test.ts | 204 -- scripts/windowTagPerWindow.spec.ts | 207 -- scripts/windowTagScope.test.ts | 363 --- scripts/windowTags.spec.ts | 94 - scripts/windowTitleCapability.test.ts | 15 - scripts/windows.checks.spec.ts | 276 +++ scripts/windows.checks.test.ts | 738 ++++++ scripts/windows.recentFiles.test.ts | 245 ++ scripts/windows.sessionRestore.spec.ts | 729 ++++++ scripts/windows.spec.ts | 988 ++++++++ scripts/windows.tagEditor.checks.test.ts | 575 +++++ scripts/windows.test.ts | 112 + scripts/windowsAreEqual.test.ts | 23 - scripts/wordCount.spec.ts | 63 - scripts/workflowSecurityAudit.test.ts | 11 - scripts/youtubeExternalFallback.test.ts | 123 - 261 files changed, 40931 insertions(+), 40087 deletions(-) delete mode 100644 scripts/anchorJumpUnderZoom.spec.ts delete mode 100644 scripts/assetScopeDotDirs.test.ts delete mode 100644 scripts/backgroundEditorPosition.test.ts delete mode 100644 scripts/calloutMarker.test.ts delete mode 100644 scripts/checkedReadMigration.spec.ts delete mode 100644 scripts/checkedReadMigration.test.ts delete mode 100644 scripts/ciCacheBudget.test.ts delete mode 100644 scripts/clipboardOwnershipX11.test.ts delete mode 100644 scripts/codeFence.test.ts delete mode 100644 scripts/coldStartDoesNotStealFocus.test.ts delete mode 100644 scripts/copyLabelFlash.spec.ts delete mode 100644 scripts/copyReferenceStyle.test.ts delete mode 100644 scripts/diagramCache.test.ts delete mode 100644 scripts/diagramThemeRefresh.spec.ts delete mode 100644 scripts/documentEncodingRoundTrip.spec.ts delete mode 100644 scripts/documentLoadFailure.spec.ts create mode 100644 scripts/documents.checks.spec.ts create mode 100644 scripts/documents.checks.test.ts create mode 100644 scripts/documents.load.spec.ts create mode 100644 scripts/documents.save.spec.ts create mode 100644 scripts/documents.test.ts delete mode 100644 scripts/duplicateTabNames.test.ts create mode 100644 scripts/editing.checks.test.ts create mode 100644 scripts/editing.spec.ts create mode 100644 scripts/editing.test.ts create mode 100644 scripts/editor.checks.spec.ts create mode 100644 scripts/editor.checks.test.ts rename scripts/{imageUndoKeepsFile.spec.ts => editor.imageUndo.checks.spec.ts} (100%) create mode 100644 scripts/editor.spec.ts create mode 100644 scripts/editor.test.ts create mode 100644 scripts/editor.vim.test.ts delete mode 100644 scripts/editorContextMenuI18n.test.ts delete mode 100644 scripts/editorLanguage.test.ts delete mode 100644 scripts/editorLineEnding.test.ts delete mode 100644 scripts/editorOptionWiring.test.ts delete mode 100644 scripts/editorPositionFlush.test.ts delete mode 100644 scripts/editorTheme.test.ts delete mode 100644 scripts/editorToolbar.test.ts delete mode 100644 scripts/editorUndoStops.test.ts delete mode 100644 scripts/editorWritesThroughTheStore.spec.ts delete mode 100644 scripts/explicitSaveCancelsAutoSave.spec.ts create mode 100644 scripts/export.checks.test.ts create mode 100644 scripts/export.test.ts delete mode 100644 scripts/exportContentWidth.test.ts delete mode 100644 scripts/exportFoldParity.test.ts delete mode 100644 scripts/exportHtml.test.ts delete mode 100644 scripts/exportOpenPrompt.test.ts delete mode 100644 scripts/exportPreviewAppearance.test.ts delete mode 100644 scripts/exportRichContent.test.ts delete mode 100644 scripts/exportSanitize.test.ts delete mode 100644 scripts/exportedThemeFidelity.test.ts delete mode 100644 scripts/externalChangeReload.spec.ts delete mode 100644 scripts/fileAssociations.test.ts delete mode 100644 scripts/fileDropRouting.test.ts delete mode 100644 scripts/findSeed.test.ts delete mode 100644 scripts/findText.spec.ts delete mode 100644 scripts/findWidgetTooltipClip.test.ts delete mode 100644 scripts/firstReadMeasuresItsOwnTab.spec.ts delete mode 100644 scripts/focusParagraph.test.ts delete mode 100644 scripts/foldKeys.test.ts delete mode 100644 scripts/foldLayout.test.ts delete mode 100644 scripts/foldMeasurementBatching.test.ts delete mode 100644 scripts/foldStatePerDocument.spec.ts delete mode 100644 scripts/folderSidebar.spec.ts delete mode 100644 scripts/fontFamily.test.ts delete mode 100644 scripts/footnoteAnchorIds.test.ts delete mode 100644 scripts/formatShortcutKeymap.test.ts delete mode 100644 scripts/frontMatter.test.ts delete mode 100644 scripts/frontMatterFieldStateScope.test.ts delete mode 100644 scripts/frontMatterProseBlock.test.ts delete mode 100644 scripts/headingInlineCode.test.ts delete mode 100644 scripts/headingLinkCompletion.test.ts delete mode 100644 scripts/headingSymbols.test.ts delete mode 100644 scripts/homeSentinelSnapshot.spec.ts delete mode 100644 scripts/homeTabRender.spec.ts delete mode 100644 scripts/i18nCoverage.test.ts delete mode 100644 scripts/imageEmbed.test.ts delete mode 100644 scripts/issue261EditorPdf.test.ts delete mode 100644 scripts/jumpScrollMotion.test.ts delete mode 100644 scripts/jumpToSelectedFragment.test.ts delete mode 100644 scripts/katexContribFlavour.test.ts delete mode 100644 scripts/largeFileLoadRevision.spec.ts create mode 100644 scripts/layout.checks.spec.ts create mode 100644 scripts/layout.checks.test.ts create mode 100644 scripts/layout.scrollSync.test.ts create mode 100644 scripts/layout.spec.ts create mode 100644 scripts/layout.test.ts delete mode 100644 scripts/lineCoordinates.test.ts delete mode 100644 scripts/lineSamplesCache.spec.ts delete mode 100644 scripts/linkClickTarget.test.ts delete mode 100644 scripts/linkHoverPreview.test.ts delete mode 100644 scripts/listContinuation.test.ts delete mode 100644 scripts/liveModeWatchedPath.test.ts delete mode 100644 scripts/localFileLinks.test.ts delete mode 100644 scripts/localeLoading.spec.ts delete mode 100644 scripts/lossyDecodeSaveGuard.test.ts delete mode 100644 scripts/lossySaveRefusalScope.spec.ts delete mode 100644 scripts/macosAppMenu.test.ts delete mode 100644 scripts/macosOpenDocumentPaths.test.ts delete mode 100644 scripts/macosPdfExport.test.ts delete mode 100644 scripts/markdownTokenColours.test.ts delete mode 100644 scripts/mathDelimiterContract.test.ts delete mode 100644 scripts/menuModalGuards.test.ts delete mode 100644 scripts/mermaidDiagramLabels.test.ts delete mode 100644 scripts/mermaidPrintTheme.test.ts delete mode 100644 scripts/monacoAltGraph.spec.ts delete mode 100644 scripts/monacoChordOwnership.spec.ts delete mode 100644 scripts/monacoImePatch.test.ts delete mode 100644 scripts/monacoStartupGraph.test.ts create mode 100644 scripts/navigation.anchors.test.ts create mode 100644 scripts/navigation.checks.test.ts create mode 100644 scripts/navigation.spec.ts create mode 100644 scripts/navigation.test.ts delete mode 100644 scripts/navigationHistory.test.ts delete mode 100644 scripts/nbspBlankLine.spec.ts delete mode 100644 scripts/newTabLinkKeepsScrollHistory.spec.ts delete mode 100644 scripts/newWindowClaim.spec.ts delete mode 100644 scripts/nsisInstallerHooks.test.ts delete mode 100644 scripts/offMainThreadCommands.test.ts delete mode 100644 scripts/oneWritePerTabInFlight.spec.ts delete mode 100644 scripts/openFileMode.test.ts delete mode 100644 scripts/openShortcutInPreview.test.ts delete mode 100644 scripts/osTypeFallback.spec.ts delete mode 100644 scripts/packageManagedUpdate.test.ts delete mode 100644 scripts/paneSlide.test.ts delete mode 100644 scripts/pasteUrlContext.test.ts delete mode 100644 scripts/pathIdentityCaseFolding.spec.ts delete mode 100644 scripts/pdfExportResult.test.ts delete mode 100644 scripts/perWindowSession.spec.ts delete mode 100644 scripts/pinnedTagFiles.spec.ts delete mode 100644 scripts/pinnedTagSave.test.ts delete mode 100644 scripts/pinnedTagWindow.test.ts delete mode 100644 scripts/platformSource.spec.ts create mode 100644 scripts/preview.checks.spec.ts create mode 100644 scripts/preview.checks.test.ts create mode 100644 scripts/preview.domShim.ts create mode 100644 scripts/preview.lazyLoad.spec.ts create mode 100644 scripts/preview.render.test.ts create mode 100644 scripts/preview.renderedHtml.spec.ts create mode 100644 scripts/preview.richContent.test.ts create mode 100644 scripts/preview.spec.ts create mode 100644 scripts/preview.test.ts delete mode 100644 scripts/previewAnchorRestore.test.ts delete mode 100644 scripts/previewAnnotations.spec.ts delete mode 100644 scripts/previewCopy.test.ts delete mode 100644 scripts/previewCopyHtml.spec.ts delete mode 100644 scripts/previewCursor.spec.ts delete mode 100644 scripts/previewPatch.spec.ts delete mode 100644 scripts/previewPatchParity.spec.ts delete mode 100644 scripts/previewSanitize.spec.ts delete mode 100644 scripts/previewSanitize.test.ts delete mode 100644 scripts/previewWidth.test.ts delete mode 100644 scripts/printOversizedMedia.test.ts delete mode 100644 scripts/printsExportArticle.test.ts delete mode 100644 scripts/readLandsInItsOwnTab.spec.ts delete mode 100644 scripts/recentFilesMultiWindow.test.ts delete mode 100644 scripts/recentFilesWriteRace.spec.ts create mode 100644 scripts/release.checks.test.ts create mode 100644 scripts/release.spec.ts create mode 100644 scripts/release.test.ts delete mode 100644 scripts/releaseWorkflow.test.ts delete mode 100644 scripts/renameFileSession.spec.ts delete mode 100644 scripts/renderPipelineConvention.test.ts delete mode 100644 scripts/renderProtocol.test.ts delete mode 100644 scripts/renderProtocolDom.test.ts delete mode 100644 scripts/renderedHtmlField.spec.ts delete mode 100644 scripts/reopenDirtyDocument.spec.ts delete mode 100644 scripts/richContentCache.test.ts delete mode 100644 scripts/richContentIdempotence.spec.ts delete mode 100644 scripts/richContentLazyLoad.spec.ts delete mode 100644 scripts/saveFromReadingMode.test.ts delete mode 100644 scripts/saveImageAsAssetUrl.test.ts delete mode 100644 scripts/scrollHistoryPerTab.spec.ts delete mode 100644 scripts/scrollSync.test.ts delete mode 100644 scripts/scrollSyncAcrossFolds.test.ts delete mode 100644 scripts/scrollSyncBlockMapping.test.ts delete mode 100644 scripts/scrollSyncInput.test.ts delete mode 100644 scripts/semanticTokens.test.ts delete mode 100644 scripts/sessionRestoreResilience.spec.ts create mode 100644 scripts/settings.checks.spec.ts create mode 100644 scripts/settings.checks.test.ts create mode 100644 scripts/settings.locale.spec.ts create mode 100644 scripts/settings.spec.ts create mode 100644 scripts/settings.test.ts create mode 100644 scripts/settings.themeLoad.spec.ts delete mode 100644 scripts/settingsFile.spec.ts delete mode 100644 scripts/settingsPersistence.spec.ts delete mode 100644 scripts/settingsSemantics.test.ts delete mode 100644 scripts/shortcutRegistry.test.ts create mode 100644 scripts/shortcuts.checks.spec.ts create mode 100644 scripts/shortcuts.checks.test.ts create mode 100644 scripts/shortcuts.spec.ts create mode 100644 scripts/shortcuts.test.ts create mode 100644 scripts/shortcuts.toolbar.test.ts delete mode 100644 scripts/silentSaveKeepsConflicts.spec.ts delete mode 100644 scripts/singleImplementationConvention.test.ts delete mode 100644 scripts/sourceTree.test.ts delete mode 100644 scripts/splitPanes.test.ts delete mode 100644 scripts/splitScrollSyncSetting.spec.ts delete mode 100644 scripts/staleThemeLoad.spec.ts delete mode 100644 scripts/statusBarHeight.test.ts delete mode 100644 scripts/tabAnchorAcrossPanes.spec.ts delete mode 100644 scripts/tabFileActions.test.ts delete mode 100644 scripts/tabNavigationHistory.spec.ts delete mode 100644 scripts/tabPathIdentity.spec.ts delete mode 100644 scripts/tabReadingPosition.spec.ts delete mode 100644 scripts/tabReorder.test.ts delete mode 100644 scripts/tabSwitchIsNotARender.test.ts delete mode 100644 scripts/tabSwitchKeepsPreviewDom.spec.ts delete mode 100644 scripts/tabTransfer.test.ts delete mode 100644 scripts/tabTransferHandoff.test.ts delete mode 100644 scripts/tabTransferRaces.spec.ts delete mode 100644 scripts/tableEditing.test.ts create mode 100644 scripts/tabs.checks.spec.ts create mode 100644 scripts/tabs.checks.test.ts create mode 100644 scripts/tabs.spec.ts create mode 100644 scripts/tabs.test.ts delete mode 100644 scripts/taskToggleLineEndings.spec.ts delete mode 100644 scripts/tauriCommandRegistration.test.ts delete mode 100644 scripts/themeCssValidation.test.ts delete mode 100644 scripts/titlebarToolbar.test.ts delete mode 100644 scripts/tocDocumentPasses.spec.ts delete mode 100644 scripts/tocFollowsEditor.test.ts delete mode 100644 scripts/tocJumpMotion.test.ts delete mode 100644 scripts/tocOverlay.test.ts delete mode 100644 scripts/tocRenderSignal.spec.ts delete mode 100644 scripts/truncatedBufferGuard.spec.ts delete mode 100644 scripts/uncHostGuard.test.ts delete mode 100644 scripts/undoHistoryPerTab.spec.ts delete mode 100644 scripts/unsavedRecovery.test.ts delete mode 100644 scripts/untitledFirstSaveWrites.spec.ts delete mode 100644 scripts/untitledTitle.test.ts delete mode 100644 scripts/updateDialogNotes.test.ts delete mode 100644 scripts/updateSettlesBeforeInstall.spec.ts delete mode 100644 scripts/versionSync.test.ts delete mode 100644 scripts/viewModeWithoutSaving.spec.ts delete mode 100644 scripts/viewerKeymap.spec.ts delete mode 100644 scripts/vimClipboardRegisters.test.ts delete mode 100644 scripts/vimScrollCommands.test.ts delete mode 100644 scripts/wikilinkFileTargets.test.ts delete mode 100644 scripts/windowClosePerTab.test.ts delete mode 100644 scripts/windowGeometryWhileRunning.test.ts delete mode 100644 scripts/windowOrganization.test.ts delete mode 100644 scripts/windowStartupHandle.test.ts delete mode 100644 scripts/windowStateRestore.spec.ts delete mode 100644 scripts/windowTagDismiss.test.ts delete mode 100644 scripts/windowTagPerWindow.spec.ts delete mode 100644 scripts/windowTagScope.test.ts delete mode 100644 scripts/windowTags.spec.ts delete mode 100644 scripts/windowTitleCapability.test.ts create mode 100644 scripts/windows.checks.spec.ts create mode 100644 scripts/windows.checks.test.ts create mode 100644 scripts/windows.recentFiles.test.ts create mode 100644 scripts/windows.sessionRestore.spec.ts create mode 100644 scripts/windows.spec.ts create mode 100644 scripts/windows.tagEditor.checks.test.ts create mode 100644 scripts/windows.test.ts delete mode 100644 scripts/windowsAreEqual.test.ts delete mode 100644 scripts/wordCount.spec.ts delete mode 100644 scripts/workflowSecurityAudit.test.ts delete mode 100644 scripts/youtubeExternalFallback.test.ts diff --git a/scripts/anchorJumpUnderZoom.spec.ts b/scripts/anchorJumpUnderZoom.spec.ts deleted file mode 100644 index 7658b477..00000000 --- a/scripts/anchorJumpUnderZoom.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { anchorScrollTop, previewZoomFactor, PREVIEW_ANCHOR_OFFSET } from '../src/lib/utils/previewAnchor.js'; - -/* - * #621: "Custom viewport zoom bogusly offsets anchor jumps (in preview mode)". - * At 150% a link scrolled past its heading, at 50% it stopped short, and at - * 100% it was right. - * - * The preview sets CSS `zoom` on `.markdown-container`, an ANCESTOR of the - * scrolling element. `getBoundingClientRect()` reports viewport pixels, which - * carry that zoom; `scrollTop` counts the scroller's own pixels, which do not. - * Both call sites added one straight to the other, so the error was exactly the - * zoom factor — invisible at 100%, and the wrong sign on either side of it. - * - * jsdom has no layout, so the elements here are stand-ins that report the - * geometry a browser would at a given zoom: a rect scaled by the factor, and an - * `offsetWidth` that is not. That is the whole relationship under test. - */ - -/** An element whose rect is `zoom`× its layout box, as a zoomed ancestor makes it. */ -function zoomedElement(layoutTop: number, layoutWidth: number, zoom: number) { - return { - offsetWidth: layoutWidth, - getBoundingClientRect: () => ({ top: layoutTop * zoom, width: layoutWidth * zoom }), - } as unknown as HTMLElement; -} - -function scroller(zoom: number, scrollTop: number) { - const el = zoomedElement(0, 800, zoom) as unknown as { scrollTop: number }; - el.scrollTop = scrollTop; - return el as unknown as HTMLElement; -} - -test('the zoom factor is measured off the container, not read from settings', () => { - assert.equal(previewZoomFactor(scroller(1, 0)), 1); - assert.equal(previewZoomFactor(scroller(1.5, 0)), 1.5); - assert.equal(previewZoomFactor(scroller(0.5, 0)), 0.5); -}); - -test('a container with no layout reports 1 rather than a division by zero', () => { - const detached = { - offsetWidth: 0, - scrollTop: 0, - getBoundingClientRect: () => ({ top: 0, width: 0 }), - } as unknown as HTMLElement; - assert.equal(previewZoomFactor(detached), 1); - assert.ok(Number.isFinite(anchorScrollTop(detached, detached))); -}); - -test('an anchor lands the same distance down the document at every zoom', () => { - // The heading sits 1000 layout pixels below the top of the scroller, which - // is already scrolled to 200. Wherever the zoom is, the answer is the same - // number of the scroller's own pixels. - const expected = 200 + 1000 - PREVIEW_ANCHOR_OFFSET; - - for (const zoom of [0.5, 1, 1.5, 2]) { - const container = scroller(zoom, 200); - const heading = zoomedElement(1000, 400, zoom); - assert.equal( - anchorScrollTop(container, heading), - expected, - `zoom ${zoom} must land at ${expected}`, - ); - } -}); - -test('the pre-fix arithmetic is what overshot, and by exactly the zoom factor', () => { - // Kept as the shape of the defect: rect delta added straight to scrollTop. - // It is only correct at 100%, which is why the bug went unseen — and the - // error is the factor itself, matching the report's "way past" at 150% and - // "way before" at 50%. - const preFix = (container: HTMLElement, el: HTMLElement) => - el.getBoundingClientRect().top - - container.getBoundingClientRect().top + - container.scrollTop - - PREVIEW_ANCHOR_OFFSET; - - const correct = 200 + 1000 - PREVIEW_ANCHOR_OFFSET; - - for (const [zoom, sign] of [ - [1.5, 'past'], - [0.5, 'short'], - ] as const) { - const container = scroller(zoom, 200); - const heading = zoomedElement(1000, 400, zoom); - const old = preFix(container, heading); - assert.notEqual(old, correct, `at zoom ${zoom} the old form must be wrong (${sign})`); - // The whole error is the un-divided delta. - assert.equal(old - correct, 1000 * zoom - 1000); - assert.equal(anchorScrollTop(container, heading), correct); - } - - // …and identical at 100%, which is why nobody saw it. - const container = scroller(1, 200); - const heading = zoomedElement(1000, 400, 1); - assert.equal(preFix(container, heading), anchorScrollTop(container, heading)); -}); diff --git a/scripts/assetScopeDotDirs.test.ts b/scripts/assetScopeDotDirs.test.ts deleted file mode 100644 index 0a916cff..00000000 --- a/scripts/assetScopeDotDirs.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -// `![](.res/pic.jpg)` rendered blank on Linux and macOS (#981). Tauri's fs -// scope defaults `requireLiteralLeadingDot` to true on Unix, so `**` does not -// match a path segment that starts with a dot and the `asset:` handler answers -// 403. Windows defaults to false, which is why it only showed up off Windows. -test('asset scope lets `**` match dot-directories on every platform', () => { - const conf = JSON.parse(readSource('src-tauri/tauri.conf.json')); - assert.deepEqual(conf.app.security.assetProtocol.scope, { - allow: ['**'], - requireLiteralLeadingDot: false, - }); -}); diff --git a/scripts/backgroundEditorPosition.test.ts b/scripts/backgroundEditorPosition.test.ts deleted file mode 100644 index aa324c67..00000000 --- a/scripts/backgroundEditorPosition.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { outgoingTabAnchorLine, type StoredTabPosition } from '../src/lib/utils/editorPosition.js'; -import { asRendererLine, EDITOR_ANCHOR_LINE_OFFSET } from '../src/lib/utils/lineCoordinates.js'; -import { readSource, offsetOf, sliceFrom } from './sourceTree.js'; - -// A tab left in EDIT-ONLY mode in the background is the one population neither -// writer of `Tab.anchorLine` covers: the preview writes only the active tab and -// is not on screen for this one, and the single editor has moved on to another -// model. What covers it in memory is `editorViewState`, which survives neither -// the window-state snapshot nor the cross-window payload -- so the anchor is -// recovered from it at those two gates. Everything below is about which tabs -// that applies to, because the tabs it must NOT apply to already have a better -// record than the view state. - -/** A view state as Monaco writes one, cut down to the field the derivation reads. */ -function viewStateAtLine(lineNumber: number) { - return { viewState: { firstPosition: { lineNumber, column: 1 }, firstPositionDeltaTop: 0 } }; -} - -function tab(over: Partial = {}): StoredTabPosition { - return { - id: 'background', - isEditing: true, - isSplit: false, - editorViewState: viewStateAtLine(40), - rawContent: 'body\n'.repeat(200), - anchorLine: asRendererLine(0), - ...over, - }; -} - -test('a background edit-only tab is recorded where its view state says it was', () => { - // This is the defect: nothing wrote `anchorLine` for this tab, so `0` is - // what would be persisted -- the top of the document. - assert.equal( - outgoingTabAnchorLine(tab(), 'some-other-tab'), - 40 + EDITOR_ANCHOR_LINE_OFFSET, - ); -}); - -test('the recovered anchor crosses out of the editor numbering, front matter and all', () => { - // `firstPosition.lineNumber` is a BUFFER line and the tab's anchor is a - // RENDERER line. The crossing has one home and this reuses it rather than - // making its own -- the defect shape `lineCoordinates.ts` exists to prevent. - const rawContent = '---\ntitle: t\n---\n\n' + 'body\n'.repeat(200); - assert.equal( - outgoingTabAnchorLine(tab({ rawContent, editorViewState: viewStateAtLine(40) }), null), - 40 + EDITOR_ANCHOR_LINE_OFFSET - 4, - ); -}); - -test('a tab that left edit mode keeps the position its preview wrote', () => { - // THE regression this change could introduce. Nothing clears - // `editorViewState` when a tab leaves edit mode, so a tab the reader edited, - // switched to reading mode and then scrolled carries a stale view state - // under a fresh `anchorLine`. Deriving there would replace a correct record - // with an older one, on the commoner path. - const stale = tab({ isEditing: false, anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(stale, 'some-other-tab'), 120); -}); - -test('a split tab keeps the position its preview wrote', () => { - // A split tab has its preview on screen writing `anchorLine` on every scroll - // event, and the editor beside it is scrolled independently unless - // `isScrollSynced`. Its record is a real reading position; the view state is - // a second opinion about a different pane. - const split = tab({ isSplit: true, anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(split, 'some-other-tab'), 120); -}); - -test('the active tab keeps what the live editor just flushed onto it', () => { - // #735 flushes the active tab from the live editor immediately before both - // gates, and that reading is the better one AND a different one: Monaco's - // `saveViewState` records the first PARTIALLY visible line, while - // `getVisibleRanges()` -- what the flush reads -- starts at the first - // completely visible one. Deriving over the flush would move the active tab - // by a line at most scroll positions. - const active = tab({ id: 'live', anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(active, 'live'), 120); -}); - -test('a tab with no saved view state is left exactly as it is', () => { - // Restored from disk, or transferred in: `editorViewState` starts null on - // both routes and there is nothing to recover from. - const fresh = tab({ editorViewState: null, anchorLine: asRendererLine(7) }); - assert.equal(outgoingTabAnchorLine(fresh, null), 7); -}); - -test('a view state written by an older Monaco is left exactly as it is', () => { - // `firstPosition` is what `reduceRestoreState` falls back from when it is - // missing, so the shape is real and reachable rather than defensive. - const legacy = tab({ editorViewState: { scrollTop: 900 }, anchorLine: asRendererLine(7) }); - assert.equal(outgoingTabAnchorLine(legacy, null), 7); -}); - -test('a percentage is not invented to go with the anchor', () => { - // The view state carries `scrollTop` but neither height, so no fraction of - // the scrollable range can be computed from it. `anchorLine` is the first - // entry of both restore cascades, so recovering it is what decides where the - // tab opens; writing a percentage nobody can justify is not. - const source = readSource('src/lib/utils/editorPosition.ts'); - const derivation = sliceFrom(source, 'export function outgoingTabAnchorLine'); - assert.doesNotMatch(derivation, /scrollPercentage/); -}); - -test('both gates that drop the view state ask for the anchor first', () => { - // The two moments a tab's position leaves memory. Neither carries - // `editorViewState`: `serializeState` omits it and `restoreState` seeds - // null, and `tabTransfer.ts` excludes it as a live Monaco object. - const store = readSource('src/lib/stores/tabs.svelte.ts'); - const serialize = sliceFrom(store, 'serializeState(extra: Record = {}): string {'); - assert.match(serialize, /anchorLine: outgoingTabAnchorLine\(t, this\.activeTabId\)/); - - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - const payload = sliceFrom(viewer, 'transferPayload: (tabId) => {'); - assert.match(payload, /anchorLine: outgoingTabAnchorLine\(tab, tabManager\.activeTabId\)/); - // After the spread, or the snapshot's own field would win. - assert.ok(offsetOf(payload, '...snapshotTab(tab)') < offsetOf(payload, 'outgoingTabAnchorLine')); -}); diff --git a/scripts/calloutMarker.test.ts b/scripts/calloutMarker.test.ts deleted file mode 100644 index a993d900..00000000 --- a/scripts/calloutMarker.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { installShimDom, parseHtml } from './renderProtocolDom.ts'; - -installShimDom(); - -const { processMarkdownHtml } = await import('../src/lib/utils/markdown.ts'); - -// Only the text a quote opens with can make it a callout, as in Obsidian and -// GitHub. The marker used to be looked for in every text node of the quote, so -// a `[!note]` shown in inline code, a code block or a nested quote turned the -// whole quote into a callout and the matched text was deleted. - -function render(html: string) { - return parseHtml(processMarkdownHtml(html, '/doc.md', new Set())); -} - -test('a marker in inline code is text, not a callout', () => { - const root = render('
\n

Use [!note] to make a callout

\n
'); - assert.equal(root.querySelector('.markdown-alert'), null); - assert.equal(root.querySelector('code')?.textContent, '[!note]'); -}); - -test('a marker at the top of a code block is text, not a callout', () => { - const root = render('
\n
[!warning] Title\n
\n
'); - assert.equal(root.querySelector('.markdown-alert'), null); - assert.equal(root.querySelector('code')?.textContent, '[!warning] Title\n'); -}); - -test('a callout in a nested quote leaves the outer quote alone', () => { - const root = render( - '
\n

Outer

\n
\n

[!tip] Inner

\n
\n
', - ); - const outer = root.querySelector('blockquote'); - assert.ok(outer, 'the outer quote is still a quote'); - assert.equal(outer.querySelector('p')?.textContent, 'Outer'); - const tip = outer.querySelector('.markdown-alert-tip'); - assert.ok(tip, 'the inner quote is the callout'); - assert.equal(tip.querySelector('.callout-title-inner')?.textContent, 'Inner'); -}); - -test('a quote that opens with the marker is still a callout, fold and title included', () => { - const root = render('
\n

[!note]+ My title
\nbody

\n
'); - const callout = root.querySelector('.markdown-alert-note'); - assert.ok(callout); - assert.ok(callout.classList.contains('callout-foldable')); - assert.equal(callout.querySelector('.callout-title-inner')?.textContent, 'My title'); - assert.match(callout.querySelector('.content-inner')?.textContent ?? '', /body/); -}); diff --git a/scripts/checkedReadMigration.spec.ts b/scripts/checkedReadMigration.spec.ts deleted file mode 100644 index 5e25bb82..00000000 --- a/scripts/checkedReadMigration.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * The behavioural half of `checkedReadMigration.test.ts`: `ensureFullContent`, - * driven for real. - * - * #379 made every read that fills a WRITABLE buffer report whether the decode - * was lossy, so the tab can refuse to write U+FFFD back over a file that was - * merely in another encoding. Three reads kept the bare command: - * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and - * the split pane. They were safe — but only because each re-read a file whose - * tab `loadMarkdown` had already flagged. That is an invariant held by call - * sites agreeing with each other, and the failure mode when one stops agreeing - * is a destroyed document. It is now held by the code: every one of them reads - * the fidelity itself. - * - * These three tests are here rather than in the `.test.ts` because they run the - * store and the session, and both are runes modules. Under `node --test` they - * only ran at all because the file installed `$state`/`$derived`/`$effect` onto - * `globalThis` itself, and a hand-written rune is not the compiler's: no deep - * proxying, no `$derived` laziness, and `$effect` never re-runs. `tab.isTruncated` - * and `tab.hasReplacementChars` are read straight back out of the store here, - * so the shim was standing exactly where the assertions look. - * - * The six that stayed behind are the two never-migrate categories: that the - * unchecked command exists nowhere in `src` and is gone from the Rust crate, - * and the four `.svelte` source-shape assertions about the component call - * sites. Nothing this file could run would observe any of them. - */ - -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -const PARTIAL = 'first half'; -const FULL = 'first half and the rest'; - -/** Set per test. `get_os_type` is the settings store booting on import. */ -let handleInvoke: (cmd: string, args: Record) => unknown = (cmd) => { - if (cmd === 'get_os_type') return 'macos'; - throw new Error(`unexpected invoke: ${cmd}`); -}; -const errors: string[] = []; - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin. Only the Tauri backend, which jsdom cannot provide, is stubbed. -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (command: string, args: Record) => - Promise.resolve(handleInvoke(command.replace(/^plugin:[^|]*\|/, ''), args ?? {})), - transformCallback: (fn: unknown) => fn, -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); - -function makeSession() { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '

x

', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: () => {}, - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: (message) => errors.push(message), - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - }); -} - -/** Open a >50KB file and leave the background full read pending forever. */ -async function openPartial() { - tabManager.closeAll(); - errors.length = 0; - handleInvoke = (cmd) => { - if (cmd === 'open_markdown_preview') return [PARTIAL, false, false]; - if (cmd === 'read_file_content_checked') return new Promise(() => {}); - throw new Error(`unexpected invoke: ${cmd}`); - }; - const session = makeSession(); - await session.loadMarkdown('/docs/big.md'); - const tab = tabManager.activeTab!; - assert.equal(tab.isTruncated, true, 'precondition: the buffer is partial'); - assert.equal(tab.hasReplacementChars, false, 'precondition: the preview decoded cleanly'); - return { session, tab }; -} - -test("completing a partial buffer carries the tail's own verdict", async () => { - // The case the old comment called safe: the preview covered the first 50KB - // and decoded cleanly, but a file can be valid UTF-8 up to there and not - // after. With the bare command the tab kept the preview's verdict and the - // next auto-save wrote U+FFFD over the file. - const { session, tab } = await openPartial(); - - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, true]; - throw new Error(`unexpected invoke: ${cmd}`); - }; - - assert.equal(await session.ensureFullContent(tab.id), true); - assert.equal(tab.rawContent, FULL); - assert.equal(tab.hasReplacementChars, true, 'the completed buffer must carry its own fidelity'); -}); - -test('completing a clean tail also clears a stale flag', async () => { - // The same mechanism in the other direction: a verdict that is decided on - // every read cannot go stale. - const { session, tab } = await openPartial(); - tabManager.setTabDecodedLossy(tab.id, true); - - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, false]; - throw new Error(`unexpected invoke: ${cmd}`); - }; - - assert.equal(await session.ensureFullContent(tab.id), true); - assert.equal(tab.hasReplacementChars, false); -}); - -test('the completed buffer is refused or accepted according to that verdict', async () => { - // End to end: the flag is not decoration, it decides the write. - const { session, tab } = await openPartial(); - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, true]; - if (cmd === 'save_file_content') return null; - throw new Error(`unexpected invoke: ${cmd}`); - }; - await session.ensureFullContent(tab.id); - - assert.equal(await session.saveContent(tab.id), false, 'a lossy buffer must not overwrite its file'); - assert.equal(session.isLossySaveRefused(tab.id), true); - assert.ok(errors.length > 0, 'and the user is told once'); -}); diff --git a/scripts/checkedReadMigration.test.ts b/scripts/checkedReadMigration.test.ts deleted file mode 100644 index 536bfd87..00000000 --- a/scripts/checkedReadMigration.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { offsetOf, readRustBackend, readSource, readSourceFiles, sliceBetween } from './sourceTree.js'; - -/* - * #379 made every read that fills a WRITABLE buffer report whether the decode - * was lossy, so the tab can refuse to write U+FFFD back over a file that was - * merely in another encoding. Three reads kept the bare command: - * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and - * the split pane. They were safe — but only because each re-read a file whose - * tab `loadMarkdown` had already flagged. That is an invariant held by call - * sites agreeing with each other, and the failure mode when one stops agreeing - * is a destroyed document. It is now held by the code: every one of them reads - * the fidelity itself. - * - * WHAT IS LEFT IN THIS FILE, AND WHY IT IS HERE RATHER THAN UNDER VITEST - * - * Every assertion below reads source text, and each one is of a kind no run can - * reach: - * - * - that the unchecked command exists NOWHERE. An absence is not observable - * at run time: executing the checked read cannot prove there is no other - * caller of the unchecked one. The same claim then crosses into the Rust - * crate, where nothing but the spelling connects the two sides. - * - the two MarkdownViewer call sites, and the two auto-save ones. They are - * in a `.svelte` component, and mounting one here would put jsdom in for - * Monaco, mermaid and KaTeX to earn a weaker claim than the text does — - * what these pin is the ORDER of statements inside a handler (flag the tab - * before the buffer is published; recognise the refusal before raising the - * generic toast), which is a property of the code as written. - * - * `ensureFullContent` is not here: it is a runes module driven for real, and - * running it under a hand-written `$state` was standing exactly where the - * assertions look. It is `checkedReadMigration.spec.ts` now. `lossyDecodeSaveGuard.test.ts` - * and `lossySaveRefusalScope.spec.ts` cover the store behaviour these source - * assertions deliberately stop short of. - * - * The second half of this file is the toast the guard's refusal used to - * trigger every 1.5 seconds. - */ - -// --- the two component call sites ------------------------------------------- - -const viewer = readSource('src/lib/MarkdownViewer.svelte'); - -test('the unchecked read command is gone, and nothing calls it', () => { - // This was a three-file allowlist — the files #379 migrated. That is the - // wrong shape for a rule that means "nobody, anywhere": a fourth file - // added the call and the test still passed. Two changes fix it. The scan - // below covers the whole tree, so a new file cannot slip under it. And the - // Rust command itself is deleted, which is what turns the rule from a - // convention into a fact: `read_file_content` is not registered any more, - // so invoking it fails loudly instead of quietly filling a buffer with - // mojibake nothing flagged. - const offenders = readSourceFiles('src') - .filter(({ text }) => /invoke\('read_file_content'/.test(text)) - .map(({ path }) => path); - assert.deepEqual(offenders, [], 'read_file_content no longer exists; read_file_content_checked is the read'); - - const rust = readRustBackend(); - assert.doesNotMatch(rust, /\basync fn read_file_content\(/, 'the unchecked command must stay deleted'); - assert.doesNotMatch(rust, /^\s*read_file_content,\s*$/m, 'and must not be registered again'); -}); - -test('entering the editor reads the fidelity and stores it', () => { - const toggle = sliceBetween(viewer, 'async function toggleEdit', 'async function saveContent'); - assert.match(toggle, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const read = offsetOf(toggle, 'read_file_content_checked'); - const flag = offsetOf(toggle, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(toggle, 'setTabRawContent(tab.id, content)'); - assert.ok(read < flag && flag < store, 'flag the tab before the buffer is published'); -}); - -test('entering split view reads the fidelity and stores it', () => { - const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); - assert.match(enter, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const flag = offsetOf(enter, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(enter, 'setTabRawContent(tab.id, content)'); - assert.ok(flag < store, 'flag the tab before the buffer is published'); -}); - -// --- the repeating toast ------------------------------------------------------ - -test('the session can tell a refusal from a failure', () => { - // `saveContent` returns false for both, which is why the auto-save timer - // could not tell them apart. The predicate is no longer set membership on - // its own: the set records what was SAID, and whether the refusal still - // stands is asked of the tab. `lossySaveRefusalScope.spec.ts` exercises - // both halves for real; this only pins that the tab is consulted at all, - // since a predicate that answers from memory alone is the defect. - const body = sliceBetween( - readSource('src/lib/sessions/documentSession.svelte.ts'), - 'function isLossySaveRefused(', - 'function updateLoading', - ); - assert.match(body, /lossySaveWarnedTabs\.has\(tabId\)/); - assert.match(body, /hasReplacementChars/, 'the live tab decides whether anything is still being refused'); -}); - -test('a refused save does not add a generic toast to its own explanation', () => { - const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); - const check = offsetOf(body, 'if (documentSession.isLossySaveRefused(s.id)) return;'); - const toast = offsetOf(body, "t('toast.autoSaveFailed'"); - assert.ok(check < toast, 'the refusal must be recognised before the generic toast is raised'); -}); - -test('a tab that can only be refused stops re-arming the timer', () => { - // Auto-save re-arms on every keystroke. Without this the guard was reached - // again every 1.5s for as long as the user kept typing — each time - // producing the console warning, the wasted round trip, and (before the - // test above) the toast. - const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); - assert.match(body, /decodedLossily: tab\.hasReplacementChars/); - assert.match(body, /const eligible = [^;]*!\(s\.decodedLossily && documentSession\.isLossySaveRefused\(s\.id\)\)/); - // The FIRST attempt must still happen: it is what produces the explanation. - // `isLossySaveRefused` is false until the guard has spoken, so eligibility - // only drops afterwards — and "Save As" to a new file clears - // `hasReplacementChars`, which restores it. - assert.match( - readSource('src/lib/sessions/documentSession.svelte.ts'), - /lossySaveWarnedTabs\.delete\(tab\.id\)/, - ); -}); diff --git a/scripts/ciCacheBudget.test.ts b/scripts/ciCacheBudget.test.ts deleted file mode 100644 index 49615255..00000000 --- a/scripts/ciCacheBudget.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -const workflows = ['build.yml', 'test.yml', 'test_build.yml', 'publish-packages.yml'].map( - (name) => ({ name, text: readSource(`.github/workflows/${name}`) }), -); - -// GitHub gives a repository 10 GB of Actions cache and evicts -// least-recently-used over it. Both assertions below exist because that budget -// was measured being exceeded — 13.22 GB across 117 entries — and because the -// entries that overflow it are evicted in the order that hurts most: the -// largest first, which is exactly the cargo caches the budget is being spent -// on. Neither of these is a law of nature; both are trades with numbers behind -// them, recorded in the workflow comments. Change them by re-doing the -// measurement, not by deleting the assertion. - -test('a cargo cache is written on master and nowhere else', () => { - // Without `save-if` every pull request writes its own copy, and a copy is - // 1.0-1.2 GB per platform: one run of #572 wrote 4.45 GB. Two pull requests - // in flight overflow the budget on their own, and what gets evicted is the - // cargo caches — so the cache becomes a slower way of not caching. - // - // This is also why test_build.yml runs on push to master. The two are one - // decision: `save-if` makes master the only writer, so master has to run. - for (const { name, text } of workflows) { - const uses = [...text.matchAll(/uses: Swatinem\/rust-cache@v2\n([\s\S]{0,400}?)(?=\n\s*- name:|\n\n)/g)]; - for (const [, block] of uses) { - assert.match( - block, - /save-if: \$\{\{ github\.ref == 'refs\/heads\/master' \}\}/, - `${name} caches cargo output without restricting the save to master`, - ); - } - } -}); - -test('setup-node does not cache npm', () => { - // It was the largest consumer of the same budget: 113 entries, 8.76 GB, - // across 50 refs, most of them pull requests closed weeks earlier. Caches - // are scoped by ref, so one is written per branch per platform and nothing - // can bound it the way `save-if` bounds the cargo caches. - // - // What it bought, on runs that hit it: `npm ci` in 6s on Linux, 9s on - // macOS, 15-17s on Windows. It caches `~/.npm`, not `node_modules`, so - // `npm ci` still runs and still links every package — the saving is the - // registry fetch alone. - for (const { name, text } of workflows) { - assert.doesNotMatch( - text, - /^\s*cache: '?npm'?\s*$/m, - `${name} re-enables the npm cache; re-read the budget note in test.yml first`, - ); - } -}); diff --git a/scripts/clipboardOwnershipX11.test.ts b/scripts/clipboardOwnershipX11.test.ts deleted file mode 100644 index 79f2b30a..00000000 --- a/scripts/clipboardOwnershipX11.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource, sliceBetween } from './sourceTree.js'; - -// #729: on Debian XFCE, everything copied out of Markpad reached an empty -// clipboard, while pasting into it worked. X11 has no clipboard store — the -// copying process owns the CLIPBOARD selection and serves the bytes on -// request — and arboard tears that ownership down when the last `Clipboard` -// handle drops, which was the moment `clipboard_write_text` returned. -// -// A source-shape assertion because the proposition cannot be run: the failure -// needs an X server and a desktop without a clipboard manager, and CI has -// neither. It names the helper on purpose; nothing but the call connects the -// two, and the function exists for no other reason. -test('the copy command keeps X11 clipboard ownership past its own return', () => { - const body = sliceBetween( - readSource('src-tauri/src/commands.rs'), - 'pub fn clipboard_write_text', - '\n}', - ); - assert.match(body, /retain_clipboard_ownership\(\)/); -}); - -test('the retained handle is never dropped', () => { - // `retain_clipboard_ownership` holding a `Clipboard` in a local would be a - // no-op: the point is that the handle outlives the call, so the invisible - // window arboard serves selection requests from is never destroyed. - const helper = sliceBetween( - readSource('src-tauri/src/commands.rs'), - 'fn retain_clipboard_ownership', - '\n}', - ); - assert.match(helper, /std::mem::forget\(/); -}); diff --git a/scripts/codeFence.test.ts b/scripts/codeFence.test.ts deleted file mode 100644 index e4f56460..00000000 --- a/scripts/codeFence.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { isInFencedCode } from '../src/lib/utils/codeFence.js'; - -/** Every line of `text`, 1-based, answered in order. */ -function inside(text: string): boolean[] { - const lines = text.split('\n'); - const doc = { getLineContent: (line: number) => lines[line - 1] }; - return lines.map((_, index) => isInFencedCode(doc, index + 1)); -} - -test('the lines after an opening fence are inside it, up to and including the closing fence', () => { - assert.deepEqual(inside('a\n```\n1. x\n```\nb'), [false, false, true, true, false]); - assert.deepEqual(inside('~~~ts\n|a|\n~~~\n|a|'), [false, true, true, false]); -}); - -test('only the same character, at least as long, with nothing after it, closes', () => { - assert.deepEqual(inside('````\n```\nx\n````\ny'), [false, true, true, true, false]); - assert.deepEqual(inside('```\n~~~\nx\n```\ny'), [false, true, true, true, false]); - assert.deepEqual(inside('```\n``` ts\nx\n``` \ny'), [false, true, true, true, false]); -}); - -test('an unclosed fence runs to the end of the document', () => { - assert.deepEqual(inside('```\na\nb'), [false, true, true]); -}); - -test('a backtick run with a backtick after it is inline code, not a fence', () => { - assert.deepEqual(inside('```a```\n1. x'), [false, false]); - // A tilde fence's info string may say anything. - assert.deepEqual(inside('~~~ a`b\nx\n~~~'), [false, true, true]); -}); - -test('a fence nested in a list item or a quote counts; two backticks do not', () => { - assert.deepEqual(inside('1. a\n ```\n 2. b\n ```\n3. c'), [false, false, true, true, false]); - assert.deepEqual(inside('> ```\n> - x\n> ```\n- y'), [false, true, true, false]); - assert.deepEqual(inside('``\n- x'), [false, false]); -}); - -test('a fence opened on the list marker line counts, and so does its closing line', () => { - assert.deepEqual(inside('- item\n- ```js\n code\n ```\n- next\n - child'), [false, false, true, true, false, false]); - assert.deepEqual(inside('1. ```bash\n ls\n ```\n2. b'), [false, true, true, false]); - assert.deepEqual(inside('* [ ] ```\n x\n ```\n+ y'), [false, true, true, false]); - // Containers nest: a quote holding a list, a list holding a list. - assert.deepEqual(inside('> - ```\n> x\n> ```\n- y'), [false, true, true, false]); - assert.deepEqual(inside('> 1. - ```\n> x\n> ```\n- y'), [false, true, true, false]); -}); - -test('a list item with inline code spans is not a fence', () => { - assert.deepEqual(inside('- use ```x``` here\n- y'), [false, false]); - assert.deepEqual(inside('- ```x```\n- y'), [false, false]); -}); - -test('inside a fence a marker line is code, never the close', () => { - // A Markdown sample showing a fence on a list item, and a diff that deletes - // one: both lines are code, and taking them for the close inverted the rest. - assert.deepEqual(inside('```md\n- ```\n x\n```\n- item\n- item2'), [false, true, true, true, false, false]); - assert.deepEqual(inside('```diff\n- ```\n+ ```ts\n```\n- item'), [false, true, true, true, false]); -}); - -test('a task box opens a fence only after a list marker', () => { - assert.deepEqual(inside('[x] ```\na\nb'), [false, false, false]); -}); diff --git a/scripts/coldStartDoesNotStealFocus.test.ts b/scripts/coldStartDoesNotStealFocus.test.ts deleted file mode 100644 index 787bdb51..00000000 --- a/scripts/coldStartDoesNotStealFocus.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readRustBackend } from './sourceTree.js'; - -const backend = readRustBackend(); - -function showWindowSource(): string { - const match = /pub async fn show_window\([\s\S]*?\n\}/.exec(backend); - assert.ok(match, 'show_window is gone: this file no longer states anything'); - return match[0]; -} - -test('the launch window is shown without being activated on a cold start', () => { - const showWindow = showWindowSource(); - - // The defect (#702): the window is built hidden and revealed once the - // frontend mounts, seconds after launch, and the reveal called `set_focus` - // unconditionally — pulling the user out of whatever they had switched to. - // The label and the one-shot flag together are what make it the FIRST show - // of the LAUNCH window that stays quiet. - assert.match(showWindow, /window\.label\(\)\s*==\s*LAUNCH_LABEL/); - assert.match(showWindow, /MAIN_WINDOW_SHOWN\.swap\(true,/); - - // Not vacuous: the other callers — a detached tab window, the close walk - // needing its dialog visible — still activate, so this cannot pass by - // `set_focus` having been deleted outright. - assert.match(showWindow, /set_focus\(\)/); - - // And the quiet path returns before reaching it. - const guard = showWindow.indexOf('return;'); - const focus = showWindow.indexOf('set_focus()'); - assert.ok(guard > 0 && guard < focus, 'the cold-start path falls through to set_focus'); -}); - -test('no window builder opts out of focus', () => { - // `.focused(false)` on a window built hidden leaves WebView2 on Windows with - // no drop target, so every file dragged onto the window is refused (#768, - // tauri-apps/wry#1639). Comments are stripped first: the builder explains - // why the call is absent by naming it. - const code = backend.replace(/\/\/.*$/gm, ''); - const found = code.match(/\.focused\(false\)/g)?.length ?? 0; - assert.equal(found, 0, `found ${found} .focused(false) in the Rust backend`); -}); diff --git a/scripts/copyLabelFlash.spec.ts b/scripts/copyLabelFlash.spec.ts deleted file mode 100644 index b253125c..00000000 --- a/scripts/copyLabelFlash.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Clicking a code block's Copy label twice within 1.5 s left it reading - * "Copied!" for good: the second click saved "Copied!" as the label to restore. - */ - -import { afterEach, expect, test, vi } from 'vitest'; - -import { flashCopiedLabel } from '../src/lib/utils/richContent.js'; - -afterEach(() => { - vi.useRealTimers(); -}); - -test('a second copy inside the 1.5 s still restores the language name', () => { - vi.useFakeTimers(); - const label = document.createElement('button'); - label.textContent = 'typescript'; - - flashCopiedLabel(label); - vi.advanceTimersByTime(500); - flashCopiedLabel(label); - expect(label.innerHTML).toBe('Copied!'); - - vi.advanceTimersByTime(1500); - expect(label.innerHTML).toBe('typescript'); - expect(label.classList.contains('copied')).toBe(false); - - // And the label still works after that. - flashCopiedLabel(label); - expect(label.innerHTML).toBe('Copied!'); - vi.advanceTimersByTime(1500); - expect(label.innerHTML).toBe('typescript'); -}); diff --git a/scripts/copyReferenceStyle.test.ts b/scripts/copyReferenceStyle.test.ts deleted file mode 100644 index 55f9bce1..00000000 --- a/scripts/copyReferenceStyle.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * "Copy Reference" writes the spelling the document is already written in. - * - * Markpad understands two ways to link a heading — `[[note#Setup]]`, which it - * rewrites before the parse, and `[Setup](note.md#setup)`, which every reader - * resolves — and this menu always produced the first. Which one is right - * depends on the person, not on the app: a vault full of `[[…]]` wants - * another one; a document written for GitHub wants a link that survives - * leaving Markpad. - * - * The document is the evidence, because a person's habit is already written - * down in the file they are working in. The inference has a floor: says - * nothing, or says both, and the answer is what the menu always produced. So - * it can be better than the old behaviour and never worse — which is the only - * reason inferring is defensible here at all. - * - * Known and accepted: a reference is usually pasted into a DIFFERENT document, - * so following this one is a guess about that one. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -import { - DEFAULT_REFERENCE_STYLE, - headingReference, - preferredReferenceStyle, -} from '../src/lib/utils/headingReference.js'; - -const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - -/* ------------------------------------------------------ reading the document */ - -test('a document that links one way gets another of the same', () => { - assert.equal( - preferredReferenceStyle('See [[Setup#Install]] for details.\n'), - 'wikilink', - ); - assert.equal( - preferredReferenceStyle('See [Install](setup.md#install) for details.\n'), - 'inline', - ); - // A same-document anchor counts as well — it is the same spelling. - assert.equal(preferredReferenceStyle('Back to [the top](#introduction).\n'), 'inline'); - // And a bare note link, which the renderer leaves literal, still says what - // the writer's habit is. - assert.equal(preferredReferenceStyle('See [[Setup]].\n'), 'wikilink'); -}); - -test('silence and disagreement both fall back to what the menu always did', () => { - assert.equal(DEFAULT_REFERENCE_STYLE, 'wikilink'); - assert.equal(preferredReferenceStyle(''), DEFAULT_REFERENCE_STYLE); - assert.equal(preferredReferenceStyle('# A document\n\nWith no links at all.\n'), DEFAULT_REFERENCE_STYLE); - assert.equal( - preferredReferenceStyle('Both [[Setup#Install]] and [Install](setup.md#install).\n'), - DEFAULT_REFERENCE_STYLE, - 'a document using both is not evidence of either', - ); - // An ordinary link with no anchor is not evidence: every document has those. - assert.equal(preferredReferenceStyle('See [the site](https://example.com).\n'), DEFAULT_REFERENCE_STYLE); -}); - -test('a document ABOUT markdown does not vote with its examples', () => { - // `samples/stress-test.md` and this project's own notes are full of - // both spellings inside fences. Counting those would make the syntax - // documentation the loudest voice in every vault. - const teaching = [ - '# Linking', - '', - '```markdown', - '[[Setup#Install]]', - '```', - '', - 'Prose with [an anchor](setup.md#install) in it.', - '', - ].join('\n'); - - assert.equal(preferredReferenceStyle(teaching), 'inline', 'the fenced example must not count'); - - const tildeFenced = '~~~md\n[[Setup#Install]]\n~~~\n\nSee [x](a.md#b).\n'; - assert.equal(preferredReferenceStyle(tildeFenced), 'inline', 'tilde fences too'); -}); - -/* ------------------------------------------------------------ writing it out */ - -test('each style is written the way its own resolver reads it', () => { - const heading = { text: 'Setup', slug: 'setup', fileName: 'notes.md' as string | null }; - - // The wikilink drops the extension — `process_wikilinks` appends `.md` - // itself — and names the heading by its TEXT. - assert.equal(headingReference({ ...heading, style: 'wikilink' }), '[[notes#Setup]]'); - // The CommonMark form keeps it, because that is how the frontend claims a - // link for local navigation, and names the heading by its rendered id. - assert.equal(headingReference({ ...heading, style: 'inline' }), '[Setup](notes.md#setup)'); -}); - -test('the wikilink drops only an extension the renderer would add back', () => { - // `process_wikilinks` appends `.md` to a target with no extension, so - // `[[note#Setup]]` reaches `note.md` and never `note.markdown`. - const reference = (fileName: string) => - headingReference({ text: 'Setup', slug: 'setup', fileName, style: 'wikilink' }); - assert.equal(reference('note.markdown'), '[[note.markdown#Setup]]'); - assert.equal(reference('notes.txt'), '[[notes.txt#Setup]]'); - // Left as `v1.2`, the target would read as having a `.2` extension. - assert.equal(reference('v1.2.md'), '[[v1.2.md#Setup]]'); - assert.equal(reference('v1.2 spec.md'), '[[v1.2 spec#Setup]]'); -}); - -test('an unsaved document has no file to name', () => { - const heading = { text: 'Setup', slug: 'setup', fileName: null }; - assert.equal(headingReference({ ...heading, style: 'wikilink' }), '#Setup'); - assert.equal(headingReference({ ...heading, style: 'inline' }), '[Setup](#setup)'); -}); - -test('the CommonMark form survives a filename with a space and a bracketed heading', () => { - // `[Setup](my notes.md#setup)` stops at the space; `[Notes [draft](…)` ends - // its label early. Neither can happen in the wikilink form, which is part - // of why this only applies to one of them. - assert.equal( - headingReference({ text: 'Setup', slug: 'setup', fileName: 'my notes.md', style: 'inline' }), - '[Setup]()', - ); - assert.equal( - headingReference({ text: 'Notes [draft', slug: 'notes-draft', fileName: null, style: 'inline' }), - '[Notes \\[draft](#notes-draft)', - ); -}); - -test('a repeated heading is addressable, because the slug comes from the render', () => { - // comrak numbers them; recomputing the id here would drift from that. - assert.equal( - headingReference({ text: 'Objectives', slug: 'objectives-1', fileName: 'n.md', style: 'inline' }), - '[Objectives](n.md#objectives-1)', - ); -}); - -/* ---------------------------------------------------------------- the wiring */ - -test('all three entries go through one helper, which reads the buffer', () => { - // The preview's heading menu, the outline's right-click, and the outline's - // context menu. All three built the string inline before, which is how they - // came to disagree about the filename in the first place. - assert.equal((viewerSource.match(/copyHeadingReference\(/g) ?? []).length, 4, 'one helper, three callers'); - assert.match(viewerSource, /style: preferredReferenceStyle\(tab\?\.rawContent \?\? ''\)/); - // The id is taken off the rendered element rather than recomputed. - assert.match(viewerSource, /heading\.id \|\| heading\.querySelector\('a\.anchor'\)\?\.id/); -}); diff --git a/scripts/diagramCache.test.ts b/scripts/diagramCache.test.ts deleted file mode 100644 index 455113ca..00000000 --- a/scripts/diagramCache.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * The preview re-renders the whole article on a 16ms debounce — in split mode, - * and in edit mode with the TOC open, that is about once per keystroke — and - * `{@html sanitizedHtml}` swaps the entire block, which puts every - * `
` back. So every diagram was redrawn from
- * scratch on every keystroke. Measured in a headless browser on a document with
- * 44 diagrams and 132 code blocks, one pass cost 2.2–2.5s, of which
- * `mermaid.render` was all but ~30ms.
- *
- * These tests pin the two halves of the fix that can go wrong quietly:
- *
- *   - The *cache*: what must miss (a changed character, a changed theme) and
- *     what the bounds are. A memo that never misses is a correctness bug and a
- *     memo that never evicts is a leak; neither shows up as a failure on screen.
- *   - The *re-identification*: Mermaid bakes the id it is handed into the
- *     diagram's `` +
-		`` +
-		`` +
-		extra +
-		`
${source.trim()}
` + - `` - ); -} - -function libraries(record: Recorder, extraFor: (source: string, id: string) => string = () => '') { - let theme = 'neutral'; - return { - hljs: { getLanguage: () => null }, - // The renderer refuses to run without these; the diagrams are what is - // under test, so they do nothing. - katex: { renderToString: () => '' }, - renderMathInElement() {}, - mermaid: { - initialize(config: { theme: string }) { - theme = config.theme; - record.themes.push(config.theme); - }, - async render(id: string, source: string) { - record.renders.push({ id, source, theme }); - return { svg: svgFor(id, source, theme, extraFor(source, id)) }; - }, - }, - }; -} - -function articleWith(...sources: string[]): string { - return sources - .map((source) => `
${source}
`) - .join('\n'); -} - -function newRecorder(): Recorder { - return { renders: [], themes: [] }; -} - -let idCounter = 0; -/** Unique per call, like the real one, and readable in a failure message. */ -function ids(): (index: number) => string { - const run = idCounter++; - return (index: number) => `mermaid-${run}-${index}`; -} - -async function render( - html: string, - options: { record: Recorder; theme?: string; extraFor?: (source: string, id: string) => string }, -): Promise { - const root = (globalThis as any).document.createElement('div'); - root.innerHTML = html; - await renderRichContent({ - roots: [root], - libraries: libraries(options.record, options.extraFor) as any, - mermaidTheme: options.theme ?? 'neutral', - idFactory: ids(), - }); - return root; -} - -function idsIn(html: string): string[] { - return [...html.matchAll(/\sid="([^"]*)"/g)].map((match) => match[1]); -} - -// ------------------------------------------------------------------- tests - -test('a second pass over the same document draws nothing again', async () => { - resetDiagramCache(); - const article = articleWith('graph TD; A-->B;', 'graph LR; C-->D;'); - - const first = newRecorder(); - const before = await render(article, { record: first }); - assert.equal(first.renders.length, 2); - - const second = newRecorder(); - const after = await render(article, { record: second }); - assert.deepEqual(second.renders, [], 'the second pass must not call mermaid.render at all'); - - // Same drawing, not just the same count: identifiers aside, the reused SVG - // is what Mermaid produced. - const normalise = (html: string) => - html - .replace(/\sid="[^"]*"/g, ' id="X"') - .replace(/url\(#[^)]*\)/g, 'url(#X)') - .replace(/#mermaid-[\w-]*/g, '#X'); - assert.equal(normalise(after.innerHTML), normalise(before.innerHTML)); -}); - -test('changing one character of a diagram redraws it', async () => { - resetDiagramCache(); - const warm = newRecorder(); - await render(articleWith('graph TD; A-->B;'), { record: warm }); - - const typed = newRecorder(); - await render(articleWith('graph TD; A-->Bc;'), { record: typed }); - assert.deepEqual( - typed.renders.map((call) => call.source), - ['graph TD; A-->Bc;'], - ); -}); - -test('changing the theme redraws every diagram, and the old theme is still warm', async () => { - resetDiagramCache(); - const source = 'graph TD; Theme-->Switch;'; - - const light = newRecorder(); - const lightRoot = await render(articleWith(source), { record: light, theme: 'neutral' }); - assert.equal(light.renders.length, 1); - assert.match(lightRoot.innerHTML, /fill:#eee/); - - const dark = newRecorder(); - const darkRoot = await render(articleWith(source), { record: dark, theme: 'dark' }); - assert.equal(dark.renders.length, 1, 'a theme switch must not reuse the other theme’s colours'); - assert.match(darkRoot.innerHTML, /fill:#333/); - - // Switching back is a hit: both themes live in the cache at once, so a user - // toggling appearance does not pay twice. - const back = newRecorder(); - const backRoot = await render(articleWith(source), { record: back, theme: 'neutral' }); - assert.deepEqual(back.renders, []); - assert.match(backRoot.innerHTML, /fill:#eee/); -}); - -test('two copies of one diagram never share an id', async () => { - resetDiagramCache(); - const source = 'graph TD; Same-->Twice;'; - const record = newRecorder(); - - // Even on the very first pass the second copy is a cache hit, so this is the - // case the cache creates and the case it has to get right. - const root = await render(articleWith(source, source), { record }); - assert.equal(record.renders.length, 1); - - const all = idsIn(root.innerHTML); - assert.equal(all.length, 4, 'two diagrams, each with an svg id and a marker id'); - assert.equal(new Set(all).size, 4, `duplicate ids in the document: ${all.join(', ')}`); - - // The re-identification has to be complete, not just applied to the ``: - // each copy's style block and marker reference must point at its own id. - const [firstSvg, secondSvg] = root.innerHTML.split('#${svgId} .node rect`), `style block not re-pointed for ${svgId}`); - assert.ok(svg.includes(`url(#${svgId}_flowchart-pointEnd)`), `marker not re-pointed for ${svgId}`); - } -}); - -test('a reused diagram still carries its source for the PDF re-render', async () => { - resetDiagramCache(); - const source = 'graph TD; Print-->Me;'; - await render(articleWith(source), { record: newRecorder() }); - - const reused = newRecorder(); - const root = await render(articleWith(source), { record: reused }); - assert.deepEqual(reused.renders, []); - // `mermaidPrint` finds diagrams by this attribute; a cached container without - // it would silently print dark diagrams on paper. - assert.match(root.innerHTML, /class="mermaid-diagram" data-mermaid-source="graph TD; Print-->Me;"/); -}); - -test('ids Mermaid does not namespace are namespaced by the cache', async () => { - resetDiagramCache(); - // A sequence diagram's actor ids come from a module counter, not from the - // svg id — two copies of one diagram would otherwise both say `actor7`. - const source = 'sequenceDiagram; A->>B: hi'; - const record = newRecorder(); - const root = await render(articleWith(source, source), { - record, - extraFor: () => '', - }); - - assert.equal(record.renders.length, 1); - const all = idsIn(root.innerHTML); - assert.equal(new Set(all).size, all.length, `duplicate ids in the document: ${all.join(', ')}`); - // The drawn copy keeps exactly what Mermaid emitted — the cache changes - // nothing about a miss. The reused copy is the one that has to be moved out - // of the way, and it lands under its own svg id. - assert.equal(all.filter((id) => id === 'actor7').length, 1); - assert.equal(all.filter((id) => id !== 'actor7' && id.endsWith('-actor7')).length, 1); -}); - -test('a diagram whose stray id is referenced is not cached at all', async () => { - resetDiagramCache(); - // The cache renames stray declarations but does not rewrite references to - // them. If a future Mermaid points at one, the honest answer is to redraw. - const source = 'graph TD; Referenced-->Stray;'; - const record = newRecorder(); - await render(articleWith(source), { - record, - extraFor: () => '', - }); - - const again = newRecorder(); - await render(articleWith(source), { - record: again, - extraFor: () => '', - }); - assert.equal(again.renders.length, 1, 'an un-templatable diagram must be redrawn, not reused'); -}); - -test('the cache is bounded by entry count', async () => { - resetDiagramCache(); - // 128 entries is the cap. Fill past it and the first one must be gone. - const first = 'graph TD; N0-->X;'; - await render(articleWith(first), { record: newRecorder() }); - for (let i = 1; i <= 128; i++) { - await render(articleWith(`graph TD; N${i}-->X;`), { record: newRecorder() }); - } - - const evicted = newRecorder(); - await render(articleWith(first), { record: evicted }); - assert.equal(evicted.renders.length, 1, 'the oldest entry must have been evicted'); - - // …and the most recent one is still there. - const kept = newRecorder(); - await render(articleWith('graph TD; N128-->X;'), { record: kept }); - assert.deepEqual(kept.renders, []); -}); - -test('the cache is bounded by size, not only by count', async () => { - resetDiagramCache(); - // Eight ~400k-character diagrams are only eight entries but 3.2M characters, - // which is over the budget: a handful of enormous diagrams must not sit in - // memory forever just because they are few. - const bulk = (n: number) => `graph TD; Big${n}["${'x'.repeat(400_000)}"];`; - const first = bulk(0); - await render(articleWith(first), { record: newRecorder() }); - for (let i = 1; i < 8; i++) await render(articleWith(bulk(i)), { record: newRecorder() }); - - const evicted = newRecorder(); - await render(articleWith(first), { record: evicted }); - assert.equal(evicted.renders.length, 1); -}); - -test('a diagram is redrawn rather than reused when its own text spells out the slot', async () => { - resetDiagramCache(); - // The template marks where the id goes with a literal token. A diagram whose - // text contains that token cannot be templated without rewriting the user's - // own text, so it is left uncached. - const source = 'graph TD; A["{{markpad-diagram-id}}"];'; - await render(articleWith(source), { record: newRecorder() }); - const again = newRecorder(); - await render(articleWith(source), { record: again }); - assert.equal(again.renders.length, 1); -}); - -// ------------------------------------------------- the templating, on its own - -test('templating refuses anything it cannot re-identify', () => { - // The id it was told about is not the one in the markup: nothing to lift out. - assert.equal(templateDiagramSvg('', 'mine'), null); - // A referenced stray id. - assert.equal( - templateDiagramSvg('', 'mine'), - null, - ); - - const template = templateDiagramSvg('', 'mine'); - assert.ok(template); - assert.equal( - fillDiagramTemplate(template as string, 'fresh'), - '', - ); -}); diff --git a/scripts/diagramThemeRefresh.spec.ts b/scripts/diagramThemeRefresh.spec.ts deleted file mode 100644 index 18e82083..00000000 --- a/scripts/diagramThemeRefresh.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Switching theme has to re-draw the diagrams, and until this file existed it - * did not — in any mode, for any theme. - * - * Mermaid bakes fills, strokes and label colours into the SVG as attributes, so - * a stylesheet cannot retarget them; `mermaidPrint.ts` says why at length, and - * re-rendering is the answer it already reaches for on the way to a PDF. The - * preview's own theme change was supposed to do the same thing through - * `renderRichContent`, and could not: that function finds diagrams by - * `pre code`, and a diagram that has been drawn once has no `
` any more —
- * it was replaced by the `.mermaid-diagram` container holding the SVG.
- *
- * That was invisible while the preview was `{@html sanitizedHtml}`, because
- * every render put the whole document back as source. the block patch (#632)
- * keeps enriched nodes across renders instead, and from then on a theme change
- * reached no diagram at all. The `
` is gone, `patch.inserted` is empty on
- * a re-render of the same document, and typing does not help either: the
- * diagram's markup is unchanged, so the patch keeps the node.
- *
- * The libraries are stood in for at the same boundary `exportRichContent` and
- * `richContentIdempotence` use. What is under test is Markpad's bookkeeping:
- * which containers it decides are stale, and what it does to them.
- */
-
-import { describe, expect, test } from 'vitest';
-
-import { patchPreview } from '../src/lib/utils/previewPatch.js';
-import { readDiagramSource } from '../src/lib/utils/mermaidPrint.js';
-import { renderRichContent, type RichContentLibraries } from '../src/lib/utils/richContent.js';
-import { resetDiagramCache } from '../src/lib/utils/diagramCache.js';
-
-const SOURCE = 'graph TD;A-->B;';
-
-/** Records every draw so "did it re-render" is a count, not an inference. */
-function libraries(draws: string[]): RichContentLibraries {
-	let theme = 'unset';
-	return {
-		hljs: { getLanguage: () => undefined, highlight: () => ({ value: '' }) },
-		katex: { renderToString: (source: string) => source },
-		renderMathInElement: () => {},
-		mermaid: {
-			initialize: (config: { theme: string }) => {
-				theme = config.theme;
-			},
-			render: async (id: string, source: string) => {
-				draws.push(`${theme}:${source}`);
-				return { svg: `` };
-			},
-		},
-	} as any;
-}
-
-function article(html: string): HTMLElement {
-	const element = document.createElement('div');
-	element.innerHTML = html;
-	document.body.replaceChildren(element);
-	return element;
-}
-
-async function render(root: HTMLElement, mermaidTheme: string, draws: string[], seed = 0) {
-	await renderRichContent({
-		roots: [root],
-		libraries: libraries(draws),
-		mermaidTheme,
-		idFactory: (index) => `d${seed}-${index}`,
-	});
-}
-
-function drawnFor(root: ParentNode): string | null {
-	return root.querySelector('.mermaid-diagram svg')?.getAttribute('data-drawn-for') ?? null;
-}
-
-const PRE = `
${SOURCE}
`; - -describe('a theme change', () => { - test('re-draws a diagram that is already on screen', async () => { - resetDiagramCache(); - const draws: string[] = []; - const root = article(PRE); - - await render(root, 'dark', draws); - expect(drawnFor(root)).toBe('dark'); - - // The whole-article pass a theme change makes. There is no `
` left.
-		expect(root.querySelectorAll('pre code').length).toBe(0);
-		await render(root, 'neutral', draws, 1);
-
-		expect(drawnFor(root)).toBe('neutral');
-		expect(draws).toEqual([`dark:${SOURCE}`, `neutral:${SOURCE}`]);
-	});
-
-	test('leaves the source on the container, so the PDF path still works', async () => {
-		resetDiagramCache();
-		const root = article(PRE);
-		await render(root, 'dark', []);
-		await render(root, 'neutral', [], 1);
-
-		expect(readDiagramSource(root.querySelector('.mermaid-diagram')!)).toBe(SOURCE);
-	});
-});
-
-describe('a pass that is not a theme change', () => {
-	test('does not re-draw a diagram already in the theme being asked for', async () => {
-		resetDiagramCache();
-		const draws: string[] = [];
-		const root = article(PRE);
-
-		await render(root, 'dark', draws);
-		// `syncPreviewForPrint` and a re-activated tab both re-run this over the
-		// whole host without the theme having moved.
-		await render(root, 'dark', draws, 1);
-		await render(root, 'dark', draws, 2);
-
-		expect(draws).toEqual([`dark:${SOURCE}`]);
-	});
-
-	test('does not reach a diagram outside the roots it was given', async () => {
-		resetDiagramCache();
-		const draws: string[] = [];
-		const host = article(`${PRE}

text

`); - await render(host, 'dark', draws); - - // The keystroke path: the patch replaces the paragraph and hands only - // that block to the enrichment. The diagram is not in it, and a theme it - // no longer matches must not drag it into the pass. - const patch = patchPreview(document.createElement('div'), '

x

', '', new Set()); - await renderRichContent({ - roots: patch.inserted, - libraries: libraries(draws), - mermaidTheme: 'neutral', - idFactory: (index) => `k${index}`, - }); - - expect(draws).toEqual([`dark:${SOURCE}`]); - expect(drawnFor(host)).toBe('dark'); - }); -}); - -/** - * Mermaid's config is one global: `initialize` writes it at once, and `render` - * reads it only when its turn in Mermaid's own queue comes. The stand-in keeps - * that shape: one global theme, read after the draw has yielded. - */ -function queuedLibraries(): RichContentLibraries { - let theme = 'unset'; - return { - hljs: { getLanguage: () => undefined, highlight: () => ({ value: '' }) }, - katex: { renderToString: (source: string) => source }, - renderMathInElement: () => {}, - mermaid: { - initialize: (config: { theme: string }) => { - theme = config.theme; - }, - render: async (id: string) => { - await new Promise((resolve) => setTimeout(resolve, 0)); - return { svg: `` }; - }, - }, - } as any; -} - -test('two passes in different themes each draw in their own', async () => { - resetDiagramCache(); - const mermaid = queuedLibraries(); - // A preview pass and a PDF export's pass over another copy, overlapping. - const preview = article(`${PRE}
graph LR;C-->D;
`); - const print = document.createElement('div'); - print.innerHTML = PRE; - - await Promise.all([ - renderRichContent({ roots: [preview], libraries: mermaid, mermaidTheme: 'dark', idFactory: (i) => `p${i}` }), - renderRichContent({ roots: [print], libraries: mermaid, mermaidTheme: 'neutral', idFactory: (i) => `x${i}` }), - ]); - - const themes = (root: ParentNode) => - [...root.querySelectorAll('.mermaid-diagram svg')].map((svg) => svg.getAttribute('data-drawn-for')); - expect(themes(preview)).toEqual(['dark', 'dark']); - expect(themes(print)).toEqual(['neutral']); -}); - -/** - * The wiring, which the tests above cannot reach: `renderRichContent` now knows - * how to re-draw a stale diagram, but the theme effect still has to call it, in - * every mode and once per theme change. - * - * A source assertion, because the caller is a Svelte effect that cannot be - * imported. It pins today's spelling of an internal call, so a rename breaks it - * without breaking anything real; what it is here for is the guard, which was - * `markdownBody && !isEditing`. That made the whole effect — the `dataset` - * writes, `monaco.editor.setTheme`, and for a `vscode:` theme an - * `invoke('read_vscode_theme')` and a re-parse — re-run on every mode toggle - * and on every tab switch that changed the mode, with nothing about the theme - * having moved. And it excluded from the re-colour every tab in edit mode, - * including a split tab entered from edit mode, whose preview is on screen. - */ -test('the theme effect re-colours in every mode, and does not re-run on a mode change', async () => { - const { readSource } = await import('./sourceTree.js'); - const viewer: string = readSource('src/lib/MarkdownViewer.svelte'); - - const effect = viewer.match( - /\t\$effect\(\(\) => \{\n\t\t\/\/ Persistence and cross-window sync[\s\S]*?\n\t\}\);/, - )?.[0]; - expect(effect, 'the theme effect must still be recognisable').toBeTruthy(); - - // The mode is not a reason to re-apply a theme, and this is the only read - // that ever made it one. - expect(effect).not.toMatch(/isEditing/); - expect(effect).toMatch( - /const recolourDiagrams = \(\) => untrack\(\(\) => \{ if \(markdownBody\) renderRichContent\(\); \}\);/, - ); - - // Once per branch: the `vscode:` branch does not know its own appearance - // until `parseAndApplyVscodeTheme` has published `dataset.themeType`, which - // is what `currentMermaidTheme` reads. - expect(effect!.match(/recolourDiagrams\(\);/g)).toHaveLength(2); - expect(effect).toMatch(/loadVscodeTheme\([\s\S]*?recolourDiagrams\(\);/); -}); diff --git a/scripts/documentEncodingRoundTrip.spec.ts b/scripts/documentEncodingRoundTrip.spec.ts deleted file mode 100644 index 1b7ff290..00000000 --- a/scripts/documentEncodingRoundTrip.spec.ts +++ /dev/null @@ -1,269 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { functionSource, readDictionarySource, readRustBackend, readSource, sliceBetween } from './sourceTree.js'; - -/* - * #372: a document in a legacy encoding (GBK, Big5, Shift-JIS, CP-1252 …) is - * detected and decoded on open, and a save writes it back as the SAME - * encoding — the behaviour Notepad, VS Code and Typora all have, and the one - * an app that calls itself "the Notepad equivalent for Markdown" is measured - * against. - * - * The decoding and encoding themselves are Rust and are tested there - * (`saving_an_unedited_legacy_document_reproduces_its_canonical_bytes` is the - * one that matters). What has to hold on this side is the wiring, because it - * is the wiring that decides which bytes reach the file: - * - * read → tab.encoding → save - * - * A break anywhere in that chain does not fail loudly. It writes the user's - * GBK document back as UTF-8, silently, on an auto-save 1.5s after the first - * keystroke — no error, no toast, and every other tool that opens the file - * afterwards sees mojibake. So the chain is driven here end to end rather - * than read for. - */ - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin, so the store and the session run under real reactivity, and jsdom -// supplies `window` and `localStorage`. Only the Tauri backend is stubbed. - -/** What the backend reports for the next read. Set per test. */ -let fileEncoding = 'UTF-8'; -let fileLossy = false; -const BODY = '# 中文标题'; - -/** Every `save_file_content` the session issued, in order. */ -let writes: Array<{ path: string; content: string; encoding: string }> = []; - -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (command: string, args: Record) => { - const cmd = command.replace(/^plugin:[^|]*\|/, ''); - if (cmd === 'get_os_type') return Promise.resolve('macos'); - if (cmd === 'canonicalize_path') return Promise.resolve(args.path); - if (cmd === 'open_markdown_preview') { - return Promise.resolve([BODY, true, fileLossy, fileEncoding]); - } - if (cmd === 'read_file_content_checked') return Promise.resolve([BODY, fileLossy, fileEncoding]); - if (cmd === 'save_file_content') { - writes.push(args as unknown as { path: string; content: string; encoding: string }); - return Promise.resolve(null); - } - return Promise.resolve(null); - }, -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); -const { buildTransferredTab, snapshotTab, validateTransferPayload } = await import( - '../src/lib/utils/tabTransfer.js' -); - -function makeSession() { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: () => {}, - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: () => {}, - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - }); -} - -/** A tab opened from a file the backend reports as `encoding`. */ -async function openedAs(encoding: string, lossy = false) { - tabManager.closeAll(); - writes = []; - fileEncoding = encoding; - fileLossy = lossy; - const session = makeSession(); - await session.loadMarkdown('/notes/legacy.md'); - return { session, tab: tabManager.activeTab! }; -} - -test('opening a legacy document records the encoding it was read in', async () => { - const { tab } = await openedAs('GBK'); - assert.equal(tab.encoding, 'GBK'); - assert.equal(tab.hasReplacementChars, false, 'a detected encoding decodes; it does not substitute'); -}); - -test('saving that document writes it back as itself, not as UTF-8', async () => { - // The whole issue in one assertion. Under the old behaviour this write - // carried the buffer and nothing else, so the file became UTF-8 — or, - // before the buffer was decoded at all, U+FFFD. - const { session, tab } = await openedAs('GBK'); - - tabManager.updateTabRawContent(tab.id, `${BODY}\n\nedited`); - assert.equal(await session.saveContent(tab.id), true); - - assert.deepEqual(writes.map((write) => write.encoding), ['GBK']); - assert.equal(writes[0].path, '/notes/legacy.md'); -}); - -test('a UTF-8 document is still written as UTF-8', async () => { - // The fence. Everything above passes for a build that writes some - // arbitrary encoding to every file it touches. - const { session, tab } = await openedAs('UTF-8'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8']); -}); - -test('a byte order mark is a property of the file, so it is carried too', async () => { - // Windows-authored Markdown, and the case a plain "is it UTF-8?" test - // misses: the text decodes perfectly, and dropping the BOM on save still - // changes the file for every tool that reads it. - const { session, tab } = await openedAs('UTF-8-BOM'); - assert.equal(tab.encoding, 'UTF-8-BOM'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8-BOM']); -}); - -test('a re-read repoints the save at what the file is NOW', async () => { - // The user converted the file to UTF-8 in another program. The tab must - // stop writing GBK, and it learns that from the read rather than from - // remembering how it was first opened. - const { session, tab } = await openedAs('GBK'); - - fileEncoding = 'UTF-8'; - await session.loadMarkdown('/notes/legacy.md'); - assert.equal(tabManager.activeTab!.encoding, 'UTF-8'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8']); -}); - -test('an untitled buffer has an encoding before anything has been read', async () => { - // Required, not optional, on the Tab — so a buffer that never came from a - // file still answers, and answers the only thing it could be. - tabManager.closeAll(); - tabManager.addNewTab(); - assert.equal(tabManager.activeTab!.encoding, 'UTF-8'); -}); - -test('a moved tab keeps its encoding across the window boundary', async () => { - // The buffer is a decode of a file in this encoding; the destination - // window auto-saves it. A payload that dropped the field would rewrite the - // user's GBK document as UTF-8 the moment it landed. - const { tab } = await openedAs('Shift_JIS'); - - const payload = JSON.stringify(snapshotTab(tab)); - const validated = validateTransferPayload(payload); - assert.notEqual(validated, null, 'precondition: the payload validates'); - assert.equal(buildTransferredTab(validated!, [], 'Untitled').encoding, 'Shift_JIS'); -}); - -test('a payload without an encoding is rejected rather than defaulted', async () => { - // Same rule as `hasReplacementChars`: a missing field must not be read as - // "UTF-8, go ahead", because that is a write to the user's file. - const { tab } = await openedAs('Big5'); - const { encoding, ...withoutEncoding } = snapshotTab(tab); - void encoding; - - assert.equal(validateTransferPayload(JSON.stringify(withoutEncoding)), null); -}); - -// ---------------------------------------------------------- the status bar -// -// Detection without an indicator is worse than no detection: a GBK document -// now opens, reads correctly and saves back as GBK, and the slot that is -// supposed to name the encoding was still rendering the literal `UTF-8` it -// had rendered since before Markpad could open the file at all. The one place -// the user could have caught a misdetection was asserting the opposite. -// -// This is the same defect #540 fixed one slot to the left, where the line -// ending was hardcoded `CRLF`. - -test('the status bar names the encoding the document was decoded from', () => { - const editor = readSource('src/lib/components/Editor.svelte'); - const statusBar = sliceBetween(editor, '
', '
\n{/if}'); - - assert.match(statusBar, /
\{encoding\}<\/div>/, 'the slot shows the state'); - assert.doesNotMatch(editor, /editor\.status\.utf8/, 'the hardcoded UTF-8 string is gone'); - - // Off the tab, not off Monaco: the encoding belongs to the file the buffer - // was decoded from, and the store is what carries it through a save. - assert.match( - editor, - /let encoding = \$derived\(tabManager\.activeTab\?\.encoding \?\? 'UTF-8'\)/, - 'and it is read from the tab rather than kept as a synced copy', - ); -}); - -test('no locale still translates a hardcoded UTF-8 label', () => { - // A dead key reads like a supported feature to whoever finds it next. - assert.doesNotMatch(readDictionarySource(), /utf8:/); -}); - -// ------------------------------------------- saying why a save was refused -// -// Refusing is only half of it. The user pasted an emoji into a GBK document -// and got "auto-save failed, unsaved changes still in memory" — true, and no -// help at all: it names neither the character nor the encoding, and the way -// out (Save As, which writes UTF-8) is not in it. -// -// Rust sends a code and this side owns the wording, so the reason a save was -// refused reaches the user in the language the rest of the app is in. The -// generic English `Failed to save file: ` stays for genuine I/O -// failures, which the user cannot act on beyond reading the OS. - -test('the unmappable-character refusal crosses as a code, not as English prose', () => { - const rust = readRustBackend(); - assert.match(rust, /const UNMAPPABLE_CODE: &str = "ENCODING_UNMAPPABLE";/); - assert.match(rust, /return Err\(UNMAPPABLE_CODE\.to_owned\(\)\)/, 'a marker, carrying nothing'); - - // The sentence it replaced must not come back: a translated toast and an - // English one saying the same thing is worse than either alone. - assert.doesNotMatch(rust, /cannot represent every character it now contains/); -}); - -test('every locale can say which encoding refused the save', () => { - const i18n = readDictionarySource(); - const locales = i18n.match(/lossySaveBlocked:/g) ?? []; - const translated = i18n.match(/encodingUnmappable:/g) ?? []; - - assert.ok(locales.length >= 5, 'precondition: the sibling refusal is translated'); - assert.equal(translated.length, locales.length, 'both refusals reach the same languages'); - - // The encoding is named, not implied — "some characters cannot be saved" - // leaves the user with nothing to search for. - for (const line of i18n.split('\n').filter((l) => l.includes('encodingUnmappable:'))) { - assert.match(line, /\{\{encoding\}\}/, `no encoding placeholder: ${line.trim().slice(0, 60)}`); - } -}); - -test('the code is turned into the translation, and only that code is', () => { - const session = readSource('src/lib/sessions/documentSession.svelte.ts'); - const describe = functionSource(session, 'describeSaveFailure'); - - assert.match(describe, /toast\.encodingUnmappable/); - assert.match(describe, /replace\('\{\{encoding\}\}', tab\.encoding\)/, - 'the encoding comes off the tab, not back out of the error'); - assert.match(describe, /return \{ message: 'Failed to save file', detail: error \}/, - 'an I/O failure still reads as one, OS message and all'); - - // `onError` renders `${message}: ${detail}`, so returning the raw error as - // the detail printed the code the user was never meant to see: - // …请用"另存为"写入一份 UTF-8 副本: ENCODING_UNMAPPABLE:GBK - // The translated branch sends the document's path instead, which is what - // `toast.lossySaveBlocked` has always done. - assert.match(describe, /detail: tab\.path,/, 'the marker never reaches the toast'); -}); diff --git a/scripts/documentLoadFailure.spec.ts b/scripts/documentLoadFailure.spec.ts deleted file mode 100644 index 62157145..00000000 --- a/scripts/documentLoadFailure.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -// A read can fail on a file that is perfectly fine a moment later: a watcher -// fires while another process is replacing the file, a network volume blinks, -// a sync client swaps the inode. Closing the tab on that error throws away the -// buffer the user could still have saved somewhere else, and dropping the -// recent-file entry throws away the way back to it. -// -// This was asserted by matching documentSession for `/tabManager\.closeTab/` -// and for the exact `options.onError('Error loading file', error);` call. The -// first is satisfied by any file that happens not to contain those characters -// — including one that closes the tab through a differently spelled route — -// and the second pins a string literal. `createDocumentSession` takes its -// collaborators as plain callbacks, and eleven other files in this suite -// already build one, so the failure can be run instead of described. - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin, so the store and the session run under real reactivity. Only -// the Tauri backend, which jsdom cannot provide, is stubbed. -let handleInvoke: (cmd: string, args: any) => unknown = (cmd) => { - throw new Error(`unexpected invoke: ${cmd}`); -}; -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (cmd: string, args: any) => Promise.resolve(handleInvoke(cmd, args)), -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); - -const errors: { message: string; error: unknown }[] = []; -const deletedRecents: string[] = []; - -function makeSession(overrides: Record = {}) { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: (path: string) => void deletedRecents.push(path), - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: (message: string, error: unknown) => void errors.push({ message, error }), - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - ...overrides, - }); -} - -/** Every read of `path` fails; anything else the load path asks for is absent too. */ -function readsFail() { - handleInvoke = () => { - throw new Error('No such file or directory (os error 2)'); - }; -} - -test('a read that fails keeps the open tab, its buffer and its recent entry', async () => { - tabManager.closeAll(); - errors.length = 0; - deletedRecents.length = 0; - - const session = makeSession(); - tabManager.addTab('/notes/a.md', 'the text the reader can still rescue'); - const before = tabManager.activeTab!; - readsFail(); - - await session.loadMarkdown('/notes/a.md'); - - assert.equal(tabManager.tabs.length, 1, 'the tab is not closed'); - assert.equal(tabManager.tabs[0].id, before.id, 'and it is the same tab, not a replacement'); - assert.equal( - tabManager.tabs[0].rawContent, - 'the text the reader can still rescue', - 'the buffer is not replaced by the failed read', - ); - assert.deepEqual(deletedRecents, [], 'the recent-file entry survives, so the file can be reopened'); -}); - -test('the failure is reported rather than swallowed', async () => { - tabManager.closeAll(); - errors.length = 0; - - const session = makeSession(); - tabManager.addTab('/notes/a.md', 'buffer'); - readsFail(); - - await session.loadMarkdown('/notes/a.md'); - - assert.equal(errors.length, 1, 'exactly one error reaches the caller'); - assert.equal(errors[0].message, 'Error loading file'); - assert.ok(errors[0].error instanceof Error, 'the cause is passed on, not flattened to a string'); -}); diff --git a/scripts/documents.checks.spec.ts b/scripts/documents.checks.spec.ts new file mode 100644 index 00000000..d1a5856b --- /dev/null +++ b/scripts/documents.checks.spec.ts @@ -0,0 +1,1071 @@ +import assert from 'node:assert/strict'; + +import ts from 'typescript'; +import { afterAll, beforeAll, describe, test } from 'vitest'; + +import { reviewDirtyTabs } from '../src/lib/sessions/closeReview.js'; +import { sourceAtPoint } from '../src/lib/utils/previewCursor.js'; +import { callbackBodies, functionSource, offsetOf, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; + +// Each describe installs its own Tauri stub in `beforeAll`. This one only +// answers the settings store, which boots when the modules below are imported. +(window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null), +}; + +const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); +const { settings } = await import('../src/lib/stores/settings.svelte.js'); +const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); + +// An unanswered external-change conflict is a question only the user can +// answer. The viewer's saves that nobody asked for — leaving the editor or +// split view, and the exit's auto-save — used to go through the same wrapper as +// Cmd+S, which authorises the overwrite whenever the bar is up. So Ctrl+E or +// quitting wrote the buffer over the other program's change and took the bar +// down without asking. +// +// The functions are lifted out of MarkdownViewer.svelte and RUN against the +// real document session, so what is asserted is what reaches the disk. +describe('silent saves keep conflicts', () => { + const disk = new Map(); + + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string, args: any) => { + if (cmd === 'canonicalize_path') return Promise.resolve(args.path); + if (cmd === 'read_file_content_checked') return Promise.resolve([disk.get(args.path) ?? '', false, 'UTF-8']); + if (cmd === 'open_markdown_preview') return Promise.resolve([disk.get(args.path) ?? '', true, false, 'UTF-8']); + if (cmd === 'save_file_content') { + disk.set(args.path, args.content); + return Promise.resolve(null); + } + if (cmd === 'get_os_type') return Promise.resolve('macos'); + return Promise.resolve(null); + }, + }; + }); + + const LIFTED = [ + 'hasExternalChangeConflict', + 'noteExternalChangeConflict', + 'clearExternalChangeConflict', + 'resolveExternalChangeByKeepingBuffer', + 'saveContent', + 'saveSilently', + 'flushBeforeLeavingEditableMode', + 'settleForExit', + ]; + + function makeViewer() { + const closeQuestions: boolean[] = []; + const toasts: string[] = []; + const scope: Record = { + tabManager, + externalChangeConflicts: {}, + comparison: null, + settings: { autoSave: true, language: 'en', restoreStateOnReopen: false }, + t: (key: string) => key, + addToast: (message: string) => void toasts.push(message), + invoke: async () => null, + tick: async () => {}, + reviewDirtyTabs, + isCloseWalkActive: false, + pinFilesAtClose: null, + openFilePaths: () => [], + savePinnedTagIfNeeded: async () => {}, + persistWindowState: async () => {}, + }; + const session = createDocumentSession({ + setShowHome: () => {}, + currentFile: () => tabManager.activeTab?.path ?? '', + resetScrollHistory: () => {}, + renderMarkdown: async (raw: string) => raw, + afterLoad: async () => {}, + saveRecentFile: () => {}, + deleteRecentFile: () => {}, + setLoadingTabs: () => {}, + measureInitialViewport: () => {}, + isScrolling: () => false, + onError: () => {}, + onDiskChangedUnderSave: (id: string) => (fns.noteExternalChangeConflict as (id: string) => void)(id), + cancelPendingAutoSave: () => {}, + askClose: async (_title: string, diskMoved: boolean) => { + closeQuestions.push(diskMoved); + return 'cancel' as const; + }, + onCloseSaveNewerEdits: () => {}, + onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, + }); + scope.documentSession = session; + scope.canCloseTab = (id: string) => session.canCloseTab(id); + + const source = readSource('src/lib/MarkdownViewer.svelte'); + const declarations = LIFTED.map((name) => `const ${name} = ${functionSource(source, name)};`).join('\n'); + const js = ts.transpileModule(declarations, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).outputText; + const fns = new Function('scope', `with (scope) { ${js}\nreturn { ${LIFTED.join(', ')} }; }`)( + new Proxy(scope, { has: (target, key) => key in target }), + ) as Record Promise>; + return { fns, scope, closeQuestions, toasts }; + } + + /** a.md was saved, another program then changed it, and the user typed once more. */ + function conflicted(viewer: ReturnType) { + tabManager.closeAll(); + disk.clear(); + disk.set('/notes/a.md', 'saved'); + tabManager.addTab('/notes/a.md', 'saved'); + const tab = tabManager.activeTab!; + tab.originalContent = 'saved'; + tab.isEditing = true; + disk.set('/notes/a.md', 'theirs'); + tabManager.updateTabRawContent(tab.id, 'saved!'); + (viewer.fns.noteExternalChangeConflict as unknown as (id: string) => void)(tab.id); + return tab; + } + + test('leaving the editor keeps the edit in the buffer and the bar up', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + await viewer.fns.flushBeforeLeavingEditableMode(tab); + + assert.equal(disk.get('/notes/a.md'), 'theirs', 'the other program\'s change was overwritten'); + assert.equal(tab.rawContent, 'saved!'); + assert.equal(viewer.fns.hasExternalChangeConflict(tab.id), true, 'the bar came down'); + assert.deepEqual(viewer.toasts, [], 'the bar already says why nothing was written'); + }); + + test('closing the window asks about a conflicted tab instead of saving it', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + assert.equal(await viewer.fns.settleForExit(), false); + + assert.equal(disk.get('/notes/a.md'), 'theirs', 'the other program\'s change was overwritten'); + assert.equal(tab.rawContent, 'saved!'); + assert.deepEqual(viewer.closeQuestions, [true], 'the walk asked the changed-on-disk question'); + }); + + test('the exit still auto-saves the other dirty tabs', async () => { + const viewer = makeViewer(); + conflicted(viewer); + disk.set('/notes/b.md', 'b'); + tabManager.addTab('/notes/b.md', 'b'); + const b = tabManager.activeTab!; + b.originalContent = 'b'; + tabManager.updateTabRawContent(b.id, 'b edited'); + + await viewer.fns.settleForExit(); + + assert.equal(disk.get('/notes/b.md'), 'b edited'); + assert.equal(disk.get('/notes/a.md'), 'theirs'); + }); + + test('Cmd+S on a conflicted tab is still the answer "keep mine"', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + assert.equal(await viewer.fns.saveContent(tab.id), true); + + assert.equal(disk.get('/notes/a.md'), 'saved!'); + assert.equal(viewer.fns.hasExternalChangeConflict(tab.id), false); + }); + + test('"Keep mine" from the comparison answers for the tab it was opened on', async () => { + const viewer = makeViewer(); + const a = conflicted(viewer); + // The user switched tabs with the comparison open: b.md is in front, edited, + // and another program has just written it too. + disk.set('/notes/b.md', 'b'); + tabManager.addTab('/notes/b.md', 'b'); + const b = tabManager.activeTab!; + b.originalContent = 'b'; + tabManager.updateTabRawContent(b.id, 'b edited'); + disk.set('/notes/b.md', 'b theirs'); + + // What the overlay's button passes: the tab the comparison was opened on. + await viewer.fns.resolveExternalChangeByKeepingBuffer(a.id); + await viewer.fns.saveSilently(b.id); + + assert.equal(disk.get('/notes/b.md'), 'b theirs', 'b.md was overwritten on an answer given about a.md'); + assert.equal(await viewer.fns.saveSilently(a.id), true, 'a.md did not get the answer'); + }); + + test('a conflict does not follow its tab onto another file', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + // A link in a.md: the close dialog is answered Don't Save, and b.md loads + // into the same tab. + disk.set('/notes/b.md', 'b'); + tab.rawContent = tab.originalContent; + await (viewer.scope.documentSession as { loadMarkdown: (path: string, options: object) => Promise }) + .loadMarkdown('/notes/b.md', { navigate: true }); + assert.equal(tab.path, '/notes/b.md'); + + // b.md is edited, and changed by another program before the watcher says so. + tabManager.updateTabRawContent(tab.id, 'b edited'); + disk.set('/notes/b.md', 'b theirs'); + + assert.equal(await viewer.fns.saveContent(tab.id), false); + assert.equal(disk.get('/notes/b.md'), 'b theirs', 'Cmd+S used the answer about a.md to overwrite b.md'); + }); +}); + +/* + * A keystroke reaches the document by ONE route, and the dirty flag is not a + * flag. + * + * THE DEFECT. Typing used to write the buffer twice. `MarkdownViewer` passed + * the editor `bind:value={tabManager.activeTab.rawContent}`, so Svelte's + * assignment put the new text on the tab; the same listener then called + * `tabManager.updateTabRawContent`, which put it there again and recomputed + * `isDirty` from it. Both writes carried the same text, so the app worked — and + * it worked *because* Svelte's assignment happened to run first. Delete the + * store call and the editor still edited, the preview still re-rendered, and + * nothing maintained `isDirty`: no dirty dot, no auto-save, no unsaved-changes + * prompt on close, and no type error anywhere to say so. + * + * WHAT IT IS NOW. `value` is a plain prop — a getter over the parent's + * `tabManager.activeTab.rawContent` — and the listener's call to the store is + * the only write. Remove it and the editor stops editing the document, which is + * a failure you can see; these tests are the ones that see it first. + * + * `isDirty` went the same way: a getter on the tab, exactly + * `rawContent !== originalContent`, so nothing can hold an opinion about + * "changed?" that its two buffers do not support. + * + * WHAT RUNS HERE. The component's own `onDidChangeModelContent` listener, + * lifted out of Editor.svelte and evaluated against the REAL `TabManager`. Only + * Monaco is a fake, and only the two methods the listener touches. + */ +describe('editor writes through the store', () => { + // ---------------------------------------------------------------- environment + // Svelte runes and the Tauri bridge, faked as the other lifting tests do, so + // `tabs.svelte.ts` imports under plain node. + + // The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the + // Svelte plugin, so the store and the session run under real reactivity, and jsdom + // supplies `window` and `localStorage`. Only the Tauri backend is stubbed. + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null), + }; + }); + + // ------------------------------------------------- the component, as written + + const EDITOR = 'src/lib/components/Editor.svelte'; + const source = readSource(EDITOR); + + const contentChangeBody = (() => { + const bodies = callbackBodies(source, 'editor.onDidChangeModelContent'); + assert.equal(bodies.length, 1, `expected exactly one model-content listener in ${EDITOR}, found ${bodies.length}`); + return bodies[0]; + })(); + + /** + * The lifted body is TypeScript and `new Function` only parses JavaScript, so + * it goes through `tsc` first, as undoHistoryPerTab.spec.ts and + * imageUndoKeepsFile.spec.ts already do. Types are erased, nothing else: the + * statements that run are the component's. + */ + const factorySource = ts.transpileModule( + `const __component = (tabManager, editor) => { + // The status bar readings. Stubbed rather than lifted because they are + // the listener's other errand and have their own tests; what is under + // test here is where the text goes. + const syncStatusFromModel = () => {}; + let emittedText = null; + return { contentChanged: () => ${contentChangeBody} }; + };`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText; + + /** + * The `value` prop, modelled as what Svelte compiles a dynamic prop to: a + * getter over the parent's expression, `value={tabManager.activeTab.rawContent}`. + * Modelling it as a local copy would be re-introducing the second buffer this + * whole change removed. + * + * A null-prototype object so `with` captures this one name and not + * `toString`, `constructor` and the rest of Object.prototype. + */ + const propsScope = Object.defineProperty(Object.create(null), 'value', { + get: () => tabManager.activeTab?.rawContent ?? '', + }); + + function editorShowing(text: string) { + let onScreen = text; + const editor = { getValue: () => onScreen }; + const factory = new Function( + '__props', + `with (__props) { ${factorySource}\nreturn __component; }`, + )(propsScope) as (tabManager: unknown, editor: unknown) => { contentChanged: () => void }; + const component = factory(tabManager, editor); + + return { + /** A keystroke: Monaco's buffer changes, then it fires the listener. */ + type: (next: string) => { + onScreen = next; + component.contentChanged(); + }, + }; + } + + function activeTab() { + const tab = tabManager.activeTab; + assert.ok(tab, 'precondition: a tab is active'); + return tab!; + } + + // ------------------------------------------------------------------ the tests + + test('a keystroke reaches the document, and the document knows it is unsaved', () => { + tabManager.closeAll(); + tabManager.addTab('/notes/a.md', 'saved text'); + const editor = editorShowing('saved text'); + + assert.equal(activeTab().isDirty, false, 'precondition: a freshly opened tab is clean'); + + editor.type('saved text and more'); + + assert.equal( + activeTab().rawContent, + 'saved text and more', + 'the keystroke never reached the document: the editor is writing to something else, or to nothing', + ); + assert.equal( + activeTab().isDirty, + true, + 'the document has unsaved edits and does not say so — no dirty dot, no auto-save, no prompt on close', + ); + }); + + test('typing the saved text back makes the tab clean again, with nobody clearing a flag', () => { + // The point of deriving it. Undo, or retyping what was deleted, leaves a + // buffer that IS the saved text — and the only thing that has to happen for + // the tab to say so is that the two buffers agree. + tabManager.closeAll(); + tabManager.addTab('/notes/b.md', 'saved text'); + const editor = editorShowing('saved text'); + + editor.type('saved text!'); + assert.equal(activeTab().isDirty, true); + + editor.type('saved text'); + assert.equal(activeTab().isDirty, false, 'the buffer is the saved text and the tab still reads dirty'); + }); + + test('following a link does not leave the tab dirty against the file it just left', () => { + // `navigate` repoints the tab at another file before the load lands, so for + // that window `path` is the new file and `rawContent` is the old document. + // A tab left dirty there arms the auto-save effect, which writes the + // document the reader left into the file they opened. + tabManager.closeAll(); + tabManager.addTab('/notes/c.md', 'saved text'); + const editor = editorShowing('saved text'); + editor.type('unsaved edits'); + + tabManager.navigate(activeTab().id, '/notes/linked.md'); + + assert.equal(activeTab().path, '/notes/linked.md'); + assert.equal(activeTab().isDirty, false, 'the tab claims unsaved edits to a document it no longer holds'); + }); +}); + +// Issue #168, second report by @dayeggpi: "allow user to switch to rendered +// view without saving/creating file ... no way to see rendered view until file +// is saved". +// +// The cause was that reading mode was drawn from DISK: leaving the editor +// called `loadMarkdown(tab.path)`, so a dirty tab had to be flushed first — +// silently with auto-save on, through a modal otherwise — or the reader would +// have shown the pre-edit file. The modal was never protecting the buffer; the +// buffer survives a view toggle. It was protecting the screen from lying. +// +// These tests run the REAL `toggleEdit` / `toggleSplitView` / the real +// `renderTabPreviewFromRaw` out of MarkdownViewer.svelte against the real +// TabManager, with the disk, the renderer and the modal faked. `loadMarkdown` +// is faked to behave like the real one (dirty short-circuit included) and to +// serve a DIFFERENT text than the buffer, so any route that goes back to the +// file is visible in the rendered output rather than merely in the call log. +// +// The boundary tests at the bottom are the other half of the argument: closing +// a tab and closing the window still ask, because there the buffer really is +// about to disappear. +describe('view mode without saving', () => { + // ---------------------------------------------------------------- environment + + // The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the + // Svelte plugin, so the store and the session run under real reactivity, and jsdom + // supplies `window` and `localStorage`. Only the Tauri backend is stubbed. + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: () => Promise.reject(new Error('no invoke expected in these tests')), + }; + }); + + // `settings` is one store for the whole file: put back what this block changes. + let settingsBefore: { autoSave: typeof settings.autoSave }; + beforeAll(() => { + settingsBefore = { autoSave: settings.autoSave }; + }); + afterAll(() => { + Object.assign(settings, settingsBefore); + }); + + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + // ------------------------------------------------------------ source plucking + + /** + * Slice one `async function (...) { ... }` out of the component. + * + * Brace counting, but string/template/comment aware — a naive count trips over + * the first `{` inside a comment or a template literal and silently returns a + * body that stops in the middle, which would make every assertion below + * meaningless in exactly the direction that hides bugs. + */ + function pluck(name: string, required = true): string { + const asyncStart = viewer.indexOf(`async function ${name}(`); + const syncStart = viewer.indexOf(`\tfunction ${name}(`); + const start = asyncStart !== -1 ? asyncStart : syncStart === -1 ? -1 : syncStart + 1; + if (start === -1) { + assert.ok(!required, `expected MarkdownViewer.svelte to define ${name}`); + return ''; + } + let i = offsetOf(viewer, '{', offsetOf(viewer, ')', start)); + let depth = 0; + for (; i < viewer.length; i++) { + const c = viewer[i]; + const next = viewer[i + 1]; + if (c === '/' && next === '/') { + i = viewer.indexOf('\n', i); + continue; + } + if (c === '/' && next === '*') { + i = viewer.indexOf('*/', i) + 1; + continue; + } + if (c === "'" || c === '"' || c === '`') { + const quote = c; + for (i++; i < viewer.length; i++) { + if (viewer[i] === '\\') i++; + else if (viewer[i] === quote) break; + } + continue; + } + if (c === '{') depth++; + else if (c === '}' && --depth === 0) return viewer.slice(start, i + 1); + } + assert.fail(`unbalanced braces while slicing ${name}`); + } + + type Harness = { + toggleEdit: () => Promise; + toggleSplitView: (tabId: string) => Promise; + }; + + type Fakes = { + disk: Map; + askCustomCalls: string[]; + saveCalls: string[]; + loadCalls: string[]; + renderedFrom: string[]; + toasts: string[]; + saveFails: boolean; + /** Text the "user" appends while `saveContent` is awaiting (TOCTOU). */ + typeDuringSave: string; + }; + + /** `renderMarkdownPreview`'s stand-in: the output names what it was given. */ + const rendered = (raw: string) => `${raw}`; + + function buildHarness(fakes: Fakes, isEditing: boolean, panes: Record = {}): Harness { + const source = [ + pluck('renderTabPreviewFromRaw'), + // Absent on the pre-fix baseline, where both toggles inline their own + // save/modal flow. Optional so this file runs — and fails on behaviour + // rather than on a missing symbol — against either version. + pluck('flushBeforeLeavingEditableMode', false), + pluck('renderPreviewLeavingEditableMode', false), + pluck('toggleEdit'), + pluck('toggleSplitView'), + ].join('\n\n'); + + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + + const factory = new Function( + 'deps', + `"use strict"; + const { + tabManager, settings, t, addToast, askCustom, saveSilently, hasExternalChangeConflict, + cancelPendingAutoSave, renderMarkdownPreview, loadMarkdown, + documentSession, invoke, isEditing, liveMode, toggleLiveMode, + tick, renderRichContent, editorPane, markdownBody, + getPreviewScrollSyncPosition, restoreAfterLeavingEditor, + } = deps; + let previewPlacing = false; + ${js} + return { toggleEdit, toggleSplitView };`, + ); + + return factory({ + tabManager, + settings, + isEditing, + hasExternalChangeConflict: () => false, + t: (key: string) => key, + addToast: (message: string) => fakes.toasts.push(message), + askCustom: async (message: string) => { + fakes.askCustomCalls.push(message); + return 'save' as const; + }, + // Mirrors documentSession.saveContent for a tab that has a path. + saveSilently: async (tabId: string) => { + fakes.saveCalls.push(tabId); + const tab = tabManager.tabs.find((item) => item.id === tabId)!; + if (fakes.saveFails) return false; + const snapshot = tab.rawContent; + await Promise.resolve(); + if (fakes.typeDuringSave) tab.rawContent = snapshot + fakes.typeDuringSave; + fakes.disk.set(tab.path, snapshot); + tab.originalContent = snapshot; + return true; + }, + cancelPendingAutoSave: () => {}, + renderMarkdownPreview: async (raw: string) => { + fakes.renderedFrom.push(raw); + return rendered(raw); + }, + // The disk route, with the real function's dirty short-circuit. + loadMarkdown: async (path: string, options: any = {}) => { + fakes.loadCalls.push(path); + const activeId = tabManager.activeTabId!; + const receiving = tabManager.tabs.find((item) => item.id === activeId)!; + if (receiving.isDirty && receiving.path === path && !options.discardUnsavedBuffer) return; + const content = fakes.disk.get(path) ?? ''; + fakes.renderedFrom.push(content); + tabManager.updateTabContent(activeId, rendered(content)); + tabManager.setTabRawContent(activeId, content); + }, + documentSession: { ensureFullContent: async () => true, isLossySaveRefused: () => false }, + invoke: async () => { + throw new Error('no invoke expected while leaving an editable pane'); + }, + liveMode: false, + toggleLiveMode: () => {}, + tick: async () => {}, + // No panes unless a test asks: where the reader lands (#799) is not + // what the rest of this file is about. + editorPane: undefined, + markdownBody: undefined, + ...panes, + }); + } + + function freshFakes(): Fakes { + return { + disk: new Map(), + askCustomCalls: [], + saveCalls: [], + loadCalls: [], + renderedFrom: [], + toasts: [], + saveFails: false, + typeDuringSave: '', + }; + } + + const ON_DISK = '# saved heading\n'; + const IN_BUFFER = '# saved heading\n\nan edit that was never written to disk\n'; + + /** + * A tab holding unsaved edits to a real file, in whichever editable pane the + * caller names, with the file on disk still carrying the pre-edit text. + */ + function dirtyTab(mode: 'edit' | 'split', path = '/notes/note.md') { + tabManager.closeAll(); + const fakes = freshFakes(); + fakes.disk.set(path, ON_DISK); + tabManager.addTab(path); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, ON_DISK); + tab.isEditing = mode === 'edit'; + tabManager.setSplitEnabled(tab.id, mode === 'split'); + tabManager.updateTabRawContent(tab.id, IN_BUFFER); + assert.equal(tab.isDirty, true, 'precondition: the tab is dirty'); + return { tab, fakes, harness: buildHarness(fakes, mode === 'edit') }; + } + + /** + * `autoSave` used to be half of a pair — the other, `confirmBeforeSave`, could + * veto it, and every decision in the app read `autoSave && !confirmBeforeSave`. + * The pair is now the one switch that expression always described, so the two + * old combinations that meant "do not write silently" are the single `false` + * here. + */ + function setSettings(autoSave: boolean) { + settings.autoSave = autoSave; + } + + // ------------------------------------------------------- leaving edit mode + + test('a dirty file switches to reading mode without a modal and without writing', async () => { + setSettings(false); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.askCustomCalls, [], 'no unsaved-changes modal on a view toggle'); + assert.deepEqual(fakes.saveCalls, [], 'nothing is written to disk'); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isEditing, false, 'the user actually reaches reading mode'); + assert.equal(tab.isDirty, true, 'the edits are still unsaved, and still flagged as such'); + assert.equal(tab.rawContent, IN_BUFFER, 'the buffer is intact'); + }); + + test('reading mode shows the buffer, not the file', async () => { + setSettings(false); + const { tab, harness, fakes } = dirtyTab('edit'); + + await harness.toggleEdit(); + + // The whole point of the bug: with a disk read here, this is `ON_DISK`. + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.deepEqual( + fakes.renderedFrom, + [IN_BUFFER], + 'the preview is rendered once, from the buffer', + ); + assert.deepEqual(fakes.loadCalls, [], 'the file is not re-read to leave the editor'); + }); + + test('an untitled buffer never reaches the Save dialog on a view toggle', async () => { + setSettings(true); + tabManager.closeAll(); + const fakes = freshFakes(); + tabManager.addTab(''); + const tab = tabManager.activeTab!; + tab.isEditing = true; + tabManager.updateTabRawContent(tab.id, '# untitled\n'); + const harness = buildHarness(fakes, true); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [], 'saveContent would open the Save dialog for a pathless tab'); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered('# untitled\n')); + }); + + test('auto-save still flushes on the way out, because the debounce is about to be dropped', async () => { + // The auto-save effect requires `isEditing || isSplit`, so this is the last + // chance to honour "save automatically" for these edits. + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.equal(fakes.disk.get(tab.path), IN_BUFFER, 'the flush actually wrote the buffer'); + assert.deepEqual(fakes.askCustomCalls, [], 'the flush is silent — it is the user\'s own setting'); + assert.equal(tab.isDirty, false); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('a file that cannot be written no longer traps the user in the editor', async () => { + // Read-only path, or a buffer the lossy-decode guard refuses: saveContent + // returns false forever, and the old code returned early on that, so + // reading mode was unreachable for the life of the tab. + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.saveFails = true; + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id], 'the flush was attempted'); + assert.ok(fakes.toasts.includes('toast.autoSaveFailed'), 'and the failure was reported'); + assert.equal(tab.isEditing, false, 'but the view still switches'); + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.equal(tab.isDirty, true, 'the buffer is still there to be rescued'); + }); + + test('edits typed during the flush are shown, and reported as not yet on disk', async () => { + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.typeDuringSave = 'typed while saving\n'; + + await harness.toggleEdit(); + + assert.equal(tab.isDirty, true); + assert.ok(fakes.toasts.includes('toast.savedNewerEdits'), 'the disk is one revision behind'); + assert.equal(tab.isEditing, false, 'the TOCTOU case is no longer a reason to stay in the editor'); + assert.equal( + tab.content, + rendered(IN_BUFFER + 'typed while saving\n'), + 'the preview shows the newest text, which is exactly what is NOT on disk', + ); + }); + + // --------------------------------------------------- closing the split view + + test('closing split view on a dirty file neither asks nor writes', async () => { + setSettings(false); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.askCustomCalls, []); + assert.deepEqual(fakes.saveCalls, []); + assert.deepEqual(fakes.loadCalls, []); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isSplit, false); + assert.equal(tab.isDirty, true); + assert.equal(tab.content, rendered(IN_BUFFER), 'the surviving pane keeps showing the buffer'); + }); + + test('closing split view honours auto-save the same way leaving edit mode does', async () => { + setSettings(true); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.deepEqual(fakes.askCustomCalls, []); + assert.equal(tab.isSplit, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('closing the window still reviews unsaved tabs', () => { + // The close handler is where the buffer is really at stake: it dies with + // the window. Source-level, because it is wired to a Tauri window event. + // Quit goes through the same handler now — `appExit` closes windows and + // decides nothing about their buffers (#390). + const exit = pluck('appExit'); + assert.doesNotMatch(exit, /askCustom/, 'quit asks nothing of its own; the close handler does the asking'); + + const closeHandler = sliceBetween(viewer, 'appWindow.onCloseRequested', 'onDragDropEvent'); + assert.match(closeHandler, /await settleForExit\(/, 'the close still runs the review'); + assert.match(pluck('settleForExit'), /canCloseTab,\n/, 'the walk still runs the per-tab dialog'); + }); + + test('the view toggles no longer re-read the file to leave an editable pane', () => { + // Belt and braces for the behaviour above: neither toggle may reach for + // the disk again. `renderTabPreviewFromRaw` is the shared "render THIS + // tab's buffer under its own path" helper (it also serves the PDF export). + const toggleEdit = pluck('toggleEdit'); + const leaveEditMode = toggleEdit.slice(0, offsetOf(toggleEdit, '// Switch to edit')); + assert.doesNotMatch(leaveEditMode, /loadMarkdown/); + assert.doesNotMatch(leaveEditMode, /askCustom/); + + const toggleSplit = pluck('toggleSplitView'); + const closeSplit = sliceFrom(toggleSplit, 'setSplitEnabled(tab.id, false)'); + assert.doesNotMatch(closeSplit, /loadMarkdown/); + assert.doesNotMatch(toggleSplit, /askCustom/); + }); + + // ------------------------------------------------ where the reader lands (#799) + + test('Ctrl+E hands each pane the line the other one was showing', async () => { + setSettings(false); + const { tab, fakes } = dirtyTab('edit'); + const fromEditor = { section: 'body', ratio: 0.4, line: 120 }; + const fromPreview = { section: 'body', ratio: 0.7, line: 300 }; + const synced: unknown[] = []; + const restored: unknown[] = []; + const panes = { + editorPane: { + scrollSyncPosition: () => fromEditor, + syncScrollToPosition: (position: unknown) => synced.push(position), + }, + markdownBody: {}, + getPreviewScrollSyncPosition: () => fromPreview, + restoreAfterLeavingEditor: (_id: string, position: unknown) => restored.push(position), + }; + + await buildHarness(fakes, true, panes).toggleEdit(); + assert.equal(tab.isEditing, false); + assert.deepEqual(restored, [fromEditor], 'the preview was not sent to the editor\'s line'); + + await buildHarness(fakes, false, panes).toggleEdit(); + assert.equal(tab.isEditing, true); + assert.deepEqual(synced, [fromPreview], 'the editor was not sent to the preview\'s line'); + }); + + test('Ctrl+E brings the cursor on screen only when the outline follows it', async () => { + // An outline marking the cursor's heading while the editor shows another + // one reads as wrong, so with 'cursor' the handoff moves a cursor left off + // screen. With 'scroll' the outline never looks at the cursor, and moving + // it would only lose the reader's place. + const asked: unknown[] = []; + for (const follows of ['cursor', 'scroll'] as const) { + settings.tocFollows = follows; + const { tab, fakes } = dirtyTab('edit'); + tab.isEditing = false; + await buildHarness(fakes, false, { + editorPane: { syncScrollToPosition: (_position: unknown, options: unknown) => asked.push(options) }, + markdownBody: {}, + getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), + }).toggleEdit(); + } + settings.tocFollows = 'scroll'; + assert.deepEqual(asked, [{ cursorIntoView: true }, { cursorIntoView: false }]); + }); + + // Scroll sync is split view's. The tab keeps `isScrollSynced` after the split + // closes, and out of split the other pane is still mounted at zero width, so + // syncing through it dragged the visible pane off its line on every Ctrl+E. + function buildSyncHarness(panes: Record) { + const source = [ + pluck('handleEditorScrollSync'), + pluck('splitScrollSyncOn', false), + pluck('syncEditorToPreviewScroll'), + pluck('restoreAfterLeavingEditor'), + pluck('followToc'), + pluck('handleEditorCursor'), + pluck('placePreviewCursor'), + ].join('\n\n'); + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; + const factory = new Function( + 'deps', + `"use strict"; + const { + tabManager, tick, viewerPaneEl, markdownBody, editorPane, lineCoords, + tabAnchorForEditorTopLine, asBufferLine, scrollPreviewToSyncPosition, + getPreviewScrollSyncPosition, getPreviewScrollAnchor, settings, hasEditorPane, hasPreviewPane, isEditing, isSplit, + previewBlocks, sourceAtPoint, readRendererLine, + } = deps; + let tocActiveLine = null; + let previewPlacing = false; + const cursorByTab = {}; + const activeCursor = deps.activeCursor ?? null; + ${js} + return { + handleEditorScrollSync, syncEditorToPreviewScroll, restoreAfterLeavingEditor, handleEditorCursor, followToc, placePreviewCursor, + toc: () => tocActiveLine, + cursors: () => cursorByTab, + startPlacing: () => { previewPlacing = true; }, + };`, + ); + return factory({ + tabManager, + tick: async () => {}, + viewerPaneEl: undefined, + markdownBody: {}, + lineCoords: { toRendererLine: (line: number) => line - 3, toBufferLine: (line: number) => line + 3 }, + settings, + hasEditorPane: false, + hasPreviewPane: true, + isEditing: false, + isSplit: false, + tabAnchorForEditorTopLine: (_coords: unknown, line: number) => line, + asBufferLine: (line: number) => line, + getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), + ...panes, + }); + } + + test('scroll sync left on in split view stays off once the split closes', async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tabManager.setSplitEnabled(tab.id, true); + if (!tab.isScrollSynced) tabManager.toggleScrollSync(tab.id); + const toPreview: unknown[] = []; + const toEditor: unknown[] = []; + const harness = buildSyncHarness({ + scrollPreviewToSyncPosition: (position: unknown) => toPreview.push(position), + editorPane: { syncScrollToPosition: (position: unknown) => toEditor.push(position) }, + }); + const position = { section: 'body', ratio: 0.5, line: 40 }; + + harness.handleEditorScrollSync(position); + harness.syncEditorToPreviewScroll({}); + assert.equal(toPreview.length, 1, 'precondition: split view syncs the preview'); + assert.equal(toEditor.length, 1, 'precondition: split view syncs the editor'); + + tabManager.setSplitEnabled(tab.id, false); + tab.isEditing = true; + assert.equal(tab.isScrollSynced, true, 'the tab still remembers the choice'); + + harness.handleEditorScrollSync(position); + harness.syncEditorToPreviewScroll({}); + assert.equal(toPreview.length, 1, 'the hidden preview was scrolled from the editor'); + assert.equal(toEditor.length, 1, 'the editor was scrolled from the hidden preview'); + assert.equal(harness.toc(), 40, 'the outline still follows the editor'); + }); + + test('leaving the editor moves the outline to the line the preview lands on', async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const harness = buildSyncHarness({ + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => 57, + }); + + await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); + + assert.equal(tab.anchorLine, 57); + assert.equal(harness.toc(), 57, 'the outline kept the entry from before the switch'); + }); + + // #799: "the table of contents should follow the cursor". An editor setting, + // because only the editor has one: the preview alone always follows the scroll. + describeOutlineSource('cursor', true, (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), 17, 'the cursor line, in the outline\'s numbering'); + h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); + assert.equal(h.toc(), 17, 'scrolling the editor left it on the cursor'); + h.handleEditorCursor(30, 1); + assert.equal(h.toc(), 27); + }); + + describeOutlineSource('cursor', false, async (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), null, 'no editor on screen, so no cursor to follow'); + await h.restoreAfterLeavingEditor(tabManager.activeTab!.id, { section: 'body', ratio: 0.5, line: 58 }); + assert.equal(h.toc(), 57, 'the preview alone follows its scroll'); + }); + + describeOutlineSource('scroll', true, (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), null, 'the default ignores the cursor'); + h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); + assert.equal(h.toc(), 60); + // The editor alone keeps the preview mounted at zero width, and its + // scroll events put the outline on a heading from that layout. + h.followToc('preview', 90); + assert.equal(h.toc(), 60, 'the hidden preview moved the outline'); + }); + + function describeOutlineSource( + follows: 'cursor' | 'scroll', + editorOnScreen: boolean, + run: (harness: ReturnType) => void | Promise, + ) { + test(`the outline follows the ${follows} setting with the editor ${editorOnScreen ? 'on' : 'off'} screen`, async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + tabManager.activeTab!.isEditing = editorOnScreen; + settings.tocFollows = follows; + try { + await run(buildSyncHarness({ + hasEditorPane: editorOnScreen, + hasPreviewPane: !editorOnScreen, + isEditing: editorOnScreen, + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => 57, + })); + } finally { + settings.tocFollows = 'scroll'; + } + }); + } + + test('the preview does not move the outline after Ctrl+E until it is placed', async () => { + // Its scroll events before that report the old position, and the outline + // flashed the first headings until the preview was placed. + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const harness = buildSyncHarness({ scrollPreviewToSyncPosition: () => {}, getPreviewScrollAnchor: () => 57 }); + + harness.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 40 }); + harness.startPlacing(); + harness.followToc('preview', 2); + assert.equal(harness.toc(), 40, 'an unplaced preview put the outline on its own line'); + + await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); + assert.equal(harness.toc(), 57, 'the placed preview decides'); + harness.followToc('preview', 60); + assert.equal(harness.toc(), 60, 'and keeps deciding once placed'); + }); + + test('reading with a cursor placed, the outline stays on it only while the preview draws it', () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + tabManager.activeTab!.isEditing = false; + settings.tocFollows = 'cursor'; + try { + settings.previewCursor = true; + const drawn = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); + drawn.followToc('cursor', 17); + drawn.followToc('preview', 90); + assert.equal(drawn.toc(), 17, 'the preview scroll moved the outline off the drawn cursor'); + + // Off, the editor's last cursor is still there for Ctrl+E, but the + // preview shows nothing, so an outline pinned to it would look stuck. + settings.previewCursor = false; + const hidden = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); + hidden.followToc('cursor', 17); + hidden.followToc('preview', 90); + assert.equal(hidden.toc(), 90, 'the outline stayed on a cursor the preview does not show'); + + const without = buildSyncHarness({ hasEditorPane: false }); + without.followToc('preview', 90); + assert.equal(without.toc(), 90, 'with no cursor yet, the outline follows the scroll'); + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + } + }); + + test('a click in the preview places the cursor only with its setting on, whatever the outline follows', () => { + // #799: the shared cursor is an editing aid in its own right, so Ctrl+E + // lands on the clicked character in either outline mode. The drawn caret + // is visual noise to some readers, so the whole aid is off by default. + const root = document.createElement('div'); + root.innerHTML = '

hello world

'; + const text = root.querySelector('p')!.firstChild!; + (document as any).caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(text, 6); + return range; + }; + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const click = () => { + const harness = buildSyncHarness({ + previewBlocks: root, + sourceAtPoint, + readRendererLine: (line: number) => (line === 2 ? 'hello world' : ''), + activeCursor: { line: 5, column: 7 }, + }); + harness.placePreviewCursor({ detail: 1, clientX: 0, clientY: 0 } as MouseEvent); + return harness; + }; + try { + settings.tocFollows = 'cursor'; + const off = click(); + assert.deepEqual(off.cursors(), {}, 'off by default: the click leaves the cursor alone'); + assert.equal(off.toc(), null); + + settings.previewCursor = true; + for (const follows of ['scroll', 'cursor'] as const) { + settings.tocFollows = follows; + const harness = click(); + assert.deepEqual(harness.cursors()[tab.id], { line: 5, column: 7 }, `${follows}: the click placed the cursor on the "w"`); + assert.equal(harness.toc(), follows === 'cursor' ? 2 : null, `${follows}: the outline`); + } + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + delete (document as any).caretRangeFromPoint; + } + }); +}); diff --git a/scripts/documents.checks.test.ts b/scripts/documents.checks.test.ts new file mode 100644 index 00000000..b8bd165f --- /dev/null +++ b/scripts/documents.checks.test.ts @@ -0,0 +1,1014 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { describe, test } from 'node:test'; + +import { LIST_MARKER, TASK_BOX } from '../src/lib/utils/listSyntax.js'; +import { MARKDOWN_LINK_EXTENSIONS } from '../src/lib/utils/markdownLinks.js'; +import { DEFAULT_OPEN_FILE_MODE } from '../src/lib/utils/openFileMode.js'; +import { viewerCommandTable } from './keymapHarness.js'; +import { + callbackBodies, + filesMatching, + functionSource, + offsetOf, + readDictionarySource, + readRustBackend, + readSource, + readSourceFiles, + sliceBetween, + sliceFrom, +} from './sourceTree.js'; + +/* + * #379 made every read that fills a WRITABLE buffer report whether the decode + * was lossy, so the tab can refuse to write U+FFFD back over a file that was + * merely in another encoding. Three reads kept the bare command: + * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and + * the split pane. They were safe — but only because each re-read a file whose + * tab `loadMarkdown` had already flagged. That is an invariant held by call + * sites agreeing with each other, and the failure mode when one stops agreeing + * is a destroyed document. It is now held by the code: every one of them reads + * the fidelity itself. + * + * WHAT IS LEFT IN THIS FILE, AND WHY IT IS HERE RATHER THAN UNDER VITEST + * + * Every assertion below reads source text, and each one is of a kind no run can + * reach: + * + * - that the unchecked command exists NOWHERE. An absence is not observable + * at run time: executing the checked read cannot prove there is no other + * caller of the unchecked one. The same claim then crosses into the Rust + * crate, where nothing but the spelling connects the two sides. + * - the two MarkdownViewer call sites, and the two auto-save ones. They are + * in a `.svelte` component, and mounting one here would put jsdom in for + * Monaco, mermaid and KaTeX to earn a weaker claim than the text does — + * what these pin is the ORDER of statements inside a handler (flag the tab + * before the buffer is published; recognise the refusal before raising the + * generic toast), which is a property of the code as written. + * + * `ensureFullContent` is not here: it is a runes module driven for real, and + * running it under a hand-written `$state` was standing exactly where the + * assertions look. It is `checkedReadMigration.spec.ts` now. `lossyDecodeSaveGuard.test.ts` + * and `lossySaveRefusalScope.spec.ts` cover the store behaviour these source + * assertions deliberately stop short of. + * + * The second half of this file is the toast the guard's refusal used to + * trigger every 1.5 seconds. + */ +describe('checked read migration', () => { + // --- the two component call sites ------------------------------------------- + + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + test('the unchecked read command is gone, and nothing calls it', () => { + // This was a three-file allowlist — the files #379 migrated. That is the + // wrong shape for a rule that means "nobody, anywhere": a fourth file + // added the call and the test still passed. Two changes fix it. The scan + // below covers the whole tree, so a new file cannot slip under it. And the + // Rust command itself is deleted, which is what turns the rule from a + // convention into a fact: `read_file_content` is not registered any more, + // so invoking it fails loudly instead of quietly filling a buffer with + // mojibake nothing flagged. + const offenders = readSourceFiles('src') + .filter(({ text }) => /invoke\('read_file_content'/.test(text)) + .map(({ path }) => path); + assert.deepEqual(offenders, [], 'read_file_content no longer exists; read_file_content_checked is the read'); + + const rust = readRustBackend(); + assert.doesNotMatch(rust, /\basync fn read_file_content\(/, 'the unchecked command must stay deleted'); + assert.doesNotMatch(rust, /^\s*read_file_content,\s*$/m, 'and must not be registered again'); + }); + + test('entering the editor reads the fidelity and stores it', () => { + const toggle = sliceBetween(viewer, 'async function toggleEdit', 'async function saveContent'); + assert.match(toggle, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); + const read = offsetOf(toggle, 'read_file_content_checked'); + const flag = offsetOf(toggle, 'setTabDecodedLossy(tab.id, lossy)'); + const store = offsetOf(toggle, 'setTabRawContent(tab.id, content)'); + assert.ok(read < flag && flag < store, 'flag the tab before the buffer is published'); + }); + + test('entering split view reads the fidelity and stores it', () => { + const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); + assert.match(enter, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); + const flag = offsetOf(enter, 'setTabDecodedLossy(tab.id, lossy)'); + const store = offsetOf(enter, 'setTabRawContent(tab.id, content)'); + assert.ok(flag < store, 'flag the tab before the buffer is published'); + }); + + // --- the repeating toast ------------------------------------------------------ + + test('the session can tell a refusal from a failure', () => { + // `saveContent` returns false for both, which is why the auto-save timer + // could not tell them apart. The predicate is no longer set membership on + // its own: the set records what was SAID, and whether the refusal still + // stands is asked of the tab. `lossySaveRefusalScope.spec.ts` exercises + // both halves for real; this only pins that the tab is consulted at all, + // since a predicate that answers from memory alone is the defect. + const body = sliceBetween( + readSource('src/lib/sessions/documentSession.svelte.ts'), + 'function isLossySaveRefused(', + 'function updateLoading', + ); + assert.match(body, /lossySaveWarnedTabs\.has\(tabId\)/); + assert.match(body, /hasReplacementChars/, 'the live tab decides whether anything is still being refused'); + }); + + test('a refused save does not add a generic toast to its own explanation', () => { + const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); + const check = offsetOf(body, 'if (documentSession.isLossySaveRefused(s.id)) return;'); + const toast = offsetOf(body, "t('toast.autoSaveFailed'"); + assert.ok(check < toast, 'the refusal must be recognised before the generic toast is raised'); + }); + + test('a tab that can only be refused stops re-arming the timer', () => { + // Auto-save re-arms on every keystroke. Without this the guard was reached + // again every 1.5s for as long as the user kept typing — each time + // producing the console warning, the wasted round trip, and (before the + // test above) the toast. + const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); + assert.match(body, /decodedLossily: tab\.hasReplacementChars/); + assert.match(body, /const eligible = [^;]*!\(s\.decodedLossily && documentSession\.isLossySaveRefused\(s\.id\)\)/); + // The FIRST attempt must still happen: it is what produces the explanation. + // `isLossySaveRefused` is false until the guard has spoken, so eligibility + // only drops afterwards — and "Save As" to a new file clears + // `hasReplacementChars`, which restores it. + assert.match( + readSource('src/lib/sessions/documentSession.svelte.ts'), + /lossySaveWarnedTabs\.delete\(tab\.id\)/, + ); + }); +}); + +// Every read path decodes leniently (#371): a file it cannot read opens with +// U+FFFD substituted for the bytes it could not, instead of failing. U+FFFD is +// not reversible — the original bytes are gone from the buffer. Writing that +// buffer back over the source file (auto-save does it 1.5s after the first +// keystroke) destroys the document permanently. +// +// Detection (#372) removed the common cause rather than the guard: a legacy +// encoding (GBK, Big5, Shift-JIS, CP-1252 ...) is now read properly and +// written back as itself — `documentEncodingRoundTrip.spec.ts` drives that. +// What is left here is the file NO encoding can read, which is still a buffer +// that must never reach its own file. +// +// The guard: Rust reports the fidelity of every decode, the tab carries it — +// through window restore and cross-window moves — and `documentSession` +// refuses to write such a buffer over the file it came from. That is the +// single choke point every writer shares, including MarkdownViewer's +// auto-save timer and the close dialogs. "Save As" to a *different* path +// stays open: the new file is genuinely UTF-8, so writing it is correct, and +// it is the user's only way out. +describe('lossy decode save guard', () => { + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + const windowSession = readSource('src/lib/sessions/windowSession.svelte.ts'); + const tabs = readSource('src/lib/stores/tabs.svelte.ts'); + const rust = readRustBackend(); + + const loadMarkdown = () => sliceBetween(session, 'async function loadMarkdown', 'async function saveContent'); + const saveContent = () => sliceBetween(session, 'async function saveContent(', 'async function saveContentAs'); + const saveContentAs = () => sliceBetween(session, 'async function saveContentAs', 'async function toggleTaskCheckbox'); + const guard = () => sliceBetween(session, 'function refuseIfLossilyDecoded', 'function updateLoading'); + + test('one decoder, and it reports what it destroyed', () => { + // The fact is known exactly once — when the bytes are decoded — and was + // thrown away. Detection (#372) made the decode able to succeed where it + // used to substitute, but not able to always succeed, so the decoder still + // carries the verdict rather than growing a second path. + assert.match(rust, /struct DecodedText \{[^}]*\bcontent: String,[^}]*\blossy: bool/s); + assert.match(rust, /fn decode_text\(bytes: &\[u8\]\) -> DecodedText/); + assert.match(rust, /fn read_to_string_lossy\(path: &str\) -> std::io::Result/); + assert.equal(rust.match(/fn decode_text\(/g)?.length, 1, 'exactly one decoder'); + }); + + test('both read commands can report fidelity', () => { + assert.match(rust, /async fn open_markdown_preview\([^)]*\)\s*-> Result<\(String, bool, bool, String\), String>/s); + assert.match(rust, /async fn read_file_content_checked\(path: String\) -> Result<\(String, bool, String\), String>/); + assert.match(rust, /read_file_content_checked,/, 'the command must be registered'); + }); + + test('a preview cut inside a multi-byte character is not called lossy', () => { + // The preview stops at a fixed byte count, so a perfectly valid UTF-8 file + // is routinely cut mid-character. Reporting that as lossy would lock every + // large CJK/emoji document out of saving — a guard worse than the bug. + const preview = sliceBetween(rust, 'fn build_markdown_preview', '#[tauri::command]'); + const trim = offsetOf(preview, 'utf8_truncation_boundary'); + assert.ok(trim < offsetOf(preview, 'decode_text('), 'the split tail is dropped before fidelity is judged'); + }); + + test('the tab carries the fidelity of its buffer', () => { + // Required, not optional: a construction site that forgot it would default + // to "safe to overwrite", and the failure mode is a destroyed document. + assert.match(tabs, /hasReplacementChars: boolean;/); + assert.doesNotMatch(tabs, /hasReplacementChars\?: boolean;/); + assert.match(tabs, /setTabDecodedLossy\(id: string, lossy: boolean\)/); + assert.equal(tabs.match(/hasReplacementChars: false/g)?.length, 4); + }); + + test('every load decides the flag instead of leaving it stale', () => { + // A reload of a file the user has since converted to UTF-8 must clear it. + const body = loadMarkdown(); + assert.match(body, /as \[string, boolean, boolean, string\]/); + assert.match(body, /setTabDecodedLossy\(activeId, lossy\)/); + // The full load REPLACES the preview's buffer, so it brings its own + // verdict: a file can be valid UTF-8 for the first 50KB and not after. + assert.match(body, /read_file_content_checked/); + assert.match(body, /setTabDecodedLossy\(activeId, fullLossy\)/); + }); + + test('the editable-pane shortcut reports fidelity too', () => { + // #374 added a second way into loadMarkdown: a pane that can WRITE skips + // the preview and reads the whole file up front. That branch fills an + // editable buffer, so it is exactly the kind of caller that must not use + // the bare command — it would hand the editor unflagged mojibake. + const body = loadMarkdown(); + assert.match( + body, + /if \(initialIsEditing \|\| initialIsSplit\) \{\s*\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked'/, + ); + // `ensureFullContent` was the one place the bare command survived, and only + // because it re-read a file whose tab loadMarkdown had already flagged — + // an invariant enforced by two call sites agreeing rather than by the code. + // It now reads the fidelity itself, so no writable buffer in this file is + // filled by a command that cannot report one. + const bare = session.match(/invoke\('read_file_content'/g)?.length ?? 0; + assert.equal(bare, 0, 'no writable buffer may be filled by the unchecked command'); + assert.match( + sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'), + /invoke\('read_file_content_checked'/, + ); + assert.match( + sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'), + /setTabDecodedLossy\(tabId, lossy\)/, + ); + }); + + test('the flag is set before the buffer can reach a writer', () => { + const body = loadMarkdown(); + const set = offsetOf(body, 'setTabDecodedLossy(activeId, lossy)'); + const buffer = offsetOf(body, 'setTabRawContent(activeId, content'); + assert.ok(set < buffer, 'the flag must be set before the buffer it describes'); + assert.doesNotMatch(body.slice(set, buffer), /await /, 'and in the same synchronous block'); + }); + + test('window restore cannot launder the flag away', () => { + // Restore reads each file back into an editable buffer. With the bare + // command it produced mojibake that looked safe to save, so reopening the + // app was a way around the guard. It now reads through `loadMarkdown`, + // which sets the flag on every load, and reads nothing itself. + assert.doesNotMatch(windowSession, /invoke\('read_file/); + assert.match(sliceBetween(session, 'function readRestoredTab', 'function readActiveIfUnread'), /return loadMarkdown\(tab\.path,/); + assert.match(loadMarkdown(), /tabManager\.setTabDecodedLossy\(activeId, lossy\)/); + }); + + test('saveContent refuses to write a lossy buffer over its own file', () => { + const body = saveContent(); + const refusal = offsetOf(body, 'refuseIfLossilyDecoded'); + assert.match(body.slice(refusal, refusal + 120), /return false;/); + assert.ok( + refusal < offsetOf(body, "invoke('save_file_content'"), + 'the guard must run before save_file_content is invoked', + ); + }); + + test('the guard protects the source file only', () => { + const body = guard(); + // An untitled buffer has no source file to destroy, and any other path is + // a new UTF-8 file — neither is blocked. + assert.match(body, /targetPath === ''/); + assert.match(body, /targetPath !== tab\.path/); + assert.match(body, /return true;/); + }); + + test('saveContent is the choke point auto-save and the close dialogs share', () => { + // Auto-save lives in MarkdownViewer.svelte's debounce effect and the close + // flow in canCloseTab; both reach saveContent, so guarding it covers them + // without touching either. + assert.match(session, /const success = await saveContent\(tabId\);/); + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + assert.match(viewer, /saveSilently\(s\.id\)\.then\(/); + assert.match(functionSource(viewer, 'saveSilently'), /documentSession\.saveContent\(tabId\)/); + }); + + test('Save As to a new file stays open as the escape hatch', () => { + const body = saveContentAs(); + // Explicitly UTF-8, never the tab's own encoding: the copy exists to be + // readable when the original could not be read, or could not represent + // what the user typed into it. + assert.match(body, /invoke\('save_file_content', \{ path: selected, content: snapshot, encoding: 'UTF-8' \}\)/); + // Once written, the buffer matches a UTF-8 file of its own. + assert.match(body, /setTabDecodedLossy\(tab\.id, false\)/); + assert.match(body, /setTabEncoding\(tab\.id, 'UTF-8'\)/); + }); + + test('Save As onto the same file is still a destructive overwrite', () => { + const body = saveContentAs(); + // "The same file", not "the same string": the dialog can come back with + // another spelling of the source file — a different case, or accents + // composed differently — and on macOS and Windows that still lands on the + // same bytes. The guard is therefore handed the target's resolved identity + // as well as its path. See pathIdentityCaseFolding.spec.ts, which drives + // that refusal for real instead of reading for it. + const refusal = offsetOf(body, 'refuseIfLossilyDecoded(tab, selected'); + assert.ok( + refusal < offsetOf(body, "invoke('save_file_content'"), + 'the guard must run before save_file_content is invoked', + ); + }); + + test('the refusal tells the user what to do and is not repeated per keystroke', () => { + // Auto-save re-arms on every edit; an undeduplicated toast would fire + // every 1.5s. The message names "Save As" because that is the only exit. + const body = guard(); + assert.match(body, /toast\.lossySaveBlocked/); + assert.match(body, /lossySaveWarnedTabs\.has\(tab\.id\)/); + const i18n = readDictionarySource(); + assert.match(i18n, /lossySaveBlocked: 'Not saved: parts of this file could not be read in any encoding/); + assert.match(i18n, /use "Save As" to write a copy/); + }); +}); + +describe('save from reading mode', () => { + test('the browser save dialog is always suppressed', () => { + // preventDefault has to run for every mode, including the no-op case, or + // reading mode would surface the webview's own Save Page dialog. It is one + // unconditional call on the command rather than one per branch now, so this + // reads the six-line handler rather than the branch it used to sit in — and + // covers all twenty-two commands instead of Save alone. + const handler = functionSource(readSource('src/lib/MarkdownViewer.svelte'), 'handleKeyDown'); + assert.match( + handler, + /if \(!command\) return;\s*\n\s*e\.preventDefault\(\);/, + 'a command that runs must have had the keystroke prevented first', + ); + }); + + /* + * The three below are the residue of this seam, and they say so. + * + * What Ctrl+S DOES is a closure over `tabManager.activeTab` inside the + * component, which is the one thing that could not move: see the note on + * `viewerCommandTable` in `keymapHarness.ts`. They are still text assertions — + * but text anchored on a `case 'save':` label rather than on a chord condition, + * so the rename that reddened five of these in #647 no longer reaches them. + */ + const saveCommand = () => viewerCommandTable()['save']; + + test('an untitled buffer can be saved from reading mode', () => { + // `path === ''` is the untitled case; documentSession.saveContent opens + // the Save dialog for it, so no extra plumbing is needed here. + assert.match(saveCommand(), /saveTarget\.path === ''/); + }); + + test('a saved, unmodified document does not write on Ctrl+S', () => { + // Writing unconditionally would touch mtime and wake the file watcher, + // which in live mode reloads the tab -- a keystroke that reads as a no-op + // to the user should not move the document underneath them. + assert.match(saveCommand(), /saveTarget\.isDirty \|\| saveTarget\.path === ''/); + }); + + test('the HOME tab is not savable', () => { + // HOME carries the sentinel path 'HOME' and is never dirty, so the guard + // excludes it without naming it. This pins that reasoning: if the guard + // ever becomes unconditional, HOME would start opening a Save dialog. + assert.doesNotMatch(saveCommand(), /^\s*saveContent\(\);\s*$/m); + }); +}); + +describe('unsaved recovery', () => { + test('the close decision commands are spelled the same in the viewer and in Rust', () => { + // Cross-language contract: only the string connects invoke() to the command. + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + const rust = readSource('src-tauri/src/window_runtime.rs'); + for (const name of ['keeps_unsaved_on_close', 'set_quitting']) { + assert.ok(viewer.includes(`invoke('${name}'`) || viewer.includes(`invoke('${name}'`), `viewer does not invoke ${name}`); + assert.match(rust, new RegExp(`pub fn ${name}\\(`), `Rust does not declare ${name}`); + } + }); +}); + +describe('live mode watched path', () => { + const runtime = readSource('src-tauri/src/window_runtime.rs'); + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + test('a watcher notification is routed to its watched path', () => { + assert.match(runtime, /emit_to\(event_label\.as_str\(\), "file-changed", watched_path\.clone\(\)\)/); + // `async` because deciding what an event means now reads the file: the + // answer to "did somebody else write this" is the file itself, not a clock. + assert.match(viewer, /await appWindow\.listen\('file-changed', async \(event\) => \{/); + assert.match(viewer, /const changedPath = event\.payload as string;/); + // The listener no longer compares the payload with the active file itself: + // resolveExternalChange looks up the tab that OWNS the changed path (and + // refuses to reload it when it has unsaved edits). See + // externalChangeReload.spec.ts for the routing behaviour. + assert.match(viewer, /await documentSession\.resolveExternalChange\(changedPath\)/); + // A conflict is reported with Live Mode off too; Live Mode gates only the reload. + assert.match(viewer, /if \(liveMode\) loadMarkdown\(outcome\.path\);/); + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + assert.match(session, /tabManager\.tabs\.find\(\(tab\) => tab\.path === changedPath\)/); + }); + + test('the watcher follows the active file instead of retaining a previous tab watcher', () => { + assert.match(viewer, /if \(currentFile\) \{\n\t\t\tinvoke\('watch_file', \{ path: currentFile \}\)/); + + // The defect #296 fixed was `documentSession` re-issuing `watch_file` on + // every load, so the watcher followed whichever document loaded last rather + // than the active one. It used to be pinned as + // `/isLiveMode\(\)\) invoke\('watch_file'/` — the exact one-line spelling + // the defect happened to be written in, against a file where neither token + // occurs any more. Rewriting the same call as an ordinary `if (…) { … }` + // evaded it. What has to hold is who may call it: the effect above, alone. + const watchers = filesMatching(readSourceFiles('src'), /invoke\(\s*'watch_file'/); + assert.deepEqual(watchers, ['src/lib/MarkdownViewer.svelte'], 'one owner for the file watcher'); + + const watchingEffects = callbackBodies(viewer, '$effect').filter((body) => /'watch_file'/.test(body)); + assert.equal(watchingEffects.length, 1, 'and one effect inside it'); + assert.match(watchingEffects[0], /invoke\('unwatch_file'\)/, 'the same effect gives the watcher up'); + + const toggleLiveMode = sliceBetween(viewer, 'function toggleLiveMode', 'async function saveImageAs'); + assert.doesNotMatch(toggleLiveMode, /loadMarkdown\(/); + }); +}); + +describe('editor line ending', () => { + test('the status bar renders the measured label and no longer a translation key', () => { + const editor = readSource('src/lib/components/Editor.svelte'); + const statusBar = sliceBetween(editor, '
', '
\n{/if}'); + + assert.match(statusBar, /
\{lineEnding\}<\/div>/, 'the line-ending item shows the state'); + assert.doesNotMatch(editor, /editor\.status\.crlf/, 'the hardcoded CRLF string is gone'); + assert.match( + editor, + /lineEnding = lineEndingLabel\(model\)/, + 'and it is filled from the model, in the function that refreshes document-wide readings', + ); + + // One line-ending decision reaches this component. A second one — a `\r\n` + // test of its own over `getValue()` — is the defect this fix replaces, in a + // new place. + assert.doesNotMatch(editor, /includes\('\\r\\n'\)/, 'no hand-rolled line-ending detection in the editor'); + }); +}); + +describe('file associations', () => { + const config = JSON.parse(readSource('src-tauri/tauri.conf.json')) as { + bundle: { + fileAssociations: Array<{ ext: string[]; contentTypes?: string[]; mimeType?: string; name: string }>; + }; + }; + + // Every extension Markpad treats as a Markdown document is associated, except + // `.txt`. An extension added to either list fails here until the other follows. + test('installer associates every Markdown extension except txt', () => { + const extensions = config.bundle.fileAssociations.flatMap((association) => association.ext); + assert.deepEqual( + extensions, + MARKDOWN_LINK_EXTENSIONS.filter((ext) => ext !== 'txt'), + ); + }); + + // macOS ignores `ext` once an association has a content type, and its + // Markdown type (net.daringfireball.markdown, owned by the system) only tags + // .md and .markdown. Any other extension must sit in an association with no + // content type or MIME type, or it gets a dynamic UTI that no association + // claims and Markpad drops out of Finder's Open With. One name keeps a single + // Windows ProgID, which hooks.nsi registers under. + test('macOS claims extensions outside the system Markdown type by extension', () => { + for (const association of config.bundle.fileAssociations) { + if (association.contentTypes) { + assert.deepEqual(association.ext, ['md', 'markdown']); + } else { + assert.equal(association.mimeType, undefined); + } + assert.equal(association.name, 'Markdown File'); + } + }); + + // The other half of the same policy, kept in this file so neither side moves + // without the reader seeing both. Claiming `.txt` in the installer takes the + // Text Document association away from Notepad (#179), which is why the test + // above exists — but Markpad does open, render and save `.txt` like any other + // note, so the Open dialog has to offer them. + test('the Open dialog offers every extension Markpad treats as a document', () => { + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + const links = readSource('src/lib/utils/markdownLinks.ts'); + + assert.match(links, /MARKDOWN_LINK_EXTENSIONS = \[[^\]]*'txt'/); + assert.match(viewer, /\{ name: 'Markdown', extensions: MARKDOWN_LINK_EXTENSIONS \}/); + // A literal list is how the two drifted apart in the first place. + assert.doesNotMatch(viewer, /extensions: \['md/); + }); + + test('the Save dialogs offer the same document extensions', () => { + // Both hard-coded `['md']`, so a `.txt` note saved under a new name had to + // go through "All Files". + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + + assert.equal(session.match(/\{ name: 'Markdown', extensions: MARKDOWN_LINK_EXTENSIONS \}/g)?.length, 2); + assert.doesNotMatch(session, /extensions: \['md/); + }); +}); + +/** + * #183: "I'd like it to remember that I had the split screen option turned on + * when opening a new session." + * + * Half of it already worked — a session restore replays each tab's saved + * `isSplit` — but every newly opened document was created `isSplit: false`, so + * the reporter pressed the split button again for each file. There was a + * preference for the neighbouring case and none for this one. + * + * It is ONE preference with three answers rather than a second checkbox beside + * the first, because `isEditing` and `isSplit` are independent flags on the tab: + * two booleans can say "editor AND split", which is not a state the reader can + * be in. That made the checkbox that already existed part of this change, and + * an existing install must not notice — hence the migration below. + * + * The two implementation details pinned here are the ones that are quietly + * expensive to get wrong: + * + * 1. Split must go through `setSplitEnabled`, not a bare `tab.isSplit = true`. + * A fresh tab is created `isScrollSynced: false`, and `setSplitEnabled` is + * what applies the remembered scroll-sync preference — assigning the flag + * directly opens split view with sync silently off. + * + * 2. Both must be applied BEFORE `initialIsSplit` is read. That variable is + * what sends the load down the full-read branch: `open_markdown_preview` + * returns only the first 50KB, and a split pane is an editor, so a document + * that opened split on a partial buffer would be one keystroke away from + * auto-saving the truncation back over the file. + */ +describe('open file mode', () => { + const sessionSource = readSource(new URL('../src/lib/sessions/documentSession.svelte.ts', import.meta.url)); + const settingsSource = readSource(new URL('../src/lib/stores/settings.svelte.ts', import.meta.url)); + const settingsComponentSource = readSource(new URL('../src/lib/components/Settings.svelte', import.meta.url)); + const tabsSource = readSource(new URL('../src/lib/stores/tabs.svelte.ts', import.meta.url)); + + test('the old key is read, never written — a rollback still finds its setting', () => { + assert.match(settingsSource, /const LEGACY_START_IN_EDITOR_KEY = 'editor\.startInEditor'/); + assert.match(settingsSource, /read: \(s\) => s\.openFileMode/); + assert.match( + settingsSource, + /resolveOpenFileMode\(raw, readStoredKey\(LEGACY_START_IN_EDITOR_KEY\)\)/, + ); + // One `key:` per persisted entry, and this entry's is the new one: the + // legacy name must not appear as a key this store writes back. + assert.doesNotMatch(settingsSource, /key: 'editor\.startInEditor'/); + }); + + /* ------------------------------------------------------------- the wiring */ + + test('opening a document applies the choice, split through the same door the toggle uses', () => { + assert.match(sessionSource, /tab\.isEditing = settings\.openFileMode === 'editor'/); + assert.match( + sessionSource, + /if \(settings\.openFileMode === 'split'\) tabManager\.setSplitEnabled\(tab\.id, true\)/, + ); + // The premise of point 1 above: a fresh tab has sync off, and + // `setSplitEnabled` is the only place the preference is applied. + assert.match(tabsSource, /isScrollSynced: false/); + assert.match( + tabsSource, + /setSplitEnabled\(id: string, enabled: boolean\)[\s\S]{0,200}?tab\.isScrollSynced = this\.splitScrollSyncPreference/, + ); + }); + + test('the choice is applied before the load decides how much of the file to read', () => { + const applied = sessionSource.indexOf("if (settings.openFileMode === 'split')"); + const read = sessionSource.indexOf('const initialIsSplit = tab?.isSplit ?? false'); + const fullRead = sessionSource.indexOf('if (initialIsEditing || initialIsSplit)'); + + assert.ok(applied > 0 && read > 0 && fullRead > 0); + assert.ok( + applied < read && read < fullRead, + 'a document opening into an editable mode must take the full-read branch, ' + + 'or the editor is bound to the 50KB preview slice', + ); + }); + + test('it applies to a newly opened document only', () => { + // Not to a reload that is preserving the mode, and not to a file already + // open in a tab — reopening a document must not flip the mode the reader + // chose for it. The same guard the checkbox always had. + assert.match(sessionSource, /if \(tab && !loadOptions\.preserveEditState && !existing\) \{/); + }); + + test('a new empty document keeps its own preference', () => { + // `Ctrl+N` asks a different question — what an empty buffer opens as — and + // answers it with `newFileDefaultMode`. This change must not annex it. + assert.match(tabsSource, /isEditing: settings\.newFileDefaultMode/); + }); + + test('the setting is one control, defaulting to preview', () => { + assert.match(settingsSource, /openFileMode = \$state\(DEFAULT_OPEN_FILE_MODE\)/); + assert.equal(DEFAULT_OPEN_FILE_MODE, 'preview'); + assert.match(settingsComponentSource, / \u00a0\u00a0indented\n\n', + ); + + assert.equal(root.querySelector('.task-text')?.textContent, '\u00a0\u00a0indented'); + }); +}); + +describe('preview annotations', () => { + // A reader's session-only highlight: the selection goes to source points, + // and comes back as the same text however the preview was re-rendered since. + + const SOURCE = ['A paragraph with **bold text** here.', '', '中文段落里有**粗体**和测试。']; + + const HTML = '

A paragraph with bold text here.

\n

中文段落里有粗体和测试。

'; + + const readLine = (line: number) => SOURCE[line - 1]; + + function render() { + const root = document.createElement('div'); + root.innerHTML = HTML; + return root; + } + + function select(root: Element, from: string, to: string) { + const text = root.textContent!; + const start = text.indexOf(from); + const end = text.indexOf(to) + to.length; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + let seen = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + if (start >= seen && start < seen + node.data.length) range.setStart(node, start - seen); + if (end > seen && end <= seen + node.data.length) range.setEnd(node, end - seen); + seen += node.data.length; + } + return range; + } + + test('a selection comes back as the same text in a fresh render', () => { + const cases: Array<[string, string, string]> = [ + ['bold', 'text', 'bold text'], + ['with', 'here', 'with bold text here'], + ['text', '粗体', 'text here.\n中文段落里有粗体'], + ['粗', '测', '粗体和测'], + ]; + for (const [from, to, expected] of cases) { + const shown = render(); + const mark = annotationOf(shown, select(shown, from, to), readLine); + assert.ok(mark, `${from}…${to} mapped`); + assert.equal(rangeOf(render(), mark, readLine)?.toString(), expected); + } + }); + + const at = (line: number, column: number) => ({ line: asRendererLine(line), column }); + + const span = (a: number, b: number): Annotation => ({ start: at(1, a), end: at(1, b) }); + + test('overlap is half-open, and a point hits the mark around it', () => { + const marks = [span(3, 8), span(12, 20)]; + assert.deepEqual(overlapping(marks, span(7, 13)), marks); + assert.deepEqual(overlapping(marks, span(8, 12)), [], 'touching ends do not overlap'); + assert.deepEqual(hitAt(marks, at(1, 3)), [marks[0]]); + assert.deepEqual(hitAt(marks, at(1, 8)), [], 'the end is outside'); + assert.deepEqual(hitAt(marks, at(2, 1)), []); + }); + + test('occurrences are the exact copies of the selected text, up to the limit', () => { + const root = document.createElement('div'); + root.innerHTML = '

cat, Cat, cat

concatenate cat

'; + const found = occurrenceRanges(root, 'cat', 100); + assert.deepEqual(found.map((range) => range.toString()), ['cat', 'cat', 'cat', 'cat'], 'case-sensitive, inside words too'); + assert.equal(occurrenceRanges(root, 'cat', 2).length, 2); + }); +}); + +describe('preview copy flavours', () => { + // #674: what a preview selection looks like once it leaves the app. The + // clipboard carries no stylesheet, so anything the preview only renders + // correctly *because* of one has to be resolved here or it arrives wrong. + + const DOC = '/Users/me/notes/report.md'; + + /** The fragment a `Range.cloneContents()` would hand over, spelled as HTML. */ + function fragmentOf(html: string): DocumentFragment { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content; + } + + test('structure survives untouched', () => { + const html = copyableHtml( + fragmentOf('

Heading

bold and italic and a link

  • one
'), + DOC, + ); + assert.match(html, /

Heading<\/h2>/); + assert.match(html, /bold<\/strong>/); + assert.match(html, /italic<\/em>/); + assert.match(html, /a link<\/a>/); + assert.match(html, /
  • one<\/li>/); + }); + + test('a formula is carried once, not twice', () => { + // KaTeX renders both halves and hides the MathML in CSS. Pasted into a + // document with no KaTeX stylesheet, both are visible — the formula reads + // twice, once as MathML text and once as the glyphs. + const katex = + '' + + 'EE=mc^2' + + '' + + ''; + const html = copyableHtml(fragmentOf(katex), DOC); + assert.doesNotMatch(html, /katex-mathml/); + assert.doesNotMatch(html, /annotation/); + // The half that stays is the one the user was looking at, and the one + // `selection.toString()` already puts in the plain-text flavour. + assert.match(html, /katex-html/); + assert.match(html, /E=mc2/); + }); + + test('the fold chevrons do not travel', () => { + const html = copyableHtml( + fragmentOf('

    Section▾

    ▾Note
    '), + DOC, + ); + assert.doesNotMatch(html, /fold-icon/); + assert.match(html, /Section/); + assert.match(html, /Note/); + }); + + test('a local image becomes a URL the receiving app can open', () => { + // `asset://` (and `http://asset.localhost` on Windows) is this app's own + // scheme; anything else pasting it shows a broken image. + const posix = copyableHtml(fragmentOf(''), DOC); + assert.match(posix, /src="file:\/\/\/Users\/me\/notes\/img\/shot\.png"/); + + const windows = copyableHtml(fragmentOf(''), DOC); + assert.match(windows, /src="file:\/\/\/C:\/notes\/img\/shot\.png"/); + }); + + test('a document-relative image is resolved against the document', () => { + const html = copyableHtml(fragmentOf(''), DOC); + assert.match(html, /src="file:\/\/\/Users\/me\/notes\/img\/diagram\.png"/); + }); + + test('a path with a space or a hash survives as a URL', () => { + // Both end a URL where they sit, so `notes #2.png` would otherwise resolve + // to `notes ` and the image would be missing rather than broken. + const html = copyableHtml(fragmentOf(''), DOC); + assert.match(html, /src="file:\/\/\/Users\/me\/my%20notes\/shot%20%232\.png"/); + }); + + test('a remote image and a data URI are left alone', () => { + const remote = copyableHtml(fragmentOf(''), DOC); + assert.match(remote, /src="https:\/\/example\.com\/a\.png"/); + + const inline = copyableHtml(fragmentOf(''), DOC); + assert.match(inline, /src="data:image\/png;base64,AAAA"/); + }); + + // ---------------------------------------------------------------- both flavours + + /** A selection over `html`, as the preview would hand one to the handler. */ + function selectionOver(html: string): Selection { + document.body.innerHTML = `
    ${html}
    `; + const range = document.createRange(); + range.selectNodeContents(document.getElementById('preview') as HTMLElement); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + selection.addRange(range); + return selection; + } + + const KATEX_INLINE = + '' + + 'EE=mc^2' + + '' + + ''; + + test('a formula copies as its source, not as the glyphs', () => { + // What `katex/contrib/copy-tex` did before this module took the copy over. + // The rendered spans read `E=mc2`, which is not the formula and cannot be + // pasted back into a Markdown document. + const { text } = copyableFlavours(selectionOver(`

    Einstein: ${KATEX_INLINE}

    `), ''); + assert.match(text, /\$E=mc\^2\$/); + assert.doesNotMatch(text, /E=mc2/); + }); + + test('a display formula keeps its display delimiters', () => { + const { text } = copyableFlavours(selectionOver(`

    ${KATEX_INLINE}

    `), ''); + assert.match(text, /\$\$E=mc\^2\$\$/); + }); + + test('a selection with no math takes its plain text from the selection itself', () => { + // `Selection.toString()` is the only source that knows what is rendered — + // `textContent` of the fragment would pick up hidden nodes and lose the line + // breaks between blocks. + const { text } = copyableFlavours(selectionOver('

    one

    two

    '), ''); + assert.match(text, /one/); + assert.match(text, /two/); + }); + + test('a soft break the preview hides is not pasted as a line break (#967)', () => { + const copy = (article: string) => { + document.body.innerHTML = `

    one
    \ntwo
    \nthree

    `; + const paragraph = document.querySelector('p') as HTMLElement; + const range = document.createRange(); + range.setStart(paragraph.firstChild as Text, 0); + range.setEnd(paragraph.lastChild as Text, 6); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + selection.addRange(range); + return copyableFlavours(selection, '').html; + }; + assert.match(copy('markdown-body'), /one
    \ntwo
    /); + assert.match(copy('markdown-body soft-breaks'), /one\ntwo
    \nthree/); + }); + + test('both flavours come back from one call', () => { + const { text, html } = copyableFlavours(selectionOver('

    bold

    '), ''); + assert.match(html, /bold<\/strong>/); + assert.match(text, /bold/); + }); +}); + +describe('preview cursor mapping', () => { + // #799's shared cursor: a click in the preview lands on a source character, + // and the editor's cursor is drawn back onto the rendered character. The HTML + // is `convert_markdown`'s real output for SOURCE, so the columns are comrak's + // UTF-8 byte offsets, which the Chinese lines exercise. + + const SOURCE = "# Title here\n\nA paragraph with **bold text** and a [link](https://example.com) here.\nSecond line with `code` and an escaped \\* star & more.\n\n中文段落里有**粗体**和测试。\n\n- item one\n- item **two**\n\n> quoted text\n\n```js\nlet x = 1;\n```\n\n| name | 值 |\n| --- | --- |\n| 甲 | beta |\n"; + + const HTML = "

    Title here

    \n

    A paragraph with bold text and a link here.
    \nSecond line with code and an escaped * star & more.

    \n

    中文段落里有粗体和测试。

    \n
      \n
    • item one
    • \n
    • item two
    • \n
    \n
    \n

    quoted text

    \n
    \n
    let x = 1;\n
    \n\n\n\n\n\n\n\n\n\n\n\n\n\n
    name值
    甲beta
    \n"; + + const sourceLines = SOURCE.split('\n'); + + const readLine = (line: number) => sourceLines[line - 1]; + + function render() { + const root = document.createElement('div'); + root.innerHTML = HTML; + return root; + } + + /** The text node and offset of the `nth` occurrence of `char` in rendered text under `selector`. */ + function charIn(root: Element, selector: string, char: string, nth = 0) { + const walker = document.createTreeWalker(root.querySelector(selector)!, NodeFilter.SHOW_TEXT); + let seen = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let offset = 0; offset < node.data.length; offset++) { + if (node.data[offset] === char && seen++ === nth) return { node, offset }; + } + } + throw new Error(`no ${char} under ${selector}`); + } + + const at = (line: number, column: number): SourcePoint => ({ line: asRendererLine(line), column }); + + test('a click lands on the source character it rendered', () => { + const root = render(); + const cases: Array<[string, string, SourcePoint, number?]> = [ + ['h1', 'T', at(1, 3)], + ['p strong', 'b', at(3, 20)], + ['p a', 'l', at(3, 39)], + ['p code', 'c', at(4, 19)], + ['p', '*', at(4, 41)], + ['p', '&', at(4, 48)], + ['p:nth-of-type(2)', '粗', at(6, 9)], + ['p:nth-of-type(2)', '测', at(6, 14)], + ['li', 'o', at(8, 8)], + ['li:nth-of-type(2) strong', 't', at(9, 10)], + ['blockquote p', 'q', at(11, 3)], + ['pre', 'x', at(14, 5)], + ['td', '甲', at(19, 3)], + ['td:nth-of-type(2)', 'b', at(19, 7)], + ]; + for (const [selector, char, expected] of cases) { + assert.deepEqual(sourceAtPoint(root, charIn(root, selector, char), readLine), expected, `${char} in ${selector}`); + assert.equal(sourceLines[expected.line - 1][expected.column - 1], char, `${char} is the source character there`); + } + }); + + test('every rendered character goes to the source and back to itself', () => { + const root = render(); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let checked = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let offset = 0; offset < node.data.length; offset++) { + if (/\s/.test(node.data[offset]) || !node.parentElement?.closest('[data-sourcepos]')) continue; + const source: SourcePoint = sourceAtPoint(root, { node, offset }, readLine)!; + const back: DomPoint = pointAtSource(root, source, readLine)!; + assert.equal(back.node, node, `"${node.data[offset]}" came back in another node`); + assert.equal(back.offset, offset, `"${node.data[offset]}" came back at another offset`); + checked++; + } + } + assert.ok(checked > 130, `the fixture was walked (${checked})`); + }); + + test('a fenced block pairs its code, not its info string', () => { + // `js` on the fence would take the first two characters of `js = 1`. + const root = document.createElement('div'); + root.innerHTML = '
    js = 1;\n
    '; + const lines = ['```js', 'js = 1;', '```']; + const point = sourceAtPoint(root, charIn(root, 'pre', 's'), (line) => lines[line - 1]); + assert.deepEqual(point, at(2, 2)); + }); + + test('a point inside KaTeX lands just after the formula', () => { + const root = document.createElement('div'); + root.innerHTML = '

    sum x2x^2 done

    '; + const katex = root.querySelector('.katex')!; + const point = sourceAtPoint(root, { node: katex.firstChild!, offset: 1 }, () => 'sum $x^2$ done'); + assert.deepEqual(point, at(1, 10), 'on the space after `$x^2$`'); + }); +}); + +describe('preview patch', () => { + /** + * The block patch, from the renderer's split output to the live article. + * `previewPatchParity.spec.ts` holds it to the whole-document pipeline over a + * real document; these pin which nodes it keeps, which it replaces, and what + * it hands on, one edit at a time. + */ + + let host: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); + }); + + const patch = (html: string, folds = new Set()) => patchPreview(host, html, '/docs/a.md', folds); + + const split = (...parts: string[]) => parts.join('\0\n'); + + const p = (line: number, text: string) => `

    ${text}

    `; + + const h = (level: number, line: number, text: string, id = text.toLowerCase()) => + `${text}`; + + const bodies = () => Array.from(host.querySelectorAll('p')) as HTMLElement[]; + + describe('the first render', () => { + test('inserts every block, folds each section under its heading, and hands all of it on', () => { + const result = patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.className)).toEqual(['foldable-header', 'foldable-content-wrapper']); + expect(host.querySelectorAll('.foldable-content-wrapper > .content-inner > p').length).toBe(2); + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1', 'P', 'P']); + expect(result.wrappers.length).toBe(1); + }); + + test('carries no whitespace between blocks, so children and childNodes stay one list', () => { + patch(split(p(1, 'a'), p(3, 'b')) + '\n'); + expect(host.childNodes.length).toBe(host.children.length); + }); + }); + + describe('raw HTML that breaks out of the batch a first render parses blocks in', () => { + test('is rendered block by block instead, with nothing attributed to the wrong block', () => { + const forged = '
    a

    b

    '; + const result = patch(split(p(1, 'alpha'), forged, p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alpha', 'a', 'b', 'beta']); + expect(result.inserted.length).toBe(4); + + patch(split(p(1, 'alphaX'), forged, p(5, 'beta'))); + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alphaX', 'a', 'b', 'beta']); + }); + }); + + describe('typing one character in the middle of a document', () => { + const before = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'), p(7, 'gamma')); + const after = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'betaX'), p(7, 'gamma')); + + test('replaces exactly that block, in place, and keeps every other node', () => { + patch(before); + const [alpha, beta, gamma] = bodies(); + const heading = host.querySelector('h1'); + const wrapper = host.querySelector('.foldable-content-wrapper'); + + const result = patch(after); + + const [alphaAfter, betaAfter, gammaAfter] = bodies(); + expect(alphaAfter).toBe(alpha); + expect(gammaAfter).toBe(gamma); + expect(betaAfter).not.toBe(beta); + expect(betaAfter.textContent).toBe('betaX'); + expect(host.querySelector('h1')).toBe(heading); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(result.inserted).toEqual([betaAfter]); + expect(result.wrappers).toEqual([]); + }); + }); + + describe('inserting a line above everything', () => { + test('keeps the blocks that only moved and gives them their new source ranges', () => { + patch(split(p(3, 'alpha'), `
      \n
    • item
    • \n
    `)); + const [alpha] = bodies(); + const item = host.querySelector('li')!; + + const result = patch(split(p(4, 'alpha'), `
      \n
    • item
    • \n
    `)); + + expect(result.inserted).toEqual([]); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('li')).toBe(item); + expect(alpha.getAttribute('data-sourcepos')).toBe('4:1-4:5'); + // The task checkbox toggle writes to the line it reads here. + expect(item.getAttribute('data-sourcepos')).toBe('6:1-6:9'); + }); + + test('moves the line anchors this side added by the same amount', () => { + const long = (line: number) => + `

    a
    \nb
    \nc

    `; + patch(long(1)); + const anchors = () => Array.from(host.querySelectorAll('.source-line-anchor')).map((a) => a.getAttribute('data-sourcepos')); + expect(anchors()).toEqual(['2:1-2:1', '3:1-3:1']); + + patch(long(4)); + expect(anchors()).toEqual(['5:1-5:1', '6:1-6:1']); + }); + }); + + describe('headings', () => { + test('typing in one replaces the heading and keeps its wrapper and everything in it', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'))); + const [alpha] = bodies(); + const wrapper = host.querySelector('.foldable-content-wrapper')!; + + const result = patch(split(h(1, 1, 'Titles'), p(3, 'alpha'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1']); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(host.querySelector('h1')!.getAttribute('data-fold-target')).toBe(wrapper.id); + }); + + test('renaming one of many identical ones replaces the renumbered headings and nothing else', () => { + const sections = (names: string[]) => { + const seen = new Map(); + return split( + ...names.flatMap((name, index) => { + const slug = name.toLowerCase(); + const n = seen.get(slug) ?? 0; + seen.set(slug, n + 1); + return [h(2, index * 4 + 1, name, n ? `${slug}-${n}` : slug), p(index * 4 + 3, `body ${index}`)]; + }), + ); + }; + patch(sections(['Objectives', 'Objectives', 'Objectives'])); + const kept = bodies(); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + + const result = patch(sections(['Goals', 'Objectives', 'Objectives'])); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2', 'H2', 'H2']); + expect(bodies()).toEqual(kept); + bodies().forEach((body, index) => expect(body).toBe(kept[index])); + expect(Array.from(host.querySelectorAll('.foldable-content-wrapper'))).toEqual(wrappers); + expect(Array.from(host.querySelectorAll('h2')).map((e) => e.id)).toEqual(['goals', 'objectives', 'objectives-1']); + }); + + test('a new section regroups the blocks after it under the new heading, without rebuilding them', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), p(5, 'beta'))); + const [alpha, beta] = bodies(); + + const result = patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2']); + expect(result.wrappers.length).toBe(2); + const sections = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(sections[0].contains(alpha)).toBe(true); + expect(sections[1].contains(beta)).toBe(true); + }); + + test('a new subsection leaves the sections around it where they were', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Three'), p(11, 'gamma'))); + const [one, twoBody, three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + two.nextElementSibling!.classList.add('is-collapsed'); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(3, 9, 'Sub'), p(11, 'x'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[1]).toBe(twoBody); + expect(wrappers[wrappers.length - 1]).toBe(three); + // Only the new subsection is wrapped: the section around it ends where it did. + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['sub']); + // The section the reader closed is still closed, and now holds the new one. + expect(two.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + expect(two.nextElementSibling!.querySelector('#sub')).not.toBeNull(); + }); + + test('a new section of the same level re-wraps only the section it splits', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), p(9, 'b2'), h(2, 11, 'Three'), p(13, 'gamma'))); + const [one, , three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const kept = bodies(); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Mid'), p(11, 'b2'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['two', 'mid']); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[3]).toBe(three); + expect(bodies().every((body, index) => body === kept[index])).toBe(true); + expect(wrappers[2].textContent).toBe('b2'); + }); + + test('a new paragraph right under a heading goes into that heading\'s section, first', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(h(2, 1, 'One'), p(3, 'new'), p(5, 'alpha'))); + + const inner = host.querySelector('.content-inner')!; + expect(Array.from(inner.children).map((e) => e.textContent)).toEqual(['new', 'alpha']); + }); + + test('a new paragraph above the first heading stays outside every section', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(p(1, 'lead'), h(2, 3, 'One'), p(5, 'alpha'))); + + expect(host.firstElementChild!.textContent).toBe('lead'); + }); + }); + + describe('folds', () => { + const doc = split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta')); + + test('a fold the document is told to keep closed is closed on the first render', () => { + patch(doc, new Set(['two'])); + expect(host.querySelector('#two')!.classList.contains('is-collapsed')).toBe(true); + expect(host.querySelector('#two')!.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + }); + + test('a fold the reader closed stays closed through an edit somewhere else', () => { + patch(doc); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + + patch(doc.replace('>alpha<', '>alphaX<')); + + expect(host.querySelector('#two')).toBe(two); + expect(two.classList.contains('is-collapsed')).toBe(true); + }); + + test('callouts with the same title get keys of their own', () => { + const callout = (line: number) => + `
    \n

    [!note]- Same
    \nx

    \n
    `; + patch(split(callout(1), callout(4))); + expect(Array.from(host.querySelectorAll('.callout-foldable')).map((e) => e.getAttribute('data-fold-key'))).toEqual([ + 'callout:Same', + 'callout:Same~1', + ]); + }); + }); + + describe('blocks that renderRichContent replaced', () => { + const pre = (line: number) => `
    x\n
    `; + + /** What the code-block pass does: a new parent, recorded for the patch. */ + function shell(element: Element): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'code-block-shell'; + element.replaceWith(wrapper); + wrapper.appendChild(element); + replacedBy.set(element, wrapper); + return wrapper; + } + + test('are kept through an edit elsewhere, and removed whole when their block goes', () => { + patch(split(pre(1), p(5, 'alpha'))); + const wrapper = shell(host.querySelector('pre')!); + + patch(split(pre(1), p(5, 'alphaX'))); + expect(host.firstElementChild).toBe(wrapper); + + patch(split(p(1, 'alphaX'))); + expect(host.querySelector('.code-block-shell')).toBeNull(); + expect(host.children.length).toBe(1); + }); + + test('get their new source range through the node that replaced them', () => { + patch(split(pre(1))); + const original = host.querySelector('pre')!; + const diagram = document.createElement('div'); + diagram.className = 'mermaid-diagram'; + diagram.setAttribute('data-sourcepos', original.getAttribute('data-sourcepos')!); + original.replaceWith(diagram); + replacedBy.set(original, diagram); + + patch(split(pre(2))); + + expect(host.firstElementChild).toBe(diagram); + expect(diagram.getAttribute('data-sourcepos')).toBe('2:1-4:3'); + }); + }); + + /** + * The memos in `previewAnchor.ts` assume a node whose source lines changed is a + * new node. A kept node with rewritten ranges is not, so the patch has to drop + * them — shown by asking `previewAnchor` its own questions across a patch. + * jsdom has no layout, so the boxes are supplied. + */ + describe('the source ranges previewAnchor has already read', () => { + test('follow a range rewritten on a node that was kept', () => { + patch(split(p(1, 'alpha'), p(3, 'beta'))); + const [alpha, beta] = bodies(); + const boxes = new Map([ + [alpha, { top: 0, height: 100 }], + [beta, { top: 100, height: 100 }], + ]); + const measure = (node: AnchorNode): AnchorBox => boxes.get(node) ?? { top: NaN, height: NaN }; + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(100); + + patch(split(p(3, 'alpha'), p(5, 'beta'))); + + expect(bodies()).toEqual([alpha, beta]); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(0); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(5), measure)).toBe(100); + }); + + test('follow kept blocks inside a kept section', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + expect(findAnchorElement(host, 5)?.element).toBe(bodies()[1]); + + patch(split(h(1, 1, 'Title'), p(8, 'alpha'), p(10, 'beta'))); + + expect(findAnchorElement(host, 10)?.element).toBe(bodies()[1]); + expect(findAnchorElement(host, 8)?.element).toBe(bodies()[0]); + }); + }); + + describe('an article somebody else rewrote', () => { + test('is rebuilt rather than diffed against a stale record', () => { + patch(split(p(1, 'a'), p(3, 'b'))); + host.appendChild(document.createElement('p')); + + const result = patch(split(p(1, 'a'), p(3, 'b'))); + + expect(result.inserted.length).toBe(2); + expect(host.children.length).toBe(2); + }); + }); +}); + +describe('preview patch parity', () => { + /** + * The block patch against the whole-document pipeline it replaced, over the + * renderer's real output for samples/markdown-syntax.md (every construct the + * preview has: callouts, folds, footnotes, math, Mermaid, task lists, raw + * `
    ` around Markdown). `preview_block_fixture_is_current` in + * markdown.rs keeps the fixture in step with the renderer. + * + * Three properties: + * - a first render is the document the old pipeline produced; + * - after an edit, the patched article is what a first render of the edited + * document would be — the incremental path loses nothing; + * - undoing the edit puts the original document back. + */ + + const SPLIT = blocks.join('\0'); + + const PATH = '/docs/markdown-syntax.md'; + + /** `wrap-N` ids only have to be unique; the two paths hand them out in a different order. */ + const normalized = (host: Element) => host.innerHTML.replace(/wrap-\d+/g, 'wrap-N'); + + function oldPipeline(split: string): string { + const host = document.createElement('div'); + host.append(sanitizeMarkdownFragment(processMarkdownHtml(split.replaceAll('\0\n', ''), PATH, new Set()))); + // The old patch carried elements only, never the whitespace between them. + for (const node of Array.from(host.childNodes)) if (node.nodeType !== 1) node.remove(); + return normalized(host); + } + + function cold(split: string): string { + const host = document.createElement('div'); + patchPreview(host, split, PATH, new Set()); + return normalized(host); + } + + /** Every `data-sourcepos` line moved down by one: a line inserted at the top. */ + const enterAtTop = (split: string) => + split.replace(/data-sourcepos="(\d+):(\d+)-(\d+):(\d+)"/g, (_, a, b, c, d) => `data-sourcepos="${+a + 1}:${b}-${+c + 1}:${d}"`); + + const at = (split: string, index: number, edit: (block: string) => string) => { + const parts = split.split('\0'); + parts[index] = edit(parts[index]); + return parts.join('\0'); + }; + + const firstIndex = (pattern: RegExp) => blocks.findIndex((block) => pattern.test(block)); + + const middleParagraph = blocks.findIndex((block, index) => index > blocks.length / 2 && /^\n?

    /); + + const edits: Record string> = { + 'a keystroke in a paragraph': (split) => at(split, middleParagraph, (b) => b.replace('

    ', 'x

    ')), + 'a line inserted at the top': enterAtTop, + 'a renamed heading': (split) => at(split, firstH2, (b) => b.replace(/(

    ]*>)/, '$1Renamed ')), + 'a new paragraph': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    new

    \n`), + 'a new section': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    Added

    \n`), + 'a deleted heading': (split) => at(split, firstH2, () => ''), + 'a new subsection': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    Sub

    \n`), + 'a new top-level heading': (split) => at(split, firstH2, (b) => `

    Top

    \n\0${b}`), + 'a heading changed level': (split) => at(split, firstH2, (b) => b.replace(/^(\n?)

    /, '

    ')), + 'an edited diagram': (split) => at(split, mermaid, (b) => b.replace('', 'C --> D\n
    ')), + 'an edited raw HTML block': (split) => at(split, details, (b) => b.replace('', ' edited')), + }; + + let host: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); + }); + + test('the fixture has the constructs the edits below aim at', () => { + expect([middleParagraph, firstH2, mermaid, details].every((index) => index > 0)).toBe(true); + }); + + test('a first render is what the whole-document pipeline produced', () => { + expect(cold(SPLIT)).toBe(oldPipeline(SPLIT)); + }); + + describe.each(Object.entries(edits))('%s', (_, edit) => { + test('patches to what a first render of the edited document is', () => { + const edited = edit(SPLIT); + expect(edited).not.toBe(SPLIT); + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edited, PATH, new Set()); + expect(normalized(host)).toBe(cold(edited)); + }); + + test('and back to the original when it is undone', () => { + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edit(SPLIT), PATH, new Set()); + patchPreview(host, SPLIT, PATH, new Set()); + expect(normalized(host)).toBe(cold(SPLIT)); + }); + }); +}); + +/** + * What the shared sanitizer actually does to an author stylesheet — run, not + * described. + * + * `previewSanitize.test.ts` pins the WIRING: that the preview reaches the + * shared policy, that the policy forbids ` + * + * and the observable is the same one: the preview injects `tab.content` into + * the APPLICATION'S OWN document, so a stylesheet that survives the filter is + * not scoped to the article — it restyles Markpad. `.titlebar` is a real + * element of the app's chrome, and `getComputedStyle` is what answers whether + * the document author moved it. + * + * The `background-image` half is the beacon: in a browser the computed value + * being a URL is followed by a request for it (the comment recorded the entry + * in `performance.getEntriesByType('resource')`, and the shipped CSP allows it + * — `img-src … https:`). jsdom does not fetch it. The computed value is the + * part that is a property of the sanitizer rather than of the network stack, + * and it is the part asserted here. + * + * WHY THE "BEFORE" ROW IS ASSERTED TOO: without it "the `'; + + /** + * A rendered document carrying it. + * + * The leading paragraph is not decoration. DOMPurify serializes its parse + * tree's ``, and the HTML parser puts a `'; + document.body.innerHTML = '
    '; + return { + titlebar: document.querySelector('.titlebar')!, + preview: document.querySelector('.markdown-body')!, + }; + } + + afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + test('the config the preview used to build lets a document restyle the application', () => { + const { titlebar, preview } = mountApp(); + assert.equal(getComputedStyle(titlebar).display, 'flex', 'precondition: the app decides where its chrome is'); + + const sanitized = DOMPurify.sanitize(RENDERED, VIEWER_LOCAL_CONFIG); + assert.match(sanitized, / - * - * and the observable is the same one: the preview injects `tab.content` into - * the APPLICATION'S OWN document, so a stylesheet that survives the filter is - * not scoped to the article — it restyles Markpad. `.titlebar` is a real - * element of the app's chrome, and `getComputedStyle` is what answers whether - * the document author moved it. - * - * The `background-image` half is the beacon: in a browser the computed value - * being a URL is followed by a request for it (the comment recorded the entry - * in `performance.getEntriesByType('resource')`, and the shipped CSP allows it - * — `img-src … https:`). jsdom does not fetch it. The computed value is the - * part that is a property of the sanitizer rather than of the network stack, - * and it is the part asserted here. - * - * WHY THE "BEFORE" ROW IS ASSERTED TOO: without it "the `'; - -/** - * A rendered document carrying it. - * - * The leading paragraph is not decoration. DOMPurify serializes its parse - * tree's ``, and the HTML parser puts a `'; - document.body.innerHTML = '
    '; - return { - titlebar: document.querySelector('.titlebar')!, - preview: document.querySelector('.markdown-body')!, - }; -} - -afterEach(() => { - document.head.innerHTML = ''; - document.body.innerHTML = ''; -}); - -test('the config the preview used to build lets a document restyle the application', () => { - const { titlebar, preview } = mountApp(); - assert.equal(getComputedStyle(titlebar).display, 'flex', 'precondition: the app decides where its chrome is'); - - const sanitized = DOMPurify.sanitize(RENDERED, VIEWER_LOCAL_CONFIG); - assert.match(sanitized, /'), + ); + + /** Every class the component's own stylesheet writes a rule for. */ + const styledClasses = new Set( + [...settingsStyle.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((match) => match[1]), + ); + + /** + * The control classes `.setting-item`'s row layout sizes. Each is sized on the + * assumption that it is a flex item of the row, so each must be a direct child + * of one. + */ + const ROW_SIZED_CONTROLS = ['select-wrapper', 'slider-container', 'toggle', 'custom-dropdown-wrapper']; + + type Row = { children: { tag: string; classes: string[] }[]; nested: string[]; label: string }; + + /** Every `.setting-item` in Settings.svelte, with its direct children. */ + function settingRows(): Row[] { + const ast = parseComponent(componentSource, 'src/lib/components/Settings.svelte'); + const rows: Row[] = []; + + const classesOf = (node: Record): string[] => { + const out: string[] = []; + for (const attribute of node.attributes ?? []) { + if (attribute.type === 'ClassDirective') out.push(attribute.name); + if (attribute.type !== 'Attribute' || attribute.name !== 'class') continue; + const parts = Array.isArray(attribute.value) ? attribute.value : [attribute.value]; + for (const part of parts) if (part.type === 'Text') out.push(...String(part.data).split(/\s+/).filter(Boolean)); + } + return out; + }; + + /** Elements below `node`, skipping `{#if}`/`{#each}` wrappers, to `depth`. */ + const elementsUnder = (node: unknown, depth: number, out: { node: Record; depth: number }[] = []) => { + if (Array.isArray(node)) { + for (const child of node) elementsUnder(child, depth, out); + return out; + } + if (!node || typeof node !== 'object') return out; + const current = node as Record; + if (current.type === 'RegularElement') { + out.push({ node: current, depth }); + elementsUnder(current.fragment, depth + 1, out); + return out; + } + // Control-flow blocks do not add a DOM level. + for (const key of ['fragment', 'nodes', 'consequent', 'alternate', 'body']) { + if (current[key]) elementsUnder(current[key], depth, out); + } + return out; + }; + + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (!node || typeof node !== 'object') return; + const current = node as Record; + + if (current.type === 'RegularElement' && classesOf(current).includes('setting-item')) { + const descendants = elementsUnder(current.fragment, 1); + const direct = descendants.filter((entry) => entry.depth === 1); + rows.push({ + label: direct[0] ? textOf(direct[0].node) : '(empty row)', + children: direct.map((entry) => ({ tag: entry.node.name, classes: classesOf(entry.node) })), + nested: descendants + .filter( + (entry) => + entry.depth > 1 && + classesOf(entry.node).some((name) => ROW_SIZED_CONTROLS.includes(name)) && + // `.number-input-wrapper` legitimately lives inside + // `.slider-container`, which is itself a direct child. + !parentIsSizedControl(descendants, entry), + ) + .map((entry) => `${entry.node.name}.${classesOf(entry.node).join('.')}`), + }); + } + + for (const [key, value] of Object.entries(current)) { + if (key === 'parent' || key === 'metadata') continue; + walk(value); + } + }; + + walk((ast as Record).fragment); + return rows; + } + + function parseComponent(text: string, filename: string): unknown { + return parse(text, { modern: true, filename }); + } + + function textOf(node: Record): string { + const first = (node.fragment?.nodes ?? []).find((child: any) => child.type === 'ExpressionTag' || child.type === 'Text'); + if (!first) return node.name; + return componentSource.slice(first.start, first.end).trim().slice(0, 48); + } + + /** True when `entry`'s nearest element ancestor is itself a sized control. */ + function parentIsSizedControl( + all: { node: Record; depth: number }[], + entry: { node: Record; depth: number }, + ): boolean { + const index = all.indexOf(entry); + for (let i = index - 1; i >= 0; i--) { + if (all[i].depth !== entry.depth - 1) continue; + const classes = (all[i].node.attributes ?? []) + .filter((a: any) => a.type === 'Attribute' && a.name === 'class' && Array.isArray(a.value)) + .flatMap((a: any) => a.value.filter((v: any) => v.type === 'Text').flatMap((v: any) => String(v.data).split(/\s+/))); + return classes.some((name: string) => ROW_SIZED_CONTROLS.includes(name)); + } + return false; + } + + const rows = settingRows(); + + test('every settings row was found', () => { + // Vacuous-pass guard: the two assertions below say nothing if the walk + // stopped finding rows. + assert.ok(rows.length > 25, `found ${rows.length} .setting-item rows`); + assert.ok(styledClasses.has('setting-item') && styledClasses.has('select-wrapper')); + }); + + test('a settings row leads with the label that names its control', () => { + const wrong = rows + .filter((row) => row.children[0]?.tag !== 'label') + .map((row) => `${row.label} leads with <${row.children[0]?.tag}>`); + assert.deepEqual(wrong, [], 'a row whose first child is not the
  • - {#if tabManager.windowTag} + {#if popovers.tagActions}
    - - - {#if tabManager.windowTag.pinned} - {/if}
    - - {#if themeMenuOpen} + {#if popovers.themeMenuOpen} '; + for (const target of root.querySelectorAll('span, textarea')) { + assert.equal(contextMenuFor(target, { modalOpen: false, inApp: true }), 'editor', target.tagName); + } + }); +}); diff --git a/scripts/preview.checks.test.ts b/scripts/preview.checks.test.ts index 9b41f0ad..5282d80e 100644 --- a/scripts/preview.checks.test.ts +++ b/scripts/preview.checks.test.ts @@ -158,13 +158,7 @@ describe('preview sanitizing', () => { const previewPatchSource = readSource('src/lib/utils/previewPatch.ts'); - test('the preview sanitizes through the shared policy, not a local config', () => { - assert.match( - previewPatchSource, - /import\s*\{[^}]*\bsanitizeMarkdownFragment\b[^}]*\}\s*from\s*'[^']*\/sanitize\.js'/, - 'the preview patch must import the shared sanitizer', - ); - + test('the viewer injects markup only through patchPreview and the footnote tooltip', () => { // The sinks: every `{@html …}` in the viewer, whatever the expression looks // like. Stated over *all* of them rather than over one known-good spelling, // so adding an injection point of the raw document is a failure instead of @@ -212,43 +206,10 @@ describe('preview sanitizing', () => { 'the preview must reach the article through patchPreview, once', ); - // Inside it: the one place markup is parsed is a block of the renderer's - // output, into a detached document of its own, before the filter — and what - // a block puts into the article is the filter's own nodes. Nothing is - // parsed after `sanitizeMarkdownFragment`, and no markup is assembled. - const parses = [...previewPatchSource.matchAll(/(\w+)\.(?:innerHTML|outerHTML|insertAdjacentHTML)\s*=\s*([^;]+);/g)].map( - (m) => `${m[1]} = ${m[2].trim()}`, - ); - assert.deepEqual(parses, ['box = markup'], 'previewPatch.ts parses only the renderer output, before the filter'); - assert.match(previewPatchSource, /const box = scratch\.createElement\('div'\);/, 'into the detached scratch document'); - const processAndSanitize = sliceBetween(previewPatchSource, 'function processAndSanitize(', '\n}\n'); - assert.match(processAndSanitize, /return sanitizeMarkdownFragment\(box\.innerHTML\);$/, 'and the filter has the last word'); - // What a block is inserted as is always the filter's own nodes. - const renderBlocks = sliceBetween(previewPatchSource, 'function renderBlocks(', '\n}\n'); - assert.deepEqual( - [...renderBlocks.matchAll(/Array\.from\((\w+)\.children\)/g)].map((m) => m[1]), - ['clean', 'lone', 'mark'], - ); - assert.match(renderBlocks, /const clean = processAndSanitize\(/); - assert.match(renderBlocks, /const lone = processAndSanitize\(/); - assert.match(renderBlocks, /const marks = Array\.from\(clean\.children\);/); - assert.match(renderBlocks, /return marks\.map\(\(mark\) =>/); - assert.doesNotMatch(previewPatchSource, /insertAdjacentHTML|outerHTML|createContextualFragment/); - - // Why the footnote sink is allowed, pinned as a direction rather than as a - // spelling: the tooltip body is read out of the rendered document, never - // built from a string the sanitizer has not seen. - // - // `previewBlocks` and not `markdownBody`, and the difference is the whole - // point of the assertion rather than a rename. The article now holds one - // host per open tab, and `previewBlocks` is the one `patchPreview` was - // handed above — so this pins the tooltip to the exact element the sanitized - // string was parsed into, where the previous spelling only reached the box - // containing it. - const footnote = sliceBetween(viewerSource, "anchor.hasAttribute('data-footnote-ref')", 'isFootnote: true'); - assert.match(footnote, /previewBlocks\?\.querySelector/, 'the footnote body is found in the rendered document'); - assert.match(footnote, /\.innerHTML/, 'and taken from the DOM the shared sanitizer already filtered'); - assert.doesNotMatch(footnote, /rawContent/, 'never from the unrendered buffer'); + // What patchPreview does with that string, and where the footnote tooltip's + // body comes from, are run in preview.spec.ts ("the preview path patches in + // the filtered nodes, with the same policy", "a footnote tooltip is read back + // out of the rendered preview"). What stays here is the set of sinks. // The regression itself — a DOMPurify call with a config assembled at the // call site — is caught for the whole tree by the call-site allowlist below; diff --git a/scripts/preview.spec.ts b/scripts/preview.spec.ts index 7bcbcb33..f8e3cc78 100644 --- a/scripts/preview.spec.ts +++ b/scripts/preview.spec.ts @@ -1338,6 +1338,21 @@ describe('link hover preview', () => { isFootnote: false, }); }); + + test('a footnote tooltip is read back out of the rendered preview', () => { + // `{@html tooltip.html}` is the one sink besides the patched article, and it + // is allowed because its body is a clone of what the sanitized article + // already holds — never markup built from the unrendered buffer. + const preview = document.createElement('div'); + preview.innerHTML = '
    1. The note ↩

    '; + const ref = document.createElement('a'); + ref.setAttribute('href', 'fn-1'); + ref.setAttribute('data-footnote-ref', ''); + const label = linkTooltipFor(ref, { findAnchorTarget: () => null, footnoteRoot: preview }); + assert.equal(label?.isFootnote, true); + assert.equal(label?.html, '

    The note

    '); + assert.equal(preview.querySelector('.footnote-backref')?.textContent, '↩', 'the preview keeps its own back-link'); + }); }); describe('preview copy handler', () => { diff --git a/scripts/preview.test.ts b/scripts/preview.test.ts index 12efeb8d..e68ff486 100644 --- a/scripts/preview.test.ts +++ b/scripts/preview.test.ts @@ -811,7 +811,7 @@ describe('preview link clicks', () => { const DOC = '/notes/report.md'; - function documentDeps(calls: Calls, openUrl = async (url: string) => void calls.push(`url ${url}`)) { + function documentDeps(calls: Calls, openUrl: (url: string) => Promise = async (url) => void calls.push(`url ${url}`)) { return { currentFile: DOC, openHere: async (t: { path: string }) => void calls.push(`open ${t.path}`), diff --git a/scripts/shortcuts.checks.test.ts b/scripts/shortcuts.checks.test.ts index 812deff6..482a2e41 100644 --- a/scripts/shortcuts.checks.test.ts +++ b/scripts/shortcuts.checks.test.ts @@ -916,21 +916,9 @@ describe('formatting shortcuts keymap', () => { }); describe('menu and modal guards', () => { - const viewer = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); const titleBar = readSource(new URL('../src/lib/components/TitleBar.svelte', import.meta.url)); const modal = readSource(new URL('../src/lib/components/Modal.svelte', import.meta.url)); - // Three of the four assertions below used to spell `\n\t\t` and `\n\t\t\t` into - // the pattern, so they pinned the indentation of the code as much as its order. - // A single `prettier` run — or wrapping one condition across two lines — turned - // all three red without changing a thing the user can see. What actually has to - // hold is which function the statement lives in and what it comes BEFORE, so - // that is what is asserted now: the scope comes from `functionSource`, and the - // ordering from offsets within that scope. - - /** The document-level right-click handler, braces included. */ - const contextMenuHandler = functionSource(viewer, 'handleContextMenu'); - /** Where `needle` sits inside `scope`, asserted to be present at all. */ function offsetIn(scope: string, needle: string, what: string): number { const at = scope.indexOf(needle); @@ -938,12 +926,6 @@ describe('menu and modal guards', () => { return at; } - test('document context menus do not open while a modal is active', () => { - // First statement, not merely present: every branch below it either opens a - // menu or returns, so a modal guard placed after any of them is not a guard. - assert.match(contextMenuHandler, /^function handleContextMenu\([^)]*\)\s*\{\s*if \(modalState\.show\)\s*return;/); - }); - test('titlebar menus close before a document context menu opens', () => { assert.match(titleBar, /window\.addEventListener\('contextmenu', handleGlobalDismiss\)/); assert.match(titleBar, /window\.addEventListener\('blur', handleGlobalDismiss\)/); @@ -961,44 +943,6 @@ describe('menu and modal guards', () => { // come after it or the prompt input loses Cut/Copy/Paste. assert.ok(prevent > carveOut && stop > carveOut, 'the backdrop suppresses the menu before letting text fields out'); }); - - test('text fields keep the webview edit menu so paste stays reachable', () => { - const carveOut = offsetIn( - contextMenuHandler, - 'closest(\'input, textarea, [contenteditable="true"]\')', - 'the text-field carve-out', - ); - const prevent = offsetIn(contextMenuHandler, 'e.preventDefault();', 'the native-menu suppression'); - - assert.ok(prevent > carveOut, 'preventDefault runs before the text-field carve-out, so paste is unreachable'); - }); - - test('a second dialog settles the first instead of orphaning it', async () => { - // One `modalState`, one `resolve`. A second `askCustom` used to overwrite the - // first caller's resolver, so that caller awaited forever — when it was the - // close walk, the window could not be closed again. The component's own - // functions run here over a stand-in `modalState`; types erased, nothing else. - const lifted = ['askCustom', 'handleModalConfirm'].map((name) => functionSource(viewer, name)).join('\n'); - const js = ts.transpileModule( - `const __component = () => { - let modalState = { show: false, resolve: null }; - ${lifted} - return { askCustom, handleModalConfirm }; - };`, - { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, - ).outputText; - const component = new Function(`${js}\nreturn __component();`)() as { - askCustom: (message: string, options: { title: string; kind: 'warning' }) => Promise; - handleModalConfirm: () => void; - }; - - const first = component.askCustom('first', { title: 'Unsaved', kind: 'warning' }); - const second = component.askCustom('second', { title: 'Unsaved', kind: 'warning' }); - component.handleModalConfirm(); - - assert.equal(await Promise.race([first, Promise.resolve('never settled')]), 'cancel'); - assert.equal(await second, 'discard'); - }); }); /** diff --git a/scripts/shortcuts.spec.ts b/scripts/shortcuts.spec.ts index 1657cbe8..39448872 100644 --- a/scripts/shortcuts.spec.ts +++ b/scripts/shortcuts.spec.ts @@ -5,6 +5,8 @@ import { beforeAll, describe, onTestFinished, test } from 'vitest'; import { canUsePreviewWidthShortcut, type KeyContext, type KeyStroke, type ViewerCommand, viewerCommandFor } from '../src/lib/utils/viewerKeymap.js'; import { PLATFORMS } from './keymapHarness.js'; +import { ChoiceDialog } from '../src/lib/utils/choiceDialog.svelte.js'; +import { contextMenuFor } from '../src/lib/utils/viewerContextMenu.js'; describe('viewer keymap', () => { /* @@ -435,3 +437,43 @@ describe('os type fallback', async () => { assert.equal(store.editorFont, DEFAULT_FONTS.macos.editorFont); }); }); + +describe('choice dialog', () => { + test('a second dialog settles the first instead of orphaning it', async () => { + // One dialog, one resolver. A second question used to overwrite the first + // caller's resolver, so that caller awaited forever — when it was the close + // walk, the window could not be closed again. + const dialog = new ChoiceDialog(); + const first = dialog.ask('first', { title: 'Unsaved', kind: 'warning' }); + const second = dialog.ask('second', { title: 'Unsaved', kind: 'warning' }); + assert.equal(dialog.message, 'second'); + dialog.close('discard'); + + assert.equal(await Promise.race([first, Promise.resolve('never settled')]), 'cancel'); + assert.equal(await second, 'discard'); + assert.equal(dialog.show, false); + }); +}); + +describe('document context menu routing', () => { + function inside(html: string, selector: string) { + const root = document.createElement('div'); + root.innerHTML = html; + return root.querySelector(selector)!; + } + + test('document context menus do not open while a modal is active', () => { + // Every case answers 'none' with a dialog up, the editor's included. + for (const target of [inside('

    text

    ', 'p'), inside('
    ', 'textarea')]) { + assert.equal(contextMenuFor(target, { modalOpen: true, inApp: true }), 'none'); + } + assert.equal(contextMenuFor(inside('

    text

    ', 'p'), { modalOpen: false, inApp: true }), 'document'); + }); + + test('text fields keep the webview edit menu so paste stays reachable', () => { + for (const html of ['', '', '
    x
    ']) { + const target = inside(html, 'input, textarea, span'); + assert.equal(contextMenuFor(target, { modalOpen: false, inApp: true }), 'native', html); + } + }); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index ff6890d4..f5275352 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -95,6 +95,8 @@ import { } from './utils/markdownLinks.js'; import { followDocumentLink, handlePreviewClick, linkTooltipFor, openRelativeMarkdownTarget as openRelativeTarget, openWithSystem as openPathWithSystem } from './utils/previewLinks.js'; import { canonicalizePath } from './utils/pathIdentity.js'; +import { ChoiceDialog } from './utils/choiceDialog.svelte.js'; +import { contextMenuFor } from './utils/viewerContextMenu.js'; import { findSeedFromSelection } from './utils/findSeed.js'; import { normalizeAssetPath } from './utils/exportHtml.js'; import { @@ -522,21 +524,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from }); let tooltip = $state({ show: false, text: '', shortcut: '', html: '', isFootnote: false, x: 0, y: 0, align: 'top' as 'top' | 'right' | 'left' | 'below' }); - let modalState = $state<{ - show: boolean; - title: string; - message: string; - kind: 'info' | 'warning' | 'error'; - showSave: boolean; - resolve: ((v: 'save' | 'discard' | 'cancel') => void) | null; - }>({ - show: false, - title: '', - message: '', - kind: 'info', - showSave: false, - resolve: null, - }); + const modalState = new ChoiceDialog(); let docContextMenu = $state<{ show: boolean; @@ -551,21 +539,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from }); function askCustom(message: string, options: { title: string; kind: 'info' | 'warning' | 'error'; showSave?: boolean }): Promise<'save' | 'discard' | 'cancel'> { - // A second question replaces the first dialog, so the first caller is - // answered 'cancel' rather than left awaiting a resolver that is about to - // be overwritten — a close walk left that way never finished, and the - // window could not be closed again. An already-answered one ignores it. - modalState.resolve?.('cancel'); - return new Promise((resolve) => { - modalState = { - show: true, - title: options.title, - message, - kind: options.kind, - showSave: options.showSave ?? false, - resolve, - }; - }); + return modalState.ask(message, options); } // window.prompt() is a silent no-op inside the webview (wry does not @@ -597,15 +571,11 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from } function closeModal(choice: 'save' | 'cancel') { - modalState.resolve?.(choice); - modalState.show = false; + modalState.close(choice); } - // Self-contained rather than closeModal('discard'): menuModalGuards.test.ts - // lifts it on its own. function handleModalConfirm() { - if (modalState.resolve) modalState.resolve('discard'); - modalState.show = false; + modalState.close('discard'); } function handleSplitterKeyDown(e: KeyboardEvent) { @@ -3062,19 +3032,12 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from } function handleContextMenu(e: MouseEvent) { - if (modalState.show) return; - if (mode !== 'app') return; - // The editor gets its own menu, and this branch has to come first: - // Monaco takes input through a hidden `
    '; + document.body.append(backdrop); + onTestFinished(() => backdrop.remove()); + backdrop.addEventListener('contextmenu', handleModalBackdropContextMenu); + let reachedDocument = 0; + const count = () => reachedDocument++; + document.addEventListener('contextmenu', count); + onTestFinished(() => document.removeEventListener('contextmenu', count)); + + const rightClick = (target: Element) => { + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event.defaultPrevented; + }; + + // No native menu over the dialog, and the app's own menus never hear it. + assert.equal(rightClick(backdrop.querySelector('p')!), true); + assert.equal(rightClick(backdrop), true); + assert.equal(reachedDocument, 0); + + // The prompt input keeps the webview's Cut/Copy/Paste. + assert.equal(rightClick(backdrop.querySelector('input')!), false); + assert.equal(rightClick(backdrop.querySelector('textarea')!), false); + assert.equal(reachedDocument, 2); + }); +}); diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 6b4db664..7c732b28 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -2,6 +2,7 @@ import { fade, scale } from 'svelte/transition'; import { t } from '../utils/i18n.js'; import { settings } from '../stores/settings.svelte.js'; + import { handleModalBackdropContextMenu } from '../utils/modalContextMenu.js'; let { show, @@ -112,12 +113,7 @@ class="modal-backdrop" transition:fade={{ duration: 150 }} onclick={handleBackdropClick} - oncontextmenu={(e) => { - // The prompt input needs the webview's Cut/Copy/Paste menu. - if ((e.target as HTMLElement).closest('input, textarea')) return; - e.preventDefault(); - e.stopPropagation(); - }} + oncontextmenu={handleModalBackdropContextMenu} role="presentation"> {/each} diff --git a/src/lib/utils/frontMatterFieldState.svelte.ts b/src/lib/utils/frontMatterFieldState.svelte.ts new file mode 100644 index 00000000..111fd397 --- /dev/null +++ b/src/lib/utils/frontMatterFieldState.svelte.ts @@ -0,0 +1,73 @@ +import type { FrontMatterField } from './frontMatter.js'; + +/** + * The front-matter panel's per-field state. Every table is keyed by document + * *and* field: a bare field name carried a `date` error from one document into + * every other document's `date` field. + */ +export class FrontMatterFieldState { + #panelKey: () => string; + #editErrors = $state>({}); + #tagDrafts = $state>({}); + #tagEditIndexes = $state>({}); + #tagEditDrafts = $state>({}); + + constructor(panelKey: () => string) { + this.#panelKey = panelKey; + } + + #key(field: FrontMatterField) { + return `${this.#panelKey()}:${field.key}`; + } + + editError(field: FrontMatterField): string | undefined { + return this.#editErrors[this.#key(field)]; + } + + setEditError(field: FrontMatterField, error: unknown) { + this.#editErrors[this.#key(field)] = String(error); + } + + clearEditError(field: FrontMatterField) { + delete this.#editErrors[this.#key(field)]; + } + + tagDraft(field: FrontMatterField) { + return this.#tagDrafts[this.#key(field)] ?? ''; + } + + setTagDraft(field: FrontMatterField, value: string) { + this.#tagDrafts[this.#key(field)] = value; + this.clearEditError(field); + } + + clearTagDraft(field: FrontMatterField) { + delete this.#tagDrafts[this.#key(field)]; + } + + tagEditIndex(field: FrontMatterField) { + return this.#tagEditIndexes[this.#key(field)] ?? null; + } + + tagEditDraft(field: FrontMatterField, fallback: string) { + return this.#tagEditDrafts[this.#key(field)] ?? fallback; + } + + setTagEditDraft(field: FrontMatterField, value: string) { + this.#tagEditDrafts[this.#key(field)] = value; + this.clearEditError(field); + } + + startTagEdit(field: FrontMatterField, index: number, value: string) { + const key = this.#key(field); + this.#tagEditIndexes[key] = index; + this.#tagEditDrafts[key] = value; + this.clearEditError(field); + } + + clearTagEdit(field: FrontMatterField) { + const key = this.#key(field); + delete this.#tagEditIndexes[key]; + delete this.#tagEditDrafts[key]; + } +} From b01cdcaff0de30ca7b6fefd3bf60b2d0c26b641e Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:17:26 +0800 Subject: [PATCH 053/118] refactor: move the viewer's tab-transfer options and movers into sessions/windowTransfer and test them --- scripts/documents.checks.test.ts | 32 ------ scripts/tabs.checks.test.ts | 70 ------------- scripts/tabs.spec.ts | 163 +++++++++++++++++++++++++++++ scripts/windows.checks.test.ts | 8 -- src/lib/MarkdownViewer.svelte | 143 ++++--------------------- src/lib/sessions/windowTransfer.ts | 148 ++++++++++++++++++++++++++ 6 files changed, 334 insertions(+), 230 deletions(-) create mode 100644 src/lib/sessions/windowTransfer.ts diff --git a/scripts/documents.checks.test.ts b/scripts/documents.checks.test.ts index 6be72cd8..cfe31d6c 100644 --- a/scripts/documents.checks.test.ts +++ b/scripts/documents.checks.test.ts @@ -810,38 +810,6 @@ describe('truncated buffer guard', () => { assert.match(enter, /tabManager\.setTabRawContent\(tab\.id, content\)/); }); - test('a partial buffer cannot be handed to another window', () => { - // The destination rebuilds the tab from the payload alone and has no way - // to know the text is incomplete, so its auto-save would truncate the file. - // - // Each subject is extracted by its own name. The previous form sliced - // `'canTransfer: (tabId)'` → `'transferPayload:'`, which ran past - // `canDetach`, and `'async function handleDetach'` → `'async function - // carryActiveTabToNextWindow'`, which ran past `moveTabToWindow`. Both - // swallowed neighbours end in a character-identical copy of the guard being - // asserted, so the `assert.match` was satisfied either way: deleting - // `&& !tab.isTruncated` from `canTransfer`, and separately deleting the whole - // four-line `ensureFullContent` guard from `handleDetach`, each left the - // suite green — on the transfer-then-auto-save path this file is named for. - // - // The two neighbours were therefore never asserted about, only stood in - // front of. Both are the same guard on the same path, so both are named. - for (const predicate of ['canTransfer', 'canDetach']) { - assert.match( - functionSource(viewer, predicate), - /!tab\.isTruncated/, - `${predicate} must refuse a tab whose buffer is still the preview slice`, - ); - } - for (const mover of ['handleDetach', 'moveTabToWindow']) { - assert.match( - functionSource(viewer, mover), - /if \(!\(await documentSession\.ensureFullContent\(tabId\)\)\) \{[\s\S]*?return false;/, - `${mover} must complete the buffer before the payload is built, and stop when it cannot`, - ); - } - }); - test('front matter edits in preview mode complete the buffer first', () => { for (const entry of ['async function handleFrontMatterEdit', 'async function handleFrontMatterListChange']) { // One handler is followed by a `function`, the other by an `async diff --git a/scripts/tabs.checks.test.ts b/scripts/tabs.checks.test.ts index f55786ff..a16a34a9 100644 --- a/scripts/tabs.checks.test.ts +++ b/scripts/tabs.checks.test.ts @@ -46,7 +46,6 @@ describe('cross-window tab transfer wiring', () => { describe('tab transfer handoff', () => { const session = readSource(new URL('../src/lib/sessions/windowSession.svelte.ts', import.meta.url)); - const viewer = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); const broker = readSource(new URL('../src-tauri/src/tab_transfer.rs', import.meta.url)); // A tab transfer is only ever allowed to end in one of two states: the source @@ -81,42 +80,6 @@ describe('tab transfer handoff', () => { assert.match(accept, /claimed = true;/); }); - test('a failed render undoes the inserted tab', () => { - // The tab must exist before it can be rendered, so the destination owns a - // document the source still shows until the render succeeds. - const accept = sliceBetween(viewer, 'acceptTransferredTab: async (snapshot)', 'onError: (message, error)'); - assert.match(accept, /\} catch \(error\) \{\s*\n\s*tabManager\.closeTab\(id\);\s*\n\s*throw error;/); - assert.match(accept, /if \(isDisposed\) \{\s*\n\s*tabManager\.closeTab\(id\);/); - }); - - test('the arriving tab is put back where its reader was, after its document lands', () => { - // The position travels in the payload (tabTransfer.ts) and nothing put it - // back. `insertTransferredTab` activates the tab synchronously, while its - // rendered `content` is still `''`, so the preview's restore effect runs on - // that activation against a host holding no document: no block owns the - // anchor line, there is no scroll range for the percentage, and the pixel - // offset has nowhere to go. The document arrives afterwards, at the top. - // - // The effect cannot cover this by depending on the render. It also runs - // while the reader is typing — split view and edit-with-outline re-render on - // a debounce — and a per-render dependency would drag the preview back to - // the saved position on every pause in typing. So the arrival asks again - // itself, once `renderTabPreviewFromRaw` has awaited the patch. - // - // Ordering is the whole assertion, which is why it is a source assertion: - // what goes wrong is WHEN the restore is called, and the two moments are one - // `await` apart inside a Svelte component. - const accept = sliceBetween(viewer, 'acceptTransferredTab: async (snapshot)', 'onError: (message, error)'); - const rendered = accept.indexOf('await renderTabPreviewFromRaw(transferred);'); - const restored = accept.indexOf('restorePreviewReadingPosition('); - assert.ok(rendered !== -1, 'the arrival must render the document it was handed'); - assert.ok(restored > rendered, 'the restore must run after the render, not before it'); - // Only the active tab's host is displayed, and they all share one scrolling - // article: restoring for a tab the user has since switched away from would - // move whichever document is on screen instead. - assert.match(accept, /tabManager\.activeTabId === id/); - }); - test('a second transfer of the same tab is refused while one is in flight', () => { // The menu entry becomes clickable again long before the transfer // resolves; two payloads for one tab means two windows each build it. @@ -255,26 +218,10 @@ describe('background edit-only tab position', () => { const derivation = sliceFrom(source, 'export function outgoingTabAnchorLine'); assert.doesNotMatch(derivation, /scrollPercentage/); }); - - test('both gates that drop the view state ask for the anchor first', () => { - // The two moments a tab's position leaves memory. Neither carries - // `editorViewState`: `serializeState` omits it and `restoreState` seeds - // null, and `tabTransfer.ts` excludes it as a live Monaco object. - const store = readSource('src/lib/stores/tabs.svelte.ts'); - const serialize = sliceFrom(store, 'serializeState(extra: Record = {}): string {'); - assert.match(serialize, /anchorLine: outgoingTabAnchorLine\(t, this\.activeTabId\)/); - - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - const payload = sliceFrom(viewer, 'transferPayload: (tabId) => {'); - assert.match(payload, /anchorLine: outgoingTabAnchorLine\(tab, tabManager\.activeTabId\)/); - // After the spread, or the snapshot's own field would win. - assert.ok(offsetOf(payload, '...snapshotTab(tab)') < offsetOf(payload, 'outgoingTabAnchorLine')); - }); }); describe('editor reading position flush', () => { const editor = readSource('src/lib/components/Editor.svelte'); - const viewer = readSource('src/lib/MarkdownViewer.svelte'); test('the editor derives the anchor in exactly one place', () => { // The teardown used to build it inline. A flush that built its own would be @@ -291,23 +238,6 @@ describe('editor reading position flush', () => { assert.match(flush, /if \(!editorReady \|\| !editor \|\| currentTabId !== tabId\) return;/); }); - test('the transfer payload flushes before it snapshots the tab', () => { - // `snapshotTab` is synchronous and runs with the editor still mounted, so - // without this the tab travels at the position of the last teardown. - const flushAt = offsetOf(viewer, 'editorPane?.flushPositionTo(tabId);'); - const snapshotAt = offsetOf(viewer, 'snapshotTab(tab)'); - assert.ok(flushAt < snapshotAt); - }); - - test('the window-state snapshot flushes too', () => { - // Same staleness at window close: `serializeState` persists the same three - // fields and the editor has not come down yet. - const serialize = sliceFrom(viewer, 'serializeState: () => {'); - const flushAt = offsetOf(serialize, 'flushPositionTo(tabManager.activeTabId)'); - const stateAt = offsetOf(serialize, 'return windowStateJson();'); - assert.ok(flushAt < stateAt); - }); - test('a sync asked for while Monaco is loading is kept, and spent after the restore', () => { // #799: Ctrl+E from the preview asks before the editor exists. Dropping the // request left the editor wherever its old view state put it. diff --git a/scripts/tabs.spec.ts b/scripts/tabs.spec.ts index db5d7049..f6e0912e 100644 --- a/scripts/tabs.spec.ts +++ b/scripts/tabs.spec.ts @@ -17,6 +17,10 @@ const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); const { createDocumentSession, claimTabForRead } = await import('../src/lib/sessions/documentSession.svelte.js'); const { createWindowSession } = await import('../src/lib/sessions/windowSession.svelte.js'); const { settings } = await import('../src/lib/stores/settings.svelte.js'); +const { createTabMovers, createTransferOptions } = await import('../src/lib/sessions/windowTransfer.js'); +const { snapshotTab } = await import('../src/lib/utils/tabTransfer.js'); +const { outgoingTabAnchorLine } = await import('../src/lib/utils/editorPosition.js'); +type Tab = import('../src/lib/stores/tabs.svelte.js').Tab; describe('a preview host per tab', () => { function patchPreviewBlocks(host: HTMLElement, html: string) { @@ -1297,4 +1301,163 @@ describe('tab transfer races', () => { await settle(); assert.equal(calls.filter((call) => call.cmd === 'stage_detached_tab').length, 2); }); + + // --- the viewer's side: what this window sends and how it takes a tab in --- + + function makeTransfer(overrides: Partial[0]> = {}) { + return createTransferOptions({ + editorPane: () => null, + windowStateJson: () => tabManager.serializeState(), + isCloseWalkActive: () => false, + isDisposed: () => false, + renderPreview: async () => {}, + restoreReadingPosition: () => {}, + ...overrides, + }); + } + + /** A payload as another window would send it, with this store left empty. */ + function arrivingSnapshot() { + const tabId = openTab('# arriving'); + const snapshot = snapshotTab(tabManager.tabs.find((tab) => tab.id === tabId)!); + tabManager.closeAll(); + return snapshot; + } + + test('a failed render undoes the inserted tab', async () => { + // The tab must exist before it can be rendered, so the destination owns a + // document the source still shows until the render succeeds. + const snapshot = arrivingSnapshot(); + const failing = makeTransfer({ renderPreview: () => Promise.reject(new Error('render failed')) }); + await assert.rejects(failing.acceptTransferredTab(snapshot), /render failed/); + assert.equal(tabManager.tabs.length, 0); + + let disposed = false; + const closing = makeTransfer({ isDisposed: () => disposed, renderPreview: async () => void (disposed = true) }); + assert.equal(await closing.acceptTransferredTab(snapshot), null); + assert.equal(tabManager.tabs.length, 0, 'a window torn down mid-render keeps no half-arrived tab'); + }); + + test('the arriving tab is put back where its reader was, after its document lands', async () => { + // `insertTransferredTab` activates the tab while its preview is still + // empty, so a restore before the render has nothing to scroll. + const snapshot = arrivingSnapshot(); + const log: string[] = []; + const transfer = makeTransfer({ + renderPreview: async () => { + await Promise.resolve(); + log.push('rendered'); + }, + restoreReadingPosition: (tab) => log.push(`restored ${tab.path}`), + }); + const id = await transfer.acceptTransferredTab(snapshot); + assert.equal(id, tabManager.activeTabId); + assert.deepEqual(log, ['rendered', 'restored /notes/a.md']); + + // Only the active tab's host is displayed: a reader who switched away + // during the render must not have the document on screen moved. + tabManager.closeAll(); + log.length = 0; + const switching = makeTransfer({ + renderPreview: async () => void tabManager.addTab('/notes/other.md', 'other'), + restoreReadingPosition: () => log.push('restored'), + }); + await switching.acceptTransferredTab(snapshot); + assert.deepEqual(log, []); + }); + + test('both gates that drop the view state ask for the anchor first', () => { + // The two moments a tab's position leaves memory. Neither carries + // `editorViewState`, so a background edit-only tab would leave at the + // anchor of its last preview scroll. + const lines = Array.from({ length: 80 }, (_, index) => `line ${index + 1}`).join('\n'); + const backgroundId = openTab(lines); + tabManager.addTab('/notes/active.md', 'active'); + const background = tabManager.tabs.find((tab) => tab.id === backgroundId)!; + background.isEditing = true; + background.anchorLine = 3 as typeof background.anchorLine; + background.editorViewState = { viewState: { firstPosition: { lineNumber: 40, column: 1 } } }; + const expected = outgoingTabAnchorLine(background, tabManager.activeTabId); + assert.notEqual(expected, 3); + + const transfer = makeTransfer(); + assert.equal(JSON.parse(transfer.transferPayload(backgroundId)).anchorLine, expected); + const saved = JSON.parse(transfer.serializeState()).tabs.find((tab: { path: string; anchorLine: number }) => tab.anchorLine === expected); + assert.ok(saved, 'the window snapshot records the editor position too'); + }); + + test('the transfer payload flushes before it snapshots the tab', () => { + // The editor holds the live position; without the flush the tab travels + // at the position of the last teardown. + const tabId = openTab('doc'); + const editorPane = { + flushPositionTo: (id: string) => { + tabManager.tabs.find((tab) => tab.id === id)!.anchorLine = 12 as Tab['anchorLine']; + }, + }; + const transfer = makeTransfer({ editorPane: () => editorPane }); + assert.equal(JSON.parse(transfer.transferPayload(tabId)).anchorLine, 12); + }); + + test('the window-state snapshot flushes too', () => { + // Same staleness at window close: the editor has not come down yet. + openTab('doc'); + const flushed: string[] = []; + const editorPane = { + flushPositionTo: (id: string) => { + flushed.push(id); + tabManager.tabs.find((tab) => tab.id === id)!.anchorLine = 12 as Tab['anchorLine']; + }, + }; + const transfer = makeTransfer({ editorPane: () => editorPane }); + assert.equal(JSON.parse(transfer.serializeState()).tabs[0].anchorLine, 12); + assert.deepEqual(flushed, [tabManager.activeTabId]); + }); + + test('a partial buffer cannot be handed to another window', async () => { + // The destination rebuilds the tab from the payload alone and cannot know + // the text is incomplete, so its auto-save would truncate the file. + const tabId = openTab('first 50KB'); + tabManager.addTab('/notes/another.md', 'another'); + const transfer = makeTransfer(); + assert.equal(transfer.canTransfer(tabId), true); + assert.equal(transfer.canDetach(tabId), true); + tabManager.tabs.find((tab) => tab.id === tabId)!.isTruncated = true; + assert.equal(transfer.canTransfer(tabId), false); + assert.equal(transfer.canDetach(tabId), false); + + // The movers complete the buffer first, and stop when they cannot. + const asked: string[] = []; + let partial = 0; + const movers = createTabMovers({ + ensureFullContent: async () => false, + onPartialDocument: () => partial++, + session: { + detach: async (id) => (asked.push(`detach ${id}`), true), + transfer: async (id) => (asked.push(`transfer ${id}`), true), + }, + }); + assert.equal(await movers.handleDetach(tabId), false); + assert.equal(await movers.moveTabToWindow(tabId, 'w2'), false); + assert.deepEqual(asked, []); + assert.equal(partial, 2); + }); + + test('moving to an existing window uses the acknowledged transfer protocol', async () => { + respond = (cmd) => (cmd === 'stage_detached_tab' ? TOKEN : null); + const tabId = openTab('doc'); + const { session } = makeSession(); + const movers = createTabMovers({ ensureFullContent: async () => true, onPartialDocument: () => {}, session }); + + const moving = movers.moveTabToWindow(tabId, 'w2', true); + await settle(); + const offer = calls.find((call) => call.cmd === 'offer_tab_to_window'); + assert.deepEqual(offer?.args, { targetLabel: 'w2', token: TOKEN }); + assert.equal(tabManager.tabs.length, 1, 'the source keeps the tab until the target acknowledges'); + + emit('tab-transfer-claimed', TOKEN); + assert.equal(await moving, true); + assert.equal(tabManager.tabs.length, 0); + assert.deepEqual(calls.find((call) => call.cmd === 'focus_window')?.args, { label: 'w2' }); + }); }); diff --git a/scripts/windows.checks.test.ts b/scripts/windows.checks.test.ts index e09d270b..f40eca21 100644 --- a/scripts/windows.checks.test.ts +++ b/scripts/windows.checks.test.ts @@ -87,14 +87,6 @@ describe('moving tabs between windows', () => { ); }); - test('moving to an existing window uses the acknowledged transfer protocol', () => { - assert.match(session, /async function transfer\(/); - assert.match(session, /invoke\('complete_detached_tab', \{ token \}\)/); - assert.match(session, /onTransferClaimed\(tabId\)/); - assert.match(session, /async function acceptOfferedTransfer/); - assert.match(viewer, /invoke\('offer_tab_to_window', \{ targetLabel, token \}\)/); - }); - // `WebviewWindowBuilder::build()` deadlocks when called from a // *synchronous* Tauri command on Windows/WebView2, because the main thread is // blocked inside the command while WebView2 waits for that same thread to pump diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 155e98ba..7e83d7a1 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -26,7 +26,7 @@ import { autoSaveFailureNeedsToast, isAutoSaveEligible } from './utils/autoSaveTimer.js'; import { exportAsHtml as _exportHtml, exportAsPdf as _exportPdf } from './utils/export'; import { askToOpenExportedFile } from './utils/openExportedFile.js'; - import { isHomePath, showsHomeScreen } from './utils/homeTab.js'; + import { showsHomeScreen } from './utils/homeTab.js'; import { copyableFlavours } from './utils/previewCopy.js'; import { hasExportableDocument } from './utils/tabFileActions.js'; import { editorLanguageFor } from './utils/editorLanguage.js'; @@ -106,8 +106,7 @@ import { createRecentFiles } from './utils/recentFiles.js'; import HomePage from './components/HomePage.svelte'; import { openNewWindow, pinnedTagFromWindowLabel } from './utils/pinnedTagWindow.js'; import { tabManager, type Tab } from './stores/tabs.svelte.js'; -import { snapshotTab } from './utils/tabTransfer.js'; -import { outgoingTabAnchorLine } from './utils/editorPosition.js'; +import { createTabMovers, createTransferOptions } from './sessions/windowTransfer.js'; import { createPaneSlider, planPaneSlide, type PanesShown } from './utils/paneSlide.js'; import { adjustPreviewMaxWidth, getPreviewContentWidth } from './utils/previewWidth.js'; import { isTocOverhanging } from './utils/tocOverlay.js'; @@ -696,19 +695,22 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from const windowStateJson = () => tabManager.serializeState({ folderRoot: folderManager.root }); let restoredFolder: string | null = null; + const transferOptions = createTransferOptions({ + editorPane: () => editorPane, + windowStateJson, + isCloseWalkActive: () => windowExit.isCloseWalkActive(), + isDisposed: () => isDisposed, + renderPreview: renderTabPreviewFromRaw, + restoreReadingPosition: (tab) => { + if (markdownBody) restorePreviewReadingPosition(markdownBody, tab, getPreviewScrollMax(markdownBody), measurePreviewBox); + }, + }); + const windowSession = createWindowSession({ windowStateKey: WINDOW_STATE_KEY, legacyStateKey: LEGACY_STATE_KEY, restoreInProgressKey: RESTORE_IN_PROGRESS_KEY, - serializeState: () => { - // Same reason as `transferPayload` below, at a different moment: this - // runs at close and at each checkpoint with the editor still mounted, - // so the tab being edited would be persisted at the position it held - // when the editor last came down. Only the active tab can be the one - // the editor holds. - if (tabManager.activeTabId) editorPane?.flushPositionTo(tabManager.activeTabId); - return windowStateJson(); - }, + serializeState: transferOptions.serializeState, shouldRestoreState: () => settings.restoreStateOnReopen, isDisposed: () => isDisposed, restoreState: (json) => { @@ -718,87 +720,11 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from restoredTabs: () => tabManager.tabs.map((tab) => ({ id: tab.id, path: tab.path })), loadRestoredTab: (tabId) => documentSession.readRestoredTab(tabId), dropRestoredTab: (tabId) => tabManager.closeTab(tabId), - // A partially loaded buffer must never be handed to another window. - // The transfer payload has no field for "incomplete" (see - // tabTransfer.ts) and the destination rebuilds the tab from the - // payload alone, so the arriving copy would look authoritative and its - // auto-save would truncate the file. handleDetach and moveTabToWindow - // complete the buffer first; these predicates are the backstop. - canTransfer: (tabId) => { - const tab = tabManager.tabs.find((item) => item.id === tabId); - return !windowExit.isCloseWalkActive() && tab !== undefined && !isHomePath(tab.path) && !tab.isTruncated; - }, - canDetach: (tabId) => { - const tab = tabManager.tabs.find((item) => item.id === tabId); - return !windowExit.isCloseWalkActive() && tab !== undefined && !isHomePath(tab.path) && !tab.isTruncated && tabManager.tabs.length >= 2; - }, - transferPayload: (tabId) => { - const tab = tabManager.tabs.find((item) => item.id === tabId); - if (!tab) throw new Error('Tab disappeared before transfer'); - // The snapshot carries the reading position and is taken here, - // synchronously, with the editor still mounted — so a tab being - // edited would travel with the position from its last teardown, and a - // tab opened straight into edit mode with 0. The preview writes its - // own fields on every scroll and needs nothing; this is edit mode's - // equivalent, and it is a no-op for any tab the editor is not on. - editorPane?.flushPositionTo(tabId); - // The other half of the same problem: the flush can only speak for - // the tab the editor is holding, and any other tab can be sent from - // here too — the tab context menu names one, and `mergeSelfInto` - // walks all of them. `editorViewState` is what covers a background - // edit-mode tab in memory and it is excluded from the payload, so - // the anchor is recovered from it before it is dropped. - return JSON.stringify({ - ...snapshotTab(tab), - anchorLine: outgoingTabAnchorLine(tab, tabManager.activeTabId), - }); - }, + canTransfer: transferOptions.canTransfer, + canDetach: transferOptions.canDetach, + transferPayload: transferOptions.transferPayload, onTransferClaimed: (tabId) => tabManager.closeTab(tabId), - acceptTransferredTab: async (snapshot) => { - // The tab has to exist before it can be rendered, which leaves a - // window where this side owns a document the source still shows. - // Anything that goes wrong from here must undo the insert, or the - // same file stays open in both windows with two auto-save timers - // writing over each other. - const id = tabManager.insertTransferredTab(snapshot); - try { - const transferred = tabManager.tabs.find((tab) => tab.id === id); - if (!transferred) return null; - await renderTabPreviewFromRaw(transferred); - if (isDisposed) { - tabManager.closeTab(id); - return null; - } - // The reading position travels with the tab, and until here nothing - // put it back. `insertTransferredTab` activates the tab - // synchronously, while its `content` is still `''`; the restore - // effect below runs on that activation against a host with no - // document in it, finds no anchor and no scroll range, and the - // document the reader left arrives afterwards at the top. That effect - // cannot cover this by depending on the render — it also runs while - // the reader is typing in split view, where a per-render dependency - // would drag the preview back on every debounce — so the arrival - // asks again, now that `renderTabPreviewFromRaw` has awaited the - // patch and the host holds the document. - // - // Guarded on the tab still being the active one: only the active - // host is displayed, and `markdownBody` is the article all of them - // share, so scrolling it for a tab the user has since switched away - // from would move somebody else's document. - if (markdownBody && tabManager.activeTabId === id) { - restorePreviewReadingPosition( - markdownBody, - transferred, - getPreviewScrollMax(markdownBody), - measurePreviewBox, - ); - } - return id; - } catch (error) { - tabManager.closeTab(id); - throw error; - } - }, + acceptTransferredTab: transferOptions.acceptTransferredTab, onError: (message, error) => { console.error(message, error); addToast(`${message}: ${String(error)}`, 'error'); @@ -816,6 +742,11 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from 'warning', ), }); + const { handleDetach, moveTabToWindow } = createTabMovers({ + ensureFullContent: (tabId) => documentSession.ensureFullContent(tabId), + onPartialDocument: () => addToast(t('toast.partialDocument', settings.language), 'error'), + session: windowSession, + }); // The session is recorded as it changes, so a crash or kill brings this // window back as it was. Tracks the window's shape only: which tabs, in @@ -3314,34 +3245,6 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from } } - // Moving a tab to a new window preserves its state as-is — dirty content, - // edit/split mode, history — so there is deliberately no canCloseTab() - // save prompt here: movement is not closing. The content travels through - // the Rust broker (never disk, never localStorage), and the source tab is - // deleted only after the destination confirms the claim, so any failure — - // window creation error, timeout — leaves the tab exactly where it was. - // A large file may still be holding only its preview slice. The receiving - // window cannot tell, so the buffer is completed here, before the payload - // is built — otherwise the document arrives short and gets written back - // that way. - async function handleDetach(tabId: string) { - if (!(await documentSession.ensureFullContent(tabId))) { - addToast(t('toast.partialDocument', settings.language), 'error'); - return false; - } - return windowSession.detach(tabId); - } - - async function moveTabToWindow(tabId: string, targetLabel: string, focusAfter = false) { - if (!(await documentSession.ensureFullContent(tabId))) { - addToast(t('toast.partialDocument', settings.language), 'error'); - return false; - } - const moved = await windowSession.transfer(tabId, (token) => invoke('offer_tab_to_window', { targetLabel, token })); - if (moved && focusAfter) await invoke('focus_window', { label: targetLabel }); - return moved; - } - async function carryActiveTabToNextWindow() { const activeId = tabManager.activeTabId; if (!activeId) return; diff --git a/src/lib/sessions/windowTransfer.ts b/src/lib/sessions/windowTransfer.ts new file mode 100644 index 00000000..1f1cab01 --- /dev/null +++ b/src/lib/sessions/windowTransfer.ts @@ -0,0 +1,148 @@ +import { invoke } from '@tauri-apps/api/core'; + +import { tabManager, type Tab } from '../stores/tabs.svelte.js'; +import { outgoingTabAnchorLine } from '../utils/editorPosition.js'; +import { isHomePath } from '../utils/homeTab.js'; +import { snapshotTab } from '../utils/tabTransfer.js'; + +type EditorPane = { flushPositionTo(tabId: string): void } | null | undefined; + +/** + * The viewer's half of the window session's options: what this window + * sends when a tab leaves it, and how it takes one in. + */ +export function createTransferOptions(options: { + editorPane: () => EditorPane; + windowStateJson: () => string; + isCloseWalkActive: () => boolean; + isDisposed: () => boolean; + renderPreview: (tab: Tab) => Promise; + /** Puts the preview back at the tab's reading position. */ + restoreReadingPosition: (tab: Tab) => void; +}) { + const find = (tabId: string) => tabManager.tabs.find((item) => item.id === tabId); + + return { + serializeState: () => { + // Same reason as `transferPayload` below, at a different moment: this + // runs at close and at each checkpoint with the editor still mounted, + // so the tab being edited would be persisted at the position it held + // when the editor last came down. Only the active tab can be the one + // the editor holds. + if (tabManager.activeTabId) options.editorPane()?.flushPositionTo(tabManager.activeTabId); + return options.windowStateJson(); + }, + // A partially loaded buffer must never be handed to another window. + // The transfer payload has no field for "incomplete" (see + // tabTransfer.ts) and the destination rebuilds the tab from the + // payload alone, so the arriving copy would look authoritative and its + // auto-save would truncate the file. handleDetach and moveTabToWindow + // complete the buffer first; these predicates are the backstop. + canTransfer: (tabId: string) => { + const tab = find(tabId); + return !options.isCloseWalkActive() && tab !== undefined && !isHomePath(tab.path) && !tab.isTruncated; + }, + canDetach: (tabId: string) => { + const tab = find(tabId); + return !options.isCloseWalkActive() && tab !== undefined && !isHomePath(tab.path) && !tab.isTruncated && tabManager.tabs.length >= 2; + }, + transferPayload: (tabId: string) => { + const tab = find(tabId); + if (!tab) throw new Error('Tab disappeared before transfer'); + // The snapshot carries the reading position and is taken here, + // synchronously, with the editor still mounted — so a tab being + // edited would travel with the position from its last teardown, and a + // tab opened straight into edit mode with 0. The preview writes its + // own fields on every scroll and needs nothing; this is edit mode's + // equivalent, and it is a no-op for any tab the editor is not on. + options.editorPane()?.flushPositionTo(tabId); + // The other half of the same problem: the flush can only speak for + // the tab the editor is holding, and any other tab can be sent from + // here too — the tab context menu names one, and `mergeSelfInto` + // walks all of them. `editorViewState` is what covers a background + // edit-mode tab in memory and it is excluded from the payload, so + // the anchor is recovered from it before it is dropped. + return JSON.stringify({ + ...snapshotTab(tab), + anchorLine: outgoingTabAnchorLine(tab, tabManager.activeTabId), + }); + }, + acceptTransferredTab: async (snapshot: Parameters[0]) => { + // The tab has to exist before it can be rendered, which leaves a + // window where this side owns a document the source still shows. + // Anything that goes wrong from here must undo the insert, or the + // same file stays open in both windows with two auto-save timers + // writing over each other. + const id = tabManager.insertTransferredTab(snapshot); + try { + const transferred = find(id); + if (!transferred) return null; + await options.renderPreview(transferred); + if (options.isDisposed()) { + tabManager.closeTab(id); + return null; + } + // The reading position travels with the tab, and until here nothing + // put it back. `insertTransferredTab` activates the tab + // synchronously, while its `content` is still `''`; the restore + // effect runs on that activation against a host with no document in + // it, finds no anchor and no scroll range, and the document the + // reader left arrives afterwards at the top. That effect cannot + // cover this by depending on the render — it also runs while the + // reader is typing in split view, where a per-render dependency + // would drag the preview back on every debounce — so the arrival + // asks again, now that the render has awaited the patch and the + // host holds the document. + // + // Guarded on the tab still being the active one: only the active + // host is displayed, and the article all of them share would move + // somebody else's document. + if (tabManager.activeTabId === id) options.restoreReadingPosition(transferred); + return id; + } catch (error) { + tabManager.closeTab(id); + throw error; + } + }, + }; +} + +/** + * Moving a tab out of this window preserves its state as-is — dirty content, + * edit/split mode, history — so there is deliberately no canCloseTab() save + * prompt here: movement is not closing. The content travels through the Rust + * broker (never disk, never localStorage), and the source tab is deleted only + * after the destination confirms the claim, so any failure — window creation + * error, timeout — leaves the tab exactly where it was. + * + * A large file may still be holding only its preview slice. The receiving + * window cannot tell, so the buffer is completed here, before the payload is + * built — otherwise the document arrives short and gets written back that way. + */ +export function createTabMovers(options: { + ensureFullContent: (tabId: string) => Promise; + onPartialDocument: () => void; + session: { + detach: (tabId: string) => Promise; + transfer: (tabId: string, deliver: (token: string) => Promise) => Promise; + }; +}) { + return { + async handleDetach(tabId: string) { + if (!(await options.ensureFullContent(tabId))) { + options.onPartialDocument(); + return false; + } + return options.session.detach(tabId); + }, + async moveTabToWindow(tabId: string, targetLabel: string, focusAfter = false) { + if (!(await options.ensureFullContent(tabId))) { + options.onPartialDocument(); + return false; + } + const moved = await options.session.transfer(tabId, (token) => invoke('offer_tab_to_window', { targetLabel, token })); + if (moved && focusAfter) await invoke('focus_window', { label: targetLabel }); + return moved; + }, + }; +} From 4fe8c388a737083079b448d078f9a1f851cc8e5b Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:19:02 +0800 Subject: [PATCH 054/118] refactor: move view toggles and scroll sync into sessions/viewMode and test them --- scripts/documents.checks.spec.ts | 694 +--------------------------- scripts/documents.checks.test.ts | 40 +- scripts/documents.viewMode.spec.ts | 651 ++++++++++++++++++++++++++ scripts/layout.checks.test.ts | 6 +- scripts/navigation.checks.test.ts | 23 +- src/lib/MarkdownViewer.svelte | 410 ++-------------- src/lib/sessions/viewMode.svelte.ts | 435 +++++++++++++++++ 7 files changed, 1158 insertions(+), 1101 deletions(-) create mode 100644 scripts/documents.viewMode.spec.ts create mode 100644 src/lib/sessions/viewMode.svelte.ts diff --git a/scripts/documents.checks.spec.ts b/scripts/documents.checks.spec.ts index bfb05271..5033a7e7 100644 --- a/scripts/documents.checks.spec.ts +++ b/scripts/documents.checks.spec.ts @@ -1,10 +1,9 @@ import assert from 'node:assert/strict'; import ts from 'typescript'; -import { afterAll, beforeAll, describe, test } from 'vitest'; +import { beforeAll, describe, test } from 'vitest'; -import { sourceAtPoint } from '../src/lib/utils/previewCursor.js'; -import { callbackBodies, offsetOf, readSource, sliceFrom } from './sourceTree.js'; +import { callbackBodies, readSource } from './sourceTree.js'; // Each describe installs its own Tauri stub in `beforeAll`. This one only // answers the settings store, which boots when the modules below are imported. @@ -14,8 +13,6 @@ import { callbackBodies, offsetOf, readSource, sliceFrom } from './sourceTree.js }; const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { settings } = await import('../src/lib/stores/settings.svelte.js'); -const { createExternalChange } = await import('../src/lib/sessions/externalChange.svelte.js'); /* * A keystroke reaches the document by ONE route, and the dirty flag is not a @@ -179,690 +176,3 @@ describe('editor writes through the store', () => { assert.equal(activeTab().isDirty, false, 'the tab claims unsaved edits to a document it no longer holds'); }); }); - -// Issue #168, second report by @dayeggpi: "allow user to switch to rendered -// view without saving/creating file ... no way to see rendered view until file -// is saved". -// -// The cause was that reading mode was drawn from DISK: leaving the editor -// called `loadMarkdown(tab.path)`, so a dirty tab had to be flushed first — -// silently with auto-save on, through a modal otherwise — or the reader would -// have shown the pre-edit file. The modal was never protecting the buffer; the -// buffer survives a view toggle. It was protecting the screen from lying. -// -// These tests run the REAL `toggleEdit` / `toggleSplitView` / the real -// `renderTabPreviewFromRaw` out of MarkdownViewer.svelte against the real -// TabManager, with the disk, the renderer and the modal faked. `loadMarkdown` -// is faked to behave like the real one (dirty short-circuit included) and to -// serve a DIFFERENT text than the buffer, so any route that goes back to the -// file is visible in the rendered output rather than merely in the call log. -// -// The boundary tests at the bottom are the other half of the argument: closing -// a tab and closing the window still ask, because there the buffer really is -// about to disappear. -describe('view mode without saving', () => { - // ---------------------------------------------------------------- environment - - // The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the - // Svelte plugin, so the store and the session run under real reactivity, and jsdom - // supplies `window` and `localStorage`. Only the Tauri backend is stubbed. - beforeAll(() => { - (window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: () => Promise.reject(new Error('no invoke expected in these tests')), - }; - }); - - // `settings` is one store for the whole file: put back what this block changes. - let settingsBefore: { autoSave: typeof settings.autoSave }; - beforeAll(() => { - settingsBefore = { autoSave: settings.autoSave }; - }); - afterAll(() => { - Object.assign(settings, settingsBefore); - }); - - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - // ------------------------------------------------------------ source plucking - - /** - * Slice one `async function (...) { ... }` out of the component. - * - * Brace counting, but string/template/comment aware — a naive count trips over - * the first `{` inside a comment or a template literal and silently returns a - * body that stops in the middle, which would make every assertion below - * meaningless in exactly the direction that hides bugs. - */ - function pluck(name: string, required = true): string { - const asyncStart = viewer.indexOf(`async function ${name}(`); - const syncStart = viewer.indexOf(`\tfunction ${name}(`); - const start = asyncStart !== -1 ? asyncStart : syncStart === -1 ? -1 : syncStart + 1; - if (start === -1) { - assert.ok(!required, `expected MarkdownViewer.svelte to define ${name}`); - return ''; - } - let i = offsetOf(viewer, '{', offsetOf(viewer, ')', start)); - let depth = 0; - for (; i < viewer.length; i++) { - const c = viewer[i]; - const next = viewer[i + 1]; - if (c === '/' && next === '/') { - i = viewer.indexOf('\n', i); - continue; - } - if (c === '/' && next === '*') { - i = viewer.indexOf('*/', i) + 1; - continue; - } - if (c === "'" || c === '"' || c === '`') { - const quote = c; - for (i++; i < viewer.length; i++) { - if (viewer[i] === '\\') i++; - else if (viewer[i] === quote) break; - } - continue; - } - if (c === '{') depth++; - else if (c === '}' && --depth === 0) return viewer.slice(start, i + 1); - } - assert.fail(`unbalanced braces while slicing ${name}`); - } - - type Harness = { - toggleEdit: () => Promise; - toggleSplitView: (tabId: string) => Promise; - }; - - type Fakes = { - disk: Map; - askCustomCalls: string[]; - saveCalls: string[]; - loadCalls: string[]; - renderedFrom: string[]; - toasts: string[]; - saveFails: boolean; - /** Text the "user" appends while `saveContent` is awaiting (TOCTOU). */ - typeDuringSave: string; - }; - - /** `renderMarkdownPreview`'s stand-in: the output names what it was given. */ - const rendered = (raw: string) => `${raw}`; - - function buildHarness(fakes: Fakes, isEditing: boolean, panes: Record = {}): Harness { - // Mirrors documentSession.saveContent for a tab that has a path. - const saveSilently = async (tabId: string) => { - fakes.saveCalls.push(tabId); - const tab = tabManager.tabs.find((item) => item.id === tabId)!; - if (fakes.saveFails) return false; - const snapshot = tab.rawContent; - await Promise.resolve(); - if (fakes.typeDuringSave) tab.rawContent = snapshot + fakes.typeDuringSave; - fakes.disk.set(tab.path, snapshot); - tab.originalContent = snapshot; - return true; - }; - const source = [ - pluck('renderTabPreviewFromRaw'), - pluck('renderPreviewLeavingEditableMode', false), - pluck('toggleEdit'), - pluck('toggleSplitView'), - ].join('\n\n'); - - const js = ts.transpileModule(source, { - compilerOptions: { target: ts.ScriptTarget.ES2022 }, - }).outputText; - - const factory = new Function( - 'deps', - `"use strict"; - const { - tabManager, settings, t, addToast, askCustom, flushBeforeLeavingEditableMode, - cancelPendingAutoSave, renderMarkdownPreview, loadMarkdown, - documentSession, invoke, isEditing, liveMode, toggleLiveMode, - tick, renderRichContent, editorPane, markdownBody, - getPreviewScrollSyncPosition, restoreAfterLeavingEditor, - } = deps; - let previewPlacing = false; - ${js} - return { toggleEdit, toggleSplitView };`, - ); - - // The real flush, from the viewer's save module, over a fake disk. - const { flushBeforeLeavingEditableMode } = createExternalChange({ - tabManager, - settings, - documentSession: { saveContent: saveSilently, allowOverwriteOnce: () => {} }, - loadMarkdown: async () => {}, - canCloseTab: async () => true, - readFile: async () => ['', false, 'UTF-8'], - editorLanguageFor: () => 'markdown', - addToast: (message: string) => void fakes.toasts.push(message), - t: (key: string) => key, - }); - - return factory({ - tabManager, - settings, - isEditing, - flushBeforeLeavingEditableMode, - t: (key: string) => key, - addToast: (message: string) => fakes.toasts.push(message), - askCustom: async (message: string) => { - fakes.askCustomCalls.push(message); - return 'save' as const; - }, - cancelPendingAutoSave: () => {}, - renderMarkdownPreview: async (raw: string) => { - fakes.renderedFrom.push(raw); - return rendered(raw); - }, - // The disk route, with the real function's dirty short-circuit. - loadMarkdown: async (path: string, options: any = {}) => { - fakes.loadCalls.push(path); - const activeId = tabManager.activeTabId!; - const receiving = tabManager.tabs.find((item) => item.id === activeId)!; - if (receiving.isDirty && receiving.path === path && !options.discardUnsavedBuffer) return; - const content = fakes.disk.get(path) ?? ''; - fakes.renderedFrom.push(content); - tabManager.updateTabContent(activeId, rendered(content)); - tabManager.setTabRawContent(activeId, content); - }, - documentSession: { ensureFullContent: async () => true, isLossySaveRefused: () => false }, - invoke: async () => { - throw new Error('no invoke expected while leaving an editable pane'); - }, - liveMode: false, - toggleLiveMode: () => {}, - tick: async () => {}, - // No panes unless a test asks: where the reader lands (#799) is not - // what the rest of this file is about. - editorPane: undefined, - markdownBody: undefined, - ...panes, - }); - } - - function freshFakes(): Fakes { - return { - disk: new Map(), - askCustomCalls: [], - saveCalls: [], - loadCalls: [], - renderedFrom: [], - toasts: [], - saveFails: false, - typeDuringSave: '', - }; - } - - const ON_DISK = '# saved heading\n'; - const IN_BUFFER = '# saved heading\n\nan edit that was never written to disk\n'; - - /** - * A tab holding unsaved edits to a real file, in whichever editable pane the - * caller names, with the file on disk still carrying the pre-edit text. - */ - function dirtyTab(mode: 'edit' | 'split', path = '/notes/note.md') { - tabManager.closeAll(); - const fakes = freshFakes(); - fakes.disk.set(path, ON_DISK); - tabManager.addTab(path); - const tab = tabManager.activeTab!; - tabManager.setTabRawContent(tab.id, ON_DISK); - tab.isEditing = mode === 'edit'; - tabManager.setSplitEnabled(tab.id, mode === 'split'); - tabManager.updateTabRawContent(tab.id, IN_BUFFER); - assert.equal(tab.isDirty, true, 'precondition: the tab is dirty'); - return { tab, fakes, harness: buildHarness(fakes, mode === 'edit') }; - } - - /** - * `autoSave` used to be half of a pair — the other, `confirmBeforeSave`, could - * veto it, and every decision in the app read `autoSave && !confirmBeforeSave`. - * The pair is now the one switch that expression always described, so the two - * old combinations that meant "do not write silently" are the single `false` - * here. - */ - function setSettings(autoSave: boolean) { - settings.autoSave = autoSave; - } - - // ------------------------------------------------------- leaving edit mode - - test('a dirty file switches to reading mode without a modal and without writing', async () => { - setSettings(false); - const { tab, fakes, harness } = dirtyTab('edit'); - - await harness.toggleEdit(); - - assert.deepEqual(fakes.askCustomCalls, [], 'no unsaved-changes modal on a view toggle'); - assert.deepEqual(fakes.saveCalls, [], 'nothing is written to disk'); - assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); - assert.equal(tab.isEditing, false, 'the user actually reaches reading mode'); - assert.equal(tab.isDirty, true, 'the edits are still unsaved, and still flagged as such'); - assert.equal(tab.rawContent, IN_BUFFER, 'the buffer is intact'); - }); - - test('reading mode shows the buffer, not the file', async () => { - setSettings(false); - const { tab, harness, fakes } = dirtyTab('edit'); - - await harness.toggleEdit(); - - // The whole point of the bug: with a disk read here, this is `ON_DISK`. - assert.equal(tab.content, rendered(IN_BUFFER)); - assert.deepEqual( - fakes.renderedFrom, - [IN_BUFFER], - 'the preview is rendered once, from the buffer', - ); - assert.deepEqual(fakes.loadCalls, [], 'the file is not re-read to leave the editor'); - }); - - test('an untitled buffer never reaches the Save dialog on a view toggle', async () => { - setSettings(true); - tabManager.closeAll(); - const fakes = freshFakes(); - tabManager.addTab(''); - const tab = tabManager.activeTab!; - tab.isEditing = true; - tabManager.updateTabRawContent(tab.id, '# untitled\n'); - const harness = buildHarness(fakes, true); - - await harness.toggleEdit(); - - assert.deepEqual(fakes.saveCalls, [], 'saveContent would open the Save dialog for a pathless tab'); - assert.equal(tab.isEditing, false); - assert.equal(tab.content, rendered('# untitled\n')); - }); - - test('auto-save still flushes on the way out, because the debounce is about to be dropped', async () => { - // The auto-save effect requires `isEditing || isSplit`, so this is the last - // chance to honour "save automatically" for these edits. - setSettings(true); - const { tab, fakes, harness } = dirtyTab('edit'); - - await harness.toggleEdit(); - - assert.deepEqual(fakes.saveCalls, [tab.id]); - assert.equal(fakes.disk.get(tab.path), IN_BUFFER, 'the flush actually wrote the buffer'); - assert.deepEqual(fakes.askCustomCalls, [], 'the flush is silent — it is the user\'s own setting'); - assert.equal(tab.isDirty, false); - assert.equal(tab.isEditing, false); - assert.equal(tab.content, rendered(IN_BUFFER)); - }); - - test('a file that cannot be written no longer traps the user in the editor', async () => { - // Read-only path, or a buffer the lossy-decode guard refuses: saveContent - // returns false forever, and the old code returned early on that, so - // reading mode was unreachable for the life of the tab. - setSettings(true); - const { tab, fakes, harness } = dirtyTab('edit'); - fakes.saveFails = true; - - await harness.toggleEdit(); - - assert.deepEqual(fakes.saveCalls, [tab.id], 'the flush was attempted'); - assert.ok(fakes.toasts.includes('toast.autoSaveFailed'), 'and the failure was reported'); - assert.equal(tab.isEditing, false, 'but the view still switches'); - assert.equal(tab.content, rendered(IN_BUFFER)); - assert.equal(tab.isDirty, true, 'the buffer is still there to be rescued'); - }); - - test('edits typed during the flush are shown, and reported as not yet on disk', async () => { - setSettings(true); - const { tab, fakes, harness } = dirtyTab('edit'); - fakes.typeDuringSave = 'typed while saving\n'; - - await harness.toggleEdit(); - - assert.equal(tab.isDirty, true); - assert.ok(fakes.toasts.includes('toast.savedNewerEdits'), 'the disk is one revision behind'); - assert.equal(tab.isEditing, false, 'the TOCTOU case is no longer a reason to stay in the editor'); - assert.equal( - tab.content, - rendered(IN_BUFFER + 'typed while saving\n'), - 'the preview shows the newest text, which is exactly what is NOT on disk', - ); - }); - - // --------------------------------------------------- closing the split view - - test('closing split view on a dirty file neither asks nor writes', async () => { - setSettings(false); - const { tab, fakes, harness } = dirtyTab('split'); - - await harness.toggleSplitView(tab.id); - - assert.deepEqual(fakes.askCustomCalls, []); - assert.deepEqual(fakes.saveCalls, []); - assert.deepEqual(fakes.loadCalls, []); - assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); - assert.equal(tab.isSplit, false); - assert.equal(tab.isDirty, true); - assert.equal(tab.content, rendered(IN_BUFFER), 'the surviving pane keeps showing the buffer'); - }); - - test('closing split view honours auto-save the same way leaving edit mode does', async () => { - setSettings(true); - const { tab, fakes, harness } = dirtyTab('split'); - - await harness.toggleSplitView(tab.id); - - assert.deepEqual(fakes.saveCalls, [tab.id]); - assert.deepEqual(fakes.askCustomCalls, []); - assert.equal(tab.isSplit, false); - assert.equal(tab.content, rendered(IN_BUFFER)); - }); - - test('the view toggles no longer re-read the file to leave an editable pane', () => { - // Belt and braces for the behaviour above: neither toggle may reach for - // the disk again. `renderTabPreviewFromRaw` is the shared "render THIS - // tab's buffer under its own path" helper (it also serves the PDF export). - const toggleEdit = pluck('toggleEdit'); - const leaveEditMode = toggleEdit.slice(0, offsetOf(toggleEdit, '// Switch to edit')); - assert.doesNotMatch(leaveEditMode, /loadMarkdown/); - assert.doesNotMatch(leaveEditMode, /askCustom/); - - const toggleSplit = pluck('toggleSplitView'); - const closeSplit = sliceFrom(toggleSplit, 'setSplitEnabled(tab.id, false)'); - assert.doesNotMatch(closeSplit, /loadMarkdown/); - assert.doesNotMatch(toggleSplit, /askCustom/); - }); - - // ------------------------------------------------ where the reader lands (#799) - - test('Ctrl+E hands each pane the line the other one was showing', async () => { - setSettings(false); - const { tab, fakes } = dirtyTab('edit'); - const fromEditor = { section: 'body', ratio: 0.4, line: 120 }; - const fromPreview = { section: 'body', ratio: 0.7, line: 300 }; - const synced: unknown[] = []; - const restored: unknown[] = []; - const panes = { - editorPane: { - scrollSyncPosition: () => fromEditor, - syncScrollToPosition: (position: unknown) => synced.push(position), - }, - markdownBody: {}, - getPreviewScrollSyncPosition: () => fromPreview, - restoreAfterLeavingEditor: (_id: string, position: unknown) => restored.push(position), - }; - - await buildHarness(fakes, true, panes).toggleEdit(); - assert.equal(tab.isEditing, false); - assert.deepEqual(restored, [fromEditor], 'the preview was not sent to the editor\'s line'); - - await buildHarness(fakes, false, panes).toggleEdit(); - assert.equal(tab.isEditing, true); - assert.deepEqual(synced, [fromPreview], 'the editor was not sent to the preview\'s line'); - }); - - test('Ctrl+E brings the cursor on screen only when the outline follows it', async () => { - // An outline marking the cursor's heading while the editor shows another - // one reads as wrong, so with 'cursor' the handoff moves a cursor left off - // screen. With 'scroll' the outline never looks at the cursor, and moving - // it would only lose the reader's place. - const asked: unknown[] = []; - for (const follows of ['cursor', 'scroll'] as const) { - settings.tocFollows = follows; - const { tab, fakes } = dirtyTab('edit'); - tab.isEditing = false; - await buildHarness(fakes, false, { - editorPane: { syncScrollToPosition: (_position: unknown, options: unknown) => asked.push(options) }, - markdownBody: {}, - getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), - }).toggleEdit(); - } - settings.tocFollows = 'scroll'; - assert.deepEqual(asked, [{ cursorIntoView: true }, { cursorIntoView: false }]); - }); - - // Scroll sync is split view's. The tab keeps `isScrollSynced` after the split - // closes, and out of split the other pane is still mounted at zero width, so - // syncing through it dragged the visible pane off its line on every Ctrl+E. - function buildSyncHarness(panes: Record) { - const source = [ - pluck('handleEditorScrollSync'), - pluck('splitScrollSyncOn', false), - pluck('syncEditorToPreviewScroll'), - pluck('restoreAfterLeavingEditor'), - pluck('followToc'), - pluck('handleEditorCursor'), - pluck('placePreviewCursor'), - ].join('\n\n'); - const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; - const factory = new Function( - 'deps', - `"use strict"; - const { - tabManager, tick, viewerPaneEl, markdownBody, editorPane, lineCoords, - tabAnchorForEditorTopLine, asBufferLine, scrollPreviewToSyncPosition, - getPreviewScrollSyncPosition, getPreviewScrollAnchor, settings, hasEditorPane, hasPreviewPane, isEditing, isSplit, - previewBlocks, sourceAtPoint, readRendererLine, - } = deps; - let tocActiveLine = null; - let previewPlacing = false; - const cursorByTab = {}; - const activeCursor = deps.activeCursor ?? null; - ${js} - return { - handleEditorScrollSync, syncEditorToPreviewScroll, restoreAfterLeavingEditor, handleEditorCursor, followToc, placePreviewCursor, - toc: () => tocActiveLine, - cursors: () => cursorByTab, - startPlacing: () => { previewPlacing = true; }, - };`, - ); - return factory({ - tabManager, - tick: async () => {}, - viewerPaneEl: undefined, - markdownBody: {}, - lineCoords: { toRendererLine: (line: number) => line - 3, toBufferLine: (line: number) => line + 3 }, - settings, - hasEditorPane: false, - hasPreviewPane: true, - isEditing: false, - isSplit: false, - tabAnchorForEditorTopLine: (_coords: unknown, line: number) => line, - asBufferLine: (line: number) => line, - getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), - ...panes, - }); - } - - test('scroll sync left on in split view stays off once the split closes', async () => { - tabManager.closeAll(); - tabManager.addTab('/notes/note.md'); - const tab = tabManager.activeTab!; - tabManager.setSplitEnabled(tab.id, true); - if (!tab.isScrollSynced) tabManager.toggleScrollSync(tab.id); - const toPreview: unknown[] = []; - const toEditor: unknown[] = []; - const harness = buildSyncHarness({ - scrollPreviewToSyncPosition: (position: unknown) => toPreview.push(position), - editorPane: { syncScrollToPosition: (position: unknown) => toEditor.push(position) }, - }); - const position = { section: 'body', ratio: 0.5, line: 40 }; - - harness.handleEditorScrollSync(position); - harness.syncEditorToPreviewScroll({}); - assert.equal(toPreview.length, 1, 'precondition: split view syncs the preview'); - assert.equal(toEditor.length, 1, 'precondition: split view syncs the editor'); - - tabManager.setSplitEnabled(tab.id, false); - tab.isEditing = true; - assert.equal(tab.isScrollSynced, true, 'the tab still remembers the choice'); - - harness.handleEditorScrollSync(position); - harness.syncEditorToPreviewScroll({}); - assert.equal(toPreview.length, 1, 'the hidden preview was scrolled from the editor'); - assert.equal(toEditor.length, 1, 'the editor was scrolled from the hidden preview'); - assert.equal(harness.toc(), 40, 'the outline still follows the editor'); - }); - - test('leaving the editor moves the outline to the line the preview lands on', async () => { - tabManager.closeAll(); - tabManager.addTab('/notes/note.md'); - const tab = tabManager.activeTab!; - tab.isEditing = false; - const harness = buildSyncHarness({ - scrollPreviewToSyncPosition: () => {}, - getPreviewScrollAnchor: () => 57, - }); - - await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); - - assert.equal(tab.anchorLine, 57); - assert.equal(harness.toc(), 57, 'the outline kept the entry from before the switch'); - }); - - // #799: "the table of contents should follow the cursor". An editor setting, - // because only the editor has one: the preview alone always follows the scroll. - describeOutlineSource('cursor', true, (h) => { - h.handleEditorCursor(20, 1); - assert.equal(h.toc(), 17, 'the cursor line, in the outline\'s numbering'); - h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); - assert.equal(h.toc(), 17, 'scrolling the editor left it on the cursor'); - h.handleEditorCursor(30, 1); - assert.equal(h.toc(), 27); - }); - - describeOutlineSource('cursor', false, async (h) => { - h.handleEditorCursor(20, 1); - assert.equal(h.toc(), null, 'no editor on screen, so no cursor to follow'); - await h.restoreAfterLeavingEditor(tabManager.activeTab!.id, { section: 'body', ratio: 0.5, line: 58 }); - assert.equal(h.toc(), 57, 'the preview alone follows its scroll'); - }); - - describeOutlineSource('scroll', true, (h) => { - h.handleEditorCursor(20, 1); - assert.equal(h.toc(), null, 'the default ignores the cursor'); - h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); - assert.equal(h.toc(), 60); - // The editor alone keeps the preview mounted at zero width, and its - // scroll events put the outline on a heading from that layout. - h.followToc('preview', 90); - assert.equal(h.toc(), 60, 'the hidden preview moved the outline'); - }); - - function describeOutlineSource( - follows: 'cursor' | 'scroll', - editorOnScreen: boolean, - run: (harness: ReturnType) => void | Promise, - ) { - test(`the outline follows the ${follows} setting with the editor ${editorOnScreen ? 'on' : 'off'} screen`, async () => { - tabManager.closeAll(); - tabManager.addTab('/notes/note.md'); - tabManager.activeTab!.isEditing = editorOnScreen; - settings.tocFollows = follows; - try { - await run(buildSyncHarness({ - hasEditorPane: editorOnScreen, - hasPreviewPane: !editorOnScreen, - isEditing: editorOnScreen, - scrollPreviewToSyncPosition: () => {}, - getPreviewScrollAnchor: () => 57, - })); - } finally { - settings.tocFollows = 'scroll'; - } - }); - } - - test('the preview does not move the outline after Ctrl+E until it is placed', async () => { - // Its scroll events before that report the old position, and the outline - // flashed the first headings until the preview was placed. - tabManager.closeAll(); - tabManager.addTab('/notes/note.md'); - const tab = tabManager.activeTab!; - tab.isEditing = false; - const harness = buildSyncHarness({ scrollPreviewToSyncPosition: () => {}, getPreviewScrollAnchor: () => 57 }); - - harness.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 40 }); - harness.startPlacing(); - harness.followToc('preview', 2); - assert.equal(harness.toc(), 40, 'an unplaced preview put the outline on its own line'); - - await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); - assert.equal(harness.toc(), 57, 'the placed preview decides'); - harness.followToc('preview', 60); - assert.equal(harness.toc(), 60, 'and keeps deciding once placed'); - }); - - test('reading with a cursor placed, the outline stays on it only while the preview draws it', () => { - tabManager.closeAll(); - tabManager.addTab('/notes/note.md'); - tabManager.activeTab!.isEditing = false; - settings.tocFollows = 'cursor'; - try { - settings.previewCursor = true; - const drawn = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); - drawn.followToc('cursor', 17); - drawn.followToc('preview', 90); - assert.equal(drawn.toc(), 17, 'the preview scroll moved the outline off the drawn cursor'); - - // Off, the editor's last cursor is still there for Ctrl+E, but the - // preview shows nothing, so an outline pinned to it would look stuck. - settings.previewCursor = false; - const hidden = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); - hidden.followToc('cursor', 17); - hidden.followToc('preview', 90); - assert.equal(hidden.toc(), 90, 'the outline stayed on a cursor the preview does not show'); - - const without = buildSyncHarness({ hasEditorPane: false }); - without.followToc('preview', 90); - assert.equal(without.toc(), 90, 'with no cursor yet, the outline follows the scroll'); - } finally { - settings.tocFollows = 'scroll'; - settings.previewCursor = false; - } - }); - - test('a click in the preview places the cursor only with its setting on, whatever the outline follows', () => { - // #799: the shared cursor is an editing aid in its own right, so Ctrl+E - // lands on the clicked character in either outline mode. The drawn caret - // is visual noise to some readers, so the whole aid is off by default. - const root = document.createElement('div'); - root.innerHTML = '

    hello world

    '; - const text = root.querySelector('p')!.firstChild!; - (document as any).caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(text, 6); - return range; - }; - tabManager.closeAll(); - tabManager.addTab('/notes/note.md'); - const tab = tabManager.activeTab!; - tab.isEditing = false; - const click = () => { - const harness = buildSyncHarness({ - previewBlocks: root, - sourceAtPoint, - readRendererLine: (line: number) => (line === 2 ? 'hello world' : ''), - activeCursor: { line: 5, column: 7 }, - }); - harness.placePreviewCursor({ detail: 1, clientX: 0, clientY: 0 } as MouseEvent); - return harness; - }; - try { - settings.tocFollows = 'cursor'; - const off = click(); - assert.deepEqual(off.cursors(), {}, 'off by default: the click leaves the cursor alone'); - assert.equal(off.toc(), null); - - settings.previewCursor = true; - for (const follows of ['scroll', 'cursor'] as const) { - settings.tocFollows = follows; - const harness = click(); - assert.deepEqual(harness.cursors()[tab.id], { line: 5, column: 7 }, `${follows}: the click placed the cursor on the "w"`); - assert.equal(harness.toc(), follows === 'cursor' ? 2 : null, `${follows}: the outline`); - } - } finally { - settings.tocFollows = 'scroll'; - settings.previewCursor = false; - delete (document as any).caretRangeFromPoint; - } - }); -}); diff --git a/scripts/documents.checks.test.ts b/scripts/documents.checks.test.ts index 6be72cd8..c15c8952 100644 --- a/scripts/documents.checks.test.ts +++ b/scripts/documents.checks.test.ts @@ -78,23 +78,6 @@ describe('checked read migration', () => { assert.doesNotMatch(rust, /^\s*read_file_content,\s*$/m, 'and must not be registered again'); }); - test('entering the editor reads the fidelity and stores it', () => { - const toggle = sliceBetween(viewer, 'async function toggleEdit', 'async function restoreAfterLeavingEditor'); - assert.match(toggle, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const read = offsetOf(toggle, 'read_file_content_checked'); - const flag = offsetOf(toggle, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(toggle, 'setTabRawContent(tab.id, content)'); - assert.ok(read < flag && flag < store, 'flag the tab before the buffer is published'); - }); - - test('entering split view reads the fidelity and stores it', () => { - const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); - assert.match(enter, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const flag = offsetOf(enter, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(enter, 'setTabRawContent(tab.id, content)'); - assert.ok(flag < store, 'flag the tab before the buffer is published'); - }); - // --- the repeating toast ------------------------------------------------------ test('the session can tell a refusal from a failure', () => { @@ -716,10 +699,11 @@ describe('external change reload', () => { // not follow the file. The button and the chord are now offered in all // three modes; a kill here would take the state straight back off. // - // An absence claim about a component that cannot be imported, so it is - // matched as source text — the same reason as the four assertions above. - const body = sliceBetween(viewer, 'async function toggleSplitView', '\n\t}'); - assert.doesNotMatch(body, /toggleLiveMode|liveMode\s*=/); + // An absence claim: the view toggles live in `sessions/viewMode`, which + // is not handed Live Mode at all. lint-candidate: no-restricted-syntax on + // `liveMode` / `toggleLiveMode` identifiers in that module. + const viewMode = readSource('src/lib/sessions/viewMode.svelte.ts'); + assert.doesNotMatch(viewMode, /toggleLiveMode|liveMode/); }); // --- #4: the irreversible answer is now visible before it is given --- @@ -796,20 +780,6 @@ describe('truncated buffer guard', () => { // --- wiring that cannot be executed outside a Svelte runtime --- - test('entering split view completes a partial buffer instead of trusting it', () => { - // The old guard was `!tab.rawContent`, which a partial buffer satisfies: - // split view opened on the truncated text, the first keystroke made it - // dirty, and auto-save wrote it back 1.5s later. - const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); - assert.match(enter, /ensureFullContent/); - // And it has to stop when the buffer could not be completed, rather than - // opening an editor over the partial text anyway. - assert.match(enter, /if \(!\(await documentSession\.ensureFullContent\(tab\.id\)\)\) \{[\s\S]*?return;/); - // The empty-buffer read must go through the store so the tab does not keep - // a stale "partial" flag that would then block every save. - assert.match(enter, /tabManager\.setTabRawContent\(tab\.id, content\)/); - }); - test('a partial buffer cannot be handed to another window', () => { // The destination rebuilds the tab from the payload alone and has no way // to know the text is incomplete, so its auto-save would truncate the file. diff --git a/scripts/documents.viewMode.spec.ts b/scripts/documents.viewMode.spec.ts new file mode 100644 index 00000000..e0c2846b --- /dev/null +++ b/scripts/documents.viewMode.spec.ts @@ -0,0 +1,651 @@ +import assert from 'node:assert/strict'; + +import { afterAll, beforeAll, describe, test } from 'vitest'; + +// Importing the store boots the settings singleton, which asks for the OS type. +(window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null), +}; + +const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); +const { settings } = await import('../src/lib/stores/settings.svelte.js'); +const { createExternalChange } = await import('../src/lib/sessions/externalChange.svelte.js'); +const { createViewMode } = await import('../src/lib/sessions/viewMode.svelte.js'); +const { asBufferLine, lineCoordinates } = await import('../src/lib/utils/lineCoordinates.js'); + +type Tab = NonNullable; +type ViewModeOptions = Parameters[0]; + +// Issue #168, second report by @dayeggpi: "allow user to switch to rendered +// view without saving/creating file ... no way to see rendered view until file +// is saved". +// +// The cause was that reading mode was drawn from DISK: leaving the editor +// called `loadMarkdown(tab.path)`, so a dirty tab had to be flushed first — +// silently with auto-save on, through a modal otherwise — or the reader would +// have shown the pre-edit file. The modal was never protecting the buffer; the +// buffer survives a view toggle. It was protecting the screen from lying. +// +// Reading mode now renders the buffer. + +/* + * The viewer's view toggles and scroll sync, run through `sessions/viewMode` + * against the real tab store and the real flush from `externalChange`. The + * doubles are the backend (the file read, the Rust render, the disk) and the + * two panes, whose measurements are not what these tests are about. + */ +describe('view mode without saving', () => { + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: () => Promise.reject(new Error('no invoke expected in these tests')), + }; + }); + + // `settings` is one store for the whole file: put back what this block changes. + let settingsBefore: Pick; + beforeAll(() => { + settingsBefore = { autoSave: settings.autoSave, tocFollows: settings.tocFollows, previewCursor: settings.previewCursor }; + }); + afterAll(() => { + Object.assign(settings, settingsBefore); + }); + + type Fakes = { + disk: Map; + /** What `read_file_content_checked` answers with besides the text. */ + lossy: boolean; + encoding: string; + saveCalls: string[]; + readCalls: string[]; + renderedFrom: string[]; + toasts: string[]; + saveFails: boolean; + /** Text the "user" appends while the save is awaiting (TOCTOU). */ + typeDuringSave: string; + fullContent: boolean; + }; + + /** The Rust render's stand-in: the output names what it was given. */ + const rendered = (raw: string) => `${raw}`; + + /** Three lines of front matter, so the two line numberings differ by 3. */ + const coords = lineCoordinates('---\na: 1\n---\n# body\n'); + + /** A scroll-sync position in the body, at buffer line `line`. */ + const at = (line: number, ratio = 0.5) => ({ section: 'body' as const, ratio, line: asBufferLine(line) }); + + function freshFakes(): Fakes { + return { + disk: new Map(), + lossy: false, + encoding: 'UTF-8', + saveCalls: [], + readCalls: [], + renderedFrom: [], + toasts: [], + saveFails: false, + typeDuringSave: '', + fullContent: true, + }; + } + + function buildViewMode(fakes: Fakes, panes: Partial = {}) { + // Mirrors documentSession.saveContent for a tab that has a path. + const saveSilently = async (tabId: string) => { + fakes.saveCalls.push(tabId); + const tab = tabManager.tabs.find((item) => item.id === tabId)!; + if (fakes.saveFails) return false; + const snapshot = tab.rawContent; + await Promise.resolve(); + if (fakes.typeDuringSave) tab.rawContent = snapshot + fakes.typeDuringSave; + fakes.disk.set(tab.path, snapshot); + tab.originalContent = snapshot; + return true; + }; + const { flushBeforeLeavingEditableMode } = createExternalChange({ + tabManager, + settings, + documentSession: { saveContent: saveSilently, allowOverwriteOnce: () => {} }, + loadMarkdown: async () => {}, + canCloseTab: async () => true, + readFile: async () => ['', false, 'UTF-8'], + editorLanguageFor: () => 'markdown', + addToast: (message: string) => void fakes.toasts.push(message), + t: (key: string) => key, + }); + return createViewMode({ + tabManager, + settings, + documentSession: { ensureFullContent: async () => fakes.fullContent }, + flushBeforeLeavingEditableMode, + readFile: async (path: string) => { + fakes.readCalls.push(path); + return [fakes.disk.get(path) ?? '', fakes.lossy, fakes.encoding]; + }, + renderTabPreviewFromRaw: async (tab: Tab) => { + fakes.renderedFrom.push(tab.rawContent); + tabManager.updateTabContent(tab.id, rendered(tab.rawContent)); + }, + tick: async () => {}, + addToast: (message: string) => void fakes.toasts.push(message), + t: (key: string) => key, + // No panes unless a test asks: where the reader lands (#799) is not + // what most of this file is about. + editorPane: undefined, + markdownBody: undefined, + previewBlocks: undefined, + lineCoords: coords, + readRendererLine: () => '', + getPreviewScrollSyncPosition: () => at(40), + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => null, + ...panes, + } as ViewModeOptions); + } + + const ON_DISK = '# saved heading\n'; + const IN_BUFFER = '# saved heading\n\nan edit that was never written to disk\n'; + + /** + * A tab holding unsaved edits to a real file, in whichever editable pane the + * caller names, with the file on disk still carrying the pre-edit text. + */ + function dirtyTab(mode: 'edit' | 'split', path = '/notes/note.md') { + tabManager.closeAll(); + const fakes = freshFakes(); + fakes.disk.set(path, ON_DISK); + tabManager.addTab(path); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, ON_DISK); + tab.isEditing = mode === 'edit'; + tabManager.setSplitEnabled(tab.id, mode === 'split'); + tabManager.updateTabRawContent(tab.id, IN_BUFFER); + assert.equal(tab.isDirty, true, 'precondition: the tab is dirty'); + return { tab, fakes, viewMode: buildViewMode(fakes) }; + } + + /** A saved, clean file in reading mode whose buffer holds what the read left. */ + function readingTab(raw: string, path = '/notes/note.md') { + tabManager.closeAll(); + tabManager.addTab(path); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, raw); + return tab; + } + + // ------------------------------------------------------- leaving edit mode + + test('a dirty file switches to reading mode without a modal and without writing', async () => { + settings.autoSave = false; + const { tab, fakes, viewMode } = dirtyTab('edit'); + + await viewMode.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [], 'nothing is written to disk'); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isEditing, false, 'the user actually reaches reading mode'); + assert.equal(tab.isDirty, true, 'the edits are still unsaved, and still flagged as such'); + assert.equal(tab.rawContent, IN_BUFFER, 'the buffer is intact'); + }); + + test('reading mode shows the buffer, not the file', async () => { + settings.autoSave = false; + const { tab, viewMode, fakes } = dirtyTab('edit'); + + await viewMode.toggleEdit(); + + // The whole point of the bug: with a disk read here, this is `ON_DISK`. + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.deepEqual(fakes.renderedFrom, [IN_BUFFER], 'the preview is rendered once, from the buffer'); + }); + + test('an untitled buffer never reaches the Save dialog on a view toggle', async () => { + settings.autoSave = true; + tabManager.closeAll(); + const fakes = freshFakes(); + tabManager.addTab(''); + const tab = tabManager.activeTab!; + tab.isEditing = true; + tabManager.updateTabRawContent(tab.id, '# untitled\n'); + + await buildViewMode(fakes).toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [], 'saveContent would open the Save dialog for a pathless tab'); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered('# untitled\n')); + }); + + test('auto-save still flushes on the way out, because the debounce is about to be dropped', async () => { + // The auto-save effect requires `isEditing || isSplit`, so this is the last + // chance to honour "save automatically" for these edits. + settings.autoSave = true; + const { tab, fakes, viewMode } = dirtyTab('edit'); + + await viewMode.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.equal(fakes.disk.get(tab.path), IN_BUFFER, 'the flush actually wrote the buffer'); + assert.equal(tab.isDirty, false); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('a file that cannot be written no longer traps the user in the editor', async () => { + // Read-only path, or a buffer the lossy-decode guard refuses: saveContent + // returns false forever, and the old code returned early on that, so + // reading mode was unreachable for the life of the tab. + settings.autoSave = true; + const { tab, fakes, viewMode } = dirtyTab('edit'); + fakes.saveFails = true; + + await viewMode.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id], 'the flush was attempted'); + assert.ok(fakes.toasts.includes('toast.autoSaveFailed'), 'and the failure was reported'); + assert.equal(tab.isEditing, false, 'but the view still switches'); + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.equal(tab.isDirty, true, 'the buffer is still there to be rescued'); + }); + + test('edits typed during the flush are shown, and reported as not yet on disk', async () => { + settings.autoSave = true; + const { tab, fakes, viewMode } = dirtyTab('edit'); + fakes.typeDuringSave = 'typed while saving\n'; + + await viewMode.toggleEdit(); + + assert.equal(tab.isDirty, true); + assert.ok(fakes.toasts.includes('toast.savedNewerEdits'), 'the disk is one revision behind'); + assert.equal(tab.isEditing, false, 'the TOCTOU case is no longer a reason to stay in the editor'); + assert.equal( + tab.content, + rendered(IN_BUFFER + 'typed while saving\n'), + 'the preview shows the newest text, which is exactly what is NOT on disk', + ); + }); + + // --------------------------------------------------- closing the split view + + test('closing split view on a dirty file neither asks nor writes', async () => { + settings.autoSave = false; + const { tab, fakes, viewMode } = dirtyTab('split'); + + await viewMode.toggleSplitView(tab.id); + + assert.deepEqual(fakes.saveCalls, []); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isSplit, false); + assert.equal(tab.isDirty, true); + assert.equal(tab.content, rendered(IN_BUFFER), 'the surviving pane keeps showing the buffer'); + }); + + test('closing split view honours auto-save the same way leaving edit mode does', async () => { + settings.autoSave = true; + const { tab, fakes, viewMode } = dirtyTab('split'); + + await viewMode.toggleSplitView(tab.id); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.equal(tab.isSplit, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('the view toggles no longer re-read the file to leave an editable pane', async () => { + settings.autoSave = false; + const edit = dirtyTab('edit'); + await edit.viewMode.toggleEdit(); + assert.deepEqual(edit.fakes.readCalls, [], 'leaving the editor read the file'); + + const split = dirtyTab('split'); + await split.viewMode.toggleSplitView(split.tab.id); + assert.deepEqual(split.fakes.readCalls, [], 'closing the split read the file'); + assert.equal(split.tab.rawContent, IN_BUFFER); + }); + + // ------------------------------------------- entering an editable pane (#379) + + test('entering the editor reads the fidelity and stores it', async () => { + // The buffer is about to be handed to the editor, which arms auto-save: + // the tab must carry the verdict of this read, not one a load left behind. + const tab = readingTab('# stale\n'); + tabManager.setTabDecodedLossy(tab.id, false); + const fakes = freshFakes(); + fakes.disk.set(tab.path, '# caf�\n'); + fakes.lossy = true; + fakes.encoding = 'windows-1252'; + + await buildViewMode(fakes).toggleEdit(); + + assert.deepEqual(fakes.readCalls, [tab.path]); + assert.equal(tab.isEditing, true); + assert.equal(tab.hasReplacementChars, true, 'the lossy decode was not recorded on the tab'); + assert.equal(tab.encoding, 'windows-1252'); + assert.equal(tab.rawContent, '# caf�\n'); + }); + + test('entering split view reads the fidelity and stores it', async () => { + // An empty buffer (a restored tab not read yet) is read before the split + // opens, and that read decides the flag too. + const tab = readingTab(''); + tabManager.setTabDecodedLossy(tab.id, false); + const fakes = freshFakes(); + fakes.disk.set(tab.path, '# caf�\n'); + fakes.lossy = true; + fakes.encoding = 'windows-1252'; + + await buildViewMode(fakes).toggleSplitView(tab.id); + + assert.deepEqual(fakes.readCalls, [tab.path]); + assert.equal(tab.isSplit, true); + assert.equal(tab.hasReplacementChars, true, 'the lossy decode was not recorded on the tab'); + assert.equal(tab.encoding, 'windows-1252'); + assert.equal(tab.rawContent, '# caf�\n'); + }); + + test('entering split view completes a partial buffer instead of trusting it', async () => { + // The old guard was `!tab.rawContent`, which a partial buffer satisfies: + // split view opened on the truncated text, the first keystroke made it + // dirty, and auto-save wrote it back 1.5s later. + const partial = readingTab('# the first 50KB\n'); + const fakes = freshFakes(); + fakes.fullContent = false; + + await buildViewMode(fakes).toggleSplitView(partial.id); + + assert.equal(partial.isSplit, false, 'split view opened over a buffer that could not be completed'); + assert.deepEqual(fakes.toasts, ['toast.partialDocument']); + + // The empty-buffer read goes through the store, so a tab restored empty + // and flagged partial stops being flagged once the file is in. + const unread = readingTab(''); + tabManager.markTabContentUnavailable(unread.id); + assert.equal(unread.isTruncated, true, 'precondition: an unread tab is partial'); + const reads = freshFakes(); + reads.disk.set(unread.path, '# whole file\n'); + + await buildViewMode(reads).toggleSplitView(unread.id); + + assert.equal(unread.isSplit, true); + assert.equal(unread.rawContent, '# whole file\n'); + assert.equal(unread.isTruncated, false, 'the read left the tab flagged partial'); + }); + + // ------------------------------------------------ where the reader lands (#799) + + test('Ctrl+E hands each pane the line the other one was showing', async () => { + settings.autoSave = false; + const { tab, fakes } = dirtyTab('edit'); + const fromEditor = at(120, 0.4); + const fromPreview = at(300, 0.7); + const synced: unknown[] = []; + const restored: unknown[] = []; + const viewMode = buildViewMode(fakes, { + editorPane: { + scrollSyncPosition: () => fromEditor, + syncScrollToPosition: (position) => void synced.push(position), + setCursor: () => {}, + }, + markdownBody: document.createElement('article'), + getPreviewScrollSyncPosition: () => fromPreview, + scrollPreviewToSyncPosition: (position) => void restored.push(position), + }); + + await viewMode.toggleEdit(); + await Promise.resolve(); + assert.equal(tab.isEditing, false); + assert.deepEqual(restored, [fromEditor], 'the preview was not sent to the editor\'s line'); + + await viewMode.toggleEdit(); + assert.equal(tab.isEditing, true); + assert.deepEqual(synced, [fromPreview], 'the editor was not sent to the preview\'s line'); + }); + + test('Ctrl+E brings the cursor on screen only when the outline follows it', async () => { + // An outline marking the cursor's heading while the editor shows another + // one reads as wrong, so with 'cursor' the handoff moves a cursor left off + // screen. With 'scroll' the outline never looks at the cursor, and moving + // it would only lose the reader's place. + const asked: unknown[] = []; + try { + for (const follows of ['cursor', 'scroll'] as const) { + settings.tocFollows = follows; + const { tab, fakes } = dirtyTab('edit'); + tab.isEditing = false; + await buildViewMode(fakes, { + editorPane: { + syncScrollToPosition: (_position, options) => void asked.push(options), + scrollSyncPosition: () => null, + setCursor: () => {}, + }, + markdownBody: document.createElement('article'), + }).toggleEdit(); + } + } finally { + settings.tocFollows = 'scroll'; + } + assert.deepEqual(asked, [{ cursorIntoView: true }, { cursorIntoView: false }]); + }); + + // Scroll sync is split view's. The tab keeps `isScrollSynced` after the split + // closes, and out of split the other pane is still mounted at zero width, so + // syncing through it dragged the visible pane off its line on every Ctrl+E. + test('scroll sync left on in split view stays off once the split closes', () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tabManager.setSplitEnabled(tab.id, true); + if (!tab.isScrollSynced) tabManager.toggleScrollSync(tab.id); + const toPreview: unknown[] = []; + const toEditor: unknown[] = []; + const viewMode = buildViewMode(freshFakes(), { + scrollPreviewToSyncPosition: (position) => void toPreview.push(position), + editorPane: { + syncScrollToPosition: (position) => void toEditor.push(position), + scrollSyncPosition: () => null, + setCursor: () => {}, + }, + }); + const position = at(40); + const preview = document.createElement('article'); + + viewMode.handleEditorScrollSync(position); + viewMode.syncEditorToPreviewScroll(preview); + assert.equal(toPreview.length, 1, 'precondition: split view syncs the preview'); + assert.equal(toEditor.length, 1, 'precondition: split view syncs the editor'); + + tabManager.setSplitEnabled(tab.id, false); + tab.isEditing = true; + assert.equal(tab.isScrollSynced, true, 'the tab still remembers the choice'); + + viewMode.handleEditorScrollSync(position); + viewMode.syncEditorToPreviewScroll(preview); + assert.equal(toPreview.length, 1, 'the hidden preview was scrolled from the editor'); + assert.equal(toEditor.length, 1, 'the editor was scrolled from the hidden preview'); + // Editor top line 40 is anchor line 42 in the buffer, 39 in the body. + assert.equal(viewMode.tocActiveLine, 39, 'the outline still follows the editor'); + }); + + test('leaving the editor moves the outline to the line the preview lands on', async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const viewMode = buildViewMode(freshFakes(), { + markdownBody: document.createElement('article'), + getPreviewScrollAnchor: () => 57 as never, + }); + + await viewMode.restoreAfterLeavingEditor(tab.id, at(58)); + + assert.equal(tab.anchorLine, 57); + assert.equal(viewMode.tocActiveLine, 57, 'the outline kept the entry from before the switch'); + }); + + // #799: "the table of contents should follow the cursor". An editor setting, + // because only the editor has one: the preview alone always follows the scroll. + describeOutlineSource('cursor', true, (v) => { + v.handleEditorCursor(20 as never, 1); + assert.equal(v.tocActiveLine, 17, 'the cursor line, in the outline\'s numbering'); + v.handleEditorScrollSync(at(60)); + assert.equal(v.tocActiveLine, 17, 'scrolling the editor left it on the cursor'); + v.handleEditorCursor(30 as never, 1); + assert.equal(v.tocActiveLine, 27); + }); + + describeOutlineSource('cursor', false, async (v) => { + v.handleEditorCursor(20 as never, 1); + assert.equal(v.tocActiveLine, null, 'no editor on screen, so no cursor to follow'); + await v.restoreAfterLeavingEditor(tabManager.activeTab!.id, at(58)); + assert.equal(v.tocActiveLine, 57, 'the preview alone follows its scroll'); + }); + + describeOutlineSource('scroll', true, (v) => { + v.handleEditorCursor(20 as never, 1); + assert.equal(v.tocActiveLine, null, 'the default ignores the cursor'); + v.handleEditorScrollSync(at(60)); + assert.equal(v.tocActiveLine, 59); + // The editor alone keeps the preview mounted at zero width, and its + // scroll events put the outline on a heading from that layout. + v.followToc('preview', 90 as never); + assert.equal(v.tocActiveLine, 59, 'the hidden preview moved the outline'); + }); + + function describeOutlineSource( + follows: 'cursor' | 'scroll', + editorOnScreen: boolean, + run: (viewMode: ReturnType) => void | Promise, + ) { + test(`the outline follows the ${follows} setting with the editor ${editorOnScreen ? 'on' : 'off'} screen`, async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + tabManager.activeTab!.isEditing = editorOnScreen; + settings.tocFollows = follows; + try { + await run(buildViewMode(freshFakes(), { + markdownBody: document.createElement('article'), + getPreviewScrollAnchor: () => 57 as never, + })); + } finally { + settings.tocFollows = 'scroll'; + } + }); + } + + test('the preview does not move the outline after Ctrl+E until it is placed', async () => { + // Its scroll events before that report the old position, and the outline + // flashed the first headings until the preview was placed. + settings.autoSave = false; + const { tab, fakes } = dirtyTab('edit'); + let place!: () => void; + let ticks = 0; + const viewMode = buildViewMode(fakes, { + editorPane: { + scrollSyncPosition: () => at(58), + syncScrollToPosition: () => {}, + setCursor: () => {}, + }, + markdownBody: document.createElement('article'), + getPreviewScrollAnchor: () => 57 as never, + // The restore waits for the render's flush; hold that one back. + tick: () => (++ticks === 1 ? new Promise((resolve) => (place = resolve)) : Promise.resolve()), + }); + + viewMode.handleEditorScrollSync(at(40)); + assert.equal(viewMode.tocActiveLine, 39); + await viewMode.toggleEdit(); + assert.equal(tab.isEditing, false); + viewMode.followToc('preview', 2 as never); + assert.equal(viewMode.tocActiveLine, 39, 'an unplaced preview put the outline on its own line'); + + place(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(viewMode.tocActiveLine, 57, 'the placed preview decides'); + viewMode.followToc('preview', 60 as never); + assert.equal(viewMode.tocActiveLine, 60, 'and keeps deciding once placed'); + }); + + test('reading with a cursor placed, the outline stays on it only while the preview draws it', () => { + /** A reading tab whose editor left its cursor on buffer line 20. */ + const withCursor = () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = true; + const viewMode = buildViewMode(freshFakes()); + viewMode.handleEditorCursor(20 as never, 3); + tab.isEditing = false; + return viewMode; + }; + settings.tocFollows = 'cursor'; + try { + settings.previewCursor = true; + const drawn = withCursor(); + drawn.followToc('cursor', 17 as never); + drawn.followToc('preview', 90 as never); + assert.equal(drawn.tocActiveLine, 17, 'the preview scroll moved the outline off the drawn cursor'); + + // Off, the editor's last cursor is still there for Ctrl+E, but the + // preview shows nothing, so an outline pinned to it would look stuck. + settings.previewCursor = false; + const hidden = withCursor(); + hidden.followToc('cursor', 17 as never); + hidden.followToc('preview', 90 as never); + assert.equal(hidden.tocActiveLine, 90, 'the outline stayed on a cursor the preview does not show'); + + settings.previewCursor = true; + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const without = buildViewMode(freshFakes()); + without.followToc('preview', 90 as never); + assert.equal(without.tocActiveLine, 90, 'with no cursor yet, the outline follows the scroll'); + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + } + }); + + test('a click in the preview places the cursor only with its setting on, whatever the outline follows', () => { + // #799: the shared cursor is an editing aid in its own right, so Ctrl+E + // lands on the clicked character in either outline mode. The drawn caret + // is visual noise to some readers, so the whole aid is off by default. + const root = document.createElement('div'); + root.innerHTML = '

    hello world

    '; + const text = root.querySelector('p')!.firstChild!; + (document as any).caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(text, 6); + return range; + }; + const click = () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const viewMode = buildViewMode(freshFakes(), { + previewBlocks: root, + readRendererLine: (line) => (line === 2 ? 'hello world' : ''), + }); + viewMode.placePreviewCursor({ detail: 1, clientX: 0, clientY: 0 } as MouseEvent); + return viewMode; + }; + try { + settings.tocFollows = 'cursor'; + const off = click(); + assert.equal(off.activeCursor, null, 'off by default: the click leaves the cursor alone'); + assert.equal(off.tocActiveLine, null); + + settings.previewCursor = true; + for (const follows of ['scroll', 'cursor'] as const) { + settings.tocFollows = follows; + const viewMode = click(); + // Body line 2 is buffer line 5 under three lines of front matter. + assert.deepEqual(viewMode.activeCursor, { line: 5, column: 7 }, `${follows}: the click placed the cursor on the "w"`); + assert.equal(viewMode.tocActiveLine, follows === 'cursor' ? 2 : null, `${follows}: the outline`); + } + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + delete (document as any).caretRangeFromPoint; + } + }); +}); diff --git a/scripts/layout.checks.test.ts b/scripts/layout.checks.test.ts index 7570f125..640e112e 100644 --- a/scripts/layout.checks.test.ts +++ b/scripts/layout.checks.test.ts @@ -219,13 +219,13 @@ describe('outline follows the editor', () => { // It goes through `tabAnchorForEditorTopLine`, the crossing the tab's own // reading position takes, so the outline follows the line the reader is // on rather than the one the viewport cuts in half (#744). - const handler = functionSource(viewerSource, 'handleEditorScrollSync'); + const handler = functionSource(readSource('src/lib/sessions/viewMode.svelte.ts'), 'handleEditorScrollSync'); const record = handler.indexOf("followToc('editor'"); const syncCheck = handler.indexOf('splitScrollSyncOn()'); assert.ok(record !== -1 && syncCheck !== -1, 'both statements must still be here'); assert.ok(record < syncCheck, 'the outline is fed before the sync check, not inside it'); - assert.match(handler, /followToc\('editor', tabAnchorForEditorTopLine\(lineCoords, asBufferLine\(position\.line\)\)\)/); + assert.match(handler, /followToc\('editor', tabAnchorForEditorTopLine\(o\.lineCoords, asBufferLine\(position\.line\)\)\)/); }); test('the preview feeds the same state, off the line it already measures', () => { @@ -237,7 +237,7 @@ describe('outline follows the editor', () => { assert.match(handler, /const anchorLine = getPreviewScrollAnchor\(target\);/); assert.match(handler, /followToc\('preview', anchorLine\);/); // One input. Which pane feeds it is `followToc`'s call. - assert.match(viewerSource, /activeLine=\{tocActiveLine\}/); + assert.match(viewerSource, /activeLine=\{viewMode\.tocActiveLine\}/); }); test('the outline no longer measures the preview to decide', () => { diff --git a/scripts/navigation.checks.test.ts b/scripts/navigation.checks.test.ts index 573fd3f8..7b2c2341 100644 --- a/scripts/navigation.checks.test.ts +++ b/scripts/navigation.checks.test.ts @@ -122,6 +122,7 @@ describe('preview anchor restore wiring', () => { describe('jump to the selected fragment wiring', () => { const editorSource = readSource(new URL('../src/lib/components/Editor.svelte', import.meta.url)); const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); + const viewModeSource = readSource('src/lib/sessions/viewMode.svelte.ts'); /* ------------------------------------------------------------------ */ /* the jump itself */ @@ -179,8 +180,8 @@ describe('jump to the selected fragment wiring', () => { test('Edit with a target never leaves edit mode', () => { // In split view the editor is already on screen, and "edit this fragment" // is the one thing that cannot mean "close the editor". - const edit = functionSource(viewerSource, 'editSourceRange'); - assert.match(edit, /if \(!isEditing\) await toggleEdit\(\)/, 'entered only when not already editing'); + const edit = functionSource(viewModeSource, 'editSourceRange'); + assert.match(edit, /if \(!isEditing\(\)\) await toggleEdit\(\)/, 'entered only when not already editing'); // And a read that failed leaves the tab in reading mode — arming the jump // anyway would fire it at whatever document is edited next. assert.match(edit, /tabManager\.activeTab\?\.isEditing/); @@ -190,7 +191,7 @@ describe('jump to the selected fragment wiring', () => { // `parseSourceposLineRange` is the only reader of the attribute, and it // keeps line numbers only. Nothing in the preview may take the `:col` half // and aim Monaco with it — see the header of this file for why. - const resolve = functionSource(viewerSource, 'getContextMenuSourceRange'); + const resolve = functionSource(viewModeSource, 'getContextMenuSourceRange'); assert.match(resolve, /findSourceLineRange\(/); assert.doesNotMatch(resolve, /startColumn|endColumn/); }); @@ -207,8 +208,8 @@ describe('jump to the selected fragment wiring', () => { // Two consumers hand a `data-sourcepos` number to the editor, and both have // to shift it. `toggleEditView` is not a third: it resolves the range and // passes it to `editSourceRange`, which is where the shift happens. - const edit = functionSource(viewerSource, 'editSourceRange'); - assert.match(edit, /pendingEditReveal = lineCoords\.toBufferRange\(range\)/, 'the context menu and ⌘E shift'); + const edit = functionSource(viewModeSource, 'editSourceRange'); + assert.match(edit, /pendingEditReveal = o\.lineCoords\.toBufferRange\(range\)/, 'the context menu and ⌘E shift'); // The outline is wired inline in the markup rather than in a function. assert.match( @@ -245,8 +246,8 @@ describe('jump to the selected fragment wiring', () => { // `tocActiveLine` has two writers. The preview's already answers in body // lines (`getPreviewScrollAnchor`); the editor's has to be converted, or the // highlighted heading changes depending on which pane you scrolled. - const fromEditor = functionSource(viewerSource, 'handleEditorScrollSync'); - assert.match(fromEditor, /followToc\('editor', tabAnchorForEditorTopLine\(lineCoords, asBufferLine\(position\.line\)\)\)/); + const fromEditor = functionSource(viewModeSource, 'handleEditorScrollSync'); + assert.match(fromEditor, /followToc\('editor', tabAnchorForEditorTopLine\(o\.lineCoords, asBufferLine\(position\.line\)\)\)/); const fromPreview = functionSource(viewerSource, 'getPreviewScrollAnchor'); assert.doesNotMatch(fromPreview, /toBufferLine|toRendererLine/, 'already a body line'); @@ -279,7 +280,7 @@ describe('jump to the selected fragment wiring', () => { // `toggleEdit` and `editSourceRange` both flip `isEditing`, and a selection // read after that would answer for the editor rather than for the preview // the reader was looking at. - const view = functionSource(viewerSource, 'toggleEditView'); + const view = functionSource(viewModeSource, 'toggleEditView'); assert.match(view, /const selected = getSelectionSourceRange\(\);/); assert.ok( view.indexOf('const selected') < view.indexOf('toggleEdit()'), @@ -306,7 +307,7 @@ describe('jump to the selected fragment wiring', () => { // The editor entry point hands Monaco's ⌘E to the same switch. assert.match(viewerSource, /oncommand=\{runViewerCommand\}/, 'the editor entry point'); // The title bar's Edit button, now one of three mode buttons (#806). - assert.match(functionSource(viewerSource, 'setViewMode'), /target === 'edit' \? toggleEditView\(\)/); + assert.match(functionSource(viewModeSource, 'setViewMode'), /target === 'edit' \? toggleEditView\(\)/); }); test('split view with nothing selected is deliberately inert', () => { @@ -314,8 +315,8 @@ describe('jump to the selected fragment wiring', () => { // granted; with no selection there is nothing to travel to either. Closing // the preview would be a layout change nobody asked for, and a mistyped ⌘E // would cost the reader the pane. - const view = functionSource(viewerSource, 'toggleEditView'); - assert.match(view, /if \(isSplit && !selected\) \{[\s\S]*?return;/); + const view = functionSource(viewModeSource, 'toggleEditView'); + assert.match(view, /if \(isSplit\(\) && !selected\) \{[\s\S]*?return;/); assert.doesNotMatch(view, /setSplitEnabled/, 'the chord never changes the layout'); }); }); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 5ad88bdf..43ab485a 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -23,6 +23,7 @@ import FindBar from './components/FindBar.svelte'; import { createWindowExit } from './sessions/windowExit.js'; import { createExternalChange } from './sessions/externalChange.svelte.js'; + import { createViewMode } from './sessions/viewMode.svelte.js'; import { autoSaveFailureNeedsToast, isAutoSaveEligible } from './utils/autoSaveTimer.js'; import { exportAsHtml as _exportHtml, exportAsPdf as _exportPdf } from './utils/export'; import { askToOpenExportedFile } from './utils/openExportedFile.js'; @@ -30,7 +31,7 @@ import { copyableFlavours } from './utils/previewCopy.js'; import { hasExportableDocument } from './utils/tabFileActions.js'; import { editorLanguageFor } from './utils/editorLanguage.js'; - import { type ViewMode, viewModeOf } from './utils/titlebarToolbar.js'; + import { viewModeOf } from './utils/titlebarToolbar.js'; import { buildMenuModel, installAppMenu, type MenuAction } from './utils/appMenu.js'; import ZoomOverlay from './components/ZoomOverlay.svelte'; import { MARKDOWN_LINK_EXTENSIONS } from './utils/markdownLinks.js'; @@ -53,28 +54,22 @@ import { dropPaneFor, dropZoneLabel, routeDroppedFile } from './utils/fileDrop.j import { fontFamilyValue } from './utils/fontFamily.js'; import { headingReference, headingText, preferredReferenceStyle } from './utils/headingReference.js'; import { - findSourceLineRange, getSourceLineAtPreviewOffset, invalidateAnchorMemos, measureAnchorBox, - mergeSourceLineRanges, restorePreviewReadingPosition, PREVIEW_ANCHOR_OFFSET, anchorScrollTop, type AnchorBox, type AnchorNode, - type LineRange, type OffsetLayoutNode, } from './utils/previewAnchor.js'; import { pointAtSource, sourceAtPoint, type SourceLineReader } from './utils/previewCursor.js'; import { annotationOf, hitAt, occurrenceRanges, overlapping, rangeOf, type Annotation } from './utils/previewAnnotations.js'; import { - asBufferLine, asRendererLine, lineCoordinates, - tabAnchorForEditorTopLine, type BufferLine, - type BufferLineRange, type RendererLine, } from './utils/lineCoordinates.js'; import { @@ -132,7 +127,7 @@ import { jumpScrollBehavior } from './utils/motion.js'; import { createWindowSession } from './sessions/windowSession.svelte.js'; import { reportWindowMeta } from './sessions/windowMeta.svelte.js'; import { createBoundedWriter, restoreRecords, recoverySnapshot } from './sessions/unsavedRecovery.js'; -import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from './sessions/documentSession.svelte.js'; +import { createDocumentSession, type LoadMarkdownOptions } from './sessions/documentSession.svelte.js'; import 'highlight.js/styles/github-dark.css'; import 'katex/dist/katex.min.css'; @@ -939,6 +934,47 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from resolveExternalChangeByKeepingBuffer, } = externalChange; + const viewMode = createViewMode({ + tabManager, + settings, + documentSession, + flushBeforeLeavingEditableMode, + readFile: (path) => invoke('read_file_content_checked', { path }), + renderTabPreviewFromRaw, + tick, + addToast, + t, + get editorPane() { + return editorPane; + }, + get markdownBody() { + return markdownBody; + }, + get previewBlocks() { + return previewBlocks; + }, + get lineCoords() { + return lineCoords; + }, + readRendererLine: (line) => readRendererLine(line), + getPreviewScrollSyncPosition, + scrollPreviewToSyncPosition, + getPreviewScrollAnchor, + }); + const { + followToc, + handleEditorCursor, + placePreviewCursor, + handleEditorScrollSync, + syncEditorToPreviewScroll, + toggleEdit, + getContextMenuSourceRange, + toggleEditView, + editSourceRange, + toggleSplitView, + setViewMode, + } = viewMode; + // Persisted through Rust, not localStorage: setItem is an async message // to the WebKit storage process that dies in transit when the last // window's close ends the process (reproduced in QA as "close secondary @@ -1550,77 +1586,9 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from markdownBody.scrollTop = targetScroll; } - /** - * The source line the reader is on, for the outline to follow (#169). - * - * BOTH panes answer here, which is the point: the outline used to decide by - * rendered box, and only the preview has those. A source line is something - * either pane can produce — the editor sends one on every scroll (the same - * position scroll sync uses, whether or not sync is on), and the preview - * already computes one for the tab's reading position. One rule then picks - * the entry, so the two panes cannot disagree about which heading is - * current while both are on screen. - */ - let tocActiveLine = $state(null); - - /** - * The one gate on `tocActiveLine`. With `settings.tocFollows` set to - * 'cursor' and a cursor the reader can see (the editor is on screen, or - * `settings.previewCursor` draws one the reader placed in the preview), only - * the cursor moves the outline, and scrolling either pane leaves it where it - * is. Otherwise the pane that scrolled last decides. In the preview alone - * with `previewCursor` off, the editor's last cursor is kept for Ctrl+E but - * not followed: nothing on screen would say why the outline stopped. - * - * A preview that is not on screen never decides: in the editor alone it is - * still mounted at zero width, and its scroll events report lines from that - * layout, which put the outline on the wrong heading after Ctrl+E (#799). - * Nor does a preview Ctrl+E has just brought back, until - * `restoreAfterLeavingEditor` has placed it: its scroll events before that - * report the first headings. - */ - function followToc(from: 'cursor' | 'editor' | 'preview', line: RendererLine) { - const cursorLeads = settings.tocFollows === 'cursor' && (hasEditorPane || (settings.previewCursor && activeCursor !== null)); - const previewSettled = hasPreviewPane && !previewPlacing; - const accepted = from === 'cursor' ? cursorLeads : !cursorLeads && (from === 'editor' || previewSettled); - if (accepted) tocActiveLine = line; - } - - /** True from Ctrl+E out of the editor until the preview is placed. */ - let previewPlacing = false; - - /** - * Each tab's cursor, shared by its two panes (#799): the editor's while it - * is on screen, and with `settings.previewCursor` a click in the preview - * moves it too. With that setting the preview draws it, and Ctrl+E puts the - * editor's cursor on it. A tab only read so far has none. - */ - let cursorByTab = $state>({}); - let activeCursor = $derived((tabManager.activeTabId && cursorByTab[tabManager.activeTabId]) || null); - - function handleEditorCursor(line: BufferLine, column: number) { - if (tabManager.activeTabId) cursorByTab[tabManager.activeTabId] = { line, column }; - followToc('cursor', lineCoords.toRendererLine(line)); - } - let bufferLines = $derived(rawContent.split('\n')); const readRendererLine: SourceLineReader = (line) => bufferLines[lineCoords.toBufferLine(asRendererLine(line)) - 1]; - function placePreviewCursor(e: MouseEvent) { - const tabId = tabManager.activeTabId; - if (!tabId || !previewBlocks || !settings.previewCursor || e.detail !== 1) return; - if (!window.getSelection()?.isCollapsed) return; - - const caret = document.caretRangeFromPoint(e.clientX, e.clientY); - const point = caret && sourceAtPoint(previewBlocks, { node: caret.startContainer, offset: caret.startOffset }, readRendererLine); - if (!point) return; - - const line = lineCoords.toBufferLine(point.line); - cursorByTab[tabId] = { line, column: point.column }; - followToc('cursor', point.line); - if (isSplit) editorPane?.setCursor(line, point.column); - } - /** The drawn cursor, in the article's scroll content; null while it isn't shown. */ let previewCursorBox = $state<{ top: number; left: number; height: number } | null>(null); @@ -1640,7 +1608,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from }); $effect(() => { - const cursor = activeCursor; + const cursor = viewMode.activeCursor; const shown = settings.previewCursor && cursor !== null && hasPreviewPane; void htmlContent; void previewLayoutVersion; @@ -1766,33 +1734,6 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from }; }); - function handleEditorScrollSync(position: ScrollSyncPosition) { - // The line the tab would record as its reading position, not the line - // the viewport cuts in half: `tabAnchorForEditorTopLine` is the one - // crossing from a Monaco top line into the outline's numbering (it - // counts from the body, and sits `EDITOR_ANCHOR_LINE_OFFSET` lines - // down). Handing over the raw top line left the outline one entry - // behind whenever a heading was the first line on screen (#744). - if (position.line !== undefined) { - followToc('editor', tabAnchorForEditorTopLine(lineCoords, asBufferLine(position.line))); - } - - if (splitScrollSyncOn()) { - scrollPreviewToSyncPosition(position); - } - } - - /** - * Scroll sync is a split-view feature, and `isScrollSynced` outlives the - * split: it is the tab's remembered choice for the next one. Out of split - * the other pane is still mounted at zero width, so a position mapped - * through it drags the visible pane off the line Ctrl+E put it on (#799). - */ - function splitScrollSyncOn() { - const tab = tabManager.activeTab; - return !!tab?.isSplit && tab.isScrollSynced; - } - /** * The source line to save as this tab's reading position: the one rendered * `PREVIEW_ANCHOR_OFFSET` below the top of the viewport, which is where the @@ -1821,13 +1762,6 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from return line === null ? null : asRendererLine(Math.round(line)); } - function syncEditorToPreviewScroll(target: HTMLElement) { - if (!splitScrollSyncOn() || !editorPane) return; - - const position = getPreviewScrollSyncPosition(target); - editorPane.syncScrollToPosition(position); - } - let isScrolling = $state(false); let scrollIdleTimer: ReturnType; @@ -2121,204 +2055,15 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from return documentSession.canCloseTab(tabId); } - /** - * Reading mode's HTML, rendered from the tab's own buffer and its own path. - * Writes through the tab id, so a tab switch during the render cannot land - * one document's HTML on another — and, unlike the `loadMarkdown` call this - * replaces, it neither activates the tab nor re-reads the file. - */ - async function renderPreviewLeavingEditableMode(tab: Tab) { - try { - await renderTabPreviewFromRaw(tab); - } catch (e) { - console.error('Failed to render markdown', e); - } - } - - /** - * Move the active tab between reading and editing. Just the mode — where - * the reader LANDS is `editSourceRange`'s business, and `toggleEditView` - * is what decides which of the two a ⌘E means. - */ - async function toggleEdit() { - const tab = tabManager.activeTab; - if (!tab) return; - - if (isEditing) { - // Back to reading. The preview renders the buffer, not the file, so no - // save is needed (#168). The tab stays dirty, and closing it or the - // window (`settleForExit`) still asks. - await flushBeforeLeavingEditableMode(tab); - // Ctrl+E lands on the line the other pane was showing, mapped the way - // split view maps it (#799). - const position = editorPane?.scrollSyncPosition() ?? null; - previewPlacing = position !== null; - tab.isEditing = false; - await renderPreviewLeavingEditableMode(tab); - if (position) void restoreAfterLeavingEditor(tab.id, position); - } else { - // Switch to edit - const position = markdownBody ? getPreviewScrollSyncPosition(markdownBody) : null; - if (tab.path !== '') { - if (tab.isDirty) { - // Unsaved edits are already in memory (made in reading - // mode, or kept when leaving the editor with auto-save - // off). Reading from disk would overwrite them, so only - // switch. - tab.isEditing = true; - } else { - const mayApply = claimTabForRead(tab.id); - try { - // This buffer is about to be handed to the editor, and - // the editor arms auto-save. The checked command is what - // says whether the decode was lossy, so the tab carries - // its own verdict instead of relying on the one - // `loadMarkdown` left behind — a file can be converted - // to UTF-8 (or away from it) between the two reads. - const [content, lossy, encoding] = (await invoke('read_file_content_checked', { path: tab.path })) as [string, boolean, string]; - // A task ticked, or the tab closed, while this was out. - if (!mayApply()) return; - tabManager.setTabDecodedLossy(tab.id, lossy); - tabManager.setTabEncoding(tab.id, encoding); - // Goes through the store so a tab that held only the - // large-file preview slice stops being flagged partial. - tabManager.setTabRawContent(tab.id, content); - tab.isEditing = true; - } catch (e) { - console.error('Failed to read file for editing', e); - } - } - } else { - tab.isEditing = true; - } - if (position && tab.isEditing) { - await tick(); - editorPane?.syncScrollToPosition(position, { cursorIntoView: settings.tocFollows === 'cursor' }); - } - } - } - - /** - * The preview half of #799. The restore effect cannot do it: the article - * stays mounted at zero width in edit mode, so nothing it depends on changes - * with the mode. The pane takes its real width in the same frame (its slide - * is a `transform`, see `utils/paneSlide.ts`), so the line is placed right - * after the render, before the old position is ever painted. - */ - async function restoreAfterLeavingEditor(tabId: string, position: ScrollSyncPosition) { - await tick(); - previewPlacing = false; - const body = markdownBody; - if (!body || tabManager.activeTab?.id !== tabId || tabManager.activeTab.isEditing) return; - scrollPreviewToSyncPosition(position); - // That scroll is marked programmatic, so the tab's anchor and the - // outline's current entry are written here. - const anchorLine = getPreviewScrollAnchor(body); - if (anchorLine !== null) { - tabManager.updateTabAnchorLine(tabId, anchorLine); - followToc('preview', anchorLine); - } - } - - /** - * A jump the editor has not been asked for yet, because it does not exist - * yet: `toggleEdit` only flips a flag, and the `Editor` it mounts — and the - * `editorPane` binding that reaches it — arrive on the next render. The - * effect below spends it the moment they do, and immediately when the - * editor is already on screen (split view). - */ - let pendingEditReveal = $state(null); - + // `viewMode.pendingEditReveal`, spent the moment the editor exists. $effect(() => { - const range = pendingEditReveal; + const range = viewMode.pendingEditReveal; if (!range || !editorPane) return; - pendingEditReveal = null; + viewMode.pendingEditReveal = null; editorPane.revealSourceRange(range.startLine, range.endLine); }); - /** - * Which source lines the reader means by right-clicking here (#90). - * - * The selection when there is one, so a range spanning several blocks opens - * the editor on all of them; a caret or a click with nothing selected falls - * through to whatever is under the pointer, which is how a click on an - * image lands on the image. - * - * `null` when nothing under the pointer came from the document: the front - * matter panel, the outline, the window chrome. The caller then leaves the - * "Edit" entry doing exactly what it did before. - */ - function getContextMenuSourceRange(e: MouseEvent): LineRange | null { - return getSelectionSourceRange() ?? findSourceLineRange(e.target as Node | null); - } - - /** - * The source lines the reader has selected in the preview, or null when - * nothing is selected or the selection came from outside the document. - * - * Both ends resolve independently and are merged, so a selection spanning - * several blocks answers with all of them, and one end landing somewhere - * with no range (the front matter panel, the outline) leaves the other end - * in charge. Direction-independent: `startContainer` is the range's start, - * not the point the drag began at. - */ - function getSelectionSourceRange(): LineRange | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null; - return mergeSourceLineRanges( - findSourceLineRange(selection.getRangeAt(0).startContainer), - findSourceLineRange(selection.getRangeAt(selection.rangeCount - 1).endContainer), - ); - } - - /** - * What ⌘E means, from every entry point that offers it — the hotkey, the - * toolbar, the title bar, and Monaco's own command. - * - * One function so the chord cannot mean two things depending on where the - * caret happens to be (`formatShortcutKeymap.test.ts` holds the two layers - * together), and so "take me to the editor" behaves the same whether the - * reader asked for it with a key or with a menu item. - */ - async function toggleEditView() { - const selected = getSelectionSourceRange(); - - if (!hasPreviewPane) { - // The editor is already the whole window: nowhere further to take - // the reader, so this is the toggle back out. - await toggleEdit(); - return; - } - - if (isSplit && !selected) { - // Deliberately nothing. - // - // What ⌘E is asked for is the ability to edit, and split view - // already grants it — the editor is on screen. With no selection - // there is no fragment to travel to either, so every remaining - // reading of the chord is a LAYOUT change nobody asked for: closing - // the preview on a mistyped ⌘E costs the reader the pane and a - // second keystroke to get it back, while doing nothing costs - // nothing. ⌘\ opens and closes the split, and stays the only way. - return; - } - - // Reading, or split with a selection: same as the context menu's "Edit". - await editSourceRange(selected); - } - - /** - * Open the editor on `range`. Toggles into edit mode when `isEditing` is - * false, including split view entered from reading mode. - */ - async function editSourceRange(range: LineRange | null) { - if (!isEditing) await toggleEdit(); - // `toggleEdit` swallows a failed read and stays in reading mode. Arming - // the jump anyway would fire it at whatever document is edited next. - if (range && tabManager.activeTab?.isEditing) pendingEditReveal = lineCoords.toBufferRange(range); - } - /** * This document's two line numberings, and the only thing that converts * between them. @@ -3066,61 +2811,6 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from if (tabManager.activeTab?.unread) untrack(() => void documentSession.readActiveIfUnread()); }); - async function toggleSplitView(tabId: string) { - const tab = tabManager.tabs.find((t) => t.id === tabId); - if (!tab) return; - - if (!tab.isSplit) { - // Split view is an editor, and its first keystroke arms auto-save. - // The buffer must be complete first (not empty, not a large file's - // partial read), or auto-save writes a truncated file. - if (tab.path && !tab.isEditing && !tab.rawContent) { - const mayApply = claimTabForRead(tab.id); - try { - // Checked, like every other read that fills an editable - // buffer: split view is an editor, so this tab must carry - // the fidelity of its own decode rather than inherit one. - const [content, lossy, encoding] = (await invoke('read_file_content_checked', { path: tab.path })) as [string, boolean, string]; - if (!mayApply()) return; - tabManager.setTabDecodedLossy(tab.id, lossy); - tabManager.setTabEncoding(tab.id, encoding); - tabManager.setTabRawContent(tab.id, content); - } catch (e) { - console.error('Failed to load raw content for split view', e); - } - } - if (!(await documentSession.ensureFullContent(tab.id))) { - addToast(t('toast.partialDocument', settings.language), 'error'); - return; - } - tabManager.setSplitEnabled(tab.id, true); - } else { - // Like leaving edit mode: the surviving pane renders the buffer, so - // nothing is saved or asked. - await flushBeforeLeavingEditableMode(tab); - tabManager.setSplitEnabled(tab.id, false); - await renderPreviewLeavingEditableMode(tab); - } - } - - /** The title bar's three mode buttons (#806): go straight to one, from any. */ - async function setViewMode(target: ViewMode) { - const tab = tabManager.activeTab; - if (!tab || viewModeOf(tab) === target) return; - if (target === 'split') return toggleSplitView(tab.id); - if (!tab.isSplit) return target === 'edit' ? toggleEditView() : toggleEdit(); - // Leaving split: the editor is already on screen and holds the full - // buffer, so Edit only drops the preview. Preview goes through the - // normal close, which flushes and renders. - if (target === 'edit') { - tab.isEditing = true; - tabManager.setSplitEnabled(tab.id, false); - return; - } - tab.isEditing = false; - await toggleSplitView(tab.id); - } - /* * `viewerKeymap.ts` decides which command a keystroke means. This maps each * command to the function that runs it. Every command is preventDefaulted @@ -3892,7 +3582,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from oncommand={runViewerCommand} onscrollsync={handleEditorScrollSync} oncursor={handleEditorCursor} - sharedCursor={settings.previewCursor ? activeCursor : null} /> + sharedCursor={settings.previewCursor ? viewMode.activeCursor : null} /> {/if} @@ -4121,7 +3811,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from onpointerdown={startTocResize} onkeydown={handleTocResizeKeyDown}> void; + setCursor: (line: number, column: number) => void; + scrollSyncPosition: () => ScrollSyncPosition | null; +}; + +export type ViewModeOptions = { + tabManager: typeof TabManager; + settings: typeof Settings; + documentSession: { ensureFullContent: (tabId: string) => Promise }; + flushBeforeLeavingEditableMode: (tab: Tab) => Promise; + /** `read_file_content_checked`: the file and the fidelity of its decode. */ + readFile: (path: string) => Promise<[string, boolean, string]>; + renderTabPreviewFromRaw: (tab: Tab) => Promise; + tick: () => Promise; + addToast: (message: string, type?: 'info' | 'error' | 'warning') => unknown; + t: (key: string, language: LanguageCode) => string; + // The viewer's panes and measurements, read when a handler runs. + readonly editorPane: ViewModeEditorPane | null | undefined; + readonly markdownBody: HTMLElement | null | undefined; + readonly previewBlocks: HTMLElement | null | undefined; + readonly lineCoords: LineCoordinates; + readRendererLine: SourceLineReader; + getPreviewScrollSyncPosition: (target: HTMLElement) => ScrollSyncPosition; + scrollPreviewToSyncPosition: (position: ScrollSyncPosition) => void; + getPreviewScrollAnchor: (target: HTMLElement) => RendererLine | null; +}; + +/** + * The viewer's three modes and what moves between them: the view toggles + * (Ctrl+E, ⌘\, the title bar's mode buttons), scroll sync between the panes, + * the outline's current entry, and the cursor the two panes share. + */ +export function createViewMode(o: ViewModeOptions) { + const { tabManager, settings, documentSession, flushBeforeLeavingEditableMode, readFile, renderTabPreviewFromRaw, tick, addToast, t } = o; + + const isEditing = () => tabManager.activeTab?.isEditing ?? false; + const isSplit = () => tabManager.activeTab?.isSplit ?? false; + const hasEditorPane = () => isEditing() || isSplit(); + const hasPreviewPane = () => !isEditing() || isSplit(); + + /** + * The source line the reader is on, for the outline to follow (#169). + * + * BOTH panes answer here, which is the point: the outline used to decide by + * rendered box, and only the preview has those. A source line is something + * either pane can produce — the editor sends one on every scroll (the same + * position scroll sync uses, whether or not sync is on), and the preview + * already computes one for the tab's reading position. One rule then picks + * the entry, so the two panes cannot disagree about which heading is + * current while both are on screen. + */ + let tocActiveLine = $state(null); + + /** + * The one gate on `tocActiveLine`. With `settings.tocFollows` set to + * 'cursor' and a cursor the reader can see (the editor is on screen, or + * `settings.previewCursor` draws one the reader placed in the preview), only + * the cursor moves the outline, and scrolling either pane leaves it where it + * is. Otherwise the pane that scrolled last decides. In the preview alone + * with `previewCursor` off, the editor's last cursor is kept for Ctrl+E but + * not followed: nothing on screen would say why the outline stopped. + * + * A preview that is not on screen never decides: in the editor alone it is + * still mounted at zero width, and its scroll events report lines from that + * layout, which put the outline on the wrong heading after Ctrl+E (#799). + * Nor does a preview Ctrl+E has just brought back, until + * `restoreAfterLeavingEditor` has placed it: its scroll events before that + * report the first headings. + */ + function followToc(from: 'cursor' | 'editor' | 'preview', line: RendererLine) { + const cursorLeads = settings.tocFollows === 'cursor' && (hasEditorPane() || (settings.previewCursor && activeCursor() !== null)); + const previewSettled = hasPreviewPane() && !previewPlacing; + const accepted = from === 'cursor' ? cursorLeads : !cursorLeads && (from === 'editor' || previewSettled); + if (accepted) tocActiveLine = line; + } + + /** True from Ctrl+E out of the editor until the preview is placed. */ + let previewPlacing = false; + + /** + * Each tab's cursor, shared by its two panes (#799): the editor's while it + * is on screen, and with `settings.previewCursor` a click in the preview + * moves it too. With that setting the preview draws it, and Ctrl+E puts the + * editor's cursor on it. A tab only read so far has none. + */ + let cursorByTab = $state>({}); + const activeCursor = () => (tabManager.activeTabId && cursorByTab[tabManager.activeTabId]) || null; + + function handleEditorCursor(line: BufferLine, column: number) { + if (tabManager.activeTabId) cursorByTab[tabManager.activeTabId] = { line, column }; + followToc('cursor', o.lineCoords.toRendererLine(line)); + } + + function placePreviewCursor(e: MouseEvent) { + const tabId = tabManager.activeTabId; + const previewBlocks = o.previewBlocks; + if (!tabId || !previewBlocks || !settings.previewCursor || e.detail !== 1) return; + if (!window.getSelection()?.isCollapsed) return; + + const caret = document.caretRangeFromPoint(e.clientX, e.clientY); + const point = caret && sourceAtPoint(previewBlocks, { node: caret.startContainer, offset: caret.startOffset }, o.readRendererLine); + if (!point) return; + + const line = o.lineCoords.toBufferLine(point.line); + cursorByTab[tabId] = { line, column: point.column }; + followToc('cursor', point.line); + if (isSplit()) o.editorPane?.setCursor(line, point.column); + } + + function handleEditorScrollSync(position: ScrollSyncPosition) { + // The line the tab would record as its reading position, not the line + // the viewport cuts in half: `tabAnchorForEditorTopLine` is the one + // crossing from a Monaco top line into the outline's numbering (it + // counts from the body, and sits `EDITOR_ANCHOR_LINE_OFFSET` lines + // down). Handing over the raw top line left the outline one entry + // behind whenever a heading was the first line on screen (#744). + if (position.line !== undefined) { + followToc('editor', tabAnchorForEditorTopLine(o.lineCoords, asBufferLine(position.line))); + } + + if (splitScrollSyncOn()) { + o.scrollPreviewToSyncPosition(position); + } + } + + /** + * Scroll sync is a split-view feature, and `isScrollSynced` outlives the + * split: it is the tab's remembered choice for the next one. Out of split + * the other pane is still mounted at zero width, so a position mapped + * through it drags the visible pane off the line Ctrl+E put it on (#799). + */ + function splitScrollSyncOn() { + const tab = tabManager.activeTab; + return !!tab?.isSplit && tab.isScrollSynced; + } + + function syncEditorToPreviewScroll(target: HTMLElement) { + const editorPane = o.editorPane; + if (!splitScrollSyncOn() || !editorPane) return; + + const position = o.getPreviewScrollSyncPosition(target); + editorPane.syncScrollToPosition(position); + } + + /** + * Reading mode's HTML, rendered from the tab's own buffer and its own path. + * Writes through the tab id, so a tab switch during the render cannot land + * one document's HTML on another — and, unlike the `loadMarkdown` call this + * replaces, it neither activates the tab nor re-reads the file. + */ + async function renderPreviewLeavingEditableMode(tab: Tab) { + try { + await renderTabPreviewFromRaw(tab); + } catch (e) { + console.error('Failed to render markdown', e); + } + } + + /** + * Move the active tab between reading and editing. Just the mode — where + * the reader LANDS is `editSourceRange`'s business, and `toggleEditView` + * is what decides which of the two a ⌘E means. + */ + async function toggleEdit() { + const tab = tabManager.activeTab; + if (!tab) return; + + if (isEditing()) { + // Back to reading. The preview renders the buffer, not the file, so no + // save is needed (#168). The tab stays dirty, and closing it or the + // window (`settleForExit`) still asks. + await flushBeforeLeavingEditableMode(tab); + // Ctrl+E lands on the line the other pane was showing, mapped the way + // split view maps it (#799). + const position = o.editorPane?.scrollSyncPosition() ?? null; + previewPlacing = position !== null; + tab.isEditing = false; + await renderPreviewLeavingEditableMode(tab); + if (position) void restoreAfterLeavingEditor(tab.id, position); + } else { + // Switch to edit + const markdownBody = o.markdownBody; + const position = markdownBody ? o.getPreviewScrollSyncPosition(markdownBody) : null; + if (tab.path !== '') { + if (tab.isDirty) { + // Unsaved edits are already in memory (made in reading + // mode, or kept when leaving the editor with auto-save + // off). Reading from disk would overwrite them, so only + // switch. + tab.isEditing = true; + } else { + const mayApply = claimTabForRead(tab.id); + try { + // This buffer is about to be handed to the editor, and + // the editor arms auto-save. The checked command is what + // says whether the decode was lossy, so the tab carries + // its own verdict instead of relying on the one + // `loadMarkdown` left behind — a file can be converted + // to UTF-8 (or away from it) between the two reads. + const [content, lossy, encoding] = await readFile(tab.path); + // A task ticked, or the tab closed, while this was out. + if (!mayApply()) return; + tabManager.setTabDecodedLossy(tab.id, lossy); + tabManager.setTabEncoding(tab.id, encoding); + // Goes through the store so a tab that held only the + // large-file preview slice stops being flagged partial. + tabManager.setTabRawContent(tab.id, content); + tab.isEditing = true; + } catch (e) { + console.error('Failed to read file for editing', e); + } + } + } else { + tab.isEditing = true; + } + if (position && tab.isEditing) { + await tick(); + o.editorPane?.syncScrollToPosition(position, { cursorIntoView: settings.tocFollows === 'cursor' }); + } + } + } + + /** + * The preview half of #799. The restore effect cannot do it: the article + * stays mounted at zero width in edit mode, so nothing it depends on changes + * with the mode. The pane takes its real width in the same frame (its slide + * is a `transform`, see `utils/paneSlide.ts`), so the line is placed right + * after the render, before the old position is ever painted. + */ + async function restoreAfterLeavingEditor(tabId: string, position: ScrollSyncPosition) { + await tick(); + previewPlacing = false; + const body = o.markdownBody; + if (!body || tabManager.activeTab?.id !== tabId || tabManager.activeTab.isEditing) return; + o.scrollPreviewToSyncPosition(position); + // That scroll is marked programmatic, so the tab's anchor and the + // outline's current entry are written here. + const anchorLine = o.getPreviewScrollAnchor(body); + if (anchorLine !== null) { + tabManager.updateTabAnchorLine(tabId, anchorLine); + followToc('preview', anchorLine); + } + } + + /** + * A jump the editor has not been asked for yet, because it does not exist + * yet: `toggleEdit` only flips a flag, and the `Editor` it mounts — and the + * `editorPane` binding that reaches it — arrive on the next render. The + * viewer's effect spends it the moment they do, and immediately when the + * editor is already on screen (split view). + */ + let pendingEditReveal = $state(null); + + /** + * Which source lines the reader means by right-clicking here (#90). + * + * The selection when there is one, so a range spanning several blocks opens + * the editor on all of them; a caret or a click with nothing selected falls + * through to whatever is under the pointer, which is how a click on an + * image lands on the image. + * + * `null` when nothing under the pointer came from the document: the front + * matter panel, the outline, the window chrome. The caller then leaves the + * "Edit" entry doing exactly what it did before. + */ + function getContextMenuSourceRange(e: MouseEvent): LineRange | null { + return getSelectionSourceRange() ?? findSourceLineRange(e.target as Node | null); + } + + /** + * The source lines the reader has selected in the preview, or null when + * nothing is selected or the selection came from outside the document. + * + * Both ends resolve independently and are merged, so a selection spanning + * several blocks answers with all of them, and one end landing somewhere + * with no range (the front matter panel, the outline) leaves the other end + * in charge. Direction-independent: `startContainer` is the range's start, + * not the point the drag began at. + */ + function getSelectionSourceRange(): LineRange | null { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null; + return mergeSourceLineRanges( + findSourceLineRange(selection.getRangeAt(0).startContainer), + findSourceLineRange(selection.getRangeAt(selection.rangeCount - 1).endContainer), + ); + } + + /** + * What ⌘E means, from every entry point that offers it — the hotkey, the + * toolbar, the title bar, and Monaco's own command. + * + * One function so the chord cannot mean two things depending on where the + * caret happens to be (`formatShortcutKeymap.test.ts` holds the two layers + * together), and so "take me to the editor" behaves the same whether the + * reader asked for it with a key or with a menu item. + */ + async function toggleEditView() { + const selected = getSelectionSourceRange(); + + if (!hasPreviewPane()) { + // The editor is already the whole window: nowhere further to take + // the reader, so this is the toggle back out. + await toggleEdit(); + return; + } + + if (isSplit() && !selected) { + // Deliberately nothing. + // + // What ⌘E is asked for is the ability to edit, and split view + // already grants it — the editor is on screen. With no selection + // there is no fragment to travel to either, so every remaining + // reading of the chord is a LAYOUT change nobody asked for: closing + // the preview on a mistyped ⌘E costs the reader the pane and a + // second keystroke to get it back, while doing nothing costs + // nothing. ⌘\ opens and closes the split, and stays the only way. + return; + } + + // Reading, or split with a selection: same as the context menu's "Edit". + await editSourceRange(selected); + } + + /** + * Open the editor on `range`. Toggles into edit mode when `isEditing` is + * false, including split view entered from reading mode. + */ + async function editSourceRange(range: LineRange | null) { + if (!isEditing()) await toggleEdit(); + // `toggleEdit` swallows a failed read and stays in reading mode. Arming + // the jump anyway would fire it at whatever document is edited next. + if (range && tabManager.activeTab?.isEditing) pendingEditReveal = o.lineCoords.toBufferRange(range); + } + + async function toggleSplitView(tabId: string) { + const tab = tabManager.tabs.find((t) => t.id === tabId); + if (!tab) return; + + if (!tab.isSplit) { + // Split view is an editor, and its first keystroke arms auto-save. + // The buffer must be complete first (not empty, not a large file's + // partial read), or auto-save writes a truncated file. + if (tab.path && !tab.isEditing && !tab.rawContent) { + const mayApply = claimTabForRead(tab.id); + try { + // Checked, like every other read that fills an editable + // buffer: split view is an editor, so this tab must carry + // the fidelity of its own decode rather than inherit one. + const [content, lossy, encoding] = await readFile(tab.path); + if (!mayApply()) return; + tabManager.setTabDecodedLossy(tab.id, lossy); + tabManager.setTabEncoding(tab.id, encoding); + tabManager.setTabRawContent(tab.id, content); + } catch (e) { + console.error('Failed to load raw content for split view', e); + } + } + if (!(await documentSession.ensureFullContent(tab.id))) { + addToast(t('toast.partialDocument', settings.language), 'error'); + return; + } + tabManager.setSplitEnabled(tab.id, true); + } else { + // Like leaving edit mode: the surviving pane renders the buffer, so + // nothing is saved or asked. + await flushBeforeLeavingEditableMode(tab); + tabManager.setSplitEnabled(tab.id, false); + await renderPreviewLeavingEditableMode(tab); + } + } + + /** The title bar's three mode buttons (#806): go straight to one, from any. */ + async function setViewMode(target: ViewMode) { + const tab = tabManager.activeTab; + if (!tab || viewModeOf(tab) === target) return; + if (target === 'split') return toggleSplitView(tab.id); + if (!tab.isSplit) return target === 'edit' ? toggleEditView() : toggleEdit(); + // Leaving split: the editor is already on screen and holds the full + // buffer, so Edit only drops the preview. Preview goes through the + // normal close, which flushes and renders. + if (target === 'edit') { + tab.isEditing = true; + tabManager.setSplitEnabled(tab.id, false); + return; + } + tab.isEditing = false; + await toggleSplitView(tab.id); + } + + return { + get tocActiveLine() { + return tocActiveLine; + }, + get activeCursor() { + return activeCursor(); + }, + get pendingEditReveal() { + return pendingEditReveal; + }, + set pendingEditReveal(range: BufferLineRange | null) { + pendingEditReveal = range; + }, + followToc, + handleEditorCursor, + placePreviewCursor, + handleEditorScrollSync, + syncEditorToPreviewScroll, + toggleEdit, + restoreAfterLeavingEditor, + getContextMenuSourceRange, + toggleEditView, + editSourceRange, + toggleSplitView, + setViewMode, + }; +} From 3262a999e466169fada633e0b566eb7486ebd378 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:22:25 +0800 Subject: [PATCH 055/118] refactor: route file-changed events through externalChange.handleFileChanged and test the save choke point --- scripts/documents.checks.test.ts | 43 +++--------------- scripts/documents.conflicts.spec.ts | 51 ++++++++++++++++++++++ scripts/documents.save.spec.ts | 53 +++++++++++++++++++++++ src/lib/MarkdownViewer.svelte | 16 +------ src/lib/sessions/externalChange.svelte.ts | 22 ++++++++++ 5 files changed, 133 insertions(+), 52 deletions(-) diff --git a/scripts/documents.checks.test.ts b/scripts/documents.checks.test.ts index cfe31d6c..6d2a8e37 100644 --- a/scripts/documents.checks.test.ts +++ b/scripts/documents.checks.test.ts @@ -265,14 +265,10 @@ describe('lossy decode save guard', () => { }); test('saveContent is the choke point auto-save and the close dialogs share', () => { - // Auto-save lives in MarkdownViewer.svelte's debounce effect and the close - // flow in canCloseTab; both reach saveContent, so guarding it covers them - // without touching either. - assert.match(session, /const success = await saveContent\(tabId\);/); + // saveSilently and canCloseTab run in documents.save.spec.ts. What stays is + // the debounce effect's wiring in the component: its writer is saveSilently. const viewer = readSource('src/lib/MarkdownViewer.svelte'); assert.match(viewer, /saveSilently\(s\.id\)\.then\(/); - const saves = readSource('src/lib/sessions/externalChange.svelte.ts'); - assert.match(functionSource(saves, 'saveSilently'), /documentSession\.saveContent\(tabId\)/); }); test('Save As to a new file stays open as the escape hatch', () => { @@ -330,20 +326,10 @@ describe('live mode watched path', () => { const viewer = readSource('src/lib/MarkdownViewer.svelte'); test('a watcher notification is routed to its watched path', () => { + // The event name across Rust and TypeScript. What the viewer does with the + // event runs in documents.conflicts.spec.ts (handleFileChanged). assert.match(runtime, /emit_to\(event_label\.as_str\(\), "file-changed", watched_path\.clone\(\)\)/); - // `async` because deciding what an event means now reads the file: the - // answer to "did somebody else write this" is the file itself, not a clock. - assert.match(viewer, /await appWindow\.listen\('file-changed', async \(event\) => \{/); - assert.match(viewer, /const changedPath = event\.payload as string;/); - // The listener no longer compares the payload with the active file itself: - // resolveExternalChange looks up the tab that OWNS the changed path (and - // refuses to reload it when it has unsaved edits). See - // externalChangeReload.spec.ts for the routing behaviour. - assert.match(viewer, /await documentSession\.resolveExternalChange\(changedPath\)/); - // A conflict is reported with Live Mode off too; Live Mode gates only the reload. - assert.match(viewer, /if \(liveMode\) loadMarkdown\(outcome\.path\);/); - const session = readSource('src/lib/sessions/documentSession.svelte.ts'); - assert.match(session, /tabManager\.tabs\.find\(\(tab\) => tab\.path === changedPath\)/); + assert.match(viewer, /await appWindow\.listen\('file-changed', \(event\) => externalChange\.handleFileChanged\(event\.payload as string, liveMode\)\)/); }); test('the watcher follows the active file instead of retaining a previous tab watcher', () => { @@ -662,7 +648,7 @@ describe('document encoding round trip', () => { }); describe('explicit save cancels auto-save', () => { - test('no call site takes the cancel duty back from saveContent', async () => { + test('no call site takes the cancel duty back from saveContent', () => { // The regression this guards is not a broken save — it is the duty // drifting back out to the call sites, where the next entry point can // forget it again. A cancel immediately before a `saveContent` is the @@ -675,16 +661,6 @@ describe('explicit save cancels auto-save', () => { `${path} pairs a manual cancel with a save; saveContent already does this`, ); } - - // And the duty is discharged before the write, not after — the source-level - // mirror of the ordering test above, so a refactor that reorders the two - // inside `saveContent` is caught even if the stub harness stops seeing it. - const session = readSource('src/lib/sessions/documentSession.svelte.ts'); - const saveContentBody = session.slice(session.indexOf('async function saveContent(')); - const cancel = saveContentBody.indexOf('options.cancelPendingAutoSave('); - const write = saveContentBody.indexOf("invoke('save_file_content'"); - assert.ok(cancel !== -1, 'saveContent must cancel the pending auto-save'); - assert.ok(cancel < write, 'the cancel must come before the write it protects'); }); }); @@ -693,13 +669,6 @@ describe('external change reload', () => { // --- wiring that cannot be executed outside a Svelte runtime --- - test('the watcher listener routes every event through the guarded resolution', () => { - const body = sliceBetween(viewer, "listen('file-changed'", '}),'); - assert.match(body, /resolveExternalChange/); - // The old body called loadMarkdown(currentFile) directly. - assert.doesNotMatch(body, /loadMarkdown\(currentFile\)/); - }); - test('turning Live Mode on does not reload', () => { // Enabling reload is not a request to discard the buffer, and this was // the one loadMarkdown call in the app with no canCloseTab in front of it. diff --git a/scripts/documents.conflicts.spec.ts b/scripts/documents.conflicts.spec.ts index d3257d84..575419be 100644 --- a/scripts/documents.conflicts.spec.ts +++ b/scripts/documents.conflicts.spec.ts @@ -275,6 +275,57 @@ describe('external change conflicts', () => { assert.equal(tab.rawContent, 'theirs'); assert.deepEqual(viewer.toasts, ['toast.reloadedFromDisk']); }); + + // --- the watcher's `file-changed` event --- + + /** a.md in the background, b.md on screen, both saved and clean. */ + function twoSavedTabs() { + tabManager.closeAll(); + disk.clear(); + for (const path of ['/notes/a.md', '/notes/b.md']) { + disk.set(path, `saved ${path}`); + tabManager.addTab(path, `saved ${path}`); + tabManager.activeTab!.originalContent = `saved ${path}`; + } + return tabManager.tabs; + } + + test('a watcher notification is routed to its watched path', async () => { + const viewer = makeViewer(); + const [a, b] = twoSavedTabs(); + disk.set('/notes/b.md', 'changed by another program'); + + await viewer.conflicts.handleFileChanged('/notes/b.md', false); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(b.rawContent, 'saved /notes/b.md', 'Live Mode off: the change is not loaded'); + + await viewer.conflicts.handleFileChanged('/notes/b.md', true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(b.rawContent, 'changed by another program'); + assert.equal(a.rawContent, 'saved /notes/a.md', 'only the tab that owns the path is touched'); + + // An event for the background file does not reload the one on screen. + disk.set('/notes/a.md', 'a changed'); + disk.set('/notes/b.md', 'b changed again'); + await viewer.conflicts.handleFileChanged('/notes/a.md', true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(b.rawContent, 'changed by another program'); + }); + + test('the watcher listener routes every event through the guarded resolution', async () => { + // A watcher event never reloads over unsaved edits, Live Mode or not: the + // tab is marked conflicted instead. + const viewer = makeViewer(); + const [, b] = twoSavedTabs(); + tabManager.updateTabRawContent(b.id, 'typed here'); + disk.set('/notes/b.md', 'changed by another program'); + + await viewer.conflicts.handleFileChanged('/notes/b.md', true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(b.rawContent, 'typed here'); + assert.equal(viewer.conflicts.hasExternalChangeConflict(b.id), true); + }); }); // Ctrl+S from any mode, on the tab in front. diff --git a/scripts/documents.save.spec.ts b/scripts/documents.save.spec.ts index 726d61d9..65683612 100644 --- a/scripts/documents.save.spec.ts +++ b/scripts/documents.save.spec.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, test } from 'vitest'; const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); const { settings } = await import('../src/lib/stores/settings.svelte.js'); const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); +const { createExternalChange } = await import('../src/lib/sessions/externalChange.svelte.js'); const { buildTransferredTab, snapshotTab, validateTransferPayload } = await import( '../src/lib/utils/tabTransfer.js' ); @@ -814,6 +815,8 @@ describe('lossy save refusal scope', () => { /** Whether `save_file_content` succeeds. Set per test. */ let writeFails = false; + /** Every `save_file_content` that reached the backend. */ + let writes = 0; const LOSSY = 'text with � in it'; beforeAll(() => { @@ -826,6 +829,7 @@ describe('lossy save refusal scope', () => { if (cmd === 'open_markdown_preview') return Promise.resolve([LOSSY, true, true]); if (cmd === 'read_file_content_checked') return Promise.resolve([LOSSY, true]); if (cmd === 'save_file_content') { + writes++; return writeFails ? Promise.reject(new Error('Os { code: 28, kind: StorageFull }')) : Promise.resolve(null); } return Promise.resolve(null); @@ -936,6 +940,55 @@ describe('lossy save refusal scope', () => { assert.equal(toasts.length, 1, 'still only the one explanation'); }); + test('saveContent is the choke point auto-save and the close dialogs share', async () => { + // Auto-save (saveSilently, the debounce's writer) and the close dialog's + // Save both reach saveContent, so the lossy guard covers them without + // either repeating it. + const { tab } = await refusedTab(); + writes = 0; + const autoSave = settings.autoSave; + settings.autoSave = true; + try { + const session = createDocumentSession({ + setShowHome: () => {}, + currentFile: () => tabManager.activeTab?.path ?? '', + resetScrollHistory: () => {}, + renderMarkdown: async () => '', + afterLoad: async () => {}, + saveRecentFile: () => {}, + deleteRecentFile: () => {}, + setLoadingTabs: () => {}, + measureInitialViewport: () => {}, + isScrolling: () => false, + onError: () => {}, + onDiskChangedUnderSave: () => {}, + cancelPendingAutoSave: () => {}, + askClose: async () => 'save' as const, + onCloseSaveNewerEdits: () => {}, + onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, + }); + const silent = createExternalChange({ + tabManager, + settings: { autoSave: true, language: 'en' }, + documentSession: session, + loadMarkdown: async () => {}, + canCloseTab: async () => true, + readFile: async () => [LOSSY, true, 'UTF-8'], + editorLanguageFor: () => 'markdown', + addToast: () => {}, + t: (key) => key, + }); + + assert.equal(await silent.saveSilently(tab.id), false, 'auto-save is refused'); + assert.equal(await session.canCloseTab(tab.id), false, 'Save in the close dialog is refused'); + assert.equal(writes, 0); + assert.equal(tab.rawContent, 'edited'); + } finally { + settings.autoSave = autoSave; + } + }); + test('a closed tab is not remembered as refused', async () => { // `closeTab` splices the tab out with no dispose hook, so the set keeps // the id forever. Ids are `crypto.randomUUID()` and never reused, so this diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 7e83d7a1..74df98c2 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -3395,21 +3395,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from document.addEventListener('visibilitychange', checkpointWhenHidden); unlisteners.push(() => document.removeEventListener('visibilitychange', checkpointWhenHidden)); unlisteners.push( - await appWindow.listen('file-changed', async (event) => { - const changedPath = event.payload as string; - // The event names the changed file. Which tab that touches — - // and whether it may be reloaded at all — is decided by the - // session: it owns the self-write grace window (so our own - // auto-save does not bounce back) and it refuses to reload a - // tab with unsaved edits. - const outcome = await documentSession.resolveExternalChange(changedPath); - if (outcome.action === 'ignore') return; - if (outcome.action === 'conflict') { - noteExternalChangeConflict(outcome.tabId); - return; - } - if (liveMode) loadMarkdown(outcome.path); - }), + await appWindow.listen('file-changed', (event) => externalChange.handleFileChanged(event.payload as string, liveMode)), ); const listeners: [string, EventCallback][] = [ diff --git a/src/lib/sessions/externalChange.svelte.ts b/src/lib/sessions/externalChange.svelte.ts index e4d4c93c..2446097f 100644 --- a/src/lib/sessions/externalChange.svelte.ts +++ b/src/lib/sessions/externalChange.svelte.ts @@ -13,6 +13,9 @@ export type ExternalChangeOptions = { documentSession: { saveContent: (tabId: string) => Promise; allowOverwriteOnce: (tabId: string) => void; + resolveExternalChange: ( + changedPath: string, + ) => Promise<{ action: 'ignore' } | { action: 'reload' | 'conflict'; tabId: string; path: string }>; }; loadMarkdown: (path: string, options: LoadMarkdownOptions) => Promise; canCloseTab: (tabId: string) => Promise; @@ -224,6 +227,24 @@ export function createExternalChange(options: ExternalChangeOptions) { clearExternalChangeConflict(id); } + /** + * A `file-changed` event from the watcher. The event names the changed + * file. Which tab that touches — and whether it may be reloaded at all — + * is decided by the session: it owns the self-write grace window (so our + * own auto-save does not bounce back) and it refuses to reload a tab with + * unsaved edits. A conflict is noted with Live Mode off too; Live Mode + * gates only the reload. + */ + async function handleFileChanged(changedPath: string, liveMode: boolean) { + const outcome = await options.documentSession.resolveExternalChange(changedPath); + if (outcome.action === 'ignore') return; + if (outcome.action === 'conflict') { + noteExternalChangeConflict(outcome.tabId); + return; + } + if (liveMode) options.loadMarkdown(outcome.path, {}); + } + return { get comparison() { return comparison; @@ -241,5 +262,6 @@ export function createExternalChange(options: ExternalChangeOptions) { reloadFromDisk, compareExternalChange, resolveExternalChangeByKeepingBuffer, + handleFileChanged, }; } From 2e5c3298a352c814098a0581826df51fd2598811 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:22:32 +0800 Subject: [PATCH 056/118] test: run the preview layout, export context and outline dismissal instead of reading the viewer --- scripts/export.checks.test.ts | 21 +------- scripts/export.test.ts | 32 +++++++++++ scripts/layout.checks.test.ts | 8 --- scripts/layout.spec.ts | 26 +++++++++ scripts/preview.checks.test.ts | 15 ++---- scripts/preview.test.ts | 17 ++++++ scripts/settings.checks.test.ts | 11 ++-- scripts/settings.test.ts | 35 ++++++++++++ src/lib/MarkdownViewer.svelte | 95 ++++++++++----------------------- src/lib/utils/previewLayout.ts | 90 +++++++++++++++++++++++++++++++ src/lib/utils/tocOverlay.ts | 23 ++++++++ 11 files changed, 259 insertions(+), 114 deletions(-) create mode 100644 src/lib/utils/previewLayout.ts diff --git a/scripts/export.checks.test.ts b/scripts/export.checks.test.ts index 2c1a88d3..8e21055c 100644 --- a/scripts/export.checks.test.ts +++ b/scripts/export.checks.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { buildExportDocument, exportThemeAttribute } from '../src/lib/utils/export.ts'; import { DEFAULT_PREVIEW_MAX_WIDTH, MIN_PREVIEW_MAX_WIDTH } from '../src/lib/utils/previewWidth.js'; import { plainAppearance } from './exportFixtures.ts'; -import { functionSource, offsetOf, readRustBackend, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; +import { offsetOf, readRustBackend, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; import { collectUsedKatexFamilies, katexFontUrlsToEmbed } from '../src/lib/utils/exportFonts.ts'; import { ALLOWED_MARKDOWN_URI_REGEXP, MARKDOWN_SANITIZE_CONFIG } from '../src/lib/utils/sanitize.js'; import { useShimDom } from './preview.domShim.ts'; @@ -31,8 +31,6 @@ describe('exported content width', () => { const appStyles = readSource(new URL('../src/styles.css', import.meta.url)); - const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - type Rule = { selectors: string[]; declarations: string; media: 'screen' | 'print' }; type Declaration = { value: string; important: boolean }; @@ -162,23 +160,6 @@ describe('exported content width', () => { appearance: plainAppearance, }); } - - test('the exporter is handed the preview\'s own derived width', () => { - // The one link in the chain that cannot be executed here: `exportAsHtml` - // lives in a `.svelte` component, which the Node test runner cannot import - // (see sourceTree.ts). So the field is checked in the source of that one - // function — `functionSource` rather than a whole-file match, so a - // `contentWidth:` appearing anywhere else in a 3700-line component cannot - // satisfy it. - const exportCall = functionSource(viewerSource, 'exportAsHtml'); - assert.match(exportCall, /contentWidth: previewContentWidth,/); - - // And `previewContentWidth` is the same derived value the live preview - // renders with, which is what makes "one source of truth" true rather than - // merely intended. (previewWidth.test.ts holds the other end of this.) - assert.match(viewerSource, /previewContentWidth = \$derived\(getPreviewContentWidth\(settings\.previewMaxWidth, settings\.previewFullWidth\)\)/); - assert.match(viewerSource, /--preview-max-width: \{previewContentWidth === null \? '100%' : `\$\{previewContentWidth\}px`\}/); - }); }); describe('folds in print and export', () => { diff --git a/scripts/export.test.ts b/scripts/export.test.ts index 6de51e31..03ad28d5 100644 --- a/scripts/export.test.ts +++ b/scripts/export.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { buildExportDocument, exportAsHtml, exportAsPdf, exportContentMaxWidth, exportThemeAttribute } from '../src/lib/utils/export.ts'; import { DEFAULT_PREVIEW_MAX_WIDTH, getPreviewContentWidth, MAX_PREVIEW_MAX_WIDTH, MIN_PREVIEW_MAX_WIDTH } from '../src/lib/utils/previewWidth.js'; import { plainAppearance } from './exportFixtures.ts'; +import { exportContextFrom, previewLayout } from '../src/lib/utils/previewLayout.ts'; import { parseFrontMatter } from '../src/lib/utils/frontMatter.js'; import { isAssetUrl, normalizeAssetPath, renderStaticFrontMatterPanel, resolveExportImagePath, rewriteMarkdownHrefForExport } from '../src/lib/utils/exportHtml.js'; import { hasMarkdownLinkExtension, MARKDOWN_LINK_EXTENSIONS, resolveMarkdownTargetPath } from '../src/lib/utils/markdownLinks.js'; @@ -1273,3 +1274,34 @@ describe('PDF export result', () => { ); }); }); + +describe('export context', () => { + const doc = { rawContent: '# x', tabTitle: 'x', tabPath: '/x.md', mermaidTheme: 'default' }; + const appearance = { previewFont: 'Lato', previewFontSize: 16, codeFont: 'Menlo', codeFontSize: 14, highlightColor: 'yellow' }; + const reading = { isEditing: false, isSplit: false, viewerWidth: 1400 }; + + test("the exporter is handed the preview's own derived width", () => { + for (const [previewMaxWidth, previewFullWidth] of [ + [1240, false], + [1240, true], + ['corrupted', false], + [99999, false], + ] as const) { + const settings = { + ...appearance, + previewMaxWidth, + previewFullWidth, + tocSide: 'left' as const, + splitEditorSide: 'left' as const, + tocWidth: 240, + previewNewlineBreaks: true, + language: 'en' as const, + }; + const preview = previewLayout(settings, reading); + const exported = exportContextFrom(settings, doc).contentWidth; + assert.equal(exported, preview.contentWidth, `${previewMaxWidth}/${previewFullWidth}`); + // And that is the value the live preview wears as `--preview-max-width`. + assert.equal(preview.maxWidthCss, exported === null ? '100%' : `${exported}px`); + } + }); +}); diff --git a/scripts/layout.checks.test.ts b/scripts/layout.checks.test.ts index 2e49155a..8f3f2339 100644 --- a/scripts/layout.checks.test.ts +++ b/scripts/layout.checks.test.ts @@ -272,14 +272,6 @@ describe('outline jump motion', () => { }); describe('outline overlay placement', () => { - test('the outline collapses itself only when it is in the way', () => { - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - // Both auto-collapse paths are gated on the same predicate, so a window wide - // enough to hold the outline beside the text keeps the old behaviour. - assert.match(viewer, /isOverhanging && !settings\.pinnedToc/); - // Click-outside must not fight the toggle button, which owns its own click. - assert.match(viewer, /tocToggleEl\?\.contains\(target\)/); - }); test('the outline\'s toggle drops the editor toolbar\'s offset once it is over the outline', () => { // Collapsed, the button floats over the editor pane and `--pane-top-chrome` diff --git a/scripts/layout.spec.ts b/scripts/layout.spec.ts index 3967febe..915fb476 100644 --- a/scripts/layout.spec.ts +++ b/scripts/layout.spec.ts @@ -7,6 +7,8 @@ import Toc from '../src/lib/components/Toc.svelte'; import { collapsedFoldsAround, type FoldHost, revealFold, toggleFold, toggleFoldFromClick } from '../src/lib/utils/foldState.js'; import { runeProps } from './runeProps.svelte.js'; import { observeFoldLayout, type FoldLayoutObservation } from '../src/lib/utils/foldLayout.js'; +import { previewLayout } from '../src/lib/utils/previewLayout.js'; +import { dismissesToc, tocAutoCollapses } from '../src/lib/utils/tocOverlay.js'; import { followFoldLayout, previewPaneShown } from '../src/lib/utils/foldLayoutEffect.svelte.js'; import { runeEffect } from './runeEffect.svelte.js'; import { patchHost } from '../src/lib/utils/previewPatch.js'; @@ -1136,3 +1138,27 @@ describe('fold layout observation', () => { run.dispose(); }); }); + +describe('outline overlay placement', () => { + test('the outline collapses itself only when it is in the way', () => { + const settings = { previewMaxWidth: 880, previewFullWidth: false, tocSide: 'left' as const, splitEditorSide: 'left' as const, tocWidth: 240 }; + const wide = previewLayout(settings, { isEditing: false, isSplit: false, viewerWidth: 1400 }); + const narrow = previewLayout(settings, { isEditing: false, isSplit: false, viewerWidth: 1000 }); + // A window wide enough to hold the outline beside the text keeps it open. + assert.equal(tocAutoCollapses(wide.isOverhanging, false), false); + assert.equal(tocAutoCollapses(narrow.isOverhanging, false), true); + // Pinned, it is a sidebar and stays. + assert.equal(tocAutoCollapses(narrow.isOverhanging, true), false); + + // Click-outside must not fight the toggle button, which owns its own click. + const panel = document.createElement('nav'); + const handle = panel.appendChild(document.createElement('div')); + const toggle = document.createElement('button'); + const icon = toggle.appendChild(document.createElement('span')); + const text = document.createElement('p'); + assert.equal(dismissesToc(icon, panel, toggle), false); + assert.equal(dismissesToc(handle, panel, toggle), false); + assert.equal(dismissesToc(text, panel, toggle), true); + assert.equal(dismissesToc(null, panel, toggle), false); + }); +}); diff --git a/scripts/preview.checks.test.ts b/scripts/preview.checks.test.ts index 8d354e56..1e33ba95 100644 --- a/scripts/preview.checks.test.ts +++ b/scripts/preview.checks.test.ts @@ -333,8 +333,6 @@ describe('preview width', () => { const settingsComponentSource = readSource(new URL('../src/lib/components/Settings.svelte', import.meta.url)); - const tocOverlaySource = readSource(new URL('../src/lib/utils/tocOverlay.ts', import.meta.url)); - test('settings load, persist, and reset the preview width through one normalizer', () => { assert.match(settingsSource, /previewMaxWidth = \$state\(DEFAULT_PREVIEW_MAX_WIDTH\)/); // Persistence moved from one all-fields effect to one entry per localStorage @@ -348,15 +346,10 @@ describe('preview width', () => { assert.match(settingsSource, /resetPreviewMaxWidth\(\)[\s\S]*this\.previewMaxWidth = DEFAULT_PREVIEW_MAX_WIDTH/); }); - test('preview layout derives width and ToC geometry from the same preference', () => { - assert.match(viewerSource, /getPreviewContentWidth\(settings\.previewMaxWidth, settings\.previewFullWidth\)/); - // The gutter arithmetic moved to `tocOverlay.ts` with #176, which added the - // question the inline expression could not answer: whether the pane under - // the outline is the preview at all. Both halves still feed on this same - // preference — see scripts/tocOverlay.test.ts for the geometry itself. - assert.match(viewerSource, /isTocOverhanging\(\{[\s\S]*?previewContentWidth,[\s\S]*?\}\)/); - assert.match(tocOverlaySource, /input\.viewerWidth - input\.previewContentWidth/); - assert.match(viewerSource, /--preview-max-width:/); + test('the stylesheet reads the width the markup writes', () => { + // Contract between the inline style and the component's '), - ); - - /** Every class the component's own stylesheet writes a rule for. */ - const styledClasses = new Set( - [...settingsStyle.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((match) => match[1]), - ); - - /** - * The control classes `.setting-item`'s row layout sizes. Each is sized on the - * assumption that it is a flex item of the row, so each must be a direct child - * of one. - */ - const ROW_SIZED_CONTROLS = ['select-wrapper', 'slider-container', 'toggle', 'custom-dropdown-wrapper']; - - type Row = { children: { tag: string; classes: string[] }[]; nested: string[]; label: string }; - - /** Every `.setting-item` in Settings.svelte, with its direct children. */ - function settingRows(): Row[] { - const ast = parseComponent(componentSource, 'src/lib/components/Settings.svelte'); - const rows: Row[] = []; - - const classesOf = (node: Record): string[] => { - const out: string[] = []; - for (const attribute of node.attributes ?? []) { - if (attribute.type === 'ClassDirective') out.push(attribute.name); - if (attribute.type !== 'Attribute' || attribute.name !== 'class') continue; - const parts = Array.isArray(attribute.value) ? attribute.value : [attribute.value]; - for (const part of parts) if (part.type === 'Text') out.push(...String(part.data).split(/\s+/).filter(Boolean)); - } - return out; - }; - - /** Elements below `node`, skipping `{#if}`/`{#each}` wrappers, to `depth`. */ - const elementsUnder = (node: unknown, depth: number, out: { node: Record; depth: number }[] = []) => { - if (Array.isArray(node)) { - for (const child of node) elementsUnder(child, depth, out); - return out; - } - if (!node || typeof node !== 'object') return out; - const current = node as Record; - if (current.type === 'RegularElement') { - out.push({ node: current, depth }); - elementsUnder(current.fragment, depth + 1, out); - return out; - } - // Control-flow blocks do not add a DOM level. - for (const key of ['fragment', 'nodes', 'consequent', 'alternate', 'body']) { - if (current[key]) elementsUnder(current[key], depth, out); - } - return out; - }; - - const walk = (node: unknown): void => { - if (Array.isArray(node)) { - for (const child of node) walk(child); - return; - } - if (!node || typeof node !== 'object') return; - const current = node as Record; - - if (current.type === 'RegularElement' && classesOf(current).includes('setting-item')) { - const descendants = elementsUnder(current.fragment, 1); - const direct = descendants.filter((entry) => entry.depth === 1); - rows.push({ - label: direct[0] ? textOf(direct[0].node) : '(empty row)', - children: direct.map((entry) => ({ tag: entry.node.name, classes: classesOf(entry.node) })), - nested: descendants - .filter( - (entry) => - entry.depth > 1 && - classesOf(entry.node).some((name) => ROW_SIZED_CONTROLS.includes(name)) && - // `.number-input-wrapper` legitimately lives inside - // `.slider-container`, which is itself a direct child. - !parentIsSizedControl(descendants, entry), - ) - .map((entry) => `${entry.node.name}.${classesOf(entry.node).join('.')}`), - }); - } - - for (const [key, value] of Object.entries(current)) { - if (key === 'parent' || key === 'metadata') continue; - walk(value); - } - }; - - walk((ast as Record).fragment); - return rows; - } - - function parseComponent(text: string, filename: string): unknown { - return parse(text, { modern: true, filename }); - } - - function textOf(node: Record): string { - const first = (node.fragment?.nodes ?? []).find((child: any) => child.type === 'ExpressionTag' || child.type === 'Text'); - if (!first) return node.name; - return componentSource.slice(first.start, first.end).trim().slice(0, 48); - } - - /** True when `entry`'s nearest element ancestor is itself a sized control. */ - function parentIsSizedControl( - all: { node: Record; depth: number }[], - entry: { node: Record; depth: number }, - ): boolean { - const index = all.indexOf(entry); - for (let i = index - 1; i >= 0; i--) { - if (all[i].depth !== entry.depth - 1) continue; - const classes = (all[i].node.attributes ?? []) - .filter((a: any) => a.type === 'Attribute' && a.name === 'class' && Array.isArray(a.value)) - .flatMap((a: any) => a.value.filter((v: any) => v.type === 'Text').flatMap((v: any) => String(v.data).split(/\s+/))); - return classes.some((name: string) => ROW_SIZED_CONTROLS.includes(name)); - } - return false; - } - - const rows = settingRows(); - - test('every settings row was found', () => { - // Vacuous-pass guard: the two assertions below say nothing if the walk - // stopped finding rows. - // The rows render from SETTINGS_PANES (settingsPanes.ts) through one - // `settingRow` snippet, so the markup holds that one generic row plus the - // rows whose control is one of a kind. - assert.ok(rows.length >= 7, `found ${rows.length} .setting-item rows`); - assert.ok(styledClasses.has('setting-item') && styledClasses.has('select-wrapper')); - }); - - test('a settings row leads with the label that names its control', () => { - const wrong = rows - .filter((row) => row.children[0]?.tag !== 'label') - .map((row) => `${row.label} leads with <${row.children[0]?.tag}>`); - assert.deepEqual(wrong, [], 'a row whose first child is not the