Web IDE architecture¶
See Web IDE for what the app actually does, and
Using the Web IDE for a walkthrough. This page is about why :ide-web is
built the way it is: why it's a thin shell around :gui rather than a separate implementation, how it gets a
real code editor into a browser, and why it's tested the way it is.
Why :ide-web is a Kotlin/JS-only multiplatform module in the first place — rather than, say, JVM-only like
:ide-swing — is covered in Kotlin Multiplatform; why it needs its own hand-rolled
kotlin.multiplatform plugin block instead of the shared project template is covered in
Gradle multi-project build. Neither is repeated here.
One :gui, two shells¶
:ide-web contains almost no Prolog-editing logic of its own. State (GuiState), the action/dispatch loop
(GuiController), syntax analysis (PrologSyntaxAnalyzer), and solver-session management all live in :gui,
shared verbatim with :ide-swing (see Module map). What :ide-web actually
contains is a DOM-shaped view: WebIdeView builds/refreshes the page from a GuiState snapshot and turns DOM
events into GuiAction/PageAction/WorkspaceAction/DocumentAction dispatches — the same actions
SwingIdeFrame dispatches from Swing widgets instead. A bug fixed in :gui (a parser edge case, a diagnostic's
line/column convention, a solver-session race) is fixed for both frontends at once, by construction, not by
discipline.
One highlighter for both frontends¶
Ace ships its own tokenizer/highlighter machinery (regex-based TextHighlightRules), and using it directly
would have meant maintaining Prolog's lexical rules twice — once in :gui's real lexer, once again as Ace
regexes, with no way to guarantee they'd ever agree with each other or with what the parser actually accepts.
AceCustomMode.kt sidesteps that: it builds an Ace "Mode" object whose getLineTokens hook is backed directly
by PrologSyntaxAnalyzer's semantic tokens (AceEditorView.lineTokens), the exact same analysis that produces
the Diagnostics tab's errors and the desktop IDE's own highlighting. Every other Ace mode hook this app doesn't
need (comment toggling, auto-indent, transformAction, autocompletion, background workers) is stubbed out —
deliberately returning a real JavaScript undefined rather than Kotlin's Unit, since Unit is a truthy
object as far as Ace's own if (result) { ... } checks are concerned, and would silently corrupt editing
behavior Ace expects to be a no-op.
Ace, vendored the old way¶
Ace predates the ES module system by a decade; its own module loader expects to be a plain global script, not
something imported into a webpack bundle. So ace.js and the two theme files (theme-github.js,
theme-github_dark.js) aren't part of the Kotlin/JS dependency graph at all — the copyAceEditor Gradle task
vendors them as static assets, and index.html loads them via ordinary <script> tags, before the compiled
ide-web.js bundle. Ace.kt's external declarations then bind Kotlin code to whatever global object those
scripts leave behind, the same way any other pre-ES-module JS library gets used from Kotlin/JS.
Two storage models, deliberately not one¶
A browser sandbox has no real file system to speak of, so "Save" can't mean the same thing it means in the
desktop IDE. :ide-web resolves this by not pretending it's the same thing: New/Open/Save/Save as write into
this browser's own localStorage (LocalStorageDocumentStore, keyed tuprolog-fs:<name>, a flat name→text
map with no versioning) — invisible outside that browser, but zero-friction and available offline. Upload/
Download instead go through the browser's real file picker/download machinery, for whenever a genuine .pl
file needs to leave the browser (emailing it, committing it, opening it in another tool). Nothing conflates the
two: a page saved with "Save" cannot be opened by another browser or another machine, only by "Upload"ing an
actually-downloaded file.
Editor zoom (font size) is the one setting explicitly shared across the two frontends despite being purely
cosmetic: EditorZoom (:gui) is the single source of the default font size and the 8–48px clamp range, used
by both SwingIdeFrame's RSyntaxTextArea and AceEditorView's Ace instance, specifically because the two used
to drift (8–48 on one frontend, 8–40 on the other) before being unified into one shared constant.
Two-tier testing¶
jsBrowserTest (Karma + headless Chrome, part of ./gradlew check) mounts individual Kotlin classes —
AceEditorView, LocalStorageDocumentStore, the workspace-persistence logic — in isolation, fast and
deterministic. What it structurally cannot catch is a bug in how those pieces are wired together on the real
page, because it never boots index.html at all. scripts/browser-e2e-test.mjs exists for exactly that gap: a
small custom harness that serves the packaged distribution over plain HTTP, drives a real headless Chrome over
the Chrome DevTools Protocol, and exercises the app the way a person would — typing through Ace's real input
pipeline (execCommand('insertText', ...) on its focused textarea, not calling a Kotlin setter directly),
clicking real buttons, reading back the rendered DOM. It is deliberately not part of check (it needs a
production webpack build and a local Chrome, both too heavy for the fast, tight inner loop check is for), and
instead runs as its own CI step (:ide-web:browserE2ETest) and via
its own how-to guide for anyone self-hosting the app.
This isn't a hypothetical distinction: every scenario in that script exists because it once failed silently
past jsBrowserTest and only surfaced this way — a missing SolverFactory import, a shadowed replaceWith
that silently detached DOM nodes, and an Ace.transformAction no-op that discarded every keystroke were all
first caught by driving a real browser against a real build, not by unit-testing the pieces in isolation.