From d724c041fd0a9590fb2d36d97718eaffde548d69 Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 14:55:40 -0300 Subject: [PATCH 01/10] refactor(action): extract update and session flows into submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls ~280 lines out of action/mod.rs into two cohesive submodules that already had a clear seam — the self-update flow and the editor-session lifecycle (switch/new/delete + debounced save). mod.rs is still the dispatch entry point; the public surface (action:: try_promote_pending_update, action::flush_session, action:: schedule_session_save) is preserved via re-exports. First chunk of the action/mod.rs split called out in the code review. --- src/action/mod.rs | 481 ++---------------------------------------- src/action/session.rs | 305 ++++++++++++++++++++++++++ src/action/update.rs | 151 +++++++++++++ 3 files changed, 479 insertions(+), 458 deletions(-) create mode 100644 src/action/session.rs create mode 100644 src/action/update.rs diff --git a/src/action/mod.rs b/src/action/mod.rs index 5db1d8b..91df93d 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::time::{Duration, Instant}; +use std::time::Instant; use ratatui::crossterm::event::{Event as CtEvent, MouseEventKind}; use ratatui_textarea::{Input, TextArea}; @@ -11,6 +11,11 @@ mod conn_form; mod conn_list; mod llm_settings; mod params_prompt; +mod session; +mod update; + +pub(crate) use session::{flush_session, schedule_session_save}; +pub use update::try_promote_pending_update; use crate::app::{App, MAX_SCHEMA_WIDTH, MIN_SCHEMA_WIDTH}; use crate::clipboard; @@ -19,7 +24,6 @@ use crate::command::{ }; use crate::datasource::{Cell, Column, QueryResult}; use crate::export::{self, ExportFormat}; -use crate::session; use crate::state::command::CommandBuffer; use crate::state::conn_form::{ConnFormPostSave, ConnFormState}; use crate::state::conn_list::ConnListState; @@ -463,9 +467,9 @@ pub fn apply(app: &mut App, action: Action) { Action::PrepareConfirmRun => prepare_confirm_run(app), Action::ConfirmRunSubmit => confirm_run_submit(app), Action::ConfirmRunCancel => confirm_run_cancel(app), - Action::UpdateAccept => apply_update_accept(app), - Action::UpdateDismiss => apply_update_dismiss(app), - Action::CheckForUpdate => apply_check_for_update(app), + Action::UpdateAccept => update::apply_update_accept(app), + Action::UpdateDismiss => update::apply_update_dismiss(app), + Action::CheckForUpdate => update::apply_check_for_update(app), Action::RunStatementUnderCursor => run_statement_under_cursor(app), Action::RunSelection => run_selection(app), Action::CancelQuery => cancel_query(app), @@ -499,15 +503,15 @@ pub fn apply(app: &mut App, action: Action) { Action::FormatEditor(scope) => format_editor(app, scope), Action::Completion(c) => completion::apply(app, c), Action::ReloadSchemaCache => reload_schema_cache(app), - Action::ResetSession => reset_session(app), - Action::ClearSession => clear_session(app), + Action::ResetSession => session::reset_session(app), + Action::ClearSession => session::clear_session(app), Action::Source => apply_source(app), Action::Mouse(target) => apply_mouse(app, target), Action::Chat(a) => chat::apply(app, a), Action::ToggleRightPanel => chat::toggle_right_panel(app), Action::SetRightPanel(mode) => chat::set_right_panel(app, mode), Action::LlmSettings(a) => llm_settings::apply(app, a), - Action::Session(s) => dispatch_session(app, s), + Action::Session(s) => session::dispatch_session(app, s), Action::ToolApproveAccept => chat::on_tool_approve_accept(app), Action::ToolApproveDeny => chat::on_tool_approve_deny(app), } @@ -691,36 +695,6 @@ fn inline_result_jump(app: &mut App, row: usize, col: usize) { }; } -fn reset_session(app: &mut App) { - if app.active_connection.is_none() { - app.status = QueryStatus::Failed { - error: "no active connection".into(), - }; - return; - } - let _ = app.cmd_tx.send(WorkerCommand::ResetSession); - app.status = QueryStatus::Notice { - msg: "session reset — open transactions rolled back".into(), - }; -} - -fn clear_session(app: &mut App) { - // Editor buffer + results — local state we own, so wipe synchronously. - crate::state::editor::replace_buffer_text(&mut app.editor.state, ""); - app.results.clear(); - app.preview_hidden = false; - app.editor_dirty = true; - // And roll back the pinned connection so a fresh prompt doesn't - // inherit a stale BEGIN. Best-effort: if there's no connection, the - // local clear still stands. - if app.active_connection.is_some() { - let _ = app.cmd_tx.send(WorkerCommand::ResetSession); - } - app.status = QueryStatus::Notice { - msg: "session cleared".into(), - }; -} - fn reload_schema_cache(app: &mut App) { let Some(name) = app.active_connection.clone() else { app.status = QueryStatus::Failed { @@ -961,27 +935,11 @@ fn dispatch_command(app: &mut App, cmd: command::Command) { C::Source => apply(app, Action::Source), C::Conn(sub) => dispatch_conn(app, sub), C::Chat(sub) => dispatch_chat(app, sub), - C::Session(sub) => apply(app, Action::Session(session_subcommand_to_action(sub))), + C::Session(sub) => apply(app, Action::Session(session::session_subcommand_to_action(sub))), C::Update => apply(app, Action::CheckForUpdate), } } -/// `:session …` ↔ `Action::Session(...)` translation. Keeps the -/// command parser independent of `SessionAction` (which lives next -/// to the dispatcher) so adding a new subcommand only touches -/// `command.rs` + this conversion + the dispatcher. -fn session_subcommand_to_action(sub: command::SessionSubcommand) -> SessionAction { - use command::SessionSubcommand as S; - match sub { - S::List => SessionAction::List, - S::Next => SessionAction::Next, - S::Prev => SessionAction::Prev, - S::New => SessionAction::New, - S::Switch(n) => SessionAction::Switch(n), - S::Delete(n) => SessionAction::Delete(n), - } -} - fn dispatch_chat(app: &mut App, sub: ChatSubcommand) { match sub { ChatSubcommand::Toggle => apply(app, Action::ToggleRightPanel), @@ -1567,154 +1525,12 @@ fn apply_worker_event(app: &mut App, event: WorkerEvent) { agents_md_loaded, } => chat::on_fs_tool_done(app, call_id, name, display, error, agents_md_loaded), WorkerEvent::UpdateAvailable { current, latest } => { - on_update_available(app, current, latest) + update::on_update_available(app, current, latest) } - WorkerEvent::UpdateInstalled { tag } => on_update_installed(app, tag), - WorkerEvent::UpdateInstallFailed { error } => on_update_install_failed(app, error), - WorkerEvent::UpdateUpToDate { current } => on_update_up_to_date(app, current), - WorkerEvent::UpdateCheckFailed { error } => on_update_check_failed(app, error), - } -} - -fn on_update_up_to_date(app: &mut App, current: String) { - app.log.info( - "update", - format!("manual check: rowdy v{current} is the latest"), - ); - app.status = QueryStatus::Notice { - msg: format!("✓ rowdy v{current} is the latest"), - }; -} - -fn on_update_check_failed(app: &mut App, error: String) { - app.log - .warn("update", format!("manual check failed: {error}")); - app.status = QueryStatus::Failed { - error: format!("update check: {error}"), - }; -} - -fn on_update_available(app: &mut App, current: String, latest: String) { - // Stash for later instead of opening the overlay immediately. - // Showing the prompt during startup (Auth screen, ConnectionList, - // Connecting overlay) would steal keyboard input from the password - // prompt or silently dismiss itself if the user types `n` in their - // password. `try_promote_pending_update` (called once per main-loop - // tick) does the deferred handoff once the user reaches Normal. - app.log.info( - "update", - format!("update {latest} pending; will prompt when user is idle"), - ); - app.pending_update_prompt = Some((current, latest)); -} - -/// Move a queued update prompt from `App::pending_update_prompt` onto -/// the live `Overlay` once the user is on `Screen::Normal` with no -/// active overlay and is not actively typing. Idempotent and cheap — -/// safe to call from the run loop on every iteration. -pub fn try_promote_pending_update(app: &mut App) { - if app.pending_update_prompt.is_none() { - return; - } - if !matches!(app.screen, Screen::Normal) { - return; - } - if app.overlay.is_some() { - return; - } - // Don't capture keystrokes from a user actively typing. - if matches!(app.focus, Focus::ChatComposer) { - return; - } - if matches!(app.focus, Focus::Editor) - && !matches!( - app.editor.editor_mode(), - edtui::EditorMode::Normal | edtui::EditorMode::Visual - ) - { - return; - } - let Some((current, latest)) = app.pending_update_prompt.take() else { - return; - }; - app.overlay = Some(Overlay::UpdateAvailable { current, latest }); -} - -fn on_update_installed(app: &mut App, tag: String) { - app.log - .info("update", format!("install.sh succeeded for {tag}")); - app.status = QueryStatus::Notice { - msg: format!("✓ updated to {tag} — restart rowdy to use it"), - }; -} - -fn on_update_install_failed(app: &mut App, error: String) { - app.log - .warn("update", format!("install.sh failed: {error}")); - app.status = QueryStatus::Failed { - error: format!("update failed: {error}"), - }; -} - -fn apply_update_accept(app: &mut App) { - let Some(Overlay::UpdateAvailable { latest, .. }) = app.overlay.take() else { - return; - }; - app.status = QueryStatus::Notice { - msg: format!("⬇ downloading {latest}…"), - }; - let install_dir = match std::env::current_exe() { - Ok(exe) => exe.parent().map(std::path::Path::to_path_buf), - Err(err) => { - app.log.warn("update", format!("current_exe failed: {err}")); - None - } - }; - let Some(install_dir) = install_dir else { - app.status = QueryStatus::Failed { - error: "update failed: cannot resolve install dir".into(), - }; - return; - }; - let evt_tx = app.evt_tx.clone(); - let logger = app.log.clone(); - let tag = latest.clone(); - tokio::spawn(async move { - let event = match crate::update::run_installer(&tag, &install_dir).await { - Ok(()) => WorkerEvent::UpdateInstalled { tag }, - Err(error) => { - logger.warn("update", format!("installer error: {error}")); - WorkerEvent::UpdateInstallFailed { error } - } - }; - let _ = evt_tx.send(event); - }); -} - -fn apply_check_for_update(app: &mut App) { - app.status = QueryStatus::Notice { - msg: "checking for updates…".into(), - }; - // Drop any stale auto-check that hasn't been promoted yet — the - // manual check is authoritative and will re-stash if a newer - // release is still available. - app.pending_update_prompt = None; - crate::update::spawn_manual_check(app.evt_tx.clone(), env!("CARGO_PKG_VERSION").to_string()); -} - -fn apply_update_dismiss(app: &mut App) { - let Some(Overlay::UpdateAvailable { latest, .. }) = app.overlay.take() else { - return; - }; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if let Err(err) = app.user_config.record_check(now, Some(latest.clone())) { - app.log - .warn("update", format!("persisting dismissal failed: {err}")); - } else { - app.log.info("update", format!("user dismissed {latest}")); + WorkerEvent::UpdateInstalled { tag } => update::on_update_installed(app, tag), + WorkerEvent::UpdateInstallFailed { error } => update::on_update_install_failed(app, error), + WorkerEvent::UpdateUpToDate { current } => update::on_update_up_to_date(app, current), + WorkerEvent::UpdateCheckFailed { error } => update::on_update_check_failed(app, error), } } @@ -1760,12 +1576,12 @@ fn on_connected(app: &mut App, name: String) { // and re-fire the catalog load. app.schema = SchemaPanel::new(app.schema.width); app.results.clear(); - app.session_indices = session::list_indices(&app.data_dir, &name); + app.session_indices = crate::session::list_indices(&app.data_dir, &name); // `list_indices` always returns at least `[0]`, so the unwrap-by-index // is safe; default to session 0 on connect. app.active_session_index = app.session_indices[0]; - load_session(app, &name, app.active_session_index); - load_chat_session(app, &name); + session::load_session(app, &name, app.active_session_index); + session::load_chat_session(app, &name); app.schema.begin_root_load(); let _ = app.cmd_tx.send(WorkerCommand::Introspect { target: IntrospectTarget::Catalogs, @@ -2528,257 +2344,6 @@ fn render_selection_sql(app: &App, id: ResultId, rect: &SelectionRect) -> Result )) } -// --------------------------------------------------------------------------- -// Editor session persistence -// --------------------------------------------------------------------------- - -const SESSION_DEBOUNCE: Duration = Duration::from_millis(800); - -/// Push the next debounced save 800ms into the future. Skips when there's -/// no active connection — the editor isn't user-reachable in those modes, -/// but the early return keeps us honest if that ever changes. -pub(super) fn schedule_session_save(app: &mut App) { - if app.active_connection.is_none() { - return; - } - app.editor_dirty = true; - app.pending_save_at = Some(tokio::time::Instant::now() + SESSION_DEBOUNCE); -} - -/// Write the current editor buffer to the active connection's -/// active session file (`session_.sql`). -/// Best-effort: failures are logged and swallowed so a flaky disk -/// can't break the editor. -pub(crate) fn flush_session(app: &mut App) { - let Some(name) = app.active_connection.clone() else { - app.editor_dirty = false; - app.pending_save_at = None; - return; - }; - let path = session::path_for(&app.data_dir, &name, app.active_session_index); - let text = app.editor.text(); - match session::save(&path, &text) { - Ok(()) => app.log.info("session", format!("saved {}", path.display())), - Err(err) => app - .log - .warn("session", format!("save {} failed: {err}", path.display())), - } - app.editor_dirty = false; - app.pending_save_at = None; -} - -/// Route a `SessionAction` against the active connection. No-ops with a -/// status notice when there's no connection — the editor isn't -/// reachable in those modes, but the early return keeps an -/// accidentally-bound `n` from silently doing nothing. -fn dispatch_session(app: &mut App, action: SessionAction) { - let Some(name) = app.active_connection.clone() else { - app.status = QueryStatus::Failed { - error: "no active connection".into(), - }; - return; - }; - match action { - SessionAction::List => session_list_status(app), - SessionAction::Next => session_switch_relative(app, &name, 1), - SessionAction::Prev => session_switch_relative(app, &name, -1), - SessionAction::New => session_create_and_switch(app, &name), - SessionAction::Switch(n) => session_switch_to_index(app, &name, n), - SessionAction::Delete(n) => session_delete(app, &name, n), - } -} - -fn session_list_status(app: &mut App) { - let list: Vec = app.session_indices.iter().map(usize::to_string).collect(); - app.status = QueryStatus::Notice { - msg: format!( - "sessions: {} (active {})", - list.join(", "), - app.active_session_index - ), - }; -} - -fn session_switch_relative(app: &mut App, name: &str, delta: i32) { - if app.session_indices.len() < 2 { - app.status = QueryStatus::Notice { - msg: format!( - "only one session ({}) — use `:session new` to create another", - app.active_session_index - ), - }; - return; - } - let pos = app - .session_indices - .iter() - .position(|&i| i == app.active_session_index) - .unwrap_or(0) as i32; - let len = app.session_indices.len() as i32; - let next_pos = (pos + delta).rem_euclid(len) as usize; - let target = app.session_indices[next_pos]; - session_switch_to_existing(app, name, target); -} - -fn session_create_and_switch(app: &mut App, name: &str) { - let new_index = session::next_free_index(&app.session_indices); - // Touch the new file so subsequent `list_indices` calls (and any - // external `ls`) see it. An empty session file round-trips - // through `load` as an empty buffer. - let path = session::path_for(&app.data_dir, name, new_index); - if let Err(err) = session::save(&path, "") { - app.log.warn( - "session", - format!("create {} failed: {err}", path.display()), - ); - app.status = QueryStatus::Failed { - error: format!("create session {new_index} failed: {err}"), - }; - return; - } - flush_session(app); - app.session_indices.push(new_index); - app.session_indices.sort_unstable(); - app.active_session_index = new_index; - load_session(app, name, new_index); - app.status = QueryStatus::Notice { - msg: format!("created session {new_index}"), - }; -} - -fn session_switch_to_index(app: &mut App, name: &str, target: usize) { - if !app.session_indices.contains(&target) { - app.status = QueryStatus::Failed { - error: format!( - "no session {target} (existing: {})", - app.session_indices - .iter() - .map(usize::to_string) - .collect::>() - .join(", ") - ), - }; - return; - } - if target == app.active_session_index { - // No-op switches still refresh the indicator so the user - // gets a confirmation of where they are. - app.status = QueryStatus::Notice { - msg: format!("session {target} (already active)"), - }; - return; - } - session_switch_to_existing(app, name, target); -} - -fn session_switch_to_existing(app: &mut App, name: &str, target: usize) { - flush_session(app); - app.active_session_index = target; - load_session(app, name, target); - app.status = QueryStatus::Notice { - msg: format!("switched to session {target}"), - }; -} - -fn session_delete(app: &mut App, name: &str, target: usize) { - if !app.session_indices.contains(&target) { - app.status = QueryStatus::Failed { - error: format!("no session {target} to delete"), - }; - return; - } - if app.session_indices.len() == 1 { - app.status = QueryStatus::Failed { - error: "can't delete the only remaining session".into(), - }; - return; - } - let active_being_deleted = app.active_session_index == target; - if let Err(err) = session::delete(&app.data_dir, name, target) { - app.log - .warn("session", format!("delete {target} failed: {err}")); - app.status = QueryStatus::Failed { - error: format!("delete session {target} failed: {err}"), - }; - return; - } - app.session_indices.retain(|&i| i != target); - if active_being_deleted { - // The buffer the user was editing belonged to the deleted - // file — discard it (deliberately *not* flushing) and load - // the previous index in the list. `session_indices` is - // guaranteed non-empty here because we refused on len==1. - let fallback = app - .session_indices - .iter() - .copied() - .rev() - .find(|&i| i < target) - .unwrap_or(app.session_indices[0]); - // Suppress the pending debounced save for the just-killed - // index — without this clear the next tick would re-write - // the file we just deleted. - app.editor_dirty = false; - app.pending_save_at = None; - app.active_session_index = fallback; - load_session(app, name, fallback); - } - app.status = QueryStatus::Notice { - msg: format!("deleted session {target}"), - }; -} - -/// Load the session at `index` for `name` into the editor. Treats a -/// missing file as an empty buffer — first save will create it. -/// Resets the dirty/timer state so the load itself doesn't trigger -/// another save. -fn load_session(app: &mut App, name: &str, index: usize) { - let path = session::path_for(&app.data_dir, name, index); - match session::load(&path) { - Ok(text) => { - app.editor.replace_text(&text); - app.log - .info("session", format!("loaded {}", path.display())); - } - Err(err) => { - app.log - .warn("session", format!("load {} failed: {err}", path.display())); - app.editor.replace_text(""); - } - } - app.editor_dirty = false; - app.pending_save_at = None; -} - -/// Load the persisted chat-session messages for `name` into -/// `app.chat.messages`. Missing file → empty history. Failures are -/// surfaced as a warning + empty history rather than a hard error; -/// chat is non-essential to the rest of the UI. -fn load_chat_session(app: &mut App, name: &str) { - let path = crate::chat_session::path_for(&app.data_dir, name); - match crate::chat_session::load(&path) { - Ok(messages) => { - let count = messages.len(); - app.chat.messages = messages; - // Land at the bottom of the loaded history — that's where - // the conversation left off, and what the user expects when - // resuming a session. - app.chat.scroll_to_bottom(); - app.chat.streaming = false; - app.chat.error = None; - app.log.info( - "chat", - format!("loaded {count} message(s) from {}", path.display()), - ); - } - Err(err) => { - app.log - .warn("chat", format!("load {} failed: {err}", path.display())); - app.chat.messages.clear(); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -2959,7 +2524,7 @@ mod tests { app.overlay = Some(Overlay::Connecting { name: "main".into(), }); - super::on_update_available(&mut app, "0.7.0".into(), "0.7.1".into()); + super::update::on_update_available(&mut app, "0.7.0".into(), "0.7.1".into()); assert!( matches!(app.overlay, Some(Overlay::Connecting { .. })), "Connecting overlay must not be replaced", @@ -2995,7 +2560,7 @@ mod tests { let (mut app, _cmd_rx, _evt_rx) = fixture_app(dir); app.screen = Screen::Auth(AuthState::new(crate::state::auth::AuthKind::FirstSetup)); - super::on_update_available(&mut app, "0.7.0".into(), "0.7.1".into()); + super::update::on_update_available(&mut app, "0.7.0".into(), "0.7.1".into()); super::try_promote_pending_update(&mut app); // Auth screen must keep its keyboard input — overlay must NOT diff --git a/src/action/session.rs b/src/action/session.rs new file mode 100644 index 0000000..fe75e33 --- /dev/null +++ b/src/action/session.rs @@ -0,0 +1,305 @@ +//! Editor-session lifecycle: switching, creating, deleting, persisting, +//! and the debounced background-save logic. + +use std::time::Duration; + +use crate::app::App; +use crate::command; +use crate::session; +use crate::state::status::QueryStatus; +use crate::worker::WorkerCommand; + +use super::SessionAction; + +const SESSION_DEBOUNCE: Duration = Duration::from_millis(800); + +pub(super) fn reset_session(app: &mut App) { + if app.active_connection.is_none() { + app.status = QueryStatus::Failed { + error: "no active connection".into(), + }; + return; + } + let _ = app.cmd_tx.send(WorkerCommand::ResetSession); + app.status = QueryStatus::Notice { + msg: "session reset — open transactions rolled back".into(), + }; +} + +pub(super) fn clear_session(app: &mut App) { + // Editor buffer + results — local state we own, so wipe synchronously. + crate::state::editor::replace_buffer_text(&mut app.editor.state, ""); + app.results.clear(); + app.preview_hidden = false; + app.editor_dirty = true; + // And roll back the pinned connection so a fresh prompt doesn't + // inherit a stale BEGIN. Best-effort: if there's no connection, the + // local clear still stands. + if app.active_connection.is_some() { + let _ = app.cmd_tx.send(WorkerCommand::ResetSession); + } + app.status = QueryStatus::Notice { + msg: "session cleared".into(), + }; +} + +/// Push the next debounced save 800ms into the future. Skips when there's +/// no active connection — the editor isn't user-reachable in those modes, +/// but the early return keeps us honest if that ever changes. +pub(crate) fn schedule_session_save(app: &mut App) { + if app.active_connection.is_none() { + return; + } + app.editor_dirty = true; + app.pending_save_at = Some(tokio::time::Instant::now() + SESSION_DEBOUNCE); +} + +/// Write the current editor buffer to the active connection's +/// active session file (`session_.sql`). +/// Best-effort: failures are logged and swallowed so a flaky disk +/// can't break the editor. +pub(crate) fn flush_session(app: &mut App) { + let Some(name) = app.active_connection.clone() else { + app.editor_dirty = false; + app.pending_save_at = None; + return; + }; + let path = session::path_for(&app.data_dir, &name, app.active_session_index); + let text = app.editor.text(); + match session::save(&path, &text) { + Ok(()) => app.log.info("session", format!("saved {}", path.display())), + Err(err) => app + .log + .warn("session", format!("save {} failed: {err}", path.display())), + } + app.editor_dirty = false; + app.pending_save_at = None; +} + +/// `:session …` ↔ `Action::Session(...)` translation. Keeps the +/// command parser independent of `SessionAction` (which lives next +/// to the dispatcher) so adding a new subcommand only touches +/// `command.rs` + this conversion + the dispatcher. +pub(super) fn session_subcommand_to_action(sub: command::SessionSubcommand) -> SessionAction { + use command::SessionSubcommand as S; + match sub { + S::List => SessionAction::List, + S::Next => SessionAction::Next, + S::Prev => SessionAction::Prev, + S::New => SessionAction::New, + S::Switch(n) => SessionAction::Switch(n), + S::Delete(n) => SessionAction::Delete(n), + } +} + +/// Route a `SessionAction` against the active connection. No-ops with a +/// status notice when there's no connection — the editor isn't +/// reachable in those modes, but the early return keeps an +/// accidentally-bound `n` from silently doing nothing. +pub(super) fn dispatch_session(app: &mut App, action: SessionAction) { + let Some(name) = app.active_connection.clone() else { + app.status = QueryStatus::Failed { + error: "no active connection".into(), + }; + return; + }; + match action { + SessionAction::List => session_list_status(app), + SessionAction::Next => session_switch_relative(app, &name, 1), + SessionAction::Prev => session_switch_relative(app, &name, -1), + SessionAction::New => session_create_and_switch(app, &name), + SessionAction::Switch(n) => session_switch_to_index(app, &name, n), + SessionAction::Delete(n) => session_delete(app, &name, n), + } +} + +fn session_list_status(app: &mut App) { + let list: Vec = app.session_indices.iter().map(usize::to_string).collect(); + app.status = QueryStatus::Notice { + msg: format!( + "sessions: {} (active {})", + list.join(", "), + app.active_session_index + ), + }; +} + +fn session_switch_relative(app: &mut App, name: &str, delta: i32) { + if app.session_indices.len() < 2 { + app.status = QueryStatus::Notice { + msg: format!( + "only one session ({}) — use `:session new` to create another", + app.active_session_index + ), + }; + return; + } + let pos = app + .session_indices + .iter() + .position(|&i| i == app.active_session_index) + .unwrap_or(0) as i32; + let len = app.session_indices.len() as i32; + let next_pos = (pos + delta).rem_euclid(len) as usize; + let target = app.session_indices[next_pos]; + session_switch_to_existing(app, name, target); +} + +fn session_create_and_switch(app: &mut App, name: &str) { + let new_index = session::next_free_index(&app.session_indices); + // Touch the new file so subsequent `list_indices` calls (and any + // external `ls`) see it. An empty session file round-trips + // through `load` as an empty buffer. + let path = session::path_for(&app.data_dir, name, new_index); + if let Err(err) = session::save(&path, "") { + app.log.warn( + "session", + format!("create {} failed: {err}", path.display()), + ); + app.status = QueryStatus::Failed { + error: format!("create session {new_index} failed: {err}"), + }; + return; + } + flush_session(app); + app.session_indices.push(new_index); + app.session_indices.sort_unstable(); + app.active_session_index = new_index; + load_session(app, name, new_index); + app.status = QueryStatus::Notice { + msg: format!("created session {new_index}"), + }; +} + +fn session_switch_to_index(app: &mut App, name: &str, target: usize) { + if !app.session_indices.contains(&target) { + app.status = QueryStatus::Failed { + error: format!( + "no session {target} (existing: {})", + app.session_indices + .iter() + .map(usize::to_string) + .collect::>() + .join(", ") + ), + }; + return; + } + if target == app.active_session_index { + // No-op switches still refresh the indicator so the user + // gets a confirmation of where they are. + app.status = QueryStatus::Notice { + msg: format!("session {target} (already active)"), + }; + return; + } + session_switch_to_existing(app, name, target); +} + +fn session_switch_to_existing(app: &mut App, name: &str, target: usize) { + flush_session(app); + app.active_session_index = target; + load_session(app, name, target); + app.status = QueryStatus::Notice { + msg: format!("switched to session {target}"), + }; +} + +fn session_delete(app: &mut App, name: &str, target: usize) { + if !app.session_indices.contains(&target) { + app.status = QueryStatus::Failed { + error: format!("no session {target} to delete"), + }; + return; + } + if app.session_indices.len() == 1 { + app.status = QueryStatus::Failed { + error: "can't delete the only remaining session".into(), + }; + return; + } + let active_being_deleted = app.active_session_index == target; + if let Err(err) = session::delete(&app.data_dir, name, target) { + app.log + .warn("session", format!("delete {target} failed: {err}")); + app.status = QueryStatus::Failed { + error: format!("delete session {target} failed: {err}"), + }; + return; + } + app.session_indices.retain(|&i| i != target); + if active_being_deleted { + // The buffer the user was editing belonged to the deleted + // file — discard it (deliberately *not* flushing) and load + // the previous index in the list. `session_indices` is + // guaranteed non-empty here because we refused on len==1. + let fallback = app + .session_indices + .iter() + .copied() + .rev() + .find(|&i| i < target) + .unwrap_or(app.session_indices[0]); + // Suppress the pending debounced save for the just-killed + // index — without this clear the next tick would re-write + // the file we just deleted. + app.editor_dirty = false; + app.pending_save_at = None; + app.active_session_index = fallback; + load_session(app, name, fallback); + } + app.status = QueryStatus::Notice { + msg: format!("deleted session {target}"), + }; +} + +/// Load the session at `index` for `name` into the editor. Treats a +/// missing file as an empty buffer — first save will create it. +/// Resets the dirty/timer state so the load itself doesn't trigger +/// another save. +pub(super) fn load_session(app: &mut App, name: &str, index: usize) { + let path = session::path_for(&app.data_dir, name, index); + match session::load(&path) { + Ok(text) => { + app.editor.replace_text(&text); + app.log + .info("session", format!("loaded {}", path.display())); + } + Err(err) => { + app.log + .warn("session", format!("load {} failed: {err}", path.display())); + app.editor.replace_text(""); + } + } + app.editor_dirty = false; + app.pending_save_at = None; +} + +/// Load the persisted chat-session messages for `name` into +/// `app.chat.messages`. Missing file → empty history. Failures are +/// surfaced as a warning + empty history rather than a hard error; +/// chat is non-essential to the rest of the UI. +pub(super) fn load_chat_session(app: &mut App, name: &str) { + let path = crate::chat_session::path_for(&app.data_dir, name); + match crate::chat_session::load(&path) { + Ok(messages) => { + let count = messages.len(); + app.chat.messages = messages; + // Land at the bottom of the loaded history — that's where + // the conversation left off, and what the user expects when + // resuming a session. + app.chat.scroll_to_bottom(); + app.chat.streaming = false; + app.chat.error = None; + app.log.info( + "chat", + format!("loaded {count} message(s) from {}", path.display()), + ); + } + Err(err) => { + app.log + .warn("chat", format!("load {} failed: {err}", path.display())); + app.chat.messages.clear(); + } + } +} diff --git a/src/action/update.rs b/src/action/update.rs new file mode 100644 index 0000000..c0642f0 --- /dev/null +++ b/src/action/update.rs @@ -0,0 +1,151 @@ +//! Self-update flow: version-check responses, install-script dispatch, +//! and the deferred-prompt promotion driven by the main loop. + +use crate::app::App; +use crate::state::focus::Focus; +use crate::state::overlay::Overlay; +use crate::state::screen::Screen; +use crate::state::status::QueryStatus; +use crate::worker::WorkerEvent; + +pub(super) fn on_update_up_to_date(app: &mut App, current: String) { + app.log.info( + "update", + format!("manual check: rowdy v{current} is the latest"), + ); + app.status = QueryStatus::Notice { + msg: format!("✓ rowdy v{current} is the latest"), + }; +} + +pub(super) fn on_update_check_failed(app: &mut App, error: String) { + app.log + .warn("update", format!("manual check failed: {error}")); + app.status = QueryStatus::Failed { + error: format!("update check: {error}"), + }; +} + +pub(super) fn on_update_available(app: &mut App, current: String, latest: String) { + // Stash for later instead of opening the overlay immediately. + // Showing the prompt during startup (Auth screen, ConnectionList, + // Connecting overlay) would steal keyboard input from the password + // prompt or silently dismiss itself if the user types `n` in their + // password. `try_promote_pending_update` (called once per main-loop + // tick) does the deferred handoff once the user reaches Normal. + app.log.info( + "update", + format!("update {latest} pending; will prompt when user is idle"), + ); + app.pending_update_prompt = Some((current, latest)); +} + +/// Move a queued update prompt from `App::pending_update_prompt` onto +/// the live `Overlay` once the user is on `Screen::Normal` with no +/// active overlay and is not actively typing. Idempotent and cheap — +/// safe to call from the run loop on every iteration. +pub fn try_promote_pending_update(app: &mut App) { + if app.pending_update_prompt.is_none() { + return; + } + if !matches!(app.screen, Screen::Normal) { + return; + } + if app.overlay.is_some() { + return; + } + // Don't capture keystrokes from a user actively typing. + if matches!(app.focus, Focus::ChatComposer) { + return; + } + if matches!(app.focus, Focus::Editor) + && !matches!( + app.editor.editor_mode(), + edtui::EditorMode::Normal | edtui::EditorMode::Visual + ) + { + return; + } + let Some((current, latest)) = app.pending_update_prompt.take() else { + return; + }; + app.overlay = Some(Overlay::UpdateAvailable { current, latest }); +} + +pub(super) fn on_update_installed(app: &mut App, tag: String) { + app.log + .info("update", format!("install.sh succeeded for {tag}")); + app.status = QueryStatus::Notice { + msg: format!("✓ updated to {tag} — restart rowdy to use it"), + }; +} + +pub(super) fn on_update_install_failed(app: &mut App, error: String) { + app.log + .warn("update", format!("install.sh failed: {error}")); + app.status = QueryStatus::Failed { + error: format!("update failed: {error}"), + }; +} + +pub(super) fn apply_update_accept(app: &mut App) { + let Some(Overlay::UpdateAvailable { latest, .. }) = app.overlay.take() else { + return; + }; + app.status = QueryStatus::Notice { + msg: format!("⬇ downloading {latest}…"), + }; + let install_dir = match std::env::current_exe() { + Ok(exe) => exe.parent().map(std::path::Path::to_path_buf), + Err(err) => { + app.log.warn("update", format!("current_exe failed: {err}")); + None + } + }; + let Some(install_dir) = install_dir else { + app.status = QueryStatus::Failed { + error: "update failed: cannot resolve install dir".into(), + }; + return; + }; + let evt_tx = app.evt_tx.clone(); + let logger = app.log.clone(); + let tag = latest.clone(); + tokio::spawn(async move { + let event = match crate::update::run_installer(&tag, &install_dir).await { + Ok(()) => WorkerEvent::UpdateInstalled { tag }, + Err(error) => { + logger.warn("update", format!("installer error: {error}")); + WorkerEvent::UpdateInstallFailed { error } + } + }; + let _ = evt_tx.send(event); + }); +} + +pub(super) fn apply_check_for_update(app: &mut App) { + app.status = QueryStatus::Notice { + msg: "checking for updates…".into(), + }; + // Drop any stale auto-check that hasn't been promoted yet — the + // manual check is authoritative and will re-stash if a newer + // release is still available. + app.pending_update_prompt = None; + crate::update::spawn_manual_check(app.evt_tx.clone(), env!("CARGO_PKG_VERSION").to_string()); +} + +pub(super) fn apply_update_dismiss(app: &mut App) { + let Some(Overlay::UpdateAvailable { latest, .. }) = app.overlay.take() else { + return; + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if let Err(err) = app.user_config.record_check(now, Some(latest.clone())) { + app.log + .warn("update", format!("persisting dismissal failed: {err}")); + } else { + app.log.info("update", format!("user dismissed {latest}")); + } +} From 29c4cd8867d6b99741144ef5fed3e91dceec2fbc Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 14:59:00 -0300 Subject: [PATCH 02/10] refactor(action): extract query lifecycle into action::query --- src/action/mod.rs | 261 ++---------------------------------- src/action/params_prompt.rs | 3 +- src/action/query.rs | 253 ++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 252 deletions(-) create mode 100644 src/action/query.rs diff --git a/src/action/mod.rs b/src/action/mod.rs index 91df93d..13b6cfc 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -1,5 +1,4 @@ use std::path::PathBuf; -use std::time::Instant; use ratatui::crossterm::event::{Event as CtEvent, MouseEventKind}; use ratatui_textarea::{Input, TextArea}; @@ -11,6 +10,7 @@ mod conn_form; mod conn_list; mod llm_settings; mod params_prompt; +mod query; mod session; mod update; @@ -22,7 +22,7 @@ use crate::clipboard; use crate::command::{ self, ChatSubcommand, ConnSubcommand, FormatScope, ParsedTarget, ThemeChoice, }; -use crate::datasource::{Cell, Column, QueryResult}; +use crate::datasource::{Cell, Column}; use crate::export::{self, ExportFormat}; use crate::state::command::CommandBuffer; use crate::state::conn_form::{ConnFormPostSave, ConnFormState}; @@ -464,15 +464,15 @@ pub fn apply(app: &mut App, action: Action) { Action::OpenCommand => app.overlay = Some(Overlay::Command(CommandBuffer::default())), Action::Command(cmd) => apply_command(app, cmd), Action::Schema(s) => apply_schema(app, s), - Action::PrepareConfirmRun => prepare_confirm_run(app), - Action::ConfirmRunSubmit => confirm_run_submit(app), - Action::ConfirmRunCancel => confirm_run_cancel(app), + Action::PrepareConfirmRun => query::prepare_confirm_run(app), + Action::ConfirmRunSubmit => query::confirm_run_submit(app), + Action::ConfirmRunCancel => query::confirm_run_cancel(app), Action::UpdateAccept => update::apply_update_accept(app), Action::UpdateDismiss => update::apply_update_dismiss(app), Action::CheckForUpdate => update::apply_check_for_update(app), - Action::RunStatementUnderCursor => run_statement_under_cursor(app), - Action::RunSelection => run_selection(app), - Action::CancelQuery => cancel_query(app), + Action::RunStatementUnderCursor => query::run_statement_under_cursor(app), + Action::RunSelection => query::run_selection(app), + Action::CancelQuery => query::cancel_query(app), Action::ExpandLatestResult => expand_latest(app), Action::CollapseResult => app.screen = Screen::Normal, Action::DismissResult => dismiss_result(app), @@ -1195,173 +1195,6 @@ fn dispatch_introspect(app: &mut App, target: IntrospectTarget) { let _ = app.cmd_tx.send(WorkerCommand::Introspect { target }); } -fn prepare_confirm_run(app: &mut App) { - let Some(range) = crate::state::editor::statement_under_cursor(&app.editor.state) else { - app.status = QueryStatus::Failed { - error: "no statement under cursor".into(), - }; - return; - }; - let style = crate::state::editor::confirm_highlight_style( - app.theme.selection_bg, - app.theme.selection_fg, - ); - crate::state::editor::highlight_range(&mut app.editor.state, &range, style); - app.overlay = Some(Overlay::ConfirmRun { - statement: range.text, - reason: crate::state::overlay::ConfirmRunReason::Manual, - }); -} - -fn confirm_run_submit(app: &mut App) { - let Some(Overlay::ConfirmRun { statement, .. }) = app.overlay.take() else { - return; - }; - crate::state::editor::clear_confirm_highlight(&mut app.editor.state); - dispatch_query(app, statement); -} - -fn confirm_run_cancel(app: &mut App) { - if !matches!(app.overlay, Some(Overlay::ConfirmRun { .. })) { - return; - } - app.overlay = None; - crate::state::editor::clear_confirm_highlight(&mut app.editor.state); -} - -fn run_statement_under_cursor(app: &mut App) { - let Some(range) = crate::state::editor::statement_under_cursor(&app.editor.state) else { - app.status = QueryStatus::Failed { - error: "no statement under cursor".into(), - }; - return; - }; - dispatch_query(app, range.text); -} - -fn run_selection(app: &mut App) { - let Some(text) = crate::state::editor::selection_text(&app.editor.state) else { - app.status = QueryStatus::Failed { - error: "no selection to run".into(), - }; - return; - }; - dispatch_query(app, text); -} - -fn cancel_query(app: &mut App) { - if app.in_flight_query.is_none() { - app.status = QueryStatus::Failed { - error: "no query running".into(), - }; - return; - } - app.in_flight_query = None; - app.status = QueryStatus::Cancelled; - let _ = app.cmd_tx.send(WorkerCommand::Cancel); -} - -fn dispatch_query(app: &mut App, sql: String) { - if app.in_flight_query.is_some() { - app.status = QueryStatus::Failed { - error: "query already in progress — :cancel first".into(), - }; - return; - } - let trimmed = sql.trim().to_string(); - if trimmed.is_empty() { - app.status = QueryStatus::Failed { - error: "no query to run".into(), - }; - return; - } - // Destructive-statement guardrail: bare UPDATE/DELETE without WHERE - // and any TRUNCATE bounce through a confirm overlay. Reuses the - // `r` confirm machinery — Enter dispatches the held SQL - // (which lands back here, but the overlay is gone by then so we - // don't loop). Skipped when the user already passed through a - // manual confirm (overlay is consumed before re-dispatch). - if app.overlay.is_none() - && let Some(dialect) = destructive_dialect(app) - && let Some(reason) = - crate::datasource::sql::requires_destructive_confirmation(&trimmed, dialect.as_ref()) - { - app.overlay = Some(Overlay::ConfirmRun { - statement: trimmed, - reason: crate::state::overlay::ConfirmRunReason::Destructive(reason), - }); - return; - } - // Placeholders (`$N` / `:name`) bounce through a popup so the user - // can fill values in. The original (unsubstituted) statement - // stays on the overlay; the substituted form is what we eventually - // send to the worker via `send_to_worker`. - if app.overlay.is_none() - && let Some(dialect) = destructive_dialect(app) - { - let scan = crate::datasource::sql::placeholders::scan(&trimmed, dialect.as_ref()); - let unique = crate::datasource::sql::placeholders::unique_params(&scan); - if !unique.is_empty() { - open_params_prompt(app, trimmed, scan, unique); - return; - } - } - send_to_worker(app, trimmed); -} - -/// Tail of `dispatch_query` — marks the query in flight and hands the -/// (already finalised) SQL to the worker. Extracted so the params -/// popup's Submit handler can reuse the exact same logging / status / -/// in-flight bookkeeping after substitution. -pub(super) fn send_to_worker(app: &mut App, sql: String) { - app.preview_hidden = false; - let req = app.requests.next(); - app.in_flight_query = Some(crate::app::InFlightQuery { - req, - sql: sql.clone(), - }); - app.status = QueryStatus::Running { - query: sql.clone(), - started_at: Instant::now(), - }; - let _ = app.cmd_tx.send(WorkerCommand::Execute { req, sql }); -} - -fn open_params_prompt( - app: &mut App, - statement: String, - placeholders: Vec, - keys: Vec, -) { - // Pre-fill from per-connection history when we have a match for the - // exact same statement text. No active connection → no history. - let prefill_map = app - .active_connection - .clone() - .and_then(|conn| crate::param_history::lookup(app, &conn, &statement)); - - let state = - crate::state::params_prompt::ParamsPromptState::new(statement, placeholders, keys, |key| { - prefill_map - .as_ref() - .and_then(|m| m.get(&key.label()).cloned()) - }); - app.overlay = Some(Overlay::ParamsPrompt(state)); -} - -/// Pick a sqlparser dialect to feed `requires_destructive_confirmation`. -/// Falls back to `Generic` when no connection is active so the guardrail -/// still fires for queries typed before connecting (rare but possible). -fn destructive_dialect(app: &App) -> Option> { - use crate::datasource::DriverKind; - let kind = app.active_dialect.unwrap_or(DriverKind::Sqlite); - Some(match kind { - DriverKind::Postgres => Box::new(sqlparser::dialect::PostgreSqlDialect {}), - DriverKind::Mysql => Box::new(sqlparser::dialect::MySqlDialect {}), - DriverKind::Sqlite => Box::new(sqlparser::dialect::SQLiteDialect {}), - }) -} - fn expand_latest(app: &mut App) { let Some(block) = app.results.last() else { app.status = QueryStatus::Failed { @@ -1493,8 +1326,8 @@ fn apply_nav_step( fn apply_worker_event(app: &mut App, event: WorkerEvent) { match event { - WorkerEvent::QueryDone { req, result } => on_query_done(app, req, result), - WorkerEvent::QueryFailed { req, error } => on_query_failed(app, req, error.to_string()), + WorkerEvent::QueryDone { req, result } => query::on_query_done(app, req, result), + WorkerEvent::QueryFailed { req, error } => query::on_query_failed(app, req, error.to_string()), WorkerEvent::SchemaLoaded { target, payload } => on_schema_loaded(app, target, payload), WorkerEvent::SchemaFailed { target, error } => { on_schema_failed(app, target, error.to_string()) @@ -1741,80 +1574,6 @@ fn cache_introspect_payload( } } -fn on_query_done(app: &mut App, req: crate::worker::RequestId, result: QueryResult) { - let Some(in_flight) = app.in_flight_query.as_ref() else { - return; - }; - if in_flight.req != req { - return; - } - let in_flight = app.in_flight_query.take().expect("checked above"); - - // DDL detection: if the just-executed SQL reshaped the schema, - // re-prime the autocomplete cache so the next popover sees the - // new state. Best-effort — failures are surfaced through the - // normal cache-stage failure path. - if crate::autocomplete::ddl::affects_schema_cache(&in_flight.sql) - && let Some(name) = app.active_connection.clone() - { - // 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; - let total_rows = result.rows.len(); - let affected = result.affected; - - // Statements run via `execute()` (DML/DDL) report no columns — there's - // nothing to render in a result block, so skip pushing one. Also hide - // the inline preview so a stale grid from an earlier SELECT doesn't - // linger on screen after a `DELETE`/`UPDATE` lands. - if !result.columns.is_empty() { - let id = ResultId(app.results.len()); - // `active_dialect` should always be Some here (we only run queries - // through an active connection), but fall back to Sqlite rather than - // panic if the invariant ever breaks. - let dialect = app - .active_dialect - .unwrap_or(crate::datasource::DriverKind::Sqlite); - app.results.push(ResultBlock { - id, - took, - columns: result.columns, - rows: result.rows, - sql: in_flight.sql, - dialect, - }); - } else { - app.preview_hidden = true; - } - - app.status = QueryStatus::Succeeded { - rows: total_rows, - affected, - took, - statements_run: result.statements_run.max(1), - }; -} - -fn on_query_failed(app: &mut App, req: crate::worker::RequestId, error: String) { - let Some(in_flight) = app.in_flight_query.as_ref() else { - return; - }; - if in_flight.req != req { - return; - } - app.in_flight_query = None; - app.status = QueryStatus::Failed { error }; -} - // --------------------------------------------------------------------------- // Auth flow // --------------------------------------------------------------------------- diff --git a/src/action/params_prompt.rs b/src/action/params_prompt.rs index 75ba89b..94b1823 100644 --- a/src/action/params_prompt.rs +++ b/src/action/params_prompt.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; -use super::{ParamsPromptAction, copy_from, cut_from, paste_into, send_to_worker}; +use super::query::send_to_worker; +use super::{ParamsPromptAction, copy_from, cut_from, paste_into}; use crate::app::App; use crate::datasource::sql::placeholders; use crate::state::overlay::Overlay; diff --git a/src/action/query.rs b/src/action/query.rs new file mode 100644 index 0000000..db5eafc --- /dev/null +++ b/src/action/query.rs @@ -0,0 +1,253 @@ +//! Query lifecycle: confirm prompts, dispatch (with destructive + +//! placeholder guardrails), cancellation, and the worker-event landing +//! sites for `QueryDone` / `QueryFailed`. + +use std::time::Instant; + +use crate::app::App; +use crate::datasource::QueryResult; +use crate::state::overlay::Overlay; +use crate::state::results::{ResultBlock, ResultId}; +use crate::state::status::QueryStatus; +use crate::worker::WorkerCommand; + +pub(super) fn prepare_confirm_run(app: &mut App) { + let Some(range) = crate::state::editor::statement_under_cursor(&app.editor.state) else { + app.status = QueryStatus::Failed { + error: "no statement under cursor".into(), + }; + return; + }; + let style = crate::state::editor::confirm_highlight_style( + app.theme.selection_bg, + app.theme.selection_fg, + ); + crate::state::editor::highlight_range(&mut app.editor.state, &range, style); + app.overlay = Some(Overlay::ConfirmRun { + statement: range.text, + reason: crate::state::overlay::ConfirmRunReason::Manual, + }); +} + +pub(super) fn confirm_run_submit(app: &mut App) { + let Some(Overlay::ConfirmRun { statement, .. }) = app.overlay.take() else { + return; + }; + crate::state::editor::clear_confirm_highlight(&mut app.editor.state); + dispatch_query(app, statement); +} + +pub(super) fn confirm_run_cancel(app: &mut App) { + if !matches!(app.overlay, Some(Overlay::ConfirmRun { .. })) { + return; + } + app.overlay = None; + crate::state::editor::clear_confirm_highlight(&mut app.editor.state); +} + +pub(super) fn run_statement_under_cursor(app: &mut App) { + let Some(range) = crate::state::editor::statement_under_cursor(&app.editor.state) else { + app.status = QueryStatus::Failed { + error: "no statement under cursor".into(), + }; + return; + }; + dispatch_query(app, range.text); +} + +pub(super) fn run_selection(app: &mut App) { + let Some(text) = crate::state::editor::selection_text(&app.editor.state) else { + app.status = QueryStatus::Failed { + error: "no selection to run".into(), + }; + return; + }; + dispatch_query(app, text); +} + +pub(super) fn cancel_query(app: &mut App) { + if app.in_flight_query.is_none() { + app.status = QueryStatus::Failed { + error: "no query running".into(), + }; + return; + } + app.in_flight_query = None; + app.status = QueryStatus::Cancelled; + let _ = app.cmd_tx.send(WorkerCommand::Cancel); +} + +pub(super) fn dispatch_query(app: &mut App, sql: String) { + if app.in_flight_query.is_some() { + app.status = QueryStatus::Failed { + error: "query already in progress — :cancel first".into(), + }; + return; + } + let trimmed = sql.trim().to_string(); + if trimmed.is_empty() { + app.status = QueryStatus::Failed { + error: "no query to run".into(), + }; + return; + } + // Destructive-statement guardrail: bare UPDATE/DELETE without WHERE + // and any TRUNCATE bounce through a confirm overlay. Reuses the + // `r` confirm machinery — Enter dispatches the held SQL + // (which lands back here, but the overlay is gone by then so we + // don't loop). Skipped when the user already passed through a + // manual confirm (overlay is consumed before re-dispatch). + if app.overlay.is_none() + && let Some(dialect) = destructive_dialect(app) + && let Some(reason) = + crate::datasource::sql::requires_destructive_confirmation(&trimmed, dialect.as_ref()) + { + app.overlay = Some(Overlay::ConfirmRun { + statement: trimmed, + reason: crate::state::overlay::ConfirmRunReason::Destructive(reason), + }); + return; + } + // Placeholders (`$N` / `:name`) bounce through a popup so the user + // can fill values in. The original (unsubstituted) statement + // stays on the overlay; the substituted form is what we eventually + // send to the worker via `send_to_worker`. + if app.overlay.is_none() + && let Some(dialect) = destructive_dialect(app) + { + let scan = crate::datasource::sql::placeholders::scan(&trimmed, dialect.as_ref()); + let unique = crate::datasource::sql::placeholders::unique_params(&scan); + if !unique.is_empty() { + open_params_prompt(app, trimmed, scan, unique); + return; + } + } + send_to_worker(app, trimmed); +} + +/// Tail of `dispatch_query` — marks the query in flight and hands the +/// (already finalised) SQL to the worker. Extracted so the params +/// popup's Submit handler can reuse the exact same logging / status / +/// in-flight bookkeeping after substitution. +pub(super) fn send_to_worker(app: &mut App, sql: String) { + app.preview_hidden = false; + let req = app.requests.next(); + app.in_flight_query = Some(crate::app::InFlightQuery { + req, + sql: sql.clone(), + }); + app.status = QueryStatus::Running { + query: sql.clone(), + started_at: Instant::now(), + }; + let _ = app.cmd_tx.send(WorkerCommand::Execute { req, sql }); +} + +fn open_params_prompt( + app: &mut App, + statement: String, + placeholders: Vec, + keys: Vec, +) { + // Pre-fill from per-connection history when we have a match for the + // exact same statement text. No active connection → no history. + let prefill_map = app + .active_connection + .clone() + .and_then(|conn| crate::param_history::lookup(app, &conn, &statement)); + + let state = + crate::state::params_prompt::ParamsPromptState::new(statement, placeholders, keys, |key| { + prefill_map + .as_ref() + .and_then(|m| m.get(&key.label()).cloned()) + }); + app.overlay = Some(Overlay::ParamsPrompt(state)); +} + +/// Pick a sqlparser dialect to feed `requires_destructive_confirmation`. +/// Falls back to `Generic` when no connection is active so the guardrail +/// still fires for queries typed before connecting (rare but possible). +fn destructive_dialect(app: &App) -> Option> { + use crate::datasource::DriverKind; + let kind = app.active_dialect.unwrap_or(DriverKind::Sqlite); + Some(match kind { + DriverKind::Postgres => Box::new(sqlparser::dialect::PostgreSqlDialect {}), + DriverKind::Mysql => Box::new(sqlparser::dialect::MySqlDialect {}), + DriverKind::Sqlite => Box::new(sqlparser::dialect::SQLiteDialect {}), + }) +} + +pub(super) fn on_query_done(app: &mut App, req: crate::worker::RequestId, result: QueryResult) { + let Some(in_flight) = app.in_flight_query.as_ref() else { + return; + }; + if in_flight.req != req { + return; + } + let in_flight = app.in_flight_query.take().expect("checked above"); + + // DDL detection: if the just-executed SQL reshaped the schema, + // re-prime the autocomplete cache so the next popover sees the + // new state. Best-effort — failures are surfaced through the + // normal cache-stage failure path. + if crate::autocomplete::ddl::affects_schema_cache(&in_flight.sql) + && let Some(name) = app.active_connection.clone() + { + // 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; + let total_rows = result.rows.len(); + let affected = result.affected; + + // Statements run via `execute()` (DML/DDL) report no columns — there's + // nothing to render in a result block, so skip pushing one. Also hide + // the inline preview so a stale grid from an earlier SELECT doesn't + // linger on screen after a `DELETE`/`UPDATE` lands. + if !result.columns.is_empty() { + let id = ResultId(app.results.len()); + // `active_dialect` should always be Some here (we only run queries + // through an active connection), but fall back to Sqlite rather than + // panic if the invariant ever breaks. + let dialect = app + .active_dialect + .unwrap_or(crate::datasource::DriverKind::Sqlite); + app.results.push(ResultBlock { + id, + took, + columns: result.columns, + rows: result.rows, + sql: in_flight.sql, + dialect, + }); + } else { + app.preview_hidden = true; + } + + app.status = QueryStatus::Succeeded { + rows: total_rows, + affected, + took, + statements_run: result.statements_run.max(1), + }; +} + +pub(super) fn on_query_failed(app: &mut App, req: crate::worker::RequestId, error: String) { + let Some(in_flight) = app.in_flight_query.as_ref() else { + return; + }; + if in_flight.req != req { + return; + } + app.in_flight_query = None; + app.status = QueryStatus::Failed { error }; +} From 03480c06db19f832a02df9d60ba2c8fced7347d4 Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 15:01:40 -0300 Subject: [PATCH 03/10] refactor(action): extract schema panel + introspection into action::schema --- src/action/mod.rs | 212 +++---------------------------------------- src/action/schema.rs | 194 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 197 deletions(-) create mode 100644 src/action/schema.rs diff --git a/src/action/mod.rs b/src/action/mod.rs index 13b6cfc..dbca511 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -11,13 +11,14 @@ mod conn_list; mod llm_settings; mod params_prompt; mod query; +mod schema; mod session; mod update; pub(crate) use session::{flush_session, schedule_session_save}; pub use update::try_promote_pending_update; -use crate::app::{App, MAX_SCHEMA_WIDTH, MIN_SCHEMA_WIDTH}; +use crate::app::App; use crate::clipboard; use crate::command::{ self, ChatSubcommand, ConnSubcommand, FormatScope, ParsedTarget, ThemeChoice, @@ -32,7 +33,7 @@ use crate::state::layout::DragState; use crate::state::overlay::Overlay; use crate::state::results::{ResultBlock, ResultCursor, ResultId, ResultViewMode, SelectionRect}; use crate::state::right_panel::RightPanelMode; -use crate::state::schema::{ExpandOutcome, NodeId, SchemaPanel}; +use crate::state::schema::{NodeId, SchemaPanel}; use crate::state::screen::Screen; use crate::state::status::QueryStatus; use crate::state::theme_picker::ThemePickerState; @@ -428,29 +429,11 @@ pub enum ResultColumnAction { Reset, } -fn schema_toggle_at(app: &mut App, id: NodeId) { - app.schema.selected = Some(id); - let outcome = app.schema.toggle_selected(); - maybe_dispatch(app, outcome); -} - -fn schema_scroll(app: &mut App, delta: i32) { - let total = app.schema.visible_rows().len(); - if total == 0 { - return; - } - app.schema.snap_to_selection = false; - let max_offset = total.saturating_sub(1); - let next = (app.schema.scroll_offset as i32).saturating_add(delta); - let next = next.clamp(0, max_offset as i32) as usize; - app.schema.scroll_offset = next; -} - pub fn apply(app: &mut App, action: Action) { match action { Action::Quit => app.should_quit = true, Action::FocusPanel(f) => focus_panel(app, f), - Action::ResizeSchema(delta) => resize_schema(app, delta), + Action::ResizeSchema(delta) => schema::resize_schema(app, delta), Action::SetPendingChord(c) => app.pending = c, Action::EditorEvent(ev) => { app.editor.events.on_event(ev, &mut app.editor.state); @@ -463,7 +446,7 @@ pub fn apply(app: &mut App, action: Action) { } Action::OpenCommand => app.overlay = Some(Overlay::Command(CommandBuffer::default())), Action::Command(cmd) => apply_command(app, cmd), - Action::Schema(s) => apply_schema(app, s), + Action::Schema(s) => schema::apply_schema(app, s), Action::PrepareConfirmRun => query::prepare_confirm_run(app), Action::ConfirmRunSubmit => query::confirm_run_submit(app), Action::ConfirmRunCancel => query::confirm_run_cancel(app), @@ -502,7 +485,7 @@ pub fn apply(app: &mut App, action: Action) { Action::HelpScroll(axis, delta) => apply_help_scroll(app, axis, delta), Action::FormatEditor(scope) => format_editor(app, scope), Action::Completion(c) => completion::apply(app, c), - Action::ReloadSchemaCache => reload_schema_cache(app), + Action::ReloadSchemaCache => schema::reload_schema_cache(app), Action::ResetSession => session::reset_session(app), Action::ClearSession => session::clear_session(app), Action::Source => apply_source(app), @@ -545,10 +528,10 @@ fn apply_mouse(app: &mut App, target: MouseTarget) { } MouseTarget::SchemaToggle(id) => { app.focus = Focus::Schema; - schema_toggle_at(app, id); + schema::schema_toggle_at(app, id); } MouseTarget::SchemaScroll(delta) => { - schema_scroll(app, delta); + schema::schema_scroll(app, delta); } MouseTarget::ResultDragStart { row, col } => result_drag_start(app, row, col), MouseTarget::ResultDragTo { row, col } => result_drag_to(app, row, col), @@ -695,19 +678,6 @@ fn inline_result_jump(app: &mut App, row: usize, col: usize) { }; } -fn reload_schema_cache(app: &mut App) { - let Some(name) = app.active_connection.clone() else { - app.status = QueryStatus::Failed { - error: "no active connection".into(), - }; - return; - }; - let _ = app.cmd_tx.send(WorkerCommand::Reload { connection: name }); - app.status = QueryStatus::Notice { - msg: "reloading schema cache…".into(), - }; -} - fn apply_help_scroll(app: &mut App, axis: HelpAxis, delta: HelpScrollDelta) { let Some(Overlay::Help { scroll, h_scroll }) = &mut app.overlay else { return; @@ -729,13 +699,6 @@ fn apply_help_scroll(app: &mut App, axis: HelpAxis, delta: HelpScrollDelta) { } } -fn resize_schema(app: &mut App, delta: i16) { - let next = (app.schema.width as i32 + delta as i32) - .clamp(MIN_SCHEMA_WIDTH as i32, MAX_SCHEMA_WIDTH as i32); - app.schema.width = next as u16; - persist_schema_width(app); -} - fn apply_command(app: &mut App, action: CommandAction) { let Some(Overlay::Command(buf)) = &mut app.overlay else { return; @@ -906,7 +869,7 @@ fn dispatch_command(app: &mut App, cmd: command::Command) { match cmd { C::Quit => app.should_quit = true, C::Help => apply(app, Action::OpenHelp), - C::SetSchemaWidth(w) => set_schema_width(app, w), + C::SetSchemaWidth(w) => schema::set_schema_width(app, w), C::Run => apply(app, Action::RunStatementUnderCursor), C::Cancel => apply(app, Action::CancelQuery), C::Expand => apply(app, Action::ExpandLatestResult), @@ -1089,11 +1052,6 @@ pub(super) fn use_connection(app: &mut App, name: &str) { dispatch_connect(app, name.to_string(), url); } -fn set_schema_width(app: &mut App, value: u16) { - app.schema.width = value.clamp(MIN_SCHEMA_WIDTH, MAX_SCHEMA_WIDTH); - persist_schema_width(app); -} - /// Resolve `:theme ` against the bundled registry. Unknown names /// surface as a status-bar error rather than aborting the command loop. fn apply_theme_named(app: &mut App, name: &str) { @@ -1158,43 +1116,6 @@ fn apply_theme_picker(app: &mut App, action: ThemePickerAction) { } } -fn persist_schema_width(app: &mut App) { - if let Err(err) = app.config.set_schema_width(app.schema.width) { - app.log - .warn("config", format!("save schema_width failed: {err}")); - } -} - -fn apply_schema(app: &mut App, action: SchemaAction) { - match action { - SchemaAction::Down => app.schema.move_selection(1), - SchemaAction::Up => app.schema.move_selection(-1), - SchemaAction::ExpandOrDescend => { - let outcome = app.schema.expand_or_descend(); - maybe_dispatch(app, outcome); - } - SchemaAction::CollapseOrAscend => app.schema.collapse_or_ascend(), - SchemaAction::Toggle => { - let outcome = app.schema.toggle_selected(); - maybe_dispatch(app, outcome); - } - SchemaAction::Top => app.schema.select_first(), - SchemaAction::Bottom => app.schema.select_last(), - } -} - -fn maybe_dispatch(app: &mut App, outcome: ExpandOutcome) { - if let ExpandOutcome::Dispatch(targets) = outcome { - for target in targets { - dispatch_introspect(app, target); - } - } -} - -fn dispatch_introspect(app: &mut App, target: IntrospectTarget) { - let _ = app.cmd_tx.send(WorkerCommand::Introspect { target }); -} - fn expand_latest(app: &mut App) { let Some(block) = app.results.last() else { app.status = QueryStatus::Failed { @@ -1328,9 +1249,11 @@ fn apply_worker_event(app: &mut App, event: WorkerEvent) { match event { WorkerEvent::QueryDone { req, result } => query::on_query_done(app, req, result), WorkerEvent::QueryFailed { req, error } => query::on_query_failed(app, req, error.to_string()), - WorkerEvent::SchemaLoaded { target, payload } => on_schema_loaded(app, target, payload), + WorkerEvent::SchemaLoaded { target, payload } => { + schema::on_schema_loaded(app, target, payload) + } WorkerEvent::SchemaFailed { target, error } => { - on_schema_failed(app, target, error.to_string()) + schema::on_schema_failed(app, target, error.to_string()) } WorkerEvent::Connected { name } => on_connected(app, name), WorkerEvent::ConnectFailed { name, error } => { @@ -1339,9 +1262,9 @@ fn apply_worker_event(app: &mut App, event: WorkerEvent) { WorkerEvent::TestConnectionResult { success, error, .. } => { on_test_result(app, success, error) } - WorkerEvent::CompletionCacheStage { stage } => on_cache_stage(app, stage), + WorkerEvent::CompletionCacheStage { stage } => schema::on_cache_stage(app, stage), WorkerEvent::CompletionCacheFailed { stage, error } => { - on_cache_failed(app, stage, error.to_string()) + schema::on_cache_failed(app, stage, error.to_string()) } WorkerEvent::ChatDelta(delta) => chat::on_delta(app, delta), WorkerEvent::ChatToolRequest { @@ -1367,28 +1290,6 @@ fn apply_worker_event(app: &mut App, event: WorkerEvent) { } } -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(), - }; - } - // Columns just landed — if the popover is currently waiting on - // them (likely showing a "loading…" placeholder), recompute. - if matches!(stage, CacheStage::Columns { .. }) && app.completion.is_some() { - completion::refresh(app); - } -} - -fn on_cache_failed(app: &mut App, stage: crate::worker::CacheStage, error: String) { - app.log.warn( - "autocomplete", - format!("cache load failed at {stage:?}: {error}"), - ); -} - fn on_connected(app: &mut App, name: String) { // Only react if we're still expecting this connection. A late event from // an aborted swap would otherwise clobber the active connection. @@ -1491,89 +1392,6 @@ fn on_test_result(app: &mut App, success: bool, error: Option) { } } -fn on_schema_loaded( - app: &mut App, - target: IntrospectTarget, - payload: crate::worker::SchemaPayload, -) { - use crate::worker::SchemaPayload; - if let Ok(mut guard) = app.schema_cache.write() { - cache_introspect_payload(&mut guard, &target, &payload); - } - match payload { - SchemaPayload::Catalogs(catalogs) => app.schema.populate_catalogs(catalogs), - other => app.schema.populate(&target, other), - } - chat::complete_pending_for_target(app, &target, None); -} - -fn on_schema_failed(app: &mut App, target: IntrospectTarget, error: String) { - if matches!(target, IntrospectTarget::Catalogs) { - app.schema.fail_root_load(error.clone()); - } else { - app.schema.record_failure(&target, error.clone()); - } - chat::complete_pending_for_target(app, &target, Some(error)); -} - -/// Mirror an introspection result into the autocomplete `SchemaCache`. -/// `worker::prime_cache` and `worker::load_columns` already do this for -/// the cache-prime / lazy-column paths; the chat auto-expand path -/// reaches the cache through here instead so a schema tool that -/// triggered the introspect can re-run against fresh data. -fn cache_introspect_payload( - cache: &mut crate::autocomplete::SchemaCache, - target: &IntrospectTarget, - payload: &crate::worker::SchemaPayload, -) { - use crate::autocomplete::cache::{CachedColumn, CachedTable}; - use crate::worker::SchemaPayload; - match (target, payload) { - (IntrospectTarget::Catalogs, SchemaPayload::Catalogs(catalogs)) => { - cache.catalogs = catalogs.iter().map(|c| c.name.clone()).collect(); - } - (IntrospectTarget::Schemas { catalog }, SchemaPayload::Schemas(schemas)) => { - cache.schemas.insert( - catalog.clone(), - schemas.iter().map(|s| s.name.clone()).collect(), - ); - } - (IntrospectTarget::Tables { catalog, schema }, SchemaPayload::Tables(tables)) => { - let cached: Vec = tables - .iter() - .map(|t| CachedTable { - name: t.name.clone(), - kind: t.kind, - }) - .collect(); - cache - .tables - .insert((catalog.clone(), schema.clone()), cached); - } - ( - IntrospectTarget::Columns { - catalog, - schema, - table, - }, - SchemaPayload::Columns(columns), - ) => { - let cached: Vec = columns - .iter() - .map(|c| CachedColumn { - name: c.name.clone(), - type_name: c.type_name.clone(), - }) - .collect(); - cache - .columns - .insert((catalog.clone(), schema.clone(), table.clone()), cached); - } - // Indices aren't in the cache and aren't surfaced as a tool. - _ => {} - } -} - // --------------------------------------------------------------------------- // Auth flow // --------------------------------------------------------------------------- diff --git a/src/action/schema.rs b/src/action/schema.rs new file mode 100644 index 0000000..49eb69d --- /dev/null +++ b/src/action/schema.rs @@ -0,0 +1,194 @@ +//! Schema panel (tree navigation + width persistence), background +//! introspection plumbing, and the autocomplete-cache event sinks. + +use crate::app::{App, MAX_SCHEMA_WIDTH, MIN_SCHEMA_WIDTH}; +use crate::state::schema::{ExpandOutcome, NodeId}; +use crate::state::status::QueryStatus; +use crate::worker::{IntrospectTarget, WorkerCommand}; + +use super::{SchemaAction, chat, completion}; + +pub(super) fn schema_toggle_at(app: &mut App, id: NodeId) { + app.schema.selected = Some(id); + let outcome = app.schema.toggle_selected(); + maybe_dispatch(app, outcome); +} + +pub(super) fn schema_scroll(app: &mut App, delta: i32) { + let total = app.schema.visible_rows().len(); + if total == 0 { + return; + } + app.schema.snap_to_selection = false; + let max_offset = total.saturating_sub(1); + let next = (app.schema.scroll_offset as i32).saturating_add(delta); + let next = next.clamp(0, max_offset as i32) as usize; + app.schema.scroll_offset = next; +} + +pub(super) fn reload_schema_cache(app: &mut App) { + let Some(name) = app.active_connection.clone() else { + app.status = QueryStatus::Failed { + error: "no active connection".into(), + }; + return; + }; + let _ = app.cmd_tx.send(WorkerCommand::Reload { connection: name }); + app.status = QueryStatus::Notice { + msg: "reloading schema cache…".into(), + }; +} + +pub(super) fn resize_schema(app: &mut App, delta: i16) { + let next = (app.schema.width as i32 + delta as i32) + .clamp(MIN_SCHEMA_WIDTH as i32, MAX_SCHEMA_WIDTH as i32); + app.schema.width = next as u16; + persist_schema_width(app); +} + +pub(super) fn set_schema_width(app: &mut App, value: u16) { + app.schema.width = value.clamp(MIN_SCHEMA_WIDTH, MAX_SCHEMA_WIDTH); + persist_schema_width(app); +} + +fn persist_schema_width(app: &mut App) { + if let Err(err) = app.config.set_schema_width(app.schema.width) { + app.log + .warn("config", format!("save schema_width failed: {err}")); + } +} + +pub(super) fn apply_schema(app: &mut App, action: SchemaAction) { + match action { + SchemaAction::Down => app.schema.move_selection(1), + SchemaAction::Up => app.schema.move_selection(-1), + SchemaAction::ExpandOrDescend => { + let outcome = app.schema.expand_or_descend(); + maybe_dispatch(app, outcome); + } + SchemaAction::CollapseOrAscend => app.schema.collapse_or_ascend(), + SchemaAction::Toggle => { + let outcome = app.schema.toggle_selected(); + maybe_dispatch(app, outcome); + } + SchemaAction::Top => app.schema.select_first(), + SchemaAction::Bottom => app.schema.select_last(), + } +} + +fn maybe_dispatch(app: &mut App, outcome: ExpandOutcome) { + if let ExpandOutcome::Dispatch(targets) = outcome { + for target in targets { + dispatch_introspect(app, target); + } + } +} + +fn dispatch_introspect(app: &mut App, target: IntrospectTarget) { + let _ = app.cmd_tx.send(WorkerCommand::Introspect { target }); +} + +pub(super) 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(), + }; + } + // Columns just landed — if the popover is currently waiting on + // them (likely showing a "loading…" placeholder), recompute. + if matches!(stage, CacheStage::Columns { .. }) && app.completion.is_some() { + completion::refresh(app); + } +} + +pub(super) fn on_cache_failed(app: &mut App, stage: crate::worker::CacheStage, error: String) { + app.log.warn( + "autocomplete", + format!("cache load failed at {stage:?}: {error}"), + ); +} + +pub(super) fn on_schema_loaded( + app: &mut App, + target: IntrospectTarget, + payload: crate::worker::SchemaPayload, +) { + use crate::worker::SchemaPayload; + if let Ok(mut guard) = app.schema_cache.write() { + cache_introspect_payload(&mut guard, &target, &payload); + } + match payload { + SchemaPayload::Catalogs(catalogs) => app.schema.populate_catalogs(catalogs), + other => app.schema.populate(&target, other), + } + chat::complete_pending_for_target(app, &target, None); +} + +pub(super) fn on_schema_failed(app: &mut App, target: IntrospectTarget, error: String) { + if matches!(target, IntrospectTarget::Catalogs) { + app.schema.fail_root_load(error.clone()); + } else { + app.schema.record_failure(&target, error.clone()); + } + chat::complete_pending_for_target(app, &target, Some(error)); +} + +/// Mirror an introspection result into the autocomplete `SchemaCache`. +/// `worker::prime_cache` and `worker::load_columns` already do this for +/// the cache-prime / lazy-column paths; the chat auto-expand path +/// reaches the cache through here instead so a schema tool that +/// triggered the introspect can re-run against fresh data. +fn cache_introspect_payload( + cache: &mut crate::autocomplete::SchemaCache, + target: &IntrospectTarget, + payload: &crate::worker::SchemaPayload, +) { + use crate::autocomplete::cache::{CachedColumn, CachedTable}; + use crate::worker::SchemaPayload; + match (target, payload) { + (IntrospectTarget::Catalogs, SchemaPayload::Catalogs(catalogs)) => { + cache.catalogs = catalogs.iter().map(|c| c.name.clone()).collect(); + } + (IntrospectTarget::Schemas { catalog }, SchemaPayload::Schemas(schemas)) => { + cache.schemas.insert( + catalog.clone(), + schemas.iter().map(|s| s.name.clone()).collect(), + ); + } + (IntrospectTarget::Tables { catalog, schema }, SchemaPayload::Tables(tables)) => { + let cached: Vec = tables + .iter() + .map(|t| CachedTable { + name: t.name.clone(), + kind: t.kind, + }) + .collect(); + cache + .tables + .insert((catalog.clone(), schema.clone()), cached); + } + ( + IntrospectTarget::Columns { + catalog, + schema, + table, + }, + SchemaPayload::Columns(columns), + ) => { + let cached: Vec = columns + .iter() + .map(|c| CachedColumn { + name: c.name.clone(), + type_name: c.type_name.clone(), + }) + .collect(); + cache + .columns + .insert((catalog.clone(), schema.clone(), table.clone()), cached); + } + // Indices aren't in the cache and aren't surfaced as a tool. + _ => {} + } +} From 0a2b3389c486d97bedcea8bed75e5633730f04ef Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 15:06:39 -0300 Subject: [PATCH 04/10] refactor(action): extract result-grid + export flow into action::results --- src/action/mod.rs | 662 ++---------------------------------------- src/action/results.rs | 632 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 652 insertions(+), 642 deletions(-) create mode 100644 src/action/results.rs diff --git a/src/action/mod.rs b/src/action/mod.rs index dbca511..6bc11e1 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -11,6 +11,7 @@ mod conn_list; mod llm_settings; mod params_prompt; mod query; +mod results; mod schema; mod session; mod update; @@ -23,15 +24,12 @@ use crate::clipboard; use crate::command::{ self, ChatSubcommand, ConnSubcommand, FormatScope, ParsedTarget, ThemeChoice, }; -use crate::datasource::{Cell, Column}; -use crate::export::{self, ExportFormat}; +use crate::export::ExportFormat; use crate::state::command::CommandBuffer; use crate::state::conn_form::{ConnFormPostSave, ConnFormState}; use crate::state::conn_list::ConnListState; use crate::state::focus::{Focus, PendingChord}; -use crate::state::layout::DragState; use crate::state::overlay::Overlay; -use crate::state::results::{ResultBlock, ResultCursor, ResultId, ResultViewMode, SelectionRect}; use crate::state::right_panel::RightPanelMode; use crate::state::schema::{NodeId, SchemaPanel}; use crate::state::screen::Screen; @@ -456,18 +454,18 @@ pub fn apply(app: &mut App, action: Action) { Action::RunStatementUnderCursor => query::run_statement_under_cursor(app), Action::RunSelection => query::run_selection(app), Action::CancelQuery => query::cancel_query(app), - Action::ExpandLatestResult => expand_latest(app), + Action::ExpandLatestResult => results::expand_latest(app), Action::CollapseResult => app.screen = Screen::Normal, - Action::DismissResult => dismiss_result(app), - Action::ResultNav(nav) => apply_result_nav(app, nav), - Action::ResultColumn(op) => apply_result_column(app, op), - Action::ResultEnterVisual => result_enter_visual(app), - Action::ResultExitVisual => result_exit_visual(app), - Action::ResultYank => result_yank(app), - Action::ResultYankFormat(fmt) => result_yank_format(app, fmt), - Action::ResultCancelYankFormat => result_cancel_yank_format(app), - Action::Export { fmt, target } => export_command(app, fmt, target), - Action::ExportSql { table, target } => export_sql_command(app, table, target), + Action::DismissResult => results::dismiss_result(app), + Action::ResultNav(nav) => results::apply_result_nav(app, nav), + Action::ResultColumn(op) => results::apply_result_column(app, op), + Action::ResultEnterVisual => results::result_enter_visual(app), + Action::ResultExitVisual => results::result_exit_visual(app), + Action::ResultYank => results::result_yank(app), + Action::ResultYankFormat(fmt) => results::result_yank_format(app, fmt), + Action::ResultCancelYankFormat => results::result_cancel_yank_format(app), + Action::Export { fmt, target } => results::export_command(app, fmt, target), + Action::ExportSql { table, target } => results::export_sql_command(app, table, target), Action::OpenThemePicker => open_theme_picker(app), Action::ThemePicker(a) => apply_theme_picker(app, a), Action::Worker(ev) => apply_worker_event(app, ev), @@ -533,11 +531,11 @@ fn apply_mouse(app: &mut App, target: MouseTarget) { MouseTarget::SchemaScroll(delta) => { schema::schema_scroll(app, delta); } - MouseTarget::ResultDragStart { row, col } => result_drag_start(app, row, col), - MouseTarget::ResultDragTo { row, col } => result_drag_to(app, row, col), - MouseTarget::ResultDragEnd => result_drag_end(app), - MouseTarget::ResultScroll(delta) => result_scroll(app, delta), - MouseTarget::InlineResultJump { row, col } => inline_result_jump(app, row, col), + MouseTarget::ResultDragStart { row, col } => results::result_drag_start(app, row, col), + MouseTarget::ResultDragTo { row, col } => results::result_drag_to(app, row, col), + MouseTarget::ResultDragEnd => results::result_drag_end(app), + MouseTarget::ResultScroll(delta) => results::result_scroll(app, delta), + MouseTarget::InlineResultJump { row, col } => results::inline_result_jump(app, row, col), MouseTarget::OverlayDismiss => overlay_dismiss(app), } } @@ -562,122 +560,6 @@ fn overlay_dismiss(app: &mut App) { } } -fn result_drag_start(app: &mut App, row: usize, col: usize) { - let Screen::ResultExpanded { id, .. } = &app.screen else { - return; - }; - let id = *id; - let Some(block) = app.results.iter().find(|b| b.id == id) else { - return; - }; - let max_rows = block.rows().len(); - let max_cols = block.columns.len(); - if max_rows == 0 || max_cols == 0 { - return; - } - let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { - return; - }; - if matches!(view, ResultViewMode::YankFormat { .. }) { - return; - } - let r = row.min(max_rows - 1); - let c = col.min(max_cols - 1); - cursor.jump_to(r, c); - // Anchor visual selection at the click cell; subsequent Drag events - // extend `cursor` while `anchor` stays put. - *view = ResultViewMode::Visual { anchor: *cursor }; - app.layout.drag = Some(DragState::ResultSelect); -} - -fn result_drag_to(app: &mut App, row: usize, col: usize) { - if !matches!(app.layout.drag, Some(DragState::ResultSelect)) { - return; - } - let Screen::ResultExpanded { id, .. } = &app.screen else { - return; - }; - let id = *id; - let Some(block) = app.results.iter().find(|b| b.id == id) else { - return; - }; - let max_rows = block.rows().len(); - let max_cols = block.columns.len(); - if max_rows == 0 || max_cols == 0 { - return; - } - let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { - return; - }; - if matches!(view, ResultViewMode::YankFormat { .. }) { - return; - } - let r = row.min(max_rows - 1); - let c = col.min(max_cols - 1); - cursor.jump_to(r, c); -} - -fn result_drag_end(app: &mut App) { - if !matches!(app.layout.drag, Some(DragState::ResultSelect)) { - return; - } - app.layout.drag = None; - // If anchor == cursor (no actual drag), drop visual mode back to - // Normal — the user just clicked a single cell. - let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { - return; - }; - if let ResultViewMode::Visual { anchor } = *view - && anchor.row == cursor.row - && anchor.col == cursor.col - { - *view = ResultViewMode::Normal; - } -} - -fn result_scroll(app: &mut App, delta: i32) { - let Screen::ResultExpanded { id, row_offset, .. } = &mut app.screen else { - return; - }; - let id = *id; - let Some(block) = app.results.iter().find(|b| b.id == id) else { - return; - }; - let total = block.rows().len(); - if total == 0 { - return; - } - let max_offset = total.saturating_sub(1) as i32; - let next = (*row_offset as i32) - .saturating_add(delta) - .clamp(0, max_offset); - *row_offset = next as usize; -} - -fn inline_result_jump(app: &mut App, row: usize, col: usize) { - let Some(block) = app.results.last() else { - return; - }; - let max_rows = block.rows().len(); - let max_cols = block.columns.len(); - if max_rows == 0 || max_cols == 0 { - return; - } - let id = block.id; - let r = row.min(max_rows - 1); - let c = col.min(max_cols - 1); - let mut cursor = ResultCursor::default(); - cursor.jump_to(r, c); - app.screen = Screen::ResultExpanded { - id, - cursor, - col_offset: 0, - row_offset: 0, - view: ResultViewMode::Normal, - column_view: crate::state::results::ColumnView::new(max_cols), - }; -} - fn apply_help_scroll(app: &mut App, axis: HelpAxis, delta: HelpScrollDelta) { let Some(Overlay::Help { scroll, h_scroll }) = &mut app.overlay else { return; @@ -1116,135 +998,6 @@ fn apply_theme_picker(app: &mut App, action: ThemePickerAction) { } } -fn expand_latest(app: &mut App) { - let Some(block) = app.results.last() else { - app.status = QueryStatus::Failed { - error: "no results to expand".into(), - }; - return; - }; - let total_cols = block.columns.len(); - app.screen = Screen::ResultExpanded { - id: block.id, - cursor: ResultCursor::default(), - col_offset: 0, - row_offset: 0, - view: ResultViewMode::Normal, - column_view: crate::state::results::ColumnView::new(total_cols), - }; -} - -/// User-driven dismiss of the inline result preview. Doesn't touch -/// `app.results` so `:expand` can still pull the same block back up; -/// the next `dispatch_query` un-hides automatically. -fn dismiss_result(app: &mut App) { - if app.results.last().is_none() { - app.status = QueryStatus::Failed { - error: "no result preview to close".into(), - }; - return; - } - app.preview_hidden = true; -} - -fn apply_result_column(app: &mut App, op: ResultColumnAction) { - let Screen::ResultExpanded { - cursor, - view, - column_view, - .. - } = &mut app.screen - else { - return; - }; - // Reordering invalidates a Visual rectangle (anchor and cursor are - // physical column indices, but the user's selection was visual); - // drop back to Normal so we don't leave a stale highlight on the grid. - if matches!(view, ResultViewMode::Visual { .. }) { - *view = ResultViewMode::Normal; - } - // Locked while the format prompt is open — mirrors the nav guard. - if matches!(view, ResultViewMode::YankFormat { .. }) { - return; - } - match op { - ResultColumnAction::MoveLeft => column_view.move_left(cursor.col), - ResultColumnAction::MoveRight => column_view.move_right(cursor.col), - ResultColumnAction::Hide => { - if let Some(next_col) = column_view.hide(cursor.col) { - cursor.col = next_col; - } else { - app.status = QueryStatus::Failed { - error: "can't hide the last visible column".into(), - }; - } - } - ResultColumnAction::Reset => column_view.reset(), - } -} - -fn apply_result_nav(app: &mut App, nav: ResultNavAction) { - let Screen::ResultExpanded { - id, - cursor, - view, - column_view, - .. - } = &mut app.screen - else { - return; - }; - // Movement is locked while the format prompt is open — we don't want - // navigation keys to silently extend the selection while we're waiting - // for `c`/`t`/`j`. - if matches!(view, ResultViewMode::YankFormat { .. }) { - return; - } - let Some(block) = app.results.iter().find(|b| b.id == *id) else { - return; - }; - let max_rows = block.rows().len(); - apply_nav_step(cursor, nav, max_rows, column_view.visible()); -} - -fn apply_nav_step( - cursor: &mut ResultCursor, - nav: ResultNavAction, - max_rows: usize, - visible: &[usize], -) { - if visible.is_empty() { - return; - } - let visual = visible.iter().position(|&p| p == cursor.col).unwrap_or(0); - match nav { - ResultNavAction::Left => { - if visual > 0 { - cursor.jump_to(cursor.row, visible[visual - 1]); - } - } - ResultNavAction::Right => { - if visual + 1 < visible.len() { - cursor.jump_to(cursor.row, visible[visual + 1]); - } - } - ResultNavAction::Up => { - if cursor.row > 0 { - cursor.row -= 1; - } - } - ResultNavAction::Down => { - if cursor.row + 1 < max_rows { - cursor.row += 1; - } - } - ResultNavAction::LineStart => cursor.jump_to(cursor.row, visible[0]), - ResultNavAction::LineEnd => cursor.jump_to(cursor.row, *visible.last().unwrap()), - ResultNavAction::Top => cursor.jump_to(0, cursor.col), - ResultNavAction::Bottom => cursor.jump_to(max_rows.saturating_sub(1), cursor.col), - } -} - fn apply_worker_event(app: &mut App, event: WorkerEvent) { match event { WorkerEvent::QueryDone { req, result } => query::on_query_done(app, req, result), @@ -1464,323 +1217,6 @@ pub(super) fn cut_from(input: &mut TextArea<'static>, log: &crate::log::Logger) } } -// --------------------------------------------------------------------------- -// Result view: visual selection, yank, export -// --------------------------------------------------------------------------- - -fn result_enter_visual(app: &mut App) { - let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { - return; - }; - if matches!(view, ResultViewMode::Normal) { - *view = ResultViewMode::Visual { anchor: *cursor }; - } -} - -fn result_exit_visual(app: &mut App) { - let Screen::ResultExpanded { view, .. } = &mut app.screen else { - return; - }; - *view = ResultViewMode::Normal; -} - -fn result_yank(app: &mut App) { - let Screen::ResultExpanded { - id, cursor, view, .. - } = &mut app.screen - else { - return; - }; - match *view { - ResultViewMode::Normal => { - // Single cell — copy the rendered string straight to the clipboard. - // No header, no quoting, no prompt. - let cur = *cursor; - let id = *id; - let Some(block) = app.results.iter().find(|b| b.id == id) else { - return; - }; - let text = block - .rows() - .get(cur.row) - .and_then(|row| row.get(cur.col)) - .map(|cell| cell.display().into_owned()) - .unwrap_or_default(); - clipboard::write(&app.log, &text); - app.status = QueryStatus::Notice { - msg: format!("yanked cell ({}, {})", cur.row + 1, cur.col + 1), - }; - } - ResultViewMode::Visual { anchor } => { - *view = ResultViewMode::YankFormat { anchor }; - } - ResultViewMode::YankFormat { .. } => {} - } -} - -fn result_yank_format(app: &mut App, fmt: ExportFormat) { - let (id, cursor, anchor) = { - let Screen::ResultExpanded { - id, cursor, view, .. - } = &app.screen - else { - return; - }; - let ResultViewMode::YankFormat { anchor } = view else { - return; - }; - (*id, *cursor, *anchor) - }; - let rect = SelectionRect::new(anchor, cursor); - let payload = match fmt { - ExportFormat::Sql => match render_selection_sql(app, id, &rect) { - Ok(p) => p, - Err(e) => { - // Stay in Visual on error — the user might want to copy the - // selection in another format, or expand it. - if let Screen::ResultExpanded { view, .. } = &mut app.screen { - *view = ResultViewMode::Visual { anchor }; - } - app.status = QueryStatus::Failed { error: e }; - return; - } - }, - _ => match render_selection(app, id, &rect, fmt) { - Some(p) => p, - None => { - // Block disappeared between expand and yank — drop back to - // Normal and surface the error. - if let Screen::ResultExpanded { view, .. } = &mut app.screen { - *view = ResultViewMode::Normal; - } - app.status = QueryStatus::Failed { - error: "result no longer available".into(), - }; - return; - } - }, - }; - clipboard::write(&app.log, &payload); - if let Screen::ResultExpanded { view, .. } = &mut app.screen { - *view = ResultViewMode::Normal; - } - app.status = QueryStatus::Notice { - msg: format!( - "yanked {}×{} as {} ({} bytes)", - rect.rows(), - rect.cols(), - fmt.label(), - payload.len() - ), - }; -} - -fn result_cancel_yank_format(app: &mut App) { - let Screen::ResultExpanded { view, .. } = &mut app.screen else { - return; - }; - if let ResultViewMode::YankFormat { anchor } = *view { - *view = ResultViewMode::Visual { anchor }; - } -} - -/// `:export sql` handler. Mirrors `export_command` (selection wins over -/// whole-block) but resolves the target table via inference when the -/// caller didn't provide one. Failure modes surface as a status error -/// so the user knows to retry with `:export sql `. -fn export_sql_command(app: &mut App, table: Option, target: ExportTarget) { - // Same selection-vs-block dispatch shape as `export_command`. The - // selection branch passes the column-index slice down to inference - // so a Visual subset can succeed even when the full projection - // wouldn't. - if let Screen::ResultExpanded { - id, cursor, view, .. - } = &app.screen - && let Some(anchor) = view.anchor() - { - let id = *id; - let cursor = *cursor; - let rect = SelectionRect::new(anchor, cursor); - let Some(block) = app.results.iter().find(|b| b.id == id) else { - app.status = QueryStatus::Failed { - error: "result no longer available".into(), - }; - return; - }; - let col_end = (rect.col_end + 1).min(block.columns.len()); - let col_start = rect.col_start.min(col_end); - let row_end = (rect.row_end + 1).min(block.rows().len()); - let row_start = rect.row_start.min(row_end); - let column_indices: Vec = (col_start..col_end).collect(); - let resolved_table = match resolve_export_table(table, block, Some(&column_indices)) { - Ok(t) => t, - Err(e) => { - app.status = QueryStatus::Failed { error: e }; - return; - } - }; - let columns: Vec<&Column> = block.columns[col_start..col_end].iter().collect(); - let rows: Vec> = block.rows()[row_start..row_end] - .iter() - .map(|row| { - let end = col_end.min(row.len()); - let start = col_start.min(end); - row[start..end].iter().collect() - }) - .collect(); - let dialect = block.dialect; - let payload = export::format_insert(dialect, &resolved_table, &columns, &rows); - let drop_visual = matches!(target, ExportTarget::Clipboard); - finish_export( - app, - ExportFormat::Sql, - target, - rect.rows(), - rect.cols(), - payload, - ); - if drop_visual && let Screen::ResultExpanded { view, .. } = &mut app.screen { - *view = ResultViewMode::Normal; - } - return; - } - let Some(block) = app.results.last() else { - app.status = QueryStatus::Failed { - error: "no result to export".into(), - }; - return; - }; - let resolved_table = match resolve_export_table(table, block, None) { - Ok(t) => t, - Err(e) => { - app.status = QueryStatus::Failed { error: e }; - return; - } - }; - let columns: Vec<&Column> = block.columns.iter().collect(); - let rows: Vec> = block - .rows() - .iter() - .map(|row| row.iter().collect()) - .collect(); - let dialect = block.dialect; - let payload = export::format_insert(dialect, &resolved_table, &columns, &rows); - let row_count = rows.len(); - let col_count = columns.len(); - finish_export( - app, - ExportFormat::Sql, - target, - row_count, - col_count, - payload, - ); -} - -/// Returns the target table for `:export sql`. If the user passed one -/// explicitly, use it; otherwise run inference and surface the (always -/// human-readable) failure reason verbatim. -fn resolve_export_table( - explicit: Option, - block: &ResultBlock, - column_indices: Option<&[usize]>, -) -> Result { - if let Some(t) = explicit { - return Ok(t); - } - crate::sql_infer::infer_source_table(&block.sql, block.dialect, column_indices) - .map_err(|e| format!("can't infer source table — {e}")) -} - -fn export_command(app: &mut App, fmt: ExportFormat, target: ExportTarget) { - // Two routes: - // - Inside an expanded result with an active selection → export the rect. - // - Otherwise → export the latest result block in full. - if let Screen::ResultExpanded { - id, cursor, view, .. - } = &app.screen - && let Some(anchor) = view.anchor() - { - let id = *id; - let cursor = *cursor; - let rect = SelectionRect::new(anchor, cursor); - let Some(payload) = render_selection(app, id, &rect, fmt) else { - app.status = QueryStatus::Failed { - error: "result no longer available".into(), - }; - return; - }; - let drop_visual = matches!(target, ExportTarget::Clipboard); - finish_export(app, fmt, target, rect.rows(), rect.cols(), payload); - if drop_visual && let Screen::ResultExpanded { view, .. } = &mut app.screen { - *view = ResultViewMode::Normal; - } - return; - } - let Some(block) = app.results.last() else { - app.status = QueryStatus::Failed { - error: "no result to export".into(), - }; - return; - }; - let columns: Vec<&Column> = block.columns.iter().collect(); - let rows: Vec> = block - .rows() - .iter() - .map(|row| row.iter().collect()) - .collect(); - let payload = export::format(fmt, &columns, &rows); - let row_count = rows.len(); - let col_count = columns.len(); - finish_export(app, fmt, target, row_count, col_count, payload); -} - -/// Deliver `payload` to `target` and set the status line. The clipboard path -/// is fire-and-forget (failures get logged inside `clipboard::write`); the -/// file path surfaces I/O errors to the user since they typed the path. -fn finish_export( - app: &mut App, - fmt: ExportFormat, - target: ExportTarget, - rows: usize, - cols: usize, - payload: String, -) { - match target { - ExportTarget::Clipboard => { - clipboard::write(&app.log, &payload); - app.status = QueryStatus::Notice { - msg: format!( - "exported {}×{} as {} ({} bytes)", - rows, - cols, - fmt.label(), - payload.len() - ), - }; - } - ExportTarget::File(path) => match std::fs::write(&path, &payload) { - Ok(()) => { - app.status = QueryStatus::Notice { - msg: format!( - "exported {}×{} as {} to {} ({} bytes)", - rows, - cols, - fmt.label(), - path.display(), - payload.len() - ), - }; - } - Err(err) => { - app.status = QueryStatus::Failed { - error: format!("export failed: {err}"), - }; - } - }, - } -} - /// Run the SQL formatter against a slice of the editor buffer and /// rewrite it in-place. Sets a status notice on success so the user sees /// the command landed even when the visible diff is just whitespace. @@ -1861,69 +1297,11 @@ fn expand_tilde(path: &str) -> PathBuf { PathBuf::from(path) } -/// Slice the selected rectangle out of `block` and run it through the -/// chosen formatter. Returns `None` only if the block has gone missing. -fn render_selection( - app: &App, - id: ResultId, - rect: &SelectionRect, - fmt: ExportFormat, -) -> Option { - let block = app.results.iter().find(|b| b.id == id)?; - let col_end = (rect.col_end + 1).min(block.columns.len()); - let col_start = rect.col_start.min(col_end); - let columns: Vec<&Column> = block.columns[col_start..col_end].iter().collect(); - let row_end = (rect.row_end + 1).min(block.rows().len()); - let row_start = rect.row_start.min(row_end); - let rows: Vec> = block.rows()[row_start..row_end] - .iter() - .map(|row| { - let end = col_end.min(row.len()); - let start = col_start.min(end); - row[start..end].iter().collect() - }) - .collect(); - Some(export::format(fmt, &columns, &rows)) -} - -/// SQL-flavoured render path for the Visual yank prompt. There's no -/// place to type a table from inside the prompt, so this always relies -/// on `infer_source_table`; on miss the caller surfaces the error and -/// keeps the user in Visual so they can retry via `:export sql
`. -fn render_selection_sql(app: &App, id: ResultId, rect: &SelectionRect) -> Result { - let block = app - .results - .iter() - .find(|b| b.id == id) - .ok_or_else(|| "result no longer available".to_string())?; - let col_end = (rect.col_end + 1).min(block.columns.len()); - let col_start = rect.col_start.min(col_end); - let row_end = (rect.row_end + 1).min(block.rows().len()); - let row_start = rect.row_start.min(row_end); - let column_indices: Vec = (col_start..col_end).collect(); - let table = - crate::sql_infer::infer_source_table(&block.sql, block.dialect, Some(&column_indices)) - .map_err(|e| format!("can't infer source table — {e}"))?; - let columns: Vec<&Column> = block.columns[col_start..col_end].iter().collect(); - let rows: Vec> = block.rows()[row_start..row_end] - .iter() - .map(|row| { - let end = col_end.min(row.len()); - let start = col_start.min(end); - row[start..end].iter().collect() - }) - .collect(); - Ok(export::format_insert( - block.dialect, - &table, - &columns, - &rows, - )) -} - #[cfg(test)] mod tests { + use super::results::apply_nav_step; use super::*; + use crate::state::results::ResultCursor; #[test] fn expand_tilde_substitutes_home() { diff --git a/src/action/results.rs b/src/action/results.rs new file mode 100644 index 0000000..79181d0 --- /dev/null +++ b/src/action/results.rs @@ -0,0 +1,632 @@ +//! Result-grid interactions: drag selection, navigation, visual mode, +//! yank, and CSV/TSV/JSON/SQL export. Used by both the expanded grid +//! screen (`Screen::ResultExpanded`) and the inline preview. + +use crate::app::App; +use crate::clipboard; +use crate::datasource::{Cell, Column}; +use crate::export::{self, ExportFormat}; +use crate::state::layout::DragState; +use crate::state::results::{ResultBlock, ResultCursor, ResultId, ResultViewMode, SelectionRect}; +use crate::state::screen::Screen; +use crate::state::status::QueryStatus; + +use super::{ExportTarget, ResultColumnAction, ResultNavAction}; + +pub(super) fn result_drag_start(app: &mut App, row: usize, col: usize) { + let Screen::ResultExpanded { id, .. } = &app.screen else { + return; + }; + let id = *id; + let Some(block) = app.results.iter().find(|b| b.id == id) else { + return; + }; + let max_rows = block.rows().len(); + let max_cols = block.columns.len(); + if max_rows == 0 || max_cols == 0 { + return; + } + let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { + return; + }; + if matches!(view, ResultViewMode::YankFormat { .. }) { + return; + } + let r = row.min(max_rows - 1); + let c = col.min(max_cols - 1); + cursor.jump_to(r, c); + // Anchor visual selection at the click cell; subsequent Drag events + // extend `cursor` while `anchor` stays put. + *view = ResultViewMode::Visual { anchor: *cursor }; + app.layout.drag = Some(DragState::ResultSelect); +} + +pub(super) fn result_drag_to(app: &mut App, row: usize, col: usize) { + if !matches!(app.layout.drag, Some(DragState::ResultSelect)) { + return; + } + let Screen::ResultExpanded { id, .. } = &app.screen else { + return; + }; + let id = *id; + let Some(block) = app.results.iter().find(|b| b.id == id) else { + return; + }; + let max_rows = block.rows().len(); + let max_cols = block.columns.len(); + if max_rows == 0 || max_cols == 0 { + return; + } + let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { + return; + }; + if matches!(view, ResultViewMode::YankFormat { .. }) { + return; + } + let r = row.min(max_rows - 1); + let c = col.min(max_cols - 1); + cursor.jump_to(r, c); +} + +pub(super) fn result_drag_end(app: &mut App) { + if !matches!(app.layout.drag, Some(DragState::ResultSelect)) { + return; + } + app.layout.drag = None; + // If anchor == cursor (no actual drag), drop visual mode back to + // Normal — the user just clicked a single cell. + let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { + return; + }; + if let ResultViewMode::Visual { anchor } = *view + && anchor.row == cursor.row + && anchor.col == cursor.col + { + *view = ResultViewMode::Normal; + } +} + +pub(super) fn result_scroll(app: &mut App, delta: i32) { + let Screen::ResultExpanded { id, row_offset, .. } = &mut app.screen else { + return; + }; + let id = *id; + let Some(block) = app.results.iter().find(|b| b.id == id) else { + return; + }; + let total = block.rows().len(); + if total == 0 { + return; + } + let max_offset = total.saturating_sub(1) as i32; + let next = (*row_offset as i32) + .saturating_add(delta) + .clamp(0, max_offset); + *row_offset = next as usize; +} + +pub(super) fn inline_result_jump(app: &mut App, row: usize, col: usize) { + let Some(block) = app.results.last() else { + return; + }; + let max_rows = block.rows().len(); + let max_cols = block.columns.len(); + if max_rows == 0 || max_cols == 0 { + return; + } + let id = block.id; + let r = row.min(max_rows - 1); + let c = col.min(max_cols - 1); + let mut cursor = ResultCursor::default(); + cursor.jump_to(r, c); + app.screen = Screen::ResultExpanded { + id, + cursor, + col_offset: 0, + row_offset: 0, + view: ResultViewMode::Normal, + column_view: crate::state::results::ColumnView::new(max_cols), + }; +} + +pub(super) fn expand_latest(app: &mut App) { + let Some(block) = app.results.last() else { + app.status = QueryStatus::Failed { + error: "no results to expand".into(), + }; + return; + }; + let total_cols = block.columns.len(); + app.screen = Screen::ResultExpanded { + id: block.id, + cursor: ResultCursor::default(), + col_offset: 0, + row_offset: 0, + view: ResultViewMode::Normal, + column_view: crate::state::results::ColumnView::new(total_cols), + }; +} + +/// User-driven dismiss of the inline result preview. Doesn't touch +/// `app.results` so `:expand` can still pull the same block back up; +/// the next `dispatch_query` un-hides automatically. +pub(super) fn dismiss_result(app: &mut App) { + if app.results.last().is_none() { + app.status = QueryStatus::Failed { + error: "no result preview to close".into(), + }; + return; + } + app.preview_hidden = true; +} + +pub(super) fn apply_result_column(app: &mut App, op: ResultColumnAction) { + let Screen::ResultExpanded { + cursor, + view, + column_view, + .. + } = &mut app.screen + else { + return; + }; + // Reordering invalidates a Visual rectangle (anchor and cursor are + // physical column indices, but the user's selection was visual); + // drop back to Normal so we don't leave a stale highlight on the grid. + if matches!(view, ResultViewMode::Visual { .. }) { + *view = ResultViewMode::Normal; + } + // Locked while the format prompt is open — mirrors the nav guard. + if matches!(view, ResultViewMode::YankFormat { .. }) { + return; + } + match op { + ResultColumnAction::MoveLeft => column_view.move_left(cursor.col), + ResultColumnAction::MoveRight => column_view.move_right(cursor.col), + ResultColumnAction::Hide => { + if let Some(next_col) = column_view.hide(cursor.col) { + cursor.col = next_col; + } else { + app.status = QueryStatus::Failed { + error: "can't hide the last visible column".into(), + }; + } + } + ResultColumnAction::Reset => column_view.reset(), + } +} + +pub(super) fn apply_result_nav(app: &mut App, nav: ResultNavAction) { + let Screen::ResultExpanded { + id, + cursor, + view, + column_view, + .. + } = &mut app.screen + else { + return; + }; + // Movement is locked while the format prompt is open — we don't want + // navigation keys to silently extend the selection while we're waiting + // for `c`/`t`/`j`. + if matches!(view, ResultViewMode::YankFormat { .. }) { + return; + } + let Some(block) = app.results.iter().find(|b| b.id == *id) else { + return; + }; + let max_rows = block.rows().len(); + apply_nav_step(cursor, nav, max_rows, column_view.visible()); +} + +pub(super) fn apply_nav_step( + cursor: &mut ResultCursor, + nav: ResultNavAction, + max_rows: usize, + visible: &[usize], +) { + if visible.is_empty() { + return; + } + let visual = visible.iter().position(|&p| p == cursor.col).unwrap_or(0); + match nav { + ResultNavAction::Left => { + if visual > 0 { + cursor.jump_to(cursor.row, visible[visual - 1]); + } + } + ResultNavAction::Right => { + if visual + 1 < visible.len() { + cursor.jump_to(cursor.row, visible[visual + 1]); + } + } + ResultNavAction::Up => { + if cursor.row > 0 { + cursor.row -= 1; + } + } + ResultNavAction::Down => { + if cursor.row + 1 < max_rows { + cursor.row += 1; + } + } + ResultNavAction::LineStart => cursor.jump_to(cursor.row, visible[0]), + ResultNavAction::LineEnd => cursor.jump_to(cursor.row, *visible.last().unwrap()), + ResultNavAction::Top => cursor.jump_to(0, cursor.col), + ResultNavAction::Bottom => cursor.jump_to(max_rows.saturating_sub(1), cursor.col), + } +} + +pub(super) fn result_enter_visual(app: &mut App) { + let Screen::ResultExpanded { cursor, view, .. } = &mut app.screen else { + return; + }; + if matches!(view, ResultViewMode::Normal) { + *view = ResultViewMode::Visual { anchor: *cursor }; + } +} + +pub(super) fn result_exit_visual(app: &mut App) { + let Screen::ResultExpanded { view, .. } = &mut app.screen else { + return; + }; + *view = ResultViewMode::Normal; +} + +pub(super) fn result_yank(app: &mut App) { + let Screen::ResultExpanded { + id, cursor, view, .. + } = &mut app.screen + else { + return; + }; + match *view { + ResultViewMode::Normal => { + // Single cell — copy the rendered string straight to the clipboard. + // No header, no quoting, no prompt. + let cur = *cursor; + let id = *id; + let Some(block) = app.results.iter().find(|b| b.id == id) else { + return; + }; + let text = block + .rows() + .get(cur.row) + .and_then(|row| row.get(cur.col)) + .map(|cell| cell.display().into_owned()) + .unwrap_or_default(); + clipboard::write(&app.log, &text); + app.status = QueryStatus::Notice { + msg: format!("yanked cell ({}, {})", cur.row + 1, cur.col + 1), + }; + } + ResultViewMode::Visual { anchor } => { + *view = ResultViewMode::YankFormat { anchor }; + } + ResultViewMode::YankFormat { .. } => {} + } +} + +pub(super) fn result_yank_format(app: &mut App, fmt: ExportFormat) { + let (id, cursor, anchor) = { + let Screen::ResultExpanded { + id, cursor, view, .. + } = &app.screen + else { + return; + }; + let ResultViewMode::YankFormat { anchor } = view else { + return; + }; + (*id, *cursor, *anchor) + }; + let rect = SelectionRect::new(anchor, cursor); + let payload = match fmt { + ExportFormat::Sql => match render_selection_sql(app, id, &rect) { + Ok(p) => p, + Err(e) => { + // Stay in Visual on error — the user might want to copy the + // selection in another format, or expand it. + if let Screen::ResultExpanded { view, .. } = &mut app.screen { + *view = ResultViewMode::Visual { anchor }; + } + app.status = QueryStatus::Failed { error: e }; + return; + } + }, + _ => match render_selection(app, id, &rect, fmt) { + Some(p) => p, + None => { + // Block disappeared between expand and yank — drop back to + // Normal and surface the error. + if let Screen::ResultExpanded { view, .. } = &mut app.screen { + *view = ResultViewMode::Normal; + } + app.status = QueryStatus::Failed { + error: "result no longer available".into(), + }; + return; + } + }, + }; + clipboard::write(&app.log, &payload); + if let Screen::ResultExpanded { view, .. } = &mut app.screen { + *view = ResultViewMode::Normal; + } + app.status = QueryStatus::Notice { + msg: format!( + "yanked {}×{} as {} ({} bytes)", + rect.rows(), + rect.cols(), + fmt.label(), + payload.len() + ), + }; +} + +pub(super) fn result_cancel_yank_format(app: &mut App) { + let Screen::ResultExpanded { view, .. } = &mut app.screen else { + return; + }; + if let ResultViewMode::YankFormat { anchor } = *view { + *view = ResultViewMode::Visual { anchor }; + } +} + +/// `:export sql` handler. Mirrors `export_command` (selection wins over +/// whole-block) but resolves the target table via inference when the +/// caller didn't provide one. Failure modes surface as a status error +/// so the user knows to retry with `:export sql
`. +pub(super) fn export_sql_command(app: &mut App, table: Option, target: ExportTarget) { + // Same selection-vs-block dispatch shape as `export_command`. The + // selection branch passes the column-index slice down to inference + // so a Visual subset can succeed even when the full projection + // wouldn't. + if let Screen::ResultExpanded { + id, cursor, view, .. + } = &app.screen + && let Some(anchor) = view.anchor() + { + let id = *id; + let cursor = *cursor; + let rect = SelectionRect::new(anchor, cursor); + let Some(block) = app.results.iter().find(|b| b.id == id) else { + app.status = QueryStatus::Failed { + error: "result no longer available".into(), + }; + return; + }; + let col_end = (rect.col_end + 1).min(block.columns.len()); + let col_start = rect.col_start.min(col_end); + let row_end = (rect.row_end + 1).min(block.rows().len()); + let row_start = rect.row_start.min(row_end); + let column_indices: Vec = (col_start..col_end).collect(); + let resolved_table = match resolve_export_table(table, block, Some(&column_indices)) { + Ok(t) => t, + Err(e) => { + app.status = QueryStatus::Failed { error: e }; + return; + } + }; + let columns: Vec<&Column> = block.columns[col_start..col_end].iter().collect(); + let rows: Vec> = block.rows()[row_start..row_end] + .iter() + .map(|row| { + let end = col_end.min(row.len()); + let start = col_start.min(end); + row[start..end].iter().collect() + }) + .collect(); + let dialect = block.dialect; + let payload = export::format_insert(dialect, &resolved_table, &columns, &rows); + let drop_visual = matches!(target, ExportTarget::Clipboard); + finish_export( + app, + ExportFormat::Sql, + target, + rect.rows(), + rect.cols(), + payload, + ); + if drop_visual && let Screen::ResultExpanded { view, .. } = &mut app.screen { + *view = ResultViewMode::Normal; + } + return; + } + let Some(block) = app.results.last() else { + app.status = QueryStatus::Failed { + error: "no result to export".into(), + }; + return; + }; + let resolved_table = match resolve_export_table(table, block, None) { + Ok(t) => t, + Err(e) => { + app.status = QueryStatus::Failed { error: e }; + return; + } + }; + let columns: Vec<&Column> = block.columns.iter().collect(); + let rows: Vec> = block + .rows() + .iter() + .map(|row| row.iter().collect()) + .collect(); + let dialect = block.dialect; + let payload = export::format_insert(dialect, &resolved_table, &columns, &rows); + let row_count = rows.len(); + let col_count = columns.len(); + finish_export( + app, + ExportFormat::Sql, + target, + row_count, + col_count, + payload, + ); +} + +/// Returns the target table for `:export sql`. If the user passed one +/// explicitly, use it; otherwise run inference and surface the (always +/// human-readable) failure reason verbatim. +fn resolve_export_table( + explicit: Option, + block: &ResultBlock, + column_indices: Option<&[usize]>, +) -> Result { + if let Some(t) = explicit { + return Ok(t); + } + crate::sql_infer::infer_source_table(&block.sql, block.dialect, column_indices) + .map_err(|e| format!("can't infer source table — {e}")) +} + +pub(super) fn export_command(app: &mut App, fmt: ExportFormat, target: ExportTarget) { + // Two routes: + // - Inside an expanded result with an active selection → export the rect. + // - Otherwise → export the latest result block in full. + if let Screen::ResultExpanded { + id, cursor, view, .. + } = &app.screen + && let Some(anchor) = view.anchor() + { + let id = *id; + let cursor = *cursor; + let rect = SelectionRect::new(anchor, cursor); + let Some(payload) = render_selection(app, id, &rect, fmt) else { + app.status = QueryStatus::Failed { + error: "result no longer available".into(), + }; + return; + }; + let drop_visual = matches!(target, ExportTarget::Clipboard); + finish_export(app, fmt, target, rect.rows(), rect.cols(), payload); + if drop_visual && let Screen::ResultExpanded { view, .. } = &mut app.screen { + *view = ResultViewMode::Normal; + } + return; + } + let Some(block) = app.results.last() else { + app.status = QueryStatus::Failed { + error: "no result to export".into(), + }; + return; + }; + let columns: Vec<&Column> = block.columns.iter().collect(); + let rows: Vec> = block + .rows() + .iter() + .map(|row| row.iter().collect()) + .collect(); + let payload = export::format(fmt, &columns, &rows); + let row_count = rows.len(); + let col_count = columns.len(); + finish_export(app, fmt, target, row_count, col_count, payload); +} + +/// Deliver `payload` to `target` and set the status line. The clipboard path +/// is fire-and-forget (failures get logged inside `clipboard::write`); the +/// file path surfaces I/O errors to the user since they typed the path. +fn finish_export( + app: &mut App, + fmt: ExportFormat, + target: ExportTarget, + rows: usize, + cols: usize, + payload: String, +) { + match target { + ExportTarget::Clipboard => { + clipboard::write(&app.log, &payload); + app.status = QueryStatus::Notice { + msg: format!( + "exported {}×{} as {} ({} bytes)", + rows, + cols, + fmt.label(), + payload.len() + ), + }; + } + ExportTarget::File(path) => match std::fs::write(&path, &payload) { + Ok(()) => { + app.status = QueryStatus::Notice { + msg: format!( + "exported {}×{} as {} to {} ({} bytes)", + rows, + cols, + fmt.label(), + path.display(), + payload.len() + ), + }; + } + Err(err) => { + app.status = QueryStatus::Failed { + error: format!("export failed: {err}"), + }; + } + }, + } +} + +/// Slice the selected rectangle out of `block` and run it through the +/// chosen formatter. Returns `None` only if the block has gone missing. +fn render_selection( + app: &App, + id: ResultId, + rect: &SelectionRect, + fmt: ExportFormat, +) -> Option { + let block = app.results.iter().find(|b| b.id == id)?; + let col_end = (rect.col_end + 1).min(block.columns.len()); + let col_start = rect.col_start.min(col_end); + let columns: Vec<&Column> = block.columns[col_start..col_end].iter().collect(); + let row_end = (rect.row_end + 1).min(block.rows().len()); + let row_start = rect.row_start.min(row_end); + let rows: Vec> = block.rows()[row_start..row_end] + .iter() + .map(|row| { + let end = col_end.min(row.len()); + let start = col_start.min(end); + row[start..end].iter().collect() + }) + .collect(); + Some(export::format(fmt, &columns, &rows)) +} + +/// SQL-flavoured render path for the Visual yank prompt. There's no +/// place to type a table from inside the prompt, so this always relies +/// on `infer_source_table`; on miss the caller surfaces the error and +/// keeps the user in Visual so they can retry via `:export sql
`. +fn render_selection_sql(app: &App, id: ResultId, rect: &SelectionRect) -> Result { + let block = app + .results + .iter() + .find(|b| b.id == id) + .ok_or_else(|| "result no longer available".to_string())?; + let col_end = (rect.col_end + 1).min(block.columns.len()); + let col_start = rect.col_start.min(col_end); + let row_end = (rect.row_end + 1).min(block.rows().len()); + let row_start = rect.row_start.min(row_end); + let column_indices: Vec = (col_start..col_end).collect(); + let table = + crate::sql_infer::infer_source_table(&block.sql, block.dialect, Some(&column_indices)) + .map_err(|e| format!("can't infer source table — {e}"))?; + let columns: Vec<&Column> = block.columns[col_start..col_end].iter().collect(); + let rows: Vec> = block.rows()[row_start..row_end] + .iter() + .map(|row| { + let end = col_end.min(row.len()); + let start = col_start.min(end); + row[start..end].iter().collect() + }) + .collect(); + Ok(export::format_insert( + block.dialect, + &table, + &columns, + &rows, + )) +} From 94baea02f92e8dd026219608a8b68d9e91eb4e2b Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 15:09:13 -0300 Subject: [PATCH 05/10] test(worker): add channel-discipline and dispatch coverage Adds a focused test module to src/worker/mod.rs covering the previously untested infrastructure boundary: clean exit on Close, clean exit on command-channel drop, no-connection guards (Cancel/Reset/Execute/ Introspect), Connect failure surfacing, and a sqlite Connect + Execute + Reload smoke test that also asserts SchemaCache mutation. --- src/worker/mod.rs | 220 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 46ec240..8ca6f32 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -649,6 +649,226 @@ async fn prime_cache( } } +#[cfg(test)] +mod tests { + use super::*; + use crate::log::Logger; + use std::sync::{Arc, RwLock}; + use std::time::Duration; + use tokio::sync::mpsc::unbounded_channel; + + fn fixture() -> ( + UnboundedSender, + UnboundedReceiver, + Arc>, + tokio::task::JoinHandle<()>, + ) { + let logger = Logger::discard(); + let (cmd_tx, cmd_rx) = unbounded_channel::(); + let (evt_tx, evt_rx) = unbounded_channel::(); + let cache = Arc::new(RwLock::new(SchemaCache::default())); + let cache_for_run = cache.clone(); + let handle = tokio::spawn(async move { + run(logger, cmd_rx, evt_tx, cache_for_run).await; + }); + (cmd_tx, evt_rx, cache, handle) + } + + /// Pull the next event off the receiver with a generous timeout so a hung + /// worker fails the test instead of stalling CI forever. + async fn next_event(rx: &mut UnboundedReceiver) -> WorkerEvent { + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("worker did not send an event in time") + .expect("worker dropped event channel before sending") + } + + #[tokio::test] + async fn worker_exits_cleanly_on_close_command() { + let (cmd_tx, _evt_rx, _cache, handle) = fixture(); + cmd_tx.send(WorkerCommand::Close).expect("send Close"); + tokio::time::timeout(Duration::from_secs(2), handle) + .await + .expect("worker did not exit after Close") + .expect("worker task panicked"); + } + + #[tokio::test] + async fn worker_exits_when_command_channel_drops() { + // No Close — just drop the sender. The recv loop should fall through. + let (cmd_tx, _evt_rx, _cache, handle) = fixture(); + drop(cmd_tx); + tokio::time::timeout(Duration::from_secs(2), handle) + .await + .expect("worker did not exit after channel drop") + .expect("worker task panicked"); + } + + #[tokio::test] + async fn cancel_without_connection_does_not_panic() { + let (cmd_tx, mut evt_rx, _cache, handle) = fixture(); + cmd_tx.send(WorkerCommand::Cancel).expect("send Cancel"); + // No event is emitted (warn-only). Follow up with Close to drain. + cmd_tx.send(WorkerCommand::Close).expect("send Close"); + handle.await.expect("worker task panicked"); + // The receiver should be empty (Cancel emits nothing). + assert!(evt_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn execute_without_connection_emits_query_failed() { + let (cmd_tx, mut evt_rx, _cache, handle) = fixture(); + let req = RequestId(42); + cmd_tx + .send(WorkerCommand::Execute { + req, + sql: "SELECT 1".into(), + }) + .expect("send Execute"); + match next_event(&mut evt_rx).await { + WorkerEvent::QueryFailed { req: got, error } => { + assert_eq!(got, req); + assert!( + matches!(error, DatasourceError::Connect(_)), + "expected Connect error, got {error:?}", + ); + } + other => panic!("expected QueryFailed, got {other:?}"), + } + cmd_tx.send(WorkerCommand::Close).ok(); + handle.await.ok(); + } + + #[tokio::test] + async fn introspect_without_connection_emits_schema_failed() { + let (cmd_tx, mut evt_rx, _cache, handle) = fixture(); + cmd_tx + .send(WorkerCommand::Introspect { + target: IntrospectTarget::Catalogs, + }) + .expect("send Introspect"); + match next_event(&mut evt_rx).await { + WorkerEvent::SchemaFailed { target, error } => { + assert_eq!(target, IntrospectTarget::Catalogs); + assert!(matches!(error, DatasourceError::Connect(_))); + } + other => panic!("expected SchemaFailed, got {other:?}"), + } + cmd_tx.send(WorkerCommand::Close).ok(); + handle.await.ok(); + } + + #[tokio::test] + async fn connect_then_execute_sqlite_emits_query_done() { + let (cmd_tx, mut evt_rx, _cache, handle) = fixture(); + cmd_tx + .send(WorkerCommand::Connect { + name: "mem".into(), + url: "sqlite::memory:".into(), + }) + .expect("send Connect"); + match next_event(&mut evt_rx).await { + WorkerEvent::Connected { name } => assert_eq!(name, "mem"), + other => panic!("expected Connected, got {other:?}"), + } + + let req = RequestId(1); + cmd_tx + .send(WorkerCommand::Execute { + req, + sql: "SELECT 1 AS one".into(), + }) + .expect("send Execute"); + match next_event(&mut evt_rx).await { + WorkerEvent::QueryDone { req: got, result } => { + assert_eq!(got, req); + assert_eq!(result.columns.len(), 1); + assert_eq!(result.rows.len(), 1); + } + other => panic!("expected QueryDone, got {other:?}"), + } + cmd_tx.send(WorkerCommand::Close).ok(); + handle.await.ok(); + } + + #[tokio::test] + async fn connect_failure_emits_connect_failed() { + let (cmd_tx, mut evt_rx, _cache, handle) = fixture(); + cmd_tx + .send(WorkerCommand::Connect { + name: "broken".into(), + url: "totally-not-a-real-url".into(), + }) + .expect("send Connect"); + match next_event(&mut evt_rx).await { + WorkerEvent::ConnectFailed { name, .. } => assert_eq!(name, "broken"), + other => panic!("expected ConnectFailed, got {other:?}"), + } + cmd_tx.send(WorkerCommand::Close).ok(); + handle.await.ok(); + } + + #[tokio::test] + async fn prime_completion_cache_populates_schema_cache() { + let (cmd_tx, mut evt_rx, cache, handle) = fixture(); + cmd_tx + .send(WorkerCommand::Connect { + name: "mem".into(), + url: "sqlite::memory:".into(), + }) + .expect("send Connect"); + // Drain the Connected event. + next_event(&mut evt_rx).await; + + // `Reload` emits the terminal `Reloaded` stage that signals the + // prime is done; `PrimeCompletionCache` walks the same stages but + // doesn't emit a terminal marker. + cmd_tx + .send(WorkerCommand::Reload { + connection: "mem".into(), + }) + .expect("send Reload"); + // Spin until we see the terminal Reloaded stage (or timeout in + // next_event). Other stages may arrive first; ignore them. + let mut saw_reloaded = false; + for _ in 0..16 { + match next_event(&mut evt_rx).await { + WorkerEvent::CompletionCacheStage { + stage: CacheStage::Reloaded, + } => { + saw_reloaded = true; + break; + } + _ => continue, + } + } + assert!(saw_reloaded, "expected CacheStage::Reloaded"); + + // Default sqlite database has one schema named "main". + let cache = cache.read().unwrap(); + assert!( + cache.schemas.values().any(|v| v.iter().any(|s| s == "main")), + "expected 'main' schema in primed cache; got {:?}", + cache.schemas, + ); + drop(cache); + + cmd_tx.send(WorkerCommand::Close).ok(); + handle.await.ok(); + } + + #[tokio::test] + async fn reset_session_without_connection_does_not_panic() { + let (cmd_tx, mut evt_rx, _cache, handle) = fixture(); + cmd_tx + .send(WorkerCommand::ResetSession) + .expect("send ResetSession"); + cmd_tx.send(WorkerCommand::Close).expect("send Close"); + handle.await.expect("worker task panicked"); + assert!(evt_rx.try_recv().is_err()); + } +} + async fn handle_introspect(datasource: &dyn Datasource, target: IntrospectTarget) -> WorkerEvent { let outcome = match &target { IntrospectTarget::Catalogs => datasource From ded7873a1fdf6ea877c25a6b3a2780728c435fe8 Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 15:13:18 -0300 Subject: [PATCH 06/10] =?UTF-8?q?test(event):=20overlay=20=C3=97=20screen?= =?UTF-8?q?=20routing=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds App-backed integration tests to src/event.rs covering the overlay precedence rules in translate_key: Help over Normal, Command over modal screens, Connecting swallowing all keys (but Ctrl+C still quits), ConfirmRun/UpdateAvailable/ConfirmToolUse key bindings, overlay swap mid-session, conn-form screen input routing, and bracketed-paste overlay routing. --- src/event.rs | 234 ++++++++++++++++++++++++++++++++++++++++++++++ src/worker/mod.rs | 89 +++++++++--------- 2 files changed, 280 insertions(+), 43 deletions(-) diff --git a/src/event.rs b/src/event.rs index a64cad3..62b3e1b 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1887,4 +1887,238 @@ mod tests { // Unknown keys yield None. assert!(translate_theme_picker_key(key(KeyCode::Char('x'))).is_none()); } + + // ----------------------------------------------------------------- + // Overlay × Screen routing matrix (App-backed integration tests) + // + // The pure translator tests above cover one branch at a time. These + // exercise `translate(&app, event)` end-to-end so a future refactor + // of the overlay-vs-screen precedence rules in `translate_key` + // breaks here rather than silently corrupting input routing. + // ----------------------------------------------------------------- + + use crate::app::App; + use crate::autocomplete::SchemaCache; + use crate::config::ConfigStore; + use crate::keybindings::keymap::Keymap; + use crate::log::Logger; + use crate::state::auth::{AuthKind, AuthState}; + use crate::state::command::CommandBuffer; + use crate::state::conn_form::ConnFormState; + use crate::state::overlay::ConfirmRunReason; + use crate::state::screen::Screen; + use crate::user_config::UserConfigStore; + use std::path::PathBuf; + use std::sync::{Arc, RwLock}; + + fn temp_dir(label: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "rowdy-event-{label}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn fixture_app() -> App { + use tokio::sync::mpsc; + let dir = temp_dir("matrix"); + let logger = Logger::open(&dir.join("test.log")).unwrap(); + let config = ConfigStore::load(&dir).unwrap(); + let user_config = UserConfigStore::empty(&dir); + let keymap = Arc::new(Keymap::defaults()); + let (cmd_tx, _cmd_rx) = mpsc::unbounded_channel(); + let (evt_tx, _evt_rx) = mpsc::unbounded_channel(); + let schema_cache = Arc::new(RwLock::new(SchemaCache::new())); + App::new( + cmd_tx, evt_tx, config, user_config, keymap, None, logger, dir, schema_cache, + ) + } + + fn dbg_action(a: Option) -> String { + a.map(|x| format!("{x:?}")).unwrap_or_else(|| "None".into()) + } + + /// `j` over Normal+Help routes to HelpScroll, not into the editor. + #[test] + fn help_overlay_preempts_normal_screen() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::Help { + scroll: 0, + h_scroll: 0, + }); + let action = translate(&app, CtEvent::Key(key(KeyCode::Char('j')))); + assert!( + matches_action(&action, "HelpScroll"), + "expected HelpScroll, got {}", + dbg_action(action) + ); + } + + /// Command overlay must intercept every printable key — even when + /// the underlying screen is something modal like ConnectionList. + #[test] + fn command_overlay_preempts_modal_screen() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::Command(CommandBuffer::default())); + app.screen = Screen::Auth(AuthState::new(AuthKind::FirstSetup)); + let action = translate(&app, CtEvent::Key(key(KeyCode::Char('q')))); + assert!( + matches_action(&action, "Command"), + "expected Action::Command(_), got {}", + dbg_action(action) + ); + } + + /// `Connecting` is in-flight — keys are inert, period. + #[test] + fn connecting_overlay_swallows_all_keys() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::Connecting { name: "x".into() }); + // Plain `q`, arrow keys, and Enter all yield None. + for k in [ + key(KeyCode::Char('q')), + key(KeyCode::Enter), + key(KeyCode::Esc), + key(KeyCode::Down), + ] { + assert!( + translate(&app, CtEvent::Key(k)).is_none(), + "Connecting must not produce an action for {k:?}" + ); + } + } + + /// Ctrl+C must still quit even when Connecting is up — the user + /// should never be stranded with no way out of a stuck connect. + #[test] + fn ctrl_c_still_quits_under_connecting_overlay() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::Connecting { name: "x".into() }); + let action = translate(&app, CtEvent::Key(ctrl(KeyCode::Char('c')))); + assert!(matches!(action, Some(Action::Quit))); + } + + /// ConfirmRun overlay: `Enter` → submit, `Esc` → cancel. Bare + /// letters intentionally don't bind (avoids accidental confirm on + /// editor-style muscle memory). + #[test] + fn confirm_run_overlay_enter_and_esc() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::ConfirmRun { + statement: "DELETE FROM t".into(), + reason: ConfirmRunReason::Manual, + }); + assert!(matches!( + translate(&app, CtEvent::Key(key(KeyCode::Enter))), + Some(Action::ConfirmRunSubmit) + )); + assert!(matches!( + translate(&app, CtEvent::Key(key(KeyCode::Esc))), + Some(Action::ConfirmRunCancel) + )); + // Bare `y` / `n` are NOT bound — must yield None. + assert!(translate(&app, CtEvent::Key(key(KeyCode::Char('y')))).is_none()); + assert!(translate(&app, CtEvent::Key(key(KeyCode::Char('n')))).is_none()); + } + + /// UpdateAvailable overlay: `y` → accept, `n`/`Esc` → dismiss. + #[test] + fn update_overlay_y_and_n() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::UpdateAvailable { + current: "0.1".into(), + latest: "0.2".into(), + }); + assert!(matches!( + translate(&app, CtEvent::Key(key(KeyCode::Char('y')))), + Some(Action::UpdateAccept) + )); + assert!(matches!( + translate(&app, CtEvent::Key(key(KeyCode::Char('n')))), + Some(Action::UpdateDismiss) + )); + assert!(matches!( + translate(&app, CtEvent::Key(key(KeyCode::Esc))), + Some(Action::UpdateDismiss) + )); + } + + /// ConfirmToolUse routes y/n to the dedicated tool-approve actions — + /// must not collide with ConfirmRun's y/n. + #[test] + fn tool_confirm_overlay_uses_dedicated_actions() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::ConfirmToolUse { + call_id: "id".into(), + name: "fs.read".into(), + args_json: "{}".into(), + }); + assert!(matches!( + translate(&app, CtEvent::Key(key(KeyCode::Char('y')))), + Some(Action::ToolApproveAccept) + )); + assert!(matches!( + translate(&app, CtEvent::Key(key(KeyCode::Char('n')))), + Some(Action::ToolApproveDeny) + )); + } + + /// Help-over-Help-over-ConfirmRun isn't a thing — App holds one + /// overlay at a time — but we still want to verify the dispatch picks + /// the *currently set* overlay's translator on every call (no stale + /// caching). Swap the overlay between calls and confirm both routes + /// fire. + #[test] + fn overlay_swap_redirects_routing_immediately() { + let mut app = fixture_app(); + app.overlay = Some(Overlay::Help { + scroll: 0, + h_scroll: 0, + }); + assert!(matches_action( + &translate(&app, CtEvent::Key(key(KeyCode::Char('j')))), + "HelpScroll", + )); + app.overlay = Some(Overlay::Command(CommandBuffer::default())); + assert!(matches_action( + &translate(&app, CtEvent::Key(key(KeyCode::Char('j')))), + "Command", + )); + } + + /// EditConnection screen with no overlay: typing characters routes + /// into the conn-form handler, not into Normal-mode handlers. + #[test] + fn conn_form_screen_consumes_input_when_no_overlay() { + let mut app = fixture_app(); + app.screen = Screen::EditConnection(ConnFormState::new_create()); + let action = translate(&app, CtEvent::Key(key(KeyCode::Char('a')))); + assert!( + matches_action(&action, "ConnForm"), + "expected ConnForm action, got {}", + dbg_action(action) + ); + } + + /// Bracketed paste lands in Command overlay first, then falls back + /// to the editor on Normal screen. Help intercepts (drops) paste so + /// it doesn't leak text under the popover. + #[test] + fn paste_routing_respects_overlay_precedence() { + let mut app = fixture_app(); + // Command overlay → routes to Command(Paste). + app.overlay = Some(Overlay::Command(CommandBuffer::default())); + assert!(matches_action( + &translate(&app, CtEvent::Paste("hi".into())), + "Command(Paste", + )); + // Help overlay → swallows paste (returns None) so the editor + // underneath doesn't get unexpected text. + app.overlay = Some(Overlay::Help { + scroll: 0, + h_scroll: 0, + }); + assert!(translate(&app, CtEvent::Paste("hi".into())).is_none()); + } } diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 8ca6f32..8d144fa 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -649,6 +649,44 @@ async fn prime_cache( } } + +async fn handle_introspect(datasource: &dyn Datasource, target: IntrospectTarget) -> WorkerEvent { + let outcome = match &target { + IntrospectTarget::Catalogs => datasource + .introspect_catalogs() + .await + .map(SchemaPayload::Catalogs), + IntrospectTarget::Schemas { catalog } => datasource + .introspect_schemas(catalog) + .await + .map(SchemaPayload::Schemas), + IntrospectTarget::Tables { catalog, schema } => datasource + .introspect_tables(catalog, schema) + .await + .map(SchemaPayload::Tables), + IntrospectTarget::Columns { + catalog, + schema, + table, + } => datasource + .introspect_columns(catalog, schema, table) + .await + .map(SchemaPayload::Columns), + IntrospectTarget::Indices { + catalog, + schema, + table, + } => datasource + .introspect_indices(catalog, schema, table) + .await + .map(SchemaPayload::Indices), + }; + match outcome { + Ok(payload) => WorkerEvent::SchemaLoaded { target, payload }, + Err(error) => WorkerEvent::SchemaFailed { target, error }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -845,13 +883,14 @@ mod tests { assert!(saw_reloaded, "expected CacheStage::Reloaded"); // Default sqlite database has one schema named "main". - let cache = cache.read().unwrap(); - assert!( - cache.schemas.values().any(|v| v.iter().any(|s| s == "main")), - "expected 'main' schema in primed cache; got {:?}", - cache.schemas, - ); - drop(cache); + { + let guard = cache.read().unwrap(); + assert!( + guard.schemas.values().any(|v| v.iter().any(|s| s == "main")), + "expected 'main' schema in primed cache; got {:?}", + guard.schemas, + ); + } cmd_tx.send(WorkerCommand::Close).ok(); handle.await.ok(); @@ -869,39 +908,3 @@ mod tests { } } -async fn handle_introspect(datasource: &dyn Datasource, target: IntrospectTarget) -> WorkerEvent { - let outcome = match &target { - IntrospectTarget::Catalogs => datasource - .introspect_catalogs() - .await - .map(SchemaPayload::Catalogs), - IntrospectTarget::Schemas { catalog } => datasource - .introspect_schemas(catalog) - .await - .map(SchemaPayload::Schemas), - IntrospectTarget::Tables { catalog, schema } => datasource - .introspect_tables(catalog, schema) - .await - .map(SchemaPayload::Tables), - IntrospectTarget::Columns { - catalog, - schema, - table, - } => datasource - .introspect_columns(catalog, schema, table) - .await - .map(SchemaPayload::Columns), - IntrospectTarget::Indices { - catalog, - schema, - table, - } => datasource - .introspect_indices(catalog, schema, table) - .await - .map(SchemaPayload::Indices), - }; - match outcome { - Ok(payload) => WorkerEvent::SchemaLoaded { target, payload }, - Err(error) => WorkerEvent::SchemaFailed { target, error }, - } -} From 35d33002f7d31be9121b46b75fdc8e62f3c0e44b Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 15:17:52 -0300 Subject: [PATCH 07/10] refactor(llm/tools): extract tool schema definitions into submodule Moves the large `all()` schema-vector and the `function_tool()` builder into src/llm/tools/schemas.rs. `tools.rs` keeps the constants, mode filtering, and dispatch logic; the prompt-engineering surface lives next door so wording tweaks don't clutter the dispatcher's git log. tools.rs: 1638 -> 1392 lines. --- src/app.rs | 10 ++ src/llm/tools.rs | 258 +------------------------------------ src/llm/tools/schemas.rs | 270 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 252 deletions(-) create mode 100644 src/llm/tools/schemas.rs diff --git a/src/app.rs b/src/app.rs index 17d3144..ce93a50 100644 --- a/src/app.rs +++ b/src/app.rs @@ -121,6 +121,16 @@ pub struct App { /// connect / `:reload` / lazy column loads; the engine reads here on /// every popover open. `Arc>` so the worker and the main /// loop can both hold handles without cloning the contents. + /// + /// **Lock-ordering contract.** Writers: only the worker task + /// (`spawn_prime_cache` / `spawn_load_columns` write under `.write()`; + /// the main-loop action handlers only ever take `.read()`). The lock + /// is never held across an `.await` and never nested with any other + /// lock — every call site takes the guard, mutates or reads, and + /// drops it within the same synchronous block. Keep it that way: if + /// you add a writer on the main loop, do it from `apply_*` (sync, + /// before any worker dispatch) and not from an async path, or + /// reader/writer contention will start showing up under heavy DDL. pub schema_cache: Arc>, /// Set while a `WorkerCommand::Reload` is in flight (sent but the /// terminal `CacheStage::Reloaded` event hasn't arrived yet). Used diff --git a/src/llm/tools.rs b/src/llm/tools.rs index 9224cde..7b86239 100644 --- a/src/llm/tools.rs +++ b/src/llm/tools.rs @@ -41,7 +41,8 @@ //! the worker channel, calls [`dispatch`], and replies via oneshot. //! No tool reaches into the network or spawns its own task. -use std::collections::HashMap; +mod schemas; + use std::fs; use std::path::{Path, PathBuf}; @@ -49,7 +50,7 @@ use grep_regex::RegexMatcherBuilder; use grep_searcher::sinks::UTF8; use grep_searcher::{BinaryDetection, SearcherBuilder}; use ignore::WalkBuilder; -use llm::chat::{FunctionTool, ParameterProperty, ParametersSchema, Tool}; +use llm::chat::Tool; use serde::Serialize; use serde_json::Value; @@ -104,222 +105,10 @@ const READ_BUFFER_DEFAULT_LIMIT: usize = 200; const READ_BUFFER_MAX_LIMIT: usize = 1000; /// All tools registered with the LLM, ready to pass to -/// `LLMProvider::chat_stream_with_tools`. Built from the public -/// `Tool`/`FunctionTool` types so we don't depend on `FunctionBuilder`'s -/// private `build()`. Use [`for_mode`] to filter the list according -/// to the user's `ReadToolsMode` preference. +/// `LLMProvider::chat_stream_with_tools`. Use [`for_mode`] to filter +/// the list according to the user's `ReadToolsMode` preference. pub fn all() -> Vec { - vec![ - function_tool( - LIST_CATALOGS, - "List the catalogs (databases) available on the active connection. \ - No arguments. Returns { catalogs: [string] }.", - &[], - &[], - ), - function_tool( - LIST_SCHEMAS, - "List the schemas (namespaces) inside a catalog. \ - Returns { schemas: [string] } (empty if the catalog is unknown \ - or its schemas haven't been loaded yet).", - &[( - "catalog", - "string", - "Catalog name. Use list_catalogs to discover.", - )], - &["catalog"], - ), - function_tool( - LIST_TABLES, - "List the tables and views inside a (catalog, schema). \ - Returns { tables: [{name, kind}] } where kind is 'table' or 'view'.", - &[ - ("catalog", "string", "Catalog name."), - ("schema", "string", "Schema name."), - ], - &["catalog", "schema"], - ), - function_tool( - DESCRIBE_TABLE, - "Get column names + types for a (catalog, schema, table). \ - Returns { columns: [{name, type}] }. Auto-loads the table's \ - columns on first use. If introspection fails the response \ - includes a `note` describing why — pass that to the user.", - &[ - ("catalog", "string", "Catalog name."), - ("schema", "string", "Schema name."), - ("table", "string", "Table or view name."), - ], - &["catalog", "schema", "table"], - ), - function_tool( - READ_BUFFER, - "Read the user's SQL editor buffer (their working SQL file — a \ - scratchpad with multiple queries, comments, and \ - work-in-progress they iterate on and run). Paginated: returns \ - { text, start_line, end_line, total_lines, remaining_lines }. \ - `text` carries the lines from `start_line` through `end_line` \ - joined with '\\n'. If `remaining_lines > 0`, call again with \ - `start_line = end_line + 1` to keep paging until you've seen \ - all of it. ALWAYS read the full buffer before any \ - write_buffer call: you need to know what queries the user \ - has there so you don't overwrite their work.", - &[ - ( - "start_line", - "integer", - "1-indexed line to start reading at. Defaults to 1.", - ), - ( - "limit", - "integer", - "Maximum number of lines to return. Defaults to 200, capped at 1000.", - ), - ], - &[], - ), - function_tool( - WRITE_BUFFER, - "Splice a snippet into the user's SQL editor buffer (find / \ - replace). `search` must match exactly once in the eligible \ - region — zero or multiple matches return an error and you \ - must extend `search` with more surrounding context. Returns \ - { ok: true, line } where `line` is the 1-indexed start line \ - of the replacement. \ - \ - The buffer is the user's working SQL file — it usually \ - contains queries they wrote and are iterating on. Treat \ - everything you didn't author this session as theirs; do NOT \ - delete or overwrite it. \ - \ - Correct uses: \ - (1) editing SQL you wrote earlier this session; \ - (2) rewriting a snippet the user explicitly asked you to \ - rewrite — point `search` at exactly that snippet, not at \ - unrelated surrounding content; \ - (3) ADDING a new query alongside existing user SQL — pick a \ - small anchor near the end of the buffer (e.g. the final `;` \ - of the last query, or the trailing newline) as `search`, and \ - set `replacement` to that same anchor followed by a blank \ - line and your new SQL. \ - \ - Anti-patterns (do NOT do these): setting `search` to the \ - entire buffer to overwrite everything; replacing the user's \ - existing queries to make room for yours; calling write_buffer \ - without first reading the buffer end-to-end. \ - \ - The user reviews and runs the SQL themselves — you do NOT \ - execute. Never paste SQL in chat as a substitute; if a write \ - fails, retry with a more specific snippet.", - &[ - ( - "search", - "string", - "Exact substring already present in the buffer. Include \ - enough surrounding context to make it match exactly once. \ - To append new SQL alongside existing user queries, use a \ - small anchor at the end of the buffer (e.g. the last `;` \ - plus newline) — do NOT set this to the entire buffer.", - ), - ( - "replacement", - "string", - "Text to substitute in place of `search`. To append, set \ - this to the anchor + blank line + your new SQL so the \ - anchor is preserved and your SQL lands after it.", - ), - ( - "start_line", - "integer", - "Optional 1-indexed line; only consider matches whose \ - start byte is at or after the start of this line.", - ), - ], - &["search", "replacement"], - ), - function_tool( - READ_FILE, - "Read a file from the user's project (the directory rowdy was \ - launched from). Paginated like read_buffer: returns \ - { text, start_line, end_line, total_lines, remaining_lines }. \ - Path is relative to the project root. `.env` files (and any \ - .env.* variant) are off-limits — the call will return a \ - refusal and you should NOT retry. Use this to ground SQL \ - suggestions in the user's real schema definitions: \ - migrations, ORM models, schema files, string-builder SQL. \ - Prefer grep_files first if you don't yet know which file \ - holds what you need.", - &[ - ("path", "string", "Path relative to the project root."), - ( - "start_line", - "integer", - "1-indexed line to start at. Defaults to 1.", - ), - ( - "limit", - "integer", - "Max lines to return. Defaults to 200, capped at 1000.", - ), - ], - &["path"], - ), - function_tool( - LIST_DIRECTORY, - "List the contents of a directory inside the user's project. \ - Returns { entries: [{name, kind}] } where kind is 'file', \ - 'dir', or 'symlink'. Path is relative to the project root; \ - omit it (or pass an empty string) to list the project root \ - itself. `.env*` files are filtered out — neither their \ - names nor contents are exposed.", - &[( - "path", - "string", - "Optional directory path relative to the project root. \ - Empty / omitted lists the root.", - )], - &[], - ), - function_tool( - GREP_FILES, - "Search the user's project for a regex pattern (Rust regex \ - syntax — same flavor ripgrep uses). Walks the project \ - respecting .gitignore, .ignore, and .git/info/exclude — so \ - target/, node_modules/, build artefacts, and other \ - gitignored noise are skipped automatically. Returns \ - { matches: [{path, line, text}], truncated: bool }. \ - Use this to find migration files, table definitions, query \ - strings in app code, fixture/seed scripts, etc., before you \ - draft SQL or claim a column exists.", - &[ - ( - "pattern", - "string", - "Regex pattern. Use (?i) at the start for \ - case-insensitive matching, or set case_insensitive=true.", - ), - ( - "path", - "string", - "Optional subdirectory to confine the search to, \ - relative to the project root.", - ), - ( - "case_insensitive", - "boolean", - "If true, the pattern matches case-insensitively. \ - Defaults to false.", - ), - ( - "max_matches", - "integer", - "Cap on total matches returned. Defaults to 100, \ - capped at 500.", - ), - ], - &["pattern"], - ), - ] + schemas::all() } /// Tool list filtered by the user's read-tools preference. When @@ -340,41 +129,6 @@ pub fn for_mode(mode: crate::user_config::ReadToolsMode) -> Vec { } } -/// Build one `Tool` value — name, description, parameters schema, required -/// list. `params` is `(name, json-type, description)` triples. -fn function_tool( - name: &str, - description: &str, - params: &[(&str, &str, &str)], - required: &[&str], -) -> Tool { - let mut properties: HashMap = HashMap::new(); - for (pname, ptype, pdesc) in params { - properties.insert( - (*pname).to_string(), - ParameterProperty { - property_type: (*ptype).to_string(), - description: (*pdesc).to_string(), - items: None, - enum_list: None, - }, - ); - } - let schema = ParametersSchema { - schema_type: "object".to_string(), - properties, - required: required.iter().map(|s| (*s).to_string()).collect(), - }; - Tool { - tool_type: "function".to_string(), - function: FunctionTool { - name: name.to_string(), - description: description.to_string(), - parameters: serde_json::to_value(schema).unwrap_or(Value::Null), - }, - cache_control: None, - } -} /// True when `name` reads from the in-memory schema cache. The chat /// dispatcher uses this to decide whether a cache miss should trigger an diff --git a/src/llm/tools/schemas.rs b/src/llm/tools/schemas.rs new file mode 100644 index 0000000..19f5e38 --- /dev/null +++ b/src/llm/tools/schemas.rs @@ -0,0 +1,270 @@ +//! Tool schema definitions handed to the LLM provider. Kept separate +//! from the dispatch logic in `tools.rs` because these descriptions are +//! prompt-engineering surface, not control flow — most edits in here +//! are wording tweaks, and isolating them keeps the noisy churn out of +//! the dispatcher's git log. + +use std::collections::HashMap; + +use llm::chat::{FunctionTool, ParameterProperty, ParametersSchema, Tool}; +use serde_json::Value; + +use super::{ + DESCRIBE_TABLE, GREP_FILES, LIST_CATALOGS, LIST_DIRECTORY, LIST_SCHEMAS, LIST_TABLES, + READ_BUFFER, READ_FILE, WRITE_BUFFER, +}; + +/// All tools registered with the LLM, ready to pass to +/// `LLMProvider::chat_stream_with_tools`. Built from the public +/// `Tool`/`FunctionTool` types so we don't depend on `FunctionBuilder`'s +/// private `build()`. The caller filters this list by +/// [`crate::user_config::ReadToolsMode`] in `super::for_mode`. +pub(super) fn all() -> Vec { + vec![ + function_tool( + LIST_CATALOGS, + "List the catalogs (databases) available on the active connection. \ + No arguments. Returns { catalogs: [string] }.", + &[], + &[], + ), + function_tool( + LIST_SCHEMAS, + "List the schemas (namespaces) inside a catalog. \ + Returns { schemas: [string] } (empty if the catalog is unknown \ + or its schemas haven't been loaded yet).", + &[( + "catalog", + "string", + "Catalog name. Use list_catalogs to discover.", + )], + &["catalog"], + ), + function_tool( + LIST_TABLES, + "List the tables and views inside a (catalog, schema). \ + Returns { tables: [{name, kind}] } where kind is 'table' or 'view'.", + &[ + ("catalog", "string", "Catalog name."), + ("schema", "string", "Schema name."), + ], + &["catalog", "schema"], + ), + function_tool( + DESCRIBE_TABLE, + "Get column names + types for a (catalog, schema, table). \ + Returns { columns: [{name, type}] }. Auto-loads the table's \ + columns on first use. If introspection fails the response \ + includes a `note` describing why — pass that to the user.", + &[ + ("catalog", "string", "Catalog name."), + ("schema", "string", "Schema name."), + ("table", "string", "Table or view name."), + ], + &["catalog", "schema", "table"], + ), + function_tool( + READ_BUFFER, + "Read the user's SQL editor buffer (their working SQL file — a \ + scratchpad with multiple queries, comments, and \ + work-in-progress they iterate on and run). Paginated: returns \ + { text, start_line, end_line, total_lines, remaining_lines }. \ + `text` carries the lines from `start_line` through `end_line` \ + joined with '\\n'. If `remaining_lines > 0`, call again with \ + `start_line = end_line + 1` to keep paging until you've seen \ + all of it. ALWAYS read the full buffer before any \ + write_buffer call: you need to know what queries the user \ + has there so you don't overwrite their work.", + &[ + ( + "start_line", + "integer", + "1-indexed line to start reading at. Defaults to 1.", + ), + ( + "limit", + "integer", + "Maximum number of lines to return. Defaults to 200, capped at 1000.", + ), + ], + &[], + ), + function_tool( + WRITE_BUFFER, + "Splice a snippet into the user's SQL editor buffer (find / \ + replace). `search` must match exactly once in the eligible \ + region — zero or multiple matches return an error and you \ + must extend `search` with more surrounding context. Returns \ + { ok: true, line } where `line` is the 1-indexed start line \ + of the replacement. \ + \ + The buffer is the user's working SQL file — it usually \ + contains queries they wrote and are iterating on. Treat \ + everything you didn't author this session as theirs; do NOT \ + delete or overwrite it. \ + \ + Correct uses: \ + (1) editing SQL you wrote earlier this session; \ + (2) rewriting a snippet the user explicitly asked you to \ + rewrite — point `search` at exactly that snippet, not at \ + unrelated surrounding content; \ + (3) ADDING a new query alongside existing user SQL — pick a \ + small anchor near the end of the buffer (e.g. the final `;` \ + of the last query, or the trailing newline) as `search`, and \ + set `replacement` to that same anchor followed by a blank \ + line and your new SQL. \ + \ + Anti-patterns (do NOT do these): setting `search` to the \ + entire buffer to overwrite everything; replacing the user's \ + existing queries to make room for yours; calling write_buffer \ + without first reading the buffer end-to-end. \ + \ + The user reviews and runs the SQL themselves — you do NOT \ + execute. Never paste SQL in chat as a substitute; if a write \ + fails, retry with a more specific snippet.", + &[ + ( + "search", + "string", + "Exact substring already present in the buffer. Include \ + enough surrounding context to make it match exactly once. \ + To append new SQL alongside existing user queries, use a \ + small anchor at the end of the buffer (e.g. the last `;` \ + plus newline) — do NOT set this to the entire buffer.", + ), + ( + "replacement", + "string", + "Text to substitute in place of `search`. To append, set \ + this to the anchor + blank line + your new SQL so the \ + anchor is preserved and your SQL lands after it.", + ), + ( + "start_line", + "integer", + "Optional 1-indexed line; only consider matches whose \ + start byte is at or after the start of this line.", + ), + ], + &["search", "replacement"], + ), + function_tool( + READ_FILE, + "Read a file from the user's project (the directory rowdy was \ + launched from). Paginated like read_buffer: returns \ + { text, start_line, end_line, total_lines, remaining_lines }. \ + Path is relative to the project root. `.env` files (and any \ + .env.* variant) are off-limits — the call will return a \ + refusal and you should NOT retry. Use this to ground SQL \ + suggestions in the user's real schema definitions: \ + migrations, ORM models, schema files, string-builder SQL. \ + Prefer grep_files first if you don't yet know which file \ + holds what you need.", + &[ + ("path", "string", "Path relative to the project root."), + ( + "start_line", + "integer", + "1-indexed line to start at. Defaults to 1.", + ), + ( + "limit", + "integer", + "Max lines to return. Defaults to 200, capped at 1000.", + ), + ], + &["path"], + ), + function_tool( + LIST_DIRECTORY, + "List the contents of a directory inside the user's project. \ + Returns { entries: [{name, kind}] } where kind is 'file', \ + 'dir', or 'symlink'. Path is relative to the project root; \ + omit it (or pass an empty string) to list the project root \ + itself. `.env*` files are filtered out — neither their \ + names nor contents are exposed.", + &[( + "path", + "string", + "Optional directory path relative to the project root. \ + Empty / omitted lists the root.", + )], + &[], + ), + function_tool( + GREP_FILES, + "Search the user's project for a regex pattern (Rust regex \ + syntax — same flavor ripgrep uses). Walks the project \ + respecting .gitignore, .ignore, and .git/info/exclude — so \ + target/, node_modules/, build artefacts, and other \ + gitignored noise are skipped automatically. Returns \ + { matches: [{path, line, text}], truncated: bool }. \ + Use this to find migration files, table definitions, query \ + strings in app code, fixture/seed scripts, etc., before you \ + draft SQL or claim a column exists.", + &[ + ( + "pattern", + "string", + "Regex pattern. Use (?i) at the start for \ + case-insensitive matching, or set case_insensitive=true.", + ), + ( + "path", + "string", + "Optional subdirectory to confine the search to, \ + relative to the project root.", + ), + ( + "case_insensitive", + "boolean", + "If true, the pattern matches case-insensitively. \ + Defaults to false.", + ), + ( + "max_matches", + "integer", + "Cap on total matches returned. Defaults to 100, \ + capped at 500.", + ), + ], + &["pattern"], + ), + ] +} + +/// Build one `Tool` value — name, description, parameters schema, required +/// list. `params` is `(name, json-type, description)` triples. +fn function_tool( + name: &str, + description: &str, + params: &[(&str, &str, &str)], + required: &[&str], +) -> Tool { + let mut properties: HashMap = HashMap::new(); + for (pname, ptype, pdesc) in params { + properties.insert( + (*pname).to_string(), + ParameterProperty { + property_type: (*ptype).to_string(), + description: (*pdesc).to_string(), + items: None, + enum_list: None, + }, + ); + } + let schema = ParametersSchema { + schema_type: "object".to_string(), + properties, + required: required.iter().map(|s| (*s).to_string()).collect(), + }; + Tool { + tool_type: "function".to_string(), + function: FunctionTool { + name: name.to_string(), + description: description.to_string(), + parameters: serde_json::to_value(schema).unwrap_or(Value::Null), + }, + cache_control: None, + } +} From 772f92f2e5d8b7cd2e891f6dd088e983a55db3a8 Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 15:19:30 -0300 Subject: [PATCH 08/10] test(autocomplete/context): subquery, UNION, dialect-quoting coverage Adds 9 parameterized tests for the gaps called out in the code review: subquery-in-FROM, subquery-in-JOIN, scalar subquery in projection, UNION/EXCEPT/INTERSECT binding scope, three-way join alias collection, chained CTEs in outer scope, PostgreSQL double-quoted identifiers, MySQL backtick identifiers. --- src/autocomplete/context.rs | 157 ++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/src/autocomplete/context.rs b/src/autocomplete/context.rs index d0b202e..c8359a4 100644 --- a/src/autocomplete/context.rs +++ b/src/autocomplete/context.rs @@ -1633,4 +1633,161 @@ mod tests { let cte = r.bindings.iter().find(|b| b.table == "u").unwrap(); assert_eq!(cte.synthetic_columns.as_deref(), Some([].as_slice())); } + + // ----------------------------------------------------------------- + // Nested subqueries / UNION / dialect quoting edge cases + // (review gap: parameterized coverage for these areas) + // ----------------------------------------------------------------- + + fn classify_with_dialect(stmt: &str, cursor: usize, dialect: DriverKind) -> ClassifyResult { + classify( + stmt, + cursor, + dialect, + ResolveContext { + default_catalog: Some("main"), + default_schema: Some("main"), + }, + &SchemaCache::new(), + ) + } + + /// Cursor in the outer SELECT of `SELECT … FROM (SELECT … FROM users) AS u` + /// must see the derived-table alias `u`, not the inner `users`. The + /// inner FROM is shadowed by the subquery boundary. + #[test] + fn subquery_in_from_exposes_only_outer_alias() { + let stmt = "SELECT FROM (SELECT id FROM users) AS u"; + let cursor = stmt.find(" FROM").unwrap() + 1; + let r = classify_at(stmt, cursor); + let names: Vec<&str> = r.bindings.iter().map(|b| b.table.as_str()).collect(); + assert!( + names.contains(&"u"), + "outer query should see derived alias 'u'; got {names:?}" + ); + } + + /// Subquery in JOIN position — the alias is bound, inner tables stay hidden. + #[test] + fn subquery_in_join_position_binds_alias() { + let stmt = + "SELECT * FROM users u JOIN (SELECT post_id FROM posts) p ON p.post_id = u.id WHERE "; + let r = classify_at_end(stmt); + let names: Vec<&str> = r.bindings.iter().map(|b| b.table.as_str()).collect(); + assert!(names.contains(&"users"), "{names:?}"); + assert!( + names.contains(&"p"), + "subquery alias 'p' should be a binding; got {names:?}" + ); + } + + /// `SELECT u. FROM users u` works; the qualifier should + /// still resolve even when a scalar subquery sits in the projection. + #[test] + fn scalar_subquery_in_projection_does_not_break_outer_alias() { + let stmt = + "SELECT u.id, (SELECT count(*) FROM posts) AS n, u. FROM users u WHERE u.id > 0"; + let cursor = stmt.rfind("u.").unwrap() + 2; + let r = classify_at(stmt, cursor); + match r.context { + CompletionContext::Column { + qualifier: Some(b), + } => assert_eq!(b.table, "users"), + other => panic!("expected u.* to resolve to users; got {other:?}"), + } + } + + /// `UNION` shouldn't fuse the two FROM clauses into one binding set + /// at the cursor — but it also shouldn't lose the alias visible in + /// the arm containing the cursor. + #[test] + fn union_arm_keeps_local_binding_in_scope() { + let stmt = "SELECT id FROM users WHERE UNION SELECT id FROM admins"; + let cursor = stmt.find("WHERE ").unwrap() + 6; + let r = classify_at(stmt, cursor); + let names: Vec<&str> = r.bindings.iter().map(|b| b.table.as_str()).collect(); + assert!( + names.contains(&"users"), + "WHERE in the left arm must see 'users'; got {names:?}" + ); + } + + /// PostgreSQL `"Quoted"` identifiers must round-trip through the + /// tokenizer when the parser is set to Postgres — the binding's + /// table name should match the unquoted form. + #[test] + fn postgres_double_quoted_identifier_binds() { + let stmt = "SELECT * FROM \"Users\" u WHERE u."; + let cursor = stmt.len(); + let r = classify_with_dialect(stmt, cursor, DriverKind::Postgres); + match r.context { + CompletionContext::Column { + qualifier: Some(b), + } => assert_eq!(b.table, "Users"), + other => panic!("expected u.* qualified-by-Users; got {other:?}"), + } + } + + /// MySQL backtick identifiers must round-trip when the parser is set + /// to MySQL. `\`Users\`` aliased as `u` should still resolve via u. + #[test] + fn mysql_backtick_identifier_binds() { + let stmt = "SELECT * FROM `Users` u WHERE u."; + let cursor = stmt.len(); + let r = classify_with_dialect(stmt, cursor, DriverKind::Mysql); + match r.context { + CompletionContext::Column { + qualifier: Some(b), + } => assert_eq!(b.table, "Users"), + other => panic!("expected u.* qualified-by-Users; got {other:?}"), + } + } + + /// Three-way join with chained aliases: every alias on the path to + /// the cursor must be collected. + #[test] + fn three_way_join_collects_all_aliases() { + let stmt = "SELECT * FROM a JOIN b ON a.id = b.a_id JOIN c ON c.b_id = b.id WHERE "; + let r = classify_at_end(stmt); + let names: Vec<&str> = r.bindings.iter().map(|b| b.table.as_str()).collect(); + for t in ["a", "b", "c"] { + assert!(names.contains(&t), "missing {t} in {names:?}"); + } + } + + /// Nested CTEs: `WITH a AS (…), b AS (SELECT … FROM a) SELECT … + /// FROM b` — both `a` and `b` should be CTE bindings in the outer + /// scope. + #[test] + fn chained_ctes_both_collected_in_outer_scope() { + let stmt = + "WITH a AS (SELECT id FROM users), b AS (SELECT id FROM a) SELECT * FROM b WHERE "; + let r = classify_at_end(stmt); + let ctes: Vec<&str> = r + .bindings + .iter() + .filter(|b| b.is_cte) + .map(|b| b.table.as_str()) + .collect(); + assert!(ctes.contains(&"a"), "{ctes:?}"); + assert!(ctes.contains(&"b"), "{ctes:?}"); + } + + /// `EXCEPT` and `INTERSECT` behave like `UNION` for binding scope. + /// Smoke-test the parser doesn't drop bindings when these keywords + /// appear (regression guard — they used to bail out of the + /// collector early). + #[test] + fn except_and_intersect_do_not_lose_bindings() { + for kw in ["EXCEPT", "INTERSECT"] { + let stmt = format!("SELECT id FROM users WHERE {kw} SELECT id FROM admins"); + let cursor = stmt.find("WHERE ").unwrap() + 6; + let r = classify_at(&stmt, cursor); + let names: Vec<&str> = r.bindings.iter().map(|b| b.table.as_str()).collect(); + assert!( + names.contains(&"users"), + "{kw}: WHERE arm must keep 'users'; got {names:?}" + ); + } + } } From 7f09b539f821c1a182eecd848f8d8b10fd0a8095 Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 15:31:01 -0300 Subject: [PATCH 09/10] chore: bump version to 0.16.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d52614..796a8cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "rowdy" -version = "0.16.2" +version = "0.16.3" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 1683448..7ce398b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rowdy" -version = "0.16.2" +version = "0.16.3" edition = "2024" rust-version = "1.86" license = "MIT" From 8188649a3c47f4863921a4b58e75b1a31904e4fc Mon Sep 17 00:00:00 2001 From: Clemento Date: Mon, 25 May 2026 16:00:47 -0300 Subject: [PATCH 10/10] chore: cargo fmt --- src/action/mod.rs | 9 +++++++-- src/autocomplete/context.rs | 15 ++++----------- src/event.rs | 15 ++++++++++----- src/llm/tools.rs | 1 - src/worker/mod.rs | 7 ++++--- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/action/mod.rs b/src/action/mod.rs index 6bc11e1..0e0e2fb 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -780,7 +780,10 @@ fn dispatch_command(app: &mut App, cmd: command::Command) { C::Source => apply(app, Action::Source), C::Conn(sub) => dispatch_conn(app, sub), C::Chat(sub) => dispatch_chat(app, sub), - C::Session(sub) => apply(app, Action::Session(session::session_subcommand_to_action(sub))), + C::Session(sub) => apply( + app, + Action::Session(session::session_subcommand_to_action(sub)), + ), C::Update => apply(app, Action::CheckForUpdate), } } @@ -1001,7 +1004,9 @@ fn apply_theme_picker(app: &mut App, action: ThemePickerAction) { fn apply_worker_event(app: &mut App, event: WorkerEvent) { match event { WorkerEvent::QueryDone { req, result } => query::on_query_done(app, req, result), - WorkerEvent::QueryFailed { req, error } => query::on_query_failed(app, req, error.to_string()), + WorkerEvent::QueryFailed { req, error } => { + query::on_query_failed(app, req, error.to_string()) + } WorkerEvent::SchemaLoaded { target, payload } => { schema::on_schema_loaded(app, target, payload) } diff --git a/src/autocomplete/context.rs b/src/autocomplete/context.rs index c8359a4..3416b24 100644 --- a/src/autocomplete/context.rs +++ b/src/autocomplete/context.rs @@ -1685,14 +1685,11 @@ mod tests { /// still resolve even when a scalar subquery sits in the projection. #[test] fn scalar_subquery_in_projection_does_not_break_outer_alias() { - let stmt = - "SELECT u.id, (SELECT count(*) FROM posts) AS n, u. FROM users u WHERE u.id > 0"; + let stmt = "SELECT u.id, (SELECT count(*) FROM posts) AS n, u. FROM users u WHERE u.id > 0"; let cursor = stmt.rfind("u.").unwrap() + 2; let r = classify_at(stmt, cursor); match r.context { - CompletionContext::Column { - qualifier: Some(b), - } => assert_eq!(b.table, "users"), + CompletionContext::Column { qualifier: Some(b) } => assert_eq!(b.table, "users"), other => panic!("expected u.* to resolve to users; got {other:?}"), } } @@ -1721,9 +1718,7 @@ mod tests { let cursor = stmt.len(); let r = classify_with_dialect(stmt, cursor, DriverKind::Postgres); match r.context { - CompletionContext::Column { - qualifier: Some(b), - } => assert_eq!(b.table, "Users"), + CompletionContext::Column { qualifier: Some(b) } => assert_eq!(b.table, "Users"), other => panic!("expected u.* qualified-by-Users; got {other:?}"), } } @@ -1736,9 +1731,7 @@ mod tests { let cursor = stmt.len(); let r = classify_with_dialect(stmt, cursor, DriverKind::Mysql); match r.context { - CompletionContext::Column { - qualifier: Some(b), - } => assert_eq!(b.table, "Users"), + CompletionContext::Column { qualifier: Some(b) } => assert_eq!(b.table, "Users"), other => panic!("expected u.* qualified-by-Users; got {other:?}"), } } diff --git a/src/event.rs b/src/event.rs index 62b3e1b..5aa6633 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1912,10 +1912,7 @@ mod tests { use std::sync::{Arc, RwLock}; fn temp_dir(label: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "rowdy-event-{label}-{}", - uuid::Uuid::new_v4() - )); + let p = std::env::temp_dir().join(format!("rowdy-event-{label}-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&p).unwrap(); p } @@ -1931,7 +1928,15 @@ mod tests { let (evt_tx, _evt_rx) = mpsc::unbounded_channel(); let schema_cache = Arc::new(RwLock::new(SchemaCache::new())); App::new( - cmd_tx, evt_tx, config, user_config, keymap, None, logger, dir, schema_cache, + cmd_tx, + evt_tx, + config, + user_config, + keymap, + None, + logger, + dir, + schema_cache, ) } diff --git a/src/llm/tools.rs b/src/llm/tools.rs index 7b86239..c023f60 100644 --- a/src/llm/tools.rs +++ b/src/llm/tools.rs @@ -129,7 +129,6 @@ pub fn for_mode(mode: crate::user_config::ReadToolsMode) -> Vec { } } - /// True when `name` reads from the in-memory schema cache. The chat /// dispatcher uses this to decide whether a cache miss should trigger an /// auto-introspection (schema tools) or fall through to the regular diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 8d144fa..d6a0e0d 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -649,7 +649,6 @@ async fn prime_cache( } } - async fn handle_introspect(datasource: &dyn Datasource, target: IntrospectTarget) -> WorkerEvent { let outcome = match &target { IntrospectTarget::Catalogs => datasource @@ -886,7 +885,10 @@ mod tests { { let guard = cache.read().unwrap(); assert!( - guard.schemas.values().any(|v| v.iter().any(|s| s == "main")), + guard + .schemas + .values() + .any(|v| v.iter().any(|s| s == "main")), "expected 'main' schema in primed cache; got {:?}", guard.schemas, ); @@ -907,4 +909,3 @@ mod tests { assert!(evt_rx.try_recv().is_err()); } } -