Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rowdy"
version = "0.16.1"
version = "0.16.2"
edition = "2024"
rust-version = "1.86"
license = "MIT"
Expand Down
17 changes: 15 additions & 2 deletions src/action/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1721,6 +1721,7 @@ fn apply_update_dismiss(app: &mut App) {
fn on_cache_stage(app: &mut App, stage: crate::worker::CacheStage) {
use crate::worker::CacheStage;
if matches!(stage, CacheStage::Reloaded) {
app.schema_reload_in_flight = false;
app.status = QueryStatus::Notice {
msg: "schema cache reloaded".into(),
};
Expand Down Expand Up @@ -1751,6 +1752,10 @@ fn on_connected(app: &mut App, name: String) {
app.overlay = None;
app.screen = Screen::Normal;
app.status = QueryStatus::Idle;
// The previous connection's reload (if any) won't deliver
// `Reloaded` against this new pool — drop the flag so the first
// DDL here can reprime.
app.schema_reload_in_flight = false;
// Fresh tree — drop any nodes left over from the previous connection
// and re-fire the catalog load.
app.schema = SchemaPanel::new(app.schema.width);
Expand Down Expand Up @@ -1936,7 +1941,15 @@ fn on_query_done(app: &mut App, req: crate::worker::RequestId, result: QueryResu
if crate::autocomplete::ddl::affects_schema_cache(&in_flight.sql)
&& let Some(name) = app.active_connection.clone()
{
let _ = app.cmd_tx.send(WorkerCommand::Reload { connection: name });
// Coalesce back-to-back DDLs: if a prior reload hasn't reported
// `CacheStage::Reloaded` yet, skip this one. The in-flight
// reload's final stage already covers the new schema state, so
// queueing a second pass just doubles the introspection cost on
// large catalogs (a real freeze symptom we've hit in practice).
if !app.schema_reload_in_flight {
app.schema_reload_in_flight = true;
let _ = app.cmd_tx.send(WorkerCommand::Reload { connection: name });
}
}

let took = result.elapsed;
Expand Down Expand Up @@ -2098,7 +2111,7 @@ fn result_yank(app: &mut App) {
.rows()
.get(cur.row)
.and_then(|row| row.get(cur.col))
.map(|cell| cell.display())
.map(|cell| cell.display().into_owned())
.unwrap_or_default();
clipboard::write(&app.log, &text);
app.status = QueryStatus::Notice {
Expand Down
7 changes: 7 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ pub struct App {
/// every popover open. `Arc<RwLock<…>>` so the worker and the main
/// loop can both hold handles without cloning the contents.
pub schema_cache: Arc<RwLock<SchemaCache>>,
/// Set while a `WorkerCommand::Reload` is in flight (sent but the
/// terminal `CacheStage::Reloaded` event hasn't arrived yet). Used
/// to dedupe back-to-back DDLs that would otherwise queue up
/// redundant full-schema reintrospections. Cleared when `Reloaded`
/// lands or when a fresh `Connect` resets the worker's view.
pub schema_reload_in_flight: bool,
/// Active autocomplete popover, if any. `Some` flips the keymap into
/// "intercept popover keys before edtui" mode (see
/// `event::translate_normal_key`).
Expand Down Expand Up @@ -271,6 +277,7 @@ impl App {
editor_dirty: false,
pending_save_at: None,
schema_cache,
schema_reload_in_flight: false,
completion: None,
completion_snoozed_at: None,
layout: LayoutCache::default(),
Expand Down
8 changes: 8 additions & 0 deletions src/autocomplete/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,14 @@ fn extract_projection_columns(
}
i += 1;
}
// Liveness guard: if the inner scan broke without advancing `i`
// (e.g. an unmatched RParen left in the body when `tokens` was
// sliced past the balanced parens), the outer `continue` would
// re-enter at the same index and spin forever. Stop instead —
// we'd rather drop a half-parsed projection than freeze the UI.
if i == item_start && !matches!(tokens.get(i), Some(Token::Comma)) {
break;
}
let item_slice = trim_trivia_slice(&tokens[item_start..i]);

// Resolve `*` and `t.*` from the FROM table's schema cache
Expand Down
38 changes: 23 additions & 15 deletions src/datasource/cell.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::borrow::Cow;

use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use uuid::Uuid;

Expand Down Expand Up @@ -28,25 +30,31 @@ pub enum Cell {

impl Cell {
/// Compact, single-line rendering for the TUI grid. Not a serialization format.
pub fn display(&self) -> String {
///
/// Returns a `Cow` so the common cases that already own a string
/// (`Text`, `Decimal`, `Other { repr }`) borrow instead of cloning.
/// The renderer calls this per visible cell on every frame; with
/// large TEXT/JSON values the clone alone could allocate megabytes
/// per redraw.
pub fn display(&self) -> Cow<'_, str> {
match self {
Self::Null => "NULL".into(),
Self::Bool(v) => v.to_string(),
Self::Int(v) => v.to_string(),
Self::UInt(v) => v.to_string(),
Self::Float(v) => v.to_string(),
Self::Decimal(v) => v.clone(),
Self::Text(v) => v.clone(),
Self::Bytes(v) => format!("<{} bytes>", v.len()),
Self::Timestamp(v) => v.to_rfc3339(),
Self::Date(v) => v.to_string(),
Self::Time(v) => v.to_string(),
Self::Uuid(v) => v.to_string(),
Self::Null => Cow::Borrowed("NULL"),
Self::Bool(v) => Cow::Owned(v.to_string()),
Self::Int(v) => Cow::Owned(v.to_string()),
Self::UInt(v) => Cow::Owned(v.to_string()),
Self::Float(v) => Cow::Owned(v.to_string()),
Self::Decimal(v) => Cow::Borrowed(v.as_str()),
Self::Text(v) => Cow::Borrowed(v.as_str()),
Self::Bytes(v) => Cow::Owned(format!("<{} bytes>", v.len())),
Self::Timestamp(v) => Cow::Owned(v.to_rfc3339()),
Self::Date(v) => Cow::Owned(v.to_string()),
Self::Time(v) => Cow::Owned(v.to_string()),
Self::Uuid(v) => Cow::Owned(v.to_string()),
Self::Other { type_name, repr } => {
if repr.is_empty() {
format!("<{type_name}>")
Cow::Owned(format!("<{type_name}>"))
} else {
repr.clone()
Cow::Borrowed(repr.as_str())
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ fn display_or_empty(cell: &Cell) -> Cow<'_, str> {
Cell::Null => Cow::Borrowed(""),
Cell::Text(s) => Cow::Borrowed(s.as_str()),
Cell::Bytes(v) => Cow::Owned(bytes_to_hex(v)),
other => Cow::Owned(other.display()),
other => other.display(),
}
}

Expand Down
74 changes: 56 additions & 18 deletions src/ui/results_view.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Expand Down Expand Up @@ -44,7 +44,7 @@ impl Widget for InlineResult<'_> {
self.theme,
None,
);
let widths = column_widths(visible_cols);
let widths = column_widths(visible_cols, inner.width);
Widget::render(
Table::new(table.rows, widths)
.header(table.header)
Expand Down Expand Up @@ -138,7 +138,7 @@ impl Widget for ExpandedResult<'_> {
self.theme,
self.selection,
);
let widths = column_widths(self.visible_cols);
let widths = column_widths(self.visible_cols, table_area.width);
Widget::render(
Table::new(table.rows, widths)
.header(table.header)
Expand Down Expand Up @@ -172,12 +172,12 @@ fn render_cell_badge(
.get(cursor.col)
.map(|c| c.name.as_str())
.unwrap_or("");
let raw_value = block
let raw_value: std::borrow::Cow<'_, str> = block
.rows()
.get(cursor.row)
.and_then(|r| r.get(cursor.col))
.map(|c| c.display())
.unwrap_or_default();
.unwrap_or(std::borrow::Cow::Borrowed(""));
// Flatten so a multi-line TEXT value stays on one line — it gets clipped
// either way, but newlines would push the badge off its own row.
let value: String = raw_value
Expand Down Expand Up @@ -242,7 +242,7 @@ fn build_table<'a>(
}

fn build_row<'a>(
row: &[Cell],
row: &'a [Cell],
absolute_row: usize,
visible_cols: &[usize],
cursor: Option<ResultCursor>,
Expand Down Expand Up @@ -284,12 +284,47 @@ fn build_row<'a>(
}))
}

fn column_widths(n: usize) -> Vec<Constraint> {
(0..n).map(|_| Constraint::Min(8)).collect()
/// Per-column widths summing (with the 1-cell column gaps) to
/// `inner_width`. Returned as `Constraint::Length` rather than `Min`
/// because ratatui's `Table` runs the kasuari solver over its column
/// constraints, and kasuari hangs (issue #18) when fed many identical
/// `Min(_)` constraints — the simplex pivots cycle on the degenerate
/// ties. Pinning each column to an explicit `Length` keeps the solver
/// off the pathological path entirely. We do the even-split arithmetic
/// here so the result still scales with terminal width.
fn column_widths(n: usize, inner_width: u16) -> Vec<Constraint> {
if n == 0 {
return Vec::new();
}
even_split(inner_width, n)
.into_iter()
.map(Constraint::Length)
.collect()
}

/// Split `total` into `n` integer pieces, accounting for the 1-cell
/// gaps that sit *between* columns (so `n - 1` gaps total). The
/// remainder is distributed across the leading pieces, mirroring how
/// ratatui's Layout solver would have spread a leftover cell. If the
/// available width is smaller than the gaps alone, every column is
/// forced to width 0 — still well-defined, just nothing to render.
fn even_split(total: u16, n: usize) -> Vec<u16> {
debug_assert!(n > 0);
let n_u = n as u32;
let gaps = n_u.saturating_sub(1);
let content = (total as u32).saturating_sub(gaps);
let base = content / n_u;
let extra = (content % n_u) as usize;
(0..n)
.map(|i| {
let w = if i < extra { base + 1 } else { base };
w as u16
})
.collect()
}

/// Distribute the inner area across `n` columns the same way ratatui's
/// `Table` widget will, given `column_widths(n)` constraints and the
/// `Table` widget will, given `column_widths(n, inner.width)` and the
/// default 1-cell column spacing. Returns the cumulative X coordinates
/// where each visible column starts, plus a sentinel at the right edge —
/// i.e. a `Vec<u16>` of length `n + 1` such that column `i` spans
Expand All @@ -298,16 +333,19 @@ fn distribute_columns(inner: Rect, n: usize) -> Vec<u16> {
if n == 0 || inner.width == 0 {
return Vec::new();
}
let constraints = column_widths(n);
// ratatui's Table inserts a 1-cell gap between columns (the default
// `column_spacing`); reproduce it via `Layout::spacing` so the boundaries
// match exactly. The Layout solver handles all the over/underflow
// arithmetic — we just read off the resulting rects.
let parts = Layout::horizontal(constraints).spacing(1).split(inner);
let mut out: Vec<u16> = parts.iter().map(|r| r.x).collect();
if let Some(last) = parts.last() {
out.push(last.x.saturating_add(last.width));
let widths = even_split(inner.width, n);
let mut out: Vec<u16> = Vec::with_capacity(n + 1);
let mut x = inner.x;
for w in &widths {
out.push(x);
x = x.saturating_add(*w).saturating_add(1); // +1 for column gap
}
// Last entry was advanced past a trailing gap that doesn't exist;
// back it out so the sentinel sits flush with the right edge of
// the final column (matching the original Layout-derived behaviour).
let last_width = widths.last().copied().unwrap_or(0);
let last_x = *out.last().unwrap_or(&inner.x);
out.push(last_x.saturating_add(last_width));
out
}

Expand Down
Loading