From cd102d08b65be1d5ae077ca2e4b322dd94ac834e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 08:07:00 +0200 Subject: [PATCH] fix(file-panels): Settings offers Source where the picker does, and review follow-ups - Settings > Files offers Source on every key except those a built-in panel of another kind (Image) claims, so a plugin that alone claims a text extension shows the same choices as the picker. - Delete the unused isSourceFilePanel. - The Markdown example warns with the file name when its unmount write fails, rather than dropping the edit silently. - Document that PluginFile stat fields are read at render time and that watch or the read hooks are how a panel learns they changed. --- packages/app/src/file-panels/catalog.ts | 7 ----- packages/app/src/file-panels/index.ts | 1 - .../app/src/file-panels/settings/rows.test.ts | 30 ++++++++++++------- packages/app/src/file-panels/settings/rows.ts | 7 +++-- packages/plugin/src/client/file.ts | 5 ++-- .../markdown-panel/client/rich-markdown.tsx | 7 ++++- public-docs/plugins/reference.md | 4 ++- 7 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts index b48f03835b..6ff5cd8d5a 100644 --- a/packages/app/src/file-panels/catalog.ts +++ b/packages/app/src/file-panels/catalog.ts @@ -41,10 +41,3 @@ export function filePanelCatalog( ): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] { return [...renderingPanels, ...pluginPanels, sourceFilePanel]; } - -/** Source is Paseo's own editor, so the file tab renders it rather than the panel host. */ -export function isSourceFilePanel( - panel: Panel | SourceFilePanel, -): panel is SourceFilePanel { - return panel.id === sourceFilePanel.id; -} diff --git a/packages/app/src/file-panels/index.ts b/packages/app/src/file-panels/index.ts index b555db82e7..5219632fcb 100644 --- a/packages/app/src/file-panels/index.ts +++ b/packages/app/src/file-panels/index.ts @@ -1,4 +1,3 @@ -export { isSourceFilePanel } from "./catalog"; export { FilePanelView } from "./view"; export type { FilePanel } from "./panels"; export { diff --git a/packages/app/src/file-panels/settings/rows.test.ts b/packages/app/src/file-panels/settings/rows.test.ts index 989d8dae3e..170ffba04e 100644 --- a/packages/app/src/file-panels/settings/rows.test.ts +++ b/packages/app/src/file-panels/settings/rows.test.ts @@ -6,6 +6,7 @@ import { buildFilePanelSettingsRows } from "./rows"; type HostedCandidate = FilePanelCandidate & { serverId?: string }; const builtinMarkdown: HostedCandidate = filePanelCatalog([])[0]; +const builtinImage: HostedCandidate = filePanelCatalog([])[2]; const source: HostedCandidate = sourceFilePanel; /** What Settings reads: the built-ins and every host's plugin panels, in catalog order. */ @@ -49,21 +50,28 @@ describe("buildFilePanelSettingsRows", () => { ]); }); - it("offers Source only on keys a built-in text panel claims, and asks only between several", () => { - const pdf: HostedCandidate = { - id: "plugin:pdf-panel/pdf", + it("offers Source on a key only a plugin panel claims, as the picker does for a text file", () => { + const toml: HostedCandidate = { + id: "plugin:toml/toml", builtin: false, - files: { extensions: ["pdf"] }, + files: { extensions: ["toml"] }, }; - expect(rows(catalog(pdf))).toEqual([]); - expect(rows(catalog(pdf), { "ext:pdf": pdf.id })).toEqual([ - { selectorKey: "ext:pdf", candidates: [pdf], associatedPanelId: pdf.id }, + expect(rows(catalog(toml))).toEqual([ + { selectorKey: "ext:toml", candidates: [toml, source], associatedPanelId: null }, ]); + const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" }; + expect(rows(catalog(dockerfile, other))[0]?.candidates).toEqual([dockerfile, other, source]); }); - it("does not offer Source on a key only plugin panels claim: their files' kind is unknown", () => { - const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" }; - expect(rows(catalog(dockerfile, other))[0]?.candidates).toEqual([dockerfile, other]); + it("does not offer Source on a key the built-in image panel claims", () => { + const viewer: HostedCandidate = { + id: "plugin:viewer/png", + builtin: false, + files: { extensions: ["png"] }, + }; + expect(rows(catalog(viewer))).toEqual([ + { selectorKey: "ext:png", candidates: [builtinImage, viewer], associatedPanelId: null }, + ]); }); it("offers a panel installed on several hosts once, as the first host has it", () => { @@ -84,7 +92,7 @@ describe("buildFilePanelSettingsRows", () => { it("keeps a remembered key whose panel is on no connected host", () => { expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([ - { selectorKey: "ext:pdf", candidates: [], associatedPanelId: "plugin:pdf-panel/pdf" }, + { selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" }, ]); }); diff --git a/packages/app/src/file-panels/settings/rows.ts b/packages/app/src/file-panels/settings/rows.ts index e4a2c180e5..b2f8bc1971 100644 --- a/packages/app/src/file-panels/settings/rows.ts +++ b/packages/app/src/file-panels/settings/rows.ts @@ -41,8 +41,9 @@ export function buildFilePanelSettingsRows /** * The panels offered under a key, by the resolver's matching rule. A panel claiming every file of - * a kind (Source) is offered where a built-in panel claiming the key reads that kind: without a - * file, that is the only knowledge of whether the key's files are text. + * a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a + * file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file + * when it opens is ignored there. */ function offeredCandidates( candidates: readonly Candidate[], @@ -54,7 +55,7 @@ function offeredCandidates( ); return candidates.filter((candidate) => candidate.files === "all" - ? claiming.some((claimant) => claimant.fileKind === candidate.fileKind) + ? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind) : claiming.includes(candidate), ); } diff --git a/packages/plugin/src/client/file.ts b/packages/plugin/src/client/file.ts index 719ce6543d..3120c6b9b2 100644 --- a/packages/plugin/src/client/file.ts +++ b/packages/plugin/src/client/file.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; /** - * The file a file panel shows. Stable for the life of the tab's target. The stat fields are - * refreshed when the file changes on disk. + * The file a file panel shows. Stable for the life of the tab's target. The stat fields hold the + * latest version Paseo knows; read them while rendering. Changing them does not re-render the + * panel: `watch`, or a re-render from the read hooks, is how a panel learns they changed. */ export interface PluginFile { /** Absolute path on the host. */ diff --git a/plugin-examples/markdown-panel/client/rich-markdown.tsx b/plugin-examples/markdown-panel/client/rich-markdown.tsx index 33de818e52..26831b8410 100644 --- a/plugin-examples/markdown-panel/client/rich-markdown.tsx +++ b/plugin-examples/markdown-panel/client/rich-markdown.tsx @@ -85,7 +85,12 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { unwatch(); clearTimeout(saveTimer.current); // Switching panels keeps the edit. Closing the tab asks first and drops it if discarded. - if (unsaved.current !== null) void file.writeText(unsaved.current).catch(() => {}); + // The panel is gone, so a failed write (a conflict, a lost connection) can only be reported. + if (unsaved.current !== null) { + void file.writeText(unsaved.current).catch((cause) => { + console.warn(`Unsaved edits to ${file.name} were not written: ${messageOf(cause)}`); + }); + } }; }, [file, load]); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 2c63391f1f..f5c1a6f1ab 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1806,7 +1806,9 @@ props described under [Theme and layout](#theme-and-layout), plus: | `location` | `PluginFileLocation` | `lineStart?`, `lineEnd?`, and `revision`, which increments on every navigation. | Paseo mounts your panel once it knows the file exists and its size, without reading it. The -`PluginFile` stat fields refresh when the file changes on disk: +`file` object is stable, and its stat fields hold the latest version Paseo knows. Read them while +rendering; changing them does not re-render your panel. A `watch` listener, or a re-render from +`useFileText`, `useFileBytes`, or `useFileUrl`, is how your panel learns they changed: | Member | Meaning | | -------------------------------- | ------------------------------------------------------------------------------------------- |