zippa-db 0.1.0

A fast, lightweight, cross-platform database client for PostgreSQL, MySQL, and SQLite.
//! Quick switcher / fuzzy object palette (`Cmd+K`).
//!
//! Provides fast search and navigation across:
//! - Open query and table tabs
//! - Tables and views in the active database schema
//! - Quick actions (New Query Tab, Open SQL File, Refresh)
//! - Available databases on the current connection

use std::rc::Rc;

use gpui_kit::assets::IconName;
use gpui_kit::component::command::{Command, CommandGroup, CommandItem, CommandState};
use gpui_kit::component::{ActiveTheme, WindowExt};
use gpui_kit::prelude::*;
use gpui_kit::{App, Context, Entity, IntoElement, Render, SharedString, Window, div, px};

use crate::db::{DatabaseObject, ObjectKind};
use crate::ui::session::Session;
use crate::ui::session::tab::ObjectViewMode;

/// Actions and destinations selectable from the quick switcher.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum SwitcherTarget {
    Tab(usize),
    Object(DatabaseObject),
    NewTab,
    OpenFile,
    Refresh,
    Explain,
    ExplainAnalyze,
    SwitchDatabase(String),
    SearchSchema,
    OpenConsole,
    OpenProcessList,
    OpenServerVariables,
    OpenQueryDigest,
    OpenMaintenance,
}

pub struct QuickSwitcherView {
    state: Entity<CommandState>,
    session: Entity<Session>,
}

impl QuickSwitcherView {
    pub fn new(session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let state = cx.new(|cx| CommandState::new(window, cx));
        Self { state, session }
    }

    /// Do what the chosen item asks for.
    ///
    /// The items name a destination rather than carrying an action to dispatch:
    /// the dialog's focus path runs to the workspace, and the session and editor
    /// an action would have to reach are a sibling branch of it, so a dispatched
    /// action finds no listener — which is what left these items inert. Every
    /// destination is driven through the session here instead.
    fn choose(&self, target: SwitcherTarget, window: &mut Window, cx: &mut Context<Self>) {
        let session = self.session.clone();
        match target {
            SwitcherTarget::Tab(ix) => {
                session.update(cx, |session, cx| session.activate_tab(ix, window, cx))
            }
            SwitcherTarget::Object(object) => session.update(cx, |session, cx| {
                session.open_object(&object, ObjectViewMode::Data, window, cx)
            }),
            SwitcherTarget::SwitchDatabase(database) => {
                session.update(cx, |session, cx| session.switch_database(database, cx))
            }
            SwitcherTarget::NewTab => {
                session.update(cx, |session, cx| session.new_query_tab(window, cx))
            }
            SwitcherTarget::OpenFile => session.update(cx, |session, cx| session.open_file(cx)),
            SwitcherTarget::Refresh => session.update(cx, |session, cx| session.refresh(cx)),
            SwitcherTarget::Explain => {
                session.update(cx, |session, cx| session.explain_active(false, cx))
            }
            SwitcherTarget::ExplainAnalyze => {
                session.update(cx, |session, cx| session.explain_active(true, cx))
            }
            // A dialog of its own, and only one can be open at a time: the
            // caller has closed this one already.
            SwitcherTarget::SearchSchema => {
                let _ = crate::ui::schema_search::open(session, window, cx);
            }
            SwitcherTarget::OpenConsole => {
                session.update(cx, |session, cx| session.open_console(window, cx))
            }
            SwitcherTarget::OpenProcessList => {
                session.update(cx, |session, cx| session.open_process_list(window, cx))
            }
            SwitcherTarget::OpenServerVariables => {
                session.update(cx, |session, cx| session.open_server_variables(window, cx))
            }
            SwitcherTarget::OpenQueryDigest => {
                session.update(cx, |session, cx| session.open_query_digest(window, cx))
            }
            SwitcherTarget::OpenMaintenance => {
                session.update(cx, |session, cx| session.open_maintenance(window, cx))
            }
        }
    }

    /// Do what a chosen item asks for, for a test to drive.
    #[cfg(test)]
    pub(crate) fn choose_for_test(
        &self,
        target: SwitcherTarget,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.choose(target, window, cx);
    }
}

