Files
coop/docs/gpui-base-migration.md
T
2026-09-17 15:19:09 +07:00

16 KiB
Raw Blame History

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/theme stays the source of truth: ThemeColors, ThemeFamily, the registry, scrollbar mode, platform, font size and radii.
  • Behavior comes from gpui-base; presentation comes from theme plus the ui styled layer. Every migrated component keeps reading cx.theme() and keeps its current spacing, radius, and shadow math, so the rendered result does not move.
  • Application code keeps importing theme::ActiveTheme and ui::* under its current names. Module paths are part of the contract; internals are not.
  • gpui-component is 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 14 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 at the workspace root, plus cargo check -p coop_web --target wasm32-unknown-unknown for 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_loading is the known candidate), list it in the pull request.

Risks and non-goals

  • Snapshot lag. The gpui-pre package is a republished snapshot, so it trails zed main by 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 — vendoring gpui-base and patching it onto zed's git repository — should stay unused.
  • The gpui dependency line is load-bearing. Depending on zed's git gpui alongside gpui-base looks 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 Theme globals. Confine gpui_base::Theme to theme::sync_base and crates/ui internals; application code keeps using theme::ActiveTheme. Avoid importing both Theme types into one file.
  • gpui_tokio has 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
510 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.