From ec54faa8f9f6efe0833535eed675bf167eed181e Mon Sep 17 00:00:00 2001 From: Jesse Hines Date: Tue, 16 Jun 2026 22:14:03 -0400 Subject: [PATCH] Add VSCode style preview tabs --- src/main.ts | 94 +++++++++++++++++++++++++++++++++++++++---------- src/settings.ts | 19 ++++++++++ src/types.d.ts | 11 ++++-- styles.css | 3 ++ 4 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 styles.css diff --git a/src/main.ts b/src/main.ts index c3b0012..2994053 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { Plugin, Workspace, WorkspaceLeaf, WorkspaceRoot, WorkspaceFloating, View, TFile, PaneType, WorkspaceTabs, - WorkspaceItem, Platform, Keymap, Notice, App, + WorkspaceItem, Platform, Keymap, Notice, App, MarkdownView, } from 'obsidian'; import * as monkeyAround from 'monkey-around'; import { @@ -59,6 +59,7 @@ const OVERRIDES = { same: buildOverride(false, {openInNewTab: false}), allow_duplicate: buildOverride(false, {deduplicateTabs: false}), opposite: buildOverride("tab", {newTabTabGroupPlacement: "opposite"}), + no_preview: buildOverride("tab", {previewTabs: false}), } export default class OpenTabSettingsPlugin extends Plugin { @@ -150,6 +151,19 @@ export default class OpenTabSettingsPlugin extends Plugin { } } })); + + this.registerEvent(this.app.workspace.on("editor-change", (editor, info) => { + if (info instanceof MarkdownView) { + this.setLeafIsPreview(info.leaf, false); + } + })) + + this.register(() => { + this.app.workspace.iterateAllLeaves(l => { + this.setLeafIsPreview(l, false); + delete l.openTabSettings; + }) + }) } registerMonkeyPatches() { @@ -178,8 +192,8 @@ export default class OpenTabSettingsPlugin extends Plugin { // we set these to be used in openFile so we can tell when to deduplicate files. leaf.openTabSettings = { - openMode, override, - openedFrom: activeLeaf?.id, + ...leaf.openTabSettings, + openInfo: { openMode, override, openedFrom: activeLeaf?.id }, } return leaf; @@ -213,9 +227,9 @@ export default class OpenTabSettingsPlugin extends Plugin { // these values are only valid immediately after creating a leaf. We clear them after openFile, // and also clear them here if the leaf somehow gets populated without openFile - if (!isEmptyLeaf(this)) delete this.openTabSettings; + if (!isEmptyLeaf(this)) delete this.openTabSettings?.openInfo; - const {openMode, override, openedFrom} = this.openTabSettings ?? {}; + const {openMode, override, openedFrom} = this.openTabSettings?.openInfo ?? {}; const settings = {...plugin.settings, ...override}; let matches = plugin.findMatchingLeaves(file); @@ -239,7 +253,7 @@ export default class OpenTabSettingsPlugin extends Plugin { ); let match: WorkspaceLeaf|undefined; - if (matches.includes(this)) match = this; + if (matches.includes(this)) match = this; // eslint-disable-line @typescript-eslint/no-this-alias // if the link opened was an internal link, always deduplicate to undo open in new tab. if (!match && isInternalLink && !isSpecialOpen) { match = matches.find(l => l.id == openedFrom)!; @@ -283,7 +297,7 @@ export default class OpenTabSettingsPlugin extends Plugin { } } - delete this.openTabSettings; + delete this.openTabSettings?.openInfo; return result; } @@ -321,8 +335,14 @@ export default class OpenTabSettingsPlugin extends Plugin { async updateSettings(newSettings: Partial) { const settings = {...this.settings, ...newSettings} + if (settings.previewTabs == true && !settings.openInNewTab) { + if (newSettings.previewTabs) throw Error(`Invalid settings: ${JSON.stringify(newSettings)}`) + settings.previewTabs = false; + } + if ( (settings.modClickBehavior == 'same' && !settings.openInNewTab) || + (settings.modClickBehavior == 'no_preview' && !settings.previewTabs) || (settings.modClickBehavior == 'allow_duplicate' && !settings.deduplicateTabs) ) { if (newSettings.modClickBehavior) throw Error(`Invalid settings: ${JSON.stringify(newSettings)}`) @@ -330,6 +350,9 @@ export default class OpenTabSettingsPlugin extends Plugin { } Object.assign(this.settings, settings); + if (!this.settings.previewTabs) { + this.app.workspace.iterateAllLeaves(l => this.setLeafIsPreview(l, false)); + } await this.saveData(this.settings); } @@ -365,6 +388,33 @@ export default class OpenTabSettingsPlugin extends Plugin { return [...tabGroups]; } + private setLeafIsPreview(leaf: WorkspaceLeaf, isPreview: boolean) { + if (leaf.openTabSettings?.isPreview === isPreview) return; + + leaf.openTabSettings = {...leaf.openTabSettings, isPreview}; + leaf.tabHeaderEl.toggleClass("open-tab-settings-is-preview", isPreview); + if (isPreview) { + if (!leaf.openTabSettings.eventCleanup) { + // I've confirmed that the events automatically get cleaned up when the leaf is closed. However we can't + // use this.registerEvent as that prevents leaf garbage collection. So instead add a cleanup function to + // the leaf. We'll call that on plugin disable, and after unpreview of a leaf + const unPreview = () => { this.setLeafIsPreview(leaf, false); }; + leaf.on("pinned-change", unPreview); + leaf.tabHeaderEl.addEventListener("dblclick", unPreview); + leaf.openTabSettings.eventCleanup = () => { + leaf.off('pinned-change', unPreview); + leaf.tabHeaderEl.removeEventListener('dblclick', unPreview); + } + } + // one preview tab per tab group (this shouldn't trigger under normal circumstances, but with empty tabs + // there's a few edge cases where createNewLeaf might end up creating 2 preview tabs in a group) + leaf.parent.children.filter(l => l !== leaf).forEach(l => this.setLeafIsPreview(l, false)); + } else if (leaf.openTabSettings.eventCleanup) { + leaf.openTabSettings.eventCleanup(); + delete leaf.openTabSettings?.eventCleanup; + } + } + /** * Custom variant of the internal workspace.createLeafInTabGroup function that follows our new tab placement logic. * @param focus Whether to focus the new tab. If undefined focus based on focusNewTab config @@ -378,12 +428,6 @@ export default class OpenTabSettingsPlugin extends Plugin { if (!activeLeaf) throw new Error("No tab group found."); const activeTabGroup = activeLeaf.parent; const activeIndex = activeTabGroup.children.indexOf(activeLeaf); - - // This is default Obsidian behavior, if active leaf is empty new tab replaces it instead of making a new one. - if (isEmptyLeaf(activeLeaf) && activeLeaf.canNavigate()) { - return activeLeaf; - } - let group: TabGroup|undefined; let index: number|undefined; @@ -421,13 +465,26 @@ export default class OpenTabSettingsPlugin extends Plugin { } } - let newLeaf: WorkspaceLeaf; - // we re-use empty tabs more aggressively than default Obsidian. If the tab at the new location is empty, re-use - // it instead of creating a new one. + let newLeaf: WorkspaceLeaf|undefined; + + // This is default Obsidian behavior, if active leaf is empty new tab replaces it instead of making a new one. + if (isEmptyLeaf(activeLeaf) && activeLeaf.canNavigate()) { + newLeaf = activeLeaf; + } + const leafToDisplace = group.children[Math.min(index, group.children.length - 1)]; - if (isEmptyLeaf(leafToDisplace) && leafToDisplace.canNavigate()) { + if (!newLeaf && isEmptyLeaf(leafToDisplace) && leafToDisplace.canNavigate()) { + // we re-use empty tabs more aggressively than default Obsidian. If the tab at the new location is empty, + // re-use it instead of creating a new one. newLeaf = leafToDisplace; - } else { + } + + if (!newLeaf && settings.previewTabs) { + // ignore index and use preview tab in group if there is one + newLeaf = group.children.find(l => l.openTabSettings?.isPreview); + } + + if (!newLeaf) { newLeaf = new (WorkspaceLeaf as new (app: App) => WorkspaceLeaf)(this.app); const currentTab = group.currentTab; // If new tab is inserted before the currently tab in a group, and we aren't setting the new tab active, we @@ -438,6 +495,7 @@ export default class OpenTabSettingsPlugin extends Plugin { } } + this.setLeafIsPreview(newLeaf, settings.previewTabs); if (focus) { workspace.setActiveLeaf(newLeaf); } diff --git a/src/settings.ts b/src/settings.ts index 39a38cb..ea56ae3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -20,10 +20,12 @@ export const MOD_CLICK_BEHAVIOR = { "same": "In same tab", "allow_duplicate": "In duplicate tab", "opposite": "In opposite tab group", + "no_preview": "Open persistent (non-preview) tab", } export interface OpenTabSettingsPluginSettings { openInNewTab: boolean, + previewTabs: boolean, deduplicateTabs: boolean, deduplicateAcrossTabGroups: boolean, newTabPlacement: keyof typeof NEW_TAB_PLACEMENTS, @@ -33,6 +35,7 @@ export interface OpenTabSettingsPluginSettings { export const DEFAULT_SETTINGS: OpenTabSettingsPluginSettings = { openInNewTab: true, + previewTabs: false, deduplicateTabs: true, deduplicateAcrossTabGroups: true, newTabPlacement: "after-active", @@ -77,6 +80,19 @@ export class OpenTabSettingsPluginSettingTab extends PluginSettingTab { }) ); + new Setting(this.containerEl) + .setName('Preview tabs') + .setDesc('VS Code style preview tabs. Initially open tabs as "preview" until interacted with. Preview tabs will be replaced instead of opening in new tab.') + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.previewTabs) + .onChange(async (value) => { + await this.plugin.updateSettings({previewTabs: value}); + this.display(); + }) + ) + .setDisabled(!this.plugin.settings.openInNewTab); + new Setting(this.containerEl) .setName('Prevent duplicate tabs') .setDesc('If a tab is already open, switch to it instead of re-opening it.') @@ -162,6 +178,9 @@ export class OpenTabSettingsPluginSettingTab extends PluginSettingTab { dropdown.addOption("allow_duplicate", MOD_CLICK_BEHAVIOR['allow_duplicate']) } dropdown.addOption("opposite", MOD_CLICK_BEHAVIOR['opposite']) + if (this.plugin.settings.previewTabs) { + dropdown.addOption("no_preview", MOD_CLICK_BEHAVIOR['no_preview']) + } dropdown .setValue(this.plugin.settings.modClickBehavior) .onChange(async value => { diff --git a/src/types.d.ts b/src/types.d.ts index 7e041a4..046b1f0 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -4,9 +4,14 @@ import { OpenTabSettingsPluginSettings } from './settings'; declare module "obsidian" { interface WorkspaceLeaf { openTabSettings?: { - openMode: PaneType|false, - override: Partial, - openedFrom?: string, + openInfo?: { + openMode: PaneType|false, + override: Partial, + openedFrom?: string, + }, + /** true when a leaf has just been opened, set to false the leaf is interacted with */ + isPreview?: boolean, + eventCleanup?: () => void, }, pinned: boolean, } diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..42dfbc6 --- /dev/null +++ b/styles.css @@ -0,0 +1,3 @@ +.open-tab-settings-is-preview { + font-style: italic; +}