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", () => {