diff --git a/apps/server/scripts/copy-builtin-plugins.ts b/apps/server/scripts/copy-builtin-plugins.ts index e9c10ad996..ff204e30a0 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 ef113f83d7..a55dff72a8 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -78,6 +78,12 @@ export const BUILTIN_PLUGINS = [ defaultEnabled: true, category: "Interface", }, + { + name: "monaco-editor", + pluginId: "monaco-editor", + 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 c1a4949662..f18eab9cec 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -229,6 +229,7 @@ describe("builtin plugin reconciliation", () => { ["plugin-api-tester", "Beaker"], ["inline-vis", "AppWindow"], ["keep-awake", "Coffee"], + ["monaco-editor", "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 3396fd191b..f8783e9440 100644 --- a/apps/server/test/services/plugins/official-plugins.test.ts +++ b/apps/server/test/services/plugins/official-plugins.test.ts @@ -100,6 +100,7 @@ describe("official plugin registry invariants", () => { "inline-vis": "Interface", "keep-awake": "Host access", memory: "Context & knowledge", + "monaco-editor": "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 5b2745eac8..70565f7609 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-editor", "pdf-preview", "provider-retry", "secrets", diff --git a/plugins/monaco-editor/README.md b/plugins/monaco-editor/README.md new file mode 100644 index 0000000000..e019e8cdce --- /dev/null +++ b/plugins/monaco-editor/README.md @@ -0,0 +1,95 @@ +# bb-plugin-monaco-editor + +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. +- **Quick palette commands.** Open the quick palette (⌘⇧P) and + type "fold", "sort", or "copy" to reach *fold level 1–5*, *fold + recursively*, *unfold all*, *unfold recursively*, *unfold at cursor*, + *sort selected lines ascending/descending*, and *copy the path / relative + path of the current file*. The sort rows appear only with a multi-line + selection, and every row acts on the Monaco tab you last worked in. +- **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-editor +``` + +`scripts/stage-assets.mjs` builds the Monaco bundle the editor loads, into +`dist/monaco`. Packaging runs it (`apps/server/scripts/copy-builtin-plugins.ts`), +since only a builtin's `dist/` ships. A source checkout never runs that path — +the dev server loads builtins straight from `plugins/` — so the plugin +builds the bundle itself when it is missing or older than `monaco-bundle/`, +which makes that one file open a few seconds slow. +`pnpm --filter bb-plugin-monaco-editor build:monaco` does it up front. + +The dev loop already rebuilds `dist/app.js` and reloads `server.ts` on save, +so editing `app.tsx`, `components/`, `lib/`, or `server.ts` needs nothing +extra. It knows nothing about the Monaco bundle, which is why the staleness +check exists: edit `monaco-bundle/` and the next file open rebuilds. + +Monaco is built rather than bundled into `app.js` because `bb plugin build` +emits one file with no code splitting: Monaco would parse at app boot for +everyone, including users who never open a file, and its worker could not be +emitted at all. `lib/monaco-loader.ts` loads the built files from a +`files.createPreview` URL the first time a file tab opens. + +`monaco-bundle/editor.js` is the entry: Monaco's own `editor.main`, which is +the API plus its contribution modules (find, folding, word navigation, +sorting, …) and every Monarch grammar. What it leaves out is the language +*services* for CSS, HTML, JSON, and TypeScript — completion and type checking +this plugin has no use for. esbuild proves what is reachable, so the result is +4.6 MB rather than the 24 MB of Monaco's prebuilt tree. + +Do not trim that entry to `editor.api` to save the difference. The API without +the contributions still opens files and still types, so the editor looks fine +while find, word navigation, and folding silently do not exist. The build +script asserts each of those is present for that reason. + +## 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-editor/app.tsx b/plugins/monaco-editor/app.tsx new file mode 100644 index 0000000000..456cda838b --- /dev/null +++ b/plugins/monaco-editor/app.tsx @@ -0,0 +1,566 @@ +// bb-plugin-monaco-editor — 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"; +import { + EDITOR_COMMANDS, + forgetEditor, + isCommandAvailable, + markEditorActive, + runEditorCommand, +} from "./lib/editor-commands.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, + 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; + // Publish to the quick-palette commands, which have no other route to + // a file tab. Creating counts as becoming active — the tab the user + // just opened is the one they mean — and focus keeps it current + // afterwards as they move between tabs and panes. + const active = { + editor, + absolutePath: file.absolutePath, + relativePath: file.relativePath, + }; + markEditorActive(active); + editor.onDidFocusEditorWidget(() => markEditorActive(active)); + 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; + if (editorRef.current) forgetEditor(editorRef.current); + 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, + }); + + // Folding and sorting are Monaco's, not ours — the palette rows only give + // them a name the user can type, since BB owns the editor's keybindings + // and its own chords reach the palette first. + for (const command of EDITOR_COMMANDS) { + app.slots.commandPaletteAction({ + id: command.id, + title: command.title, + isAvailable: () => isCommandAvailable(command), + run: () => runEditorCommand(command), + }); + } +}); diff --git a/plugins/monaco-editor/components/ContextMenu.tsx b/plugins/monaco-editor/components/ContextMenu.tsx new file mode 100644 index 0000000000..b713d33c0f --- /dev/null +++ b/plugins/monaco-editor/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-editor/components/FileToolbar.tsx b/plugins/monaco-editor/components/FileToolbar.tsx new file mode 100644 index 0000000000..8765f6b9d4 --- /dev/null +++ b/plugins/monaco-editor/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-editor/components/FileTreePanel.tsx b/plugins/monaco-editor/components/FileTreePanel.tsx new file mode 100644 index 0000000000..534300e3c0 --- /dev/null +++ b/plugins/monaco-editor/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-editor/lib/editor-commands.ts b/plugins/monaco-editor/lib/editor-commands.ts new file mode 100644 index 0000000000..5b5b9a8c08 --- /dev/null +++ b/plugins/monaco-editor/lib/editor-commands.ts @@ -0,0 +1,185 @@ +import { toast } from "sonner"; +import type * as MonacoNs from "monaco-editor"; + +type Editor = MonacoNs.editor.IStandaloneCodeEditor; + +/** An editor plus what a command needs to know about the file in it. */ +export type ActiveEditor = { + editor: Editor; + /** Where the file lives on the host that owns it. */ + absolutePath: string; + /** That path within the workspace (or storage directory) root. */ + relativePath: string; +}; + +/** + * The editor a quick-palette command should act on. + * + * The palette is host chrome mounted beside the routes: its `run` receives a + * thread id, a project id, and an `openPanel` — nothing that reaches a file + * tab. But the palette and every mounted `fileOpener` are the same plugin + * bundle in one browser context, so the editors can simply publish + * themselves here and the commands can read them back. + * + * "Which editor" is the whole problem. A user can have several Monaco tabs + * open across split panes, all mounted at once. Last-focused is the answer + * that matches what the palette user means by "the editor": they clicked or + * typed in it a moment ago, then hit the palette shortcut, which moved focus + * to the palette input. + */ +let lastFocused: ActiveEditor | null = null; + +/** Called on create and on every focus, so the newest tab wins immediately. */ +export function markEditorActive(active: ActiveEditor): void { + lastFocused = active; +} + +/** Called when an editor unmounts; a disposed editor must never be reachable. */ +export function forgetEditor(editor: Editor): void { + if (lastFocused?.editor === editor) lastFocused = null; +} + +/** + * The last-focused editor, if it is still on screen. + * + * Being remembered is not enough: the user can switch to another tab, leaving + * our editor mounted but hidden, and folding a buffer nobody can see would be + * a command that appears to do nothing. `isConnected` rules out a torn-down + * node, `offsetParent` a hidden one — the container is statically positioned, + * so a null parent means an ancestor is `display: none`. + */ +function targetEditor(): ActiveEditor | null { + const node = lastFocused?.editor.getDomNode(); + if (!node || !node.isConnected || node.offsetParent === null) return null; + return lastFocused; +} + +/** + * True when the selection spans more than one line. + * + * Monaco's sort actions treat an empty or single-line selection as "sort the + * whole document", which is a surprising amount of damage to do to a file + * from a palette row the user reached by typing "sort". Requiring a real + * multi-line selection keeps the command meaning what its title says. + */ +function hasMultiLineSelection({ editor }: ActiveEditor): boolean { + return ( + editor + .getSelections() + ?.some( + (selection) => + !selection.isEmpty() && + selection.startLineNumber !== selection.endLineNumber, + ) ?? false + ); +} + +export type EditorCommand = { + /** Palette row id, unique within this plugin. */ + id: string; + /** Palette row label. The palette matches on this text. */ + title: string; + /** Extra condition beyond "there is a visible editor". */ + precondition?: (active: ActiveEditor) => boolean; + run: (active: ActiveEditor) => void | Promise; +}; + +/** A palette row that runs one of Monaco's own editor actions. */ +function monacoAction( + id: string, + title: string, + actionId: string, + precondition?: (active: ActiveEditor) => boolean, +): EditorCommand { + return { + id, + title, + precondition, + run: async ({ editor }) => { + // The palette took focus, and the fold actions are gated on + // `editorTextFocus`. + editor.focus(); + await editor.getAction(actionId)?.run(); + }, + }; +} + +/** Clipboard write with the same toast treatment as the file tree's copies. */ +function copy(text: string, successMessage: string): Promise { + return navigator.clipboard + .writeText(text) + .then(() => { + toast.success(successMessage); + }) + .catch(() => { + toast.error("Failed to copy"); + }); +} + +export const EDITOR_COMMANDS: readonly EditorCommand[] = [ + // Monaco registers `editor.foldLevel1` through `editor.foldLevel7`; the + // first five are the ones with any practical use on real files. + ...[1, 2, 3, 4, 5].map((level) => + monacoAction( + `fold-level-${level}`, + `Monaco: fold level ${level}`, + `editor.foldLevel${level}`, + ), + ), + monacoAction( + "fold-recursively", + "Monaco: fold recursively", + "editor.foldRecursively", + ), + // Monaco has no "unfold level N" to mirror the rows above — collapsing is + // level-based, expanding is not — so "unfold all" is what undoes them. + monacoAction("unfold-all", "Monaco: unfold all", "editor.unfoldAll"), + monacoAction( + "unfold-recursively", + "Monaco: unfold recursively", + "editor.unfoldRecursively", + ), + monacoAction("unfold", "Monaco: unfold at cursor", "editor.unfold"), + monacoAction( + "sort-lines-ascending", + "Monaco: sort selected lines ascending", + "editor.action.sortLinesAscending", + hasMultiLineSelection, + ), + monacoAction( + "sort-lines-descending", + "Monaco: sort selected lines descending", + "editor.action.sortLinesDescending", + hasMultiLineSelection, + ), + { + id: "copy-path", + title: "Monaco: copy path of current file", + run: ({ absolutePath }) => copy(absolutePath, "Absolute path copied"), + }, + { + id: "copy-relative-path", + title: "Monaco: copy relative path of current file", + run: ({ relativePath }) => copy(relativePath, "Relative path copied"), + }, +]; + +/** Whether the palette should list this command right now. */ +export function isCommandAvailable(command: EditorCommand): boolean { + const active = targetEditor(); + if (!active) return false; + return command.precondition?.(active) ?? true; +} + +/** + * Runs the command against the editor in view. + * + * Re-checking availability here rather than trusting the earlier + * `isAvailable` matters because the palette can sit open while the tab it was + * listed for closes. + */ +export async function runEditorCommand(command: EditorCommand): Promise { + const active = targetEditor(); + if (!active || !(command.precondition?.(active) ?? true)) return; + await command.run(active); +} diff --git a/plugins/monaco-editor/lib/file-tree.test.ts b/plugins/monaco-editor/lib/file-tree.test.ts new file mode 100644 index 0000000000..c5b1ca5a5c --- /dev/null +++ b/plugins/monaco-editor/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-editor/lib/file-tree.ts b/plugins/monaco-editor/lib/file-tree.ts new file mode 100644 index 0000000000..228f1c661e --- /dev/null +++ b/plugins/monaco-editor/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-editor/lib/languages.test.ts b/plugins/monaco-editor/lib/languages.test.ts new file mode 100644 index 0000000000..7c2a421b0b --- /dev/null +++ b/plugins/monaco-editor/lib/languages.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { CLAIMED_EXTENSIONS, languageForPath } from "./languages.js"; + +/** + * The Monaco bundle is trimmed to `basic-languages` (see + * `monaco-bundle/editor.js`), so a language this plugin maps to must still be + * one Monaco registers. Reads the built bundle rather than importing Monaco: + * the point is to check what actually ships, and importing `monaco-editor` + * here would prove nothing about the artifact. + */ +async function bundledLanguageIds(): Promise | null> { + const { readFile } = await import("node:fs/promises"); + const { fileURLToPath } = await import("node:url"); + const path = await import("node:path"); + const bundle = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "dist", + "monaco", + "editor.js", + ); + let source: string; + try { + source = await readFile(bundle, "utf8"); + } catch { + return null; + } + // Every basic-language registers itself with `id:""` in its + // language declaration; the minified bundle keeps those string literals. + return new Set( + [...source.matchAll(/id:"([a-z0-9+#-]+)"/g)].map((match) => match[1]!), + ); +} + +describe("claimed languages", () => { + it("maps every claimed extension to a language id", () => { + for (const extension of CLAIMED_EXTENSIONS) { + expect(languageForPath(`file.${extension}`)).toBeTruthy(); + } + }); + + it("only maps to languages the shipped bundle registers", async () => { + const bundled = await bundledLanguageIds(); + if (bundled === null) { + // The bundle is a build artifact; a source checkout that has not run + // `build:monaco` should not fail here. + expect(CLAIMED_EXTENSIONS.length).toBeGreaterThan(0); + return; + } + const missing = [ + ...new Set( + CLAIMED_EXTENSIONS.map((extension) => languageForPath(`f.${extension}`)), + ), + ] + // Monaco always has plaintext; it has no basic-language declaration. + .filter((language) => language !== "plaintext" && !bundled.has(language)); + + expect(missing).toEqual([]); + }); +}); diff --git a/plugins/monaco-editor/lib/languages.ts b/plugins/monaco-editor/lib/languages.ts new file mode 100644 index 0000000000..9b462b462c --- /dev/null +++ b/plugins/monaco-editor/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-editor/lib/monaco-loader.ts b/plugins/monaco-editor/lib/monaco-loader.ts new file mode 100644 index 0000000000..73d7646e44 --- /dev/null +++ b/plugins/monaco-editor/lib/monaco-loader.ts @@ -0,0 +1,159 @@ +import type * as MonacoNs from "monaco-editor"; + +/** + * Loads the Monaco bundle this plugin builds for itself, from a URL, the + * first time a file tab opens. + * + * Not bundled into app.js: `bb plugin build` emits one file with no code + * splitting, so Monaco would parse at app boot for every user — including + * everyone who never opens a file — and its worker could not be emitted at + * all. Loading it from a URL keeps the plugin bundle at a few KB and defers + * every byte of Monaco until it is needed. + * + * The bundle is built by `scripts/stage-assets.mjs` into `dist/monaco` and + * served by `bb.sdk.files.createPreview` (see server.ts) — same origin as the + * app, so the worker is not cross-origin. + */ + +/** One load per app window, shared by every open editor tab. */ +let bootPromise: Promise | null = null; + +export function loadMonaco(baseUrl: string): Promise { + bootPromise ??= boot(baseUrl); + return bootPromise; +} + +interface MonacoModule { + monaco?: typeof MonacoNs; +} + +async function boot(baseUrl: string): Promise { + // Monaco styles its widgets from a stylesheet esbuild emits beside the + // bundle; without it the editor mounts and paints nothing. + await injectStylesheet(`${baseUrl}/editor.css`); + + // Workers are same-origin, so a plain module worker is enough — no blob + // trampoline. Set before the editor loads: Monaco reads this when it first + // needs a worker, which can be during the first `create`. + (globalThis as { MonacoEnvironment?: unknown }).MonacoEnvironment = { + getWorker: () => + new Worker(new URL(`${baseUrl}/editor.worker.js`, window.location.origin), { + type: "module", + }), + }; + + // The URL is a runtime value, so the plugin's own bundler leaves this + // import alone rather than trying to inline the module. + const loaded: MonacoModule = await import( + /* @vite-ignore */ `${baseUrl}/editor.js` + ); + const monaco = loaded.monaco; + if (!monaco) { + throw new Error("the Monaco bundle did not expose its API"); + } + registerOccurrenceHighlighting(monaco); + return monaco; +} + +/** + * Highlights every occurrence of the identifier under the cursor. + * + * Monaco's word-highlight contribution ships in the bundle but does nothing + * on its own: it renders whatever a `DocumentHighlightProvider` reports, and + * the only provider that would register one is a language service. Monaco has + * a textual provider of its own (`textualHighlightProvider.js`) but never + * wires it into the standalone editor, and it takes internal services a + * plugin cannot reach — so supply one through the public API instead. + * + * Textual by design: matches are whole-word and case-sensitive, with no + * notion of whether two spellings mean the same symbol. That is what makes it + * useful without a language server, and it costs nothing to ship. + */ +function registerOccurrenceHighlighting(monaco: typeof MonacoNs): void { + const languageIds = monaco.languages.getLanguages().map((entry) => entry.id); + monaco.languages.registerDocumentHighlightProvider(languageIds, { + provideDocumentHighlights(model, position) { + const word = model.getWordAtPosition(position); + if (word === null) return []; + return model + .findMatches( + word.word, + false, + false, + true, + // Non-null word separators make this whole-word, so `set` does not + // light up every `offset` in the file. + USUAL_WORD_SEPARATORS, + false, + MAX_OCCURRENCE_MATCHES, + ) + .map((match) => ({ + range: match.range, + kind: monaco.languages.DocumentHighlightKind.Text, + })); + }, + }); +} + +/** Monaco's own default; repeated because the constant is not exported. */ +const USUAL_WORD_SEPARATORS = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; + +/** A ceiling so a hot loop over a huge file cannot stall the editor. */ +const MAX_OCCURRENCE_MATCHES = 1000; + +function injectStylesheet(href: string): Promise { + return new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error(`Failed to load ${href}`)); + document.head.appendChild(link); + }); +} + +const OVERFLOW_NODE_ID = "bb-plugin-monaco-editor-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}`; +} diff --git a/plugins/monaco-editor/monaco-bundle/editor.js b/plugins/monaco-editor/monaco-bundle/editor.js new file mode 100644 index 0000000000..206c9f5bcd --- /dev/null +++ b/plugins/monaco-editor/monaco-bundle/editor.js @@ -0,0 +1,18 @@ +/** + * The Monaco we ship. + * + * `editor.main` is Monaco's own standalone-editor entry: the API plus its 59 + * contribution modules (find, folding, word navigation, sorting, suggest, + * bracket matching, …) and the Monarch grammars for every language it knows. + * + * An earlier revision imported `editor.api` alone to save ~1.3 MB. That is + * the API surface *without* the contributions, so the editor still opened and + * still typed — while find, option+arrow word navigation, and the folding + * commands silently did not exist. Contributions are the editor; only the + * language *services* (completion and type checking for CSS, HTML, JSON, and + * TypeScript) are optional here, and this entry does not pull them in: the + * plugin has no language server, and Monaco's TypeScript checker sees only + * the open file, so its "cannot find module" errors would be wrong. + */ +export * as monaco from "monaco-editor/editor/editor.api.js"; +import "monaco-editor/editor/editor.main.js"; diff --git a/plugins/monaco-editor/monaco-bundle/worker.js b/plugins/monaco-editor/monaco-bundle/worker.js new file mode 100644 index 0000000000..6c8d9f0b5d --- /dev/null +++ b/plugins/monaco-editor/monaco-bundle/worker.js @@ -0,0 +1,6 @@ +/** + * Monaco's base editor worker. It backs the editor's own background work — + * diff computation, link detection, word-based suggestions — not language + * services, which this bundle omits. + */ +import "monaco-editor/editor/editor.worker.start.js"; diff --git a/plugins/monaco-editor/package.json b/plugins/monaco-editor/package.json new file mode 100644 index 0000000000..cfe6259e2f --- /dev/null +++ b/plugins/monaco-editor/package.json @@ -0,0 +1,66 @@ +{ + "name": "bb-plugin-monaco-editor", + "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 editor", + "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", + "build:monaco": "node scripts/stage-assets.mjs" + }, + "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", + "esbuild": "^0.28.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-editor/scripts/stage-assets.mjs b/plugins/monaco-editor/scripts/stage-assets.mjs new file mode 100644 index 0000000000..fd77f08e02 --- /dev/null +++ b/plugins/monaco-editor/scripts/stage-assets.mjs @@ -0,0 +1,91 @@ +/** + * Builds the Monaco bundle this plugin serves, into `dist/monaco`. + * + * Monaco cannot go through `bb plugin build` with everything else: that + * config emits one file with no code splitting, so Monaco would parse at app + * boot for every user — including everyone who never opens a file — and its + * worker could not be emitted at all. Building it here instead keeps it + * lazy: `lib/monaco-loader.ts` imports these files from a + * `files.createPreview` URL the first time a file tab opens. + * + * Building rather than copying Monaco's prebuilt AMD bundle is what keeps + * this small. esbuild proves which modules are reachable from the entry, so + * the language services this plugin does not use are dropped by construction + * — 3.3 MB against 24 MB for the AMD tree — with no risk that something we + * pruned is requested later at runtime. + * + * Packaging ships only a builtin's `dist/` and `skills/` + * (`apps/server/scripts/copy-builtin-plugins.ts`, which runs this), so + * `dist/` is the only place these files can live. + */ +import { mkdir, readFile, rm } 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")); +const esbuild = require("esbuild"); + +const outDir = path.join(pluginRoot, "dist", "monaco"); +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); + +const shared = { + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + minify: true, + legalComments: "none", + absWorkingDir: pluginRoot, + // Monaco's contributions style their icons with a webfont. Inlining it + // keeps the served bundle to the three files the loader knows how to + // fetch, rather than adding an asset whose URL would have to resolve + // relative to the preview lease. + loader: { ".ttf": "dataurl" }, +}; + +// Two entries, not one: the worker runs in its own global scope and must be +// a separate file for `new Worker(url)`. +const editor = await esbuild.build({ + ...shared, + entryPoints: [path.join(pluginRoot, "monaco-bundle", "editor.js")], + outfile: path.join(outDir, "editor.js"), + metafile: true, +}); +await esbuild.build({ + ...shared, + entryPoints: [path.join(pluginRoot, "monaco-bundle", "worker.js")], + outfile: path.join(outDir, "editor.worker.js"), +}); + +// A bundle can be missing whole features and still load, still open a file, +// and still let you type — which is how an earlier entry shipped without the +// find widget or word navigation, and how a wrong entry could ship without +// grammars and render every file as plain text. Fail the build instead of +// discovering it by hand. +const inputs = Object.keys(editor.metafile.inputs); +const output = await readFile(path.join(outDir, "editor.js"), "utf8"); +const missing = [ + ["language grammars", () => inputs.some((i) => i.includes("languages/definitions/") || i.includes("basic-languages"))], + ["editor contributions", () => inputs.some((i) => i.includes("editor/contrib/"))], + ["find widget", () => output.includes("find-widget")], + ["folding", () => output.includes("foldRecursively")], + ["word navigation", () => output.includes("cursorWordLeft")], + ["line sorting", () => output.includes("sortLinesAscending")], +] + .filter(([, present]) => !present()) + .map(([name]) => name); +if (missing.length > 0) { + throw new Error( + `the Monaco bundle is missing: ${missing.join(", ")} — check monaco-bundle/editor.js`, + ); +} + +const total = Object.values(editor.metafile.outputs).reduce( + (bytes, output) => bytes + output.bytes, + 0, +); +console.log( + `monaco: built ${outDir} (${(total / 1024 / 1024).toFixed(2)} MB editor + worker)`, +); diff --git a/plugins/monaco-editor/server.ts b/plugins/monaco-editor/server.ts new file mode 100644 index 0000000000..dd7ae81351 --- /dev/null +++ b/plugins/monaco-editor/server.ts @@ -0,0 +1,406 @@ +// bb-plugin-monaco-editor — 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, readdirSync, statSync } from "node:fs"; +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(), + /** + * Where the file actually lives, and its path within the root + * `read`/`write` confine to. The frontend has neither: BB hands the + * opener a path relative to the workspace (or an absolute one, for a + * host file), and resolving it needs the environment lookup that only + * happens here. Returned with the content so the "copy path" palette + * commands need no second round trip. + */ + absolutePath: z.string(), + relativePath: 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(), + }), + ]), + }, +}); + +/** + * Whether a built bundle predates what it was built from. + * + * The dev loop rebuilds `dist/app.js` and reloads `server.ts` on save, but + * knows nothing about this bundle, so without this an edit to + * `monaco-bundle/` or a bumped `monaco-editor` would leave a stale bundle in + * place and the change would silently not appear. + * + * Only meaningful in a source checkout. A packaged plugin has no + * `monaco-bundle/` beside it — nothing to be newer than the artifact — so + * this returns false there without stat-ing anything that matters. + */ +function isBundleStale(moduleDir: string, bundleDir: string): boolean { + const builtAtMs = statSync(path.join(bundleDir, "editor.js")).mtimeMs; + const entryDir = path.join(moduleDir, "monaco-bundle"); + if (!existsSync(entryDir)) return false; + + const inputs = [ + path.join(moduleDir, "scripts", "stage-assets.mjs"), + ...readdirSync(entryDir).map((name) => path.join(entryDir, name)), + // A `monaco-editor` bump changes nothing this plugin owns, so compare + // against the installed package itself. + path.join(moduleDir, "package.json"), + ]; + return inputs.some( + (input) => existsSync(input) && statSync(input).mtimeMs > builtAtMs, + ); +} + +/** + * The directory holding the Monaco bundle this plugin serves, building it + * first if it is not there. + * + * `scripts/stage-assets.mjs` builds it into `dist/monaco`. Packaging runs + * that script (`apps/server/scripts/copy-builtin-plugins.ts`), so a released + * BB always finds it already built — but a source checkout never runs it: the + * dev server loads builtins straight from `plugins/` and rebuilds only + * `dist/app.js` on demand. Without the fallback below, `pnpm dev` on a fresh + * clone would load this plugin into an error state. + * + * The two candidates are the two layouts this file runs under. Packaged, the + * server bundle sits at `dist/server.js` with `dist/monaco` beside it; from + * source, `server.ts` sits at the plugin root with `dist/monaco` below it. + */ +async function ensureMonacoBundleDir( + log: (message: string) => void, +): Promise { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDir, "monaco"), + path.join(moduleDir, "dist", "monaco"), + ]; + const built = candidates.find((candidate) => + existsSync(path.join(candidate, "editor.js")), + ); + if (built !== undefined && !isBundleStale(moduleDir, built)) return built; + + // Building takes a few seconds, so say why this file open is slow. + log( + built === undefined + ? "Monaco bundle missing; building it (first run in a source checkout)" + : "Monaco bundle is older than its sources; rebuilding it", + ); + // A computed specifier, so the plugin's own bundler leaves it alone rather + // than trying to inline a build script into the server bundle. Importing it + // runs it — the same contract packaging relies on. + const script = new URL("./scripts/stage-assets.mjs", import.meta.url).href; + await import(script); + + const staged = candidates.find((candidate) => + existsSync(path.join(candidate, "editor.js")), + ); + if (staged === undefined) { + throw new Error( + "could not build the Monaco bundle; run `pnpm --filter bb-plugin-monaco-editor build:monaco`", + ); + } + return staged; +} + +export default async function plugin(bb: BbPluginApi) { + + 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 + ) { + const bundleDir = await ensureMonacoBundleDir((message) => + bb.log.info(message), + ); + // No hostId: the bundle is part of the plugin, on the server. + assetLease = await bb.sdk.files.createPreview({ + rootPath: bundleDir, + 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 } : {}), + }; + } + + /** + * `root`-relative form of `target`. The daemon may hand back a Windows + * root, so pick the path flavour from the root rather than the host we + * happen to be running on. + */ + function relativeTo(root: string, target: string): string { + const api = path.win32.isAbsolute(root) ? path.win32 : path.posix; + return api.relative(root, target) || api.basename(target); + } + + 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, + absolutePath: target.path, + // Same rooting as `tree`, so the two agree on what "relative" means: + // the worktree for a workspace file, the thread's storage directory, + // and — for a bare host path — the file's own directory, which leaves + // just the filename. + relativePath: relativeTo(target.rootPath, target.path), + }; + }, + + /** + * 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 }; + }, + }); + +} diff --git a/plugins/monaco-editor/tsconfig.json b/plugins/monaco-editor/tsconfig.json new file mode 100644 index 0000000000..09f569141b --- /dev/null +++ b/plugins/monaco-editor/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-editor/vitest.config.ts b/plugins/monaco-editor/vitest.config.ts new file mode 100644 index 0000000000..06494fe7a6 --- /dev/null +++ b/plugins/monaco-editor/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; + +export default defineWorkspaceTestConfig({ + test: { + silent: "passed-only", + name: "bb-plugin-monaco-editor", + include: ["**/*.test.{ts,tsx}"], + exclude: ["node_modules/**"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ca0dca6a8..80a33cc92c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3233,6 +3233,58 @@ 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-editor: + 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) + esbuild: + specifier: ^0.28.0 + version: 0.28.1 + 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': @@ -4797,11 +4849,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==} @@ -10343,6 +10395,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==} @@ -12335,6 +12390,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'} @@ -12786,6 +12846,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==} @@ -22618,6 +22681,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 @@ -24915,6 +24982,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@16.4.2: {} marked@18.0.5: {} @@ -25890,6 +25959,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: {} diff --git a/turbo.json b/turbo.json index 0b34e45d4b..9b87aea335 100644 --- a/turbo.json +++ b/turbo.json @@ -707,6 +707,12 @@ "bb-plugin-memory#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] }, + "bb-plugin-monaco-editor#typecheck": { + "dependsOn": [ + "@get-bb/plugin-sdk#build:types", + "topo" + ] + }, "bb-plugin-pdf-preview#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] },