// // main.js — extension entry point. // // The commands are thin: work out which file the preview is for, regenerate its // page, and keep regenerating it while you edit. Everything about rendering // lives in render.js and the page assets; everything about where files go lives // in preview.js. // // A preview is tracked from the moment it is opened until either tab involved // closes, at which point the generated page is thrown away again. // const { conf, log, warn } = require("./util.js"); const preview = require("./preview.js"); const EXPLAINED_KEY = "mermaidpreview.didExplainPreviewTab"; // Toggling a tab between source and preview may close and reopen the editor // behind it. Cleanup waits this long and looks again, so that switching a page // into Preview is not mistaken for closing it. const CLEANUP_GRACE_MS = 2500; class Controller { constructor() { // sourcePath -> { source, outputPath, disposables, cleanupTimer } this.previews = new Map(); this.disposables = new CompositeDisposable(); } // -- lifecycle --------------------------------------------------------- activate() { this.disposables.add( nova.commands.register("mermaidpreview.preview", () => this.previewCommand()) ); this.disposables.add( nova.commands.register("mermaidpreview.refresh", () => this.refreshCommand()) ); this.disposables.add(nova.commands.register("mermaidpreview.clean", () => this.cleanCommand())); // Switching between "on save" and "as you type" changes which editor // event we listen to, so the watchers have to be rebuilt. this.disposables.add( nova.config.onDidChange("mermaidpreview.updateMode", () => this.rewireWatchers()) ); // A regenerated page has to be rewritten for the new theme or width; // the preview tab reloads itself once the file changes. for (const key of ["mermaidpreview.theme", "mermaidpreview.contentWidth"]) { this.disposables.add(nova.config.onDidChange(key, () => this.regenerateAll())); } } deactivate() { // Quitting Nova closes the previews too, so leave nothing behind. for (const entry of Array.from(this.previews.values())) { this.forget(entry, this.cleanUpOnClose()); } this.disposables.dispose(); } cleanUpOnClose() { return conf("mermaidpreview.cleanUpOnClose", true) !== false; } // -- commands ---------------------------------------------------------- async previewCommand() { const source = preview.describeSource(nova.workspace.activeTextEditor); if (!source) { nova.workspace.showErrorMessage( "Open a Markdown (.md) or Mermaid (.mmd) file and try again. Unsaved files have no " + "location on disk, so there is nothing to resolve their images and links against." ); return; } let opened; try { opened = await preview.open(source); } catch (err) { this.report(err); return; } this.watch(source, opened.outputPath, opened.editor); this.explainPreviewTabOnce(); } refreshCommand() { const source = preview.describeSource(nova.workspace.activeTextEditor); if (source && this.previews.has(source.path)) { this.regenerate(this.previews.get(source.path).source); return; } if (source) { this.previewCommand(); return; } this.regenerateAll(); } async cleanCommand() { let directory; try { directory = preview.outputDirectory(); } catch (err) { this.report(err); return; } const confirmed = await new Promise((resolve) => { nova.workspace.showActionPanel( `Remove every page generated by Mermaid Preview, along with its assets?\n\n${directory}`, { buttons: ["Remove", "Cancel"] }, (index) => resolve(index === 0) ); }); if (!confirmed) return; try { const removed = preview.clean(); for (const entry of Array.from(this.previews.values())) this.forget(entry, false); nova.workspace.showInformativeMessage( removed ? "Generated preview files removed." : "There was nothing to remove." ); } catch (err) { this.report(err); } } // -- keeping the page current ----------------------------------------- /** * Watches both ends of a preview: the Markdown file, so the page follows * your edits, and the generated page, so closing it cleans up. */ watch(source, outputPath, previewEditor) { const existing = this.previews.get(source.path); if (existing) this.forget(existing, false); const disposables = new CompositeDisposable(); const entry = { source, outputPath, disposables, cleanupTimer: null }; this.previews.set(source.path, entry); const mode = String(conf("mermaidpreview.updateMode", "save")); if (mode === "live") { disposables.add(source.editor.onDidStopChanging(() => this.regenerate(source))); } else if (mode === "save") { disposables.add(source.editor.onDidSave(() => this.regenerate(source))); } // Closing the Markdown file leaves a page nothing will update again. disposables.add( source.editor.onDidDestroy(() => this.forget(entry, this.cleanUpOnClose())) ); // openFile hands back the editor it opened, except when Nova restored the // tab straight into its preview state; then look it up instead. const pageEditor = previewEditor || preview.editorFor(outputPath); if (pageEditor) { disposables.add(pageEditor.onDidDestroy(() => this.scheduleCleanup(entry))); } } /** * Deletes the generated page once it has stayed closed. Nova may destroy and * rebuild the editor when a tab flips between source and preview, so a * destroyed editor on its own is not proof the tab is gone. */ scheduleCleanup(entry) { if (!this.cleanUpOnClose()) return; if (entry.cleanupTimer) clearTimeout(entry.cleanupTimer); entry.cleanupTimer = setTimeout(() => { entry.cleanupTimer = null; if (!this.previews.has(entry.source.path)) return; if (preview.isOpen(entry.outputPath)) return; this.forget(entry, true); }, CLEANUP_GRACE_MS); } /** Stops tracking a preview, optionally deleting the page it generated. */ forget(entry, deletePage) { if (entry.cleanupTimer) { clearTimeout(entry.cleanupTimer); entry.cleanupTimer = null; } entry.disposables.dispose(); this.previews.delete(entry.source.path); if (!deletePage) return; try { preview.discard(entry.outputPath); } catch (err) { warn(`Could not remove ${entry.outputPath}: ${err.message || err}`); } } /** Rebuilds every watcher, used when the update mode changes. */ rewireWatchers() { for (const entry of Array.from(this.previews.values())) { this.watch(entry.source, entry.outputPath, null); } } regenerate(source) { try { preview.generate(source); } catch (err) { // A failure here is a background one — the user asked for nothing — // so it belongs in the console, not in an alert on every keystroke. warn(`Could not regenerate the preview for ${source.path}: ${err.message || err}`); } } regenerateAll() { for (const entry of this.previews.values()) this.regenerate(entry.source); } // -- messaging --------------------------------------------------------- /** * The generated page opens as an editor tab. Nova remembers per file whether * it was last shown as source or preview, so this only needs saying once. */ explainPreviewTabOnce() { if (nova.config.get(EXPLAINED_KEY, "boolean")) return; nova.config.set(EXPLAINED_KEY, true); nova.workspace.showInformativeMessage( "The generated page is open. Switch it to Preview once (Editor → Preview, or the preview " + "button in the tab bar) and Nova will keep showing it that way, refreshing as you edit." ); } report(err) { if (err instanceof preview.PreviewError) { nova.workspace.showErrorMessage(err.message); } else { warn(err); nova.workspace.showErrorMessage(`Could not build the preview: ${err.message || err}`); } } } const controller = new Controller(); exports.activate = function () { controller.activate(); }; exports.deactivate = function () { controller.deactivate(); };