16 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.
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)
republished unchanged, so nothing is forked and there is no source to align. Coop
currently pins zed's git repository at 4b47ceb (2026-09-17), roughly four 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) |
3 | 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 have no consumers at all; the message list in
crates/chat_ui uses GPUI's own list::ListState. The 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 will 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: a theme::sync_base(cx) that projects coop's colors into
gpui_base::Theme::global_mut(cx).tokens (SemanticThemeTokens: colors, radius,
typography, shadow) plus ThemeAppearance, ScrollbarTheme, and ResizableTheme. It
runs from ui::init and on every theme change. This is needed because base paints a
few things itself — the focus ring from FocusableExt, text selection under glyphs,
scrollbars, resize handles, and the dialog backdrop — and those should follow coop's
palette rather than base's default.
What each module becomes
ui module |
LOC | Plan | gpui-base counterpart |
|---|---|---|---|
input/ (state, element, display_map, rope_ext, mask_pattern, movement, selection, indent, mode, change, cursor, blink_cursor, clear_button) |
6,929 | Replace; keep ui::input::{Input, InputEvent, InputState} as the import path |
Input/InputState, Textarea/TextareaState, Editor |
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 | Port onto base parts; keep Modal, ModalButtonProps, and window.open_modal |
Dialog, AlertDialog |
notification.rs |
584 | Port; keep Notification, NotificationKind, and window.push_notification |
Toast, ToastManager, ToastStack |
popover.rs |
432 | Replace with a coop-styled wrapper | Popover, Popup, Positioner |
tooltip.rs |
36 | Replace with a coop-styled wrapper | 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, index_path.rs, element_ext.rs, event.rs, focusable.rs |
340 | Delete | History/UndoHistory, IndexPath, ElementExt, InteractiveElementExt, FocusableExt, FocusTrapElement |
styled.rs, actions.rs, animation.rs |
305 | Keep ui::StyledExt, Size, and Sizable as the app's import; base's h_flex/v_flex helpers are identical (flex_row + items_center) and can be delegated to |
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 and focused_input |
— |
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" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
gpui-base = "0.6.1"
Because of the package = alias, use gpui::… and use gpui_platform::… keep
compiling unchanged. gpui_web moves from web/Cargo.toml into the workspace 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 missing piece: longbridge does not republish it, and
crates/state uses it in three places (init, spawn, spawn_result). Either vendor
zed's small crate into the workspace, or drop it for cx.background_spawn. Decide in
phase 0.
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)
Point the workspace's GPUI entries at the published gpui-pre crates and fix whatever
the four days of API drift between 4b47ceb and zed@d89e9c2 broke. There is no GPUI
source to align, patch, or vendor. Confirm that the entry points coop calls still exist
in 0.3.5: gpui_platform::application(), web_init(), and single_threaded_web().
Exit criteria: cargo check passes for desktop and for
cargo check -p coop_web --target wasm32-unknown-unknown, and the drift fixes are
listed in the pull request. 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)
Add gpui-base, make ui::init call gpui_base::init(cx) followed by
theme::sync_base(cx), and re-export the base utilities the app already imports under
their current names (ElementExt, InteractiveElementExt, IndexPath, History,
Disableable, Selectable). Delete checkbox.rs and list/, which have no
consumers, along with history.rs, index_path.rs, element_ext.rs, and event.rs
once base supplies them. Drop the dependencies this leaves unused.
Exit criteria: no diff outside crates/ui and crates/theme, the app launches, and
switching the theme still restyles everything.
Phase 2 — input/ (the largest single win, ~6.9k lines)
The mapping is close to 1:1 with what the app actually uses:
| Coop today | gpui-base |
|---|---|
InputState::new(window, cx).placeholder(..) |
same |
.auto_grow(1, 20) (chat composer) |
TextareaState::auto_grow(2, 8) with Textarea |
.masked(true) (nsec, password, key) |
InputState::masked(true), unmask_value() |
.set_value(value, window, cx) |
set_value(value, window, cx) |
InputEvent::{Change, PressEnter, Focus, Blur} |
identical variants |
Input::new(&state).appearance(false) |
coop's Input keeps these chrome options |
Known gaps to reconcile here, verified against the 0.6.1 source before starting:
clean_on_escape(), set_loading() (called from crates/workspace/src/sidebar/mod.rs),
and the InputEditorStyle hook that has to be filled from coop tokens. Everything else
in input/ — display_map, rope_ext, mask_pattern, movement, selection,
indent, mode, element — is deleted. Afterwards, ropey, sum_tree,
lsp-types, tree-sitter, regex, unicode-segmentation, and uuid can probably
leave crates/ui's manifest.
Surfaces to re-verify: the chat composer (auto-grow, Enter to send, IME), the subject line, the settings dialog, profile, relay and messaging lists, the import/restore/backup dialogs, and sidebar search.
Phase 3 — overlays and feedback
popover becomes a wrapper over base Popover; modal composes base Dialog and
AlertDialog while keeping the Modal API and window.open_modal; notification
moves onto Toast/ToastManager (base owns the stack, timers, and motion; coop owns
the visual and the placement from theme.notification); tooltip becomes a wrapper
over base Tooltip. Root and window_ext keep their public API and host the new
layers. No call site changes.
Phase 4 — leaf controls, scroll, and resizable (one module per pull request)
Order: tooltip, 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 checkat the workspace root, pluscargo check -p coop_web --target wasm32-unknown-unknownfor the web target.- 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 for phases 1, 3, and 4; if a call site has to change
because base has no equivalent (
set_loadingis the known candidate), list it in the pull request.
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_tokiohas to be vendored or dropped (phase 0).- 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 |
|---|---|---|
| 1 | Phase 0: move gpui to the gpui-pre package, fix drift |
Cargo.toml, possibly crates/state |
| 2 | Phase 1: base wiring, sync_base, deletions |
none |
| 3 | Phase 2: input | none, or the named gaps above |
| 4 | Phase 3: popover, modal, notification, tooltip | none |
| 5–10 | Phase 4: one leaf module each | none |
| later | Phase 5: dock, as its own plan | crates/workspace, crates/chat_ui |
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.