From 6c6f2b57f29a4b9152cb8b3ba59d4c1db3830ad8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 15:19:09 +0700 Subject: [PATCH] add plan --- docs/gpui-base-migration.md | 258 ++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/gpui-base-migration.md diff --git a/docs/gpui-base-migration.md b/docs/gpui-base-migration.md new file mode 100644 index 00000000..09bca188 --- /dev/null +++ b/docs/gpui-base-migration.md @@ -0,0 +1,258 @@ +# 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 | 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.