Add VSCode style preview tabs

This commit is contained in:
Jesse Hines 2026-06-16 22:14:03 -04:00
parent b156ddac6f
commit ec54faa8f9
No known key found for this signature in database
4 changed files with 106 additions and 21 deletions

View file

@ -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<OpenTabSettingsPluginSettings>) {
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);
}

View file

@ -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 => {

11
src/types.d.ts vendored
View file

@ -4,9 +4,14 @@ import { OpenTabSettingsPluginSettings } from './settings';
declare module "obsidian" {
interface WorkspaceLeaf {
openTabSettings?: {
openMode: PaneType|false,
override: Partial<OpenTabSettingsPluginSettings>,
openedFrom?: string,
openInfo?: {
openMode: PaneType|false,
override: Partial<OpenTabSettingsPluginSettings>,
openedFrom?: string,
},
/** true when a leaf has just been opened, set to false the leaf is interacted with */
isPreview?: boolean,
eventCleanup?: () => void,
},
pinned: boolean,
}

3
styles.css Normal file
View file

@ -0,0 +1,3 @@
.open-tab-settings-is-preview {
font-style: italic;
}