Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
825 changes: 757 additions & 68 deletions crates/tsql/src/app/app.rs

Large diffs are not rendered by default.

1,105 changes: 1,085 additions & 20 deletions crates/tsql/src/config/connections.rs

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions crates/tsql/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ mod keymap;
mod schema;

pub use connections::{
connections_path, load_connections, save_connections, ConnectionColor, ConnectionEntry,
ConnectionsFile, DbKind, SslMode,
connections_path, export_to_path, import_from_path, load_connections, save_connections,
write_connections_atomic, ConnectionColor, ConnectionEntry, ConnectionsFile, DbKind,
ImportConflict, ImportSummary, SortMode, SslMode,
};
pub use keymap::{Action, KeyBinding, Keymap};
pub use schema::{
Expand Down
106 changes: 56 additions & 50 deletions crates/tsql/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use tokio::runtime::Runtime;
use tokio::sync::mpsc;

use tsql::app::App;
use tsql::config::{self, load_connections};
use tsql::config;
use tsql::session::load_session;
use tsql::ui::GridModel;

Expand Down Expand Up @@ -153,6 +153,9 @@ fn print_usage() {
eprintln!(" -V, --version Print version information");
eprintln!(" --debug-keys Print detected key/mouse events (for troubleshooting)");
eprintln!(" --mouse (with --debug-keys) Also print mouse events");
eprintln!(" --safe-mode Skip session reconnect and startup side effects");
eprintln!(" --no-auto-connect");
eprintln!(" Alias for --safe-mode");
eprintln!();
eprintln!("Environment Variables:");
eprintln!(" DATABASE_URL Default connection URL if not provided as argument");
Expand Down Expand Up @@ -189,7 +192,7 @@ fn onepassword_cli_available() -> bool {
.unwrap_or(false)
}

fn onepassword_startup_warning(onepassword_enabled: bool) -> Option<String> {
fn onepassword_startup_warning_nonblocking(onepassword_enabled: bool) -> Option<String> {
if !onepassword_enabled {
return None;
}
Expand All @@ -202,14 +205,26 @@ fn onepassword_startup_warning(onepassword_enabled: bool) -> Option<String> {
);
}

if !onepassword_cli_available() {
return Some(
use std::sync::mpsc;
use std::time::Duration;

let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(onepassword_cli_available());
});

match rx.recv_timeout(Duration::from_millis(750)) {
Ok(true) => None,
Ok(false) => Some(
"1Password support is enabled, but `op` was not found on PATH. Install/sign in via \
1Password CLI or disable `connection.enable_onepassword`."
.to_string(),
);
),
Err(_) => Some(
"1Password CLI probe timed out; use `tsql --safe-mode` to bypass startup side effects."
.to_string(),
),
}
None
}

fn main() -> Result<()> {
Expand Down Expand Up @@ -240,34 +255,39 @@ fn main() -> Result<()> {
return run_debug_keys(debug_mouse);
}

let safe_mode = args
.iter()
.any(|a| a == "--safe-mode" || a == "--no-auto-connect");
let mut startup_warnings: Vec<String> = Vec::new();

if let Err(err) = config::migrate_legacy_config_dir_on_startup() {
eprintln!(
"Warning: Failed to migrate legacy config directory to ~/.tsql: {}",
startup_warnings.push(format!(
"Failed to migrate legacy config directory to ~/.tsql: {}",
err
);
));
}

// Load configuration from ~/.tsql/config.toml
let cfg = config::load_config().unwrap_or_else(|e| {
eprintln!("Warning: Failed to load config: {}", e);
startup_warnings.push(format!("Failed to load config: {}", e));
config::Config::default()
});
let onepassword_enabled = cfg.connection.enable_onepassword;

// Load session state if persistence is enabled
let session = if cfg.editor.persist_session {
load_session().unwrap_or_else(|e| {
eprintln!("Warning: Failed to load session: {}", e);
startup_warnings.push(format!("Failed to load session: {}", e));
Default::default()
})
} else {
Default::default()
};

// Connection string priority: CLI arg > DATABASE_URL env var > libpq env vars > config file
let (conn_str, libpq_warning) = if args.len() > 1 && !args[1].starts_with('-') {
// First argument is the connection string
(Some(args[1].clone()), None)
let positional_url = args.iter().skip(1).find(|a| !a.starts_with('-'));
let (conn_str, libpq_warning) = if let Some(url) = positional_url {
(Some(url.clone()), None)
} else if let Ok(url) = env::var("DATABASE_URL") {
// Fall back to DATABASE_URL environment variable
(Some(url), None)
Expand All @@ -291,54 +311,40 @@ fn main() -> Result<()> {
conn_str.clone(),
cfg,
);
app.set_safe_mode(safe_mode);

// Display startup warnings.
if let Some(warning) = libpq_warning {
app.last_status = Some(warning);
startup_warnings.push(warning);
}
if let Some(warning) = onepassword_startup_warning(onepassword_enabled) {
app.last_status = Some(match app.last_status.take() {
Some(existing) => format!("{} | {}", existing, warning),
None => warning,
});
if !safe_mode {
if let Some(warning) = onepassword_startup_warning_nonblocking(onepassword_enabled) {
startup_warnings.push(warning);
}
} else {
startup_warnings.push(
"Running in safe mode: session reconnect and startup update checks disabled"
.to_string(),
);
}
if !startup_warnings.is_empty() {
app.last_status = Some(startup_warnings.join(" | "));
}

// Apply session state (editor content, sidebar visibility, pending schema expanded)
let session_connection = app.apply_session_state(session);

// Auto-connect from session if no CLI/env connection was specified
let mut session_reconnected = false;
if conn_str.is_none() {
// Auto-connect from session if no CLI/env connection was specified. Queue
// it for after the first draw so keychain/1Password cannot black-screen
// the terminal before the user sees the UI.
if conn_str.is_none() && !safe_mode {
if let Some(conn_name) = session_connection {
// Verify connection still exists
let connections = load_connections().unwrap_or_default();
if let Some(entry) = connections.find_by_name(&conn_name) {
// Check if password is available (not requiring prompt)
let timeout_ms = if onepassword_enabled && entry.password_onepassword.is_some() {
5000
} else {
500
};
match entry.get_password_with_timeout_and_options(timeout_ms, onepassword_enabled) {
Ok(Some(_)) | Ok(None) => {
// Password available or not needed - auto-connect
app.connect_to_entry(entry.clone());
session_reconnected = true;
}
Err(_) => {
// Password retrieval failed - skip auto-connect
// User can manually connect
}
}
}
// If connection doesn't exist, silently skip auto-connect
}

// Only open connection picker if no connection was established
// (no CLI/env URL and no session reconnection)
if !session_reconnected {
app.set_pending_startup_reconnect(Some(conn_name));
} else {
app.open_connection_picker();
}
} else if conn_str.is_none() && safe_mode {
app.open_connection_picker();
}

let res = app.run(&mut terminal);
Expand Down
2 changes: 1 addition & 1 deletion crates/tsql/src/ui/confirm_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pub enum ConfirmContext {
/// Closing connection form with unsaved changes.
CloseConnectionForm,
/// Switching to a new connection with unsaved query changes.
SwitchConnection { entry: ConnectionEntry },
SwitchConnection { entry: Box<ConnectionEntry> },
/// Applying an in-app binary update.
ApplyUpdate { info: UpdateInfo },
/// Opening the AI assistant when current query editor has content.
Expand Down
Loading