diff --git a/apps/server/scripts/copy-builtin-plugins.ts b/apps/server/scripts/copy-builtin-plugins.ts index ecf00a6ac3..6d9fea97bc 100644 --- a/apps/server/scripts/copy-builtin-plugins.ts +++ b/apps/server/scripts/copy-builtin-plugins.ts @@ -96,6 +96,18 @@ async function writeRuntimePackageJson(args: { ); } +/** + * Runs `/scripts/stage-assets.mjs` when a plugin has one. The + * script's side effects are its contract: it populates `dist/` with runtime + * files the bundlers cannot produce (the Monaco plugin copies Monaco's AMD + * build in this way). + */ +async function runStageAssets(sourceRoot: string): Promise { + const scriptPath = path.join(sourceRoot, "scripts", "stage-assets.mjs"); + if (!(await exists(scriptPath))) return; + await import(pathToFileURL(scriptPath).href); +} + async function copyBuiltinPlugin(args: { bbVersion: string; build: boolean; @@ -120,6 +132,10 @@ async function copyBuiltinPlugin(args: { if (packageJson.bb.host !== undefined) { await buildPluginHost(args.sourceRoot, args.bbVersion, toolchain); } + // A plugin that needs files on disk at runtime (rather than bundled into + // its server/app) stages them into `dist/` here, because `RUNTIME_DIRS` + // below is all that ships. Optional: most plugins have no such script. + await runStageAssets(args.sourceRoot); } const targetDir = path.join(args.targetRoot, args.name); diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts index 1f52ebb6bf..36d97834ed 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -72,6 +72,12 @@ export const BUILTIN_PLUGINS = [ defaultEnabled: true, category: "Interface", }, + { + name: "monaco", + pluginId: "monaco", + defaultEnabled: true, + category: "Interface", + }, { name: "pdf-preview", pluginId: "pdf-preview", diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 28e2cb10a5..0ee6b2c2f8 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -214,6 +214,7 @@ describe("builtin plugin reconciliation", () => { ["custom-instructions", "EditFile"], ["inline-vis", "AppWindow"], ["keep-awake", "Coffee"], + ["monaco", "Code"], ["pdf-preview", "FileText"], ["provider-acp", "./icons/acp.svg"], ["provider-claude-code", "./icons/claude-code.svg"], diff --git a/apps/server/test/services/plugins/official-plugins.test.ts b/apps/server/test/services/plugins/official-plugins.test.ts index f841b4b229..85fe3d4304 100644 --- a/apps/server/test/services/plugins/official-plugins.test.ts +++ b/apps/server/test/services/plugins/official-plugins.test.ts @@ -97,6 +97,7 @@ describe("official plugin registry invariants", () => { "inline-vis": "Interface", "keep-awake": "Host access", memory: "Context & knowledge", + monaco: "Interface", "pdf-preview": "Interface", "provider-acp": "Agent interaction", "provider-claude-code": "Agent interaction", diff --git a/packages/bb-app/scripts/smoke-tarball.mjs b/packages/bb-app/scripts/smoke-tarball.mjs index 3e6ac3aefd..ec2ad761ef 100644 --- a/packages/bb-app/scripts/smoke-tarball.mjs +++ b/packages/bb-app/scripts/smoke-tarball.mjs @@ -33,6 +33,7 @@ const EXPECTED_RUNNING_BUILTIN_PLUGINS = [ "custom-instructions", "inline-vis", "keep-awake", + "monaco", "pdf-preview", "provider-retry", "secrets", diff --git a/plugins/monaco/README.md b/plugins/monaco/README.md new file mode 100644 index 0000000000..ddf1a5405d --- /dev/null +++ b/plugins/monaco/README.md @@ -0,0 +1,65 @@ +# bb-plugin-monaco + +Opens files in BB using [Monaco](https://microsoft.github.io/monaco-editor/), +the editor from VS Code, instead of BB's read-only file preview. + +It applies everywhere BB opens a file: links clicked in chat, the secondary +panel's file search, and `bb thread open`. + +## Features + +- **Edit and save.** ⌘S writes the file. If it changed on disk + since you opened it — often because the agent edited it — the save stops and + offers Reload or Overwrite rather than clobbering the change. +- **Find in file** with ⌘F, plus Monaco's usual editing: multiple + cursors, block selection, bracket matching, code folding. +- **Syntax highlighting** for ~86 common file types. +- **File tree.** Toggle it from the file bar to browse the project, filter by + path, expand and collapse directories, and jump to another file. It opens + with the current file revealed. Right-click any row to copy its absolute + path, relative path, or filename. +- **Follows your theme,** including light/dark switches and custom palettes. + +## Development + +Ships with BB as a builtin; there is nothing to install. + +``` +pnpm exec turbo run typecheck test --filter=bb-plugin-monaco +``` + +Monaco's AMD build is what the editor loads at runtime, and packaging copies +only a builtin's `dist/`, so `scripts/stage-assets.mjs` copies +`monaco-editor/min/vs` into `dist/vs` during the build +(`apps/server/scripts/copy-builtin-plugins.ts` runs it). Running from source +there is no `dist/`, and the server falls back to resolving `monaco-editor` +from `node_modules`. + +## Which files it opens + +The plugin claims the extensions listed in `lib/languages.ts` — common code, +config, and text formats. Binaries like `png` and `pdf` are left to BB's own +preview, which renders them properly. + +To change any file type back, use **Settings → File openers**, which offers +Automatic, BB's built-in preview, or Monaco per extension. Right-clicking a +file link also offers a one-off "Open with…". + +## Roadmap + +- **Language intelligence.** There is no language server, so no + go-to-definition, find-references, or type checking. Monaco ships a + TypeScript checker, but it can only see the one open file, so every import + looks unresolved — it is switched off rather than showing errors that are + wrong. +- **File operations.** The tree is read-only; renaming, creating, and + deleting files are not implemented yet. +- **Hidden files and `node_modules`** never appear in the tree. BB's path + listing excludes them and offers no way to ask for them + ([#2093](https://github.com/get-bb/bb/issues/2093)). +- **Opening a file from the tree reuses the current tab,** so the tab title + keeps naming the file it was opened with. A plugin cannot ask BB to open a + file or retitle its tab ([#2102](https://github.com/get-bb/bb/issues/2102)). +- **No "open in editor" button** like BB's preview has; that capability is not + available to plugins. +- **Thread-storage files on a remote machine** fail to open. diff --git a/plugins/monaco/app.tsx b/plugins/monaco/app.tsx new file mode 100644 index 0000000000..fdc7d4b1cc --- /dev/null +++ b/plugins/monaco/app.tsx @@ -0,0 +1,535 @@ +// bb-plugin-monaco — frontend entry. +// +// Registers a `fileOpener`, which is BB's seam for replacing the built-in +// file preview. Every file-open flow in the app funnels through one call site +// (`useThreadFileTabs`'s `openTab`), so this single registration covers file +// links clicked in chat, the secondary panel's "+" file search, and +// `bb thread open` alike. +import { useCallback, useEffect, useRef, useState } from "react"; +import { + definePluginApp, + useRpc, + type PluginFileOpenerProps, +} from "@get-bb/plugin-sdk/app"; +import type * as MonacoNs from "monaco-editor"; +import type { rpcContract } from "./server.js"; +import { CLAIMED_EXTENSIONS, languageForPath } from "./lib/languages.js"; +import { + loadMonaco, + overflowWidgetsNode, + setOverflowWidgetsTheme, +} from "./lib/monaco-loader.js"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { FileToolbar, type SaveIndicator } from "./components/FileToolbar.js"; +import { FileTreePanel } from "./components/FileTreePanel.js"; +import type { FlatEntry } from "./lib/file-tree.js"; + +type SaveState = + | { kind: "clean" } + | { kind: "dirty" } + | { kind: "saving" } + | { kind: "error"; message: string } + | { kind: "conflict" }; + +/** Monaco's dark/light pair, following the app's ``. */ +function useMonacoTheme(): "vs-dark" | "vs" { + const [isDark, setIsDark] = useState( + () => document.documentElement.classList.contains("dark"), + ); + useEffect(() => { + const target = document.documentElement; + const observer = new MutationObserver(() => { + setIsDark(target.classList.contains("dark")); + }); + observer.observe(target, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + return isDark ? "vs-dark" : "vs"; +} + +function MonacoFileOpener({ + path, + source, + experimental_Original: Original, +}: PluginFileOpenerProps) { + const rpc = useRpc(); + const theme = useMonacoTheme(); + const containerRef = useRef(null); + const editorRef = useRef(null); + + // The file actually in the editor. It starts as the one BB opened the tab + // for and changes when the user picks another from the file tree, so every + // read and write below targets this rather than the prop. BB's tab title + // keeps naming the original file: a plugin cannot retitle its own tab. + const [activePath, setActivePath] = useState(path); + useEffect(() => setActivePath(path), [path]); + + // The hash the file had when we last agreed with disk. It guards every + // save, and a save advances it — so it lives in a ref rather than state: + // the cmd+S handler is registered once and must see the current value. + const sha256Ref = useRef(null); + const saveStateRef = useRef({ kind: "clean" }); + + const [saveState, setSaveStateValue] = useState({ kind: "clean" }); + const [isRefreshing, setIsRefreshing] = useState(false); + const [pendingDiscard, setPendingDiscard] = useState(false); + const [isFilesOpen, setIsFilesOpen] = useState(false); + // A file picked from the tree while the buffer was dirty, held until the + // user says whether to discard. + const [pendingOpen, setPendingOpen] = useState(null); + const [tree, setTree] = useState<{ + entries: readonly FlatEntry[]; + root: string; + truncated: boolean; + isLoading: boolean; + error: string | null; + }>({ + entries: [], + root: "", + truncated: false, + isLoading: false, + error: null, + }); + const [status, setStatus] = useState< + | { kind: "loading" } + | { kind: "ready" } + | { kind: "delegate"; reason: string } + | { kind: "error"; message: string } + >({ kind: "loading" }); + + const setSaveState = useCallback((next: SaveState) => { + saveStateRef.current = next; + setSaveStateValue(next); + }, []); + + const save = useCallback(async () => { + const editor = editorRef.current; + if (!editor) return; + if (saveStateRef.current.kind === "saving") return; + setSaveState({ kind: "saving" }); + try { + const result = await rpc.call("write", { + path: activePath, + source, + content: editor.getValue(), + expectedSha256: sha256Ref.current, + }); + if (result.outcome === "conflict") { + // Someone else — very often the agent working in this thread — wrote + // the file after we read it. Never clobber: surface it and let the + // user choose. + setSaveState({ kind: "conflict" }); + return; + } + sha256Ref.current = result.sha256; + setSaveState({ kind: "clean" }); + } catch (error) { + setSaveState({ + kind: "error", + message: error instanceof Error ? error.message : "Save failed", + }); + } + }, [activePath, rpc, setSaveState, source]); + + const saveRef = useRef(save); + saveRef.current = save; + + /** Discard local edits and take what is on disk now. */ + const reloadFromDisk = useCallback(async () => { + const editor = editorRef.current; + if (!editor) return; + setIsRefreshing(true); + try { + const file = await rpc.call("read", { path: activePath, source }); + if (file.kind !== "text") return; + sha256Ref.current = file.sha256; + // `setValue` resets undo history, which is correct here: the buffer no + // longer descends from what the user was editing. + editor.setValue(file.content); + setSaveState({ kind: "clean" }); + } catch (error) { + setSaveState({ + kind: "error", + message: error instanceof Error ? error.message : "Reload failed", + }); + } finally { + setIsRefreshing(false); + } + }, [activePath, rpc, setSaveState, source]); + + /** + * Lists the project once, the first time the panel is opened. The listing + * is a snapshot; the reload button is the way to pick up files created + * since. Fetching lazily keeps a 5,000-entry request off the open path for + * everyone who never opens the tree. + */ + // "Have we already asked?" is a ref, not state, on purpose. Deriving it + // from `tree` would put `tree.isLoading` in this effect's dependencies — + // and since the effect's own first act is to set that flag, React would + // tear the effect down mid-flight, the cleanup would mark the in-flight + // request cancelled, and the response would be dropped. The panel then sits + // on "Loading files…" forever. + const treeRequestedRef = useRef(false); + useEffect(() => { + if (!isFilesOpen || treeRequestedRef.current) return; + treeRequestedRef.current = true; + let cancelled = false; + setTree((current) => ({ ...current, isLoading: true, error: null })); + void rpc + .call("tree", { source }) + .then((result) => { + if (cancelled) return; + setTree({ + entries: result.entries, + root: result.root, + truncated: result.truncated, + isLoading: false, + error: null, + }); + }) + .catch((error: unknown) => { + if (cancelled) return; + // Let the next open retry rather than latching the failure forever. + treeRequestedRef.current = false; + setTree({ + entries: [], + root: "", + truncated: false, + isLoading: false, + error: + error instanceof Error ? error.message : "Could not list files", + }); + }); + return () => { + cancelled = true; + }; + }, [isFilesOpen, rpc, source]); + + /** Switch the editor to another file, guarding unsaved work. */ + const openFromTree = useCallback( + (next: string) => { + if (next === activePath) return; + if (saveStateRef.current.kind === "dirty") { + setPendingOpen(next); + return; + } + setActivePath(next); + }, + [activePath], + ); + + /** + * Toolbar reload. With unsaved edits this asks first — reloading is the one + * control here that can destroy work the user has not committed to disk. + */ + const requestRefresh = useCallback(() => { + if (saveStateRef.current.kind === "dirty") { + setPendingDiscard(true); + return; + } + void reloadFromDisk(); + }, [reloadFromDisk]); + + /** Take our buffer as the truth, dropping the hash guard for one write. */ + const overwrite = useCallback(async () => { + sha256Ref.current = null; + const editor = editorRef.current; + if (!editor) return; + setSaveState({ kind: "saving" }); + try { + const result = await rpc.call("write", { + path: activePath, + source, + content: editor.getValue(), + // An absent guard is an unconditional write; `null` would mean + // create-only, which is not what "overwrite" means here. + expectedSha256: null, + }); + if (result.outcome === "conflict") { + setSaveState({ kind: "conflict" }); + return; + } + sha256Ref.current = result.sha256; + setSaveState({ kind: "clean" }); + } catch (error) { + setSaveState({ + kind: "error", + message: error instanceof Error ? error.message : "Save failed", + }); + } + }, [activePath, rpc, setSaveState, source]); + + // Boot: fetch the asset URL and the file content in parallel, then create + // the editor. Re-runs when the tab is pointed at a different file. + useEffect(() => { + let disposed = false; + setStatus({ kind: "loading" }); + + void (async () => { + try { + const [{ baseUrl }, file] = await Promise.all([ + rpc.call("assets"), + rpc.call("read", { path: activePath, source }), + ]); + if (disposed) return; + if (file.kind === "unsupported") { + setStatus({ kind: "delegate", reason: file.reason }); + return; + } + + const monaco = await loadMonaco(baseUrl); + if (disposed) return; + const container = containerRef.current; + if (!container) return; + + sha256Ref.current = file.sha256; + const editor = monaco.editor.create(container, { + value: file.content, + language: languageForPath(activePath), + automaticLayout: true, + lineNumbers: "on", + // Read from the DOM rather than the hook so the editor is created + // in the right theme; re-theming on toggle is a separate effect. + theme: document.documentElement.classList.contains("dark") + ? "vs-dark" + : "vs", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + // Matches BB's own file preview, which renders its code table as + // `font-mono text-xs leading-5` — 12px on 20px, since the app + // leaves Tailwind's default `--text-xs` alone at desktop widths. + fontSize: 12, + lineHeight: 20, + // Read the app's mono stack rather than restating it, so a custom + // theme's font follows through to the editor. + fontFamily: + getComputedStyle(document.documentElement).getPropertyValue( + "--font-mono", + ) || undefined, + // Hovers, suggestions, and parameter hints render into a body-level + // node so BB's panel cannot clip them. Both options are required — + // see overflowWidgetsNode(). + fixedOverflowWidgets: true, + overflowWidgetsDomNode: overflowWidgetsNode(), + }); + editorRef.current = editor; + setStatus({ kind: "ready" }); + + editor.onDidChangeModelContent(() => { + if (saveStateRef.current.kind === "clean") { + setSaveState({ kind: "dirty" }); + } + }); + editor.addCommand( + monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, + () => void saveRef.current(), + ); + } catch (error) { + if (disposed) return; + setStatus({ + kind: "error", + message: + error instanceof Error ? error.message : "Could not open this file", + }); + } + })(); + + return () => { + disposed = true; + editorRef.current?.getModel()?.dispose(); + editorRef.current?.dispose(); + editorRef.current = null; + }; + }, [activePath, rpc, setSaveState, source]); + + useEffect(() => { + editorRef.current?.updateOptions({ theme }); + // The overflow host lives outside the editor, so Monaco does not re-theme + // it for us. + setOverflowWidgetsTheme(theme); + }, [theme, status]); + + // Binary and oversized files are ordinary things to click on, and this + // plugin claims broad extensions. Hand them back to BB's own preview, which + // renders them properly, rather than showing an editor that cannot. + if (status.kind === "delegate") return ; + + return ( +
+ {isFilesOpen ? ( + setIsFilesOpen(false)} + onOpenFile={openFromTree} + truncated={tree.truncated} + /> + ) : null} + setIsFilesOpen((open) => !open)} + /> + setPendingDiscard(false)} + onDiscardConfirm={() => { + setPendingDiscard(false); + void reloadFromDisk(); + }} + onOpenCancel={() => setPendingOpen(null)} + onOpenConfirm={() => { + const next = pendingOpen; + setPendingOpen(null); + if (next !== null) setActivePath(next); + }} + onOverwrite={() => void overwrite()} + onReload={() => void reloadFromDisk()} + pendingDiscard={pendingDiscard} + pendingOpen={pendingOpen} + saveState={saveState} + status={status} + /> +
+
+ ); +} + +/** Collapses the editor's internal states into the toolbar's one dot. */ +function indicatorFor( + saveState: SaveState, + status: { kind: string }, +): SaveIndicator { + if (status.kind === "error") return "error"; + switch (saveState.kind) { + case "saving": + return "saving"; + case "dirty": + return "dirty"; + case "error": + case "conflict": + return "error"; + default: + return "clean"; + } +} + +/** + * A thin row under the toolbar, shown only when there is something the user + * must decide or know. The dot carries routine state; this carries the rest, + * so nothing that needs a choice is reduced to a colored circle. + */ +function Notice({ + onDiscardCancel, + onDiscardConfirm, + onOpenCancel, + onOpenConfirm, + onOverwrite, + onReload, + pendingDiscard, + pendingOpen, + saveState, + status, +}: { + onDiscardCancel: () => void; + onDiscardConfirm: () => void; + onOpenCancel: () => void; + onOpenConfirm: () => void; + onOverwrite: () => void; + onReload: () => void; + pendingDiscard: boolean; + pendingOpen: string | null; + saveState: SaveState; + status: { kind: string; message?: string }; +}) { + if (status.kind === "error") { + return {status.message}; + } + if (saveState.kind === "conflict") { + return ( + + This file changed on disk since you opened it. + Reload + Overwrite + + ); + } + if (pendingOpen !== null) { + return ( + + Open {pendingOpen.split("/").at(-1)} and discard your unsaved changes? + Discard and open + Cancel + + ); + } + // Reloading would throw away edits, so the toolbar's reload turns into a + // question rather than doing it. + if (pendingDiscard) { + return ( + + Reload from disk and discard your unsaved changes? + Discard + Cancel + + ); + } + if (saveState.kind === "error") { + return {saveState.message}; + } + return null; +} + +function NoticeRow({ + children, + tone, +}: { + children: React.ReactNode; + tone: "error" | "warning"; +}) { + return ( +
+ {children} +
+ ); +} + +function NoticeAction({ + children, + onClick, +}: { + children: React.ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +export default definePluginApp((app) => { + app.slots.fileOpener({ + id: "monaco", + title: "Monaco", + extensions: CLAIMED_EXTENSIONS, + component: MonacoFileOpener, + }); +}); diff --git a/plugins/monaco/components/ContextMenu.tsx b/plugins/monaco/components/ContextMenu.tsx new file mode 100644 index 0000000000..b713d33c0f --- /dev/null +++ b/plugins/monaco/components/ContextMenu.tsx @@ -0,0 +1,138 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@bb/shared-ui/lib/utils"; + +/** + * A small right-click menu. + * + * Hand-rolled rather than vendored from the BB registry: the registry's + * context menu pulls in an icon module and with it the whole hugeicons map, + * which is a lot of bundle for three copy actions. That trade would flip the + * moment this menu needs submenus, checkboxes, or typeahead — at which point + * `npx shadcn add @bb/context-menu` is the right move rather than growing + * this file. + * + * Portals to the body so the panel's `overflow-y-auto` cannot clip it, the + * same reason Monaco's hovers need their own host node. + */ + +export interface ContextMenuItem { + label: string; + onSelect: () => void; +} + +export interface ContextMenuState { + x: number; + y: number; + items: ContextMenuItem[]; +} + +const VIEWPORT_MARGIN_PX = 8; + +/** + * Chrome copied from BB's timeline selection menu (the "Reply in side chat" + * popover) so the two read as the same surface. Container and item classes + * are its `SELECTION_MENU_CONTENT_CLASS` and `SELECTION_ACTION_BUTTON_CLASS`, + * minus the radix `data-[state]` variants — this menu is not radix, so it + * animates in unconditionally on mount. + */ +const MENU_CLASS = + "fixed z-50 w-auto rounded-md border bg-popover p-0.5 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95"; +const ITEM_CLASS = + "flex w-full cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-left text-xs text-foreground transition-colors select-none hover:bg-surface-recessed focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none max-md:pointer-coarse:min-h-7 max-md:pointer-coarse:px-2 max-md:pointer-coarse:py-1"; + +export function ContextMenu({ + state, + onClose, +}: { + state: ContextMenuState | null; + onClose: () => void; +}) { + const menuRef = useRef(null); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + // Measure before paint, so a menu opened near an edge never renders + // off-screen for a frame first. + useLayoutEffect(() => { + if (state === null) return; + // Width is measured rather than fixed: the menu sizes to its labels, like + // the selection menu it mirrors. + const menu = menuRef.current; + const width = menu?.offsetWidth ?? 0; + const height = menu?.offsetHeight ?? 0; + setPosition({ + x: Math.max( + VIEWPORT_MARGIN_PX, + Math.min(state.x, window.innerWidth - width - VIEWPORT_MARGIN_PX), + ), + y: Math.max( + VIEWPORT_MARGIN_PX, + Math.min(state.y, window.innerHeight - height - VIEWPORT_MARGIN_PX), + ), + }); + }, [state]); + + useEffect(() => { + if (state === null) return; + menuRef.current?.querySelector("button")?.focus(); + const onPointerDown = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node)) onClose(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const menu = menuRef.current; + if (menu === null) return; + const buttons = Array.from(menu.querySelectorAll("button")); + if (buttons.length === 0) return; + const index = buttons.indexOf(document.activeElement as HTMLButtonElement); + const delta = event.key === "ArrowDown" ? 1 : -1; + const next = (index + delta + buttons.length) % buttons.length; + buttons[next]?.focus(); + }; + // `true` so a scroll anywhere — including inside the tree — dismisses + // rather than leaving the menu floating over unrelated rows. + window.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("scroll", onClose, true); + window.addEventListener("resize", onClose); + return () => { + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", onClose, true); + window.removeEventListener("resize", onClose); + }; + }, [state, onClose]); + + if (state === null) return null; + + return createPortal( +
+ {state.items.map((item) => ( + + ))} +
, + document.body, + ); +} diff --git a/plugins/monaco/components/FileToolbar.tsx b/plugins/monaco/components/FileToolbar.tsx new file mode 100644 index 0000000000..8765f6b9d4 --- /dev/null +++ b/plugins/monaco/components/FileToolbar.tsx @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { cn } from "@bb/shared-ui/lib/utils"; + +/** + * The bar above the editor. Mirrors BB's own file-preview header (`h-9`, + * `bg-surface-raised`, monospace `text-file-accent` path) so a plugin-opened + * file does not look like a different application from a BB-opened one. + */ + +export type SaveIndicator = "clean" | "dirty" | "saving" | "error"; + +export interface FileToolbarProps { + path: string; + indicator: SaveIndicator; + isRefreshing: boolean; + onRefresh: () => void; + isFilesOpen: boolean; + onToggleFiles: () => void; +} + +export function FileToolbar({ + path, + indicator, + isRefreshing, + onRefresh, + isFilesOpen, + onToggleFiles, +}: FileToolbarProps) { + return ( +
+
+ + + + + +
+ + + + +
+ ); +} + +/** + * Present only while the file differs from disk, the way editors do it — a + * saved file is the resting state and needs no ornament. The slot keeps its + * width either way so the reload button does not shift when the dot appears. + */ +function SaveDot({ indicator }: { indicator: SaveIndicator }) { + if (indicator === "clean") { + return ; + } + const label = + indicator === "saving" + ? "Saving…" + : indicator === "error" + ? "Could not save — unsaved changes" + : "Unsaved changes"; + return ( + + + + ); +} + +/** Truncates from the start, so the file name stays visible in a narrow panel. */ +function CopyablePath({ path }: { path: string }) { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + useEffect( + () => () => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + }, + [], + ); + + const copy = useCallback(() => { + void navigator.clipboard + .writeText(path) + .then(() => { + setCopied(true); + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + toast.success("File path copied"); + }) + .catch(() => toast.error("Failed to copy file path")); + }, [path]); + + return ( + + ); +} + +function ToolbarButton({ + label, + onClick, + disabled, + pressed, + children, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + pressed?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +function TreeIcon() { + return ( + + + + ); +} + +/** + * Inline glyphs rather than an icon dependency: the plugin needs three shapes, + * and pulling in an icon package to get them would bundle a whole map. + */ +function FileGlyph({ path, className }: { path: string; className?: string }) { + const kind = glyphKindForPath(path); + if (kind === "code") { + return ( + + + + ); + } + if (kind === "data") { + return ( + + + + ); + } + return ( + + + + + ); +} + +const DATA_EXTENSIONS = new Set([ + "json", + "jsonc", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "xml", + "csv", + "tsv", +]); + +const DOC_EXTENSIONS = new Set([ + "md", + "mdx", + "markdown", + "txt", + "text", + "rst", + "adoc", + "log", +]); + +function glyphKindForPath(path: string): "code" | "data" | "doc" { + const name = path.split("/").at(-1) ?? path; + const dotIndex = name.lastIndexOf("."); + const extension = dotIndex <= 0 ? "" : name.slice(dotIndex + 1).toLowerCase(); + if (DATA_EXTENSIONS.has(extension)) return "data"; + if (DOC_EXTENSIONS.has(extension)) return "doc"; + return "code"; +} + +function RotateIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/plugins/monaco/components/FileTreePanel.tsx b/plugins/monaco/components/FileTreePanel.tsx new file mode 100644 index 0000000000..534300e3c0 --- /dev/null +++ b/plugins/monaco/components/FileTreePanel.tsx @@ -0,0 +1,306 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ancestorsOf, + buildTree, + filterTree, + type FlatEntry, + type TreeNode, +} from "../lib/file-tree.js"; +import { toast } from "sonner"; +import { ContextMenu, type ContextMenuState } from "./ContextMenu.js"; +import { cn } from "@bb/shared-ui/lib/utils"; + +export interface FileTreePanelProps { + entries: readonly FlatEntry[]; + /** Absolute path the entries are relative to; "" until the listing lands. */ + root: string; + /** True while the listing is in flight; the panel opens before it lands. */ + isLoading: boolean; + error: string | null; + truncated: boolean; + /** The file currently in the editor, revealed and highlighted. */ + activePath: string; + onOpenFile: (path: string) => void; + onClose: () => void; +} + +const INDENT_PER_LEVEL_PX = 12; + +export function FileTreePanel({ + entries, + root, + isLoading, + error, + truncated, + activePath, + onOpenFile, + onClose, +}: FileTreePanelProps) { + const [query, setQuery] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + const [menu, setMenu] = useState(null); + const activeRowRef = useRef(null); + + const openMenu = (event: React.MouseEvent, node: TreeNode) => { + event.preventDefault(); + setMenu({ + x: event.clientX, + y: event.clientY, + items: [ + { + label: "Copy absolute path", + onSelect: () => + copy( + // The daemon may hand back a Windows root; joining with "/" + // there would produce a path nothing on that host accepts. + root === "" + ? node.path + : root.includes("\\") + ? `${root}\\${node.path.replace(/\//g, "\\")}` + : `${root}/${node.path}`, + "Absolute path copied", + ), + }, + { + label: "Copy relative path", + onSelect: () => copy(node.path, "Relative path copied"), + }, + { + label: "Copy filename", + onSelect: () => copy(node.name, "Filename copied"), + }, + ], + }); + }; + + const tree = useMemo(() => buildTree(entries), [entries]); + const filtered = useMemo(() => filterTree(tree, query), [tree, query]); + + // Reveal the open file: every directory above it starts expanded. Re-runs + // when the editor moves to another file, so the tree follows along. + useEffect(() => { + setExpanded((current) => { + const next = new Set(current); + for (const ancestor of ancestorsOf(activePath)) next.add(ancestor); + return next; + }); + }, [activePath]); + + // Scroll the revealed file into view once the rows for it exist. + useEffect(() => { + activeRowRef.current?.scrollIntoView({ block: "nearest" }); + }, [activePath, entries.length]); + + const effectiveExpanded = useMemo(() => { + if (filtered.expand.size === 0) return expanded; + // While filtering, matches are shown regardless of what the user has + // collapsed; their own expansion state is preserved for when the query + // is cleared. + return new Set([...expanded, ...filtered.expand]); + }, [expanded, filtered.expand]); + + const toggle = (path: string) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + return ( + // Sits above the toolbar, so the divider goes on the bottom edge to + // separate the tree from the file bar beneath it. +
+
+ setQuery(event.target.value)} + onKeyDown={(event) => { + // Escape clears a query first, and closes only once the box is + // empty — so it never discards a filter and the panel in one press. + if (event.key !== "Escape") return; + event.stopPropagation(); + if (query !== "") setQuery(""); + else onClose(); + }} + placeholder="Filter files…" + aria-label="Filter files" + spellCheck={false} + className={cn( + "h-6 min-w-0 flex-1 rounded-sm bg-background px-2 text-sm text-foreground", + "placeholder:text-muted-foreground", + "focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none", + )} + /> + +
+
+ {error !== null ? ( + {error} + ) : isLoading ? ( + Loading files… + ) : filtered.nodes.length === 0 ? ( + + {query.trim() === "" ? "No files" : `No files match “${query}”`} + + ) : ( + + )} + {truncated && error === null ? ( + + Showing the first {entries.length.toLocaleString()} entries; this + project is larger. + + ) : null} +
+ setMenu(null)} /> +
+ ); +} + +/** Clipboard write with the same toast treatment as the toolbar's path copy. */ +function copy(text: string, successMessage: string): void { + void navigator.clipboard + .writeText(text) + .then(() => toast.success(successMessage)) + .catch(() => toast.error("Failed to copy")); +} + +function Rows({ + activePath, + activeRowRef, + expanded, + level, + nodes, + onContextMenu, + onOpenFile, + onToggle, +}: { + activePath: string; + activeRowRef: React.RefObject; + expanded: ReadonlySet; + level: number; + nodes: readonly TreeNode[]; + onContextMenu: (event: React.MouseEvent, node: TreeNode) => void; + onOpenFile: (path: string) => void; + onToggle: (path: string) => void; +}) { + return ( + <> + {nodes.map((node) => { + const isDirectory = node.kind === "directory"; + const isOpen = isDirectory && expanded.has(node.path); + const isActive = !isDirectory && node.path === activePath; + return ( +
+ + {isDirectory && isOpen ? ( + + ) : null} +
+ ); + })} + + ); +} + +function Chevron({ isOpen }: { isOpen: boolean }) { + return ( + + + + ); +} + +function Message({ + children, + tone, +}: { + children: React.ReactNode; + tone?: "error"; +}) { + return ( +

+ {children} +

+ ); +} diff --git a/plugins/monaco/lib/file-tree.test.ts b/plugins/monaco/lib/file-tree.test.ts new file mode 100644 index 0000000000..c5b1ca5a5c --- /dev/null +++ b/plugins/monaco/lib/file-tree.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { ancestorsOf, buildTree, filterTree } from "./file-tree.js"; + +describe("buildTree", () => { + it("nests flat paths and sorts directories before files", () => { + const tree = buildTree([ + { path: "readme.md", kind: "file" }, + { path: "src", kind: "directory" }, + { path: "src/index.ts", kind: "file" }, + { path: "src/lib", kind: "directory" }, + { path: "src/lib/util.ts", kind: "file" }, + ]); + + expect(tree.map((node) => node.name)).toEqual(["src", "readme.md"]); + const src = tree[0]!; + expect(src.children.map((node) => node.name)).toEqual([ + "lib", + "index.ts", + ]); + expect(src.children[0]!.children[0]!.path).toBe("src/lib/util.ts"); + }); + + // A truncated listing can carry a file whose parent directory entry was cut, + // and dropping it would misrepresent the tree as smaller than it is. + it("synthesises directories that the listing omitted", () => { + const tree = buildTree([{ path: "a/b/c.ts", kind: "file" }]); + + expect(tree).toHaveLength(1); + expect(tree[0]!.kind).toBe("directory"); + expect(tree[0]!.children[0]!.path).toBe("a/b"); + expect(tree[0]!.children[0]!.children[0]!.path).toBe("a/b/c.ts"); + }); + + it("sorts case-insensitively", () => { + const tree = buildTree([ + { path: "beta.ts", kind: "file" }, + { path: "Alpha.ts", kind: "file" }, + ]); + + expect(tree.map((node) => node.name)).toEqual(["Alpha.ts", "beta.ts"]); + }); +}); + +describe("ancestorsOf", () => { + it("lists each containing directory, nearest last", () => { + expect(ancestorsOf("a/b/c.ts")).toEqual(["a", "a/b"]); + }); + + it("has none for a root-level file", () => { + expect(ancestorsOf("readme.md")).toEqual([]); + }); +}); + +describe("filterTree", () => { + const tree = buildTree([ + { path: "src/index.ts", kind: "file" }, + { path: "src/ui/button.tsx", kind: "file" }, + { path: "docs/guide.md", kind: "file" }, + ]); + + it("keeps matches with the directories leading to them, and says which to open", () => { + const filtered = filterTree(tree, "button"); + + expect(filtered.matchCount).toBe(1); + expect(filtered.nodes.map((node) => node.name)).toEqual(["src"]); + // Both ancestors must expand or the match stays hidden behind a collapsed + // row, which is the whole point of filtering. + expect([...filtered.expand].sort()).toEqual(["src", "src/ui"]); + }); + + it("matches on the whole relative path, not just the file name", () => { + expect(filterTree(tree, "src/ui").matchCount).toBe(1); + }); + + it("is case-insensitive and returns nothing when nothing matches", () => { + expect(filterTree(tree, "BUTTON").matchCount).toBe(1); + expect(filterTree(tree, "nothing-here").nodes).toEqual([]); + }); + + it("passes the tree through untouched when the query is blank", () => { + const filtered = filterTree(tree, " "); + + expect(filtered.nodes).toHaveLength(2); + expect(filtered.expand.size).toBe(0); + }); +}); diff --git a/plugins/monaco/lib/file-tree.ts b/plugins/monaco/lib/file-tree.ts new file mode 100644 index 0000000000..228f1c661e --- /dev/null +++ b/plugins/monaco/lib/file-tree.ts @@ -0,0 +1,144 @@ +/** + * Turning the server's flat path list into something a tree view can render. + * Kept free of React so the nesting, filtering, and reveal rules can be read + * (and reasoned about) on their own. + */ + +export type EntryKind = "file" | "directory"; + +export interface FlatEntry { + path: string; + kind: EntryKind; +} + +export interface TreeNode { + /** Root-relative, `/`-separated. Unique; used as the React key. */ + path: string; + name: string; + kind: EntryKind; + /** Empty for files. Directories first, then case-insensitive by name. */ + children: TreeNode[]; +} + +/** + * Nests flat entries. Intermediate directories are synthesised when missing — + * a truncated listing can contain `a/b/c.ts` with no entry for `a/b`, and + * dropping that file would misrepresent the tree as smaller than it is. + */ +export function buildTree(entries: readonly FlatEntry[]): TreeNode[] { + const root: TreeNode = { + path: "", + name: "", + kind: "directory", + children: [], + }; + const byPath = new Map([["", root]]); + + const directoryAt = (path: string): TreeNode => { + const existing = byPath.get(path); + if (existing !== undefined) return existing; + const separator = path.lastIndexOf("/"); + const parent = directoryAt(separator === -1 ? "" : path.slice(0, separator)); + const node: TreeNode = { + path, + name: path.slice(separator + 1), + kind: "directory", + children: [], + }; + byPath.set(path, node); + parent.children.push(node); + return node; + }; + + for (const entry of entries) { + const path = normalize(entry.path); + if (path === "") continue; + if (entry.kind === "directory") { + directoryAt(path); + continue; + } + if (byPath.has(path)) continue; + const separator = path.lastIndexOf("/"); + const parent = directoryAt(separator === -1 ? "" : path.slice(0, separator)); + const node: TreeNode = { + path, + name: path.slice(separator + 1), + kind: "file", + children: [], + }; + byPath.set(path, node); + parent.children.push(node); + } + + sortRecursively(root); + return root.children; +} + +function normalize(path: string): string { + return path.replace(/^\.?\//, "").replace(/\/+$/, ""); +} + +function sortRecursively(node: TreeNode): void { + node.children.sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; + return left.name.localeCompare(right.name, undefined, { + sensitivity: "base", + }); + }); + for (const child of node.children) sortRecursively(child); +} + +/** Every directory containing `path`, nearest last: `a`, `a/b` for `a/b/c.ts`. */ +export function ancestorsOf(path: string): string[] { + const segments = normalize(path).split("/"); + segments.pop(); + const ancestors: string[] = []; + let current = ""; + for (const segment of segments) { + current = current === "" ? segment : `${current}/${segment}`; + ancestors.push(current); + } + return ancestors; +} + +export interface FilteredTree { + nodes: TreeNode[]; + /** Directories to force open so every match is visible without clicking. */ + expand: Set; + matchCount: number; +} + +/** + * Filters to files whose path contains `query`, keeping the directories that + * lead to them. Matching is on the whole relative path, not just the file + * name, so "components/ui" narrows by directory as readily as by file. + */ +export function filterTree(nodes: readonly TreeNode[], query: string): FilteredTree { + const needle = query.trim().toLowerCase(); + if (needle === "") { + return { nodes: [...nodes], expand: new Set(), matchCount: 0 }; + } + + const expand = new Set(); + let matchCount = 0; + + const visit = (node: TreeNode): TreeNode | null => { + if (node.kind === "file") { + if (!node.path.toLowerCase().includes(needle)) return null; + matchCount += 1; + return node; + } + const children = node.children + .map(visit) + .filter((child): child is TreeNode => child !== null); + if (children.length === 0) return null; + expand.add(node.path); + return { ...node, children }; + }; + + return { + nodes: nodes.map(visit).filter((node): node is TreeNode => node !== null), + expand, + matchCount, + }; +} diff --git a/plugins/monaco/lib/languages.ts b/plugins/monaco/lib/languages.ts new file mode 100644 index 0000000000..9b462b462c --- /dev/null +++ b/plugins/monaco/lib/languages.ts @@ -0,0 +1,139 @@ +/** + * The extensions this plugin claims as a file opener, and their Monaco + * language ids. + * + * Claiming an extension makes Monaco the *default* viewer for it the moment + * the plugin is installed: BB picks the first registration matching an + * extension whenever the user has no per-extension preference. Users opt back + * out one extension at a time under Settings → File openers, and that + * settings page renders one row per distinct claimed extension — so this list + * is deliberately "common text and code" rather than exhaustive. + * + * Two kinds of file can never reach us regardless of what is listed here: + * binaries we deliberately leave out (png, pdf, zip — BB's preview renders + * them properly), and files BB reads as having no extension at all, which + * includes dotfiles: its `getFileExtension` returns null when the last dot is + * at index 0 or absent, so `Makefile`, `LICENSE`, and `.gitignore` always use + * the built-in preview. + * + * Extensions must be lowercase alphanumerics with no dot — the SDK rejects + * the registration otherwise. + */ +const LANGUAGE_BY_EXTENSION: Record = { + // Web + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + tsx: "typescript", + mts: "typescript", + cts: "typescript", + html: "html", + htm: "html", + css: "css", + scss: "scss", + less: "less", + vue: "html", + svelte: "html", + + // Data and config + json: "json", + jsonc: "json", + yaml: "yaml", + yml: "yaml", + toml: "ini", + ini: "ini", + cfg: "ini", + conf: "ini", + env: "shell", + xml: "xml", + csv: "plaintext", + tsv: "plaintext", + + // Docs + md: "markdown", + mdx: "markdown", + markdown: "markdown", + txt: "plaintext", + text: "plaintext", + rst: "plaintext", + adoc: "plaintext", + + // Systems + c: "c", + h: "c", + cc: "cpp", + cpp: "cpp", + cxx: "cpp", + hpp: "cpp", + hh: "cpp", + rs: "rust", + go: "go", + zig: "plaintext", + swift: "swift", + m: "objective-c", + mm: "objective-c", + + // JVM / .NET + java: "java", + kt: "kotlin", + kts: "kotlin", + scala: "scala", + groovy: "plaintext", + cs: "csharp", + fs: "fsharp", + + // Scripting + py: "python", + pyi: "python", + rb: "ruby", + php: "php", + pl: "perl", + lua: "lua", + r: "r", + sh: "shell", + bash: "shell", + zsh: "shell", + fish: "shell", + ps1: "powershell", + bat: "bat", + cmd: "bat", + + // Query and schema + sql: "sql", + graphql: "graphql", + gql: "graphql", + proto: "plaintext", + + // Infra + tf: "hcl", + tfvars: "hcl", + hcl: "hcl", + dockerfile: "dockerfile", + + // Other + dart: "dart", + ex: "plaintext", + exs: "plaintext", + erl: "plaintext", + clj: "clojure", + hs: "plaintext", + jl: "julia", + patch: "plaintext", + diff: "plaintext", + log: "plaintext", +}; + +/** Every extension this plugin claims, for `app.slots.fileOpener`. */ +export const CLAIMED_EXTENSIONS: readonly string[] = + Object.keys(LANGUAGE_BY_EXTENSION); + +/** The Monaco language id for a path, defaulting to plaintext. */ +export function languageForPath(path: string): string { + const name = path.split("/").at(-1) ?? path; + const dotIndex = name.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === name.length - 1) return "plaintext"; + const extension = name.slice(dotIndex + 1).toLowerCase(); + return LANGUAGE_BY_EXTENSION[extension] ?? "plaintext"; +} diff --git a/plugins/monaco/lib/monaco-loader.ts b/plugins/monaco/lib/monaco-loader.ts new file mode 100644 index 0000000000..ccdd7165fb --- /dev/null +++ b/plugins/monaco/lib/monaco-loader.ts @@ -0,0 +1,222 @@ +import type * as MonacoNs from "monaco-editor"; + +/** + * Loads Monaco's prebuilt AMD bundle from a URL at runtime. + * + * Monaco is deliberately NOT bundled into app.js. `bb plugin build` runs one + * fixed esbuild config — single entry, single outfile, no code splitting and + * no loader map — which means the ESM build fails outright (its stylesheet + * pulls in codicon.ttf, and no loader is configured for `.ttf`), and even + * patched around it, a dynamic import cannot split, so all ~4.4 MB would + * parse at app boot for every user with the plugin enabled. Loading the AMD + * build from a URL keeps our bundle at a few KB, defers every byte of Monaco + * until a file tab actually opens, and gets the stylesheet, fonts, workers, + * and on-demand language definitions working exactly as Microsoft ships them. + * + * The URL comes from `bb.sdk.files.createPreview` over `monaco-editor/min/vs` + * (see server.ts) — same origin as the app, so workers are not cross-origin. + * + * Caveat worth knowing: Monaco's README marks the AMD build deprecated. It + * ships in 0.56 and works; if it is ever dropped, the replacement is to serve + * a self-built ESM bundle from the same preview URL, which changes this file + * and nothing else. + */ + +type AmdRequire = { + (modules: string[], onLoad: () => void, onError: (error: unknown) => void): void; + config(options: { paths: Record }): void; +}; + +type MonacoGlobals = { + require?: AmdRequire; + define?: unknown; + monaco?: typeof MonacoNs; + MonacoEnvironment?: unknown; +}; + +/** + * One load per app window, shared by every open editor tab. Keyed by nothing: + * the asset URL can change when a lease is re-issued, but the loader is + * already configured by then and Monaco is in memory, so re-booting it would + * be wasteful and would re-register its global `define`. + */ +let bootPromise: Promise | null = null; + +export function loadMonaco(baseUrl: string): Promise { + bootPromise ??= boot(baseUrl); + return bootPromise; +} + +async function boot(baseUrl: string): Promise { + // Monaco's loader installs `require`/`define`/`monaco` as page globals; + // TypeScript's view of globalThis knows nothing about them. + const globals = globalThis as unknown as MonacoGlobals; + + await injectScript(`${baseUrl}/loader.js`); + const amdRequire = globals.require; + if (!amdRequire) { + throw new Error("Monaco's AMD loader did not install itself"); + } + amdRequire.config({ paths: { vs: baseUrl } }); + + // Deliberately NOT setting MonacoEnvironment: Monaco 0.56's AMD build + // assigns `self.MonacoEnvironment` itself during `editor.main`, with a + // getWorker that builds blob workers from its own bundled worker assets. + // Anything set here is overwritten by it. + await new Promise((resolve, reject) => { + amdRequire( + ["vs/editor/editor.main"], + () => resolve(), + (error) => + reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + + const monaco = globals.monaco; + if (!monaco) { + throw new Error("Monaco loaded but did not expose its API"); + } + configureDiagnostics(monaco); + return monaco; +} + +/** + * Turns off type checking, leaving syntax checking on. + * + * `monaco-editor` bundles the whole TypeScript compiler (its `ts.worker` is + * ~7 MB) and `editor.main` wires it up by default, so opening a `.ts` file + * silently starts a type checker in a web worker. Nothing in BB asks for + * this and BB has no LSP — it is Monaco's own batteries. + * + * That checker has no file system. It sees exactly one file: the open model. + * Every import therefore fails to resolve, and the editor fills with + * "Cannot find module" on imports that are perfectly correct on disk. The + * diagnostics are not just noisy, they are wrong. + * + * Syntax diagnostics stay on: an unbalanced brace is a real error in a + * single file, and reporting it needs no project graph. If BB ever grows + * real language-server support, semantic checking belongs there — with the + * whole project behind it — not in this worker. + */ +interface DiagnosticsDefaults { + setDiagnosticsOptions(options: { + noSemanticValidation: boolean; + noSyntaxValidation: boolean; + noSuggestionDiagnostics: boolean; + }): void; +} + +interface TypescriptNamespace { + typescriptDefaults?: unknown; + javascriptDefaults?: unknown; +} + +function isDiagnosticsDefaults(value: unknown): value is DiagnosticsDefaults { + return ( + typeof value === "object" && + value !== null && + typeof (value as { setDiagnosticsOptions?: unknown }) + .setDiagnosticsOptions === "function" + ); +} + +/** + * Monaco 0.56 deprecated `languages.typescript` in favour of a top-level + * `typescript` namespace, but the AMD build still installs the working + * implementation on `languages` and types the deprecated path as an inert + * `{ deprecated: true }` stub. The declarations and the runtime disagree, so + * probe both and narrow what comes back. + */ +function typescriptNamespaceOf(monaco: typeof MonacoNs): TypescriptNamespace[] { + const candidates: unknown[] = [ + (monaco as { typescript?: unknown }).typescript, + (monaco.languages as { typescript?: unknown } | undefined)?.typescript, + ]; + return candidates.filter( + (candidate): candidate is TypescriptNamespace => + typeof candidate === "object" && candidate !== null, + ); +} + +function configureDiagnostics(monaco: typeof MonacoNs): void { + let configured = 0; + for (const namespace of typescriptNamespaceOf(monaco)) { + for (const defaults of [ + namespace.typescriptDefaults, + namespace.javascriptDefaults, + ]) { + if (!isDiagnosticsDefaults(defaults)) continue; + defaults.setDiagnosticsOptions({ + noSemanticValidation: true, + noSyntaxValidation: false, + noSuggestionDiagnostics: true, + }); + configured += 1; + } + } + if (configured === 0) { + // Not fatal, but the editor will fill with false "Cannot find module" + // errors, and the cause would otherwise be invisible. + console.warn( + "[monaco] could not disable semantic diagnostics: Monaco's typescript" + + " defaults were not found at either the current or deprecated path", + ); + } +} + +const OVERFLOW_NODE_ID = "bb-plugin-monaco-overflow-widgets"; + +/** + * A body-level host for Monaco's "overflow widgets" — hovers, the suggest + * list, the parameter hints, the context menu. + * + * By default Monaco renders these inside the editor's own DOM, where BB's + * panel chrome clips them: a hover wider than the panel is cut off at its + * edge rather than overflowing across the conversation. + * + * `fixedOverflowWidgets: true` alone is not enough here. It switches the + * widgets to `position: fixed`, which normally escapes ancestor clipping — + * but one of the panel's ancestors is a Tailwind `@container`, and + * `container-type: inline-size` establishes a containing block for fixed + * descendants, so they stay trapped. Giving Monaco a node outside that + * subtree is what actually frees them. + * + * Shared by every open editor (Monaco supports that) and deliberately not + * torn down: it is one empty div, and removing it while another tab's editor + * still references it would break that editor's widgets. + */ +export function overflowWidgetsNode(): HTMLElement { + const existing = document.getElementById(OVERFLOW_NODE_ID); + if (existing !== null) return existing; + const node = document.createElement("div"); + node.id = OVERFLOW_NODE_ID; + // Monaco's widget CSS is scoped under `.monaco-editor`, so the host node + // has to carry that class or the hovers render unstyled. + node.className = "monaco-editor"; + node.style.position = "absolute"; + node.style.top = "0"; + node.style.left = "0"; + // Above BB's panel chrome. Kept below the 50+ band that dialogs and the + // app header occupy, and since radix portals mount later in the body they + // still stack over this. + node.style.zIndex = "40"; + document.body.appendChild(node); + return node; +} + +/** Keeps the overflow host on the same Monaco theme as the editors. */ +export function setOverflowWidgetsTheme(theme: "vs" | "vs-dark"): void { + const node = document.getElementById(OVERFLOW_NODE_ID); + if (node !== null) node.className = `monaco-editor ${theme}`; +} + +function injectScript(src: string): Promise { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load ${src}`)); + document.head.appendChild(script); + }); +} diff --git a/plugins/monaco/package.json b/plugins/monaco/package.json new file mode 100644 index 0000000000..7f92a494aa --- /dev/null +++ b/plugins/monaco/package.json @@ -0,0 +1,64 @@ +{ + "name": "bb-plugin-monaco", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Edit files in BB with the Monaco editor instead of the read-only preview.", + "license": "MIT", + "homepage": "https://github.com/get-bb/bb#readme", + "bugs": { + "url": "https://github.com/get-bb/bb/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/get-bb/bb.git", + "directory": "plugins/monaco" + }, + "files": [ + "dist", + "server.ts", + "app.tsx", + "components", + "lib", + "README.md" + ], + "engines": { + "bb": ">=0.0", + "bbPluginSdk": ">=0.4.9" + }, + "bb": { + "name": "Monaco", + "description": "Edit files in BB with the Monaco editor instead of the read-only preview.", + "branding": { + "icon": "Code" + }, + "server": "./server.ts", + "app": "./app.tsx" + }, + "keywords": [ + "bb-plugin" + ], + "scripts": { + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@bb/shared-ui": "workspace:*", + "monaco-editor": "^0.56.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@get-bb/plugin-sdk": "workspace:*", + "@testing-library/react": "^16.3.2", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^29.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "sonner": "^1.7.4", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-7": "npm:typescript@^7.0.2", + "vitest": "^4.1.1" + } +} diff --git a/plugins/monaco/scripts/stage-assets.mjs b/plugins/monaco/scripts/stage-assets.mjs new file mode 100644 index 0000000000..a4629f44b9 --- /dev/null +++ b/plugins/monaco/scripts/stage-assets.mjs @@ -0,0 +1,35 @@ +/** + * Copies Monaco's prebuilt AMD bundle into `dist/vs`. + * + * Packaging ships only a builtin plugin's `dist/` and `skills/` directories + * (`apps/server/scripts/copy-builtin-plugins.ts`), so anything the plugin + * needs on disk at runtime has to be inside `dist/`. The server serves this + * directory over a `files.createPreview` URL and the frontend loads Monaco + * from it; without the copy, a released BB has no Monaco to serve. + * + * Run after `build-official-plugins.mjs`, which clears `dist/` first. + */ +import { cp, mkdir, stat } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const pluginRoot = path.resolve(import.meta.dirname, ".."); +const require = createRequire(path.join(pluginRoot, "package.json")); + +// `exports` rewrites every subpath to ./esm/vs/*, so resolve the package root +// (whose `require` condition is min/vs/index.js) and take its directory. +const sourceDir = path.dirname(require.resolve("monaco-editor")); +const targetDir = path.join(pluginRoot, "dist", "vs"); + +const loader = path.join(sourceDir, "loader.js"); +try { + await stat(loader); +} catch { + throw new Error( + `monaco-editor's AMD build is missing (${loader}); run npm/pnpm install first`, + ); +} + +await mkdir(path.dirname(targetDir), { recursive: true }); +await cp(sourceDir, targetDir, { recursive: true }); +console.log(`monaco: staged ${sourceDir} -> ${targetDir}`); diff --git a/plugins/monaco/server.ts b/plugins/monaco/server.ts new file mode 100644 index 0000000000..da530629dc --- /dev/null +++ b/plugins/monaco/server.ts @@ -0,0 +1,322 @@ +// bb-plugin-monaco — backend entry. +// +// Three jobs, all in service of the `fileOpener` slot in app.tsx: +// 1. `assets` — hand the frontend a URL it can load Monaco's AMD build from. +// 2. `read` — read the opened file off whichever host owns it. +// 3. `write` — save it back, guarded by a content hash. +// +// Everything file-shaped goes through `bb.sdk.files`, never `node:fs`: the +// file being edited may live on an enrolled remote machine, and `rootPath` +// confinement plus the compare-and-swap guard are the reason to use it even +// when it does not. +import path from "node:path"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +/** Files above this are refused: Monaco bogs down and the tab is unusable. */ +const MAX_EDITABLE_BYTES = 8 * 1024 * 1024; + +/** + * Ceiling on the file-tree listing — also the daemon's own maximum, which + * rejects anything larger with "Too big: expected number to be <=10000". + * A repository bigger than this lists partially and says so. + */ +const MAX_TREE_ENTRIES = 10_000; + +/** + * Preview lease lifetime. One hour is the server's maximum — it rejects + * anything larger with "Too big: expected number to be <=3600000" — so the + * lease is re-issued rather than held. + */ +const ASSET_LEASE_TTL_MS = 60 * 60 * 1000; + +/** + * Re-issue the lease when it has less than this left. Comfortably longer than + * a page load, so a tab opening near expiry never races it. + */ +const ASSET_LEASE_REFRESH_MARGIN_MS = 5 * 60 * 1000; + +/** + * `PluginFileOpenerSource` as it arrives over the wire. BB owns this shape; + * we re-validate it because RPC input is a boundary like any other. + */ +const sourceSchema = z + .object({ + kind: z.enum(["workspace", "host", "thread-storage"]), + threadId: z.string().nullable(), + environmentId: z.string().nullable(), + projectId: z.string().nullable(), + /** Set for a project-backed workspace file opened on a non-primary host. */ + experimental_hostId: z.string().optional(), + }) + .strict(); + +const fileSchema = z + .object({ path: z.string().min(1), source: sourceSchema }) + .strict(); + +export const rpcContract = defineRpcContract({ + assets: { + input: z.null(), + output: z.object({ baseUrl: z.string(), expiresAtMs: z.number() }), + }, + read: { + input: fileSchema, + output: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("text"), + content: z.string(), + sha256: z.string(), + }), + // Not an error: binary and oversized files are ordinary things to click + // on. The frontend renders BB's own preview for these instead. + z.object({ kind: z.literal("unsupported"), reason: z.string() }), + ]), + }, + tree: { + input: z.object({ source: sourceSchema }).strict(), + output: z.object({ + /** Absolute root the entries are relative to, for "copy absolute path". */ + root: z.string(), + entries: z.array( + z.object({ + path: z.string(), + kind: z.enum(["file", "directory"]), + }), + ), + // The daemon caps its own listing; surfacing the flag lets the UI say + // "showing the first N" instead of quietly presenting a partial tree + // as if it were the whole project. + truncated: z.boolean(), + }), + }, + write: { + input: fileSchema.extend({ + content: z.string(), + // The hash `read` returned. Null means "create only" — we never send it + // today, but the SDK distinguishes it from an absent guard, so the + // contract keeps the distinction rather than collapsing it. + expectedSha256: z.string().nullable(), + }), + output: z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("written"), sha256: z.string() }), + z.object({ + outcome: z.literal("conflict"), + currentSha256: z.string().nullable(), + }), + ]), + }, +}); + +/** + * Monaco's prebuilt AMD bundle directory. + * + * Two layouts, because a builtin plugin ships differently from how it is + * developed. Packaging copies only `dist/` and `skills/` (see + * `apps/server/scripts/copy-builtin-plugins.ts`), so `stage-monaco.mjs` puts + * a copy of `min/vs` at `dist/vs` at build time and that is what a released + * BB serves. Running from source there is no `dist/`, so fall back to + * node_modules. + * + * The node_modules lookup resolves the package root rather than the file: + * monaco's `exports` map rewrites every subpath to `./esm/vs/*`, so + * `require.resolve("monaco-editor/min/vs/loader.js")` fails, while resolving + * the root under the `require` condition lands on `min/vs/index.js`. + */ +function resolveMonacoVsDir(): string { + const staged = path.join(path.dirname(fileURLToPath(import.meta.url)), "vs"); + if (existsSync(path.join(staged, "loader.js"))) return staged; + const require = createRequire(import.meta.url); + return path.dirname(require.resolve("monaco-editor")); +} + +export default async function plugin(bb: BbPluginApi) { + const vsDir = resolveMonacoVsDir(); + + let assetLease: { baseUrl: string; expiresAtMs: number } | null = null; + + /** + * A preview URL over Monaco's asset directory, refreshed before it lapses. + * `createPreview` serves any file beneath the root from BB's own origin, + * which is what lets the AMD loader pull `editor.main.js`, the stylesheet, + * and each language definition on demand. + */ + async function assets() { + const now = Date.now(); + if ( + assetLease === null || + assetLease.expiresAtMs - now < ASSET_LEASE_REFRESH_MARGIN_MS + ) { + // No hostId: Monaco lives in this plugin's node_modules, on the server. + assetLease = await bb.sdk.files.createPreview({ + rootPath: vsDir, + ttlMs: ASSET_LEASE_TTL_MS, + }); + } + return assetLease; + } + + /** + * The thread-storage root, mirroring the server's own resolution: the + * `BB_THREAD_STORAGE` override if set, else `/thread-storage`. + * Reading `process.env` is legitimate here — plugins run in-process inside + * the server, so this is the same environment the server resolved from. + */ + async function threadStorageRoot(): Promise { + const override = process.env.BB_THREAD_STORAGE; + if (override && override.trim().length > 0) return path.resolve(override); + const { dataDir } = await bb.sdk.system.config(); + return path.join(dataDir, "thread-storage"); + } + + /** + * Where a file the user clicked actually lives. `workspace` paths are + * worktree-relative and need the environment to become absolute; `host` + * paths are already absolute; thread-storage paths are relative to the + * thread's own storage directory. All three are confined to a root, so a + * traversal in the path cannot escape the worktree, the file's directory, + * or the thread's storage. + * + * BB's public API is read-only over thread storage, so we resolve it to a + * plain filesystem path instead and get editing for free. Known limitation: + * `dataDir` is the *server's*, so a thread whose environment lives on an + * enrolled remote machine resolves to a path that does not exist there and + * fails to open rather than silently touching the wrong host's disk. + */ + async function resolveTarget( + source: z.infer, + filePath: string, + ): Promise<{ path: string; rootPath: string; hostId?: string }> { + if (source.kind === "thread-storage") { + if (source.threadId === null) { + throw new Error("This thread-storage file has no thread"); + } + const rootPath = path.join(await threadStorageRoot(), source.threadId); + return { path: path.join(rootPath, filePath), rootPath }; + } + // A workspace file opened from a project surface has no environment: it + // lives directly in one of the project's source checkouts. `hostId` is + // explicit there, because a project's sources can span hosts. + if (source.environmentId === null && source.kind === "workspace") { + if (source.projectId === null) { + throw new Error("This file has no environment or project"); + } + const project = await bb.sdk.projects.get({ + projectId: source.projectId, + }); + const sources = project.sources; + const checkout = + source.experimental_hostId === undefined + ? (sources.find((entry) => entry.isDefault) ?? sources[0]) + : sources.find( + (entry) => entry.hostId === source.experimental_hostId, + ); + if (checkout === undefined) { + throw new Error("This project has no matching source checkout"); + } + return { + path: path.join(checkout.path, filePath), + rootPath: checkout.path, + hostId: checkout.hostId, + }; + } + if (source.environmentId === null) { + throw new Error("This file has no environment to resolve it against"); + } + const environment = await bb.sdk.environments.get({ + environmentId: source.environmentId, + }); + + if (source.kind === "host") { + // Absolute already; confine to its own directory so the path cannot + // walk somewhere else on the host. + const api = path.win32.isAbsolute(filePath) ? path.win32 : path.posix; + return { + path: filePath, + rootPath: api.dirname(filePath), + ...(environment.hostId ? { hostId: environment.hostId } : {}), + }; + } + + if (!environment.path) { + throw new Error("This environment has no workspace path"); + } + return { + path: path.join(environment.path, filePath), + rootPath: environment.path, + ...(environment.hostId ? { hostId: environment.hostId } : {}), + }; + } + + bb.rpc.register(rpcContract, { + assets: () => assets(), + + async read({ path: filePath, source }) { + const target = await resolveTarget(source, filePath); + const file = await bb.sdk.files.read(target); + + // The daemon returns base64 when the bytes are not valid UTF-8. Handing + // that to Monaco would render mojibake and, worse, saving it would + // corrupt the file. + if (file.contentEncoding !== "utf8") { + return { kind: "unsupported" as const, reason: "This file is not text" }; + } + if (file.sizeBytes > MAX_EDITABLE_BYTES) { + return { + kind: "unsupported" as const, + reason: `This file is too large to edit (${Math.round(file.sizeBytes / 1024 / 1024)} MB)`, + }; + } + return { + kind: "text" as const, + content: file.content, + sha256: file.sha256, + }; + }, + + /** + * The file tree the "Show in files" panel renders, as a flat list of + * root-relative paths; the frontend nests them. + * + * Rooted at the same place `read`/`write` confine to — the worktree for a + * workspace file, the thread's storage directory, the file's own + * directory for a bare host path (there is no project to speak of there). + */ + async tree({ source }) { + const target = await resolveTarget(source, "."); + const result = await bb.sdk.files.listPaths({ + path: target.rootPath, + includeFiles: true, + includeDirectories: true, + limit: MAX_TREE_ENTRIES, + ...(target.hostId !== undefined ? { hostId: target.hostId } : {}), + }); + return { + root: target.rootPath, + entries: result.paths.map((entry) => ({ + path: entry.path, + kind: entry.kind, + })), + truncated: result.truncated, + }; + }, + + async write({ path: filePath, source, content, expectedSha256 }) { + const target = await resolveTarget(source, filePath); + const result = await bb.sdk.files.write({ + ...target, + content, + contentEncoding: "utf8", + expectedSha256, + }); + return result.outcome === "written" + ? { outcome: "written" as const, sha256: result.sha256 } + : { outcome: "conflict" as const, currentSha256: result.currentSha256 }; + }, + }); + + bb.log.info(`serving Monaco from ${vsDir}`); +} diff --git a/plugins/monaco/tsconfig.json b/plugins/monaco/tsconfig.json new file mode 100644 index 0000000000..09f569141b --- /dev/null +++ b/plugins/monaco/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": [ + "ES2022", + "DOM" + ], + "noEmit": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "server.ts", + "app.tsx", + "components", + "lib", + "vitest.config.ts" + ] +} diff --git a/plugins/monaco/vitest.config.ts b/plugins/monaco/vitest.config.ts new file mode 100644 index 0000000000..e7108d919b --- /dev/null +++ b/plugins/monaco/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; + +export default defineWorkspaceTestConfig({ + test: { + silent: "passed-only", + name: "bb-plugin-monaco", + include: ["**/*.test.{ts,tsx}"], + exclude: ["node_modules/**"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 386e416b34..d3e64da26c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1677,7 +1677,7 @@ importers: version: typescript@7.0.2 vitest: specifier: ^4.1.1 - version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) packages/client-core: dependencies: @@ -3323,6 +3323,55 @@ importers: specifier: ^4.1.1 version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + plugins/monaco: + dependencies: + '@bb/shared-ui': + specifier: workspace:* + version: link:../../packages/shared-ui + monaco-editor: + specifier: ^0.56.0 + version: 0.56.0 + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@get-bb/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/node': + specifier: ^22.0.0 + version: 22.19.10 + '@types/react': + specifier: ^19.0.0 + version: 19.2.13 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.13) + jsdom: + specifier: ^29.0.1 + version: 29.0.1(@noble/hashes@2.0.1) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + sonner: + specifier: ^1.7.4 + version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + typescript-7: + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + vitest: + specifier: ^4.1.1 + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + plugins/pdf-preview: devDependencies: '@get-bb/plugin-sdk': @@ -4822,11 +4871,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -5472,7 +5521,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@57.0.16': resolution: {integrity: sha512-+HyMY2nAS6QBJb0nSeMz92p11bZ1AtWSRnhWnklznN07IRoQirisnKq2vr18Ayjb/fnb5F/um+n6FRhF0fZm1Q==} @@ -10241,6 +10290,9 @@ packages: domino@2.1.8: resolution: {integrity: sha512-qLkTcPRkqvifAB0bweVznkWgAu2hwin8An+HbLy0bH1ZtMyJzwXiHKMhynwR1Wq4PgBvEyg2Y749j4JJnlfDKg==} + dompurify@3.4.8: + resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} + dompurify@3.4.9: resolution: {integrity: sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==} @@ -10627,6 +10679,7 @@ packages: eslint@9.39.3: resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -12332,6 +12385,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -12783,6 +12841,9 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + monaco-editor@0.56.0: + resolution: {integrity: sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -14579,10 +14640,6 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} - engines: {node: '>=14.0.0'} - tinyrainbow@3.1.1: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} @@ -19336,9 +19393,7 @@ snapshots: metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.79.6': {} @@ -21210,7 +21265,7 @@ snapshots: dependencies: '@vitest/pretty-format': 4.1.1 convert-source-map: 2.0.0 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.1 '@vue/compiler-core@3.5.39': dependencies: @@ -22641,6 +22696,10 @@ snapshots: domino@2.1.8: {} + dompurify@3.4.8: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.4.9: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -23708,10 +23767,6 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -25065,6 +25120,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@16.4.2: {} marked@18.0.5: {} @@ -26040,6 +26097,11 @@ snapshots: moment@2.30.1: optional: true + monaco-editor@0.56.0: + dependencies: + dompurify: 3.4.8 + marked: 14.0.0 + ms@2.0.0: {} ms@2.1.3: {} @@ -28111,8 +28173,8 @@ snapshots: tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyglobby@0.2.17: dependencies: @@ -28123,8 +28185,6 @@ snapshots: tinyrainbow@2.0.0: {} - tinyrainbow@3.0.3: {} - tinyrainbow@3.1.1: {} tinyspy@4.0.4: {} @@ -28598,11 +28658,11 @@ snapshots: vite@6.4.1(@types/node@22.19.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.26 rollup: 4.62.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.19.10 fsevents: 2.3.3 @@ -28615,11 +28675,11 @@ snapshots: vite@6.4.1(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.26 rollup: 4.62.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.12.4 fsevents: 2.3.3 @@ -28788,6 +28848,35 @@ snapshots: - tsx - yaml + vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.10 + jsdom: 29.0.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - msw + vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.19.12)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.1 diff --git a/turbo.json b/turbo.json index 8a8bdee3aa..3f88fd1af8 100644 --- a/turbo.json +++ b/turbo.json @@ -820,6 +820,12 @@ "topo" ] }, + "bb-plugin-monaco#typecheck": { + "dependsOn": [ + "@get-bb/plugin-sdk#build:types", + "topo" + ] + }, "bb-plugin-pdf-preview#typecheck": { "dependsOn": [ "@get-bb/plugin-sdk#build:types",