-
Notifications
You must be signed in to change notification settings - Fork 289
Add plugin navigation primitives #2005
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ymichael
merged 18 commits into
main
from
bb/implement-plugin-navigation-primitives-thr_9rqwy7pmsd
Aug 20, 2026
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
8ba55a8
Add shared plugin URL navigation
ymichael 4e7cce8
Add shared plugin file navigation
ymichael f7de535
Stabilize lazy file navigation coverage
ymichael d882872
Add generic fixed-tab navigation
ymichael e9d524c
Fix plugin navigation lint failures
ymichael e6ab42e
Require explicit fixed tab owners
ymichael d1e9b81
Migrate all first-party plugin navigation links
ymichael a240496
Prove core Diff uses generic fixed-tab routing
ymichael 5cd3e33
Consolidate secondary panel file opening
ymichael d11f91d
Share plugin panel full-region policy
ymichael 1fdf488
Share plugin page full-region panel policy
ymichael 696986f
fix(app): remove duplicate plugin panel padding
ymichael 91984d5
fix(github): align plugin panel padding
ymichael d96b688
fix(plugins): align full-region panel padding
ymichael 56c6551
fix(plugins): retain fixed tab targets for app session
ymichael 9a8d74a
perf(app): trim fixed tab target state
ymichael 1875021
fix(plugins): preserve optional file opener hosts
ymichael d68a893
fix(github): keep issue details in main page
ymichael File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
77 changes: 77 additions & 0 deletions
77
apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <button | ||
| type="button" | ||
| onClick={() => | ||
| navigation.openFileExternally({ | ||
| target: { | ||
| kind: "workspace", | ||
| environmentId: "env_1", | ||
| path: "src/example.ts", | ||
| }, | ||
| location: { kind: "line", line: 12, column: 3 }, | ||
| }) | ||
| } | ||
| > | ||
| Open external | ||
| </button> | ||
| ); | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| openPreferred.mockReset(); | ||
| openPreferred.mockResolvedValue(true); | ||
| }); | ||
|
|
||
| describe("AppFileExternalNavigationHost", () => { | ||
| it("resolves and dispatches an accepted intent through the preferred target", async () => { | ||
| render( | ||
| <AppFileExternalNavigationHost> | ||
| <Probe /> | ||
| </AppFileExternalNavigationHost>, | ||
| ); | ||
| 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 }, | ||
| ); | ||
| }); | ||
| }); |
68 changes: 68 additions & 0 deletions
68
apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ExperimentalFileOpenOptions[]>([]); | ||
| 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 ( | ||
| <AppNavigationHostProvider capabilities={capabilities}> | ||
| {children} | ||
| {current === null ? null : ( | ||
| <Suspense fallback={null}> | ||
| <LazyAppFileExternalNavigationDispatcher | ||
| intent={current} | ||
| onSettled={settleCurrent} | ||
| /> | ||
| </Suspense> | ||
| )} | ||
| </AppNavigationHostProvider> | ||
| ); | ||
| } |
73 changes: 73 additions & 0 deletions
73
apps/app/src/components/plugin/ExperimentalFileLink.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <MemoryRouter> | ||
| <RouteNavigationProvider> | ||
| <AppNavigationHostProvider capabilities={{ openFilePreview }}> | ||
| <ExperimentalFileLink | ||
| target={target} | ||
| location={{ kind: "line", line: 12, column: 4 }} | ||
| > | ||
| example.ts:12 | ||
| </ExperimentalFileLink> | ||
| </AppNavigationHostProvider> | ||
| </RouteNavigationProvider> | ||
| </MemoryRouter>, | ||
| ); | ||
| 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( | ||
| <MemoryRouter> | ||
| <AppNavigationHostProvider capabilities={{ openFilePreview }}> | ||
| <ExperimentalFileLink target={target}> | ||
| example.ts | ||
| </ExperimentalFileLink> | ||
| </AppNavigationHostProvider> | ||
| </MemoryRouter>, | ||
| ); | ||
| 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( | ||
| <MemoryRouter> | ||
| <AppNavigationHostProvider capabilities={{ openFilePreview }}> | ||
| <ExperimentalFileLink target={{ ...target, path: "../secret" }}> | ||
| invalid | ||
| </ExperimentalFileLink> | ||
| </AppNavigationHostProvider> | ||
| </MemoryRouter>, | ||
| ); | ||
| fireEvent.click(screen.getByRole("link", { name: "invalid" })); | ||
| expect(openFilePreview).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚨
slopcop/review— The external-file queue stops after its first item.React reuses this dispatcher when the host removes the first queue item.
didSettleRefstays true, so the second accepted request never runs or leaves the queue.Give each request an ID and use that ID as the dispatcher key. Add a test that submits two requests before the first settles.