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

Filter by extension

Filter by extension


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

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rowdy"
version = "0.16.3"
version = "0.17.0"
edition = "2024"
rust-version = "1.86"
license = "MIT"
Expand Down
13 changes: 13 additions & 0 deletions src/action/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ mod llm_settings;
mod params_prompt;
mod query;
mod results;
mod saved_queries;
mod schema;
mod session;
mod update;

pub use saved_queries::SavedQueryAction;

pub(crate) use session::{flush_session, schedule_session_save};
pub use update::try_promote_pending_update;

Expand Down Expand Up @@ -158,6 +161,11 @@ pub enum Action {
/// the action layer replies to the LLM with `{"error": "user
/// denied access"}` so the turn keeps moving.
ToolApproveDeny,
/// Saved-query overlay / picker interaction. The translation layer
/// keeps `:save` / `:load` / `:run-saved` outside this variant
/// (they're dispatched directly through `dispatch_command`) so this
/// only handles the overlay key flow.
SavedQuery(SavedQueryAction),
}

/// What a click or scroll-wheel was aimed at. Translated from
Expand Down Expand Up @@ -495,6 +503,7 @@ pub fn apply(app: &mut App, action: Action) {
Action::Session(s) => session::dispatch_session(app, s),
Action::ToolApproveAccept => chat::on_tool_approve_accept(app),
Action::ToolApproveDeny => chat::on_tool_approve_deny(app),
Action::SavedQuery(a) => saved_queries::apply(app, a),
}
}

Expand Down Expand Up @@ -785,6 +794,10 @@ fn dispatch_command(app: &mut App, cmd: command::Command) {
Action::Session(session::session_subcommand_to_action(sub)),
),
C::Update => apply(app, Action::CheckForUpdate),
C::Save(name) => saved_queries::apply_save(app, name),
C::Load(name) => saved_queries::apply_load(app, name),
C::RunSaved(Some(name)) => saved_queries::apply_run_saved(app, name),
C::RunSaved(None) => saved_queries::open_run_picker(app),
}
}

Expand Down
220 changes: 220 additions & 0 deletions src/action/saved_queries.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//! `:save`, `:load`, `:run-saved` — per-connection named query store.

use crate::app::App;
use crate::saved_queries;
use crate::state::overlay::Overlay;
use crate::state::saved_query_picker::SavedQueryPickerState;
use crate::state::status::QueryStatus;

#[derive(Debug, Clone)]
pub enum SavedQueryAction {
/// Picker cursor — up/down step.
PickerMove(i32),
PickerTop,
PickerBottom,
/// Picker Enter — load / run the selected entry.
PickerConfirm,
/// Picker Esc.
PickerCancel,
/// `:save` overwrite prompt — Enter.
ConfirmOverwrite,
/// `:save` overwrite prompt — Esc / n.
CancelOverwrite,
}

pub fn apply_save(app: &mut App, name: String) {
let Some(conn) = app.active_connection.clone() else {
app.status = QueryStatus::Failed {
error: "no active connection".into(),
};
return;
};
if let Err(err) = saved_queries::validate_name(&name) {
app.status = QueryStatus::Failed { error: err };
return;
}
let Some(sql) = resolve_sql_to_save(app) else {
app.status = QueryStatus::Failed {
error: "no selection or statement under cursor to save".into(),
};
return;
};
if saved_queries::exists(&app.data_dir, &conn, &name) {
app.overlay = Some(Overlay::ConfirmSaveOverwrite { name, sql });
return;
}
write_and_notice(app, &conn, &name, &sql);
}

