diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,38 @@ - **Linux/macOS**: Nix devshell from papermario-dx flake - **Windows**: bundled `papermario-dx-windows.zip` toolchain - **Flatpak**: bundled pre-built tools (no Nix dependency) +## Development workflow + +Always run `yarn debug` in the background at the start of a session. It runs webpack in watch mode and launches Electron with remote debugging on port 9222 — no need for `yarn build`, just reload the Electron window after code changes. After making code changes, check the background task output to verify compilation succeeded before moving on. + +Must launch from the repo root (via `yarn debug`) for `--plugins=local-dir:../plugins` to resolve correctly. Launching `electron` from the repo root directly will cause plugins (syntax highlighting, clangd, etc.) to not load. + +## Debugging via CDP + +List available debug targets: + +```bash +curl -s http://localhost:9222/json +``` + +This returns JSON with `webSocketDebuggerUrl` for each target. The `"type": "page"` entry is the main renderer. + +Evaluate JS in the renderer via CDP (Chrome DevTools Protocol) using a Node script — `ws` package is available from `node_modules`. Connect to the websocket URL, send `Runtime.enable` then `Runtime.evaluate` messages. To capture `console.log` output, enable `Runtime.enable` _before_ `Page.reload` and listen for `Runtime.consoleAPICalled` events. + +To reload the Electron window via CDP: + +```bash +# Send Page.reload +echo '{"id":1,"method":"Page.reload","params":{}}' | websocat "$WS_URL" +``` + +## Theia/Monaco gotchas + +- `EditorManager.onCreated` fires when the widget is created, but the Monaco model may still be null. Use `monacoEditor.onDidChangeModel()` to wait for it. +- `model.tokenization` is an internal API — access via `(model as any).tokenization`. +- `model.onDidChangeTokens` fires when tokenization completes (e.g. after a TextMate grammar loads from a plugin). +- `bindViewContribution` already binds `CommandContribution`, `MenuContribution`, `KeybindingContribution` — don't bind them again. + ## Versioning Lockstep with papermario-dx: studio v2024.04 = papermario-dx v2024.04. diff --git a/extensions/studio/package.json b/extensions/studio/package.json --- a/extensions/studio/package.json +++ b/extensions/studio/package.json @@ -9,6 +9,8 @@ "dependencies": { "@theia/core": "1.69.0", "@theia/editor": "1.69.0", "@theia/filesystem": "1.69.0", + "@theia/monaco": "1.69.0", + "@theia/monaco-editor-core": "1.96.302", "@theia/workspace": "1.69.0" }, "devDependencies": { diff --git a/extensions/studio/src/browser/c-language/doc-comment-hover.ts b/extensions/studio/src/browser/c-language/doc-comment-hover.ts new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/c-language/doc-comment-hover.ts @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { injectable, inject } from "@theia/core/shared/inversify"; +import { FrontendApplicationContribution } from "@theia/core/lib/browser"; +import { FileService } from "@theia/filesystem/lib/browser/file-service"; +import URI from "@theia/core/lib/common/uri"; +import { StandaloneServices } from "@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices"; +import { ILanguageFeaturesService } from "@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures"; +import type { + Position, + CancellationToken, +} from "@theia/monaco-editor-core/esm/vs/editor/editor.api"; +import type { ITextModel } from "@theia/monaco-editor-core/esm/vs/editor/common/model"; + +interface EvtOutput { + name: string; + description: string; +} + +function parseDocComment( + fileLines: string[], + declarationLineIndex: number, +): string[] | undefined { + const lines: string[] = []; + for (let i = declarationLineIndex - 1; i >= 0; i--) { + const trimmed = fileLines[i].trimStart(); + if (trimmed.startsWith("///")) { + const content = trimmed.startsWith("/// ") + ? trimmed.slice(4) + : trimmed.slice(3); + lines.unshift(content); + } else { + break; + } + } + return lines.length > 0 ? lines : undefined; +} + +function parseEvtParams(docLines: string[]): string[] { + const params: string[] = []; + for (const line of docLines) { + const match = line.match(/^@param\s+(\w+)/); + if (match) { + params.push(match[1]); + } + } + return params; +} + +function parseEvtOutputs(docLines: string[]): EvtOutput[] { + const outputs: EvtOutput[] = []; + for (const line of docLines) { + const match = line.match(/^@evtout\s+(\w+)\s+(.*)/); + if (match) { + outputs.push({ name: match[1], description: match[2] }); + } + } + return outputs; +} + +function stripEvtTags(docLines: string[]): string[] { + return docLines.filter( + (line) => + !line.startsWith("@param") && + !line.startsWith("@evtout") && + !line.startsWith("@evtapi"), + ); +} + +function formatHover(signature: string, docLines?: string[]): string { + const body = docLines?.join("\n").trim(); + return body + ? `\`\`\`c\n${signature}\n\`\`\`\n\n${body}` + : `\`\`\`c\n${signature}\n\`\`\``; +} + +function buildHover( + fileLines: string[], + lineIndex: number, +): string | undefined { + const docLines = parseDocComment(fileLines, lineIndex); + const declLine = fileLines[lineIndex]; + + const apiCallableMatch = declLine.match(/API_CALLABLE\((\w+)\)/); + if (apiCallableMatch) { + const name = apiCallableMatch[1]; + const params = docLines ? parseEvtParams(docLines) : []; + const outputs = docLines ? parseEvtOutputs(docLines) : []; + const bodyLines = docLines ? stripEvtTags(docLines) : undefined; + + const paramList = + params.length > 0 ? `${name}, ${params.join(", ")}` : name; + let signature = `Call(${paramList})`; + + for (const out of outputs) { + signature += `\n→ ${out.name}: ${out.description}`; + } + + return formatHover(signature, bodyLines); + } + + const defineMatch = declLine.match(/^\s*#\s*define\s+(\w+)(\([^)]*\))?/); + if (defineMatch) { + const signature = defineMatch[2] + ? `#define ${defineMatch[1]}${defineMatch[2]}` + : `#define ${defineMatch[1]}`; + return formatHover(signature, docLines); + } + + const funcMatch = declLine.match(/^\s*[\w\s*]+\s+\**\s*(\w+)\s*\(/); + if (funcMatch) { + let signature = declLine.trimEnd(); + if (!signature.includes(")")) { + for ( + let i = lineIndex + 1; + i < fileLines.length && i < lineIndex + 10; + i++ + ) { + signature += "\n" + fileLines[i].trimEnd(); + if (fileLines[i].includes(")")) { + break; + } + } + } + const closeParen = signature.indexOf(")"); + if (closeParen !== -1) { + signature = signature.slice(0, closeParen + 1); + } + return formatHover(signature.trim(), docLines); + } + + const signature = declLine.trim(); + if (signature) { + return formatHover(signature, docLines); + } + + return undefined; +} + +function selectorMatchesCLanguage(selector: any): boolean { + if (typeof selector === "string") { + return selector === "c" || selector === "cpp"; + } + if (Array.isArray(selector)) { + return selector.some((s) => selectorMatchesCLanguage(s)); + } + if (selector && typeof selector === "object") { + return selector.language === "c" || selector.language === "cpp"; + } + return false; +} + +@injectable() +export class DocCommentHoverContribution + implements FrontendApplicationContribution +{ + @inject(FileService) + protected readonly fileService!: FileService; + + onStart(): void { + const featuresService = StandaloneServices.get(ILanguageFeaturesService); + const registry = featuresService.hoverProvider; + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const originalRegister = registry.register.bind(registry); + + // Intercept hover provider registrations for C/C++ (e.g. clangd) + // and wrap them to replace hover results with our doc comment hover + // when available. + registry.register = function (selector: any, provider: any) { + if (selectorMatchesCLanguage(selector)) { + return originalRegister(selector, { + provideHover: async ( + model: ITextModel, + position: Position, + token: CancellationToken, + context?: any, + ) => { + const original = await provider.provideHover( + model, + position, + token, + context, + ); + try { + const docHover = await self.resolveDocHover( + featuresService, + model, + position, + token, + ); + if (docHover) { + return { + contents: [{ value: docHover }], + range: original?.range ?? self.wordRange(model, position), + }; + } + } catch { + // fall through to original + } + return original; + }, + }); + } + return originalRegister(selector, provider); + } as typeof registry.register; + } + + protected wordRange( + model: ITextModel, + position: Position, + ): + | { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + } + | undefined { + const word = model.getWordAtPosition(position); + if (!word) { + return undefined; + } + return { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }; + } + + protected async resolveDocHover( + featuresService: ILanguageFeaturesService, + model: ITextModel, + position: Position, + token: CancellationToken, + ): Promise { + // Try declaration first (header files with doc comments), + // then fall back to definition + const locations = await this.findLocations( + featuresService, + model, + position, + token, + ); + + for (const loc of locations) { + const hover = await this.buildHoverFromLocation(loc); + if (hover) { + return hover; + } + } + + return null; + } + + protected async findLocations( + featuresService: ILanguageFeaturesService, + model: ITextModel, + position: Position, + token: CancellationToken, + ): Promise<{ uri: string; line: number }[]> { + const locations: { uri: string; line: number }[] = []; + const seen = new Set(); + + for (const registry of [ + featuresService.declarationProvider, + featuresService.definitionProvider, + ]) { + for (const provider of registry.ordered(model)) { + if (token.isCancellationRequested) { + return locations; + } + const method = + "provideDeclaration" in provider + ? (provider as any).provideDeclaration + : (provider as any).provideDefinition; + const result = await method.call(provider, model, position, token); + if (!result) { + continue; + } + const items = Array.isArray(result) ? result : [result]; + for (const item of items) { + if (!item?.uri || item.range == null) { + continue; + } + const line: number = + item.range.startLineNumber ?? item.range.start?.line + 1; + if (!line) { + continue; + } + const key = `${item.uri.toString()}:${line}`; + if (!seen.has(key)) { + seen.add(key); + locations.push({ uri: item.uri.toString(), line }); + } + } + break; + } + } + + return locations; + } + + protected async buildHoverFromLocation(loc: { + uri: string; + line: number; + }): Promise { + const targetUri = new URI(loc.uri); + + let content: string; + try { + const fileContent = await this.fileService.read(targetUri); + content = fileContent.value; + } catch { + return null; + } + + const fileLines = content.split("\n"); + const lineIndex = loc.line - 1; + + if (lineIndex < 0 || lineIndex >= fileLines.length) { + return null; + } + + return buildHover(fileLines, lineIndex) ?? null; + } +} diff --git a/extensions/studio/src/browser/comment-font-decorator.ts b/extensions/studio/src/browser/comment-font-decorator.ts new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/comment-font-decorator.ts @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + injectable, + inject, + postConstruct, +} from "@theia/core/shared/inversify"; +import { FrontendApplicationContribution } from "@theia/core/lib/browser"; +import { PreferenceService } from "@theia/core/lib/common/preferences/preference-service"; +import { EditorManager } from "@theia/editor/lib/browser/editor-manager"; +import { MonacoEditor } from "@theia/monaco/lib/browser/monaco-editor"; +import { DisposableCollection, Disposable } from "@theia/core/lib/common"; + +const COMMENT_TOKEN_TYPE = 1; // StandardTokenType.Comment + +@injectable() +export class CommentFontDecorator implements FrontendApplicationContribution { + @inject(EditorManager) + protected readonly editorManager!: EditorManager; + + @inject(PreferenceService) + protected readonly preferenceService!: PreferenceService; + + protected readonly toDispose = new DisposableCollection(); + + private readonly attached = new WeakSet(); + + @postConstruct() + protected init(): void { + this.editorManager.onCreated((widget) => { + const editor = widget.editor; + if (editor instanceof MonacoEditor && !this.attached.has(editor)) { + this.attached.add(editor); + this.attachToEditor(editor); + } + }); + + for (const widget of this.editorManager.all) { + const editor = widget.editor; + if (editor instanceof MonacoEditor && !this.attached.has(editor)) { + this.attached.add(editor); + this.attachToEditor(editor); + } + } + } + + async onStart(): Promise { + await this.preferenceService.ready; + const current = this.preferenceService.get("editor.fontFamily"); + if (!current || current === "monospace") { + this.preferenceService.set( + "editor.fontFamily", + "'Monaspace Neon', monospace", + undefined, + undefined, + ); + } + } + + protected attachToEditor(editor: MonacoEditor): void { + const monacoEditor = editor.getControl(); + const model = monacoEditor.getModel(); + if (model) { + this.decorateModel(monacoEditor, model, editor); + } else { + const sub = monacoEditor.onDidChangeModel(() => { + const m = monacoEditor.getModel(); + if (m) { + sub.dispose(); + this.decorateModel(monacoEditor, m, editor); + } + }); + } + } + + protected decorateModel( + monacoEditor: ReturnType, + model: NonNullable< + ReturnType["getModel"]> + >, + editor: MonacoEditor, + ): void { + const disposables = new DisposableCollection(); + let decorations = monacoEditor.createDecorationsCollection([]); + + const update = () => { + const newDecorations: { + range: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + }; + options: { description: string; inlineClassName: string }; + }[] = []; + + const tokenization = (model as any).tokenization; + if (!tokenization) { + return; + } + + for ( + let lineNumber = 1; + lineNumber <= model.getLineCount(); + lineNumber++ + ) { + tokenization.forceTokenization(lineNumber); + const lineTokens = tokenization.getLineTokens(lineNumber); + const lineContent = model.getLineContent(lineNumber); + + let lineIsComment = false; + for (let i = 0; i < lineTokens.getCount(); i++) { + if (lineTokens.getStandardTokenType(i) === COMMENT_TOKEN_TYPE) { + lineIsComment = true; + break; + } + } + + if (!lineIsComment) { + continue; + } + + const trimmed = lineContent.trimStart(); + const isDocComment = trimmed.startsWith("///"); + const className = isDocComment + ? "comment-font-xenon" + : "comment-font-radon"; + + newDecorations.push({ + range: { + startLineNumber: lineNumber, + startColumn: 1, + endLineNumber: lineNumber, + endColumn: lineContent.length + 1, + }, + options: { + description: className, + inlineClassName: className, + }, + }); + } + + decorations.set(newDecorations as any); + }; + + let timeout: ReturnType | undefined; + const scheduleUpdate = () => { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(update, 100); + }; + + disposables.push(model.onDidChangeContent(scheduleUpdate)); + disposables.push((model as any).onDidChangeTokens(scheduleUpdate)); + disposables.push( + Disposable.create(() => { + if (timeout) { + clearTimeout(timeout); + } + }), + ); + + update(); + + editor.onDispose(() => disposables.dispose()); + } +} diff --git a/extensions/studio/src/browser/fonts/LICENSE-Monaspace b/extensions/studio/src/browser/fonts/LICENSE-Monaspace new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/fonts/LICENSE-Monaspace @@ -0,0 +1,93 @@ +Copyright (c) 2023, GitHub https://github.com/githubnext/monaspace +with Reserved Font Name "Monaspace", including subfamilies: "Argon", "Neon", "Xenon", "Radon", and "Krypton" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/extensions/studio/src/browser/fonts/MonaspaceNeon.ttf b/extensions/studio/src/browser/fonts/MonaspaceNeon.ttf new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/fonts/MonaspaceNeon.ttf diff --git a/extensions/studio/src/browser/fonts/MonaspaceRadon.ttf b/extensions/studio/src/browser/fonts/MonaspaceRadon.ttf new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/fonts/MonaspaceRadon.ttf diff --git a/extensions/studio/src/browser/fonts/MonaspaceXenon.ttf b/extensions/studio/src/browser/fonts/MonaspaceXenon.ttf new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/fonts/MonaspaceXenon.ttf diff --git a/extensions/studio/src/browser/fonts/fonts.css b/extensions/studio/src/browser/fonts/fonts.css new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/fonts/fonts.css @@ -0,0 +1,36 @@ +/* SPDX-FileCopyrightText: 2026 Star Haven contributors */ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +@font-face { + font-family: "Monaspace Neon"; + src: url("./MonaspaceNeon.ttf") format("truetype"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Monaspace Radon"; + src: url("./MonaspaceRadon.ttf") format("truetype"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Monaspace Xenon"; + src: url("./MonaspaceXenon.ttf") format("truetype"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +/* Regular comments: Radon (handwritten style) */ +.monaco-editor .comment-font-radon { + font-family: "Monaspace Radon", monospace !important; +} + +/* Doc comments: Xenon (serif style) */ +.monaco-editor .comment-font-xenon { + font-family: "Monaspace Xenon", monospace !important; +} diff --git a/extensions/studio/src/browser/studio-frontend-module.ts b/extensions/studio/src/browser/studio-frontend-module.ts --- a/extensions/studio/src/browser/studio-frontend-module.ts +++ b/extensions/studio/src/browser/studio-frontend-module.ts @@ -21,8 +21,11 @@ AssetBrowserContribution, AssetBrowserUndoRedoHandler, } from "./asset-browser/contribution"; import { LayoutContribution } from "./layout-contribution"; +import { CommentFontDecorator } from "./comment-font-decorator"; +import { DocCommentHoverContribution } from "./c-language/doc-comment-hover"; import "../../src/browser/asset-browser/style.css"; +import "../../src/browser/fonts/fonts.css"; export default new ContainerModule((bind) => { bind(AssetResourceResolver).toSelf().inSingletonScope(); @@ -47,4 +50,10 @@ bind(LayoutContribution).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(LayoutContribution); bind(MenuContribution).toService(LayoutContribution); + + bind(CommentFontDecorator).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(CommentFontDecorator); + + bind(DocCommentHoverContribution).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(DocCommentHoverContribution); }); diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "download:plugins": "yarn --cwd app download:plugins", "build": "yarn --cwd app download:plugins && yarn --cwd app build", "start": "yarn --cwd extensions/studio watch & yarn --cwd app watch & yarn --cwd app start & wait", + "debug": "yarn --cwd extensions/studio watch & yarn --cwd app watch & yarn --cwd app start --remote-debugging-port=9222 & wait", "package": "yarn --cwd app package" }, "workspaces": [