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
23 changes: 23 additions & 0 deletions linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,29 @@ exits. A file already open brings its window forward. A database on a
network or FUSE filesystem asks whether to open read-only first, because
SQLite's locking cannot be trusted there.

## Vim keybindings

Turn on **Preferences → Editor → Vim Keybindings**. The SQL editor then
runs GtkSourceView's Vim emulation: normal, insert, replace and visual
modes, motions, text objects, registers, marks, search with `/` and `?`,
and `.` to repeat. The line under the text shows the mode and the command
you are typing.

| Command | Does |
|---|---|
| `:w` | Saves the tab's file, as Ctrl+S does. A tab with no file asks for one |
| `:w name` | Saves to `name`, next to the tab's file or in your home folder, and the tab follows it. An existing file is left alone; `:w! name` replaces it |
| `:e` | Reads the tab's file again, asking first when that loses changes. `:e!` does not ask |
| `:e name` | Opens `name` in the tab, as Open SQL File does. `:e! name` does not ask |
| `:q`, `:q!` | Closes the tab. A tab with unsaved file changes or an open transaction asks first, as closing does |
| `:wq`, `:x` | Saves, then closes the tab. `:x` skips the save when the file has no changes |

The editor's own Ctrl shortcuts work in every mode: Ctrl+Return runs,
Ctrl+Shift+Return runs the statement at the cursor, and find, find and
replace, format, toggle comment, open and save keep their keys. Every
other key goes to Vim, Escape too, so while Vim is on the **Stop** button
stops a running query. Completion opens in insert mode only.

## SQL Server with Windows integrated auth

