Repository navigation
fix: open a window on Windows β WebView2 had nowhere to write its data - #3606
Closed
Dale-Black wants to merge 173 commits into
Closed
Dale-Black wants to merge 173 commits into
Dale-Black wants to merge 173 commits into
Conversation
β¦f running - EvaluationOptions.on_code_change: "autorun" (default, vanilla) | "lazy" - Cell.stale flag, sent to clients in cell_results - External file edits (update_from_file) mark the changed cells + their downstream closure stale via mark_stale!, with no execution. Cell removals fall back to a reactive run so workspace variables are cleaned. - Pull-based runs: run_multiple_cells expands requested cells with their stale ancestors (expand_stale_ancestors), so nothing computes against outdated inputs. Downstream of an explicit run still runs (Pluto's reactive guarantee is unchanged). - Lazy mode implies file watching, so external tools (e.g. coding agents) can edit the notebook file and the UI shows staleness live. - Watcher self-save detection by content hash (Notebook.last_saved_file_hash) instead of a time cooldown, which could swallow external edits landing just after a save. Handles atomic temp-file+rename writes. - Frontend: stale trafficlight color (--stale-cell-color in both themes), floating 'N cells are stale (RUN)' notice reusing the undo_delete pattern, hidden in print/export. i18n keys t_stale_cells / t_run_stale_cells_link. - test/LazyMode.jl: 50 tests incl. watcher integration Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each cell records result_hash = H(output) and execution_key_produced = H(own code, sorted immediate-upstream result hashes) when it produces output. verify_stale! clears stale marks that are provably unnecessary (key matches + no stale upstream), in topological order: - reverting an edit un-stales the whole closure without running - early cutoff: an upstream re-run with identical result un-stales downstream (backdating, as in salsa/Shake) - 'always_stale' cell metadata opts impure cells (rand, time, I/O) out of verification; synced to frontend DEFAULT_CELL_METADATA Runs after every reactive run and after every lazy stale-marking. These keys also become the cache-trust mechanism for the output sidecar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
<notebook>.jl.pluto-cache.toml is written (atomically) after every reactive run in lazy mode. Per cell: execution_key + result_hash (for verification), errored, runtime, mime, a truncated plain-text representation (so external tools can read outputs by reading a file), and a MsgPack+Base64 packed copy of the full output + published_objects for exact restore. Opening a notebook in lazy mode no longer runs it. Cached outputs are restored, all cells are marked stale, and execution-key verification immediately clears every cell whose code and upstream results are unchanged β those display their old output and are flagged workspace_cold (new Cell field, in the client protocol too): display is current, but their variables don't exist in the fresh process. Cold cells are pulled like stale ancestors (expand_stale_ancestors, and bond runs under lazy now expand too), so the first interaction with a restored notebook re-runs exactly the chain it needs. The worker process is warmed in the background on open. The sidecar is a pure cache: deleting it costs only cached outputs. The notebook .jl file stays byte-compatible with vanilla Pluto. test/OutputCache.jl: 24 tests (restart survival, cold pull, offline-edit staleness on open). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦AB.md
- Every server writes a connection file ($XDG_STATE_HOME/pluto/servers/
<port>.json: pid, port, secret) on start, removed on shutdown β the
Jupyter kernel-connection-file idiom. chmod 600.
- /api/v1 HTTP endpoints (behind Pluto's existing secret auth):
GET /api/v1/notebooks open notebooks
GET /api/v1/notebook?path|id per-cell state: code, stale, cold,
errored, runtime, output text, key
POST /api/v1/notebook/run?stale=true | cells=<ids> BLOCKING run via
the normal execution path/token; stale+cold ancestors pulled in;
X-Pluto-Cells-Errored header for exit codes
POST /api/v1/notebook/interrupt
All take format=json|text β text means clients need no JSON parser.
Input via query params; minimal hand-rolled JSON writer (no new deps).
- bin/pluto-collab: pure bash+curl+sed CLI (servers / notebooks / status /
run / interrupt). Server-side realpath matching (handles /tmp symlinks).
Exit codes: 0 ok, 1 cells errored, 2 no live server.
- test/collab_acceptance.sh: 20-step end-to-end test with a real server:
lazy open β CLI run β atomic-rename agent edit β stale closure β run
closure only β error exit codes β single-cell run pulls ancestors β
restart restores outputs from sidecar (verified, cold) β JSON output.
All 20 pass.
- COLLAB.md: feature documentation + AGENTS.md stanza template for
notebook repos.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
⦠stale color - pluto-cell.stale trafficlight color no longer applies while the cell is queued/running, so the normal activity animation is visible during runs - the 'N cells are stale (RUN)' notice switches to 'Running⦠N stale cells left' while cells execute (counts down live as cells finish), and hides the RUN link during the run Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stale cell and a locally-modified cell mean the same thing to a human β 'the displayed output does not match the code; run to update' β so they now look identical: .stale is added to every code_differs style rule (input tint, trafficlight, run-button prominence, gutters) instead of having its own color. Custom --stale-cell-color theme vars removed. Cascade position matches code_differs, so running/queued/errored visuals override stale the same way they override modified cells. (The server-side stale field itself remains necessary: code_differs is a browser-local draft marker β non-transitive, invisible to the server and other clients, gone on reload. stale is its server-side, dependency- propagating, restart-surviving counterpart.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦+S flow The custom 'N cells are stale (RUN)' floating notice is GONE β Pluto already has the right affordances, so we light them up instead: - In lazy mode, typing in a cell debounce-syncs the code to the server after 1.5s of inactivity, through the exact same update_save_run!(external_trigger) path as an external file edit: code lands in the .jl file, the cell is marked stale (same yellow), nothing runs. Browser edits and agent file edits are now indistinguishable to every participant. - Pluto's existing βS/Ctrl+S badge (Preamble) now appears when any cell is stale, and set_and_run_all_changed_remote_cells runs local drafts AND stale cells. One affordance for 'changed but not run', whoever changed it. - update_notebook handler: lazy + CodeChanged routes the changed cells through the lazy stale-marking path (saves the file too). Cell deletions keep the vanilla path (run_multiple_cells([]) handles workspace cleanup). - notebook_to_js gains on_code_change so the frontend knows the mode. - RunStaleCellsButton.js, its CSS, hide-ui rule, and i18n keys removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦ runs Running a cell no longer pulls in stale (edited-but-not-run) ancestors β exactly like vanilla Pluto, where running a cell computes against the workspace values from each ancestor's last run, and other cells' pending edits apply only when those cells run (individually, or all at once with Ctrl+S / run --stale). expand_stale_ancestors now pulls ONLY workspace-cold ancestors (outputs restored from cache after a restart): those are a physical necessity β their variables don't exist in the kernel until they run once. Tests updated: LazyMode asserts a pending upstream edit does not affect a downstream run (53 tests); acceptance asserts running a cell does not pull a stale ancestor's fix (22 steps, all green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦e requested cells On a freshly restored (cold) notebook, running a top cell reactively re-runs its dependents β which may reference cold cells from OTHER branches (not ancestors of the requested cell). Those weren't being pulled, causing UndefVarError in the closure (observed live: running x re-ran summary, which referenced the still-cold verdict). expand_stale_ancestors now BFSes upstream from the entire downstream closure of the requested cells. New regression test in OutputCache.jl (31 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦hows during runs Being queued for execution consumes the pending mark (run_reactive_core! sets stale=false alongside queued=true) β matching vanilla Pluto, where submitting a draft clears its modified state the moment the run starts. Previously stale stayed set until the cell finished, so queued/running cells were dominated by the gold stale styling instead of the normal activity visuals. CSS stale rules additionally guarded with :not(.queued):not(.running) to cover the client-side waiting window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pluto.run(workspace="/path") turns the server into a hub (the future PlutoLand.jl): the root page becomes frontend/land.html β a VS-Code-like shell with a workspace file tree in the sidebar, a running-notebooks list, and notebooks open as TABS. Every tab is the stock, unmodified Pluto editor in an iframe (own websocket, state preserved across tab switches). Maximal reuse of existing machinery: - multi-notebook + one Malt worker each: native session.notebooks - open/new/move/shutdown: the existing endpoints, called from land.js - styling: Pluto theme variables (themes/*.css) only β no new colors - GET /api/v1/workspace: recursive tree (depth/entry budgeted, dotfiles + node_modules skipped, .jl files sniffed for the Pluto header to distinguish notebooks from scripts) - connection file gains "workspace" so agents know the root Closing a tab keeps the notebook running (JupyterHub semantics); shutdown is explicit. New-notebook flow: /new then /move into the workspace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦d by default - Notebooks opened from the workspace tree now open WITHOUT execution_allowed, so Pluto's standard Safe-preview intro shows (the 'run this notebook' flow, SafePreviewUI) β with cached outputs already restored by the sidecar, the preview isn't even blank. - Bubble design language for the shell (the welcome page's card idiom: rounded cards with soft shadows, pill-shaped entries and tabs), still exclusively Pluto theme variables β follows light/dark automatically. - Workspace folders are collapsed by default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦ sidebar Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drag the gap between sidebar and editor to resize (180β560px); the β¨ button in the header hides the sidebar entirely, a β° pill brings it back. Width and hidden state persist in localStorage. While dragging, iframe pointer events are disabled so the drag isn't swallowed by the editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦Code The root page is now ALWAYS the PlutoLand hub (classic welcome remains at /index.html). Without a workspace, it shows an Open Folder screen: browse the server's filesystem (GET /api/v1/browse), recent workspaces (localStorage), one click opens a folder as the workspace at runtime (POST /api/v1/workspace/open β also rewrites the connection file so agents see the new root; the server now remembers its bound port for this). Already-running notebooks (e.g. Pluto.run(notebook=β¦)) auto-open as tabs on first load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦, paste-a-path The opener rendered outside #land so the bubble/pill styles never applied (raw browser buttons β the jank). Shared classes are now global. Redesign in the welcome-page idiom: large centered bubble card, recent workspaces as a card grid, clickable monospace breadcrumbs for navigation, subfolders as a folder-pill grid, an accent-ringed 'Open X as workspace' primary button, and a paste-a-path input. All Pluto theme variables. (Considered Pluto's FilePicker.js for path entry β it's the right machinery but needs a live PlutoConnection on the page for tab-completion; deferred. PlutoUI.FilePicker is a browser upload widget β wrong tool for server paths.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
β¦cket - src/webserver/PTY.jl: vendored from Sessions.jl services/pty.jl (same author/org, Tachikoma lineage) β openpty + posix_spawnp, nonblocking reader via poll_fd, TIOCSWINSZ resize. Unix only. - src/webserver/CollabTerminal.jl: one PTY per /terminal websocket (authenticated with the normal Pluto secret). Trivial wire protocol: '0:<keys>' and '1:<rows>,<cols>' text frames in, raw PTY bytes out as binary frames β no parser on either side. Login shell starts cd'ed into the workspace folder. - WebServer.jl: /terminal upgrades route to the PTY bridge, everything else to the notebook protocol as before. - Land UI: 'β¨ Terminal' toggle pill in the tab strip (strip now always visible), bottom bubble panel with xterm.js (esm.sh) + fit addon, theme pulled from Pluto CSS vars, drag-to-resize height, open state + height persisted. Panel stays mounted when hidden so the shell lives on. - Verified headless: WS client β shell echo round-trip + cwd. β Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dock-toggle pill (β¨/β¬) next to the terminal toggle flips the panel between bottom (height-resizable) and right (width-resizable). The frames and panel share one DOM structure with a flipped flex-direction, so switching docks never remounts the editor iframes. Dock side, panel width/height, open state, and sidebar prefs all persist in localStorage (per-browser UI prefs β survives reloads and server restarts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shell now belongs to a terminal-session id (tid, stable in localStorage), not the websocket: a hard refresh detaches, the shell keeps running server-side, and the next connection replays a 200KB scrollback ring and reattaches β tmux semantics without tmux. Multiple sockets can attach to one tid (mirrored terminals). Shells end only when they exit or the server stops; the registry cleans up after exited shells. Tab strip: tabs scroll horizontally in their own sub-strip; the β¨ Terminal and dock toggles are pinned at the far right and always visible no matter how many notebooks are open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A π button in the sidebar header brings back the Open Folder screen as an overlay (with a back button). Picking a folder while notebooks are running asks for confirmation, shuts them down (files stay on disk, outputs cached in sidecars), clears the tabs, and switches the workspace at runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- frontend/img/plutoland.svg: the PlutoLand mark, derived from Pluto's β the exact three-circle totem (same fills and strokes as logo.svg), landed on a sandy island with sprouts and water shimmer, a flag planted on top. Used in the sidebar header and the workspace opener. - Sidebar header rebuilt as a flex row (logo Β· title+root Β· buttons) β the π and β¨ buttons no longer collide with the title at any width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The tab strip now lives INSIDE the editor card (slim top bar with a hairline rule): tab pills stay distinct, but visually belong to the notebook card β no more floating row + uneven top gap, and the empty state fills the card right under the bar. - Pills/toggles recolored for the card background; active tab keeps the accent ring. Terminal toggles remain pinned right of the scrolling strip. - Sidebar title ellipsizes instead of sliding under the header buttons at narrow widths (the remaining header wonk). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Plain files in the workspace tree now open as editor TABS alongside notebooks: a pane built on Pluto's bundled CodeMirror (imports/CodemirrorPlutoSetup.js) with language support by extension (jl/md/toml/css/js/html/py) and syntax colors wired to Pluto's --cm-color-* theme variables. Save button + Ctrl/Cmd+S, dirty indicator, unsaved-changes confirm on close. Backend: GET /api/v1/file (UTF-8 text, β€2MB) and POST /api/v1/file/save (atomic tmp+mv) behind the secret. Editing a notebook file this way flows through the watcher like any agent edit β cells go stale in its notebook tab. - New PlutoLand mark: minimal. Pluto's exact three-circle stack standing at a waterline, reflected in the water β nothing added, the place is implied. Replaces the busier island+flag version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mark is now NOTHING but Pluto's three circles (identical fills, strokes, radii, overlap ratio), rearranged from the floating vertical stack into a mound: two on the ground, one resting on top. No island, no flag, no water β the configuration itself is the land. All floating cards (sidebar bubbles, editor card, terminal panel) get a hairline --rule-color ring + deeper drop shadow so the notebook panel's boundary reads clearly against the page in both themes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The aside column no longer scrolls (that was clipping the bubbles' hairline rings and shadows at the edges): the workspace tree card scrolls internally as before, and the running list scrolls within a capped height. Header and footer stay fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
+ New notebook, the β° sidebar-reopen pill, and the opener's recent-workspace cards now wear the same hairline --rule-color ring + shadow as every other floating card β no more borderless-looking bubbles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hover actions in the tree (considered context menus and header-only buttons; hover actions won β direct manipulation where the eye already is, discoverable, touch-friendly, no new menu machinery): - '+' on folder rows and the Workspace header: prompt for a name β ending in .jl creates a real Pluto notebook (/new + /move, opens as a tab), anything else creates an empty file (POST /api/v1/file/new) opened in a file tab. - 'β' on file/notebook rows: confirm dialog (explicit: permanent, no trash), then POST /api/v1/file/delete β a running notebook is shut down first and its .pluto-cache.toml sidecar is removed with it. Any open tab for the deleted path closes. Agents already have this power via the filesystem; the tree poll picks up their changes β this gives humans parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
land.html now serves img/plutoland.svg (the landed dots) as its icon, with the old PNG only as a fallback for browsers without SVG favicon support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_in/delete_entry were defined above refresh while listing it in
their dependency arrays β evaluating the deps at render hit the temporal
dead zone ('Cannot access refresh before initialization'), crashing the
whole component into a blank page. The callbacks now sit below refresh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
β¦#15) Three stylesheets reference copy-outline.svg; on a cold cache all three resolves saw 'no file' and raced parallel writeFile calls onto one cache path β parcel could read a half-written (empty) asset, and the corrupt-files check failed the Bundle run (release branch would have shipped a 0-byte icon). Downloads are now deduped per cache path (in-flight promise map), written to a unique temp file and renamed into place, and an empty cached file counts as a miss. The CI check also names the offending files instead of a bare exit 1. Build-infra only: the registered package never contains frontend-dist, so no release is needed. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
β¦irely (#16) With code execution disabled β Safe Preview (waiting_for_permission, how the workspace hub opens notebooks until a human grants execution) or a dead worker (no_process) β update_save_run! silently skips running, and POST /api/v1/notebook/run counted 0 errored cells and returned "RESULT: ok (N cells ran)", exit 0. An unattended agent believed its cells executed when nothing did. The endpoint now answers 409 with the process state and what to do about it (grant execution in the browser / reopen with execution allowed / `restart` for a dead worker); both CLIs surface it as a non-zero exit. Verified live: Safe Preview run β 409 + exit 2; after granting execution β runs, exit 0. test/collab_cli.sh 13/13. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Validate remote installations before launch, fail closed on notebook sync errors, scope terminal tabs per workspace, migrate legacy PlutoSpace names, and tighten collab CLI discovery and argument handling.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: prevent duplicate terminal paste in Safari * test: ensure terminal paste avoids clipboard prompt * docs: describe clipboard fix across browsers
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: avoid duplicate terminal on refresh * test: initialize terminal refresh state before mount
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
β¦older (#24) The sidebar showed two folders and nothing else on a workspace holding a `.venv`. `_workspace_entries` walked depth-first while spending one shared 2000-entry budget, so the first directory it reached could spend all of it before any sibling was looked at β and the `break` inside a fused `for want_dir in (true, false), name in names` loop left both loops, cancelling the file pass whenever directories exhausted the budget. Which folders survived came down to `sort(readdir(...))` being ASCII. The walk is breadth-first now: every entry of a folder, directories and files, is listed before anything descends into a subfolder, so shallow entries win the budget and the workspace root is always listed in full. A folder the walk stopped short of carries a "β¦ not listed" marker rather than looking complete. Common dependency directories (`.venv`, `__pycache__`, `.tox`, β¦) join the skiplist. On top of that the sidebar loads one folder at a time. `GET /api/v1/workspace/listing?path=β¦` returns a single folder's entries; `GET /api/v1/workspace` now returns the root listing plus the git branch, with `?depth=N` still pre-walking for callers that want the whole shape at once; and the hub's 10s poll re-reads exactly the folders currently expanded. A 14k-entry workspace polls 12KB instead of 760KB and stays flat as it grows, the six-level depth cap is gone, and a normal tree produces no markers at all. Two maintainer fixes are squashed in: - The new confinement check compared `path` against `root * "/"`. On Windows β where `tamepath` (`abspath`) and the `joinpath` that builds every entry's path produce `\` β that answered "outside the workspace" for every folder below the root, so nothing could be expanded. It asks `splitpath` now: root's components must be a prefix of path's. Base then owns every separator, drive and UNC question, `/ws_other` stays outside `/ws`, and a `\` in a unix filename stays an ordinary character. - The hub's `basename` split on `"/"` alone, so on Windows the tab titles, window title, recent workspaces list and the "New file in β¦/" prompt showed a full path instead of a name. It splits on both separators now, like the basename in `components/FilePicker.js`. Pre-existing on main, not introduced by this PR. Tests: `test/WorkspaceTree.jl` is new and wired into `runtests.jl` β the regression itself, the truncation marker, budget exhaustion stopping the walk, the skiplist, the depth limit, `depth=0` reachability to arbitrary depth, and confinement including a `..` escape, a prefix-sharing sibling and both path separators. `test/workspace_tree.sh` exercises the endpoints against a real server on an oversized workspace, following the existing convention for the collab shell tests (run by hand). Co-authored-by: Dale Black <dalejamesblack@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#26) `GET /api/v1/browse` answered with bare directory names, so the hub had to join them itself, and it built the breadcrumb bar by splitting `listing.path` on "/". On Windows a path like `C:\Users\me` survives that split whole, so the bar rendered one crumb and rejoining put a `/` in front of it β clicking it browsed to `/C:\Users\me`, which does not exist. The folder pills built paths the same way and worked only because `tamepath` normalizes mixed separators on the way back in. The endpoint now returns paths that are already joined, and the browser builds none: - `entries` β each subfolder as `{name, path}`, joined with `joinpath` (replaces `dirs`) - `crumbs` β the ancestors of `path`, from `splitpath`, each as `{name, path}` The crumb separator keys off "is this the first component" rather than `name != "/"`, since the root component is `C:\` on Windows. Same reasoning as the confinement fix in #24: the server knows whether a path separates with `/` or `\` and what sits at the front of it, so it is the side that should take paths apart and put them back together. Verified against a running server as well as in unit tests: the browse response carries joined `entries` and a full `crumbs` chain, a subfolder listing resolves, and both `/etc` and a sibling sharing the workspace's name prefix are refused with 403. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
β¦ort (#28) * fix: recognize every official Pluto file extension, not just .jl Pluto Desktop recommends `.plutojl` so that only notebooks get associated with the app, and `pluto_file_extensions` lists several more (`.pluto.jl`, `.nb.jl`, `.pljl`, β¦). SpaceStation only ever looked for `.jl`, so those notebooks appeared in the workspace sidebar as plain files and opened in the text editor instead of as notebooks. Closes #27. The extension list already existed in `src/notebook/path helpers.jl` β it was simply unused. `_is_pluto_notebook_file` now calls the canonical `endswith_pluto_file_extension` rather than hardcoding `.jl`. The header on line 1 still decides: an extension only gets a file considered, so an ordinary `.jl` script stays a file. Creating a notebook had the same assumption in two places, and there the server cannot be asked β the file does not exist yet: - the hub's "new notebook" / "new file in folder" prompts turned `notes.plutojl` into `notes.plutojl.jl`, and treated a name ending in anything but `.jl` as a plain text file. - the editor's rename box offered `notes.plutojl.jl (new)` as a completion, since only `.jl` short-circuited its suggestion loop. Both now share `frontend/common/PlutoFileExtensions.js`. The hub prefers the list served at `/api/v1/config` so it follows the server it is talking to, falling back to the bundled copy; a test asserts that copy still equals the Julia list, so the two cannot drift. Tests cover every extension in the list, in both directions (header present => notebook, header absent => file). They fail on exactly the six non-`.jl` extensions without the fix. * feat: show the workspace folder name under the wordmark Working on several projects at once means several browser windows that all say "SpaceStation" and nothing else, so telling them apart meant clicking into one. The sidebar header now carries the workspace's folder name under the wordmark, with the full path as its tooltip. `basename(workspace.root)` is the same name the tab title already uses, so the window title and the header agree. A root with no basename (a drive root) falls back to the path itself. The `.workspace-root` rule this uses was already in land.css β styled and never rendered by anything. Only the markup was missing. Reported by goerz on Discourse. * feat: let the terminal's colours be switched independently of the page theme The integrated terminal was dark whatever the page theme was β light.css and dark.css shipped identical `--terminal-bg`/`--terminal-fg` values, both dark. Rather than derive the terminal's colours from the page theme, this makes them their own preference: a β/βΎ button on the terminal tab strip switches between light and dark, and the choice is remembered in localStorage next to the other hub preferences ("spacestation terminal scheme"). A light UI with a dark terminal is a common setup, so tying the two together would have replaced one wrong assumption with another. Dark keeps xterm's default ANSI palette and only sets background/foreground, so it looks exactly as it always has. Light has to name all sixteen colours β xterm's bright defaults (yellow #fce94f, cyan #34e2e2, white #eeeeec) are close to unreadable on a pale ground β and uses GitHub's light ANSI set. Switching recolours the running terminal in place: the scheme reaches the xterm instance through a ref rather than the mount effect's deps, so changing it never tears down the websocket or the live shell. The ref also covers a change arriving while the dynamic imports are still in flight. Verified in a browser: dark -> light -> reload (persisted, applied on a fresh mount) -> back to dark, with coloured shell output legible in both. Reported by goerz on Discourse; the button-and-preference shape is Dale's call. * fix: typeset math and highlight code in Safe preview outputs Safe preview rendered its outputs through `RawHTMLContainer`, which bailed out at `if (sanitize_html) return` before reaching `apply_enhanced_markup_features`. That early return is there to stop notebook-provided <script>s from running and bonds from being wired up β the whole point of Safe preview β but the markup pass is not part of that. It typesets LaTeX, highlights code blocks and adds the markdown copy button, all over the DOM DOMPurify has already cleaned. So a notebook in Safe preview showed unhighlighted code, no copy buttons, and math that had never been typeset. Math was the least reliable symptom rather than the most: MathJax is set up with `startup.typeset`, so its own one-shot pass over the document rescues whatever happens to have rendered by the time it loads. On a dev server pulling MathJax from a CDN that pass lands late and the math looks fine; where MathJax is served locally and loads fast β the offline bundle β it runs before the outputs arrive over the websocket and the TeX stays raw. Same for anything that re-renders after startup. Highlighting and copy buttons, having no such fallback, were broken every time. This matters more in SpaceStation than upstream because notebooks open lazily and their cached outputs are restored, so Safe preview is the normal way a notebook is first seen rather than a rare state. Measured before/after on a notebook with inline math, display math and a julia block: highlighted spans 0 -> 2, copy buttons 0 -> 1, mjx containers 2 -> 2. Granting execution afterwards leaves all three counts unchanged, so the markup pass is not applied twice. The new E2E case fails without this change. All four safe_preview tests pass. Reported by goerz on Discourse; the bug is upstream Pluto's. * fix: scope the auth cookie by the port the browser used, not the port we bound Opening a second workspace logged the first one out: every request it made with its cookie came back 403, and refreshing it did the same to the second. The report was an SSH remote plus any other workspace, but the cause is not SSH specific. Cookies are scoped by host + name + path β never by port β so `secret_cookie_name` put the port in the cookie's NAME to keep servers on one host apart. It used the port we bound. A tunnel is exactly the case where that is not the port the browser dialled: `ssh -L 45200:127.0.0.1:1234` means the browser says `localhost:45200` while the server believes it is on `1234`. And since every SpaceStation defaults to `port_hint = 1234`, the local hub and *every* remote node all bound 1234 and all claimed the name `pluto_secret_1234` on `localhost`. Whichever page loaded last overwrote the others' cookie; the rest 403'd until refreshed, which then evicted this one. Hence the ping-pong. The name now comes from the `Host` header β the authority the browser can actually name, which is what its cookie jar is keyed on β falling back to the bound port when there is no usable `Host` (non-browser clients, which carry no cookies). `origin_matches_host` already reads `Host` for the same reason. Reproduced with two servers both bound to port 7777 (one on 127.0.0.1, one on ::1) with a forwarder standing in for the tunnel, driven through one cookie jar: before, step 4 and step 7 were 403; after, all eight steps are 200, both cookies coexist in the jar, and a tampered cookie is still refused. * fix: give each SSH host a stable local tunnel port The browser addresses a remote workspace as `http://localhost:<local_port>/`, so that port is the workspace's identity to an open tab, a bookmark or a reload. It was handed out with `listenany(45200)` β "the first free port at this moment" β which makes it a function of arrival order rather than of the host: - reconnect a host after its tunnel died and it could come back on a different port, so every tab already open on the old one was dead for good. That is the "close the workspace tab and reopen it from homebase" ritual. - worse, a host reconnecting while another's port sat idle could inherit that port, silently pointing the other host's open tabs at the wrong machine. A host now gets the same port every time. Unseen hosts start from a hash of the name so two hosts rarely contend, and the mapping is written to `tunnel-ports.tsv` (0600, beside the connection files) so it survives a hub restart β the tunnels die with the hub but the tabs do not, and they only need the port to come back. A port promised to another host is refused even while it is free, which is exactly when a newcomer would otherwise take it over. `_stable_hash` is spelled out rather than using `Base.hash`, which is not promised to be stable across Julia versions β every host would silently move ports on upgrade. Tests cover: a host keeping its port, two hosts never sharing, a newcomer whose preferred port belongs to a disconnected host being refused it, and a corrupt map not taking the tunnel down. * feat: supervise SSH tunnels so a closed lid recovers on its own `ssh -N -L` runs as a child process and nothing watched it. With ServerAliveInterval=15 and ServerAliveCountMax=4 it gives up about a minute after the network stops answering β which is exactly what closing a laptop does. The remote server itself survives (nohup'd and disowned), so the work is still there; only the path to it is gone. `open_remote_session!` already rebuilds a dead tunnel, but only when something calls it, and the only caller was the Connect button. So waking up meant: reopen homebase, find the host, click connect, then reopen the workspace tab. A watchdog now checks every ready session every 5s β the ssh child still alive, and the local port still answering /ping β and re-runs the ordinary connect path when it is not. That path is idempotent and already handles the remote server having died too, and since the previous commit it lands on the same local port every time, so a tab left open across the gap starts working again by itself. Failures back off 5s β 120s: a node that is off for the weekend must not cost an SSH round trip every 5s. A session that comes back healthy forgets its backoff. Sessions the user explicitly disconnected are dropped from the registry, so they are never resurrected. The state goes to "tunneling" with "connection lost β reconnecting", rather than leaving a stale "ready" on screen while nothing works. Tested by standing a socket up on the tunnel port and killing it: healthy sessions are left alone, a dead one flips state and schedules a retry, and a second pass is held off by the backoff. Ran 3x to check for flakiness. * feat: wait out a lost connection instead of erroring, and reattach on startup Two halves of the same problem: a workspace whose server it cannot reach. Frontend. The workspace poll turned every failure into a permanent error banner, including the one that is not an error β a rejected fetch, meaning nothing answered at all. For a remote workspace that is just what a closed laptop looks like. It now shows a "Reconnectingβ¦" card instead, polls its own origin, and reloads as soon as anything answers. The reload is deliberate rather than only clearing the banner: each notebook iframe holds its own dead websocket, and a reload is the single action that revives all of them. A response that arrives and is bad is still reported as an error, as before. It also probes on `focus`, `visibilitychange` and `online` rather than waiting out the 10s poll, so reopening the laptop reconnects at once β coming back from sleep does not reliably fire `online` on its own. Backend. The set of hosts you are attached to is now remembered in `active-remotes.tsv` (0600) and reattached on startup, each landing on its stable port from the previous commit. Without this, a hub restart left every tunnel down until you reconnected by hand β and a hard refresh in a tab left open across a reboot hits the browser's own "site can't be reached", where none of our code runs. Restoring is in the grain of the existing design: the remote servers are deliberately left running when the hub goes away, so this only reopens the door to work that is still there. Explicit disconnects are forgotten, so they never come back; restores are capped at 8 hosts and fail quietly, with the watchdog retrying. Verified in a browser against a server behind a stand-in tunnel: killing the tunnel raises the card and no error banner, and restarting it makes the page reload itself and stay usable. * feat: hold the workspace port while the tunnel is down, so a reload is never a dead end The previous commits let a workspace tab wait out a lost connection, but only if the tab was still loaded. Reload it while the tunnel is down β a hard refresh, reopening the browser, restoring a session β and `ssh -L` no longer owned the port, nothing answered, and the browser showed its own "this site can't be reached". None of our code runs in that page, so the tab could do nothing for itself; the only way out was to reopen the workspace from homebase. While a tunnel is down the hub now takes the port over and answers 503 with a page that names the host, says the work is still running on it, and reloads once the real server is back. A hard refresh therefore lands on our page. The port is taken the moment the watchdog sees the tunnel is dead, released just before `ssh -L` binds it again, and also held when a reconnect attempt fails outright β so the window in which nothing answers is as small as we can make it. Two details that are load-bearing: - The status stays 503. `_local_ping_ok` treats only 200 as healthy, so a placeholder that looked healthy would convince the watchdog there was nothing to fix and the reconnect would never run again. - The response sets Content-Length. Left to stream chunked, Chrome aborts the navigation (net::ERR_ABORTED) and shows its error page anyway β defeating the whole point. curl accepts the chunked response, so this only reproduces in a real browser; found by driving it with puppeteer. Releasing the port before choosing it also matters: `stable_tunnel_port` would otherwise see our own placeholder as an occupied port and move the host somewhere else, losing the stability the tab depends on. Verified end to end: with the tunnel down, a hard load returns 503 and our "Reconnecting to <host>β¦" page; restoring the tunnel makes it reload itself back into the live workspace. * feat: name the culprit when a duplicate ssh_config entry shadows the live one OpenSSH uses the FIRST value it finds for each keyword across every block that matches a host. Tools that write a config entry per compute job β HPC3 Launcher, and most SLURM helpers β append a new block each time a node is reallocated, so the alias ends up defined several times and the OLDEST definition is the one in force. Connections then go through a jump host whose job finished weeks ago. The alias still resolves, so ssh does not complain; it just fails to connect, looking exactly like "your keys are wrong" or "the hop is slow". Both of the messages we showed for that sent the user to look at keys or at the network, when the answer was three lines up in a file we could have read. A failed first contact now appends what we can establish, e.g. Your ~/.ssh/config defines `hpc3-gpu-m54-01` 8 times with different ProxyJump values. SSH uses the FIRST one it finds, so this connection went through `hpc_login_53624817`, while the last block names `hpc_login_55364482`. β¦ The effective value comes from `ssh -G`, which resolves the config without connecting β far safer than re-implementing Match/Include/wildcard precedence. It stays silent unless the situation is unambiguous AND actionable: several blocks, disagreeing ProxyJumps, and ssh landing on one that is not the last written. If ssh already resolves to the newest block the duplication is harmless today, and if the effective value came from somewhere we did not read we do not understand the file well enough to advise on it. Diagnosis only. Editing somebody's ~/.ssh/config is not ours to do. Checked against a real config that had the bug: it names the right hosts, the right jump, and the right replacement, and says nothing about a host with one block. Checked against the same config after cleanup: no false positives. * perf: halve the tunnel watchdog period to 2s This poll is the only thing that notices the failure that actually happens. A sleeping laptop freezes the ssh child rather than killing it, so on wake it is alive with dead TCP and can sit for up to a minute β ServerAliveInterval 15 x ServerAliveCountMax 4 β accepting connections and resetting them instantly. Watching for the process to exit would therefore be watching for a signal that arrives long after it is useful, which is why that idea was dropped in favour of simply looking more often. Until we notice, a reload gets a browser error whichever way the tunnel died (measured: an unbound port refuses in 0.3ms, a bound one whose upstream is gone resets in 1ms β both instant, neither ours). So the period is the exposure, and halving it halves the worst case. It costs nothing. The probe is a local connect and a dead one comes back in about a millisecond, and iterations run one after another, so a slow probe delays the next tick rather than piling up.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
β¦#32) Switching between terminal tabs left the previous terminal with its bottom rows cut off and a dead scrollbar until the panel width was dragged. Root cause: inactive terminal bodies are hidden with display:none while their shells keep streaming output. xterm's Viewport keeps syncing its scroll geometry against the hidden element β offsetHeight measures 0, the browser clamps scrollTop writes to 0 and drops the element's scroll position outright. On reveal, FitAddon.fit() is a no-op when the proposed rows/cols are unchanged (always, on a plain tab switch β every tab shares the same panel), so nothing repaired the corrupt state. A width drag changed the column count, forcing a real resize whose _afterResize re-syncs the viewport β which is why it "fixed" the tab. Fix, at the single refit() choke point so it covers tab switches, the terminal-as-editor-tab pane, and the closed panel alike: record the view's position (lines above the bottom) when a terminal goes hidden, and on reveal restore it, force viewport.syncScrollArea(true), and repaint β exactly what a real resize does internally. The _core.viewport reach- through is version-safe: the xterm import is pinned to 5.5.0 and FitAddon itself uses _core the same way. Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
β¦e installers (#34) * feat: experimental Deno desktop shell (desktop/) A native desktop app for SpaceStation built on Deno Desktop (deno >= 2.9): a thin shell that boots the Julia server and shows it in a native window. - desktop/boot.ts: Julia discovery (juliaup first; SPACESTATION_JULIA override), project resolution (SPACESTATION_PROJECT -> source checkout -> managed env bootstrapped from the General registry on first run), free port pick, readiness via /ping, access secret from SpaceStation's connection file, graceful SIGTERM shutdown. - desktop/splash.ts + main.ts: boot splash served by Deno.serve (the desktop runtime wires it to the startup window), live log tail, then navigate to the secret URL; errors return the window to the splash. - desktop/smoke.ts: headless end-to-end check (no window) - passes against the real server: boot -> ready -> secret -> hub 200 -> desktop:true -> clean shutdown. - New SPACESTATION_DESKTOP flag: /api/v1/config serves desktop:true; the hub opens workspaces in-place (one webview window, no browser tabs - same mechanism as tunneled servers) while keeping the SSH sections visible. Home button returns in-place likewise. - deno.json tasks: dev (HMR), smoke, build/.dmg/.msi-dir/.AppImage targets; .app bundle build verified on macOS arm64. Experimental: not wired into releases; no icon; unsigned; webview clipboard/download quirks untested. See desktop/README.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: keep desktop-mode workspaces inside the app window Launching the built .app opened workspaces in the system browser, for two reasons: 1. The compiled app fell through to the managed environment and installed SpaceStation from the General registry β a release that predates the desktop flag entirely, so the old hub spawned child-server tabs which the webview routed to the default browser. Builds now bake buildinfo (gen_buildinfo.ts, restored after): the checkout path β used directly when it exists on the running machine β and the git url+rev, which the managed environment installs pinned (a marker file upgrades a stale env instead of trusting `import`). 2. The hub had remaining new-tab escapes in desktop mode: running- workspace cards and SSH ready pills (target="_blank" + homebase fragment) and the connect_local/connect_remote auto-open (window.open). All now navigate in place under the desktop flag, read through a ref in the polls so a mid-poll flag flip is honoured. The shell also appends ?desktop=1 to the URL so the hub knows it is inside the desktop synchronously, closing the pre-config-fetch race. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: desktop workspaces run as real child servers; harden webview msgpack reads Three issues from desktop testing: 1. Returning Home showed the just-opened workspace as not running. Root cause: desktop mode opened workspaces in-place on the main server, so nothing was registered as a running child β the launcher's live 3s poll was correct, not stale. Desktop now uses the same child-server model as the browser: connect_local spawns the workspace, the single window NAVIGATES to it (homebase fragment kept as the way back, ?desktop=1 appended), Home navigates back to the launcher in place, and the workspace stays alive on the Running Workspaces list. 2. "Failed to unpack message NotReadableError" opening a notebook: the editor websocket received binary frames as Blobs, and WKWebView's file-backed Blob reads can fail. The socket now uses binaryType = "arraybuffer" (skips the Blob I/O and a copy on every platform). 3. Native menu: Edit roles so macOS routes clipboard shortcuts into the webview, plus View > Reload (Cmd+R) and Back to Launcher (Cmd+Shift+L) as conveniences β nothing requires them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: deck β Warp-style tabs for the desktop shell The desktop window now shows the deck: a tab strip in the title-bar area (transparentTitlebar) with the Launcher pinned first and each workspace as a tab, every tab a live iframe served from the shell's /deck page. This replaces flag-delivered desktop detection, which proved fragile: a reattached child server spawned by an earlier browser session had no SPACESTATION_DESKTOP env, and ?desktop=1 didn't survive the auth redirect β so its hub ran browser paths and window.open escaped to the system browser. Hub pages now detect desktop mode STRUCTURALLY (framed by the deck: window.self !== window.top) and drive the deck over postMessage: workspaces open as tabs (deduped per server), Home focuses the Launcher tab, closing a tab leaves the workspace running. Tabs survive Cmd+R via sessionStorage; iframes stay alive across switches. Frames carry clipboard-read/write allowances for the terminal. Menu "Back to Launcher" now focuses the deck's Launcher tab (executeJs), falling back to navigating to /deck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: app icon, one-surface header, OS-standard menu only - App icon: the SpaceStation planets mark rasterized from frontend/img/spacestation.svg at 1024px -> icons/spacestation.icns (iconutil, all sizes + @2x) for macOS and the 1024 PNG for Linux, wired via desktop.app.icons. Windows .ico still TODO. - Header: empty window title + transparentTitlebar so the native bar blends into the deck - one dark surface with the traffic lights top- left and the tabs directly under. Exact Warp-style (tabs ON the traffic-light row) is not achievable yet: measured innerHeight is identical with and without transparentTitlebar, so content never extends under the bar, and frameless drops the native chrome; documented in the README to revisit as the API grows. - Removed the deck's "+" tab button (workspaces are added and removed from the Launcher tab) and the View menu items with their accelerators: no app-specific shortcuts - OS-standard roles only (Edit roles stay: macOS routes clipboard shortcuts through the menu). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: full-bleed app icon; tighter deck header The .icns was built from a letterboxed SVG render (the SVG's intrinsic 100x100 size kept qlmanage from scaling, leaving the mark in the top- left quarter of a padded canvas) β the dock showed a white square with a tiny logo. Rasterize with width/height forced to 1024 so the mark fills the frame, and rebuild all iconset sizes from it. Tabs at the exact traffic-light level remain impossible in current Deno Desktop: the webview host sets titlebarAppearsTransparent and titleVisibility but never NSWindowStyleMaskFullSizeContentView, so web content cannot extend under the 28px bar (and frameless drops the native chrome). Shrink the strip to 30px flush under it so the header stays one tight dark surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: Warp-style header via AppKit FFI; proper dark-tile app icon Tabs now sit ON the traffic-light row. Deno Desktop never sets NSWindowStyleMaskFullSizeContentView, so the shell sets it itself via libobjc FFI (macos_titlebar.ts). AppKit is main-thread-only and our JS runs elsewhere β a direct setStyleMask: SIGTRAPs β so the mutation rides a pure-ObjC trampoline: performSelectorOnMainThread with setValuesForKeysWithDictionary, KVC unboxing NSNumbers into the scalar setters on the main thread. Verified: styleMask reads back 0x800f and the WKWebView (contentView) fills the whole window frame. On success the shell opens /deck?inset=1 and the strip lays out on the title-bar row (84px for the traffic lights); otherwise the compact below-bar layout stays. Non-mac and any failure fall through cleanly. App icon rebuilt as a macOS-style tile: dark rounded square (#16141f, matching the app's surfaces) with the planets mark inlined as VECTOR into the tile SVG (referencing the PNG composited it on white) and centered at uniform scale β fixes the washed-out look and the aspect. Source SVG kept at icons/spacestation-icon.svg. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: deck tab same-site auth, tab centering, halo-free icon - Workspace tabs 403'd on every API call: the servers' auth cookie is SameSite=Strict and the child/tunnel URLs use "localhost" while the deck lives on 127.0.0.1 β different SITES, so the iframe loaded via ?secret but the cookie was withheld. open_tab now normalizes loopback hostnames to the deck's own, keeping every tab same-site (the same reason the Launcher tab always worked). - Inset tab strip is now 28px β the native title-bar height β so the pills center on the traffic lights' row. - Icon: rendered via AppKit (CoreSVG) instead of qlmanage, whose thumbnailer blended the tile edge toward white β that was the halo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: fullscreen auto-hiding tab strip; Apple-grid icon proportions - Fullscreen: the deck strip now hides with the native chrome and slides down when the cursor hits a 6px hot zone at the top (a fixed overlay β mouse moves inside the iframes never reach the deck document), mirroring the macOS menu-bar gesture. Fullscreen is detected as window == screen size, which holds because content extends under the title bar. - Icon: the tile filled 94% of the canvas; Apple's icon grid is an ~824px squircle centered in 1024, and macOS force-masks non-conforming icons β that mismatch was the "warped" dock look. Rebuilt on the official grid (824px, rx 185, 100px margins). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: detect fullscreen via the NSWindow style bit (notched Macs) The size heuristic (window == screen) never held on notched MacBooks: fullscreen there is the screen MINUS the notch band, indistinguishable from a zoomed window β so the strip never auto-hid. The shell now exposes /fullscreen, reading NSWindowStyleMaskFullScreen over the same libobjc channel (property reads are safe off the main thread), and the deck asks on every resize, keeping the exact-size check as the fallback for plain displays and other platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: poll fullscreen state instead of trusting webview resize events The NSWindow fullscreen-bit read is verified correct (probed: mask 0x400f during fullscreen, titled bit kept), but the deck only asked on webview resize events, whose timing around the fullscreen transition proved unreliable. The deck now polls /fullscreen every 1.5s (plus on resize for responsiveness) β a local GET, effectively free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: tab strip rides the native fullscreen overlay as one chrome The native overlay (menu bar + title-bar band on hover at the screen top) and our strip revealed independently β and worse, the overlay is a system window that steals the mouse, so its appearance fired our strip's mouseleave and hid the tabs exactly when the native chrome showed. The shell now measures the overlay itself: it's a borderless full-width system window stacked over ours, so its frame yields both visibility and how far it has slid down (overlay_px). /fullscreen returns that, the deck polls it (250ms while fullscreen), and the strip reveals WITH the overlay, positioned right below its bottom edge, retracting with it. The in-window hover zone stays as the secondary trigger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: identify the fullscreen overlay by class, not geometry The loose geometry match (any visible borderless full-width window) latched onto an unrelated helper window and pinned the strip revealed mid-content. Probing shows the toolbar overlay window doesn't even exist in the window list until it slides out β so require the class name (NSToolbarFullScreenWindow family, matched on "FullScreen") plus visibility and alpha. No match degrades to hover-only reveal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: keep the deck strip always visible β no fullscreen auto-hide Auto-hiding the strip in sync with the native fullscreen overlay broke more ways than it worked (notch geometry, the overlay stealing the mouse and firing our mouseleave, unrelated helper windows fooling the window-list detection). Removed entirely: the strip is fixed chrome at the top, identical windowed and fullscreen. Also removes the /fullscreen endpoint and the overlay-tracking FFI, leaving macos_titlebar.ts with just the under-titlebar extension that works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: Launch Station β Julia version picker, one-click installs, don't-ask preference The desktop shell now handles every Julia situation on launch: - Power users: a picker (styled as the SpaceStation launcher β same card/pills/headings/logo lifted from land.css and themes/dark.css) lists every installed juliaup channel with version and default badge, preselecting the saved or default channel; Enter or one click boots `julia +channel`. juliaup state is read from its juliaup.json metadata, never parsed from CLI output. - One-click installs: curated channels (release, lts, 1.12, 1.11, 1.10) not yet installed run `juliaup add` first, streaming into the splash log. - Complete newbies: no Julia at all shows a single "Install Julia" button that bootstraps juliaup via the official installer script (macOS/Linux; Windows points at julialang.org for now), then boots. - VS Code-style opt-out: "Always use this version β don't ask at launch" persists in app-data settings.json; future launches boot straight in. SpaceStation β "Julia Versionβ¦" (plain menu item, no accelerator) reopens the picker; switching stops the old server and boots the new Julia, with a restart warning and a Cancel back to the deck when a server is already running. Shell plumbing: julia.ts (settings + juliaup metadata + detection), theme.ts (shared shell-page styling + inlined planets mark β the splash now matches too), boot.ts grows idle/installing-julia phases and BootOptions {channel, add_channel, bootstrap}, splash.ts becomes the routed UI server (/launch, /api/julia, /api/julia/launch), and main.ts swaps server instances on version switch behind stable closures. Verified: julia +channel selects correctly, /api/julia payload renders all channels, headless smoke passes end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: Launch Station shows the real juliaup catalog β updates included The picker previously offered a tiny curated list; now it reflects what juliaup actually knows (`juliaup list`, tolerantly parsed and cached β the one juliaup datum without a metadata file): - Installed channels show pending updates ("1.12.6 β 1.12.7") with an "update to X" badge-button: the row alone launches what you have, the badge runs `juliaup update <channel>` first (progress on the splash). - "Get another version" offers the alias channels (release, lts, beta, rc, nightly) and the six most recent minor channels not installed, plus a free-text box with autocomplete over every concrete version (1.6+, ~100 entries) β typing "1.12.7" installs and launches exactly that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: app-wide light/dark/auto toggle (hub + editors, browser + desktop) One appearance choice β system / light / dark β now themes the whole app. Pluto's themes are @media (prefers-color-scheme) blocks, which no class can override, so common/ColorScheme.js rewrites those rules' media conditions via CSSOM (all / not all / original), remembering each rule's original identity in a WeakMap so the cycle round-trips. The choice persists in localStorage and propagates LIVE to every same-origin page via storage events: notebook editor iframes inside the hub, standalone editor tabs, and the welcome page (side-effect import in editor.js / index.js). The toggle (β / β / βΎ) sits beside the terminal controls in the workspace tab strip and in the launcher card's corner. The integrated terminal's own scheme toggle stays deliberately separate β a light UI with a dark terminal is a legitimate preference. JS-side dark decisions (CodeMirror dark flags in CellInput, FilePicker, ProjectTomlEditor, the hub file editor) now route through prefers_dark(), which honours the override before the OS preference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * ci: cross-platform desktop testing, packaging, signing, release artifacts .github/workflows/Desktop.yml: - test matrix (macOS / Windows / Linux): typecheck the shell, then the headless smoke test boots the REAL stack on each OS β Julia, the SpaceStation server, /ping readiness, the connection-file secret, the authenticated hub page, desktop config, clean shutdown. Trust comes from booting the whole thing on every platform, not from stubs. - build matrix: .app (arm64 + intel), Windows directory bundle, Linux AppImage; uploaded as CI artifacts on every run and attached to the GitHub release when one is published β so release-please releases ship desktop downloads automatically once this branch lands on main. - macOS signing + notarization run when the repo has the Developer ID secrets (documented in desktop/README.md), and skip cleanly otherwise (ad-hoc-signed bundles). Windows Authenticode is TODO. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: appearance toggle lives on the launcher only, applies to all workspaces The workspace tab strip had the app-wide toggle sitting next to the terminal's own scheme toggle β two competing controls. The app toggle now lives ONLY on the launcher card, and its choice reaches every workspace immediately despite workspaces being separate origins (their own ports, so localStorage can't carry it): the desktop deck rebroadcasts a color-scheme message to every tab (and to tabs opened later), and workspace links carry a ?scheme= seed for browser tabs. ColorScheme.js accepts both (message listener + URL seed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: bundle juliaup's portable build β installer-free Julia bootstrap The bundle now ships juliaup's PORTABLE build (a static Rust pair: juliaup + the julialauncher shim named `julia`) for its target, fetched at build time (vendor_juliaup.ts, pinned to juliaup 1.22.2, musl for Linux) and materialized into the app-data dir on first use (executables can't run from the compiled VFS). Consequences: - A machine with NO Julia needs no installer script: "Install Julia" is now `vendored juliaup add release` β Windows included (the previous Windows TODO is gone; the curl|sh script remains only as a fallback for bundles built without vendor/). - Channel selection works without any user-installed juliaup: the vendored launcher resolves +channel against ~/.julia/juliaup. - juliaup_bin() search: ~/.juliaup, brew paths, then vendored. Also fixes the Windows CI build failure: import.meta URL .pathname yields "/D:/β¦" on Windows, which git/tar reject β gen_buildinfo.ts, vendor_juliaup.ts, and boot.ts's checkout detection now use the OS-native import.meta.dirname. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: desktop's managed install also registers the spacestation CLI The managed bootstrap now runs Pkg.Apps.add alongside Pkg.add (same source spec: pinned git rev or registry), registering the real `spacestation` executable in ~/.julia/bin per Project.toml's [apps] β so desktop users get the CLI for free. Best-effort (try/catch): the app never depends on it. The reverse stays deliberately one-way: Pkg.Apps.add installs only the CLI, never the desktop shell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * feat: release installers (.dmg/.msi/.AppImage) with install guide; README desktop section Borrowing the patterns proven by MolloiLab/hpc3-launcher: - Desktop.yml now builds real installers β SpaceStation-mac-arm64.dmg, mac-x64.dmg, win-x64.msi, linux-x64.AppImage β attaches them to published releases (workflow now has contents: write), and a release-notes job appends an install guide to the release body: the per-platform download table plus the one-time unsigned-build workarounds (macOS xattr -dr com.apple.quarantine / Privacy & Security "Open Anyway"; Windows SmartScreen "More info β Run anyway"; Linux chmod +x) until signing credentials exist. - Main README gains the desktop-app section under Install & run: the download table, the same first-launch steps, the no-Julia-required pitch, and the one-way CLI note. - New deno tasks build:dmg-intel and build:msi. --compress dropped from dmg builds: it appends a self-extracting payload that fails codesign, and dmg compresses internally anyway. Verified: the arm64 .dmg builds locally (36MB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: CLI on PATH after desktop install; theme override reaches late stylesheets - Downloaded-app installs now finish the CLI job: after the server is ready, the shell idempotently adds ~/.julia/bin to the user's shells β a guarded export line in .zshrc/.bashrc/.profile (skipped per-file when anything already references .julia/bin), or the User PATH registry value on Windows via [Environment]::SetEnvironmentVariable (never setx, which truncates and flattens). Pkg.Apps installs the spacestation shim but only prints a PATH hint; now new terminals just work. - The light/dark override missed stylesheets that arrive AFTER page load β notebook cell outputs inject their own <style> tags with their own prefers-color-scheme blocks (PlutoUI's TableOfContents being the visible case: dark panel on a light page). A MutationObserver now re-applies the override (idempotent via the rule WeakMap) whenever style/link nodes enter the document, including link loads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: pin the UA color-scheme and shadow-root styles to the theme override Full inspection (puppeteer against the live editor: dark OS emulated, light override forced) found the real root cause of the washed-out logs, invisible bottom-bar labels, and dark ToC: the CSSOM media rewrite governs AUTHOR styles only β the page still declared dark support, so the UA rendered ITS side (default text color, form controls) for the dark OS: white UA text on light author backgrounds. Fix: apply() now also sets the color-scheme property on :root, which overrides the meta and pins UA defaults to the override. Also: the rewrite walks shadow roots and adopted stylesheets (one component sheet was unreachable before), and the stylesheet MutationObserver narrows added nodes via instanceof Element (fixes the checkJs errors from the previous commit). Verified end-to-end with the inspection harness: zero unreached prefers-color-scheme rules; body/logs/bottom-bar compute to black-on-white under forced light on a dark OS; screenshot eyeballed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ * fix: narrow TreeWalker nodes to Element for checkJs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
The Desktop workflow's `release: published` trigger can never fire: release-please creates releases with GITHUB_TOKEN (no PAT configured), and events created with GITHUB_TOKEN don't trigger other workflows β v0.4.0 published with no installers attached. Explicit workflow_dispatch API calls from GITHUB_TOKEN ARE allowed, so the ReleasePlease workflow now dispatches Desktop.yml with the new tag (needs actions: write), and Desktop.yml accepts a release_tag input that routes the attach + release-notes steps to that release. The input also allows manual backfills (v0.4.0 first). Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
β¦name (#37) deno desktop derives the bundle name from the -o filename, so building straight to the release asset name produced SpaceStation-mac-arm64.app inside the dmg β users dragged that into /Applications and got an app named SpaceStation-mac-arm64 in Launchpad. Same for the msi product and the AppImage. Builds now always emit the clean product name (SpaceStation.dmg / .msi / .AppImage) and CI renames the FILE afterwards for the platform-suffixed release asset, so the installed application is SpaceStation everywhere. Verified locally: dist/SpaceStation.dmg mounts to SpaceStation.app plus the Applications symlink. Also adds a hard rule to .claude/CLAUDE.md: no AI attribution (session links, Co-Authored-By, generated-with footers) in commits, PRs, comments, or release notes.
ReleasePlease dispatches the Desktop workflow at the same moment the release commit lands on main, so the dispatched run and the ordinary push run shared the concurrency group desktop-<ref> and cancelled each other β which is how v0.4.0's installer backfill died. The group now includes the release tag, so release builds never race CI builds.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
β¦#40) The first-launch commands were inline code inside a table cell, so the xattr quarantine command had no copy button and wrapped badly. The release-notes job now emits a proper install section: a plain download table, then per-OS steps with fenced code blocks (which GitHub renders with a copy button), plus a collapsible no-Terminal alternative for macOS. The job also REPLACES a previously appended guide instead of skipping when one exists, so wording fixes reach releases that already have one. README updated to match.
Clicking "update to X" changed nothing visible β it set internal state and left the button reading "Launch", so there was no way to tell whether it had registered or what pressing Launch would now do. Selection now runs through one function that owns the row highlight, the badge's active state and the button's label together, so they can never disagree: picking an update fills the badge and the button reads "Update & Launch"; picking an install reads "Install & Launch"; clicking a plain row clears both back to "Launch". The badge also carries a tooltip naming the exact juliaup command. The underlying action was already correct (juliaup update <channel>, streamed to the splash) β only the feedback was missing. Verified headlessly: label Launch -> Update & Launch on badge click, exactly one active badge, and state clears when another row is picked.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#43) A downloaded build reports "SpaceStation is damaged and can't be opened" β macOS's wording for any app not signed with a paid Developer ID. Clearing the download quarantine alone was not enough on Apple Silicon; the app also has to be re-signed locally (ad-hoc) before it will launch, which is what actually clears the dialog. The macOS steps in the release-notes guide and the README now give both commands, explain that nothing is really damaged, say not to run the app from the mounted disk image, and note that both steps disappear once releases are notarized. Dropped the right-click/Open Anyway fallback: it does not work for the "damaged" case, so offering it just wastes a user's time.
The Windows app started and then did nothing: no window, no taskbar entry, and a process that never exited, so every click left another one behind (issue #55). WebView2 defaults its user-data folder to `<exe>.WebView2`, beside the binary. The MSI installs into %ProgramFiles%\SpaceStation and deno desktop hard-codes ProgramFiles64Folder with no per-user option, so on a standard account that path is read-only: creating the WebView2 environment failed, the window was never created, and nothing treated that as fatal. Microsoft documents the case β an unpackaged app in a protected install directory must name its own user-data folder. desktop/webview2.ts now points WEBVIEW2_USER_DATA_FOLDER at %LOCALAPPDATA%\SpaceStation\WebView2 before the first window exists. That reaches the loader because our code runs inside the webview host process: the app executable IS the laufey_webview host, which loads the Deno runtime and only then runs main.ts, while the WebView2 environment is created later on the first BrowserWindow. Also, so this cannot recur silently: - main.ts gets a liveness watchdog. The runtime navigates the startup window to the shell's own server, so a healthy webview fetches a page within seconds; if nothing ever arrives the shell now says why and exits non-zero instead of idling forever behind Deno.serve, which is what produced the pile of invisible processes. - window_smoke.ts is a headed companion to smoke.ts: it opens a real window and makes the webview prove it rendered by fetching a beacon. The window layer had never once been executed in CI, which is why this shipped. - Desktop.yml runs that headed smoke on macOS and Windows, and now installs the .msi it just built and launches it. Because GitHub's Windows runners are administrators with UAC disabled, Program Files is writable there and the bug would not reproduce on its own, so the step revokes its own write access to the install directory first and then asserts the profile landed under LOCALAPPDATA and not beside the binary. Two smaller Windows bugs found along the way: - home_dir() preferred HOME over USERPROFILE. Git Bash/MSYS set HOME to somewhere Julia's homedir() never looks, and the two must agree β the server writes its connection file under homedir() and boot.ts reads the access secret back out of it. - juliaup_info() split JULIA_DEPOT_PATH on ":", which severs the drive letter on Windows and turned "C:\Users\me\.julia" into "C". build:win passes --icon because deno decodes desktop.app.icons.macos (.icns) even when targeting Windows and fails the build; that task was broken. The .ico is added and declared, though deno 2.9.5 embeds no Windows icon yet.
Author
|
Opened against the wrong repository by mistake β this targets the GroupTherapyOrg/SpaceStation.jl fork, not Pluto.jl. Closing; sorry for the noise. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #55.
What users saw
The Windows app started and did nothing: no window, no taskbar entry, and a process that never exited β so every click left another one behind. The reporter ended up with five
SpaceStation.exeprocesses and, on the first launch after a reboot, one native dialog:Why
WebView2 defaults its user-data folder to
<exe>.WebView2β beside the binary. Our MSI installs into%ProgramFiles%\SpaceStation, anddeno desktophard-codesProgramFiles64Folderwith no per-user install option, so on a standard account that path is read-only. Creating the WebView2 environment failed, the window was never created, and nothing in the shell treated that as fatal. Microsoft documents exactly this case: an unpackaged app in a protected install directory must name its own user-data folder.The fix
desktop/webview2.tspointsWEBVIEW2_USER_DATA_FOLDERat%LOCALAPPDATA%\SpaceStation\WebView2before the first window exists. That reaches the loader because our code runs inside the webview host process β the app executable IS thelaufey_webviewhost, which loads the Deno runtime and only then runsmain.ts, while the WebView2 environment is created later on the firstBrowserWindow. Verified by finding theWEBVIEW2_USER_DATA_FOLDERliteral andGetEnvironmentVariableWin the shippedlaufey_webview.exe, and by tracing the startup order on a real bundle.Why CI never caught it
desktop/smoke.tsis headless by design β "everything except the window". The window layer had never once been executed in CI, on any OS. The.msiwas built, uploaded and attached to releases without anything ever installing or running it (msiexecappeared nowhere in the repo).So this PR also adds:
desktop/window_smoke.tsβ a headed companion that opens a real window and makes the webview prove it rendered by fetching a beacon URL. Runs on macOS and Windows in thetestjob. (Linux is deliberately excluded: the runner has no display or WebKitGTK stack, and a check that is allowed to fail is how this stayed invisible.).msiit just built to the real Program Files location and launches it. GitHub's Windows runners are administrators with UAC disabled, so Program Files is writable there and Reload browser after pluto restart is confusingΒ #55 would not reproduce on its own; a naive test would go green against the very bug it exists to catch. The step therefore revokes its own write access to the install directory first, then asserts the profile landed underLOCALAPPDATAand that nothing was written beside the binary.main.tsβ the runtime navigates the startup window to the shell's own server, so a healthy webview fetches a page within seconds. If nothing ever arrives, the shell now says why and exits non-zero instead of idling forever behindDeno.serve. That is what produced the pile of invisible processes.Two smaller Windows bugs found along the way
home_dir()preferredHOMEoverUSERPROFILE. Git Bash/MSYS setHOMEto somewhere Julia'shomedir()never looks, and the two must agree β the server writes its connection file underhomedir()andboot.tsreads the access secret back out of it.juliaup_info()splitJULIA_DEPOT_PATHon":", which severs the drive letter on Windows and turnedC:\Users\me\.juliaintoC.build:winnow passes--icon:deno desktopdecodesdesktop.app.icons.macos(.icns) even when targeting Windows and fails the build, so that task was broken. A.icois added and declared, though deno 2.9.5 embeds no Windows icon yet.Known, not fixed here
Every build declares the same installer identity (
ProductVersion1.0.0, fixedProductCode, noUpgradetable), so Windows can skip the upgrade and leave the old app in place. The release notes now tell Windows users to uninstall first; stamping real per-release identity is follow-up work.Try this Pull Request!
Open Julia and type: