<script lang="ts">
import { resolvePalette } from "@wsb/uikit/primitives";
import { onMount } from "svelte";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
import { ui } from "../../stores/ui.svelte.js";
import { explorer as explorerStore } from "../../stores/explorer.svelte.js";
import { commands } from "../../stores/commands.svelte.js";
import type { WorkbenchTheme, TokenColorRule } from "../../api/types/workbench.js";
import type { WorktreeId } from "../../api/scope.svelte.js";
import { codeIntel } from "../../stores/code-intel.svelte.js";
import { inWorktree } from "../../stores/per-worktree.svelte.js";
import { SEMANTIC_FALLBACK_SCOPES, ruleForScope, toLspChanges } from "../../code/lsp-convert.js";
import { acquire, builtinLanguages, install, mark, release, setLegend } from "../../code/monaco-code.js";
import { scoped } from "../../stores/worktree-scope.svelte.js";
const explorer = scoped(explorerStore);
type Monaco = typeof import("monaco-editor");
type MonacoEditorInstance =
import("monaco-editor").editor.IStandaloneCodeEditor;
function resolvedLength(
element: HTMLElement,
property: string,
fallback: number,
): number {
const value = Number.parseFloat(
getComputedStyle(element).getPropertyValue(property),
);
return Number.isFinite(value) ? value : fallback;
}
function resolvedEditorDimensions(element: HTMLElement) {
return {
fontSize: resolvedLength(element, "--wb-monaco-font-size", 12),
padding: {
top: resolvedLength(element, "--wb-monaco-padding-block", 10),
bottom: resolvedLength(element, "--wb-monaco-padding-block", 10),
},
};
}
function applyMonacoTheme(monacoInstance: Monaco, theme: WorkbenchTheme) {
const base = theme.ui_theme.startsWith("hc") ? "hc-black" as const :
theme.ui_theme === "vs-dark" ? "vs-dark" as const : "vs" as const;
const rules: import("monaco-editor").editor.ITokenThemeRule[] = [];
const flat: { scope: string; foreground?: string; background?: string; fontStyle?: string }[] = [];
for (const rule of (theme.token_colors as TokenColorRule[])) {
const scopes = Array.isArray(rule.scope) ? rule.scope : [rule.scope];
for (const scope of scopes) {
flat.push({ scope, ...rule.settings });
rules.push({
token: scope,
foreground: rule.settings.foreground?.replace("#", ""),
background: rule.settings.background?.replace("#", ""),
fontStyle: rule.settings.fontStyle,
});
}
}
// Semantic tokens are coloured by their type's rule; a theme of TextMate scopes names
// none, so each type takes the colour of the scope it falls back to.
for (const [type, scope] of Object.entries(SEMANTIC_FALLBACK_SCOPES)) {
const rule = ruleForScope(flat, scope);
if (rule) {
rules.push({
token: type,
foreground: rule.foreground?.replace("#", ""),
fontStyle: rule.fontStyle,
});
}
}
// The colours the page paints: the UI kit's palette under whatever theme
// is in force — an extension theme's own, and the kit's for what it does
// not name. Read as painted, so Monaco and the window around it agree.
const colors = resolvePalette(document.documentElement);
monacoInstance.editor.defineTheme("wsb-active", {
base,
inherit: true,
rules,
colors,
});
monacoInstance.editor.setTheme("wsb-active");
}
interface Props {
value: string;
language?: string;
readOnly?: boolean;
onchange: (value: string) => void;
height?: string;
/**
* A line to put in the middle and select, 1-based.
*
* What a compiler error is FOR: `…/caps.rs:61:18` names a line, and a link
* that opened the file at the top would leave the reader scrolling for the
* thing they clicked on.
*/
revealLine?: number | null;
/** The column on that line, when the message named one. */
revealColumn?: number | null;
/**
* The worktree file this editor shows, for omnicode's answers about it: its model is the
* page's one model of that file, and its edits reach the language server.
*/
file?: { worktree: WorktreeId; path: string } | null;
}
let {
value,
language = "plaintext",
readOnly = false,
onchange,
height = "240px",
revealLine = null,
revealColumn = null,
file = null,
}: Props = $props();
/**
* Put the named line in the middle, whenever one is named.
*
* An effect rather than a one-off at creation: the same tab is reused when
* somebody clicks a second error in the same file, and the editor is already
* there by then.
*/
$effect(() => {
const line = revealLine;
const column = revealColumn;
if (!editor || line === null || line < 1) return;
const model = editor.getModel();
if (!model) return;
const clamped = Math.min(line, model.getLineCount());
editor.revealLineInCenter(clamped);
editor.setPosition({ lineNumber: clamped, column: Math.max(1, column ?? 1) });
editor.focus();
});
let container: HTMLDivElement | undefined = $state();
// State, so what waits for the editor — the line to reveal, the value to show — runs once
// Monaco has loaded and made it, not only before.
let editor: MonacoEditorInstance | null = $state.raw(null);
let monaco: Monaco | null = $state.raw(null);
function registerExtensionLanguages(monaco: Monaco) {
const registered = new Set(
monaco.languages.getLanguages().map((entry) => entry.id),
);
for (const extensionLanguage of explorer.languages) {
if (!registered.has(extensionLanguage.id)) {
monaco.languages.register({
id: extensionLanguage.id,
aliases: extensionLanguage.aliases,
extensions: extensionLanguage.extensions,
});
registered.add(extensionLanguage.id);
}
const configuration = toMonacoLanguageConfiguration(
extensionLanguage.configuration,
);
if (configuration) {
monaco.languages.setLanguageConfiguration(
extensionLanguage.id,
configuration,
);
}
}
}
function toMonacoLanguageConfiguration(
configuration: Record<string, unknown> | null,
): import("monaco-editor").languages.LanguageConfiguration | null {
if (!configuration) {
return null;
}
const next: import("monaco-editor").languages.LanguageConfiguration = {};
if (Array.isArray(configuration.comments)) {
next.comments = configuration.comments as import("monaco-editor").languages.CommentRule;
} else if (isRecord(configuration.comments)) {
next.comments = configuration.comments as import("monaco-editor").languages.CommentRule;
}
if (Array.isArray(configuration.brackets)) {
next.brackets = configuration.brackets as [string, string][];
}
if (Array.isArray(configuration.autoClosingPairs)) {
next.autoClosingPairs =
configuration.autoClosingPairs as import("monaco-editor").languages.IAutoClosingPair[];
}
if (Array.isArray(configuration.surroundingPairs)) {
next.surroundingPairs =
configuration.surroundingPairs as import("monaco-editor").languages.IAutoClosingPair[];
}
if (Array.isArray(configuration.wordPattern)) {
return next;
}
if (typeof configuration.wordPattern === "string") {
next.wordPattern = new RegExp(configuration.wordPattern);
}
if (typeof configuration.autoCloseBefore === "string") {
next.autoCloseBefore = configuration.autoCloseBefore;
}
if (Array.isArray(configuration.folding)) {
return next;
}
if (isRecord(configuration.folding)) {
next.folding = configuration.folding as import("monaco-editor").languages.FoldingRules;
}
return next;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
(self as typeof self & {
MonacoEnvironment?: {
getWorker(_: string, label: string): Worker;
};
}).MonacoEnvironment = {
getWorker(_, label) {
if (label === "json") return new jsonWorker();
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
onMount(() => {
let disposable: { dispose(): void } | null = null;
let tokenObserver: MutationObserver | null = null;
let cancelled = false;
let model: import("monaco-editor").editor.ITextModel | null = null;
let unlisten: (() => void) | null = null;
const code = file ? inWorktree(codeIntel, file.worktree) : null;
(async () => {
if (!container) return;
monaco = await import("monaco-editor");
if (!container || cancelled || !monaco) return;
registerExtensionLanguages(monaco);
// Define and apply the active workbench theme before creating the editor
if (ui.appliedTheme) applyMonacoTheme(monaco, ui.appliedTheme);
const dimensions = resolvedEditorDimensions(container);
if (file) {
model = acquire(monaco, file.worktree, file.path, value, language);
}
editor = monaco.editor.create(container, {
...(model ? { model } : { value, language }),
"semanticHighlighting.enabled": true,
inlayHints: { enabled: "on" },
readOnly,
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
theme: "wsb-active",
fontSize: dimensions.fontSize,
fontFamily: "var(--wsb-font-mono, monospace)",
padding: dimensions.padding,
});
tokenObserver = new MutationObserver(() => {
if (!editor || !container) return;
editor.updateOptions(resolvedEditorDimensions(container));
});
tokenObserver.observe(document.documentElement, { attributes: true });
// Disable Monaco's native command palette entirely.
// All commands go through the app-level command palette + registry.
// F1 is Monaco's default keybinding for "Quick Command" — steal it.
editor.addCommand(monaco.KeyCode.F1, () => {
void commands.execute("workbench.action.showCommands");
});
// Override the "Command Palette" context menu action with a no-op ID
// that has no contextMenuGroupId, effectively removing it from the menu.
editor.addAction({
id: "editor.action.quickCommand",
label: "Command Palette",
run: () => {
void commands.execute("workbench.action.showCommands");
},
});
disposable = editor.onDidChangeModelContent((event) => {
if (file && code) code.edit(file.path, toLspChanges(event.changes));
onchange(editor?.getValue() ?? "");
});
installCode();
if (file && code && model) {
const m = monaco;
const shown = model;
unlisten = code.onDiagnostics((path) => {
if (path === file.path) void mark(m, file.worktree, path);
});
void code.open(file.path, () => shown.getValue()).then(() => mark(m, file.worktree, file.path));
}
})();
return () => {
cancelled = true;
disposable?.dispose();
tokenObserver?.disconnect();
unlisten?.();
editor?.dispose();
if (file && code && model) {
void code.close(file.path);
release(model);
}
editor = null;
monaco = null;
};
});
/**
* omnicode's providers, once `omni` says what it answers for: its token legend, its
* trigger characters, and Monaco's own TypeScript features off where omnicode serves it.
* Whichever comes last — Monaco loaded, or the `omni` described — installs them.
*/
function installCode() {
if (!file || !monaco) return;
const omni = inWorktree(codeIntel, file.worktree).omni;
if (!omni) return;
setLegend(omni.legend);
install(monaco, omni.completion_triggers, omni.signature_triggers);
builtinLanguages(monaco, new Set(Object.keys(omni.languages)));
}
$effect(() => {
if (file) void inWorktree(codeIntel, file.worktree).omni;
installCode();
});
$effect(() => {
if (!editor) return;
const model = editor.getModel();
if (!model) return;
if (editor.getValue() !== value) {
editor.setValue(value);
}
});
$effect(() => {
if (!editor || !monaco) return;
registerExtensionLanguages(monaco);
editor.updateOptions({ readOnly });
monaco.editor.setModelLanguage(editor.getModel()!, language);
// Re-applied once each theme is in force: the palette is read from the
// page, so it is read after the theme has been put on it.
if (ui.appliedTheme) applyMonacoTheme(monaco, ui.appliedTheme);
});
</script>
<div class="monaco-shell" style:height bind:this={container}></div>
<style>
.monaco-shell {
width: 100%;
border: 1px solid var(--vscode-widget-border);
border-radius: var(--wsb-radius-sm);
overflow: hidden;
background: var(--vscode-input-background);
}
</style>