Pick **Method → Windows (Kerberos)** in the connect dialog. There is no
Expand Down
4 changes: 2 additions & 2 deletions linux/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor
| Files | Open a .sql file into the editor (Ctrl+O) or drop one on it, and save to it (Ctrl+S, or Save As with Ctrl+Shift+S) |
| Results | Several result sets per run, a limit of 10,000 rows per result and 100,000 per run with a banner and Export All, and Go to Error |
| Drafts | Each editor tab's text is kept, and a connection's tabs (table tabs with page and sort, structure and editor tabs) reopen when it opens |
| Vim mode | GtkSourceView's Vim emulation, off by default, with the mode and pending command under the text; `:w`, `:e`, `:q`, `:wq` and `:x` act on the tab, and the editor's Ctrl shortcuts work in every mode ([README](README.md#vim-keybindings)) |

### History and saved queries

Expand Down Expand Up @@ -103,7 +104,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor
| Session restore | The windows of saved connections and SQLite files open at quit open again at launch (Restore Session, on by default), also after New Window or a quit during the restore |
| Closing safely | Unsaved edits, a save in flight or of unknown outcome, an open editor transaction, or a running export ask before a window closes or the app quits; logout waits while any of those holds, and suspend while a save runs or an export writes |
| Notifications | Work that finishes in a tab you are not looking at sends a notification |
| Preferences | Restore session, page size, the bulk delete confirmation, query history, editor font and scheme, query timeout, the MCP server |
| Preferences | Restore session, page size, the bulk delete confirmation, query history, editor font and scheme, Vim keybindings, query timeout, the MCP server |
| Shortcuts and About | A shortcuts dialog, and About from the metainfo with third-party licenses and debug info |

### MCP server
Expand Down Expand Up @@ -149,7 +150,6 @@ Not built yet, in no order.
### SQL editor

- Multi-cursor editing.
- Vim mode.
- Snippets.

### Release and project
Expand Down
1 change: 1 addition & 0 deletions linux/changelog.d/3804.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The SQL editor has Vim keybindings, turned on in Preferences, and :w, :e, :q and :wq act on the tab.
1 change: 1 addition & 0 deletions linux/changelog.d/3804.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Open SQL File replaced a tab's file without asking when its text was deleted and not saved.
67 changes: 67 additions & 0 deletions linux/crates/app/src/application/accelerators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,30 @@ pub fn component_matches(action: &str, keyval: gdk::Key, state: gdk::ModifierTyp
.any(|(key, mods)| mods == modifiers && (key == keyval || keypad_twins(key).contains(&keyval)))
}

/// The SQL editor's own Ctrl keys, which Vim mode leaves to the editor in
/// every mode. A trigger, so an event matches it as the editor's shortcut
/// controllers match it, layout and consumed Shift included. Escape and
/// the other plain keys are Vim's.
pub fn editor_kept_keys() -> Option<gtk::ShortcutTrigger> {
let accels: Vec<&str> = ACCELERATORS
.iter()
.filter(|entry| is_editors(entry))
.flat_map(|entry| entry.triggers.iter().copied())
.filter(|accel| parse(accel).is_some_and(|(_, mods)| mods.contains(gdk::ModifierType::CONTROL_MASK)))
.collect();
shortcut_trigger(&accels)
}

fn is_editors(entry: &Accelerator) -> bool {
matches!(
entry.scope,
Local {
owner: SqlEditor | SqlEditorTab,
..
}
)
}

/// GtkPopoverMenu shows an application accelerator by itself; a Local or
/// Component one has to be put on the item.
pub fn menu_item(label: &str, detailed_action: &str) -> gio::MenuItem {
Expand Down Expand Up @@ -663,6 +687,49 @@ mod tests {
assert!(!component_matches("cell-editor.pick-date", gdk::Key::Return, none));
}

#[gtk4::test]
fn the_editor_keeps_its_ctrl_keys_from_vim() {
let kept: HashSet<Keystroke> = trigger_keys(&editor_kept_keys().expect("the editor has Ctrl keys"))
.into_iter()
.collect();

for accel in [
"<Primary>Return",
"<Primary>KP_Enter",
"<Primary><Shift>Return",
"<Primary><Shift>f",
"<Primary>slash",
"<Primary>f",
"<Primary>h",
"<Primary>g",
"<Primary><Shift>g",
"<Primary>o",
"<Primary>s",
"<Primary><Shift>s",
] {
assert!(kept.contains(&keystroke(accel)), "{accel}");
}
for accel in ["Escape", "<Primary>r", "<Primary>d", "<Primary>v", "<Primary>w", "f"] {
assert!(!kept.contains(&keystroke(accel)), "{accel}");
}
}

#[gtk4::test]
fn every_ctrl_key_of_the_editor_is_kept_from_vim() {
let kept: HashSet<Keystroke> = trigger_keys(&editor_kept_keys().expect("the editor has Ctrl keys"))
.into_iter()
.collect();
let owned: HashSet<Keystroke> = ACCELERATORS
.iter()
.filter(|entry| is_editors(entry))
.flat_map(|entry| entry.triggers.iter())
.flat_map(|accel| keystrokes_with_twins(accel))
.filter(|(_, mods)| mods.contains(gdk::ModifierType::CONTROL_MASK))
.collect();

assert_eq!(kept, owned);
}

#[gtk4::test]
fn shortcuts_dialog_matches_accelerator_table() {
adw::init().expect("libadwaita initialises under the test backend");
Expand Down
4 changes: 2 additions & 2 deletions linux/crates/app/src/application/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ pub use accelerator_scope::AcceleratorScope;
#[cfg(test)]
pub(crate) use accelerators::keypad_twins;
pub use accelerators::{
ACCELERATORS, component_matches, component_trigger, controller_name, install_application_accelerators,
local_shortcut_controller, menu_item, triggers_for,
ACCELERATORS, component_matches, component_trigger, controller_name, editor_kept_keys,
install_application_accelerators, local_shortcut_controller, menu_item, triggers_for,
};
pub use app_actions::install_app_actions;
pub use app_services::{AppServices, AppServicesParts};
Expand Down
110 changes: 82 additions & 28 deletions linux/crates/app/src/ui/editor/editor_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,42 @@ impl SqlEditor {

/// Ctrl+S: write to the tab's file, or ask for one.
pub(super) fn save(&self) {
match self.file() {
Some(file) => self.write_reporting(file),
None => self.save_as(),
}
self.save_then(|_| {});
}

/// A cancelled choice or a failed write skips `then`.
pub(super) fn save_then(&self, then: impl FnOnce(&SqlEditor) + 'static) {
let weak = self.downgrade();
let file = self.file();
let chosen = file.is_none().then(|| self.choose_save_file());
glib::spawn_future_local(async move {
let file = match (file, chosen) {
(Some(file), _) => Some(file),
(None, Some(chosen)) => chosen.await,
(None, None) => None,
};
let (Some(file), Some(editor)) = (file, weak.upgrade()) else {
return;
};
if editor.write_reporting(&file).await {
then(&editor);
}
});
}

/// Ctrl+Shift+S: ask where to write, starting from the tab's file.
pub(super) fn save_as(&self) {
let weak = self.downgrade();
let chosen = self.choose_save_file();
glib::spawn_future_local(async move {
if let (Some(file), Some(editor)) = (chosen.await, weak.upgrade()) {
editor.write_reporting(&file).await;
}
});
}

/// Holds only the dialog's window while it is open.
fn choose_save_file(&self) -> impl Future<Output = Option<gio::File>> + use<> {
let filter = sql_filter();
let filters = gio::ListStore::new::<gtk4::FileFilter>();
filters.append(&filter);
Expand All @@ -89,15 +117,7 @@ impl SqlEditor {
None => dialog.set_initial_name(Some(&suggested_file_name(&buffer_text(&self.buffer())))),
}
let parent = self.root().and_downcast::<gtk4::Window>();
let weak = self.downgrade();
glib::spawn_future_local(async move {
let Ok(file) = dialog.save_future(parent.as_ref()).await else {
return;
};
if let Some(editor) = weak.upgrade() {
editor.write_reporting(file);
}
});
async move { dialog.save_future(parent.as_ref()).await.ok() }
}

/// Write the text to the tab's file, for a close that chose Save.
Expand All @@ -112,32 +132,49 @@ impl SqlEditor {
}
}

fn write_reporting(&self, file: gio::File) {
let editor = self.clone();
glib::spawn_future_local(async move {
if let Err(message) = editor.write_to(&file).await {
editor.alert_save_failed(&message);
/// Whether the text landed in `file`; an alert says why it did not.
pub(super) async fn write_reporting(&self, file: &gio::File) -> bool {
match self.write_to(file).await {
Ok(()) => true,
Err(message) => {
self.alert_save_failed(&message);
false
}
});
}
}

async fn write_to(&self, file: &gio::File) -> Result<(), String> {
self.write_with(file, false)
.await
.map_err(|failure| failure.message(file))
}

/// As Save As to a name no file has yet; one that appears meanwhile
/// is left alone and fails with `Exists`.
pub(super) async fn write_new(&self, file: &gio::File) -> Result<(), WriteFailure> {
self.write_with(file, true).await
}

/// Text typed while the write runs is not in the file, so the tab
/// stays changed.
async fn write_to(&self, file: &gio::File) -> Result<(), String> {
async fn write_with(&self, file: &gio::File, create: bool) -> Result<(), WriteFailure> {
let imp = self.imp();
if imp.saving.replace(true) {
return Err(crate::i18n::gettext("This tab is already being saved."));
return Err(WriteFailure::Busy);
}
let buffer = self.buffer();
let edits = imp.edits.get();
let written = crate::ui::file_write::replace_file(file, buffer_text(&buffer).into_bytes()).await;
let contents = buffer_text(&buffer).into_bytes();
let written = match create {
true => crate::ui::file_write::create_file(file, contents).await,
false => crate::ui::file_write::replace_file(file, contents).await,
};
imp.saving.set(false);
if let Err(error) = written {
tracing::warn!(%error, "could not save an editor tab to its file");
return Err(crate::i18n::gettext_f(
"“{name}” could not be saved: {error}",
&[("name", &file_name(file)), ("error", error.message())],
));
if !error.matches(gio::IOErrorEnum::Exists) {
tracing::warn!(%error, "could not save an editor tab to its file");
}
return Err(WriteFailure::Io(error));
}
imp.file.replace(Some(file.clone()));
if imp.edits.get() == edits {
Expand All @@ -147,7 +184,7 @@ impl SqlEditor {
Ok(())
}

fn alert_save_failed(&self, message: &str) {
pub(super) fn alert_save_failed(&self, message: &str) {
let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Could Not Save File")), Some(message));
dialog.add_response("close", &crate::i18n::gettext("_Close"));
dialog.set_default_response(Some("close"));
Expand All @@ -156,6 +193,23 @@ impl SqlEditor {
}
}

pub(super) enum WriteFailure {
Busy,
Io(glib::Error),
}

impl WriteFailure {
pub(super) fn message(&self, file: &gio::File) -> String {
match self {
Self::Busy => crate::i18n::gettext("This tab is already being saved."),
Self::Io(error) => crate::i18n::gettext_f(
"“{name}” could not be saved: {error}",
&[("name", &file_name(file)), ("error", error.message())],
),
}
}
}

pub(super) fn sql_filter() -> gtk4::FileFilter {
let filter = gtk4::FileFilter::new();
filter.set_name(Some(&crate::i18n::gettext("SQL files")));
Expand Down
11 changes: 11 additions & 0 deletions linux/crates/app/src/ui/editor/imp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use super::find_bar::FindBar;
use super::results_pane::ResultsPane;
use super::run_log::RunLog;
use super::transaction_dirty::OpenTransaction;
use super::vim_mode::VimMode;
use crate::ui::grid::GridSink;
use crate::ui::handler_guard::HandlerGuard;

Expand All @@ -41,6 +42,12 @@ pub struct SqlEditor {
#[template_child]
pub source_view: TemplateChild<sourceview5::View>,
#[template_child]
pub text_pane: TemplateChild<adw::ToolbarView>,
#[template_child]
pub vim_command_bar: TemplateChild<gtk4::Label>,
#[template_child]
pub vim_command: TemplateChild<gtk4::Label>,
#[template_child]
pub results: TemplateChild<ResultsPane>,

pub(super) deps: OnceCell<EditorDeps>,
Expand Down Expand Up @@ -69,6 +76,7 @@ pub struct SqlEditor {
/// settings, which would otherwise outlive the tab.
pub(super) app_handlers: RefCell<Vec<HandlerGuard>>,
pub(super) completion_providers: RefCell<Vec<sourceview5::CompletionProvider>>,
pub(super) vim: RefCell<Option<VimMode>>,
}

#[glib::object_subclass]
Expand Down Expand Up @@ -114,13 +122,16 @@ impl ObjectImpl for SqlEditor {
.param_types([bool::static_type()])
.build(),
Signal::builder("file-changed").build(),
// Vim's :q, which closes the tab through its close prompts.
Signal::builder("close-requested").build(),
]
})
}

fn dispose(&self) {
self.obj().shutdown();
self.app_handlers.take();
self.obj().detach_vim();
self.obj().release_view();
}
}
Expand Down
Loading
Loading