/// Open the quick switcher dialog over the active window.
pub fn open(session: Entity<Session>, window: &mut Window, cx: &mut App) {
    if window.has_active_dialog(cx) {
        return;
    }

    let switcher = cx.new(|cx| QuickSwitcherView::new(session, window, cx));
    let state = switcher.read(cx).state.clone();

    window.open_dialog(cx, move |dialog, _window, _cx| {
        dialog
            .w(px(580.))
            .margin_top(px(80.))
            .p_0()
            .close_button(false)
            .overlay_closable(true)
            .keyboard(true)
            .child(switcher.clone())
    });

    state.update(cx, |state, cx| state.focus(window, cx));
}

impl Render for QuickSwitcherView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let (tabs_info, objects, databases, is_file_based, current_db, active_tab_ix) = {
            let s = self.session.read(cx);
            let tabs: Vec<(SharedString, IconName, bool, Option<String>)> = s
                .panels()
                .iter()
                .map(|panel| {
                    let panel = panel.read(cx);
                    (
                        panel.title(),
                        panel.icon(),
                        panel.is_query(),
                        panel.file_name(),
                    )
                })
                .collect();
            let objects = s.objects().to_vec();
            let databases = s.databases().to_vec();
            let is_file_based = s.connection().config.engine.is_file_based();
            let current_db = s.connection().database().to_string();
            let active_tab_ix = s.active_tab_index();
            (
                tabs,
                objects,
                databases,
                is_file_based,
                current_db,
                active_tab_ix,
            )
        };

        let mut targets: Vec<Vec<SwitcherTarget>> = Vec::new();
        let mut groups: Vec<CommandGroup> = Vec::new();

        // 1. Open Tabs
        if !tabs_info.is_empty() {
            let mut tab_targets = Vec::new();
            let mut tab_group = CommandGroup::new().label("Open Tabs");
            for (ix, (title, icon, is_query, path)) in tabs_info.into_iter().enumerate() {
                let mut keywords = vec!["tab", if is_query { "query" } else { "table" }, "open"];
                if let Some(p) = &path {
                    keywords.push(p.as_str());
                }
                tab_group = tab_group.item(
                    CommandItem::new()
                        .label(title)
                        .icon(icon)
                        .checked(ix == active_tab_ix)
                        .keywords(keywords),
                );
                tab_targets.push(SwitcherTarget::Tab(ix));
            }
            targets.push(tab_targets);
            groups.push(tab_group);
        }

        // 2. Database Objects (Tables & Views)
        if !objects.is_empty() {
            let mut obj_targets = Vec::new();
            let mut obj_group = CommandGroup::new().label("Tables & Views");
            for obj in objects {
                let icon = match obj.kind {
                    ObjectKind::Table => IconName::Table,
                    ObjectKind::View => IconName::Eye,
                };
                let kind_str = match obj.kind {
                    ObjectKind::Table => "table",
                    ObjectKind::View => "view",
                };
                let mut keywords = vec![kind_str, "schema", "object"];
                if let Some(schema) = &obj.schema {
                    keywords.push(schema.as_str());
                }
                obj_group = obj_group.item(
                    CommandItem::new()
                        .label(obj.label())
                        .icon(icon)
                        .keywords(keywords),
                );
                obj_targets.push(SwitcherTarget::Object(obj));
            }
            targets.push(obj_targets);
            groups.push(obj_group);
        }

        // 3. Actions / Commands
        {
            let mut act_targets = Vec::new();
            let mut act_group = CommandGroup::new().label("Actions");

            act_group = act_group.item(
                CommandItem::new()
                    .label("New Query Tab")
                    .icon(IconName::Plus)
                    .keywords(["new", "query", "tab", "sql", "editor", "create"]),
            );
            act_targets.push(SwitcherTarget::NewTab);

            act_group = act_group.item(
                CommandItem::new()
                    .label("Open SQL File...")
                    .icon(IconName::FolderOpen)
                    .keywords(["open", "file", "sql", "load", "import"]),
            );
            act_targets.push(SwitcherTarget::OpenFile);

            act_group = act_group.item(
                CommandItem::new()
                    .label("Refresh Schema & Tables")
                    .icon(IconName::RefreshCw)
                    .keywords(["refresh", "reload", "schema", "tables", "metadata"]),
            );
            act_targets.push(SwitcherTarget::Refresh);

            act_group = act_group.item(
                CommandItem::new()
                    .label("Search Schema...")
                    .icon(IconName::Search)
                    .keywords([
                        "search", "find", "schema", "column", "index", "routine", "trigger",
                    ]),
            );
            act_targets.push(SwitcherTarget::SearchSchema);

            act_group = act_group.item(
                CommandItem::new()
                    .label("Explain Query")
                    .icon(IconName::Route)
                    .keywords(["explain", "plan", "query", "cost", "tree"]),
            );
            act_targets.push(SwitcherTarget::Explain);

            act_group = act_group.item(
                CommandItem::new()
                    .label("Explain Query & Analyze")
                    .icon(IconName::Gauge)
                    .keywords(["explain", "analyze", "plan", "query", "timing"]),
            );
            act_targets.push(SwitcherTarget::ExplainAnalyze);

            act_group = act_group.item(
                CommandItem::new()
                    .label("Console")
                    .icon(IconName::SquareTerminal)
                    .keywords(["console", "log", "statements", "history"]),
            );
            act_targets.push(SwitcherTarget::OpenConsole);

            if is_file_based {
                act_group = act_group.item(
                    CommandItem::new()
                        .label("Maintenance")
                        .icon(IconName::Wrench)
                        .keywords([
                            "maintenance",
                            "integrity",
                            "check",
                            "optimize",
                            "vacuum",
                            "analyze",
                        ]),
                );
                act_targets.push(SwitcherTarget::OpenMaintenance);
            } else {
                act_group = act_group.item(
                    CommandItem::new()
                        .label("Processes")
                        .icon(IconName::Activity)
                        .keywords(["processes", "connections", "activity", "kill"]),
                );
                act_targets.push(SwitcherTarget::OpenProcessList);

                act_group = act_group.item(
                    CommandItem::new()
                        .label("Variables")
                        .icon(IconName::SlidersHorizontal)
                        .keywords(["variables", "settings", "configuration", "server"]),
                );
                act_targets.push(SwitcherTarget::OpenServerVariables);

                act_group = act_group.item(
                    CommandItem::new()
                        .label("Query Digest")
                        .icon(IconName::Gauge)
                        .keywords(["digest", "slow", "queries", "performance"]),
                );
                act_targets.push(SwitcherTarget::OpenQueryDigest);
            }

            targets.push(act_targets);
            groups.push(act_group);
        }

        // 4. Databases (if multi-database engine and databases known)
        if !is_file_based && databases.len() > 1 {
            let mut db_targets = Vec::new();
            let mut db_group = CommandGroup::new().label("Switch Database");
            for db in databases {
                let is_current = db == current_db;
                db_group = db_group.item(
                    CommandItem::new()
                        .label(format!("Database: {db}"))
                        .icon(IconName::HardDrive)
                        .checked(is_current)
                        .keywords(["database", "db", "switch"]),
                );
                db_targets.push(SwitcherTarget::SwitchDatabase(db));
            }
            targets.push(db_targets);
            groups.push(db_group);
        }

        let targets = Rc::new(targets);
        let targets_for_confirm = targets.clone();
        let view_for_confirm = cx.weak_entity();

        let mut command = Command::new(&self.state)
            .placeholder("Search tables, views, open queries, commands...")
            .bordered(false)
            .max_h(px(360.))
            .empty(|_, _, cx| {
                div()
                    .p_6()
                    .text_center()
                    .text_sm()
                    .text_color(cx.theme().muted_foreground)
                    .child("No matching tables, views, queries or commands")
            })
            .on_confirm(move |index_path, window, cx| {
                let target = targets_for_confirm
                    .get(index_path.section)
                    .and_then(|section| section.get(index_path.row))
                    .cloned();

                // Taken before the dialog is closed: closing drops the last
                // strong reference to this view, and the choice still has to be
                // carried through it.
                let view = view_for_confirm.upgrade();

                // The dialog goes first. Several of these open a dialog of
                // their own — the file picker, the schema search, the
                // confirmation an analysed write asks for — and closing after
                // would take that one off the screen instead. Doing it first
                // also leaves the focus where the action puts it rather than
                // back on the grid.
                window.close_dialog(cx);

                let (Some(target), Some(view)) = (target, view) else {
                    return;
                };
                view.update(cx, |view, cx| view.choose(target, window, cx));
            })
            .on_cancel(move |window, cx| {
                window.close_dialog(cx);
            });

        for group in groups {
            command = command.group(group);
        }

        command
    }
}