diff --git a/crates/app/src/actions.rs b/crates/app/src/actions.rs index bac30b30..6210273c 100644 --- a/crates/app/src/actions.rs +++ b/crates/app/src/actions.rs @@ -15,6 +15,7 @@ actions!( // Workspace commands. NewWindow, NewProject, + Save, // Dock/panel toggles (handled on the App root — they need a `Window`). ToggleLeftDock, ToggleBottomDock, @@ -29,6 +30,9 @@ pub fn key_bindings() -> Vec { vec![ KeyBinding::new("ctrl-shift-n", NewWindow, None), KeyBinding::new("ctrl-n", NewProject, None), + // Save the open project's data to its `.qrate` file. Global: saving shouldn't depend on + // where focus sits (a focused cell editor's `Input` context doesn't bind Ctrl+S). + KeyBinding::new("ctrl-s", Save, None), KeyBinding::new("ctrl-b", ToggleLeftDock, None), KeyBinding::new("ctrl-`", ToggleBottomDock, None), KeyBinding::new("ctrl-alt-b", ToggleRightDock, None), @@ -49,4 +53,5 @@ pub fn register_global_handlers(cx: &mut App) { // 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(|_: &Save, cx| table::save_now(cx)); } diff --git a/crates/app/src/app_menus.rs b/crates/app/src/app_menus.rs index 86a33d8e..f2403461 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -1,7 +1,7 @@ use gpui::*; use window_wrapper::OpenBrowser; -use crate::actions::{NewProject, ToggleBottomDock, ToggleLeftDock, ToggleRightDock}; +use crate::actions::{NewProject, Save, ToggleBottomDock, ToggleLeftDock, ToggleRightDock}; use crate::theming::{SwitchTheme, THEME_CHOICES}; // ponytail: Undo/Redo/Cut/Copy/Paste are declared and wired into the Edit menu but have no @@ -31,6 +31,8 @@ pub fn app_menus() -> Vec { MenuItem::action("New Project…", NewProject), MenuItem::Separator, MenuItem::action("Open Projects…", OpenProjects), + MenuItem::action("Save", Save), + MenuItem::Separator, MenuItem::action("Settings", OpenSettings), MenuItem::Separator, MenuItem::action("Quit", Quit), diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index f118ada2..c6167e87 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -4,8 +4,8 @@ use gpui::{ StatefulInteractiveElement as _, Styled as _, div, px, }; use gpui_component::{ - ActiveTheme as _, IconName, Sizable as _, - button::{Button, ButtonVariants as _}, + ActiveTheme as _, Icon, IconName, Sizable as _, + button::Button, h_flex, popover::Popover, setting::{SettingField, SettingGroup, SettingItem, SettingPage}, @@ -15,32 +15,77 @@ use settings::{Setting, columns, project::CurrentProject}; pub fn build_pages(cx: &App) -> Vec { vec![ - SettingPage::new("Table").group( - SettingGroup::new().title("Appearance").item( - Setting::Switch { - key: table::TABLE_STRIPES_KEY, - label: "Row Stripes", - description: "Alternate row background color in the data table.", - } - .into(), - ), - ), + SettingPage::new("Table") + .group( + SettingGroup::new().title("Appearance").item( + Setting::Switch { + key: table::TABLE_STRIPES_KEY, + label: "Row Stripes", + description: "Alternate row background color in the data table.", + } + .into(), + ), + ) + .group(saving_group(cx)), columns_page(cx), ] } -/// Per-column settings, project-scoped. The page starts empty: a column is added by name, which -/// is what surfaces its settings. Built from `&App` rather than a fixed list because the columns +/// Autosave as a toggle plus, when on, the method. Both edit the one `AUTOSAVE_KEY` the table reads +/// (`off`/`timed`/`immediate`): the switch is off iff the value is `off`, so an unset value reads as +/// on. The method row only exists while autosave is on — the Settings window observes settings +/// globals (see `SettingsWindow`), so flipping the switch rebuilds this page live. +fn saving_group(cx: &App) -> SettingGroup { + let mut group = SettingGroup::new().title("Saving").item( + SettingItem::new( + "Autosave", + SettingField::switch( + |cx: &App| settings::scoped_text(settings::AUTOSAVE_KEY, cx) != "off", + |on: bool, cx: &mut App| { + settings::set_scoped_text( + settings::AUTOSAVE_KEY, + if on { "timed" } else { "off" }.into(), + cx, + ); + }, + ), + ) + .description("Save cell edits automatically. Ctrl+S always saves."), + ); + + if settings::scoped_text(settings::AUTOSAVE_KEY, cx) != "off" { + group = group.item( + SettingItem::new( + "Method", + SettingField::dropdown( + vec![ + ("timed".into(), "After a short pause".into()), + ("immediate".into(), "On every edit".into()), + ], + |cx: &App| { + let v = settings::scoped_text(settings::AUTOSAVE_KEY, cx); + if v == "immediate" { v } else { "timed".into() } + }, + |val: SharedString, cx: &mut App| { + settings::set_scoped_text(settings::AUTOSAVE_KEY, val, cx); + }, + ), + ) + .description("When edits reach the file: after you pause typing, or on every edit."), + ); + } + group +} + +/// Per-column filters, project-scoped. A master switch gates the feature; when on, a multi-select +/// picker chooses which columns show a filter dropdown (a picked column *is* a filter-enabled one — +/// selection and `filter_enabled` are the same thing now). Built from `&App` because the columns /// depend on whichever project is open. -/// -/// These use `SettingField`/`SettingItem` directly rather than the `Setting::Switch` enum — that -/// one's `key` is `&'static str` and routes through the user/project scope helpers, while a column -/// key is computed at runtime and is always project-scoped. fn columns_page(cx: &App) -> SettingPage { let Some(project) = cx.try_global::() else { return SettingPage::new("Columns").group( SettingGroup::new() - .title("Add column") + .title("Filters") .description("Open a project to configure its columns."), ); }; @@ -54,109 +99,115 @@ fn columns_page(cx: &App) -> SettingPage { .enumerate() .map(|(ix, name)| (format!("c{ix}"), SharedString::from(name.clone()))) .collect(); - let tracked = columns::tracked(cx); - let unadded: Vec<(String, SharedString)> = headers - .iter() - .filter(|(key, _)| !tracked.contains(key)) - .cloned() - .collect(); - // Heading + one subtitle row carrying the Add button on its right. - let mut page = SettingPage::new("Columns").group(SettingGroup::new().title("Add column").item( + let mut group = SettingGroup::new().title("Filters").item( SettingItem::new( - "Add a column to change its settings", - SettingField::element(move |_opts: &_, _window: &mut _, _cx: &mut _| { - add_column_button(unadded.clone()) - }), - ), - )); + "Enable column filters", + SettingField::switch( + |cx: &App| columns::filters_master_enabled(cx), + |on: bool, cx: &mut App| columns::set_filters_master_enabled(on, cx), + ), + ) + .description("Show a filter dropdown in the header of the columns you pick."), + ); - for key in tracked { - let name = headers - .iter() - .find(|(k, _)| *k == key) - .map(|(_, n)| n.clone()) - .unwrap_or_else(|| SharedString::from(key.clone())); - let (get_key, set_key, remove_key) = (key.clone(), key.clone(), key.clone()); - page = page.group( - SettingGroup::new() - // Column name is the group title so it lists under "Columns" in the sidebar (titled groups only). - .title(name) - .item(SettingItem::render(move |_opts, _window, _cx| { - let remove_key = remove_key.clone(); - h_flex().w_full().justify_end().child( - Button::new(SharedString::from(format!("remove-{remove_key}"))) - .icon(IconName::Delete) - .label("Remove") - .ghost() - .xsmall() - .on_click(move |_, _, cx: &mut App| { - columns::remove(&remove_key, cx); - }), - ) - })) - .item( - SettingItem::new( - "Enable filter", - SettingField::switch( - move |cx: &App| columns::get(&get_key, cx).filter_enabled, - move |on: bool, cx: &mut App| { - columns::update(&set_key, |s| s.filter_enabled = on, cx); - }, - ), - ) - .description("Show a filter dropdown in this column's header."), - ), + if columns::filters_master_enabled(cx) { + group = group.item( + SettingItem::new( + "Filtered columns", + SettingField::element(move |_opts: &_, _window: &mut _, cx: &mut _| { + let label = picker_label(&headers, cx); + filtered_columns_picker(headers.clone(), label) + }), + ) + .description("Columns that show a filter dropdown."), ); } - page + SettingPage::new("Columns").group(group) +} + +/// Trigger label for the picker: "First +N others", or "No columns" when nothing is selected. +fn picker_label(headers: &[(String, SharedString)], cx: &App) -> SharedString { + let selected: Vec<&SharedString> = headers + .iter() + .filter(|(key, _)| columns::get(key, cx).filter_enabled) + .map(|(_, name)| name) + .collect(); + match selected.len() { + 0 => "No columns".into(), + 1 => selected[0].clone(), + n => format!( + "{} +{} other{}", + selected[0], + n - 1, + if n - 1 == 1 { "" } else { "s" } + ) + .into(), + } } -/// The Add button and its column list. A popover rather than a `Dropdown` so the list can scroll -/// and so the trigger reads as an action rather than a value being picked. -fn add_column_button(unadded: Vec<(String, SharedString)>) -> impl gpui::IntoElement { - Popover::new("add-column") +/// The picker trigger ("First +N others") and its checklist. A popover, not a `Dropdown`, so the +/// list scrolls and clicking a row toggles that column's filter without dismissing. +fn filtered_columns_picker( + headers: Vec<(String, SharedString)>, + label: SharedString, +) -> impl gpui::IntoElement { + Popover::new("filtered-columns") .trigger( - Button::new("add-column-btn") - .icon(IconName::Plus) - .label("Add") + Button::new("filtered-columns-btn") + .label(label) .outline() .xsmall(), ) .content(move |_state, _window, cx| { - let (hover, muted) = (cx.theme().secondary_hover, cx.theme().muted_foreground); + // Match gpui-component's own menu items: text_sm rows, accent highlight on hover, a + // right-aligned check marking selection (see `menu/popup_menu.rs`). + let (accent, accent_fg, muted) = ( + cx.theme().accent, + cx.theme().accent_foreground, + cx.theme().muted_foreground, + ); let radius = cx.theme().radius; - let popover = cx.entity(); - let rows: Vec<_> = unadded.clone(); + let rows = headers.clone(); v_flex() - .id("add-column-list") - .w(px(220.)) - .max_h(px(280.)) - // `overflow_y_scroll` only — `overflow_hidden` would set *both* axes and silently - // undo it. + .id("filtered-columns-list") + .w(px(240.)) + .max_h(px(320.)) + .p_1() + // `overflow_y_scroll` only — `overflow_hidden` would set *both* axes and undo it. .overflow_y_scroll() .when(rows.is_empty(), |list| { list.child( div() .px_2() .py_1() + .text_sm() .text_color(muted) - .child("Every column has been added"), + .child("No columns in this project"), ) }) - .children(rows.into_iter().map(move |(key, label)| { - let popover = popover.clone(); - div() - .id(SharedString::from(format!("add-{key}"))) - .px_2() - .py_1() + .children(rows.into_iter().map(move |(key, name)| { + let on = columns::get(&key, cx).filter_enabled; + let toggle_key = key.clone(); + h_flex() + .id(SharedString::from(format!("pick-{key}"))) + .h(px(26.)) + .px(px(8.)) + .gap_x_1() .rounded(radius) + .items_center() + .justify_between() + .text_sm() .cursor_pointer() - .hover(|r| r.bg(hover)) - .child(label) - .on_click(move |_, window, cx: &mut App| { - columns::add(&key, cx); - popover.update(cx, |state, cx| state.dismiss(window, cx)); + .hover(|r| r.bg(accent).text_color(accent_fg)) + .child(name) + .when(on, |r| r.child(Icon::new(IconName::Check).xsmall())) + .on_click(move |_, _, cx: &mut App| { + columns::update( + &toggle_key, + |s| s.filter_enabled = !s.filter_enabled, + cx, + ); }) })) }) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 67300cf6..984b5a5c 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -144,6 +144,9 @@ pub struct App { workspace: Entity, status_bar: Entity, _main_window_bounds_sub: Subscription, + /// Repaints the title bar's unsaved-changes dot when the dirty set changes. `dirty::mark`/ + /// `clear` mutate the global, so this fires on every edit and every save. + _dirty_sub: Subscription, } impl App { @@ -180,20 +183,51 @@ impl App { }); // Native X skips `on_app_quit` on Windows (zed#40385/#40290), so flush here too — unless locked. - window.on_window_should_close(cx, |_, cx| { + window.on_window_should_close(cx, |window, cx| { if WindowLock::is_locked(cx) { return false; } + // Unsaved cell edits (autosave off, or a pending "timed" save) are the only thing the + // user might want to discard — layout/settings auto-persist on the flush below. + if settings::dirty::Dirty::has(settings::dirty::PROJECT_DATA, cx) { + let answer = window.prompt( + PromptLevel::Warning, + "You have unsaved changes.", + Some("Save them before closing?"), + &["Save", "Don't Save", "Cancel"], + cx, + ); + cx.spawn(async move |cx| { + let choice = answer.await.unwrap_or(2); + cx.update(|cx| { + match choice { + // Save: the quit flush persists PROJECT_DATA (still dirty). + 0 => {} + // Don't Save: drop the mark so the flush skips it — edits are discarded. + 1 => settings::dirty::clear(settings::dirty::PROJECT_DATA, cx), + // Cancel: leave the window open. + _ => return, + } + cx.quit(); + }); + }) + .detach(); + // Veto this close; the async handler quits once the user decides. + return false; + } flush_all_state(cx); true }); set_main_window_title(window, cx); + let _dirty_sub = cx.observe_global::(|_, cx| cx.notify()); + Self { workspace, status_bar, _main_window_bounds_sub, + _dirty_sub, } } @@ -229,11 +263,20 @@ impl Render for App { .child( v_flex() .size_full() - .child(AppTitleBar::new( - cx.try_global::() - .map(|p| p.display_name()) - .unwrap_or_default(), - )) + .child( + AppTitleBar::new( + cx.try_global::() + .map(|p| p.display_name()) + .unwrap_or_default(), + ) + // Only cell data (gated by autosave/Ctrl+S) can be genuinely unsaved; + // column layout/settings auto-persist via the debounced writer, so `any()` + // would light the dot forever for those (nothing clears them until quit). + .dirty(settings::dirty::Dirty::has( + settings::dirty::PROJECT_DATA, + cx, + )), + ) .child( div() .id("window-body") @@ -267,6 +310,10 @@ fn flush_all_state(cx: &mut gpui::App) { if let Err(err) = settings::flush_app_settings(AppSettings::get(cx)) { eprintln!("failed to flush app settings on quit: {err}"); } + // Flush unsaved cell edits (a pending "timed" autosave, or edits made with autosave off). + if settings::dirty::Dirty::has(settings::dirty::PROJECT_DATA, cx) { + table::save_now(cx); + } // Everything above reached disk synchronously, so nothing is outstanding. settings::dirty::clear_all(cx); } diff --git a/crates/settings/src/columns.rs b/crates/settings/src/columns.rs index 25be0d9d..db2528dc 100644 --- a/crates/settings/src/columns.rs +++ b/crates/settings/src/columns.rs @@ -21,6 +21,11 @@ use crate::project::CurrentProject; /// `__settings` key holding the JSON map below. pub const COLUMN_SETTINGS_KEY: &str = "table_column_settings"; +/// `__settings` key for the master "column filters" switch — the kill-switch gating every +/// column's filter dropdown. Absent means on, so opening a project that already selected columns +/// shows their filters without first flipping a toggle. +pub const COLUMN_FILTERS_ENABLED_KEY: &str = "table_column_filters_enabled"; + /// A column's preferences. A column only has an entry once the user adds it on the Columns /// settings page; absent means "not configured", which reads as all-defaults. #[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)] @@ -56,19 +61,30 @@ pub fn load(cx: &App) -> ColumnSettingsMap { ) } -/// The column keys the user has added to the settings page, in `c{ix}` order. -pub fn tracked(cx: &App) -> Vec { - load(cx).into_keys().collect() -} - /// One column's settings, all-defaults if it was never added. pub fn get(col_key: &str, cx: &App) -> ColumnSettings { load(cx).get(col_key).cloned().unwrap_or_default() } -/// Whether a column has been added to the settings page. -pub fn is_tracked(col_key: &str, cx: &App) -> bool { - load(cx).contains_key(col_key) +/// The master filter switch (default on — see [`COLUMN_FILTERS_ENABLED_KEY`]). +pub fn filters_master_enabled(cx: &App) -> bool { + cx.try_global::() + .and_then(|p| { + p.data + .values + .get(COLUMN_FILTERS_ENABLED_KEY) + .map(|v| v.bool()) + }) + .unwrap_or(true) +} + +/// Flip the master filter switch. No-op without an open project. +pub fn set_filters_master_enabled(on: bool, cx: &mut App) { + if !cx.has_global::() { + return; + } + CurrentProject::set_bool(COLUMN_FILTERS_ENABLED_KEY, on, cx); + dirty::mark(dirty::COLUMN_SETTINGS, cx); } /// Persist the whole map, updating `CurrentProject`'s cache and queueing a debounced write. @@ -84,26 +100,6 @@ fn store(map: &ColumnSettingsMap, cx: &mut App) { dirty::mark(dirty::COLUMN_SETTINGS, cx); } -/// Add a column to the settings page, unlocking its settings at their defaults. Adding one that -/// is already there leaves it untouched. -pub fn add(col_key: &str, cx: &mut App) { - let mut map = load(cx); - if map.contains_key(col_key) { - return; - } - map.insert(col_key.to_string(), ColumnSettings::default()); - store(&map, cx); -} - -/// Drop a column from the settings page, discarding its settings. -pub fn remove(col_key: &str, cx: &mut App) { - let mut map = load(cx); - if map.remove(col_key).is_none() { - return; - } - store(&map, cx); -} - /// Mutate one column's settings. Adds the entry first if it is missing, so a toggle can't /// silently no-op. pub fn update(col_key: &str, f: impl FnOnce(&mut ColumnSettings), cx: &mut App) { diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 7acd33d3..3af5ab1f 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -32,6 +32,10 @@ use crate::path_picker::PathPickerApp; /// `AppSettings` value key for the Settings window's last size (a JSON [`MainWindowBounds`]). pub const SETTINGS_WINDOW_BOUNDS_KEY: &str = "settings_window_bounds"; +/// Setting key (either scope) for autosave behavior: `"timed"` (buffered, the default), `"immediate"`, +/// or `"off"`. Read by the table crate to decide when a committed cell edit reaches disk. +pub const AUTOSAVE_KEY: &str = "autosave"; + // --- Settings Scope --- /// Which store a settings field reads and writes. The same fields render in both scopes; only @@ -88,7 +92,7 @@ fn set_scoped_bool(key: &'static str, val: bool, cx: &mut App) { } } -fn scoped_text(key: &str, cx: &App) -> SharedString { +pub fn scoped_text(key: &str, cx: &App) -> SharedString { match SettingsScope::current(cx) { SettingsScope::User => AppSettings::get(cx) .values @@ -102,7 +106,7 @@ fn scoped_text(key: &str, cx: &App) -> SharedString { } } -fn set_scoped_text(key: &'static str, val: SharedString, cx: &mut App) { +pub fn set_scoped_text(key: &'static str, val: SharedString, cx: &mut App) { let target = match SettingsScope::current(cx) { SettingsScope::Project if cx.has_global::() => { SettingsScope::Project @@ -131,6 +135,21 @@ pub fn effective_bool(key: &str, cx: &App) -> bool { .unwrap_or(false) } +/// Resolves a text setting for a *consumer*: the open project's value wins if present, else the +/// user-wide default, else empty. Text sibling of [`effective_bool`]. +pub fn effective_text(key: &str, cx: &App) -> SharedString { + if let Some(project) = cx.try_global::() + && let Some(v) = project.data.values.get(key) + { + return v.text(); + } + AppSettings::get(cx) + .values + .get(key) + .map(|v| v.text()) + .unwrap_or_default() +} + // --- Setting Field Enum --- pub enum Setting { @@ -437,6 +456,10 @@ pub struct SettingsWindow { pub build_pages: fn(&App) -> Vec, /// Persists the window's size (debounced) so it reopens where it was left. _bounds_sub: Subscription, + /// Re-render when any setting changes so scope-dependent pages (autosave's method row, the + /// columns filter picker) rebuild live instead of on the next unrelated repaint. + _settings_sub: Subscription, + _project_sub: Subscription, } impl SettingsWindow { @@ -451,9 +474,13 @@ impl SettingsWindow { AppSettings::set_text(SETTINGS_WINDOW_BOUNDS_KEY, json.into(), cx); } }); + let _settings_sub = cx.observe_global::(|_this, cx| cx.notify()); + let _project_sub = cx.observe_global::(|_this, cx| cx.notify()); Self { build_pages, _bounds_sub, + _settings_sub, + _project_sub, } } } diff --git a/crates/settings/src/project.rs b/crates/settings/src/project.rs index b9d9dba4..c83f6756 100644 --- a/crates/settings/src/project.rs +++ b/crates/settings/src/project.rs @@ -395,9 +395,15 @@ pub fn queue_write(file: &Path, key: &str, value: &str, cx: &gpui::App) { } } -/// Creates `dataset_main` with one TEXT column per header and bulk-inserts the -/// rows. Ragged rows (flexible CSV) are padded/truncated to the header count. -fn write_dataset(conn: &Connection, headers: &[String], rows: &[Vec]) -> Result<()> { +/// Creates `dataset_main` (one TEXT column per header) and bulk-inserts the rows. Ragged rows +/// (flexible CSV) are padded/truncated to the header count. Manages no transaction of its own — +/// the caller wraps it, so the create and the inserts commit together (and, on a re-save, +/// atomically with the preceding drop). +fn create_and_fill_dataset( + conn: &Connection, + headers: &[String], + rows: &[Vec], +) -> Result<()> { let idents = dataset_column_idents(headers); let cols_sql: Vec = idents.iter().map(|i| format!("{i} TEXT")).collect(); conn.execute_batch(&format!( @@ -412,22 +418,44 @@ fn write_dataset(conn: &Connection, headers: &[String], rows: &[Vec]) -> idents.join(", "), placeholders.join(", ") ); - conn.execute_batch("BEGIN")?; - { - let mut stmt = conn.prepare(&insert_sql).context("Prepare row insert")?; - let empty = String::new(); - for row in rows { - let padded: Vec<&String> = (0..idents.len()) - .map(|i| row.get(i).unwrap_or(&empty)) - .collect(); - stmt.execute(rusqlite::params_from_iter(padded)) - .context("Insert row")?; - } + let mut stmt = conn.prepare(&insert_sql).context("Prepare row insert")?; + let empty = String::new(); + for row in rows { + let padded: Vec<&String> = (0..idents.len()) + .map(|i| row.get(i).unwrap_or(&empty)) + .collect(); + stmt.execute(rusqlite::params_from_iter(padded)) + .context("Insert row")?; } + Ok(()) +} + +/// Creates and fills `dataset_main` in one transaction (the create-time path). +fn write_dataset(conn: &Connection, headers: &[String], rows: &[Vec]) -> Result<()> { + conn.execute_batch("BEGIN")?; + create_and_fill_dataset(conn, headers, rows)?; conn.execute_batch("COMMIT")?; Ok(()) } +/// Rewrites `dataset_main` from the in-memory rows — the whole table, in one transaction, so a +/// crash mid-save leaves the previous version intact (an uncommitted transaction rolls back when +/// the connection drops). `headers`/`rows` must be in the project's *original* column order: the +/// `.qrate` schema keeps a fixed physical column order, and the separately-saved column layout +/// maps that to display order. A blank project (no headers, no `dataset_main`) is a no-op. +pub fn save_dataset(path: &Path, headers: &[String], rows: &[Vec]) -> Result<()> { + if headers.is_empty() { + return Ok(()); + } + let conn = open_rw(path)?; + conn.execute_batch("BEGIN; DROP TABLE IF EXISTS dataset_main;") + .context("Begin dataset rewrite")?; + create_and_fill_dataset(&conn, headers, rows)?; + conn.execute_batch("COMMIT") + .context("Commit dataset rewrite")?; + Ok(()) +} + /// Quotes each header as a SQL identifier, de-duplicating case-insensitively /// (`Title`, `title` → `"Title"`, `"title_2"`) and naming blanks `column_N` — /// spreadsheet headers are user data and can collide or be empty. @@ -554,6 +582,28 @@ mod tests { assert!(!path.with_extension("qrate-wal").exists()); } + #[test] + fn save_dataset_rewrites_rows_and_round_trips() { + let path = tempfile("save.qrate"); + let headers = vec!["Digital ID".to_string(), "Title".to_string()]; + let rows = vec![vec!["1".to_string(), "First".to_string()]]; + create_project_file(&path, "S", "CSV", None, None, &[], &headers, &rows).unwrap(); + + // Edit a cell and add a row, then save the whole table back. + let edited = vec![ + vec!["1".to_string(), "Edited".to_string()], + vec!["2".to_string(), "Second".to_string()], + ]; + save_dataset(&path, &headers, &edited).unwrap(); + + let data = load_project_file(&path).unwrap(); + assert_eq!(data.headers, headers); + assert_eq!(data.rows, edited); + // DELETE mode's invariant survives the rewrite: only the `.qrate` file at rest. + assert!(!path.with_extension("qrate-journal").exists()); + assert!(!path.with_extension("qrate-wal").exists()); + } + #[test] fn load_project_file_caches_settings_values() { let path = tempfile("values.qrate"); diff --git a/crates/table/src/cell.rs b/crates/table/src/cell.rs index d666f585..55d5aede 100644 --- a/crates/table/src/cell.rs +++ b/crates/table/src/cell.rs @@ -3,10 +3,58 @@ //! (`cell_selectable`) — the library draws the active-cell highlight and emits the //! `TableEvent`s that `TablePanel` bridges (double-click → edit, cursor updates, etc.). -use gpui::{AnyElement, Context, IntoElement, ParentElement as _, Styled as _, Window, div}; -use gpui_component::{input::Input, table::TableState}; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Bounds, Context, CursorStyle, DragMoveEvent, + InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, Size, + StatefulInteractiveElement as _, Styled as _, Window, deferred, div, px, size, +}; +use gpui_component::{ActiveTheme as _, input::Input, table::TableState}; +use settings::AppSettings; -use crate::{delegate::QrateTableDelegate, editing::EditState}; +use crate::{ + EditorResizeAnchor, delegate::QrateTableDelegate, editing::EditState, floating::clamped_float, +}; + +/// User-scope ("global") keys for the persisted editor box size, in pixels. +const CELL_EDITOR_W_KEY: &str = "cell_editor_w"; +const CELL_EDITOR_H_KEY: &str = "cell_editor_h"; + +const MIN_W: f32 = 160.; +const MIN_H: f32 = 80.; + +/// The persisted editor size (user scope), clamped to sane bounds and never larger than the table. +/// Width defaults to ≈40% of the table so a small window opens a proportionally small box. +fn editor_size(cx: &App, table: Bounds) -> Size { + let (max_w, max_h) = (table.size.width, table.size.height); + let read = |k: &str| { + AppSettings::get(cx) + .values + .get(k) + .and_then(|v| v.text().parse::().ok()) + .map(px) + }; + let w = read(CELL_EDITOR_W_KEY) + .unwrap_or((max_w * 0.4).max(px(MIN_W))) + .clamp(px(MIN_W).min(max_w), max_w); + let h = read(CELL_EDITOR_H_KEY) + .unwrap_or(px(160.)) + .clamp(px(MIN_H).min(max_h), max_h); + size(w, h) +} + +/// Invisible drag preview for the resize handle — the box tracks the cursor via persisted size, so +/// the drag itself renders nothing. +struct ResizeGhost; +impl Render for ResizeGhost { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +/// Marker payload so `on_drag_move` only fires for the editor resize (matched by type). +#[derive(Clone)] +struct EditorResize; /// `row_ix` is a source (not view) row index — `render_td` maps through `visible_rows` first. /// `col_ix` is a data-column index, not shifted for the pinned row-index column. @@ -15,17 +63,92 @@ pub(crate) fn render_cell( row_ix: usize, col_ix: usize, _window: &mut Window, - _cx: &mut Context>, + cx: &mut Context>, ) -> AnyElement { - if delegate.editing + let editing = delegate.editing == (EditState::Editing { row: row_ix, col: col_ix, - }) - { - return Input::new(&delegate.editor).into_any_element(); - } + }); + // Keep the plain text underneath so the row height and neighbouring cells are unaffected; the + // editor floats over it. let text = delegate.cell(row_ix, col_ix).cloned().unwrap_or_default(); - div().size_full().child(text).into_any_element() + // The table's rect (measured in `panel.rs`) bounds the editor: it caps the box size and is the + // rect `clamped_float` keeps the box inside. + let table = cx + .try_global::() + .map(|b| b.0) + .unwrap_or_default(); + let box_size = editor_size(cx, table); + let (max_w, max_h) = (table.size.width, table.size.height); + div() + .size_full() + .child(text) + .when(editing, |cell| { + // `deferred` paints the box above the grid and lets it escape the cell's clip; + // `clamped_float` confines it to the *table* rect (not the window), so it can't spill + // over the details or any other side panel. Dismissed only by the user (Enter/blur + // commit, Escape, or a filter dropping the row) — never by scrolling. + cell.child(deferred(clamped_float( + table, + div() + // Swallow mouse events so clicking/selecting inside the editor doesn't fall + // through to the cells painted behind it (moving the table's selection). + .occlude() + .relative() + .w(box_size.width) + .h(box_size.height) + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .shadow_lg() + .child(Input::new(&delegate.editor).h_full()) + .child(resize_handle(cx, table, max_w, max_h)), + ))) + }) + .into_any_element() +} + +/// A corner grip the user drags to resize the editor. The chosen size persists in user-scope +/// settings, so every cell opens at the size last set. `AppSettings` debounces the disk write, so +/// updating it on every drag-move is cheap. +fn resize_handle( + cx: &mut Context>, + table: Bounds, + max_w: Pixels, + max_h: Pixels, +) -> impl IntoElement { + div() + .id("cell-editor-resize") + .absolute() + .bottom_0() + .right_0() + .size(px(14.)) + .cursor(CursorStyle::ResizeUpLeftDownRight) + // `window.mouse_position()` (absolute) — the `Point` gpui passes here is element-relative, + // which wouldn't match `on_drag_move`'s absolute positions and would spike the delta. + .on_drag(EditorResize, move |_, _, window, cx| { + cx.set_global(EditorResizeAnchor { + mouse: window.mouse_position(), + size: editor_size(cx, table), + }); + cx.new(|_| ResizeGhost) + }) + .on_drag_move( + cx.listener(move |_, ev: &DragMoveEvent, _, cx| { + let Some((mouse, start)) = cx + .try_global::() + .map(|a| (a.mouse, a.size)) + else { + return; + }; + let delta = ev.event.position - mouse; + let w = (start.width + delta.x).clamp(px(MIN_W).min(max_w), max_w); + let h = (start.height + delta.y).clamp(px(MIN_H).min(max_h), max_h); + AppSettings::set_text(CELL_EDITOR_W_KEY, format!("{}", f32::from(w)).into(), cx); + AppSettings::set_text(CELL_EDITOR_H_KEY, format!("{}", f32::from(h)).into(), cx); + }), + ) } diff --git a/crates/table/src/delegate.rs b/crates/table/src/delegate.rs index 41d1c07e..6525db14 100644 --- a/crates/table/src/delegate.rs +++ b/crates/table/src/delegate.rs @@ -252,6 +252,19 @@ impl QrateTableDelegate { self.selection } + /// The `(source_row, data_col)` whose row-number and column header should be highlighted + /// (Google-Sheets style): the cell being edited, else the selected cell. `None` when nothing + /// cell-specific is active (a whole-row/column selection, or none). + pub(crate) fn active_cell(&self) -> Option<(usize, usize)> { + if let EditState::Editing { row, col } = self.editing { + return Some((row, col)); + } + match self.selection { + Some(Selection::Cell { row, col }) => Some((row, col)), + _ => None, + } + } + /// The row's cells as `(column header, cell text)` pairs, in display order — what the /// Details panel shows. pub fn row_fields(&self, row: usize) -> Vec<(SharedString, SharedString)> { @@ -267,6 +280,31 @@ impl QrateTableDelegate { .collect() } + /// Headers + rows in the project's *original* column order, recovered from each column's + /// stable `c{ix}` key rather than the current display order. `dataset_main` keeps a fixed + /// physical column order (the separately-saved [`ColumnLayout`] maps it to display order), so + /// a save after a column move must not reshuffle the stored columns. Cells are stringified for + /// the `.qrate` writer. + pub(crate) fn dataset_snapshot(&self) -> (Vec, Vec>) { + let mut order: Vec = (0..self.columns.len()).collect(); + order.sort_by_key(|&i| orig_col_ix(&self.columns[i])); + let headers = order + .iter() + .map(|&i| self.columns[i].name.to_string()) + .collect(); + let rows = self + .rows + .iter() + .map(|row| { + order + .iter() + .map(|&i| row.get(i).map(|c| c.to_string()).unwrap_or_default()) + .collect() + }) + .collect(); + (headers, rows) + } + /// Current column layout (keys + widths in display order) for persistence. pub(crate) fn column_layout(&self) -> ColumnLayout { ColumnLayout { @@ -324,6 +362,16 @@ impl QrateTableDelegate { } } +/// The original column index encoded in a `c{ix}` key (minted in [`QrateTableDelegate::set_data`]). +/// An unexpected key sorts last (`usize::MAX`) rather than colliding at 0. +fn orig_col_ix(col: &Column) -> usize { + col.key + .as_ref() + .strip_prefix('c') + .and_then(|n| n.parse().ok()) + .unwrap_or(usize::MAX) +} + /// Move every row's cell at `from` to position `to`, mirroring a column move so cell lookups /// stay positional (display order == storage order). fn move_col(rows: &mut [Vec], from: usize, to: usize) { @@ -460,8 +508,10 @@ impl TableDelegate for QrateTableDelegate { return div().into_any_element(); }; if col_ix == row_index::COL_IX { - // The source row number — the row's stable identity, not its view position. - row_index::render_td(source, cx) + // The source row number — the row's stable identity, not its view position. Highlight + // it while its row holds the active (edited/selected) cell. + let highlighted = self.active_cell().is_some_and(|(r, _)| r == source); + row_index::render_td(source, highlighted, cx) } else { cell::render_cell(self, source, col_ix - 1, window, cx) } @@ -524,6 +574,20 @@ mod tests { .collect() } + #[test] + fn snapshot_order_follows_ckey_not_display_order() { + // A display where columns sit out of their original `c{ix}` order (as after moves). + let cols = [ + Column::new("c2", "C"), + Column::new("c0", "A"), + Column::new("c1", "B"), + ]; + let mut order: Vec = (0..cols.len()).collect(); + order.sort_by_key(|&i| orig_col_ix(&cols[i])); + let names: Vec<&str> = order.iter().map(|&i| cols[i].name.as_ref()).collect(); + assert_eq!(names, vec!["A", "B", "C"]); + } + #[test] fn move_col_shifts_every_row() { let mut g = rows(&[&["a", "b", "c"], &["d", "e", "f"]]); diff --git a/crates/table/src/filter.rs b/crates/table/src/filter.rs index ddf28854..5ccc6f36 100644 --- a/crates/table/src/filter.rs +++ b/crates/table/src/filter.rs @@ -44,12 +44,16 @@ pub(crate) fn render_th( ) -> AnyElement { let name = delegate.column_name(data_col); let active = delegate.column_has_filter(data_col); + // Highlight this header while its column holds the active (edited/selected) cell. The header + // row is sticky, so the highlight stays put as the grid scrolls under it. + let editing_col = delegate.active_cell().is_some_and(|(_, c)| c == data_col); h_flex() .size_full() .justify_between() .items_center() .gap_1() + .when(editing_col, |th| th.bg(_cx.theme().secondary_hover)) .child(div().flex_1().truncate().child(name)) .when(delegate.column_filter_enabled(data_col), |th| { th.child( diff --git a/crates/table/src/floating.rs b/crates/table/src/floating.rs new file mode 100644 index 00000000..8cdf4b06 --- /dev/null +++ b/crates/table/src/floating.rs @@ -0,0 +1,101 @@ +//! `clamped_float`: like `anchored().position_mode(Local).snap_to_window()`, but confined to a +//! caller-supplied rect (the table area) instead of the whole window. gpui's `anchored` only ever +//! clamps to the viewport, so a `deferred` floating editor spills over a side panel; this keeps the +//! floating cell editor inside the table. Wrap it in `deferred` to escape the cell's clip, exactly +//! as `anchored` is used. + +use std::panic::Location; + +use gpui::{ + App, Bounds, Display, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + LayoutId, Pixels, Position, Style, Window, +}; + +pub(crate) struct ClampedFloat { + rect: Bounds, + child: gpui::AnyElement, +} + +/// Float `child` at its natural (local) position, shifted the minimum needed to stay inside `rect`. +pub(crate) fn clamped_float(rect: Bounds, child: impl IntoElement) -> ClampedFloat { + ClampedFloat { + rect, + child: child.into_any_element(), + } +} + +impl IntoElement for ClampedFloat { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ClampedFloat { + type RequestLayoutState = LayoutId; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let child_id = self.child.request_layout(window, cx); + let style = Style { + position: Position::Absolute, + display: Display::Flex, + ..Style::default() + }; + let layout_id = window.request_layout(style, [child_id], cx); + (layout_id, child_id) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + child_id: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) { + // `bounds.origin` is where the box lands naturally — the edited cell's top-left. Shift it the + // least amount that keeps the box (its real, just-laid-out size) inside the table rect. + let size = window.layout_bounds(*child_id).size; + let mut origin = bounds.origin; + origin.x = origin + .x + .min(self.rect.right() - size.width) + .max(self.rect.left()); + origin.y = origin + .y + .min(self.rect.bottom() - size.height) + .max(self.rect.top()); + + let offset = origin - bounds.origin; + window.with_element_offset(offset, |window| self.child.prepaint(window, cx)); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.child.paint(window, cx); + } +} diff --git a/crates/table/src/lib.rs b/crates/table/src/lib.rs index 1e0b4a9e..851e9601 100644 --- a/crates/table/src/lib.rs +++ b/crates/table/src/lib.rs @@ -8,6 +8,7 @@ mod cell; mod delegate; mod editing; mod filter; +mod floating; mod panel; pub mod photos; mod row_index; @@ -18,10 +19,56 @@ pub use panel::{Search, TablePanel}; /// Settings key (in either scope) for the alternating-row-stripe toggle. pub const TABLE_STRIPES_KEY: &str = "table_stripes"; -use gpui::{Global, WeakEntity}; +use gpui::{App, Bounds, Global, Pixels, Point, Size, WeakEntity, px, size}; use gpui_component::table::TableState; /// Global handle to the live table state, so cross-crate status-bar items (the fake-data button /// and the selected-cell widget in the `app` crate) can reach the table. pub struct TableStateHandle(pub WeakEntity>); impl Global for TableStateHandle {} + +/// The table area's window-space rectangle, measured each frame (see `panel.rs`). The floating +/// cell editor caps its wrap width to this and clamps its position to it, so a long value wraps +/// within the panel instead of scrolling sideways and the box never spills over a side panel. +pub(crate) struct TableViewportBounds(pub Bounds); +impl Global for TableViewportBounds {} + +impl Default for TableViewportBounds { + /// A rect large enough to be a no-op clamp until the first real measurement lands (editing + /// can't start before the panel has rendered once anyway). + fn default() -> Self { + Self(Bounds::new(Default::default(), size(px(4000.), px(4000.)))) + } +} + +/// Captured when an editor resize-drag begins: the mouse position and box size at that instant, so +/// each drag-move applies the delta from the start. Transient — only present while dragging. +pub(crate) struct EditorResizeAnchor { + pub mouse: Point, + pub size: Size, +} +impl Global for EditorResizeAnchor {} + +/// Persist the open project's table data to its `.qrate` file, synchronously, and clear the +/// `PROJECT_DATA` dirty mark. No-op with no project open, no live table, or a blank project. Runs +/// on the calling thread — qrate's grids are small enough that a full rewrite stays well under a +/// frame; move it onto the background executor if large projects ever stutter here. +pub fn save_now(cx: &mut App) { + let Some(file) = cx + .try_global::() + .map(|p| p.file.clone()) + else { + return; + }; + let Some(state) = cx + .try_global::() + .and_then(|h| h.0.upgrade()) + else { + return; + }; + let (headers, rows) = state.read(cx).delegate().dataset_snapshot(); + match settings::project::save_dataset(&file, &headers, &rows) { + Ok(()) => settings::dirty::clear(settings::dirty::PROJECT_DATA, cx), + Err(err) => eprintln!("failed to save project data: {err}"), + } +} diff --git a/crates/table/src/panel.rs b/crates/table/src/panel.rs index b787664e..35d85fda 100644 --- a/crates/table/src/panel.rs +++ b/crates/table/src/panel.rs @@ -67,11 +67,20 @@ pub struct TablePanel { /// as its search box is typed into. _search_sub: Subscription, _filter_search_sub: Subscription, + /// Pending debounced autosave (the "timed" mode). Replacing it drops the prior task, which + /// cancels its timer — that drop *is* the debounce, coalescing a burst of edits into one write. + _autosave_task: Option>, } impl TablePanel { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let editor = cx.new(|cx| InputState::new(window, cx)); + // Multi-line so long values wrap; the editor fills the user-resizable box (`cell.rs`) and + // scrolls inside it. `submit_on_enter` keeps Enter as commit (Shift+Enter inserts a newline). + let editor = cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .submit_on_enter(true) + }); let filter_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search values")); let search_input = cx.new(|cx| InputState::new(window, cx).placeholder("Find in table")); let mut delegate = QrateTableDelegate::new(editor.clone(), filter_search.clone()); @@ -85,8 +94,9 @@ impl TablePanel { loaded_project = Some(project.file.clone()); } let column_settings = settings::columns::load(cx); + let filters_on = settings::columns::filters_master_enabled(cx); delegate.apply_column_settings(|key| { - column_settings.get(key).is_some_and(|s| s.filter_enabled) + filters_on && column_settings.get(key).is_some_and(|s| s.filter_enabled) }); let state = cx.new(|cx| { TableState::new(delegate, window, cx) @@ -103,7 +113,7 @@ impl TablePanel { cx.set_global(TableStateHandle(state.downgrade())); let table_state = state.clone(); - let _edit_sub = cx.subscribe(&editor, move |_this, _editor, event: &InputEvent, cx| { + let _edit_sub = cx.subscribe(&editor, move |this, _editor, event: &InputEvent, cx| { if !matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { return; } @@ -112,6 +122,8 @@ impl TablePanel { cx.emit(TableChanged); cx.notify(); }); + // The commit marked PROJECT_DATA dirty; persist per the Autosave setting. + this.schedule_autosave(cx); }); let _table_sub = cx.subscribe_in( @@ -182,9 +194,10 @@ impl TablePanel { // A project-scoped setting write: re-apply per-column settings only, don't reload (loses state). if this.loaded_project.as_ref() == Some(&file) { let column_settings = settings::columns::load(cx); + let filters_on = settings::columns::filters_master_enabled(cx); this.state.update(cx, |state, cx| { state.delegate_mut().apply_column_settings(|key| { - column_settings.get(key).is_some_and(|s| s.filter_enabled) + filters_on && column_settings.get(key).is_some_and(|s| s.filter_enabled) }); state.refresh(cx); cx.emit(TableChanged); @@ -198,12 +211,13 @@ impl TablePanel { let image_paths = Self::resolve_images(&project.data); this.loaded_project = Some(file.clone()); let column_settings = settings::columns::load(cx); + let filters_on = settings::columns::filters_master_enabled(cx); this.state.update(cx, |state, cx| { state.delegate_mut().set_data(&headers, &rows); Self::apply_saved_layout(state.delegate_mut(), &file); state.delegate_mut().set_image_paths(image_paths); state.delegate_mut().apply_column_settings(|key| { - column_settings.get(key).is_some_and(|s| s.filter_enabled) + filters_on && column_settings.get(key).is_some_and(|s| s.filter_enabled) }); state.refresh(cx); cx.emit(TableChanged); @@ -256,6 +270,25 @@ impl TablePanel { search_error: false, _search_sub, _filter_search_sub, + _autosave_task: None, + } + } + + /// React to a committed cell edit per the Autosave setting: write immediately, buffer behind a + /// short debounce (the default), or leave it for Ctrl+S / quit. The default and any unset/ + /// unrecognized value both mean "timed". + fn schedule_autosave(&mut self, cx: &mut Context) { + match settings::effective_text(settings::AUTOSAVE_KEY, cx).as_ref() { + "off" => {} + "immediate" => crate::save_now(cx), + _ => { + self._autosave_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(800)) + .await; + this.update(cx, |_this, cx| crate::save_now(cx)).ok(); + })); + } } } @@ -554,6 +587,16 @@ impl Render for TablePanel { div() .flex_1() .min_h_0() + // Record the table area's rect so the floating cell editor can wrap to it and + // stay clamped inside it (never over a side panel). + .child( + canvas( + |bounds, _, cx| cx.set_global(crate::TableViewportBounds(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) .child(DataTable::new(&self.state).bordered(false).stripe(stripe)), ) } diff --git a/crates/table/src/row_index.rs b/crates/table/src/row_index.rs index bc8c6a5d..082b3ec9 100644 --- a/crates/table/src/row_index.rs +++ b/crates/table/src/row_index.rs @@ -4,7 +4,8 @@ use std::sync::OnceLock; -use gpui::{Context, IntoElement, SharedString, div, prelude::*, px}; +use gpui::prelude::FluentBuilder as _; +use gpui::{Context, FontWeight, IntoElement, SharedString, div, prelude::*, px}; use gpui_component::{ ActiveTheme, table::{Column, TableState}, @@ -33,14 +34,25 @@ pub(crate) fn column() -> Column { pub(crate) fn render_td( row_ix: usize, + highlighted: bool, cx: &mut Context>, ) -> gpui::AnyElement { + // Active row's number reads in full-strength text on a filled cell (like a sheet's row + // header); other rows stay muted. The `#` column is `fixed_left`, so this stays visible and + // highlighted while scrolling horizontally. + let (fg, bg) = if highlighted { + (cx.theme().foreground, cx.theme().secondary_hover) + } else { + (cx.theme().muted_foreground, cx.theme().transparent) + }; div() .size_full() .flex() .items_center() .justify_center() - .text_color(cx.theme().muted_foreground) + .bg(bg) + .text_color(fg) + .when(highlighted, |d| d.font_weight(FontWeight::SEMIBOLD)) .child(SharedString::from((row_ix + 1).to_string())) .into_any_element() } diff --git a/crates/window-wrapper/src/title_bar.rs b/crates/window-wrapper/src/title_bar.rs index 2544ad14..031f8005 100644 --- a/crates/window-wrapper/src/title_bar.rs +++ b/crates/window-wrapper/src/title_bar.rs @@ -1,7 +1,7 @@ use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::{ - Sizable, TitleBar, + ActiveTheme, Sizable, TitleBar, button::{Button, ButtonVariants}, menu::{DropdownMenu, PopupMenu}, }; @@ -109,14 +109,23 @@ impl Render for TitleMenus { pub struct AppTitleBar { /// Centered title text (the open project's name); empty renders nothing. title: SharedString, + /// Whether there is unsaved work — draws a dot before the title. + dirty: bool, } impl AppTitleBar { pub fn new(title: impl Into) -> Self { Self { title: title.into(), + dirty: false, } } + + /// Show the unsaved-changes dot (fed from `settings::dirty::Dirty::any`). + pub fn dirty(mut self, dirty: bool) -> Self { + self.dirty = dirty; + self + } } impl RenderOnce for AppTitleBar { @@ -139,6 +148,12 @@ impl RenderOnce for AppTitleBar { .child( gpui_component::h_flex() .justify_center() + .items_center() + .gap_1p5() + // Unsaved-changes dot, left of the project name (editor convention). + .when(self.dirty, |this| { + this.child(div().size(px(6.)).rounded_full().bg(cx.theme().foreground)) + }) .when(!self.title.is_empty(), |this| { this.child(self.title.clone()) }), diff --git a/crates/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs index 684545bb..3f127fd0 100644 --- a/crates/workspace/src/panels/details.rs +++ b/crates/workspace/src/panels/details.rs @@ -184,13 +184,23 @@ fn render_image_frame(image_path: Option, cx: &App) -> AnyElement { Some(path) => frame .map(|frame| { if show_image { - // `rounded` on the `img` itself: gpui's overflow mask is a rect, so `Img` must round itself. + // gpui's `img` stamps the element with the *image's* aspect ratio, so a + // `size_full` img ignores the frame shape and `object_fit` has nothing to + // letterbox. Size it by its intrinsic ratio under `max_w/h_full` (where that + // aspect logic applies) and center it, so it shrinks to fit — bars and all. frame.child( - img(path.clone()) + div() .size_full() - .rounded(cx.theme().radius) - .object_fit(ObjectFit::Contain) - .with_fallback(placeholder), + .flex() + .items_center() + .justify_center() + .child( + img(path.clone()) + .max_w_full() + .max_h_full() + .object_fit(ObjectFit::Contain) + .with_fallback(placeholder), + ), ) } else { frame.child(placeholder())