feat: migrate from Wails/Go to native C++/WebView host#28
Open
KrisPowers wants to merge 472 commits into
Open
Conversation
Workflow runs now live in a global store so they keep streaming and finish in the background regardless of which page or pane is active. A bottom-right notification follows the user across the app, showing a spinner while a run is in progress, pass/fail on completion, a log download with downloading/downloaded states, and a dismiss button. Also loosens up the Workflows panel layout (wider sidebar, larger type, more spacing) so run output and step lists are easier to read.
Replace the single shared layoutRoot/focusedPaneId with a per-workspace layouts map keyed by top-level tab id. New tabs get their own isolated single-pane layout instead of being swept into whichever pane is focused in the currently viewed tab, so split-view tabs no longer leak terminals into each other. The global tab bar now lists top-level tabs and switches between workspaces' layouts.
…un timeline Replace the dense list+detail layout with spacious workflow cards in the sidebar, a hero header for the selected workflow, a segmented Code/Run control, and a redesigned Run view with a vertical step timeline above a single combined output log.
…cal sections Top header now shows only the workflow name, path, and a separated run control cluster (status badge + Run button). The Run button gets a sun-fade/glare animation while a run is active, and now reflects the global run state so it keeps showing "Running" across page navigation. Code/Run tabs are replaced with a vertical Code / Events Map / History nav. Events Map renders a node graph of triggers -> jobs -> outcomes parsed from the workflow YAML via a new lightweight parser. History lists past runs (pass/fail, timestamp, local) with an expandable timeline + log view. Sidebar is wider and scroll areas use a custom scrollbar.
.replace('}', '') only stripped the first match, leaving a stray
brace and unsanitized ref text rendered for stash refs containing
multiple braces.
Adds NotepadPage with notes scoped to the current working directory, registers the 'notepad' page type, and wires recentPaths/onSelectRecentPath into App so the sidebar's recent-paths menu can cd the active terminal via a terminal:cd-to event. Also drops the old quick-paths terminal banner and disables drag-to-split for the page already focused in the pane.
Merge the duplicate Sidebar import, drop unused paneModel imports (serializeLayout/deserializeLayout/SerializedNode), remove the dead duplicate handleAddSiblingTerminal callback, and drop the unused updateRatioInTree import from SplitPaneView.
Replace the raw xterm passthrough with a block-based view: each command gets a status dot (running/success/background/error), a header showing [branch], cwd, and command, and an L-shaped indent for its output. xterm now stays headless except during interactive PTY sessions, and a single always-visible input row drives typing, history, and tab completion in every alignment mode. Backend now tracks process exit codes through run_command and includes them in the bar-prompt event so the frontend can derive each block's status.
…dback
In default alignment the input row now flows inline after the last
block's output instead of being pinned to the bottom of the pane.
Branch/cwd in block headers and the input row are white, with the
branch wrapped in parentheses and bold.
Re-add ctrl+scroll font zoom (attached to the pane root instead of the
now-hidden xterm element) and drive both xterm and the block/input row
font size from it via a --term-font-size CSS variable, so default_zoom
and live zoom apply to the new UI too.
cd and clear now print a confirmation line ('Path changed to ...' /
'Cleared successfully') as block output.
Strip leading blank lines from a block's output (not just trailing), so the leading \r\n on builtin messages like 'Path changed to ...' no longer pushes the └ connector onto its own empty line. Size the status dot in em units so it scales with --term-font-size instead of staying a fixed 8px next to zoomed header text.
…a runtime is ready The input row now matches a traditional shell: while the latest block is still 'running' (which the blocking run_command() backend already enforces), the input becomes read-only with a 'Running... (Ctrl+C to interrupt)' placeholder, and only Ctrl+C is processed. A still-running block whose output matches common dev-server "ready" signals (ready in, compiled successfully, listening on, a localhost URL, etc.) now shows a steady green dot instead of the white pulse, indicating the runtime came up even though the command hasn't exited.
…ops clipping the pwd/branch
FullscreenIDE was only rendered while a pane's activePage was 'editor', so switching to the terminal (or another workspace tab) and back fully unmounted it - losing open files, explorer scroll/expand state, and cursor/scroll position. It's now rendered in an always-mounted overlay (like terminals) and just hidden/shown per pane. Also preserve per-file cursor/scroll position when switching between open files within the editor, since each file still gets its own Monaco model and was previously discarding view state on every tab switch.
… re-entry WorkflowsPanel was only rendered while a pane's activePage was 'workflows', so switching away and back fully unmounted it - refetching the workflow list and re-reading the open workflow's YAML every time, with a full skeleton flash. It's now rendered in an always-mounted overlay (like terminals/editor) and just hidden/shown per pane, so its already-fetched list/content render instantly on re-entry. On re-entry (and periodically while open), it also does a silent background re-check for new/changed workflows - no skeleton, doesn't touch an in-progress run, and shows a small spinner next to the workflow count while checking.
Adds follow-up jobs to lint.yml and audit.yml that run eslint --fix or npm audit fix on the PR's own branch when the corresponding check fails, then open a small PR with the corrections targeting that branch.
Remove unused GitFileEntry import and mark fire-and-forget git operations with void to satisfy no-floating-promises.
Sidebar pages are no longer hardcoded; an apps/ registry tracks install state in config.json and a Vite-glob loader code-splits each app package so uninstalled apps are never fetched or parsed (verified notepad now builds to its own chunk separate from the main bundle). Notepad is the first page converted to this pattern; Ports, Database, Version Control, Workflows, and Live Preview stay as core pages for now pending their own conversion. Also drops the dead ai-plugin and git (Git Insights) plugins, whose install path never worked.
🔧 Auto-fixed ESLint issues — app/frontendThe |
Both move out of app/frontend/src/components into packages/ports and packages/versioncontrol, exporting an AppManifest with a sidebar slot instead of being statically wired into App.tsx/Sidebar.tsx. They're picked up by the existing app loader/registry with no further plumbing. Pins react/react-dom/lucide-react via vite resolve.alias since app packages live outside the frontend project root and Rolldown's bare import resolution doesn't reliably walk up to find them otherwise.
Pin react/react-dom/lucide-react aliases (vite.config + tsconfig) since app packages live outside the frontend project root.
Moves out of app/frontend/src/components into packages/database. Adds focusPath to AppContext (plugin-sdk) so opening a specific .db file from elsewhere still pre-selects it; privacy mode is now self-fetched via config.get instead of prop-drilled from App.tsx.
…seeding New first screen asks what kind of user you are (Hobbyist, Student, Software Engineer, Data Engineer, Process Engineer, Network Engineer) and seeds the corresponding non-essential apps for first launch via a one-shot marker file the main app's Config::load() consumes once and deletes -- the choice itself is never stored or transmitted beyond that. Window grows from 460x330 to 640x520 to fit the new step. The version picker now surfaces latest stable and latest dev build as quick picks above a scrollable full release list; GetReleases no longer filters out prereleases at the API layer, since the channel split is now a frontend concern.
Apps can now contribute UI to other parts of the host instead of the host hardcoding knowledge of them: AppManifest.contributes.fileExplorerContextMenu lets an app add right-click items to the Code Editor's file explorer. Live Preview's hardcoded 'Open Live Preview' menu item (for .md/.html files) moves out of FileExplorer.tsx into the Live Preview app package as its first user. Live Preview's state also moves out of App.tsx into a standalone lib/livePreviewStore.ts (same pattern as workflowRunsStore), so the page can mount/unmount freely with the App Store instead of needing to stay always-mounted. A new generic 'apps:navigate' window event lets any app ask to be brought to the foreground after doing something elsewhere in the host.
…lay mechanism Workflows needed to stay mounted across page switches (its internal selected-file/scroll state isn't lifted to a store like the run-tracking state already is), so the editor-only useStickyLeafIds is generalized into useStickyLeafIdsMap, driven by any installed app whose manifest sets sidebar.sticky. App.tsx no longer has Workflows-specific overlay code; future sticky apps need zero App.tsx changes. The app self-derives gpuColors/indentGuides/zoom via config.get instead of receiving them as props, and edits route through the generic context.openFile (now pane-aware) instead of a dedicated callback.
App backends are now meant to be separate DLLs, LoadLibrary'd by the
host only when that app is installed (driven by config.json's
installed_apps), instead of being statically linked into the host
binary regardless of install state -- the native-code equivalent of
the frontend's dynamic-import code splitting.
app_plugin_abi.h defines the C ABI boundary (binder_app_init/dispatch/
alloc/free/shutdown exports, BinderHostApi callback table for
config get/set and event emission) so plugin DLLs never link
config.cpp or dispatch.cpp directly -- that would create a second,
unsynced copy of the Config singleton inside each DLL's own CRT
instance. app_plugin_loader.{hpp,cpp} is the host-side LoadLibrary/
FreeLibrary manager and dispatch fallback, wired into Dispatcher's
existing OR-chain and a new app.loadBackend/app.unloadBackend IPC pair
for live install/uninstall without a restart.
No app DLLs exist yet -- this is the infra only, landed first so the
ABI can be proven correct on one simple app before converting the rest.
Proof of concept for the native app-plugin ABI: git.cpp moves out of binder-backend-lib into its own SHARED target (cpp/apps/versioncontrol), exporting the 4 C ABI functions via a thin dllmain.cpp wrapper around the unchanged git_ops::dispatch. Verified with dumpbin that all 5 exports (init/dispatch/alloc/free/shutdown) resolve with clean, unmangled names, and that binder-host still builds and links without git.cpp -- Version Control's backend now contributes zero compiled code to the host binary unless its DLL is actually loaded. Added req_id as an explicit binder_app_dispatch parameter (some existing dispatch handlers use it as more than a response-correlation id, e.g. a temp-file uniqueness key) before converting any more apps.
webview::webview(false, nullptr) let the library create and immediately show its own default WS_OVERLAPPEDWINDOW (generic icon, default position) before our code ever got to style or centre it, then MakeFrameless hid and re-showed it without calling SetForegroundWindow. That produced the visible flash of a generic window followed by the installer landing on the taskbar without focus. Now mirrors the pattern already used by cpp/host: pre-create our own hidden, already-styled, already-centred window and hand its HWND to the webview constructor (m_owns_window=false skips webview's internal CreateWindow+ShowWindow entirely). The window stays hidden until the React app signals it has painted its first frame via a new installer.ready IPC call, at which point we ShowWindow + SetForegroundWindow.
Replace the version dropdown with an always-visible checklist (radio rows) so the version is printed up front instead of hidden behind a menu, with exactly one selectable at a time. Give every persona card a fixed width and height (was auto-height per grid row, so cards varied in size depending on blurb length) and add a lucide icon per persona for a less bare look. Swap the remaining inline Unicode glyphs (close button, dismiss button, done checkmark) for lucide-react icons for visual consistency.
First app needing the host_api config bridge: port_forward persists its rules through host_api->config_get/config_set instead of linking config.cpp directly, since linking it into the DLL would create a second, unsynced Config singleton. start_persisted()/stop_all() move from being called directly in Dispatcher's constructor/destructor to the DLL's binder_app_init/shutdown, so persisted forwards only start running if the Ports app is actually installed. ws2_32 moves off binder-backend-lib onto the ports DLL target; iphlpapi/ psapi stay on binder-backend-lib since sysinfo.cpp (core) needs them too.
Passing our own pre-created HWND to webview::webview sets its internal m_owns_window=false, which skips webview's own CoInitializeEx and DPI-awareness setup (gated behind if(m_owns_window) in win32_edge_engine's constructor). Without COM initialized on this thread, WebView2's environment/controller creation has nothing to rely on and the installer never opened. cpp/host/main.cpp already does this init explicitly before constructing webview for the same reason; port it over here too.
workflows.cpp + workflow_yaml.cpp + workflow_expr.cpp + workflow_runner.cpp + process_registry.cpp (only ever used by workflow_runner) move wholesale out of binder-backend-lib into one DLL -- no host_api bridging needed, the whole bundle was already self-contained. workflows.run/workflows.stop used to be special-cased directly in Dispatcher::dispatch_worker (not through the dispatch() OR-chain) so the runner could capture this->emit. That capture doesn't work across a DLL boundary, so those two cases move into the DLL's binder_app_dispatch, streaming run output/step/done events through host_api->emit_event instead. They now flow through the same generic dispatch path as everything else.
InstallerApp::Ready ran on the detached IPC worker thread and called ShowWindow/SetForegroundWindow directly there instead of marshalling them onto the UI thread via wv_.dispatch(), which is what the working precedent in cpp/host/dispatch.cpp's app.ready handler does. The hidden setup window was never actually being shown as a result.
preview.cpp (markdown rendering + the local static-file/preview HTTP server) moves out of binder-backend-lib into its own DLL with httplib+ cmark linked privately there instead of into the core lib -- cmark was only ever used by preview.cpp, so it drops off binder-backend-lib entirely; httplib stays since updater.cpp (core) also needs it. The terminal's /preview slash command used to call preview_ops::dispatch directly from inside Dispatcher::dispatch_worker; that call now goes through app_plugin_loader::dispatch like everything else, so /preview degrades to a silent no-op instead of a link error when Live Preview isn't installed.
The __binder_invoke handler's try/catch only wrapped std::thread construction, not the detached thread's actual lambda body. Any exception thrown inside a handler (e.g. GetReleases' GitHub network call) escaped the thread's entry function, which calls std::terminate() -> abort() and kills the whole process. Windows logs this as Application Error, exception code 0xc0000409, with no other trace. This explains the installer silently vanishing shortly after launch instead of opening. Also wrap the rest of wWinMain in a try/catch with a visible MessageBoxW so future startup failures (e.g. missing WebView2 runtime) show the user something instead of disappearing without a trace.
… DLL
db.read/db.exec were inlined directly in Dispatcher::dispatch_worker,
each spawning its own nested detached thread and resolving the IPC
seq directly via d->resolve_ok/resolve_err -- not even their own
module, let alone DLL-able. Extracted into cpp/src/database.{hpp,cpp}
as database_ops::dispatch(type, msg, id, resp), matching every other
module's convention, and made synchronous: the nested thread was
redundant since the caller (a per-request worker thread already) was
never the UI thread to begin with.
This was the last of the five apps with native backends -- Version
Control, Ports, Workflows, Live Preview, and now Database all build as
separate DLLs, LoadLibrary'd by the host only when installed, with
nothing static-linked into binder-backend-lib on their behalf anymore.
Notepad has no backend and needs none, unchanged.
installApp()/uninstallApp() now call app.loadBackend/app.unloadBackend right after config.set succeeds, so toggling an app with a native backend (Version Control, Ports, Workflows, Live Preview, Database) takes effect immediately in the running process instead of only on next launch -- the host only scans installed_apps for backends to load once, at its own startup, so without this the DLL wouldn't actually load until a restart. Verified end-to-end: closed the running dev instance, relaunched with all five apps already in installed_apps, and confirmed via the live process's module list that every one of versioncontrol.dll, ports.dll, workflows.dll, livepreview.dll, and database.dll loaded correctly.
Root cause of "the window stays hidden / nothing appears": resources.rc embeds generated/frontend.zip via RCDATA, but MSBuild's RC compile step only tracks resources.rc's own timestamp, not the zip's contents. Every prior rebuild today kept re-embedding the same stale zip (predating the installer.ready IPC call) no matter how many times the C++ logic changed, because frontend.zip itself regenerated correctly but the resource compiler never noticed and never re-embedded it. Fixed by declaring frontend.zip as an OBJECT_DEPENDS of resources.rc, and switching the dist file glob to CONFIGURE_DEPENDS so Vite's content-hashed output filenames are picked up without a manual reconfigure. Also hardened ExtractInstallerAssets' cache-key hash to cover the whole resource instead of just the first 64 bytes, since a couple of embedded files are byte-identical across builds and could collide a short prefix hash, causing the same staleness bug to resurface via the extraction cache instead of the resource compiler. Confirmed via direct Win32 IsWindowVisible() check that the window now actually becomes visible after installer.ready fires.
Full structural rebuild, not a restyle: fixed titlebar with small logo mark, a step indicator (Profile / Configure / Install) instead of no progress feedback, a single reusable select-row component shared by both the persona list and version list instead of two unrelated ad hoc widgets, a toggle switch instead of a raw checkbox, and a persistent footer action bar instead of inline buttons baked into each screen. Added a Back button on the configure step since there was previously no way to revisit the persona choice. New CSS design system built on custom properties (surface/border/text/accent scale) instead of scattered inline rgba() values repeated per component.
Drop Hobbyist entirely (PERSONAS, PERSONA_APPS, Persona type) — five roles remain. Replace the vertical select-row list for the role picker with a 2-column grid of fixed-size block cards, selection indicated by a blue border instead of a radio dot. The version picker keeps the select-row list, only the role-type screen changes.
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.
What this is
Merges the full C++/WebView migration into main. The app no longer runs on Wails and Go. It's now a from-scratch C++ host wrapped around WebView2 (and
webview/webviewfor cross-platform parity), talking to the frontend over a typed IPC layer instead of Wails bindings.This was done in two stages on top of each other:
feat/cpp-migration): got the C++ backend compiling and talking to the existing Go process over a named pipe.feat/webview-migration, merged via feat: complete Go/Wails → C++/WebView migration #26): built the real native window, wired up IPC end-to-end, ported every backend feature, rebuilt the installer in C++/WebView, and deleted the entire Go/Wails codebase. No.gofiles remain outside.git/.See #26 for the full writeup of how the migration went.
Also included
This branch carried a long tail of feature work alongside the migration: the rebrand to Binder, a CWE security scanner with a redesigned Problems UI, a SQLite-backed database panel with privacy mode, a tab/search UX overhaul, terminal quality-of-life fixes (Ctrl+click to open/cd, CWD truncation, resize guards), the events map rebuild, local TCP/UDP port forwarding, theme simplification down to dark/light, and settings consolidation.
Test plan
cmdide-host.exeopens a window with the frontend UI and the terminal works end to end.gofiles remain outside.git/