#2770 · Plugin file opener props omit saved line ranges
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A file opener plugin does not receive the saved line range for its file tab. A direct mount test reproduced this loss in two clean clones. The app keeps the range in the tab owner. The plugin contract and mount omit the range. Monaco cannot select or reveal a line that it does not receive.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The local file parser creates a line range. | Verified | Trusted parser tests cover colon and fragment forms. |
| The tab owner keeps initial and later ranges. | Verified | Trusted tab tests keep the tab identity and change its saved range. |
| The plugin mount omits the saved range. | Verified | The new boundary test received null in two clean clones. |
| Monaco has no initial or later navigation input. | Verified | The opener props omit a range. Monaco creates the editor without a position or reveal call. |
3. Environment
- Trusted branch:
origin/mainatf4bbc2fe81a9b7639ff9a7396e172bddd89109e4. - Host: macOS 26.6.1 on arm64.
- Node.js:
v22.22.3. - Two separate clean repository clones used the same trusted commit.
- The full Turbo build completed before the first test.
- An isolated browser fixture did not open the editor reliably. The report does not use that fixture as evidence.
4. Minimal reproduction
- Copy the saved test into a trusted clone.
cp issues/2770/repro/file-opener-line-range.repro.test.tsx \ apps/app/src/components/plugin/file-opener-line-range.repro.test.tsx
- Run the focused test.
pnpm exec turbo run test --filter=@bb/app --force -- \ src/components/plugin/file-opener-line-range.repro.test.tsx
- Observe the failed boundary assertion.
Expected: "{\"startLineNumber\":7,\"endLineNumber\":9}" Received: "null" Test Files 1 failed (1) Tests 1 failed (1)
The second clean clone produced the same result with no test correction.
Repro file: file-opener-line-range.repro.test.tsx
Full reproduction test
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import type { PluginFileOpenerProps } from "@get-bb/plugin-sdk";
import { createPluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
type PluginRegistrationSet,
} from "@/lib/plugin-slots";
import { PluginPanelTabContent } from "./PluginPanelActions";
type ExpectedFileOpenerProps = PluginFileOpenerProps & {
lineRange?: {
startLineNumber: number;
endLineNumber: number;
} | null;
};
function registrationSet(
overrides: Partial<PluginRegistrationSet>,
): PluginRegistrationSet {
return {
homepageSections: [],
settingsSections: [],
navPanels: [],
threadPanelActions: [],
composerCustomizations: [],
sidebarFooterActions: [],
fileOpeners: [],
messageDirectives: [],
...overrides,
};
}
afterEach(() => {
cleanup();
resetPluginSlotStoreForTest();
});
it("passes the owner's line range to a file opener", () => {
function FileOpenerProbe({ lineRange }: ExpectedFileOpenerProps) {
return (
<output data-testid="file-opener-line-range">
{JSON.stringify(lineRange ?? null)}
</output>
);
}
setPluginSlotRegistrations(
"notes",
registrationSet({
fileOpeners: [
{
id: "editor",
title: "Notes editor",
extensions: ["md"],
component: FileOpenerProbe,
},
],
}),
);
const tab = {
...createPluginPanelFixedPanelTab({
actionId: "file-opener:editor",
paramsJson: JSON.stringify({
path: "notes/todo.md",
source: {
kind: "workspace",
threadId: null,
environmentId: "env_1",
projectId: null,
},
}),
pluginId: "notes",
title: "todo.md",
}),
fileOpenerOwner: {
kind: "workspace-file-preview" as const,
environmentId: "env_1",
projectId: null,
tab: {
lineRange: { startLineNumber: 7, endLineNumber: 9 },
path: "notes/todo.md",
source: { kind: "working-tree" as const },
statusLabel: null,
},
threadId: null,
},
};
render(
<PluginPanelTabContent
tab={tab}
context={{ kind: "new-thread", projectId: null }}
fileOpenerOriginal={<div>native preview</div>}
/>,
);
expect(screen.getByTestId("file-opener-line-range").textContent).toBe(
'{"startLineNumber":7,"endLineNumber":9}',
);
});
5. Root cause
The app stores the range in the file-opener owner. The public opener props define only the path, source, and native preview.
export interface PluginFileOpenerProps {
path: string;
source: PluginFileOpenerSource;
Original: ComponentType;
experimental_Original?: ComponentType;
}
The plugin mount then sends only those values.
<opener.component
path={file.path}
source={file.source}
Original={BoundOriginal}
experimental_Original={deprecatedOriginalAlias(BoundOriginal)}
/>
The tab state still contains the range. Therefore, the mount boundary loses the value.
The Monaco opener creates its editor without a position or reveal call. The visible navigation failure follows from the missing input.
6. Proposed fix
- Add one experimental line-range contract to the plugin SDK.
- Send the owner range through the plugin mount.
- Apply the range after Monaco creates its model.
- Apply later range changes to the current editor.
- Add mount and Monaco tests for initial and later navigation.
- Update the API audit document and the plugin API map.
This fix needs at least seven files and a public SDK contract decision. It does not meet the simple-fix rule.
7. Related issues
No matching open or closed issue was found. Issue #1072 concerns Monaco performance and does not match this defect.
8. Appendix
Commands
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/app --force -- \ src/components/plugin/file-opener-line-range.repro.test.tsx git log f4bbc2fe81a9b7639ff9a7396e172bddd89109e4..origin/main -- \ packages/plugin-sdk/src/app-contract.ts \ apps/app/src/components/plugin/PluginPanelActions.tsx \ plugins/monaco-editor/app.tsx
The investigation treated all issue content as untrusted data. It used no issue command, patch, branch, or external link.