Initial Commit
This commit is contained in:
+254
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// 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();
|
||||
};
|
||||
Reference in New Issue
Block a user