Tornado Extension
VS Code extension for developing applications on a Tornado application
server: browse the server's inventory and sync an application's design
elements (CSS, JS, HTML, XML, XSL, text, Java) into your workspace.
Started as a skeleton; inventory browsing, download/sync, and upload/watch
are now implemented (with some deliberate gaps noted below). See the
TODO markers in src/ for what's still open.
Status
Diagnostics: every HTTP request TornadoClient makes (method, full
URL, request body size — and the body itself when under 2KB — response
status, and on failure the response body) is logged to the "Tornado"
output channel (View → Output, select "Tornado" from the dropdown), along
with sync milestones (files written, byte counts, skipped uploads and why)
and a trace of every command: what ran, what it was aimed at, whether it
finished, and how long it took. It auto-opens on a sync failure. If a sync
seems to do nothing, check there first before assuming it's silent — it
isn't.
Failures appear in red. The channel is a LogOutputChannel
(logging.ts), so ordinary progress is logged at info level and anything
that failed goes through error(), which the Output panel renders in red:
failed uploads and downloads (any non-2xx or network error), ecj's compile
diagnostics, unexpected response shapes, watcher errors, and any command
that throws. logError() is the one entry point for that, and falls back to
a plain line if handed a channel that isn't one of ours.
The same stream is mirrored to console.log/console.error, timestamped
and prefixed with [Tornado] — which is where the Debug Console shows it
when the extension is run with F5, and Help → Toggle Developer Tools shows it
in a normal install. So a debugging session sees extension activity
interleaved with its own breakpoints and stack traces without switching to
the Output panel. Only what's already written to the channel is mirrored:
design element contents (base64) are never logged, just names and byte
counts.
File editing: CSS, JS, HTML, XML, XSL, plain text, and Java are all
covered by VS Code's built-in language support — no custom language
contribution needed. If Tornado stores design elements under non-standard
file extensions, add a contributes.languages file-association entry
in package.json mapping the extension to an existing language id (e.g.
*.tornxsl → xml) rather than defining a new grammar.
Connection configuration: multiple named connections (e.g. Staging,
Production) can be configured, each with its own server URL, username, and
password. Tornado: Add Connection prompts for all four and makes the new
connection active; Tornado: Select Connection switches the active one
(persists across windows/folders); Tornado: Edit Connection updates an
existing one's name/URL/username (password field is left blank to keep
the current password); Tornado: Delete Connection removes one (settings
entry + stored credentials). All four are reachable via the Command
Palette and via icons on the Inventory view's title bar (refresh/select/
add always visible; edit/delete under the "..." overflow menu) — not just
from the initial empty-state welcome screen, which disappears once a
connection is active. Connection name/URL pairs are stored
in User settings (tornado.connections, global scope — available in
every workspace, not just the one they were added from); usernames/
passwords are stored in VS Code's SecretStorage (OS keychain-backed),
keyed by each connection's generated id — never in plain text or in
settings.json.
Inventory: the Tornado activity bar icon shows an "Inventory" tree
view for the active connection (shown in the view's description).
TornadoClient.fetchInventory() does GET {serverUrl}/vortex with HTTP
Basic Auth and lists the returned apps (fields: appname, appid,
appdisplayname?, appgroup, description, templatename,
appversion?, inheritfrom), grouped into a collapsible node per
appgroup (apps with no group land under "(ungrouped)"), groups sorted
alphabetically, and apps within each group sorted by appname. Apps whose
templatename is set show a template icon; each app's appversion (when
present) shows as its description text; apps whose inheritfrom is set
are shown in red, via a FileDecorationProvider keyed off a synthetic
resourceUri on each item (VS Code tree items have no direct label-color
property). The
empty-state message distinguishes three cases: no connections configured
yet, connections exist but none is
active, and (via treeView.message, plus a line in the "Tornado" output
channel) an active connection whose inventory fetch actually failed —
distinct from an empty tree meaning "not configured."
Design element folder: clicking an app in the Inventory tree creates
tornado/<connectionName>_<appgroup>_<appname>/ in the open workspace
(appgroup is optional and dropped when empty), fetches its full design via
GET /vortex/{appid}/ with HTTP Basic Auth, and writes each element from
the response's designelements array to disk (designSync.ts).
Opening an app that's already synced replaces the local copy rather than
writing over the top of it, so what lands on disk matches the server
exactly — otherwise an element deleted on the server lingers locally
forever, and the watcher could upload a stale file back. Because that
discards local work, a modal confirmation comes first
(confirmAndResetAppFolder() in extension.ts), offering Delete & Sync
Fresh, Sync Without Deleting (the older write-over-the-top behaviour,
for when there's local work to keep) and Cancel. It only appears when the
folder actually has something in it — a first sync, or a re-sync into an
empty folder, goes straight through.
Three things that matter on the delete path: the app's watcher is
disposed before anything is removed (its delete handler treats a vanished
file as "the user deleted this design element" and asks whether to delete it
server-side too, which is the last thing a refresh should trigger) and
restarted afterwards if it was running; the folder goes to the OS trash
where the filesystem supports it, falling back to a permanent delete; and
Documentation/devconfig.json is carried across, since the per-app
javaVersion override is local dev-tooling config rather than server design
and is meant to survive re-syncing. zbin/ compiled output is not
preserved — the next compile rebuilds it. Tornado: Refresh from Server is
unchanged and still writes over the top without deleting. If no
folder is open, the error offers an "Open Folder..." button
(vscode.openFolder) instead of a dead end — note that opening a folder
reloads the window, so the sync has to be retried afterward rather than
resuming automatically. Running Tornado: Sync Application to Workspace
from the Command Palette instead only prompts for an app name — since it
doesn't know the app's id, it can create the local folder but can't
download (a warning explains this).
All seven design-type subfolders (Pages, Resources, Actions, SharedCode,
Documentation, ScheduledActions, Widgets) are always created, even ones
with nothing in them, so an app's structure is visible in the Explorer
right away rather than folders only appearing once something's added to
them. Each element is written under the subfolder named for its
designtype (1=Pages, 2=Resources, 3=Actions, 4=SharedCode,
5=Documentation, 6=ScheduledActions, 7=Widgets). Actions/SharedCode/ScheduledActions decode
designsource (base64) to a .java file; Documentation also uses
designsource, with its extension guessed from contenttype. Everything
else (Pages, Resources, Widgets, and any jar found in SharedCode via
contenttype == "application/java-archive") decodes designdata instead
— Pages get a .phtml extension rather than .html, since Tornado pages
mix in non-standard tags that would mislead HTML tooling; other types get
an extension guessed from contenttype.
An extension is only ever added to the design types whose server-side
name is bare (Pages, Actions, SharedCode, ScheduledActions). Resources,
Documentation and Widgets already carry theirs in the name, so the name is
the filename — otherwise Documentation/CLAUDE.md lands as CLAUDE.md.md.
fileNameFor() in designSync.ts is the single place that decides this,
used by both the download and the rename path, and is the exact inverse of
serverNameFor() — which is what stops a round trip from quietly renaming
an element (adding .md to a Documentation element named notes would
push it back as a rename to notes.md). A .tornado-manifest.json is
written alongside each app's files (designSync.ts) recording each
element's designbucketid/name/type/contenttype/etc. plus which
connection it came from — the extension host restarts when a folder is
opened, so this is what tornado.startWatching uses to map a local file
back to its server-side element without another round trip.
This logic was informed by vortex-cli-mirror, an existing Python CLI for
the same server (SOAP-based, not REST, so only its file-handling
conventions transferred, not its API calls) — worth checking if you
extend this further, e.g. for Java package-based subfolder nesting or a
compile pipeline, neither of which is implemented here.
Uploading local changes (appWatcher.ts): after a sync, the success
message offers a "Start Watching" button (or run Tornado: Start Watching Application / Tornado: Stop Watching Application from the
Command Palette, which lists synced apps found via
tornado/*/.tornado-manifest.json). While watching, a
vscode.FileSystemWatcher on that app's folder does:
- Change →
PUT /vortex/{appid}/design/{designbucketid}, same JSON
shape as a downloaded element, with the edited field (designsource or
designdata, per the same type rule as download) refreshed.
- Create →
POST /vortex/{appid}/design. The new element's
designtype comes from the file's parent folder name and contenttype
is inferred backwards from its extension (the reverse of the
download-side mapping) — the server is assumed to respond with the
full created element (to learn its new designbucketid); this is
unverified against a real request and fails loudly rather than
silently corrupting the manifest if the response shape doesn't match.
The server-side name sent depends on design type: Pages and the
Java-source types are addressed by a bare name (a local Home.phtml
becomes server name Home, confirmed by real sample data — Actions
have no .java in their name either), while Resources, Documentation,
and Widgets keep the extension as part of the name (a local
Documentation/Claude.md becomes server name Claude.md, contenttype
text/markdown) — see serverNameFor() in designSync.ts.
- Delete → confirmation prompt (modal, since deleting from a live
server can't be undone), then
DELETE /vortex/{appid}/design/{designbucketid}.
The local file is already gone by the time the event fires — declining
the prompt just leaves the server-side element in place, it doesn't
restore the local file.
One thing deliberately not handled:
- Java source/class changes are skipped by the watcher entirely
(Create and Update, for both
.java and .class files under
Actions/SharedCode/ScheduledActions) — designdata for these types is
compiled bytecode, not the source text, so PUTting designsource
alone would silently fail to take effect. These are handled instead by
the dedicated compile pipeline below, not the per-file watcher; changes
are logged to the "Tornado" output channel and otherwise ignored here.
(.jar files under SharedCode are unaffected — they upload normally via
designdata.)
- Re-syncing an already-watched app is wrapped in
AppWatcher.runSuppressed(), which pauses event handling (with a
short drain delay, since filesystem events can lag slightly behind the
write that caused them) so the fresh download doesn't get echoed back
to the server as a wave of redundant uploads.
Progress feedback: syncing an app (clicking it in the Inventory tree
or running Tornado: Sync Application to Workspace), the sync half of
Tornado: Create Application, and Tornado: Refresh from Server each
wrap their download in a vscode.window.withProgress notification-style
toast so background work is visible rather than silent — sync/create
titles it with the app's /appgroup/appname (appPathLabel() in
extension.ts, group segment dropped when ungrouped) so a same-named app
in a different group isn't ambiguous. Notification-location progress
stacks per call, so clicking several apps in quick succession shows one
toast per app rather than one being silently overwritten by the next.
Refreshing from the server: Tornado: Refresh from Server re-runs a
sync (fetch + overwrite local files, using the same
syncDesignToFolder()/suppression logic as the initial sync and as
re-syncing a watched app) for an app already synced into the workspace,
after a confirmation prompt since it overwrites local files. It's also
available as a $(cloud-download) button in the file Explorer's
title bar (not the Tornado Inventory view), next to VS Code's native
refresh button — VS Code doesn't allow extensions to hook into or wrap
built-in commands like "Refresh Explorer" directly, so this is a
companion button rather than the native one gaining new behavior.
Editing design element properties: Tornado: Edit Design Element Properties, from the Command Palette (using the active editor's file) or
by right-clicking a design element file in the Explorer, opens a picker
for the element's name/comment/options/inheritfrom — content
(designdata/designsource) and designparams aren't touched, and are
re-sent exactly as fetched fresh from the server rather than reconstructed
from the local file, so a Java element's compiled bytecode (designdata)
is never overwritten with its source text by accident.
The Explorer entry is offered only on a file that is actually inside a
design-type folder (tornado/<app>/<TypeFolder>/<file>, including a
Type<N>/ folder for a design type this extension doesn't know by name).
It stays off CLAUDE.md/AGENTS.md, .tornado-manifest.json,
Documentation/devconfig.json, compiled zbin/ output and the cached
.lib/ jars — none of which is a tracked design element, so offering the
command there could only ever end in an error.
Renaming:
- Updates the server via the same
PUT used elsewhere, then renames the
local file to match (via a WorkspaceEdit, so any editor with it open
follows) and updates the manifest — suppressing the app's watcher around
the rename first, if it's running, so the delete+create filesystem
events a rename produces aren't mistaken for a real deletion.
- Is refused for a nested/inner/anonymous class (
Foo$Bar) — its name is
fixed by its enclosing top-level class, and Tornado: Compile & Upload Java would just recreate it under the original name on the next
compile, orphaning the renamed copy on the server.
- For a top-level Actions/SharedCode/ScheduledActions class, succeeds but
warns that the
public class declaration inside the file still needs
updating to match — the compiler (not the server) ties a Java design
element to its class name, and a mismatched declaration fails to
compile.
Editing design element parameters (designparams): Tornado: Edit Design Element Parameters, from the Command Palette (using the active
editor's file) or by right-clicking a design element file in the Explorer.
Only Pages, Resources and Actions have editable parameters — SharedCode,
Documentation, ScheduledActions and Widgets have none, so the context-menu
entry is hidden for them (its when clause matches only the Pages,
Resources and Actions folders, which also keeps it off devconfig.json,
CLAUDE.md/AGENTS.md and zbin/; note that ScheduledActions/ does
not match the Actions alternative, since the clause anchors each folder
name between slashes). The command re-checks the design type itself, since
the Command Palette route runs against whatever file is in the active
editor without consulting any when clause. supportsDesignParams() in
designSync.ts is the source of truth for the type numbers; the folder
names in package.json have to be kept in step with it.
For the types that do have them, which parameters exist depends on the
design type, mirroring the
server's own saveParams(): every type carries AnonymousAccess,
MinifyLevel and CompositeElement (each set to 1 or not set at all),
and a Page additionally carries OpenAction and SaveAction (picked
from the app's Actions) and ParentPage (picked from its Pages). Any other
parameter the element already has is listed after those as free text and
re-sent as-is — the client never drops one it doesn't recognise, though
whether the server persists a name outside its own saveParams() list is
up to the server. The 1-or-absent rule is the same as for application
parameters above: a parameter set to nothing is omitted rather than written
as an empty string, while one the server already has blank and nobody
touched is re-sent untouched.
Reading and writing go through GET/PUT /vortex/{appid}/design/{designbucketid}/params — the design-element-level
counterpart of /vortex/{appid}/appparams — with the PUT body wrapped as
{"designparams": [...]} (that key rather than the URL's params, to match
the field name design elements already use in their own JSON). The
element's content is never fetched or re-sent, so a parameter change can't
disturb designdata/designsource. The manifest's copy of
designparams is updated too, and that matters: the watcher re-sends the
manifest's parameters with every file upload, so a stale copy there would
silently revert a parameter change the next time the file was saved.
Creating an application: Tornado: Create Application (Command
Palette, or the $(new-file) button in the Inventory view's title bar once
a connection is active) prompts for the same property set as editing one
below (appname/appgroup/description/templatename/inheritfrom)
via the same picker, then POSTs it to the active connection. POST /vortex is not a confirmed endpoint either — extrapolated the same way
updateApplication() extrapolates its PUT, by mirroring
createDesignElement()'s POST-and-read-back-the-new-id pattern onto the
application level; see TornadoClient.createApplication(). Once created,
behaves like picking a freshly-appeared app from the Inventory tree: a
local folder is created and its (likely empty) design is synced into it,
with an offer to start watching it — but since the application already
exists on the server by that point, a failure in this local-sync half is
reported as a warning telling you to sync it manually via the Inventory
tree, not as a creation failure, so nothing about a network hiccup here
would suggest re-running the command (which would create a duplicate
application server-side).
The three application-level commands — Edit Application Properties,
Edit Application Parameters and Edit Keywords — appear in the Explorer
context menu only on an application's own root folder
(tornado/<app>), never on the design-type subfolders inside it, on
zbin/, on the shared .lib/ cache, or on tornado/ itself. Those have no
manifest to act on, so the commands could only fail there. The Command
Palette route is unaffected: it offers a picker of synced applications
(pickSyncedAppFolder()).
Editing application properties: Tornado: Edit Application Properties, from the Command Palette (offers a picker of synced apps) or
by right-clicking a synced app's root folder in the Explorer, edits the
application itself rather than one of its design elements —
appname/appgroup/description/templatename/inheritfrom.
appdisplayname and appversion are read (used in the picker's title and
left untouched in the update payload) but not editable here. PUT /vortex/{appid} is not a confirmed
endpoint — nothing in this codebase corroborates it, unlike the
design-element endpoints; it's extrapolated by symmetry with the existing
GET /vortex/{appid}/ (see TornadoClient.updateApplication()) and needs
verifying against a real server. Renaming appname/appgroup — which
double as the local folder-name segments, see folderName() in
workspaceStorage.ts — renames the local tornado/<folder> to match
(again via a WorkspaceEdit), tearing down and restarting the app's
watcher around the move if one was running, since a live
vscode.FileSystemWatcher can't just follow its root folder being
renamed out from under it the way a suppressed one can follow a single
file rename.
Editing application parameters (APPPARAM): Tornado: Edit Application Parameters, from the Command Palette or by right-clicking a synced app's
folder in the Explorer, edits the application's key/value parameters. The
dialog is the same QuickPick loop the two property editors use, listing ten
well-known parameter names with their current values: OpenAction,
OpenAction1, SaveAction, SaveAction1 (picked from the app's Actions),
LoginPage (picked from its Pages), DefaultOpen (free text),
DisableApp, DisableScheduledActions, ForceSecureConn (set to 1 or
not set at all), and DefaultLocale (picked from a locale list showing
e.g. "English (Australia)" while storing en-AU). Action/Page choices come
from the local manifest — i.e. the last sync — so every choice list also
offers manual entry for a value that exists server-side but isn't synced
locally. Locale labels are generated with Intl.DisplayNames
(languageDisplay: "standard", with a hand-composed fallback for older ICU
builds that ignore the option, which would otherwise render "Australian
English"). Parameters the application already has beyond those ten are
listed after them as free text and re-sent as-is — the client never drops
one it doesn't recognise, though whether the server persists a name outside
its own list is up to the server.
A parameter set to nothing is omitted from the save, not written as an
empty string — that's how "1 or not set" is expressed. To avoid that
rule deleting rows nobody touched, a parameter the server already has with
a blank value is re-sent unchanged unless it was actually edited.
Reading and writing go through GET/PUT /vortex/{appid}/appparams, a
collection of its own alongside /vortex/{appid}/design — so editing a
parameter touches neither the application's own properties nor its design
elements. The PUT body is {"appparams": [...]} — the full set, which
replaces the old one, so a parameter the editor drops is expressed by its
absence (a partial write couldn't remove anything). It's wrapped in an
object rather than sent as a bare top-level array because the server reads
no data from a bare array, and because every other write in this API is an
object anyway. The read is deliberately tolerant (extractAppParams() in
tornadoClient.ts): it takes that wrapped form, an object wrapping the
array under any other key, or a bare array.
Editing keywords (KEYWORD / KEYWORDDATA, keywordEditor.ts):
Tornado: Edit Keywords, from the Command Palette or by right-clicking a
synced app's folder in the Explorer, opens the extension's only webview —
every other editor here is a QuickPick, which can't present a table of up to
50 value rows. One panel per application (a second invocation reveals the
open one rather than starting a rival editor with its own unsaved state):
keywords down the left with New/Delete, and the selected keyword's name plus
its value rows as an editable table on the right (each row is a data
value and its keywordorder, named as the server's columns are).
- A row has no order by default.
keywordorder starts null on a new
row (the box shows a "by value" placeholder), and such rows sort by their
data. An order is something a user opts into for the rows they want
pinned. Clearing the box returns a row to unordered — it does not mean
zero, which is a real order like any other.
- Rows carrying an explicit order come first, in that order; the rest follow
sorted by
data. Ties on order fall back to data. Sorting is applied on
load and after a save, never while typing, which would make rows jump
under the cursor.
- Order values are preserved, never renumbered. The order column is
saved exactly as typed, gaps and duplicates included. The ↑/↓ buttons
swap the two rows' order values rather than renumbering the list, so the
numbers only ever change as the direct result of an action. Rows that
share an order value — or that have no order to swap — therefore can't be
rearranged that way, and the panel says so instead of doing nothing.
- The
n of 50 rows counter and the disabled "Add row" past 50 are a
guide, not a limit: nothing blocks a save on row count, so a keyword
that already holds more than 50 rows server-side stays editable.
- Edits are kept per keyword for as long as the panel is open, so switching
between keywords never discards work in progress; unsaved keywords carry a
dot in the list, Revert restores the last loaded copy, and closing the
panel with edits pending warns that they were discarded (a webview can't
veto its own close).
- Deleting a keyword goes through the same modal confirmation as deleting a
design element from the server.
description isn't editable in the panel
but is carried through the save untouched, since the PUT replaces the
whole keyword.
- The panel holds no credentials and makes no requests: it exchanges
messages with the extension, which owns every HTTP call. Its
Content-Security-Policy allows one nonced inline script and inline styles
and nothing else — no network, no remote resources — and all of its
content is set through
textContent/value rather than innerHTML.
Reads come from the app pull; only writes have their own endpoints.
Keywords arrive in the existing GET /vortex/{appid}/ alongside
designelements, so there is no keyword read endpoint to build:
GET /vortex/{appid}/ (the existing app pull)
-> {..., "designelements": [...],
"keywords": [{"keywordid": 12, "appid": 7, "name": "Country",
"description": "", "keyworddata": [
{"keyworddataid": 88, "data": "AU", "keywordorder": 1},
{"keyworddataid": 89, "data": "NZ", "keywordorder": null}]}]}
POST /vortex/{appid}/keywords body {"keyword": {...}} (no keywordid)
-> the created keyword, incl. its new keywordid
PUT /vortex/{appid}/keywords/{kwid} body {"keyword": {...}}
DELETE /vortex/{appid}/keywords/{kwid}
These three write endpoints did not exist server-side when this was
written — the contract is as much a spec for the server as a client
implementation. The PUT sends the whole keyword: its keyworddata array
replaces what's stored, so a deleted row is expressed by its absence, and
rows carry keyworddataid only when they already exist. Bodies are wrapped
in an object for the same reason the parameter collections are.
keywordorder is always present and is null when the row has no
explicit order — the key is never omitted, so the server never has to
guess whether a missing field means "no order" or "leave it alone". Store
that null as SQL NULL (not 0, which is a legitimate order a user can
type). On the read side the client accepts null, an absent key, or an
empty string as "no order", and coerces a numeric string to a number.
What the server must reply: only the POST response is parsed. It has to
be JSON (an empty 200 fails), either {"keyword": {...}} or the bare
object, carrying a keywordid — a number, or a numeric string, since an id
serialised from a Java long often arrives quoted. Nothing else in it is
required: the editor re-reads the app immediately afterwards, so
{"keywordid": 412} is enough. PUT and DELETE responses are never read
— any 2xx is success, an empty body is fine, and a non-2xx surfaces its
status plus the first 500 characters of its body. When a POST reply can't
be used, the error quotes the first 300 characters of what actually came
back, since a 200 with the wrong shape isn't otherwise logged.
The keyword array is located in the app pull by extractKeywords() — by
shape (objects with name and keyworddata, which designelements can't
satisfy) or by a keyword-ish key name, so it survives the exact key being
something other than keywords. A response with no keyword array is not
an error: an app can simply have none. A keyword whose rows arrive under
some other name (e.g. a pre-rename values) renders as a keyword with no
values and logs a line naming the keys it did see — do not save such a
keyword, since saving would write that emptiness back. Keywords are
fetched live and are never synced to disk or recorded in the manifest.
Note that the app pull carries every design element's base64 content, so
opening the editor (and each reload after a save) is a heavy request for a
small amount of data. If that ever bites on a large application, a
lightweight GET /vortex/{appid}/keywords is the fix — fetchKeywords() is
the only place that would change.
Compiling and uploading Java (javaCompiler.ts): Tornado: Compile & Upload Java batch-compiles all .java files under a synced app's
Actions, SharedCode, and ScheduledActions folders together in one
invocation (they can reference each other, so per-file compilation on
save isn't viable — this is why it's a separate explicit command, not
wired into the file watcher). Compiled classes land in a zbin/ folder
at the app root rather than next to their .java — a single -d flag
needs one shared output root regardless of which of the three folders a
source came from. zbin isn't a recognised design-type folder, so the
watcher (if running) silently ignores anything written there.
Compiles with ecj (the Eclipse Compiler for Java), not javac —
deliberately recreating Eclipse's own compilation model rather than
javac's, since that's what was asked for. ecj is literally the compiler
Eclipse's IDE runs internally, invoked here as java -jar ecj-<version>.jar -d zbin -cp <classpath> --release <N> -proceedOnError <sources>.
-proceedOnError is the reason for choosing it: verified empirically that
it keeps generating .class output for every source it can — including a
stub for a class with a genuinely unresolved import — embedding an error
that only throws at runtime, if that specific broken part is actually
reached, instead of javac's behaviour where a single unresolved import
can discard output for the entire batch. That's what makes "every save
still reaches the server, even if something elsewhere doesn't compile"
possible without an exclude-and-retry workaround. Pinned to a known-good
version (org.eclipse.jdt:ecj:3.46.0, EPL-licensed) rather than
"latest", downloaded once from Maven Central into the extension's
cross-workspace global storage (context.globalStorageUri, via
ensureEcj()) — it's a dev tool, not tied to any one server connection
or workspace, unlike the server jars above.
The compile classpath comes from the server itself, not a jar bundled
with the extension: GET /vortex/systemjar downloads the server's own
puakma.jar, and GET /vortex/libraries downloads a zip of its other
shared library jars (unpacked locally with unzip, since a zip-of-jars
isn't itself a valid classpath entry the way a single jar/zip of classes
would be). This guarantees client and server are compiling against the
same code, and sidesteps having to bundle or license a third-party
server-framework jar. Both are cached per-connection under
tornado/.lib/<connectionId>/ (shared across every app synced from that
connection) and only re-downloaded if missing — this happens
automatically on every sync/refresh (syncDesignToFolder() in
extension.ts, non-fatal if it fails, so a server without these
endpoints doesn't break a normal file sync), not only lazily on first
compile, so the classpath is warm as soon as an app is connected. Run
Tornado: Refresh Server Libraries after a server-side upgrade to force
a fresh copy.
tornado.compileClasspath (a settings array, empty by default) can add
extra jar/directory paths on top of the server-provided ones if needed.
If the shared libraries zip has a CLAUDE.md and/or AGENTS.md at its
root, they're mirrored into the root of every app folder synced from that
connection (copyAgentInstructionFiles() in javaCompiler.ts) — guidance
for AI coding assistants working in the app folder, whichever agent is in
use. This is a one-way copy, not a design element: re-run on every sync/
compile/refresh (not only when the zip is freshly downloaded), so a local
edit is silently overwritten the next time round. The watcher explicitly
skips both filenames (AGENT_INSTRUCTION_FILENAMES in designSync.ts), so
neither is ever uploaded. These are the app-root copies — a CLAUDE.md that
exists as a Documentation design element on the server is a different
thing, synced to Documentation/CLAUDE.md like any other element.
The java launcher used to run ecj is located via tornado.javaHome
(a setting, empty by default), then $JAVA_HOME, then java on PATH.
Compiled bytecode's target version (--release) comes from
Documentation/devconfig.json ({ "javaVersion": "..." }).
tornado.javaRelease is only the fallback when that file is missing or
doesn't set javaVersion. This exists because the server doesn't expose the
Java version it expects via REST the way vortex-cli-mirror reads it over
SOAP; adjust the per-app file if compilation rejects the release, or if
uploaded classes fail to load on the server.
devconfig.json is a real Documentation design element, stored on the
server (ensureDevConfig() in designSync.ts), so a whole team shares
one per-app configuration instead of each checkout inventing its own:
- When the application has one, it arrives with the rest of the design like
any other element, lands in the manifest, and is never overwritten with a
local default.
- When it doesn't, the default (seeded from
tornado.javaRelease, default
"8", a conservative floor) is written locally and pushed to the
server as a new Documentation element, with the new designbucketid
recorded in the manifest.
- That push is best-effort: a server that rejects it (no permission, or an
older build) leaves the local copy in place and logs the reason to the
Tornado output channel rather than failing the sync.
- Once tracked, local edits upload like any other element. The watcher skips
it only on create, which is reached only when the push didn't succeed —
retrying that as an incidental file creation isn't the watcher's job. It's
also kept out of the design-element property editors: it's the extension's
own configuration, and renaming it would just break the lookup that reads
it.
After compiling, each resulting top-level class is matched back to its
manifest entry by class name and uploaded via the same
PUT /vortex/{appid}/design/{designbucketid} used elsewhere, with both
designdata (the compiled bytecode) and designsource (the current
.java text) refreshed in the same request, so both server-side fields
stay in sync rather than just the bytecode.
Nested/inner/anonymous classes (any .class file with $ in its name,
e.g. Outer$Inner.class) have no .java of their own to match a manifest
entry by name, so they're deployed separately as SharedCode design
elements (designtype 4): updated in place if a same-named SharedCode
element already exists, or created via POST /vortex/{appid}/design the
first time one is seen, with the new designbucketid recorded in the
manifest. Only designdata (the bytecode) is sent — there's no source to
put in designsource. On sync, these come back down as a plain .class
file (never a .java), so they're never mistaken for a real source file
and fed back into ecj.
A broken file doesn't hold back the rest of the app: because of
-proceedOnError above, CompileResult.failedSourceNames (sources that
produced no class output at all, not even a stub) is normally empty
even when other sources have real errors — hadErrors can be true
while everything still uploaded. The command and auto-compile-on-save
only surface a warning toast when failedSourceNames is actually
non-empty (a source produced nothing to upload); a hadErrors-but-fully-
uploaded run is logged to the output channel only, since under this
model that's routine, not exceptional — the whole point is that transient
errors elsewhere shouldn't interrupt saving.
Auto-compile on save: while an app is being watched, saving any
.java file under Actions/SharedCode/ScheduledActions triggers this same
compile-and-upload automatically (tornado.compileOnSave, on by
default) — but it always recompiles the whole app's Java, not just the
saved file, since these can reference each other and a single-file
compile could miss that. Debounced per app folder (400ms) so saving
several files at once (e.g. Save All) triggers one compile, and a second
save while a compile is still running is skipped rather than overlapped.
Not tied to tornado.compileAndUpload's manual invocation in any other
way — both just call the same underlying compileAndUploadFolder().
Java editor IntelliSense (javaIntellisense.ts): separate from the
javac compile classpath above — Actions/SharedCode/ScheduledActions are
loose files with no Maven/Gradle/Eclipse project behind them, so without
this, VS Code's Java language server (the redhat.java extension) has no
classpath for them and shows framework types like ActionRunner as
"cannot be resolved to a type" even though compilation works fine. Every
sync/refresh that downloads server jars also adds
tornado/.lib/**/*.jar to the workspace's java.project.referencedLibraries
setting (merged in, not overwritten, and only once — existing entries are
left alone). Requires the redhat.java extension to be installed; if it
isn't, this is skipped with a note in the "Tornado" output channel rather
than failing. The Java language server can need a reload or "Java: Clean
the Java Language Server Workspace" to pick up a newly-added classpath
entry — the extension prompts for that the first time it adds one.
Open decision: whether tornado/ should be git-ignored (a local sync
cache) or committed (the source of truth) — not yet resolved.
Development
Requires Node.js ≥ 18 (a .nvmrc pinning 20 is included — run nvm use).
npm install
npm run watch # or press F5 in VS Code to launch the Extension Development Host
npm run lint — ESLint
npm test — compiles and runs the extension test suite
npm run package — production bundle; npx @vscode/vsce package to build a .vsix
| |