13 files changed, 911 insertions(+), 0 deletions(-)

A => .hgignore
A => README.md
A => esbuild.config.mjs
A => manifest.json
A => package.json
A => src/cmplugin.ts
A => src/fileopenpatch.ts
A => src/main.ts
A => src/panes.ts
A => src/settings.ts
A => styles.css
A => tsconfig.json
A => versions.json
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"
+}