use std::cmp::Ordering;
use std::sync::Arc;
use chrono::Utc;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
use gpui_kit::prelude::*;
use gpui_kit::{App, Context, Entity, EventEmitter, FocusHandle, Global, Window, div, px};
use uuid::Uuid;
use crate::app::NewConnection;
use crate::db::store::SecretEdits;
use crate::db::{Connection, ConnectionConfig, Credentials, Engine, runtime, store};
use crate::ui::{notify_error, sql_file};
mod card;
mod editor;
use card::render_card;
use editor::{ConnectionEditor, EditorEvent};
pub(crate) use editor::{EditorClose, EditorConnect};
const EDITOR_WIDTH: f32 = 560.;
pub enum WelcomeEvent {
Connected(Arc<Connection>),
}
pub(crate) struct SavedConnections {
list: Vec<ConnectionConfig>,
load_error: Option<String>,
}
impl Global for SavedConnections {}
impl SavedConnections {
pub(crate) fn get(cx: &mut App) -> &[ConnectionConfig] {
if !cx.has_global::<Self>() {
let (list, load_error) = match store::load() {
Ok(list) => (list, None),
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
};
cx.set_global(Self { list, load_error });
}
&cx.global::<Self>().list
}
fn read(cx: &App) -> &[ConnectionConfig] {
cx.try_global::<Self>()
.map(|saved| saved.list.as_slice())
.unwrap_or_default()
}
}
pub struct Welcome {
query: Entity<InputState>,
connecting: Option<Uuid>,
error: Option<String>,
editor: Option<Entity<ConnectionEditor>>,
focus: FocusHandle,
}
impl EventEmitter<WelcomeEvent> for Welcome {}
impl Welcome {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
SavedConnections::get(cx);
let error = cx.global_mut::<SavedConnections>().load_error.take();
cx.observe_global::<SavedConnections>(|_, cx| cx.notify())
.detach();
let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search connections"));
cx.subscribe_in(&query, window, Self::on_search_event)
.detach();
Self {
query,
connecting: None,
error,
editor: None,
focus: cx.focus_handle(),
}
}
fn on_search_event(
&mut self,
_query: &Entity<InputState>,
event: &InputEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(event, InputEvent::Change) {
cx.notify();
}
}
pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
if !SavedConnections::read(cx).is_empty() {
self.query.update(cx, |input, cx| input.focus(window, cx));
} else {
self.focus.focus(window, cx);
}
}
fn is_connecting(&self, id: &Uuid) -> bool {
self.connecting == Some(*id)
}
fn open_editor(
&mut self,
config: Option<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.editor.is_some() {
return;
}
let title = if config.is_some() {
"Edit connection"
} else {
"New connection"
};
let editor = cx.new(|cx| ConnectionEditor::new(config, window, cx));
cx.subscribe_in(&editor, window, Self::on_editor_event)
.detach();
self.editor = Some(editor.clone());
let welcome = cx.entity().downgrade();
let body = editor.clone();
window.open_dialog(cx, move |dialog, _window, _cx| {
let welcome = welcome.clone();
dialog
.w(px(EDITOR_WIDTH))
.h(px(600.))
.title(title)
.keyboard(false)
.on_close(move |_, _window, cx| {
welcome
.update(cx, |this, cx| {
this.editor = None;
cx.notify();
})
.ok();
})
.child(body.clone())
});
editor.update(cx, |editor, cx| editor.focus(window, cx));
}
fn close_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor = None;
window.close_dialog(cx);
cx.notify();
}
fn on_editor_event(
&mut self,
_editor: &Entity<ConnectionEditor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorEvent::Saved { config, secrets } => {
self.save(config.clone(), secrets.clone(), window, cx);
self.close_editor(window, cx);
}
EditorEvent::Connect {
config,
secrets,
save,
} => {
let saved = SavedConnections::read(cx)
.iter()
.any(|saved| saved.id == config.id);
if *save {
self.save(config.clone(), secrets.clone(), window, cx);
}
self.close_editor(window, cx);
self.connect_resolving(config.clone(), secrets.clone(), saved, cx);
}
EditorEvent::Dismissed => self.close_editor(window, cx),
}
}
fn on_new_connection(
&mut self,
_: &NewConnection,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_editor(None, window, cx);
}
fn save(
&mut self,
config: ConnectionConfig,
secrets: SecretEdits,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let (connections, ticket) = change_saved(cx, |list| {
match list.iter_mut().find(|saved| saved.id == config.id) {
Some(saved) => {
let last_connected = saved.last_connected;
*saved = config.clone();
saved.last_connected = last_connected;
}
None => list.push(config.clone()),
}
});
let id = config.id;
store_in_background(
move || {
store::save(&connections, ticket)?;
secrets.save(&id)
},
cx,
);
}
pub(crate) fn connect_saved_with_confirmation(
&mut self,
config: ConnectionConfig,
window: &mut Window,
cx: &mut Context<Self>,
) {
if window.has_active_dialog(cx) {
return;
}
if !config.is_risky_auto_apply() {
self.connect_saved(config, window, cx);
return;
}
let name = config.display_name();
let welcome = cx.entity().downgrade();
window.open_alert_dialog(cx, move |alert, _window, _cx| {
let welcome = welcome.clone();
let config = config.clone();
alert
.title(format!("Connect to {name}?"))
.description(
"This connection is marked production and set to auto-apply: edits are \
written the moment you leave a row, with no confirmation.",
)
.button_props(
DialogButtonProps::default()
.ok_text("Connect")
.ok_variant(ButtonVariant::Danger)
.cancel_text("Cancel")
.show_cancel(true),
)
.on_ok(move |_, window, cx| {
if let Some(welcome) = welcome.upgrade() {
welcome.update(cx, |this, cx| {
this.connect_saved(config.clone(), window, cx)
});
}
true
})
});
}
fn connect_saved(
&mut self,
config: ConnectionConfig,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.connect_resolving(config, SecretEdits::default(), true, cx);
}
fn connect_resolving(
&mut self,
config: ConnectionConfig,
secrets: SecretEdits,
saved: bool,
cx: &mut Context<Self>,
) {
let credentials = {
let config = config.clone();
cx.background_spawn(async move { store::credentials(&config, &secrets, saved) })
};
cx.spawn(async move |this, cx| {
let credentials = credentials.await;
this.update_in(cx, |this, window, cx| match credentials {
Ok(credentials) => this.connect(config, credentials, window, cx),
Err(error) => {
this.error = Some(format!("{error:#}"));
cx.notify();
}
})
.ok();
})
.detach();
}
pub(crate) fn open(&mut self, id: Uuid, window: &mut Window, cx: &mut Context<Self>) {
let Some(config) = SavedConnections::read(cx)
.iter()
.find(|config| config.id == id)
.cloned()
else {
return;
};
self.connect_saved(config, window, cx);
}
fn connect(
&mut self,
config: ConnectionConfig,
credentials: Credentials,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.connecting.is_some() {
return;
}
self.connecting = Some(config.id);
self.error = None;
cx.notify();
let task =
runtime::spawn(async move { Connection::open_with(config.clone(), credentials).await });
cx.spawn(async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
this.connecting = None;
match result {
Ok(Ok(connection)) => {
this.error = None;
this.mark_connected(&connection.config.id, cx);
cx.emit(WelcomeEvent::Connected(Arc::new(connection)));
}
Ok(Err(error)) => {
let message = format!("{error:#}");
this.error = Some(message.clone());
notify_error(window, cx, format!("Error: {message}"));
}
Err(_) => {
this.error = Some("the connection was cancelled".into());
}
}
cx.notify();
})
.ok();
})
.detach();
}
fn mark_connected(&mut self, id: &Uuid, cx: &mut Context<Self>) {
let (connections, ticket) = change_saved(cx, |list| {
if let Some(config) = list.iter_mut().find(|config| &config.id == id) {
config.last_connected = Some(Utc::now());
}
});
store_in_background(move || store::save(&connections, ticket), cx);
}
fn edit(&mut self, id: Uuid, window: &mut Window, cx: &mut Context<Self>) {
let Some(config) = SavedConnections::read(cx)
.iter()
.find(|config| config.id == id)
.cloned()
else {
return;
};
self.open_editor(Some(config), window, cx);
}
fn duplicate(&mut self, id: Uuid, _window: &mut Window, cx: &mut Context<Self>) {
let Some(source) = SavedConnections::read(cx)
.iter()
.find(|config| config.id == id)
.cloned()
else {
return;
};
let mut copy = source.clone();
copy.id = Uuid::new_v4();
copy.last_connected = None;
if !source.name.trim().is_empty() {
copy.name = format!("{} copy", source.name.trim());
}
let (connections, ticket) = change_saved(cx, |list| list.push(copy));
store_in_background(move || store::save(&connections, ticket), cx);
}
fn delete(&mut self, id: Uuid, window: &mut Window, cx: &mut Context<Self>) {
let Some(config) = SavedConnections::read(cx)
.iter()
.find(|config| config.id == id)
.cloned()
else {
return;
};
let name = config.display_name();
let welcome = cx.entity().downgrade();
window.open_alert_dialog(cx, move |alert, _window, _cx| {
let welcome = welcome.clone();
alert
.title(format!("Delete \"{name}\"?"))
.description("This removes the saved connection and its stored password.")
.button_props(
DialogButtonProps::default()
.ok_text("Delete")
.ok_variant(ButtonVariant::Danger)
.cancel_text("Cancel")
.show_cancel(true),
)
.on_ok(move |_, window, cx| {
if let Some(welcome) = welcome.upgrade() {
welcome.update(cx, |this, cx| this.delete_confirmed(id, window, cx));
}
true
})
});
}
fn delete_confirmed(&mut self, id: Uuid, _window: &mut Window, cx: &mut Context<Self>) {
let (connections, ticket) = change_saved(cx, |list| list.retain(|config| config.id != id));
store_in_background(
move || {
store::save(&connections, ticket)?;
store::delete_password(&id)?;
store::delete_ssh_secret(&id)
},
cx,
);
}
fn open_sqlite_file(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let prompt = sql_file::prompt_for_open(cx);
cx.spawn(async move |this, cx| {
let paths = match prompt.await {
Ok(Some(paths)) => paths,
_ => return,
};
let Some(path) = paths.into_iter().next() else {
return;
};
this.update_in(cx, |this, window, cx| {
let mut config = ConnectionConfig::new(Engine::Sqlite);
config.database = path.to_string_lossy().into_owned();
this.open_editor(Some(config), window, cx);
})
.ok();
})
.detach();
}
fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.w_full()
.max_w(px(640.))
.mx_auto()
.flex_none()
.justify_between()
.gap_3()
.px_6()
.pt_6()
.child(
v_flex()
.gap_1()
.child(div().text_xl().child("Zippa DB"))
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Pick a connection, or set up a new one."),
),
)
.child(
Button::new("welcome-new-connection")
.primary()
.label("New connection")
.tooltip_with_action("New connection", &NewConnection, Some("Welcome"))
.on_click(
cx.listener(|this, _, window, cx| this.open_editor(None, window, cx)),
),
)
}
fn render_error(&self, error: &str, cx: &mut Context<Self>) -> impl IntoElement {
div().id("error-banner").flex_none().px_6().pt_3().child(
div()
.max_h(px(96.))
.overflow_y_scrollbar()
.text_sm()
.text_color(cx.theme().danger)
.child(format!("Error: {error}")),
)
}
fn render_body(&self, cx: &mut Context<Self>) -> impl IntoElement {
let connections = SavedConnections::read(cx).to_vec();
if connections.is_empty() {
return self.render_empty(cx).into_any_element();
}
let query = self.query.read(cx).value().trim().to_string();
let visible: Vec<&ConnectionConfig> = ordered(&connections)
.into_iter()
.filter(|config| matches_query(config, &query))
.collect();
v_flex()
.w_full()
.max_w(px(640.))
.mx_auto()
.flex_1()
.min_h_0()
.px_6()
.pt_3()
.pb_6()
.gap_3()
.child(Input::new(&self.query).id("connection-search"))
.when(visible.is_empty(), |this| {
this.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No connections match"),
)
})
.child(
div()
.id("connections")
.flex_1()
.min_h_0()
.overflow_y_scrollbar()
.child(
v_flex().gap_2().children(
visible
.into_iter()
.map(|config| render_card(self, config, cx)),
),
),
)
.into_any_element()
}
fn render_empty(&self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.flex_1()
.items_center()
.justify_center()
.gap_2()
.p_6()
.child(div().text_lg().child("No connections yet"))
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Add a connection to start querying your databases."),
)
.child(
Button::new("empty-new-connection")
.primary()
.label("New connection")
.tooltip_with_action("New connection", &NewConnection, Some("Welcome"))
.on_click(
cx.listener(|this, _, window, cx| this.open_editor(None, window, cx)),
),
)
.child(
Button::new("open-sqlite-file")
.outline()
.label("Open SQLite file…")
.on_click(cx.listener(|this, _, window, cx| this.open_sqlite_file(window, cx))),
)
}
#[cfg(test)]
pub(crate) fn set_connections_for_test(
&mut self,
connections: Vec<ConnectionConfig>,
cx: &mut Context<Self>,
) {
change_saved(cx, |list| *list = connections);
}
#[cfg(test)]
pub(crate) fn show_error_for_test(
&mut self,
message: impl Into<String>,
cx: &mut Context<Self>,
) {
self.error = Some(message.into());
cx.notify();
}
#[cfg(test)]
pub(crate) fn editor_open_for_test(&self) -> bool {
self.editor.is_some()
}
#[cfg(test)]
pub(crate) fn connections_for_test<'a>(&self, cx: &'a App) -> &'a [ConnectionConfig] {
SavedConnections::read(cx)
}
#[cfg(test)]
pub(crate) fn save_for_test(
&mut self,
config: ConnectionConfig,
password: Option<&str>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let secrets = SecretEdits {
password: password.map(str::to_string),
ssh: None,
};
self.save(config, secrets, window, cx);
}
#[cfg(test)]
pub(crate) fn edit_for_test(&mut self, id: Uuid, window: &mut Window, cx: &mut Context<Self>) {
self.edit(id, window, cx);
}
#[cfg(test)]
pub(crate) fn editor_password_intent_for_test(&self, cx: &App) -> Option<String> {
self.editor
.as_ref()
.and_then(|editor| editor.read(cx).password_intent_for_test(cx))
}
#[cfg(test)]
pub(crate) fn focus_editor_password_for_test(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(editor) = self.editor.clone() else {
return;
};
editor.update(cx, |editor, cx| editor.focus_password_for_test(window, cx));
}
#[cfg(test)]
pub(crate) fn new_connection_for_test(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open_editor(None, window, cx);
}
#[cfg(test)]
pub(crate) fn with_editor_for_test<R>(
&self,
cx: &mut Context<Self>,
f: impl FnOnce(&mut ConnectionEditor, &mut Context<ConnectionEditor>) -> R,
) -> R {
let editor = self.editor.clone().expect("the editor should be open");
editor.update(cx, f)
}
#[cfg(test)]
pub(crate) fn duplicate_for_test(
&mut self,
id: Uuid,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.duplicate(id, window, cx);
}
#[cfg(test)]
pub(crate) fn delete_confirmed_for_test(
&mut self,
id: Uuid,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.delete_confirmed(id, window, cx);
}
}
fn change_saved(
cx: &mut App,
change: impl FnOnce(&mut Vec<ConnectionConfig>),
) -> (Vec<ConnectionConfig>, store::Ticket) {
SavedConnections::get(cx);
let saved = cx.global_mut::<SavedConnections>();
change(&mut saved.list);
(saved.list.clone(), store::ticket())
}
fn store_in_background<F>(work: F, cx: &mut Context<Welcome>)
where
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
{
let result = cx.background_spawn(async move { work() });
cx.spawn(async move |this, cx| {
if let Err(error) = result.await {
let message = format!("{error:#}");
this.update_in(cx, |this, window, cx| {
notify_error(window, cx, format!("Error: {message}"));
this.error = Some(message);
cx.notify();
})
.ok();
}
})
.detach();
}
impl Render for Welcome {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.track_focus(&self.focus)
.key_context("Welcome")
.on_action(cx.listener(Self::on_new_connection))
.child(self.render_header(cx))
.when_some(self.error.clone(), |this, error| {
this.child(self.render_error(&error, cx))
})
.child(self.render_body(cx))
}
}
fn ordered(connections: &[ConnectionConfig]) -> Vec<&ConnectionConfig> {
let mut ordered: Vec<&ConnectionConfig> = connections.iter().collect();
ordered.sort_by(|a, b| match (a.last_connected, b.last_connected) {
(Some(x), Some(y)) => y
.cmp(&x)
.then_with(|| a.display_name().cmp(&b.display_name())),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a.display_name().cmp(&b.display_name()),
});
ordered
}
fn matches_query(config: &ConnectionConfig, query: &str) -> bool {
if query.is_empty() {
return true;
}
let query = query.to_lowercase();
config.name.to_lowercase().contains(&query)
|| config.host.to_lowercase().contains(&query)
|| config.database.to_lowercase().contains(&query)
|| config.engine.label().to_lowercase().contains(&query)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(name: &str, last_connected: Option<&str>) -> ConnectionConfig {
ConnectionConfig {
name: name.to_string(),
host: "db.internal".into(),
database: "app".into(),
last_connected: last_connected
.map(|when| when.parse::<chrono::DateTime<Utc>>().expect("a timestamp")),
..ConnectionConfig::new(Engine::Postgres)
}
}
#[test]
fn ordering_puts_recent_first_then_name() {
let a = config("alpha", Some("2024-01-01T00:00:00Z"));
let b = config("beta", Some("2024-01-03T00:00:00Z"));
let c = config("gamma", None);
let names: Vec<String> = ordered(&[a, c, b])
.into_iter()
.map(|config| config.name.clone())
.collect();
assert_eq!(names, ["beta", "alpha", "gamma"]);
}
#[test]
fn search_matches_name_host_database_and_engine() {
let config = config("Prod DB", None);
assert!(matches_query(&config, "prod"));
assert!(matches_query(&config, "internal"));
assert!(matches_query(&config, "app"));
assert!(matches_query(&config, "postgres"));
assert!(!matches_query(&config, "mysql"));
}
#[test]
fn ordering_uses_display_name_when_untitled() {
let mut a = config("", None);
a.database = "zebra.sqlite".into();
a.engine = Engine::Sqlite;
let mut b = config("", None);
b.database = "alpha.sqlite".into();
b.engine = Engine::Sqlite;
let names: Vec<String> = ordered(&[a, b])
.into_iter()
.map(|config| config.display_name())
.collect();
assert_eq!(names, ["alpha.sqlite", "zebra.sqlite"]);
}
}