From 0c922897ad511f329d18afc38734adcec1171c4d Mon Sep 17 00:00:00 2001 From: Sam Nystrom Date: Mon, 9 Feb 2026 15:46:13 -0500 Subject: [PATCH 1/3] feat: add vertical tabs compatibility --- src/managers/TabIconManager.ts | 97 ++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/src/managers/TabIconManager.ts b/src/managers/TabIconManager.ts index ed9f9bb..381b871 100644 --- a/src/managers/TabIconManager.ts +++ b/src/managers/TabIconManager.ts @@ -1,16 +1,23 @@ -import { Platform } from 'obsidian'; -import IconicPlugin, { Category, FileItem, TabItem, STRINGS } from 'src/IconicPlugin'; +import { Platform, WorkspaceLeaf } from 'obsidian'; +import IconicPlugin, { Category, FileItem, TabItem, STRINGS, PLUGIN_TAB_TYPES } from 'src/IconicPlugin'; import IconManager from 'src/managers/IconManager'; import RuleEditor from 'src/dialogs/RuleEditor'; import IconPicker from 'src/dialogs/IconPicker'; +const VERTICAL_TABS_VIEW_TYPE = 'vertical-tabs'; + /** * Handles icons in workspace tab headers. */ export default class TabIconManager extends IconManager { + private vtObserverTimeout: number | null = null; + constructor(plugin: IconicPlugin) { super(plugin); - this.plugin.registerEvent(this.app.workspace.on('layout-change', () => this.refreshIcons())); + this.plugin.registerEvent(this.app.workspace.on('layout-change', () => { + this.refreshIcons(); + this.observeVerticalTabs(); + })); this.plugin.registerEvent(this.app.workspace.on('active-leaf-change', () => this.refreshIcons())); // Refresh icons in tab selector dropdown ▼ @@ -34,6 +41,7 @@ export default class TabIconManager extends IconManager { }); this.refreshIcons(); + this.observeVerticalTabs(); } /** @@ -142,6 +150,8 @@ export default class TabIconManager extends IconManager { } } } + + this.refreshVerticalTabsIcons(unloading); } /** @@ -238,6 +248,87 @@ export default class TabIconManager extends IconManager { } } + /** + * Get the Vertical Tabs plugin container element, if present. + */ + private getVerticalTabsContainer(): HTMLElement | null { + const leaves = this.app.workspace.getLeavesOfType(VERTICAL_TABS_VIEW_TYPE); + if (leaves.length === 0) return null; + return leaves[0].view.containerEl ?? null; + } + + /** + * Set up MutationObserver to catch Vertical Tabs React re-renders. + */ + private observeVerticalTabs(): void { + const vtContainer = this.getVerticalTabsContainer(); + if (!vtContainer) return; + + this.setMutationObserver(vtContainer, { childList: true, subtree: true }, () => { + if (this.vtObserverTimeout) window.cancelAnimationFrame(this.vtObserverTimeout); + this.vtObserverTimeout = window.requestAnimationFrame(() => { + this.refreshVerticalTabsIcons(); + }); + }); + } + + /** + * Refresh icons in Vertical Tabs plugin view. + */ + private refreshVerticalTabsIcons(unloading?: boolean): void { + const vtContainer = this.getVerticalTabsContainer(); + if (!vtContainer) return; + + // Build a map of leaf IDs to leaves + const leafMap = new Map(); + this.app.workspace.iterateAllLeaves(leaf => { + // @ts-expect-error (Private API) + leafMap.set(leaf.id, leaf); + }); + + const vtTabs = vtContainer.querySelectorAll('.tree-item.is-tab'); + + for (const vtTabEl of vtTabs) { + const leafId = vtTabEl.getAttribute('data-id'); + if (!leafId) continue; + + const vtIconEl = vtTabEl.querySelector(':scope > .tree-item-self > .tree-item-icon') as HTMLElement | null; + if (!vtIconEl) continue; + + const leaf = leafMap.get(leafId); + if (!leaf) continue; + + const viewType = leaf.view.getViewType(); + if (viewType === 'webviewer') continue; + const filePath = leaf.view.getState().file; + + let icon: string | null = null; + let color: string | null = null; + let iconDefault: string | null = leaf.view.getIcon(); + let category: Category = 'tab'; + let id: string = viewType; + + if (filePath && !PLUGIN_TAB_TYPES.includes(viewType)) { + category = 'file'; + id = typeof filePath === 'string' ? filePath : ''; + const fileIcon = this.plugin.settings.fileIcons[id] ?? {}; + icon = unloading ? null : fileIcon.icon ?? null; + color = unloading ? null : fileIcon.color ?? null; + } else { + const tabIcon = this.plugin.settings.tabIcons[viewType] ?? {}; + icon = unloading ? null : tabIcon.icon ?? null; + color = unloading ? null : tabIcon.color ?? null; + } + + const item = { id, name: leaf.getDisplayText(), icon, color, iconDefault, category }; + const rule = category === 'file' + ? this.plugin.ruleManager.checkRuling('file', id, unloading) ?? item + : item; + + this.refreshIcon(rule, vtIconEl); + } + } + /** * @override */ From 1d4c9353b63d33f2e2c13aadf865a589b30f9f8d Mon Sep 17 00:00:00 2001 From: Sam Nystrom Date: Mon, 9 Feb 2026 16:02:27 -0500 Subject: [PATCH 2/3] fix: don't break vertical tabs context menu --- src/managers/FileIconManager.ts | 4 ++-- src/managers/MenuManager.ts | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/managers/FileIconManager.ts b/src/managers/FileIconManager.ts index 82bbc6a..c35bcd1 100644 --- a/src/managers/FileIconManager.ts +++ b/src/managers/FileIconManager.ts @@ -17,12 +17,12 @@ export default class FileIconManager extends IconManager { constructor(plugin: IconicPlugin) { super(plugin); this.plugin.registerEvent(this.app.workspace.on('file-menu', (menu, tFile) => { - if (this.plugin.settings.showMenuActions) { + if (this.plugin.settings.showMenuActions && !this.plugin.menuManager.hasMenu()) { this.onContextMenu(tFile.path); } })); this.plugin.registerEvent(this.app.workspace.on('files-menu', (menu, tFiles) => { - if (this.plugin.settings.showMenuActions) { + if (this.plugin.settings.showMenuActions && !this.plugin.menuManager.hasMenu()) { this.onContextMenu(...tFiles.map(tFile => tFile.path)); } })); diff --git a/src/managers/MenuManager.ts b/src/managers/MenuManager.ts index 8683423..be6a8a3 100644 --- a/src/managers/MenuManager.ts +++ b/src/managers/MenuManager.ts @@ -6,6 +6,8 @@ import { Menu, MenuItem, MenuPositionDef } from 'obsidian'; export default class MenuManager { private menu: Menu | null; private queuedActions: (() => void)[] = []; + /** When true, the next menu opened will have queued actions applied to it. */ + private expectingMenu: boolean = false; private showAtPositionOriginal: typeof Menu.prototype.showAtPosition; private showAtPositionProxy: typeof Menu.prototype.showAtPosition; @@ -19,8 +21,9 @@ export default class MenuManager { this.showAtPositionProxy = new Proxy(Menu.prototype.showAtPosition, { apply(showAtPosition, menu: Menu, args: [position: MenuPositionDef, doc?: Document]) { manager.menu = menu; - if (manager.queuedActions.length > 0) { - manager.runQueuedActions.call(manager); // Menu is unhappy with your customer service + if (manager.expectingMenu && manager.queuedActions.length > 0) { + manager.runQueuedActions.call(manager); + manager.expectingMenu = false; } return showAtPosition.call(menu, ...args); } @@ -117,12 +120,20 @@ export default class MenuManager { } /** - * Close menu and flush any queued actions. + * Check if a menu is currently captured. + */ + hasMenu(): boolean { + return this.menu !== null; + } + + /** + * Close menu and flush any queued actions, then prepare for a new menu. */ closeAndFlush(): void { this.menu?.close(); this.menu = null; this.flush(); + this.expectingMenu = true; } /** From 0bc5bd1914476787e8730f1d85e738094c640f11 Mon Sep 17 00:00:00 2001 From: Sam Nystrom Date: Wed, 11 Feb 2026 19:24:26 -0500 Subject: [PATCH 3/3] Revert "fix: don't break vertical tabs context menu" This reverts commit 1d4c9353b63d33f2e2c13aadf865a589b30f9f8d. --- src/managers/FileIconManager.ts | 4 ++-- src/managers/MenuManager.ts | 17 +++-------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/managers/FileIconManager.ts b/src/managers/FileIconManager.ts index c35bcd1..82bbc6a 100644 --- a/src/managers/FileIconManager.ts +++ b/src/managers/FileIconManager.ts @@ -17,12 +17,12 @@ export default class FileIconManager extends IconManager { constructor(plugin: IconicPlugin) { super(plugin); this.plugin.registerEvent(this.app.workspace.on('file-menu', (menu, tFile) => { - if (this.plugin.settings.showMenuActions && !this.plugin.menuManager.hasMenu()) { + if (this.plugin.settings.showMenuActions) { this.onContextMenu(tFile.path); } })); this.plugin.registerEvent(this.app.workspace.on('files-menu', (menu, tFiles) => { - if (this.plugin.settings.showMenuActions && !this.plugin.menuManager.hasMenu()) { + if (this.plugin.settings.showMenuActions) { this.onContextMenu(...tFiles.map(tFile => tFile.path)); } })); diff --git a/src/managers/MenuManager.ts b/src/managers/MenuManager.ts index be6a8a3..8683423 100644 --- a/src/managers/MenuManager.ts +++ b/src/managers/MenuManager.ts @@ -6,8 +6,6 @@ import { Menu, MenuItem, MenuPositionDef } from 'obsidian'; export default class MenuManager { private menu: Menu | null; private queuedActions: (() => void)[] = []; - /** When true, the next menu opened will have queued actions applied to it. */ - private expectingMenu: boolean = false; private showAtPositionOriginal: typeof Menu.prototype.showAtPosition; private showAtPositionProxy: typeof Menu.prototype.showAtPosition; @@ -21,9 +19,8 @@ export default class MenuManager { this.showAtPositionProxy = new Proxy(Menu.prototype.showAtPosition, { apply(showAtPosition, menu: Menu, args: [position: MenuPositionDef, doc?: Document]) { manager.menu = menu; - if (manager.expectingMenu && manager.queuedActions.length > 0) { - manager.runQueuedActions.call(manager); - manager.expectingMenu = false; + if (manager.queuedActions.length > 0) { + manager.runQueuedActions.call(manager); // Menu is unhappy with your customer service } return showAtPosition.call(menu, ...args); } @@ -120,20 +117,12 @@ export default class MenuManager { } /** - * Check if a menu is currently captured. - */ - hasMenu(): boolean { - return this.menu !== null; - } - - /** - * Close menu and flush any queued actions, then prepare for a new menu. + * Close menu and flush any queued actions. */ closeAndFlush(): void { this.menu?.close(); this.menu = null; this.flush(); - this.expectingMenu = true; } /**