diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 9a25e277ed..1691ea970e 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -10,6 +10,8 @@ import { AppLayout } from "./components/layout/AppLayout"; import { AuthCallbackView } from "./views/AuthCallbackView"; import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject"; import { RouteNavigationProvider } from "./components/ui/app-route-anchor"; +import { AppNavigationUrlHost } from "./lib/url-open-routing"; +import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; import { useDesktopThemeSync } from "./hooks/useDesktopThemeSync"; @@ -384,18 +386,22 @@ export function App() { - - - } - /> - } /> - - {/* Outside : a provider CLI install outlives the page that - started it, so its failure toast can be clicked from any route — - including auth callback, which renders no app shell. */} - + + + + + } + /> + } /> + + {/* Outside : a provider CLI install outlives the page that + started it, so its failure toast can be clicked from any route — + including auth callback, which renders no app shell. */} + + + diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx new file mode 100644 index 0000000000..d7954b9df0 --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { appToast } from "@/components/ui/app-toast"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; +import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; + +/** Lazily loaded only after an external-file intent has been accepted. */ +export function AppFileExternalNavigationDispatcher({ + intent, + onSettled, +}: { + intent: ExperimentalFileOpenOptions; + onSettled: () => void; +}) { + const didSettleRef = useRef(false); + const resolvedTarget = useResolvedLiveFileTarget(intent.target, { + enabled: true, + }); + const { isLoading: areLocalTargetsLoading, openPathInPreferredFileTarget } = + useLocalOpenTargets({ + enabled: resolvedTarget.status === "available", + ...(resolvedTarget.status === "available" + ? { openContext: resolvedTarget.openContext } + : {}), + }); + + useEffect(() => { + if ( + didSettleRef.current || + resolvedTarget.status === "loading" || + areLocalTargetsLoading + ) { + return; + } + didSettleRef.current = true; + onSettled(); + if (resolvedTarget.status === "unavailable") { + appToast.error("Failed to open file externally", { + description: "The file target is not available on its declared host.", + }); + return; + } + const location = getExperimentalFileLocationStart(intent.location); + void openPathInPreferredFileTarget({ + columnNumber: location.columnNumber, + lineNumber: location.lineNumber, + path: resolvedTarget.absolutePath, + }); + }, [ + intent.location, + areLocalTargetsLoading, + openPathInPreferredFileTarget, + onSettled, + resolvedTarget, + ]); + + return null; +} diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx new file mode 100644 index 0000000000..fbd27cbe52 --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { AppFileExternalNavigationHost } from "./AppFileExternalNavigationHost"; + +const openPreferred = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/useResolvedLiveFileTarget", () => ({ + useResolvedLiveFileTarget: () => ({ + status: "available", + absolutePath: "/workspace/src/example.ts", + hostId: "host_1", + openContext: { kind: "local" }, + }), +})); + +vi.mock("@/hooks/useLocalOpenTargets", () => ({ + useLocalOpenTargets: () => ({ + isLoading: false, + openPathInPreferredFileTarget: openPreferred, + }), +})); + +function Probe() { + const navigation = useAppNavigationHost(); + return ( + + ); +} + +afterEach(() => { + cleanup(); + openPreferred.mockReset(); + openPreferred.mockResolvedValue(true); +}); + +describe("AppFileExternalNavigationHost", () => { + it("resolves and dispatches an accepted intent through the preferred target", async () => { + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open external" })); + await waitFor( + () => + expect(openPreferred).toHaveBeenCalledWith({ + columnNumber: 3, + lineNumber: 12, + path: "/workspace/src/example.ts", + }), + { timeout: 5_000 }, + ); + }); +}); diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx new file mode 100644 index 0000000000..bbed6792b9 --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx @@ -0,0 +1,68 @@ +import { + lazy, + Suspense, + useCallback, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; + +const MAX_PENDING_EXTERNAL_FILE_INTENTS = 32; +const LazyAppFileExternalNavigationDispatcher = lazy(() => + import("./AppFileExternalNavigationDispatcher").then( + ({ AppFileExternalNavigationDispatcher }) => ({ + default: AppFileExternalNavigationDispatcher, + }), + ), +); + +/** App-wide preferred-external file dispatcher; discovery starts on activation. */ +export function AppFileExternalNavigationHost({ + children, +}: { + children: ReactNode; +}) { + const [queue, setQueue] = useState([]); + const queueRef = useRef(queue); + const replaceQueue = useCallback((next: ExperimentalFileOpenOptions[]) => { + queueRef.current = next; + setQueue(next); + }, []); + const openFileExternally = useCallback( + (intent: ExperimentalFileOpenOptions): boolean => { + if (queueRef.current.length >= MAX_PENDING_EXTERNAL_FILE_INTENTS) { + return false; + } + // Public SDK callers are parsed by useBbNavigate before capabilities are + // invoked; this host only queues that already-normalized internal value. + replaceQueue([...queueRef.current, intent]); + return true; + }, + [replaceQueue], + ); + const current = queue[0] ?? null; + const settleCurrent = useCallback(() => { + replaceQueue(queueRef.current.slice(1)); + }, [replaceQueue]); + + const capabilities = useMemo( + () => ({ openFileExternally }), + [openFileExternally], + ); + return ( + + {children} + {current === null ? null : ( + + + + )} + + ); +} diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx new file mode 100644 index 0000000000..0bb7c31790 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; +import { ExperimentalFileLink } from "./ExperimentalFileLink"; + +afterEach(cleanup); + +const target = { + kind: "workspace" as const, + environmentId: "env_1", + path: "src/example.ts", +}; + +describe("ExperimentalFileLink", () => { + it("sends ordinary activation to the shared preview host", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + + example.ts:12 + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "example.ts:12" })); + expect(openFilePreview).toHaveBeenCalledWith({ + target, + location: { kind: "line", line: 12, column: 4 }, + }); + }); + + it("leaves modifier clicks native", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + example.ts + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "example.ts" }), { + metaKey: true, + }); + expect(openFilePreview).not.toHaveBeenCalled(); + }); + + it("does not dispatch a malformed target supplied across a JavaScript boundary", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + invalid + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "invalid" })); + expect(openFilePreview).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.tsx new file mode 100644 index 0000000000..24be2d40d0 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLink.tsx @@ -0,0 +1,83 @@ +import { + lazy, + Suspense, + useCallback, + useMemo, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; +import type { ExperimentalFileLinkProps } from "@get-bb/plugin-sdk"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@bb/shared-ui/context-menu"; +import { RouteAnchor } from "@/components/ui/app-route-anchor"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { normalizeExperimentalFileOpenOptions } from "@/lib/live-file-navigation"; + +const LazyExperimentalFileLinkMenu = lazy(() => + import("./ExperimentalFileLinkMenu").then(({ ExperimentalFileLinkMenu }) => ({ + default: ExperimentalFileLinkMenu, + })), +); + +function shouldHandleFileClick( + event: ReactMouseEvent, +): boolean { + return !( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") + ); +} + +/** Host-rendered live-file anchor shared by plugins and BB-owned surfaces. */ +export function ExperimentalFileLink({ + target, + location = null, + onClick, + ...anchorProps +}: ExperimentalFileLinkProps) { + const navigation = useAppNavigationHost(); + const [isMenuOpen, setMenuOpen] = useState(false); + const intent = useMemo( + () => normalizeExperimentalFileOpenOptions({ target, location }), + [location, target], + ); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onClick?.(event); + if (intent === null || !shouldHandleFileClick(event)) { + return; + } + event.preventDefault(); + navigation.openFilePreview(intent); + }, + [intent, navigation, onClick], + ); + const anchor = ( + + ); + + if (intent === null) return anchor; + return ( + + {anchor} + + {isMenuOpen ? ( + Loading…} + > + + + ) : null} + + + ); +} diff --git a/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx new file mode 100644 index 0000000000..8a2bb66f45 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx @@ -0,0 +1,148 @@ +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@bb/shared-ui/context-menu"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { copyToClipboardWithToast } from "@/lib/clipboard"; +import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +function getFileBasename(path: string): string { + const normalizedPath = path.replace(/[\\/]+$/u, ""); + return normalizedPath.split(/[\\/]/u).at(-1) ?? path; +} + +function getFileExtension(path: string): string | null { + const name = getFileBasename(path); + const dotIndex = name.lastIndexOf("."); + return dotIndex > 0 && dotIndex < name.length - 1 + ? name.slice(dotIndex + 1).toLowerCase() + : null; +} + +/** Lazily mounted destination discovery for `experimental_FileLink`. */ +export function ExperimentalFileLinkMenu({ + intent, +}: { + intent: ExperimentalFileOpenOptions; +}) { + const navigation = useAppNavigationHost(); + const resolved = useResolvedLiveFileTarget(intent.target, { enabled: true }); + const localTargets = useLocalOpenTargets({ + enabled: resolved.status === "available", + ...(resolved.status === "available" + ? { openContext: resolved.openContext } + : {}), + }); + const { fileOpeners } = usePluginSlots(); + const extension = getFileExtension(intent.target.path); + const matchingOpeners = + extension === null + ? [] + : fileOpeners.filter((opener) => opener.extensions.includes(extension)); + const location = getExperimentalFileLocationStart(intent.location); + + return ( + <> + navigation.openFilePreview(intent)}> + Open preview + + {matchingOpeners.length > 0 ? ( + + Open with + + + navigation.openFilePreview({ ...intent, viewer: "builtin" }) + } + > + BB preview + + {matchingOpeners.map((opener) => ( + + navigation.openFilePreview({ + ...intent, + viewer: { + pluginId: opener.pluginId, + openerId: opener.id, + }, + }) + } + > + {opener.title} + + ))} + + + ) : null} + navigation.openFileExternally(intent)} + > + Open externally + + {resolved.status === "available" && + localTargets.fileOpenTargets.length > 0 ? ( + + Open in + + {localTargets.fileOpenTargets.map((target) => ( + { + void localTargets.openPathInFileTarget({ + columnNumber: location.columnNumber, + lineNumber: location.lineNumber, + path: resolved.absolutePath, + rememberTarget: false, + targetId: target.id, + }); + }} + > + {target.label} + + ))} + + + ) : null} + + { + void copyToClipboardWithToast( + resolved.status === "available" + ? resolved.absolutePath + : intent.target.path, + { + successMessage: "File path copied", + errorMessage: "Failed to copy file path", + }, + ); + }} + > + Copy file path + + { + void copyToClipboardWithToast(getFileBasename(intent.target.path), { + successMessage: "File name copied", + errorMessage: "Failed to copy file name", + }); + }} + > + Copy file name + + + ); +} diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx new file mode 100644 index 0000000000..d97966ca33 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; +import { ExperimentalUrlLink } from "./ExperimentalUrlLink"; + +afterEach(cleanup); + +describe("ExperimentalUrlLink", () => { + it("sends an ordinary web activation to the navigation host", () => { + const openUrl = vi.fn(() => true); + render( + + + + + Example + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Example" })); + expect(openUrl).toHaveBeenCalledWith({ url: "https://example.com" }); + }); + + it("leaves modifier clicks native", () => { + const openUrl = vi.fn(() => true); + render( + + + + Example + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Example" }), { + metaKey: true, + }); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it("routes internal links through browser history before URL preferences", () => { + const openUrl = vi.fn(() => true); + render( + + + + Settings + + Settings route} /> + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Settings" })); + expect(screen.getByText("Settings route")).toBeTruthy(); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx new file mode 100644 index 0000000000..3ac1984d24 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx @@ -0,0 +1,63 @@ +import { useCallback, type MouseEvent as ReactMouseEvent } from "react"; +import type { ExperimentalUrlLinkProps } from "@get-bb/plugin-sdk"; +import { RouteAnchor } from "@/components/ui/app-route-anchor"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { resolveRouteHref } from "@/lib/route-paths"; + +function shouldHandleUrlClick( + event: ReactMouseEvent, +): boolean { + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") + ) { + return false; + } + return true; +} + +function isCurrentAppRoute(href: string): boolean { + return ( + typeof window !== "undefined" && + resolveRouteHref({ currentOrigin: window.location.origin, href }) !== null + ); +} + +/** Host-rendered URL link shared by plugins and first-party app surfaces. */ +export function ExperimentalUrlLink({ + href, + onClick, + rel, + target, + ...anchorProps +}: ExperimentalUrlLinkProps) { + const navigation = useAppNavigationHost(); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onClick?.(event); + if ( + !shouldHandleUrlClick(event) || + isCurrentAppRoute(href) || + !navigation.openUrl({ url: href }) + ) { + return; + } + event.preventDefault(); + }, + [href, navigation, onClick], + ); + return ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index ca292ff3d7..e090ebef4c 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -15,19 +15,50 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { createEmptyFixedPanelTabsState, + createPluginPanelFixedPanelTab, createTerminalFixedPanelTab, getFixedPanelTabsStateStorageKey, serializeFixedPanelTabsState, } from "@/lib/fixed-panel-tabs-state"; import { PluginPanelRightPanelHost } from "./PluginPanelRightPanelHost"; import { getPluginPagePanelStateId } from "./plugin-page-panel-state"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { + getPluginFixedTabOwnerId, + useAppFixedTabTarget, +} from "@/lib/app-fixed-tab-navigation"; interface TestFixedTabRegistration { + panelId: string; id: string; title: string; icon: string; component: (props: { subPath: string }) => ReactNode; + experimental_target?: { + validate(value: import("@get-bb/plugin-sdk").JsonValue): boolean; + }; + layout?: "padded" | "flush"; +} + +interface TestFileOpenerRegistration { + id: string; + title: string; + extensions: string[]; + component: () => ReactNode; + pluginId: string; + generation: number; +} + +interface TestNewThreadPanelActionRegistration { + id: string; + title: string; + component: (props: { + projectId: string | null; + params: import("@get-bb/plugin-sdk").JsonValue | null; + }) => ReactNode; layout?: "padded" | "flush"; + pluginId: string; + generation: number; } const browserState = vi.hoisted(() => ({ available: false })); @@ -75,6 +106,8 @@ const terminalQueryState = vi.hoisted(() => ({ const fixedTabState = vi.hoisted(() => ({ panelRegistered: true, registrations: [] as TestFixedTabRegistration[], + fileOpeners: [] as TestFileOpenerRegistration[], + newThreadPanelActions: [] as TestNewThreadPanelActionRegistration[], })); const hostState = vi.hoisted(() => ({ hosts: [ @@ -109,7 +142,8 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ vi.mock("@/lib/plugin-slots", () => ({ usePluginSlots: () => ({ - fileOpeners: [], + fileOpeners: fixedTabState.fileOpeners, + newThreadPanelActions: fixedTabState.newThreadPanelActions, navPanels: fixedTabState.panelRegistered ? [ { @@ -238,6 +272,7 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanel", () => ({ browserDeck, fileTabs, fileTabContent, + fileTabContentFillsRegion, fixedTabs, fixedTabContent, onClose, @@ -252,6 +287,7 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanel", () => ({ onSelect: () => void; }>; fileTabContent: ReactNode; + fileTabContentFillsRegion?: boolean; fixedTabs: Array<{ tab: { id: string }; title: string; @@ -264,6 +300,9 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanel", () => ({ }) => (