From 1f76e7162161f7c6025e6d334f768cb846c1178a Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Thu, 5 Nov 2026 14:54:57 -0800 Subject: [PATCH] fix(viewers): yield image viewer to registered text editor wrappers (#1638) A plugin's side-by-side custom editor for an image-extension subtype (such as *.excalidraw.svg) was intercepted by the image viewer. The image viewer now yields to a matching text editor wrapper, and an image tab already open switches to the text editor when such a wrapper registers later (after plugins load). Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- src/editor/core/controller.test.ts | 19 ++++++++++++++ src/editor/core/controller.ts | 37 ++++++++++++++++++++++++++++ src/features/viewers/index.ts | 7 +++++- src/features/viewers/viewers.test.ts | 18 ++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 5724c1222..d4130ab0a 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -4,6 +4,7 @@ import { createMemoryBackend } from "./backend"; import { initialTabsState } from "./model/tabs"; import { useDocMeta } from "./model/document"; import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree"; +import { registerTextEditorWrapper } from "@/workbench/contributions"; import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; import { resolveMenuItems } from "@/ui"; @@ -319,4 +320,22 @@ describe("EditorController", () => { dispose(); useProject.setState({ project: null, settings: null }); }); + + it("switches an open image tab to a text tab when a matching text editor wrapper registers", async () => { + ctrl = new EditorController(createMemoryBackend({ "/p/arch.diagram.svg": "" })); + ctrl.start(); + await ctrl.openInput({ kind: "image", key: "image:/p/arch.diagram.svg", path: "/p/arch.diagram.svg" }); + expect(openFilePaths(useTabs.getState())).toEqual([]); + const offWrapper = registerTextEditorWrapper({ + id: "plugin.acme.diagram.canvas", + matches: (p) => p.endsWith(".diagram.svg"), + component: () => null, + }); + try { + expect(openFilePaths(useTabs.getState())).toEqual(["/p/arch.diagram.svg"]); + await vi.waitFor(() => expect(ctrl?.activeSlot?.model.path).toBe("/p/arch.diagram.svg")); + } finally { + offWrapper(); + } + }); }); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 3c31d4761..217a8de1f 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -23,13 +23,16 @@ import { menuGroupItems, notifications, resolveFileEditor, + textEditorWrapperFor, useContributions, useStandaloneEditor, + useTextEditorWrappers, type EditorCloseSession, type EditorInput, type OpenOptions, type FileEditorResolution, type OpenEditorTab, + type TextEditorWrapperContribution, } from "@/workbench/contributions"; import { useDistractionFree } from "@/workbench/distractionFree"; import { ask } from "@/workbench/messages"; @@ -344,6 +347,11 @@ export class EditorController implements SlotHost { ); // Gutter, centred column and sticky lines follow Distraction Free Mode. this.disposers.push(useDistractionFree.subscribe(() => this.manager.applySettings())); + this.disposers.push( + useTextEditorWrappers.subscribe(({ wrappers }) => { + this.upgradeWrappedImageTabs(wrappers); + }), + ); if (prevRoot) this.restoreSession(prevRoot); if (this.manager.backend.onFsChanged) this.disposers.push(this.manager.backend.onFsChanged((e) => this.onFilesRemoved(e.removed))); @@ -454,6 +462,35 @@ export class EditorController implements SlotHost { // --- session (open tabs, splits, carets per project) --------------------------- + private upgradeWrappedImageTabs(wrappers: ReadonlyMap) { + const s = useTabs.getState(); + let changed = false; + const panes: TabsState["panes"] = {}; + const toEnsure: string[] = []; + for (const [paneId, pane] of Object.entries(s.panes)) { + let paneChanged = false; + const tabs = pane.tabs.map((tab) => { + const path = tab.input.path; + if ( + tab.input.kind === "image" && + path && + textEditorWrapperFor(wrappers, path) && + !pane.tabs.some((other) => other.id !== tab.id && other.input.key === fileKey(path)) + ) { + paneChanged = true; + changed = true; + if (pane.active === tab.id) toEnsure.push(tab.id); + return { ...tab, input: fileInput(path) }; + } + return tab; + }); + panes[paneId] = paneChanged ? { ...pane, tabs } : pane; + } + if (!changed) return; + useTabs.setState({ ...s, panes }, true); + for (const tabId of toEnsure) void this.ensureSlot(tabId); + } + private scheduleSessionSave() { if (!this.sessionRoot) return; clearTimeout(this.sessionTimer); diff --git a/src/features/viewers/index.ts b/src/features/viewers/index.ts index a0137a656..85b65a8fb 100644 --- a/src/features/viewers/index.ts +++ b/src/features/viewers/index.ts @@ -13,6 +13,8 @@ import { registerEditorKind, registerFileEditorProvider, registerTextEditorWrapper, + textEditorWrapperFor, + useTextEditorWrappers, type EditorInput, } from "@/workbench/contributions"; import { @@ -85,7 +87,10 @@ export function activate(): () => void { registerFileEditorProvider({ id: "viewers.image", order: 10, - resolve: (path) => (isImagePath(path) ? imageInput(path) : null), + resolve: (path) => + isImagePath(path) && !textEditorWrapperFor(useTextEditorWrappers.getState().wrappers, path) + ? imageInput(path) + : null, }), registerFileEditorProvider({ id: "viewers.hex", diff --git a/src/features/viewers/viewers.test.ts b/src/features/viewers/viewers.test.ts index 1c643ed2a..0270e5ac1 100644 --- a/src/features/viewers/viewers.test.ts +++ b/src/features/viewers/viewers.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { registerTextEditorWrapper, resolveFileEditor } from "@/workbench/contributions"; +import { activate as activateViewers } from "./index"; import { binaryInput, formatSize, imageInput, isImagePath, isMarkdownPath, isSvgPath, wantsHexView } from "./fileKinds"; import { BYTES_PER_ROW, @@ -45,6 +47,22 @@ describe("file kinds", () => { expect(formatSize(1536)).toBe("1.5 KB"); expect(formatSize(300 * 1024 * 1024)).toBe("300 MB"); }); + + it("lets a text editor wrapper take precedence over the image viewer", async () => { + const offViewers = activateViewers(); + const offWrapper = registerTextEditorWrapper({ + id: "plugin.acme.diagram.canvas", + matches: (p) => p.endsWith(".diagram.svg"), + component: () => null, + }); + try { + expect((await resolveFileEditor("/p/icon.svg"))?.input.kind).toBe("image"); + expect(await resolveFileEditor("/p/arch.diagram.svg")).toBeNull(); + } finally { + offWrapper(); + offViewers(); + } + }); }); describe("hex model", () => {