package ui import ( "fmt" "path/filepath" "strings" "time" "git.bullercodeworks.com/brian/boltbrowser/models" "git.bullercodeworks.com/brian/wandle" "git.bullercodeworks.com/brian/widdles" "github.com/nsf/termbox-go" "github.com/spf13/viper" bolt "go.etcd.io/bbolt" ) const ( BS_CmdRefresh = BrowseId | iota BS_CmdRefreshTree BS_CmdDBTimeout ) type BrowseMsg struct { source int data interface{} err error } type browseScreen struct { ui *Ui dbPath string db *models.BoltDB status *widdles.Text treePane *BoltTreePane detailPane *BoltDetailPane inputDialog *widdles.InputDialog confirmDialog *widdles.ConfirmDialog initialized bool } func NewBrowseScreen(u *Ui) *browseScreen { return &browseScreen{ ui: u, inputDialog: widdles.NewInputDialog("Edit", ""), confirmDialog: widdles.NewConfirmDialog("Are you sure?", ""), } } func (s *browseScreen) Init() wandle.Cmd { if s.initialized { return nil } w, h := termbox.Size() dbs := viper.GetStringSlice("dbs") dbidx := viper.GetInt("dbidx") if len(dbs) <= dbidx { return wandle.Quit } s.dbPath = dbs[dbidx] s.treePane = NewBoltTreePane(0, 3, w/2, h-6) s.treePane.SetInsertPairCommand(s.insertPair) s.treePane.SetInsertBucketCommand(s.insertBucket) s.treePane.SetEditPairKeyCommand(s.editPairKey) s.treePane.SetEditPairValueCommand(s.editPairValue) s.treePane.SetRenameBucketCommand(s.editBucket) s.treePane.SetDeleteItemCommand(s.deleteItem) s.treePane.SetStatusFunc(s.setStatus) s.detailPane = &BoltDetailPane{ x: w/2 + 2, y: 2, width: w / 2, height: h - 6, } s.detailPane.Init() s.treePane.SetDetailPane(s.detailPane) s.status = widdles.NewText("Press '?' for help", 0, (h - 1), w, 1) s.inputDialog.Init() s.confirmDialog.Init() return func() wandle.Msg { timeout, err := time.ParseDuration(viper.GetString("version")) if err != nil { timeout = time.Second } db, err := bolt.Open(s.dbPath, 0600, &bolt.Options{Timeout: timeout}) if err == bolt.ErrTimeout { return func() wandle.Msg { return BrowseMsg{source: BS_CmdDBTimeout} } } s.db = models.NewBoltDB(db) if viper.GetBool("readonly") { db.Close() } s.treePane.SetDB(s.db) s.resizeWindow(w, h) s.initialized = true return nil } } func (s *browseScreen) Update(msg wandle.Msg) wandle.Cmd { switch msg := msg.(type) { case BrowseMsg: return s.handleBrowseMsg(msg) case termbox.Event: return s.handleTermboxEvent(msg) } return nil } func (s *browseScreen) handleBrowseMsg(msg BrowseMsg) wandle.Cmd { if msg.source == BS_CmdRefreshTree { return s.treePane.Update(msg) } return nil } func (s *browseScreen) handleTermboxEvent(msg termbox.Event) wandle.Cmd { if s.inputDialog.IsActive() { return s.inputDialog.Update(msg) } if s.confirmDialog.IsActive() { return s.confirmDialog.Update(msg) } switch msg.Type { case termbox.EventKey: if cmd := s.treePane.Update(msg); cmd != nil { return cmd } switch { case msg.Ch == '?': return wandle.SwitchScreenCmd(NewAboutScreen(s.ui)) case msg.Ch == 'q' || msg.Key == termbox.KeyCtrlC: return wandle.Quit case msg.Ch == 'x': return s.exportValue case msg.Ch == 'X': return s.exportJSON // TODO: External editor //case msg.Ch == 'E': // return s.startEditor case msg.Ch == 'J': // TODO: Move Right Pane Down case msg.Ch == 'K': // TODO: Move Right Pane Up case msg.Key == termbox.KeyCtrlN: // Next File idx := viper.GetInt("dbidx") + 1 if idx >= len(viper.GetStringSlice("dbs")) { s.setStatus("Already at last file", time.Second) } else { viper.Set("dbidx", idx) return wandle.SwitchScreenCmd(NewBrowseScreen(s.ui)) } case msg.Key == termbox.KeyCtrlP: // Previous File idx := viper.GetInt("dbidx") - 1 if idx < 0 { s.setStatus("Already at first file", time.Second) } else { viper.Set("dbidx", idx) return wandle.SwitchScreenCmd(NewBrowseScreen(s.ui)) } } case termbox.EventResize: s.resizeWindow(msg.Width, msg.Height) } return nil } func (s *browseScreen) resizeWindow(w, h int) { lw := w if lw > 80 { lw = lw / 2 } // Re-build Tree pane s.treePane.SetWidth(lw) s.treePane.SetHeight(h - 4) // Re-build Right Pane buffer s.detailPane.SetX(lw + 1) s.detailPane.SetWidth(w - lw) s.detailPane.SetHeight(h - 2) // Re-build the input dialog dlgWidth := 4 if w < 80 { dlgWidth = 2 } s.inputDialog.SetX((w / 2) - (w / (dlgWidth * 2))) s.inputDialog.SetWidth(w / dlgWidth) s.inputDialog.SetY((h / 2) - (h / 4)) s.inputDialog.SetHeight(4) // Re-build the confirmation dialog s.confirmDialog.SetX((w / 2) - (w / (dlgWidth * 2))) s.confirmDialog.SetWidth(w / dlgWidth) s.confirmDialog.SetY((h / 2) - (h / 4)) s.confirmDialog.SetHeight(4) s.confirmDialog.EnableHotkeys() } func (s *browseScreen) View(style wandle.Style) { w, h := termbox.Size() s.drawHeader(style) s.treePane.View(style) midX := s.detailPane.GetX() - 1 if w > 80 { termbox.SetCell(midX, 1, '╤', style.Foreground, style.Background) wandle.Fill('│', midX, 2, midX, h-3, style) s.detailPane.View(style) } wandle.Fill('═', 0, h-2, w, h-2, style) termbox.SetCell(midX, h-2, '╧', style.Foreground, style.Background) s.status.View(style) if s.inputDialog.IsVisible() { s.inputDialog.View(style) } if s.confirmDialog.IsVisible() { s.confirmDialog.View(style) } } func (s *browseScreen) drawHeader(style wandle.Style) { width, _ := termbox.Size() headerStringLen := func(fileName string) int { return len("boltbrowser") + len(fileName) + 1 } headerFileName := s.dbPath if headerStringLen(headerFileName) > width { headerFileName = filepath.Base(headerFileName) } headerString := "boltbrowser" + ": " + headerFileName count := ((width - len(headerString)) / 2) + 1 if count < 0 { count = 0 } spaces := strings.Repeat(" ", count) wandle.Print(0, 0, style, fmt.Sprintf("%s%s%s", spaces, headerString, spaces)) wandle.Fill('═', 0, 1, width, 1, style) } func (s *browseScreen) setStatus(status string, timeout time.Duration) { s.status.SetText(status) if timeout > 0 { go func() { time.Sleep(timeout) if s.status.GetText() == status { s.status.SetText("") s.ui.wandle.Send(BS_CmdRefresh) } }() } } func (s *browseScreen) insertPair(bucket *models.BoltBucket) wandle.Cmd { title := fmt.Sprintf("New Pair: %s", pathToString(bucket.GetPath())) s.inputDialog.SetTitle(title) s.inputDialog.SetMessage("New Pair Key") s.inputDialog.SetValue("") s.inputDialog.SetOkCommand(func() wandle.Msg { key := s.inputDialog.GetValue() if key == "" { s.setStatus("Pair key cannot be empty", time.Second) } else { s.inputDialog.SetMessage("New Pair Value") s.inputDialog.SetValue("") s.inputDialog.SetOkCommand(func() wandle.Msg { val := s.inputDialog.GetValue() if err := s.db.InsertPair(bucket.GetPath(), key, val); err != nil { s.setStatus("Error inserting pair.", time.Second) } else { s.inputDialog.Hide() return BrowseMsg{source: BS_CmdRefreshTree} } return nil }) } return nil }) s.inputDialog.SetCancelCommand(func() wandle.Msg { s.inputDialog.Hide() return nil }) s.inputDialog.Show() return nil } func (s *browseScreen) insertBucket(bucket *models.BoltBucket) wandle.Cmd { title := fmt.Sprintf("New Bucket: %s", pathToString(s.treePane.currentPath)) s.inputDialog.SetTitle(title) s.inputDialog.SetValue("") s.inputDialog.SetOkCommand(func() wandle.Msg { key := s.inputDialog.GetValue() if key == "" { s.setStatus("Pair key cannot be empty", time.Second) } else { if err := s.db.InsertBucket(bucket.GetPath(), key); err != nil { s.setStatus("Error inserting pair.", time.Second) } else { s.inputDialog.Hide() return BrowseMsg{source: BS_CmdRefreshTree} } } return nil }) s.inputDialog.SetCancelCommand(func() wandle.Msg { s.inputDialog.Hide() return nil }) s.inputDialog.Show() return nil } func (s *browseScreen) editPairKey(pair *models.BoltPair) wandle.Cmd { s.inputDialog.SetTitle(fmt.Sprintf("Input new value for '%s'", pair.GetKey())) s.inputDialog.SetValue(pair.GetKey()) s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setStatus("No value given, did you mean to (d)elete?", time.Second) } else if err := s.db.UpdatePairKey(s.treePane.currentPath, v); err != nil { s.setStatus("Error changing pair key", time.Second) } else { s.treePane.currentPath[len(s.treePane.currentPath)-1] = v s.inputDialog.Hide() return BrowseMsg{source: BS_CmdRefreshTree} } return nil }) s.inputDialog.SetCancelCommand(func() wandle.Msg { s.inputDialog.Hide() return nil }) s.inputDialog.Show() return nil } func (s *browseScreen) editPairValue(pair *models.BoltPair) wandle.Cmd { s.inputDialog.SetTitle(fmt.Sprintf("Input new value for '%s'", pair.GetKey())) s.inputDialog.SetValue(pair.GetValue()) s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setStatus("No value given, did you mean to (d)elete?", time.Second) } else if err := s.treePane.db.UpdatePairValue(s.treePane.currentPath, v); err != nil { s.setStatus("Error updating pair value.", time.Second) } else { s.inputDialog.Hide() return BrowseMsg{source: BS_CmdRefreshTree} } return nil }) s.inputDialog.SetCancelCommand(func() wandle.Msg { s.inputDialog.Hide() return nil }) s.inputDialog.Show() return nil } func (s *browseScreen) editBucket(bucket *models.BoltBucket) wandle.Cmd { s.inputDialog.SetTitle(fmt.Sprintf("Rename Bucket '%s' to:", bucket.GetName())) s.inputDialog.SetValue(bucket.GetName()) s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setStatus("A Bucket has to have a name.", time.Second) } else if err := s.db.RenameBucket(s.treePane.currentPath, v); err != nil { s.setStatus("Error renaming bucket.", time.Second) } else { bkt, _ := s.treePane.db.GetBucketFromPath(s.treePane.currentPath) if bkt != nil { bkt.SetName(v) } oldPath := make([]string, len(s.treePane.currentPath)) copy(oldPath, s.treePane.currentPath) s.treePane.currentPath[len(s.treePane.currentPath)-1] = v s.inputDialog.Hide() return BrowseMsg{ source: BS_CmdRefreshTree, data: TreePaneRenamePath{ oldPath: oldPath, newPath: s.treePane.currentPath, }, } } return nil }) s.inputDialog.Show() return nil } func (s *browseScreen) deleteItem(path []string) wandle.Cmd { key := path[len(path)-1] var itemType string b, p, _ := s.treePane.db.GetGenericFromPath(path) if b != nil { itemType = "Bucket" } else if p != nil { itemType = "Pair" } else { s.setStatus("Error deleting item.", time.Second) } s.confirmDialog.SetTitle(fmt.Sprintf("Delete %s '%s'?", itemType, key)) s.confirmDialog.SetMessage(fmt.Sprintf("Are you sure you want to delete this %s?", itemType)) s.confirmDialog.SetOkCommand(func() wandle.Msg { s.treePane.moveCursorUp() if err := s.treePane.db.DeleteKey(path); err != nil { s.setStatus(fmt.Sprintf("Error deleting %s.", itemType), time.Second) } s.confirmDialog.Hide() return BrowseMsg{source: BS_CmdRefreshTree} }) s.confirmDialog.SetCancelCommand(func() wandle.Msg { s.confirmDialog.Hide() return nil }) s.confirmDialog.Show() return nil } func (s *browseScreen) exportValue() wandle.Msg { path := s.treePane.GetCurrentPath() b, p, e := s.treePane.GetSelected() if e != nil || p == nil { s.setStatus("Couldn't do string export on "+path[len(path)-1]+" (did you mean 'X'?)", time.Second) return nil } s.inputDialog.SetTitle(fmt.Sprintf("Export value of '%s' to:", b.GetName())) s.inputDialog.SetValue("") s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setStatus("Must give a file name to export to.", time.Second) } else { if err := s.db.ExportValue(s.treePane.GetCurrentPath(), v); err != nil { s.setStatus("Error Exporting Value", time.Second) } } return nil }) s.inputDialog.Show() return nil } func (s *browseScreen) exportJSON() wandle.Msg { b, p, e := s.treePane.GetSelected() if e != nil { s.setStatus("Error getting value to export", time.Second) return nil } var nm string if b != nil { nm = b.GetName() } else if p != nil { nm = p.GetKey() } s.inputDialog.SetTitle(fmt.Sprintf("Export JSON of '%s' to:", nm)) s.inputDialog.SetValue("") s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setStatus("Must give a file name to export to.", time.Second) } else { if err := s.db.ExportJSON(s.treePane.GetCurrentPath(), v); err != nil { s.setStatus("Error Exporting JSON", time.Second) } } return nil }) s.inputDialog.Show() return nil } /* * TODO: This kind of works, but we need to stop termbox from intercepting * keypresses, and we need to make sure that the screen redraws when we * return. func (s *browseScreen) startEditor() wandle.Msg { editor := os.Getenv("EDITOR") if editor == "" { s.setStatus("EDITOR environment variable is not set.", time.Second) return nil } path := s.treePane.GetCurrentPath() _, p, _ := s.treePane.GetSelected() if p == nil { s.setStatus("Error pulling value to write to temp file", time.Second) return nil } f, err := os.CreateTemp(os.TempDir(), "boltbrowser-") if err != nil { return err } defer f.Close() defer os.Remove(f.Name()) if _, err := f.Write([]byte(p.GetValue())); err != nil { s.setStatus("Error writing value to temp file", time.Second) return nil } c := exec.Command(editor, f.Name()) c.Stdin = os.Stdin c.Stdout = os.Stdout e := c.Run() if e != nil { return nil } if b, e := ioutil.ReadAll(f); e != nil { s.setStatus("Error reading edited temp file", time.Second) } else { if err := s.treePane.db.UpdatePairValue(path, string(b)); err != nil { s.setStatus("Error saving new value to db", time.Second) } } return nil } */