32 KiB
Migrating crates/ui to gpui-base
crates/ui is a fork of an early version of gpui-component: 71 files and roughly
21.9k lines that mix behavior, presentation, and application shell. This document is
the plan for moving its behavior half onto the upstream gpui-base crate while the
application keeps the design system it has today.
The facts below were checked against gpui-base 0.6.1 (crates.io), the gpui-kit
repository at main, and this workspace's Cargo.lock (zed at 4b47ceb,
2026-09-17). Line counts come from wc -l under crates/ui/src.
Status
- Phase 0: landed. Manifest only; no Rust changed. The API drift across the three days between the snapshot and the old pin turned out to be purely additive, so nothing had to be fixed.
- Phase 1: landed. Base is wired in,
sync_baseis in place, and 1,945 lines of dead weight are gone.history.rsmoved to phase 2 once it turned out its only consumer isinput/state.rs. No dependency became unused, so the pruning step is a no-op (four dependencies were already unused before this work). - Phase 2: landed.
input/runs on base's editing engine. 6,715 lines of engine and history are gone and 306 are written, takingcrates/ui/src/inputfrom 6,573 lines to 321. Six call sites changed, all named in phase 2 below. - Phase 3: landed.
tooltip,popover,modal, andnotificationrun on base's overlay and feedback primitives. Those four modules are 1,434 lines where they were 1,592, and nothing outsidecrates/uichanged. The behavioural differences are named in phase 3 below; the largest is that base's toast stack replaces the fork's notification list. - One pre-existing, unrelated breakage was found; see A pre-existing wasm blocker.
The two facts that shape the work
GPUI still comes from upstream — addressed as the gpui-pre package. gpui-base
declares its GPUI dependency as gpui = { package = "gpui-pre", version = "0.3.1" }:
the crate in the graph is the published package gpui-pre, and gpui is only the name
used in code. That package is upstream zed's gpui (a snapshot of zed@d89e9c2,
published 2026-09-14) republished unchanged, so nothing is forked and there is no
source to align. Coop previously pinned zed's git repository at 4b47ceb
(2026-09-17), a few days ahead of that snapshot.
The two cannot be mixed. Zed's git gpui and the gpui-pre package are different
crates, so App, Window, Entity, and elements from one are not the other's types,
and a dependency graph that contains both does not compile. Zed's crates.io gpui
(0.2.2, October 2025) is also far behind the APIs coop already uses. The workspace's
gpui entry therefore has to resolve to the gpui-pre package; with the package =
alias, every use gpui::… site stays as it is.
The fork's external contract is small. Outside crates/ui, the crate is consumed
as 38 imported items plus a single ui::init(cx) call, across 13 modules, and never
deeper than ui::<module>::<Item>:
| Module | Items | Consumer files |
|---|---|---|
crate root (Icon, IconName, h_flex, v_flex, divider, Root, TitleBar, Sizable, Selectable, Disableable, StyledExt, WindowExtension, InteractiveElementExt) |
13 | 17 |
input (InputState, Input, InputEvent; plus TextareaState and Textarea after phase 2) |
3, then 5 | 10 |
button (Button, ButtonVariants) |
2 | 14 |
dock (Panel, PanelView, DockArea, DockItem, DockPlacement, PanelEvent, ClosePanel) |
7 | 10 |
notification, avatar, menu, scroll, group_box, indicator, switch, modal, tooltip |
12 | 16 |
list, checkbox, popover, resizable, skeleton, tab, divider (module), history, animation, actions |
0 references | 0 |
ui::list and ui::checkbox had no consumers at all — the message list in
crates/chat_ui uses GPUI's own list::ListState — so phase 1 deleted both. The other
modules with zero external references still serve as internal machinery for dock,
menu, modal, and input.
The consequence: this is not a rewrite of an app-facing library. Most of the work is deleting internals and re-expressing a few thousand lines of presentation over base primitives.
What must not change
crates/themestays the source of truth:ThemeColors,ThemeFamily, the registry, scrollbar mode, platform, font size and radii.- Behavior comes from
gpui-base; presentation comes fromthemeplus theuistyled layer. Every migrated component keeps readingcx.theme()and keeps its current spacing, radius, and shadow math, so the rendered result does not move. - Application code keeps importing
theme::ActiveThemeandui::*under its current names. Module paths are part of the contract; internals are not. gpui-componentis not adopted. It is a complete, styled visual language, and taking it would replace the design system rather than preserve it.
Two Theme types exist — theme::Theme and gpui_base::Theme — as separate GPUI
globals. Coop's stays the application-facing one. Base's is touched in exactly one
place: theme::sync_base(cx), called from ui::init and from Theme::change so that
it re-runs on every theme change. It is a no-op before coop's theme global exists,
which is the case when ui::init runs ahead of theme::init; Theme::change is the
hook that actually keeps the projection current.
It projects the color roles base can act on — the focus ring, the wash under selected text, scrollbars, and overlay backdrops — and nothing else:
gpui_base::ColorTokens |
coop ThemeColors |
|---|---|
background / foreground |
background / text |
surface / surface_foreground |
surface_background / text |
primary / primary_foreground |
element_background / element_foreground |
secondary / secondary_foreground |
secondary_background / secondary_foreground |
muted / muted_foreground |
ghost_element_background_alt / text_muted |
accent / accent_foreground |
ghost_element_hover / text |
destructive / destructive_foreground |
danger_background / danger_foreground |
border / input |
border |
ring |
ring |
selection |
selection |
It also sets ThemeAppearance from coop's mode, ScrollbarTheme's mode from coop's
scrollbar_mode, and TypographyTokens::sans from coop's font_family. Radius,
spacing, typography sizes, shadows, and scrollbar geometry keep their base defaults:
coop has a single radius/radius_lg/font_size where base has six-point scales, so
any mapping would be invented rather than derived. ResizableTheme needs nothing —
base's documented None fallback already resolves to border at rest and ring while
dragging, both of which are projected.
What each module becomes
LOC is the count before the work; a module whose phase has landed reads
before → after.
ui module |
LOC | Plan | gpui-base counterpart |
|---|---|---|---|
input/ (input, clear_button) |
6,573 | Replace; keep ui::input::{Input, InputEvent, InputState} as the import path. 321 lines remain, and the engine paints itself through InputEditorStyle |
InputState/TextareaState (InputBaseState in two modes) plus the InputBase frame |
list/ |
1,477 | Delete | GPUI's own list (already in use) |
checkbox.rs |
312 | Delete | Checkbox |
scroll/ (scrollbar, scrollable, scrollable_mask) |
1,332 | Replace; keep the ScrollableElement and Scrollbar names |
Scrollbar, ScrollableMask |
resizable/ |
927 | Replace; base exports the same names (h_resizable, v_resizable, resizable_panel, PANEL_MIN_SIZE, resize_handle) |
Resizable + ResizeHandleRenderer for the coop hairline |
modal.rs |
540 → 500 | Port onto base parts; Modal, ModalButtonProps, and window.open_modal unchanged. Root still owns the stack |
Dialog — focus trap, Escape/Enter/backdrop dispatch, layer priority, deferred host |
notification.rs |
584 → 663 | Port; Notification, NotificationKind, and window.push_notification unchanged |
ToastManager (storage, ids, timers, exit), ToastStack (geometry, motion), Toast (Role::Alert) |
popover.rs |
432 → 234 | Coop's builder over base's element; PopoverState is base's, re-exported |
Popover, Popup, Positioner |
tooltip.rs |
36 → 37 | Coop's view rooted at base's element | Tooltip (Role::Tooltip) |
button.rs |
626 | Skin: base behavior plus coop's existing variant tables | Button, StateStyle |
switch.rs |
287 | Skin | Switch, SwitchTrack, SwitchThumb |
avatar.rs |
141 | Skin | Avatar, AvatarImage, AvatarFallback |
history.rs |
184 | Delete. Base's input keeps its own UndoManager, and UndoHistory is a separate public utility the input never touches, so nothing has to be re-based. Its only consumer was input/state.rs |
— |
index_path.rs, element_ext.rs, event.rs, focusable.rs |
156 | Delete | IndexPath, ElementExt, InteractiveElementExt. FocusableCycle has no counterpart — base's FocusableExt is a different concept (whether a component draws a focus ring) — so it is dropped rather than re-based |
styled.rs, actions.rs, animation.rs |
305 | Keep ui::StyledExt, Size, and Sizable as the app's import. Selectable, Disableable, and Collapsible now come from gpui_base::component_traits; the local three-line h_flex/v_flex wrappers stay rather than delegating to base's identical ones |
styled, StateStyle |
icon.rs, kbd.rs, divider.rs, skeleton.rs, group_box.rs, indicator.rs |
1,023 | Keep; no base equivalent, these are the design system | — |
menu/ |
2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on Popup/Positioner |
Popup (optional) |
dock/ + tab/ |
3,356 | Keep for now; see phase 5 | base dock (different contract) |
root.rs, window_ext.rs, title_bar.rs |
965 | Keep; app shell. Root continues to host the dialog and toast layers. Its focused_input field and the two WindowExtension methods that read it are gone — the only thing that ever set them was the deleted input paint hook, and no crate consumed them |
— |
Roughly 10k lines are removed, 3k are re-expressed as thin skins, and 8k are kept.
Dependency change
The workspace manifest's GPUI entries become:
[workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.5" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" }
gpui_web = { package = "gpui-pre-web", version = "0.3.5" }
gpui_util = { package = "gpui-pre-util", version = "0.3.5" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
gpui_tokio = { path = "crates/gpui_tokio" }
gpui-base = "0.6.1"
Because of the package = alias, use gpui::… and use gpui_platform::… keep
compiling unchanged. The aliases match the ones gpui-pre uses internally, and
gpui_web moved out of web/Cargo.toml into this table with the rest.
The only alternative — leaving the workspace on zed's git gpui and redirecting
gpui-base's dependency to it — means vendoring gpui-base and owning its source.
That is a fork, and this plan deliberately avoids it.
gpui_tokio is the one crate in the family longbridge does not republish. crates/state
uses it to run browser-signer-proxy and nostr-blossom work, and the nostr client's
reqwest backend needs a Tokio reactor, so the runtime cannot be dropped for
cx.background_spawn. It is vendored verbatim from zed at 4b47ceb into
crates/gpui_tokio (Apache-2.0, ~100 lines), which is the smallest change that keeps
the existing behaviour.
gpui-base and gpui-pre move together on minor versions (0.6.x requires 0.3.x);
bump both in the same change.
Phases
Phase 0 — move gpui onto the gpui-pre package (manifest only) — landed
Point the workspace's GPUI entries at the published gpui-pre crates. There is no GPUI
source to align, patch, or vendor.
No drift had to be fixed. The gap between the snapshot (zed@d89e9c2) and the old
pin (4b47ceb) is 67 commits, but only 16 touch the GPUI crates, and the public surface
only gained names: ShapedLineCursor, MissingGlyphSink, MissingGlyph,
FallbackFontClass, MEASUREMENT_VERSION, dynamic font installation, and inspector
registration. Nothing coop used was removed or changed shape, so every use gpui::…
compiled unchanged. The three entry points coop calls —
gpui_platform::application(), gpui_platform::web_init(), and
gpui_platform::single_threaded_web() — all exist in 0.3.5.
Exit criteria: cargo check passes for desktop, and the wasm criterion is blocked by
a pre-existing bug unrelated to GPUI — see
A pre-existing wasm blocker. cargo check -p theme -p ui --target wasm32-unknown-unknown, which covers everything this migration touches,
passes. The change rewrites the dependency graph, so it stays in a pull request of its
own.
Phase 1 — Wire base, delete dead weight (no visual change) — landed
What changed:
crates/uiandcrates/themetakegpui-base.ui::initcallsgpui_base::init(cx)thentheme::sync_base(cx); thelist::init(cx)call went withlist/.ui's crate root re-exportsElementExt,IndexPath, andInteractiveElementExtfromgpui_base, so existinguse ui::{…}sites are unchanged. In particularchat_ui's.on_double_click(…)is served by base'sInteractiveElementExt, which is the same implementation as the fork's.ui::styledno longer definesSelectable,Disableable, orCollapsible; it re-exports them fromgpui_base::component_traits. All three are signature-identical to the fork's, so theimplblocks inavatar,button,input, and the rest compile untouched. The path iscomponent_traitsrather than the crate root becausegpui_base::Collapsibleis base's component of that name, not the trait.- Deleted:
checkbox.rs(312),list/(1,477),index_path.rs(69),element_ext.rs(27),event.rs(21),focusable.rs(39) — 1,945 lines, with no external consumers and a base counterpart for everything exceptFocusableCycle.
Two corrections this phase produced:
history.rsmoved to phase 2. It maps to base'sUndoHistory, notHistory: base'sHistoryis navigation (back/forward), whileUndoHistoryis the grouped undo/redo. Swapping it means editinginput/state.rs— sixignorewrites becomeset_ignoring, andChangeloses itsHistoryItemimpl — which is phase 2's file.- No dependency became unused.
ropey,sum_tree,lsp-types,tree-sitter,regex,unicode-segmentation,uuid, andinstantare all still used byinput/andhistory.rs, and the deleted files used none of the others, so pruning happens in phase 2. Separately, four dependencies —common,anyhow,itertools, andsmol— were already unreferenced anywhere incrates/ui/srcbefore this change. They are left alone here because removing them is unrelated to the migration.
Exit criteria: no diff outside crates/ui and crates/theme — met; the only files
touched are the two manifests, ui/src/lib.rs, ui/src/styled.rs, and
theme/src/lib.rs. cargo check and cargo build both pass with no warnings, and
theme and ui still compile for wasm32-unknown-unknown. The remaining part of the
acceptance — launching the app and walking the settings dialog and chat panel — has to
be done by hand and has not been run.
Phase 2 — input/ (the largest single win) — landed
crates/ui/src/input is three files and 321 lines: a rewritten 299-line input.rs, a
7-line mod.rs that re-exports base, and the untouched 15-line clear_button.rs. Deleted:
state.rs, element.rs, display_map/, rope_ext.rs, mask_pattern.rs, movement.rs,
selection.rs, indent.rs, mode.rs, change.rs, cursor.rs, blink_cursor.rs, and
history.rs — 6,715 lines.
The names the application imports are unchanged, but two of them are base's now:
| Coop before | ui::input now |
|---|---|
InputState, one struct that became multi-line through auto_grow/multi_line |
InputState = InputBaseState<InputMode> and TextareaState = InputBaseState<TextareaMode>; multi-line is a property of the state's kind |
Input, one element that rendered whatever kind of state it was given |
Input for InputState and Textarea for TextareaState — one generic element, two names |
InputEvent::{Change, PressEnter, Focus, Blur} |
identical |
history::History and HistoryItem |
gone; Change keeps no trait impl |
The styled element is a frame around base's engine rather than the engine itself. Base's
InputBaseState::render registers the key context, the focus handle, every editing
action, the text element and the editor scrollbar, so the coop element no longer carries
any of it. What is left is chrome — background, radius, font size, prefix and suffix
slots, clear button, mask toggle, loading indicator — plus three projections onto the
state:
set_editor_style(InputEditorStyle), fillingforeground,muted_foreground,selectionandcaretfromtext,text_muted,selectionandcursor. Base resolves any color left transparent from its own palette, and that palette is only a projection of coop's, so every color coop paints with is named rather than left to resolve. The remaining fields stay at base's defaults: coop configures no highlighter, no diagnostics and no gutter.set_editor_paddings(Edges<Pixels>), for multi-line only, resolved from the sameSizetable the single-line frame applies, through the window's rem size. Base puts that padding on the text element itself so the text, the gutter and the scrollbar share one inset; putting it on the frame and passing it here would double it. Passing the frame's own value is also what keeps the scrollbar where the fork drew it.set_disabledandset_text_align, replacing the fork's direct writes tostate.size,state.disabledandstate.text_align.
history.rs folded in as predicted, with one correction: it did not need re-basing at
all. Base's engine owns an UndoManager, and gpui_base::UndoHistory — the grouped
undo/redo, not the back/forward History — is a separate utility the engine never
reaches for. Removing pub mod history is safe because nothing outside crates/ui
referenced it.
The gaps named before the phase started all resolved in base's favor: clean_on_escape()
and set_loading() both exist in 0.6.1, and InputEditorStyle is the third piece.
Six call sites changed, and none of them is churn:
| Call site | Change | Why |
|---|---|---|
chat_ui composer |
InputState → TextareaState, Input::new → Textarea::new |
multi-line is the state's kind, not a layout flag |
workspace, profile bio |
the same, and .multi_line(true) is dropped |
the same; auto_grow(3, 8) is unchanged |
workspace, sidebar |
set_loading(status, cx) → set_loading(status, window, cx) |
base's signature takes the window |
workspace, sidebar |
the .loading field read → .presentation().is_loading() |
loading is private; InputPresentation is the facade for reading it |
ui::window_ext |
focused_input and has_focused_input are removed |
their only implementation was the deleted paint hook, and no crate consumed them |
ui::init |
input::init(cx) is removed |
gpui_base::init binds the same keys, and its set is a strict superset |
Three visible differences survive, all of them base's, none of them a color, radius or spacing value:
- The mask character is
•, not*. Base'sMASK_CHARis a private constant, so the fork's*cannot be restored. It shows only in masked inputs. - The caret is
0.85 × line_heightat every size. The fork scaled it bySize(0.75 at small, 1.0 at large) from asizefield base does not have. - Inputs are tab stops. Base builds the state's focus handle with
tab_stop(true); the fork's frame was not a tab stop, so Tab skipped text fields and now lands on them.
Two things came along with InputBase that the fork's plain div did not do: the frame
carries the TextInput accessibility role, and a left click anywhere in the frame —
including the padding outside the text element — focuses the input. The second has to be
restated on the frame because base handles its own mouse events on the inner element only.
Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME), the profile bio, the subject line, the settings dialog, the relay and messaging lists, the import/restore/backup dialogs, and the sidebar search field.
Phase 3 — overlays and feedback — landed
All four modules keep their names, builders, and call sites. The four files go from
1,592 lines to 1,434, and no file outside crates/ui changed.
ui module |
What stayed coop's | What is base's now |
|---|---|---|
tooltip |
the whole look, Tooltip::new(text, window, cx) and the Render view |
the element and Role::Tooltip |
popover |
every builder, the content styling, the anchor | open lifecycle, dismissal, focus capture and restore, deferred registration, trigger measurement and anchor math |
modal |
Modal, ModalButtonProps, Root's stack, window.open_modal, the card, buttons, shadows and animations |
focus trap, Escape/Enter/backdrop dispatch with a cancel veto, layer priority, the deferred host, Role::Dialog |
notification |
Notification, NotificationKind, window.push_notification, the card and the placement from theme.notification |
id-replacing storage, auto-hide and exit timers, stack geometry and motion, Role::Alert |
tooltip. The view and its new are unchanged; the styled box inside is
gpui_base::Tooltip instead of a bare div. That is what carries the role. Base's
window-level TooltipOverlay is deliberately not adopted — gpui's own .tooltip()
layer already provides the delay and the placement, and taking the overlay would mean
rewriting every .tooltip(..) call site onto Popup plus hover state.
popover. PopoverState is gpui_base::PopoverState, re-exported so
ui::popover::PopoverState still resolves, and the hand-rolled anchored/deferred
layer, resolved_corner and render_popover are gone — base's Popup measures the
trigger, resolves the anchor and snaps to the window edge. The rest of the file is the
fork's builder, unchanged, including trigger_style, which the fork already stored
without ever reading. Two bindings changed hands: popover::init (escape → coop's
Cancel in the Popover context) is deleted, because gpui_base::init binds
escape/enter/space in that same context and coop's lone escape binding would have
shadowed base's Confirm — the one that opens a popover from its trigger.
modal. Modal still assembles the card, title, close button, footer buttons,
the two shadows and the fade-in/slide-down animations; Root still owns the stack,
the focus restore, and the one-visible-overlay rule, now expressed as base's
layer(index, topmost). What changed underneath:
- Escape, Enter and the backdrop now run through base's
Dialogdecisions, soon_cancel/on_okreturningfalsevetoes all three. The fork honored the veto on the buttons and the backdrop but ignored it on Escape. - Enter on a modal that has a footer but no
on_oknow callson_closebefore closing; the fork closed silently. No caller combines the two, andon_closedefaults to a no-op. - Tab is trapped inside the modal, and the dialog surface carries
Role::Dialog. modal::init(escape/enter in theModalcontext) is deleted; base binds them in its ownDialogcontext, which theDialoghost installs whenkeyboardis on.- The dim does not move: coop's backdrop element keeps the
window_paddingsinset and theview_sizethat the fork used. Its hit area does move — base's host covers the whole viewport, so a click in the client-side-decoration shadow band now dismisses the modal instead of starting a window resize.
AlertDialog turned out to be unnecessary. Coop's alert() and confirm() select a
button set, not an ARIA role, and they already opt out of backdrop dismissal, which is
the whole of what AlertDialog adds over Dialog.
notification. Notification keeps its builder and its card. closing: bool
becomes base's ToastTransitionStatus, dismiss now emits a DismissRequest the list
turns into a ToastManager::dismiss, and the exit delay is base's 200 ms rather than
the fork's fixed 150 ms. NotificationList holds
ToastManager<NotificationId, Entity<Notification>> plus one ToastStackState; its
expanded field and hover handler are gone, and a 50 ms lifecycle tick runs only while
something is mounted. The stack is base's:
- It collapses to three layers with a 14 px peek and a 5% width step per layer, expands on hover or focus, and pauses auto-hide while expanded.
- The newest notification sits nearest the window edge; the fork's list grew downwards with the oldest first.
- Motion is
ToastMotion::default(), base's shadcn/Sonner figures. Coop contributes the width the fork's card had, the placement and the margins fromtheme.notification.
That stack is the one visible change of the phase, and it is the one to judge by hand.
If it is not wanted, the smaller step is to keep the list's own v_flex and use only
ToastManager together with Toast — base separates the lifecycle from the geometry,
so nothing else has to come back.
Surfaces to re-verify by hand: the settings dialog (its Escape and Enter paths), the
import, restore and screening modals (a modal with a textarea, and one with
keyboard(false)), the dropdown menus that ride the popover, and every
push_notification site — sending an empty message, a failed upload with its retry
action, and the device-approval notification that never auto-hides.
Phase 4 — leaf controls, scroll, and resizable (one module per pull request)
Order: avatar, switch, button, scroll/, resizable/. button is the
largest skin: the ButtonVariants and ButtonCustomVariant tables, the compact,
loading, and caret builders, and the variant names stay as they are, with styling
supplied through base's semantic-state styles. scroll/ keeps the ScrollableElement
trait name so .vertical_scrollbar(..) call sites keep compiling, and resizable/
becomes a thin re-export of base's identically named API plus a ResizeHandleRenderer
for the coop hairline.
Each of these is independently shippable. Acceptance for each: no change outside
crates/ui, and the surfaces that use the module are pixel-identical before and after.
Phase 5 — dock, tab, and menu (deliberately later)
Base has a full dock, but its contract is "layout is data, and the application
implements the renderer traits", while coop's Panel/PanelView/DockArea/DockItem
is an app-specific shell already consumed by crates/workspace and crates/chat_ui.
Moving it is a project of its own, and it would also retire tab/ and touch menu/.
Keep them local until phases 1–4 have landed, then plan dock separately. Re-basing
menu positioning and dismissal on base Popup/Positioner is optional and later still.
Verification
There is no UI test suite to lean on, so each phase gets the same treatment:
cargo check --workspaceandcargo build(default members builddesktop).cargo build --workspacecannot link the web crate's host dylib:coop_webiscrate-type = ["cdylib", "rlib"]and depends onwasm-bindgen,web-sys,console_logandtracing-wasmunconditionally, so its dylib is a wasm artifact. That is a property of the manifest rather than of any migrated crate —cargo check -p coop_webpasses, and the desktop binary links the same crates.cargo check -p theme -p ui --target wasm32-unknown-unknown. The web target cannot be checked end to end until the pre-existing blocker below is fixed, so the migrated crates are checked directly.- Launch the app and walk the surfaces the phase touched. The settings dialog is the densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu, PopupMenuItem), followed by the chat panel and the sidebar.
- For phase 4, record before/after screenshots per module.
- Keep the call-site diff at zero where the phase claims it — phases 1 and 3–4 do; if a call site has to change because base has no equivalent, list it in the pull request. Phase 2 needed six, tabulated above, and the list is the record of what "no equivalent" turned out to mean in practice.
A pre-existing wasm blocker
cargo check -p coop_web --target wasm32-unknown-unknown fails while compiling
errno 0.3.14, which refuses wasm32-unknown-unknown. The path is
coop_web → workspace → browser-signer-proxy → smol → async-io → rustix → errno, none
of which involves GPUI. crates/workspace/Cargo.toml declares browser-signer-proxy,
but nothing under crates/workspace/src references it; the crate is only used by
crates/state, where it is already gated #[cfg(not(target_arch = "wasm32"))].
Every version on that path (errno 0.3.14, rustix 1.1.5, async-io 2.6.0,
smol 2.0.2) is identical before and after phase 0, and no file on it is part of this
work, so the web build was already broken. The remedy is deleting that one stale
dependency line, but that is unrelated to the migration and is deliberately left out.
Until it is done, read the wasm exit criterion for phases 1-4 as "theme and ui
compile for wasm32-unknown-unknown".
Risks and non-goals
- Snapshot lag. The
gpui-prepackage is a republished snapshot, so it trails zedmainby however long it takes longbridge to cut the next release (a few days). A new GPUI API is therefore unavailable until then. That is the price of not maintaining a fork; the escape hatch — vendoringgpui-baseand patching it onto zed's git repository — should stay unused. - The
gpuidependency line is load-bearing. Depending on zed's gitgpuialongsidegpui-baselooks harmless and is not: it puts two GPUI crates in the graph and every window, context, and element crossing between them becomes a type error. - Two
Themeglobals. Confinegpui_base::Themetotheme::sync_baseandcrates/uiinternals; application code keeps usingtheme::ActiveTheme. Avoid importing bothThemetypes into one file. gpui_tokiois vendored, not ours.crates/gpui_tokiois zed's crate kept verbatim atcrates/gpui_tokio/src/lib.rsbecause thegpui-prefamily does not publish it and the nostr client needs a Tokio reactor. Re-sync or delete it if longbridge ever ships an equivalent.- Non-goals: adopting
gpui-component, migrating dock/tab/menu, rewriting the self-contained pieces (icon,kbd,divider,skeleton,group_box,indicator), and changing any color, radius, or spacing value.
Pull request sequence
| PR | Content | Touches outside crates/ui |
Status |
|---|---|---|---|
| 1 | Phase 0: gpui moves to the gpui-pre package, gpui_tokio vendored |
root Cargo.toml, Cargo.lock, web/Cargo.toml, new crates/gpui_tokio; crates/state needed no edit |
landed |
| 2 | Phase 1: base wiring, sync_base, deletions |
crates/theme |
landed |
| 3 | Phase 2: input, plus history.rs and the ropey/sum_tree/lsp-types/regex/unicode-segmentation/tree-sitter pruning |
crates/workspace, crates/chat_ui (six call sites); no manifest outside crates/ui |
landed |
| 4 | Phase 3: popover, modal, notification, tooltip | none | landed |
| 5–10 | Phase 4: one leaf module each | none | not started |
| later | Phase 5: dock, as its own plan | crates/workspace, crates/chat_ui |
not started |
The end state: the application keeps its design system and its call sites, crates/ui
shrinks by roughly half, and the parts that are genuinely hard — text editing,
focus and IME, drag-resize arithmetic, overlay lifecycle, accessibility semantics — are
maintained upstream instead of in a fork.