Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ interface AppHeaderProps {
* the shared header shows plugin logo + title, plus the registration's
* `headerContent` as the actions. */
pluginPanel?: PluginNavPanelSlot;
/** The panel route's splat remainder ("" at the panel root). */
pluginPanelSubPath?: string;
meta: {
title: string;
subtitle?: string;
Expand All @@ -260,6 +262,7 @@ function AppHeader({
projectId,
project,
pluginPanel,
pluginPanelSubPath,
meta,
}: AppHeaderProps) {
const headerBreadcrumbs = meta.breadcrumbs;
Expand Down Expand Up @@ -325,7 +328,10 @@ function AppHeader({
) : null;

const actions = pluginPanel ? (
<PluginPanelHeaderActions panel={pluginPanel} />
<PluginPanelHeaderActions
panel={pluginPanel}
subPath={pluginPanelSubPath ?? ""}
/>
) : usesProjectChromeStyle &&
projectId &&
!isProjectlessProjectId(projectId) ? (
Expand Down Expand Up @@ -684,6 +690,7 @@ export function AppLayout({ children }: AppLayoutProps) {
projectId={projectId}
project={project}
pluginPanel={pluginPanel}
pluginPanelSubPath={pluginPanelMatch?.params["*"] ?? ""}
meta={meta}
/>
) : null}
Expand Down
68 changes: 68 additions & 0 deletions apps/app/src/components/plugin/PluginPanelActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
type PluginThreadPanelActionSlot,
} from "@/lib/plugin-slots";
import type { PluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import {
fileOpenerIdFromActionId,
parseFileOpenerParams,
} from "./file-opener-tabs";
import { PluginSlotMount } from "./PluginSlotMount";

/**
Expand Down Expand Up @@ -124,6 +128,20 @@ export function PluginPanelTabContent({
}: {
tab: PluginPanelFixedPanelTab;
threadId: string | null | undefined;
}) {
const openerId = fileOpenerIdFromActionId(tab.actionId);
if (openerId !== null) {
return <FileOpenerTabContent openerId={openerId} tab={tab} />;
}
return <ActionTabContent tab={tab} threadId={threadId} />;
}

function ActionTabContent({
tab,
threadId,
}: {
tab: PluginPanelFixedPanelTab;
threadId: string | null | undefined;
}) {
const { threadPanelActions } = usePluginSlots();
const action =
Expand Down Expand Up @@ -169,3 +187,53 @@ export function PluginPanelTabContent({
</div>
);
}

/**
* A file diverted to a plugin `fileOpener` (see file-opener-tabs.ts). Same
* degrade rules as action tabs: missing opener/plugin or unparsable params
* render a placeholder, never a crash.
*/
function FileOpenerTabContent({
openerId,
tab,
}: {
openerId: string;
tab: PluginPanelFixedPanelTab;
}) {
const { fileOpeners } = usePluginSlots();
const opener =
fileOpeners.find(
(candidate) =>
candidate.pluginId === tab.pluginId && candidate.id === openerId,
) ?? null;
const file = useMemo(
() => parseFileOpenerParams(tab.paramsJson),
[tab.paramsJson],
);
if (opener === null || file === null) {
return (
<div className="p-4">
<EmptyStatePanel className="rounded-lg p-6 text-sm">
This file opener is not available. The plugin may still be loading,
or it has been disabled or removed — reopen the file to use the
built-in preview.
</EmptyStatePanel>
</div>
);
}
return (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
data-testid="plugin-file-opener-tab-content"
>
<PluginSlotMount
key={`${opener.pluginId}/${opener.id}/${opener.generation}`}
pluginId={opener.pluginId}
slotKind="fileOpener"
slotId={opener.id}
>
<opener.component path={file.path} source={file.source} />
</PluginSlotMount>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/app/src/components/plugin/PluginPanelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ export function PluginPanelHeaderCenter({
*/
export function PluginPanelHeaderActions({
panel,
subPath,
}: {
panel: PluginNavPanelSlot;
subPath: string;
}) {
const HeaderContent = panel.headerContent;
if (HeaderContent === undefined || panel.chrome === "none") return null;
Expand All @@ -83,7 +85,7 @@ export function PluginPanelHeaderActions({
data-bb-plugin={panel.pluginId}
className="flex shrink-0 items-center gap-2"
>
<HeaderContent />
<HeaderContent subPath={subPath} />
</div>
</PluginContext.Provider>
</HeaderContentBoundary>
Expand Down
195 changes: 195 additions & 0 deletions apps/app/src/components/plugin/file-opener-tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import type { PluginFileOpenerProps, PluginFileOpenerSource } from "@bb/plugin-sdk";
import {
createPluginPanelFixedPanelTab,
type PluginPanelFixedPanelTab,
} from "@/lib/fixed-panel-tabs-state";
import {
resolvePreferredFileOpener,
type FileOpenerPreferenceMap,
} from "@/lib/file-opener-preference";
import type { PluginFileOpenerSlot } from "@/lib/plugin-slots";
import type { OpenSecondaryPanelTabRequest } from "@/components/secondary-panel/useThreadFileTabs";

/**
* Plugin file-opener tabs ride the existing `plugin-panel` tab kind: the
* action id carries this prefix + the opener's id, and `paramsJson` persists
* the opened file (`PluginFileOpenerProps`). Same identity semantics as
* action tabs — same opener + same file focuses the existing tab.
*/
export const FILE_OPENER_ACTION_ID_PREFIX = "file-opener:";

export function isFileOpenerPanelTab(tab: PluginPanelFixedPanelTab): boolean {
return tab.actionId.startsWith(FILE_OPENER_ACTION_ID_PREFIX);
}

export function fileOpenerIdFromActionId(actionId: string): string | null {
return actionId.startsWith(FILE_OPENER_ACTION_ID_PREFIX)
? actionId.slice(FILE_OPENER_ACTION_ID_PREFIX.length)
: null;
}

export function buildFileOpenerPanelTab(
opener: Pick<PluginFileOpenerSlot, "id" | "pluginId">,
file: PluginFileOpenerProps,
): PluginPanelFixedPanelTab {
return createPluginPanelFixedPanelTab({
actionId: `${FILE_OPENER_ACTION_ID_PREFIX}${opener.id}`,
paramsJson: JSON.stringify({ path: file.path, source: file.source }),
pluginId: opener.pluginId,
title: file.path.split("/").at(-1) ?? file.path,
});
}

/** Parse a persisted opener tab's params; null on any mismatch (degrade). */
export function parseFileOpenerParams(
paramsJson: string | null,
): PluginFileOpenerProps | null {
if (paramsJson === null) return null;
let parsed: unknown;
try {
parsed = JSON.parse(paramsJson);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const { path, source } = parsed as { path?: unknown; source?: unknown };
if (typeof path !== "string" || path.length === 0) return null;
if (typeof source !== "object" || source === null) return null;
const { kind, threadId, environmentId, projectId } = source as {
kind?: unknown;
threadId?: unknown;
environmentId?: unknown;
projectId?: unknown;
};
if (kind !== "workspace" && kind !== "host" && kind !== "thread-storage") {
return null;
}
return {
path,
source: {
kind,
threadId: typeof threadId === "string" ? threadId : null,
environmentId: typeof environmentId === "string" ? environmentId : null,
projectId: typeof projectId === "string" ? projectId : null,
},
};
}

/**
* A per-open viewer choice (the link context menu): "builtin" pins the
* built-in preview; an opener ref forces that plugin opener. Absent =
* follow the per-extension default.
*/
export type FileTabViewerOverride =
| "builtin"
| { pluginId: string; openerId: string };

export interface CreateFileOpenerTabForRequestArgs {
fileOpeners: readonly PluginFileOpenerSlot[];
preference: FileOpenerPreferenceMap;
projectId: string | null;
request: OpenSecondaryPanelTabRequest;
resolvedEnvironmentId: string | null | undefined;
threadId: string | null | undefined;
viewer?: FileTabViewerOverride;
}

/**
* The plugin-opener tab a file-open request should divert to, or null for
* the built-in path. Diversion applies only to live file content — working
* tree, host, and thread-storage previews; git-ref snapshots and deleted
* files always use the built-in preview.
*/
export function createFileOpenerTabForRequest({
fileOpeners,
preference,
projectId,
request,
resolvedEnvironmentId,
threadId,
viewer,
}: CreateFileOpenerTabForRequestArgs): PluginPanelFixedPanelTab | null {
if (viewer === "builtin") return null;
const file = fileForOpenRequest({
projectId,
request,
resolvedEnvironmentId,
threadId,
});
if (file === null) return null;
const opener =
viewer !== undefined
? (fileOpeners.find(
(candidate) =>
candidate.pluginId === viewer.pluginId &&
candidate.id === viewer.openerId,
) ?? null)
: resolvePreferredFileOpener({
openers: fileOpeners,
preference,
path: file.path,
});
if (opener === null) return null;
return buildFileOpenerPanelTab(opener, file);
}

function fileForOpenRequest({
projectId,
request,
resolvedEnvironmentId,
threadId,
}: Omit<CreateFileOpenerTabForRequestArgs, "fileOpeners" | "preference">):
| PluginFileOpenerProps
| null {
switch (request.kind) {
case "workspace-file-preview": {
// Same guard as the built-in path, plus live-content-only rules.
if (resolvedEnvironmentId === undefined) return null;
if (request.tab.source.kind !== "working-tree") return null;
if (request.tab.statusLabel === "deleted") return null;
return {
path: request.tab.path,
source: buildSource("workspace", {
environmentId: resolvedEnvironmentId,
projectId: resolvedEnvironmentId === null ? projectId : null,
threadId: threadId ?? null,
}),
};
}
case "host-file-preview": {
if (!threadId || !resolvedEnvironmentId) return null;
return {
path: request.tab.path,
source: buildSource("host", {
environmentId: resolvedEnvironmentId,
projectId: null,
threadId,
}),
};
}
case "thread-storage-file-preview": {
if (!threadId) return null;
return {
path: request.tab.path,
source: buildSource("thread-storage", {
environmentId: resolvedEnvironmentId ?? null,
projectId: null,
threadId,
}),
};
}
default:
return null;
}
}

function buildSource(
kind: PluginFileOpenerSource["kind"],
fields: {
environmentId: string | null;
projectId: string | null;
threadId: string | null;
},
): PluginFileOpenerSource {
return { kind, ...fields };
}
Loading
Loading