From ec7d4adabb299ceb3f255f3671edbfb686325dad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 12:52:08 +0000 Subject: [PATCH 1/8] Add project-creation wizard (ASNT-15): launcher + multi-step New Project flow Implements the Claude Design handoff "Qrate Wizard Flow": a Photoshop-style startup launcher (Recent Projects + Create New cards for Blank / CSV+folder / Google Sheet), and a New Project wizard window with Name, Files (CSV or Sheet fork), Link-files, Columns (with recently-used and load-from-file/Sheet sub-dialogs), Review, and Success steps. - New crates/project-wizard crate: wizard shell + per-step render impls, real CSV parsing (csv crate) and folder matching validated against sample/, column-config CSV loading with the design's error variants, recent-projects persistence through AppSettings, and project.json manifest written on create. Google Sheets validation is mocked pending a real Sheets API integration (documented in data.rs). - Launcher replaces the ASNT-13 debug startup window; crates/app/src/debug.rs and its call sites are removed as its comments instructed. - NewProject action wired to the wizard (menu item + existing ctrl-n binding). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LNjLXbAcnnHeTZvk3gbvhh --- Cargo.lock | 37 ++ Cargo.toml | 2 + crates/app/Cargo.toml | 1 + crates/app/src/actions.rs | 8 +- crates/app/src/app_menus.rs | 5 +- crates/app/src/debug.rs | 152 ------- crates/app/src/main.rs | 73 ++-- crates/project-wizard/Cargo.toml | 15 + crates/project-wizard/src/data.rs | 468 +++++++++++++++++++++ crates/project-wizard/src/launcher.rs | 225 ++++++++++ crates/project-wizard/src/lib.rs | 14 + crates/project-wizard/src/project.rs | 68 +++ crates/project-wizard/src/recent.rs | 72 ++++ crates/project-wizard/src/steps/columns.rs | 432 +++++++++++++++++++ crates/project-wizard/src/steps/files.rs | 358 ++++++++++++++++ crates/project-wizard/src/steps/link.rs | 124 ++++++ crates/project-wizard/src/steps/mod.rs | 6 + crates/project-wizard/src/steps/name.rs | 115 +++++ crates/project-wizard/src/steps/review.rs | 156 +++++++ crates/project-wizard/src/steps/success.rs | 66 +++ crates/project-wizard/src/wizard.rs | 421 ++++++++++++++++++ 21 files changed, 2635 insertions(+), 183 deletions(-) delete mode 100644 crates/app/src/debug.rs create mode 100644 crates/project-wizard/Cargo.toml create mode 100644 crates/project-wizard/src/data.rs create mode 100644 crates/project-wizard/src/launcher.rs create mode 100644 crates/project-wizard/src/lib.rs create mode 100644 crates/project-wizard/src/project.rs create mode 100644 crates/project-wizard/src/recent.rs create mode 100644 crates/project-wizard/src/steps/columns.rs create mode 100644 crates/project-wizard/src/steps/files.rs create mode 100644 crates/project-wizard/src/steps/link.rs create mode 100644 crates/project-wizard/src/steps/mod.rs create mode 100644 crates/project-wizard/src/steps/name.rs create mode 100644 crates/project-wizard/src/steps/review.rs create mode 100644 crates/project-wizard/src/steps/success.rs create mode 100644 crates/project-wizard/src/wizard.rs diff --git a/Cargo.lock b/Cargo.lock index e1048262..cd85868c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,7 @@ dependencies = [ "gpui", "gpui-component", "gpui-component-assets", + "project-wizard", "rusqlite", "schemars", "serde", @@ -1264,6 +1265,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.4.3" @@ -4404,6 +4426,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "project-wizard" +version = "0.1.0" +dependencies = [ + "anyhow", + "csv", + "dirs 6.0.0", + "gpui", + "gpui-component", + "serde", + "serde_json", + "settings", + "window-wrapper", +] + [[package]] name = "psm" version = "0.1.30" diff --git a/Cargo.toml b/Cargo.toml index eed063a2..46327436 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/app", "crates/ai", + "crates/project-wizard", "crates/settings", "crates/table", "crates/window-wrapper", @@ -24,6 +25,7 @@ opt-level = 3 [workspace.dependencies] anyhow = "1" +csv = "1.3" gpui = "0.2.2" gpui-component = "0.5.1" gpui-component-assets = "0.5.1" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 09896d19..cdec7583 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -14,6 +14,7 @@ serde.workspace = true serde_json.workspace = true schemars.workspace = true window-wrapper = { path = "../window-wrapper" } +project-wizard = { path = "../project-wizard" } settings = { path = "../settings" } table = { path = "../table" } workspace = { path = "../workspace" } diff --git a/crates/app/src/actions.rs b/crates/app/src/actions.rs index f7230209..aa911658 100644 --- a/crates/app/src/actions.rs +++ b/crates/app/src/actions.rs @@ -35,9 +35,11 @@ pub fn key_bindings() -> Vec { /// Handlers that don't need a `Window`. Window-needing actions (the dock toggles) are handled /// on the `App` view in `main.rs` instead, since global handlers only get `&mut App`. +/// +/// `NewProject` is registered in `main.rs` alongside `OpenSettings` (it needs to call into the +/// `project-wizard` crate). Only `NewWindow` is still a stub here. pub fn register_global_handlers(cx: &mut App) { - // ponytail: stubs. NewProject has no project concept yet; NewWindow is blocked on the - // bar registries being globals (single-window) — de-globalize them per-window first. + // ponytail: NewWindow is blocked on the bar registries being globals (single-window) — + // de-globalize them per-window first. cx.on_action(|_: &NewWindow, _cx| eprintln!("NewWindow: TODO (bar registries are global)")); - cx.on_action(|_: &NewProject, _cx| eprintln!("NewProject: TODO")); } diff --git a/crates/app/src/app_menus.rs b/crates/app/src/app_menus.rs index 9aa3c56b..22c93843 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -1,6 +1,6 @@ use gpui::*; -use crate::debug::DebugOpenBoth; // DEBUG (ASNT-13 manual test scaffolding) +use crate::actions::NewProject; actions!(nav, [OpenSettings, Quit]); @@ -8,9 +8,8 @@ pub fn app_menus() -> Vec { vec![Menu { name: "App".into(), items: vec![ + MenuItem::action("New Project…", NewProject), MenuItem::action("Settings", OpenSettings), - // DEBUG (ASNT-13 manual test scaffolding): remove once ASNT-13 is verified. - MenuItem::action("Debug: Open Settings + Project", DebugOpenBoth), MenuItem::Separator, MenuItem::action("Quit", Quit), ], diff --git a/crates/app/src/debug.rs b/crates/app/src/debug.rs deleted file mode 100644 index 2aa75350..00000000 --- a/crates/app/src/debug.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! DEBUG (ASNT-13 manual test scaffolding — delete this module and its two -//! call sites in `main.rs`/`app_menus.rs` once ASNT-13's multi-window -//! behavior has been manually verified, and before ASNT-15 lands the real -//! project-creation wizard): a mock startup window with a button to open the -//! real main window, plus a menu action that opens Settings and a stub -//! "project" window together so two window kinds can be exercised live at -//! once. - -use gpui::*; -use gpui_component::{Root, StyledExt, TitleBar, button::Button, label::Label, v_flex}; -use settings::AppSettings; -use window_wrapper::WindowRegistry; - -// Aliased: a bare `App` import here would shadow `gpui::App` (from the glob -// import above) for the whole module, since explicit imports win over globs. -use crate::App as MainWindow; -use crate::open_settings_window; - -actions!(debug, [DebugOpenBoth]); - -const MAIN_WINDOW_KIND: &str = "main"; -const DEBUG_PROJECT_KIND: &str = "debug-project-stub"; - -/// The real main-window-opening body, previously inline at the end of -/// `main()`. Now reusable so the mock startup window's button can open it on -/// demand, with the same focus-existing-or-open-new dedup Settings already -/// gets from `WindowRegistry`. -pub fn open_main_window(cx: &mut App) { - if WindowRegistry::focus_or_clear(MAIN_WINDOW_KIND, cx) { - return; - } - - let (main_bounds, main_display) = AppSettings::get(cx).main_window_startup_placement(cx); - let window_options = WindowOptions { - titlebar: Some(TitleBar::title_bar_options()), - window_bounds: Some(WindowBounds::Windowed(main_bounds)), - display_id: main_display, - window_min_size: Some(Size::new(px(520.0), px(300.0))), - ..Default::default() - }; - - cx.spawn(async move |cx| { - let result = cx.open_window(window_options, |window, cx| { - let view = cx.new(|cx| MainWindow::new(window, cx)); - cx.new(|cx| Root::new(view, window, cx)) - }); - - if let Ok(window_handle) = result { - cx.update(|cx| { - WindowRegistry::register(MAIN_WINDOW_KIND, window_handle.into(), cx); - }) - .ok(); - } - }) - .detach(); -} - -/// Bare-bones window standing in for the not-yet-built project-creation -/// wizard (ASNT-15) — just enough to be a second, visibly distinct window -/// kind to test alongside Settings. -struct DebugProjectStub; - -impl Render for DebugProjectStub { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .child(TitleBar::new().child(Label::new("Project Creation (debug)").font_semibold())) - .child( - v_flex() - .flex_1() - .items_center() - .justify_center() - .child("Project Creation (debug stub) — ASNT-15 not built yet"), - ) - } -} - -fn open_debug_project_stub(cx: &mut App) { - if WindowRegistry::focus_or_clear(DEBUG_PROJECT_KIND, cx) { - return; - } - - let bounds = Bounds::centered(None, size(px(500.0), px(300.0)), cx); - let window_options = WindowOptions { - titlebar: Some(TitleBar::title_bar_options()), - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }; - - cx.spawn(async move |cx| { - let result = cx.open_window(window_options, |window, cx| { - let view = cx.new(|_| DebugProjectStub); - cx.new(|cx| Root::new(view, window, cx)) - }); - - if let Ok(window_handle) = result { - cx.update(|cx| { - WindowRegistry::register(DEBUG_PROJECT_KIND, window_handle.into(), cx); - }) - .ok(); - } - }) - .detach(); -} - -/// Registers the `DebugOpenBoth` menu action: opens Settings and the stub -/// project window at once, so their `WindowRegistry` dedup can be exercised -/// with two windows live simultaneously. -pub fn register_debug_menu_action(cx: &mut App) { - cx.on_action(|_: &DebugOpenBoth, cx| { - open_settings_window(cx); - open_debug_project_stub(cx); - }); -} - -/// Mock "project management" window shown at startup instead of the real -/// main window. Its one button opens the real main window and closes this -/// one. -pub struct DebugMockProjectWindow; - -impl Render for DebugMockProjectWindow { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .child(TitleBar::new().child(Label::new("Project Management (debug)").font_semibold())) - .child( - v_flex() - .flex_1() - .items_center() - .justify_center() - .gap_4() - .child("Debug: mock project management window") - .child( - Button::new("open-main-project") - .label("Open Main Project") - .on_click(|_, window, cx| { - open_main_window(cx); - window.remove_window(); - }), - ), - ) - } -} - -pub fn mock_startup_window_options(cx: &mut App) -> WindowOptions { - let bounds = Bounds::centered(None, size(px(400.0), px(200.0)), cx); - WindowOptions { - titlebar: Some(TitleBar::title_bar_options()), - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - } -} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 1a7f650a..6519cd85 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2,13 +2,13 @@ mod actions; mod app_menus; mod app_settings; mod components; -mod debug; // DEBUG (ASNT-13 manual test scaffolding) — see crates/app/src/debug.rs mod helpers; mod status_items; mod title_items; use gpui::*; use gpui_component::{Root, TitleBar, v_flex}; +use project_wizard::{EntryKind, LauncherHooks}; use settings::{ AppSettings, MainWindowBounds, SettingsPersistence, SettingsWindow, SettingsWriter, load_app_settings, @@ -18,12 +18,12 @@ use window_wrapper::{ }; const SETTINGS_WINDOW_KIND: &str = "settings"; +const MAIN_WINDOW_KIND: &str = "main"; use crate::app_settings::build_pages; use crate::{ - actions::{ToggleBottomDock, ToggleLeftDock, ToggleRightDock}, + actions::{NewProject, ToggleBottomDock, ToggleLeftDock, ToggleRightDock}, app_menus::{OpenSettings, Quit, app_menus}, - debug::register_debug_menu_action, // DEBUG (ASNT-13 manual test scaffolding) status_items::build_status_bar_registry, title_items::build_title_bar_registry, }; @@ -31,8 +31,6 @@ use gpui_component::dock::DockPlacement; use workspace::Workspace; /// Opens the Settings window, focusing the existing one if it's already open. -/// Shared by the real `OpenSettings` action and the debug menu's -/// open-two-windows-at-once action (see `crate::debug`). // `gpui::App`, spelled out: a bare `App` here would resolve to this file's own // `pub struct App` below, not the GPUI context type — local items always win // over glob imports regardless of source order. @@ -64,6 +62,38 @@ pub(crate) fn open_settings_window(cx: &mut gpui::App) { .detach(); } +/// Opens the real main app window, focusing the existing one if it's already open. Called by +/// the launcher (`project-wizard` crate) when a recent project is opened or a wizard finishes. +pub(crate) fn open_main_window(cx: &mut gpui::App) { + if WindowRegistry::focus_or_clear(MAIN_WINDOW_KIND, cx) { + return; + } + + let (main_bounds, main_display) = AppSettings::get(cx).main_window_startup_placement(cx); + let window_options = WindowOptions { + titlebar: Some(TitleBar::title_bar_options()), + window_bounds: Some(WindowBounds::Windowed(main_bounds)), + display_id: main_display, + window_min_size: Some(Size::new(px(520.0), px(300.0))), + ..Default::default() + }; + + cx.spawn(async move |cx| { + let result = cx.open_window(window_options, |window, cx| { + let view = cx.new(|cx| App::new(window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }); + + if let Ok(window_handle) = result { + cx.update(|cx| { + WindowRegistry::register(MAIN_WINDOW_KIND, window_handle.into(), cx); + }) + .ok(); + } + }) + .detach(); +} + pub struct App { workspace: Entity, status_bar: Entity, @@ -160,7 +190,16 @@ fn main() { }); cx.set_global(WindowRegistry::default()); + // 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`. + cx.set_global(LauncherHooks { + open_main_window, + }); + cx.on_action(|_: &OpenSettings, cx| open_settings_window(cx)); + cx.on_action(|_: &NewProject, cx| { + project_wizard::open_project_wizard(EntryKind::Blank, cx) + }); // ---------------------------------------------- cx.set_menus(app_menus()); @@ -177,25 +216,9 @@ fn main() { cx.quit(); }); - // DEBUG (ASNT-13 manual test scaffolding): opens Settings + a stub - // project window together via the "Debug: Open Settings + Project" - // menu item. See crates/app/src/debug.rs. - register_debug_menu_action(cx); - - // DEBUG (ASNT-13 manual test scaffolding): the startup window is a mock - // project-management window instead of the real main window, so the real - // main window, Settings, and the stub project window can all be opened - // independently to test WindowRegistry. Its button calls - // debug::open_main_window. Revert this block to open `App::new` directly - // once ASNT-13 has been manually verified. - let window_options = debug::mock_startup_window_options(cx); - cx.spawn(async move |cx| { - cx.open_window(window_options, |window, cx| { - let view = cx.new(|_| debug::DebugMockProjectWindow); - cx.new(|cx| Root::new(view, window, cx)) - }) - .expect("Failed to open window"); - }) - .detach(); + // The launcher (Recent Projects + Create New) is the real startup window — it opens + // the main window itself for a recent project, or a project-creation wizard window for + // "Create New". + project_wizard::open_launcher_window(cx); }); } diff --git a/crates/project-wizard/Cargo.toml b/crates/project-wizard/Cargo.toml new file mode 100644 index 00000000..bf21fa23 --- /dev/null +++ b/crates/project-wizard/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "project-wizard" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +csv.workspace = true +dirs.workspace = true +gpui.workspace = true +gpui-component.workspace = true +serde.workspace = true +serde_json.workspace = true +settings = { path = "../settings" } +window-wrapper = { path = "../window-wrapper" } diff --git a/crates/project-wizard/src/data.rs b/crates/project-wizard/src/data.rs new file mode 100644 index 00000000..f89b35b3 --- /dev/null +++ b/crates/project-wizard/src/data.rs @@ -0,0 +1,468 @@ +//! Real CSV parsing and folder-matching logic used by the Files, Link, and +//! Columns steps. Google Sheets has no OAuth/API integration in this codebase +//! yet, so [`validate_sheet_link`] mocks a successful connection once the URL +//! shape checks out (see its doc comment). + +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +#[derive(Clone, Debug)] +pub struct SpreadsheetPreview { + pub headers: Vec, + pub rows: Vec>, +} + +impl SpreadsheetPreview { + pub fn row_count(&self) -> usize { + self.rows.len() + } + + pub fn column_count(&self) -> usize { + self.headers.len() + } +} + +#[derive(Clone, Debug)] +pub enum SpreadsheetError { + NotCsv, + Empty, + NoHeaderRow, + Io(String), +} + +impl SpreadsheetError { + pub fn message(&self) -> String { + match self { + SpreadsheetError::NotCsv => "That doesn't look like a CSV file".into(), + SpreadsheetError::Empty => "This spreadsheet has no rows yet".into(), + SpreadsheetError::NoHeaderRow => { + "We couldn't find a header row — the first row looks like data".into() + } + SpreadsheetError::Io(e) => format!("We couldn't open that file — {e}"), + } + } +} + +pub fn load_csv_preview(path: &str) -> Result { + let p = Path::new(path); + let looks_like_csv = p + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("csv")) + .unwrap_or(false); + if !looks_like_csv { + return Err(SpreadsheetError::NotCsv); + } + + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .flexible(true) + .from_path(p) + .map_err(|e| SpreadsheetError::Io(e.to_string()))?; + + let headers: Vec = rdr + .headers() + .map_err(|e| SpreadsheetError::Io(e.to_string()))? + .iter() + .map(|s| s.to_string()) + .collect(); + + if headers.iter().all(|h| h.trim().is_empty()) { + return Err(SpreadsheetError::Empty); + } + // Heuristic: if every header cell parses as a bare number, the "header" + // row is almost certainly a data row and there's no real header. + if headers.iter().all(|h| h.trim().parse::().is_ok()) { + return Err(SpreadsheetError::NoHeaderRow); + } + + let mut rows = Vec::new(); + for result in rdr.records() { + let record = result.map_err(|e| SpreadsheetError::Io(e.to_string()))?; + rows.push(record.iter().map(|s| s.to_string()).collect()); + } + if rows.is_empty() { + return Err(SpreadsheetError::Empty); + } + + Ok(SpreadsheetPreview { headers, rows }) +} + +#[derive(Clone, Debug)] +pub struct SheetCheckResult { + pub title: String, + pub row_count: usize, + pub used_first_tab: bool, +} + +#[derive(Clone, Debug)] +pub enum SheetError { + BadLink, +} + +impl SheetError { + pub fn message(&self) -> &'static str { + match self { + SheetError::BadLink => "That doesn't look like a Google Sheets link", + } + } +} + +/// Mocked: qrate has no Google Sheets API/OAuth integration yet. Once the +/// link shape checks out this reports a canned successful connection so the +/// rest of the wizard flow (folder matching, column setup) can be exercised +/// end-to-end. Swap this out for a real Sheets API call when that lands. +pub fn validate_sheet_link(link: &str) -> Result { + let trimmed = link.trim(); + let looks_like_sheet = trimmed.contains("docs.google.com/spreadsheets"); + if trimmed.is_empty() || !looks_like_sheet { + return Err(SheetError::BadLink); + } + Ok(SheetCheckResult { + title: "Aderman Collection Metadata".into(), + row_count: 142, + used_first_tab: true, + }) +} + +#[derive(Clone, Debug)] +pub struct FolderMatch { + pub matched_rows: usize, + pub total_rows: usize, + pub extra_files: usize, +} + +#[derive(Clone, Debug)] +pub enum FolderError { + NotFound, + Permission, + Empty, + NoMatches, +} + +impl FolderError { + pub fn message(&self) -> &'static str { + match self { + FolderError::NotFound | FolderError::NoMatches => { + "This folder doesn't look right — we didn't find any files that match your spreadsheet here" + } + FolderError::Permission => { + "qrate doesn't have permission to open this folder — check its settings and try again" + } + FolderError::Empty => "This folder is empty", + } + } +} + +pub fn list_files(folder: &str) -> Result, FolderError> { + let dir = Path::new(folder); + if !dir.is_dir() { + return Err(FolderError::NotFound); + } + let entries = fs::read_dir(dir).map_err(|_| FolderError::Permission)?; + let files: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_file()) + .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) + .collect(); + if files.is_empty() { + return Err(FolderError::Empty); + } + Ok(files) +} + +/// Matches spreadsheet rows against files in `folder` by looking for any cell +/// whose value equals a filename (or filename stem) in the folder, case +/// insensitively — the same rule the Link step's "match by exact filename" +/// uses, just summarized here for the Files step's inline validation. +pub fn match_folder( + preview: &SpreadsheetPreview, + folder: &str, +) -> Result { + let files = list_files(folder)?; + let file_stems: HashSet = files + .iter() + .map(|f| f.to_lowercase()) + .collect(); + let file_names_no_ext: HashSet = files + .iter() + .map(|f| { + Path::new(f) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(f) + .to_lowercase() + }) + .collect(); + + let mut matched_rows = 0usize; + let mut used_files: HashSet = HashSet::new(); + for row in &preview.rows { + let hit = row.iter().find_map(|cell| { + let c = cell.trim().to_lowercase(); + if c.is_empty() { + return None; + } + if file_stems.contains(&c) { + return Some(c); + } + if file_names_no_ext.contains(&c) { + return Some(c); + } + None + }); + if let Some(hit) = hit { + matched_rows += 1; + used_files.insert(hit); + } + } + + if matched_rows == 0 { + return Err(FolderError::NoMatches); + } + + Ok(FolderMatch { + matched_rows, + total_rows: preview.rows.len(), + extra_files: files.len().saturating_sub(used_files.len()), + }) +} + +/// Same idea as [`match_folder`], but for the Google Sheet path where there's +/// no real row data to inspect (see [`validate_sheet_link`]'s doc comment) — +/// just a row count from the mocked sheet check. Reports every folder file up +/// to that row count as matched. +pub fn match_folder_sheet(sheet_row_count: usize, folder: &str) -> Result { + let files = list_files(folder)?; + let matched_rows = files.len().min(sheet_row_count); + Ok(FolderMatch { + matched_rows, + total_rows: sheet_row_count, + extra_files: files.len().saturating_sub(matched_rows), + }) +} + +#[derive(Clone, Debug)] +pub struct ColumnConfigEntry { + pub name: String, + pub data_type: String, + pub description: String, +} + +#[derive(Clone, Debug)] +pub struct ColumnConfigPreview { + pub entries: Vec, +} + +#[derive(Clone, Debug)] +pub enum ColumnConfigError { + MissingDataType, + DuplicateNames(String), + NoMatch, + Io(String), +} + +impl ColumnConfigError { + pub fn message(&self) -> String { + match self { + ColumnConfigError::MissingDataType => { + "This config is missing a Data Type column — we need that to load it".into() + } + ColumnConfigError::DuplicateNames(name) => { + format!("Two columns are both named '{name}' — column names must be unique") + } + ColumnConfigError::NoMatch => { + "This doesn't mention any columns from your spreadsheet — double check you picked the right file".into() + } + ColumnConfigError::Io(e) => format!("We couldn't open that file — {e}"), + } + } +} + +/// Loads a `column_config.csv`-shaped file (Column Name, Data Type, +/// Description) and checks it against the spreadsheet's own headers. +pub fn load_column_config( + path: &str, + against_headers: &[String], +) -> Result { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .flexible(true) + .from_path(path) + .map_err(|e| ColumnConfigError::Io(e.to_string()))?; + + let headers = rdr + .headers() + .map_err(|e| ColumnConfigError::Io(e.to_string()))? + .clone(); + + let name_ix = headers + .iter() + .position(|h| h.eq_ignore_ascii_case("Column Name")); + let type_ix = headers + .iter() + .position(|h| h.eq_ignore_ascii_case("Data Type")); + let desc_ix = headers + .iter() + .position(|h| h.eq_ignore_ascii_case("Description")); + + let (Some(name_ix), Some(type_ix)) = (name_ix, type_ix) else { + return Err(ColumnConfigError::MissingDataType); + }; + + let mut entries = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for result in rdr.records() { + let record = result.map_err(|e| ColumnConfigError::Io(e.to_string()))?; + let name = record.get(name_ix).unwrap_or_default().trim().to_string(); + if name.is_empty() { + continue; + } + if !seen.insert(name.to_lowercase()) { + return Err(ColumnConfigError::DuplicateNames(name)); + } + entries.push(ColumnConfigEntry { + name: name.clone(), + data_type: record.get(type_ix).unwrap_or_default().trim().to_string(), + description: desc_ix + .and_then(|i| record.get(i)) + .unwrap_or_default() + .trim() + .to_string(), + }); + } + + let matches_any = against_headers.is_empty() + || entries + .iter() + .any(|e| against_headers.iter().any(|h| h.eq_ignore_ascii_case(&e.name))); + if !matches_any { + return Err(ColumnConfigError::NoMatch); + } + + Ok(ColumnConfigPreview { entries }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn sample_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../sample") + .canonicalize() + .expect("sample/ directory present in repo") + } + + #[test] + fn loads_sample_csv() { + let csv = sample_dir().join("aderman_collection.csv"); + let preview = load_csv_preview(csv.to_str().unwrap()).unwrap(); + assert_eq!(preview.headers[0], "Digital ID"); + assert_eq!(preview.row_count(), 4); + assert_eq!(preview.column_count(), 6); + } + + #[test] + fn rejects_non_csv() { + assert!(matches!( + load_csv_preview("/tmp/whatever.txt"), + Err(SpreadsheetError::NotCsv) + )); + } + + #[test] + fn matches_sample_photos_by_digital_id() { + let csv = sample_dir().join("aderman_collection.csv"); + let preview = load_csv_preview(csv.to_str().unwrap()).unwrap(); + let photos = sample_dir().join("photos"); + // Digital IDs 1-4 match photos/1.jpg..4.jpg by filename stem. + let m = match_folder(&preview, photos.to_str().unwrap()).unwrap(); + assert_eq!(m.matched_rows, 4); + assert_eq!(m.total_rows, 4); + assert_eq!(m.extra_files, 0); + } + + #[test] + fn folder_errors() { + let csv = sample_dir().join("aderman_collection.csv"); + let preview = load_csv_preview(csv.to_str().unwrap()).unwrap(); + assert!(matches!( + match_folder(&preview, "/nonexistent/folder"), + Err(FolderError::NotFound) + )); + let empty = tempdir("qrate-empty-folder"); + assert!(matches!( + match_folder(&preview, empty.to_str().unwrap()), + Err(FolderError::Empty) + )); + } + + #[test] + fn sheet_link_validation() { + assert!(validate_sheet_link("https://docs.google.com/spreadsheets/d/abc123/edit").is_ok()); + assert!(matches!( + validate_sheet_link("https://example.com/not-a-sheet"), + Err(SheetError::BadLink) + )); + assert!(matches!(validate_sheet_link(""), Err(SheetError::BadLink))); + } + + #[test] + fn column_config_round_trip_and_errors() { + let dir = tempdir("qrate-config-test"); + + let good = dir.join("column_config.csv"); + write!( + std::fs::File::create(&good).unwrap(), + "Column Name,Data Type,Description\nTitle,Text,The title\nDate Created,Date (YYYY-MM-DD),When made\n" + ) + .unwrap(); + let headers = vec!["Title".to_string(), "Creator".to_string()]; + let preview = load_column_config(good.to_str().unwrap(), &headers).unwrap(); + assert_eq!(preview.entries.len(), 2); + assert_eq!(preview.entries[0].data_type, "Text"); + + let missing_type = dir.join("missing_type.csv"); + write!( + std::fs::File::create(&missing_type).unwrap(), + "Column Name,Description\nTitle,The title\n" + ) + .unwrap(); + assert!(matches!( + load_column_config(missing_type.to_str().unwrap(), &headers), + Err(ColumnConfigError::MissingDataType) + )); + + let dupes = dir.join("dupes.csv"); + write!( + std::fs::File::create(&dupes).unwrap(), + "Column Name,Data Type\nTitle,Text\ntitle,Text\n" + ) + .unwrap(); + assert!(matches!( + load_column_config(dupes.to_str().unwrap(), &headers), + Err(ColumnConfigError::DuplicateNames(_)) + )); + + let unrelated = dir.join("unrelated.csv"); + write!( + std::fs::File::create(&unrelated).unwrap(), + "Column Name,Data Type\nSomething Else,Text\n" + ) + .unwrap(); + assert!(matches!( + load_column_config(unrelated.to_str().unwrap(), &headers), + Err(ColumnConfigError::NoMatch) + )); + } + + fn tempdir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } +} diff --git a/crates/project-wizard/src/launcher.rs b/crates/project-wizard/src/launcher.rs new file mode 100644 index 00000000..c6a07904 --- /dev/null +++ b/crates/project-wizard/src/launcher.rs @@ -0,0 +1,225 @@ +//! Stage 1 · the Photoshop-style launcher shown at startup: a Recent +//! Projects list on the left, "Create New" entry cards on the right. +//! +//! Opening a recent project or "Create New" both need to hand off to the +//! real main app window, which lives in the `app` crate — a crate this one +//! 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 gpui::{prelude::FluentBuilder, *}; +use gpui_component::label::Label; +use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex}; +use window_wrapper::WindowRegistry; + +use crate::recent::{self, RecentProject}; +use crate::wizard::{self, EntryKind}; + +pub const LAUNCHER_WINDOW_KIND: &str = "project-launcher"; + +/// Set once at startup (see `crates/app/src/main.rs`) so the launcher can +/// open the real main window without depending on the `app` crate. +#[derive(Clone, Copy)] +pub struct LauncherHooks { + pub open_main_window: fn(&mut App), +} + +impl Global for LauncherHooks {} + +pub struct Launcher { + recents: Vec, +} + +impl Launcher { + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + Self { + recents: recent::list(cx), + } + } + + fn open_recent(&mut self, path: String, name: String, window: &mut Window, cx: &mut Context) { + recent::record_opened(name, path, cx); + if let Some(hooks) = cx.try_global::().copied() { + (hooks.open_main_window)(cx); + } + window.remove_window(); + } + + fn start_new(&mut self, entry_kind: EntryKind, cx: &mut Context) { + wizard::open_project_wizard(entry_kind, cx); + } +} + +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 mut list = v_flex().gap_0(); + for (ix, project) in self.recents.iter().enumerate() { + let path = project.path.clone(); + let name = project.name.clone(); + let when = recent::relative_time(project.opened_at_unix); + list = list.child( + h_flex() + .id(("recent-project", ix)) + .cursor_pointer() + .gap_2p5() + .items_start() + .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( + v_flex() + .min_w(px(0.)) + .child(div().font_semibold().text_sm().child(project.name.clone())) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(format!("{} · opened {when}", project.path)), + ), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_recent(path.clone(), name.clone(), window, cx) + })), + ); + } + list + }; + + v_flex() + .size_full() + .bg(cx.theme().background) + .child(TitleBar::new().child(Label::new("qrate").font_semibold())) + .child( + h_flex() + .id("launcher-body") + .flex_1() + .gap_5() + .p_5() + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .gap_2() + .child( + h_flex() + .justify_between() + .items_baseline() + .child(div().text_lg().font_semibold().child("Recent Projects")) + .child( + Label::new("Open other…") + .text_sm() + .text_color(cx.theme().muted_foreground), + ), + ) + .child(recent_list) + .child( + Label::new("Opening a recent project skips the wizard entirely.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ), + ) + .child( + v_flex() + .w(px(220.)) + .flex_none() + .gap_2() + .pl_5() + .border_l_1() + .border_color(cx.theme().border) + .child(div().text_lg().font_semibold().child("Create New")) + .child(create_card( + "new-blank", + "Blank", + "Add files whenever you're ready.", + cx, + )) + .child(create_card( + "new-csv", + "CSV + folder", + "Import a spreadsheet and its files.", + cx, + )) + .child(create_card( + "new-sheet", + "Google Sheet", + "Use a shared spreadsheet link.", + cx, + )), + ), + ) + .children(dialog_layer) + } +} + +fn create_card( + id: &'static str, + title: &'static str, + description: &'static str, + cx: &mut Context, +) -> impl IntoElement { + let entry_kind = match id { + "new-csv" => EntryKind::Csv, + "new-sheet" => EntryKind::Sheet, + _ => EntryKind::Blank, + }; + v_flex() + .id(id) + .cursor_pointer() + .gap_1() + .p_2p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .hover(|el| el.border_color(cx.theme().primary)) + .child(div().font_semibold().text_sm().child(title)) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(description), + ) + .on_click(cx.listener(move |this, _, _, cx| this.start_new(entry_kind, cx))) +} + +pub fn open_launcher_window(cx: &mut App) { + if WindowRegistry::focus_or_clear(LAUNCHER_WINDOW_KIND, cx) { + return; + } + let bounds = Bounds::centered(None, size(px(760.0), px(480.0)), cx); + let window_options = WindowOptions { + titlebar: Some(TitleBar::title_bar_options()), + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(Size::new(px(600.0), px(380.0))), + ..Default::default() + }; + + cx.spawn(async move |cx| { + let result = cx.open_window(window_options, |window, cx| { + let view = cx.new(|cx| Launcher::new(window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }); + if let Ok(window_handle) = result { + cx.update(|cx| { + WindowRegistry::register(LAUNCHER_WINDOW_KIND, window_handle.into(), cx); + }) + .ok(); + } + }) + .detach(); +} diff --git a/crates/project-wizard/src/lib.rs b/crates/project-wizard/src/lib.rs new file mode 100644 index 00000000..99815034 --- /dev/null +++ b/crates/project-wizard/src/lib.rs @@ -0,0 +1,14 @@ +//! The qrate "New Project" wizard: the Photoshop-style [`launcher`] shown on +//! open, and the multi-step [`wizard`] it launches for the Blank / CSV / +//! Google Sheet entry paths. See `crates/project-wizard/../../chats/chat1.md` +//! in the design handoff bundle for the UX rationale behind each step. + +mod data; +pub mod launcher; +mod project; +mod recent; +mod steps; +pub mod wizard; + +pub use launcher::{LauncherHooks, open_launcher_window}; +pub use wizard::{EntryKind, open_project_wizard}; diff --git a/crates/project-wizard/src/project.rs b/crates/project-wizard/src/project.rs new file mode 100644 index 00000000..c4d93d1c --- /dev/null +++ b/crates/project-wizard/src/project.rs @@ -0,0 +1,68 @@ +//! Writes the on-disk result of "Create Project". qrate has no project data +//! model yet (see `crates/app/src/actions.rs`'s `NewProject` stub comment), +//! so this writes a small `project.json` manifest into a folder named after +//! the project — enough for the wizard to produce a real, inspectable result +//! until a proper project format lands. + +use std::path::Path; + +use serde::Serialize; + +#[derive(Serialize)] +pub struct ManifestColumn { + pub name: String, + pub data_type: String, + pub description: String, +} + +#[derive(Serialize)] +pub struct ProjectManifest { + pub name: String, + pub source: String, + pub files_matched: Option<(usize, usize)>, + pub link_method: Option, + pub columns: Vec, +} + +pub fn sanitize_folder_name(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| { + if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + "Untitled Project".into() + } else { + trimmed.into() + } +} + +pub fn project_dir(save_dir: &str, name: &str) -> std::path::PathBuf { + Path::new(save_dir).join(sanitize_folder_name(name)) +} + +pub fn name_taken(save_dir: &str, name: &str) -> bool { + if save_dir.trim().is_empty() || name.trim().is_empty() { + return false; + } + project_dir(save_dir, name).exists() +} + +pub fn write_project_file( + save_dir: &str, + name: &str, + manifest: &ProjectManifest, +) -> anyhow::Result { + let dir = project_dir(save_dir, name); + std::fs::create_dir_all(&dir)?; + let path = dir.join("project.json"); + let json = serde_json::to_string_pretty(manifest)?; + std::fs::write(&path, json)?; + Ok(dir.to_string_lossy().to_string()) +} diff --git a/crates/project-wizard/src/recent.rs b/crates/project-wizard/src/recent.rs new file mode 100644 index 00000000..41356b74 --- /dev/null +++ b/crates/project-wizard/src/recent.rs @@ -0,0 +1,72 @@ +//! Recent-projects list shown on the launcher. Persisted through the same +//! `AppSettings` key-value store + `SettingsWriter` the rest of the app uses, +//! under one JSON-encoded key — there's no dedicated "projects" table yet. + +use gpui::App; +use serde::{Deserialize, Serialize}; +use settings::AppSettings; + +const RECENTS_KEY: &str = "project_wizard.recent_projects"; +const MAX_RECENTS: usize = 20; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RecentProject { + pub name: String, + pub path: String, + pub opened_at_unix: i64, +} + +pub fn list(cx: &App) -> Vec { + let raw = AppSettings::get(cx) + .values + .get(RECENTS_KEY) + .map(|v| v.text()) + .unwrap_or_default(); + if raw.is_empty() { + return Vec::new(); + } + serde_json::from_str(&raw).unwrap_or_default() +} + +pub fn record_opened(name: String, path: String, cx: &mut App) { + let mut recents = list(cx); + recents.retain(|p| p.path != path); + recents.insert( + 0, + RecentProject { + name, + path, + opened_at_unix: now_unix(), + }, + ); + recents.truncate(MAX_RECENTS); + let Ok(json) = serde_json::to_string(&recents) else { + return; + }; + AppSettings::set_text(RECENTS_KEY, json.into(), cx); +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +pub fn relative_time(opened_at_unix: i64) -> String { + let now = now_unix(); + let delta = (now - opened_at_unix).max(0); + if delta < 60 { + "just now".into() + } else if delta < 3600 { + format!("{} minutes ago", delta / 60) + } else if delta < 86_400 { + format!("{} hours ago", delta / 3600) + } else if delta < 86_400 * 7 { + format!("{} days ago", delta / 86_400) + } else if delta < 86_400 * 30 { + format!("{} weeks ago", delta / (86_400 * 7)) + } else { + format!("{} months ago", delta / (86_400 * 30)) + } +} diff --git a/crates/project-wizard/src/steps/columns.rs b/crates/project-wizard/src/steps/columns.rs new file mode 100644 index 00000000..232ee68a --- /dev/null +++ b/crates/project-wizard/src/steps/columns.rs @@ -0,0 +1,432 @@ +//! Stage 4 · Set up your columns, plus the Stage 4b/4c sub-dialogs opened +//! from the "recently used" and "load from file/Sheet" cards. + +use gpui::{prelude::FluentBuilder, *}; +use gpui_component::button::Button; +use gpui_component::collapsible::Collapsible; +use gpui_component::dialog::DialogButtonProps; +use gpui_component::input::Input; +use gpui_component::label::Label; +use gpui_component::tab::{Tab, TabBar}; +use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; + +use crate::data; +use crate::steps::files::{MsgKind, inline_message}; +use crate::wizard::{ColumnSource, LoadConfigTab, ProjectWizard, RECENT_CONFIGS, option_card}; + +impl ProjectWizard { + fn browse_config_file(&mut self, cx: &mut Context) { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Choose a column config file".into()), + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = receiver.await + && let Some(path) = paths.first() + { + let s = path.to_string_lossy().to_string(); + this.update(cx, |this, cx| { + this.load_config_from_file(s, cx); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + } + + fn load_config_from_file(&mut self, path: String, _cx: &mut Context) { + self.config_file_path = path; + let headers = self.spreadsheet_headers(); + match data::load_column_config(&self.config_file_path, &headers) { + Ok(preview) => { + self.config_preview = Some(preview); + self.config_error = None; + } + Err(e) => { + self.config_error = Some(e.message().into()); + self.config_preview = None; + } + } + } + + /// Mocked, same rationale as `data::validate_sheet_link` — no Sheets API + /// integration exists yet, so a plausible config (one Text column per + /// spreadsheet header) is synthesized once the link shape checks out. + fn load_config_from_sheet(&mut self, cx: &mut Context) { + let link = self.sheet_link_input.read(cx).value().to_string(); + match data::validate_sheet_link(&link) { + Ok(_) => { + self.config_preview = Some(data::ColumnConfigPreview { + entries: self + .spreadsheet_headers() + .into_iter() + .map(|name| data::ColumnConfigEntry { + name, + data_type: "Text".into(), + description: String::new(), + }) + .collect(), + }); + self.config_error = None; + } + Err(e) => { + self.config_error = Some(e.message().into()); + self.config_preview = None; + } + } + } + + fn open_recent_config_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let entity = cx.entity(); + window.open_dialog(cx, move |dialog, _window, cx| { + let selected_ix = entity.read(cx).recent_config_selected; + + let mut list = v_flex().gap_2(); + for (ix, config) in RECENT_CONFIGS.iter().enumerate() { + let entity_row = entity.clone(); + list = list.child( + option_card( + ("recent-config", ix), + config.name, + format!( + "{} columns · last used {}", + config.column_count, config.last_used + ), + ix == selected_ix, + cx, + ) + .on_click(move |_, _, cx| { + entity_row.update(cx, |this, cx| { + this.recent_config_selected = ix; + cx.notify(); + }); + }), + ); + } + + let warning = if RECENT_CONFIGS[selected_ix].name == "Photo Donations 2025" { + Some("'Photo Donations 2025' only matches 2 of your 8 columns — use it anyway, or start fresh?") + } else { + None + }; + + let entity_ok = entity.clone(); + dialog + .title("Choose a saved column config") + .child( + v_flex() + .gap_3() + .child(list) + .when_some(warning, |el, msg| { + el.child(inline_message(msg, MsgKind::Warning, cx)) + }), + ) + .confirm() + .button_props( + DialogButtonProps::default() + .ok_text("Use this config") + .cancel_text("Cancel"), + ) + .on_ok(move |_, _, cx| { + entity_ok.update(cx, |this, cx| { + this.column_source = ColumnSource::RecentlyUsed; + cx.notify(); + }); + true + }) + }); + } + + fn open_load_config_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let entity = cx.entity(); + window.open_dialog(cx, move |dialog, _window, cx| { + let state = entity.read(cx); + let tab = state.load_config_tab; + let file_path = state.config_file_path.clone(); + let preview = state.config_preview.clone(); + let error = state.config_error.clone(); + let sheet_link_input = state.sheet_link_input.clone(); + + let tabs = TabBar::new("load-config-tabs") + .segmented() + .selected_index(match tab { + LoadConfigTab::File => 0, + LoadConfigTab::Sheet => 1, + }) + .on_click({ + let entity = entity.clone(); + move |ix: &usize, _, cx| { + let ix = *ix; + entity.update(cx, |this, cx| { + this.load_config_tab = if ix == 0 { + LoadConfigTab::File + } else { + LoadConfigTab::Sheet + }; + cx.notify(); + }); + } + }) + .child(Tab::new().label("From file")) + .child(Tab::new().label("From Google Sheet")); + + let body = match tab { + LoadConfigTab::File => { + let entity_browse = entity.clone(); + v_flex() + .gap_1() + .child(Label::new("Config file").text_sm()) + .child( + h_flex() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .px_2() + .py_1p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(if file_path.is_empty() { + "Choose a file…".to_string() + } else { + file_path.clone() + }), + ) + .child( + Button::new("browse-config") + .label("Browse…") + .outline() + .on_click(move |_, _, cx| { + entity_browse.update(cx, |this, cx| { + this.browse_config_file(cx) + }); + }), + ), + ) + .into_any_element() + } + LoadConfigTab::Sheet => { + let entity_check = entity.clone(); + v_flex() + .gap_1() + .child(Label::new("Sheet link").text_sm()) + .child( + h_flex() + .gap_2() + .child(Input::new(&sheet_link_input).flex_1()) + .child( + Button::new("check-config-sheet") + .label("Check") + .outline() + .on_click(move |_, _, cx| { + entity_check.update(cx, |this, cx| { + this.load_config_from_sheet(cx) + }); + }), + ), + ) + .into_any_element() + } + }; + + let status = match (&preview, &error) { + (Some(p), _) => Some( + inline_message( + format!("✓ Loaded {} columns", p.entries.len()), + MsgKind::Success, + cx, + ) + .into_any_element(), + ), + (None, Some(e)) => { + Some(inline_message(e.clone(), MsgKind::Error, cx).into_any_element()) + } + (None, None) => None, + }; + + let entity_ok = entity.clone(); + dialog + .title("Load column config") + .child(v_flex().gap_3().child(tabs).child(body).children(status)) + .confirm() + .button_props( + DialogButtonProps::default() + .ok_text("Next →") + .cancel_text("← Back"), + ) + .on_ok(move |_, _, cx| { + let has_preview = entity_ok.read(cx).config_preview.is_some(); + if has_preview { + entity_ok.update(cx, |this, cx| { + this.column_source = ColumnSource::LoadFromFileOrSheet; + cx.notify(); + }); + true + } else { + false + } + }) + }); + } + + fn render_advanced_mapping(&mut self, cx: &mut Context) -> impl IntoElement { + let headers = self.spreadsheet_headers(); + let config = self.config_preview.clone(); + let open = self.show_advanced_mapping; + + Collapsible::new() + .open(open) + .child( + v_flex() + .id("advanced-mapping-toggle") + .cursor_pointer() + .gap_1() + .p_2p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child( + Label::new(if open { + "▾ Advanced: column mapping (optional)" + } else { + "▸ Advanced: column mapping (optional)" + }) + .text_sm(), + ) + .when(!open, |el| { + el.child( + Label::new( + "We matched your columns automatically. Only open this if something looks off.", + ) + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.show_advanced_mapping = !this.show_advanced_mapping; + cx.notify(); + })), + ) + .content({ + let mut table = v_flex().gap_1().mt_2().child( + Label::new("Spreadsheet column → qrate field") + .text_sm() + .text_color(cx.theme().muted_foreground), + ); + if headers.is_empty() { + table = table.child( + Label::new("No spreadsheet columns yet.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ); + } + for header in &headers { + let mapped = config.as_ref().and_then(|c| { + c.entries + .iter() + .find(|e| e.name.eq_ignore_ascii_case(header)) + .map(|e| { + if e.data_type.is_empty() { + e.name.clone() + } else { + format!("{} ({})", e.name, e.data_type) + } + }) + }); + let (target, unmapped) = match mapped { + Some(name) => (name, false), + None => ("Unmapped".to_string(), true), + }; + table = table.child( + h_flex() + .justify_between() + .text_sm() + .py_1() + .border_b_1() + .border_color(cx.theme().border) + .when(unmapped, |el| el.text_color(cx.theme().warning)) + .child(header.clone()) + .child(format!( + "→ {target}{}", + if unmapped { " ⚠" } else { "" } + )), + ); + } + table + }) + } + + pub(crate) fn render_columns_step( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let auto_selected = self.column_source == ColumnSource::AutoFromSpreadsheet; + let recent_selected = self.column_source == ColumnSource::RecentlyUsed; + let load_selected = self.column_source == ColumnSource::LoadFromFileOrSheet; + let recent_summary = RECENT_CONFIGS + .iter() + .map(|c| format!("\"{}\"", c.name)) + .collect::>() + .join(" · "); + + v_flex() + .gap_3() + .child(div().text_lg().font_semibold().child("Set up your columns")) + .child( + Label::new("Choose where the field list comes from.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + .child( + option_card( + "col-auto", + "Auto-created from this spreadsheet", + "Default — columns matched from what you just imported.", + auto_selected, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.column_source = ColumnSource::AutoFromSpreadsheet; + cx.notify(); + })), + ) + .child( + option_card( + "col-recent", + "Choose a recently used config", + recent_summary, + recent_selected, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.open_recent_config_dialog(window, cx) + })), + ) + .child( + option_card( + "col-load", + "Load from a file or Sheet link…", + "Same picker as your main import — CSV file or Google Sheet.", + load_selected, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.open_load_config_dialog(window, cx) + })), + ) + .child(self.render_advanced_mapping(cx)) + .child( + Label::new("You can always adjust this later in project settings.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + } +} diff --git a/crates/project-wizard/src/steps/files.rs b/crates/project-wizard/src/steps/files.rs new file mode 100644 index 00000000..8087d9fc --- /dev/null +++ b/crates/project-wizard/src/steps/files.rs @@ -0,0 +1,358 @@ +//! Stage 3a/3b · Files — the CSV and Google Sheet forks. Both need a files +//! folder and share the same folder-matching validation; only the top field +//! (spreadsheet file vs. sheet link) differs. + +use gpui::*; +use gpui_component::button::Button; +use gpui_component::input::Input; +use gpui_component::label::Label; +use gpui_component::{ActiveTheme, Icon, IconName, StyledExt, h_flex, v_flex}; + +use crate::data; +use crate::wizard::{EntryKind, ProjectWizard}; + +pub(crate) fn inline_message( + text: impl Into, + kind: MsgKind, + cx: &App, +) -> impl IntoElement { + let (color, icon) = match kind { + MsgKind::Success => (cx.theme().success, IconName::CircleCheck), + MsgKind::Warning => (cx.theme().warning, IconName::TriangleAlert), + MsgKind::Error => (cx.theme().danger, IconName::TriangleAlert), + }; + h_flex() + .gap_1() + .text_sm() + .text_color(color) + .child(Icon::new(icon)) + .child(text.into()) +} + +pub(crate) enum MsgKind { + Success, + Warning, + Error, +} + +impl ProjectWizard { + fn browse_for_csv(&mut self, window: &mut Window, cx: &mut Context) { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Choose your spreadsheet".into()), + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = receiver.await + && let Some(path) = paths.first() + { + let s = path.to_string_lossy().to_string(); + this.update(cx, |this, cx| { + this.set_csv_path(s, cx); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + let _ = window; + } + + fn browse_for_folder(&mut self, window: &mut Window, cx: &mut Context) { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Choose your files folder".into()), + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = receiver.await + && let Some(path) = paths.first() + { + let s = path.to_string_lossy().to_string(); + this.update(cx, |this, cx| { + this.set_folder_path(s, cx); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + let _ = window; + } + + fn set_csv_path(&mut self, path: String, _cx: &mut Context) { + self.csv_path = path; + match data::load_csv_preview(&self.csv_path) { + Ok(preview) => { + self.csv_preview = Some(preview); + self.csv_error = None; + } + Err(e) => { + self.csv_error = Some(e.message().into()); + self.csv_preview = None; + } + } + self.revalidate_folder(); + } + + fn set_folder_path(&mut self, path: String, _cx: &mut Context) { + self.folder_path = path; + self.revalidate_folder(); + } + + fn revalidate_folder(&mut self) { + if self.folder_path.is_empty() { + self.folder_match = None; + self.folder_error = None; + return; + } + let result = match self.entry_kind { + EntryKind::Csv => self.csv_preview.as_ref().map(|preview| { + data::match_folder(preview, &self.folder_path) + }), + EntryKind::Sheet => self + .sheet_check + .as_ref() + .map(|check| data::match_folder_sheet(check.row_count, &self.folder_path)), + EntryKind::Blank => None, + }; + match result { + Some(Ok(m)) => { + self.folder_match = Some(m); + self.folder_error = None; + } + Some(Err(e)) => { + self.folder_match = None; + self.folder_error = Some(e.message().into()); + } + None => { + self.folder_match = None; + self.folder_error = None; + } + } + } + + fn check_sheet_link(&mut self, cx: &mut Context) { + let link = self.sheet_link_input.read(cx).value().to_string(); + match data::validate_sheet_link(&link) { + Ok(check) => { + self.sheet_check = Some(check); + self.sheet_error = None; + } + Err(e) => { + self.sheet_error = Some(e.message().into()); + self.sheet_check = None; + } + } + self.revalidate_folder(); + } + + pub(crate) fn render_files_step( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + match self.entry_kind { + EntryKind::Csv => self.render_csv_files(window, cx).into_any_element(), + EntryKind::Sheet => self.render_sheet_files(window, cx).into_any_element(), + EntryKind::Blank => div().into_any_element(), + } + } + + fn render_csv_files(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let csv_display = if self.csv_path.is_empty() { + "Choose a CSV file…".to_string() + } else { + self.csv_path.clone() + }; + let folder_display = if self.folder_path.is_empty() { + "Choose your files folder…".to_string() + } else { + self.folder_path.clone() + }; + + v_flex() + .gap_3() + .child( + div() + .text_lg() + .font_semibold() + .child("Choose your spreadsheet & files"), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Spreadsheet (CSV)").text_sm()) + .child( + h_flex() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .px_2() + .py_1p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(csv_display), + ) + .child( + Button::new("browse-csv").label("Browse…").outline().on_click( + cx.listener(|this, _, window, cx| { + this.browse_for_csv(window, cx) + }), + ), + ), + ) + .child(match (&self.csv_preview, &self.csv_error) { + (Some(p), _) => inline_message( + format!("✓ {} rows, {} columns found", p.row_count(), p.column_count()), + MsgKind::Success, + cx, + ) + .into_any_element(), + (None, Some(e)) => inline_message(e.clone(), MsgKind::Error, cx).into_any_element(), + (None, None) => div().into_any_element(), + }), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Files folder").text_sm()) + .child( + h_flex() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .px_2() + .py_1p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(folder_display), + ) + .child( + Button::new("browse-folder").label("Browse…").outline().on_click( + cx.listener(|this, _, window, cx| { + this.browse_for_folder(window, cx) + }), + ), + ), + ) + .child(self.render_folder_status(cx)), + ) + } + + fn render_sheet_files(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let folder_display = if self.folder_path.is_empty() { + "Choose your files folder…".to_string() + } else { + self.folder_path.clone() + }; + + v_flex() + .gap_3() + .child( + div() + .text_lg() + .font_semibold() + .child("Connect your Google Sheet"), + ) + .child( + 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(cx)), + ), + ), + ) + .child(match (&self.sheet_check, &self.sheet_error) { + (Some(c), _) => inline_message( + format!( + "✓ Found \"{}\" — {} rows{}", + c.title, + c.row_count, + if c.used_first_tab { + " (using the first tab, 'Sheet1')" + } else { + "" + } + ), + MsgKind::Success, + cx, + ) + .into_any_element(), + (None, Some(e)) => inline_message(e.clone(), MsgKind::Error, cx).into_any_element(), + (None, None) => div().into_any_element(), + }), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Files folder").text_sm()) + .child( + h_flex() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .px_2() + .py_1p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(folder_display), + ) + .child( + Button::new("browse-folder-sheet") + .label("Browse…") + .outline() + .on_click(cx.listener(|this, _, window, cx| { + this.browse_for_folder(window, cx) + })), + ), + ) + .child(self.render_folder_status(cx)), + ) + } + + fn render_folder_status(&self, cx: &App) -> AnyElement { + match (&self.folder_match, &self.folder_error) { + (Some(m), _) if m.matched_rows == m.total_rows => inline_message( + format!("✓ {} of {} files matched", m.matched_rows, m.total_rows), + MsgKind::Success, + cx, + ) + .into_any_element(), + (Some(m), _) => inline_message( + format!( + "⚠ Matched {} of {} files — review mismatches", + m.matched_rows, m.total_rows + ), + MsgKind::Warning, + cx, + ) + .into_any_element(), + (None, Some(e)) => inline_message(e.clone(), MsgKind::Error, cx).into_any_element(), + (None, None) => div().into_any_element(), + } + } +} diff --git a/crates/project-wizard/src/steps/link.rs b/crates/project-wizard/src/steps/link.rs new file mode 100644 index 00000000..b71c8488 --- /dev/null +++ b/crates/project-wizard/src/steps/link.rs @@ -0,0 +1,124 @@ +//! Stage 3.5 · Link your files to rows. Default "match by exact filename" +//! reuses the folder match already computed in the Files step; the +//! collapsible advanced section offers a custom naming pattern. + +use gpui::{prelude::FluentBuilder, *}; +use gpui_component::collapsible::Collapsible; +use gpui_component::input::Input; +use gpui_component::label::Label; +use gpui_component::{ActiveTheme, Icon, IconName, StyledExt, h_flex, v_flex}; + +use crate::wizard::{LinkMethod, ProjectWizard, option_card}; + +impl ProjectWizard { + pub(crate) fn render_link_step( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let folder = self.folder_path.clone(); + let exact_selected = self.link_method == LinkMethod::ExactFilename; + let pattern_selected = self.link_method == LinkMethod::CustomPattern; + + v_flex() + .gap_3() + .child(div().text_lg().font_semibold().child("Link your files to rows")) + .child( + Label::new(format!( + "Tell qrate how each row matches a file in {}.", + if folder.is_empty() { "your files folder" } else { &folder } + )) + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + .child( + option_card( + "link-exact", + "Match by exact filename", + "Uses the \"Filename\" column — recommended for most collections.", + exact_selected, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.link_method = LinkMethod::ExactFilename; + cx.notify(); + })), + ) + .child(match &self.folder_match { + Some(m) => h_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().success) + .child(Icon::new(IconName::CircleCheck)) + .child(format!("{} of {} rows matched a file", m.matched_rows, m.total_rows)) + .into_any_element(), + None => div().into_any_element(), + }) + .child(match &self.folder_match { + Some(m) if m.extra_files > 0 => h_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().warning) + .child(Icon::new(IconName::TriangleAlert)) + .child(format!( + "{} file{} in this folder aren't mentioned in your spreadsheet — they'll be left out unless you link them", + m.extra_files, + if m.extra_files == 1 { "" } else { "s" } + )) + .into_any_element(), + _ => div().into_any_element(), + }) + .child( + Collapsible::new() + .open(self.show_advanced_pattern) + .child( + v_flex() + .id("advanced-pattern-toggle") + .cursor_pointer() + .gap_1() + .p_2p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child( + Label::new(if self.show_advanced_pattern { + "▾ Advanced: custom naming pattern (optional)" + } else { + "▸ Advanced: custom naming pattern (optional)" + }) + .text_sm(), + ) + .when(!self.show_advanced_pattern, |el| { + el.child( + Label::new("e.g. {id}_*.jpg — for files that don't share an exact name.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.show_advanced_pattern = !this.show_advanced_pattern; + cx.notify(); + })), + ) + .content( + v_flex() + .gap_2() + .mt_2() + .child( + option_card( + "link-pattern", + "Use a custom pattern", + "Match filenames against a pattern instead of an exact match.", + pattern_selected, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.link_method = LinkMethod::CustomPattern; + cx.notify(); + })), + ) + .child(Input::new(&self.link_pattern_input).w_full()), + ), + ) + } +} diff --git a/crates/project-wizard/src/steps/mod.rs b/crates/project-wizard/src/steps/mod.rs new file mode 100644 index 00000000..72e1abb6 --- /dev/null +++ b/crates/project-wizard/src/steps/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod columns; +pub(crate) mod files; +pub(crate) mod link; +pub(crate) mod name; +pub(crate) mod review; +pub(crate) mod success; diff --git a/crates/project-wizard/src/steps/name.rs b/crates/project-wizard/src/steps/name.rs new file mode 100644 index 00000000..5038f699 --- /dev/null +++ b/crates/project-wizard/src/steps/name.rs @@ -0,0 +1,115 @@ +//! Stage 2 · Name — every entry path goes through this step. + +use gpui::{prelude::FluentBuilder, *}; +use gpui_component::input::Input; +use gpui_component::label::Label; +use gpui_component::{ActiveTheme, IconName, Icon, StyledExt, h_flex, v_flex}; + +use crate::project; +use crate::wizard::ProjectWizard; + +impl ProjectWizard { + pub(crate) fn validate_name(&mut self, cx: &App) -> bool { + let name = self.project_name(cx); + if name.trim().is_empty() { + self.name_error = Some("Give your project a name to continue".into()); + return false; + } + if project::name_taken(&self.save_path, &name) { + self.name_error = + Some("This name is already taken — try another, or pick a different save folder".into()); + return false; + } + self.name_error = None; + true + } + + pub(crate) fn render_name_step( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .gap_3() + .child(div().text_lg().font_semibold().child("Name your project")) + .child( + v_flex() + .gap_1() + .child(Label::new("Project name").text_sm()) + .child(Input::new(&self.name_input).w_full()), + ) + .when_some(self.name_error.clone(), |el, err| { + el.child( + h_flex() + .gap_1() + .p_2() + .rounded_md() + .border_1() + .border_color(cx.theme().danger) + .bg(cx.theme().danger.opacity(0.06)) + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::TriangleAlert)) + .child(err), + ) + }) + .child( + v_flex() + .gap_1() + .child(Label::new("Save project to").text_sm()) + .child( + h_flex() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .px_2() + .py_1p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(if self.save_path.is_empty() { + "Choose a folder…".to_string() + } else { + self.save_path.clone() + }), + ) + .child( + gpui_component::button::Button::new("browse-save-path") + .label("Browse…") + .outline() + .on_click(cx.listener(|_this, _, _, cx| { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Choose where to save this project".into()), + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = receiver.await + && let Some(path) = paths.first() + { + let s = path.to_string_lossy().to_string(); + this.update(cx, |this, cx| { + this.save_path = s; + this.name_error = None; + cx.notify(); + }) + .ok(); + } + }) + .detach(); + })), + ), + ) + .child( + Label::new("This is where your project file and settings live.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ), + ) + } +} diff --git a/crates/project-wizard/src/steps/review.rs b/crates/project-wizard/src/steps/review.rs new file mode 100644 index 00000000..00078976 --- /dev/null +++ b/crates/project-wizard/src/steps/review.rs @@ -0,0 +1,156 @@ +//! Stage 5 · Review & Create — every path converges here. + +use gpui::{prelude::FluentBuilder, *}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::label::Label; +use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; + +use crate::project::{self, ProjectManifest}; +use crate::recent; +use crate::wizard::{ColumnSource, EntryKind, LinkMethod, ProjectWizard, WizardStep}; + +impl ProjectWizard { + pub(crate) fn create_project(&mut self, _window: &mut Window, cx: &mut Context) { + let name = self.project_name(cx); + let source = match self.entry_kind { + EntryKind::Blank => "Blank".to_string(), + EntryKind::Csv => "CSV + folder".to_string(), + EntryKind::Sheet => "Google Sheet".to_string(), + }; + let files_matched = self + .folder_match + .as_ref() + .map(|m| (m.matched_rows, m.total_rows)); + let link_method = (self.entry_kind != EntryKind::Blank).then(|| { + match self.link_method { + LinkMethod::ExactFilename => "exact filename".to_string(), + LinkMethod::CustomPattern => "custom pattern".to_string(), + } + }); + let columns = match self.entry_kind { + EntryKind::Blank => Vec::new(), + _ => self + .config_preview + .as_ref() + .map(|p| { + p.entries + .iter() + .map(|e| project::ManifestColumn { + name: e.name.clone(), + data_type: e.data_type.clone(), + description: e.description.clone(), + }) + .collect() + }) + .unwrap_or_else(|| { + // No explicit config: auto-created from the spreadsheet, + // every column defaults to Text (same rule the mocked + // Sheet-config load uses). + self.spreadsheet_headers() + .into_iter() + .map(|name| project::ManifestColumn { + name, + data_type: "Text".into(), + description: String::new(), + }) + .collect() + }), + }; + + let manifest = ProjectManifest { + name: name.clone(), + source, + files_matched, + link_method, + columns, + }; + + match project::write_project_file(&self.save_path, &name, &manifest) { + Ok(dir) => { + recent::record_opened(name, dir.clone(), cx); + self.created_dir = Some(dir); + self.step = WizardStep::Success; + } + Err(e) => { + self.name_error = Some(format!("Couldn't create the project — {e}").into()); + self.step = WizardStep::Name; + } + } + } + + pub(crate) fn render_review_step( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let name = self.project_name(cx); + let source = match self.entry_kind { + EntryKind::Blank => "Blank project", + EntryKind::Csv => "CSV + folder", + EntryKind::Sheet => "Google Sheet", + }; + let files_line = self.folder_match.as_ref().map(|m| { + let method = match self.link_method { + LinkMethod::ExactFilename => "exact filename", + LinkMethod::CustomPattern => "custom pattern", + }; + format!("{} of {} ({method})", m.matched_rows, m.total_rows) + }); + let columns_line = match self.column_source { + ColumnSource::AutoFromSpreadsheet => "Auto-matched from spreadsheet".to_string(), + ColumnSource::RecentlyUsed => "From a saved config".to_string(), + ColumnSource::LoadFromFileOrSheet => "Loaded from file/Sheet".to_string(), + }; + + v_flex() + .gap_3() + .child( + div() + .text_lg() + .font_semibold() + .child("Ready to create your project"), + ) + .child( + v_flex() + .gap_1() + .p_3() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .text_sm() + .child(summary_row("Name", &name)) + .child(summary_row("Location", &self.save_path)) + .child(summary_row("Source", source)) + .when_some(files_line.clone(), |el, line| { + el.child(summary_row("Files matched", &line)) + }) + .when(self.entry_kind != EntryKind::Blank, |el| { + el.child(summary_row("Columns", &columns_line)) + }), + ) + .child( + Button::new("create-project") + .label("Create Project") + .primary() + .w_full() + .on_click(cx.listener(|this, _, window, cx| this.create_project(window, cx))), + ) + .child( + div() + .id("review-back") + .cursor_pointer() + .text_sm() + .text_center() + .text_color(cx.theme().muted_foreground) + .child("← Back") + .on_click(cx.listener(|this, _, window, cx| this.go_back(window, cx))), + ) + } +} + +fn summary_row(label: &str, value: &str) -> impl IntoElement { + h_flex() + .gap_1() + .child(Label::new(format!("{label}:")).font_semibold()) + .child(value.to_string()) +} diff --git a/crates/project-wizard/src/steps/success.rs b/crates/project-wizard/src/steps/success.rs new file mode 100644 index 00000000..f78abcae --- /dev/null +++ b/crates/project-wizard/src/steps/success.rs @@ -0,0 +1,66 @@ +//! Stage 6 · Success. + +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::label::Label; +use gpui_component::{ActiveTheme, Icon, IconName, StyledExt, v_flex}; + +use crate::launcher; +use crate::wizard::ProjectWizard; + +impl ProjectWizard { + pub(crate) fn render_success_step( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let name = self.project_name(cx); + v_flex() + .items_center() + .text_center() + .gap_2() + .py_6() + .child( + div() + .size_12() + .rounded_full() + .border_2() + .border_color(cx.theme().success) + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().success) + .child(Icon::new(IconName::Check)), + ) + .child(div().text_lg().font_semibold().child("Your project is ready")) + .child( + Label::new(format!("{name} is set up and ready to go.")) + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + .child( + Button::new("open-project") + .label("Open Project") + .primary() + .on_click(cx.listener(|_this, _, window, cx| { + if let Some(hooks) = cx.try_global::().copied() { + (hooks.open_main_window)(cx); + } + window.remove_window(); + })), + ) + .child( + div() + .id("create-another") + .cursor_pointer() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Create another") + .on_click(cx.listener(|this, _, window, cx| { + let entry_kind = this.entry_kind; + window.remove_window(); + crate::wizard::open_project_wizard(entry_kind, cx); + })), + ) + } +} diff --git a/crates/project-wizard/src/wizard.rs b/crates/project-wizard/src/wizard.rs new file mode 100644 index 00000000..b7ceeade --- /dev/null +++ b/crates/project-wizard/src/wizard.rs @@ -0,0 +1,421 @@ +//! The multi-step "New Project" wizard window (Stage 2-6 of the design). +//! Opened from the launcher's "Create New" cards. A single long-lived +//! `ProjectWizard` view holds all step state; `steps/*.rs` each add a +//! `render_*` method via a separate `impl ProjectWizard` block. + +use gpui::{prelude::FluentBuilder, *}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::InputState; +use gpui_component::label::Label; +use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex}; +use window_wrapper::WindowRegistry; + +use crate::data::{ColumnConfigPreview, FolderMatch, SheetCheckResult, SpreadsheetPreview}; +use crate::launcher; + +pub const WIZARD_WINDOW_KIND: &str = "project-creation"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum EntryKind { + Blank, + Csv, + Sheet, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WizardStep { + Name, + Files, + Link, + Columns, + Review, + Success, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LinkMethod { + ExactFilename, + CustomPattern, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ColumnSource { + AutoFromSpreadsheet, + RecentlyUsed, + LoadFromFileOrSheet, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LoadConfigTab { + File, + Sheet, +} + +pub struct RecentConfig { + pub name: &'static str, + pub column_count: usize, + pub last_used: &'static str, +} + +pub const RECENT_CONFIGS: &[RecentConfig] = &[ + RecentConfig { + name: "Aderman Collection", + column_count: 7, + last_used: "2 days ago", + }, + RecentConfig { + name: "Photo Donations 2025", + column_count: 5, + last_used: "1 month ago", + }, + RecentConfig { + name: "Oral History Intake", + column_count: 9, + last_used: "3 months ago", + }, +]; + +pub struct ProjectWizard { + pub(crate) step: WizardStep, + pub(crate) entry_kind: EntryKind, + + // Name step + pub(crate) name_input: Entity, + pub(crate) save_path: String, + pub(crate) name_error: Option, + + // Files step - CSV + pub(crate) csv_path: String, + pub(crate) csv_preview: Option, + pub(crate) csv_error: Option, + pub(crate) folder_path: String, + pub(crate) folder_match: Option, + pub(crate) folder_error: Option, + + // Files step - Sheet + pub(crate) sheet_link_input: Entity, + pub(crate) sheet_check: Option, + pub(crate) sheet_error: Option, + + // Link step + pub(crate) link_method: LinkMethod, + pub(crate) link_pattern_input: Entity, + pub(crate) show_advanced_pattern: bool, + + // Columns step + pub(crate) column_source: ColumnSource, + pub(crate) show_advanced_mapping: bool, + pub(crate) recent_config_selected: usize, + pub(crate) load_config_tab: LoadConfigTab, + pub(crate) config_file_path: String, + pub(crate) config_preview: Option, + pub(crate) config_error: Option, + + // Review / Success + pub(crate) created_dir: Option, +} + +impl ProjectWizard { + pub fn new(entry_kind: EntryKind, window: &mut Window, cx: &mut Context) -> Self { + let name_input = cx.new(|cx| { + InputState::new(window, cx).placeholder("e.g. Aderman Family Collection") + }); + let sheet_link_input = cx.new(|cx| { + InputState::new(window, cx).placeholder("docs.google.com/spreadsheets/d/…") + }); + let link_pattern_input = cx.new(|cx| { + InputState::new(window, cx).placeholder("e.g. {id}_*.jpg") + }); + + let default_save_dir = dirs::document_dir() + .map(|d| d.join("qrate")) + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + Self { + step: WizardStep::Name, + entry_kind, + name_input, + save_path: default_save_dir, + name_error: None, + csv_path: String::new(), + csv_preview: None, + csv_error: None, + folder_path: String::new(), + folder_match: None, + folder_error: None, + sheet_link_input, + sheet_check: None, + sheet_error: None, + link_method: LinkMethod::ExactFilename, + link_pattern_input, + show_advanced_pattern: false, + column_source: ColumnSource::AutoFromSpreadsheet, + show_advanced_mapping: false, + recent_config_selected: 0, + load_config_tab: LoadConfigTab::File, + config_file_path: String::new(), + config_preview: None, + config_error: None, + created_dir: None, + } + } + + pub(crate) fn project_name(&self, cx: &App) -> String { + self.name_input.read(cx).value().to_string() + } + + pub(crate) fn spreadsheet_headers(&self) -> Vec { + match self.entry_kind { + EntryKind::Csv => self + .csv_preview + .as_ref() + .map(|p| p.headers.clone()) + .unwrap_or_default(), + EntryKind::Sheet => Vec::new(), + EntryKind::Blank => Vec::new(), + } + } + + fn breadcrumb_items(&self) -> Vec<(&'static str, WizardStep)> { + match self.entry_kind { + EntryKind::Blank => vec![ + ("Name", WizardStep::Name), + ("Create", WizardStep::Review), + ], + _ => vec![ + ("Name", WizardStep::Name), + ("Files", WizardStep::Files), + ("Link", WizardStep::Link), + ("Columns", WizardStep::Columns), + ("Create", WizardStep::Review), + ], + } + } + + fn step_index(&self) -> usize { + self.breadcrumb_items() + .iter() + .position(|(_, s)| *s == self.step) + .unwrap_or(0) + } + + fn render_breadcrumb(&self, cx: &Context) -> impl IntoElement { + let items = self.breadcrumb_items(); + let current_ix = self.step_index(); + let mut row = h_flex().gap_2().items_center().text_sm().flex_wrap(); + row = row.child( + div() + .text_color(cx.theme().success) + .child("✓ Start"), + ); + for (ix, (label, _)) in items.iter().enumerate() { + row = row.child(div().text_color(cx.theme().muted_foreground).child("·")); + let el = if ix < current_ix { + div() + .text_color(cx.theme().success) + .child(format!("✓ {label}")) + } else if ix == current_ix { + div() + .font_semibold() + .text_color(cx.theme().primary) + .child(*label) + } else { + div().text_color(cx.theme().muted_foreground).child(*label) + }; + row = row.child(el); + } + row + } + + pub(crate) fn go_back(&mut self, window: &mut Window, cx: &mut Context) { + self.step = match (self.entry_kind, self.step) { + (_, WizardStep::Name) => { + // Back to the launcher: close this window and show it again. + launcher::open_launcher_window(cx); + window.remove_window(); + return; + } + (_, WizardStep::Files) => WizardStep::Name, + (_, WizardStep::Link) => WizardStep::Files, + (_, WizardStep::Columns) => WizardStep::Link, + (EntryKind::Blank, WizardStep::Review) => WizardStep::Name, + (_, WizardStep::Review) => WizardStep::Columns, + (_, WizardStep::Success) => WizardStep::Success, + }; + cx.notify(); + } + + fn go_next(&mut self, window: &mut Window, cx: &mut Context) { + match self.step { + WizardStep::Name => { + if !self.validate_name(cx) { + cx.notify(); + return; + } + self.step = if self.entry_kind == EntryKind::Blank { + WizardStep::Review + } else { + WizardStep::Files + }; + } + WizardStep::Files => self.step = WizardStep::Link, + WizardStep::Link => self.step = WizardStep::Columns, + WizardStep::Columns => self.step = WizardStep::Review, + WizardStep::Review => self.create_project(window, cx), + WizardStep::Success => {} + } + cx.notify(); + } + + fn render_footer(&self, cx: &mut Context) -> impl IntoElement { + let next_label = if self.step == WizardStep::Review { + "Create Project" + } else { + "Next →" + }; + h_flex() + .justify_between() + .items_center() + .mt_4() + .child( + div() + .id("wizard-back") + .text_sm() + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .child("← Back") + .on_click(cx.listener(|this, _, window, cx| this.go_back(window, cx))), + ) + .child( + Button::new("wizard-next") + .label(next_label) + .primary() + .on_click(cx.listener(|this, _, window, cx| this.go_next(window, cx))), + ) + } +} + +impl Render for ProjectWizard { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let dialog_layer = Root::render_dialog_layer(window, cx); + let title = match self.step { + WizardStep::Success => "qrate".into(), + _ => "qrate — New Project".to_string(), + }; + + let body = match self.step { + WizardStep::Name => self.render_name_step(window, cx).into_any_element(), + WizardStep::Files => self.render_files_step(window, cx).into_any_element(), + WizardStep::Link => self.render_link_step(window, cx).into_any_element(), + WizardStep::Columns => self.render_columns_step(window, cx).into_any_element(), + WizardStep::Review => self.render_review_step(window, cx).into_any_element(), + WizardStep::Success => self.render_success_step(window, cx).into_any_element(), + }; + + let show_chrome = self.step != WizardStep::Success; + let breadcrumb = if show_chrome { + Some(self.render_breadcrumb(cx).into_any_element()) + } else { + None + }; + // The Review step renders its own big "Create Project" button and + // centered back link (matching the prototype), so the shared footer + // only appears on the other steps. + let footer = if show_chrome && self.step != WizardStep::Review { + Some(self.render_footer(cx).into_any_element()) + } else { + None + }; + + v_flex() + .size_full() + .bg(cx.theme().background) + .child(TitleBar::new().child(Label::new(title).font_semibold())) + .child( + v_flex() + .id("wizard-body") + .flex_1() + .p_5() + .gap_3() + .children(breadcrumb) + .child(body) + .children(footer), + ) + .children(dialog_layer) + } +} + +pub fn open_project_wizard(entry_kind: EntryKind, cx: &mut App) { + let bounds = Bounds::centered(None, size(px(560.0), px(680.0)), cx); + let window_options = WindowOptions { + titlebar: Some(TitleBar::title_bar_options()), + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(Size::new(px(480.0), px(520.0))), + ..Default::default() + }; + + cx.spawn(async move |cx| { + let result = cx.open_window(window_options, |window, cx| { + let view = cx.new(|cx| ProjectWizard::new(entry_kind, window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }); + if let Ok(window_handle) = result { + cx.update(|cx| { + WindowRegistry::register(WIZARD_WINDOW_KIND, window_handle.into(), cx); + }) + .ok(); + } + }) + .detach(); +} + +/// Small bordered, clickable "radio card" used for the entry-kind picker, +/// link-method picker, and column-source picker — matches the prototype's +/// ◉/○ bordered option cards. +pub(crate) fn option_card( + id: impl Into, + title: impl Into, + description: impl Into, + selected: bool, + cx: &App, +) -> Stateful
{ + let border = if selected { + cx.theme().primary + } else { + cx.theme().border + }; + let dot = div() + .size_3() + .rounded_full() + .border_2() + .border_color(if selected { + cx.theme().primary + } else { + cx.theme().muted_foreground + }) + .when(selected, |el| el.bg(cx.theme().primary)); + v_flex() + .id(id.into()) + .cursor_pointer() + .gap_1() + .p_3() + .rounded_md() + .border_1() + .border_color(border) + .when(selected, |el| el.bg(cx.theme().primary.opacity(0.06))) + .child( + h_flex() + .gap_2() + .items_center() + .child(dot) + .child(div().font_semibold().text_sm().child(title.into())), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(description.into()), + ) +} From 41eb8d81d98a836b93f1a55e591c5b275ce8f89a Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:18:22 +0530 Subject: [PATCH 2/8] feat(cloud-sync): fetch public Google Sheets as xlsx (rows + cell notes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New minimal crate for importing a public ("anyone with the link") Google Sheet, no OAuth or Sheets API v4. `fetch_sheet` pulls the built-in `?format=xlsx` export once and derives both the first tab's rows (via calamine) and its cell notes (zip + quick-xml over `xl/comments*.xml`). A private sheet serves an HTML sign-in page and yields a clear error. Threaded comments aren't reachable this way — they survive no export and would need the Drive API + OAuth; deferred. The older CSV-export path is kept as marked-legacy dead code until the wizard is rewired onto `fetch_sheet`. --- Cargo.lock | 259 +++++++++++++++++++++++++++ Cargo.toml | 1 + crates/cloud-sync/Cargo.toml | 17 ++ crates/cloud-sync/src/lib.rs | 329 +++++++++++++++++++++++++++++++++++ 4 files changed, 606 insertions(+) create mode 100644 crates/cloud-sync/Cargo.toml create mode 100644 crates/cloud-sync/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index cd85868c..c1f3f176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arc-swap" @@ -759,6 +762,40 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "calamine" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1" +dependencies = [ + "byteorder", + "codepage", + "encoding_rs", + "log", + "quick-xml 0.31.0", + "serde", + "zip", +] + [[package]] name = "calloop" version = "0.13.0" @@ -889,6 +926,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "cloud-sync" +version = "0.1.0" +dependencies = [ + "calamine", + "quick-xml 0.37.5", + "reqwest", + "thiserror 2.0.18", + "zip", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -949,6 +997,15 @@ dependencies = [ "objc", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1023,6 +1080,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -1205,6 +1268,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1314,6 +1392,23 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -3409,6 +3504,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -3780,6 +3896,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -4342,6 +4464,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4431,6 +4559,7 @@ name = "project-wizard" version = "0.1.0" dependencies = [ "anyhow", + "cloud-sync", "csv", "dirs 6.0.0", "gpui", @@ -4481,6 +4610,25 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.2" @@ -4836,7 +4984,9 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -5437,6 +5587,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -6059,6 +6220,25 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -7770,6 +7950,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "y4m" version = "0.8.0" @@ -8114,12 +8303,82 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 46327436..cf858086 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/app", "crates/ai", + "crates/cloud-sync", "crates/project-wizard", "crates/settings", "crates/table", diff --git a/crates/cloud-sync/Cargo.toml b/crates/cloud-sync/Cargo.toml new file mode 100644 index 00000000..847608e3 --- /dev/null +++ b/crates/cloud-sync/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cloud-sync" +version.workspace = true +edition.workspace = true + +[dependencies] +# blocking, not async: gpui's executor isn't a tokio reactor, so we run the +# one-shot fetch on the background executor with the blocking client. +reqwest = { version = "0.12", features = ["blocking"] } +thiserror = "2" +# One .xlsx download carries both the row values and the cell notes (CSV drops +# metadata). calamine reads the values; zip + quick-xml pull notes out of +# xl/comments*.xml (hand-rolling xlsx value parsing / XML entity decoding is the +# flimsy trap we avoid). +calamine = "0.26" +zip = "2" +quick-xml = "0.37" diff --git a/crates/cloud-sync/src/lib.rs b/crates/cloud-sync/src/lib.rs new file mode 100644 index 00000000..739c2a82 --- /dev/null +++ b/crates/cloud-sync/src/lib.rs @@ -0,0 +1,329 @@ +//! Minimal Google Sheets fetching for the project wizard. +//! +//! Scope is deliberately tiny: pull a *public* ("anyone with the link") Google +//! Sheet via the built-in export endpoint. [`fetch_sheet`] grabs the `.xlsx` +//! export once and pulls out both the row values and the cell notes; the older +//! CSV path ([`fetch_sheet_csv`]) is kept as marked-legacy dead code until the +//! wizard is rewired onto `fetch_sheet` (see the "wire cloud-sync xlsx" task). +//! No OAuth, no Sheets API v4 — a private sheet just yields a clear error. +//! ponytail: export-endpoint only. Add OAuth/API v4 when private sheets matter. + +use std::fs; +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SheetSyncError { + #[error("That doesn't look like a Google Sheets link")] + BadLink, + #[error( + "This sheet isn't public — share it as 'Anyone with the link' (Viewer) and try again, or use a public sheet" + )] + Private, + #[error("We couldn't reach Google Sheets — {0}")] + Http(#[from] reqwest::Error), + #[error("We couldn't save the downloaded sheet — {0}")] + Io(#[from] std::io::Error), + #[error("We couldn't open the downloaded sheet — {0}")] + Zip(String), + #[error("We couldn't read the sheet's notes — {0}")] + Parse(String), +} + +impl SheetSyncError { + /// User-facing one-liner (mirrors the wizard's other `.message()` errors). + pub fn message(&self) -> String { + self.to_string() + } +} + +/// The `{id}` and optional `{gid}` pulled out of a Sheets URL. +struct SheetRef { + id: String, + gid: Option, +} + +/// Parse the spreadsheet id (`/spreadsheets/d/{id}`) and optional tab gid +/// (`gid=NNN`, in either the query or the `#fragment`) out of a Sheets link. +fn parse_sheet_ref(link: &str) -> Result { + let marker = "/spreadsheets/d/"; + let start = link.find(marker).ok_or(SheetSyncError::BadLink)? + marker.len(); + let rest = &link[start..]; + let id: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + if id.is_empty() { + return Err(SheetSyncError::BadLink); + } + + let gid = link.find("gid=").map(|i| { + link[i + 4..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + }); + let gid = gid.filter(|g| !g.is_empty()); + + Ok(SheetRef { id, gid }) +} + +// === LEGACY / DEAD CODE (kept per request) =================================== +// The CSV-export path below is superseded by `fetch_sheet`, which gets rows AND +// notes from a single .xlsx download. It's still called by the wizard's Sheet +// entry path today; delete it once that's rewired onto `fetch_sheet` (see the +// "wire cloud-sync xlsx fetch into the wizard" task). Do not build on it. +// ============================================================================= + +/// Fetch a public Google Sheet as CSV and write it to a temp file, returning +/// the path. Blocking — call it off the UI thread (background executor). +pub fn fetch_sheet_csv(link: &str) -> Result { + let SheetRef { id, gid } = parse_sheet_ref(link)?; + + let mut url = format!("https://docs.google.com/spreadsheets/d/{id}/export?format=csv"); + if let Some(gid) = &gid { + url.push_str(&format!("&gid={gid}")); + } + + let resp = reqwest::blocking::get(&url)?.error_for_status()?; + + // A private sheet doesn't 4xx — Google serves an HTML sign-in page (often + // after redirecting to accounts.google.com). Treat any non-CSV/HTML + // response as "not public". + let final_host = resp.url().host_str().unwrap_or_default().to_string(); + let is_html = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|ct| ct.contains("text/html")) + .unwrap_or(false); + if final_host.contains("accounts.google.com") || is_html { + return Err(SheetSyncError::Private); + } + + let body = resp.bytes()?; + let lower = body.get(..64).unwrap_or(&body); + if lower.starts_with(b" Option { + let SheetRef { id, .. } = parse_sheet_ref(link).ok()?; + Some( + std::env::temp_dir() + .join("qrate-sheets") + .join(format!("{id}.csv")), + ) +} + +/// A single Google Sheets cell note (the "Insert > Note" sticky kind), pinned +/// to a cell like `"B2"`. Threaded comments are *not* here — they don't survive +/// any export; only the Drive API (OAuth) can reach those. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CellNote { + pub cell: String, + pub text: String, +} + +/// A fetched sheet: the first tab's rows plus its cell notes, from one `.xlsx` +/// download. `headers` is the first row; `rows` the rest. +#[derive(Clone, Debug, Default)] +pub struct SheetData { + pub headers: Vec, + pub rows: Vec>, + pub notes: Vec, +} + +/// Fetch a public Sheet as `.xlsx` (one download) and pull out both the first +/// tab's rows and its cell notes. CSV export drops the notes, so we use xlsx +/// for everything. The `.xlsx` is cached in the temp dir. Blocking — call it +/// off the UI thread. +pub fn fetch_sheet(link: &str) -> Result { + let SheetRef { id, .. } = parse_sheet_ref(link)?; + let url = format!("https://docs.google.com/spreadsheets/d/{id}/export?format=xlsx"); + let resp = reqwest::blocking::get(&url)?.error_for_status()?; + + if resp + .url() + .host_str() + .unwrap_or_default() + .contains("accounts.google.com") + { + return Err(SheetSyncError::Private); + } + let body = resp.bytes()?; + // A private sheet serves an HTML sign-in page instead of the zip; every + // xlsx starts with the "PK" local-file-header magic. + if !body.starts_with(b"PK") { + return Err(SheetSyncError::Private); + } + + let dir = std::env::temp_dir().join("qrate-sheets"); + fs::create_dir_all(&dir)?; + fs::write(dir.join(format!("{id}.xlsx")), &body)?; + + let (headers, rows) = parse_xlsx_rows(&body)?; + let notes = parse_xlsx_notes(&body)?; + Ok(SheetData { + headers, + rows, + notes, + }) +} + +/// First tab's values via calamine — row 0 is the header, the rest are data. +fn parse_xlsx_rows(bytes: &[u8]) -> Result<(Vec, Vec>), SheetSyncError> { + use calamine::{Reader, Xlsx}; + + let mut wb: Xlsx> = + calamine::open_workbook_from_rs(std::io::Cursor::new(bytes)) + .map_err(|e: calamine::XlsxError| SheetSyncError::Zip(e.to_string()))?; + let range = wb + .worksheet_range_at(0) + .ok_or_else(|| SheetSyncError::Parse("the sheet has no tabs".into()))? + .map_err(|e| SheetSyncError::Parse(e.to_string()))?; + + let mut iter = range.rows(); + let headers = iter + .next() + .map(|r| r.iter().map(|c| c.to_string()).collect()) + .unwrap_or_default(); + let rows = iter + .map(|r| r.iter().map(|c| c.to_string()).collect()) + .collect(); + Ok((headers, rows)) +} + +/// Cell notes from every `xl/comments*.xml` entry in the xlsx zip. +fn parse_xlsx_notes(bytes: &[u8]) -> Result, SheetSyncError> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|e| SheetSyncError::Zip(e.to_string()))?; + let mut notes = Vec::new(); + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| SheetSyncError::Zip(e.to_string()))?; + let name = file.name().to_string(); + if name.starts_with("xl/comments") && name.ends_with(".xml") { + let mut xml = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut xml)?; + parse_comments_xml(&xml, &mut notes)?; + } + } + Ok(notes) +} + +/// Pull `(ref, text)` pairs out of an xlsx `comments*.xml`. Each `` +/// carries a cell `ref` and one `` made of one or more `…` +/// runs, which we concatenate. +fn parse_comments_xml(xml: &[u8], out: &mut Vec) -> Result<(), SheetSyncError> { + use quick_xml::events::Event; + + let mut reader = quick_xml::Reader::from_reader(xml); + let mut buf = Vec::new(); + let mut cur_ref: Option = None; + let mut cur_text = String::new(); + let mut in_t = false; + loop { + let event = reader + .read_event_into(&mut buf) + .map_err(|e| SheetSyncError::Parse(e.to_string()))?; + match event { + Event::Start(e) if e.local_name().as_ref() == b"comment" => { + cur_ref = e + .attributes() + .flatten() + .find(|a| a.key.local_name().as_ref() == b"ref") + .and_then(|a| a.unescape_value().ok().map(|v| v.into_owned())); + cur_text.clear(); + } + Event::Start(e) if e.local_name().as_ref() == b"t" => in_t = true, + Event::Text(e) if in_t => { + cur_text.push_str(&e.unescape().unwrap_or_default()); + } + Event::End(e) if e.local_name().as_ref() == b"t" => in_t = false, + Event::End(e) if e.local_name().as_ref() == b"comment" => { + if let Some(cell) = cur_ref.take() { + let text = cur_text.trim().to_string(); + if !text.is_empty() { + out.push(CellNote { cell, text }); + } + } + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_id_and_gid() { + let r = parse_sheet_ref("https://docs.google.com/spreadsheets/d/1AbC-dEf_123/edit#gid=456") + .unwrap(); + assert_eq!(r.id, "1AbC-dEf_123"); + assert_eq!(r.gid.as_deref(), Some("456")); + + let r = parse_sheet_ref("https://docs.google.com/spreadsheets/d/xyz789/edit?usp=sharing") + .unwrap(); + assert_eq!(r.id, "xyz789"); + assert_eq!(r.gid, None); + + let r = parse_sheet_ref("https://docs.google.com/spreadsheets/d/onlyid").unwrap(); + assert_eq!(r.id, "onlyid"); + } + + #[test] + fn parses_notes_with_entities_and_multiple_runs() { + let xml = br#" +A +hello & note +line one two + +"#; + let mut out = Vec::new(); + parse_comments_xml(xml, &mut out).unwrap(); + // D9 is whitespace-only and dropped; B2 keeps its unescaped `&`; C5's + // two runs are concatenated. + assert_eq!( + out, + vec![ + CellNote { + cell: "B2".into(), + text: "hello & note".into() + }, + CellNote { + cell: "C5".into(), + text: "line one two".into() + }, + ] + ); + } + + #[test] + fn rejects_non_sheet() { + assert!(matches!( + parse_sheet_ref("https://example.com/not-a-sheet"), + Err(SheetSyncError::BadLink) + )); + assert!(matches!( + parse_sheet_ref("https://docs.google.com/spreadsheets/d/"), + Err(SheetSyncError::BadLink) + )); + } +} From a40c13575e32a01a03714e7bd3fc1d87f4352b60 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:18:32 +0530 Subject: [PATCH 3/8] fix(project-wizard): blank flow, skip-files, link input, columns panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blank projects now flow through the Files and Columns steps (folder picker + a "skip columns for now" card) instead of jumping past them. - Add an "I'll add a files folder later" toggle that also skips the Link step (skip_files / skips_link). - Move the custom-pattern input inside its Link option card, revealed on select, rather than floating outside it. - Fix a gpui panic ("cannot read ProjectWizard while it is already being updated") — the recent/load-config dialogs now open via window.defer, outside the click listener's own entity update. - Wire the Google Sheet entry path to cloud-sync for real fetching, and drop the now-unused success step. --- crates/project-wizard/Cargo.toml | 1 + crates/project-wizard/src/data.rs | 72 +----- crates/project-wizard/src/launcher.rs | 58 ++++- crates/project-wizard/src/recent.rs | 15 ++ crates/project-wizard/src/steps/columns.rs | 100 +++++--- crates/project-wizard/src/steps/files.rs | 275 +++++++++++++-------- crates/project-wizard/src/steps/link.rs | 34 +-- crates/project-wizard/src/steps/mod.rs | 1 - crates/project-wizard/src/steps/name.rs | 13 +- crates/project-wizard/src/steps/review.rs | 76 +++--- crates/project-wizard/src/steps/success.rs | 66 ----- crates/project-wizard/src/wizard.rs | 265 ++++++++++++++------ 12 files changed, 575 insertions(+), 401 deletions(-) delete mode 100644 crates/project-wizard/src/steps/success.rs diff --git a/crates/project-wizard/Cargo.toml b/crates/project-wizard/Cargo.toml index bf21fa23..f92f2d06 100644 --- a/crates/project-wizard/Cargo.toml +++ b/crates/project-wizard/Cargo.toml @@ -11,5 +11,6 @@ gpui.workspace = true gpui-component.workspace = true serde.workspace = true serde_json.workspace = true +cloud-sync = { path = "../cloud-sync" } settings = { path = "../settings" } window-wrapper = { path = "../window-wrapper" } diff --git a/crates/project-wizard/src/data.rs b/crates/project-wizard/src/data.rs index f89b35b3..134cf344 100644 --- a/crates/project-wizard/src/data.rs +++ b/crates/project-wizard/src/data.rs @@ -1,7 +1,6 @@ //! Real CSV parsing and folder-matching logic used by the Files, Link, and -//! Columns steps. Google Sheets has no OAuth/API integration in this codebase -//! yet, so [`validate_sheet_link`] mocks a successful connection once the URL -//! shape checks out (see its doc comment). +//! Columns steps. Google Sheets are fetched as CSV by the `cloud-sync` crate +//! and then run through the same [`load_csv_preview`] / [`match_folder`] path. use std::collections::HashSet; use std::fs; @@ -96,36 +95,6 @@ pub struct SheetCheckResult { pub used_first_tab: bool, } -#[derive(Clone, Debug)] -pub enum SheetError { - BadLink, -} - -impl SheetError { - pub fn message(&self) -> &'static str { - match self { - SheetError::BadLink => "That doesn't look like a Google Sheets link", - } - } -} - -/// Mocked: qrate has no Google Sheets API/OAuth integration yet. Once the -/// link shape checks out this reports a canned successful connection so the -/// rest of the wizard flow (folder matching, column setup) can be exercised -/// end-to-end. Swap this out for a real Sheets API call when that lands. -pub fn validate_sheet_link(link: &str) -> Result { - let trimmed = link.trim(); - let looks_like_sheet = trimmed.contains("docs.google.com/spreadsheets"); - if trimmed.is_empty() || !looks_like_sheet { - return Err(SheetError::BadLink); - } - Ok(SheetCheckResult { - title: "Aderman Collection Metadata".into(), - row_count: 142, - used_first_tab: true, - }) -} - #[derive(Clone, Debug)] pub struct FolderMatch { pub matched_rows: usize, @@ -181,10 +150,7 @@ pub fn match_folder( folder: &str, ) -> Result { let files = list_files(folder)?; - let file_stems: HashSet = files - .iter() - .map(|f| f.to_lowercase()) - .collect(); + let file_stems: HashSet = files.iter().map(|f| f.to_lowercase()).collect(); let file_names_no_ext: HashSet = files .iter() .map(|f| { @@ -229,20 +195,6 @@ pub fn match_folder( }) } -/// Same idea as [`match_folder`], but for the Google Sheet path where there's -/// no real row data to inspect (see [`validate_sheet_link`]'s doc comment) — -/// just a row count from the mocked sheet check. Reports every folder file up -/// to that row count as matched. -pub fn match_folder_sheet(sheet_row_count: usize, folder: &str) -> Result { - let files = list_files(folder)?; - let matched_rows = files.len().min(sheet_row_count); - Ok(FolderMatch { - matched_rows, - total_rows: sheet_row_count, - extra_files: files.len().saturating_sub(matched_rows), - }) -} - #[derive(Clone, Debug)] pub struct ColumnConfigEntry { pub name: String, @@ -334,9 +286,11 @@ pub fn load_column_config( } let matches_any = against_headers.is_empty() - || entries - .iter() - .any(|e| against_headers.iter().any(|h| h.eq_ignore_ascii_case(&e.name))); + || entries.iter().any(|e| { + against_headers + .iter() + .any(|h| h.eq_ignore_ascii_case(&e.name)) + }); if !matches_any { return Err(ColumnConfigError::NoMatch); } @@ -400,16 +354,6 @@ mod tests { )); } - #[test] - fn sheet_link_validation() { - assert!(validate_sheet_link("https://docs.google.com/spreadsheets/d/abc123/edit").is_ok()); - assert!(matches!( - validate_sheet_link("https://example.com/not-a-sheet"), - Err(SheetError::BadLink) - )); - assert!(matches!(validate_sheet_link(""), Err(SheetError::BadLink))); - } - #[test] fn column_config_round_trip_and_errors() { let dir = tempdir("qrate-config-test"); diff --git a/crates/project-wizard/src/launcher.rs b/crates/project-wizard/src/launcher.rs index c6a07904..85565bdf 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -36,7 +36,13 @@ impl Launcher { } } - fn open_recent(&mut self, path: String, name: String, window: &mut Window, cx: &mut Context) { + fn open_recent( + &mut self, + path: String, + name: String, + window: &mut Window, + cx: &mut Context, + ) { recent::record_opened(name, path, cx); if let Some(hooks) = cx.try_global::().copied() { (hooks.open_main_window)(cx); @@ -44,8 +50,17 @@ impl Launcher { window.remove_window(); } - fn start_new(&mut self, entry_kind: EntryKind, cx: &mut Context) { + 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 + // first step reopens it. Without this it lingers behind the new project. + window.remove_window(); + } + + fn remove_recent(&mut self, path: String, cx: &mut Context) { + recent::remove(&path, cx); + self.recents = recent::list(cx); + cx.notify(); } } @@ -84,6 +99,7 @@ impl Render for Launcher { ) .child( v_flex() + .flex_1() .min_w(px(0.)) .child(div().font_semibold().text_sm().child(project.name.clone())) .child( @@ -93,6 +109,23 @@ impl Render for Launcher { .child(format!("{} · opened {when}", project.path)), ), ) + .child({ + let remove_path = project.path.clone(); + div() + .id(("remove-recent", ix)) + .flex_none() + .px_1p5() + .text_sm() + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .hover(|el| el.text_color(cx.theme().danger)) + .child("✕") + // Stop the click from also opening the project. + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.remove_recent(remove_path.clone(), cx); + })) + }) .on_click(cx.listener(move |this, _, window, cx| { this.open_recent(path.clone(), name.clone(), window, cx) })), @@ -109,15 +142,23 @@ impl Render for Launcher { h_flex() .id("launcher-body") .flex_1() + .min_h(px(0.)) + .h_full() .gap_5() .p_5() .child( v_flex() .flex_1() + // min_h(0) lets this column be bounded to the row + // height so the inner list — not the column — scrolls, + // keeping the heading pinned. Without it the flex + // item's default min-height:auto grows to fit content. + .min_h(px(0.)) .min_w(px(0.)) .gap_2() .child( h_flex() + .flex_none() .justify_between() .items_baseline() .child(div().text_lg().font_semibold().child("Recent Projects")) @@ -127,11 +168,14 @@ impl Render for Launcher { .text_color(cx.theme().muted_foreground), ), ) - .child(recent_list) + // Heading above stays pinned; only the list scrolls. .child( - Label::new("Opening a recent project skips the wizard entirely.") - .text_sm() - .text_color(cx.theme().muted_foreground), + div() + .id("recents-scroll") + .flex_1() + .min_h(px(0.)) + .overflow_y_scroll() + .child(v_flex().gap_2().child(recent_list)), ), ) .child( @@ -194,7 +238,7 @@ fn create_card( .text_color(cx.theme().muted_foreground) .child(description), ) - .on_click(cx.listener(move |this, _, _, cx| this.start_new(entry_kind, cx))) + .on_click(cx.listener(move |this, _, window, cx| this.start_new(entry_kind, window, cx))) } pub fn open_launcher_window(cx: &mut App) { diff --git a/crates/project-wizard/src/recent.rs b/crates/project-wizard/src/recent.rs index 41356b74..6acb3a8f 100644 --- a/crates/project-wizard/src/recent.rs +++ b/crates/project-wizard/src/recent.rs @@ -46,6 +46,21 @@ pub fn record_opened(name: String, path: String, cx: &mut App) { AppSettings::set_text(RECENTS_KEY, json.into(), cx); } +/// Drops a project from the recent list. Only forgets the entry — the project +/// folder on disk is left untouched. +pub fn remove(path: &str, cx: &mut App) { + let mut recents = list(cx); + let before = recents.len(); + recents.retain(|p| p.path != path); + if recents.len() == before { + return; + } + let Ok(json) = serde_json::to_string(&recents) else { + return; + }; + AppSettings::set_text(RECENTS_KEY, json.into(), cx); +} + fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/project-wizard/src/steps/columns.rs b/crates/project-wizard/src/steps/columns.rs index 232ee68a..940fddbd 100644 --- a/crates/project-wizard/src/steps/columns.rs +++ b/crates/project-wizard/src/steps/columns.rs @@ -12,7 +12,9 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; use crate::data; use crate::steps::files::{MsgKind, inline_message}; -use crate::wizard::{ColumnSource, LoadConfigTab, ProjectWizard, RECENT_CONFIGS, option_card}; +use crate::wizard::{ + ColumnSource, EntryKind, LoadConfigTab, ProjectWizard, RECENT_CONFIGS, option_card, +}; impl ProjectWizard { fn browse_config_file(&mut self, cx: &mut Context) { @@ -52,16 +54,22 @@ impl ProjectWizard { } } - /// Mocked, same rationale as `data::validate_sheet_link` — no Sheets API - /// integration exists yet, so a plausible config (one Text column per - /// spreadsheet header) is synthesized once the link shape checks out. + /// Fetches the given public Sheet as CSV and turns its headers into a + /// column config (one Text column each). + // ponytail: blocking fetch on the UI thread — fine for a "Check" click; + // move to the background executor if it ever drags. fn load_config_from_sheet(&mut self, cx: &mut Context) { let link = self.sheet_link_input.read(cx).value().to_string(); - match data::validate_sheet_link(&link) { - Ok(_) => { + let fetched = cloud_sync::fetch_sheet_csv(&link) + .map_err(|e| e.message()) + .and_then(|path| { + data::load_csv_preview(&path.to_string_lossy()).map_err(|e| e.message()) + }); + match fetched { + Ok(preview) => { self.config_preview = Some(data::ColumnConfigPreview { - entries: self - .spreadsheet_headers() + entries: preview + .headers .into_iter() .map(|name| data::ColumnConfigEntry { name, @@ -72,15 +80,17 @@ impl ProjectWizard { }); self.config_error = None; } - Err(e) => { - self.config_error = Some(e.message().into()); + Err(msg) => { + self.config_error = Some(msg.into()); self.config_preview = None; } } } - fn open_recent_config_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let entity = cx.entity(); + /// Associated fn (not `&mut self`): it must run *outside* the click + /// listener's entity update, because `open_dialog`'s builder reads the + /// same entity synchronously. Callers defer it via `window.defer`. + fn open_recent_config_dialog(entity: Entity, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, cx| { let selected_ix = entity.read(cx).recent_config_selected; @@ -140,8 +150,8 @@ impl ProjectWizard { }); } - fn open_load_config_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let entity = cx.entity(); + /// See [`Self::open_recent_config_dialog`] — same deferred-open contract. + fn open_load_config_dialog(entity: Entity, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, cx| { let state = entity.read(cx); let tab = state.load_config_tab; @@ -204,9 +214,8 @@ impl ProjectWizard { .label("Browse…") .outline() .on_click(move |_, _, cx| { - entity_browse.update(cx, |this, cx| { - this.browse_config_file(cx) - }); + entity_browse + .update(cx, |this, cx| this.browse_config_file(cx)); }), ), ) @@ -368,9 +377,11 @@ impl ProjectWizard { _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + let is_blank = self.entry_kind == EntryKind::Blank; let auto_selected = self.column_source == ColumnSource::AutoFromSpreadsheet; let recent_selected = self.column_source == ColumnSource::RecentlyUsed; let load_selected = self.column_source == ColumnSource::LoadFromFileOrSheet; + let skip_selected = self.column_source == ColumnSource::SkipForNow; let recent_summary = RECENT_CONFIGS .iter() .map(|c| format!("\"{}\"", c.name)) @@ -385,19 +396,21 @@ impl ProjectWizard { .text_sm() .text_color(cx.theme().muted_foreground), ) - .child( - option_card( - "col-auto", - "Auto-created from this spreadsheet", - "Default — columns matched from what you just imported.", - auto_selected, - cx, + .when(!is_blank, |el| { + el.child( + option_card( + "col-auto", + "Auto-created from this spreadsheet", + "Default — columns matched from what you just imported.", + auto_selected, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.column_source = ColumnSource::AutoFromSpreadsheet; + cx.notify(); + })), ) - .on_click(cx.listener(|this, _, _, cx| { - this.column_source = ColumnSource::AutoFromSpreadsheet; - cx.notify(); - })), - ) + }) .child( option_card( "col-recent", @@ -406,8 +419,11 @@ impl ProjectWizard { recent_selected, cx, ) - .on_click(cx.listener(|this, _, window, cx| { - this.open_recent_config_dialog(window, cx) + .on_click(cx.listener(|_this, _, window, cx| { + let entity = cx.entity(); + window.defer(cx, move |window, cx| { + Self::open_recent_config_dialog(entity, window, cx); + }); })), ) .child( @@ -418,10 +434,28 @@ impl ProjectWizard { load_selected, cx, ) - .on_click(cx.listener(|this, _, window, cx| { - this.open_load_config_dialog(window, cx) + .on_click(cx.listener(|_this, _, window, cx| { + let entity = cx.entity(); + window.defer(cx, move |window, cx| { + Self::open_load_config_dialog(entity, window, cx); + }); })), ) + .when(is_blank, |el| { + el.child( + option_card( + "col-skip", + "Skip — set up columns later", + "Start empty; add columns in project settings whenever you're ready.", + skip_selected, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.column_source = ColumnSource::SkipForNow; + cx.notify(); + })), + ) + }) .child(self.render_advanced_mapping(cx)) .child( Label::new("You can always adjust this later in project settings.") diff --git a/crates/project-wizard/src/steps/files.rs b/crates/project-wizard/src/steps/files.rs index 8087d9fc..f9b40ee6 100644 --- a/crates/project-wizard/src/steps/files.rs +++ b/crates/project-wizard/src/steps/files.rs @@ -2,11 +2,11 @@ //! folder and share the same folder-matching validation; only the top field //! (spreadsheet file vs. sheet link) differs. -use gpui::*; +use gpui::{prelude::FluentBuilder, *}; use gpui_component::button::Button; use gpui_component::input::Input; use gpui_component::label::Label; -use gpui_component::{ActiveTheme, Icon, IconName, StyledExt, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Disableable, Icon, IconName, StyledExt, h_flex, v_flex}; use crate::data; use crate::wizard::{EntryKind, ProjectWizard}; @@ -109,13 +109,12 @@ impl ProjectWizard { return; } let result = match self.entry_kind { - EntryKind::Csv => self.csv_preview.as_ref().map(|preview| { - data::match_folder(preview, &self.folder_path) - }), - EntryKind::Sheet => self - .sheet_check + // Sheet reuses `csv_preview` (its fetched CSV is parsed the same + // way), so both match folders against real row data. + EntryKind::Csv | EntryKind::Sheet => self + .csv_preview .as_ref() - .map(|check| data::match_folder_sheet(check.row_count, &self.folder_path)), + .map(|preview| data::match_folder(preview, &self.folder_path)), EntryKind::Blank => None, }; match result { @@ -134,19 +133,43 @@ impl ProjectWizard { } } - fn check_sheet_link(&mut self, cx: &mut Context) { + pub(crate) fn check_sheet_link(&mut self, cx: &mut Context) { let link = self.sheet_link_input.read(cx).value().to_string(); - match data::validate_sheet_link(&link) { - Ok(check) => { - self.sheet_check = Some(check); - self.sheet_error = None; - } - Err(e) => { - self.sheet_error = Some(e.message().into()); - self.sheet_check = None; - } - } - self.revalidate_folder(); + // Fetch is a blocking network call — run it on a background thread so + // the UI doesn't freeze, then apply the result back on the UI thread. + let fetch = cx + .background_executor() + .spawn(async move { cloud_sync::fetch_sheet_csv(&link) }); + cx.spawn(async move |this, cx| { + let result = fetch.await; + this.update(cx, |this, cx| { + match result.map(|path| data::load_csv_preview(&path.to_string_lossy())) { + Ok(Ok(preview)) => { + this.sheet_check = Some(data::SheetCheckResult { + title: "Google Sheet".into(), + row_count: preview.row_count(), + used_first_tab: true, + }); + this.csv_preview = Some(preview); + this.sheet_error = None; + } + Ok(Err(e)) => { + this.sheet_error = Some(e.message().into()); + this.sheet_check = None; + this.csv_preview = None; + } + Err(e) => { + this.sheet_error = Some(e.message().into()); + this.sheet_check = None; + this.csv_preview = None; + } + } + this.revalidate_folder(); + cx.notify(); + }) + .ok(); + }) + .detach(); } pub(crate) fn render_files_step( @@ -154,24 +177,111 @@ impl ProjectWizard { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - match self.entry_kind { + let body = match self.entry_kind { EntryKind::Csv => self.render_csv_files(window, cx).into_any_element(), EntryKind::Sheet => self.render_sheet_files(window, cx).into_any_element(), - EntryKind::Blank => div().into_any_element(), - } + EntryKind::Blank => self.render_blank_files(window, cx).into_any_element(), + }; + v_flex() + .gap_3() + .child(body) + .child(self.render_skip_files_toggle(cx)) } - fn render_csv_files(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let csv_display = if self.csv_path.is_empty() { - "Choose a CSV file…".to_string() - } else { - self.csv_path.clone() - }; + /// The "Files folder" picker, shared by all three Files variants. Dims and + /// disables itself when "add files later" is checked — only this field, not + /// the spreadsheet/sheet input above it. `show_status` appends the + /// folder-match result (Blank has no spreadsheet to match against). + fn folder_field( + &self, + browse_id: &'static str, + show_status: bool, + cx: &mut Context, + ) -> impl IntoElement { let folder_display = if self.folder_path.is_empty() { "Choose your files folder…".to_string() } else { self.folder_path.clone() }; + let dimmed = self.skip_files; + let status = show_status.then(|| self.render_folder_status(cx)); + v_flex() + .gap_1() + .when(dimmed, |el| el.opacity(0.4)) + .child(Label::new("Files folder").text_sm()) + .child( + h_flex() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .px_2() + .py_1p5() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(folder_display), + ) + .child( + Button::new(browse_id) + .label("Browse…") + .outline() + .disabled(dimmed) + .on_click(cx.listener(|this, _, window, cx| { + this.browse_for_folder(window, cx) + })), + ), + ) + .children(status) + } + + /// "I'll add files later" — flips `skip_files`, which also skips the Link + /// step (see `ProjectWizard::skips_link`). Shown on every Files variant. + fn render_skip_files_toggle(&self, cx: &mut Context) -> impl IntoElement { + let checked = self.skip_files; + h_flex() + .id("skip-files-toggle") + .gap_1() + .cursor_pointer() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(if checked { "☑" } else { "☐" }) + .child("I'll add a files folder later — skips the linking step") + .on_click(cx.listener(|this, _, _, cx| { + this.skip_files = !this.skip_files; + cx.notify(); + })) + } + + fn render_blank_files( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .gap_3() + .child(div().text_lg().font_semibold().child("Add your files")) + .child( + Label::new("Point qrate at a folder of files, or skip and add them later.") + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + .child(self.folder_field("browse-folder-blank", false, cx)) + } + + fn render_csv_files( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let csv_display = if self.csv_path.is_empty() { + "Choose a CSV file…".to_string() + } else { + self.csv_path.clone() + }; v_flex() .gap_3() @@ -202,63 +312,39 @@ impl ProjectWizard { .child(csv_display), ) .child( - Button::new("browse-csv").label("Browse…").outline().on_click( - cx.listener(|this, _, window, cx| { + Button::new("browse-csv") + .label("Browse…") + .outline() + .on_click(cx.listener(|this, _, window, cx| { this.browse_for_csv(window, cx) - }), - ), + })), ), ) .child(match (&self.csv_preview, &self.csv_error) { (Some(p), _) => inline_message( - format!("✓ {} rows, {} columns found", p.row_count(), p.column_count()), + format!( + "✓ {} rows, {} columns found", + p.row_count(), + p.column_count() + ), MsgKind::Success, cx, ) .into_any_element(), - (None, Some(e)) => inline_message(e.clone(), MsgKind::Error, cx).into_any_element(), + (None, Some(e)) => { + inline_message(e.clone(), MsgKind::Error, cx).into_any_element() + } (None, None) => div().into_any_element(), }), ) - .child( - v_flex() - .gap_1() - .child(Label::new("Files folder").text_sm()) - .child( - h_flex() - .gap_2() - .child( - div() - .flex_1() - .min_w(px(0.)) - .px_2() - .py_1p5() - .rounded_md() - .border_1() - .border_color(cx.theme().border) - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(folder_display), - ) - .child( - Button::new("browse-folder").label("Browse…").outline().on_click( - cx.listener(|this, _, window, cx| { - this.browse_for_folder(window, cx) - }), - ), - ), - ) - .child(self.render_folder_status(cx)), - ) + .child(self.folder_field("browse-folder", true, cx)) } - fn render_sheet_files(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let folder_display = if self.folder_path.is_empty() { - "Choose your files folder…".to_string() - } else { - self.folder_path.clone() - }; - + fn render_sheet_files( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { v_flex() .gap_3() .child( @@ -276,9 +362,12 @@ impl ProjectWizard { .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(cx)), - ), + Button::new("check-sheet") + .label("Check") + .outline() + .on_click( + cx.listener(|this, _, _, cx| this.check_sheet_link(cx)), + ), ), ) .child(match (&self.sheet_check, &self.sheet_error) { @@ -297,41 +386,13 @@ impl ProjectWizard { cx, ) .into_any_element(), - (None, Some(e)) => inline_message(e.clone(), MsgKind::Error, cx).into_any_element(), + (None, Some(e)) => { + inline_message(e.clone(), MsgKind::Error, cx).into_any_element() + } (None, None) => div().into_any_element(), }), ) - .child( - v_flex() - .gap_1() - .child(Label::new("Files folder").text_sm()) - .child( - h_flex() - .gap_2() - .child( - div() - .flex_1() - .min_w(px(0.)) - .px_2() - .py_1p5() - .rounded_md() - .border_1() - .border_color(cx.theme().border) - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(folder_display), - ) - .child( - Button::new("browse-folder-sheet") - .label("Browse…") - .outline() - .on_click(cx.listener(|this, _, window, cx| { - this.browse_for_folder(window, cx) - })), - ), - ) - .child(self.render_folder_status(cx)), - ) + .child(self.folder_field("browse-folder-sheet", true, cx)) } fn render_folder_status(&self, cx: &App) -> AnyElement { diff --git a/crates/project-wizard/src/steps/link.rs b/crates/project-wizard/src/steps/link.rs index b71c8488..46360b18 100644 --- a/crates/project-wizard/src/steps/link.rs +++ b/crates/project-wizard/src/steps/link.rs @@ -101,23 +101,25 @@ impl ProjectWizard { })), ) .content( - v_flex() - .gap_2() - .mt_2() - .child( - option_card( - "link-pattern", - "Use a custom pattern", - "Match filenames against a pattern instead of an exact match.", - pattern_selected, - cx, - ) - .on_click(cx.listener(|this, _, _, cx| { - this.link_method = LinkMethod::CustomPattern; - cx.notify(); - })), + v_flex().gap_2().mt_2().child( + // The pattern input lives *inside* the option card + // (appended to `option_card`'s own v_flex), revealed + // once the card is selected. + option_card( + "link-pattern", + "Use a custom pattern", + "Match filenames against a pattern instead of an exact match.", + pattern_selected, + cx, ) - .child(Input::new(&self.link_pattern_input).w_full()), + .on_click(cx.listener(|this, _, _, cx| { + this.link_method = LinkMethod::CustomPattern; + cx.notify(); + })) + .when(pattern_selected, |el| { + el.child(Input::new(&self.link_pattern_input).w_full()) + }), + ), ), ) } diff --git a/crates/project-wizard/src/steps/mod.rs b/crates/project-wizard/src/steps/mod.rs index 72e1abb6..4f64e400 100644 --- a/crates/project-wizard/src/steps/mod.rs +++ b/crates/project-wizard/src/steps/mod.rs @@ -3,4 +3,3 @@ pub(crate) mod files; pub(crate) mod link; pub(crate) mod name; pub(crate) mod review; -pub(crate) mod success; diff --git a/crates/project-wizard/src/steps/name.rs b/crates/project-wizard/src/steps/name.rs index 5038f699..7330b60f 100644 --- a/crates/project-wizard/src/steps/name.rs +++ b/crates/project-wizard/src/steps/name.rs @@ -3,7 +3,7 @@ use gpui::{prelude::FluentBuilder, *}; use gpui_component::input::Input; use gpui_component::label::Label; -use gpui_component::{ActiveTheme, IconName, Icon, StyledExt, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Icon, IconName, StyledExt, h_flex, v_flex}; use crate::project; use crate::wizard::ProjectWizard; @@ -15,9 +15,12 @@ impl ProjectWizard { self.name_error = Some("Give your project a name to continue".into()); return false; } + // ponytail: fs .exists() runs per keystroke via the live subscription; + // debounce if it ever stutters. if project::name_taken(&self.save_path, &name) { - self.name_error = - Some("This name is already taken — try another, or pick a different save folder".into()); + self.name_error = Some( + "This name is already taken — try another, or pick a different save folder".into(), + ); return false; } self.name_error = None; @@ -86,7 +89,9 @@ impl ProjectWizard { files: false, directories: true, multiple: false, - prompt: Some("Choose where to save this project".into()), + prompt: Some( + "Choose where to save this project".into(), + ), }); cx.spawn(async move |this, cx| { if let Ok(Ok(Some(paths))) = receiver.await diff --git a/crates/project-wizard/src/steps/review.rs b/crates/project-wizard/src/steps/review.rs index 00078976..c9f0714a 100644 --- a/crates/project-wizard/src/steps/review.rs +++ b/crates/project-wizard/src/steps/review.rs @@ -1,16 +1,16 @@ //! Stage 5 · Review & Create — every path converges here. use gpui::{prelude::FluentBuilder, *}; -use gpui_component::button::{Button, ButtonVariants}; use gpui_component::label::Label; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; +use crate::launcher; use crate::project::{self, ProjectManifest}; use crate::recent; use crate::wizard::{ColumnSource, EntryKind, LinkMethod, ProjectWizard, WizardStep}; impl ProjectWizard { - pub(crate) fn create_project(&mut self, _window: &mut Window, cx: &mut Context) { + pub(crate) fn create_project(&mut self, window: &mut Window, cx: &mut Context) { let name = self.project_name(cx); let source = match self.entry_kind { EntryKind::Blank => "Blank".to_string(), @@ -21,11 +21,9 @@ impl ProjectWizard { .folder_match .as_ref() .map(|m| (m.matched_rows, m.total_rows)); - let link_method = (self.entry_kind != EntryKind::Blank).then(|| { - match self.link_method { - LinkMethod::ExactFilename => "exact filename".to_string(), - LinkMethod::CustomPattern => "custom pattern".to_string(), - } + let link_method = (self.entry_kind != EntryKind::Blank).then(|| match self.link_method { + LinkMethod::ExactFilename => "exact filename".to_string(), + LinkMethod::CustomPattern => "custom pattern".to_string(), }); let columns = match self.entry_kind { EntryKind::Blank => Vec::new(), @@ -67,9 +65,12 @@ impl ProjectWizard { match project::write_project_file(&self.save_path, &name, &manifest) { Ok(dir) => { - recent::record_opened(name, dir.clone(), cx); - self.created_dir = Some(dir); - self.step = WizardStep::Success; + recent::record_opened(name, dir, cx); + // No success screen — hand off to the main app right away. + if let Some(hooks) = cx.try_global::().copied() { + (hooks.open_main_window)(cx); + } + window.remove_window(); } Err(e) => { self.name_error = Some(format!("Couldn't create the project — {e}").into()); @@ -89,17 +90,38 @@ impl ProjectWizard { EntryKind::Csv => "CSV + folder", EntryKind::Sheet => "Google Sheet", }; + let spreadsheet_line = self + .csv_preview + .as_ref() + .map(|p| format!("{} rows · {} columns", p.row_count(), p.column_count())); let files_line = self.folder_match.as_ref().map(|m| { let method = match self.link_method { LinkMethod::ExactFilename => "exact filename", LinkMethod::CustomPattern => "custom pattern", }; - format!("{} of {} ({method})", m.matched_rows, m.total_rows) + let extra = if m.extra_files > 0 { + format!(" · {} not linked", m.extra_files) + } else { + String::new() + }; + format!( + "{} of {} matched ({method}){extra}", + m.matched_rows, m.total_rows + ) }); - let columns_line = match self.column_source { - ColumnSource::AutoFromSpreadsheet => "Auto-matched from spreadsheet".to_string(), - ColumnSource::RecentlyUsed => "From a saved config".to_string(), - ColumnSource::LoadFromFileOrSheet => "Loaded from file/Sheet".to_string(), + let column_count = self + .config_preview + .as_ref() + .map(|c| c.entries.len()) + .unwrap_or_else(|| self.spreadsheet_headers().len()); + let columns_line = { + let source_desc = match self.column_source { + ColumnSource::AutoFromSpreadsheet => "Auto-matched from spreadsheet", + ColumnSource::RecentlyUsed => "From a saved config", + ColumnSource::LoadFromFileOrSheet => "Loaded from file/Sheet", + ColumnSource::SkipForNow => "Set up later", + }; + format!("{source_desc} · {column_count} columns") }; v_flex() @@ -121,30 +143,18 @@ impl ProjectWizard { .child(summary_row("Name", &name)) .child(summary_row("Location", &self.save_path)) .child(summary_row("Source", source)) + .when_some(spreadsheet_line.clone(), |el, line| { + el.child(summary_row("Spreadsheet", &line)) + }) .when_some(files_line.clone(), |el, line| { - el.child(summary_row("Files matched", &line)) + el.child(summary_row("Files", &line)) }) .when(self.entry_kind != EntryKind::Blank, |el| { el.child(summary_row("Columns", &columns_line)) }), ) - .child( - Button::new("create-project") - .label("Create Project") - .primary() - .w_full() - .on_click(cx.listener(|this, _, window, cx| this.create_project(window, cx))), - ) - .child( - div() - .id("review-back") - .cursor_pointer() - .text_sm() - .text_center() - .text_color(cx.theme().muted_foreground) - .child("← Back") - .on_click(cx.listener(|this, _, window, cx| this.go_back(window, cx))), - ) + // The shared wizard footer supplies the "Create Project" (Next) and + // "← Back" controls — see `ProjectWizard::render_footer`. } } diff --git a/crates/project-wizard/src/steps/success.rs b/crates/project-wizard/src/steps/success.rs deleted file mode 100644 index f78abcae..00000000 --- a/crates/project-wizard/src/steps/success.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Stage 6 · Success. - -use gpui::*; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::label::Label; -use gpui_component::{ActiveTheme, Icon, IconName, StyledExt, v_flex}; - -use crate::launcher; -use crate::wizard::ProjectWizard; - -impl ProjectWizard { - pub(crate) fn render_success_step( - &mut self, - _window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let name = self.project_name(cx); - v_flex() - .items_center() - .text_center() - .gap_2() - .py_6() - .child( - div() - .size_12() - .rounded_full() - .border_2() - .border_color(cx.theme().success) - .flex() - .items_center() - .justify_center() - .text_color(cx.theme().success) - .child(Icon::new(IconName::Check)), - ) - .child(div().text_lg().font_semibold().child("Your project is ready")) - .child( - Label::new(format!("{name} is set up and ready to go.")) - .text_sm() - .text_color(cx.theme().muted_foreground), - ) - .child( - Button::new("open-project") - .label("Open Project") - .primary() - .on_click(cx.listener(|_this, _, window, cx| { - if let Some(hooks) = cx.try_global::().copied() { - (hooks.open_main_window)(cx); - } - window.remove_window(); - })), - ) - .child( - div() - .id("create-another") - .cursor_pointer() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Create another") - .on_click(cx.listener(|this, _, window, cx| { - let entry_kind = this.entry_kind; - window.remove_window(); - crate::wizard::open_project_wizard(entry_kind, cx); - })), - ) - } -} diff --git a/crates/project-wizard/src/wizard.rs b/crates/project-wizard/src/wizard.rs index b7ceeade..c238c38f 100644 --- a/crates/project-wizard/src/wizard.rs +++ b/crates/project-wizard/src/wizard.rs @@ -5,9 +5,9 @@ use gpui::{prelude::FluentBuilder, *}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::InputState; +use gpui_component::input::{InputEvent, InputState}; use gpui_component::label::Label; -use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Disableable, Root, StyledExt, TitleBar, h_flex, v_flex}; use window_wrapper::WindowRegistry; use crate::data::{ColumnConfigPreview, FolderMatch, SheetCheckResult, SpreadsheetPreview}; @@ -29,7 +29,6 @@ pub enum WizardStep { Link, Columns, Review, - Success, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -43,6 +42,8 @@ pub enum ColumnSource { AutoFromSpreadsheet, RecentlyUsed, LoadFromFileOrSheet, + /// Blank projects have no spreadsheet to derive columns from — defer setup. + SkipForNow, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -83,6 +84,8 @@ pub struct ProjectWizard { pub(crate) name_input: Entity, pub(crate) save_path: String, pub(crate) name_error: Option, + /// Live-validates the name as it's typed; kept alive by holding it here. + _name_sub: Subscription, // Files step - CSV pub(crate) csv_path: String, @@ -91,6 +94,8 @@ pub struct ProjectWizard { pub(crate) folder_path: String, pub(crate) folder_match: Option, pub(crate) folder_error: Option, + /// "I'll add files later" — skips folder matching and the whole Link step. + pub(crate) skip_files: bool, // Files step - Sheet pub(crate) sheet_link_input: Entity, @@ -110,22 +115,24 @@ pub struct ProjectWizard { pub(crate) config_file_path: String, pub(crate) config_preview: Option, pub(crate) config_error: Option, - - // Review / Success - pub(crate) created_dir: Option, } impl ProjectWizard { pub fn new(entry_kind: EntryKind, window: &mut Window, cx: &mut Context) -> Self { - let name_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("e.g. Aderman Family Collection") - }); - let sheet_link_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("docs.google.com/spreadsheets/d/…") - }); - let link_pattern_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("e.g. {id}_*.jpg") + let name_input = + cx.new(|cx| InputState::new(window, cx).placeholder("e.g. Aderman Family Collection")); + // Live-validate the name on every keystroke so the inline error and the + // Next button react as the user types, not just on click. + let name_sub = cx.subscribe(&name_input, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.validate_name(cx); + cx.notify(); + } }); + let sheet_link_input = cx + .new(|cx| InputState::new(window, cx).placeholder("docs.google.com/spreadsheets/d/…")); + let link_pattern_input = + cx.new(|cx| InputState::new(window, cx).placeholder("e.g. {id}_*.jpg")); let default_save_dir = dirs::document_dir() .map(|d| d.join("qrate")) @@ -139,26 +146,32 @@ impl ProjectWizard { name_input, save_path: default_save_dir, name_error: None, + _name_sub: name_sub, csv_path: String::new(), csv_preview: None, csv_error: None, folder_path: String::new(), folder_match: None, folder_error: None, + skip_files: false, sheet_link_input, sheet_check: None, sheet_error: None, link_method: LinkMethod::ExactFilename, link_pattern_input, show_advanced_pattern: false, - column_source: ColumnSource::AutoFromSpreadsheet, + // Blank has no spreadsheet to auto-derive columns from. + column_source: if entry_kind == EntryKind::Blank { + ColumnSource::SkipForNow + } else { + ColumnSource::AutoFromSpreadsheet + }, show_advanced_mapping: false, recent_config_selected: 0, load_config_tab: LoadConfigTab::File, config_file_path: String::new(), config_preview: None, config_error: None, - created_dir: None, } } @@ -168,30 +181,109 @@ impl ProjectWizard { pub(crate) fn spreadsheet_headers(&self) -> Vec { match self.entry_kind { - EntryKind::Csv => self + // Sheet reuses `csv_preview` too — its fetched CSV is parsed the + // same way (see steps/files.rs `check_sheet_link`). + EntryKind::Csv | EntryKind::Sheet => self .csv_preview .as_ref() .map(|p| p.headers.clone()) .unwrap_or_default(), - EntryKind::Sheet => Vec::new(), EntryKind::Blank => Vec::new(), } } + /// The Link step is skipped when there's no folder to link against + /// (files skipped) or no spreadsheet rows to link (Blank). + pub(crate) fn skips_link(&self) -> bool { + self.skip_files || self.entry_kind == EntryKind::Blank + } + + /// Whether the current step is valid enough to advance. `Ok(())` means the + /// Next button is live; `Err(reason)` carries the human-readable reason it's + /// blocked, which the footer shows beside the greyed-out button. Reads + /// existing per-step state — the actual validators run elsewhere. + pub(crate) fn can_advance(&self, cx: &App) -> Result<(), SharedString> { + match self.step { + WizardStep::Name => { + let name = self.project_name(cx); + if name.trim().is_empty() { + return Err("Give your project a name to continue".into()); + } + if self.save_path.trim().is_empty() { + return Err("Choose where to save this project".into()); + } + if crate::project::name_taken(&self.save_path, &name) { + return Err("A project with this name already exists here".into()); + } + Ok(()) + } + WizardStep::Files => { + if self.skip_files || self.entry_kind == EntryKind::Blank { + return Ok(()); + } + match self.entry_kind { + EntryKind::Csv => { + if self.csv_preview.is_none() { + return Err("Choose a valid CSV spreadsheet".into()); + } + if self.folder_match.is_none() { + return Err( + "Choose a files folder that matches your spreadsheet".into() + ); + } + Ok(()) + } + EntryKind::Sheet => { + if self.sheet_check.is_none() { + return Err("Check your Google Sheet link first".into()); + } + if self.folder_match.is_none() { + return Err("Choose a files folder that matches your sheet".into()); + } + Ok(()) + } + EntryKind::Blank => Ok(()), + } + } + WizardStep::Link => { + if self.link_method == LinkMethod::CustomPattern + && self.link_pattern_input.read(cx).value().trim().is_empty() + { + return Err("Enter a naming pattern, or switch back to exact filename".into()); + } + Ok(()) + } + WizardStep::Columns => { + if self.column_source == ColumnSource::LoadFromFileOrSheet + && self.config_preview.is_none() + { + return Err("Load a column config, or pick a different source".into()); + } + Ok(()) + } + WizardStep::Review => Ok(()), + } + } + + /// On the Files step for a Google Sheet, pressing Next fetches the sheet + /// itself if it hasn't been checked yet — the separate "Check" button + /// becomes optional. True while a link is typed but not yet fetched. + fn needs_sheet_check(&self, cx: &App) -> bool { + self.step == WizardStep::Files + && self.entry_kind == EntryKind::Sheet + && !self.skip_files + && self.sheet_check.is_none() + && !self.sheet_link_input.read(cx).value().trim().is_empty() + } + fn breadcrumb_items(&self) -> Vec<(&'static str, WizardStep)> { - match self.entry_kind { - EntryKind::Blank => vec![ - ("Name", WizardStep::Name), - ("Create", WizardStep::Review), - ], - _ => vec![ - ("Name", WizardStep::Name), - ("Files", WizardStep::Files), - ("Link", WizardStep::Link), - ("Columns", WizardStep::Columns), - ("Create", WizardStep::Review), - ], + let mut items = vec![("Name", WizardStep::Name), ("Files", WizardStep::Files)]; + if !self.skips_link() { + items.push(("Link", WizardStep::Link)); } + items.push(("Columns", WizardStep::Columns)); + items.push(("Create", WizardStep::Review)); + items } fn step_index(&self) -> usize { @@ -205,11 +297,7 @@ impl ProjectWizard { let items = self.breadcrumb_items(); let current_ix = self.step_index(); let mut row = h_flex().gap_2().items_center().text_sm().flex_wrap(); - row = row.child( - div() - .text_color(cx.theme().success) - .child("✓ Start"), - ); + row = row.child(div().text_color(cx.theme().success).child("✓ Start")); for (ix, (label, _)) in items.iter().enumerate() { row = row.child(div().text_color(cx.theme().muted_foreground).child("·")); let el = if ix < current_ix { @@ -239,32 +327,47 @@ impl ProjectWizard { } (_, WizardStep::Files) => WizardStep::Name, (_, WizardStep::Link) => WizardStep::Files, - (_, WizardStep::Columns) => WizardStep::Link, - (EntryKind::Blank, WizardStep::Review) => WizardStep::Name, + (_, WizardStep::Columns) => { + if self.skips_link() { + WizardStep::Files + } else { + WizardStep::Link + } + } (_, WizardStep::Review) => WizardStep::Columns, - (_, WizardStep::Success) => WizardStep::Success, }; cx.notify(); } fn go_next(&mut self, window: &mut Window, cx: &mut Context) { + // Google Sheet: Next doubles as "Check" until the sheet is fetched. + // The async result re-renders; the user then presses Next to advance. + if self.needs_sheet_check(cx) { + self.check_sheet_link(cx); + return; + } + if self.can_advance(cx).is_err() { + cx.notify(); + return; + } match self.step { WizardStep::Name => { if !self.validate_name(cx) { cx.notify(); return; } - self.step = if self.entry_kind == EntryKind::Blank { - WizardStep::Review + self.step = WizardStep::Files; + } + WizardStep::Files => { + self.step = if self.skips_link() { + WizardStep::Columns } else { - WizardStep::Files + WizardStep::Link }; } - WizardStep::Files => self.step = WizardStep::Link, WizardStep::Link => self.step = WizardStep::Columns, WizardStep::Columns => self.step = WizardStep::Review, WizardStep::Review => self.create_project(window, cx), - WizardStep::Success => {} } cx.notify(); } @@ -275,6 +378,15 @@ impl ProjectWizard { } else { "Next →" }; + // When the sheet still needs checking, Next doubles as "Check" and is + // live regardless of the blocker. Otherwise a blocker greys out Next and + // its reason is shown beside it. + let blocker = if self.needs_sheet_check(cx) { + None + } else { + self.can_advance(cx).err() + }; + let disabled = blocker.is_some(); h_flex() .justify_between() .items_center() @@ -289,10 +401,24 @@ impl ProjectWizard { .on_click(cx.listener(|this, _, window, cx| this.go_back(window, cx))), ) .child( - Button::new("wizard-next") - .label(next_label) - .primary() - .on_click(cx.listener(|this, _, window, cx| this.go_next(window, cx))), + h_flex() + .gap_2() + .items_center() + .when_some(blocker, |el, reason| { + el.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(reason), + ) + }) + .child( + Button::new("wizard-next") + .label(next_label) + .primary() + .disabled(disabled) + .on_click(cx.listener(|this, _, window, cx| this.go_next(window, cx))), + ), ) } } @@ -300,10 +426,6 @@ impl ProjectWizard { impl Render for ProjectWizard { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let dialog_layer = Root::render_dialog_layer(window, cx); - let title = match self.step { - WizardStep::Success => "qrate".into(), - _ => "qrate — New Project".to_string(), - }; let body = match self.step { WizardStep::Name => self.render_name_step(window, cx).into_any_element(), @@ -311,37 +433,40 @@ impl Render for ProjectWizard { WizardStep::Link => self.render_link_step(window, cx).into_any_element(), WizardStep::Columns => self.render_columns_step(window, cx).into_any_element(), WizardStep::Review => self.render_review_step(window, cx).into_any_element(), - WizardStep::Success => self.render_success_step(window, cx).into_any_element(), }; - let show_chrome = self.step != WizardStep::Success; - let breadcrumb = if show_chrome { - Some(self.render_breadcrumb(cx).into_any_element()) - } else { - None - }; - // The Review step renders its own big "Create Project" button and - // centered back link (matching the prototype), so the shared footer - // only appears on the other steps. - let footer = if show_chrome && self.step != WizardStep::Review { - Some(self.render_footer(cx).into_any_element()) - } else { - None - }; + // Shared scaffold for every step: pinned breadcrumb on top, scrolling + // body in the middle, pinned Back/Next footer at the bottom. Each step + // only supplies `body`; navigation and validation live in the footer. + let breadcrumb = self.render_breadcrumb(cx).into_any_element(); + let footer = self.render_footer(cx).into_any_element(); v_flex() .size_full() .bg(cx.theme().background) - .child(TitleBar::new().child(Label::new(title).font_semibold())) + .child(TitleBar::new().child(Label::new("qrate — New Project").font_semibold())) .child( v_flex() .id("wizard-body") .flex_1() + // min_h(0) bounds this to the window height (below the title + // bar) so the scroll region — not the whole page — grows. + // Without it the flex item's default min-height:auto expands + // to fit all steps' content, pushing the footer off-screen. + .min_h(px(0.)) .p_5() .gap_3() - .children(breadcrumb) - .child(body) - .children(footer), + .child(breadcrumb) + // Middle region scrolls; breadcrumb and footer stay pinned. + .child( + div() + .id("wizard-scroll") + .flex_1() + .min_h(px(0.)) + .overflow_y_scroll() + .child(body), + ) + .child(footer), ) .children(dialog_layer) } From 6787eca321de3d7690651762acee5d643c2e5c72 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:45:41 +0530 Subject: [PATCH 4/8] style(app): apply rustfmt to main.rs Unformatted since ec7d4ad; caught by `cargo fmt --all --check` (the CI gate). --- crates/app/src/main.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 6519cd85..b0e56cd7 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -192,9 +192,7 @@ fn main() { // 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`. - cx.set_global(LauncherHooks { - open_main_window, - }); + cx.set_global(LauncherHooks { open_main_window }); cx.on_action(|_: &OpenSettings, cx| open_settings_window(cx)); cx.on_action(|_: &NewProject, cx| { From 725d85006a9507b12f9dc4fc97721e879cfc8184 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:45:41 +0530 Subject: [PATCH 5/8] fix(project-wizard): honor skip-files/blank in the manifest & review Address Copilot review on PR #20: - When "add files later" is checked, the folder/link state is stale, so don't populate files_matched / link_method or show a Files line that claims a match happened. - Blank projects now flow through the Columns step, so show the Columns row in review and keep any config they loaded there instead of forcing an empty column list. - Pluralize relative_time ("1 minute ago", not "1 minutes ago"). --- crates/project-wizard/src/recent.rs | 11 ++- crates/project-wizard/src/steps/review.rs | 110 ++++++++++++---------- 2 files changed, 64 insertions(+), 57 deletions(-) diff --git a/crates/project-wizard/src/recent.rs b/crates/project-wizard/src/recent.rs index 6acb3a8f..fa1a10b0 100644 --- a/crates/project-wizard/src/recent.rs +++ b/crates/project-wizard/src/recent.rs @@ -71,17 +71,18 @@ fn now_unix() -> i64 { pub fn relative_time(opened_at_unix: i64) -> String { let now = now_unix(); let delta = (now - opened_at_unix).max(0); + let ago = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }); if delta < 60 { "just now".into() } else if delta < 3600 { - format!("{} minutes ago", delta / 60) + ago(delta / 60, "minute") } else if delta < 86_400 { - format!("{} hours ago", delta / 3600) + ago(delta / 3600, "hour") } else if delta < 86_400 * 7 { - format!("{} days ago", delta / 86_400) + ago(delta / 86_400, "day") } else if delta < 86_400 * 30 { - format!("{} weeks ago", delta / (86_400 * 7)) + ago(delta / (86_400 * 7), "week") } else { - format!("{} months ago", delta / (86_400 * 30)) + ago(delta / (86_400 * 30), "month") } } diff --git a/crates/project-wizard/src/steps/review.rs b/crates/project-wizard/src/steps/review.rs index c9f0714a..924085c5 100644 --- a/crates/project-wizard/src/steps/review.rs +++ b/crates/project-wizard/src/steps/review.rs @@ -17,43 +17,45 @@ impl ProjectWizard { EntryKind::Csv => "CSV + folder".to_string(), EntryKind::Sheet => "Google Sheet".to_string(), }; - let files_matched = self - .folder_match - .as_ref() - .map(|m| (m.matched_rows, m.total_rows)); - let link_method = (self.entry_kind != EntryKind::Blank).then(|| match self.link_method { + // When files are skipped the folder/link state is stale — don't let the + // manifest claim files were matched or a link method was chosen. + let files_matched = (!self.skip_files) + .then(|| { + self.folder_match + .as_ref() + .map(|m| (m.matched_rows, m.total_rows)) + }) + .flatten(); + let link_method = (!self.skips_link()).then(|| match self.link_method { LinkMethod::ExactFilename => "exact filename".to_string(), LinkMethod::CustomPattern => "custom pattern".to_string(), }); - let columns = match self.entry_kind { - EntryKind::Blank => Vec::new(), - _ => self - .config_preview - .as_ref() - .map(|p| { - p.entries - .iter() - .map(|e| project::ManifestColumn { - name: e.name.clone(), - data_type: e.data_type.clone(), - description: e.description.clone(), - }) - .collect() - }) - .unwrap_or_else(|| { - // No explicit config: auto-created from the spreadsheet, - // every column defaults to Text (same rule the mocked - // Sheet-config load uses). - self.spreadsheet_headers() - .into_iter() - .map(|name| project::ManifestColumn { - name, - data_type: "Text".into(), - description: String::new(), - }) - .collect() - }), - }; + // Blank projects go through the Columns step too, so honor any config + // loaded there; otherwise fall back to spreadsheet headers (empty for + // Blank), every column defaulting to Text. + let columns = self + .config_preview + .as_ref() + .map(|p| { + p.entries + .iter() + .map(|e| project::ManifestColumn { + name: e.name.clone(), + data_type: e.data_type.clone(), + description: e.description.clone(), + }) + .collect() + }) + .unwrap_or_else(|| { + self.spreadsheet_headers() + .into_iter() + .map(|name| project::ManifestColumn { + name, + data_type: "Text".into(), + description: String::new(), + }) + .collect() + }); let manifest = ProjectManifest { name: name.clone(), @@ -94,21 +96,26 @@ impl ProjectWizard { .csv_preview .as_ref() .map(|p| format!("{} rows · {} columns", p.row_count(), p.column_count())); - let files_line = self.folder_match.as_ref().map(|m| { - let method = match self.link_method { - LinkMethod::ExactFilename => "exact filename", - LinkMethod::CustomPattern => "custom pattern", - }; - let extra = if m.extra_files > 0 { - format!(" · {} not linked", m.extra_files) - } else { - String::new() - }; - format!( - "{} of {} matched ({method}){extra}", - m.matched_rows, m.total_rows - ) - }); + // Skipped files → no folder was matched, so don't show a stale Files line. + let files_line = (!self.skip_files) + .then(|| { + self.folder_match.as_ref().map(|m| { + let method = match self.link_method { + LinkMethod::ExactFilename => "exact filename", + LinkMethod::CustomPattern => "custom pattern", + }; + let extra = if m.extra_files > 0 { + format!(" · {} not linked", m.extra_files) + } else { + String::new() + }; + format!( + "{} of {} matched ({method}){extra}", + m.matched_rows, m.total_rows + ) + }) + }) + .flatten(); let column_count = self .config_preview .as_ref() @@ -149,9 +156,8 @@ impl ProjectWizard { .when_some(files_line.clone(), |el, line| { el.child(summary_row("Files", &line)) }) - .when(self.entry_kind != EntryKind::Blank, |el| { - el.child(summary_row("Columns", &columns_line)) - }), + // Blank projects go through Columns too, so always show it. + .child(summary_row("Columns", &columns_line)), ) // The shared wizard footer supplies the "Create Project" (Next) and // "← Back" controls — see `ProjectWizard::render_footer`. From 966f98e4b91324b6bb6772cbfe469f85246c4460 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:45:41 +0530 Subject: [PATCH 6/8] fix(cloud-sync): time out the sheet fetch Address Copilot review on PR #20: a bare blocking request has no timeout, so a stalled connection hangs the background worker forever. Use a client with a 30s timeout so it errors out instead. --- crates/cloud-sync/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/cloud-sync/src/lib.rs b/crates/cloud-sync/src/lib.rs index 739c2a82..708d7a22 100644 --- a/crates/cloud-sync/src/lib.rs +++ b/crates/cloud-sync/src/lib.rs @@ -151,7 +151,14 @@ pub struct SheetData { pub fn fetch_sheet(link: &str) -> Result { let SheetRef { id, .. } = parse_sheet_ref(link)?; let url = format!("https://docs.google.com/spreadsheets/d/{id}/export?format=xlsx"); - let resp = reqwest::blocking::get(&url)?.error_for_status()?; + // Explicit timeout so a stalled connection returns an error instead of + // hanging the background worker thread forever. + let resp = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()? + .get(&url) + .send()? + .error_for_status()?; if resp .url() From c3c9d303bea6f5c9acf13b19291ada403f651e16 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:50:21 +0530 Subject: [PATCH 7/8] chore: add shared pre-commit rustfmt hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs `cargo fmt --all --check` (CI's fmt gate) on every commit so unformatted code can't land — the check that missed ec7d4ad. Shared via core.hooksPath; enable per clone with `git config core.hooksPath .githooks`. --- .githooks/pre-commit | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..2c978069 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,12 @@ +#!/bin/sh +# Block commits that would fail CI's `cargo fmt --all --check` gate — the exact +# check that let an unformatted main.rs slip into ec7d4ad. +# ponytail: fmt only (instant). clippy/test/build stay in the pre-PR + CI gate, +# too slow to run on every commit. +# +# Shared via core.hooksPath — enable once per clone: git config core.hooksPath .githooks +if ! cargo fmt --all --check; then + echo >&2 + echo "pre-commit: rustfmt check failed. Run 'cargo fmt --all', re-stage, and commit again." >&2 + exit 1 +fi From 83de97950417f36b15530f7844569f75fbe21194 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:55:44 +0530 Subject: [PATCH 8/8] ci: drop redundant build step `cargo clippy --all-targets` and `cargo test --workspace` already compile every target (incl. non-test bin code), so a separate `cargo build` adds nothing but CI time. --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a73b332..953b90ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,3 @@ jobs: - name: Test run: cargo test --workspace - - - name: Build - run: cargo build --workspace