From 8799b37fee96095e22ba338adc91d8a42886e359 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:48:44 +0530 Subject: [PATCH 1/7] feat(project): persist cell edits to .qrate with autosave and dirty indicator Cell edits were held in memory only; nothing wrote them back. Add a whole-table rewrite of dataset_main (in the project's original column order, so the saved column layout stays in sync) triggered by: - a new Autosave setting: timed/buffered (default), immediate, or off - Ctrl+S (global action), and a quit-time flush of pending edits Surface unsaved state as a dot before the project name in the title bar, fed by the dirty tracker via a global observer. --- crates/app/src/actions.rs | 5 ++ crates/app/src/app_settings/mod.rs | 34 ++++++++--- crates/app/src/main.rs | 23 ++++++-- crates/settings/src/lib.rs | 19 +++++++ crates/settings/src/project.rs | 78 +++++++++++++++++++++----- crates/table/src/delegate.rs | 49 ++++++++++++++++ crates/table/src/lib.rs | 26 ++++++++- crates/table/src/panel.rs | 26 ++++++++- crates/window-wrapper/src/title_bar.rs | 17 +++++- 9 files changed, 246 insertions(+), 31 deletions(-) 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_settings/mod.rs b/crates/app/src/app_settings/mod.rs index f118ada2..c6678b8a 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -15,16 +15,32 @@ 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( + SettingGroup::new().title("Saving").item( + Setting::Dropdown { + key: settings::AUTOSAVE_KEY, + label: "Autosave", + description: "When cell edits reach the project file. Ctrl+S always saves.", + options: &[ + ("timed", "After a short pause"), + ("immediate", "On every edit"), + ("off", "Manual only (Ctrl+S)"), + ], + } + .into(), + ), ), - ), columns_page(cx), ] } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 67300cf6..83d8b6c5 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 { @@ -190,10 +193,13 @@ impl App { 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 +235,14 @@ 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(), + ) + .dirty(settings::dirty::Dirty::any(cx)), + ) .child( div() .id("window-body") @@ -267,6 +276,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/lib.rs b/crates/settings/src/lib.rs index 7acd33d3..228b5913 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 @@ -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 { 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/delegate.rs b/crates/table/src/delegate.rs index 41d1c07e..22dd4a28 100644 --- a/crates/table/src/delegate.rs +++ b/crates/table/src/delegate.rs @@ -267,6 +267,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 +349,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) { @@ -524,6 +559,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/lib.rs b/crates/table/src/lib.rs index 1e0b4a9e..187575b3 100644 --- a/crates/table/src/lib.rs +++ b/crates/table/src/lib.rs @@ -18,10 +18,34 @@ 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, Global, WeakEntity}; 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 {} + +/// 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..d7b25f63 100644 --- a/crates/table/src/panel.rs +++ b/crates/table/src/panel.rs @@ -67,6 +67,9 @@ 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 { @@ -103,7 +106,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 +115,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( @@ -256,6 +261,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(); + })); + } } } 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()) }), From 5ac7b424e512999801a629677eb3729b4465660d Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:56:20 +0530 Subject: [PATCH 2/7] feat(table): floating cell editor with sticky highlighted row/column headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline cell editor with a floating one: `deferred` + `anchored` (Local mode) paint the editor above the grid so it overflows the cramped cell, and `snap_to_window` keeps it fully on-screen near any edge. The plain text stays underneath so row height and neighbours are unaffected. While a cell is active (being edited or selected), highlight its row number and column header (Google-Sheets style). Both are already sticky — the `#` column is fixed_left and the header row pins to the top — so the highlight stays put as the grid scrolls under it. --- crates/table/src/cell.rs | 46 ++++++++++++++++++++++++++++------- crates/table/src/delegate.rs | 19 +++++++++++++-- crates/table/src/filter.rs | 4 +++ crates/table/src/row_index.rs | 16 ++++++++++-- 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/table/src/cell.rs b/crates/table/src/cell.rs index d666f585..2dbfd7f0 100644 --- a/crates/table/src/cell.rs +++ b/crates/table/src/cell.rs @@ -3,8 +3,12 @@ //! (`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::{ + AnchoredPositionMode, AnyElement, Context, IntoElement, ParentElement as _, Styled as _, + Window, anchored, deferred, div, px, +}; +use gpui_component::{ActiveTheme as _, input::Input, table::TableState}; use crate::{delegate::QrateTableDelegate, editing::EditState}; @@ -15,17 +19,41 @@ 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() + div() + .size_full() + .child(text) + .when(editing, |cell| { + // A floating editor over the cell: `deferred` + `anchored` (Local) paint it above the + // grid and let it overflow the cramped cell, while `snap_to_window` keeps it fully + // on-screen whichever edge the cell sits near. Dismissed only by the user (Enter/blur + // commit, Escape, or a filter dropping the row) — never by scrolling. + cell.child(deferred( + anchored() + .position_mode(AnchoredPositionMode::Local) + .snap_to_window() + .child( + div() + .min_w(px(240.)) + .max_w(px(520.)) + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .shadow_lg() + .child(Input::new(&delegate.editor)), + ), + )) + }) + .into_any_element() } diff --git a/crates/table/src/delegate.rs b/crates/table/src/delegate.rs index 22dd4a28..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)> { @@ -495,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) } 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/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() } From cc9a9bd7cf7f5c87821d04004560e54ffd324685 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:51:29 +0530 Subject: [PATCH 3/7] feat(table): make the cell editor multi-line, panel-bound, and click-safe Long values now wrap and grow the box downward (`auto_grow`, Enter commits / Shift+Enter newlines) instead of scrolling sideways, capped to the table's measured width. A new `clamped_float` element floats the editor like `anchored(Local)` but confines it to the table rect (gpui only clamps to the window), so it can't spill over a side panel, and `.occlude()` stops clicks falling through to the grid behind it. --- crates/table/src/cell.rs | 53 ++++++++++-------- crates/table/src/floating.rs | 101 +++++++++++++++++++++++++++++++++++ crates/table/src/lib.rs | 17 +++++- crates/table/src/panel.rs | 18 ++++++- 4 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 crates/table/src/floating.rs diff --git a/crates/table/src/cell.rs b/crates/table/src/cell.rs index 2dbfd7f0..aa6472ba 100644 --- a/crates/table/src/cell.rs +++ b/crates/table/src/cell.rs @@ -5,12 +5,12 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - AnchoredPositionMode, AnyElement, Context, IntoElement, ParentElement as _, Styled as _, - Window, anchored, deferred, div, px, + AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, + Window, deferred, div, px, }; use gpui_component::{ActiveTheme as _, input::Input, table::TableState}; -use crate::{delegate::QrateTableDelegate, editing::EditState}; +use crate::{delegate::QrateTableDelegate, editing::EditState, floating::clamped_float}; /// `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. @@ -30,30 +30,39 @@ pub(crate) fn render_cell( // 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(); + // The table's rect (measured in `panel.rs`) both caps the editor's wrap width — so long text + // wraps multi-line instead of scrolling sideways (height is `auto_grow`'s job) — and is the + // rect `clamped_float` keeps the box inside. + let table = cx + .try_global::() + .map(|b| b.0) + .unwrap_or_default(); + let max_w = table.size.width; div() .size_full() .child(text) .when(editing, |cell| { - // A floating editor over the cell: `deferred` + `anchored` (Local) paint it above the - // grid and let it overflow the cramped cell, while `snap_to_window` keeps it fully - // on-screen whichever edge the cell sits near. Dismissed only by the user (Enter/blur + // `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( - anchored() - .position_mode(AnchoredPositionMode::Local) - .snap_to_window() - .child( - div() - .min_w(px(240.)) - .max_w(px(520.)) - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .shadow_lg() - .child(Input::new(&delegate.editor)), - ), - )) + 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() + // Triple the old 240px minimum so the box opens comfortably wide, but never + // past the panel width (`clamped_float` then shifts it left to fit at the edge). + .min_w(max_w.min(px(720.))) + .max_w(max_w) + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .shadow_lg() + .child(Input::new(&delegate.editor)), + ))) }) .into_any_element() } 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 187575b3..44cc93ec 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,7 +19,7 @@ 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::{App, Global, WeakEntity}; +use gpui::{App, Bounds, Global, Pixels, 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 @@ -26,6 +27,20 @@ use gpui_component::table::TableState; 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.)))) + } +} + /// 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 diff --git a/crates/table/src/panel.rs b/crates/table/src/panel.rs index d7b25f63..71366afc 100644 --- a/crates/table/src/panel.rs +++ b/crates/table/src/panel.rs @@ -74,7 +74,13 @@ pub struct TablePanel { 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 and the box grows down instead of scrolling sideways; + // `submit_on_enter` keeps Enter as commit (Shift+Enter inserts a newline). + let editor = cx.new(|cx| { + InputState::new(window, cx) + .auto_grow(1, 8) + .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()); @@ -578,6 +584,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)), ) } From 25cd479abaa43a748c86851741d5f078156ed3c6 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:03:16 +0530 Subject: [PATCH 4/7] fix(details): letterbox the preview image and drop its rounded corners gpui's `img` stamps the element with the image's own aspect ratio, so a `size_full` img ignored the frame shape and `object_fit(Contain)` had nothing to letterbox. Size it by its intrinsic ratio under `max_w/h_full` inside a centered wrapper so it fits with bars, and render square corners. --- crates/workspace/src/panels/details.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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()) From 3d786a62347db9e8c55e12146838075b239ece58 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:51:26 +0530 Subject: [PATCH 5/7] feat(table): user-resizable floating cell editor Drag the bottom-right grip to resize the floating editor; the chosen width/height persist in user-scope settings and are clamped to sane minimums and the table bounds. The drag captures the start mouse position and box size so each move applies the delta from the start. --- crates/table/src/cell.rs | 108 +++++++++++++++++++++++++++++++++++---- crates/table/src/lib.rs | 10 +++- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/crates/table/src/cell.rs b/crates/table/src/cell.rs index aa6472ba..55d5aede 100644 --- a/crates/table/src/cell.rs +++ b/crates/table/src/cell.rs @@ -5,12 +5,56 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, - Window, deferred, div, px, + 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, floating::clamped_float}; +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. @@ -30,14 +74,14 @@ pub(crate) fn render_cell( // 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(); - // The table's rect (measured in `panel.rs`) both caps the editor's wrap width — so long text - // wraps multi-line instead of scrolling sideways (height is `auto_grow`'s job) — and is the + // 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 max_w = table.size.width; + let box_size = editor_size(cx, table); + let (max_w, max_h) = (table.size.width, table.size.height); div() .size_full() .child(text) @@ -52,17 +96,59 @@ pub(crate) fn render_cell( // 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() - // Triple the old 240px minimum so the box opens comfortably wide, but never - // past the panel width (`clamped_float` then shifts it left to fit at the edge). - .min_w(max_w.min(px(720.))) - .max_w(max_w) + .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)), + .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/lib.rs b/crates/table/src/lib.rs index 44cc93ec..851e9601 100644 --- a/crates/table/src/lib.rs +++ b/crates/table/src/lib.rs @@ -19,7 +19,7 @@ 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::{App, Bounds, Global, Pixels, WeakEntity, px, size}; +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 @@ -41,6 +41,14 @@ impl Default for TableViewportBounds { } } +/// 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 From 2e6e7395e496c7f74699ab055c39304bd76d006f Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:51:57 +0530 Subject: [PATCH 6/7] feat(settings): autosave toggle and column-filter revamp Autosave becomes a switch plus a method dropdown (shown only while on), both editing the one AUTOSAVE_KEY the table reads. Column filters become a master switch gating a multi-select picker whose selection is the set of filter-enabled columns; the picker mirrors gpui-component's menu-item styling and its trigger shows "First +N others". The Settings window now observes the settings globals so these scope-dependent rows rebuild live. The table gates every filter dropdown behind the master switch. --- crates/app/src/app_settings/mod.rs | 239 +++++++++++++++++------------ crates/settings/src/columns.rs | 52 +++---- crates/settings/src/lib.rs | 12 +- crates/table/src/panel.rs | 15 +- 4 files changed, 180 insertions(+), 138 deletions(-) diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index c6678b8a..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}, @@ -26,37 +26,66 @@ pub fn build_pages(cx: &App) -> Vec { .into(), ), ) - .group( - SettingGroup::new().title("Saving").item( - Setting::Dropdown { - key: settings::AUTOSAVE_KEY, - label: "Autosave", - description: "When cell edits reach the project file. Ctrl+S always saves.", - options: &[ - ("timed", "After a short pause"), - ("immediate", "On every edit"), - ("off", "Manual only (Ctrl+S)"), - ], - } - .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."), ); }; @@ -70,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/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 228b5913..3af5ab1f 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -92,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 @@ -106,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 @@ -456,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 { @@ -470,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/table/src/panel.rs b/crates/table/src/panel.rs index 71366afc..35d85fda 100644 --- a/crates/table/src/panel.rs +++ b/crates/table/src/panel.rs @@ -74,11 +74,11 @@ pub struct TablePanel { impl TablePanel { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - // Multi-line so long values wrap and the box grows down instead of scrolling sideways; - // `submit_on_enter` keeps Enter as commit (Shift+Enter inserts a newline). + // 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) - .auto_grow(1, 8) + .multi_line(true) .submit_on_enter(true) }); let filter_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search values")); @@ -94,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) @@ -193,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); @@ -209,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); From 14c720da94f4314f0b22dc59fd15342d12407af9 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:52:05 +0530 Subject: [PATCH 7/7] feat(app): Save menu item, unsaved-dot fix, and save-on-close prompt Add File > Save (wired to the existing Save action / Ctrl+S). The title-bar unsaved dot now tracks only PROJECT_DATA instead of any dirty domain, so auto-persisted column layout/settings no longer light it forever. Closing the window with unsaved cell edits prompts Save / Don't Save / Cancel; Don't Save clears the mark so the quit flush discards it. --- crates/app/src/app_menus.rs | 4 +++- crates/app/src/main.rs | 38 +++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) 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/main.rs b/crates/app/src/main.rs index 83d8b6c5..984b5a5c 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -183,10 +183,38 @@ 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 }); @@ -241,7 +269,13 @@ impl Render for App { .map(|p| p.display_name()) .unwrap_or_default(), ) - .dirty(settings::dirty::Dirty::any(cx)), + // 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()