259 lines
16 KiB
Markdown
259 lines
16 KiB
Markdown
# 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:
|
||
|
||
```toml
|
||
[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 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 |
|
||
| 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.
|