From 1be1aae0af0f5777408e6472442b1ad984fd2a6a Mon Sep 17 00:00:00 2001 From: muk Date: Fri, 20 Feb 2026 14:59:25 +0000 Subject: [PATCH 1/2] Add transaction management UI with keyboard shortcuts Adds transaction controls with visual feedback for managing BEGIN/COMMIT/ROLLBACK within the SQL editor. - Ctrl+T to begin a transaction - Ctrl+K to commit a transaction - Ctrl+Shift+R to rollback a transaction - Visual [TXN ACTIVE] indicator in header bar with duration and query count - Header turns warning color when transaction is active - Automatic detection of manually typed BEGIN/COMMIT/ROLLBACK - Toast notifications for all transaction events - Transaction query counting Closes #36 Co-Authored-By: Claude Opus 4.6 --- src/ui/app.rs | 127 +++++++++++++++++++++++++++++++++++++++++++ src/ui/components.rs | 33 +++++++++-- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index cd3e4e5..7bc6f16 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -76,6 +76,12 @@ impl ExportFormat { } } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TransactionState { + None, + Active, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum SidebarTab { Databases, @@ -156,6 +162,11 @@ pub struct App { // Async connection task pub pending_connection: Option<(ConnectionConfig, JoinHandle>)>, + + // Transaction management + pub transaction_state: TransactionState, + pub transaction_start: Option, + pub transaction_query_count: usize, } #[derive(Debug, Clone, Copy)] @@ -419,6 +430,9 @@ impl App { table_inspector: None, export_selected: 0, pending_connection: None, + transaction_state: TransactionState::None, + transaction_start: None, + transaction_query_count: 0, } } @@ -885,6 +899,16 @@ impl App { self.execute_query().await?; self.focus = Focus::Results; } + // Transaction management + KeyCode::Char('t') if ctrl => { + self.begin_transaction().await?; + } + KeyCode::Char('k') if ctrl => { + self.commit_transaction().await?; + } + KeyCode::Char('r') if ctrl && shift => { + self.rollback_transaction().await?; + } KeyCode::Enter => { self.editor.insert_newline(); self.autocomplete.active = false; @@ -1631,6 +1655,22 @@ impl App { return Ok(()); } + // Detect transaction commands typed manually + let query_upper = query.trim().to_uppercase(); + if query_upper == "BEGIN" || query_upper == "START TRANSACTION" { + self.transaction_state = TransactionState::Active; + self.transaction_start = Some(Instant::now()); + self.transaction_query_count = 0; + } else if query_upper == "COMMIT" || query_upper == "END" { + self.transaction_state = TransactionState::None; + self.transaction_start = None; + self.transaction_query_count = 0; + } else if query_upper == "ROLLBACK" || query_upper == "ABORT" { + self.transaction_state = TransactionState::None; + self.transaction_start = None; + self.transaction_query_count = 0; + } + if self.connection.client.is_some() { self.start_loading("Executing query...".to_string()); @@ -1686,6 +1726,11 @@ impl App { None }; + // Track transaction query count + if self.transaction_state == TransactionState::Active { + self.transaction_query_count += 1; + } + self.results.push(result); self.explain_plans.push(plan); self.current_result = self.results.len() - 1; @@ -1704,6 +1749,88 @@ impl App { Ok(()) } + async fn begin_transaction(&mut self) -> Result<()> { + if self.connection.client.is_none() { + self.set_status("Not connected to database".to_string(), StatusType::Error); + return Ok(()); + } + if self.transaction_state == TransactionState::Active { + self.set_status("Transaction already active".to_string(), StatusType::Warning); + return Ok(()); + } + let client = self.connection.client.as_ref().unwrap(); + let result = execute_query(client, "BEGIN").await?; + if result.error.is_some() { + self.set_status("BEGIN failed".to_string(), StatusType::Error); + } else { + self.transaction_state = TransactionState::Active; + self.transaction_start = Some(Instant::now()); + self.transaction_query_count = 0; + self.set_status("Transaction started".to_string(), StatusType::Info); + } + Ok(()) + } + + async fn commit_transaction(&mut self) -> Result<()> { + if self.connection.client.is_none() { + self.set_status("Not connected to database".to_string(), StatusType::Error); + return Ok(()); + } + if self.transaction_state != TransactionState::Active { + self.set_status("No active transaction".to_string(), StatusType::Warning); + return Ok(()); + } + let client = self.connection.client.as_ref().unwrap(); + let result = execute_query(client, "COMMIT").await?; + if result.error.is_some() { + self.set_status("COMMIT failed".to_string(), StatusType::Error); + } else { + let duration = self + .transaction_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + let msg = format!( + "Transaction committed ({} queries, {}s)", + self.transaction_query_count, duration + ); + self.transaction_state = TransactionState::None; + self.transaction_start = None; + self.transaction_query_count = 0; + self.set_status(msg, StatusType::Success); + } + Ok(()) + } + + async fn rollback_transaction(&mut self) -> Result<()> { + if self.connection.client.is_none() { + self.set_status("Not connected to database".to_string(), StatusType::Error); + return Ok(()); + } + if self.transaction_state != TransactionState::Active { + self.set_status("No active transaction".to_string(), StatusType::Warning); + return Ok(()); + } + let client = self.connection.client.as_ref().unwrap(); + let result = execute_query(client, "ROLLBACK").await?; + if result.error.is_some() { + self.set_status("ROLLBACK failed".to_string(), StatusType::Error); + } else { + let duration = self + .transaction_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + let msg = format!( + "Transaction rolled back ({} queries, {}s)", + self.transaction_query_count, duration + ); + self.transaction_state = TransactionState::None; + self.transaction_start = None; + self.transaction_query_count = 0; + self.set_status(msg, StatusType::Warning); + } + Ok(()) + } + fn update_autocomplete(&mut self) { let line = self.editor.current_line().to_string(); let cursor_x = self.editor.cursor_x; diff --git a/src/ui/components.rs b/src/ui/components.rs index fdae4d5..7669e78 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -12,7 +12,7 @@ use crate::explain::{ }; use crate::ui::{ is_sql_function, is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, - EXPORT_FORMATS, SPINNER_FRAMES, + TransactionState, EXPORT_FORMATS, SPINNER_FRAMES, }; pub fn draw(frame: &mut Frame, app: &App) { @@ -83,12 +83,26 @@ pub fn draw(frame: &mut Frame, app: &App) { fn draw_header(frame: &mut Frame, app: &App, area: Rect) { let theme = &app.theme; + let txn_indicator = if app.transaction_state == TransactionState::Active { + let duration = app + .transaction_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + format!( + " | [TXN ACTIVE {}s, {} queries]", + duration, app.transaction_query_count + ) + } else { + String::new() + }; + let connection_info = if app.connection.is_connected() { format!( - " {} | {} | {} ", + " {} | {} | {}{}", app.connection.config.display_string(), app.connection.current_database, - app.connection.current_schema + app.connection.current_schema, + txn_indicator ) } else { " Not Connected ".to_string() @@ -100,7 +114,15 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect) { " ".repeat(area.width.saturating_sub(connection_info.len() as u16 + 10) as usize) ); - let header = Paragraph::new(header_text).style(theme.header()); + let header_style = if app.transaction_state == TransactionState::Active { + Style::default() + .fg(theme.bg_primary) + .bg(theme.warning) + .add_modifier(Modifier::BOLD) + } else { + theme.header() + }; + let header = Paragraph::new(header_text).style(header_style); frame.render_widget(header, area); } @@ -1501,6 +1523,9 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Ctrl+Shift+Z/Y Redo", " Ctrl+A Select all", " Ctrl+Space Trigger autocomplete", + " Ctrl+T Begin transaction", + " Ctrl+K Commit transaction", + " Ctrl+Shift+R Rollback transaction", " Tab Insert spaces", "", " SIDEBAR", From ec1a13ba928523477a9f926119b9a48a73fbf809 Mon Sep 17 00:00:00 2001 From: muk Date: Fri, 20 Feb 2026 16:10:30 +0000 Subject: [PATCH 2/2] Fix CI: rustfmt formatting and clippy warning - Combine identical COMMIT/ROLLBACK if-else blocks (clippy::if_same_then_else) - Run cargo fmt for consistent formatting Co-Authored-By: Claude Opus 4.6 --- src/ui/app.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 7bc6f16..e6dad78 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1661,11 +1661,11 @@ impl App { self.transaction_state = TransactionState::Active; self.transaction_start = Some(Instant::now()); self.transaction_query_count = 0; - } else if query_upper == "COMMIT" || query_upper == "END" { - self.transaction_state = TransactionState::None; - self.transaction_start = None; - self.transaction_query_count = 0; - } else if query_upper == "ROLLBACK" || query_upper == "ABORT" { + } else if query_upper == "COMMIT" + || query_upper == "END" + || query_upper == "ROLLBACK" + || query_upper == "ABORT" + { self.transaction_state = TransactionState::None; self.transaction_start = None; self.transaction_query_count = 0; @@ -1755,7 +1755,10 @@ impl App { return Ok(()); } if self.transaction_state == TransactionState::Active { - self.set_status("Transaction already active".to_string(), StatusType::Warning); + self.set_status( + "Transaction already active".to_string(), + StatusType::Warning, + ); return Ok(()); } let client = self.connection.client.as_ref().unwrap();