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 <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-06 06:54:57 +08:00
1 джерело e6b800971a
коміт 1f76e71621
4 files changed
+80 -1

No files matched your search

+19
Переглянути файл
@@ -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": "<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();
}
});
});
+37
Переглянути файл
@@ -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<string, TextEditorWrapperContribution>) {
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);
+6 -1
Переглянути файл
@@ -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",
+18
Переглянути файл
@@ -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", () => {