├── .npmrc ├── styles.css ├── .eslintignore ├── versions.json ├── manifest.json ├── .gitignore ├── .hgignore ├── tsconfig.json ├── .hgtags ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE.txt ├── README.md ├── src ├── settings.ts └── main.ts └── esbuild.config.mjs /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* No styles */ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build 3 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.13.17", 3 | "1.0.1": "0.13.17", 4 | "1.0.2": "0.13.17", 5 | "1.0.3": "0.13.17", 6 | "1.0.4": "0.13.17", 7 | "1.0.5": "0.13.17", 8 | "1.0.6": "0.15.0", 9 | "1.0.7": "1.4.0", 10 | "1.1.0": "1.4.0", 11 | "1.1.1": "1.4.0", 12 | "1.1.2": "1.5.0" 13 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-remember-file-state", 3 | "name": "Remember File State", 4 | "version": "1.1.2", 5 | "minAppVersion": "1.5.0", 6 | "description": "Remembers cursor position, selection, scrolling, and more for each file", 7 | "author": "Ludovic Chabant", 8 | "authorUrl": "https://ludovic.chabant.com", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | package-lock.json 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # Exclude macOS Finder (System Explorer) View States 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | 3 | # vscode 4 | .vscode 5 | 6 | # Intellij 7 | *.iml 8 | .idea 9 | 10 | # npm 11 | node_modules 12 | package-lock.json 13 | 14 | # Don't include the compiled main.js file in the repo. 15 | # They should be uploaded to GitHub releases instead. 16 | main.js 17 | 18 | # Exclude sourcemaps 19 | *.map 20 | 21 | # obsidian 22 | data.json 23 | 24 | # Exclude macOS Finder (System Explorer) View States 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "lib": [ 14 | "DOM", 15 | "ES5", 16 | "ES6", 17 | "ES7" 18 | ] 19 | }, 20 | "include": [ 21 | "**/*.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | 7975d7c73f8ae5f6ac176ae908c48615512deb60 1.0.0 2 | f669172572a6068f7e184727830cc90bb71db079 1.0.1 3 | d2741019e3b9317d9c00a33f1c23010e90f72a62 1.0.2 4 | 67468d40c903ec4c02d79dcd4be6720510633f75 1.0.3 5 | a50ef39473b6ce16cf4656bea8aefbcb6142a27f 1.0.4 6 | 6c168de105c0c23c8ef1f4a765fba03f817eb823 1.0.5 7 | 88c1c125e621a901a015e75479aaad59d507a34f 1.0.6 8 | a371a2001e95104b89183ed3a76386b6eb3baab7 1.0.7 9 | 712761e7625bbc34d757346a7b075641f700afe9 1.1.0 10 | 3cc1967cd12268954853db14a3461d662573713a 1.1.1 11 | 1d6fe880946f7836a3f6c1ff6eb973387addd737 1.1.2 12 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | console.log(`Bumping version to ${targetVersion}`); 5 | 6 | // read minAppVersion from manifest.json and bump version to target version 7 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 8 | const { minAppVersion } = manifest; 9 | manifest.version = targetVersion; 10 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 11 | 12 | // update versions.json with target version and minAppVersion from manifest.json 13 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 14 | versions[targetVersion] = minAppVersion; 15 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 16 | 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-remember-file-state", 3 | "version": "1.1.2", 4 | "description": "Plugin for Obsidian that remembers cursor position, selection, scrolling, and more for each file", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "dogfood": "node esbuild.config.mjs dogfood", 10 | "version": "node version-bump.mjs" 11 | }, 12 | "keywords": [], 13 | "author": "Ludovic Chabant", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@codemirror/history": "^0.19.0", 17 | "@codemirror/state": "^6.0.0", 18 | "@codemirror/view": "^6.0.0", 19 | "@types/node": "^16.11.6", 20 | "@typescript-eslint/eslint-plugin": "^5.29.0", 21 | "@typescript-eslint/parser": "^5.29.0", 22 | "builtin-modules": "^3.3.0", 23 | "esbuild": "0.17.3", 24 | "monkey-around": "^2.2.0", 25 | "obsidian": "latest", 26 | "tslib": "2.4.0", 27 | "typescript": "4.7.4" 28 | }, 29 | "dependencies": {} 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remember File State 2 | 3 | This [Obsidian](https://obsidian.md) plugin remembers the editor state of files 4 | as you switch between them. It restores the cursor position and scrolling 5 | position. By default, it also remembers these states across sessions by saving 6 | the data to disk. 7 | 8 | This plugin doesn't do any polling and doesn't register any timers. It strives 9 | to only do work when opening and closing files in order to not slow down the 10 | editing experience. 11 | 12 | 13 | ## Developer Quickstart 14 | 15 | My own workflow for working on this plugin is the following: 16 | 17 | 1. Install the "_Remember File State_" plugin in a test vault. Don't forget to 18 | enable it. 19 | 2. Clone the `obsidian-remember-file-state` repository. 20 | 3. Run the usual incantations, such as `npm install`. 21 | 4. Run the build process in watch mode so that it compiles the TypeScript code 22 | and overwrites the test vault's plugin: `npm run dogfood 23 | /path/to/vault/.obsidian/plugins/obsidian-remember-file-state`. 24 | 5. When making changes, trigger the "_Reload App Without Saving_" command to 25 | reload Obsidian. 26 | 6. Optionally, hit `Ctrl-Shift-I` to open the developer console and see the 27 | console log. 28 | 29 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | PluginSettingTab, 4 | Setting 5 | } from 'obsidian'; 6 | 7 | import RememberFileStatePlugin from './main'; 8 | 9 | export interface RememberFileStatePluginSettings { 10 | rememberMaxFiles: number; 11 | persistStates: boolean; 12 | } 13 | 14 | export const DEFAULT_SETTINGS: RememberFileStatePluginSettings = { 15 | // Matches the number of files Obsidian remembers the undo/redo 16 | // history for by default (at least as of 0.13.17). 17 | rememberMaxFiles: 20, 18 | persistStates: true 19 | } 20 | 21 | export class RememberFileStatePluginSettingTab extends PluginSettingTab { 22 | plugin: RememberFileStatePlugin; 23 | 24 | constructor(app: App, plugin: RememberFileStatePlugin) { 25 | super(app, plugin); 26 | this.plugin = plugin; 27 | } 28 | 29 | display(): void { 30 | const {containerEl} = this; 31 | 32 | containerEl.empty(); 33 | 34 | new Setting(containerEl) 35 | .setName('Remember files') 36 | .setDesc('How many files to remember at most') 37 | .addText(text => text 38 | .setValue(this.plugin.settings.rememberMaxFiles?.toString()) 39 | .onChange(async (value: string) => { 40 | const intValue = parseInt(value); 41 | if (!isNaN(intValue)) { 42 | this.plugin.settings.rememberMaxFiles = intValue; 43 | await this.plugin.saveSettings(); 44 | } 45 | })); 46 | 47 | new Setting(containerEl) 48 | .setName('Save states') 49 | .setDesc('Whether to save the state of all open files to disk') 50 | .addToggle(toggle => toggle 51 | .setValue(this.plugin.settings.persistStates) 52 | .onChange(async (value: boolean) => { 53 | this.plugin.settings.persistStates = value; 54 | await this.plugin.saveSettings(); 55 | })); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | 9 | If you want to view the source, please visit one of the following: 10 | - https://hg.bolt80.com/obsidian-remember-file-state 11 | - https://hg.sr.ht/~ludovicchabant/obsidian-remember-file-state 12 | - https://github.com/ludovicchabant/obsidian-remember-file-state 13 | */ 14 | `; 15 | 16 | const prod = (process.argv[2] === 'production'); 17 | const dogfood = (process.argv[2] === 'dogfood'); 18 | 19 | var outdir = (dogfood ? process.argv[3] : ''); 20 | if (outdir != undefined && outdir != '') { 21 | if (outdir.slice(-1) != '/' && outdir.slice(-1) != "\\") { 22 | outdir += '/'; 23 | } 24 | } else if (dogfood) { 25 | throw("Please provide an output directory to put the dog food into"); 26 | } 27 | 28 | const outfile = outdir + 'main.js'; 29 | 30 | const context = await esbuild.context({ 31 | banner: { 32 | js: banner, 33 | }, 34 | entryPoints: ['src/main.ts'], 35 | bundle: true, 36 | external: [ 37 | 'obsidian', 38 | 'electron', 39 | '@codemirror/autocomplete', 40 | '@codemirror/closebrackets', 41 | '@codemirror/collab', 42 | '@codemirror/commands', 43 | '@codemirror/comment', 44 | '@codemirror/fold', 45 | '@codemirror/gutter', 46 | '@codemirror/highlight', 47 | '@codemirror/history', 48 | '@codemirror/language', 49 | '@codemirror/lint', 50 | '@codemirror/matchbrackets', 51 | '@codemirror/panel', 52 | '@codemirror/rangeset', 53 | '@codemirror/rectangular-selection', 54 | '@codemirror/search', 55 | '@codemirror/state', 56 | '@codemirror/stream-parser', 57 | '@codemirror/text', 58 | '@codemirror/tooltip', 59 | '@codemirror/view', 60 | ...builtins], 61 | format: 'cjs', 62 | target: 'es2018', 63 | logLevel: "info", 64 | sourcemap: prod ? false : 'inline', 65 | treeShaking: true, 66 | outfile: outfile, 67 | }); 68 | 69 | if (prod) { 70 | await context.rebuild(); 71 | process.exit(0); 72 | } else { 73 | await context.watch(); 74 | } 75 | 76 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | 5 | import { 6 | App, 7 | Editor, 8 | MarkdownView, 9 | Modal, 10 | OpenViewState, 11 | Plugin, 12 | TAbstractFile, 13 | TFile, 14 | Tasks, 15 | View, 16 | WorkspaceLeaf 17 | } from 'obsidian'; 18 | 19 | import { 20 | EditorView 21 | } from '@codemirror/view'; 22 | 23 | import { 24 | EditorState, 25 | EditorSelection 26 | } from '@codemirror/state'; 27 | 28 | import { 29 | around 30 | } from 'monkey-around'; 31 | 32 | import { 33 | DEFAULT_SETTINGS, 34 | RememberFileStatePluginSettings, 35 | RememberFileStatePluginSettingTab 36 | } from './settings'; 37 | 38 | declare var app: App; 39 | 40 | // Interface for CM6 editor view 41 | interface EditorWithCM6 extends Editor { 42 | cm: EditorView 43 | }; 44 | 45 | // View with unique ID 46 | interface ViewWithID extends View { 47 | __uniqueId: number 48 | }; 49 | 50 | // Scroll info interface 51 | interface ScrollInfo { 52 | top: number, left: number 53 | }; 54 | 55 | interface StateData { 56 | selection: EditorSelection, 57 | scrollInfo: ScrollInfo 58 | }; 59 | 60 | // Interface for a file state. 61 | interface RememberedFileState { 62 | path: string; 63 | lastSavedTime: number; 64 | stateData: StateData; 65 | } 66 | 67 | // Interface for all currently remembered file states. 68 | interface RememberFileStatePluginData { 69 | rememberedFiles: Record; 70 | } 71 | 72 | // Default empty list of remembered file states. 73 | const DEFAULT_DATA: RememberFileStatePluginData = { 74 | rememberedFiles: {} 75 | }; 76 | 77 | // Where to save the states database. 78 | const STATE_DB_PATH: string = '.obsidian/plugins/obsidian-remember-file-state/states.json'; 79 | 80 | // Simple warning message. 81 | class WarningModal extends Modal { 82 | title: string = ""; 83 | message: string = ""; 84 | 85 | constructor(app: App, title: string, message: string) { 86 | super(app) 87 | this.title = title; 88 | this.message = message; 89 | } 90 | onOpen() { 91 | this.contentEl.createEl('h2', {text: this.title}); 92 | this.contentEl.createEl('p', {text: this.message}); 93 | } 94 | }; 95 | 96 | export default class RememberFileStatePlugin extends Plugin { 97 | settings: RememberFileStatePluginSettings; 98 | data: RememberFileStatePluginData; 99 | 100 | // Don't restore state on the next file being opened. 101 | private _suppressNextFileOpen: boolean = false; 102 | // Next unique ID to identify views without keeping references to them. 103 | private _nextUniqueViewId: number = 0; 104 | 105 | // Remember last open file in each view. 106 | private _lastOpenFiles: Record = {}; 107 | 108 | // Functions to unregister any monkey-patched view hooks on plugin unload. 109 | private _viewUninstallers: Record = {}; 110 | // Functions to unregister any global callbacks on plugin unload. 111 | private _globalUninstallers: Function[] = []; 112 | 113 | async onload() { 114 | // Enable this for troubleshooting. 115 | const enableLogfile: boolean = false; 116 | if (enableLogfile) { 117 | const outLogPath = path.join(os.tmpdir(), 'obsidian-remember-file-state.log'); 118 | this.setupLogFile(outLogPath); 119 | } 120 | 121 | console.log("RememberFileState: loading plugin"); 122 | 123 | await this.loadSettings(); 124 | 125 | this.data = Object.assign({}, DEFAULT_DATA); 126 | 127 | await this.readStateDatabase(STATE_DB_PATH); 128 | 129 | this.registerEvent(this.app.workspace.on('file-open', this.onFileOpen, this)); 130 | this.registerEvent(this.app.workspace.on('quit', this.onAppQuit, this)); 131 | this.registerEvent(this.app.vault.on('rename', this.onFileRename, this)); 132 | this.registerEvent(this.app.vault.on('delete', this.onFileDelete, this)); 133 | 134 | this.app.workspace.onLayoutReady(() => { this.onLayoutReady(); }); 135 | 136 | const _this = this; 137 | var uninstall = around(this.app.workspace, { 138 | openLinkText: function(next) { 139 | return async function( 140 | linktext: string, sourcePath: string, 141 | newLeaf?: boolean, openViewState?: OpenViewState) { 142 | // When opening a link, we don't want to restore the 143 | // scroll position/selection/etc because there's a 144 | // good chance we want to show the file back at the 145 | // top, or we're going straight to a specific block. 146 | _this._suppressNextFileOpen = true; 147 | return await next.call( 148 | this, linktext, sourcePath, newLeaf, openViewState); 149 | }; 150 | } 151 | }); 152 | this._globalUninstallers.push(uninstall); 153 | 154 | this.addSettingTab(new RememberFileStatePluginSettingTab(this.app, this)); 155 | } 156 | 157 | onunload() { 158 | console.log("RememberFileState: unloading plugin"); 159 | 160 | // Unregister unload callbacks on all views. 161 | this.unregisterAllViews(); 162 | 163 | // Forget which files are opened in which views. 164 | this._lastOpenFiles = {}; 165 | 166 | // Run global unhooks. 167 | this._globalUninstallers.forEach((cb) => cb()); 168 | } 169 | 170 | async loadSettings() { 171 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 172 | } 173 | 174 | async saveSettings() { 175 | await this.saveData(this.settings); 176 | } 177 | 178 | private readonly onLayoutReady = function() { 179 | this.app.workspace.getLeavesOfType("markdown").forEach( 180 | (leaf: WorkspaceLeaf) => { 181 | var view = leaf.view as MarkdownView; 182 | 183 | // On startup, assign unique IDs to views and register the 184 | // unload callback to remember their state. 185 | this.registerOnUnloadFile(view); 186 | 187 | // Also remember which file is opened in which view. 188 | const viewId = this.getUniqueViewId(view as unknown as ViewWithID); 189 | if (viewId != undefined) { 190 | this._lastOpenFiles[viewId] = view.file.path; 191 | } 192 | 193 | // Restore state for each opened pane on startup. 194 | const existingFile = this.data.rememberedFiles[view.file.path]; 195 | if (existingFile) { 196 | const savedStateData = existingFile.stateData; 197 | console.debug("RememberFileState: restoring saved state for:", view.file.path, savedStateData); 198 | this.restoreState(savedStateData, view); 199 | } 200 | }); 201 | } 202 | 203 | private readonly registerOnUnloadFile = function(view: MarkdownView) { 204 | var filePath = view.file.path; 205 | var viewId = this.getUniqueViewId(view as unknown as ViewWithID, true); 206 | if (viewId in this._viewUninstallers) { 207 | return; 208 | } 209 | 210 | console.debug(`RememberFileState: registering callback on view ${viewId}`, filePath); 211 | const _this = this; 212 | var uninstall = around(view, { 213 | onUnloadFile: function(next) { 214 | return async function (unloaded: TFile) { 215 | _this.rememberFileState(this, unloaded); 216 | return await next.call(this, unloaded); 217 | }; 218 | } 219 | }); 220 | this._viewUninstallers[viewId] = uninstall; 221 | 222 | view.register(() => { 223 | // Don't hold a reference to this plugin here because this callback 224 | // will outlive it if it gets deactivated. So let's find it, and 225 | // do nothing if we don't find it. 226 | // @ts-ignore 227 | var plugin: RememberFileStatePlugin = app.plugins.getPlugin("obsidian-remember-file-state"); 228 | if (plugin) { 229 | console.debug(`RememberFileState: unregistering view ${viewId} callback`, filePath); 230 | delete plugin._viewUninstallers[viewId]; 231 | delete plugin._lastOpenFiles[viewId]; 232 | uninstall(); 233 | } else { 234 | console.debug( 235 | "RememberFileState: plugin was unloaded, ignoring unregister"); 236 | } 237 | }); 238 | } 239 | 240 | private readonly unregisterAllViews = function() { 241 | // Run view uninstallers on all current views. 242 | var numViews: number = 0; 243 | this.app.workspace.getLeavesOfType("markdown").forEach( 244 | (leaf: WorkspaceLeaf) => { 245 | const filePath = (leaf.view as MarkdownView).file.path; 246 | const viewId = this.getUniqueViewId(leaf.view as ViewWithID); 247 | if (viewId != undefined) { 248 | var uninstaller = this._viewUninstallers[viewId]; 249 | if (uninstaller) { 250 | console.debug(`RememberFileState: uninstalling hooks for view ${viewId}`, filePath); 251 | uninstaller(leaf.view); 252 | ++numViews; 253 | } else { 254 | console.debug("RememberFileState: found markdown view without an uninstaller!", filePath); 255 | } 256 | // Clear the ID so we don't get confused if the plugin 257 | // is re-enabled later. 258 | this.clearUniqueViewId(leaf.view as ViewWithID); 259 | } else { 260 | console.debug("RememberFileState: found markdown view without an ID!", filePath); 261 | } 262 | }); 263 | console.debug(`RememberFileState: unregistered ${numViews} view callbacks`); 264 | this._viewUninstallers = {}; 265 | } 266 | 267 | private readonly onFileOpen = async ( 268 | openedFile: TFile 269 | ): Promise => { 270 | // If `openedFile` is null, it's because the last pane was closed 271 | // and there is now an empty pane. 272 | if (!openedFile) { 273 | return; 274 | } 275 | 276 | var shouldSuppressThis: boolean = this._suppressNextFileOpen; 277 | this._suppressNextFileOpen = false; 278 | if (shouldSuppressThis) { 279 | console.debug("RememberFileState: not restoring file state because of explicit suppression"); 280 | return; 281 | } 282 | 283 | // Check that the file is handled by a markdown editor, which is the 284 | // only editor we support for now. 285 | var activeView: MarkdownView = this.app.workspace.getActiveViewOfType(MarkdownView); 286 | if (!activeView) { 287 | console.debug("RememberFileState: not restoring file state, it's not a markdown view"); 288 | return; 289 | } 290 | 291 | this.registerOnUnloadFile(activeView); 292 | 293 | // Check if this is a genuine file open, and not returning to pane that 294 | // already had this file opened in it. 295 | var isRealFileOpen = true; 296 | const viewId = this.getUniqueViewId(activeView as unknown as ViewWithID); 297 | if (viewId != undefined) { 298 | const lastOpenFileInView = this._lastOpenFiles[viewId]; 299 | isRealFileOpen = (lastOpenFileInView != openedFile.path); 300 | this._lastOpenFiles[viewId] = openedFile.path; 301 | } 302 | if (!isRealFileOpen) { 303 | console.debug("RememberFileState: not restoring file state, that file was already open in this pane."); 304 | return; 305 | } 306 | 307 | // Restore the state! 308 | try { 309 | const existingFile = this.data.rememberedFiles[openedFile.path]; 310 | if (existingFile) { 311 | const savedStateData = existingFile.stateData; 312 | console.debug("RememberFileState: restoring saved state for:", openedFile.path, savedStateData); 313 | this.restoreState(savedStateData, activeView); 314 | } else { 315 | // If we don't have any saved state for this file, let's see if 316 | // it's opened in another pane. If so, restore that. 317 | const otherPaneState = this.findFileStateFromOtherPane(openedFile, activeView); 318 | if (otherPaneState) { 319 | console.debug("RememberFileState: restoring other pane state for:", openedFile.path, otherPaneState); 320 | this.restoreState(otherPaneState, activeView); 321 | } 322 | } 323 | } catch (err) { 324 | console.error("RememberFileState: couldn't restore file state: ", err); 325 | } 326 | } 327 | 328 | private readonly rememberFileState = async (view: MarkdownView, file?: TFile): Promise => { 329 | const stateData = this.getState(view); 330 | 331 | if (file === undefined) { 332 | file = view.file; 333 | } 334 | var existingFile = this.data.rememberedFiles[file.path]; 335 | if (existingFile) { 336 | existingFile.lastSavedTime = Date.now(); 337 | existingFile.stateData = stateData; 338 | } else { 339 | let newFileState = { 340 | path: file.path, 341 | lastSavedTime: Date.now(), 342 | stateData: stateData 343 | }; 344 | this.data.rememberedFiles[file.path] = newFileState; 345 | 346 | // If we need to keep the number of remembered files under a maximum, 347 | // do it now. 348 | this.forgetExcessFiles(); 349 | } 350 | console.debug("RememberFileState: remembered state for:", file.path, stateData); 351 | } 352 | 353 | private readonly getState = function(view: MarkdownView) { 354 | // Save scrolling position (Obsidian API only gives vertical position). 355 | const scrollInfo = {top: view.currentMode.getScroll(), left: 0}; 356 | 357 | // Save current selection. CodeMirror returns a JSON object (not a 358 | // JSON string!) when we call toJSON. 359 | const cm6editor = view.editor as EditorWithCM6; 360 | const stateSelection: EditorSelection = cm6editor.cm.state.selection; 361 | const stateSelectionJSON = stateSelection.toJSON(); 362 | 363 | const stateData = {'scrollInfo': scrollInfo, 'selection': stateSelectionJSON}; 364 | 365 | return stateData; 366 | } 367 | 368 | private readonly restoreState = function(stateData: StateData, view: MarkdownView) { 369 | // Restore scrolling position (Obsidian API only allows setting vertical position). 370 | view.currentMode.applyScroll(stateData.scrollInfo.top); 371 | 372 | // Restore last known selection, if any. 373 | if (stateData.selection !== undefined) { 374 | const cm6editor = view.editor as EditorWithCM6; 375 | var transaction = cm6editor.cm.state.update({ 376 | selection: EditorSelection.fromJSON(stateData.selection)}) 377 | 378 | cm6editor.cm.dispatch(transaction); 379 | } 380 | } 381 | 382 | private readonly findFileStateFromOtherPane = function(file: TFile, activeView: MarkdownView) { 383 | var otherView = null; 384 | this.app.workspace.getLeavesOfType("markdown").every( 385 | (leaf: WorkspaceLeaf) => { 386 | var curView = leaf.view as MarkdownView; 387 | if (curView != activeView && 388 | curView.file.path == file.path && 389 | this.getUniqueViewId(curView) >= 0 // Skip views that have never been activated. 390 | ) { 391 | otherView = curView; 392 | return false; // Stop iterating leaves. 393 | } 394 | return true; 395 | }, 396 | this // thisArg 397 | ); 398 | return otherView ? this.getState(otherView) : null; 399 | } 400 | 401 | private readonly forgetExcessFiles = function() { 402 | const keepMax = this.settings.rememberMaxFiles; 403 | if (keepMax <= 0) { 404 | return; 405 | } 406 | 407 | // Sort newer files first, older files last. 408 | var filesData: RememberedFileState[] = Object.values(this.data.rememberedFiles); 409 | filesData.sort((a, b) => { 410 | if (a.lastSavedTime > b.lastSavedTime) return -1; // a before b 411 | if (a.lastSavedTime < b.lastSavedTime) return 1; // b before a 412 | return 0; 413 | }); 414 | 415 | // Remove older files past the limit. 416 | for (var i = keepMax; i < filesData.length; ++i) { 417 | var fileData = filesData[i]; 418 | delete this.data.rememberedFiles[fileData.path]; 419 | } 420 | } 421 | 422 | private readonly getUniqueViewId = function(view: ViewWithID, autocreateId: boolean = false) { 423 | if (view.__uniqueId == undefined) { 424 | if (!autocreateId) { 425 | return -1; 426 | } 427 | view.__uniqueId = (this._nextUniqueViewId++); 428 | return view.__uniqueId; 429 | } 430 | return view.__uniqueId; 431 | } 432 | 433 | private readonly clearUniqueViewId = function(view: ViewWithID) { 434 | delete view["__uniqueId"]; 435 | } 436 | 437 | private readonly onFileRename = async ( 438 | file: TAbstractFile, 439 | oldPath: string, 440 | ): Promise => { 441 | const existingFile: RememberedFileState = this.data.rememberedFiles[oldPath]; 442 | if (existingFile) { 443 | existingFile.path = file.path; 444 | delete this.data.rememberedFiles[oldPath]; 445 | this.data.rememberedFiles[file.path] = existingFile; 446 | } 447 | }; 448 | 449 | private readonly onFileDelete = async ( 450 | file: TAbstractFile, 451 | ): Promise => { 452 | delete this.data.rememberedFiles[file.path]; 453 | }; 454 | 455 | private readonly onAppQuit = function(tasks: Tasks) { 456 | this.unregisterAllViews(); 457 | this.rememberAllOpenedFileStates(); 458 | this.writeStateDatabase(STATE_DB_PATH); 459 | console.log("RememberFileState: done with app-quit cleanup."); 460 | } 461 | 462 | private readonly rememberAllOpenedFileStates = function() { 463 | console.log("RememberFileState: remembering all opened file states..."); 464 | this.app.workspace.getLeavesOfType("markdown").forEach( 465 | (leaf: WorkspaceLeaf) => { 466 | const view = leaf.view as MarkdownView; 467 | this.rememberFileState(view); 468 | } 469 | ); 470 | } 471 | 472 | private readonly writeStateDatabase = function(path: string) { 473 | const fs = this.app.vault.adapter; 474 | const jsonDb = JSON.stringify(this.data); 475 | console.log("RememberFileState: writing state database..."); 476 | fs.write(path, jsonDb) 477 | .then(() => { console.log("RememberFileState: wrote state database."); }); 478 | } 479 | 480 | private readonly readStateDatabase = async function(path: string): Promise { 481 | const fs = this.app.vault.adapter; 482 | if (await fs.exists(path)) { 483 | const jsonDb = await fs.read(path); 484 | try 485 | { 486 | this.data = JSON.parse(jsonDb); 487 | const numLoaded = Object.keys(this.data.rememberedFiles).length; 488 | console.debug(`RememberFileState: read ${numLoaded} record from state database.`); 489 | } catch (err) { 490 | console.error("RememberFileState: error loading state database:", err); 491 | console.error(jsonDb); 492 | } 493 | } 494 | } 495 | 496 | private readonly setupLogFile = function(outLogPath: string) { 497 | console.log("RememberFileState: setting up log file: ", outLogPath); 498 | 499 | const makeWrapper = function(origFunc: () => void) { 500 | return function () { 501 | origFunc.apply(console, arguments); 502 | 503 | var text: string = ""; 504 | for (var i: number = 0; i < arguments.length; i++) { 505 | if (i > 0) text += " "; 506 | text += arguments[i].toString(); 507 | } 508 | text += "\n"; 509 | fs.appendFileSync(outLogPath, text); 510 | }; 511 | }; 512 | console.log = makeWrapper(console.log); 513 | console.debug = makeWrapper(console.debug); 514 | console.info = makeWrapper(console.info); 515 | console.warn = makeWrapper(console.warn); 516 | console.error = makeWrapper(console.error); 517 | 518 | const banner: string = "\n\nDebug log start\n===============\n"; 519 | fs.appendFileSync(outLogPath, banner); 520 | } 521 | } 522 | 523 | --------------------------------------------------------------------------------