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
21 changes: 10 additions & 11 deletions .claude/skills/add-persisted-setting/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: add-persisted-setting
description: Add or change a persisted setting in qrate — a user-wide (AppSettings) or per-project (.qrate file) preference, optionally with a toggle/field in the Settings window. Use when asked to "add a setting", "persist X", "save X to the project", "save X app-wide", "make X configurable", "add a settings toggle", or wire a value into global/project persistence. Covers the scope switcher, the read/write API, and the effective-value resolver.
description: Add or change a persisted setting in qrate — a user-wide (AppSettings) or per-project (.qrate file) preference, optionally with a toggle/field in the Settings window. Use when asked to "add a setting", "persist X", "save X to the project", "save X app-wide", "make X configurable", "add a settings toggle", or wire a value into global/project persistence. Covers how Settings-window edits land, the read/write API, and the effective-value resolver.
---

# Add a persisted setting (global or project scope)
Expand All @@ -17,7 +17,7 @@ Both stores hold `HashMap<String, Val>` where `Val = Text(SharedString) | Bool(b
## The 4 pieces of any setting

1. **A key constant** — `pub const FOO_KEY: &str = "foo";`. Put it in the crate that *consumes* the value, not in `settings` (e.g. `table::TABLE_STRIPES_KEY` in `crates/table/src/lib.rs`). The Settings-window page references the same constant.
2. **A UI field** (optional) — a `Setting::{Switch,Text,Dropdown}` entry in `crates/app/src/app_settings/mod.rs::build_pages()`. This automatically routes to whichever scope the Settings window's User/Project tab has active — you write it **once**, it works in both scopes. `FilePicker`/`DirPicker` are user-only by design (a machine path isn't project data).
2. **A UI field** (optional) — a `Setting::{Switch,Text,Dropdown}` entry in `crates/app/src/app_settings/mod.rs::build_pages()`. It shows the effective value (`effective_*`) and writes user-wide (`set_user_*`). `FilePicker`/`DirPicker` are user-only too (a machine path isn't project data).
3. **A consumer read** — call `settings::effective_bool(FOO_KEY, cx)` (or read the store directly) where the value is used. `effective_bool` = **project value wins, else user default, else false**.
4. **A repaint trigger** — the consuming view must `cx.observe_global::<settings::AppSettings>(..)` (user-scope changes) and, if it reads project values, `cx.observe_global::<settings::project::CurrentProject>(..)` (project-scope changes; writing a project setting mutates that global, which auto-notifies). See `crates/table/src/panel.rs`.

Expand All @@ -33,13 +33,13 @@ Both stores hold `HashMap<String, Val>` where `Val = Text(SharedString) | Bool(b
- Low-level (rarely needed directly): `project::read_setting(path, key)` (opens a fresh RO connection — **never call per-render**), `project::queue_write(path, key, val, cx)`

**Scope-agnostic resolver** for consumers:
- `settings::effective_bool(key, cx)` — project-overrides-user fallback. This is what a feature should read; it ignores the Settings-window's active tab.
- `settings::effective_bool(key, cx)` — project-overrides-user fallback. This is what a feature should read.

## The scope switcher (already built)
## How Settings-window edits land (already built)

`crates/settings/src/lib.rs` owns `SettingsScope { User, Project }` + the `CurrentSettingsScope` global, and `SettingsWindow::render` shows the small text tabs (Zed-style: `foreground`+semibold when selected, `muted_foreground` otherwise) fixed at the top of the content section. The Project tab is disabled when no project is open. `Setting::Switch`/`Text`/`Dropdown` go through `scoped_bool`/`scoped_text` helpers that read/write the active scope. **You do not touch any of this to add a normal setting** — it's automatic. Only touch it if you're adding a *new field kind* or changing scoping rules.
There is no User/Project switcher. `Setting::Switch`/`Text`/`Dropdown` read through `effective_bool`/`effective_text` and write through `settings::set_user_bool`/`set_user_text`, which also **clear the open project's own value for that key** so the edit takes effect at once. A project that already has an override (from before the switcher was removed) shows it in the row description, and the row's Reset clears it. New project-scoped values are written by code (`CurrentProject::set_*`), not by the Settings window. **You do not touch any of this to add a normal setting.**

## Worked example — a bool, both scopes, with a UI toggle
## Worked example — a bool with a UI toggle

This is exactly what "table row stripes" did (see `git log` for the commit). To add `my_flag`:

Expand All @@ -66,11 +66,11 @@ let _settings_sub =
// store `_settings_sub: Subscription` on the struct so it stays alive
```

That's the whole feature. No new persistence plumbing — `set_scoped_bool` and the debounced writers already move the bytes.
That's the whole feature. No new persistence plumbing — `set_user_bool` and the debounced writers already move the bytes.

## Adding a NEW field kind or a typed field (deeper)

- **A new `Setting` variant** (e.g. a slider): add the enum arm in `lib.rs`, then an arm in `impl From<Setting> for SettingItem` that builds a `SettingField::<kind>(getter, setter)`. Route getter/setter through the `scoped_*` helpers (not `AppSettings::*` directly) so it respects the User/Project tab. `SettingField` closures run at render/click time, so they can consult the scope global then.
- **A new `Setting` variant** (e.g. a slider): add the enum arm in `lib.rs`, then an arm in `impl From<Setting> for SettingItem` that builds a `SettingField::<kind>(getter, setter)`. Read through `effective_*` and write through `set_user_*` (not `AppSettings::*` directly), so a legacy project override is cleared rather than silently winning.
- **A typed value that isn't a bare `Val`** (like `MainWindowBounds`): serialize to JSON and store as a `Val::Text`. It rides the existing `values` blob for user scope, or a `__settings` row for project scope — **no schema change** to `db.rs`/`project.rs`. See `SETTINGS_WINDOW_BOUNDS_KEY` (user scope) and `MAIN_WINDOW_BOUNDS_KEY` (project scope) for the pattern.
- Only touch `db.rs`'s `PersistSettings` / `SETTINGS_SCHEMA_VERSION` if you add a **dedicated struct field** on `AppSettings` (like `main_window_bounds`). Storing under `values` needs none of that.

Expand All @@ -84,13 +84,12 @@ bash .claude/skills/add-persisted-setting/verify.sh

Expected tail: `test result: ok. 5 passed` then `ALL GREEN`. If you added a new project-scoped key and want a regression test, copy `load_project_file_caches_settings_values` in `crates/settings/src/project.rs` — it round-trips a `__settings` row into `ProjectData.values` with no gpui context needed.

For a UI field, also compile the app: `cargo check -p app` (gpui **won't build on Linux** — do this on Windows/macOS). There is no headless GUI harness; the manual check is: open Settings, toggle under both User and Project tabs, confirm the consumer repaints live, reopen the project to confirm the project value persisted in the `.qrate` file.
For a UI field, also compile the app: `cargo check -p app` (gpui **won't build on Linux** — do this on Windows/macOS). There is no headless GUI harness; the manual check is: open Settings, toggle the field, confirm the consumer repaints live, and restart to confirm it persisted.

## Gotchas (things that bite)

- **Never call `project::read_setting` in `render`** — it opens a fresh SQLite connection each call. Read from the cached `CurrentProject.data.values` (via `get_bool`) instead. That cache is populated once in `load_project_file` and kept current by `set_bool`/`set_text`.
- **`effective_bool` vs `scoped_bool`**: consumers use `effective_bool` (project-overrides-user). The Settings-window checkbox uses `scoped_bool` (reflects the active tab's own store). Don't mix them up — a consumer using `scoped_bool` would flip based on which tab is open.
- **Stale Project scope**: the setters fall back to User scope if `Project` is active but no `CurrentProject` global exists, so a closed-project-with-Project-tab-selected can't panic. Keep that guard if you add new scoped setters.
- **`effective_bool` vs `AppSettings::get`**: consumers use `effective_bool` (project-overrides-user). Reading `AppSettings` directly ignores a project override, which is only right for user-only values like paths.
- **`global_mut` auto-notifies**: gpui's `cx.global_mut::<T>()` pushes a global-observer notification, so mutating `CurrentProject`/`AppSettings` repaints observers for free — no manual plumbing.
- **`.when(..)` needs `use gpui::prelude::FluentBuilder as _;`** and `cx.theme()` needs `use gpui_component::ActiveTheme as _;` — both in `lib.rs` already.
- **User store is one JSON blob, project store is one row per key.** Different shapes; don't assume `values.len()` maps to rows for the user scope.
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ problem — read the `--check` diff and apply it by hand rather than fighting th
| `app` | the binary — `main`, menu bar, actions/keybindings, theming, logging, export entry points |
| `agent-runtime` | the bundled Pi executable contract, isolated profile, and restricted PTY/session |
| `table` | the spreadsheet grid: delegate, cell editing, undo history, filters, notes, photos, file links |
| `workspace` | panel hosting — the panel registry, dock buttons, image viewer |
| `workspace` | panel hosting — the panel registry, dock buttons, image viewer, the dock skin, and `extension` (how another crate draws over the centre or reacts to the view) |
| `onboarding` | Getting started — the workspace guide card, its one-time tips, the export tip. Plugs in as a `workspace::WorkspaceExtension`; its per-project keys live in `settings::onboarding` so `project-wizard` can switch it on without depending on it |
| `window-wrapper` | shared window chrome: title bar, status bar, the window registry |
| `settings` | `AppSettings` (user-wide) and `.qrate` (per project), column config, plugin settings |
| `project-wizard` | new/open project flow, recent projects, the launcher |
Expand Down
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"crates/components",
"crates/data-exchange",
"crates/diagnostics",
"crates/onboarding",
"crates/file-ingest",
"crates/plugin-api",
"crates/plugin-host",
Expand Down
1 change: 1 addition & 0 deletions crates/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ keyring = { version = "3", features = [
"crypto-rust",
] }
window-wrapper = { path = "../window-wrapper" }
onboarding = { path = "../onboarding" }
project-wizard = { path = "../project-wizard" }
checks = { path = "../checks" }
data-exchange = { path = "../data-exchange" }
Expand Down
4 changes: 4 additions & 0 deletions crates/app/src/app_menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,16 @@ fn app_menus(cx: &gpui::App) -> Vec<Menu> {
name: "Help".into(),
disabled: false,
items: vec![
// Reopens the workspace's guide with its progress, or the launcher's welcome
// when no project is open.
MenuItem::action("Getting Started", onboarding::ShowGettingStarted),
MenuItem::action(
"User Guide",
OpenBrowser {
url: format!("{REPO_URL}/blob/main/docs/index.md"),
},
),
MenuItem::Separator,
MenuItem::submenu(Menu {
name: "GitHub".into(),
disabled: false,
Expand Down
22 changes: 10 additions & 12 deletions crates/app/src/app_settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,7 @@ pub fn build_pages(cx: &App) -> Vec<SettingPage> {
.collect(),
|cx: &App| cx.theme().theme_name().clone(),
|name: SharedString, cx: &mut App| {
cx.dispatch_action(&crate::theming::SwitchTheme {
name: name.to_string(),
});
crate::theming::switch_theme(&name, cx);
},
),
))
Expand Down Expand Up @@ -252,11 +250,11 @@ pub fn build_pages(cx: &App) -> Vec<SettingPage> {
"Byte order mark",
SettingField::switch(
|cx: &App| {
settings::scoped_text(crate::export::CSV_BOM_KEY, cx)
settings::effective_text(crate::export::CSV_BOM_KEY, cx)
!= "false"
},
|on: bool, cx: &mut App| {
settings::set_scoped_text(
settings::set_user_text(
crate::export::CSV_BOM_KEY,
if on { "true" } else { "false" }.into(),
cx,
Expand Down Expand Up @@ -1078,9 +1076,9 @@ fn saving_group(cx: &App) -> SettingGroup {
SettingItem::new(
"Autosave",
SettingField::switch(
|cx: &App| settings::scoped_text(settings::AUTOSAVE_KEY, cx) != "off",
|cx: &App| settings::effective_text(settings::AUTOSAVE_KEY, cx) != "off",
|on: bool, cx: &mut App| {
settings::set_scoped_text(
settings::set_user_text(
settings::AUTOSAVE_KEY,
if on { "timed" } else { "off" }.into(),
cx,
Expand All @@ -1091,7 +1089,7 @@ fn saving_group(cx: &App) -> SettingGroup {
.description("Save cell edits automatically. Ctrl+S always saves."),
);

if settings::scoped_text(settings::AUTOSAVE_KEY, cx) != "off" {
if settings::effective_text(settings::AUTOSAVE_KEY, cx) != "off" {
group = group.item(
SettingItem::new(
"Method",
Expand All @@ -1101,11 +1099,11 @@ fn saving_group(cx: &App) -> SettingGroup {
("immediate".into(), "On every edit".into()),
],
|cx: &App| {
let v = settings::scoped_text(settings::AUTOSAVE_KEY, cx);
let v = settings::effective_text(settings::AUTOSAVE_KEY, cx);
if v == "immediate" { v } else { "timed".into() }
},
|val: SharedString, cx: &mut App| {
settings::set_scoped_text(settings::AUTOSAVE_KEY, val, cx);
settings::set_user_text(settings::AUTOSAVE_KEY, val, cx);
},
),
)
Expand All @@ -1126,7 +1124,7 @@ fn spelling_group(cx: &App) -> SettingGroup {
SettingField::switch(
|cx: &App| spellcheck::enabled(cx),
|on: bool, cx: &mut App| {
settings::set_scoped_bool(spellcheck::SPELLCHECK_ENABLED_KEY, on, cx);
settings::set_user_bool(spellcheck::SPELLCHECK_ENABLED_KEY, on, cx);
crate::register_spell_checker(cx);
},
),
Expand All @@ -1141,7 +1139,7 @@ fn spelling_group(cx: &App) -> SettingGroup {
SettingField::switch(
|cx: &App| spellcheck::ignore_capitalized(cx),
|on: bool, cx: &mut App| {
settings::set_scoped_bool(spellcheck::SPELLCHECK_NAMES_KEY, on, cx);
settings::set_user_bool(spellcheck::SPELLCHECK_NAMES_KEY, on, cx);
crate::register_spell_checker(cx);
},
),
Expand Down
19 changes: 11 additions & 8 deletions crates/app/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,16 +360,19 @@ pub fn run(format: ExportFormat, window: &mut Window, cx: &mut App) {
files,
csv: csv_options(cx),
};
// A project that already knows its spreadsheet refills that one; otherwise "Sync" asks
// Google's chooser, which is also what grants qrate access to the file.
let linked = project
.data
.values
.get(settings::project::GOOGLE_SHEET_ID_KEY)
.map(|v| v.text().to_string())
.filter(|id| !id.is_empty());

// Once, the first time an export actually starts: what it includes, and where Sheets sync lives.
onboarding::show_export_tip(window, cx);

if is_google(format) {
// A project that already knows its spreadsheet refills that one; otherwise "Sync" asks
// Google's chooser, which is also what grants qrate access to the file.
let linked = project
.data
.values
.get(settings::project::GOOGLE_SHEET_ID_KEY)
.map(|v| v.text().to_string())
.filter(|id| !id.is_empty());
let target = match (format, linked) {
(ExportFormat::GoogleSheet, _) => SheetTarget::New,
(_, Some(id)) => SheetTarget::Existing(id),
Expand Down
Loading
Loading