diff --git a/src/lib/fileIcons.ts b/src/lib/fileIcons.ts index 32fdeeaa4..3168b6dbc 100644 --- a/src/lib/fileIcons.ts +++ b/src/lib/fileIcons.ts @@ -687,9 +687,9 @@ class FileIconRegistry { for (const $tile of root.querySelectorAll( '[data-type="dir"][data-name], [data-type="root"][data-name]', )) { - const expanded = !$tile - .closest(".collapsible") - ?.classList.contains("hidden"); + const $collapsible = $tile.closest(".collapsible"); + const expanded = + $collapsible !== null && !$collapsible.classList.contains("hidden"); applyLeadClass( $tile, this.icon({ @@ -1094,7 +1094,22 @@ function applyLeadClass($tile: HTMLElement, className: string): void { ) { return; } - $lead.className = className; + + // Some consumers add structural classes around the resolved icon class. + // Keep those classes when a late-loading plugin refreshes an existing row. + // File Browser uses `icon` for sizing. `user-added-storage` is its colour + // hook, and it must survive after the builtin `folder` glyph class is + // replaced. Keeping `folder` would also paint the icon-font glyph. + const structuralClasses = ["icon", "user-added-storage"].filter((name) => + $lead.classList.contains(name), + ); + const resolvedClasses = new Set(className.split(/\s+/).filter(Boolean)); + const missingStructuralClasses = structuralClasses.filter( + (name) => !resolvedClasses.has(name), + ); + $lead.className = [className, ...missingStructuralClasses] + .filter(Boolean) + .join(" "); } const fileIcons = new FileIconRegistry(); diff --git a/src/pages/fileBrowser/fileBrowser.scss b/src/pages/fileBrowser/fileBrowser.scss index 0a262aaef..f95a97519 100644 --- a/src/pages/fileBrowser/fileBrowser.scss +++ b/src/pages/fileBrowser/fileBrowser.scss @@ -54,10 +54,12 @@ &.folder { color: rgb(206, 206, 53); + } - &.user-added-storage { - color: rgb(53, 101, 206); - } + // Plugin icon themes replace the builtin `folder` glyph class. + // Storage colour has to match `user-added-storage` on its own. + &.user-added-storage { + color: rgb(53, 101, 206); } &.code { diff --git a/tests/unit/codeHighlight.test.js b/tests/unit/codeHighlight.test.js index b434faa8c..327a763b0 100644 --- a/tests/unit/codeHighlight.test.js +++ b/tests/unit/codeHighlight.test.js @@ -134,30 +134,41 @@ describe("codeHighlight", () => { }); it("updates fallback styles while the shadow host is still detached", () => { - initHighlighting(); - const host = document.createElement("div"); - const shadow = host.attachShadow({ mode: "open" }); - Object.defineProperty(shadow, "adoptedStyleSheets", { - configurable: true, - get() { - throw new Error("adoptedStyleSheets unavailable"); - }, - set() { - throw new Error("adoptedStyleSheets unavailable"); - }, - }); - - applyHighlightStyles(shadow); - const style = shadow.querySelector("#cm-static-highlight-styles"); - expect(style).toBeTruthy(); - expect(style.isConnected).toBe(false); - expect(style.textContent).toContain("#c678dd"); - - settings.value.editorTheme = "githubLight"; - for (const listener of themeListeners) listener(); - - expect(style.parentNode).toBe(shadow); - expect(style.textContent).toContain("#cf222e"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + initHighlighting(); + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + Object.defineProperty(shadow, "adoptedStyleSheets", { + configurable: true, + get() { + throw new Error("adoptedStyleSheets unavailable"); + }, + set() { + throw new Error("adoptedStyleSheets unavailable"); + }, + }); + + applyHighlightStyles(shadow); + const style = shadow.querySelector("#cm-static-highlight-styles"); + expect(style).toBeTruthy(); + expect(style.isConnected).toBe(false); + expect(style.textContent).toContain("#c678dd"); + + settings.value.editorTheme = "githubLight"; + for (const listener of themeListeners) listener(); + + expect(style.parentNode).toBe(shadow); + expect(style.textContent).toContain("#cf222e"); + expect(warn).toHaveBeenCalledWith( + "Failed to adopt highlight stylesheet", + expect.objectContaining({ + message: "adoptedStyleSheets unavailable", + }), + ); + } finally { + warn.mockRestore(); + } }); it("updates adopted shadow styles when the editor theme changes", () => { diff --git a/tests/unit/fileIconAssets.test.ts b/tests/unit/fileIconAssets.test.ts index 239e34c77..eef03d770 100644 --- a/tests/unit/fileIconAssets.test.ts +++ b/tests/unit/fileIconAssets.test.ts @@ -97,6 +97,36 @@ describe("icon assets", () => { expect(tile.firstElementChild?.className).toBe(fileIcons.icon("notes.txt")); }); + it("preserves file browser layout and closed-folder state during a late plugin refresh", () => { + document.body.innerHTML = ` +
+ +
+
+ +
`; + fileIcons.register({ + id: "classes", + pluginId: "test.plugin", + icons: { + file: { className: "pack-file" }, + folder: { className: "pack-folder" }, + "folder-open": { className: "pack-folder-open" }, + }, + file: "file", + folder: "folder", + folderExpanded: "folder-open", + }); + + fileIcons.use("classes", { persist: false }); + + const [file, folder] = document.body.querySelectorAll("span"); + expect(file.className).toBe("pack-file icon"); + expect(folder.className).toBe( + "pack-folder icon user-added-storage", + ); + }); + it("uses distinct CSS classes for IDs that previously collided", () => { fileIcons.register({ id: "ids", diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js index b66dab3d9..bae0b1abf 100644 --- a/tests/unit/sftpProfiles.test.js +++ b/tests/unit/sftpProfiles.test.js @@ -257,23 +257,40 @@ describe("SFTP secure profiles", () => { }); it("scrubs malformed legacy SFTP values instead of marking them migrated", async () => { - globalThis.sftp = { saveProfile: vi.fn() }; - localStorage.setItem( - "recentFiles", - JSON.stringify([ - "sftp:///missing-host", - "sftp://user:%E0%A4%A@example.com/bad-encoding", - ]), - ); - - const result = await migrateLegacySftpProfiles(); - - expect(JSON.parse(localStorage.getItem("recentFiles"))).toEqual([]); - expect(result.failures[0].message).toBe( - "The saved SFTP address is incomplete", - ); - expect(result.failures[1].message).toBe("URI malformed"); - expect(localStorage.getItem("sftpNativeProfileMigration")).toBe("2"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + globalThis.sftp = { saveProfile: vi.fn() }; + localStorage.setItem( + "recentFiles", + JSON.stringify([ + "sftp:///missing-host", + "sftp://user:%E0%A4%A@example.com/bad-encoding", + ]), + ); + + const result = await migrateLegacySftpProfiles(); + + expect(JSON.parse(localStorage.getItem("recentFiles"))).toEqual([]); + expect(result.failures[0].message).toBe( + "The saved SFTP address is incomplete", + ); + expect(result.failures[1].message).toBe("URI malformed"); + expect(localStorage.getItem("sftpNativeProfileMigration")).toBe("2"); + expect(warn).toHaveBeenNthCalledWith( + 1, + "Could not migrate legacy SFTP URL", + expect.objectContaining({ + message: "The saved SFTP address is incomplete", + }), + ); + expect(warn).toHaveBeenNthCalledWith( + 2, + "Could not migrate legacy SFTP URL", + expect.objectContaining({ message: "URI malformed" }), + ); + } finally { + warn.mockRestore(); + } }); it("deletes an app-owned legacy key copy when its profile cannot migrate", async () => {