diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2861aecc..d69f6cb492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ paseo host add 'relay://...' --label laptop - Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8)) - Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8)) +### Deprecated + +- Deprecated plugin panel `context` and flat `agentId` props, and `addWorkspacePanel`'s `context` field (including the agent registration alias). Removal after 2027-04-23; use `params.agentId` and `addAgentPanel`. + +### Changed + +- Changed beta file panel props to `workspaceId`, read-only `params`, `revision`, `file`, and `tab` with persisted per-tab state. Removed beta-only `location`, `PluginFileLocation`, and `PluginFileTab`. + ### Plugins - Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) diff --git a/docs/plugins.md b/docs/plugins.md index bf83669d55..2a223bfe85 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -570,7 +570,7 @@ and resolves conflicts. The host has no bar and no notion of a panel's views: th panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's -editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place: +editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place: `packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel; make that panel faster instead. Source highlights a large file on native in idle-time slices, the lines around the opening line first. diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts index 9159a60644..460faeb4ef 100644 --- a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -105,6 +105,17 @@ async function selectFileView(page: Page, view: "Preview" | "Source") { await expect(option).toHaveAttribute("aria-selected", "true"); } +async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") { + const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true }); + await button.click(); + await page.reload(); + if (view === "Source") { + await expectMarkdownEditor(page, "Edited by Rich Markdown"); + } else { + await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); + } +} + /** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ async function pdfViewer(page: Page): Promise { const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/; @@ -220,6 +231,9 @@ test("directory examples mount the PDF frame and write Markdown through the file await expectMarkdownEditor(page, "Edited by Rich Markdown"); }); + await rememberMarkdownViewAcrossReload(page, "Preview"); + await rememberMarkdownViewAcrossReload(page, "Source"); + // "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the // cursor and vim mode in the panel's toolbar. await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo"); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 606b27368e..5f70030819 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,5 +1,6 @@ +import { usePaneContext } from "@/panels/pane-context"; import { useEffect, useMemo, useState } from "react"; -import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client"; +import type { PluginFile, PluginTab } from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -69,13 +70,9 @@ export function FilePane({ () => ({ lineStart: location.lineStart, lineEnd: location.lineEnd, - revision: navigationRevision, + path, }), - [location.lineEnd, location.lineStart, navigationRevision], - ); - const panelTarget = useMemo( - () => ({ serverId, workspaceRoot: root, path }), - [path, root, serverId], + [location.lineEnd, location.lineStart, path], ); let body; @@ -113,8 +110,8 @@ export function FilePane({ key={`${panel.id}:${targetKey}`} panel={panel} file={handle.file} - location={panelLocation} - target={panelTarget} + params={panelLocation} + revision={navigationRevision} onModifiedChange={setModified} /> @@ -131,21 +128,27 @@ export function FilePane({ function MountedFilePanel({ panel, file, - location, - target, + params, + revision, onModifiedChange, }: { panel: FilePanel; file: PluginFile; - location: { lineStart?: number; lineEnd?: number; revision: number }; - target: { serverId: string; workspaceRoot: string; path: string }; + params: { path: string; lineStart?: number; lineEnd?: number }; + revision: number; onModifiedChange: (modified: boolean) => void; }) { - const [tab] = useState(() => ({ - setModified: (modified) => onModifiedChange(modified), - })); + const { state, setCurrentTabState } = usePaneContext(); + const tab = useMemo( + () => ({ + setModified: onModifiedChange, + state, + setState: setCurrentTabState, + }), + [onModifiedChange, state, setCurrentTabState], + ); useEffect(() => () => onModifiedChange(false), [onModifiedChange]); - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx index f47f11d029..5407719ab4 100644 --- a/packages/app/src/file-panels/builtin.tsx +++ b/packages/app/src/file-panels/builtin.tsx @@ -1,4 +1,4 @@ -import { useFileUrl } from "@getpaseo/plugin/client"; +import { useWorkspace, useFileUrl, type PluginFilePanelProps } from "@getpaseo/plugin/client"; import { useCallback, useState } from "react"; import { ScrollView, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -7,28 +7,28 @@ import { useSessionStore } from "@/stores/session-store"; import { FileHtmlPreview } from "./html-preview"; import { FilePanelLoading } from "./loading"; import { FileMarkdownPreview } from "./markdown-preview"; -import type { BuiltinFilePanelProps } from "./panels"; import { TextFilePanel, type TextFilePreviewProps } from "./source/panel"; import type { RenderingFilePanel, SourceFilePanel } from "./catalog"; /** Source: the text file panel with no Preview, for text files no other built-in panel claims. */ -function SourcePanel(props: BuiltinFilePanelProps) { +function SourcePanel(props: PluginFilePanelProps) { return ; } /** Markdown: the text file panel with the rendered document as its Preview. */ -function MarkdownPanel(props: BuiltinFilePanelProps) { +function MarkdownPanel(props: PluginFilePanelProps) { return ; } /** HTML: the text file panel with the sandboxed page as its Preview. */ -function HtmlPanel(props: BuiltinFilePanelProps) { +function HtmlPanel(props: PluginFilePanelProps) { return ; } /** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */ -function MarkdownPreview({ text, target }: TextFilePreviewProps) { - const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null); +function MarkdownPreview({ text, params, workspaceId, host }: TextFilePreviewProps) { + const client = useSessionStore((state) => state.sessions[host.id]?.client ?? null); + const workspaceRoot = useWorkspace(workspaceId, (workspace) => workspace.directory); const [rendered, setRendered] = useState(false); const handleLayout = useCallback(() => setRendered(true), []); return ( @@ -37,10 +37,10 @@ function MarkdownPreview({ text, target }: TextFilePreviewProps) { @@ -58,7 +58,7 @@ function HtmlPreview({ text }: TextFilePreviewProps) { ); } -function ImagePanel({ file }: BuiltinFilePanelProps) { +function ImagePanel({ file }: PluginFilePanelProps) { const url = useFileUrl(file); if (url.status === "loading") return ; if (url.status === "error") return ; @@ -83,7 +83,7 @@ const components = { export function BuiltinFilePanelView({ panel, ...props -}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { +}: PluginFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { const Component = components[panel.id]; return ; } diff --git a/packages/app/src/file-panels/panels.ts b/packages/app/src/file-panels/panels.ts index 7646755074..b3f33e040a 100644 --- a/packages/app/src/file-panels/panels.ts +++ b/packages/app/src/file-panels/panels.ts @@ -1,5 +1,4 @@ import { useMemo } from "react"; -import type { PluginFile, PluginFileLocation, PluginFileTab } from "@getpaseo/plugin/client"; import { useInstalledPlugins } from "@/plugins/registry"; import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types"; import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog"; @@ -13,14 +12,6 @@ export interface PluginFilePanel { } export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel; -/** A built-in panel gets the plugin panel's file, tab and location, plus where the file lives. */ -export interface BuiltinFilePanelProps { - file: PluginFile; - tab: PluginFileTab; - location: PluginFileLocation; - target: { serverId: string; workspaceRoot: string; path: string }; -} - export function useFilePanels(serverId: string): readonly FilePanel[] { const plugins = useInstalledPlugins(); return useMemo( diff --git a/packages/app/src/file-panels/plugin-host.tsx b/packages/app/src/file-panels/plugin-host.tsx index 06b2260a8b..4cc96cdc3d 100644 --- a/packages/app/src/file-panels/plugin-host.tsx +++ b/packages/app/src/file-panels/plugin-host.tsx @@ -1,54 +1,28 @@ -import { useMemo } from "react"; import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; -import type { PluginTheme } from "@getpaseo/plugin"; -import { withUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { usePaneContext } from "@/panels/pane-context"; -import { useHosts } from "@/runtime/host-runtime"; +import type { PluginFilePanelProps } from "@getpaseo/plugin/client"; +import { useMemo } from "react"; import { createPluginClientStateSource } from "@/plugins/client-state/source"; -import { usePluginHostNavigation } from "@/plugins/host-navigation"; import { PluginInstallationProvider } from "@/plugins/installation-provider"; import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary"; -import { resolvePluginPlatform } from "@/plugins/platform"; -import { toPluginTheme } from "@/plugins/theme"; -import type { Theme } from "@/styles/theme"; -import type { BuiltinFilePanelProps, PluginFilePanel } from "./panels"; +import type { PluginFilePanel } from "./panels"; -type Props = Pick & { panel: PluginFilePanel }; - -function PluginHost({ panel, file, tab, location, theme }: Props & { theme: PluginTheme }) { - const { workspaceId } = usePaneContext(); +export function PluginFilePanelHost({ + panel, + ...props +}: PluginFilePanelProps & { panel: PluginFilePanel }) { const { plugin, contribution } = panel; - const serverId = plugin.serverId; - const compact = useIsCompactFormFactor(); - const hosts = useHosts(); - const navigation = usePluginHostNavigation(serverId); - const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; - const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]); - const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const stateSource = useMemo( + () => createPluginClientStateSource(plugin.serverId), + [plugin.serverId], + ); const Component = contribution.Component; return ( - + ); } -const ThemedHost = withUnistyles(PluginHost); -const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); -export function PluginFilePanelHost(props: Props) { - return ; -} diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx index 7339cc50e7..23ea42f875 100644 --- a/packages/app/src/file-panels/source/panel.tsx +++ b/packages/app/src/file-panels/source/panel.tsx @@ -7,7 +7,11 @@ import { type ComponentProps, type ComponentType, } from "react"; -import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client"; +import { + useFileText, + type PluginFilePanelProps, + type PluginFilePanelParams, +} from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -18,7 +22,6 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { formatFileSize } from "@/utils/format-file-size"; import type { Theme } from "@/styles/theme"; import { FilePanelLoading } from "../loading"; -import type { BuiltinFilePanelProps } from "../panels"; import type { FileConflictAlertState } from "./conflict-alert"; import { FileEditorModel, getFileConflictCallout } from "./editor/model"; import { FileEditorView } from "./editor/view"; @@ -51,10 +54,12 @@ const MAX_EDITABLE_BYTES = 1024 * 1024; /** A rendered view of the file's text: the panel's Preview, shown instead of its source. */ export interface TextFilePreviewProps { text: string; - target: BuiltinFilePanelProps["target"]; + params: PluginFilePanelProps["params"]; + workspaceId: string; + host: PluginFilePanelProps["host"]; } -type TextFilePanelProps = BuiltinFilePanelProps & { +type TextFilePanelProps = PluginFilePanelProps & { /** The panel's Preview; with one, the toolbar switches between Preview and Source. */ preview?: ComponentType; }; @@ -78,9 +83,10 @@ export function TextFilePanel(props: TextFilePanelProps) { * each later line target switches to Source again. */ function useTextFileView( - location: PluginFileLocation, + params: PluginFilePanelParams, + revision: number, ): [TextFileView, (view: TextFileView) => void] { - const lineTarget = location.lineStart ? location.revision : null; + const lineTarget = params.lineStart ? revision : null; const [view, setView] = useState(lineTarget === null ? "preview" : "source"); const [shownLineTarget, setShownLineTarget] = useState(lineTarget); if (lineTarget !== null && lineTarget !== shownLineTarget) { @@ -102,17 +108,19 @@ function TooLargeTextFile({ size }: { size: number }) { function ReadonlyTextFile({ file, - location, - target, + params, + revision, + workspaceId, + host, preview: Preview, presentation, }: TextFilePanelProps & { presentation: "highlighted" | "plain" }) { const text = useFileText(file); const { t } = useTranslation(); - const [view, setView] = useTextFileView(location); + const [view, setView] = useTextFileView(params, revision); const sourceLocation = useMemo( - () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), - [location.lineEnd, location.lineStart, target.path], + () => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }), + [params.lineEnd, params.lineStart, params.path], ); if (text.status === "loading") return ; if (text.status === "error") { @@ -132,13 +140,13 @@ function ReadonlyTextFile({ onViewChange={Preview ? setView : undefined} /> {showPreview ? ( - + ) : ( )} @@ -146,11 +154,19 @@ function ReadonlyTextFile({ ); } -function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) { +function EditableTextFile({ + file, + tab, + params, + revision, + workspaceId, + host, + preview: Preview, +}: TextFilePanelProps) { const { settings } = useAppSettings(); const { t } = useTranslation(); const [model] = useState(() => new FileEditorModel({ file })); - const [view, setView] = useTextFileView(location); + const [view, setView] = useTextFileView(params, revision); const [cursor, setCursor] = useState({ line: 1, column: 1 }); const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); @@ -181,8 +197,8 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex const handleOverwrite = useCallback(() => void model.overwrite(), [model]); const handleRetry = useCallback(() => void model.reload(), [model]); const editorLocation = useMemo( - () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), - [location.lineEnd, location.lineStart, target.path], + () => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }), + [params.lineEnd, params.lineStart, params.path], ); if (snapshot.status === "loading") return ; @@ -222,13 +238,13 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex conflict={conflict} /> {showPreview ? ( - + ) : ( ; +type Props = Pick & { + panel: FilePanel; +}; +function FilePanelHost({ panel, theme, ...input }: Props & { theme: PluginTheme }) { + const { workspaceId, serverId } = usePaneContext(); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const label = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const host = useMemo(() => ({ id: serverId, label }), [serverId, label]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); + const navigation = usePluginHostNavigation(serverId); + const props: PluginFilePanelProps = { ...input, workspaceId, theme, host, layout, navigation }; + if (panel.builtin) + return ( + + + + ); return ; } +const ThemedHost = withUnistyles(FilePanelHost); +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); +export function FilePanelView(props: Props) { + return ; +} diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index 1d5112359e..d26bd3271d 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -30,7 +30,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) { function FilePanel() { const { t } = useTranslation(); - const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext(); + const { serverId, workspaceId, target, panelRevision } = usePaneContext(); const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId); invariant(target.kind === "file", "FilePanel requires file target"); if (!workspaceDirectory) { @@ -45,7 +45,7 @@ function FilePanel() { serverId={serverId} workspaceRoot={workspaceDirectory} location={target} - navigationRevision={fileNavigationRevision ?? 0} + navigationRevision={panelRevision ?? 0} /> ); } diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 5e716b006a..52b33e5960 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -13,7 +13,7 @@ export interface PaneContextValue { tabId: string; target: WorkspaceTabTarget; state?: JsonValue; - fileNavigationRevision?: number; + panelRevision?: number; openTab: (target: WorkspaceTabTarget) => void; openPreferredTarget: (target: WorkspaceTabTarget, source: OpenInSidePaneSource) => void; openTargetToSide?: (target: WorkspaceTabTarget) => void; diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index f0c768139a..c1f95b87fa 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -381,6 +381,23 @@ describe("evaluatePluginClientBundle", () => { ).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]); }); + it("registers workspace and agent panels with stable aliases", () => { + const plugin = evaluatePluginClientBundle( + "panels", + bundle(` + const Component = () => null; + plugin.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component }); + plugin.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component }); + plugin.addWorkspacePanel({ id: "legacy", title: "Legacy", icon: "File", context: "agent", Component }); + `), + ); + expect(plugin.workspacePanels.map(({ id, context }) => ({ id, context }))).toEqual([ + { id: "workspace", context: "workspace" }, + { id: "agent", context: "agent" }, + { id: "legacy", context: "agent" }, + ]); + }); + it("registers inline SVG and component icons", () => { const svg = ''; const plugin = evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index db5fece8c7..44bcbec57d 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -15,6 +15,8 @@ import * as Zod from "zod"; import { type PluginCleanup } from "@getpaseo/plugin"; import { type PluginClientContext, + type PluginAgentPanelContribution, + type PluginWorkspacePanelContribution as WorkspacePanelInput, type PluginScreenProps, type PluginScreenTitle, type PluginText, @@ -206,6 +208,31 @@ export function runPluginClientBundle( () => ids.delete(normalizedId), ); } + function registerPanel(contribution: PluginWorkspacePanelContribution): PluginCleanup { + const normalizedId = requireId(contribution.id, "workspace panel id"); + if (workspacePanelIds.has(normalizedId)) { + throw new Error(`Duplicate workspace panel: ${normalizedId}`); + } + const title = normalizePluginText(contribution.title); + if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); + const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); + if (typeof contribution.Component !== "function") { + throw new Error(`Workspace panel ${normalizedId} is not a component`); + } + const locations = normalizePanelLocations(normalizedId, contribution.locations); + workspacePanelIds.add(normalizedId); + return register( + collector.workspacePanels, + { + ...contribution, + id: normalizedId, + title, + icon, + locations, + }, + () => workspacePanelIds.delete(normalizedId), + ); + } const pluginContext: PluginClientContext = { ...runtime, addSettingsScreen(contribution) { @@ -313,33 +340,17 @@ export function runPluginClientBundle( () => filePanelIds.delete(panelId), ); }, - addWorkspacePanel(contribution: PluginWorkspacePanelContribution) { - const normalizedId = requireId(contribution.id, "workspace panel id"); - if (workspacePanelIds.has(normalizedId)) { - throw new Error(`Duplicate workspace panel: ${normalizedId}`); + addAgentPanel(contribution: PluginAgentPanelContribution) { + return registerPanel({ ...contribution, context: "agent" }); + }, + addWorkspacePanel(input: WorkspacePanelInput) { + // COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23 + if (input.context === "agent") return pluginContext.addAgentPanel(input); + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + if (input.context !== undefined && input.context !== "workspace") { + throw new Error(`Workspace panel ${input.id} has invalid context`); } - const title = normalizePluginText(contribution.title); - if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); - const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); - if (contribution.context !== "workspace" && contribution.context !== "agent") { - throw new Error(`Workspace panel ${normalizedId} has invalid context`); - } - if (typeof contribution.Component !== "function") { - throw new Error(`Workspace panel ${normalizedId} is not a component`); - } - const locations = normalizePanelLocations(normalizedId, contribution.locations); - workspacePanelIds.add(normalizedId); - return register( - collector.workspacePanels, - { - ...contribution, - id: normalizedId, - title, - icon, - locations, - }, - () => workspacePanelIds.delete(normalizedId), - ); + return registerPanel({ ...input, context: "workspace" }); }, addCommandCenterItem(contribution: PluginCommandCenterItemContribution) { const normalizedId = requireId(contribution.id, "Command Center item id"); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 63d5f1c9f9..9c2a88a210 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -32,7 +32,10 @@ type PluginSidebarContribution = SDKPluginSidebarContribution; type PluginSidebarItemContribution = SDKPluginSidebarItemContribution; type PluginScreenContribution = SDKPluginScreenContribution; type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution; -type PluginWorkspacePanelContribution = SDKPluginWorkspacePanelContribution; +type WorkspacePanelInput = SDKPluginWorkspacePanelContribution; +type PluginWorkspacePanelContribution = + | (Extract & { context: "workspace" }) + | Extract; export type PluginSidebarSection = "header" | "footer"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 5534f78113..096400fb66 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,8 +1,13 @@ -import type { PluginAgentPanelProps, PluginWorkspacePanelProps } from "@getpaseo/plugin/client"; +import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; +import type { + PluginAgentPanelProps, + PluginWorkspacePanelProps, + PluginTab, +} from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { CircleAlert } from "lucide-react-native"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; @@ -29,7 +34,20 @@ const pluginThemeMapping = (theme: Theme) => ({ }); function PluginPanelBody({ theme }: { theme: PluginTheme }) { - const { serverId, workspaceId, target } = usePaneContext(); + const { + serverId, + workspaceId, + target, + state: tabState, + setCurrentTabState, + panelRevision, + } = usePaneContext(); + const [modified, setModified] = useState(false); + usePublishPanelInstanceAttributes({ modified }); + const tab = useMemo( + () => ({ state: tabState, setState: setCurrentTabState, setModified }), + [tabState, setCurrentTabState], + ); invariant(target.kind === "plugin", "PluginPanel requires plugin target"); const plugin = useInstalledPlugin(serverId, target.pluginId); const contribution = resolvePluginWorkspacePanel(plugin, target); @@ -63,7 +81,11 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { let Surface: unknown; if (contribution.context === "workspace") { const props: PluginWorkspacePanelProps = { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 context: "workspace", + params: {}, + revision: panelRevision ?? 0, + tab, theme, host, layout, @@ -75,12 +97,17 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { panel = ; } else if (agentExists && target.context === "agent") { const props: PluginAgentPanelProps = { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 context: "agent", + params: { agentId: target.agentId }, + revision: panelRevision ?? 0, + tab, theme, host, layout, navigation, workspaceId, + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 agentId: target.agentId, }; const Component = contribution.Component; diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index 4c3af57c35..4f0703564f 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,11 +1,11 @@ -import type { PluginWorkspacePanelContribution } from "../types"; +import type { EvaluatedPluginWorkspacePanelContribution } from "../types"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; export function resolvePluginWorkspacePanel( plugin: InstalledPlugin | null, target: PluginWorkspaceTabTarget, -): PluginWorkspacePanelContribution | null { +): EvaluatedPluginWorkspacePanelContribution | null { return ( plugin?.workspacePanels.find( (panel) => panel.id === target.panelId && panel.context === target.context, diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 0ef496a94b..21476a85a4 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -27,7 +27,7 @@ export interface BuildWorkspacePaneContentModelInput { normalizedServerId: string; normalizedWorkspaceId: string; host: PaneHost; - fileNavigationRevision?: number; + panelRevision?: number; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onOpenPreferredTarget: ( target: WorkspaceTabDescriptor["target"], @@ -46,7 +46,7 @@ export function buildWorkspacePaneContentModel({ normalizedServerId, normalizedWorkspaceId, host, - fileNavigationRevision, + panelRevision, onOpenTab, onOpenPreferredTarget, onOpenTargetToSide, @@ -69,7 +69,7 @@ export function buildWorkspacePaneContentModel({ tabId: tab.tabId, target: tab.target, state: tab.state, - fileNavigationRevision, + panelRevision, openTab: onOpenTab, openPreferredTarget: onOpenPreferredTarget, openTargetToSide: onOpenTargetToSide, @@ -112,7 +112,7 @@ export function WorkspacePaneContent({ tabId: paneContextValue.tabId, target: paneContextValue.target, state: paneContextValue.state, - fileNavigationRevision: paneContextValue.fileNavigationRevision, + panelRevision: paneContextValue.panelRevision, openTab, openPreferredTarget, openTargetToSide: paneContextValue.openTargetToSide ? openTargetToSide : undefined, @@ -130,7 +130,7 @@ export function WorkspacePaneContent({ openPreferredTarget, openTargetToSide, paneContextValue.serverId, - paneContextValue.fileNavigationRevision, + paneContextValue.panelRevision, paneContextValue.tabId, paneContextValue.target, paneContextValue.state, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 8c1adf7520..ed1bbfdfcd 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1130,17 +1130,9 @@ function WorkspaceScreenContent({ ) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }), [openTab], ); - // File targets stay identity-stable so the same path reuses its tab. Keep navigation - // requests separate so clicking an unchanged path:line can still recenter the pane. - const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState< - Record - >({}); - const requestFileNavigation = useCallback((tabId: string) => { - setFileNavigationRevisionByTabId((current) => ({ - ...current, - [tabId]: (current[tabId] ?? 0) + 1, - })); - }, []); + const panelRevisions = useWorkspaceLayoutStore((state) => + persistenceKey ? state.panelRevisionByWorkspace[persistenceKey] : undefined, + ); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, @@ -1680,7 +1672,6 @@ function WorkspaceScreenContent({ ? revealWorkspaceChildTab(persistenceKey, target, parentTabId, FOCUSED_PANE_PLACEMENT) : openWorkspaceTabFocused(persistenceKey, target, FOCUSED_PANE_PLACEMENT); if (tabId) { - requestFileNavigation(tabId); navigateToTabId(tabId); } }, @@ -1690,7 +1681,6 @@ function WorkspaceScreenContent({ openWorkspaceTabFocused, revealWorkspaceChildTab, persistenceKey, - requestFileNavigation, showMobileAgent, ], ); @@ -1705,18 +1695,15 @@ function WorkspaceScreenContent({ return; } - const tabId = openPreferredWorkspaceTarget({ + openPreferredWorkspaceTarget({ workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), source: "chatFiles", preferences: openInSidePane, parentTabId: input.parentTabId, }); - if (tabId) { - requestFileNavigation(tabId); - } }, - [openInSidePane, persistenceKey, requestFileNavigation], + [openInSidePane, persistenceKey], ); const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ @@ -1736,14 +1723,11 @@ function WorkspaceScreenContent({ if (request.disposition === "side") { const location = normalizeWorkspaceFileLocation(request.location); if (!location || !persistenceKey) return; - const tabId = openWorkspaceTargetBeside({ + openWorkspaceTargetBeside({ workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), parentTabId, }); - if (tabId) { - requestFileNavigation(tabId); - } return; } if (request.disposition === "preferred") { @@ -2947,7 +2931,7 @@ function WorkspaceScreenContent({ host: input.host ?? (input.paneId !== null && input.paneId === explorerSidebarPaneId ? "explorer" : "main"), - fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0, + panelRevision: panelRevisions?.[input.tab.tabId] ?? 0, onOpenTab: (target) => { if (!persistenceKey) { return; @@ -2964,7 +2948,7 @@ function WorkspaceScreenContent({ }, onOpenPreferredTarget: (target, source) => { if (!persistenceKey) return; - const tabId = openPreferredWorkspacePreview({ + openPreferredWorkspacePreview({ workspaceKey: persistenceKey, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -2974,7 +2958,6 @@ function WorkspaceScreenContent({ source, preferences: openInSidePane, }); - if (tabId && target.kind === "file") requestFileNavigation(tabId); }, onOpenTargetToSide: canRenderDesktopPaneSplits && @@ -2982,12 +2965,11 @@ function WorkspaceScreenContent({ input.paneId === explorerSidebarPaneId ? (target) => { if (!persistenceKey) return; - const tabId = openWorkspaceTargetBeside({ + openWorkspaceTargetBeside({ workspaceKey: persistenceKey, target, parentTabId: input.tab.tabId, }); - if (tabId && target.kind === "file") requestFileNavigation(tabId); } : undefined, onCloseCurrentTab: () => { @@ -3016,7 +2998,7 @@ function WorkspaceScreenContent({ }), [ handleCloseTabById, - fileNavigationRevisionByTabId, + panelRevisions, handleOpenWorkspaceFileFromPane, navigateToTabId, normalizedServerId, @@ -3024,7 +3006,6 @@ function WorkspaceScreenContent({ canRenderDesktopPaneSplits, openImportSheet, openInSidePane, - requestFileNavigation, revealWorkspaceChildTab, persistenceKey, replaceWorkspaceTabTarget, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index cfac2f999a..431d550823 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -79,6 +79,39 @@ function createDeterministicWorkspaceLayoutIds() { const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds); +it("bumps panel params revision when the same tab is opened again", () => { + const storage = { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, + }; + const store = createWorkspaceLayoutStore(workspaceLayoutIds, storage); + const workspaceKey = "server-1:workspace-params"; + const targets = [ + { kind: "file" as const, path: "notes.md", lineStart: 3 }, + { + kind: "plugin" as const, + pluginId: "notes", + panelId: "workspace", + context: "workspace" as const, + }, + { + kind: "plugin" as const, + pluginId: "notes", + panelId: "agent", + context: "agent" as const, + agentId: "agent-1", + }, + ]; + for (const target of targets) { + const first = store.getState().openTab({ workspaceKey, target, intent: "reveal" })!; + const revision = store.getState().panelRevisionByWorkspace[workspaceKey][first]; + const second = store.getState().openTab({ workspaceKey, target, intent: "reveal" }); + expect(second).toBe(first); + expect(store.getState().panelRevisionByWorkspace[workspaceKey][first]).toBe(revision + 1); + } +}); + it("observes open chats across unmounted workspaces until their tabs close", () => { const store = createWorkspaceLayoutStore(workspaceLayoutIds); store.setState({ layoutByWorkspace: {} }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 4091b7d53c..60f96ae330 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -128,6 +128,7 @@ export type WorkspaceTabDestination = | { kind: "sheet"; tabId: string }; interface WorkspaceLayoutStore { + panelRevisionByWorkspace: Record>; /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; pendingOpenByWorkspace: Record; @@ -997,6 +998,22 @@ function createPersistedStateProjection() { }; } +function advancePanelRevision( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, + target: WorkspaceTabTarget, +) { + if (target.kind !== "file" && target.kind !== "plugin") return {}; + const revisions = state.panelRevisionByWorkspace[workspaceKey] ?? {}; + return { + panelRevisionByWorkspace: { + ...state.panelRevisionByWorkspace, + [workspaceKey]: { ...revisions, [tabId]: (revisions[tabId] ?? 0) + 1 }, + }, + }; +} + export function createWorkspaceLayoutStore( ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds, storage: StateStorage = AsyncStorage, @@ -1023,6 +1040,7 @@ export function createWorkspaceLayoutStore( } return tabId; }, + panelRevisionByWorkspace: {}, pendingOpenByWorkspace: {}, deferOpenTab: (input) => { set((state) => ({ @@ -1095,7 +1113,15 @@ export function createWorkspaceLayoutStore( ? [existing] : []; }); - set((state) => withSheets(state, normalizedWorkspaceKey, next)); + set((state) => ({ + ...withSheets(state, normalizedWorkspaceKey, next), + ...advancePanelRevision( + state, + normalizedWorkspaceKey, + sheet.tab.tabId, + normalizedTarget, + ), + })); return sheet.tab.tabId; } const placement = getOpenTabPlacement( @@ -1151,6 +1177,7 @@ export function createWorkspaceLayoutStore( const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + ...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget), // A tab brought to the front dismisses the sheet over it. ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: @@ -1530,7 +1557,15 @@ export function createWorkspaceLayoutStore( })), }; if (policy) { - set({ sheetsByWorkspace: next.sheetsByWorkspace }); + set((state) => ({ + sheetsByWorkspace: next.sheetsByWorkspace, + ...advancePanelRevision( + state, + normalizedWorkspaceKey, + normalizedTabId, + normalizedTarget, + ), + })); return normalizedTabId; } // A target sheets do not host becomes the tab it would have opened as. @@ -1548,6 +1583,7 @@ export function createWorkspaceLayoutStore( if (!result) return null; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + ...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index d69f5f44b0..e2547154bc 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -70,6 +70,26 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien return violations; } +function typeErrors(code: string): string[] { + const configPath = path.resolve(sourceDirectory, "../tsconfig.examples.json"); + const { config } = ts.readConfigFile(configPath, ts.sys.readFile); + const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath)); + const file = path.resolve( + sourceDirectory, + "../../../plugin-examples/skill-example/client/example.tsx", + ); + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile; + host.getSourceFile = (name, language, ...rest) => + path.resolve(name) === file + ? ts.createSourceFile(name, code, language) + : getSourceFile.call(host, name, language, ...rest); + host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name); + const program = ts.createProgram([file], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +} describe("plugin SDK import boundaries", () => { it("publishes process capabilities without their Windows implementation helpers", () => { expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); @@ -142,3 +162,33 @@ describe("plugin example import boundaries", () => { expect(violations).toEqual([]); }); }); + +describe("plugin panel SDK boundary", () => { + it("accepts uniform params and persisted tab state alongside 0.12 panels", () => { + expect( + typeErrors(` + import { type PluginClientContext, type PluginFilePanelProps, type PluginWorkspacePanelProps, type PluginAgentPanelProps, type PluginTab, useTabState } from "@getpaseo/plugin/client"; + import { z } from "zod"; + function FilePanel({ params, revision, tab, file }: PluginFilePanelProps) { + const [state, setState] = useTabState(tab, z.object({ view: z.enum(["preview", "source"]) }), { view: "preview" }); + setState({ view: "source" }); + tab.setState({ nested: [true, null, 3] }); + // @ts-expect-error only JSON state crosses the host boundary + tab.setState(() => {}); + // @ts-expect-error params belong to the opener + params.path = "other"; + return null; + } + function OldWorkspace({ context, workspaceId }: PluginWorkspacePanelProps) { return null; } + function OldAgent({ context, workspaceId, agentId }: PluginAgentPanelProps) { return null; } + function Agent({ params }: PluginAgentPanelProps) { params.agentId.toUpperCase(); return null; } + function contribute(client: PluginClientContext) { + client.addWorkspacePanel({ id: "old-workspace", title: "Old", icon: "File", context: "workspace", Component: OldWorkspace }); + client.addWorkspacePanel({ id: "old-agent", title: "Old", icon: "File", context: "agent", Component: OldAgent }); + client.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component: OldWorkspace }); + client.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component: Agent }); + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 5e268d0849..7675a655ff 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,9 +1,9 @@ import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; import type { PaseoApi } from "@getpaseo/client"; -import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; -import type { PluginFile, PluginFileLocation, PluginFileTab } from "./file.js"; +import type { PluginFile } from "./file.js"; import type { PluginRpcContract } from "../rpc.js"; import type { PluginButtonRegistration, @@ -111,23 +111,44 @@ interface PluginWorkspacePanelBase { locations?: readonly PluginPanelLocation[]; } -export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { - context: "workspace"; - workspaceId: string; +export interface PluginTab { + /** Shows the tab's unsaved dot and asks before closing. */ + setModified(modified: boolean): void; + /** Opaque persisted JSON. Undefined until written; the panel validates it. */ + readonly state: unknown; + setState(state: JsonValue): void; } -export interface PluginAgentPanelProps extends PluginNavigableHostProps { - context: "agent"; +export interface PluginPanelProps extends PluginNavigableHostProps { workspaceId: string; + readonly params: Readonly; + revision: number; + tab: PluginTab; +} +export interface PluginWorkspacePanelParams {} +export interface PluginAgentPanelParams { + readonly agentId: string; +} +export interface PluginFilePanelParams { + readonly path: string; + readonly lineStart?: number; + readonly lineEnd?: number; +} +export interface PluginWorkspacePanelProps extends PluginPanelProps { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Use the panel registration to select the context. */ + context: "workspace"; +} +export interface PluginAgentPanelProps extends PluginPanelProps { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Use the panel registration to select the context. */ + context: "agent"; + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Read `params.agentId`. */ agentId: string; } - -export interface PluginFilePanelProps extends PluginNavigableHostProps { - workspaceId: string; - /** The file itself. The panel decides how to read it, show it, and save it. */ +export interface PluginFilePanelProps extends PluginPanelProps { file: PluginFile; - tab: PluginFileTab; - location: PluginFileLocation; } export interface PluginFilePanelContribution { @@ -160,6 +181,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl addScreen(contribution: PluginScreenContribution): PluginCleanup; addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addAgentPanel(contribution: PluginAgentPanelContribution): PluginCleanup; addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; addCommandCenterItem( contribution: PluginCommandCenterItemContribution, @@ -187,14 +209,24 @@ export type PluginClientContribution = (client: PluginClientContext) => PluginCl export type PluginWorkspacePanelContribution = | (PluginWorkspacePanelBase & { - context: "workspace"; + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Omit context. */ + context?: "workspace"; Component: ComponentType; }) | (PluginWorkspacePanelBase & { + // COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Register with `addAgentPanel`. */ context: "agent"; Component: ComponentType; }); +export interface PluginAgentPanelContribution< + Text extends PluginText = string, +> extends PluginWorkspacePanelBase { + Component: ComponentType; +} + export interface PluginSettingsScreenContribution { id: string; title: Text; diff --git a/packages/plugin/src/client/file.ts b/packages/plugin/src/client/file.ts index 2c11fcdd0a..7f37e31c9c 100644 --- a/packages/plugin/src/client/file.ts +++ b/packages/plugin/src/client/file.ts @@ -44,19 +44,6 @@ export interface PluginFileWriteOptions { /** What happened to the file on disk. Switch on `type`; later versions may add fields. */ export type PluginFileEvent = { type: "changed" } | { type: "deleted" }; -/** What the panel reports back to its tab. */ -export interface PluginFileTab { - /** Shows the tab's unsaved dot and asks before the tab closes. */ - setModified(modified: boolean): void; -} - -/** The line a link or search result opened the file at. `revision` bumps on every new target. */ -export interface PluginFileLocation { - lineStart?: number; - lineEnd?: number; - revision: number; -} - /** The file changed on disk since the handle last read or wrote it. */ export class PluginFileConflictError extends Error { constructor(message = "The file changed on disk.") { diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index db4292453e..62cf7e2dc3 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -1,5 +1,11 @@ export type { PluginHostProps, + PluginTab, + PluginPanelProps, + PluginWorkspacePanelParams, + PluginAgentPanelParams, + PluginFilePanelParams, + PluginAgentPanelContribution, PluginFilePanelContribution, PluginFilePanelProps, PluginSurfaceProps, @@ -52,8 +58,6 @@ export type { PluginFile, PluginFileBytesState, PluginFileEvent, - PluginFileLocation, - PluginFileTab, PluginFileTextState, PluginFileUrlState, PluginFileWriteOptions, @@ -91,3 +95,5 @@ export declare function openExternalUrl(url: string): Promise; export { defineTranslations, useTranslation } from "./translations.js"; export type { PluginI18n } from "./translations.js"; export type { PluginText, PluginMessage, PluginTranslations } from "../text.js"; + +export { useTabState } from "./tab.js"; diff --git a/packages/plugin/src/client/tab.ts b/packages/plugin/src/client/tab.ts new file mode 100644 index 0000000000..a0d11058a0 --- /dev/null +++ b/packages/plugin/src/client/tab.ts @@ -0,0 +1,16 @@ +import { useCallback, useMemo } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { ZodType } from "zod"; +import type { PluginTab } from "./contracts.js"; + +/** Validates the panel's persisted state, using the default for missing or invalid values. */ +export function useTabState( + tab: PluginTab, + schema: ZodType, + defaultState: T, +): [T, (next: T) => void] { + const parsed = useMemo(() => schema.safeParse(tab.state), [schema, tab.state]); + const writeState = tab.setState; + const setState = useCallback((next: T) => writeState(next), [writeState]); + return [parsed.success ? parsed.data : defaultState, setState]; +} diff --git a/plugin-examples/markdown-panel/client/rich-markdown.tsx b/plugin-examples/markdown-panel/client/rich-markdown.tsx index b11eb4bf51..765e1692c6 100644 --- a/plugin-examples/markdown-panel/client/rich-markdown.tsx +++ b/plugin-examples/markdown-panel/client/rich-markdown.tsx @@ -1,10 +1,12 @@ +import { z } from "zod"; import { PluginFileConflictError, PluginFileDeletedError, type PluginFilePanelProps, useFileText, + useTabState, } from "@getpaseo/plugin/client"; -import { Loading } from "@getpaseo/plugin/client/ui"; +import { Loading, Markdown } from "@getpaseo/plugin/client/ui"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Pressable, ScrollView, Text, View } from "react-native"; import { type MarkdownEditorHandle, mountEditor } from "./web"; @@ -17,7 +19,7 @@ type EditorState = "loading" | "ready" | "conflict" | "deleted"; * Reads the file once, writes 500 ms after the last edit, and follows the disk while nothing is * unsaved. With unsaved edits, a change on disk is a conflict: Reload or Overwrite. */ -function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { +function MarkdownEditor({ file, tab: { setModified }, theme }: PluginFilePanelProps) { const parent = useRef(null); const editor = useRef(null); const text = useRef(""); @@ -34,11 +36,11 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { return; } unsaved.current = null; - tab.setModified(false); + setModified(false); editor.current?.setText(text.current); setError(null); setState("ready"); - }, [file, tab]); + }, [file, setModified]); const save = useCallback( async (force: boolean) => { @@ -54,19 +56,19 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { } if (unsaved.current === next) { unsaved.current = null; - tab.setModified(false); + setModified(false); } setError(null); setState("ready"); }, - [file, tab], + [file, setModified], ); useLayoutEffect(() => { editor.current = mountEditor(parent.current, text.current, theme, (next) => { text.current = next; unsaved.current = next; - tab.setModified(true); + setModified(true); clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => void save(false), SAVE_DELAY_MS); }); @@ -74,7 +76,7 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { editor.current?.destroy(); editor.current = null; }; - }, [save, tab, theme]); + }, [save, setModified, theme]); useEffect(() => { void load(); @@ -166,9 +168,63 @@ function MarkdownText({ file, theme }: PluginFilePanelProps) { ); } +const viewSchema = z.object({ view: z.enum(["preview", "source"]) }); +const defaultView = { view: "source" } as const; + +function MarkdownPreview({ file, theme }: PluginFilePanelProps) { + const text = useFileText(file); + const errorStyle = useMemo(() => ({ color: theme.colors.statusDanger }), [theme]); + if (text.status === "loading") return ; + if (text.status === "error") return {text.error.message}; + return ( + + {text.text} + + ); +} + +const toolbarStyle = { flexDirection: "row", gap: 16, padding: 12 } as const; +const selected = { selected: true }; +const unselected = { selected: false }; + export function RichMarkdownPanel(props: PluginFilePanelProps) { - if (props.layout.platform === "web") return ; - return ; + const [state, setState] = useTabState(props.tab, viewSchema, defaultView); + const styles = useMemo( + () => ({ + panel: { flex: 1, minHeight: 0, backgroundColor: props.theme.colors.surface0 }, + label: { color: props.theme.colors.foreground }, + }), + [props.theme], + ); + const showPreview = useCallback(() => setState({ view: "preview" }), [setState]); + const showSource = useCallback(() => setState({ view: "source" }), [setState]); + let content; + if (state.view === "preview") content = ; + else if (props.layout.platform === "web") content = ; + else content = ; + return ( + + + + Preview + + + Source + + + {content} + + ); } function messageOf(cause: unknown): string { diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index 8d08343ec0..f430dff92a 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -75,6 +75,7 @@ Use this table as the complete registration checklist. | New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` | | New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` | | `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | +| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` | | `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | | `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | | `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | @@ -96,6 +97,22 @@ Import `PluginClientContext` from `@getpaseo/plugin/client` and `PluginServerCon `@getpaseo/plugin/server`. Remove imports of the old context type. Client registrations return idempotent removal functions, except header buttons and composer pills, which return `{ update, remove }` handles. Preserve any remover the plugin calls before teardown; Paseo removes outstanding registrations after the entry cleanup runs. +### Panel props in 0.13 + +All panel kinds share [params, revision, and persisted tab state](./reference.md#workspace-panels). + +| Previous API | Replacement | +| ----------------------------------------------------------------------- | ------------------------------------------------ | +| Workspace/agent `context` prop | Select the kind at registration. | +| Agent `agentId` prop | `params.agentId` | +| `addWorkspacePanel({ context: "workspace", ... })` | `addWorkspacePanel({ ... })` | +| `addWorkspacePanel({ context: "agent", ... })` | `addAgentPanel({ ... })` | +| Beta file `location.lineStart`, `location.lineEnd`, `location.revision` | `params.lineStart`, `params.lineEnd`, `revision` | +| Beta `PluginFileTab`, `PluginFileLocation` | `PluginTab`, `PluginFilePanelParams` | + +The 0.12 aliases remain available until removal after 2027-04-23. The beta file API changes +outright. Declare `requirements.paseo` as `>=0.13.0` when adopting the new API. + ### Composer pills Update the plugin project's `@getpaseo/plugin` dependency, then run `npm run typecheck`. diff --git a/public-docs/plugins/migration.zh-CN.md b/public-docs/plugins/migration.zh-CN.md index cb38831b67..600b65f4d9 100644 --- a/public-docs/plugins/migration.zh-CN.md +++ b/public-docs/plugins/migration.zh-CN.md @@ -68,6 +68,7 @@ my-plugin/ | 新的侧边栏底部贡献 | `index.client.tsx` 中的 `client.addSidebarFooterItem({ id, title, Component })` | | 新的文件面板贡献 | `index.client.tsx` 中的 `client.addFilePanel(panel)` | | 旧根入口中的 `plugin.addWorkspacePanel(panel)` | `index.client.tsx` 中的 `client.addWorkspacePanel(panel)` | +| 带有 `context: "agent"` 的 Agent 面板 | `index.client.tsx` 中的 `client.addAgentPanel(panel)`;省略 `context` | | 旧根入口中的 `plugin.addCommandCenterItem(item)` | `index.client.tsx` 中的 `client.addCommandCenterItem(item)` | | 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` | | 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 | @@ -85,6 +86,22 @@ my-plugin/ 从 `@getpaseo/plugin/client` 导入 `PluginClientContext`,从 `@getpaseo/plugin/server` 导入 `PluginServerContext`。删除对旧 context 类型的导入。客户端注册返回幂等的移除函数,但标题栏按钮和 Composer 标签除外,它们返回 `{ update, remove }` 句柄。保留插件在销毁之前调用的所有移除函数;入口的清理函数运行后,Paseo 会移除尚未移除的注册。 +### 0.13 的面板属性 {#panel-props-in-013} + +所有面板类型共享 [params、revision 和持久化的标签页状态](./reference.md#workspace-panels)。 + +| 旧 API | 替代方式 | +| ----------------------------------------------------------------------------- | ------------------------------------------------ | +| 工作区/Agent 面板的 `context` 属性 | 在注册时选择面板类型。 | +| Agent 面板的 `agentId` 属性 | `params.agentId` | +| `addWorkspacePanel({ context: "workspace", ... })` | `addWorkspacePanel({ ... })` | +| `addWorkspacePanel({ context: "agent", ... })` | `addAgentPanel({ ... })` | +| Beta 文件面板的 `location.lineStart`、`location.lineEnd`、`location.revision` | `params.lineStart`、`params.lineEnd`、`revision` | +| Beta 的 `PluginFileTab`、`PluginFileLocation` | `PluginTab`、`PluginFilePanelParams` | + +0.12 的别名会保留到 2027-04-23 之后移除。Beta 文件 API 直接替换。采用新 API 时,将 +`requirements.paseo` 声明为 `>=0.13.0`。 + ### Composer 标签 {#composer-pills} 更新插件项目的 `@getpaseo/plugin` 依赖,然后运行 `npm run typecheck`。旧的贡献缺少必需的 `button` 字段,`PluginComposerPillProps` 不再导出,并且把新的注册当作函数调用会产生 TypeScript 错误。固定在旧 SDK 上的项目仍然按旧的契约检查;安装或重新加载插件并不会运行 TypeScript 的类型检查器。 diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index c35a62ca33..81b99b0dd0 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1771,7 +1771,8 @@ for immediate controls, a draft editor with validation, custom content, and Comm ## Workspace panels -Register one panel for workspace or agent context: +Register workspace panels with `addWorkspacePanel` and agent panels with `addAgentPanel`. +Both receive the same panel contract as [file panels](#file-panels). `client/review.tsx`: @@ -1780,9 +1781,9 @@ import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/pl import { useMemo } from "react"; import { Text, View } from "react-native"; -export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ theme, layout, workspaceId, params }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); - const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); + const agent = useAgent(params.agentId, ({ id, title }) => ({ id, title })); const styles = useMemo( () => ({ screen: { @@ -1811,11 +1812,10 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { - client.addWorkspacePanel({ + client.addAgentPanel({ id: "review", title: "Review", icon: "Scan", - context: "agent", locations: ["workspace", "explorer"], Component: ReviewPanel, }); @@ -1823,20 +1823,43 @@ export default function contribute(client: PluginClientContext) { } ``` -`addWorkspacePanel` fields: +`addWorkspacePanel` and `addAgentPanel` fields: | Field | Required | Meaning | | ----------- | -------- | ------------------------------------------------------------- | | `id` | Yes | Plugin-local panel ID. | | `title` | Yes | Workspace-tab title. | | `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | -| `context` | Yes | `workspace` or `agent`. | | `locations` | No | `workspace` and/or `explorer`. Defaults to `workspace`. | | `Component` | Yes | React Native component matching the selected context's props. | -A workspace panel receives `PluginWorkspacePanelProps`: `context: "workspace"`, `theme`, `host`, `layout`, and `workspaceId`. An agent panel receives `PluginAgentPanelProps`: `context: "agent"`, the same common fields and `workspaceId`, plus `agentId`. +Every panel receives `PluginPanelProps` with `theme`, `host`, `layout`, `navigation`, +`workspaceId`, read-only `params`, `revision`, and `tab`. Workspace panels have empty +`PluginWorkspacePanelParams`; agent panels have `PluginAgentPanelParams` containing `agentId`. +`revision` increments whenever params are re-sent to the same tab, including an identical request. -Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot. +`tab.setModified(boolean)` marks unsaved work and asks before closing. `tab.state` is opaque +persisted JSON, initially `undefined`; `tab.setState(value)` replaces it. Each tab owns its state. +Paseo stores it without interpreting it. Validate it with `useTabState(tab, schema, defaultState)`: + +```tsx +const schema = z.object({ view: z.enum(["preview", "source"]) }); +const [state, setState] = useTabState(tab, schema, { view: "preview" }); +// Missing or invalid persisted state returns the default. +// In an event handler, call setState({ view: "source" }). +``` + +Import `useTabState` from `@getpaseo/plugin/client` and `z` from `zod`. The setter replaces the +entire JSON value. State survives tab restoration and app reloads. + +The unified props, `addAgentPanel`, and `useTabState` require Paseo 0.13.0 or later. + +**Deprecated until removal after 2027-04-23:** the 0.12 panel `context` and flat `agentId` props, +`addWorkspacePanel({ context: "workspace" })`, and `addWorkspacePanel({ context: "agent" })` +continue to work. Omit workspace `context`, register agent panels with `addAgentPanel`, and read +`params.agentId`. + +Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(params.agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot. Both hooks return `null` when the record is unavailable. Otherwise they run synchronously against normalized client state. Snapshot DTOs and their nested values are deeply readonly and frozen at runtime. Do not call plugin RPC to discover the current workspace or agent. Fetch optional or vendor-specific enrichment after the component renders. @@ -1923,12 +1946,13 @@ for a filename-only selector. At least one extension or filename is required. `PluginFilePanelProps` includes the common `theme`, `host`, `layout`, and optional `navigation` props described under [Theme and layout](#theme-and-layout), plus: -| Prop | Type | Meaning | -| ------------- | -------------------- | ------------------------------------------------------------------------------- | -| `workspaceId` | `string` | Workspace containing the file tab. | -| `file` | `PluginFile` | Handle to the file. Stable while the tab shows this file. | -| `tab` | `PluginFileTab` | `setModified(modified)` shows the tab's unsaved dot and confirms before close. | -| `location` | `PluginFileLocation` | `lineStart?`, `lineEnd?`, and `revision`, which increments on every navigation. | +| Prop | Type | Meaning | +| ------------- | ----------------------- | --------------------------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Workspace containing the file tab. | +| `params` | `PluginFilePanelParams` | Read-only `path`, `lineStart?`, and `lineEnd?` from the opener. | +| `revision` | `number` | Increments each time params are re-sent to this tab, even for the same line. | +| `file` | `PluginFile` | File capability resolved from `params.path`, stable while the tab shows this file. | +| `tab` | `PluginTab` | `setModified`, opaque persisted `state`, and `setState`. See [Workspace panels](#workspace-panels). | Paseo mounts your panel once it knows the file exists and its size, without reading it. The `file` object is stable, and its stat fields hold the latest version Paseo knows. Read them while diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 9b9f443cba..62caec83e5 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1525,7 +1525,8 @@ if (current.status === "ready") { ## 工作区面板 {#workspace-panels} -为工作区或 Agent 上下文注册一个面板: +使用 `addWorkspacePanel` 注册工作区面板,使用 `addAgentPanel` 注册 Agent 面板。 +两者与[文件面板](#file-panels)使用相同的面板契约。 `client/review.tsx`: @@ -1534,9 +1535,9 @@ import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/pl import { useMemo } from "react"; import { Text, View } from "react-native"; -export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ theme, layout, workspaceId, params }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); - const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); + const agent = useAgent(params.agentId, ({ id, title }) => ({ id, title })); const styles = useMemo( () => ({ screen: { @@ -1565,11 +1566,10 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { - client.addWorkspacePanel({ + client.addAgentPanel({ id: "review", title: "Review", icon: "Scan", - context: "agent", locations: ["workspace", "explorer"], Component: ReviewPanel, }); @@ -1577,20 +1577,42 @@ export default function contribute(client: PluginClientContext) { } ``` -`addWorkspacePanel` 的字段: +`addWorkspacePanel` 和 `addAgentPanel` 的字段: | 字段 | 是否必填 | 含义 | | ----------- | -------- | --------------------------------------------------- | | `id` | 是 | 插件内部的面板 ID。 | | `title` | 是 | 工作区标签页的标题。 | | `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 | -| `context` | 是 | `workspace` 或 `agent`。 | | `locations` | 否 | `workspace` 和/或 `explorer`。默认为 `workspace`。 | | `Component` | 是 | 与所选上下文的属性相匹配的 React Native 组件。 | -工作区面板接收 `PluginWorkspacePanelProps`:`context: "workspace"`、`theme`、`host`、`layout` 和 `workspaceId`。Agent 面板接收 `PluginAgentPanelProps`:`context: "agent"`、相同的通用字段和 `workspaceId`,外加 `agentId`。 +每个面板都接收 `PluginPanelProps`,包含 `theme`、`host`、`layout`、`navigation`、 +`workspaceId`、只读的 `params`、`revision` 和 `tab`。工作区面板使用空的 +`PluginWorkspacePanelParams`;Agent 面板的 `PluginAgentPanelParams` 包含 `agentId`。 +每次向同一标签页重新发送 params 时,`revision` 都会递增,即使请求与上次相同。 -用 `useWorkspace(workspaceId, selector)` 和 `useAgent(agentId, selector)` 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 `{ name, status }` 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。 +`tab.setModified(boolean)` 标记未保存的更改,并在关闭前询问。`tab.state` 是由面板定义的持久化 +JSON,初始为 `undefined`;`tab.setState(value)` 替换整个值。每个标签页拥有独立状态。 +Paseo 只保存状态,不解释其内容。用 `useTabState(tab, schema, defaultState)` 验证它: + +```tsx +const schema = z.object({ view: z.enum(["preview", "source"]) }); +const [state, setState] = useTabState(tab, schema, { view: "preview" }); +// Missing or invalid persisted state returns the default. +// In an event handler, call setState({ view: "source" }). +``` + +从 `@getpaseo/plugin/client` 导入 `useTabState`,从 `zod` 导入 `z`。setter 替换整个 JSON 值。 +标签页恢复和应用重新加载后,状态仍然保留。 + +统一属性、`addAgentPanel` 和 `useTabState` 需要 Paseo 0.13.0 或更高版本。 + +**已弃用,将在 2027-04-23 之后移除**:0.12 面板的 `context` 和顶层 `agentId` 属性、 +`addWorkspacePanel({ context: "workspace" })` 以及 `addWorkspacePanel({ context: "agent" })` +仍然有效。省略工作区的 `context`,用 `addAgentPanel` 注册 Agent 面板,并读取 `params.agentId`。 + +用 `useWorkspace(workspaceId, selector)` 和 `useAgent(params.agentId, selector)` 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 `{ name, status }` 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。 当记录不可用时,两个 hook 都返回 `null`。否则它们会针对规范化的客户端状态同步运行。快照 DTO 及其嵌套值都是深度只读的,并在运行时被冻结。不要调用插件 RPC 来获取当前的工作区或 Agent。可选的或特定于厂商的补充数据,请在组件渲染之后再获取。 @@ -1670,12 +1692,13 @@ export default function contribute(client: PluginClientContext) { `PluginFilePanelProps` 包含[主题和布局](#theme-and-layout)中介绍的通用属性 `theme`、`host`、`layout` 和可选的 `navigation`,另外还有: -| 属性 | 类型 | 含义 | -| ------------- | -------------------- | ------------------------------------------------------------------ | -| `workspaceId` | `string` | 包含该文件标签页的工作区。 | -| `file` | `PluginFile` | 文件的句柄。标签页显示该文件期间保持不变。 | -| `tab` | `PluginFileTab` | `setModified(modified)` 会显示标签页的未保存圆点,并在关闭前确认。 | -| `location` | `PluginFileLocation` | `lineStart?`、`lineEnd?` 和 `revision`,后者在每次导航时递增。 | +| 属性 | 类型 | 含义 | +| ------------- | ----------------------- | ------------------------------------------------------------------------------------------ | +| `workspaceId` | `string` | 包含该文件标签页的工作区。 | +| `params` | `PluginFilePanelParams` | 打开请求提供的只读 `path`、`lineStart?` 和 `lineEnd?`。 | +| `revision` | `number` | 每次向该标签页重新发送 params 时递增,即使是同一行。 | +| `file` | `PluginFile` | 从 `params.path` 解析的文件能力;标签页显示该文件期间保持不变。 | +| `tab` | `PluginTab` | `setModified`、持久化的自定义 `state` 和 `setState`。参见[工作区面板](#workspace-panels)。 | Paseo 在知道文件存在及其大小后就会挂载你的面板,不会先读取文件。`file` 对象保持不变,它的 stat 字段保存 Paseo 已知的最新版本。请在渲染时读取这些字段;它们变化时不会重新渲染你的面板。面板要通过 `watch` 监听器,或 `useFileText`、`useFileBytes`、`useFileUrl` 引起的重新渲染,得知它们已变化: