diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..1513e07 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,25 @@ +name: Tag master + +on: + push: + branches: [ "master" ] + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + + steps: + - name: Create tag + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version') + + gh api \ + --method POST \ + "/repos/${{ github.repository }}/git/refs" \ + -f ref="refs/tags/$TAG" \ + -f sha="${{ github.sha }}" diff --git a/Cargo.lock b/Cargo.lock index f917c2f..208ecf9 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -318,12 +318,13 @@ dependencies = [ [[package]] name = "dbclient" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "async-trait", "chrono", "clap", + "futures", "iocraft", "prost", "prost-build", diff --git a/Cargo.toml b/Cargo.toml index c0ad714..f56aeeb 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dbclient" -version = "0.4.1" +version = "0.4.2" edition = "2021" [dependencies] @@ -16,6 +16,7 @@ prost-types = "0.14" anyhow = "1" async-trait = "0.1" tokio = { version = "1.47", features = ["full"] } +futures = "0.3" #db clients redis = { version = "0.31" } diff --git a/README.md b/README.md index 94f13a6..564dc91 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,12 @@ A universal Terminal User Interface (TUI) client for database management with Vi ## Features ### Currently Supported -- **Redis** - Read-only operations -- **Read-only mode** - Browse and query your databases safely -- **Executing custom query** - You can execute any query of any type (RW-mode) +- **PostgreSQL support** - Partial integration with Postgres databases +- **Operations** - Read, create, update, and delete functionality for PostgreSQL databases ### Planned Features -- **PostgreSQL support** - Full integration with Postgres databases +- **Redis support** - Full integration with Redis databases - **MySQL support** - Complete MySQL database management -- **Write operations** - Create, update, and delete functionality for all supported databases - **UI improvements** - Better UI interactions (for example: horizontal scrolling) - **UX improvements** - For example: get notification about any error instead of fall to panic @@ -50,30 +48,25 @@ dbclient --config-path config.toml #### Query page - Database objects widget: - `j|k|↑|↓` - Navigate through objects - - `/` - Search - - `n` - Go to next search pattern matching - - `N` - Go to previous search pattern matching + - `` - Open object sub-tree - `` - Get all items in selected object - - `L|→` - Go to query result widget + - `l|→` - Go to query result widget - `` - Quit to main page - Query result widget: - `j|k|↑|↓` - Navigate through records - - `/` - Search - - `n` - Go to next search pattern matching - - `N` - Go to previous search pattern matching - - `q` - Open query input popup - - `H|←` - Go to database objects widget - - `g` - Go to the first record (in future will be replaced with `gg`) + - `h|←` - Go to database objects widget + - `gg` - Go to the first record - `G` - Go to the last record + - `a` - Open popup to add record into database object + - `i` - Open popup to modify record of database object + - `dd` - Remove record of database object - `` - Quit -- Search popup: - - `i` - Activate insert mode - - `` - If in insert mode then activate normal mode else - close popup - - `` - In normal mode, apply search pattern. After this use `` to close popup -- Query popup: - - `i` - Activate insert mode - - `` - If in insert mode then activate normal mode else - close popup - - `` - In normal mode, apply query. After this use `` to close popup +- Popup: + - `j|k|↑|↓` - Navigate through fields + - `i` - Activate insert mode for selected field + - `` in insert mode - Activate normal navigation mode through fields + - `` in normal mode - Close popup + - `` in normal mode - Apply operation ## Configuration @@ -81,9 +74,9 @@ Create a configuration file any directory: ```toml [[connections]] -connection_type = "Redis" +connection_type = "Postgres" name = "local" -connection_string = "redis://localhost:6379" +connection_string = "postgresql://user:password@localhost/postgres?connect_timeout=10" ``` ## Requirements @@ -118,5 +111,5 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Acknowledgments -- Built with [tui-realm](https://github.com/veeso/tui-realm) TUI framework +- Built with [iocraft](https://github.com/ccbrown/iocraft) TUI framework - Inspired by various database GUI clients diff --git a/src/hotkey_manager.rs b/src/hotkey_manager.rs new file mode 100644 index 0000000..558cd89 --- /dev/null +++ b/src/hotkey_manager.rs @@ -0,0 +1,572 @@ +use std::{fmt::Debug, marker::PhantomData}; + +#[derive(Debug, PartialEq)] +pub enum HotKeyError { + Collision, + CallbackError, + CombinationNotFound, +} + +pub trait HotKeyCallback: + FnMut(&mut CallbackState) -> Result + Send +{ +} + +impl HotKeyCallback for F where + F: FnMut(&mut CallbackState) -> Result + Send +{ +} + +pub struct HotKeyManager { + graph: Graph< + TKey, + CallbackState, + CallbackResult, + Box>, + >, + comb_buf: Vec, +} + +impl + HotKeyManager +{ + pub fn new() -> Self { + Self { + graph: Graph::new(), + comb_buf: vec![], + } + } + + pub fn clear_buffer(&mut self) { + self.comb_buf.clear(); + } + + pub fn register( + &mut self, + combination: &[TKey], + callback: impl HotKeyCallback + 'static, + ) -> Result<(), HotKeyError> { + self.graph.write(combination, Box::new(callback)) + } + + pub fn push(&mut self, payload: TKey) -> Result<(), HotKeyError> { + self.comb_buf.push(payload); + if !self.graph.is_possible_path(&self.comb_buf) { + self.clear_buffer(); + } + Ok(()) + } + + pub fn invoke_if_matched( + &mut self, + state: &mut CallbackState, + ) -> Result { + let result = if !self.graph.is_possible_path(&self.comb_buf) { + Err(HotKeyError::CombinationNotFound) + } else { + let r = self.graph.invoke(&self.comb_buf, state); + match r { + Err(HotKeyError::CombinationNotFound) => {} + _ => { + self.comb_buf.clear(); + } + } + r + }; + + return result; + } +} + +trait GraphCallback: FnMut(&mut State) -> Result {} + +impl GraphCallback for F where + F: FnMut(&mut CallbackState) -> Result +{ +} + +struct Graph< + TKey: PartialEq + Copy, + CallbackState, + CallbackResult, + Callback: GraphCallback, +> { + begin: GraphNode, +} + +#[derive(Debug)] +enum GraphNode< + TKey, + CallbackState, + CallbackResult, + Callback: GraphCallback, +> { + Node( + Option, + Box>>, + PhantomData, + PhantomData, + ), + List(TKey, Callback), +} + +impl< + TKey: PartialEq + Copy, + CallbackState, + CallbackResult, + Callback: GraphCallback, + > Graph +{ + pub fn new() -> Self { + Self { + begin: GraphNode::Node(None, Box::new(vec![]), PhantomData, PhantomData), + } + } + + pub fn write(&mut self, payload: &[TKey], callback: Callback) -> Result<(), HotKeyError> { + let mut callback = Some(callback); + let _ = Self::_write(&mut self.begin, payload, &mut callback)?; + return Ok(()); + } + + fn _write( + content: &mut GraphNode, + payload: &[TKey], + callback: &mut Option, + ) -> Result { + match content { + GraphNode::Node(value, graph_nodes, _, _) => { + if *value != None && *value != Some(payload[0]) { + return Ok(false); + } + + let tail = if *value == None { + payload + } else { + &payload[1..] + }; + + for gn in graph_nodes.iter_mut() { + if Self::_write(gn, tail, callback)? { + return Ok(true); + } + } + if tail.len() > 1 { + let node = + GraphNode::Node(Some(tail[0]), Box::new(vec![]), PhantomData, PhantomData); + graph_nodes.push(node); + let node = graph_nodes.last_mut().unwrap(); + return Self::_write(node, tail, callback); + } else { + let callback = callback.take().unwrap(); + let node = GraphNode::List(tail[0], callback); + graph_nodes.push(node); + return Ok(true); + } + } + GraphNode::List(value, _) => { + if payload.len() > 0 && *value == payload[0] { + return Err(HotKeyError::Collision); + } + return Ok(false); + } + } + } + + pub fn is_possible_path(&self, payload: &[TKey]) -> bool { + if payload.len() == 0 { + return true; + } + Self::_is_possible_path(&self.begin, payload) + } + + fn _is_possible_path( + node: &GraphNode, + payload: &[TKey], + ) -> bool { + match node { + GraphNode::Node(value, graph_nodes, _, _) => { + if let Some(v) = *value { + if v != payload[0] { + return false; + } + } + let tail = if *value == None { + payload + } else { + &payload[1..] + }; + + if tail.len() > 0 { + for gn in graph_nodes.iter() { + if Self::_is_possible_path(gn, tail) { + return true; + } + } + return false; + } + return true; + } + GraphNode::List(value, _) => return payload.len() == 1 && *value == payload[0], + } + } + + pub fn invoke( + &mut self, + payload: &[TKey], + state: &mut CallbackState, + ) -> Result { + if payload.is_empty() { + return Err(HotKeyError::CombinationNotFound); + } + Self::_invoke(&mut self.begin, payload, state) + } + + fn _invoke( + node: &mut GraphNode, + payload: &[TKey], + state: &mut CallbackState, + ) -> Result { + match node { + GraphNode::Node(value, graph_nodes, _, _) => { + if let Some(v) = *value { + if v != payload[0] { + return Err(HotKeyError::CombinationNotFound); + } + } + + let tail = if *value == None { + payload + } else { + &payload[1..] + }; + + if tail.len() > 0 { + for gn in graph_nodes.iter_mut() { + let r = Self::_invoke(gn, tail, state); + match r { + Err(HotKeyError::CombinationNotFound) => continue, + _ => return r, + }; + } + return Err(HotKeyError::CombinationNotFound); + } + + return Err(HotKeyError::CombinationNotFound); + } + GraphNode::List(value, clbk) => { + if payload.len() == 1 && *value == payload[0] { + return clbk(state); + } + Err(HotKeyError::CombinationNotFound) + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::hotkey_manager::{Graph, GraphCallback, GraphNode, HotKeyError}; + + type TestCallback = fn(&mut ()) -> Result<(), HotKeyError>; + + fn noop(_: &mut ()) -> Result<(), HotKeyError> { + Ok(()) + } + + mod graph { + use std::marker::PhantomData; + + use super::{noop, TestCallback}; + use crate::hotkey_manager::{Graph, GraphCallback, GraphNode, HotKeyError}; + + fn assert_graph( + expected: &Graph, + actual: &Graph, + ) { + _assert_node(&expected.begin, &actual.begin); + } + + fn _assert_node( + expected: &GraphNode, + actual: &GraphNode, + ) { + match (expected, actual) { + (GraphNode::List(exp_key, _), GraphNode::List(act_key, _)) => { + assert_eq!(exp_key, act_key) + } + ( + GraphNode::Node(exp_key, exp_nodes, _, _), + GraphNode::Node(act_key, act_nodes, _, _), + ) => { + assert_eq!(exp_key, act_key); + assert_eq!(exp_nodes.len(), act_nodes.len()); + for i in 0..exp_nodes.len() { + _assert_node(&exp_nodes[i], &act_nodes[i]); + } + } + _ => panic!("nodes are not equal"), + } + } + + #[test] + fn should_add_single_node() { + let mut graph = Graph::::new(); + let payload = ['a']; + assert_eq!(graph.write(&payload, noop), Ok(())); + assert_graph( + &Graph { + begin: GraphNode::Node( + None, + Box::new(vec![GraphNode::List('a', noop)]), + PhantomData, + PhantomData, + ), + }, + &graph, + ); + } + + #[test] + fn should_add_single_node_on_double_key() { + let mut graph = Graph::::new(); + let payload = ['a', 'a']; + assert_eq!(graph.write(&payload, noop), Ok(())); + assert_graph( + &Graph { + begin: GraphNode::Node( + None, + Box::new(vec![GraphNode::Node( + Some('a'), + Box::new(vec![GraphNode::List('a', noop)]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + ), + }, + &graph, + ); + } + + #[test] + fn should_add_single_sequence() { + let mut graph = Graph::::new(); + let payload = ['a', 'b', 'c']; + assert_eq!(graph.write(&payload, noop), Ok(())); + assert_graph( + &Graph { + begin: GraphNode::Node( + None, + Box::new(vec![GraphNode::Node( + Some('a'), + Box::new(vec![GraphNode::Node( + Some('b'), + Box::new(vec![GraphNode::List('c', noop)]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + ), + }, + &graph, + ); + } + + #[test] + fn should_add_sequences() { + let mut graph = Graph::::new(); + let payload1 = ['a', 'b', 'c']; + let payload2 = ['a', 'b', 'v']; + assert_eq!(graph.write(&payload1, noop), Ok(())); + assert_eq!(graph.write(&payload2, noop), Ok(())); + assert_graph( + &Graph { + begin: GraphNode::Node( + None, + Box::new(vec![GraphNode::Node( + Some('a'), + Box::new(vec![GraphNode::Node( + Some('b'), + Box::new(vec![ + GraphNode::List('c', noop), + GraphNode::List('v', noop), + ]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + ), + }, + &graph, + ); + } + + #[test] + fn should_add_different_sequences() { + let mut graph = Graph::::new(); + let payload1 = ['a', 'b', 'c']; + let payload2 = ['q', 'w', 'e']; + assert_eq!(graph.write(&payload1, noop), Ok(())); + assert_eq!(graph.write(&payload2, noop), Ok(())); + assert_graph( + &Graph { + begin: GraphNode::Node( + None, + Box::new(vec![ + GraphNode::Node( + Some('a'), + Box::new(vec![GraphNode::Node( + Some('b'), + Box::new(vec![GraphNode::List('c', noop)]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + ), + GraphNode::Node( + Some('q'), + Box::new(vec![GraphNode::Node( + Some('w'), + Box::new(vec![GraphNode::List('e', noop)]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + ), + ]), + PhantomData, + PhantomData, + ), + }, + &graph, + ); + } + + #[test] + fn should_fail_on_same_sequences() { + let mut graph = Graph::::new(); + let payload1 = ['a', 'b', 'c']; + let payload2 = ['a', 'b', 'c']; + assert_eq!(graph.write(&payload1, noop), Ok(())); + assert_eq!(graph.write(&payload2, noop), Err(HotKeyError::Collision)); + assert_graph( + &Graph { + begin: GraphNode::Node( + None, + Box::new(vec![GraphNode::Node( + Some('a'), + Box::new(vec![GraphNode::Node( + Some('b'), + Box::new(vec![GraphNode::List('c', noop)]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + )]), + PhantomData, + PhantomData, + ), + }, + &graph, + ); + } + + #[test] + fn should_fail_on_same_substring() { + let mut graph = Graph::::new(); + let payload1 = ['a']; + let payload2 = ['a', 'b']; + assert_eq!(graph.write(&payload1, noop), Ok(())); + assert_eq!(graph.write(&payload2, noop), Err(HotKeyError::Collision)); + assert_graph( + &Graph { + begin: GraphNode::Node( + None, + Box::new(vec![GraphNode::List('a', noop)]), + PhantomData, + PhantomData, + ), + }, + &graph, + ); + } + + #[test] + fn should_find_path() { + let mut graph = Graph::::new(); + let payload = ['a']; + assert_eq!(graph.write(&payload, noop), Ok(())); + assert!(graph.is_possible_path(&payload)); + } + + #[test] + fn should_find_path_on_one_sequence() { + let mut graph = Graph::::new(); + let payload = ['a', 'b', 'c']; + assert_eq!(graph.write(&payload, noop), Ok(())); + assert!(graph.is_possible_path(&payload)); + } + + #[test] + fn should_find_path_on_one_subsequence() { + let mut graph = Graph::::new(); + let payload = ['a', 'b', 'c']; + assert_eq!(graph.write(&payload, noop), Ok(())); + assert!(graph.is_possible_path(&payload[..2])); + } + + #[test] + fn should_find_path_on_several_subsequences() { + let mut graph = Graph::::new(); + let payload1 = ['a', 'b', 'c']; + let payload2 = ['a', 'g', 'h', 'c']; + assert_eq!(graph.write(&payload1, noop), Ok(())); + assert_eq!(graph.write(&payload2, noop), Ok(())); + assert!(graph.is_possible_path(&payload1[..1])); + assert!(graph.is_possible_path(&payload1[..2])); + assert!(graph.is_possible_path(&payload2[..3])); + } + } + + mod hotkey_manager { + use crate::hotkey_manager::{HotKeyError, HotKeyManager}; + + #[test] + fn should_invoke() { + let mut hk_manager = HotKeyManager::new(); + assert_eq!( + hk_manager.register(&['a'], |_: &mut ()| { Ok(Some(true)) }), + Ok(()) + ); + assert_eq!(hk_manager.push('a'), Ok(())); + assert_eq!(hk_manager.invoke_if_matched(&mut ()), Ok(Some(true))) + } + + #[test] + fn should_throw_err_on_invoke() { + let mut hk_manager = HotKeyManager::new(); + assert_eq!( + hk_manager.register(&['a'], move |_: &mut ()| { Ok(Some(true)) }), + Ok(()) + ); + assert_eq!(hk_manager.push('b'), Ok(())); + assert_eq!( + hk_manager.invoke_if_matched(&mut ()), + Err(HotKeyError::CombinationNotFound) + ) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index cb9938e..fc993aa 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ use chrono::{DateTime, Utc}; +pub mod hotkey_manager; + #[derive(Debug, PartialEq, Clone, Hash, Eq)] pub enum Field { String(Option), diff --git a/src/ui4/components/table_view.rs b/src/ui4/components/table_view.rs index ac1469d..24efdac 100644 --- a/src/ui4/components/table_view.rs +++ b/src/ui4/components/table_view.rs @@ -5,17 +5,17 @@ use iocraft::{ components::{BorderStyle, ScrollView, ScrollViewHandle, Text, View}, element, hooks::{UseRef, UseState, UseTerminalEvents}, - AnyElement, Color, Edges, FlexDirection, Hooks, KeyCode, KeyEvent, KeyEventKind, Props, - TerminalEvent, + AnyElement, Color, Edges, FlexDirection, Hooks, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, + Props, TerminalEvent, }; use tokio::sync::Mutex as TokioMutex; -use crate::core::proto::common::db_field::Field; +use crate::{core::proto::common::db_field::Field, ui4::control::UseHotkeys}; -pub type TableKeyHandler = Arc, Vec)) + Send>>>; -pub fn table_key_handler(callback: F) -> TableKeyHandler +pub type TableEventHandler = Arc, Vec) + Send>>>; +pub fn table_event_handler(callback: F) -> TableEventHandler where - F: FnMut(KeyCode, (Vec, Vec)) + Send + 'static, + F: FnMut(Vec, Vec) + Send + 'static, { Arc::new(Mutex::new(Box::new(callback))) } @@ -26,7 +26,9 @@ pub struct TableViewProps { pub rows: Vec>, pub has_focus: bool, pub selected_row: Arc>>, - pub on_key: Option, + pub on_add_row: Option, + pub on_update_row: Option, + pub on_delete_row: Option, } fn field_to_string(field: &Field) -> String { @@ -57,9 +59,16 @@ fn field_to_string(field: &Field) -> String { pub fn TableView(props: &TableViewProps, mut hooks: Hooks) -> impl Into> { let mut cursor = hooks.use_state(|| 0usize); let mut scroll_handle = hooks.use_ref_default::(); + let rows = props.rows.clone(); + let mut rows_ref = hooks.use_ref(|| rows.clone()); + rows_ref.set(rows.clone()); let columns = props.columns.clone(); + let mut columns_ref = hooks.use_ref(|| columns.clone()); + columns_ref.set(columns.clone()); let row_count = rows.len(); + let mut row_count_ref = hooks.use_ref(|| row_count.clone()); + row_count_ref.set(row_count.clone()); if rows.is_empty() { cursor.set(0); @@ -90,40 +99,108 @@ pub fn TableView(props: &TableViewProps, mut hooks: Hooks) -> impl Into 0 => { - cursor.set((cursor.get() + 1).min(row_count - 1)); + { + let on_update_row = on_update_row.clone(); + let _ = hotkey_manager.register( + &[(KeyCode::Char('i'), KeyModifiers::NONE, KeyEventKind::Press)], + move |_: &mut ()| { + if let Some(handler) = on_update_row.as_ref() { + if let Ok(mut handler) = handler.lock() { + let cur_pos = cursor.get(); + let columns = columns_ref.read().clone(); + let row = + rows_ref.read().get(cur_pos).unwrap_or(&Vec::new()).clone(); + handler(columns, row); + } + } + Ok(()) + }, + ); } - KeyCode::Up | KeyCode::Char('k') if row_count > 0 => { - cursor.set(cursor.get().saturating_sub(1)); + + { + let on_delete_row = on_delete_row.clone(); + let _ = hotkey_manager.register( + &[ + (KeyCode::Char('d'), KeyModifiers::NONE, KeyEventKind::Press), + (KeyCode::Char('d'), KeyModifiers::NONE, KeyEventKind::Press), + ], + move |_: &mut ()| { + if let Some(handler) = on_delete_row.as_ref() { + if let Ok(mut handler) = handler.lock() { + let cur_pos = cursor.get(); + let columns = columns_ref.read().clone(); + let row = + rows_ref.read().get(cur_pos).unwrap_or(&Vec::new()).clone(); + handler(columns, row); + } + } + Ok(()) + }, + ); } - _ => {} - } - }); + }, + ); element! { View(width: 100pct, height: 100pct, flex_direction: FlexDirection::Column) { diff --git a/src/ui4/connections_list.rs b/src/ui4/connections_list.rs index 720308c..69bc94b 100644 --- a/src/ui4/connections_list.rs +++ b/src/ui4/connections_list.rs @@ -4,15 +4,18 @@ use iocraft::{ component, components::{BorderStyle, Text, TextDecoration, View}, element, - hooks::{UseFuture, UseState, UseTerminalEvents, UseTerminalSize}, + hooks::{UseContext, UseFuture, UseState, UseTerminalEvents, UseTerminalSize}, AnyElement, Color, Edges, FlexDirection, Hooks, JustifyContent, KeyCode, KeyEvent, - KeyEventKind, Props, TerminalEvent, Weight, + KeyEventKind, KeyModifiers, Props, SystemContext, TerminalEvent, Weight, }; use tokio::sync::Mutex; use crate::{ core::proto::{self, connections::GetAvailableConnectionsRequest}, - ui4::app_state::{AppState, Page}, + ui4::{ + app_state::{AppState, Page}, + control::UseHotkeys, + }, }; #[derive(Default, Props)] @@ -28,6 +31,8 @@ pub async fn ConnectionsList( ) -> impl Into> { let connections = hooks.use_state(Vec::::new); let mut selected_row = hooks.use_state(|| 0); + let mut system = hooks.use_context_mut::(); + let mut should_exit = hooks.use_state(|| false); if let Some(client) = props.connections_client.clone() { let mut connections = connections.clone(); @@ -41,6 +46,33 @@ pub async fn ConnectionsList( }); } + hooks.use_hotkeys( + || true, + move |hot_key_manager| { + let _ = hot_key_manager.register( + &[ + (KeyCode::Char('g'), KeyModifiers::NONE, KeyEventKind::Press), + (KeyCode::Char('g'), KeyModifiers::NONE, KeyEventKind::Press), + ], + move |_: &mut ()| { + selected_row.set(0); + Ok(()) + }, + ); + let _ = hot_key_manager.register( + &[(KeyCode::Char('G'), KeyModifiers::SHIFT, KeyEventKind::Press)], + move |_: &mut ()| { + selected_row.set(connections.read().len().saturating_sub(1)); + Ok(()) + }, + ); + }, + ); + + if should_exit.get() { + system.exit(); + } + let state = props.state.clone(); hooks.use_terminal_events({ move |event| match event { @@ -71,6 +103,9 @@ pub async fn ConnectionsList( guard.selected_connection = Some(conn.unwrap().clone()); guard.selected_page = Page::QueryArea; } + KeyCode::Esc => { + should_exit.set(true); + } _ => {} } } @@ -96,15 +131,30 @@ pub async fn ConnectionsList( } #(connections.read().iter().enumerate().map(|(i, connection)| element! { - // View(background_color: if selected_row.get() == i { Some(Color::Blue) } else { if i % 2 == 0 {None} else {Some(Color::DarkGrey)} }) { View( - background_color: if selected_row.get() == i { Some(Color::Blue) } else { None }, + background_color: if selected_row.get() == i { + Some(Color::Blue) + } else if i % 2 == 0 { + None + } else { + Some(Color::DarkGrey) + } ) { View(width: 50pct, justify_content: JustifyContent::Start, padding_right: 2) { - Text(content: connection.id.clone(), color: if selected_row.get() == i {Some(Color::Black)} else {None}) + Text( + content: connection.id.clone(), + color: if selected_row.get() == i || i % 2 != 0 { + Some(Color::Black) + } else {None} + ) } View(width: 50pct, justify_content: JustifyContent::Center, padding_right: 2) { - Text(content: "TODO".to_string(), color: if selected_row.get() == i {Some(Color::Black)} else {None}) + Text( + content: "TODO".to_string(), + color: if selected_row.get() == i || i % 2 != 0 { + Some(Color::Black) + } else {None} + ) } } })) diff --git a/src/ui4/control/mod.rs b/src/ui4/control/mod.rs new file mode 100644 index 0000000..f1bd05d --- /dev/null +++ b/src/ui4/control/mod.rs @@ -0,0 +1,88 @@ +use std::{pin::pin, task::Poll}; + +use dbclient::hotkey_manager::HotKeyManager; +use futures::Stream; +use iocraft::{Hook, Hooks, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, TerminalEvents}; + +pub trait UseHotkeys { + fn use_hotkeys( + &mut self, + is_focused: FocusGetter, + registerer: Registerer, + ) where + Registerer: FnMut(&mut HotKeyManager<(KeyCode, KeyModifiers, KeyEventKind), (), ()>) + + Send + + 'static, + FocusGetter: Fn() -> bool + Send + Unpin + 'static; +} + +impl UseHotkeys for Hooks<'_, '_> { + fn use_hotkeys( + &mut self, + is_focused: FocusGetter, + mut registerer: Registerer, + ) where + Registerer: FnMut(&mut HotKeyManager<(KeyCode, KeyModifiers, KeyEventKind), (), ()>) + + Send + + 'static, + FocusGetter: Fn() -> bool + Send + Unpin + 'static, + { + let mut hotkey_manager = HotKeyManager::new(); + registerer(&mut hotkey_manager); + let h = self.use_hook(move || UseHotkeysImpl { + events: None, + hotkey_manager: hotkey_manager, + is_focused: None, + }); + h.is_focused = Some(is_focused); + } +} + +struct UseHotkeysImpl bool> { + is_focused: Option, + hotkey_manager: HotKeyManager<(KeyCode, KeyModifiers, KeyEventKind), (), ()>, + events: Option, +} + +impl bool + Send + Unpin> Hook for UseHotkeysImpl { + fn poll_change( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context, + ) -> std::task::Poll<()> { + while let Some(Poll::Ready(Some(event))) = self + .events + .as_mut() + .map(|events| pin!(events).poll_next(cx)) + { + match event { + iocraft::TerminalEvent::Key(KeyEvent { + code, + kind, + modifiers, + .. + }) => { + if kind == KeyEventKind::Release { + continue; + } + if let Some(is_focused) = &self.is_focused { + if !is_focused() { + self.hotkey_manager.clear_buffer(); + return Poll::Pending; + } + } + let _ = self.hotkey_manager.push((code, modifiers, kind)); + // TODO: log debug if combination doesn't match + let _ = self.hotkey_manager.invoke_if_matched(&mut ()); + } + _ => {} + } + } + Poll::Pending + } + + fn post_component_update(&mut self, updater: &mut iocraft::prelude::ComponentUpdater) { + if self.events.is_none() { + self.events = updater.terminal_events(); + } + } +} diff --git a/src/ui4/db_objects.rs b/src/ui4/db_objects.rs index abbc3f6..6382820 100644 --- a/src/ui4/db_objects.rs +++ b/src/ui4/db_objects.rs @@ -15,7 +15,7 @@ use crate::{ common::{self, db_object::Specification, postgres_object::Object}, }, ui4::{ - app_state::{AppState, QueryResultCmd, Widget}, + app_state::{AppState, Page, QueryResultCmd, Widget}, file_tree::{FileTree, FileTreeNode}, }, }; @@ -268,6 +268,9 @@ pub fn DbObjects(props: &DbObjectsProps, mut hooks: Hooks) -> impl Into { state.focus_widget = Widget::QueryResult; } + KeyCode::Esc => { + state.selected_page = Page::ConnectionsList; + } _ => {} }; }); diff --git a/src/ui4/forms/add_record_to_object.rs b/src/ui4/forms/add_record_to_object.rs index dc0a816..d5bab87 100644 --- a/src/ui4/forms/add_record_to_object.rs +++ b/src/ui4/forms/add_record_to_object.rs @@ -126,7 +126,10 @@ fn FormTextInput(props: &FormTextInputProps, _hooks: Hooks) -> impl Into impl Into { state.focus_widget = crate::ui4::app_state::Widget::DbObjects; } + KeyCode::Esc => { + state.selected_page = Page::ConnectionsList; + } _ => {} } }); @@ -312,7 +315,8 @@ pub fn QueryResult( #( match postgres_record.record.as_ref() { Some(proto::common::postgres_record::Record::Table(table)) => { - let table_callback_state = props.state.clone(); + let table_callback_state_on_add = props.state.clone(); + let table_callback_state_on_update = props.state.clone(); let cmd_sender = props.cmd_pipe.clone(); element! { TableView( @@ -320,44 +324,38 @@ pub fn QueryResult( rows: table.rows.iter().map(|row| row.values.iter().filter_map(|value| value.field.clone()).collect::>()).collect::>>(), has_focus: tokio::task::block_in_place(|| props.state.blocking_lock().focus_widget == crate::ui4::app_state::Widget::QueryResult), selected_row: selected_row.read().clone(), - on_key: Some(table_key_handler(move |code, (columns, row)| { - match code { - KeyCode::Char('a') => { - let mut state_guard = tokio::task::block_in_place(|| table_callback_state.blocking_lock()); - render_popup.set(Some(Popup::AddRecordToObject(false))); - state_guard.focus_widget = crate::ui4::app_state::Widget::AnyPopup; - - } - KeyCode::Char('d') => { - let cmd = QueryResultCmd::RemoveRecordFromObject(proto::common::DbRecord { - specification: Some(proto::common::db_record::Specification::Postgres(proto::common::PostgresRecord { - record: Some(proto::common::postgres_record::Record::Table(proto::common::PostgresRecordTable { - rows: vec![PostgresRecordTableRow { - columns: columns.iter().map(|c| proto::common::PostgresTableColumn { name: c.clone(), field: None }).collect(), - values: row.iter().map(|r| proto::common::DbField { field: Some(r.clone()) }).collect() - }] - })) - })) - }); - tokio::task::block_in_place(|| cmd_sender.as_ref().and_then(|ch| Some(ch.blocking_send(cmd)))); - }, - KeyCode::Char('i') => { - let mut state_guard = tokio::task::block_in_place(|| table_callback_state.blocking_lock()); - let initial_record = proto::common::DbRecord { - specification: Some(proto::common::db_record::Specification::Postgres(proto::common::PostgresRecord { - record: Some(proto::common::postgres_record::Record::Table(proto::common::PostgresRecordTable { - rows: vec![PostgresRecordTableRow { - columns: columns.iter().map(|c| proto::common::PostgresTableColumn { name: c.clone(), field: None }).collect(), - values: row.iter().map(|r| proto::common::DbField { field: Some(r.clone()) }).collect() - }] - })) - })) - }; - render_popup.set(Some(Popup::UpdateRecordOfObject(initial_record))); - state_guard.focus_widget = crate::ui4::app_state::Widget::AnyPopup; - } - _ => {} - } + on_add_row: Some(table_event_handler(move |_, _| { + let mut state_guard = tokio::task::block_in_place(|| table_callback_state_on_add.blocking_lock()); + render_popup.set(Some(Popup::AddRecordToObject(false))); + state_guard.focus_widget = crate::ui4::app_state::Widget::AnyPopup; + })), + on_update_row: Some(table_event_handler(move |columns, row| { + let mut state_guard = tokio::task::block_in_place(|| table_callback_state_on_update.blocking_lock()); + let initial_record = proto::common::DbRecord { + specification: Some(proto::common::db_record::Specification::Postgres(proto::common::PostgresRecord { + record: Some(proto::common::postgres_record::Record::Table(proto::common::PostgresRecordTable { + rows: vec![PostgresRecordTableRow { + columns: columns.iter().map(|c| proto::common::PostgresTableColumn { name: c.clone(), field: None }).collect(), + values: row.iter().map(|r| proto::common::DbField { field: Some(r.clone()) }).collect() + }] + })) + })) + }; + render_popup.set(Some(Popup::UpdateRecordOfObject(initial_record))); + state_guard.focus_widget = crate::ui4::app_state::Widget::AnyPopup; + })), + on_delete_row: Some(table_event_handler(move |columns, row| { + let cmd = QueryResultCmd::RemoveRecordFromObject(proto::common::DbRecord { + specification: Some(proto::common::db_record::Specification::Postgres(proto::common::PostgresRecord { + record: Some(proto::common::postgres_record::Record::Table(proto::common::PostgresRecordTable { + rows: vec![PostgresRecordTableRow { + columns: columns.iter().map(|c| proto::common::PostgresTableColumn { name: c.clone(), field: None }).collect(), + values: row.iter().map(|r| proto::common::DbField { field: Some(r.clone()) }).collect() + }] + })) + })) + }); + tokio::task::block_in_place(|| cmd_sender.as_ref().and_then(|ch| Some(ch.blocking_send(cmd)))); })) ) }