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.
This commit is contained in:
Mohamed Boudra
2026-10-20 08:07:00 +02:00
parent ee7d422cc7
commit cd102d08b6
7 changed files with 35 additions and 26 deletions
-7
View File
@@ -41,10 +41,3 @@ export function filePanelCatalog<Plugin extends FilePanelCandidate>(
): 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 extends { id: string }>(
panel: Panel | SourceFilePanel,
): panel is SourceFilePanel {
return panel.id === sourceFilePanel.id;
}
-1
View File
@@ -1,4 +1,3 @@
export { isSourceFilePanel } from "./catalog";
export { FilePanelView } from "./view";
export type { FilePanel } from "./panels";
export {
@@ -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" },
]);
});
@@ -41,8 +41,9 @@ export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>
/**
* 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<Candidate extends FilePanelCandidate>(
candidates: readonly Candidate[],
@@ -54,7 +55,7 @@ function offeredCandidates<Candidate extends FilePanelCandidate>(
);
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),
);
}
+3 -2
View File
@@ -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. */
@@ -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]);
+3 -1
View File
@@ -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 |
| -------------------------------- | ------------------------------------------------------------------------------------------- |