From 7718ba18a9ce13c0de7e38258c52672574a4cc9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 04:00:31 +0000 Subject: [PATCH 01/10] feat(project-wizard): first-run welcome, example project, clearer Files/Columns/Create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the onboarding design (frames 1b, 3, 4a, 5a): - Launcher: with no recent projects, the empty list becomes a short welcome — no account needed, media stays in its folder, an "Explore an example project" card, and a separate "Open an existing .qrate project" button. - Example project: the repo's sample/ collection is compiled in, unpacked to qrate's data folder the first time, and reopened (with any edits) after. - Files step: "Link a files folder" / "Add files later" cards replace the checkbox at the bottom; a partial match explains each group; folder rows, description standard and duplicates fold into one Advanced block. - Columns step: a note under File when files are deferred. - Create step: the full .qrate path, a Records row, a "media stays in its folder" note, and a hint that a Getting started card follows. - settings::onboarding holds the guide's keys; a new project writes them so the workspace opens with its Getting started card. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mg1nNrSAaj2A33ZNXkzYy --- crates/project-wizard/src/example.rs | 152 ++++ crates/project-wizard/src/launcher.rs | 276 +++++-- crates/project-wizard/src/lib.rs | 1 + crates/project-wizard/src/steps/columns.rs | 32 +- crates/project-wizard/src/steps/files.rs | 821 ++++++++++++++------- crates/project-wizard/src/steps/review.rs | 138 +++- crates/project-wizard/src/wizard.rs | 14 + crates/settings/src/lib.rs | 1 + crates/settings/src/onboarding.rs | 46 ++ 9 files changed, 1130 insertions(+), 351 deletions(-) create mode 100644 crates/project-wizard/src/example.rs create mode 100644 crates/settings/src/onboarding.rs 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 ac0f51a2..26177e90 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -10,9 +10,12 @@ 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}; @@ -144,6 +147,149 @@ 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)) + }; + let thumbnail = std::sync::Arc::new(Image::from_bytes( + ImageFormat::Jpeg, + example::THUMBNAIL.to_vec(), + )); + 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( + img(thumbnail) + .size(px(56.)) + .flex_none() + .rounded_sm() + .border_1() + .border_color(cx.theme().border) + .object_fit(ObjectFit::Cover), + ) + .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 +407,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(); @@ -362,65 +502,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.)) diff --git a/crates/project-wizard/src/lib.rs b/crates/project-wizard/src/lib.rs index ce93d63b..8b08bea2 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; 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 40bdadf0..8d62270a 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/lib.rs b/crates/settings/src/lib.rs index 701d7dc2..092e8059 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; 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) + } +} From 7c4beb822fb6e6ea2b684930e849d6f29d5864df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 04:00:31 +0000 Subject: [PATCH 02/10] feat(onboarding): Getting started guide as its own crate, plus panel chrome The workspace guide from the onboarding design (frames 6a/6c, 7-11) lives in a new `onboarding` crate that plugs into `workspace` through a small extension point (workspace::extension): attach, centre/window overlays, view changes and centre resizes. The workspace no longer knows onboarding exists. - A floating card of three tasks picked from what the project was made from (blank / spreadsheet / linked media), ticked from the grid's own state (table::guide_facts). Folds into a status-bar label below a 640x360 centre or on "Show me later"; Dismiss shows a 6 s note with Undo. - One-time tips (Title cell, add a field, Problems, Gallery, Details moved) and a first-export tip. Help > Getting Started reopens the card, or the launcher when no project is open. Chrome from the same design: - Dock skin wrapper: a lone panel's title row gets the tab-bar tint and a rule under it, like a tab strip already has. - Details: fields as a full-width striped table with a fixed label column. - Status bar: "No problems" instead of two zero counts. - View switcher: Table / Gallery labels beside their icons. - Title bar: "Editing as " instead of "Author: ". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mg1nNrSAaj2A33ZNXkzYy --- CLAUDE.md | 3 +- Cargo.lock | 14 + Cargo.toml | 1 + crates/app/Cargo.toml | 1 + crates/app/src/app_menus.rs | 4 + crates/app/src/main.rs | 18 +- crates/app/src/status_items/mod.rs | 7 + crates/onboarding/Cargo.toml | 16 + crates/onboarding/src/lib.rs | 1370 ++++++++++++++++++++++++ crates/table/src/lib.rs | 74 ++ crates/window-wrapper/src/title_bar.rs | 14 +- crates/workspace/Cargo.toml | 1 + crates/workspace/src/dock_button.rs | 9 + crates/workspace/src/extension.rs | 87 ++ crates/workspace/src/lib.rs | 20 +- crates/workspace/src/panels/details.rs | 38 +- crates/workspace/src/skin.rs | 190 ++++ crates/workspace/src/views/mod.rs | 37 +- 18 files changed, 1865 insertions(+), 39 deletions(-) create mode 100644 crates/onboarding/Cargo.toml create mode 100644 crates/onboarding/src/lib.rs create mode 100644 crates/workspace/src/extension.rs create mode 100644 crates/workspace/src/skin.rs 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 01de74fe..c28bdb94 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" @@ -10806,6 +10819,7 @@ version = "0.5.0-beta.1" dependencies = [ "agent-runtime", "diagnostics", + "gpui-base", "gpui-component", "gpui-pre", "log", diff --git a/Cargo.toml b/Cargo.toml index d1205633..0f139461 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 0ed736aa..761c0550 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -238,12 +238,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/main.rs b/crates/app/src/main.rs index 2359d4b5..3d57c20f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -285,7 +285,9 @@ impl Render for App { })) // Here rather than globally: the Zotero mapping dialog opens in this window. .on_action(cx.listener(|_, action: &export::Export, window, cx| { - export::run(action.format, window, cx) + export::run(action.format, window, cx); + // Once, the first time: what an export includes, and where Sheets sync lives. + onboarding::show_export_tip(window, cx); })) // Here for the same reason: the dialog opens in this window. .on_action(cx.listener(|_, _: &LoadColumnConfig, window, cx| { @@ -305,7 +307,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). @@ -412,6 +414,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()) @@ -632,6 +637,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() @@ -701,6 +709,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/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..e0452560 --- /dev/null +++ b/crates/onboarding/src/lib.rs @@ -0,0 +1,1370 @@ +//! 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); + 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. To keep \ + a Google Sheet in sync, turn it on in Settings ▸ Google first.", + 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/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 5ed2768e..383dc5e8 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 5c4d248f..3c3d8cb1 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -2,15 +2,18 @@ //! (center table, left details, right agent, bottom problems) with layout persistence. 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, @@ -22,8 +25,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; @@ -89,6 +92,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 { @@ -111,7 +116,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) { @@ -198,11 +203,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, } } @@ -438,6 +447,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 fc57be0b..0ec57fdc 100644 --- a/crates/workspace/src/panels/details.rs +++ b/crates/workspace/src/panels/details.rs @@ -1235,7 +1235,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() @@ -1254,17 +1256,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 @@ -1279,8 +1284,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. @@ -1416,15 +1420,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(); @@ -1457,7 +1454,6 @@ impl Render for DetailsPanel { .child( resizable_panel() .size_range(px(SECTION_MIN_H)..Pixels::MAX) - .pr_2() .child(fields), ) .child( @@ -1472,7 +1468,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/mod.rs b/crates/workspace/src/views/mod.rs index 05fac658..24e4337b 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,17 @@ 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() + .child(Icon::new(mode.icon()).size_3p5()) + .child(mode.label()), + ) + })) } } @@ -322,6 +335,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 +483,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 +515,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)), ) } } From cb166274732a4ea5a72c74dfa7472d2050891c59 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:58:38 -0700 Subject: [PATCH 03/10] fix(settings): apply themes fully and remove scope switcher --- crates/app/src/app_settings/mod.rs | 18 +-- crates/app/src/theming.rs | 1 + crates/settings/src/columns.rs | 4 +- crates/settings/src/lib.rs | 212 +++++------------------------ crates/spellcheck/src/lib.rs | 2 +- 5 files changed, 46 insertions(+), 191 deletions(-) diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index ddcc3b3a..4b6cbfb6 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -251,11 +251,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, @@ -1065,9 +1065,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, @@ -1078,7 +1078,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", @@ -1088,11 +1088,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); }, ), ) @@ -1113,7 +1113,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); }, ), @@ -1128,7 +1128,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/theming.rs b/crates/app/src/theming.rs index 640237c9..5bfb3d08 100644 --- a/crates/app/src/theming.rs +++ b/crates/app/src/theming.rs @@ -102,6 +102,7 @@ fn apply_saved_theme(cx: &mut App) { fn apply_theme_by_name(name: &str, cx: &mut App) { 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(); } } 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/lib.rs b/crates/settings/src/lib.rs index 092e8059..2ffea108 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -21,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}, @@ -37,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"; @@ -63,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 @@ -71,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. -pub fn has_project_override(key: &str, cx: &App) -> bool { +/// user-wide one. Legacy overrides remain visible and can be cleared from Settings. +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), - } +/// 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); } -/// 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), - } -} - -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) @@ -246,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 { @@ -259,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, ), @@ -279,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); }, ), @@ -300,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, ), @@ -324,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, ), @@ -348,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); }, ), @@ -400,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 { @@ -641,8 +563,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, } @@ -673,48 +594,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( @@ -723,33 +604,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/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 { From abf2add9792f1a7f81d9a7076b371ae8190b5047 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:58:55 -0700 Subject: [PATCH 04/10] feat(onboarding): preview recent projects and add test flag --- Cargo.lock | 2 + crates/app/src/main.rs | 11 +++ crates/project-wizard/Cargo.toml | 2 + crates/project-wizard/src/launcher.rs | 103 +++++++++++++++++++------ crates/project-wizard/src/lib.rs | 4 +- crates/project-wizard/src/recent.rs | 104 ++++++++++++++++++++++++++ crates/workspace/src/views/mod.rs | 3 +- 7 files changed, 205 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c28bdb94..31fba60e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6467,6 +6467,8 @@ dependencies = [ "gpui-pre", "log", "plugin-api", + "preview", + "qrate-export", "serde", "serde_json", "settings", diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 3d57c20f..4046c1ff 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -122,6 +122,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; } @@ -150,6 +153,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()); } } @@ -568,6 +574,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) @@ -618,6 +626,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`. 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/launcher.rs b/crates/project-wizard/src/launcher.rs index 26177e90..ed771646 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -6,6 +6,9 @@ //! 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; @@ -22,6 +25,10 @@ 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. @@ -56,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, @@ -71,8 +79,34 @@ impl Launcher { .try_global::() .copied() .map(|hooks| (hooks.title_items)(cx)); + let recents = if cx.has_global::() { + Vec::new() + } else { + recent::list(cx) + }; + if !recents.is_empty() { + let paths: Vec<_> = recents.iter().map(|project| project.path.clone()).collect(); + let scan = cx.background_spawn(async move { + paths + .into_iter() + .filter_map(|path| { + recent::preview_source(Path::new(&path)).map(|image| (path, image)) + }) + .collect::>() + }); + cx.spawn(async move |this, cx| { + let previews = scan.await; + this.update(cx, |this, cx| { + this.previews = previews; + cx.notify(); + }) + .ok(); + }) + .detach(); + } Self { - recents: recent::list(cx), + recents, + previews: HashMap::new(), error: None, title_items, } @@ -177,10 +211,6 @@ impl Launcher { .child(div().font_semibold().child(strong)) .child(div().text_color(muted).child(rest)) }; - let thumbnail = std::sync::Arc::new(Image::from_bytes( - ImageFormat::Jpeg, - example::THUMBNAIL.to_vec(), - )); v_flex() .flex_1() .min_h(px(0.)) @@ -232,15 +262,7 @@ impl Launcher { .border_dashed() .border_color(cx.theme().border) .bg(cx.theme().tiles) - .child( - img(thumbnail) - .size(px(56.)) - .flex_none() - .rounded_sm() - .border_1() - .border_color(cx.theme().border) - .object_fit(ObjectFit::Cover), - ) + .child(project_thumbnail(Thumbnail::Example, px(56.), cx)) .child( v_flex() .flex_1() @@ -421,14 +443,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() @@ -606,6 +627,44 @@ 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, 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 8b08bea2..5ae27a88 100644 --- a/crates/project-wizard/src/lib.rs +++ b/crates/project-wizard/src/lib.rs @@ -13,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..8f37201f 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; @@ -28,6 +29,48 @@ pub fn list(cx: &App) -> Vec { serde_json::from_str(&raw).unwrap_or_default() } +/// Resolve a representative file without opening the project in the UI. The table uses this +/// same filename index; the launcher runs the scan off the UI thread and `preview::thumb` reads +/// its cached rendering when it paints the result. +pub fn preview_source(project_file: &Path) -> Option { + let data = settings::project::load_project_file(project_file).ok()?; + let folder = data.values.get(settings::project::FILES_FOLDER_KEY)?.text(); + if folder.trim().is_empty() { + return None; + } + let root = PathBuf::from(folder.as_ref()); + 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)) + }) +} + pub fn record_opened(name: String, path: String, cx: &mut App) { let mut recents = list(cx); recents.retain(|p| p.path != path); @@ -86,3 +129,64 @@ pub fn relative_time(opened_at_unix: i64) -> String { ago(delta / (86_400 * 30), "month") } } + +#[cfg(test)] +mod tests { + use super::preview_source; + use settings::project::{ProjectColumn, ProjectSpec, create_project_file}; + + #[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 = temp.path().join("collection.qrate"); + let columns = [ProjectColumn { + name: "Image".into(), + data_type: "Filename".into(), + notes: String::new(), + }]; + let headers = ["Image".to_string()]; + let rows = [vec!["object.jpg".to_string()]]; + let folder = files.to_string_lossy(); + create_project_file( + &project, + &ProjectSpec { + name: "Collection", + files_folder: Some(&folder), + columns: &columns, + headers: &headers, + rows: &rows, + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(preview_source(&project), 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 = temp.path().join("collection.qrate"); + let folder = files.to_string_lossy(); + create_project_file( + &project, + &ProjectSpec { + name: "Collection", + files_folder: Some(&folder), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(preview_source(&project), Some(image)); + } +} diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 24e4337b..0f871e50 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -287,7 +287,8 @@ impl ViewsPanel { h_flex() .gap_1() .items_center() - .child(Icon::new(mode.icon()).size_3p5()) + .text_xs() + .child(Icon::new(mode.icon()).size_3()) .child(mode.label()), ) })) From de7734195fb7758fcce75bb7dbe1efefc64eb6a3 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:25:46 -0700 Subject: [PATCH 05/10] fix(settings): apply themes from dropdown and log persistence --- crates/app/src/app_settings/mod.rs | 4 +- crates/app/src/main.rs | 5 +- crates/app/src/theming.rs | 26 +++++++- crates/preview/src/lib.rs | 59 ++++++++++------- crates/project-wizard/src/launcher.rs | 7 +- crates/settings/src/db.rs | 24 ++++++- crates/settings/src/lib.rs | 4 ++ crates/settings/src/project.rs | 8 +++ crates/workspace/src/panels/details.rs | 88 +++++++++++++------------- crates/workspace/src/views/gallery.rs | 7 +- 10 files changed, 155 insertions(+), 77 deletions(-) diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index 4b6cbfb6..0e700a13 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -85,9 +85,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); }, ), )) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 4046c1ff..18be0c97 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -612,7 +612,10 @@ fn main() { cx.register_url_scheme("qrate").detach(); // 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()), diff --git a/crates/app/src/theming.rs b/crates/app/src/theming.rs index 5bfb3d08..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,16 +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); } } @@ -123,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/preview/src/lib.rs b/crates/preview/src/lib.rs index 97254351..890ce29b 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -664,7 +664,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 @@ -675,7 +676,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. @@ -696,25 +697,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() @@ -776,7 +779,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}; @@ -1030,11 +1033,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) } } @@ -1050,14 +1058,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 @@ -1088,7 +1103,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 @@ -1173,7 +1188,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/src/launcher.rs b/crates/project-wizard/src/launcher.rs index ed771646..2a6f3d1c 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -653,7 +653,12 @@ fn project_thumbnail(image: Thumbnail<'_>, size: Pixels, cx: &App) -> AnyElement ) .into_any_element(), Thumbnail::Recent(Some(path)) => frame - .child(preview::thumb(Some(path), preview::CARD, cx)) + .child(preview::thumb( + Some(path), + preview::CARD, + ObjectFit::Cover, + cx, + )) .into_any_element(), Thumbnail::Recent(None) => frame .flex() diff --git a/crates/settings/src/db.rs b/crates/settings/src/db.rs index 082b5bbb..dbaf6a71 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(()) } @@ -186,12 +196,18 @@ impl SettingsWriter { } Err(mpsc::RecvTimeoutError::Timeout) => { if let Some(s) = pending.take() { - let _ = save_app_settings_snapshot(s); + if 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 Err(error) = save_app_settings_snapshot(s) { + log::error!( + "settings: failed to save final app snapshot: {error:#}" + ); + } } break; } @@ -203,6 +219,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 2ffea108..b74b8c03 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -539,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)); }); 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/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs index 0ec57fdc..8695b380 100644 --- a/crates/workspace/src/panels/details.rs +++ b/crates/workspace/src/panels/details.rs @@ -984,49 +984,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 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()) }) From f581bed3028aa8640dfca66fd348fdc8ca1abc2d Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:30:15 -0700 Subject: [PATCH 06/10] fix(settings): expose project override query for row height --- crates/settings/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index b74b8c03..d6d19bae 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -72,7 +72,7 @@ pub const FILTER_SUBDELIMITER_KEY: &str = "filter_subdelimiter"; /// Whether the open project holds a value of its own for `key`, as opposed to inheriting the /// user-wide one. Legacy overrides remain visible and can be cleared from Settings. -fn has_project_override(key: &str, cx: &App) -> bool { +pub fn has_project_override(key: &str, cx: &App) -> bool { cx.try_global::() .is_some_and(|p| p.data.values.contains_key(key)) } From 3187b968dfc620304a25a88854d170375780b2ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:01:45 +0000 Subject: [PATCH 07/10] fix(ci): collapse nested ifs in settings writer, update ThumbProbe test CI's clippy (-D warnings) rejected the two nested if-lets in the app settings writer thread (collapsible_if), and the preview test added for forgetting a file still built ThumbProbe with one field after it gained the cover/contain flag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mg1nNrSAaj2A33ZNXkzYy --- crates/preview/src/lib.rs | 2 +- crates/settings/src/db.rs | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 890ce29b..422fa1c7 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -1081,7 +1081,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(); diff --git a/crates/settings/src/db.rs b/crates/settings/src/db.rs index dbaf6a71..0c85d66a 100644 --- a/crates/settings/src/db.rs +++ b/crates/settings/src/db.rs @@ -195,19 +195,17 @@ impl SettingsWriter { } } Err(mpsc::RecvTimeoutError::Timeout) => { - if let Some(s) = pending.take() { - if let Err(error) = save_app_settings_snapshot(s) { - log::error!("settings: failed to save app snapshot: {error:#}"); - } + 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() { - if let Err(error) = save_app_settings_snapshot(s) { - log::error!( - "settings: failed to save final app snapshot: {error:#}" - ); - } + 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; } From 0f1ac8f3752435f988a8a07481555b8653d0fec2 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:51:39 -0700 Subject: [PATCH 08/10] perf(project-wizard): cache launcher thumbnails instead of rescanning Each recent project remembers its thumbnail and the folder it came from, so the launcher no longer walks every files folder (often an external drive) each time it opens. An unreachable folder is never scanned. --- crates/project-wizard/src/launcher.rs | 32 +++-- crates/project-wizard/src/recent.rs | 180 ++++++++++++++++++++------ 2 files changed, 168 insertions(+), 44 deletions(-) diff --git a/crates/project-wizard/src/launcher.rs b/crates/project-wizard/src/launcher.rs index 2a6f3d1c..7a5d1bcc 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -84,20 +84,36 @@ impl Launcher { } 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 paths: Vec<_> = recents.iter().map(|project| project.path.clone()).collect(); + let cached: Vec<_> = recents + .iter() + .map(|project| (project.path.clone(), project.preview.clone())) + .collect(); let scan = cx.background_spawn(async move { - paths + cached .into_iter() - .filter_map(|path| { - recent::preview_source(Path::new(&path)).map(|image| (path, image)) + .filter_map(|(path, cached)| { + recent::preview(Path::new(&path), cached).map(|preview| (path, preview)) }) - .collect::>() + .collect::>() }); cx.spawn(async move |this, cx| { - let previews = scan.await; + let resolved = scan.await; this.update(cx, |this, cx| { - this.previews = previews; + this.previews = resolved + .iter() + .filter_map(|(path, preview)| Some((path.clone(), preview.image.clone()?))) + .collect(); + recent::store_previews(resolved, cx); cx.notify(); }) .ok(); @@ -106,7 +122,7 @@ impl Launcher { } Self { recents, - previews: HashMap::new(), + previews, error: None, title_items, } diff --git a/crates/project-wizard/src/recent.rs b/crates/project-wizard/src/recent.rs index 8f37201f..2bac2e7b 100644 --- a/crates/project-wizard/src/recent.rs +++ b/crates/project-wizard/src/recent.rs @@ -15,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 { @@ -29,17 +40,37 @@ pub fn list(cx: &App) -> Vec { serde_json::from_str(&raw).unwrap_or_default() } -/// Resolve a representative file without opening the project in the UI. The table uses this -/// same filename index; the launcher runs the scan off the UI thread and `preview::thumb` reads -/// its cached rendering when it paints the result. -pub fn preview_source(project_file: &Path) -> Option { - let data = settings::project::load_project_file(project_file).ok()?; - let folder = data.values.get(settings::project::FILES_FOLDER_KEY)?.text(); +/// 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 root = PathBuf::from(folder.as_ref()); - let paths = file_ingest::scan(&root, true).ok()?; + 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()), ); @@ -71,15 +102,37 @@ pub fn preview_source(project_file: &Path) -> Option { }) } +/// 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); @@ -132,40 +185,52 @@ pub fn relative_time(opened_at_unix: i64) -> String { #[cfg(test)] mod tests { - use super::preview_source; + use std::path::{Path, PathBuf}; + + use super::{CachedPreview, preview}; use settings::project::{ProjectColumn, ProjectSpec, create_project_file}; - #[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 = temp.path().join("collection.qrate"); + /// 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!["object.jpg".to_string()]]; - let folder = files.to_string_lossy(); + 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, - headers: &headers, + 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!(preview_source(&project), Some(image)); + assert_eq!(image_of(&project, None), Some(image)); } #[test] @@ -175,18 +240,61 @@ mod tests { std::fs::create_dir(&files).unwrap(); let image = files.join("object.jpg"); std::fs::write(&image, b"thumbnail source").unwrap(); - let project = temp.path().join("collection.qrate"); - let folder = files.to_string_lossy(); - create_project_file( - &project, - &ProjectSpec { - name: "Collection", - files_folder: Some(&folder), - ..Default::default() - }, - ) - .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!(preview_source(&project), Some(image)); + assert_eq!(image_of(&project, Some(deleted)), Some(image.clone())); + assert_eq!(image_of(&project, Some(relinked)), Some(image)); } } From fadc950c4741ffa85849ea32c142f105cb0bcf68 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:51:46 -0700 Subject: [PATCH 09/10] fix(onboarding): show the export tip only once an export starts Skips the tip when export returns early (no project, Google disabled), and drops the Google hint when the integration is already on. --- crates/app/src/export.rs | 19 +++++++++++-------- crates/app/src/main.rs | 4 +--- crates/onboarding/src/lib.rs | 8 ++++++-- 3 files changed, 18 insertions(+), 13 deletions(-) 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 18be0c97..6ac7c19c 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -291,9 +291,7 @@ impl Render for App { })) // Here rather than globally: the Zotero mapping dialog opens in this window. .on_action(cx.listener(|_, action: &export::Export, window, cx| { - export::run(action.format, window, cx); - // Once, the first time: what an export includes, and where Sheets sync lives. - onboarding::show_export_tip(window, cx); + export::run(action.format, window, cx) })) // Here for the same reason: the dialog opens in this window. .on_action(cx.listener(|_, _: &LoadColumnConfig, window, cx| { diff --git a/crates/onboarding/src/lib.rs b/crates/onboarding/src/lib.rs index e0452560..4d8679e7 100644 --- a/crates/onboarding/src/lib.rs +++ b/crates/onboarding/src/lib.rs @@ -647,13 +647,17 @@ pub fn show_export_tip(window: &mut Window, cx: &mut App) { } 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. To keep \ - a Google Sheet in sync, turn it on in Settings ▸ Google first.", + "Every export includes all {rows} row{}, including rows hidden by a filter.{sheets}", if rows == 1 { "" } else { "s" } )) .autohide(false), From 329c22e477f34f88a22f5a3c01b52cbbb9b9f4c7 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:51:55 -0700 Subject: [PATCH 10/10] docs(skills): drop the removed scope switcher from add-persisted-setting --- .claude/skills/add-persisted-setting/SKILL.md | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) 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.