diff --git a/README.md b/README.md index d20e3c5..2fb4873 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ odbc+postgres://user:pass@localhost:port/dbname?option1= - [ ] Support for NoSQL databases - [ ] Columns and indexes creation through TUI -- [ ] Table tree input filter +- [x] Table tree input filter - [ ] Custom keybindings - [x] Show keybindings on a modal - [x] Rewrite row `create`, `update` and `delete` logic diff --git a/app/Keymap.go b/app/Keymap.go index 1ea9333..f80a57d 100644 --- a/app/Keymap.go +++ b/app/Keymap.go @@ -40,6 +40,7 @@ func (c KeymapSystem) Resolve(event *tcell.EventKey) cmd.Command { const ( HomeGroup = "home" TreeGroup = "tree" + TreeFilterGroup = "treefilter" TableGroup = "table" EditorGroup = "editor" ConnectionGroup = "connection" @@ -73,6 +74,15 @@ var Keymaps = KeymapSystem{ Bind{Key: Key{Code: tcell.KeyDown}, Cmd: cmd.MoveDown, Description: "Go down"}, Bind{Key: Key{Char: 'k'}, Cmd: cmd.MoveUp, Description: "Go up"}, Bind{Key: Key{Code: tcell.KeyUp}, Cmd: cmd.MoveUp, Description: "Go up"}, + Bind{Key: Key{Char: '/'}, Cmd: cmd.Search, Description: "Search"}, + Bind{Key: Key{Char: 'n'}, Cmd: cmd.NextFoundNode, Description: "Go to next found node"}, + Bind{Key: Key{Char: 'p'}, Cmd: cmd.PreviousFoundNode, Description: "Go to previous found node"}, + Bind{Key: Key{Char: 'c'}, Cmd: cmd.TreeCollapseAll, Description: "Collapse all"}, + Bind{Key: Key{Char: 'e'}, Cmd: cmd.ExpandAll, Description: "Expand all"}, + }, + TreeFilterGroup: { + Bind{Key: Key{Code: tcell.KeyEscape}, Cmd: cmd.UnfocusTreeFilter, Description: "Unfocus tree filter"}, + Bind{Key: Key{Code: tcell.KeyEnter}, Cmd: cmd.CommitTreeFilter, Description: "Commit tree filter search"}, }, TableGroup: { Bind{Key: Key{Char: '/'}, Cmd: cmd.Search, Description: "Search"}, diff --git a/commands/commands.go b/commands/commands.go index 8e98ab5..7929380 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -54,6 +54,12 @@ const ( AppendNewRow SortAsc SortDesc + UnfocusTreeFilter + CommitTreeFilter + NextFoundNode + PreviousFoundNode + TreeCollapseAll + ExpandAll // Connection NewConnection @@ -161,6 +167,18 @@ func (c Command) String() string { return "ForeignKeysMenu" case IndexesMenu: return "IndexesMenu" + case UnfocusTreeFilter: + return "UnfocusTreeFilter" + case CommitTreeFilter: + return "CommitTreeFilter" + case NextFoundNode: + return "NextFoundNode" + case PreviousFoundNode: + return "PreviousFoundNode" + case TreeCollapseAll: + return "TreeCollapseAll" + case ExpandAll: + return "ExpandAll" } return "Unknown" } diff --git a/components/ConnectionSelection.go b/components/ConnectionSelection.go index 821004e..53f34d0 100644 --- a/components/ConnectionSelection.go +++ b/components/ConnectionSelection.go @@ -180,7 +180,7 @@ func (cs *ConnectionSelection) Connect(connection models.Connection) { MainPages.SwitchToPage(connection.URL) newHome.Tree.SetCurrentNode(newHome.Tree.GetRoot()) - newHome.Tree.SetTitle(fmt.Sprintf("%s (%s)", connection.Name, strings.ToUpper(connection.Provider))) + newHome.Tree.Wrapper.SetTitle(fmt.Sprintf("%s (%s)", connection.Name, strings.ToUpper(connection.Provider))) App.SetFocus(newHome.Tree) App.Draw() } diff --git a/components/Home.go b/components/Home.go index c217350..cc8f07c 100644 --- a/components/Home.go +++ b/components/Home.go @@ -48,7 +48,7 @@ func NewHomePage(connection models.Connection, dbdriver drivers.Driver) *Home { go home.subscribeToTreeChanges() leftWrapper.SetBorderColor(tview.Styles.InverseTextColor) - leftWrapper.AddItem(tree, 0, 1, true) + leftWrapper.AddItem(tree.Wrapper, 0, 1, true) rightWrapper.SetBorderColor(tview.Styles.InverseTextColor) rightWrapper.SetBorder(true) @@ -113,6 +113,13 @@ func (home *Home) subscribeToTreeChanges() { } app.App.ForceDraw() + case "IsFiltering": + isFiltering := stateChange.Value.(bool) + if isFiltering { + home.SetInputCapture(nil) + } else { + home.SetInputCapture(home.homeInputCapture) + } } } } diff --git a/components/Tree.go b/components/Tree.go index 3d06fcb..fadf3d9 100644 --- a/components/Tree.go +++ b/components/Tree.go @@ -10,19 +10,26 @@ import ( "github.com/jorgerojas26/lazysql/app" "github.com/jorgerojas26/lazysql/commands" "github.com/jorgerojas26/lazysql/drivers" + "github.com/jorgerojas26/lazysql/helpers/logger" "github.com/jorgerojas26/lazysql/models" ) type TreeState struct { - selectedDatabase string - selectedTable string + currentFocusFoundNode *tview.TreeNode + selectedDatabase string + selectedTable string + searchFoundNodes []*tview.TreeNode + isFiltering bool } type Tree struct { + DBDriver drivers.Driver *tview.TreeView - state *TreeState - DBDriver drivers.Driver - subscribers []chan models.StateChange + state *TreeState + Filter *tview.InputField + Wrapper *tview.Flex + FoundNodeCountInput *tview.InputField + subscribers []chan models.StateChange } func NewTree(dbName string, dbdriver drivers.Driver) *Tree { @@ -32,18 +39,21 @@ func NewTree(dbName string, dbdriver drivers.Driver) *Tree { } tree := &Tree{ - TreeView: tview.NewTreeView(), - state: state, - subscribers: []chan models.StateChange{}, - DBDriver: dbdriver, + Wrapper: tview.NewFlex(), + TreeView: tview.NewTreeView(), + state: state, + subscribers: []chan models.StateChange{}, + DBDriver: dbdriver, + Filter: tview.NewInputField(), + FoundNodeCountInput: tview.NewInputField(), } tree.SetTopLevel(1) tree.SetGraphicsColor(tview.Styles.PrimaryTextColor) - tree.SetBorder(true) + // tree.SetBorder(true) tree.SetTitle("Databases") tree.SetTitleAlign(tview.AlignLeft) - tree.SetBorderPadding(0, 0, 1, 1) + // tree.SetBorderPadding(0, 0, 1, 1) rootNode := tview.NewTreeNode("-") tree.SetRoot(rootNode) @@ -63,12 +73,23 @@ func NewTree(dbName string, dbdriver drivers.Driver) *Tree { } if tree.GetSelectedDatabase() == "" { - for _, child := range databases { - childNode := tview.NewTreeNode(child) + for _, database := range databases { + childNode := tview.NewTreeNode(database) childNode.SetExpanded(false) - childNode.SetReference(child) + childNode.SetReference(database) childNode.SetColor(tview.Styles.PrimaryTextColor) rootNode.AddChild(childNode) + + go func(database string, node *tview.TreeNode) { + tables, err := tree.DBDriver.GetTables(database) + if err != nil { + logger.Error(err.Error(), nil) + return + } + + tree.databasesToNodes(tables, node, true) + App.Draw() + }(database, childNode) } } tree.SetFocusFunc(nil) @@ -88,7 +109,7 @@ func NewTree(dbName string, dbdriver drivers.Driver) *Tree { }) nodeText := node.GetText() - node.SetText(fmt.Sprintf("[%s:]%s", tview.Styles.SecondaryTextColor.Name(), nodeText)) + node.SetText(fmt.Sprintf("[%s:dark]%s", tview.Styles.SecondaryTextColor.Name(), nodeText)) }) tree.SetSelectedFunc(func(node *tview.TreeNode) { @@ -98,15 +119,15 @@ func NewTree(dbName string, dbdriver drivers.Driver) *Tree { } else { tree.SetSelectedDatabase(node.GetReference().(string)) - if node.GetChildren() == nil { - tables, err := tree.DBDriver.GetTables(tree.GetSelectedDatabase()) - if err != nil { - // TODO: Handle error - return - } - - tree.updateNodes(tables, node, true) - } + // if node.GetChildren() == nil { + // tables, err := tree.DBDriver.GetTables(tree.GetSelectedDatabase()) + // if err != nil { + // // TODO: Handle error + // return + // } + // + // tree.databasesToNodes(tables, node, true) + // } node.SetExpanded(true) } @@ -166,14 +187,104 @@ func NewTree(dbName string, dbdriver drivers.Driver) *Tree { // Can't "select" the current node via TreeView api. // So fake it by sending it a Enter key event return tcell.NewEventKey(tcell.KeyEnter, 0, 0) + case commands.Search: + tree.RemoveHighlight() + App.SetFocus(tree.Filter) + tree.SetIsFiltering(true) + case commands.NextFoundNode: + tree.goToNextFoundNode() + case commands.PreviousFoundNode: + tree.goToPreviousFoundNode() + case commands.TreeCollapseAll: + tree.CollapseAll() + case commands.ExpandAll: + tree.ExpandAll() } return nil }) + tree.Filter.SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + + filterText := tree.Filter.GetText() + + if filterText == "" { + tree.search("") + tree.FoundNodeCountInput.SetText("") + tree.SetBorderPadding(0, 0, 0, 0) + } else { + tree.FoundNodeCountInput.SetText(fmt.Sprintf("[%d/%d]", len(tree.state.searchFoundNodes), len(tree.state.searchFoundNodes))) + tree.SetBorderPadding(1, 0, 0, 0) + } + + case tcell.KeyEscape: + tree.search("") + tree.FoundNodeCountInput.SetText("") + tree.SetBorderPadding(0, 0, 0, 0) + tree.Filter.SetText("") + } + + tree.SetIsFiltering(false) + tree.Highlight() + App.SetFocus(tree) + }) + + tree.Filter.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() != tcell.KeyEscape && event.Key() != tcell.KeyEnter { + isBackSpace := event.Key() == tcell.KeyBackspace2 + + filterText := tree.Filter.GetText() + + if isBackSpace { + if len(filterText) > 0 { + tree.search(filterText[:len(filterText)-1]) + } else { + tree.search("") + } + } else { + tree.search(filterText + string(event.Rune())) + } + } + + return event + }) + + tree.Filter.SetFieldStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tview.Styles.PrimaryTextColor)) + tree.Filter.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tview.Styles.InverseTextColor)) + tree.Filter.SetBorderPadding(0, 0, 0, 0) + tree.Filter.SetBorderColor(tview.Styles.PrimaryTextColor) + tree.Filter.SetLabel("Search: ") + tree.Filter.SetLabelColor(tview.Styles.InverseTextColor) + + tree.Filter.SetFocusFunc(func() { + tree.Filter.SetLabelColor(tview.Styles.TertiaryTextColor) + tree.Filter.SetFieldTextColor(tview.Styles.PrimaryTextColor) + }) + + tree.Filter.SetBlurFunc(func() { + if tree.Filter.GetText() == "" { + tree.Filter.SetLabelColor(tview.Styles.InverseTextColor) + } else { + tree.Filter.SetLabelColor(tview.Styles.TertiaryTextColor) + } + tree.Filter.SetFieldTextColor(tview.Styles.InverseTextColor) + }) + + tree.FoundNodeCountInput.SetFieldStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tview.Styles.PrimaryTextColor)) + + tree.Wrapper.SetDirection(tview.FlexRow) + tree.Wrapper.SetBorder(true) + tree.Wrapper.SetBorderPadding(0, 0, 1, 1) + + tree.Wrapper.AddItem(tree.Filter, 1, 0, false) + tree.Wrapper.AddItem(tree.FoundNodeCountInput, 1, 0, false) + tree.Wrapper.AddItem(tree, 0, 1, true) + return tree } -func (tree *Tree) updateNodes(children map[string][]string, node *tview.TreeNode, defaultExpanded bool) { +func (tree *Tree) databasesToNodes(children map[string][]string, node *tview.TreeNode, defaultExpanded bool) { node.ClearChildren() for key, values := range children { @@ -209,6 +320,40 @@ func (tree *Tree) updateNodes(children map[string][]string, node *tview.TreeNode } } +func (tree *Tree) search(searchText string) { + rootNode := tree.GetRoot() + lowerSearchText := strings.ToLower(searchText) + tree.state.searchFoundNodes = []*tview.TreeNode{} + + if lowerSearchText == "" { + rootNode.Walk(func(_, parent *tview.TreeNode) bool { + if parent != nil && parent != rootNode && parent.IsExpanded() { + parent.SetExpanded(false) + } + return true + }) + return + } + + // filteredNodes := make([]*TreeStateNode, 0, len(treeNodes)) + + rootNode.Walk(func(node, parent *tview.TreeNode) bool { + nodeText := strings.ToLower(node.GetText()) + + if strings.Contains(nodeText, lowerSearchText) { + if parent != nil { + parent.SetExpanded(true) + } + tree.state.searchFoundNodes = append(tree.state.searchFoundNodes, node) + tree.SetCurrentNode(node) + tree.state.currentFocusFoundNode = node + } + return true + }) + + App.ForceDraw() +} + // Subscribe to changes in the tree state func (tree *Tree) Subscribe() chan models.StateChange { subscriber := make(chan models.StateChange) @@ -232,6 +377,10 @@ func (tree *Tree) GetSelectedTable() string { return tree.state.selectedTable } +func (tree *Tree) GetIsFiltering() bool { + return tree.state.isFiltering +} + func (tree *Tree) SetSelectedDatabase(database string) { tree.state.selectedDatabase = database tree.Publish(models.StateChange{ @@ -248,6 +397,14 @@ func (tree *Tree) SetSelectedTable(table string) { }) } +func (tree *Tree) SetIsFiltering(isFiltering bool) { + tree.state.isFiltering = isFiltering + tree.Publish(models.StateChange{ + Key: "IsFiltering", + Value: isFiltering, + }) +} + // Blur func func (tree *Tree) RemoveHighlight() { tree.SetBorderColor(tview.Styles.InverseTextColor) @@ -332,3 +489,67 @@ func (tree *Tree) Highlight() { } } + +func (tree *Tree) goToNextFoundNode() { + foundNodesText := make([]string, len(tree.state.searchFoundNodes)) + + for i, node := range tree.state.searchFoundNodes { + foundNodesText[i] = node.GetText() + } + + for i, node := range tree.state.searchFoundNodes { + if node == tree.state.currentFocusFoundNode { + var newFocusNodeIndex int + + if i+1 < len(tree.state.searchFoundNodes) { + newFocusNodeIndex = i + 1 + } else { + newFocusNodeIndex = 0 + } + + newFocusNode := tree.state.searchFoundNodes[newFocusNodeIndex] + tree.SetCurrentNode(newFocusNode) + tree.state.currentFocusFoundNode = newFocusNode + tree.FoundNodeCountInput.SetText(fmt.Sprintf("[%d/%d]", newFocusNodeIndex+1, len(tree.state.searchFoundNodes))) + break + } + } +} + +func (tree *Tree) goToPreviousFoundNode() { + for i, node := range tree.state.searchFoundNodes { + if node == tree.state.currentFocusFoundNode { + var newFocusNodeIndex int + + if i-1 >= 0 { + newFocusNodeIndex = i - 1 + } else { + newFocusNodeIndex = len(tree.state.searchFoundNodes) - 1 + } + + newFocusNode := tree.state.searchFoundNodes[newFocusNodeIndex] + tree.SetCurrentNode(newFocusNode) + tree.state.currentFocusFoundNode = newFocusNode + tree.FoundNodeCountInput.SetText(fmt.Sprintf("[%d/%d]", newFocusNodeIndex+1, len(tree.state.searchFoundNodes))) + break + } + } +} + +func (tree *Tree) CollapseAll() { + tree.GetRoot().Walk(func(node, _ *tview.TreeNode) bool { + if node.IsExpanded() && node != tree.GetRoot() { + node.Collapse() + } + return true + }) +} + +func (tree *Tree) ExpandAll() { + tree.GetRoot().Walk(func(node, _ *tview.TreeNode) bool { + if !node.IsExpanded() && node != tree.GetRoot() { + node.Expand() + } + return true + }) +}