pub fn apply_load(app: &mut App, name: String) {
let Some(conn) = app.active_connection.clone() else {
app.status = QueryStatus::Failed {
error: "no active connection".into(),
};
return;
};
if let Err(err) = saved_queries::validate_name(&name) {
app.status = QueryStatus::Failed { error: err };
return;
}
let sql = match saved_queries::load(&app.data_dir, &conn, &name) {
Ok(s) => s,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
app.status = QueryStatus::Failed {
error: format!("no saved query named {name:?}"),
};
return;
}
Err(err) => {
app.status = QueryStatus::Failed {
error: format!("load {name:?} failed: {err}"),
};
return;
}
};
crate::state::editor::insert_text_at_cursor(&mut app.editor.state, &sql);
app.editor_dirty = true;
super::schedule_session_save(app);
app.status = QueryStatus::Notice {
msg: format!("loaded saved query {name:?}"),
};
}

pub fn apply_run_saved(app: &mut App, name: String) {
let Some(conn) = app.active_connection.clone() else {
app.status = QueryStatus::Failed {
error: "no active connection".into(),
};
return;
};
if let Err(err) = saved_queries::validate_name(&name) {
app.status = QueryStatus::Failed { error: err };
return;
}
let sql = match saved_queries::load(&app.data_dir, &conn, &name) {
Ok(s) => s,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
app.status = QueryStatus::Failed {
error: format!("no saved query named {name:?}"),
};
return;
}
Err(err) => {
app.status = QueryStatus::Failed {
error: format!("load {name:?} failed: {err}"),
};
return;
}
};
super::query::dispatch_query(app, sql);
}

pub fn open_run_picker(app: &mut App) {
let Some(conn) = app.active_connection.clone() else {
app.status = QueryStatus::Failed {
error: "no active connection".into(),
};
return;
};
let entries = match saved_queries::list(&app.data_dir, &conn) {
Ok(v) => v,
Err(err) => {
app.status = QueryStatus::Failed {
error: format!("list saved queries failed: {err}"),
};
return;
}
};
app.overlay = Some(Overlay::SavedQueryPicker(SavedQueryPickerState::new(
entries,
)));
}

pub fn apply(app: &mut App, action: SavedQueryAction) {
match action {
SavedQueryAction::PickerMove(delta) => {
if let Some(Overlay::SavedQueryPicker(state)) = app.overlay.as_mut() {
state.move_selection(delta);
}
}
SavedQueryAction::PickerTop => {
if let Some(Overlay::SavedQueryPicker(state)) = app.overlay.as_mut() {
state.jump_top();
}
}
SavedQueryAction::PickerBottom => {
if let Some(Overlay::SavedQueryPicker(state)) = app.overlay.as_mut() {
state.jump_bottom();
}
}
SavedQueryAction::PickerCancel => {
if matches!(app.overlay, Some(Overlay::SavedQueryPicker(_))) {
app.overlay = None;
}
}
SavedQueryAction::PickerConfirm => picker_confirm(app),
SavedQueryAction::ConfirmOverwrite => confirm_overwrite(app),
SavedQueryAction::CancelOverwrite => {
if matches!(app.overlay, Some(Overlay::ConfirmSaveOverwrite { .. })) {
app.overlay = None;
app.status = QueryStatus::Notice {
msg: "save cancelled".into(),
};
}
}
}
}

fn picker_confirm(app: &mut App) {
let Some(Overlay::SavedQueryPicker(state)) = app.overlay.as_ref() else {
return;
};
let Some(name) = state.selected_name().map(str::to_string) else {
// Empty list — just close the overlay.
app.overlay = None;
return;
};
app.overlay = None;
apply_run_saved(app, name);
}

fn confirm_overwrite(app: &mut App) {
let Some(Overlay::ConfirmSaveOverwrite { name, sql }) = app.overlay.take() else {
return;
};
let Some(conn) = app.active_connection.clone() else {
app.status = QueryStatus::Failed {
error: "no active connection".into(),
};
return;
};
write_and_notice(app, &conn, &name, &sql);
}

fn resolve_sql_to_save(app: &App) -> Option<String> {
if let Some(text) = crate::state::editor::selection_text(&app.editor.state) {
return Some(text);
}
let range = crate::state::editor::statement_under_cursor(&app.editor.state)?;
let trimmed = range.text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}

