use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Editor, EditorState, Enter, IndentInline};
use gpui_kit::component::{ActiveTheme, Disableable, IconName, Sizable, h_flex, v_flex};
use gpui_kit::prelude::*;
use gpui_kit::{
ClipboardItem, Context, Entity, EventEmitter, KeyDownEvent, Window, actions, div, px,
};
use gpui_kit::assets::IconName as AssetIcon;
use std::cell::RefCell;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::rc::Rc;
use crate::db::{Blocker, Engine, statement, transaction_blocker};
use crate::settings::{self, CompletionKey, Settings};
use crate::ui::completion::{SharedCatalog, SqlCompletions};
use crate::ui::session::{OpenFile, SaveFile};
actions!(
zippa_db,
[
RunQuery,
RunScript,
RunScriptIgnoringErrors,
Explain,
ExplainAnalyze,
ToggleComment,
CopyLine,
CutLine,
DeleteLine,
MoveLineUp,
MoveLineDown,
DuplicateLineUp,
DuplicateLineDown,
SelectNextOccurrence,
SelectPreviousOccurrence,
SelectLine,
SmartHome,
]
);
const COMPLETION_MENU_WIDTH: f32 = 480.;
pub enum QueryEditorEvent {
Run(String),
RunScript(String),
RunScriptIgnoringErrors(String),
Explain { sql: String, analyze: bool },
Open,
Save,
}
pub struct QueryEditor {
state: Entity<EditorState>,
#[cfg(test)]
completions: Rc<SqlCompletions>,
running: bool,
engine: Engine,
statement_cache: RefCell<Option<StatementCache>>,
blocker_cache: RefCell<Option<(u64, Option<Blocker>)>>,
}
struct StatementCache {
key: u64,
statement: Option<String>,
writes: bool,
}
impl EventEmitter<QueryEditorEvent> for QueryEditor {}
impl QueryEditor {
pub fn with_text(
sql: impl Into<String>,
engine: Engine,
catalog: SharedCatalog,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let sql = sql.into();
let completions = Rc::new(SqlCompletions { catalog, engine });
let state = cx.new(|cx| {
let mut state = EditorState::new(window, cx)
.language("sql")
.placeholder("SELECT * FROM …")
.default_value(sql);
let lsp = state.lsp_mut();
lsp.completion_provider = Some(completions.clone());
lsp.completion_menu.max_width = px(COMPLETION_MENU_WIDTH);
state
});
Self {
state,
#[cfg(test)]
completions,
running: false,
engine,
statement_cache: RefCell::new(None),
blocker_cache: RefCell::new(None),
}
}
#[cfg(test)]
pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
let handle = self.focus_handle(cx);
handle.focus(window, cx);
}
pub fn focus_handle(&self, cx: &gpui_kit::App) -> gpui_kit::FocusHandle {
use gpui_kit::Focusable as _;
self.state.read(cx).focus_handle(cx)
}
#[cfg(test)]
pub fn set_sql(&self, sql: &str, window: &mut Window, cx: &mut Context<Self>) {
self.state
.update(cx, |state, cx| state.set_value(sql.to_string(), window, cx));
}
#[cfg(test)]
pub(crate) fn set_cursor_for_test(&self, offset: usize, cx: &mut Context<Self>) {
self.state
.update(cx, |state, cx| state.set_selected_range(offset..offset, cx));
}
#[cfg(test)]
pub(crate) fn select_for_test(&self, range: std::ops::Range<usize>, cx: &mut Context<Self>) {
self.state
.update(cx, |state, cx| state.set_selected_range(range, cx));
}
#[cfg(test)]
pub(crate) fn completions_for_test(&self, cx: &gpui_kit::App) -> Vec<String> {
let state = self.state.read(cx);
self.completions
.complete(state.text(), state.cursor())
.map(|completions| completions.items.into_iter().map(|s| s.label).collect())
.unwrap_or_default()
}
#[cfg(test)]
pub(crate) fn statement_for_test(&self, cx: &gpui_kit::App) -> Option<String> {
self.statement(cx)
}
pub fn sql(&self, cx: &gpui_kit::App) -> String {
self.state.read(cx).value().to_string()
}
#[cfg(test)]
pub(crate) fn analyze_disabled_for_test(&self, cx: &gpui_kit::App) -> bool {
self.running || self.statement_writes(cx) || !self.analyze_supported()
}
pub(crate) fn analyze_supported(&self) -> bool {
!matches!(self.engine, Engine::Sqlite)
}
pub fn set_running(&mut self, running: bool, cx: &mut Context<Self>) {
self.running = running;
cx.notify();
}
fn run(&mut self, _: &RunQuery, _window: &mut Window, cx: &mut Context<Self>) {
self.emit_run(cx);
}
fn run_script(&mut self, _: &RunScript, _window: &mut Window, cx: &mut Context<Self>) {
self.emit_run_script(false, cx);
}
fn run_script_ignoring_errors(
&mut self,
_: &RunScriptIgnoringErrors,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.emit_run_script(true, cx);
}
fn explain(&mut self, _: &Explain, _window: &mut Window, cx: &mut Context<Self>) {
self.emit_explain(false, cx);
}
fn toggle_comment(&mut self, _: &ToggleComment, window: &mut Window, cx: &mut Context<Self>) {
let (text, selected, scroll) = {
let state = self.state.read(cx);
(
state.value().to_string(),
state.selected_range(),
state.scroll_offset(),
)
};
let Some((text, selected)) = toggle_comments(&text, selected) else {
return;
};
self.state.update(cx, |state, cx| {
state.replace_all(text, window, cx);
state.set_selected_range(selected, cx);
state.set_scroll_offset(scroll, cx);
});
}
fn explain_analyze(
&mut self,
_: &ExplainAnalyze,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.emit_explain(true, cx);
}
fn edit_buffer(
&mut self,
edit: impl FnOnce(&str, std::ops::Range<usize>) -> Option<(String, std::ops::Range<usize>)>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let (text, selected, scroll) = {
let state = self.state.read(cx);
(
state.value().to_string(),
state.selected_range(),
state.scroll_offset(),
)
};
let Some((text, selected)) = edit(&text, selected) else {
return;
};
self.state.update(cx, |state, cx| {
state.replace_all(text, window, cx);
state.set_selected_range(selected, cx);
state.set_scroll_offset(scroll, cx);
});
}
fn copy_line(&mut self, _: &CopyLine, _window: &mut Window, cx: &mut Context<Self>) {
let text = {
let state = self.state.read(cx);
let selected = state.selected_range();
if selected.is_empty() {
line_clip_text(&state.value(), selected.start)
} else {
state.selected_value().to_string()
}
};
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn cut_line(&mut self, _: &CutLine, window: &mut Window, cx: &mut Context<Self>) {
self.copy_line(&CopyLine, window, cx);
self.edit_buffer(
|text, selected| {
if selected.is_empty() {
delete_lines(text, selected)
} else {
delete_range(text, selected)
}
},
window,
cx,
);
}
fn delete_line(&mut self, _: &DeleteLine, window: &mut Window, cx: &mut Context<Self>) {
self.edit_buffer(delete_lines, window, cx);
}
fn move_line_up(&mut self, _: &MoveLineUp, window: &mut Window, cx: &mut Context<Self>) {
self.edit_buffer(
|text, selected| move_lines(text, selected, true),
window,
cx,
);
}
fn move_line_down(&mut self, _: &MoveLineDown, window: &mut Window, cx: &mut Context<Self>) {
self.edit_buffer(
|text, selected| move_lines(text, selected, false),
window,
cx,
);
}
fn duplicate_line_up(
&mut self,
_: &DuplicateLineUp,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.edit_buffer(
|text, selected| duplicate_lines(text, selected, true),
window,
cx,
);
}
fn duplicate_line_down(
&mut self,
_: &DuplicateLineDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.edit_buffer(
|text, selected| duplicate_lines(text, selected, false),
window,
cx,
);
}
fn select_next_occurrence(
&mut self,
_: &SelectNextOccurrence,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let (text, selected) = {
let state = self.state.read(cx);
(state.value().to_string(), state.selected_range())
};
if let Some(range) = next_occurrence(&text, selected) {
self.state
.update(cx, |state, cx| state.set_selected_range(range, cx));
}
}
fn select_previous_occurrence(
&mut self,
_: &SelectPreviousOccurrence,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let (text, selected) = {
let state = self.state.read(cx);
(state.value().to_string(), state.selected_range())
};
if let Some(range) = previous_occurrence(&text, selected) {
self.state
.update(cx, |state, cx| state.set_selected_range(range, cx));
}
}
fn select_line(&mut self, _: &SelectLine, _window: &mut Window, cx: &mut Context<Self>) {
let (text, selected) = {
let state = self.state.read(cx);
(state.value().to_string(), state.selected_range())
};
let range = select_line_range(&text, selected);
self.state
.update(cx, |state, cx| state.set_selected_range(range, cx));
}
fn smart_home(&mut self, _: &SmartHome, _window: &mut Window, cx: &mut Context<Self>) {
let (text, caret) = {
let state = self.state.read(cx);
(state.value().to_string(), state.cursor())
};
let target = smart_home_offset(&text, caret);
self.state
.update(cx, |state, cx| state.set_selected_range(target..target, cx));
}
fn completion_open(&self, cx: &gpui_kit::App) -> bool {
self.state.read(cx).completion_menu_state().open
}
fn on_key_down(&mut self, _: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
self.follow_caret(window, cx);
}
fn follow_caret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let editor = cx.entity().downgrade();
window.on_next_frame(move |window, _| {
window.on_next_frame(move |_, cx| {
let Some(editor) = editor.upgrade() else {
return;
};
let state = editor.read(cx).state.clone();
if state.read(cx).completion_menu_state().open {
state.update(cx, |_, cx| cx.notify());
}
});
});
}
fn accept_with_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context<Self>) {
if Settings::global(cx).accept_completion != CompletionKey::Tab || !self.completion_open(cx)
{
return;
}
let accept = Box::new(Enter {
secondary: false,
shift: false,
});
self.state.update(cx, |state, cx| {
state.route_overlay_action(accept, window, cx)
});
cx.stop_propagation();
}
fn enter_with_menu_open(&mut self, action: &Enter, _: &mut Window, cx: &mut Context<Self>) {
if action.secondary
|| Settings::global(cx).accept_completion != CompletionKey::Tab
|| !self.completion_open(cx)
{
return;
}
self.state
.update(cx, |state, cx| state.dismiss_lsp_overlays(cx));
}
fn emit_run(&mut self, cx: &mut Context<Self>) {
if self.running {
return;
}
let Some(sql) = self.statement(cx) else {
return;
};
cx.emit(QueryEditorEvent::Run(sql));
}
fn emit_run_script(&mut self, ignoring_errors: bool, cx: &mut Context<Self>) {
if self.running {
return;
}
let sql = self.state.read(cx).value().to_string();
if sql.trim().is_empty() {
return;
}
cx.emit(if ignoring_errors {
QueryEditorEvent::RunScriptIgnoringErrors(sql)
} else {
QueryEditorEvent::RunScript(sql)
});
}
pub(crate) fn emit_explain(&mut self, analyze: bool, cx: &mut Context<Self>) {
if self.running {
return;
}
let Some(sql) = self.statement(cx) else {
return;
};
cx.emit(QueryEditorEvent::Explain { sql, analyze });
}
pub(crate) fn statement_writes(&self, cx: &gpui_kit::App) -> bool {
self.cached_statement(cx).1
}
fn statement(&self, cx: &gpui_kit::App) -> Option<String> {
self.cached_statement(cx).0
}
fn cached_statement(&self, cx: &gpui_kit::App) -> (Option<String>, bool) {
let key = {
let state = self.state.read(cx);
let mut hasher = DefaultHasher::new();
for chunk in state.text().chunks() {
chunk.hash(&mut hasher);
}
state.cursor().hash(&mut hasher);
state.selected_range().hash(&mut hasher);
hasher.finish()
};
if let Some(cached) = self.statement_cache.borrow().as_ref()
&& cached.key == key
{
return (cached.statement.clone(), cached.writes);
}
let statement = self.compute_statement(cx);
let writes = statement.as_ref().is_some_and(|sql| {
let inner = statement::explained(sql, self.engine)
.map(|(inner, _)| inner)
.unwrap_or_else(|| sql.clone());
statement::first_write(&inner, self.engine).is_some()
});
*self.statement_cache.borrow_mut() = Some(StatementCache {
key,
statement: statement.clone(),
writes,
});
(statement, writes)
}
pub(crate) fn transaction_blocker(&self, cx: &gpui_kit::App) -> Option<Blocker> {
let key = {
let mut hasher = DefaultHasher::new();
for chunk in self.state.read(cx).text().chunks() {
chunk.hash(&mut hasher);
}
hasher.finish()
};
if let Some((cached, blocker)) = self.blocker_cache.borrow().as_ref()
&& *cached == key
{
return blocker.clone();
}
let sql = self.state.read(cx).value().to_string();
let blocker = transaction_blocker(self.engine, &statement::split(&sql, self.engine));
*self.blocker_cache.borrow_mut() = Some((key, blocker.clone()));
blocker
}
fn compute_statement(&self, cx: &gpui_kit::App) -> Option<String> {
let state = self.state.read(cx);
let selected = state.selected_value().to_string();
if !selected.trim().is_empty() {
return Some(selected);
}
let sql = state.value().to_string();
statement::at_cursor(&sql, state.cursor(), self.engine).map(|statement| statement.text)
}
}
fn toggle_comments(
text: &str,
selected: std::ops::Range<usize>,
) -> Option<(String, std::ops::Range<usize>)> {
let len = text.len();
let start = selected.start.min(len);
let end = selected.end.min(len);
let last = if end > start && text[..end].ends_with('\n') {
end - 1
} else {
end
};
let mut spans = Vec::new();
let last_line_end = line_end(text, last);
let mut cursor = line_start(text, start);
loop {
let end_of_line = line_end(text, cursor);
spans.push((cursor, end_of_line));
if end_of_line == last_line_end {
break;
}
cursor = end_of_line + 1;
}
let blank = |line: &str| line.trim().is_empty();
let commented = |line: &str| line.trim_start_matches([' ', '\t']).starts_with("--");
let non_blank: Vec<&str> = spans
.iter()
.map(|&(s, e)| &text[s..e])
.filter(|line| !blank(line))
.collect();
let uncomment = !non_blank.is_empty() && non_blank.iter().all(|line| commented(line));
let mut out = String::with_capacity(len);
let mut edits: Vec<(usize, isize)> = Vec::new();
let mut prev = 0;
for &(s, e) in &spans {
out.push_str(&text[prev..s]);
let line = &text[s..e];
if blank(line) {
out.push_str(line);
} else {
let indent = line.len() - line.trim_start_matches([' ', '\t']).len();
let at = s + indent;
out.push_str(&line[..indent]);
if uncomment {
let rest = &line[indent..];
let marker = if rest.starts_with("-- ") { 3 } else { 2 };
out.push_str(&rest[marker..]);
edits.push((at, -(marker as isize)));
} else {
out.push_str("-- ");
out.push_str(&line[indent..]);
edits.push((at, 3));
}
}
prev = e;
}
out.push_str(&text[prev..]);
if out == text {
return None;
}
let shift = |offset: usize| -> usize {
let mut shifted = offset as isize;
for &(at, delta) in &edits {
if delta > 0 {
if offset > at {
shifted += delta;
}
} else {
let removed = (-delta) as usize;
if offset >= at + removed {
shifted += delta;
} else if offset > at {
shifted -= (offset - at) as isize;
}
}
}
shifted.clamp(0, out.len() as isize) as usize
};
let selection = shift(start)..shift(end);
Some((out, selection))
}
fn line_start(text: &str, offset: usize) -> usize {
text[..offset].rfind('\n').map(|at| at + 1).unwrap_or(0)
}
fn line_end(text: &str, offset: usize) -> usize {
text[offset..]
.find('\n')
.map(|at| offset + at)
.unwrap_or(text.len())
}
fn all_lines(text: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut start = 0;
for (at, byte) in text.bytes().enumerate() {
if byte == b'\n' {
spans.push((start, at));
start = at + 1;
}
}
spans.push((start, text.len()));
spans
}
fn touched_lines(
spans: &[(usize, usize)],
text: &str,
selected: std::ops::Range<usize>,
) -> (usize, usize) {
let len = text.len();
let start = selected.start.min(len);
let end = selected.end.min(len);
let last = if end > start && text[..end].ends_with('\n') {
end - 1
} else {
end
};
let line_of = |offset: usize| spans.iter().rposition(|&(s, _)| s <= offset).unwrap_or(0);
(line_of(start), line_of(last))
}
fn join_lines(text: &str, spans: &[(usize, usize)], order: &[usize]) -> (String, Vec<usize>) {
let mut out = String::with_capacity(text.len());
let mut new_starts = vec![0usize; spans.len()];
for (at, &line) in order.iter().enumerate() {
if at > 0 {
out.push('\n');
}
new_starts[line] = out.len();
let (s, e) = spans[line];
out.push_str(&text[s..e]);
}
(out, new_starts)
}
fn shift_selection(
selected: std::ops::Range<usize>,
delta: isize,
len: usize,
) -> std::ops::Range<usize> {
let shift = |offset: usize| (offset as isize + delta).clamp(0, len as isize) as usize;
shift(selected.start)..shift(selected.end)
}
fn delete_lines(
text: &str,
selected: std::ops::Range<usize>,
) -> Option<(String, std::ops::Range<usize>)> {
let spans = all_lines(text);
let (first, last) = touched_lines(&spans, text, selected);
let (start, _) = spans[first];
let (_, end) = spans[last];
let (rm_start, rm_end) = if end < text.len() {
(start, end + 1)
} else if start > 0 {
(start - 1, end)
} else {
(start, end)
};
if rm_start == rm_end {
return None;
}
let mut out = String::with_capacity(text.len());
out.push_str(&text[..rm_start]);
out.push_str(&text[rm_end..]);
let caret = rm_start.min(out.len());
Some((out, caret..caret))
}
fn delete_range(
text: &str,
selected: std::ops::Range<usize>,
) -> Option<(String, std::ops::Range<usize>)> {
if selected.is_empty() {
return None;
}
let mut out = String::with_capacity(text.len());
out.push_str(&text[..selected.start]);
out.push_str(&text[selected.end..]);
Some((out, selected.start..selected.start))
}
fn move_lines(
text: &str,
selected: std::ops::Range<usize>,
up: bool,
) -> Option<(String, std::ops::Range<usize>)> {
let spans = all_lines(text);
let (first, last) = touched_lines(&spans, text, selected.clone());
let mut order: Vec<usize> = (0..spans.len()).collect();
if up {
if first == 0 {
return None;
}
let block: Vec<usize> = order.drain(first..=last).collect();
for (at, line) in block.into_iter().enumerate() {
order.insert(first - 1 + at, line);
}
} else {
if last + 1 >= spans.len() {
return None;
}
let block: Vec<usize> = order.drain(first..=last).collect();
for (at, line) in block.into_iter().enumerate() {
order.insert(first + 1 + at, line);
}
}
let (out, new_starts) = join_lines(text, &spans, &order);
let delta = new_starts[first] as isize - spans[first].0 as isize;
let len = out.len();
Some((out, shift_selection(selected, delta, len)))
}
fn duplicate_lines(
text: &str,
selected: std::ops::Range<usize>,
up: bool,
) -> Option<(String, std::ops::Range<usize>)> {
let spans = all_lines(text);
let (first, last) = touched_lines(&spans, text, selected.clone());
let block: Vec<usize> = (first..=last).collect();
let mut order: Vec<usize> = (0..spans.len()).collect();
let copy_at = if up { first } else { last + 1 };
for (at, line) in block.iter().enumerate() {
order.insert(copy_at + at, *line);
}
let mut out = String::with_capacity(text.len() + spans[last].1 - spans[first].0 + 1);
let mut copy_start = 0;
for (at, &line) in order.iter().enumerate() {
if at > 0 {
out.push('\n');
}
if at == copy_at {
copy_start = out.len();
}
let (s, e) = spans[line];
out.push_str(&text[s..e]);
}
let delta = copy_start as isize - spans[first].0 as isize;
let len = out.len();
Some((out, shift_selection(selected, delta, len)))
}
fn line_clip_text(text: &str, caret: usize) -> String {
let spans = all_lines(text);
let caret = caret.min(text.len());
let index = spans.iter().rposition(|&(s, _)| s <= caret).unwrap_or(0);
let (s, e) = spans[index];
let mut out = text[s..e].to_string();
if e < text.len() {
out.push('\n');
}
out
}
fn select_line_range(text: &str, selected: std::ops::Range<usize>) -> std::ops::Range<usize> {
let spans = all_lines(text);
let (first, last) = touched_lines(&spans, text, selected.clone());
let with_break = |index: usize| {
let (_, e) = spans[index];
if e < text.len() { e + 1 } else { e }
};
let full = spans[first].0..with_break(last);
if !selected.is_empty() && selected == full && last + 1 < spans.len() {
spans[first].0..with_break(last + 1)
} else {
full
}
}
fn smart_home_offset(text: &str, caret: usize) -> usize {
let caret = caret.min(text.len());
let start = line_start(text, caret);
let indent = text[start..].len() - text[start..].trim_start_matches([' ', '\t']).len();
let first_text = start + indent;
if caret == first_text {
start
} else {
first_text
}
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn word_at(text: &str, caret: usize) -> Option<std::ops::Range<usize>> {
let bytes = text.as_bytes();
let caret = caret.min(bytes.len());
let mut start = caret;
while start > 0 && is_word_byte(bytes[start - 1]) {
start -= 1;
}
let mut end = caret;
while end < bytes.len() && is_word_byte(bytes[end]) {
end += 1;
}
if start == end { None } else { Some(start..end) }
}
fn next_occurrence(text: &str, selected: std::ops::Range<usize>) -> Option<std::ops::Range<usize>> {
let selected = if selected.is_empty() {
word_at(text, selected.start)?
} else {
selected
};
let needle = &text[selected.clone()];
if needle.is_empty() {
return None;
}
let len = needle.len();
let mut found = text[selected.end..]
.find(needle)
.map(|at| selected.end + at);
if found.is_none() {
found = text[..selected.end].find(needle);
}
let start = found?;
let range = start..start + len;
if range == selected { None } else { Some(range) }
}
fn previous_occurrence(
text: &str,
selected: std::ops::Range<usize>,
) -> Option<std::ops::Range<usize>> {
let selected = if selected.is_empty() {
word_at(text, selected.start)?
} else {
selected
};
let needle = &text[selected.clone()];
if needle.is_empty() {
return None;
}
let len = needle.len();
let mut found = text[..selected.start].rfind(needle);
if found.is_none() {
found = text[selected.start..]
.rfind(needle)
.map(|at| selected.start + at);
}
let start = found?;
let range = start..start + len;
if range == selected { None } else { Some(range) }
}
impl Render for QueryEditor {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let writes = self.statement_writes(cx);
let can_analyze = self.analyze_supported();
let blocker = self.transaction_blocker(cx);
v_flex()
.key_context("QueryEditor")
.on_action(cx.listener(Self::run))
.on_action(cx.listener(Self::run_script))
.on_action(cx.listener(Self::run_script_ignoring_errors))
.on_action(cx.listener(Self::explain))
.on_action(cx.listener(Self::explain_analyze))
.on_action(cx.listener(Self::toggle_comment))
.on_action(cx.listener(Self::copy_line))
.on_action(cx.listener(Self::cut_line))
.on_action(cx.listener(Self::delete_line))
.on_action(cx.listener(Self::move_line_up))
.on_action(cx.listener(Self::move_line_down))
.on_action(cx.listener(Self::duplicate_line_up))
.on_action(cx.listener(Self::duplicate_line_down))
.on_action(cx.listener(Self::select_next_occurrence))
.on_action(cx.listener(Self::select_previous_occurrence))
.on_action(cx.listener(Self::select_line))
.on_action(cx.listener(Self::smart_home))
.capture_key_down(cx.listener(Self::on_key_down))
.capture_action(cx.listener(Self::accept_with_tab))
.capture_action(cx.listener(Self::enter_with_menu_open))
.size_full()
.border_b_1()
.border_color(cx.theme().border)
.child(
h_flex()
.px_2()
.py_1()
.gap_2()
.justify_between()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("QUERY"),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("open-file")
.ghost()
.small()
.icon(IconName::FolderOpen)
.accessibility_label("Open a SQL file")
.tooltip_with_action(
"Open a SQL file",
&OpenFile,
Some("Session"),
)
.on_click(cx.listener(|_this, _, _window, cx| {
cx.emit(QueryEditorEvent::Open)
})),
)
.child(
Button::new("save-file")
.ghost()
.small()
.icon(IconName::HardDrive)
.accessibility_label("Save to a SQL file")
.tooltip_with_action(
"Save to a SQL file",
&SaveFile,
Some("Session"),
)
.on_click(cx.listener(|_this, _, _window, cx| {
cx.emit(QueryEditorEvent::Save)
})),
)
.child(
Button::new("explain")
.ghost()
.small()
.icon(AssetIcon::Route)
.accessibility_label("Explain the statement")
.tooltip_with_action(
"Show the statement's plan without running it",
&Explain,
Some("QueryEditor"),
)
.disabled(self.running)
.on_click(cx.listener(|this, _, _window, cx| {
this.emit_explain(false, cx)
})),
)
.child(
Button::new("explain-analyze")
.ghost()
.small()
.icon(AssetIcon::Gauge)
.accessibility_label("Explain the statement and run it")
.tooltip_with_action(
if !can_analyze {
"SQLite has no EXPLAIN ANALYZE; actual times are not available"
} else if writes {
"Analyze runs the query; this statement changes data"
} else {
"Explain the statement and run it for actual times"
},
&ExplainAnalyze,
Some("QueryEditor"),
)
.disabled(self.running || writes || !can_analyze)
.on_click(cx.listener(|this, _, _window, cx| {
this.emit_explain(true, cx)
})),
)
.child({
let (icon, label) = match &blocker {
Some(blocker) => (
IconName::TriangleAlert,
format!(
"Run every statement without a transaction: {}",
blocker.message()
),
),
None => (
IconName::SquareTerminal,
"Run every statement in one transaction, asking about \
each error"
.to_string(),
),
};
Button::new("run-script")
.ghost()
.small()
.icon(icon)
.accessibility_label(label.clone())
.tooltip_with_action(label, &RunScript, Some("QueryEditor"))
.disabled(self.running)
.on_click(cx.listener(|this, _, _window, cx| {
this.emit_run_script(false, cx)
}))
})
.child(
Button::new("run-script-ignoring-errors")
.ghost()
.small()
.icon(AssetIcon::FastForward)
.accessibility_label(
"Run every statement without a transaction, skipping errors",
)
.tooltip_with_action(
"Run every statement without a transaction, skipping errors",
&RunScriptIgnoringErrors,
Some("QueryEditor"),
)
.disabled(self.running)
.on_click(cx.listener(|this, _, _window, cx| {
this.emit_run_script(true, cx)
})),
)
.child(
Button::new("run")
.primary()
.small()
.icon(IconName::Play)
.label(if self.running { "Running…" } else { "Run" })
.tooltip_with_action(
"Run the selection, or the statement the caret is in",
&RunQuery,
Some("QueryEditor"),
)
.disabled(self.running)
.on_click(
cx.listener(|this, _, _window, cx| this.emit_run(cx)),
),
),
),
)
.child(
Editor::new(&self.state)
.h_full()
.appearance(false)
.border_t_1()
.border_color(cx.theme().border)
.font_family(settings::editor_font(cx)),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn toggle(text: &str, selected: std::ops::Range<usize>) -> (String, std::ops::Range<usize>) {
toggle_comments(text, selected).expect("there should be a change to make")
}
#[test]
fn the_caret_line_is_commented() {
assert_eq!(toggle("select 1", 3..3), ("-- select 1".to_string(), 6..6));
}
#[test]
fn commenting_keeps_the_indentation() {
assert_eq!(
toggle(" select 1", 6..6),
(" -- select 1".to_string(), 9..9)
);
}
#[test]
fn a_commented_line_comes_back_off() {
assert_eq!(toggle("-- select 1", 3..3), ("select 1".to_string(), 0..0));
assert_eq!(
toggle(" -- select 1", 5..5),
(" select 1".to_string(), 2..2)
);
}
#[test]
fn a_marker_with_no_space_comes_off_cleanly() {
assert_eq!(toggle("--select 1", 2..2), ("select 1".to_string(), 0..0));
}
#[test]
fn a_selection_comments_every_line_it_touches() {
let (new, selection) = toggle("select 1\nselect 2\nselect 3", 0..18);
assert_eq!(new, "-- select 1\n-- select 2\nselect 3");
assert_eq!(
selection,
0..24,
"the selection should still cover the lines it commented"
);
}
#[test]
fn a_selection_ending_at_the_next_line_does_not_take_it() {
let (new, _) = toggle("select 1\nselect 2", 0..9);
assert_eq!(new, "-- select 1\nselect 2");
}
#[test]
fn a_fully_commented_run_is_uncommented_together() {
let text = "-- select 1\n -- select 2";
let (new, selection) = toggle(text, 0..text.len());
assert_eq!(new, "select 1\n select 2");
assert_eq!(selection, 0..19);
}
#[test]
fn a_mixed_run_is_commented_as_a_whole() {
let text = "-- select 1\nselect 2";
let (new, _) = toggle(text, 0..text.len());
assert_eq!(new, "-- -- select 1\n-- select 2");
}
#[test]
fn blank_lines_are_left_alone() {
let text = "select 1\n\nselect 2";
let (new, _) = toggle(text, 0..text.len());
assert_eq!(new, "-- select 1\n\n-- select 2");
}
#[test]
fn a_blank_caret_line_is_a_no_op() {
assert_eq!(toggle_comments(" \nselect 1", 1..1), None);
}
#[test]
fn the_selection_survives_the_comments_coming_off() {
let text = "-- select 1\n-- select 2";
let (new, selection) = toggle(text, 0..text.len());
assert_eq!(new, "select 1\nselect 2");
assert_eq!(selection, 0..17);
}
#[test]
fn delete_lines_removes_the_caret_line_and_its_newline() {
let (new, caret) = delete_lines("select 1\nselect 2\nselect 3", 3..3).unwrap();
assert_eq!(new, "select 2\nselect 3");
assert_eq!(caret, 0..0);
}
#[test]
fn delete_lines_takes_the_newline_before_the_last_line() {
let (new, caret) = delete_lines("select 1\nselect 2", 12..12).unwrap();
assert_eq!(new, "select 1");
assert_eq!(caret, 8..8);
}
#[test]
fn delete_lines_removes_every_line_the_selection_touches() {
let (new, caret) = delete_lines("a\nb\nc", 0..4).unwrap();
assert_eq!(new, "c");
assert_eq!(caret, 0..0);
}
#[test]
fn delete_lines_stops_at_a_line_start() {
let (new, _) = delete_lines("a\nb\nc", 0..2).unwrap();
assert_eq!(new, "b\nc");
}
#[test]
fn delete_lines_on_an_empty_buffer_is_a_no_op() {
assert_eq!(delete_lines("", 0..0), None);
}
#[test]
fn delete_range_removes_the_selection() {
let (new, caret) = delete_range("select 1", 2..8).unwrap();
assert_eq!(new, "se");
assert_eq!(caret, 2..2);
}
#[test]
fn delete_range_with_a_bare_caret_is_a_no_op() {
assert_eq!(delete_range("select 1", 3..3), None);
}
#[test]
fn move_lines_up_swaps_with_the_line_above() {
let (new, selection) = move_lines("a\nb\nc", 2..2, true).unwrap();
assert_eq!(new, "b\na\nc");
assert_eq!(selection, 0..0, "the caret rides along with its line");
}
#[test]
fn move_lines_down_swaps_with_the_line_below() {
let (new, selection) = move_lines("a\nb\nc", 2..2, false).unwrap();
assert_eq!(new, "a\nc\nb");
assert_eq!(selection, 4..4);
}
#[test]
fn move_lines_moves_a_whole_selection() {
let (new, selection) = move_lines("a\nb\nc\nd", 2..5, true).unwrap();
assert_eq!(new, "b\nc\na\nd");
assert_eq!(selection, 0..3);
}
#[test]
fn move_lines_at_the_edges_is_a_no_op() {
assert_eq!(move_lines("a\nb", 0..0, true), None);
assert_eq!(move_lines("a\nb", 2..2, false), None);
}
#[test]
fn move_lines_down_past_a_trailing_newline() {
let (new, selection) = move_lines("a\nb\n", 2..2, false).unwrap();
assert_eq!(new, "a\n\nb");
assert_eq!(selection, 3..3);
}
#[test]
fn duplicate_lines_down_copies_below_and_moves_the_caret_onto_it() {
let (new, selection) = duplicate_lines("a\nb\nc", 2..2, false).unwrap();
assert_eq!(new, "a\nb\nb\nc");
assert_eq!(selection, 4..4);
}
#[test]
fn duplicate_lines_up_copies_above_and_moves_the_caret_onto_it() {
let (new, selection) = duplicate_lines("a\nb\nc", 2..2, true).unwrap();
assert_eq!(new, "a\nb\nb\nc");
assert_eq!(selection, 2..2);
}
#[test]
fn duplicate_lines_down_at_the_end_grows_the_buffer() {
let (new, selection) = duplicate_lines("a\nb", 2..2, false).unwrap();
assert_eq!(new, "a\nb\nb");
assert_eq!(selection, 4..4);
}
#[test]
fn duplicate_lines_copies_a_whole_selection() {
let (new, selection) = duplicate_lines("a\nb\nc", 0..3, false).unwrap();
assert_eq!(new, "a\nb\na\nb\nc");
assert_eq!(selection, 4..7);
}
#[test]
fn line_clip_text_carries_the_newline() {
assert_eq!(line_clip_text("a\nb\nc", 2), "b\n");
assert_eq!(line_clip_text("a\nb", 2), "b");
assert_eq!(line_clip_text("a\nb\nc", 0), "a\n");
}
#[test]
fn select_line_range_takes_the_line_and_its_newline() {
assert_eq!(select_line_range("a\nb\nc", 2..2), 2..4);
assert_eq!(select_line_range("a\nb", 2..2), 2..3);
}
#[test]
fn select_line_range_expands_a_partial_selection() {
assert_eq!(select_line_range("a\nb", 0..1), 0..2);
}
#[test]
fn select_line_range_pressed_twice_reaches_the_next_line() {
assert_eq!(select_line_range("a\nb\nc", 2..4), 2..5);
assert_eq!(select_line_range("a\nb\nc\n", 2..4), 2..6);
}
#[test]
fn smart_home_toggles_between_indent_and_line_start() {
assert_eq!(smart_home_offset(" select 1", 6), 4);
assert_eq!(smart_home_offset(" select 1", 4), 0);
assert_eq!(smart_home_offset(" select 1", 0), 4);
assert_eq!(smart_home_offset(" select 1", 2), 4);
}
#[test]
fn smart_home_on_a_blank_line_stays_put() {
assert_eq!(smart_home_offset("select 1", 3), 0);
assert_eq!(smart_home_offset("select 1", 0), 0);
}
#[test]
fn smart_home_counts_tabs_as_indentation() {
assert_eq!(smart_home_offset("\t\tselect", 5), 2);
assert_eq!(smart_home_offset("\t\tselect", 2), 0);
}
#[test]
fn word_at_finds_the_word_touching_the_caret() {
assert_eq!(word_at("foo bar", 1), Some(0..3));
assert_eq!(word_at("foo bar", 0), Some(0..3));
assert_eq!(word_at("foo bar", 3), Some(0..3));
assert_eq!(word_at("foo bar", 4), None);
assert_eq!(word_at("foo_bar", 4), Some(0..7));
}
#[test]
fn next_occurrence_selects_the_word_first_then_advances() {
assert_eq!(next_occurrence("foo bar foo", 1..1), Some(8..11));
assert_eq!(next_occurrence("foo bar foo", 0..3), Some(8..11));
}
#[test]
fn next_occurrence_wraps_around() {
assert_eq!(next_occurrence("foo bar foo", 8..11), Some(0..3));
}
#[test]
fn next_occurrence_with_a_lone_match_is_a_no_op() {
assert_eq!(next_occurrence("foo", 0..3), None);
assert_eq!(next_occurrence("foo", 1..1), None);
assert_eq!(next_occurrence("a + b", 2..2), None);
}
#[test]
fn occurrence_matching_is_case_sensitive() {
assert_eq!(next_occurrence("Foo foo", 0..3), None);
}
#[test]
fn previous_occurrence_goes_backwards_and_wraps() {
assert_eq!(previous_occurrence("foo bar foo", 8..11), Some(0..3));
assert_eq!(previous_occurrence("foo bar foo", 0..3), Some(8..11));
}
#[test]
fn previous_occurrence_with_a_lone_match_is_a_no_op() {
assert_eq!(previous_occurrence("foo", 0..3), None);
assert_eq!(previous_occurrence("foo bar", 5..5), None);
}
}