425 lines
16 KiB
Vue
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>
|