diff --git a/.claude/skills/add-persisted-setting/SKILL.md b/.claude/skills/add-persisted-setting/SKILL.md index 17dd119d..45efd4fc 100644 --- a/.claude/skills/add-persisted-setting/SKILL.md +++ b/.claude/skills/add-persisted-setting/SKILL.md @@ -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) @@ -17,7 +17,7 @@ Both stores hold `HashMap` 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::(..)` (user-scope changes) and, if it reads project values, `cx.observe_global::(..)` (project-scope changes; writing a project setting mutates that global, which auto-notifies). See `crates/table/src/panel.rs`. @@ -33,13 +33,13 @@ Both stores hold `HashMap` 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`: @@ -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 for SettingItem` that builds a `SettingField::(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 for SettingItem` that builds a `SettingField::(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. @@ -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::()` 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. diff --git a/CLAUDE.md b/CLAUDE.md index fcb411a2..2b0c8043 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index ea1d2915..f9440433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,6 +272,7 @@ dependencies = [ "gpui-pre-platform", "keyring", "log", + "onboarding", "os_info", "plugin-api", "plugin-host", @@ -5794,6 +5795,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "onboarding" +version = "0.5.0-beta.1" +dependencies = [ + "diagnostics", + "gpui-component", + "gpui-pre", + "settings", + "table", + "workspace", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -6454,6 +6467,8 @@ dependencies = [ "gpui-pre", "log", "plugin-api", + "preview", + "qrate-export", "serde", "serde_json", "settings", @@ -10807,6 +10822,7 @@ dependencies = [ "agent-runtime", "components", "diagnostics", + "gpui-base", "gpui-component", "gpui-pre", "log", diff --git a/Cargo.toml b/Cargo.toml index 462c4980..6950c4a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/components", "crates/data-exchange", "crates/diagnostics", + "crates/onboarding", "crates/file-ingest", "crates/plugin-api", "crates/plugin-host", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8234122f..4af5980d 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -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" } diff --git a/crates/app/src/app_menus.rs b/crates/app/src/app_menus.rs index 22d0c028..9e5705d1 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -258,12 +258,16 @@ fn app_menus(cx: &gpui::App) -> Vec { 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, diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index 532b66e6..ba531aeb 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -86,9 +86,7 @@ pub fn build_pages(cx: &App) -> Vec { .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); }, ), )) @@ -252,11 +250,11 @@ pub fn build_pages(cx: &App) -> Vec { "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, @@ -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, @@ -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", @@ -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); }, ), ) @@ -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); }, ), @@ -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); }, ), diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs index 5756b6dd..b28ce414 100644 --- a/crates/app/src/export.rs +++ b/crates/app/src/export.rs @@ -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), diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b5c621dd..b2241bdd 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -121,6 +121,9 @@ pub(crate) fn open_main_window(cx: &mut gpui::App) { set_main_window_title(window, cx); }) .ok(); + if cx.has_global::() { + onboarding::reopen(cx); + } return; } @@ -148,6 +151,9 @@ pub(crate) fn open_main_window(cx: &mut gpui::App) { cx.new(|cx| Root::new(view, window, cx)) }) { WindowRegistry::register(MAIN_WINDOW_KIND, window_handle.into(), cx); + if cx.has_global::() { + onboarding::reopen(cx); + } log::info!("opened the main window in {:?}", started.elapsed()); } } @@ -303,7 +309,7 @@ impl Render for App { .map(|p| p.display_name()) .unwrap_or_default(), ) - .author(settings::history::author(cx).unwrap_or_else(|| "Not set".into())) + .author(settings::history::author(cx).unwrap_or_default()) // Only cell data (gated by autosave/Ctrl+S) can be genuinely unsaved; // column layout/settings auto-persist via the debounced writer, so `any()` // would light the dot forever for those (nothing clears them until quit). @@ -410,6 +416,9 @@ fn register_variant_checker(cx: &mut gpui::App) { /// diagnostic's source row and column *name*, the delegate's filtered view row, and the table's /// display column (data col + 1, past the pinned `#`). fn reveal_in_table(location: &diagnostics::Location, cx: &mut gpui::App) { + // Opening a finding is what "review what qrate found" asks for; fixing it is not. + onboarding::note_finding_reviewed(cx); + let Some(state) = cx .try_global::() .and_then(|h| h.0.upgrade()) @@ -574,6 +583,8 @@ fn main() { // First, so failures in GPUI platform construction and startup still reach the log file. logging::init(); log::info!("site origin: {}", site::url("/")); + let onboarding_preview = + cfg!(debug_assertions) && std::env::args().any(|argument| argument == "--onboarding"); let initial_project = std::env::args_os() .skip(1) .map(std::path::PathBuf::from) @@ -612,7 +623,10 @@ fn main() { instance_handoff::register_linux_scheme(); // Settings ------------------------------------ - let settings = load_app_settings().unwrap_or_default(); + let settings = load_app_settings().unwrap_or_else(|error| { + log::error!("settings: failed to load app settings: {error:#}"); + AppSettings::default() + }); cx.set_global(settings); cx.set_global(SettingsPersistence { writer: Some(SettingsWriter::start()), @@ -628,6 +642,9 @@ fn main() { preview::cache::set_cap(app_settings::preview_cache_bytes(cx)); theming::init(cx); cx.set_global(WindowRegistry::default()); + if onboarding_preview { + cx.set_global(project_wizard::OnboardingPreview); + } // Lets the launcher (in the `project-wizard` crate, which can't depend on `app`) open // the real main window without a crate cycle. See `project_wizard::launcher`. @@ -647,6 +664,9 @@ fn main() { revalidate: table::revalidate_now, }); diagnostics::init(cx); + // Getting started plugs into the workspace as an extension, so it has to be registered + // before the main window builds one. + onboarding::init(cx); log::debug!( "startup: settings and theme ready at {:?}", started.elapsed() @@ -716,6 +736,12 @@ fn main() { project_wizard::open_project_wizard(EntryKind::Blank, cx) }); cx.on_action(|_: &OpenProjects, cx| project_wizard::open_launcher_window(cx)); + // The guide belongs to the open project; with none, the launcher's welcome is the start. + cx.on_action(|_: &onboarding::ShowGettingStarted, cx| { + if !onboarding::reopen(cx) { + project_wizard::open_launcher_window(cx); + } + }); // ---------------------------------------------- app_menus::install(cx); diff --git a/crates/app/src/status_items/mod.rs b/crates/app/src/status_items/mod.rs index 8d036857..9826330e 100644 --- a/crates/app/src/status_items/mod.rs +++ b/crates/app/src/status_items/mod.rs @@ -29,6 +29,13 @@ pub fn build_status_bar_registry(cx: &mut App, dock: WeakEntity) -> St }) .collect(); + // Leftmost: the Getting started label, when the guide is folded into the bar ("Show me + // later", or a centre too small for the card). Takes up only its label, so it fits any size. + let guide = cx.new(onboarding::GuideStatusItem::new); + registry + .items_mut() + .add_left_if(guide, onboarding::status_label_visible); + let left_panels = cx.new(|cx| PanelButtons::new(BarSide::Left, buttons.clone(), cx)); registry .items_mut() diff --git a/crates/app/src/theming.rs b/crates/app/src/theming.rs index 640237c9..22ffcc0e 100644 --- a/crates/app/src/theming.rs +++ b/crates/app/src/theming.rs @@ -45,6 +45,7 @@ pub fn apply_ui_scale(cx: &mut App) { let mode = theme.mode; Theme::change(mode, None, cx); cx.refresh_windows(); + log::debug!("settings: applied interface scale {percent}%"); } // `EMBEDDED_THEMES: &[(file_name, contents)]`, generated by `build.rs` from `assets/themes/`. @@ -83,6 +84,10 @@ fn extract_embedded_themes(dir: &PathBuf) -> anyhow::Result<()> { /// Runs once the registry has finished loading in the background: the menu can only list the /// themes now that they exist, and the saved theme can only be applied now that it is registered. fn on_themes_loaded(cx: &mut App) { + log::debug!( + "settings: theme registry loaded {} choices", + theme_choices(cx).len() + ); crate::app_menus::install(cx); apply_saved_theme(cx); } @@ -94,15 +99,32 @@ fn apply_saved_theme(cx: &mut App) { .get(THEME_NAME_KEY) .map(|v| v.text()) else { + log::debug!("settings: no saved theme to apply"); return; }; + log::debug!("settings: applying saved theme {name}"); apply_theme_by_name(&name, cx); } -fn apply_theme_by_name(name: &str, cx: &mut App) { +fn apply_theme_by_name(name: &str, cx: &mut App) -> bool { if let Some(config) = ThemeRegistry::global(cx).themes().get(name).cloned() { Theme::global_mut(cx).apply_config(&config); + Theme::sync_base(cx); cx.refresh_windows(); + log::info!("settings: applied theme {name}"); + true + } else { + log::warn!("settings: theme {name} is not registered"); + false + } +} + +/// Shared by the menu action and Settings dropdown. A dropdown callback already runs inside a +/// window update, so redispatching the action through that window can silently fail. +pub fn switch_theme(name: &str, cx: &mut App) { + log::info!("settings: theme selected {name}"); + if apply_theme_by_name(name, cx) { + AppSettings::set_text(THEME_NAME_KEY, name.to_string().into(), cx); } } @@ -122,8 +144,7 @@ pub fn init(cx: &mut App) { } cx.on_action(|action: &SwitchTheme, cx| { - apply_theme_by_name(&action.name, cx); - AppSettings::set_text(THEME_NAME_KEY, action.name.clone().into(), cx); + switch_theme(&action.name, cx); }); } diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml new file mode 100644 index 00000000..578e6282 --- /dev/null +++ b/crates/onboarding/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "onboarding" +version.workspace = true +edition.workspace = true + +# Getting started: the workspace guide card, its one-time tips and the export tip. A crate of its +# own so onboarding can grow without threading through `workspace`: it plugs in as a +# `workspace::WorkspaceExtension` and reads the grid through `table`'s public facts. + +[dependencies] +gpui.workspace = true +gpui-component.workspace = true +diagnostics = { path = "../diagnostics" } +settings = { path = "../settings" } +table = { path = "../table" } +workspace = { path = "../workspace" } diff --git a/crates/onboarding/src/lib.rs b/crates/onboarding/src/lib.rs new file mode 100644 index 00000000..4d8679e7 --- /dev/null +++ b/crates/onboarding/src/lib.rs @@ -0,0 +1,1374 @@ +//! Getting started: a small card of at most three tasks chosen from what the project was made +//! from, and one-time tips pinned to the part of the window they explain. +//! +//! Nothing here dims or blocks the grid, and nothing takes focus on arrival. The card floats in +//! the centre's bottom-right corner; when the centre is too small for it to sit there without +//! covering the grid, or when the archivist says "Show me later", it folds into a label in the +//! status bar that opens upward. Tips are shown once each, app-wide, the first time their subject +//! appears; "Show me" on a task can bring one back. +//! +//! Progress is read, not reported: most tasks tick from the grid's own state +//! ([`table::guide_facts`]), so a task done before it was suggested still counts. The two that +//! can't be read — reviewing a finding and visiting Gallery — are told to the guide by the code +//! that does them ([`note_finding_reviewed`], [`note_view`]). +//! +//! State lives in the project (`settings::onboarding`), so progress follows the `.qrate` file. A +//! project without it predates the guide and gets no card until Help ▸ Getting Started asks. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::time::Duration; + +use gpui::prelude::FluentBuilder as _; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::dock::DockArea; +use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex}; +use settings::onboarding::{ + GUIDE_DONE_KEY, GUIDE_KEY, GUIDE_KIND_KEY, GUIDE_OPEN, GuideKind, TIPS_SEEN_KEY, +}; + +use workspace::{DETAILS_META, PROBLEMS_META, PanelRegistry, ViewMode, WorkspaceExtension}; + +actions!( + qrate, + [ + /// Help ▸ Getting Started: bring the card back with its progress, or switch it on for a + /// project that predates it. `app` handles it, since with no project open it opens the + /// launcher instead. + ShowGettingStarted + ] +); + +/// Below this the floating card would cover the grid it is teaching, so the guide falls back to +/// the status-bar label. Measured on the centre, not the window: docks eat into it. +const COMPACT_WIDTH: Pixels = px(640.); +const COMPACT_HEIGHT: Pixels = px(360.); +const CARD_WIDTH: Pixels = px(292.); +const POPOVER_WIDTH: Pixels = px(300.); +const TIP_WIDTH: Pixels = px(268.); +/// Tips narrow to this when the centre is compact, and stay pinned to their anchor. +const TIP_WIDTH_COMPACT: Pixels = px(220.); +/// How long the "Getting started hidden" note stays up after Dismiss, with its Undo. +const TOAST_FOR: Duration = Duration::from_secs(6); +const BODY: Pixels = px(13.); +const BODY_LINE: Pixels = px(18.); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Mode { + /// No guide for this project: it predates the feature, or no project is open. + Off, + Open, + /// "Show me later": folded into the status-bar label. + Later, + Dismissed, +} + +impl Mode { + fn parse(text: &str) -> Self { + match text { + GUIDE_OPEN => Self::Open, + "later" => Self::Later, + "dismissed" => Self::Dismissed, + _ => Self::Off, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Off => "", + Self::Open => GUIDE_OPEN, + Self::Later => "later", + Self::Dismissed => "dismissed", + } + } + + fn active(self) -> bool { + matches!(self, Self::Open | Self::Later) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum Task { + NameFirstRecord, + AddFields, + AddFilesFolder, + OpenRow, + ReviewFindings, + SelectRow, + BrowseGallery, +} + +const ALL_TASKS: [Task; 7] = [ + Task::NameFirstRecord, + Task::AddFields, + Task::AddFilesFolder, + Task::OpenRow, + Task::ReviewFindings, + Task::SelectRow, + Task::BrowseGallery, +]; + +fn tasks_for(kind: GuideKind) -> [Task; 3] { + match kind { + GuideKind::Blank => [Task::NameFirstRecord, Task::AddFields, Task::AddFilesFolder], + GuideKind::Import => [Task::OpenRow, Task::ReviewFindings, Task::AddFilesFolder], + GuideKind::Media => [Task::SelectRow, Task::BrowseGallery, Task::ReviewFindings], + } +} + +impl Task { + fn key(self) -> &'static str { + match self { + Self::NameFirstRecord => "name-first-record", + Self::AddFields => "add-fields", + Self::AddFilesFolder => "add-files-folder", + Self::OpenRow => "open-row", + Self::ReviewFindings => "review-findings", + Self::SelectRow => "select-row", + Self::BrowseGallery => "browse-gallery", + } + } + + fn parse(text: &str) -> Option { + ALL_TASKS.into_iter().find(|task| task.key() == text) + } + + fn title(self, compact: bool) -> &'static str { + match self { + Self::NameFirstRecord => "Name your first record", + Self::AddFields => "Add more fields", + Self::AddFilesFolder => "Add a files folder", + Self::OpenRow if compact => "Open a row and check your columns", + Self::OpenRow => "Open a row and check how your columns came across", + Self::ReviewFindings => "Review what qrate found", + Self::SelectRow => "Select a row to see its file and fields", + Self::BrowseGallery => "Browse in Gallery", + } + } + + fn hint(self, kind: GuideKind, findings: usize) -> String { + match self { + Self::NameFirstRecord => { + "Type in the first Title cell. The row is already there.".into() + } + Self::AddFields => { + "Add a column for each thing you record, like a date or a creator.".into() + } + Self::AddFilesFolder if kind == GuideKind::Blank => { + "Bring in files or a folder. Each file arrives as its own row.".into() + } + Self::AddFilesFolder => "Link the folder that holds your photos and scans.".into(), + Self::OpenRow => "Select any row. Details shows every field it came in with.".into(), + Self::ReviewFindings if findings == 0 => { + "Problems lists anything qrate notices. Nothing so far.".into() + } + Self::ReviewFindings => format!( + "{findings} finding{} in Problems. Reviewing counts; fixing is up to you.", + if findings == 1 { "" } else { "s" } + ), + Self::SelectRow => "Details shows its file above the fields.".into(), + Self::BrowseGallery => "Every linked file as a thumbnail.".into(), + } + } +} + +/// A one-time callout pinned to what it explains. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Tip { + TitleCell, + AddFields, + Problems, + Gallery, + DetailsMoved, +} + +impl Tip { + fn key(self) -> &'static str { + match self { + Self::TitleCell => "title-cell", + Self::AddFields => "add-fields", + Self::Problems => "problems", + Self::Gallery => "gallery", + Self::DetailsMoved => "details-moved", + } + } +} + +/// The one-time export tip's key, shown from `app` the first time something is exported. +const EXPORT_TIP: &str = "export"; + +pub struct Guide { + project: Option, + mode: Mode, + kind: GuideKind, + done: BTreeSet, + facts: table::GuideFacts, + tip: Option, + /// The dismiss note's generation while it is up, so a timer from an earlier Dismiss can't + /// take down a newer note. + toast: Option, + toast_generation: u64, + /// The status-bar label's popover. + popover: bool, + centre: Size, + view: ViewMode, + dock_area: WeakEntity, + _project_sub: Subscription, + _handle_sub: Subscription, + _diagnostics_sub: Subscription, + _table_sub: Option, +} + +/// The live guide, reached by the centre (card and tips), the status bar (label) and `app` +/// (Help menu, export tip, a revealed finding). Held here rather than by the workspace, which +/// doesn't know it; replaced whenever a main window's workspace is built. +struct ActiveGuide(Entity); +impl Global for ActiveGuide {} + +fn active(cx: &App) -> Option> { + cx.try_global::().map(|guide| guide.0.clone()) +} + +/// Plug the guide into the workspace. Call once at startup, before the main window opens. +pub fn init(cx: &mut App) { + workspace::extension::register( + WorkspaceExtension { + attach: install, + centre_overlay, + window_overlay, + view_changed: note_view, + centre_resized: set_centre_size, + }, + cx, + ); +} + +fn tips_seen(cx: &App) -> BTreeSet { + settings::AppSettings::get(cx) + .values + .get(TIPS_SEEN_KEY) + .map(|value| { + value + .text() + .split(',') + .filter(|key| !key.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn mark_tip_seen(key: &str, cx: &mut App) { + let mut seen = tips_seen(cx); + if seen.insert(key.to_string()) { + let joined = seen.into_iter().collect::>().join(","); + settings::AppSettings::set_text(TIPS_SEEN_KEY, joined.into(), cx); + } +} + +impl Guide { + fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { + let mut this = Self { + project: None, + mode: Mode::Off, + kind: GuideKind::Import, + done: BTreeSet::new(), + facts: table::GuideFacts::default(), + tip: None, + toast: None, + toast_generation: 0, + popover: false, + centre: Size::default(), + view: ViewMode::parse(&settings::effective_text(workspace::VIEW_MODE_KEY, cx)), + dock_area, + _project_sub: cx.observe_global::(|this, cx| { + this.load(cx); + this.refresh(cx); + }), + _handle_sub: cx.observe_global::(|this, cx| { + this.bind(cx); + this.refresh(cx); + }), + _diagnostics_sub: cx.observe_global::(|this, cx| { + this.refresh(cx); + }), + _table_sub: None, + }; + this.load(cx); + this.bind(cx); + this.refresh(cx); + this + } + + fn bind(&mut self, cx: &mut Context) { + self._table_sub = cx + .try_global::() + .and_then(|handle| handle.0.upgrade()) + .map(|state| { + cx.subscribe(&state, |this, _, _: &table::TableChanged, cx| { + this.refresh(cx) + }) + }); + } + + /// Re-read the guide's state when the current project changes. `CurrentProject` is written + /// by every project-scoped setting, this guide's own included, so a same-file reload is + /// skipped — the in-memory state is already what was written. + fn load(&mut self, cx: &mut Context) { + let project = cx.try_global::(); + let file = project.map(|project| project.file.clone()); + if file == self.project { + return; + } + self.project = file; + self.tip = None; + self.toast = None; + self.popover = false; + let text = |key: &str| { + project + .and_then(|project| project.data.values.get(key)) + .map(|value| value.text().to_string()) + .unwrap_or_default() + }; + self.mode = Mode::parse(&text(GUIDE_KEY)); + self.kind = GuideKind::parse(&text(GUIDE_KIND_KEY)).unwrap_or(GuideKind::Import); + self.done = text(GUIDE_DONE_KEY) + .split(',') + .filter_map(Task::parse) + .collect(); + cx.notify(); + } + + fn tasks(&self) -> [Task; 3] { + tasks_for(self.kind) + } + + fn current(&self) -> Option { + self.tasks() + .into_iter() + .find(|task| !self.done.contains(task)) + } + + fn progress(&self) -> usize { + self.tasks() + .iter() + .filter(|task| self.done.contains(task)) + .count() + } + + fn compact(&self) -> bool { + // Unmeasured yet — assume there's room rather than flashing the label on the first frame. + self.centre.width > px(0.) + && (self.centre.width < COMPACT_WIDTH || self.centre.height < COMPACT_HEIGHT) + } + + fn files_folder_set(cx: &App) -> bool { + cx.try_global::() + .and_then(|project| project.data.values.get(settings::project::FILES_FOLDER_KEY)) + .is_some_and(|value| !value.text().trim().is_empty()) + } + + /// Read the grid again, tick whatever it shows is done, and bring up the tip the current task + /// asks for if it hasn't been shown yet. + fn refresh(&mut self, cx: &mut Context) { + let facts = table::guide_facts(cx).unwrap_or_default(); + let changed = facts != self.facts; + self.facts = facts; + if !self.mode.active() { + if changed { + cx.notify(); + } + return; + } + let files_folder = Self::files_folder_set(cx); + let reached: Vec = self + .tasks() + .into_iter() + .filter(|task| match task { + Task::NameFirstRecord => facts.any_title, + // A blank project starts with its two required columns. + Task::AddFields => facts.columns > 2, + Task::AddFilesFolder => files_folder || facts.linked_files > 0, + Task::OpenRow | Task::SelectRow => facts.selected, + Task::BrowseGallery => self.view == ViewMode::Gallery, + Task::ReviewFindings => false, + }) + .collect(); + for task in reached { + self.complete(task, cx); + } + if self.tip.is_none() + && self.mode == Mode::Open + && !self.compact() + && let Some(tip) = self.due_tip(cx) + { + self.show_tip(tip, cx); + } + cx.notify(); + } + + /// The tip the current task wants on its own, if its subject is on screen and it hasn't + /// been seen. + fn due_tip(&self, cx: &App) -> Option { + let seen = tips_seen(cx); + let tip = match self.current()? { + Task::ReviewFindings + if !diagnostics::Diagnostics::all(cx).is_empty() + && self.dock_area.upgrade().is_some_and(|dock| { + PanelRegistry::visible(PROBLEMS_META.name, &dock, cx) + }) => + { + Tip::Problems + } + Task::BrowseGallery if self.view == ViewMode::Table && self.facts.selected => { + Tip::Gallery + } + _ => return None, + }; + (!seen.contains(tip.key())).then_some(tip) + } + + fn complete(&mut self, task: Task, cx: &mut Context) { + if !self.mode.active() || !self.done.insert(task) { + return; + } + if self.tip == Some(Tip::Problems) && task == Task::ReviewFindings { + self.tip = None; + } + let joined = self + .done + .iter() + .map(|task| task.key()) + .collect::>() + .join(","); + if cx.has_global::() { + settings::project::CurrentProject::set_text(GUIDE_DONE_KEY, joined.into(), cx); + } + cx.notify(); + } + + fn set_mode(&mut self, mode: Mode, cx: &mut Context) { + self.mode = mode; + if mode != Mode::Open && mode != Mode::Later { + self.tip = None; + self.popover = false; + } + if cx.has_global::() { + settings::project::CurrentProject::set_text(GUIDE_KEY, mode.as_str().into(), cx); + } + // The status bar decides its dividers from each item's occupancy when it draws, so a + // label appearing or leaving has to repaint the bar itself, not just the label. + cx.refresh_windows(); + cx.notify(); + } + + fn show_tip(&mut self, tip: Tip, cx: &mut Context) { + self.tip = Some(tip); + mark_tip_seen(tip.key(), cx); + cx.notify(); + } + + fn close_tip(&mut self, cx: &mut Context) { + if self.tip.take() == Some(Tip::Problems) { + // "Looking through the list counts as progress." + self.complete(Task::ReviewFindings, cx); + } + cx.notify(); + } + + fn dismiss(&mut self, cx: &mut Context) { + let finished = self.current().is_none(); + self.set_mode(Mode::Dismissed, cx); + if finished { + return; + } + self.toast_generation += 1; + let generation = self.toast_generation; + self.toast = Some(generation); + cx.spawn(async move |this, cx| { + cx.background_executor().timer(TOAST_FOR).await; + this.update(cx, |this, cx| { + if this.toast == Some(generation) { + this.toast = None; + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + fn undo_dismiss(&mut self, cx: &mut Context) { + self.toast = None; + self.set_mode(Mode::Open, cx); + } + + /// Help ▸ Getting Started. A project that predates the guide gets one now, its tasks + /// guessed from what it holds. + fn reopen(&mut self, cx: &mut Context) { + if self.mode == Mode::Off && cx.has_global::() { + self.kind = if Self::files_folder_set(cx) || self.facts.linked_files > 0 { + GuideKind::Media + } else if self.facts.rows <= 1 && !self.facts.any_title { + GuideKind::Blank + } else { + GuideKind::Import + }; + settings::project::CurrentProject::set_text( + GUIDE_KIND_KEY, + self.kind.as_str().into(), + cx, + ); + } + self.toast = None; + self.set_mode(Mode::Open, cx); + self.popover = self.compact(); + self.refresh(cx); + } + + fn label_visible(&self) -> bool { + self.project.is_some() + && (self.mode == Mode::Later || (self.mode == Mode::Open && self.compact())) + } + + fn subtitle(&self, cx: &App) -> String { + let rows = self.facts.rows; + let plural = + |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many }); + match self.kind { + GuideKind::Blank => { + let file = cx + .try_global::() + .and_then(|project| project.file.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let rows = if self.facts.any_title { + plural(rows, "row", "rows") + } else { + plural(rows, "empty row", "empty rows") + }; + format!("{file} · {rows}") + } + GuideKind::Import => format!("{} imported", plural(rows, "row", "rows")), + GuideKind::Media => format!( + "{} · {}", + plural(rows, "row", "rows"), + plural(self.facts.linked_files, "linked file", "linked files") + ), + } + } +} + +/// Build the guide for a new workspace and make it the one every surface reads. +fn install(dock_area: WeakEntity, cx: &mut App) { + let guide = cx.new(|cx| Guide::new(dock_area, cx)); + // Every change to the guide is a change to what the workspace draws for it. + cx.observe(&guide, |_, cx| workspace::extension::repaint(cx)) + .detach(); + cx.set_global(ActiveGuide(guide)); +} + +/// The centre's measured size, from `ViewsPanel`'s canvas. +fn set_centre_size(size: Size, cx: &mut App) { + let Some(guide) = active(cx) else { return }; + if guide.read(cx).centre == size { + return; + } + guide.update(cx, |guide, cx| { + let was = guide.compact(); + guide.centre = size; + if was != guide.compact() { + guide.popover = false; + // The label lives in another view; it has to hear the fold too. + cx.refresh_windows(); + } + cx.notify(); + }); +} + +/// The centre switched views. Visiting Gallery ticks its task, and the first visit explains why +/// Details jumped to the other side. +fn note_view(mode: ViewMode, cx: &mut App) { + let Some(guide) = active(cx) else { return }; + guide.update(cx, |guide, cx| { + let entering_gallery = mode == ViewMode::Gallery && guide.view != mode; + guide.view = mode; + if guide.tip == Some(Tip::Gallery) || guide.tip == Some(Tip::DetailsMoved) { + guide.tip = None; + } + if entering_gallery + && guide.mode.active() + && !guide.compact() + && !tips_seen(cx).contains(Tip::DetailsMoved.key()) + && PanelRegistry::placement(DETAILS_META.name, cx) + == Some(gpui_component::dock::DockPlacement::Right) + { + guide.show_tip(Tip::DetailsMoved, cx); + } + guide.refresh(cx); + }); +} + +/// A finding was opened from the Problems panel — the "review" half of that task. +pub fn note_finding_reviewed(cx: &mut App) { + if let Some(guide) = active(cx) { + guide.update(cx, |guide, cx| guide.complete(Task::ReviewFindings, cx)); + } +} + +/// Help ▸ Getting Started. `false` when there's no workspace with a project to show it in, for +/// the caller to open the launcher instead. +pub fn reopen(cx: &mut App) -> bool { + // The guide outlives its window; a dock area that's gone means there's no workspace to show + // it in. + let Some(guide) = active(cx).filter(|guide| guide.read(cx).dock_area.upgrade().is_some()) + else { + return false; + }; + if !cx.has_global::() { + return false; + } + guide.update(cx, |guide, cx| guide.reopen(cx)); + cx.refresh_windows(); + true +} + +/// Whether the status bar's Getting started label has anything to show. +pub fn status_label_visible(cx: &App) -> bool { + active(cx).is_some_and(|guide| guide.read(cx).label_visible()) +} + +/// The export tip, shown once, the first time anything is exported: every row goes out, filtered +/// or not, and Google Sheets sync is something to switch on first rather than a sign-in here. +pub fn show_export_tip(window: &mut Window, cx: &mut App) { + if tips_seen(cx).contains(EXPORT_TIP) { + return; + } + mark_tip_seen(EXPORT_TIP, cx); + let rows = table::guide_facts(cx).map_or(0, |facts| facts.rows); + let sheets = if settings::google_enabled(cx) { + "" + } else { + " To keep a Google Sheet in sync, turn it on in Settings ▸ Google first." + }; + use gpui_component::WindowExt as _; + window.push_notification( + gpui_component::notification::Notification::new() + .title("Share or move your catalog") + .message(format!( + "Every export includes all {rows} row{}, including rows hidden by a filter.{sheets}", + if rows == 1 { "" } else { "s" } + )) + .autohide(false), + cx, + ); +} + +// --------------------------------------------------------------------------------------------- +// Actions behind the buttons. Free functions over the entity, each holding the guide for one +// short update: "Show Gallery" re-arranges the docks, which dumps every panel and ends in +// `note_view` updating this guide again, so no lease may still be out when it runs. + +fn focus_centre(guide: &Entity, window: &mut Window, cx: &mut App) { + if let Some(dock) = guide.read(cx).dock_area.upgrade() { + PanelRegistry::focus_frontmost( + gpui_component::dock::DockPlacement::Center, + &dock, + window, + cx, + ); + } +} + +fn ensure_visible(guide: &Entity, panel: &str, window: &mut Window, cx: &mut App) { + if let Some(dock) = guide.read(cx).dock_area.upgrade() + && !PanelRegistry::visible(panel, &dock, cx) + { + PanelRegistry::toggle(panel, &dock, window, cx); + } +} + +fn show_me(guide: &Entity, task: Task, window: &mut Window, cx: &mut App) { + guide.update(cx, |guide, cx| { + guide.popover = false; + cx.notify(); + }); + match task { + Task::NameFirstRecord => { + focus_centre(guide, window, cx); + table::select_first_row(true, cx); + guide.update(cx, |guide, cx| guide.show_tip(Tip::TitleCell, cx)); + } + Task::AddFields => { + focus_centre(guide, window, cx); + guide.update(cx, |guide, cx| guide.show_tip(Tip::AddFields, cx)); + } + Task::AddFilesFolder => { + let blank = guide.read(cx).kind == GuideKind::Blank; + if let Some(table) = cx + .try_global::() + .and_then(|handle| handle.0.upgrade()) + { + table.update(cx, |table, cx| { + if blank { + table.choose_import_paths(window, cx) + } else { + table.choose_files_root(window, cx) + } + }); + } + } + Task::OpenRow | Task::SelectRow => { + ensure_visible(guide, DETAILS_META.name, window, cx); + focus_centre(guide, window, cx); + table::select_first_row(false, cx); + } + Task::ReviewFindings => { + ensure_visible(guide, PROBLEMS_META.name, window, cx); + guide.update(cx, |guide, cx| guide.show_tip(Tip::Problems, cx)); + } + Task::BrowseGallery => { + guide.update(cx, |guide, cx| guide.show_tip(Tip::Gallery, cx)); + } + } +} + +fn close_tip(guide: &Entity, window: &mut Window, cx: &mut App) { + guide.update(cx, |guide, cx| guide.close_tip(cx)); + // Esc and every button return focus to what the tip pointed at, which is the centre for all + // but Problems — and the centre is the one that keeps the keyboard working either way. + focus_centre(guide, window, cx); +} + +// --------------------------------------------------------------------------------------------- +// Rendering. + +fn text_body() -> Div { + div().text_size(BODY).line_height(BODY_LINE) +} + +/// Everything the card needs, read out once so the builders below never hold the guide. +struct CardModel { + progress: usize, + total: usize, + title: &'static str, + subtitle: String, + rows: Vec<(Task, TaskState, String)>, + current_hint: Option, + finished: bool, +} + +#[derive(Clone, Copy, PartialEq)] +enum TaskState { + Done, + Current, + Todo, +} + +fn card_model(guide: &Guide, compact: bool, cx: &App) -> CardModel { + let current = guide.current(); + let findings = diagnostics::Diagnostics::all(cx).len(); + let finished = current.is_none(); + CardModel { + progress: guide.progress(), + total: 3, + title: if finished { + "You're all set" + } else { + "Your project is ready" + }, + subtitle: if finished { + "Help ▸ User Guide covers the rest.".into() + } else { + guide.subtitle(cx) + }, + rows: guide + .tasks() + .into_iter() + .map(|task| { + let state = if guide.done.contains(&task) { + TaskState::Done + } else if Some(task) == current { + TaskState::Current + } else { + TaskState::Todo + }; + (task, state, task.title(compact).to_string()) + }) + .collect(), + current_hint: current.map(|task| task.hint(guide.kind, findings)), + finished, + } +} + +fn status_dot(state: TaskState, cx: &App) -> Div { + let base = div().size(px(16.)).flex_none().rounded_full().mt(px(1.)); + match state { + TaskState::Done => base + .bg(cx.theme().success) + .text_color(gpui::white()) + .text_size(px(11.)) + .line_height(px(16.)) + .flex() + .items_center() + .justify_center() + .child("✓"), + TaskState::Current => base.border_2().border_color(cx.theme().primary), + TaskState::Todo => base + .border(px(1.5)) + .border_color(cx.theme().muted_foreground), + } +} + +/// The card's content, shared by the floating card (6a) and the status-bar popover (6c/11b). +/// `compact` drops the subtitle and tightens the rows so the list fits 238px of body. +fn card_body( + guide: &Entity, + model: CardModel, + compact: bool, + header_suffix: Option, + cx: &App, +) -> Div { + let muted = cx.theme().muted_foreground; + let current_bg = cx.theme().primary.opacity(0.07); + let hint = model.current_hint.clone(); + v_flex() + .text_color(cx.theme().foreground) + .child( + v_flex() + .px_3() + .pt_3() + .pb_2() + .gap_0p5() + .child( + h_flex() + .justify_between() + .items_center() + .text_xs() + .text_color(muted) + .child(format!( + "Getting started · {} of {}", + model.progress, model.total + )) + .children(header_suffix), + ) + .when(!compact, |el| { + el.child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(model.title), + ) + .child(text_body().text_color(muted).child(model.subtitle.clone())) + }), + ) + .child(v_flex().px_1().pb_1().children(model.rows.into_iter().map( + |(task, state, title)| { + let current = state == TaskState::Current; + h_flex() + .id(SharedString::from(format!("guide-task-{}", task.key()))) + .gap_2p5() + .items_start() + .p(if compact { px(6.) } else { px(8.) }) + .rounded(px(6.)) + .when(current, |el| el.bg(current_bg)) + .child(status_dot(state, cx)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + text_body() + .when(state == TaskState::Done, |el| el.text_color(muted)) + .when(current, |el| el.font_weight(FontWeight::SEMIBOLD)) + .child(title), + ) + .when(current && !compact, |el| { + el.children( + hint.clone() + .map(|hint| text_body().text_color(muted).child(hint)), + ) + }), + ) + .when(current, |el| { + let guide = guide.clone(); + el.child( + Button::new(SharedString::from(format!( + "guide-show-me-{}", + task.key() + ))) + .label("Show me") + .link() + .small() + .on_click(move |_, window, cx| show_me(&guide, task, window, cx)), + ) + }) + }, + ))) + .child( + h_flex() + .justify_end() + .gap_1() + .px_2() + .py_1p5() + .border_t_1() + .border_color(cx.theme().border) + .map(|footer| { + if model.finished { + let guide = guide.clone(); + footer.child( + Button::new("guide-finish") + .label("Close") + .ghost() + .xsmall() + .on_click(move |_, _, cx| { + guide.update(cx, |guide, cx| guide.dismiss(cx)) + }), + ) + } else { + let later = guide.clone(); + let dismiss = guide.clone(); + footer + .child( + Button::new("guide-later") + .label("Show me later") + .ghost() + .xsmall() + .on_click(move |_, _, cx| { + later.update(cx, |guide, cx| { + guide.popover = false; + guide.set_mode(Mode::Later, cx); + }); + cx.refresh_windows(); + }), + ) + .child( + Button::new("guide-dismiss") + .label("Dismiss") + .ghost() + .xsmall() + .on_click(move |_, _, cx| { + dismiss.update(cx, |guide, cx| guide.dismiss(cx)); + cx.refresh_windows(); + }), + ) + } + }), + ) +} + +fn surface(cx: &App) -> Div { + div() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded(px(8.)) + .shadow_lg() +} + +/// Which way a tip's arrow points, and how far along its edge. +#[derive(Clone, Copy)] +enum Arrow { + Up(Pixels), + Down(Pixels), + Right(Pixels), +} + +struct TipCopy { + title: &'static str, + lines: Vec, + /// `(label, shortcut)` for a primary action other than "Got it". + primary: Option<(&'static str, Option<&'static str>)>, + secondary: Option<&'static str>, +} + +fn tip_copy(tip: Tip) -> TipCopy { + let got_it = |title, lines: &[&str]| TipCopy { + title, + lines: lines.iter().map(|line| line.to_string()).collect(), + primary: None, + secondary: None, + }; + match tip { + Tip::TitleCell => got_it( + "Name your first record", + &["Type a title and press Enter to keep it. This row is already here for you."], + ), + Tip::AddFields => got_it( + "Add a field", + &[ + "Use Insert ▸ Column Right, then Data ▸ Rename Column… to name it for what you \ + record, like Date or Creator.", + ], + ), + Tip::Problems => got_it( + "Review what qrate found", + &[ + "Each finding points to a cell. A suggested correction changes the value only if \ + you accept it.", + "Looking through the list counts as progress. You don't have to fix anything.", + ], + ), + Tip::Gallery => TipCopy { + title: "Browse in Gallery", + lines: vec![ + "See every linked file as a thumbnail. Selecting one selects its row, and Details \ + moves to the right while you browse." + .into(), + ], + primary: Some(( + "Show Gallery", + Some(if cfg!(target_os = "macos") { + "⌘2" + } else { + "Ctrl+2" + }), + )), + secondary: Some("Not now"), + }, + Tip::DetailsMoved => got_it( + "Details moved here", + &[ + "In Gallery, the selected item's file and fields sit on the right. Switch back to \ + Table and your layout returns.", + ], + ), + } +} + +fn render_tip(guide: &Entity, tip: Tip, compact: bool, cx: &App) -> AnyElement { + let copy = tip_copy(tip); + let muted = cx.theme().muted_foreground; + let width = if compact { + TIP_WIDTH_COMPACT + } else { + TIP_WIDTH + }; + let popover = cx.theme().popover; + let border = cx.theme().border; + // Where it's pinned, in the centre body's coordinates (below the view switcher). The grid's + // header and first row are 32px each, so the Title cell's tip hangs just under row 1. + let (placed, arrow) = match tip { + Tip::TitleCell => (div().left(px(62.)).top(px(74.)), Arrow::Up(px(30.))), + Tip::AddFields => (div().left(px(62.)).top(px(42.)), Arrow::Up(px(30.))), + Tip::Gallery => (div().left(px(48.)).top(px(8.)), Arrow::Up(px(40.))), + Tip::Problems => (div().left(px(10.)).bottom(px(10.)), Arrow::Down(px(28.))), + Tip::DetailsMoved => (div().right(px(12.)).top(px(40.)), Arrow::Right(px(16.))), + }; + let arrow_el = { + let base = div() + .absolute() + .size(px(10.)) + .bg(popover) + .border_color(border); + // A rotated square is the usual caret; gpui has no transforms on divs, so the caret is a + // small bordered notch instead, drawn over the edge it points through. + match arrow { + Arrow::Up(at) => base + .left(at) + .top(px(-6.)) + .border_l_1() + .border_t_1() + .rounded_tl(px(2.)), + Arrow::Down(at) => base + .left(at) + .bottom(px(-6.)) + .border_r_1() + .border_b_1() + .rounded_br(px(2.)), + Arrow::Right(at) => base + .top(at) + .right(px(-6.)) + .border_r_1() + .border_t_1() + .rounded_tr(px(2.)), + } + }; + let escape = guide.clone(); + let primary = guide.clone(); + placed + .absolute() + .w(width) + .child( + surface(cx) + .id(SharedString::from(format!("guide-tip-{}", tip.key()))) + .relative() + .p_3() + .flex() + .flex_col() + .gap_1p5() + .occlude() + // Esc closes the tip and hands focus back to what it points at. + .on_key_down(move |event, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + close_tip(&escape, window, cx); + } + }) + .child(arrow_el) + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(copy.title), + ) + .children( + copy.lines + .into_iter() + .map(|line| text_body().text_color(muted).child(line)), + ) + .child( + h_flex() + .justify_end() + .items_center() + .gap_1p5() + .mt_1() + .when_some(copy.primary.and_then(|(_, key)| key), |row, key| { + row.child( + div() + .px_1() + .rounded(px(3.)) + .border_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child(key), + ) + .child(div().flex_1()) + }) + .when_some(copy.secondary, |row, label| { + let guide = guide.clone(); + row.child( + Button::new("guide-tip-secondary") + .label(label) + .ghost() + .xsmall() + .on_click(move |_, window, cx| close_tip(&guide, window, cx)), + ) + }) + .child(match copy.primary { + Some((label, _)) => Button::new("guide-tip-primary") + .label(label) + .primary() + .xsmall() + .on_click(move |_, window, cx| { + primary.update(cx, |guide, cx| guide.close_tip(cx)); + workspace::show_view(ViewMode::Gallery, window, cx); + }), + None => Button::new("guide-tip-primary") + .label("Got it") + .primary() + .xsmall() + .on_click(move |_, window, cx| close_tip(&primary, window, cx)), + }), + ), + ) + .into_any_element() +} + +fn render_toast(guide: &Entity, cx: &App) -> AnyElement { + let undo = guide.clone(); + div() + .absolute() + .right(px(16.)) + .bottom(px(16.)) + .child( + surface(cx) + .id("guide-toast") + .occlude() + .flex() + .items_center() + .gap_3() + .pl_3() + .pr_1p5() + .py_1p5() + .child( + h_flex() + .gap_1() + .flex_wrap() + .text_size(BODY) + .child("Getting started hidden. Reopen it from") + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child("Help ▸ Getting Started"), + ) + .child("."), + ) + .child( + Button::new("guide-undo") + .label("Undo") + .link() + .small() + .on_click(move |_, _, cx| { + undo.update(cx, |guide, cx| guide.undo_dismiss(cx)); + cx.refresh_windows(); + }), + ), + ) + .into_any_element() +} + +/// What the centre draws over its view: the floating card (6a), the current tip, and the note +/// that follows Dismiss. `None` when there is nothing, which is most of the time. +fn centre_overlay(cx: &App) -> Option { + let guide = active(cx)?; + let state = guide.read(cx); + // No project, no guide — the empty workspace before one opens. + state.project.as_ref()?; + let compact = state.compact(); + let mut layers: Vec = Vec::new(); + if state.mode == Mode::Open && !compact { + let model = card_model(state, false, cx); + layers.push( + div() + .absolute() + .right(px(16.)) + .bottom(px(16.)) + .w(CARD_WIDTH) + .child( + surface(cx) + .id("guide-card") + .occlude() + .child(card_body(&guide, model, false, None, cx)), + ) + .into_any_element(), + ); + } + if let Some(tip) = state.tip.filter(|_| state.mode.active()) { + layers.push(render_tip(&guide, tip, compact, cx)); + } + if state.toast.is_some() { + layers.push(render_toast(&guide, cx)); + } + (!layers.is_empty()).then(|| { + div() + .absolute() + .inset_0() + .children(layers) + .into_any_element() + }) +} + +/// The status-bar label's popover (6c, 11b), drawn by the workspace so it can open upward over +/// the window body. Pinned just above the label at the bar's left end. +fn window_overlay(cx: &App) -> Option { + let guide = active(cx)?; + let state = guide.read(cx); + if !state.popover || !state.label_visible() { + return None; + } + let compact = state.compact(); + let model = card_model(state, compact, cx); + let collapse = guide.clone(); + let chevron = Button::new("guide-popover-close") + .icon(IconName::ChevronDown) + .ghost() + .xsmall() + .on_click(move |_, _, cx| { + collapse.update(cx, |guide, cx| { + guide.popover = false; + cx.notify(); + }); + cx.refresh_windows(); + }) + .into_any_element(); + Some( + div() + .absolute() + .left(px(8.)) + .bottom(px(6.)) + .w(POPOVER_WIDTH) + .child( + surface(cx) + .id("guide-popover") + .occlude() + .max_h(px(238. + 60.)) + .overflow_hidden() + .child(card_body(&guide, model, compact, Some(chevron), cx)), + ) + .into_any_element(), + ) +} + +/// The status bar's "Getting started · n of 3" label. Registered by `app`, which owns the bar. +pub struct GuideStatusItem { + _sub: Option, +} + +impl GuideStatusItem { + pub fn new(cx: &mut Context) -> Self { + Self { + _sub: active(cx).map(|guide| cx.observe(&guide, |_, _, cx| cx.notify())), + } + } +} + +impl Render for GuideStatusItem { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(guide) = active(cx) else { + return div().into_any_element(); + }; + if self._sub.is_none() { + self._sub = Some(cx.observe(&guide, |_, _, cx| cx.notify())); + } + let state = guide.read(cx); + if !state.label_visible() { + return div().into_any_element(); + } + let label = format!("Getting started · {} of 3", state.progress()); + let primary = cx.theme().primary; + h_flex() + .id("guide-status-label") + .cursor_pointer() + .gap_1p5() + .items_center() + .h(px(20.)) + .px_2() + .rounded(px(10.)) + .bg(primary.opacity(0.14)) + .text_xs() + .text_color(cx.theme().foreground) + .child(div().size(px(7.)).rounded_full().bg(primary)) + .child(label) + .on_click(move |_, _, cx| { + guide.update(cx, |guide, cx| { + guide.popover = !guide.popover; + cx.notify(); + }); + cx.refresh_windows(); + }) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::{ALL_TASKS, Mode, Task, tasks_for}; + use settings::onboarding::GuideKind; + + #[test] + fn every_task_and_mode_round_trips_through_its_setting() { + for task in ALL_TASKS { + assert_eq!(Task::parse(task.key()), Some(task)); + } + for mode in [Mode::Open, Mode::Later, Mode::Dismissed] { + assert_eq!(Mode::parse(mode.as_str()), mode); + } + assert_eq!( + Mode::parse(""), + Mode::Off, + "no key means the project predates the guide" + ); + } + + #[test] + fn each_kind_offers_three_distinct_tasks() { + for kind in [GuideKind::Blank, GuideKind::Import, GuideKind::Media] { + let tasks = tasks_for(kind); + assert!(tasks[0] != tasks[1] && tasks[1] != tasks[2] && tasks[0] != tasks[2]); + } + } +} diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 556f1a5f..608e13fa 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -676,7 +676,8 @@ pub fn forget(path: &Path, cx: &mut App) { } /// The file's contents fit to whatever box the caller gives it, or a type icon when there is -/// nothing to draw — no path, an undecodable one, or a decode that fails at paint time. +/// nothing to draw — no path, an undecodable one, or a decode that fails at paint time. `fit` +/// lets a small icon fill its frame while gallery and details views show the entire file. /// /// Callers supply the surrounding chrome (the Details panel's bordered frame and its action /// buttons, a gallery card's caption and selection ring); this is only the picture. It centres @@ -687,7 +688,7 @@ pub fn forget(path: &Path, cx: &mut App) { /// impls, and gpui's chained builder calls produce deeply nested generic types; propagating that /// concrete type into every caller overflows rustc's stack during type-checking instead of just /// hitting a slow compile. -pub fn thumb(path: Option<&Path>, max_edge: u32, cx: &App) -> AnyElement { +pub fn thumb(path: Option<&Path>, max_edge: u32, fit: ObjectFit, cx: &App) -> AnyElement { // Also `img()`'s decode-failure fallback, so a file that passes `can_preview` but turns out to // be truncated still lands on the icon. Captures by value because the `'static` fallback // closure can't borrow `cx` or the path. @@ -708,25 +709,27 @@ pub fn thumb(path: Option<&Path>, max_edge: u32, cx: &App) -> AnyElement { } }; + let cover = matches!(fit, ObjectFit::Cover); div() .size_full() .flex() .items_center() .justify_center() .map(|frame| match path.filter(|path| can_preview(path)) { - // gpui's `img` stamps the element with the *image's* aspect ratio, so a `size_full` - // img ignores the frame shape and `object_fit` has nothing to letterbox. Size it by - // its intrinsic ratio under `max_w/h_full` (where that aspect logic applies) so it - // shrinks to fit — bars and all. - Some(path) => frame.child( - // Page one: a card or a details pane shows what the file *is*, and paging through - // a document is the viewer's job. - img(source(path, max_edge, 0)) - .max_w_full() - .max_h_full() - .object_fit(ObjectFit::Contain) - .with_fallback(placeholder), - ), + // For contain, keep the image's intrinsic ratio under `max_w/h_full` so it can + // letterbox. Cover gives the image the frame's full size so GPUI crops it. + Some(path) => { + let image = img(source(path, max_edge, 0)) + .object_fit(fit) + .with_fallback(placeholder); + frame.child(if cover { + image.size_full() + } else { + // Page one: a card or details pane shows the entire file; paging through a + // document is the viewer's job. Constrain its intrinsic aspect ratio. + image.max_w_full().max_h_full() + }) + } None => frame.child(placeholder()), }) .into_any_element() @@ -788,7 +791,7 @@ mod tests { // No `use super::*`: chain-globbing `gpui::*` shadows the built-in `#[test]` and recurses (see CLAUDE.md). use std::path::{Path, PathBuf}; - use gpui::{Context, IntoElement, Render, TestAppContext, Window}; + use gpui::{Context, IntoElement, ObjectFit, Render, TestAppContext, Window}; use gpui_component::{IconName, IconNamed as _}; use crate::{can_preview, placeholder_icon, thumb}; @@ -1042,11 +1045,16 @@ mod tests { /// Wraps [`thumb`] in a root `Render` view so a test can actually draw it — `Img`'s real /// load/fallback logic runs during layout/paint, not at element construction, so building the /// element tree alone (without a window draw) wouldn't exercise it. - struct ThumbProbe(Option); + struct ThumbProbe(Option, bool); impl Render for ThumbProbe { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - thumb(self.0.as_deref(), crate::CARD, cx) + let fit = if self.1 { + ObjectFit::Cover + } else { + ObjectFit::Contain + }; + thumb(self.0.as_deref(), crate::CARD, fit, cx) } } @@ -1062,14 +1070,21 @@ mod tests { fn renders_a_resolved_image_without_panicking(cx: &mut TestAppContext) { cx.update(gpui_component::init); let path = sample_photo("1.jpg"); - cx.add_window_view(|_, _| ThumbProbe(Some(path))); + cx.add_window_view(|_, _| ThumbProbe(Some(path), false)); + } + + #[gpui::test] + fn renders_a_cover_thumbnail_without_panicking(cx: &mut TestAppContext) { + cx.update(gpui_component::init); + let path = sample_photo("1.jpg"); + cx.add_window_view(|_, _| ThumbProbe(Some(path), true)); } #[gpui::test] fn falls_back_when_the_resolved_path_is_missing_on_disk(cx: &mut TestAppContext) { cx.update(gpui_component::init); let path = PathBuf::from("/nonexistent/qrate-test-image.jpg"); - cx.add_window_view(|_, _| ThumbProbe(Some(path))); + cx.add_window_view(|_, _| ThumbProbe(Some(path), false)); } /// A file rewritten on disk is decoded afresh: forgetting it lets go of its page count and @@ -1078,7 +1093,7 @@ mod tests { fn a_forgotten_file_is_decoded_afresh(cx: &mut TestAppContext) { cx.update(gpui_component::init); let path = sample_photo("2.jpg"); - let (_, cx) = cx.add_window_view(|_, _| ThumbProbe(Some(path.clone()))); + let (_, cx) = cx.add_window_view(|_, _| ThumbProbe(Some(path.clone()), false)); cx.run_until_parked(); cx.update(|window, _| window.refresh()); cx.run_until_parked(); @@ -1100,7 +1115,7 @@ mod tests { #[gpui::test] fn renders_the_icon_when_there_is_no_path(cx: &mut TestAppContext) { cx.update(gpui_component::init); - cx.add_window_view(|_, _| ThumbProbe(None)); + cx.add_window_view(|_, _| ThumbProbe(None, false)); } /// The one that would ship silently. `RenderImage` is BGRA, but every decoder we will add @@ -1185,7 +1200,7 @@ mod tests { #[gpui::test] fn scrolling_past_more_images_than_fit_releases_the_oldest(cx: &mut TestAppContext) { cx.update(gpui_component::init); - let (_probe, cx) = cx.add_window_view(|_, _| ThumbProbe(None)); + let (_probe, cx) = cx.add_window_view(|_, _| ThumbProbe(None, false)); // 32×32 RGBA = 4096 bytes each; a budget of three lets the fourth push the first out. let image = || { diff --git a/crates/project-wizard/Cargo.toml b/crates/project-wizard/Cargo.toml index 31ca6983..5671fabd 100644 --- a/crates/project-wizard/Cargo.toml +++ b/crates/project-wizard/Cargo.toml @@ -13,6 +13,8 @@ serde.workspace = true serde_json.workspace = true data-exchange = { path = "../data-exchange" } file-ingest = { path = "../file-ingest" } +preview = { path = "../preview" } +qrate-export = { path = "../qrate-export" } diagnostics = { path = "../diagnostics" } # Column configs can carry a plugin's mapping, and this is where its shape lives. plugin-api = { path = "../plugin-api" } diff --git a/crates/project-wizard/src/example.rs b/crates/project-wizard/src/example.rs new file mode 100644 index 00000000..44f96ae8 --- /dev/null +++ b/crates/project-wizard/src/example.rs @@ -0,0 +1,152 @@ +//! "Explore an example project" on the first-run launcher: the repo's `sample/` collection, built +//! into a real `.qrate` the first time someone asks for it and reopened after that. +//! +//! The sample is compiled in rather than looked up next to the executable, so the button works the +//! same from an installer, a portable zip and `cargo run`. It is small — a four-row sheet, its +//! column config and four photos — which is what makes that affordable. +//! +//! The project is written under qrate's data folder, not beside the archivist's own work: it is +//! qrate's to recreate, and edits made while exploring it are kept until the file is deleted. + +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; + +use crate::data; +use crate::project; +use crate::steps::review::{column_settings, project_columns}; + +const PROJECT_NAME: &str = "Aderman Example"; +const SHEET: &str = "aderman_collection.csv"; +const CONFIG: &str = "column_config.csv"; +const PHOTOS: &str = "photos"; +/// The sheet has no column called Title, so the photographer's own caption stands in for one: +/// it is the field that reads as a name for each slide. +const TITLE_COLUMN: &str = "Photographer’s Note"; +/// Declared `Filename` in the sample's column config — `1` resolves to `photos/1.jpg`. +const FILE_COLUMN: &str = "Digital ID"; + +const SAMPLE: [(&str, &[u8]); 6] = [ + ( + SHEET, + include_bytes!("../../../sample/aderman_collection.csv"), + ), + (CONFIG, include_bytes!("../../../sample/column_config.csv")), + ( + "photos/1.jpg", + include_bytes!("../../../sample/photos/1.jpg"), + ), + ( + "photos/2.jpg", + include_bytes!("../../../sample/photos/2.jpg"), + ), + ( + "photos/3.jpg", + include_bytes!("../../../sample/photos/3.jpg"), + ), + ( + "photos/4.jpg", + include_bytes!("../../../sample/photos/4.jpg"), + ), +]; + +/// The launcher card's thumbnail: the first slide of the collection it opens. +pub(crate) const THUMBNAIL: &[u8] = include_bytes!("../../../sample/photos/1.jpg"); + +/// Where the example lives: its own folder in qrate's data directory. +fn example_dir() -> Option { + settings::data_dir().map(|dir| dir.join("example")) +} + +/// The example's `.qrate` file, created on first use. Later calls return the existing file +/// untouched, so whatever someone changed while exploring is still there next time. +pub(crate) fn ensure_example_project(cx: &gpui::App) -> anyhow::Result { + let dir = example_dir().context("couldn't find qrate's data folder")?; + let file = project::project_file_path(&dir.to_string_lossy(), PROJECT_NAME); + if file.exists() { + return Ok(file); + } + write_sample(&dir)?; + create_project(&dir, cx)?; + Ok(file) +} + +/// Unpacks the sample. Files already there are overwritten: a half-written folder from an earlier +/// failed attempt is the only way one exists without the project beside it. +fn write_sample(dir: &Path) -> anyhow::Result<()> { + for (name, bytes) in SAMPLE { + let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, bytes).with_context(|| format!("couldn't write {name}"))?; + } + Ok(()) +} + +/// The same `.qrate` the wizard would make from Spreadsheet + folder with the sample's column +/// config loaded, without the wizard: its columns, rows, and the column settings `__columns` +/// can't hold (authority, spell checking, severity). +fn create_project(dir: &Path, cx: &gpui::App) -> anyhow::Result<()> { + let sheet = dir.join(SHEET); + let preview = data::load_spreadsheet_preview(&sheet.to_string_lossy()) + .map_err(|e| anyhow::anyhow!("{}", e.message()))?; + let config = data::load_column_config(&dir.join(CONFIG).to_string_lossy(), &preview.headers) + .map_err(|e| anyhow::anyhow!("{}", e.message()))?; + let columns = project_columns(&preview.headers, Some(&config), TITLE_COLUMN, FILE_COLUMN); + let photos = dir.join(PHOTOS); + let photos = photos.to_string_lossy(); + let file = project::write_project_file( + &dir.to_string_lossy(), + &project::ProjectSpec { + name: PROJECT_NAME, + source: "Spreadsheet + folder", + link_method: Some("exact filename"), + files_folder: Some(&photos), + columns: &columns, + headers: &preview.headers, + rows: &preview.rows, + }, + )?; + for (key, value) in [ + ( + settings::onboarding::GUIDE_KEY, + settings::onboarding::GUIDE_OPEN, + ), + ( + settings::onboarding::GUIDE_KIND_KEY, + settings::onboarding::GuideKind::Media.as_str(), + ), + ] { + settings::project::write_setting(Path::new(&file), key, value)?; + } + let settings = column_settings(&preview.headers, Some(&config), cx); + if !settings.is_empty() { + settings::project::write_setting( + Path::new(&file), + settings::columns::COLUMN_SETTINGS_KEY, + &serde_json::to_string(&settings)?, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + /// The committed sample is what the launcher's example unpacks; a renamed column would leave + /// it without a title or file role and fail only when somebody clicks the button. + #[test] + fn the_example_roles_are_columns_of_the_sample() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../sample"); + let headers = + crate::data::load_spreadsheet_preview(root.join(super::SHEET).to_str().unwrap()) + .unwrap() + .headers; + for role in [super::TITLE_COLUMN, super::FILE_COLUMN] { + assert!( + headers.iter().any(|h| h == role), + "{role} is not in the sample" + ); + } + } +} diff --git a/crates/project-wizard/src/launcher.rs b/crates/project-wizard/src/launcher.rs index e9ce8328..67b15ca9 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -6,19 +6,29 @@ //! can't depend on without a cycle. Callers pass that behavior in as a plain //! `fn` pointer (mirrors `SettingsWindow::new`'s `build_pages: fn() -> ...`). +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + use gpui::{prelude::FluentBuilder, *}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::label::Label; use gpui_component::scroll::ScrollableElement; -use gpui_component::{ActiveTheme, IconName, Root, Sizable, StyledExt, TitleBar, h_flex, v_flex}; +use gpui_component::{ + ActiveTheme, Icon, IconName, Root, Sizable, StyledExt, TitleBar, h_flex, v_flex, +}; use window_wrapper::WindowRegistry; +use crate::example; use crate::project; use crate::recent::{self, RecentProject}; use crate::wizard::{self, EntryKind}; pub const LAUNCHER_WINDOW_KIND: &str = "project-launcher"; +/// Development launch mode: show the first-run welcome even with recent projects. +pub struct OnboardingPreview; +impl Global for OnboardingPreview {} + /// The launcher's one spacing unit — the window's inset on every side, what each column keeps off /// the divider, and the air under each heading. One value so nothing here drifts a few pixels away /// from the rest and makes the window look assembled from parts. @@ -53,6 +63,7 @@ impl Global for LauncherHooks {} pub struct Launcher { recents: Vec, + previews: HashMap, /// Shown above the recents list when opening a project fails (missing or /// unreadable `.qrate`). error: Option, @@ -68,8 +79,50 @@ impl Launcher { .try_global::() .copied() .map(|hooks| (hooks.title_items)(cx)); + let recents = if cx.has_global::() { + Vec::new() + } else { + recent::list(cx) + }; + // Remembered thumbnails paint at once; the check below only walks a folder whose cache + // is missing or stale. + let previews = recents + .iter() + .filter_map(|project| { + let image = project.preview.as_ref()?.image.clone()?; + Some((project.path.clone(), image)) + }) + .collect(); + if !recents.is_empty() { + let cached: Vec<_> = recents + .iter() + .map(|project| (project.path.clone(), project.preview.clone())) + .collect(); + let scan = cx.background_spawn(async move { + cached + .into_iter() + .filter_map(|(path, cached)| { + recent::preview(Path::new(&path), cached).map(|preview| (path, preview)) + }) + .collect::>() + }); + cx.spawn(async move |this, cx| { + let resolved = scan.await; + this.update(cx, |this, cx| { + this.previews = resolved + .iter() + .filter_map(|(path, preview)| Some((path.clone(), preview.image.clone()?))) + .collect(); + recent::store_previews(resolved, cx); + cx.notify(); + }) + .ok(); + }) + .detach(); + } Self { - recents: recent::list(cx), + recents, + previews, error: None, title_items, } @@ -144,6 +197,137 @@ impl Launcher { .detach(); } + /// "Open example" — unpacks the bundled sample collection the first time, then opens it like + /// any other project, so the Getting started guide and Problems behave as they would for real. + fn open_example(&mut self, window: &mut Window, cx: &mut Context) { + match example::ensure_example_project(cx) { + Ok(file) => self.open_project_file(file.to_string_lossy().into_owned(), window, cx), + Err(e) => { + self.error = Some(format!("Couldn't set up the example project — {e}").into()); + cx.notify(); + } + } + } + + /// First run, 1b: a short welcome where the recents list would be — what qrate is, the two + /// things people worry about (an account, their media), the example, and opening a project + /// that already exists as its own button so it can't be mistaken for a way to create one. + fn render_welcome(&self, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let reassurance = |strong: &'static str, rest: &'static str| { + h_flex() + .gap_2() + .items_center() + .text_sm() + .child( + Icon::new(IconName::CircleCheck) + .small() + .text_color(cx.theme().success), + ) + .child(div().font_semibold().child(strong)) + .child(div().text_color(muted).child(rest)) + }; + v_flex() + .flex_1() + .min_h(px(0.)) + .min_w(px(0.)) + .pr(GAP) + .child( + div() + .text_lg() + .font_semibold() + .pb_1() + .child("Welcome to qrate"), + ) + .child( + div() + .text_sm() + .child("Build a catalog from a spreadsheet, or start with a blank one."), + ) + .child( + v_flex() + .gap_1p5() + .mt_3p5() + .child(reassurance( + "No account needed.", + "Projects live on this computer.", + )) + .child(reassurance( + "Your media stays in its folder.", + "qrate links to it.", + )), + ) + .when_some(self.error.clone(), |el, msg| { + el.child( + div() + .mt_3() + .text_sm() + .text_color(cx.theme().danger) + .child(msg), + ) + }) + .child( + h_flex() + .id("open-example") + .gap_3() + .items_center() + .mt(px(18.)) + .p_3() + .rounded_md() + .border_1() + .border_dashed() + .border_color(cx.theme().border) + .bg(cx.theme().tiles) + .child(project_thumbnail(Thumbnail::Example, px(56.), cx)) + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .gap_0p5() + .child( + div() + .font_semibold() + .text_sm() + .child("Explore an example project"), + ) + .child(div().text_sm().text_color(muted).child( + "Four slides from a family collection, with photos to browse \ + in Gallery and findings to review in Problems.", + )), + ) + .child( + Button::new("open-example-button") + .label("Open example") + .outline() + .small() + .on_click( + cx.listener(|this, _, window, cx| this.open_example(window, cx)), + ), + ), + ) + .child(div().flex_1()) + .child( + h_flex() + .gap_3() + .items_center() + .flex_wrap() + .pt(px(14.)) + .pb(px(18.)) + .border_t_1() + .border_color(cx.theme().border) + .child( + Button::new("open-existing") + .icon(IconName::FolderOpen) + .label("Open an existing .qrate project…") + .outline() + .on_click( + cx.listener(|this, _, window, cx| this.open_other(window, cx)), + ), + ) + .child(div().text_xs().text_color(muted).child("or drop one here")), + ) + } + fn start_new(&mut self, entry_kind: EntryKind, window: &mut Window, cx: &mut Context) { wizard::open_project_wizard(entry_kind, cx); // Close the launcher while the wizard is up; `go_back` from the wizard's @@ -261,13 +445,7 @@ impl Render for Launcher { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let dialog_layer = Root::render_dialog_layer(window, cx); - let recent_list = if self.recents.is_empty() { - v_flex().child( - Label::new("No recent projects yet — create one to get started.") - .text_sm() - .text_color(cx.theme().muted_foreground), - ) - } else { + let recent_list = { let mut list = v_flex().gap_0(); for (ix, project) in self.recents.iter().enumerate() { let path = project.path.clone(); @@ -281,14 +459,13 @@ impl Render for Launcher { .py_2() .when(ix > 0, |el| el.border_t_1().border_color(cx.theme().border)) .hover(|el| el.bg(cx.theme().secondary_hover)) - .child( - div() - .size_7() - .flex_none() - .rounded_md() - .border_1() - .border_color(cx.theme().border), - ) + .child(project_thumbnail( + Thumbnail::Recent( + self.previews.get(&project.path).map(PathBuf::as_path), + ), + px(28.), + cx, + )) .child( v_flex() .flex_1() @@ -362,65 +539,75 @@ impl Render for Launcher { // continuing rather than as ending in a band of empty window. .px(GAP) .pt(GAP - HEADING_LEAD) - .child( - v_flex() - .flex_1() - // min_h(0) overrides the flex default min-height:auto so the inner list scrolls, not the column. - .min_h(px(0.)) - .min_w(px(0.)) - .gap(px(0.)) - .child( - h_flex() - .flex_none() - .justify_between() - .items_baseline() - // Everything but the scrollbar keeps clear of the divider by - // the same amount the Create New column does. - .pr(GAP) - // The heading carries the air under it, not the list: the - // rows scroll, and a gap that scrolls with them is a gap that - // disappears the moment somebody uses the list. - .pb(GAP - HEADING_LEAD) - .child(div().text_lg().font_semibold().child("Recent Projects")) - .child( - div() - .id("open-other") - .text_sm() - .text_color(cx.theme().muted_foreground) - .cursor_pointer() - .hover(|el| el.text_color(cx.theme().primary)) - .child("Open other…") - .on_click(cx.listener(|this, _, window, cx| { - this.open_other(window, cx) - })), - ), - ) - .when_some(self.error.clone(), |el, msg| { - el.child( - div() + .when(self.recents.is_empty(), |body| { + body.child(self.render_welcome(cx)) + }) + .when(!self.recents.is_empty(), |body| { + body.child( + v_flex() + .flex_1() + // min_h(0) overrides the flex default min-height:auto so the inner list scrolls, not the column. + .min_h(px(0.)) + .min_w(px(0.)) + .gap(px(0.)) + .child( + h_flex() .flex_none() + .justify_between() + .items_baseline() + // Everything but the scrollbar keeps clear of the divider by + // the same amount the Create New column does. .pr(GAP) + // The heading carries the air under it, not the list: the + // rows scroll, and a gap that scrolls with them is a gap that + // disappears the moment somebody uses the list. .pb(GAP - HEADING_LEAD) - .text_sm() - .text_color(cx.theme().danger) - .child(msg), + .child( + div() + .text_lg() + .font_semibold() + .child("Recent Projects"), + ) + .child( + div() + .id("open-other") + .text_sm() + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .hover(|el| el.text_color(cx.theme().primary)) + .child("Open other…") + .on_click(cx.listener(|this, _, window, cx| { + this.open_other(window, cx) + })), + ), ) - }) - // Heading above stays pinned; only the list scrolls. - .child( - div() - .id("recents-scroll") - .flex_1() - .min_h(px(0.)) - // Scrollbar, not bare overflow: a launcher opened on a long - // history has to say there is more of it below the fold. - .overflow_y_scrollbar() - // Matches the inset on the other side of the divider, and holds - // the rows off the scrollbar drawn at this column's right edge. - .pr(GAP - SCROLLBAR) - .child(recent_list), - ), - ) + .when_some(self.error.clone(), |el, msg| { + el.child( + div() + .flex_none() + .pr(GAP) + .pb(GAP - HEADING_LEAD) + .text_sm() + .text_color(cx.theme().danger) + .child(msg), + ) + }) + // Heading above stays pinned; only the list scrolls. + .child( + div() + .id("recents-scroll") + .flex_1() + .min_h(px(0.)) + // Scrollbar, not bare overflow: a launcher opened on a long + // history has to say there is more of it below the fold. + .overflow_y_scrollbar() + // Matches the inset on the other side of the divider, and holds + // the rows off the scrollbar drawn at this column's right edge. + .pr(GAP - SCROLLBAR) + .child(recent_list), + ), + ) + }) .child( v_flex() .w(px(220.)) @@ -456,6 +643,49 @@ impl Render for Launcher { } } +enum Thumbnail<'a> { + Example, + Recent(Option<&'a Path>), +} + +fn project_thumbnail(image: Thumbnail<'_>, size: Pixels, cx: &App) -> AnyElement { + let frame = div() + .size(size) + .flex_none() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().tiles) + .overflow_hidden(); + match image { + Thumbnail::Example => frame + .child( + img(std::sync::Arc::new(Image::from_bytes( + ImageFormat::Jpeg, + example::THUMBNAIL.to_vec(), + ))) + .size_full() + .object_fit(ObjectFit::Cover), + ) + .into_any_element(), + Thumbnail::Recent(Some(path)) => frame + .child(preview::thumb( + Some(path), + preview::CARD, + ObjectFit::Cover, + cx, + )) + .into_any_element(), + Thumbnail::Recent(None) => frame + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::File).size_4()) + .into_any_element(), + } +} + fn create_card( id: &'static str, title: &'static str, diff --git a/crates/project-wizard/src/lib.rs b/crates/project-wizard/src/lib.rs index ce93d63b..5ae27a88 100644 --- a/crates/project-wizard/src/lib.rs +++ b/crates/project-wizard/src/lib.rs @@ -5,6 +5,7 @@ mod column_config; mod data; +mod example; pub mod launcher; mod project; mod recent; @@ -12,7 +13,9 @@ mod steps; pub mod wizard; pub use column_config::open_column_config_dialog; -pub use launcher::{LauncherHooks, open_launcher_window, open_launcher_with_error}; +pub use launcher::{ + LauncherHooks, OnboardingPreview, open_launcher_window, open_launcher_with_error, +}; pub use project::open_project; pub use recent::record_opened; pub use wizard::{EntryKind, open_project_wizard}; diff --git a/crates/project-wizard/src/recent.rs b/crates/project-wizard/src/recent.rs index cb7db0b2..2bac2e7b 100644 --- a/crates/project-wizard/src/recent.rs +++ b/crates/project-wizard/src/recent.rs @@ -5,6 +5,7 @@ use gpui::App; use serde::{Deserialize, Serialize}; use settings::AppSettings; +use std::path::{Path, PathBuf}; const RECENTS_KEY: &str = "project_wizard.recent_projects"; const MAX_RECENTS: usize = 20; @@ -14,6 +15,17 @@ pub struct RecentProject { pub name: String, pub path: String, pub opened_at_unix: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, +} + +/// A recent project's launcher thumbnail, remembered with the folder it was found in so the +/// launcher doesn't walk that folder (often an external drive) every time it opens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CachedPreview { + pub folder: String, + /// `None` when the folder held nothing previewable. + pub image: Option, } pub fn list(cx: &App) -> Vec { @@ -28,15 +40,99 @@ pub fn list(cx: &App) -> Vec { serde_json::from_str(&raw).unwrap_or_default() } +/// The launcher thumbnail for a recent project. `cached` is reused while the project still links +/// the same folder and its file is still there; a folder that isn't reachable (an unplugged drive) +/// is never scanned, and keeps its cache for when it's back. `None` when there's nothing to show +/// or to remember. +pub fn preview(project_file: &Path, cached: Option) -> Option { + let folder = settings::project::read_setting(project_file, settings::project::FILES_FOLDER_KEY) + .ok()??; + if folder.trim().is_empty() { + return None; + } + let reachable = Path::new(&folder).is_dir(); + if let Some(cached) = cached.filter(|cached| cached.folder == folder) + && cached + .image + .as_ref() + .is_none_or(|image| !reachable || image.exists()) + { + return Some(cached); + } + if !reachable { + return None; + } + let image = scan_preview(project_file, Path::new(&folder)); + Some(CachedPreview { folder, image }) +} + +/// Walks `root` for a representative file: the first row's linked file, else any previewable one. +/// Uses the same filename index the table does. +fn scan_preview(project_file: &Path, root: &Path) -> Option { + let data = settings::project::load_project_file(project_file).ok()?; + let paths = file_ingest::scan(root, true).ok()?; + let index = qrate_export::PhotoIndex::from_paths( + paths.files().map(|entry| entry.relative_path.clone()), + ); + let declared: Vec<_> = data + .columns + .iter() + .filter(|column| { + settings::columns::ColumnType::from_declared(&column.data_type) + == settings::columns::ColumnType::Filename + }) + .map(|column| column.name.clone()) + .collect(); + data.rows + .iter() + .find_map(|row| { + let relative = index.resolve_row(&data.headers, &declared, row)?; + let path = if relative.is_absolute() { + relative + } else { + root.join(relative) + }; + preview::can_preview(&path).then_some(path) + }) + .or_else(|| { + paths + .files() + .map(|entry| root.join(&entry.relative_path)) + .find(|path| preview::can_preview(path)) + }) +} + +/// Remembers freshly resolved thumbnails, keyed by project path. Entries dropped from the list +/// while the scan ran are skipped. +pub fn store_previews(previews: Vec<(String, CachedPreview)>, cx: &mut App) { + let mut recents = list(cx); + let mut changed = false; + for (path, preview) in previews { + if let Some(project) = recents.iter_mut().find(|p| p.path == path) + && project.preview.as_ref() != Some(&preview) + { + project.preview = Some(preview); + changed = true; + } + } + if changed && let Ok(json) = serde_json::to_string(&recents) { + AppSettings::set_text(RECENTS_KEY, json.into(), cx); + } +} + pub fn record_opened(name: String, path: String, cx: &mut App) { let mut recents = list(cx); - recents.retain(|p| p.path != path); + let preview = recents + .iter() + .position(|p| p.path == path) + .and_then(|ix| recents.remove(ix).preview); recents.insert( 0, RecentProject { name, path, opened_at_unix: now_unix(), + preview, }, ); recents.truncate(MAX_RECENTS); @@ -86,3 +182,119 @@ pub fn relative_time(opened_at_unix: i64) -> String { ago(delta / (86_400 * 30), "month") } } + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::{CachedPreview, preview}; + use settings::project::{ProjectColumn, ProjectSpec, create_project_file}; + + /// A project linking `folder`, with one Filename column when `rows` is non-empty. + fn project_linking(dir: &Path, folder: &Path, rows: &[&str]) -> PathBuf { + let project = dir.join("collection.qrate"); + let columns = [ProjectColumn { + name: "Image".into(), + data_type: "Filename".into(), + notes: String::new(), + }]; + let headers = ["Image".to_string()]; + let rows: Vec> = rows.iter().map(|row| vec![row.to_string()]).collect(); + let folder = folder.to_string_lossy(); + create_project_file( + &project, + &ProjectSpec { + name: "Collection", + files_folder: Some(&folder), + columns: &columns[..usize::from(!rows.is_empty())], + headers: &headers[..usize::from(!rows.is_empty())], + rows: &rows, + ..Default::default() + }, + ) + .unwrap(); + project + } + + fn image_of(project: &Path, cached: Option) -> Option { + preview(project, cached).and_then(|preview| preview.image) + } + + #[test] + fn recent_project_preview_resolves_nested_row_image() { + let temp = tempfile::tempdir().unwrap(); + let files = temp.path().join("photos"); + let nested = files.join("batch"); + std::fs::create_dir_all(&nested).unwrap(); + let image = nested.join("object.jpg"); + std::fs::write(&image, b"thumbnail source").unwrap(); + let project = project_linking(temp.path(), &files, &["object.jpg"]); + + assert_eq!(image_of(&project, None), Some(image)); + } + + #[test] + fn recent_project_preview_uses_linked_folder_without_image_rows() { + let temp = tempfile::tempdir().unwrap(); + let files = temp.path().join("photos"); + std::fs::create_dir(&files).unwrap(); + let image = files.join("object.jpg"); + std::fs::write(&image, b"thumbnail source").unwrap(); + let project = project_linking(temp.path(), &files, &[]); + + assert_eq!(image_of(&project, None), Some(image)); + } + + /// The point of the cache: a folder on an external drive is walked once, not per launcher. + #[test] + fn a_cached_preview_is_reused_without_rescanning() { + let temp = tempfile::tempdir().unwrap(); + let files = temp.path().join("photos"); + std::fs::create_dir(&files).unwrap(); + std::fs::write(files.join("scanned.jpg"), b"x").unwrap(); + let remembered = files.join("remembered.jpg"); + std::fs::write(&remembered, b"x").unwrap(); + let project = project_linking(temp.path(), &files, &[]); + let cached = CachedPreview { + folder: files.to_string_lossy().into_owned(), + image: Some(remembered.clone()), + }; + + assert_eq!(image_of(&project, Some(cached)), Some(remembered)); + } + + #[test] + fn an_unplugged_drive_keeps_its_cache_and_is_not_scanned() { + let temp = tempfile::tempdir().unwrap(); + let files = temp.path().join("unplugged"); + let project = project_linking(temp.path(), &files, &[]); + let cached = CachedPreview { + folder: files.to_string_lossy().into_owned(), + image: Some(files.join("object.jpg")), + }; + + assert_eq!(preview(&project, Some(cached.clone())), Some(cached)); + assert_eq!(preview(&project, None), None, "nothing to remember yet"); + } + + #[test] + fn a_cache_for_another_folder_or_a_deleted_file_is_rescanned() { + let temp = tempfile::tempdir().unwrap(); + let files = temp.path().join("photos"); + std::fs::create_dir(&files).unwrap(); + let image = files.join("object.jpg"); + std::fs::write(&image, b"x").unwrap(); + let project = project_linking(temp.path(), &files, &[]); + let deleted = CachedPreview { + folder: files.to_string_lossy().into_owned(), + image: Some(files.join("gone.jpg")), + }; + let relinked = CachedPreview { + folder: "elsewhere".into(), + image: None, + }; + + assert_eq!(image_of(&project, Some(deleted)), Some(image.clone())); + assert_eq!(image_of(&project, Some(relinked)), Some(image)); + } +} diff --git a/crates/project-wizard/src/steps/columns.rs b/crates/project-wizard/src/steps/columns.rs index ce191a66..7c7a9980 100644 --- a/crates/project-wizard/src/steps/columns.rs +++ b/crates/project-wizard/src/steps/columns.rs @@ -7,7 +7,9 @@ use gpui_component::combobox::{Combobox, ComboboxState}; use gpui_component::dialog::DialogButtonProps; use gpui_component::label::Label; use gpui_component::searchable_list::SearchableVec; -use gpui_component::{ActiveTheme, IndexPath, Sizable, StyledExt, WindowExt, v_flex}; +use gpui_component::{ + ActiveTheme, Icon, IconName, IndexPath, Sizable, StyledExt, WindowExt, h_flex, v_flex, +}; use crate::column_config::{ColumnConfigLoader, mapping}; use crate::data; @@ -264,7 +266,33 @@ impl ProjectWizard { .small() .w_full() .placeholder("Choose a column…"), - ), + ) + // The rule still asks for a File column when files are deferred; say + // why that costs nothing, rather than letting it read as "link now". + .when(self.skip_files, |el| { + el.child( + h_flex() + .gap_1p5() + .items_start() + .mt_0p5() + .child( + Icon::new(IconName::Info) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .flex_1() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child( + "You're adding files later, so this column \ + can stay empty for now. qrate fills it when \ + you link a folder.", + ), + ), + ) + }), ) .when(missing, |block| { block.child(inline_message( diff --git a/crates/project-wizard/src/steps/files.rs b/crates/project-wizard/src/steps/files.rs index a5bed550..0242831d 100644 --- a/crates/project-wizard/src/steps/files.rs +++ b/crates/project-wizard/src/steps/files.rs @@ -2,15 +2,16 @@ use gpui::{prelude::FluentBuilder, *}; use gpui_component::alert::Alert; use gpui_component::button::Button; use gpui_component::checkbox::Checkbox; +use gpui_component::collapsible::Collapsible; use gpui_component::input::Input; use gpui_component::label::Label; use gpui_component::text::Text; -use gpui_component::{ActiveTheme, Disableable, Selectable, Sizable, StyledExt, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Icon, IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; use file_ingest::duplicates::DuplicatePolicy; use crate::data; -use crate::wizard::{EntryKind, ProjectWizard}; +use crate::wizard::{EntryKind, ProjectWizard, option_card}; pub(crate) fn inline_message( id: impl Into, @@ -369,8 +370,21 @@ impl ProjectWizard { .border_color(border) .text_sm() .text_color(muted) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() .child(text) }; + // A label with its requirement on the right: "Spreadsheet … Required", "Files … Optional". + let field_heading = |label: &'static str, tag: Option<&'static str>| { + h_flex() + .justify_between() + .items_baseline() + .child(Label::new(label).text_sm()) + .when_some(tag, |el, tag| { + el.child(div().text_xs().text_color(muted).child(tag)) + }) + }; let (title, browse_id) = match self.entry_kind { EntryKind::LocalFile => ("Choose your spreadsheet & files", "browse-folder"), EntryKind::Sheet => ("Connect your Google Sheet", "browse-folder-sheet"), @@ -381,192 +395,230 @@ impl ProjectWizard { .folder_match .as_ref() .map_or(0, |matched| matched.ambiguous_files); - let description_profile = self.description_profile; - let duplicate_policy = self.duplicate_policy; - let dimmed = self.skip_files; + let skip_files = self.skip_files; let checking = !self.import_paths.is_empty() && self.folder_match.is_none() && self.folder_error.is_none() && (self.entry_kind == EntryKind::Blank || self.spreadsheet_preview.is_some()); + // Once files are deferred, the spreadsheet is the only thing this step asks for, so it + // says so instead of leaving the archivist to wonder whether "later" covered it too. + let source_tag = skip_files.then_some("Required"); - let body = v_flex() - .gap_3() - .child(div().text_lg().font_semibold().child(title)) - .child(match self.entry_kind { - EntryKind::LocalFile => { - v_flex() - .gap_1() - .child(Label::new("Spreadsheet (CSV, Excel, ODS)").text_sm()) + let source = match self.entry_kind { + EntryKind::LocalFile => v_flex() + .gap_1() + .child(field_heading("Spreadsheet (CSV, Excel, ODS)", source_tag)) + .child( + h_flex() + .gap_2() + .child(path_box(if self.local_path.is_empty() { + "Choose a spreadsheet file…".to_string() + } else { + self.local_path.clone() + })) .child( - h_flex() - .gap_2() - .child(path_box(if self.local_path.is_empty() { - "Choose a spreadsheet file…".to_string() - } else { - self.local_path.clone() - })) - .child( - Button::new("browse-local") - .label("Browse…") - .outline() - .on_click(cx.listener(|this, _, _, cx| { - this.browse_for_local_file(cx) - })), + Button::new("browse-local") + .label("Browse…") + .outline() + .on_click( + cx.listener(|this, _, _, cx| this.browse_for_local_file(cx)), ), - ) - .child(match (&self.spreadsheet_preview, &self.local_error) { - (Some(p), _) => inline_message( - "local-status", - format!("{} rows, {} columns found", p.rows.len(), p.headers.len()), - MsgKind::Success, - ) - .into_any_element(), - (None, Some(e)) => { - inline_message("local-status", e.clone(), MsgKind::Error) - .into_any_element() - } - (None, None) if !self.local_path.is_empty() => { - Label::new("Reading the spreadsheet…") - .text_sm() - .text_color(muted) - .into_any_element() + ), + ) + .child(match (&self.spreadsheet_preview, &self.local_error) { + (Some(p), _) => inline_message( + "local-status", + format!("{} rows, {} columns found", p.rows.len(), p.headers.len()), + MsgKind::Success, + ) + .into_any_element(), + (None, Some(e)) => { + inline_message("local-status", e.clone(), MsgKind::Error).into_any_element() + } + (None, None) if !self.local_path.is_empty() => { + Label::new("Reading the spreadsheet…") + .text_sm() + .text_color(muted) + .into_any_element() + } + (None, None) => div().into_any_element(), + }) + .into_any_element(), + EntryKind::Sheet => v_flex() + .gap_1() + .child(field_heading("Sheet link", source_tag)) + .child( + h_flex() + .gap_2() + .child(Input::new(&self.sheet_link_input).flex_1()) + .child( + Button::new("check-sheet") + .label("Check") + .outline() + .on_click( + cx.listener(|this, _, _, cx| this.check_sheet_link(false, cx)), + ), + ), + ) + .child(match (&self.sheet_check, &self.sheet_error) { + (Some(c), _) => inline_message( + "sheet-status", + format!( + "Found \"{}\" — {} rows{}", + c.title, + c.row_count, + if c.used_first_tab { + " (using the first tab, 'Sheet1')" + } else { + "" } - (None, None) => div().into_any_element(), - }) - .into_any_element() - } - EntryKind::Sheet => v_flex() - .gap_1() - .child(Label::new("Sheet link").text_sm()) - .child( - h_flex() - .gap_2() - .child(Input::new(&self.sheet_link_input).flex_1()) - .child( - Button::new("check-sheet") - .label("Check") - .outline() - .on_click(cx.listener(|this, _, _, cx| { - this.check_sheet_link(false, cx) - })), - ), + ), + MsgKind::Success, ) - .child(match (&self.sheet_check, &self.sheet_error) { - (Some(c), _) => inline_message( - "sheet-status", - format!( - "Found \"{}\" — {} rows{}", - c.title, - c.row_count, - if c.used_first_tab { - " (using the first tab, 'Sheet1')" - } else { - "" - } - ), - MsgKind::Success, - ) - .into_any_element(), - (None, Some(e)) => { - inline_message("sheet-status", e.clone(), MsgKind::Error) - .into_any_element() - } - (None, None) => div().into_any_element(), - }) .into_any_element(), - EntryKind::Blank => { - Label::new("Point qrate at a folder of files, or skip and add them later.") - .text_sm() - .text_color(muted) - .into_any_element() - } - }) + (None, Some(e)) => { + inline_message("sheet-status", e.clone(), MsgKind::Error).into_any_element() + } + (None, None) => div().into_any_element(), + }) + .into_any_element(), + EntryKind::Blank => { + Label::new("Point qrate at a folder of files, or skip and add them later.") + .text_sm() + .text_color(muted) + .into_any_element() + } + }; + + // The two-way choice that replaced the "I'll add a files folder later" checkbox at the + // bottom of the step: the choice now sits beside the thing it decides about. + let choice = h_flex() + .gap_2() + .items_stretch() .child( - v_flex() - .gap_1() - .when(dimmed, |el| el.opacity(0.4)) - .child(Label::new("Files folder").text_sm()) - .child( + option_card( + "files-link-now", + "Link a files folder", + "Match photos and scans to rows now.", + !skip_files, + cx, + ) + .flex_1() + .on_click(cx.listener(|this, _, _, cx| { + this.skip_files = false; + cx.notify(); + })), + ) + .child( + option_card( + "files-add-later", + "Add files later", + if self.entry_kind == EntryKind::Blank { + "Start with an empty table. Skips linking." + } else { + "Import the spreadsheet only. Skips linking." + }, + skip_files, + cx, + ) + .flex_1() + .on_click(cx.listener(|this, _, _, cx| { + this.skip_files = true; + cx.notify(); + })), + ); + + let folder_status = match (&self.folder_match, &self.folder_error) { + (Some(m), _) if self.entry_kind == EntryKind::Blank => inline_message( + "folder-status", + format!( + "{} file{} will become table rows", + m.extra_files.len(), + if m.extra_files.len() == 1 { "" } else { "s" }, + ), + MsgKind::Success, + ) + .into_any_element(), + (Some(m), _) + if m.matched_rows == m.total_rows + && m.extra_files.is_empty() + && m.ambiguous_files == 0 => + { + inline_message( + "folder-status", + format!("{} of {} files matched", m.matched_rows, m.total_rows), + MsgKind::Success, + ) + .into_any_element() + } + (Some(m), _) => self.mismatch_breakdown(m, cx).into_any_element(), + (None, Some(e)) => { + inline_message("folder-status", e.clone(), MsgKind::Error).into_any_element() + } + (None, None) if checking => Label::new("Checking the folder…") + .text_sm() + .text_color(muted) + .into_any_element(), + (None, None) => div().into_any_element(), + }; + + let files = v_flex() + .gap_1p5() + .pt_2p5() + .border_t_1() + .border_color(border) + .when(self.entry_kind != EntryKind::Blank, |el| { + el.child(field_heading("Files", Some("Optional"))) + }) + .child(choice) + .map(|el| { + if skip_files { + el.child(self.deferred_files_summary(cx)) + } else { + el.child( h_flex() .gap_2() + .mt_1() .child(path_box(if self.folder_path.is_empty() { "Choose your files folder…".to_string() } else { self.folder_path.clone() })) - .child( - Button::new(browse_id) - .label("Browse…") - .outline() - .disabled(dimmed) - .on_click( - cx.listener(|this, _, _, cx| this.browse_for_folder(cx)), - ), - ), + .child(Button::new(browse_id).label("Browse…").outline().on_click( + cx.listener(|this, _, _, cx| this.browse_for_folder(cx)), + )), ) - .child(match (&self.folder_match, &self.folder_error) { - (Some(m), _) if self.entry_kind == EntryKind::Blank => inline_message( - "folder-status", - format!( - "{} file{} will become table rows", - m.extra_files.len(), - if m.extra_files.len() == 1 { "" } else { "s" }, - ), - MsgKind::Success, - ) - .into_any_element(), - (Some(m), _) if m.matched_rows == m.total_rows => inline_message( - "folder-status", - format!("{} of {} files matched", m.matched_rows, m.total_rows), - MsgKind::Success, - ) - .into_any_element(), - (Some(m), _) => inline_message( - "folder-status", - format!( - "Matched {} of {} files — review mismatches", - m.matched_rows, m.total_rows - ), - MsgKind::Warning, - ) - .into_any_element(), - (None, Some(e)) => { - inline_message("folder-status", e.clone(), MsgKind::Error) - .into_any_element() - } - (None, None) if checking => Label::new("Checking the folder…") - .text_sm() - .text_color(muted) - .into_any_element(), - (None, None) => div().into_any_element(), - }), - ) - .when( - self.entry_kind == EntryKind::Blank && self.folder_path.is_empty() && selected > 0, - |files| { - files.child(inline_message( - "selected-import-paths", - format!( - "Selected {selected} file{} or folder{}.", - if selected == 1 { "" } else { "s" }, - if selected == 1 { "" } else { "s" } - ), - MsgKind::Success, - )) - }, - ); + .child(folder_status) + .when( + self.entry_kind == EntryKind::Blank + && self.folder_path.is_empty() + && selected > 0, + |files| { + files.child(inline_message( + "selected-import-paths", + format!( + "Selected {selected} file{} or folder{}.", + if selected == 1 { "" } else { "s" }, + if selected == 1 { "" } else { "s" } + ), + MsgKind::Success, + )) + }, + ) + } + }); v_flex() .id("files-drop-area") .gap_3() - .drag_over::(|style, _, _, cx| { - style.bg(cx.theme().secondary_hover) - }) + .drag_over::(|style, _, _, cx| style.bg(cx.theme().secondary_hover)) .on_drop(cx.listener(|this, paths: &ExternalPaths, _, cx| { let paths = paths.paths(); if this.entry_kind == EntryKind::Blank { + this.skip_files = false; this.set_import_paths(paths.to_vec(), cx); } else if paths.len() == 1 && paths[0].is_dir() { + this.skip_files = false; this.set_folder_path(paths[0].to_string_lossy().into_owned(), cx); } else if this.entry_kind == EntryKind::LocalFile && paths.len() == 1 @@ -581,108 +633,299 @@ impl ProjectWizard { } cx.notify(); })) - .child(body) - .when(!self.skip_files, |this| { - this.child( - Checkbox::new("include-root-folder") - .label("Create a row for the selected files folder") - .checked(self.include_root_folder) - .on_click(cx.listener(|this, checked: &bool, _, cx| { - this.include_root_folder = *checked; - if !this.import_paths.is_empty() { - if this.folder_path.is_empty() { - this.set_import_paths(this.import_paths.clone(), cx); - } else { - this.revalidate_folder_in_background(cx); - } - } - cx.notify(); - })), - ) - .child( - Label::new( - "Leave this off when the folder only contains this project's material.", - ) + .child(div().text_lg().font_semibold().child(title)) + .child(source) + .child(files) + .when(!skip_files, |el| el.child(self.render_files_advanced(ambiguous, cx))) + } + + /// 3b: a partial match, broken down by what happens to each group. Every sentence describes + /// behaviour qrate already has — unmatched files become rows, missing files reach Problems. + fn mismatch_breakdown( + &self, + m: &data::FolderMatch, + cx: &mut Context, + ) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let foreground = cx.theme().foreground; + let warning = cx.theme().warning; + let missing = m.total_rows.saturating_sub(m.matched_rows); + let extra = m.extra_files.len(); + let ambiguous = m.ambiguous_files; + let plural = |n: usize, one: &'static str, many: &'static str| { + format!("{n} {}", if n == 1 { one } else { many }) + }; + let line = |count: String, rest: &'static str| { + div().text_sm().text_color(muted).child( + h_flex() + .flex_wrap() + .gap_1() + .child(div().text_color(foreground).child(count)) + .child(rest), + ) + }; + v_flex() + .gap_1p5() + .px_2p5() + .py_2() + .rounded_md() + .border_1() + .border_color(warning.opacity(0.6)) + .bg(warning.opacity(0.1)) + .child( + h_flex() + .gap_2() + .items_center() .text_sm() - .text_color(muted), - ) - .child( - v_flex() - .gap_2() - .pt_2() - .border_t_1() - .border_color(border) - .child(Label::new("Archival description standard").text_sm()) - .child( - Label::new( - "Folder and file rows become archival components using this vocabulary.", - ) - .text_sm() - .text_color(muted), - ) - .child( - h_flex().gap_1().flex_wrap().children( - settings::description::DescriptionProfile::ALL - .into_iter() - .enumerate() - .map(|(index, profile)| { - Button::new(("description-profile", index)) - .label(profile.label()) - .outline() - .selected(profile == description_profile) - .on_click(cx.listener(move |this, _, window, cx| { - this.description_profile = profile; - let defaults = profile.defaults(); - let label_for = |key: &str, fallback| { - defaults - .levels - .iter() - .find(|level| level.key == key) - .map_or(fallback, |level| { - level.label.clone() - }) - }; - let folder_label = label_for( - &defaults.folder_level_key, - "Group".to_string(), - ); - let file_label = label_for( - &defaults.file_level_key, - "Item".to_string(), - ); - this.folder_level_input.update(cx, |input, cx| { - input.set_value(folder_label, window, cx) - }); - this.file_level_input.update(cx, |input, cx| { - input.set_value(file_label, window, cx) - }); - cx.notify(); - })) - }), - ), - ) - .child( + .child( + Icon::new(IconName::TriangleAlert) + .small() + .text_color(warning), + ) + .child(format!( + "Matched {} of {} files — review mismatches", + m.matched_rows, m.total_rows + )), + ) + .child( + v_flex() + .gap_0p5() + .pl_6() + .when(missing > 0, |el| { + el.child(line( + plural(missing, "row", "rows"), + "name a file that isn't in this folder. They'll be listed in Problems.", + )) + }) + .when(extra > 0, |el| { + el.child(line( + plural(extra, "file", "files"), + "aren't named by any row. Each arrives as its own row.", + )) + }) + .when(ambiguous > 0, |el| { + el.child( h_flex() - .gap_2() + .flex_wrap() + .gap_1() + .text_sm() + .text_color(muted) .child( - v_flex() - .flex_1() - .gap_1() - .child(Label::new("Folders are called").text_sm()) - .child(Input::new(&self.folder_level_input)), + div() + .text_color(foreground) + .child(plural(ambiguous, "file", "files")), ) + .child(format!( + "{} named by more than one row. {}", + if ambiguous == 1 { "is" } else { "are" }, + duplicate_summary(self.duplicate_policy), + )) .child( - v_flex() - .flex_1() - .gap_1() - .child(Label::new("Files are called").text_sm()) - .child(Input::new(&self.file_level_input)), + div() + .id("duplicates-open-advanced") + .cursor_pointer() + .text_color(cx.theme().link) + .hover(|el| el.underline()) + .child("Change in Advanced") + .on_click(cx.listener(|this, _, _, cx| { + this.show_advanced_files = true; + cx.notify(); + })), ), + ) + }), + ) + } + + /// 3c: what "Add files later" means, in place of the folder field it removed. + fn deferred_files_summary(&self, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let rows = self.spreadsheet_preview.as_ref().map(|p| p.rows.len()); + let first = match (self.entry_kind, rows) { + (EntryKind::Blank, _) => "The project starts with one empty row to type into.".into(), + (EntryKind::Sheet, Some(n)) => format!("All {n} rows are imported from the sheet."), + (EntryKind::Sheet, None) => "Every row is imported from the sheet.".into(), + (_, Some(n)) => format!("All {n} rows are imported from the spreadsheet."), + (_, None) => "Every row is imported from the spreadsheet.".into(), + }; + let second = if self.entry_kind == EntryKind::Blank { + "No folder is added now." + } else { + "No folder is matched, and the Link step is skipped." + }; + let line = |mark: &'static str, mark_color: Hsla, text: AnyElement| { + h_flex() + .gap_2() + .items_start() + .child(div().flex_none().text_color(mark_color).child(mark)) + .child(div().flex_1().min_w(px(0.)).child(text)) + }; + v_flex() + .gap_1p5() + .px_3() + .py_2p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().tiles) + .text_sm() + .child(line("✓", cx.theme().success, first.into_any_element())) + .child(line( + "–", + muted, + div().text_color(muted).child(second).into_any_element(), + )) + .child(line( + "→", + muted, + h_flex() + .flex_wrap() + .gap_1() + .text_color(muted) + .child("Add a folder any time from") + .child( + div() + .text_color(cx.theme().foreground) + .child("File ▸ Import Files or Folders…"), + ) + .into_any_element(), + )) + } + + /// Folder rows, description standard and duplicate handling, folded into one block that + /// opens the same way the Columns step's Advanced mapping does. Closed, it reads back the + /// defaults in one line so nobody has to open it to know what will happen. + fn render_files_advanced(&self, ambiguous: usize, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let border = cx.theme().border; + let open = self.show_advanced_files; + let description_profile = self.description_profile; + let duplicate_policy = self.duplicate_policy; + let folder_label = self.folder_level_input.read(cx).value().trim().to_string(); + let file_label = self.file_level_input.read(cx).value().trim().to_string(); + let summary = { + let mut parts = vec![ + format!("Using {}", description_profile.label()), + format!("folders are {folder_label}, files are {file_label}"), + if self.include_root_folder { + "a row for the selected folder".to_string() + } else { + "no row for the selected folder".to_string() + }, + ]; + if ambiguous > 0 { + parts.push(format!( + "duplicates: {}", + duplicate_label(duplicate_policy).to_lowercase() + )); + } + format!("{}.", parts.join(" · ")) + }; + + let content = v_flex() + .gap_3() + .mt_2() + .child( + v_flex() + .gap_1() + .child( + Checkbox::new("include-root-folder") + .label("Create a row for the selected files folder") + .checked(self.include_root_folder) + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.include_root_folder = *checked; + if !this.import_paths.is_empty() { + if this.folder_path.is_empty() { + this.set_import_paths(this.import_paths.clone(), cx); + } else { + this.revalidate_folder_in_background(cx); + } + } + cx.notify(); + })), + ) + .child( + Label::new( + "Leave this off when the folder only contains this project's material.", + ) + .text_sm() + .text_color(muted), + ), + ) + .child( + v_flex() + .gap_2() + .pt_2() + .border_t_1() + .border_color(border) + .child(Label::new("Archival description standard").text_sm()) + .child( + Label::new( + "Folder and file rows become archival components using this vocabulary.", + ) + .text_sm() + .text_color(muted), + ) + .child( + h_flex().gap_1().flex_wrap().children( + settings::description::DescriptionProfile::ALL + .into_iter() + .enumerate() + .map(|(index, profile)| { + Button::new(("description-profile", index)) + .label(profile.label()) + .outline() + .selected(profile == description_profile) + .on_click(cx.listener(move |this, _, window, cx| { + this.description_profile = profile; + let defaults = profile.defaults(); + let label_for = |key: &str, fallback| { + defaults + .levels + .iter() + .find(|level| level.key == key) + .map_or(fallback, |level| level.label.clone()) + }; + let folder_label = label_for( + &defaults.folder_level_key, + "Group".to_string(), + ); + let file_label = label_for( + &defaults.file_level_key, + "Item".to_string(), + ); + this.folder_level_input.update(cx, |input, cx| { + input.set_value(folder_label, window, cx) + }); + this.file_level_input.update(cx, |input, cx| { + input.set_value(file_label, window, cx) + }); + cx.notify(); + })) + }), ), - ) - }) - .when(!self.skip_files && ambiguous > 0, |this| { - this.child( + ) + .child( + h_flex() + .gap_2() + .child( + v_flex() + .flex_1() + .gap_1() + .child(Label::new("Folders are called").text_sm()) + .child(Input::new(&self.folder_level_input)), + ) + .child( + v_flex() + .flex_1() + .gap_1() + .child(Label::new("Files are called").text_sm()) + .child(Input::new(&self.file_level_input)), + ), + ), + ) + .when(ambiguous > 0, |el| { + el.child( v_flex() .gap_2() .pt_2() @@ -701,23 +944,15 @@ impl ProjectWizard { .child( h_flex().gap_1().flex_wrap().children( [ - ( - DuplicatePolicy::Skip, - "Leave them for me", - "each file becomes its own row", - ), - ( - DuplicatePolicy::Update, - "Link the first row", - "in spreadsheet order", - ), - (DuplicatePolicy::AddAsNew, "Add as new rows", "link nothing"), + (DuplicatePolicy::Skip, "each file becomes its own row"), + (DuplicatePolicy::Update, "in spreadsheet order"), + (DuplicatePolicy::AddAsNew, "link nothing"), ] .into_iter() .enumerate() - .map(|(index, (policy, label, hint))| { + .map(|(index, (policy, hint))| { Button::new(("duplicate-policy", index)) - .label(label) + .label(duplicate_label(policy)) .tooltip(hint) .outline() .selected(policy == duplicate_policy) @@ -729,20 +964,52 @@ impl ProjectWizard { ), ), ) - }) + }); + + Collapsible::new() + .open(open) .child( - h_flex() - .id("skip-files-toggle") - .gap_1() + v_flex() + .id("advanced-files-toggle") .cursor_pointer() - .text_sm() - .text_color(muted) - .child(if self.skip_files { "☑" } else { "☐" }) - .child("I'll add a files folder later — skips the linking step") + .gap_1() + .p_2p5() + .rounded_md() + .border_1() + .border_color(border) + .child( + Label::new(if open { + "▾ Advanced: folder rows, description standard, duplicates (optional)" + } else { + "▸ Advanced: folder rows, description standard, duplicates (optional)" + }) + .text_sm(), + ) + .when(!open, |el| { + el.child(Label::new(summary).text_sm().text_color(muted)) + }) .on_click(cx.listener(|this, _, _, cx| { - this.skip_files = !this.skip_files; + this.show_advanced_files = !this.show_advanced_files; cx.notify(); })), ) + .content(content) + } +} + +fn duplicate_label(policy: DuplicatePolicy) -> &'static str { + match policy { + DuplicatePolicy::Skip => "Leave them for me", + DuplicatePolicy::Update => "Link the first row", + DuplicatePolicy::AddAsNew => "Add as new rows", + } +} + +/// What the chosen duplicate policy does, as the end of a sentence about those files. +fn duplicate_summary(policy: DuplicatePolicy) -> &'static str { + match policy { + DuplicatePolicy::Skip => "qrate leaves them for you.", + DuplicatePolicy::Update => "Each links the first row that names it.", + DuplicatePolicy::AddAsNew => "Each arrives as a new row, linked to nothing.", } } diff --git a/crates/project-wizard/src/steps/review.rs b/crates/project-wizard/src/steps/review.rs index 4983092f..bdbc5bca 100644 --- a/crates/project-wizard/src/steps/review.rs +++ b/crates/project-wizard/src/steps/review.rs @@ -8,7 +8,7 @@ use gpui::{prelude::FluentBuilder, *}; use gpui_component::description_list::DescriptionList; use gpui_component::label::Label; use gpui_component::scroll::ScrollableElement; -use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use plugin_api::ColumnMapContributions; use settings::columns::{ColumnSettings, ColumnSettingsMap}; @@ -26,7 +26,7 @@ use crate::wizard::{ColumnSource, EntryKind, LinkMethod, ProjectWizard, WizardSt /// Keyed by header name, the identity the table mints and the settings page reads. A config row /// naming a column the sheet doesn't have is dropped here; the wizard already warned about it on /// the Columns step. -fn column_settings( +pub(crate) fn column_settings( headers: &[String], preview: Option<&ColumnConfigPreview>, cx: &gpui::App, @@ -56,7 +56,7 @@ fn column_settings( map } -fn project_columns( +pub(crate) fn project_columns( headers: &[String], preview: Option<&ColumnConfigPreview>, title_column: &str, @@ -328,10 +328,22 @@ impl ProjectWizard { { log::error!("couldn't save the imported folder hierarchy — {error}"); } - for (key, value) in description.values().into_iter().chain([( - settings::project::IMPORT_DUPLICATE_POLICY_KEY, - self.duplicate_policy.key().to_string(), - )]) { + for (key, value) in description.values().into_iter().chain([ + ( + settings::project::IMPORT_DUPLICATE_POLICY_KEY, + self.duplicate_policy.key().to_string(), + ), + // A project made here is new to its archivist, so it opens with the + // Getting started card, and the tasks it offers follow what was imported. + ( + settings::onboarding::GUIDE_KEY, + settings::onboarding::GUIDE_OPEN.to_string(), + ), + ( + settings::onboarding::GUIDE_KIND_KEY, + self.guide_kind().as_str().to_string(), + ), + ]) { if let Err(error) = settings::project::write_setting(std::path::Path::new(&file), key, &value) { @@ -408,6 +420,49 @@ impl ProjectWizard { } } + /// Which Getting started tasks fit what this project was made from: linked media gets + /// Gallery, a spreadsheet alone gets its columns and Problems, and an empty project gets its + /// first record. + fn guide_kind(&self) -> settings::onboarding::GuideKind { + use settings::onboarding::GuideKind; + let linked = !self.skip_files && self.folder_plan.is_some(); + match (self.entry_kind, linked) { + (_, true) => GuideKind::Media, + (EntryKind::Blank, false) => GuideKind::Blank, + (_, false) => GuideKind::Import, + } + } + + /// How many rows the new project will open with: the spreadsheet's, plus a row for each file + /// or folder no row claims, or the one empty row a project with nothing in it is given. Runs + /// the same folder merge `create_project` does, on copies, so the count cannot drift from it. + pub(crate) fn records_to_create(&self) -> usize { + let headers = self.effective_headers(); + let (headers, mut rows) = self + .spreadsheet_preview + .as_ref() + .map(|preview| (preview.headers.clone(), preview.rows.clone())) + .unwrap_or_else(|| (headers, Vec::new())); + if !self.skip_files + && let Some(plan) = self.folder_plan.as_ref() + { + let description = settings::description::DescriptionProfile::Rad.defaults(); + append_folder_components( + &FolderImport { + headers: &headers, + title_column: self.title_column.as_deref(), + file_column: self.file_column.as_deref(), + folder: &self.folder_path, + plan, + description: &description, + policy: self.duplicate_policy, + }, + &mut rows, + ); + } + rows.len().max(1) + } + pub(crate) fn render_review_step( &mut self, _window: &mut Window, @@ -462,6 +517,16 @@ impl ProjectWizard { let folder_plan = (!self.skip_files) .then_some(self.folder_plan.as_ref()) .flatten(); + let location = project::project_file_path(&self.save_path, &name) + .to_string_lossy() + .into_owned(); + let records = self.records_to_create(); + let records_line = format!( + "{records} {} will be created", + if records == 1 { "row" } else { "rows" } + ); + let media_folder = (!self.skip_files && !self.folder_path.trim().is_empty()) + .then(|| self.folder_path.clone()); v_flex() .gap_3() @@ -478,15 +543,70 @@ impl ProjectWizard { .columns(1) .small() .item("Name", name.to_string(), 1) - .item("Location", self.save_path.clone(), 1) + // The file itself, not just its folder: it is what gets backed up and moved. + .item("Location", location, 1) .item("Source", source, 1) .when_some(spreadsheet_line.clone(), |list, line| { list.item("Spreadsheet", line, 1) }) .when_some(files_line.clone(), |list, line| list.item("Files", line, 1)) // Blank projects go through Columns too, so always show it. - .item("Columns", columns_line, 1), + .item("Columns", columns_line, 1) + .item( + div().font_semibold().child("Records").into_any_element(), + div().font_semibold().child(records_line).into_any_element(), + 1, + ), ) + .when_some(media_folder, |review, folder| { + review.child( + h_flex() + .gap_2p5() + .items_start() + .px_3() + .py_2p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().tiles) + .child( + Icon::new(IconName::FolderOpen) + .text_color(cx.theme().muted_foreground) + .mt_0p5(), + ) + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .gap_0p5() + .child( + div() + .font_semibold() + .child("Your media stays in its folder"), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child( + h_flex() + .flex_wrap() + .gap_1() + .child("qrate links to") + .child( + div() + .text_color(cx.theme().foreground) + .child(folder), + ) + .child( + "and doesn't copy or move files. The .qrate \ + file holds only the catalog.", + ), + ), + ), + ), + ) + }) .when_some(folder_plan, |review, plan| { const MAX_VISIBLE: usize = 100; let files = plan diff --git a/crates/project-wizard/src/wizard.rs b/crates/project-wizard/src/wizard.rs index b6e8c8b7..44f78961 100644 --- a/crates/project-wizard/src/wizard.rs +++ b/crates/project-wizard/src/wizard.rs @@ -95,6 +95,9 @@ pub struct ProjectWizard { pub(crate) folder_error: Option, /// "I'll add files later" — skips folder matching and the whole Link step. pub(crate) skip_files: bool, + /// The Files step's Advanced block: folder rows, description standard, duplicates. Collapsed + /// by default so the match result sits directly under the folder it describes. + pub(crate) show_advanced_files: bool, pub(crate) description_profile: settings::description::DescriptionProfile, pub(crate) duplicate_policy: file_ingest::duplicates::DuplicatePolicy, pub(crate) folder_level_input: Entity, @@ -287,6 +290,7 @@ impl ProjectWizard { folder_plan: None, folder_error: None, skip_files: false, + show_advanced_files: false, description_profile: settings::description::DescriptionProfile::Rad, duplicate_policy: file_ingest::duplicates::DuplicatePolicy::default(), folder_level_input, @@ -573,6 +577,16 @@ impl ProjectWizard { h_flex() .gap_2() .items_center() + // Sets expectations for what opens next, so the workspace's guide card + // arrives as something promised rather than a surprise. + .when(self.step == WizardStep::Review && blocker.is_none(), |el| { + el.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Opens with a short Getting started card"), + ) + }) .when_some(blocker, |el, reason| { el.child( div() diff --git a/crates/settings/src/columns.rs b/crates/settings/src/columns.rs index 41776398..51f2fcfd 100644 --- a/crates/settings/src/columns.rs +++ b/crates/settings/src/columns.rs @@ -1,7 +1,7 @@ //! Per-column preferences for the data table, stored per project. //! -//! Columns belong to a project, so these are project-scoped only — there is no user-scope -//! fallback and no `SettingsScope` involvement. Values live as one JSON blob in the `.qrate` +//! Columns belong to a project, so these are project-scoped only, with no user fallback. +//! Values live as one JSON blob in the `.qrate` //! file's `__settings` table (the same shape `table_columns` uses), read back through //! `CurrentProject`'s in-memory cache so nothing here touches disk during a render. //! diff --git a/crates/settings/src/db.rs b/crates/settings/src/db.rs index 082b5bbb..0c85d66a 100644 --- a/crates/settings/src/db.rs +++ b/crates/settings/src/db.rs @@ -135,11 +135,17 @@ pub fn load_app_settings() -> Result { .context("Query settings")?; let Some(json) = json else { + log::debug!("settings: no persisted app settings found"); return Ok(AppSettings::default()); }; let persist: PersistSettings = serde_json::from_str(&json).context("Deserialize persisted settings")?; + log::debug!( + "settings: loaded app settings schema={} keys={}", + persist.settings_version, + persist.values.len() + ); Ok(persist.into()) } @@ -158,6 +164,10 @@ fn save_app_settings_snapshot(snapshot: PersistSettings) -> Result<()> { params![SETTINGS_KEY, json], ) .context("Upsert settings row")?; + log::debug!( + "settings: saved app snapshot keys={}", + snapshot.values.len() + ); Ok(()) } @@ -185,13 +195,17 @@ impl SettingsWriter { } } Err(mpsc::RecvTimeoutError::Timeout) => { - if let Some(s) = pending.take() { - let _ = save_app_settings_snapshot(s); + if let Some(s) = pending.take() + && let Err(error) = save_app_settings_snapshot(s) + { + log::error!("settings: failed to save app snapshot: {error:#}"); } } Err(mpsc::RecvTimeoutError::Disconnected) => { - if let Some(s) = pending.take() { - let _ = save_app_settings_snapshot(s); + if let Some(s) = pending.take() + && let Err(error) = save_app_settings_snapshot(s) + { + log::error!("settings: failed to save final app snapshot: {error:#}"); } break; } @@ -203,6 +217,8 @@ impl SettingsWriter { } pub fn enqueue_save(&self, settings: &AppSettings) { - let _ = self.tx.send(PersistSettings::from(settings)); + if self.tx.send(PersistSettings::from(settings)).is_err() { + log::error!("settings: app writer is unavailable"); + } } } diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 701d7dc2..d6d19bae 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -6,6 +6,7 @@ pub mod description; pub mod dirty; pub use qrate_export::filenames; pub mod history; +pub mod onboarding; pub mod os_open; pub mod path_picker; pub mod plugins; @@ -20,10 +21,9 @@ pub const SETTINGS_SCHEMA_VERSION: u32 = 1; use std::collections::HashMap; use std::sync::Arc; -use gpui::prelude::FluentBuilder as _; use gpui::*; use gpui_component::{ - ActiveTheme as _, StyledExt, TitleBar, h_flex, + ActiveTheme as _, StyledExt, TitleBar, input::InputState, label::Label, setting::{SelectIndex, SettingField, SettingItem, SettingPage, Settings}, @@ -36,7 +36,7 @@ use crate::path_picker::PathPickerApp; /// `AppSettings` value key for the Settings window's last size (a JSON [`MainWindowBounds`]). pub const SETTINGS_WINDOW_BOUNDS_KEY: &str = "settings_window_bounds"; -/// Setting key (either scope) for autosave behavior: `"timed"` (buffered, the default), `"immediate"`, +/// Setting key for autosave behavior: `"timed"` (buffered, the default), `"immediate"`, /// or `"off"`. Read by the table crate to decide when a committed cell edit reaches disk. pub const AUTOSAVE_KEY: &str = "autosave"; @@ -62,7 +62,7 @@ pub fn google_enabled(cx: &App) -> bool { /// elsewhere is how someone runs this flow against their own Google Cloud project. pub const GOOGLE_CONFIG_ENDPOINT_KEY: &str = "google_config_endpoint"; -/// Setting key (either scope) for the string that separates several values inside one cell, e.g. +/// Setting key for the string that separates several values inside one cell, e.g. /// `;` in `Film; Video`. Empty means a cell is one indivisible value. /// /// Lives here rather than in `table` because it is no longer only the table's: a plugin checking a @@ -70,105 +70,34 @@ pub const GOOGLE_CONFIG_ENDPOINT_KEY: &str = "google_config_endpoint"; /// `table` to ask. pub const FILTER_SUBDELIMITER_KEY: &str = "filter_subdelimiter"; -// --- Settings Scope --- - -/// Which store a settings field reads and writes. The same fields render in both scopes; only -/// the backing store differs — `User` is the app-wide `AppSettings`, `Project` is the open -/// project's `.qrate` file. -#[derive(Clone, Copy, PartialEq, Eq, Default)] -pub enum SettingsScope { - #[default] - User, - Project, -} - -/// The scope the Settings window's fields currently target. Unset means [`SettingsScope::User`]. -#[derive(Clone, Copy, Default)] -pub struct CurrentSettingsScope(pub SettingsScope); - -impl Global for CurrentSettingsScope {} - -impl SettingsScope { - pub fn current(cx: &App) -> Self { - cx.try_global::() - .map(|s| s.0) - .unwrap_or_default() - } -} - /// Whether the open project holds a value of its own for `key`, as opposed to inheriting the -/// user-wide one. This is what the Project scope's reset button keys off: only an override can be -/// cleared, because only an override exists. +/// user-wide one. Legacy overrides remain visible and can be cleared from Settings. pub fn has_project_override(key: &str, cx: &App) -> bool { cx.try_global::() .is_some_and(|p| p.data.values.contains_key(key)) } /// Drops the open project's own value for `key`, so it inherits the user-wide one again. -pub fn clear_project_override(key: &str, cx: &mut App) { - if cx.has_global::() { +fn clear_project_override(key: &str, cx: &mut App) { + if has_project_override(key, cx) { project::CurrentProject::clear(key, cx); } } -/// Reads a bool from whichever scope the Settings window currently targets. The Project scope -/// resolves the same way the rest of the app does ([`effective_bool`]) rather than reading the -/// project store raw: an unset key there means "inherits the user default", and showing it as -/// `false` would state the opposite. -fn scoped_bool(key: &str, cx: &App) -> bool { - match SettingsScope::current(cx) { - SettingsScope::User => AppSettings::get(cx) - .values - .get(key) - .map(|v| v.bool()) - .unwrap_or(false), - SettingsScope::Project => effective_bool(key, cx), - } -} - -/// Writes a bool to whichever scope the Settings window currently targets. Falls back to the -/// user scope when `Project` is active but no project is open, so a stale scope can't panic. -pub fn set_scoped_bool(key: &'static str, val: bool, cx: &mut App) { - let target = match SettingsScope::current(cx) { - SettingsScope::Project if cx.has_global::() => { - SettingsScope::Project - } - _ => SettingsScope::User, - }; - match target { - SettingsScope::User => AppSettings::set_bool(key, val, cx), - SettingsScope::Project => project::CurrentProject::set_bool(key, val, cx), - } -} - -/// Text sibling of [`scoped_bool`], inheriting in the Project scope for the same reason. -pub fn scoped_text(key: &str, cx: &App) -> SharedString { - match SettingsScope::current(cx) { - SettingsScope::User => AppSettings::get(cx) - .values - .get(key) - .map(|v| v.text()) - .unwrap_or_default(), - SettingsScope::Project => effective_text(key, cx), - } +/// New edits are user-wide. Clear an old project override so the edit takes effect now. +pub fn set_user_bool(key: &'static str, val: bool, cx: &mut App) { + clear_project_override(key, cx); + AppSettings::set_bool(key, val, cx); } -pub fn set_scoped_text(key: &'static str, val: SharedString, cx: &mut App) { - let target = match SettingsScope::current(cx) { - SettingsScope::Project if cx.has_global::() => { - SettingsScope::Project - } - _ => SettingsScope::User, - }; - match target { - SettingsScope::User => AppSettings::set_text(key, val, cx), - SettingsScope::Project => project::CurrentProject::set_text(key, val, cx), - } +/// Text sibling of [`set_user_bool`]. +pub fn set_user_text(key: &'static str, val: SharedString, cx: &mut App) { + clear_project_override(key, cx); + AppSettings::set_text(key, val, cx); } /// Resolves a bool setting for a *consumer* (e.g. the table's stripe toggle): the open project's -/// value wins if present, else the user-wide default, else `false`. Unlike [`scoped_bool`], this -/// ignores the Settings window's active scope — it's what the feature should actually use. +/// value wins if present, else the user-wide default, else `false`. pub fn effective_bool(key: &str, cx: &App) -> bool { if let Some(project) = cx.try_global::() && let Some(v) = project.data.values.get(key) @@ -245,8 +174,7 @@ pub enum Setting { } impl Setting { - /// Builds the row this setting draws. Takes `&App` because a dual-scope row says whether the - /// open project overrides it, which only the live stores know. + /// Builds the row this setting draws. Takes `&App` to show legacy project overrides. pub fn into_item(self, cx: &App) -> SettingItem { match self { Setting::Text { @@ -258,8 +186,8 @@ impl Setting { resettable( key, SettingField::input( - move |cx: &App| scoped_text(key, cx), - move |val: SharedString, cx: &mut App| set_scoped_text(key, val, cx), + move |cx: &App| effective_text(key, cx), + move |val: SharedString, cx: &mut App| set_user_text(key, val, cx), ), None, ), @@ -278,9 +206,9 @@ impl Setting { resettable( key, SettingField::input( - move |cx: &App| scoped_text(key, cx), + move |cx: &App| effective_text(key, cx), move |val: SharedString, cx: &mut App| { - set_scoped_text(key, val, cx); + set_user_text(key, val, cx); on_change(cx); }, ), @@ -299,8 +227,8 @@ impl Setting { resettable( key, SettingField::switch( - move |cx: &App| scoped_bool(key, cx), - move |val: bool, cx: &mut App| set_scoped_bool(key, val, cx), + move |cx: &App| effective_bool(key, cx), + move |val: bool, cx: &mut App| set_user_bool(key, val, cx), ), None, ), @@ -323,8 +251,8 @@ impl Setting { key, SettingField::dropdown( opts, - move |cx: &App| scoped_text(key, cx), - move |val: SharedString, cx: &mut App| set_scoped_text(key, val, cx), + move |cx: &App| effective_text(key, cx), + move |val: SharedString, cx: &mut App| set_user_text(key, val, cx), ), None, ), @@ -347,9 +275,9 @@ impl Setting { .iter() .map(|(k, v)| ((*k).into(), (*v).into())) .collect(), - move |cx: &App| scoped_text(key, cx), + move |cx: &App| effective_text(key, cx), move |val: SharedString, cx: &mut App| { - set_scoped_text(key, val, cx); + set_user_text(key, val, cx); on_change(cx); }, ), @@ -399,27 +327,22 @@ fn user_text(key: &'static str, cx: &App) -> SharedString { .unwrap_or_default() } -/// Tells a dual-scope row apart from an inherited one while the Project scope is showing. Without -/// it the two look identical — the field shows the same resolved value either way. +/// Mark a legacy project override beside the effective value it currently supplies. fn described(description: &'static str, key: &'static str, cx: &App) -> SharedString { - match SettingsScope::current(cx) == SettingsScope::Project && has_project_override(key, cx) { - true => format!("{description}\n\nSet for this project.").into(), + match has_project_override(key, cx) { + true => format!("{description}\n\nProject override; Reset uses the user default.").into(), false => description.into(), } } -/// Wires a dual-scope field to the page's Reset button, which clears the project's own value so the -/// row inherits the user-wide one again. Nothing to reset in the User scope: that store *is* the -/// default this returns to. +/// Let an existing project override be cleared without exposing a scope switcher. fn resettable( key: &'static str, field: SettingField, on_change: Option, ) -> SettingField { field.on_reset( - move |cx: &App| { - SettingsScope::current(cx) == SettingsScope::Project && has_project_override(key, cx) - }, + move |cx: &App| has_project_override(key, cx), move |_window: &mut Window, cx: &mut App| { clear_project_override(key, cx); if let Some(on_change) = on_change { @@ -616,12 +539,16 @@ impl AppSettings { } pub fn set_text(key: &'static str, val: SharedString, cx: &mut App) { + if key != SETTINGS_WINDOW_BOUNDS_KEY { + log::debug!("settings: app text changed key={key}"); + } Self::update(cx, |s| { s.values.insert(key.into(), Val::Text(val)); }); } pub fn set_bool(key: &'static str, val: bool, cx: &mut App) { + log::debug!("settings: app bool changed key={key} value={val}"); Self::update(cx, |s| { s.values.insert(key.into(), Val::Bool(val)); }); @@ -640,8 +567,7 @@ pub struct SettingsWindow { initial_page: Option, /// Persists the window's size (debounced) so it reopens where it was left. _bounds_sub: Subscription, - /// Re-render when any setting changes so scope-dependent pages (autosave's method row, the - /// columns filter picker) rebuild live instead of on the next unrelated repaint. + /// Re-render when any setting changes so dynamic pages rebuild live. _settings_sub: Subscription, _project_sub: Subscription, } @@ -672,48 +598,8 @@ impl SettingsWindow { } } -/// A small text-only scope tab (Zed-style): accented when selected, muted otherwise. -fn scope_tab( - id: &'static str, - label: &'static str, - selected: bool, - enabled: bool, - target: SettingsScope, - cx: &mut Context, -) -> Stateful
{ - let color = if selected { - cx.theme().foreground - } else { - cx.theme().muted_foreground - }; - let base = div() - .id(id) - .text_sm() - .text_color(color) - .when(selected, |d| d.font_semibold()) - .child(label); - if enabled { - base.cursor_pointer() - .on_click(cx.listener(move |_this, _ev, _window, cx| { - cx.set_global(CurrentSettingsScope(target)); - cx.notify(); - })) - } else { - base - } -} - impl Render for SettingsWindow { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_project = cx.has_global::(); - // A stale `Project` scope (project closed while it was selected) snaps back to User so - // fields don't target a nonexistent store. - let scope = if has_project { - SettingsScope::current(cx) - } else { - SettingsScope::User - }; - v_flex() .size_full() .child( @@ -722,33 +608,6 @@ impl Render for SettingsWindow { .text_color(cx.theme().foreground) .child(Label::new("Settings").font_semibold()), ) - // Fixed scope switcher, right-aligned over the settings content section. - .child( - h_flex() - .flex_none() - .justify_end() - .gap_4() - .px_4() - .py_2() - .border_b_1() - .border_color(cx.theme().border) - .child(scope_tab( - "scope-user", - "User", - scope == SettingsScope::User, - true, - SettingsScope::User, - cx, - )) - .child(scope_tab( - "scope-project", - "Project", - scope == SettingsScope::Project, - has_project, - SettingsScope::Project, - cx, - )), - ) .child(div().flex_1().min_h_0().child({ let pages = (self.build_pages)(cx); let page_ix = self diff --git a/crates/settings/src/onboarding.rs b/crates/settings/src/onboarding.rs new file mode 100644 index 00000000..ccb7b512 --- /dev/null +++ b/crates/settings/src/onboarding.rs @@ -0,0 +1,46 @@ +//! Keys for the workspace's Getting started guide, here because two crates that don't know each +//! other write them: `project-wizard` switches the guide on for a project it has just created, and +//! `workspace` reads and advances it. +//! +//! A project that has no [`GUIDE_KEY`] predates the guide, and gets no first-run card — only the +//! Help ▸ Getting Started entry, which writes the key when it is used. + +/// Project setting: `open`, `later` (collapsed to the status bar) or `dismissed`. +pub const GUIDE_KEY: &str = "onboarding_guide"; +/// Project setting: which three tasks the guide offers — see [`GuideKind`]. +pub const GUIDE_KIND_KEY: &str = "onboarding_kind"; +/// Project setting: the tasks already done, comma-separated, so progress survives a restart. +pub const GUIDE_DONE_KEY: &str = "onboarding_done"; +/// App setting: the one-time tips already shown, comma-separated. User-wide rather than per +/// project, since what the Problems panel does is learned once. +pub const TIPS_SEEN_KEY: &str = "onboarding_tips_seen"; + +/// The value of [`GUIDE_KEY`] a new project starts with. +pub const GUIDE_OPEN: &str = "open"; + +/// What the project was made from, which decides the tasks worth teaching first. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GuideKind { + /// Nothing imported: one empty row to type into. + Blank, + /// A spreadsheet with no files linked yet. + Import, + /// Rows with linked media. + Media, +} + +impl GuideKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Blank => "blank", + Self::Import => "import", + Self::Media => "media", + } + } + + pub fn parse(text: &str) -> Option { + [Self::Blank, Self::Import, Self::Media] + .into_iter() + .find(|kind| kind.as_str() == text) + } +} diff --git a/crates/settings/src/project.rs b/crates/settings/src/project.rs index 3c0951de..ed14645b 100644 --- a/crates/settings/src/project.rs +++ b/crates/settings/src/project.rs @@ -858,6 +858,8 @@ impl ProjectSettingsWriter { writes.len(), file.display() ); + } else { + log::debug!("settings: saved {} project keys", writes.len()); } } } @@ -879,6 +881,10 @@ pub fn queue_write(file: &Path, key: &str, value: &str, cx: &gpui::App) { /// [`queue_write`] for any queued operation, removals included. fn queue(file: &Path, key: &str, value: Option, cx: &gpui::App) { + log::debug!( + "settings: project {} key={key}", + if value.is_some() { "set" } else { "cleared" } + ); let writer = cx .try_global::() .and_then(|p| p.writer.clone()); @@ -887,6 +893,8 @@ fn queue(file: &Path, key: &str, value: Option, cx: &gpui::App) { None => { if let Err(err) = write_batch(file, &[(key.to_string(), value)]) { log::error!("failed to save project setting {key}: {err:#}"); + } else { + log::debug!("settings: saved project key={key}"); } } } diff --git a/crates/spellcheck/src/lib.rs b/crates/spellcheck/src/lib.rs index 169f6eed..d9cbba79 100644 --- a/crates/spellcheck/src/lib.rs +++ b/crates/spellcheck/src/lib.rs @@ -312,7 +312,7 @@ pub fn languages(cx: &App) -> Vec { } pub fn set_languages(codes: &[String], cx: &mut App) { - settings::set_scoped_text(SPELLCHECK_LANGUAGE_KEY, codes.join(",").into(), cx); + settings::set_user_text(SPELLCHECK_LANGUAGE_KEY, codes.join(",").into(), cx); } impl SpellCheck { diff --git a/crates/table/src/lib.rs b/crates/table/src/lib.rs index 33627973..95b81816 100644 --- a/crates/table/src/lib.rs +++ b/crates/table/src/lib.rs @@ -958,3 +958,77 @@ pub fn save_now(cx: &mut App) -> Result<(), String> { log::debug!("saved {rows} rows in {:?}", started.elapsed()); Ok(()) } + +/// What the workspace's Getting started guide reads off the grid to tell which of its tasks are +/// already done. Read rather than reported: the grid has no idea a guide exists, and a task an +/// archivist finished on their own (typing a title before being asked) should still tick. +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +pub struct GuideFacts { + /// Source rows, including filtered-away ones. + pub rows: usize, + /// Data columns. + pub columns: usize, + /// Whether any row has text in the Title column (the first column, when none is declared). + pub any_title: bool, + /// Whether a row or cell is selected — what opens a row in Details. + pub selected: bool, + /// Rows whose file resolved to something on disk. + pub linked_files: usize, +} + +/// The data column holding each row's title: the one declared Title, else the first. +fn title_col(delegate: &QrateTableDelegate) -> usize { + (0..delegate.column_count()) + .find(|&col| delegate.column_type(col) == ColumnType::Title) + .unwrap_or(0) +} + +/// See [`GuideFacts`]. `None` before the grid exists. +pub fn guide_facts(cx: &App) -> Option { + let state = cx.try_global::()?.0.upgrade()?; + let delegate = state.read(cx).delegate(); + let rows = delegate.row_count(); + let title = title_col(delegate); + Some(GuideFacts { + rows, + columns: delegate.column_count(), + any_title: (0..rows).any(|row| { + delegate + .cell(row, title) + .is_some_and(|text| !text.trim().is_empty()) + }), + selected: matches!( + delegate.selection(), + Some(Selection::Cell { .. } | Selection::Row(_)) + ), + linked_files: (0..rows) + .filter(|&row| delegate.row_image(row).is_some()) + .count(), + }) +} + +/// Select the first visible row — its Title cell when `title_cell`, else the whole row — the way +/// a click would, so Details follows. The guide's "Show me" for the tasks that start in the grid. +pub fn select_first_row(title_cell: bool, cx: &mut App) { + let Some(state) = cx + .try_global::() + .and_then(|h| h.0.upgrade()) + else { + return; + }; + let target = { + let delegate = state.read(cx).delegate(); + delegate.visible().first().map(|_| (0, title_col(delegate))) + }; + let Some((view_row, col)) = target else { + return; + }; + state.update(cx, |state, cx| { + if title_cell { + // `+ 1`: the library counts the pinned row-number column, the data does not. + state.set_selected_cell(view_row, col + 1, cx) + } else { + state.set_selected_row(view_row, cx) + } + }); +} diff --git a/crates/window-wrapper/src/title_bar.rs b/crates/window-wrapper/src/title_bar.rs index 6eb6cb64..aa9eb554 100644 --- a/crates/window-wrapper/src/title_bar.rs +++ b/crates/window-wrapper/src/title_bar.rs @@ -79,11 +79,15 @@ impl RenderOnce for AppTitleBar { .when(!self.title.is_empty(), |this| { this.child(self.title.clone()) }) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child(format!("· Author: {}", self.author)), - ), + // Who the change log will credit, said as what it is: the name this + // session's edits go out under, not a property of the project. + .child(div().text_color(cx.theme().muted_foreground).child( + if self.author.is_empty() { + "· No author set".to_string() + } else { + format!("· Editing as {}", self.author) + }, + )), ) .child( gpui_component::h_flex() diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 7519852f..2cdb7770 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true log.workspace = true gpui.workspace = true gpui-component.workspace = true +gpui-base.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/workspace/src/dock_button.rs b/crates/workspace/src/dock_button.rs index eccede87..de577ce5 100644 --- a/crates/workspace/src/dock_button.rs +++ b/crates/workspace/src/dock_button.rs @@ -165,6 +165,15 @@ impl Render for DockToggleButton { // Errors and warnings side by side rather than one total: the icon and colour say // which is which, so `self.icon` has nothing left to add here. let (errors, warnings) = Diagnostics::counts(cx); + // Two zeroes read as a score to beat; nothing to report is said in words. + if errors == 0 && warnings == 0 { + return this.child( + div() + .text_xs() + .when(!lit, |el| el.text_color(cx.theme().muted_foreground)) + .child("No problems"), + ); + } this.children([ severity_badge(Severity::Error, IconName::CircleX, errors, cx), severity_badge(Severity::Warning, IconName::TriangleAlert, warnings, cx), diff --git a/crates/workspace/src/extension.rs b/crates/workspace/src/extension.rs new file mode 100644 index 00000000..f8e7f1c3 --- /dev/null +++ b/crates/workspace/src/extension.rs @@ -0,0 +1,87 @@ +//! How a crate that builds on the workspace gets a place in it without the workspace knowing it +//! exists — the same inversion `LauncherHooks` and the bar registries already use. +//! +//! An extension is a set of plain `fn`s, registered once at startup. The workspace calls +//! [`attach`](WorkspaceExtension::attach) when a main window's workspace is built, draws each +//! extension's overlays over the centre and over the window body, and tells it when the centre +//! switches views or changes size. Onboarding is the first; anything else that needs to float +//! over the grid, or react to the view, goes through the same list instead of growing its own +//! wiring into `Workspace` and `ViewsPanel`. + +use gpui::{AnyElement, App, Global, Pixels, Size, WeakEntity}; +use gpui_component::dock::DockArea; + +use crate::ViewMode; + +#[derive(Clone, Copy)] +pub struct WorkspaceExtension { + /// A main window's workspace was built; `dock_area` is how the extension reaches its panels. + pub attach: fn(WeakEntity, &mut App), + /// Drawn over the centre's view, filling it — an absolutely positioned layer. + pub centre_overlay: fn(&App) -> Option, + /// Drawn over the whole window body, above the docks — an absolutely positioned layer. + pub window_overlay: fn(&App) -> Option, + /// The centre switched views. + pub view_changed: fn(ViewMode, &mut App), + /// The centre's body was measured at a new size. + pub centre_resized: fn(Size, &mut App), +} + +#[derive(Default)] +struct Extensions(Vec); +impl Global for Extensions {} + +/// Add an extension. Call before the main window opens; a workspace built earlier never attaches +/// to it. +pub fn register(extension: WorkspaceExtension, cx: &mut App) { + cx.default_global::().0.push(extension); +} + +fn all(cx: &App) -> Vec { + cx.try_global::() + .map(|extensions| extensions.0.clone()) + .unwrap_or_default() +} + +/// Bumped by [`repaint`]; the views that draw overlays observe it. +#[derive(Default)] +pub(crate) struct OverlaysChanged(u64); +impl Global for OverlaysChanged {} + +/// An extension's overlay changed: repaint the centre and the window body that draw it. Cheaper +/// than a whole-window refresh, and the workspace needn't know the extension's own entities. +pub fn repaint(cx: &mut App) { + cx.default_global::().0 += 1; +} + +pub(crate) fn attach(dock_area: WeakEntity, cx: &mut App) { + for extension in all(cx) { + (extension.attach)(dock_area.clone(), cx); + } +} + +pub(crate) fn centre_overlays(cx: &App) -> Vec { + all(cx) + .iter() + .filter_map(|extension| (extension.centre_overlay)(cx)) + .collect() +} + +pub(crate) fn window_overlays(cx: &App) -> Vec { + all(cx) + .iter() + .filter_map(|extension| (extension.window_overlay)(cx)) + .collect() +} + +pub(crate) fn view_changed(mode: ViewMode, cx: &mut App) { + for extension in all(cx) { + (extension.view_changed)(mode, cx); + } +} + +pub(crate) fn centre_resized(size: Size, cx: &mut App) { + for extension in all(cx) { + (extension.centre_resized)(size, cx); + } +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index edea6cf4..199d4402 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -3,15 +3,18 @@ mod component_banner; mod dock_button; +pub mod extension; mod panel_registry; mod panels; +mod skin; mod viewer; mod views; pub use viewer::{CloseViewerLayer, Scope as ViewerScope, VIEWER_CONTEXT, open_viewer}; pub use dock_button::DockToggleButton; -pub use panel_registry::{BarSide, PANELS, PanelMeta, PanelRegistry, bar_side}; +pub use extension::WorkspaceExtension; +pub use panel_registry::{BarSide, PANELS, PROBLEMS_META, PanelMeta, PanelRegistry, bar_side}; /// `record` is how the agent bridge in `app` files an entry for the Agent panel to show. pub use panels::{ AGENT_FONT_KEY, AGENT_FONT_SIZE_KEY, AGENT_FONT_SIZES, AgentCall, AgentEntry, DETAILS_META, @@ -23,8 +26,8 @@ use std::sync::Arc; use gpui::*; use gpui_component::dock::{ - BasePanelView, DockArea, DockAreaState, DockEvent, DockLayout, DockPlacement, DockSkin, - panel_handle, register_panel, + BasePanelView, DockArea, DockAreaState, DockEvent, DockLayout, DockPlacement, panel_handle, + register_panel, }; use settings::AppSettings; @@ -90,6 +93,8 @@ pub struct Workspace { _viewer_sub: Subscription, /// Brings the History panel forward when the grid asks it to show a cell's changes. _history_sub: Subscription, + /// Repaints the extensions' window overlays when one of them says they changed. + _overlays_sub: Subscription, } impl Workspace { @@ -112,7 +117,7 @@ impl Workspace { // The appearance is a separate object in 0.6, installed as the area's renderer. The // handle it hands back is how its settings are reached afterwards. let (dock_area, skin) = - DockSkin::dock_area("qrate-main", Some(DOCK_LAYOUT_VERSION), window, cx); + skin::QrateSkin::dock_area("qrate-main", Some(DOCK_LAYOUT_VERSION), window, cx); // Restore if saved, else build default; building first then loading would orphan a throwaway table. if !Self::restore_layout(&dock_area, window, cx) { @@ -199,11 +204,15 @@ impl Workspace { }, ); + extension::attach(dock_area.downgrade(), cx); + let _overlays_sub = cx.observe_global::(|_, cx| cx.notify()); + Self { dock_area, _layout_sub, _viewer_sub, _history_sub, + _overlays_sub, } } @@ -439,6 +448,9 @@ impl Render for Workspace { .child(self.dock_area.clone()), ) .children(viewer) + // Extensions' layers over the whole body — a popover opening upward from the + // status bar, say. + .children(extension::window_overlays(cx)) } } diff --git a/crates/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs index 0aa19751..05d57437 100644 --- a/crates/workspace/src/panels/details.rs +++ b/crates/workspace/src/panels/details.rs @@ -988,49 +988,51 @@ fn render_image_frame( .overflow_hidden() .bg(cx.theme().muted) .map(|frame| match image_path { - Some(path) => frame.child(thumb(Some(&path), preview::PANE, cx)).child( - div() - .absolute() - .top_1() - .right_1() - .flex() - .gap_1() - .rounded(cx.theme().radius) - .bg(cx.theme().background) - // Fullscreen only makes sense for something we can actually render; an - // icon-placeholder file has nothing to zoom into. - .when(show_image, |group| { - let path = path.clone(); - group.child( - Button::new("fullscreen-image") - .icon(IconName::Maximize) - .ghost() - .small() - .tooltip("View fullscreen") - .on_click(move |_, window, cx| { - crate::open_viewer( - path.clone(), - crate::ViewerScope::Workspace, - window, - cx, - ) - }), - ) - }) - .child(action( - "open-image", - IconName::ExternalLink, - "Open in default app", - path.clone(), - )) - .child(action( - "reveal-image", - IconName::FolderOpen, - "Reveal in folder", - path, - )), - ), - None => frame.child(thumb(None, preview::PANE, cx)), + Some(path) => frame + .child(thumb(Some(&path), preview::PANE, ObjectFit::Contain, cx)) + .child( + div() + .absolute() + .top_1() + .right_1() + .flex() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().background) + // Fullscreen only makes sense for something we can actually render; an + // icon-placeholder file has nothing to zoom into. + .when(show_image, |group| { + let path = path.clone(); + group.child( + Button::new("fullscreen-image") + .icon(IconName::Maximize) + .ghost() + .small() + .tooltip("View fullscreen") + .on_click(move |_, window, cx| { + crate::open_viewer( + path.clone(), + crate::ViewerScope::Workspace, + window, + cx, + ) + }), + ) + }) + .child(action( + "open-image", + IconName::ExternalLink, + "Open in default app", + path.clone(), + )) + .child(action( + "reveal-image", + IconName::FolderOpen, + "Reveal in folder", + path, + )), + ), + None => frame.child(thumb(None, preview::PANE, ObjectFit::Contain, cx)), }) // Top-left, over the picture rather than taking a row out of the frame — the pane is a // height the user drags, and the other two edges are spoken for: the action buttons sit @@ -1247,7 +1249,9 @@ impl Render for DetailsPanel { // Hand-built attribute list, not `DescriptionList`/`DataTable`: the fields are fixed pairs, // and it reads as a list rather than a second grid — alternating rows carry the structure, - // no borders. + // no borders. Edge to edge across the dock, like the grid's own rows, rather than an inset + // card: the stripes are what line a label up with its value, and they read best at full + // width. let editing_col = self.editing.as_ref().map(|(_, col, _)| *col); let rows = fields .iter() @@ -1266,17 +1270,20 @@ impl Render for DetailsPanel { div() .flex() .items_start() - .when(ix % 2 == 1, |r| r.bg(cx.theme().muted.opacity(0.4))) + .gap_2() + .px_3() + .text_size(px(13.)) + .line_height(px(18.)) + .when(ix % 2 == 1, |r| r.bg(cx.theme().description_list_label)) .child( - // Both columns are shares of the panel's width, not fixed pixels: this dock - // resizes, and a fixed label column either wastes half a wide panel or crushes - // the values in a narrow one. + // A fixed label column, so every value starts on the same line down the + // list however the dock is sized; a long label wraps inside it rather + // than pushing its value along. div() - .w(relative(0.35)) + .w(px(96.)) .flex_shrink_0() - .px_2() - .py_1p5() - .text_color(cx.theme().muted_foreground) + .py(px(7.)) + .text_color(cx.theme().description_list_label_foreground) .child(k.clone()), ) // Plain text rather than `TextView`, which parses markdown/html and mangles raw @@ -1291,8 +1298,7 @@ impl Render for DetailsPanel { // Positions the measuring canvas below against this field, not against // whatever ancestor happens to be positioned. .relative() - .px_2() - .py_1p5() + .py(px(7.)) .cursor_text() // `text_ellipsis` is what puts the … on the last kept line; `line_clamp` // alone would cut the text off mid-word with nothing to say it had. @@ -1428,15 +1434,8 @@ impl Render for DetailsPanel { .w_full() .min_h_0() .overflow_y_scrollbar() - .px_3() - .pt_2() .pb(px(12.)) - .child( - div() - .rounded(cx.theme().radius) - .overflow_hidden() - .children(rows), - ), + .child(div().children(rows)), ) .children(self.field_editor(window, cx)) .into_any_element(); @@ -1469,7 +1468,6 @@ impl Render for DetailsPanel { .child( resizable_panel() .size_range(px(SECTION_MIN_H)..Pixels::MAX) - .pr_2() .child(fields), ) .child( @@ -1484,7 +1482,7 @@ impl Render for DetailsPanel { Some(notes) => v_flex() .size_full() .min_h_0() - .child(div().flex_1().min_h_0().pr_2().child(fields)) + .child(div().flex_1().min_h_0().child(fields)) .child( div() .flex_none() diff --git a/crates/workspace/src/skin.rs b/crates/workspace/src/skin.rs new file mode 100644 index 00000000..7c40d2cb --- /dev/null +++ b/crates/workspace/src/skin.rs @@ -0,0 +1,190 @@ +//! qrate's dock appearance: the library's `DockSkin`, with one change — a single panel's title +//! row is a strip of its own, tinted like the title bar and ruled off from the panel under it. +//! +//! The library draws a lone panel's title straight on the panel's own background with nothing +//! between the two, so the name, the view switcher and the panel's first row of content run +//! together. A tab strip (two or more panels) already gets the tint and the rule; this gives the +//! one-panel case the same, so every dock and the centre read as "header, then content". +//! +//! Done here, around the skin, rather than in each panel: the title row is the skin's, and five +//! panels each drawing a border at their top would be five places to keep in step — and would +//! double up under a tab strip, which already has its rule. + +use std::rc::Rc; +use std::sync::Arc; + +use gpui::*; +use gpui_base::ResizeHandleContext; +use gpui_component::ActiveTheme as _; +use gpui_component::dock::{ + DockArea, DockAreaRenderer, DockContext, DockSkin, DropIndicator, NodeId, PanelState, + PanelStyle, TabGroupContext, TabGroupRenderer, TilesRenderer, +}; + +pub(crate) struct QrateSkin { + inner: Rc, +} + +impl QrateSkin { + /// [`DockSkin::dock_area`], wearing this skin. The returned `DockSkin` is the same one this + /// delegates to, so its settings still apply. + pub(crate) fn dock_area( + id: impl Into, + version: Option, + window: &mut Window, + cx: &mut App, + ) -> (Entity, Rc) { + let mut skin = None; + let area = cx.new(|cx| { + let inner = DockSkin::new(cx); + skin = Some(inner.clone()); + DockArea::new(id, version, window, cx).with_renderer(Rc::new(Self { inner })) + }); + ( + area, + skin.expect("DockSkin::new ran inside the constructor"), + ) + } +} + +impl DockAreaRenderer for QrateSkin { + fn frame(&self, window: &mut Window, cx: &mut App) -> Stateful
{ + self.inner.frame(window, cx) + } + + fn split_frame( + &self, + node: NodeId, + axis: Axis, + window: &mut Window, + cx: &mut App, + ) -> Stateful
{ + self.inner.split_frame(node, axis, window, cx) + } + + fn center_frame(&self, window: &mut Window, cx: &mut App) -> Stateful
{ + self.inner.center_frame(window, cx) + } + + fn render_split_handle( + &self, + handle: &ResizeHandleContext, + window: &mut Window, + cx: &mut App, + ) -> Option { + self.inner.render_split_handle(handle, window, cx) + } + + fn render_dock( + &self, + dock: &DockContext, + content: AnyElement, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + self.inner.render_dock(dock, content, window, cx) + } + + fn build_placeholder( + &self, + state: &PanelState, + window: &mut Window, + cx: &mut App, + ) -> Option> { + self.inner.build_placeholder(state, window, cx) + } + + fn tab_group_renderer(&self) -> Rc { + Rc::new(QrateTabGroup { + inner: self.inner.tab_group_renderer(), + skin: self.inner.clone(), + }) + } + + fn tiles_renderer(&self) -> Rc { + self.inner.tiles_renderer() + } +} + +struct QrateTabGroup { + inner: Rc, + skin: Rc, +} + +impl QrateTabGroup { + /// Whether the library is about to draw the plain one-panel title, not a tab strip — the + /// same test its own `render_tab_bar` makes. + fn draws_plain_title(&self, group: &TabGroupContext, cx: &App) -> bool { + self.skin.panel_style() == PanelStyle::Auto + && group + .panels() + .iter() + .filter(|panel| panel.visible(cx)) + .count() + == 1 + } +} + +impl TabGroupRenderer for QrateTabGroup { + fn frame(&self, group: &TabGroupContext, window: &mut Window, cx: &mut App) -> Stateful
{ + self.inner.frame(group, window, cx) + } + + fn content_frame( + &self, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> Stateful
{ + self.inner.content_frame(group, window, cx) + } + + fn render_tab_bar( + &self, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let plain = self.draws_plain_title(group, cx); + let bar = self.inner.render_tab_bar(group, window, cx); + if !plain { + return bar; + } + div() + .flex_none() + .w_full() + .bg(cx.theme().tab_bar) + .border_b_1() + .border_color(cx.theme().border) + .child(bar) + .into_any_element() + } + + fn render_active_panel( + &self, + panel: AnyView, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + self.inner.render_active_panel(panel, group, window, cx) + } + + fn render_drop_indicator( + &self, + indicator: DropIndicator, + window: &mut Window, + cx: &mut App, + ) -> Option { + self.inner.render_drop_indicator(indicator, window, cx) + } + + fn render_empty( + &self, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> Option { + self.inner.render_empty(group, window, cx) + } +} diff --git a/crates/workspace/src/views/gallery.rs b/crates/workspace/src/views/gallery.rs index d8579c49..0152dc1b 100644 --- a/crates/workspace/src/views/gallery.rs +++ b/crates/workspace/src/views/gallery.rs @@ -142,7 +142,12 @@ fn card( .when(!selected, |frame| { frame.hover(|frame| frame.border_color(cx.theme().ring)) }) - .child(thumb(path.as_deref(), preview::CARD, cx)) + .child(thumb( + path.as_deref(), + preview::CARD, + ObjectFit::Contain, + cx, + )) .when(pages > 1, |frame| { frame.child(badge(IconName::Copy, pages).top_1()) }) diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 05fac658..0f871e50 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -135,6 +135,10 @@ pub struct ViewsPanel { _changed_sub: Option, /// Mounts and unmounts the centre-scoped photo overlay as cards are clicked and dismissed. _viewer_sub: Subscription, + /// Repaints the extensions' centre overlays when one of them says they changed. + _overlays_sub: Subscription, + /// The body's last measured size, so extensions hear about a resize once, not per frame. + body_size: Size, /// Cards per row in the gallery. Owned here rather than in `gallery::render`, which is a free /// function with no state of its own to keep between frames. thumb: Entity, @@ -190,6 +194,9 @@ impl ViewsPanel { }), _changed_sub: None, _viewer_sub: cx.observe_global::(|_, cx| cx.notify()), + _overlays_sub: cx + .observe_global::(|_, cx| cx.notify()), + body_size: Size::default(), thumb, _thumb_sub, _table_sub: cx.observe(&table, |_, _, cx| cx.notify()), @@ -273,11 +280,18 @@ impl ViewsPanel { }; switch(&this, mode, window, cx); }) - .children( - ViewMode::ALL - .into_iter() - .map(|mode| Tab::new().icon(mode.icon()).label(mode.label())), - ) + // Icon and name as the tab's content, not `.icon()` + `.label()`: the library draws + // an iconed tab as a fixed icon-only square and drops its label. + .children(ViewMode::ALL.into_iter().map(|mode| { + Tab::new().aria_label(mode.label()).child( + h_flex() + .gap_1() + .items_center() + .text_xs() + .child(Icon::new(mode.icon()).size_3()) + .child(mode.label()), + ) + })) } } @@ -322,6 +336,7 @@ fn switch(panel: &Entity, mode: ViewMode, window: &mut Window, cx: & let focus = panel.read(cx).focus_handle.clone(); focus.focus(window, cx); } + crate::extension::view_changed(mode, cx); // The switcher is drawn by the parent `TabPanel` (via `Panel::title`), which does not observe // this panel — `cx.notify()` above repaints the body but leaves the highlight on the old tab. // Switching views is a rare, deliberate click, so a full redraw is the cheap honest fix. @@ -469,6 +484,15 @@ impl Render for ViewsPanel { cx.notify(); } }); + // Extensions hear the size only when it changes, not per frame. + let resized = this.update(cx, |this, _| { + let resized = this.body_size != bounds.size; + this.body_size = bounds.size; + resized + }); + if resized { + crate::extension::centre_resized(bounds.size, cx); + } }, |_, _, _, _| {}, ) @@ -492,7 +516,9 @@ impl Render for ViewsPanel { ) } }) - .children(crate::viewer::viewer_in(ViewerScope::Centre, cx)), + .children(crate::viewer::viewer_in(ViewerScope::Centre, cx)) + // Extensions' layers over the view, under nothing else. + .children(crate::extension::centre_overlays(cx)), ) } }