Files
mathew/frontend/app/components/LeanWorkbench.vue
Aran Roig 6df7950161
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s
Ai slop
2026-10-03 14:13:08 +02:00

425 lines
16 KiB
Vue

<script setup lang="ts">
/* The Lean 4 editor, native to the wiki.
*
* Where the iframe framed another server's whole app, this page *is* the
* editor: Monaco wears the wiki's own theme, and the wiki talks Lean
* directly — one websocket to the lean4web server (see lean/), carrying
* the language protocol: the buffer goes across as it is typed, errors and
* `#check` output come back as diagnostics, and the goal at the cursor
* answers `$/lean/plainGoal`. The infoview is therefore the wiki's own —
* Goals and Messages panes below the wiki's own hand — and it follows the
* wiki's theme live, which a cross-origin frame never could.
*
* Lean's unicode input survives too (`\le` → `≤`), and the classic lean4web
* client stays one click away for its own richer toys (collaboration,
* widgets). Monaco is loaded dynamically: this component only ever runs in
* the browser, and the editor is far too heavy to ride along in the
* server bundle. */
import type * as Monaco from 'monaco-editor'
import { leanAbbreviations } from '~/utils/lean/abbreviations'
const { t } = useI18n()
const { th } = useAppLocale()
const session = useLeanSession()
const leanUrl = String(useRuntimeConfig().public.leanUrl || '')
const editorEl = ref<HTMLElement>()
let monaco: typeof Monaco | null = null
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let disposables: Array<{ dispose: () => void }> = []
let themeObserver: MutationObserver | null = null
/* whether the infoview stands open is the reader's own — the fold slides it
* away and leaves the editor the whole width; the choice is kept between
* visits, the way the calculators keep their docks */
const PANEL_KEY = 'mathew:lean-panel-open'
const panelOpen = ref(true)
function togglePanel() {
panelOpen.value = !panelOpen.value
try {
localStorage.setItem(PANEL_KEY, panelOpen.value ? '1' : '0')
}
catch {
/* private mode etc. — the fold just won't persist */
}
}
const phaseText = computed(() => {
switch (session.phase.value) {
case 'connecting': return t('lean.connecting')
case 'initializing': return t('lean.initializing')
case 'ready': return session.busy.value ? t('lean.elaborating') : t('lean.ready', { version: session.version.value ?? '' })
default: return ''
}
})
onMounted(async () => {
if (!editorEl.value) return
try {
panelOpen.value = localStorage.getItem(PANEL_KEY) !== '0'
}
catch {
/* storage unreadable — the panel stays open */
}
const monacoMod = await import('monaco-editor')
const { registerLeanLanguage, currentLeanTheme, applyLeanTheme, LEAN_LANGUAGE } = await import('~/utils/lean/lean-language')
monaco = monacoMod
registerLeanLanguage()
/* Monaco runs from one web worker (text processing). The `?worker` import
* lives in a wrapper (`utils/lean/monaco-worker`) that this pulls in only
* here, in the browser. */
if (!window.MonacoEnvironment) {
const { EditorWorker } = await import('~/utils/lean/monaco-worker')
window.MonacoEnvironment = { getWorker: () => new EditorWorker() }
}
if (!session.text.value) session.text.value = t('lean.seedDoc')
editor = monacoMod.editor.create(editorEl.value, {
value: session.text.value,
language: LEAN_LANGUAGE,
theme: currentLeanTheme(),
fontFamily: "ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
fontSize: 13.5,
lineHeight: 23,
tabSize: 2,
minimap: { enabled: false },
lineNumbersMinChars: 2,
scrollBeyondLastLine: false,
smoothScrolling: true,
automaticLayout: true,
padding: { top: 16 },
fixedOverflowWidgets: true,
quickSuggestions: false,
// Lean files are full of legitimate unicode — no confusable warnings
unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false },
ariaLabel: t('lean.editorAria'),
})
/* edits → the server (debounced inside the session); cursor → goals */
disposables.push(
editor.onDidChangeModelContent(() => session.change(editor!.getValue())),
editor.onDidChangeCursorPosition(({ position }) => {
session.cursorAt(position.lineNumber - 1, position.column - 1)
}),
)
/* diagnostics → editor marks, in the wiki's reds and ambers (the theme
* owns the colours); the session replays the current set on (re)attach.
* The session hands back a plain unsubscribe function, not an IDisposable
* like Monaco's handles — wrap it, or unmounting would throw over the
* whole page */
const offDiagnostics = session.onDiagnostics((diags) => {
const model = editor?.getModel()
if (!model || !monaco) return
monaco.editor.setModelMarkers(model, 'lean', diags.map(d => ({
severity: d.severity === 1
? monaco!.MarkerSeverity.Error
: d.severity === 2
? monaco!.MarkerSeverity.Warning
: d.severity === 4
? monaco!.MarkerSeverity.Hint
: monaco!.MarkerSeverity.Info,
message: d.message,
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
})))
})
disposables.push({ dispose: () => offDiagnostics() })
registerProviders(monacoMod, editor)
/* the wiki's theme *and* accent switches reach the editor live — same page
* now; applyLeanTheme re-reads the --accent tokens, so the caret, selection
* and keywords follow whichever accent the account wears */
themeObserver = new MutationObserver(() => {
applyLeanTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
await session.start()
const position = editor.getPosition()
if (position) session.cursorAt(position.lineNumber - 1, position.column - 1)
})
onBeforeUnmount(() => {
// the session outlives the component on purpose (see useLeanSession)
themeObserver?.disconnect()
for (const d of disposables) d.dispose()
disposables = []
editor?.dispose()
editor = null
})
/* ---------- LSP-backed intelligence ---------- */
function registerProviders(monacoMod: typeof Monaco, host: Monaco.editor.IStandaloneCodeEditor) {
const toRange = (r: { start: { line: number, character: number }, end: { line: number, character: number } }) => ({
startLineNumber: r.start.line + 1,
startColumn: r.start.character + 1,
endLineNumber: r.end.line + 1,
endColumn: r.end.character + 1,
})
/* the cursor, in server (0-based) coordinates. Lean's LSP and Monaco both
* count positions in UTF-16 code units, so a column simply shifts by one */
const positionOf = (_model: Monaco.editor.ITextModel, position: Monaco.Position) => ({
line: position.lineNumber - 1,
character: position.column - 1,
})
/* Lean tactic/term completions */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: [],
provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position)
const fallbackRange = {
startLineNumber: position.lineNumber,
startColumn: word.wordMinimumColumn,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
try {
const res = await session.request<{ items?: unknown[] } | unknown[]>(
'textDocument/completion',
{
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
context: { triggerKind: 1 },
},
)
const items = Array.isArray(res) ? res : (res.items ?? [])
return {
incomplete: true,
suggestions: (items as any[]).slice(0, 300).map((item) => {
const insertText = String(item.insertText ?? item.label ?? '')
const range = item.textEdit?.range ?? item.range
const doc = item.documentation
const suggestion = {
label: String(item.label ?? insertText),
kind: lspCompletionKind(monacoMod, item.kind),
insertText,
insertTextRules: item.insertTextFormat === 2
? monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
detail: item.detail ? String(item.detail) : undefined,
documentation: doc
? (typeof doc === 'string'
? { value: doc }
: { value: String(doc.value ?? ''), isMarkdown: doc.kind === 'markdown' })
: undefined,
range: range ? toRange(range) : fallbackRange,
// Lean attachments (auto-imports) ride along once confirmed
additionalTextEdits: undefined,
command: item.additionalTextEdits?.length
? {
id: 'mathew-lean-apply-edits',
title: '',
arguments: [item.additionalTextEdits],
}
: undefined,
}
return suggestion
}),
}
}
catch { return { suggestions: [] } }
},
}))
/* Lean unicode input: `\le` → `≤`, the wiki's own copy of the table */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: ['\\'],
provideCompletionItems: (model, position) => {
const line = model.getLineContent(position.lineNumber)
const upto = line.slice(0, position.column - 1)
const match = /\\([^\s]*)$/.exec(upto)
if (!match) return { suggestions: [] }
const typed = match[1]
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column - typed.length,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
const keys = Object.keys(leanAbbreviations)
.filter(k => k.startsWith(typed))
.sort((a, b) => a.length - b.length || a.localeCompare(b))
.slice(0, 100)
return {
suggestions: keys.map(k => ({
label: `\\${k}`,
kind: monacoMod.languages.CompletionItemKind.SymbolValue,
insertText: snippetFrom(leanAbbreviations[k]),
insertTextRules: monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: leanAbbreviations[k].replace(/\$CURSOR/g, ''),
range,
})),
}
},
}))
/* hover: Lean's docstrings, as markdown, in the wiki's widget colours */
disposables.push(monacoMod.languages.registerHoverProvider('lean4', {
provideHover: async (model, position) => {
try {
const res = await session.request<{
contents?: { kind: string, value: string } | string
} | null>('textDocument/hover', {
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
})
if (!res?.contents) return null
const value = typeof res.contents === 'string' ? res.contents : res.contents.value
return { contents: [{ value }] }
}
catch { return null }
},
}))
/* auto-imports the server attached to a chosen completion: an action
* registers under its own id, which is what the completion's `command`
* invokes after inserting the text */
disposables.push(host.addAction({
id: 'mathew-lean-apply-edits',
label: 'Apply Lean imports',
run: (target, ...args) => {
const edits = args[0] as Array<{
range: { start: { line: number, character: number }, end: { line: number, character: number } }
newText: string
}> | undefined
if (!edits) return
target.executeEdits('lean-import', edits.map(e => ({
range: monacoMod.Range.lift(toRange(e.range)),
text: e.newText,
})))
},
}))
}
/* LSP completion kinds (1-based) → Monaco's; anything unmapped is a value */
function lspCompletionKind(monacoMod: typeof Monaco, kind?: number) {
const K = monacoMod.languages.CompletionItemKind
const map: Record<number, Monaco.languages.CompletionItemKind> = {
1: K.Text, 2: K.Method, 3: K.Function, 4: K.Constructor, 5: K.Field,
6: K.Variable, 7: K.Class, 8: K.Interface, 9: K.Module, 10: K.Property,
11: K.Unit, 12: K.Value, 13: K.Enum, 14: K.Keyword, 15: K.Snippet,
16: K.Color, 17: K.File, 18: K.Reference, 19: K.Folder, 20: K.EnumMember,
21: K.Constant, 22: K.Struct, 23: K.Event, 24: K.Operator, 25: K.TypeParameter,
}
return (kind !== undefined && map[kind]) || K.Value
}
/* the table's `$CURSOR` becomes a Monaco snippet tab stop; other `$` are literal */
function snippetFrom(value: string) {
return value
.split('$CURSOR')
.map(part => part.replace(/\\/g, '\\\\').replace(/\$/g, '\\$'))
.join('$0')
}
</script>
<template>
<div class="lean-stage lean-work-shell">
<div class="lean-work" :data-panel="panelOpen ? 'open' : 'collapsed'">
<div ref="editorEl" class="lean-work-editor" :aria-label="t('lean.editorAria')" />
<aside class="lean-work-panel">
<div class="lean-work-status" :data-phase="session.phase.value" :data-busy="session.busy.value ? 'true' : null">
<span class="lean-work-dot" aria-hidden="true" />
<span class="lean-work-phase">{{ phaseText }}</span>
<a
class="lean-work-classic"
:href="leanUrl"
target="_blank"
rel="noopener"
:title="t('lean.openExternal')"
:aria-label="t('lean.openExternal')"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
</a>
<button
type="button"
class="lean-work-collapse"
:title="t('lean.collapse')"
:aria-label="t('lean.collapse')"
@click="togglePanel()"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<section class="lean-work-goals">
<h3>{{ t('lean.goals') }}</h3>
<template v-if="session.goals.value.length">
<pre v-for="(goal, i) in session.goals.value" :key="i">{{ goal }}</pre>
</template>
<p v-else class="lean-work-none">{{ t('lean.noGoals') }}</p>
</section>
<section class="lean-work-messages">
<h3>{{ t('lean.messages') }}</h3>
<ul v-if="session.messages.value.length">
<li v-for="(m, i) in session.messages.value" :key="i">
<span class="lean-work-msg-line">{{ m.line }}</span>
<pre>{{ m.text }}</pre>
</li>
</ul>
<p v-else class="lean-work-none">—</p>
</section>
</aside>
<!-- while the panel is folded away, a quiet card at the editor's
top-right corner waits to bring it back -->
<button
v-if="!panelOpen"
type="button"
class="lean-panel-open"
:title="t('lean.expand')"
:aria-label="t('lean.expand')"
@click="togglePanel()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
{{ t('lean.infoview') }}
</button>
</div>
<!-- the editor answers as soon as Lean answers; until then the wiki
speaks for it -->
<div v-if="!leanUrl" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="t('lean.noServer')"></span>
</div>
</div>
<div v-else-if="session.phase.value === 'lost'" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="th('lean.unreachable', { url: leanUrl })"></span>
</div>
<button type="button" class="btn btn-ghost btn-sm lean-retry" @click="session.retry()">
{{ t('lean.retry') }}
</button>
</div>
<div v-else-if="session.phase.value === 'connecting' || session.phase.value === 'initializing'" class="lean-state">
<span class="lean-spinner" aria-hidden="true" />
<p class="lean-loading-text">{{ phaseText }}</p>
</div>
</div>
</template>