diff --git a/.gitignore b/.gitignore index 7249d8d..49f3c16 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ target/ plans/ .DS_Store +.idea \ No newline at end of file diff --git a/editor/src/main.rs b/editor/src/main.rs index 586f944..7add816 100644 --- a/editor/src/main.rs +++ b/editor/src/main.rs @@ -1,9 +1,19 @@ use std::{path::PathBuf, sync::Arc}; use gpui::{ - AppContext, Bounds, KeyBinding, Menu, MenuItem, QuitMode, WindowBounds, WindowOptions, px, size, + App, AppContext, Bounds, KeyBinding, Menu, MenuItem, QuitMode, WindowBounds, WindowOptions, px, + size, }; use gpui_component::dock::ClosePanel; +use gpui_component::input::{ + Backspace, Copy, Cut, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, + DeleteToNextWordEnd, DeleteToPreviousWordStart, Enter, Escape as InputEscape, Indent, + IndentInline, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, + MoveToEnd, MoveToNextWord, MoveToStart, MoveToPreviousWord, MoveToStartOfLine, + MoveToEndOfLine, MoveUp, Outdent, OutdentInline, Paste as InputPaste, Redo, SelectAll, + SelectToEnd, SelectToEndOfLine, SelectToNextWordEnd, SelectToPreviousWordStart, SelectToStart, + SelectToStartOfLine, Undo, +}; use gpui_component::{GlobalState, Root}; mod app_theme; @@ -23,6 +33,7 @@ use ui::panels::file_editor::{ ToggleEditorReplace, ToggleEditorSearch, }; use ui::panels::file_search::ToggleFileSearch; +use ui::panels::keymap::{CustomKeymap, ToggleKeymap, load_custom_keymap}; use ui::panels::project_search::ToggleProjectSearch; use ui::panels::result::{ CloseActiveTab as ResultCloseActiveTab, CopyResultSelection, @@ -40,7 +51,8 @@ use ui::panels::terminal::{ use workspace::{ CloseRecentFolders, ConfirmRecentFolder, ConfirmSelectedConnection, OpenFolder, OpenRecentFolders, SelectNextConnection, SelectNextRecentFolder, SelectPreviousConnection, - SelectPreviousRecentFolder, ToggleSearchReplace, Workspace, load_recent_folders, + SelectPreviousRecentFolder, ToggleLeftDock, ToggleResultsPanel, ToggleRightDock, + ToggleSearchReplace, ToggleTerminal, Workspace, load_recent_folders, }; fn app_icon() -> Option> { @@ -54,9 +66,325 @@ fn app_icon() -> Option> { } } +fn register_component_bindings(cx: &mut App) { + const INPUT: Option<&str> = Some("Input"); + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, INPUT), + KeyBinding::new("shift-backspace", Backspace, INPUT), + KeyBinding::new("delete", Delete, INPUT), + KeyBinding::new("shift-delete", Delete, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-backspace", Backspace, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-delete", DeleteToEndOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-backspace", DeleteToPreviousWordStart, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-delete", DeleteToNextWordEnd, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-backspace", DeleteToPreviousWordStart, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, INPUT), + KeyBinding::new("enter", Enter { secondary: false, shift: false }, INPUT), + KeyBinding::new("shift-enter", Enter { secondary: false, shift: true }, INPUT), + KeyBinding::new("escape", InputEscape, INPUT), + KeyBinding::new("up", MoveUp, INPUT), + KeyBinding::new("down", MoveDown, INPUT), + KeyBinding::new("left", MoveLeft, INPUT), + KeyBinding::new("right", MoveRight, INPUT), + KeyBinding::new("pageup", MovePageUp, INPUT), + KeyBinding::new("pagedown", MovePageDown, INPUT), + KeyBinding::new("home", MoveHome, INPUT), + KeyBinding::new("end", MoveEnd, INPUT), + KeyBinding::new("tab", IndentInline, INPUT), + KeyBinding::new("shift-tab", OutdentInline, INPUT), + KeyBinding::new("shift-home", SelectToStartOfLine, INPUT), + KeyBinding::new("shift-end", SelectToEndOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-]", Indent, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-[", Outdent, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-]", Indent, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-[", Outdent, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-a", MoveHome, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-e", MoveEnd, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-shift-e", SelectToEndOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-left", MoveToStartOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-right", MoveToEndOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("shift-cmd-left", SelectToStartOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("shift-cmd-right", SelectToEndOfLine, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-up", MoveToStart, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-down", MoveToEnd, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-up", SelectToStart, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-down", SelectToEnd, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-left", MoveToPreviousWord, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-right", MoveToNextWord, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-shift-left", SelectToPreviousWordStart, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-shift-right", SelectToNextWordEnd, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-left", MoveToPreviousWord, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-right", MoveToNextWord, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-shift-left", SelectToPreviousWordStart, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-shift-right", SelectToNextWordEnd, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-a", SelectAll, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-a", SelectAll, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-c", Copy, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-c", Copy, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-x", Cut, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-x", Cut, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-v", InputPaste, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-v", InputPaste, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-z", Undo, INPUT), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-z", Redo, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-z", Undo, INPUT), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-y", Redo, INPUT), + ]); + + // Panel-specific key bindings also wiped by clear_key_bindings. + ui::panels::file_tree::init(cx); + ui::panels::file_editor::editor::init(cx); + ui::panels::file_search::init(cx); + ui::panels::project_search::init(cx); + ui::panels::keymap::init(cx); +} + +pub fn bind_all_keys(cx: &mut App, custom: &CustomKeymap) { + let key = |id: &str, default: &'static str| -> String { + custom + .get(id) + .cloned() + .unwrap_or_else(|| default.to_string()) + }; + + cx.clear_key_bindings(); + register_component_bindings(cx); + cx.bind_keys(vec![ + KeyBinding::new(&key("close_active_tab", "cmd-w"), ClosePanel, None), + KeyBinding::new( + &key("close_active_tab", "cmd-w"), + EditorCloseActiveTab, + Some("editor_tabs"), + ), + KeyBinding::new( + &key("close_active_tab", "cmd-w"), + TerminalCloseActiveTab, + Some("terminal_panel"), + ), + KeyBinding::new( + &key("close_active_tab", "cmd-w"), + ResultCloseActiveTab, + Some("results_panel"), + ), + KeyBinding::new( + &key("open_recent_folders", "cmd-o"), + OpenRecentFolders, + None, + ), + KeyBinding::new(&key("open_folder", "cmd-shift-o"), OpenFolder, None), + KeyBinding::new(&key("toggle_file_search", "cmd-e"), ToggleFileSearch, None), + KeyBinding::new( + &key("toggle_editor_search", "cmd-f"), + ToggleEditorSearch, + Some("Input"), + ), + KeyBinding::new( + &key("toggle_project_search", "cmd-shift-f"), + ToggleProjectSearch, + None, + ), + KeyBinding::new( + &key("toggle_editor_replace", "cmd-shift-h"), + ToggleSearchReplace, + None, + ), + KeyBinding::new( + &key("execute_query", "cmd-enter"), + ExecuteQuery, + Some("Input"), + ), + KeyBinding::new(&key("save_file", "cmd-s"), SaveFile, Some("Input")), + KeyBinding::new( + &key("format_query", "cmd-alt-l"), + FormatQuery, + Some("Input"), + ), + KeyBinding::new( + &key("toggle_comment_lines", "cmd-/"), + ToggleCommentLines, + Some("Input"), + ), + KeyBinding::new( + &key("indent_lines", "tab"), + IndentLines, + Some("file_editor"), + ), + KeyBinding::new( + &key("outdent_lines", "shift-tab"), + OutdentLines, + Some("file_editor"), + ), + KeyBinding::new( + &key("cut_editor_line", "cmd-x"), + CutEditorLine, + Some("file_editor"), + ), + KeyBinding::new("shift-up", ExtendResultSelectionUp, Some("results_panel")), + KeyBinding::new( + "shift-down", + ExtendResultSelectionDown, + Some("results_panel"), + ), + KeyBinding::new( + "shift-left", + ExtendResultSelectionLeft, + Some("results_panel"), + ), + KeyBinding::new( + "shift-right", + ExtendResultSelectionRight, + Some("results_panel"), + ), + KeyBinding::new("shift-up", ExtendResultSelectionUp, Some("DataTable")), + KeyBinding::new("shift-down", ExtendResultSelectionDown, Some("DataTable")), + KeyBinding::new("shift-left", ExtendResultSelectionLeft, Some("DataTable")), + KeyBinding::new("shift-right", ExtendResultSelectionRight, Some("DataTable")), + KeyBinding::new("left", SelectResultCellLeft, Some("DataTable")), + KeyBinding::new("right", SelectResultCellRight, Some("DataTable")), + KeyBinding::new("home", SelectResultFirstCellColumn, Some("DataTable")), + KeyBinding::new("end", SelectResultLastCellColumn, Some("DataTable")), + KeyBinding::new("up", SelectResultCellUp, Some("DataTable")), + KeyBinding::new("down", SelectResultCellDown, Some("DataTable")), + KeyBinding::new( + &key("edit_result_cell", "enter"), + EditResultCell, + Some("DataTable"), + ), + KeyBinding::new( + &key("toggle_bottom_panel", "cmd-j"), + ToggleBottomPanelMode, + None, + ), + KeyBinding::new( + &key("new_terminal_tab", "cmd-t"), + NewTerminalTab, + Some("terminal_panel"), + ), + KeyBinding::new( + &key("copy_terminal_selection", "cmd-c"), + CopyTerminalSelection, + Some("terminal_panel"), + ), + KeyBinding::new( + &key("paste_terminal", "cmd-v"), + Paste, + Some("terminal_panel"), + ), + KeyBinding::new( + &key("copy_result_selection", "cmd-c"), + CopyResultSelection, + None, + ), + KeyBinding::new(&key("cycle_tab_forward", "ctrl-tab"), CycleTabForward, None), + KeyBinding::new( + &key("cycle_tab_backward", "ctrl-shift-tab"), + CycleTabBackward, + None, + ), + KeyBinding::new(&key("navigate_back", "cmd-["), NavigateBack, None), + KeyBinding::new(&key("navigate_forward", "cmd-]"), NavigateForward, None), + KeyBinding::new( + &key("cycle_tab_forward", "ctrl-tab"), + TerminalCycleTabForward, + Some("terminal_panel"), + ), + KeyBinding::new( + &key("cycle_tab_backward", "ctrl-shift-tab"), + TerminalCycleTabBackward, + Some("terminal_panel"), + ), + KeyBinding::new( + &key("cycle_tab_forward", "ctrl-tab"), + ResultCycleTabForward, + Some("results_panel"), + ), + KeyBinding::new( + &key("cycle_tab_backward", "ctrl-shift-tab"), + ResultCycleTabBackward, + Some("results_panel"), + ), + KeyBinding::new("up", SelectPreviousQuery, None), + KeyBinding::new("down", SelectNextQuery, None), + KeyBinding::new("enter", ConfirmSelectedQuery, None), + KeyBinding::new("up", SelectPreviousConnection, Some("ConnectionSelector")), + KeyBinding::new("down", SelectNextConnection, Some("ConnectionSelector")), + KeyBinding::new( + "enter", + ConfirmSelectedConnection, + Some("ConnectionSelector"), + ), + KeyBinding::new("up", SelectPreviousRecentFolder, Some("RecentFolders")), + KeyBinding::new("down", SelectNextRecentFolder, Some("RecentFolders")), + KeyBinding::new("enter", ConfirmRecentFolder, Some("RecentFolders")), + KeyBinding::new("escape", CloseRecentFolders, Some("RecentFolders")), + KeyBinding::new(&key("toggle_keymap", "cmd-,"), ToggleKeymap, None), + KeyBinding::new(&key("toggle_terminal", "cmd-shift-t"), ToggleTerminal, None), + KeyBinding::new( + &key("toggle_results_panel", "cmd-shift-r"), + ToggleResultsPanel, + None, + ), + KeyBinding::new(&key("toggle_left_dock", "cmd-b"), ToggleLeftDock, None), + KeyBinding::new( + &key("toggle_right_dock", "cmd-shift-b"), + ToggleRightDock, + None, + ), + ]); +} + fn app_menus(cx: &gpui::App) -> Vec { vec![ - Menu::new("sq/lab").items(vec![app_theme::themes_menu_item(cx)]), + Menu::new("sq/lab").items(vec![ + app_theme::themes_menu_item(cx), + MenuItem::separator(), + MenuItem::action("Keyboard Shortcuts...", ToggleKeymap), + ]), Menu::new("File").items(vec![ MenuItem::action("Open Recent Folder...", OpenRecentFolders), MenuItem::action("Open Folder...", OpenFolder), @@ -126,6 +454,7 @@ fn main() { ui::panels::file_editor::editor::init(cx); ui::panels::file_search::init(cx); ui::panels::project_search::init(cx); + ui::panels::keymap::init(cx); app_theme::init(cx); @@ -137,87 +466,8 @@ fn main() { } }); - cx.bind_keys(vec![ - KeyBinding::new("cmd-w", ClosePanel, None), - KeyBinding::new("cmd-w", EditorCloseActiveTab, Some("editor_tabs")), - KeyBinding::new("cmd-w", TerminalCloseActiveTab, Some("terminal_panel")), - KeyBinding::new("cmd-w", ResultCloseActiveTab, Some("results_panel")), - KeyBinding::new("cmd-o", OpenRecentFolders, None), - KeyBinding::new("cmd-shift-o", OpenFolder, None), - KeyBinding::new("cmd-e", ToggleFileSearch, None), - KeyBinding::new("cmd-f", ToggleEditorSearch, Some("Input")), - KeyBinding::new("cmd-shift-f", ToggleProjectSearch, None), - KeyBinding::new("cmd-shift-h", ToggleSearchReplace, None), - KeyBinding::new("cmd-enter", ExecuteQuery, Some("Input")), - KeyBinding::new("cmd-s", SaveFile, Some("Input")), - KeyBinding::new("cmd-alt-l", FormatQuery, Some("Input")), - KeyBinding::new("cmd-/", ToggleCommentLines, Some("Input")), - KeyBinding::new("tab", IndentLines, Some("file_editor")), - KeyBinding::new("shift-tab", OutdentLines, Some("file_editor")), - KeyBinding::new("cmd-x", CutEditorLine, Some("file_editor")), - KeyBinding::new("shift-up", ExtendResultSelectionUp, Some("results_panel")), - KeyBinding::new( - "shift-down", - ExtendResultSelectionDown, - Some("results_panel"), - ), - KeyBinding::new( - "shift-left", - ExtendResultSelectionLeft, - Some("results_panel"), - ), - KeyBinding::new( - "shift-right", - ExtendResultSelectionRight, - Some("results_panel"), - ), - KeyBinding::new("shift-up", ExtendResultSelectionUp, Some("DataTable")), - KeyBinding::new("shift-down", ExtendResultSelectionDown, Some("DataTable")), - KeyBinding::new("shift-left", ExtendResultSelectionLeft, Some("DataTable")), - KeyBinding::new("shift-right", ExtendResultSelectionRight, Some("DataTable")), - KeyBinding::new("left", SelectResultCellLeft, Some("DataTable")), - KeyBinding::new("right", SelectResultCellRight, Some("DataTable")), - KeyBinding::new("home", SelectResultFirstCellColumn, Some("DataTable")), - KeyBinding::new("end", SelectResultLastCellColumn, Some("DataTable")), - KeyBinding::new("up", SelectResultCellUp, Some("DataTable")), - KeyBinding::new("down", SelectResultCellDown, Some("DataTable")), - KeyBinding::new("enter", EditResultCell, Some("DataTable")), - KeyBinding::new("cmd-j", ToggleBottomPanelMode, None), - KeyBinding::new("cmd-t", NewTerminalTab, Some("terminal_panel")), - KeyBinding::new("cmd-c", CopyTerminalSelection, Some("terminal_panel")), - KeyBinding::new("cmd-v", Paste, Some("terminal_panel")), - KeyBinding::new("cmd-c", CopyResultSelection, None), - KeyBinding::new("ctrl-tab", CycleTabForward, None), - KeyBinding::new("ctrl-shift-tab", CycleTabBackward, None), - KeyBinding::new("cmd-[", NavigateBack, None), - KeyBinding::new("cmd-]", NavigateForward, None), - KeyBinding::new("ctrl-tab", TerminalCycleTabForward, Some("terminal_panel")), - KeyBinding::new( - "ctrl-shift-tab", - TerminalCycleTabBackward, - Some("terminal_panel"), - ), - KeyBinding::new("ctrl-tab", ResultCycleTabForward, Some("results_panel")), - KeyBinding::new( - "ctrl-shift-tab", - ResultCycleTabBackward, - Some("results_panel"), - ), - KeyBinding::new("up", SelectPreviousQuery, None), - KeyBinding::new("down", SelectNextQuery, None), - KeyBinding::new("enter", ConfirmSelectedQuery, None), - KeyBinding::new("up", SelectPreviousConnection, Some("ConnectionSelector")), - KeyBinding::new("down", SelectNextConnection, Some("ConnectionSelector")), - KeyBinding::new( - "enter", - ConfirmSelectedConnection, - Some("ConnectionSelector"), - ), - KeyBinding::new("up", SelectPreviousRecentFolder, Some("RecentFolders")), - KeyBinding::new("down", SelectNextRecentFolder, Some("RecentFolders")), - KeyBinding::new("enter", ConfirmRecentFolder, Some("RecentFolders")), - KeyBinding::new("escape", CloseRecentFolders, Some("RecentFolders")), - ]); + let custom_keymap = load_custom_keymap(); + bind_all_keys(cx, &custom_keymap); set_app_menus(cx); cx.activate(true); cx.set_quit_mode(QuitMode::LastWindowClosed); diff --git a/editor/src/ui/panels/keymap.rs b/editor/src/ui/panels/keymap.rs new file mode 100644 index 0000000..0dcf256 --- /dev/null +++ b/editor/src/ui/panels/keymap.rs @@ -0,0 +1,785 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use gpui::{ + App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + IntoElement, KeyBinding, KeyDownEvent, ParentElement, Render, ScrollHandle, + StatefulInteractiveElement, Styled, Window, actions, div, prelude::FluentBuilder, px, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::menu::{ContextMenuExt, PopupMenuItem}; +use gpui_component::scroll::Scrollbar; +use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex}; + +actions!(keymap, [ToggleKeymap, CloseKeymap]); + +const CONTEXT: &str = "KeymapPanel"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([KeyBinding::new("escape", CloseKeymap, Some(CONTEXT))]); +} + +pub struct KeymapDescriptor { + pub label: &'static str, + pub category: &'static str, + pub action_id: &'static str, + pub default_key: &'static str, + #[allow(dead_code)] + pub context: Option<&'static str>, +} + +pub const ALL_DESCRIPTORS: &[KeymapDescriptor] = &[ + // File + KeymapDescriptor { + label: "Open Recent Folder", + category: "File", + action_id: "open_recent_folders", + default_key: "cmd-o", + context: None, + }, + KeymapDescriptor { + label: "Open Folder...", + category: "File", + action_id: "open_folder", + default_key: "cmd-shift-o", + context: None, + }, + KeymapDescriptor { + label: "Save File", + category: "File", + action_id: "save_file", + default_key: "cmd-s", + context: Some("Input"), + }, + // Editor + KeymapDescriptor { + label: "Execute Query", + category: "Editor", + action_id: "execute_query", + default_key: "cmd-enter", + context: Some("Input"), + }, + KeymapDescriptor { + label: "Format Query", + category: "Editor", + action_id: "format_query", + default_key: "cmd-alt-l", + context: Some("Input"), + }, + KeymapDescriptor { + label: "Find", + category: "Editor", + action_id: "toggle_editor_search", + default_key: "cmd-f", + context: Some("Input"), + }, + KeymapDescriptor { + label: "Find in Files", + category: "Editor", + action_id: "toggle_project_search", + default_key: "cmd-shift-f", + context: None, + }, + KeymapDescriptor { + label: "Replace", + category: "Editor", + action_id: "toggle_editor_replace", + default_key: "cmd-shift-h", + context: None, + }, + KeymapDescriptor { + label: "Toggle Comment", + category: "Editor", + action_id: "toggle_comment_lines", + default_key: "cmd-/", + context: Some("Input"), + }, + KeymapDescriptor { + label: "Indent Lines", + category: "Editor", + action_id: "indent_lines", + default_key: "tab", + context: Some("file_editor"), + }, + KeymapDescriptor { + label: "Outdent Lines", + category: "Editor", + action_id: "outdent_lines", + default_key: "shift-tab", + context: Some("file_editor"), + }, + KeymapDescriptor { + label: "Cut Line", + category: "Editor", + action_id: "cut_editor_line", + default_key: "cmd-x", + context: Some("file_editor"), + }, + // Navigation + KeymapDescriptor { + label: "Go Back", + category: "Navigation", + action_id: "navigate_back", + default_key: "cmd-[", + context: None, + }, + KeymapDescriptor { + label: "Go Forward", + category: "Navigation", + action_id: "navigate_forward", + default_key: "cmd-]", + context: None, + }, + KeymapDescriptor { + label: "Search Files", + category: "Navigation", + action_id: "toggle_file_search", + default_key: "cmd-e", + context: None, + }, + KeymapDescriptor { + label: "Cycle Tab Forward", + category: "Navigation", + action_id: "cycle_tab_forward", + default_key: "ctrl-tab", + context: None, + }, + KeymapDescriptor { + label: "Cycle Tab Backward", + category: "Navigation", + action_id: "cycle_tab_backward", + default_key: "ctrl-shift-tab", + context: None, + }, + KeymapDescriptor { + label: "Close Active Tab", + category: "Navigation", + action_id: "close_active_tab", + default_key: "cmd-w", + context: None, + }, + // Results + KeymapDescriptor { + label: "Copy Results", + category: "Results", + action_id: "copy_result_selection", + default_key: "cmd-c", + context: None, + }, + KeymapDescriptor { + label: "Edit Result Cell", + category: "Results", + action_id: "edit_result_cell", + default_key: "enter", + context: Some("DataTable"), + }, + // Terminal + KeymapDescriptor { + label: "New Terminal Tab", + category: "Terminal", + action_id: "new_terminal_tab", + default_key: "cmd-t", + context: Some("terminal_panel"), + }, + KeymapDescriptor { + label: "Copy Terminal Selection", + category: "Terminal", + action_id: "copy_terminal_selection", + default_key: "cmd-c", + context: Some("terminal_panel"), + }, + KeymapDescriptor { + label: "Paste in Terminal", + category: "Terminal", + action_id: "paste_terminal", + default_key: "cmd-v", + context: Some("terminal_panel"), + }, + // Panels + KeymapDescriptor { + label: "Toggle Left Panel", + category: "Panels", + action_id: "toggle_left_dock", + default_key: "cmd-b", + context: None, + }, + KeymapDescriptor { + label: "Toggle Right Panel", + category: "Panels", + action_id: "toggle_right_dock", + default_key: "cmd-shift-b", + context: None, + }, + KeymapDescriptor { + label: "Toggle Terminal", + category: "Panels", + action_id: "toggle_terminal", + default_key: "cmd-shift-t", + context: None, + }, + KeymapDescriptor { + label: "Toggle Results", + category: "Panels", + action_id: "toggle_results_panel", + default_key: "cmd-shift-r", + context: None, + }, + KeymapDescriptor { + label: "Toggle Bottom Panel", + category: "Panels", + action_id: "toggle_bottom_panel", + default_key: "cmd-j", + context: None, + }, + KeymapDescriptor { + label: "Keyboard Shortcuts", + category: "Panels", + action_id: "toggle_keymap", + default_key: "cmd-,", + context: None, + }, +]; + +pub type CustomKeymap = HashMap; + +fn keymap_path() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".sqlab") + .join("keymap.json") +} + +pub fn load_custom_keymap() -> CustomKeymap { + let path = keymap_path(); + let Ok(content) = std::fs::read_to_string(&path) else { + return HashMap::new(); + }; + serde_json::from_str(&content).unwrap_or_default() +} + +fn save_custom_keymap(keymap: &CustomKeymap) { + let path = keymap_path(); + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + eprintln!("failed to create keymap directory: {}", e); + return; + } + match serde_json::to_string_pretty(keymap) { + Ok(content) => { + if let Err(e) = std::fs::write(&path, content) { + eprintln!("failed to save keymap: {}", e); + } + } + Err(e) => eprintln!("failed to serialize keymap: {}", e), + } +} + +fn is_modifier_key(key: &str) -> bool { + matches!( + key, + "shift" + | "control" + | "ctrl" + | "alt" + | "option" + | "command" + | "cmd" + | "meta" + | "super" + | "hyper" + | "fn" + | "caps_lock" + | "capslock" + ) +} + +fn keystroke_to_binding_str(k: &gpui::Keystroke) -> String { + let mut s = String::new(); + if k.modifiers.control { + s.push_str("ctrl-"); + } + if k.modifiers.alt { + s.push_str("alt-"); + } + if k.modifiers.shift { + s.push_str("shift-"); + } + if k.modifiers.platform { + s.push_str("cmd-"); + } + s.push_str(&k.key); + s +} + +pub struct KeymapPanel { + search_input: Entity, + /// List index whose remap modal is currently open. + recording_ix: Option, + filtered_indices: Vec, + custom_keymap: CustomKeymap, + visible: bool, + focus_handle: FocusHandle, + scroll_handle: ScrollHandle, + _search_subscription: gpui::Subscription, +} + +pub enum KeymapPanelEvent { + Closed, + KeymapChanged(CustomKeymap), +} + +impl EventEmitter for KeymapPanel {} + +impl KeymapPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + let search_input = + cx.new(|cx| InputState::new(window, cx).placeholder("Search shortcuts...")); + + let search_subscription = cx.subscribe_in( + &search_input, + window, + |this: &mut KeymapPanel, _, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Change) { + this.filter_results(cx); + } + }, + ); + + let custom_keymap = load_custom_keymap(); + let filtered_indices = (0..ALL_DESCRIPTORS.len()).collect(); + + Self { + search_input, + recording_ix: None, + filtered_indices, + custom_keymap, + visible: false, + focus_handle, + scroll_handle: ScrollHandle::default(), + _search_subscription: search_subscription, + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { + if self.visible { + self.close(window, cx); + } else { + self.open(window, cx); + } + } + + fn open(&mut self, window: &mut Window, cx: &mut Context) { + self.visible = true; + self.custom_keymap = load_custom_keymap(); + self.recording_ix = None; + self.filter_results(cx); + cx.notify(); + window.focus(&self.search_input.read(cx).focus_handle(cx), cx); + } + + pub fn close(&mut self, _window: &mut Window, cx: &mut Context) { + if !self.visible { + return; + } + self.visible = false; + self.recording_ix = None; + cx.emit(KeymapPanelEvent::Closed); + cx.notify(); + } + + fn filter_results(&mut self, cx: &mut Context) { + let query = self.search_input.read(cx).value().to_lowercase(); + if query.is_empty() { + self.filtered_indices = (0..ALL_DESCRIPTORS.len()).collect(); + } else { + self.filtered_indices = ALL_DESCRIPTORS + .iter() + .enumerate() + .filter(|(_, d)| { + d.label.to_lowercase().contains(&query) + || d.category.to_lowercase().contains(&query) + || d.default_key.to_lowercase().contains(&query) + }) + .map(|(i, _)| i) + .collect(); + } + self.recording_ix = None; + cx.notify(); + } + + fn current_key(&self, descriptor: &KeymapDescriptor) -> String { + self.custom_keymap + .get(descriptor.action_id) + .cloned() + .unwrap_or_else(|| descriptor.default_key.to_string()) + } + + fn is_customized(&self, descriptor: &KeymapDescriptor) -> bool { + self.custom_keymap.contains_key(descriptor.action_id) + } + + fn start_recording(&mut self, list_ix: usize, window: &mut Window, cx: &mut Context) { + let Some(&descriptor_ix) = self.filtered_indices.get(list_ix) else { + return; + }; + let Some(_) = ALL_DESCRIPTORS.get(descriptor_ix) else { + return; + }; + self.recording_ix = Some(list_ix); + cx.notify(); + window.focus(&self.focus_handle, cx); + } + + fn handle_key_capture( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let key = &event.keystroke.key; + if is_modifier_key(key) { + return; + } + // Pure Escape → cancel via CloseKeymap action. + let has_modifier = event.keystroke.modifiers.control + || event.keystroke.modifiers.alt + || event.keystroke.modifiers.shift + || event.keystroke.modifiers.platform; + if key == "escape" && !has_modifier { + return; + } + let binding_str = keystroke_to_binding_str(&event.keystroke); + self.save_recorded_key(binding_str, window, cx); + } + + fn save_recorded_key( + &mut self, + key_str: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(list_ix) = self.recording_ix else { + return; + }; + let Some(&descriptor_ix) = self.filtered_indices.get(list_ix) else { + self.cancel_recording(window, cx); + return; + }; + let Some(descriptor) = ALL_DESCRIPTORS.get(descriptor_ix) else { + self.cancel_recording(window, cx); + return; + }; + + if !key_str.is_empty() && key_str != descriptor.default_key { + self.custom_keymap + .insert(descriptor.action_id.to_string(), key_str); + } else { + self.custom_keymap.remove(descriptor.action_id); + } + + save_custom_keymap(&self.custom_keymap); + cx.emit(KeymapPanelEvent::KeymapChanged(self.custom_keymap.clone())); + self.recording_ix = None; + cx.notify(); + window.focus(&self.search_input.read(cx).focus_handle(cx), cx); + } + + pub fn filtered_indices(&self) -> &[usize] { + &self.filtered_indices + } + + pub fn recording_descriptor(&self) -> Option<(&KeymapDescriptor, String, bool)> { + let list_ix = self.recording_ix?; + let &descriptor_ix = self.filtered_indices.get(list_ix)?; + let descriptor = ALL_DESCRIPTORS.get(descriptor_ix)?; + let current_key = self.current_key(descriptor); + let customized = self.is_customized(descriptor); + Some((descriptor, current_key, customized)) + } + + pub fn cancel_recording(&mut self, window: &mut Window, cx: &mut Context) { + self.recording_ix = None; + cx.notify(); + window.focus(&self.search_input.read(cx).focus_handle(cx), cx); + } + + pub fn reset_to_default(&mut self, list_ix: usize, cx: &mut Context) { + let Some(&descriptor_ix) = self.filtered_indices.get(list_ix) else { + return; + }; + let Some(descriptor) = ALL_DESCRIPTORS.get(descriptor_ix) else { + return; + }; + self.custom_keymap.remove(descriptor.action_id); + save_custom_keymap(&self.custom_keymap); + cx.emit(KeymapPanelEvent::KeymapChanged(self.custom_keymap.clone())); + cx.notify(); + } +} + +impl Render for KeymapPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.visible { + return div().into_any_element(); + } + + let filtered = self.filtered_indices.clone(); + + v_flex() + .id("keymap-panel") + .key_context(CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_action_toggle)) + .on_action(cx.listener(Self::on_action_close)) + .when(self.recording_ix.is_some(), |el| { + el.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + this.handle_key_capture(event, window, cx); + })) + }) + .w(px(580.)) + .max_h(px(520.)) + .overflow_hidden() + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .shadow_md() + .child( + v_flex() + .flex_shrink_0() + .px_3() + .py_2() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child("Keyboard Shortcuts"), + ) + .child(div().flex_1()) + .child( + Button::new("keymap-close") + .icon(IconName::Close) + .xsmall() + .ghost() + .tooltip("Close") + .on_click(cx.listener(|this, _, window, cx| { + this.close(window, cx); + })), + ), + ) + .child(Input::new(&self.search_input)), + ) + .child( + v_flex() + .flex_1() + .min_h(px(0.)) + .relative() + .overflow_hidden() + .child( + div() + .id("keymap-scroll") + .flex_1() + .track_scroll(&self.scroll_handle) + .overflow_y_scroll() + .children(self.render_grouped_rows(&filtered, cx)), + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&self.scroll_handle)), + ), + ) + .into_any_element() + } +} + +impl KeymapPanel { + fn render_grouped_rows( + &self, + filtered: &[usize], + cx: &mut Context, + ) -> Vec { + let mut children: Vec = Vec::new(); + let mut last_category: Option<&'static str> = None; + + for (list_ix, &descriptor_ix) in filtered.iter().enumerate() { + let Some(descriptor) = ALL_DESCRIPTORS.get(descriptor_ix) else { + continue; + }; + + if last_category != Some(descriptor.category) { + last_category = Some(descriptor.category); + children.push( + div() + .px_3() + .pt_3() + .pb_1() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(descriptor.category.to_uppercase()) + .into_any_element(), + ); + } + + children.push(self.render_binding_row(list_ix, descriptor, cx)); + } + + if children.is_empty() { + children.push( + div() + .px_3() + .py_6() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No matching shortcuts found") + .into_any_element(), + ); + } + + children.push(div().h(px(8.)).into_any_element()); + children + } + + fn render_binding_row( + &self, + list_ix: usize, + descriptor: &KeymapDescriptor, + cx: &mut Context, + ) -> gpui::AnyElement { + let is_customized = self.is_customized(descriptor); + let current_key = self.current_key(descriptor); + let is_active_recording = self.recording_ix == Some(list_ix); + + let entity = cx.entity(); + let entity_edit = entity.clone(); + let entity_remove = entity.clone(); + + h_flex() + .id(format!("keymap-row-{}", list_ix)) + .w_full() + .px_3() + .py_1p5() + .gap_2() + .items_center() + .when(is_active_recording, |this| { + this.bg(cx.theme().primary.opacity(0.06)) + }) + .when(!is_active_recording, |this| { + this.hover(|style| style.bg(cx.theme().accent.opacity(0.08))) + }) + // Double-click opens the remap modal. + .on_click(cx.listener(move |this, event: &gpui::ClickEvent, window, cx| { + if event.click_count() == 2 { + this.start_recording(list_ix, window, cx); + } + })) + // Right-click / secondary click opens the context menu. + .context_menu(move |menu, _window, _cx| { + let entity_e = entity_edit.clone(); + let entity_r = entity_remove.clone(); + menu.item( + PopupMenuItem::new("Edit shortcut").on_click(move |_, window, cx| { + entity_e.update(cx, |this, cx| { + this.start_recording(list_ix, window, cx); + }); + }), + ) + .item( + PopupMenuItem::new("Remove shortcut") + .disabled(!is_customized) + .on_click(move |_, _window, cx| { + entity_r.update(cx, |this, cx| { + this.reset_to_default(list_ix, cx); + }); + }), + ) + }) + .child( + div() + .flex_1() + .text_sm() + .text_color(cx.theme().foreground) + .child(descriptor.label), + ) + .child(render_key_badge(¤t_key, is_customized, cx)) + .into_any_element() + } +} + +fn render_key_badge( + key: &str, + is_customized: bool, + cx: &mut Context, +) -> gpui::AnyElement { + let parts: Vec = key.split('-').map(|part| part.to_string()).collect(); + + h_flex() + .gap_0p5() + .items_center() + .children(parts.into_iter().map(|part| { + div() + .px_1() + .py_0p5() + .rounded_sm() + .border_1() + .border_color(if is_customized { + cx.theme().primary.opacity(0.6) + } else { + cx.theme().border + }) + .bg(cx.theme().secondary) + .text_xs() + .font_weight(if is_customized { + gpui::FontWeight::MEDIUM + } else { + gpui::FontWeight::NORMAL + }) + .text_color(if is_customized { + cx.theme().primary + } else { + cx.theme().muted_foreground + }) + .child(part) + })) + .into_any_element() +} + +impl Focusable for KeymapPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl KeymapPanel { + fn on_action_toggle(&mut self, _: &ToggleKeymap, window: &mut Window, cx: &mut Context) { + self.toggle(window, cx); + } + + fn on_action_close(&mut self, _: &CloseKeymap, window: &mut Window, cx: &mut Context) { + if self.recording_ix.is_some() { + self.cancel_recording(window, cx); + } else { + self.close(window, cx); + } + } +} diff --git a/editor/src/ui/panels/mod.rs b/editor/src/ui/panels/mod.rs index 18bbbcd..cf972f0 100644 --- a/editor/src/ui/panels/mod.rs +++ b/editor/src/ui/panels/mod.rs @@ -4,6 +4,7 @@ pub mod diagram; pub mod file_editor; pub mod file_search; pub mod file_tree; +pub mod keymap; pub mod project_search; pub mod result; pub mod terminal; diff --git a/editor/src/workspace.rs b/editor/src/workspace.rs index 6abc5bb..aae2217 100644 --- a/editor/src/workspace.rs +++ b/editor/src/workspace.rs @@ -35,6 +35,7 @@ use crate::ui::panels::file_editor::{ }; use crate::ui::panels::file_search::{FileSearch, FileSearchEvent, ToggleFileSearch}; use crate::ui::panels::file_tree::{FileTreePanel, OpenFileEvent, RootChangedEvent}; +use crate::ui::panels::keymap::{KeymapPanel, KeymapPanelEvent, ToggleKeymap}; use crate::ui::panels::project_search::{ProjectSearch, ProjectSearchEvent, ToggleProjectSearch}; use crate::ui::panels::result::ResultPanel; use crate::ui::panels::terminal::TerminalPanel; @@ -55,7 +56,11 @@ actions!( ToggleSearchReplace, SelectPreviousConnection, SelectNextConnection, - ConfirmSelectedConnection + ConfirmSelectedConnection, + ToggleTerminal, + ToggleResultsPanel, + ToggleLeftDock, + ToggleRightDock, ] ); @@ -680,6 +685,7 @@ pub struct Workspace { file_search: Entity, recent_folders: Entity, project_search: Entity, + keymap_panel: Entity, dock_area: Entity, editor_tabs: Entity, bottom_panel: Entity, @@ -706,6 +712,7 @@ impl Workspace { let file_search = cx.new(|cx| FileSearch::new(root_path.clone(), window, cx)); let recent_folders = cx.new(|cx| RecentFolderSelector::new(cx)); let project_search = cx.new(|cx| ProjectSearch::new(root_path.clone(), window, cx)); + let keymap_panel = cx.new(|cx| KeymapPanel::new(window, cx)); let data_source_manager = cx.new(|_cx| { DataSourceManager::load().unwrap_or_else(|e| { eprintln!("failed to load data source config: {}", e); @@ -843,6 +850,32 @@ impl Workspace { ) .detach(); + let editor_tabs_for_keymap = editor_tabs.clone(); + cx.subscribe_in( + &keymap_panel, + window, + move |this, _keymap, event: &KeymapPanelEvent, window, cx| { + match event { + KeymapPanelEvent::KeymapChanged(custom) => { + crate::bind_all_keys(cx, custom); + } + KeymapPanelEvent::Closed => { + // Move focus to workspace root synchronously so actions (cmd-,) + // remain dispatchable while the deferred editor focus hasn't fired yet. + window.focus(&this.focus_handle, cx); + let editor_tabs = editor_tabs_for_keymap.clone(); + cx.defer_in(window, move |_, window, cx| { + if let Some(editor) = editor_tabs.read(cx).active_editor() { + let input_focus = editor.read(cx).editor_focus_handle(cx); + window.focus(&input_focus, cx); + } + }); + } + } + }, + ) + .detach(); + // Subscribe to project search results let editor_tabs_for_project = editor_tabs.clone(); cx.subscribe_in(&project_search, window, { @@ -952,6 +985,7 @@ impl Workspace { file_search, recent_folders, project_search, + keymap_panel, dock_area, editor_tabs, bottom_panel, @@ -1750,6 +1784,21 @@ impl Workspace { }); } + fn on_toggle_keymap(&mut self, _: &ToggleKeymap, window: &mut Window, cx: &mut Context) { + self.recent_folders.update(cx, |recent, cx| { + recent.close(cx); + }); + self.file_search.update(cx, |search, cx| { + search.close(window, cx); + }); + self.project_search.update(cx, |search, cx| { + search.close(window, cx); + }); + self.keymap_panel.update(cx, |panel, cx| { + panel.toggle(window, cx); + }); + } + fn on_toggle_search_replace( &mut self, _: &ToggleSearchReplace, @@ -1794,6 +1843,17 @@ impl Workspace { self.toggle_bottom_panel(BottomPanelMode::Terminal, window, cx); } + fn restore_focus_to_editor(&self, window: &mut Window, cx: &mut Context) { + window.focus(&self.focus_handle, cx); + let editor_tabs = self.editor_tabs.clone(); + cx.defer_in(window, move |_, window, cx| { + if let Some(editor) = editor_tabs.read(cx).active_editor() { + let fh = editor.read(cx).editor_focus_handle(cx); + window.focus(&fh, cx); + } + }); + } + fn toggle_bottom_panel( &mut self, mode: BottomPanelMode, @@ -1813,6 +1873,7 @@ impl Workspace { } dock_area.remove_bottom_dock(window, cx); }); + self.restore_focus_to_editor(window, cx); return; } @@ -1887,15 +1948,60 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - self.dock_area.update(cx, |dock_area, cx| { - dock_area.toggle_dock(placement, window, cx); - }); - self.editor_tabs.update(cx, |tabs, cx| { - tabs.sync_zoomed_side_docks(cx); - }); - self.bottom_panel.update(cx, |panel, cx| { - panel.sync_zoomed_side_docks(cx); - }); + let is_open = self.dock_area.read(cx).is_dock_open(placement, cx); + + let dock_entity = match placement { + DockPlacement::Left => self.dock_area.read(cx).left_dock().cloned(), + DockPlacement::Right => self.dock_area.read(cx).right_dock().cloned(), + _ => None, + }; + + let dock_focus = dock_entity + .as_ref() + .map(|dock| dock.read(cx).panel().view().focus_handle(cx)); + + if is_open { + let is_focused = dock_focus + .as_ref() + .map(|fh| fh.contains_focused(window, cx)) + .unwrap_or(false); + + if is_focused { + // Focused → close and return focus to editor + self.dock_area.update(cx, |dock_area, cx| { + dock_area.toggle_dock(placement, window, cx); + }); + self.restore_focus_to_editor(window, cx); + self.editor_tabs.update(cx, |tabs, cx| { + tabs.sync_zoomed_side_docks(cx); + }); + self.bottom_panel.update(cx, |panel, cx| { + panel.sync_zoomed_side_docks(cx); + }); + } else { + // Open but not focused → just focus the active panel + if let Some(fh) = dock_focus { + window.focus(&fh, cx); + } + } + } else { + // Closed → open it and focus the active panel + self.dock_area.update(cx, |dock_area, cx| { + dock_area.toggle_dock(placement, window, cx); + }); + if let Some(dock) = dock_entity { + cx.defer_in(window, move |_, window, cx| { + let fh = dock.read(cx).panel().view().focus_handle(cx); + window.focus(&fh, cx); + }); + } + self.editor_tabs.update(cx, |tabs, cx| { + tabs.sync_zoomed_side_docks(cx); + }); + self.bottom_panel.update(cx, |panel, cx| { + panel.sync_zoomed_side_docks(cx); + }); + } } fn toggle_bottom_panel_from_bottom_bar( @@ -1911,6 +2017,22 @@ impl Workspace { } } + fn on_toggle_terminal(&mut self, _: &ToggleTerminal, window: &mut Window, cx: &mut Context) { + self.toggle_bottom_panel_from_bottom_bar(BottomPanelMode::Terminal, window, cx); + } + + fn on_toggle_results_panel(&mut self, _: &ToggleResultsPanel, window: &mut Window, cx: &mut Context) { + self.toggle_bottom_panel_from_bottom_bar(BottomPanelMode::Results, window, cx); + } + + fn on_toggle_left_dock(&mut self, _: &ToggleLeftDock, window: &mut Window, cx: &mut Context) { + self.toggle_side_dock_from_bottom_bar(DockPlacement::Left, window, cx); + } + + fn on_toggle_right_dock(&mut self, _: &ToggleRightDock, window: &mut Window, cx: &mut Context) { + self.toggle_side_dock_from_bottom_bar(DockPlacement::Right, window, cx); + } + fn render_bottom_bar(&self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let (is_busy, activity_label, activity_count) = { let tracker = self.activity_tracker.read(cx); @@ -1970,11 +2092,11 @@ impl Workspace { }) .xsmall() .ghost() - .tooltip(if is_left_open { - "Collapse Left Panel" - } else { - "Expand Left Panel" - }) + .tooltip_with_action( + if is_left_open { "Collapse Left Panel" } else { "Expand Left Panel" }, + &ToggleLeftDock, + None, + ) .on_click(cx.listener(|this, _, window, cx| { this.toggle_side_dock_from_bottom_bar(DockPlacement::Left, window, cx); })), @@ -2003,7 +2125,7 @@ impl Workspace { .icon(Icon::new(IconName::File).path("icons/results-table.svg")) .xsmall() .ghost() - .tooltip("Query Results"); + .tooltip_with_action("Query Results", &ToggleResultsPanel, None); let btn = if is_results_active && is_dock_open { btn.text_color(active_bottom_button_fg) @@ -2024,7 +2146,7 @@ impl Workspace { .icon(Icon::new(IconName::File).path("icons/square-terminal.svg")) .xsmall() .ghost() - .tooltip("Terminal"); + .tooltip_with_action("Terminal", &ToggleTerminal, None); let btn = if is_terminal_active && is_dock_open { btn.text_color(active_bottom_button_fg) @@ -2049,11 +2171,11 @@ impl Workspace { }) .xsmall() .ghost() - .tooltip(if is_right_open { - "Collapse Right Panel" - } else { - "Expand Right Panel" - }) + .tooltip_with_action( + if is_right_open { "Collapse Right Panel" } else { "Expand Right Panel" }, + &ToggleRightDock, + None, + ) .on_click(cx.listener(|this, _, window, cx| { this.toggle_side_dock_from_bottom_bar( DockPlacement::Right, @@ -2072,6 +2194,191 @@ impl Focusable for Workspace { } } +impl Workspace { + fn render_keymap_recording_modal( + &self, + label: &'static str, + current_key: &str, + is_customized: bool, + action_id: &'static str, + cx: &mut Context, + ) -> gpui::AnyElement { + let action_id_for_reset = action_id; + + // Key badge parts + let key_parts: Vec = current_key.split('-').map(|p| p.to_string()).collect(); + + div() + .absolute() + .inset_0() + .occlude() + .bg(cx.theme().background.opacity(0.65)) + .flex() + .items_center() + .justify_center() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.keymap_panel.update(cx, |panel, cx| { + panel.cancel_recording(window, cx); + }); + }), + ) + .child( + v_flex() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .w(px(400.)) + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .shadow_lg() + .child( + h_flex() + .px_4() + .py_3() + .border_b_1() + .border_color(cx.theme().border) + .items_center() + .child( + div() + .flex_1() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child("Remap Shortcut"), + ) + .child( + Button::new("keymap-modal-close") + .icon(IconName::Close) + .xsmall() + .ghost() + .tooltip("Cancel (Esc)") + .on_click(cx.listener(|this, _, window, cx| { + this.keymap_panel.update(cx, |panel, cx| { + panel.cancel_recording(window, cx); + }); + })), + ), + ) + .child( + v_flex() + .px_4() + .py_5() + .gap_4() + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Command"), + ) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child(label), + ), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Current shortcut"), + ) + .child( + h_flex() + .gap_0p5() + .items_center() + .children(key_parts.into_iter().map(|part| { + div() + .px_1() + .py_0p5() + .rounded_sm() + .border_1() + .border_color(if is_customized { + cx.theme().primary.opacity(0.6) + } else { + cx.theme().border + }) + .bg(cx.theme().secondary) + .text_xs() + .text_color(if is_customized { + cx.theme().primary + } else { + cx.theme().muted_foreground + }) + .child(part) + })), + ), + ) + .child( + div() + .w_full() + .px_4() + .py_4() + .rounded_md() + .border_1() + .border_color(cx.theme().primary.opacity(0.45)) + .bg(cx.theme().primary.opacity(0.06)) + .flex() + .justify_center() + .child( + div() + .text_sm() + .text_color(cx.theme().primary) + .child("Press a key combination..."), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground.opacity(0.7)) + .child("Press Esc to cancel"), + ) + .child(div().flex_1()) + .when(is_customized, |el| { + el.child( + Button::new("keymap-modal-reset") + .label("Reset to default") + .xsmall() + .ghost() + .on_click(cx.listener(move |this, _, window, cx| { + this.keymap_panel.update(cx, |panel, cx| { + // find the list_ix for this action_id + if let Some(list_ix) = panel + .filtered_indices() + .iter() + .position(|&di| { + crate::ui::panels::keymap::ALL_DESCRIPTORS + .get(di) + .is_some_and(|d| d.action_id == action_id_for_reset) + }) + { + panel.reset_to_default(list_ix, cx); + } + panel.cancel_recording(window, cx); + }); + })), + ) + }), + ), + ), + ) + .into_any_element() + } +} + impl Render for Workspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_dark = cx.theme().is_dark(); @@ -2084,6 +2391,16 @@ impl Render for Workspace { let is_file_search_visible = self.file_search.read(cx).is_visible(); let is_recent_folders_visible = self.recent_folders.read(cx).is_visible(); let is_project_search_visible = self.project_search.read(cx).is_visible(); + let is_keymap_panel_visible = self.keymap_panel.read(cx).is_visible(); + let keymap_recording = if is_keymap_panel_visible { + self.keymap_panel.read(cx).recording_descriptor().map( + |(descriptor, current_key, is_customized)| { + (descriptor.label, current_key, is_customized, descriptor.action_id) + }, + ) + } else { + None + }; v_flex() .id("workspace") @@ -2095,8 +2412,13 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_execute_query)) .on_action(cx.listener(Self::on_toggle_file_search)) .on_action(cx.listener(Self::on_toggle_project_search)) + .on_action(cx.listener(Self::on_toggle_keymap)) .on_action(cx.listener(Self::on_toggle_search_replace)) .on_action(cx.listener(Self::on_toggle_bottom_panel_mode)) + .on_action(cx.listener(Self::on_toggle_terminal)) + .on_action(cx.listener(Self::on_toggle_results_panel)) + .on_action(cx.listener(Self::on_toggle_left_dock)) + .on_action(cx.listener(Self::on_toggle_right_dock)) .on_action(cx.listener(Self::on_switch_theme)) .child( TitleBar::new().child( @@ -2256,6 +2578,46 @@ impl Render for Workspace { .child(self.project_search.clone()), ), ) + }) + .when(is_keymap_panel_visible, |overlay| { + overlay + .child( + div() + .absolute() + .size_full() + .inset_0() + .occlude() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.keymap_panel.update(cx, |panel, cx| { + panel.close(window, cx); + }); + }), + ), + ) + .child( + div() + .absolute() + .size_full() + .top(px(80.)) + .flex() + .justify_center() + .items_start() + .child( + div() + .occlude() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child(self.keymap_panel.clone()), + ), + ) + }) + .when_some(keymap_recording, |overlay, (label, current_key, is_customized, action_id)| { + overlay.child(self.render_keymap_recording_modal( + label, ¤t_key, is_customized, action_id, cx, + )) }), ) .child(self.render_bottom_bar(window, cx))