fn write_and_notice(app: &mut App, conn: &str, name: &str, sql: &str) {
match saved_queries::save(&app.data_dir, conn, name, sql) {
Ok(()) => {
app.status = QueryStatus::Notice {
msg: format!("saved query {name:?}"),
};
}
Err(err) => {
app.status = QueryStatus::Failed {
error: format!("save {name:?} failed: {err}"),
};
}
}
}
81 changes: 81 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ pub enum Command {
/// version → "v0.7.x is the latest" notice; network failure →
/// error in the bottom bar.
Update,
/// `:save <name>` — persist the selection (or statement under cursor)
/// as a named query scoped to the active connection.
Save(String),
/// `:load <name>` — insert a previously saved query at the cursor.
Load(String),
/// `:run-saved [name]` — execute a saved query. Bare form opens a
/// picker overlay; with a name, dispatches straight through the
/// existing query pipeline (placeholders prompt, etc.).
RunSaved(Option<String>),
}

/// `:chat` subcommands. Bare `:chat` toggles the right panel between
Expand Down Expand Up @@ -334,6 +343,21 @@ pub static COMMAND_TREE: &[CommandSpec] = &[
aliases: &[],
children: &[],
},
CommandSpec {
name: "save",
aliases: &[],
children: &[],
},
CommandSpec {
name: "load",
aliases: &[],
children: &[],
},
CommandSpec {
name: "run-saved",
aliases: &[],
children: &[],
},
];

/// Parse a single `:` line. `Ok(None)` is the empty-line case (treat
Expand Down Expand Up @@ -365,11 +389,42 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
"chat" => Command::Chat(parse_chat(&args)?),
"session" | "sess" => Command::Session(parse_session(&args)?),
"update" => Command::Update,
"save" => parse_save(&args)?,
"load" => parse_load(&args)?,
"run-saved" => parse_run_saved(&args),
_ => return Err(format!("unknown command: {cmd}")),
};
Ok(Some(parsed))
}

fn parse_save(args: &[&str]) -> Result<Command, String> {
let name = args.join(" ");
let name = name.trim();
if name.is_empty() {
return Err("usage: :save <name>".to_string());
}
Ok(Command::Save(name.to_string()))
}

fn parse_load(args: &[&str]) -> Result<Command, String> {
let name = args.join(" ");
let name = name.trim();
if name.is_empty() {
return Err("usage: :load <name>".to_string());
}
Ok(Command::Load(name.to_string()))
}

fn parse_run_saved(args: &[&str]) -> Command {
let joined = args.join(" ");
let trimmed = joined.trim();
if trimmed.is_empty() {
Command::RunSaved(None)
} else {
Command::RunSaved(Some(trimmed.to_string()))
}
}

fn parse_format(args: &[&str]) -> Result<Command, String> {
let scope = match args.first().copied() {
None => FormatScope::Cursor,
Expand Down Expand Up @@ -946,6 +1001,32 @@ mod tests {
assert!(matches!(parse("session yikes"), Err(msg) if msg.contains("unknown")));
}

#[test]
fn save_requires_name() {
assert_eq!(parse("save daily"), Ok(Some(Command::Save("daily".into()))));
// Names with spaces survive (sanitizer maps them on save).
assert_eq!(
parse("save weekly cohort"),
Ok(Some(Command::Save("weekly cohort".into())))
);
assert!(matches!(parse("save"), Err(msg) if msg.contains("usage:")));
}

#[test]
fn load_requires_name() {
assert_eq!(parse("load daily"), Ok(Some(Command::Load("daily".into()))));
assert!(matches!(parse("load"), Err(msg) if msg.contains("usage:")));
}

#[test]
fn run_saved_optional_name() {
assert_eq!(parse("run-saved"), Ok(Some(Command::RunSaved(None))));
assert_eq!(
parse("run-saved daily"),
Ok(Some(Command::RunSaved(Some("daily".into()))))
);
}

#[test]
fn chat_unknown_subcommand() {
assert!(matches!(
Expand Down
Loading
Loading