A => .hgignore +3 -0
@@ 0,0 1,3 @@
+main.js
+node_modules
+package-lock.json
A => README.md +18 -0
@@ 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.
+
A => esbuild.config.mjs +35 -0
@@ 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));
A => manifest.json +10 -0
@@ 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
+}
A => package.json +26 -0
@@ 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"
+ }
+}
A => src/cmplugin.ts +89 -0
@@ 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<OpenedFilesCodeMirrorViewPlugin> | 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;
+ }
+}
+
A => src/fileopenpatch.ts +71 -0
@@ 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]);
+ }
+ }
+ });
+ }
+}
A => src/main.ts +367 -0
@@ 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<void> => {
+ 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<void> => {
+ // 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<void> => {
+ 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<void> => {
+ 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();
+ }
+ };
+}
+
A => src/panes.ts +196 -0
@@ 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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" class="cross" width="16" height="16"><path fill="currentColor" stroke="currentColor" d="M15.4,12.6l-2.9,2.9L47.1,50L12.6,84.6l2.9,2.9L50,52.9l34.6,34.6l2.9-2.9L52.9,50l34.6-34.6l-2.9-2.9L50,47.1L15.4,12.6z "></path></svg>';
+
A => src/settings.ts +45 -0
@@ 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();
+ }
+ });
+ }
+}
A => styles.css +25 -0
@@ 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;
+}
+
A => tsconfig.json +22 -0
@@ 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"
+ ]
+}
A => versions.json +4 -0
@@ 0,0 1,4 @@
+{
+ "1.0.1": "0.9.12",
+ "1.0.0": "0.9.7"
+}