# HG changeset patch # User Ludovic Chabant # Date 1640499039 28800 # Sat Dec 25 22:10:39 2021 -0800 # Node ID 5b722aa622e804ea2fe4b0e25cf648929b726d19 # Parent 0000000000000000000000000000000000000000 Initial commit diff --git a/.hgignore b/.hgignore new file mode 100644 --- /dev/null +++ b/.hgignore @@ -0,0 +1,3 @@ +main.js +node_modules +package-lock.json diff --git a/README.md b/README.md new file mode 100755 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +## Obsidian Opened Files Plugin + +This [Obsidian](https://obsidian.md) plugin keeps files opened until explicitly +closed, turning the app into a more "proper" multi-document editor. This means +that when switching from document A to document B, and then back to document A, +you still have document A's state: your undo/redo history, your position in the +file, your selection, and so on. + +Some important notes: + +- Behind the scenes, the documents _are_ closed. The plugin saves the undo/redo + history, last selection, and more, before the underlying editor is discarded. + This means that there's very little impact on memory usage. + +- This plugin is using a number of horrible hacks to achieve its goal, and + should be considered a "proof of concept" more than anything else. It could be + broken with any Obsidian update. + diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100755 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,35 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from 'builtin-modules' + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === 'production'); + +esbuild.build({ + banner: { + js: banner, + }, + entryPoints: ['src/main.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + '@codemirror', + '@codemirror/state', + '@codemirror/view', + ...builtins + ], + format: 'cjs', + watch: !prod, + target: 'es2016', + logLevel: "info", + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', +}).catch(() => process.exit(1)); diff --git a/manifest.json b/manifest.json new file mode 100755 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "opened-files", + "name": "Opened Files", + "version": "0.1.0", + "minAppVersion": "0.13.0", + "description": "Keeps files internally opened (including their undo/redo history) until explicitly closed", + "author": "Ludovic Chabant", + "authorUrl": "https://ludovic.chabant.com", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100755 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "obsidian-opened-files", + "version": "0.1.0", + "description": "Keeps files internally opened (including their undo/redo history) until explicitly closed", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs production" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@codemirror/history": "^0.19.0", + "@codemirror/state": "^0.19.6", + "@codemirror/view": "^0.19.36", + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "^5.2.0", + "@typescript-eslint/parser": "^5.2.0", + "builtin-modules": "^3.2.0", + "esbuild": "0.13.12", + "obsidian": "^0.12.17", + "tslib": "2.3.1", + "typescript": "4.4.4" + } +} diff --git a/src/cmplugin.ts b/src/cmplugin.ts new file mode 100644 --- /dev/null +++ b/src/cmplugin.ts @@ -0,0 +1,89 @@ +import { + Editor +} from 'codemirror'; + +import { + historyField, + BranchName +} from '@codemirror/history'; + +import { + EditorState +} from '@codemirror/state'; + +import { + EditorView, + PluginValue, + ViewPlugin +} from '@codemirror/view'; + +export var PluginType: ViewPlugin | null = null; + +// CodeMirror6 plugin that captures when a file is closed, so we can save +// its state (history, selection, scrolling position, etc). +export class OpenedFilesCodeMirrorViewPlugin implements PluginValue { + plugin: OpenedFilesPlugin; + view: EditorView; + + constructor(readonly plugin: OpenedFilesPlugin, readonly view: EditorView) { + this.plugin = plugin; + this.view = view; + + // We use this plugin instance as a key to know what file is being + // closed (see destroy() method below). + this.plugin.registerCodeMirrorPlugin(this); + } + + destroy() { + // Save the document state to JSON and hand it over to the Obsidian + // plugin for safe-keeping until the file is re-opened. + var viewStateJson = this.view.state.toJSON({"historyField": historyField}); + var savedState = { + 'viewState': viewStateJson, + 'scrollTop': this.view.scrollDOM.scrollTop + }; + this.plugin.unregisterCodeMirrorPlugin(this, savedState); + } + + public static register(plugin: OpenedFilesPlugin) { + const cmPlugin = ViewPlugin.define( + (view: EditorView) => { + return new OpenedFilesCodeMirrorViewPlugin(plugin, view); + }); + plugin.registerEditorExtension(cmPlugin); + PluginType = cmPlugin; + } +} + +export class CodeMirrorStateManager { + static restore(state: Object, editor: Editor) { + //var transaction = editor.cm.state.update([{ + // selection: savedState.selection, + // annotations: [ + // fromHistory.of({side: BranchName.Done, rest: savedState.done}), + // fromHistory.of({side: BranchName.Undone, rest: savedSate.undone}) + // ] + //}]); + //editor.cm.dispatch(transaction); + //editor.cm.setState(allState); + + // Restore history by stomping it with JSON deserialization. + // (probably mega unsafe and unsupported...) + var histFieldValue = editor.cm.state.field(historyField); + Object.assign( + histFieldValue, + historyField.spec.fromJSON(state.viewState.historyField)); + + // No need to deserialize the history field now, we just want the selection. + var viewStateObj = EditorState.fromJSON(state.viewState); + var transaction = editor.cm.state.update({ + selection: viewStateObj.selection, + scrollIntoView: true + }); + editor.cm.dispatch(transaction); + + // Restore scrolling position. + editor.cm.scrollDOM.scrollTop = state.scrollTop; + } +} + diff --git a/src/fileopenpatch.ts b/src/fileopenpatch.ts new file mode 100644 --- /dev/null +++ b/src/fileopenpatch.ts @@ -0,0 +1,71 @@ +import { + OpenedFilesPlugin +} from './main'; + +export class FileOpenPatch { + private plugin: OpenedFilesPlugin | null; + private keyupEventHandled: boolean = false; + + constructor(plugin: OpenedFilesPlugin) { + this.plugin = plugin; + } + + public register() { + var ws = this.plugin.app.workspace; + ws._origPushClosable = ws.pushClosable; + ws.pushClosable = (c) => { this.onPushClosable(c); }; + console.debug("Registered file-open modal patch."); + } + + public unregister() { + var ws = this.plugin.app.workspace; + ws.pushClosable = ws._origPushClosable; + delete ws._origPushClosable; + console.debug("Unregistered file-open modal patch."); + } + + private onPushClosable(c) { + var ws = this.plugin.app.workspace; + ws._origPushClosable(c); + + // Ugly way to detect the modal's type. + if (c.emptyStateText && + c.emptyStateText.startsWith("No notes found.")) { + this.keyupEventHandled = true; + c.containerEl.addEventListener("keyup", (e) => { this.onKeyup(e); }); + this.updateModalCapsules(ws); + } + } + + private onKeyup(e) { + this.updateModalCapsules(); + } + + private updateModalCapsules() { + var openedFileMap = {}; + this.plugin.data.openedFiles.forEach((data) => { + var fileDisplayName = data.path.substring( + 0, data.path.length - data.extension.length - 1); + openedFileMap[fileDisplayName] = true; + }); + + var ws = this.plugin.app.workspace; + var closeable = ws.closeables[ws.closeables.length - 1]; + var items = Array.from( + closeable.containerEl.getElementsByClassName('suggestion-item')); + items.forEach((item) => { + var children = Array.from(item.childNodes); + var textChild = children.find((c) => { return c.nodeType == document.TEXT_NODE; }); + var capsules = item.getElementsByClassName("opened-file-capsule"); + if (textChild && textChild.wholeText in openedFileMap) { + if (capsules.length == 0) { + item.createSpan({cls:"opened-file-capsule", text:"open"}); + } + } else { + if (capsules.length > 0) { + item.removeChild(capsules[0]); + } + } + }); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100755 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,367 @@ +import { + Editor, + FileView, + MarkdownView, + Plugin, +} from 'obsidian'; + +import { + DEFAULT_SETTINGS, + OpenedFilesPluginSettings, + OpenedFilesPluginSettingTab +} from './settings'; + +import { + OpenedFilesListView, + OpenedFilesListViewType +} from './panes'; + +import { + FileOpenPatch +} from './fileopenpatch'; + +import { + PluginType, + CodeMirrorStateManager, + OpenedFilesCodeMirrorViewPlugin +} from './cmplugin'; + +// Interface for an opened file. +interface OpenedFileData { + basename: string; + path: string; + extension: string; + lastOpenedTime: number; + cmPlugin: OpenedFilesCodeMirrorViewPlugin | null; + state: Object; +} + +// Interface for all currently opened files. +interface OpenedFilesData { + openedFiles: OpenedFileData[]; +} + +// Default empty list of opened files. +const DEFAULT_DATA: OpenedFilesData = { + openedFiles: [] +}; + +export default class OpenedFilesPlugin extends Plugin { + settings: OpenedFilesPluginSettings; + view: OpenedFilesListView; + data: OpenedFilesData; + + private fileOpenPatch: FileOpenPatch | null; + + private pendingCodeMirrorPlugin: OpenedFilesCodeMirrorViewPlugin | null; + private isResettingState: boolean = false; + + async onload() { + console.log("Loading OpenedFiles plugin"); + + await this.loadSettings(); + + this.data = Object.assign({}, DEFAULT_DATA); + + const ws = this.app.workspace; + + OpenedFilesCodeMirrorViewPlugin.register(this); + + this.fileOpenPatch = new FileOpenPatch(this); + this.fileOpenPatch.register(); + + this.registerEvent(ws.on('file-open', this.onFileOpen)); + this.registerEvent(this.app.vault.on('rename', this.onFileRename)); + this.registerEvent(this.app.vault.on('delete', this.onFileDelete)); + + this.registerView( + OpenedFilesListViewType, + (leaf) => (this.view = new OpenedFilesListView(leaf, this, this.data)), + ); + + this.addCommand({ + id: 'show-opened-files-pane', + name: 'Show opened files pane', + callback: () => { this.activateView(true); } + }); + this.addCommand({ + id: 'close-active-file', + name: 'Close active file', + editorCheckCallback: (checking, editor, view) => { + return this.closeActiveFile(checking, editor, view); } + }); + + this.app.workspace.registerHoverLinkSource( + OpenedFilesListViewType, + { + display: 'Recent Files', + defaultMod: true, + }); + + this.addSettingTab(new OpenedFilesPluginSettingTab(this.app, this)); + + this.gatherAlreadyOpenedFiles(); + } + + onunload() { + this.app.workspace.detachLeavesOfType(OpenedFilesListViewType); + + if (this.fileOpenPatch) { + this.fileOpenPatch.unregister(); + } + } + + private gatherAlreadyOpenedFiles() { + this.app.workspace.iterateRootLeaves((leaf) => { + if (!(leaf.view instanceof MarkdownView)) { + return; + } + + var file = leaf.view.file; + var cmPlugin = leaf.view.editor.cm.plugin(PluginType); + + this.data.openedFiles.unshift({ + basename: file.basename, + path: file.path, + extension: file.extension, + lastOpenedTime: Date.now(), + cmPlugin: cmPlugin + }); + }); + console.debug(`Found ${this.data.openedFiles.length} opened files`); + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + async activateView(reveal: boolean) { + let ws: Workspace = this.app.workspace; + let existingLeaf: WorkspaceLeaf = null; + for (var leaf of ws.getLeavesOfType(OpenedFilesListViewType)) { + existingLeaf = leaf; + } + if (!existingLeaf) { + existingLeaf = ws.getLeftLeaf(false); + await existingLeaf.setViewState({ + type: OpenedFilesListViewType, + active: true, + }); + } + if (reveal && existingLeaf) { + ws.revealLeaf(existingLeaf); + } + } + + async closeActiveFile(checking: boolean, editor: Editor, view: MarkdownView) { + // Close the opened file matching the current editor. + var existingIndex = this.data.openedFiles.findIndex( + curFile => curFile.path == view.file.path + ); + if (checking) { + return existingIndex >= 0; + } + this.data.openedFiles.splice(existingIndex, 1); + + // Close the active pane too. + this.app.commands.executeCommandById("workspace:close"); + } + + public registerCodeMirrorPlugin(plugin: OpenedFilesCodeMirrorViewPlugin) { + if (this.isResettingState) { + return; + } + + // On plugin init, if there are already multiple markdown panes + // open, we would overwrite the pending plugin reference multiple + // times and never actually use it. This is handled in the plugin + // initialization though. + this.pendingCodeMirrorPlugin = plugin; + console.debug("Registered CodeMirror view plugin"); + } + + public unregisterCodeMirrorPlugin(plugin: OpenedFilesCodeMirrorViewPlugin, state: Object) { + if (this.isResettingState) { + return; + } + + var existingIndex = this.data.openedFiles.findIndex( + curFile => curFile.cmPlugin == plugin + ); + if (existingIndex < 0) { + this.pendingCodeMirrorPlugin = null; + } else { + var existingFile = this.data.openedFiles[existingIndex]; + existingFile.cmPlugin = null; + existingFile.state = state; + } + console.debug("Unregistered CodeMirror view plugin"); + } + + private readonly updateData = async (file: TFile, editor: Editor): Promise => { + var existingFile = this.data.openedFiles.find( + curFile => curFile.path == file.path + ); + if (existingFile) { + const hadPlugin: boolean = (existingFile.cmPlugin != null); + if (!hadPlugin) { + // Reopening a file we have previous data for... restore its + // editing state. + existingFile.cmPlugin = this.pendingCodeMirrorPlugin; + this.pendingCodeMirrorPlugin = null; + if (existingFile.state) { + this.isResettingState = true; + CodeMirrorStateManager.restore(existingFile.state, editor); + console.debug("Restored editing state for file:", file.path); + this.isResettingState = false; + } else { + console.warn("Can't restore editing state for file:", file.path); + } + } else { + // For some reason Obsidian triggers `file-open` when merely + // switching focus between markdown editor leaves... this + // would already be hooked up to a CodeMirror view plugin, so + // we don't have anything to do. + console.debug("Nothing to do for re-opening:", file.path); + } + existingFile.lastOpenedTime = Date.now(); + } else { + // New file being opened. + let newOpenedFileData = { + basename: file.basename, + path: file.path, + extension: file.extension, + lastOpenedTime: Date.now(), + cmPlugin: this.pendingCodeMirrorPlugin + }; + this.pendingCodeMirrorPlugin = null; + + this.data.openedFiles.unshift(newOpenedFileData); + console.debug("Linked pending CodeMirror plugin to:", file.path); + } + } + + private readonly onFileOpen = async ( + openedFile: TFile + ): Promise => { + // Update our list of opened files. + // If `openedFile` is null, it's because the last pane was closed + // and there is now an empty pane. + if (openedFile) { + var activeView = this.app.workspace.activeLeaf.view; + await this.updateData(openedFile, activeView.editor); + } + + // When closing a leaf that had a markdown editor, Obsidian doesn't + // seem to shutdown the CodeMirror instance so our plugin never + // gets destroyed. We have to figure it out ourselves by doing some + // garbage collection. + // + // Thankfully, since closing a leaf changes focus to another leaf, + // Obsidian triggers a `file-open` event. So we can do this dirty + // work here. + this.garbageCollectOpenedFiles(); + + // If we need to keep the number of opened files under a maximum, + // do it now. + this.closeExcessFiles(); + + if (this.view) { + this.view.redraw(); + } + } + + private garbageCollectOpenedFiles() { + var openedFiles = this.data.openedFiles; + var validIndices = Array.from( + {length: openedFiles.length}, (v, i) => false); + + // Search all workspace leaves to figure out which opened files + // are currently visible in a markdown editor. + this.app.workspace.iterateRootLeaves((leaf) => { + if (!(leaf.view instanceof FileView)) { + return; + } + + const filePath = leaf.view.file.path; + const existingIndex = this.data.openedFiles.findIndex( + curFile => curFile.path == filePath + ); + if (existingIndex >= 0) { + validIndices[existingIndex] = true; + } + }); + + // Anything that didn't have a markdown editor is leaking their + // CodeMirror plugin, so let's clear that. + for (var i = validIndices.length - 1; i >= 0; --i) { + if (validIndices[i]) { + continue; + } + if (openedFiles[i].cmPlugin) { + console.debug("Removing garbage CodeMirror plugin:", + i, openedFiles[i].path); + openedFiles[i].cmPlugin = null; + } + } + + var numCmPlugins = 0; + openedFiles.forEach((curFile) => { + if (curFile.cmPlugin) { + ++numCmPlugins; + } + }); + console.debug("Opened files:", this.data.openedFiles.length); + console.debug("Files still hooked to CodeMirror:", numCmPlugins); + } + + private closeExcessFiles() { + const keepMax = this.settings.keepMaxOpenFiles; + if (keepMax <= 0) { + return; + } + + this.data.openedFiles.sort((a, b) => a.lastOpenedTime < b.lastOpenedTime); + + if (this.data.openedFiles.length > keepMax) { + this.data.openedFiles.splice(keepMax); + console.debug("Closing files to keep under:", keepMax); + } + } + + private readonly onFileRename = async ( + file: TAbstractFile, + oldPath: string, + ): Promise => { + const existingFile = this.data.openedFiles.find( + (curFile) => curFile.path === oldPath + ); + if (existingFile) { + existingFile.basename = file.basename; + existingFile.path = file.path; + existingFile.extension = file.extension; + + if (this.view) { + this.view.redraw(); + } + } + }; + + private readonly onFileDelete = async ( + file: TAbstractFile, + ): Promise => { + const previousLength = this.data.openedFiles.length; + this.data.openedFiles = this.data.openedFiles.filter( + (curFile) => curFile.path !== file.path + ); + + if (this.view && previousLength != this.data.openedFiles.length) { + this.view.redraw(); + } + }; +} + diff --git a/src/panes.ts b/src/panes.ts new file mode 100644 --- /dev/null +++ b/src/panes.ts @@ -0,0 +1,196 @@ +import { + FilePath, + FileView, + ItemView, + Menu, + WorkspaceLeaf, +} from 'obsidian'; + +import { + OpenedFilesData, + OpenedFilesPlugin +} from './main'; + + +// View type for the opened files pane. +export const OpenedFilesListViewType = 'opened-files'; + +// Opened files pane. +export class OpenedFilesListView extends ItemView { + private readonly plugin: OpenedFilesPlugin; + private data: OpenedFilesData; + + constructor( + leaf: WorkspaceLeaf, + plugin: OpenedFilesPlugin, + data: OpenedFilesData, + ) { + super(leaf); + + this.plugin = plugin; + this.data = data; + this.redraw(); + } + + public getViewType(): string { + return OpenedFilesListViewType; + } + + public getDisplayText(): string { + return 'Opened Files'; + } + + public getIcon(): string { + return 'sheets-in-box'; + } + + public onHeaderMenu(menu: Menu): void { + menu + .addItem((item) => { + item + .setTitle('Clear list') + .setIcon('sweep') + .onClick(async () => { + this.data.openedFiles = []; + this.redraw(); + }); + }) + .addItem((item) => { + item + .setTitle('Close') + .setIcon('cross') + .onClick(() => { + this.app.workspace.detachLeavesOfType(OpenedFilesListViewType); + }); + }); + } + + public readonly redraw = (): void => { + const openFile = this.app.workspace.getActiveFile(); + + const rootEl = createDiv({cls: 'nav-folder mod-root opened-files-pane'}); + const childrenEl = rootEl.createDiv({cls: 'nav-folder-children'}); + + this.data.openedFiles.forEach((curFile) => { + const navFile = childrenEl.createDiv({cls: 'nav-file'}); + const navFileTitle = navFile.createDiv({cls: 'nav-file-title'}); + + if (openFile && curFile.path === openFile.path) { + navFileTitle.addClass('is-active'); + } + + navFileTitle.createDiv({ + cls: 'nav-file-title-content', + text: curFile.basename, + }); + + const navFileClose = navFileTitle.createDiv( + {cls: 'nav-file-close'}); + const navFileCloseLink = navFileClose.createEl( + 'a', {cls: 'view-action mod-close-file', 'aria-label': 'Close'}); + navFileCloseLink.innerHTML = SVG_CLOSE_ICON; + + navFile.setAttr('draggable', 'true'); + navFile.addEventListener('dragstart', (event: DragEvent) => { + const file = this.app.metadataCache.getFirstLinkpathDest( + curFile.path, ''); + const dragManager = this.app.dragManager; + const dragData = dragManager.dragFile(event, file); + dragManager.onDragStart(event, dragData); + }); + + navFile.addEventListener('mouseover', (event: MouseEvent) => { + this.app.workspace.trigger('hover-link', { + event, + source: OpenedFilesListViewType, + hoverParent: rootEl, + targetEl: navFile, + linktext: curFile.path, + }); + }); + + navFile.addEventListener('contextmenu', (event: MouseEvent) => { + const menu = new Menu(this.app); + const file = this.app.vault.getAbstractFileByPath(curFile.path); + this.app.workspace.trigger( + 'file-menu', + menu, + file, + 'link-context-menu', + this.leaf, + ); + menu.showAtPosition({x: event.clientX, y: event.clientY}); + }); + + navFile.addEventListener('click', (event: MouseEvent) => { + this.openFile(curFile, event.ctrlKey || event.metaKey); + }); + + navFileCloseLink.addEventListener('click', (event: MouseEvent) => { + // Don't propagate this event to the parent, because the + // parent div handles the click event as opening the file + // we want to close! + event.stopPropagation(); + + this.closeFile(curFile); + }); + }); + + const contentEl = this.containerEl.children[1]; + contentEl.empty(); + contentEl.appendChild(rootEl); + }; + + private readonly openFile = (file: FilePath, shouldSplit = false): void => { + const targetFile = this.app.vault + .getFiles() + .find((f) => f.path === file.path); + + if (targetFile) { + let leaf = this.app.workspace.getMostRecentLeaf(); + + const createLeaf = shouldSplit || leaf.getViewState().pinned; + if (createLeaf) { + leaf = this.app.workspace.createLeafBySplit(leaf); + } + leaf.openFile(targetFile); + } else { + new Notice('Cannot find a file with that name'); + this.data.openedFiles = this.data.openedFiles.filter( + (fp) => fp.path !== file.path, + ); + this.redraw(); + } + }; + + private readonly closeFile = (file: FilePath): void => { + var existingIndex = this.data.openedFiles.findIndex( + (curFile) => curFile.path == file.path + ); + if (existingIndex >= 0) { + console.debug("Closing file:", file.path); + this.data.openedFiles.splice(existingIndex, 1); + } + + var leavesToClose = []; + this.app.workspace.iterateRootLeaves((leaf: WorkspaceLeaf) => { + if (!(leaf.view instanceof FileView)) { + return; + } + + const filePath = leaf.view.file.path; + if (filePath == file.path) { + leavesToClose.push(leaf); + } + }); + console.debug(`Closing ${leavesToClose.length} leaves for file:`, file.path); + leavesToClose.forEach((leaf) => { + leaf.detach(); + }); + + this.redraw(); + }; +} + +const SVG_CLOSE_ICON = ''; + diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,45 @@ +import { + PluginSettingTab, + Setting +} from 'obsidian'; + +import { + OpenedFilesPlugin +} from './main' + +export interface OpenedFilesPluginSettings { + keepMaxOpenFiles: number; +} + +export const DEFAULT_SETTINGS: OpenedFilesPluginSettings = { + keepMaxOpenFiles: 0 +} + +export class OpenedFilesPluginSettingTab extends PluginSettingTab { + plugin: OpenedFilesPlugin; + + constructor(app: App, plugin: OpenedFilesPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const {containerEl} = this; + + containerEl.empty(); + + new Setting(containerEl) + .setName('Keep maximum open files') + .setDesc('How many files to keep open at most ' + + '(set to 0 to keep all files open until explicitly closed)') + .addText(text => text + .setValue(this.plugin.settings.keepMaxOpenFiles?.toString())) + .onChange(async (value) => { + const intValue = parseInt(value); + if (!isNaN(intValue)) { + this.plugin.settings.keepMaxOpenFiles = intValue; + await this.plugin.saveSettings(); + } + }); + } +} diff --git a/styles.css b/styles.css new file mode 100755 --- /dev/null +++ b/styles.css @@ -0,0 +1,25 @@ + +/* Capsule showing 'open' in the open file dialog. + * TODO: don't hard code the colors */ +.opened-file-capsule { + margin: 0 1em; + padding: 0.2em 1em; + font-size: 0.75em; + color: gray; + border: 1px solid #333; + border-style: solid; + border-radius: 1em; + background: #333; +} + +/* Close button in the opened files pane. */ +.opened-files-pane .nav-file-close { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.opened-files-pane .view-action>svg { + vertical-align: text-bottom; +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100755 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ] + }, + "include": [ + "**/*.ts" + ] +} diff --git a/versions.json b/versions.json new file mode 100755 --- /dev/null +++ b/versions.json @@ -0,0 +1,4 @@ +{ + "1.0.1": "0.9.12", + "1.0.0": "0.9.7" +}