This commit is contained in:
@@ -4,8 +4,9 @@ export interface ArticleTab {
|
||||
/** the article this page is of — its canonical slug, and the page's key */
|
||||
identity: string
|
||||
/** set when the page is one of the wiki's own pages — the link map, the
|
||||
* graphing calculator — laid on the book instead of an article: the page
|
||||
* draws that live surface, at its own address, for as long as it is open */
|
||||
* graphing calculator, the Lean editor — laid on the book instead of an
|
||||
* article: the page draws that live surface, at its own address, for as
|
||||
* long as it is open */
|
||||
tool?: ToolPage
|
||||
/** the language this page is turned to */
|
||||
lang: string
|
||||
@@ -24,6 +25,8 @@ const MAX_TABS = 10
|
||||
export const TOOL_LABEL_KEYS: Record<ToolPage, string> = {
|
||||
graph: 'sidebar.graph',
|
||||
calculator: 'sidebar.calculator',
|
||||
calculator3d: 'sidebar.calculator3d',
|
||||
lean: 'sidebar.lean',
|
||||
}
|
||||
|
||||
/** The key of the book page holding one of the wiki's own pages. No article
|
||||
@@ -123,9 +126,10 @@ export function useArticleTabs() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay one of the wiki's own pages — the link map, the graphing calculator —
|
||||
* on the book beside the articles. One surface is one page however many
|
||||
* times it is asked for: asking again just finds the page already there.
|
||||
* Lay one of the wiki's own pages — the link map, the graphing calculator,
|
||||
* the Lean editor — on the book beside the articles. One surface is one
|
||||
* page however many times it is asked for: asking again just finds the
|
||||
* page already there.
|
||||
*/
|
||||
function openTool(page: ToolPage) {
|
||||
const identity = toolPageIdentity(page)
|
||||
|
||||
287
frontend/app/composables/useLeanSession.ts
Normal file
287
frontend/app/composables/useLeanSession.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/* The wiki's Lean session (client-side).
|
||||
*
|
||||
* One websocket to the lean4web server (`/websocket/<project>`), through
|
||||
* which the wiki plays the part of a Lean editor's language client: open a
|
||||
* document, send edits, ask for the goal at the cursor (the server's
|
||||
* `$/lean/plainGoal`), and read the diagnostics — whose severity-3 entries
|
||||
* are Lean's `#check`/`#eval` style messages, shown in the Messages pane.
|
||||
*
|
||||
* The session lives at module scope on purpose: the book may move the Lean
|
||||
* page between its full-page and pane hosts (unmounting the editor), but a
|
||||
* running Lean elaboration and its open document are expensive to restart —
|
||||
* so they outlive the component, and the editor simply re-attaches. */
|
||||
import { RpcSocket } from '~/utils/lean/lsp'
|
||||
|
||||
export type LeanPhase = 'idle' | 'connecting' | 'initializing' | 'ready' | 'lost'
|
||||
|
||||
export interface LeanInfoMessage { line: number, text: string }
|
||||
|
||||
/* the shape the Lean server publishes per diagnostic (a slice of LSP) */
|
||||
export interface LeanDiagnostic {
|
||||
range: { start: { line: number, character: number }, end: { line: number, character: number } }
|
||||
severity?: number
|
||||
message: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
const DOCUMENT_NAME = 'Main.lean'
|
||||
const FALLBACK_PROJECT = 'MathlibDemo'
|
||||
|
||||
interface LeanSession {
|
||||
phase: Ref<LeanPhase>
|
||||
goals: Ref<string[]>
|
||||
messages: Ref<LeanInfoMessage[]>
|
||||
busy: Ref<boolean>
|
||||
version: Ref<string | null>
|
||||
error: Ref<string | null>
|
||||
text: Ref<string>
|
||||
/* the uri the lean server knows this buffer by — LSP requests must
|
||||
* name the document the server opened, not the editor's local one */
|
||||
serverDocUri: Ref<string>
|
||||
start: () => Promise<void>
|
||||
retry: () => void
|
||||
change: (text: string) => void
|
||||
cursorAt: (line: number, character: number) => void
|
||||
request: <T>(method: string, params?: unknown) => Promise<T>
|
||||
onDiagnostics: (cb: (diags: LeanDiagnostic[]) => void) => () => void
|
||||
}
|
||||
|
||||
let singleton: LeanSession | null = null
|
||||
|
||||
export function useLeanSession(): LeanSession {
|
||||
const config = useRuntimeConfig()
|
||||
const leanUrl = String(config.public.leanUrl || '')
|
||||
|
||||
if (!singleton && import.meta.client && leanUrl) singleton = createSession(leanUrl)
|
||||
|
||||
/* a dormant stand-in keeps the component honest on the server, where
|
||||
* there is nothing to speak to */
|
||||
return singleton ?? {
|
||||
phase: ref('idle') as Ref<LeanPhase>,
|
||||
goals: ref([]) as Ref<string[]>,
|
||||
messages: ref([]) as Ref<LeanInfoMessage[]>,
|
||||
busy: ref(false) as Ref<boolean>,
|
||||
version: ref(null) as Ref<string | null>,
|
||||
error: ref(null) as Ref<string | null>,
|
||||
text: ref('') as Ref<string>,
|
||||
serverDocUri: ref('') as Ref<string>,
|
||||
start: async () => {},
|
||||
retry: () => {},
|
||||
change: () => {},
|
||||
cursorAt: () => {},
|
||||
request: async () => { throw new Error('no lean session') },
|
||||
onDiagnostics: () => () => {},
|
||||
}
|
||||
}
|
||||
|
||||
function createSession(leanUrl: string): LeanSession {
|
||||
const phase = ref<LeanPhase>('idle')
|
||||
const goals = ref<string[]>([])
|
||||
const messages = ref<LeanInfoMessage[]>([])
|
||||
const busy = ref(false)
|
||||
const version = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const text = ref('')
|
||||
const serverDocUri = ref('')
|
||||
|
||||
let rpc: RpcSocket | null = null
|
||||
let docUri = `${FALLBACK_PROJECT}/${DOCUMENT_NAME}`
|
||||
let sentVersion = 0
|
||||
let lastSentText = ''
|
||||
const cursor = { line: 0, character: 0 }
|
||||
const diagListeners = new Set<(diags: LeanDiagnostic[]) => void>()
|
||||
let lastDiags: LeanDiagnostic[] = []
|
||||
|
||||
let changeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let goalTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const wsBase = leanUrl.replace(/\/+$/, '').replace(/^http/, 'ws')
|
||||
|
||||
async function pickProject(): Promise<string> {
|
||||
try {
|
||||
// the server's REST endpoints carry no CORS headers — probing them from
|
||||
// another origin only earns a blocked request, so stay quiet cross-origin
|
||||
// and name the canonical project (the websocket is CORS-free)
|
||||
if (new URL(leanUrl, window.location.href).origin !== window.location.origin) {
|
||||
return FALLBACK_PROJECT
|
||||
}
|
||||
const res = await fetch(`${leanUrl.replace(/\/+$/, '')}/api/projects`)
|
||||
const projects = (await res.json()) as Array<{ folder: string, config?: { default?: boolean, hidden?: boolean } }>
|
||||
const visible = projects.filter(p => !p.config?.hidden)
|
||||
return (visible.find(p => p.config?.default) ?? visible[0])?.folder || FALLBACK_PROJECT
|
||||
}
|
||||
catch { return FALLBACK_PROJECT }
|
||||
}
|
||||
|
||||
function handleNotification(method: string, params: unknown) {
|
||||
const p = params as Record<string, any> | undefined
|
||||
if (method === '$/lean/fileProgress') {
|
||||
busy.value = Array.isArray(p?.processing) && p.processing.length > 0
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/publishDiagnostics') {
|
||||
const raw = Array.isArray(p?.diagnostics) ? p.diagnostics as LeanDiagnostic[] : []
|
||||
// the server marks some diagnostics silent (elaboration noise); the
|
||||
// wiki shows errors/warnings in the editor, info in the Messages pane
|
||||
const diags = raw.filter(d => !('isSilent' in d && (d as any).isSilent))
|
||||
lastDiags = diags
|
||||
messages.value = diags
|
||||
.filter(d => d.severity === 3)
|
||||
.map(d => ({ line: d.range.start.line + 1, text: d.message }))
|
||||
for (const cb of diagListeners) cb(diags)
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
phase.value = 'connecting'
|
||||
error.value = null
|
||||
const project = await pickProject()
|
||||
docUri = `${project}/${DOCUMENT_NAME}`
|
||||
// a fresh document identity means the buffer below re-sends as new
|
||||
sentVersion = 0
|
||||
lastSentText = ''
|
||||
|
||||
try {
|
||||
rpc = new RpcSocket(`${wsBase}/websocket/${project}`, {
|
||||
onNotification: handleNotification,
|
||||
onClose: () => {
|
||||
if (phase.value !== 'idle' && phase.value !== 'lost') {
|
||||
phase.value = 'lost'
|
||||
busy.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
await rpc.whenOpen
|
||||
|
||||
phase.value = 'initializing'
|
||||
const init = await rpc.request<{
|
||||
serverInfo?: { name?: string, version?: string }
|
||||
}>('initialize', {
|
||||
processId: null,
|
||||
clientInfo: { name: 'mathew', version: '0.1.0' },
|
||||
rootUri: '',
|
||||
capabilities: {
|
||||
general: { positionEncodings: ['utf-16'] },
|
||||
workspace: { configuration: false, workspaceFolders: false, applyEdit: true },
|
||||
window: {
|
||||
showMessage: {},
|
||||
showDocument: { support: true },
|
||||
workDoneProgress: true,
|
||||
configuration: true,
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: { didChange: true, willSave: false, didSave: false },
|
||||
publishDiagnostics: { relatedInformation: true, versionSupport: true },
|
||||
hover: { contentFormat: ['markdown', 'plaintext'] },
|
||||
definition: { linkSupport: true },
|
||||
documentHighlight: {},
|
||||
completion: {
|
||||
completionItem: {
|
||||
snippetSupport: true,
|
||||
documentationFormat: ['markdown', 'plaintext'],
|
||||
insertTextFormat: { valueSet: [1, 2] },
|
||||
additionalTextEditsSupport: true,
|
||||
},
|
||||
contextSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initializationOptions: { hasWidgets: true },
|
||||
})
|
||||
version.value = init.serverInfo?.version ?? null
|
||||
rpc.notify('initialized', {})
|
||||
rpc.notify('textDocument/didOpen', {
|
||||
textDocument: { uri: docUri, languageId: 'lean4', version: 1, text: text.value },
|
||||
})
|
||||
sentVersion = 1
|
||||
lastSentText = text.value
|
||||
phase.value = 'ready'
|
||||
void refreshGoal()
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
phase.value = 'lost'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGoal() {
|
||||
if (!rpc || phase.value !== 'ready') return
|
||||
try {
|
||||
const res = await rpc.request<{ goals?: string[], rendered?: string } | null>(
|
||||
'$/lean/plainGoal',
|
||||
{ textDocument: { uri: docUri }, position: cursor },
|
||||
)
|
||||
// the server answers `null` away from tactic goals; `rendered` is the
|
||||
// goals as a markdown fence — the array is the honest plain text
|
||||
goals.value = res?.goals ?? []
|
||||
}
|
||||
catch { /* raced by a newer edit (ContentModified) — the next ask wins */ }
|
||||
}
|
||||
|
||||
function sendChange() {
|
||||
if (!rpc || phase.value !== 'ready' || text.value === lastSentText) return
|
||||
sentVersion += 1
|
||||
// LSP allows one change spanning the whole document — simple and exact
|
||||
const endLine = Math.max(lastSentText.split('\n').length - 1, 0)
|
||||
const endChar = lastSentText.slice(lastSentText.lastIndexOf('\n') + 1).length
|
||||
rpc.notify('textDocument/didChange', {
|
||||
textDocument: { uri: docUri, version: sentVersion },
|
||||
contentChanges: [{
|
||||
range: { start: { line: 0, character: 0 }, end: { line: endLine, character: endChar } },
|
||||
text: text.value,
|
||||
}],
|
||||
})
|
||||
lastSentText = text.value
|
||||
void refreshGoal()
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
goals,
|
||||
messages,
|
||||
busy,
|
||||
version,
|
||||
error,
|
||||
text,
|
||||
|
||||
async start() {
|
||||
if (phase.value !== 'idle') return
|
||||
await connect()
|
||||
},
|
||||
|
||||
retry() {
|
||||
try { rpc?.close() }
|
||||
catch { /* gone */ }
|
||||
rpc = null
|
||||
goals.value = []
|
||||
messages.value = []
|
||||
busy.value = false
|
||||
void connect()
|
||||
},
|
||||
|
||||
change(newText) {
|
||||
text.value = newText
|
||||
clearTimeout(changeTimer)
|
||||
changeTimer = setTimeout(sendChange, 400)
|
||||
},
|
||||
|
||||
cursorAt(line, character) {
|
||||
cursor.line = line
|
||||
cursor.character = character
|
||||
clearTimeout(goalTimer)
|
||||
goalTimer = setTimeout(() => void refreshGoal(), 200)
|
||||
},
|
||||
|
||||
request<T>(method: string, params?: unknown) {
|
||||
if (!rpc || phase.value !== 'ready') return Promise.reject(new Error('lean is not ready'))
|
||||
return rpc.request<T>(method, params)
|
||||
},
|
||||
|
||||
onDiagnostics(cb) {
|
||||
diagListeners.add(cb)
|
||||
// a freshly (re)attached editor wants the current marks at once
|
||||
cb(lastDiags)
|
||||
return () => diagListeners.delete(cb)
|
||||
},
|
||||
}
|
||||
}
|
||||
119
frontend/app/composables/useVarSliders.ts
Normal file
119
frontend/app/composables/useVarSliders.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/* The free-variable sliders both calculators wear: one value per variable
|
||||
in a scope the compile pass reads, one slider per variable anchored under
|
||||
the row that claims it — the row declaring it ("k", "k = 2") when one
|
||||
asks, else the first row speaking it — and the values kept between visits
|
||||
per calculator. A name the scope does not carry yet reads as 1, where
|
||||
every slider starts. */
|
||||
|
||||
export interface VarSlider {
|
||||
name: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
/* one row's reading, as far as sliders care: the variables it speaks and
|
||||
the declaration its text claims (a null start says "k", not "k = 2") */
|
||||
export interface RowVarDecls {
|
||||
rowId: number
|
||||
vars: string[]
|
||||
declared: { name: string; value: number | null } | null
|
||||
}
|
||||
|
||||
/* how far a slider reaches either side of zero; a value set past an end —
|
||||
by "k = 40" or by typing — pulls that end out so the thumb can reach it */
|
||||
const SLIDER_MIN = -10
|
||||
const SLIDER_MAX = 10
|
||||
|
||||
export function useVarSliders(
|
||||
varValues: Record<string, number>,
|
||||
decls: () => RowVarDecls[],
|
||||
storageKey: string,
|
||||
) {
|
||||
/* every variable spoken, with the value its "k = 2" row asks to start at
|
||||
— or 1, where sliders start. One variable wears one slider however many
|
||||
rows speak it. */
|
||||
const varStarts = computed(() => {
|
||||
const starts = new Map<string, number>()
|
||||
for (const d of decls()) {
|
||||
if (d.declared && !starts.has(d.declared.name)) starts.set(d.declared.name, d.declared.value ?? 1)
|
||||
for (const name of d.vars) if (!starts.has(name)) starts.set(name, 1)
|
||||
}
|
||||
return starts
|
||||
})
|
||||
|
||||
// a variable the rows only now started speaking takes its start value;
|
||||
// one the reader already moved keeps the value they left it at. Filling
|
||||
// from the first pass keeps what the server draws and what the reader
|
||||
// finds after mounting the same
|
||||
function fillDefaults() {
|
||||
for (const [name, value] of varStarts.value) {
|
||||
if (!(name in varValues)) varValues[name] = value
|
||||
}
|
||||
}
|
||||
watch(varStarts, fillDefaults, { immediate: true })
|
||||
|
||||
/* one slider per variable, filed under the row it sits beneath: the row
|
||||
claiming it ("k", "k = 2") wins, else the first row that speaks it */
|
||||
const slidersByRow = computed(() => {
|
||||
const anchor = new Map<string, number>()
|
||||
for (const d of decls()) {
|
||||
if (d.declared && !anchor.has(d.declared.name)) anchor.set(d.declared.name, d.rowId)
|
||||
}
|
||||
for (const d of decls()) {
|
||||
for (const name of d.vars) {
|
||||
if (!anchor.has(name)) anchor.set(name, d.rowId)
|
||||
}
|
||||
}
|
||||
const byRow = new Map<number, VarSlider[]>()
|
||||
for (const d of decls()) {
|
||||
for (const name of d.vars) {
|
||||
if (anchor.get(name) !== d.rowId) continue
|
||||
const value = varValues[name] ?? 1
|
||||
const list = byRow.get(d.rowId) ?? []
|
||||
list.push({ name, value, min: Math.min(SLIDER_MIN, value), max: Math.max(SLIDER_MAX, value) })
|
||||
byRow.set(d.rowId, list)
|
||||
}
|
||||
}
|
||||
return byRow
|
||||
})
|
||||
|
||||
function setVar(name: string, raw: number) {
|
||||
if (Number.isFinite(raw)) varValues[name] = raw
|
||||
}
|
||||
|
||||
/* the values keep themselves — only the ones a row still speaks, so a
|
||||
variable nobody asks for anymore is forgotten on the next save rather
|
||||
than haunting the store */
|
||||
function save() {
|
||||
try {
|
||||
const kept: Record<string, number> = {}
|
||||
for (const [name, value] of Object.entries(varValues)) {
|
||||
if (varStarts.value.has(name) && Number.isFinite(value)) kept[name] = value
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(kept))
|
||||
} catch {
|
||||
/* private mode etc. — the slider values just won't persist */
|
||||
}
|
||||
}
|
||||
watch([varValues, varStarts], save)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (raw) {
|
||||
const stored: unknown = JSON.parse(raw)
|
||||
if (typeof stored === 'object' && stored !== null) {
|
||||
for (const [name, value] of Object.entries(stored)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) varValues[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* storage unreadable — the sliders start where their rows ask */
|
||||
}
|
||||
fillDefaults()
|
||||
})
|
||||
|
||||
return { slidersByRow, setVar }
|
||||
}
|
||||
Reference in New Issue
Block a user