Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ These mirror the manifest lints; internalize them before writing code:
- Deck is an app, so `.expect()` on genuinely-infallible GPUI handles is fine
(see `main.rs` and `tray.rs`). Prefer `Result`/`?` everywhere else.

## Performance — keep the UI thread sacred

Deck should feel instant for the same reason Linear does: **nothing on the UI hot
path waits on I/O or the network.** A native GPUI app already gets most of "how is
Linear so fast" for free — the heap is your data store, there's no bundle to split,
no reflow, no vdom diff — so these are the few rules that still need discipline.
Full contrast and rationale: `docs/LEARNINGS.md` §17.

- **Never block the render thread on I/O.** Apply changes to in-memory state and
`cx.notify()` now; persist off the hot path — at a coarse boundary (blur/commit)
or on `cx.background_executor()`, never on a per-keystroke `InputEvent::Change`.
Use `Settings::save_best_effort()` for UI writes; `save()` returns the `io::Result`
when a write is load-bearing.
- **`cx.notify()` the smallest entity that changed.** It marks the view *and its
ancestors* dirty, so volatile state held as fields on `Shell` repaints the whole
page. Give it its own `Entity<T>` (like `name_input` and the palette), not the root.
- **Render large lists with `uniform_list` / `list`,** never a flex column of N
children (it rebuilds N elements + N layout nodes every frame).
- **Filter and search in memory** — the ⌘K palette already does (synchronous, no
I/O per keystroke). Do the same for your own pickers.
- **Prefer an in-memory `Entity` over a data store** — any new store or network
client is a new dep, which is approval-gated (Definition of Done #4).

## Design work

Ground UI changes in `README.md` and `docs/LEARNINGS.md` (real screenshots and
Expand Down
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ These mirror the manifest lints; internalize them before writing code:
- Deck is an app, so `.expect()` on genuinely-infallible GPUI handles is fine
(see `main.rs` and `tray.rs`). Prefer `Result`/`?` everywhere else.

## Performance — keep the UI thread sacred

Deck should feel instant for the same reason Linear does: **nothing on the UI hot
path waits on I/O or the network.** A native GPUI app already gets most of "how is
Linear so fast" for free — the heap is your data store, there's no bundle to split,
no reflow, no vdom diff — so these are the few rules that still need discipline.
Full contrast and rationale: `docs/LEARNINGS.md` §17.

- **Never block the render thread on I/O.** Apply changes to in-memory state and
`cx.notify()` now; persist off the hot path — at a coarse boundary (blur/commit)
or on `cx.background_executor()`, never on a per-keystroke `InputEvent::Change`.
Use `Settings::save_best_effort()` for UI writes; `save()` returns the `io::Result`
when a write is load-bearing.
- **`cx.notify()` the smallest entity that changed.** It marks the view *and its
ancestors* dirty, so volatile state held as fields on `Shell` repaints the whole
page. Give it its own `Entity<T>` (like `name_input` and the palette), not the root.
- **Render large lists with `uniform_list` / `list`,** never a flex column of N
children (it rebuilds N elements + N layout nodes every frame).
- **Filter and search in memory** — the ⌘K palette already does (synchronous, no
I/O per keystroke). Do the same for your own pickers.
- **Prefer an in-memory `Entity` over a data store** — any new store or network
client is a new dep, which is approval-gated (Definition of Done #4).

## Design work

Ground UI changes in `README.md` and `docs/LEARNINGS.md` (real screenshots and
Expand Down
60 changes: 56 additions & 4 deletions docs/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,15 @@ fn path() -> Option<PathBuf> {
.map(|d| d.config_dir().join("settings.json"))
}
// load(): read_to_string + serde_json::from_str, fall back to Default on missing/corrupt
// save(): create_dir_all + serde_json::to_string_pretty + write (best-effort)
// save() -> io::Result: create_dir_all + to_string_pretty + write; save_best_effort() logs & moves on
```

On macOS that resolves to `~/Library/Application Support/com.Example.Deck/settings.json`. Load
once at startup (so the theme reflects saved prefs before the first paint); save on every change.
The two non-obvious bits: **`#[serde(default)]`** so old config files survive you adding fields, and
**using the platform config dir** (not `~/.myapp`) so you're a good macOS citizen.
once at startup (so the theme reflects saved prefs before the first paint); persist **off the UI hot
path** — at a coarse boundary (blur/commit) or the background executor, never on a per-keystroke
`InputEvent::Change` ([§17](#performance)). The two non-obvious bits: **`#[serde(default)]`** so old
config files survive you adding fields, and **using the platform config dir** (not `~/.myapp`) so
you're a good macOS citizen.

### The spectrum of options

Expand Down Expand Up @@ -509,3 +511,53 @@ less wiring and gives full control of position and motion.
`.top()` there would be shaky.
- **Pre-select on open.** A fresh list with an empty query has nothing selected, and `↵` only fires
when something is selected — so `open_palette` calls `set_selected_index(first_row)` before focusing.

---

## 17. Performance — Linear-esque snappiness, the native way {#performance}

> *"How is [Linear so fast](https://performance.dev/how-is-linear-so-fast-a-technical-breakdown) — and
> which of it applies here?"*

Linear feels instant for **one** reason: the read/write hot path never touches the network. It reads
from an in-memory object graph, applies edits optimistically, and syncs in the background — the
[local-first thesis](https://www.inkandswitch.com/essay/local-first/) (*"there is never a need for the
user to wait for a request to a server to complete"*). Almost everything else people credit — IndexedDB
caching, code-splitting, service-worker precaching, "animate only `transform`/`opacity`," tuning away
React's vdom diff — is **web-platform tax a native binary simply doesn't pay**, and GPUI hands you the
result for free: the heap is your data store, there's no bundle to split, layout is rebuilt each frame
(no reflow to dodge), and clean views are skipped (no vdom to diff). Don't port those tricks. These are
the rules that *do* carry over:

**1. Never block the UI thread on I/O.** Apply the change to in-memory state and `cx.notify()` *now*;
persist *later*, off the hot path. This is the one spot the bare starter originally got wrong: the name
field called `Settings::save()` — a full synchronous `fs::write` of the whole struct — on **every
keystroke** (`InputEvent::Change`). It now mirrors the value into memory on change and persists on
**blur** (`shell.rs`), so the disk never sees the keystroke hot path. `Settings::save()` returns an
`io::Result` for load-bearing writes; `save_best_effort()` is the UI entry point (logs and moves on — a
lost preference must never crash or stall the UI). For a heavier write, **debounce onto the background
executor** instead:

```rust
// store `save_task: Option<Task<()>>` on the view; each keystroke drops the prior
// task (cancelling its timer), coalescing a burst into one write off the render thread:
let settings = self.settings.clone();
self.save_task = Some(cx.spawn(async move |cx: &mut gpui::AsyncApp| {
cx.background_executor().timer(Duration::from_millis(250)).await;
cx.background_executor().spawn(async move { settings.save_best_effort() }).await;
}));
```

**2. `cx.notify()` the smallest entity that owns the change.** `notify` marks the view *and all its
ancestors* dirty, so volatile state held as plain fields on `Shell` re-renders the whole page each
tick. Give it its own small `Entity<T>` (as `name_input` and the palette already are) so its churn —
and the root's — stay insulated. GPUI tracks reads per *entity*, not per field, so this is a modeling
choice, not free infrastructure.

**3. Render large collections with `uniform_list` / `list`,** never a flex column of N children — a
naive column rebuilds N elements + N Taffy layout nodes every dirty frame. The virtualized lists render
only the visible window. The ⌘K palette already uses `ListState`; reach for `uniform_list` once a fork
renders real rows.

**4. Filter and search in memory.** The palette matches its registry with a synchronous, dependency-
free fuzzy scorer — no I/O, no background thread per keystroke (§16). Do the same for your own pickers.
2 changes: 1 addition & 1 deletion src/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
//! - **A new group.** Add a `Category` variant and list it in `Category::ORDER`.
//! - **Persistent recents.** Recents are session-only by design (no disk I/O on the
//! hot path). To survive restarts: add `recents: Vec<String>` to `Settings`, load it
//! in `Shell::new`, and call `self.settings.save()` from `record_recent` — the same
//! in `Shell::new`, and call `self.settings.save_best_effort()` from `record_recent` — the same
//! pattern `settings.rs` already uses for `display_name`.

use std::ops::Range;
Expand Down
28 changes: 22 additions & 6 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,30 @@ impl Settings {
}
}

/// Write to disk (best-effort; creates the config dir if needed).
pub fn save(&self) {
let Some(path) = Self::path() else { return };
/// Write to disk, creating the config dir if needed. Returns the IO/serialize
/// error so a caller can actually handle it; UI call sites use `save_best_effort`.
///
/// Keep this OFF the UI hot path: it rewrites the whole file, cheap only because
/// this struct is tiny. Persist at a coarse boundary (blur/commit) or on the
/// background executor — never on a per-keystroke `InputEvent::Change`. The "why"
/// and the debounce option are in `docs/LEARNINGS.md` §17.
pub fn save(&self) -> std::io::Result<()> {
let Some(path) = Self::path() else {
return Ok(());
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
std::fs::create_dir_all(parent)?;
}
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&path, json);
let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
std::fs::write(&path, json)
}

/// Best-effort persist for UI call sites: a lost preference write should never
/// crash or block the UI, so we log and move on. Prefer `save` (and real error
/// handling) when the write is load-bearing — e.g. an agent fork's chat history.
pub fn save_best_effort(&self) {
if let Err(err) = self.save() {
eprintln!("deck: could not save settings: {err}");
}
}
}
8 changes: 5 additions & 3 deletions src/settings_view.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! The Settings page — rendered by `Shell` when the route is `Settings`.
//! Every control writes straight back into `self.settings` and calls `.save()`,
//! and theme changes apply live. This is the template for your own settings.
//! Every control writes straight back into `self.settings` and persists via
//! `save_best_effort()`, and theme changes apply live. These controls fire at
//! click frequency, so a synchronous best-effort write is fine here; a text
//! field would persist on blur instead (see `shell.rs`). Template for your own.

use gpui::{
div, px, rgb, AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement,
Expand Down Expand Up @@ -106,7 +108,7 @@ impl Shell {
.checked(self.settings.launch_minimized)
.on_click(cx.listener(|this, checked: &bool, _, cx| {
this.settings.launch_minimized = *checked;
this.settings.save();
this.settings.save_best_effort();
cx.notify();
}))
.into_any_element();
Expand Down
13 changes: 9 additions & 4 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,16 @@ impl Shell {
.default_value(settings.display_name.clone())
});

// Persist the text field as the user types (and on blur).
// Mirror the field into memory on every keystroke (cheap, no I/O) so the
// Welcome greeting stays live — but only touch the disk on blur, off the
// per-keystroke hot path. `save` is cheap today, but this is the rule an
// agent fork (streaming tokens through this same view) must inherit. §17.
cx.subscribe(&name_input, |this, state, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change | InputEvent::Blur) {
this.settings.display_name = state.read(cx).value().to_string();
this.settings.save();
}
if matches!(event, InputEvent::Blur) {
this.settings.save_best_effort();
}
})
.detach();
Expand Down Expand Up @@ -89,7 +94,7 @@ impl Shell {

pub fn set_accent(&mut self, accent: Accent, cx: &mut Context<Self>) {
self.settings.accent = accent;
self.settings.save();
self.settings.save_best_effort();
self.apply_theme(cx);
// Keep the menu-bar tray icon (if running) in sync with the accent.
#[cfg(feature = "tray")]
Expand All @@ -99,7 +104,7 @@ impl Shell {

pub fn set_mode(&mut self, mode: ThemeModePref, cx: &mut Context<Self>) {
self.settings.theme_mode = mode;
self.settings.save();
self.settings.save_best_effort();
self.apply_theme(cx);
cx.notify();
}
Expand Down
Loading