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 BS_ScreenAuto = iota BS_ScreenSplit BS_ScreenFullTree BS_ScreenFullDetail ) type BrowseMsg struct { source int data interface{} err error } type browseScreen struct { ui *Ui dbPath string db *models.BoltDB status *widdles.Text statusMsg string statusIdx int screenMode int screenModeIsAuto bool treePane *BoltTreePane detailPane *BoltDetailPane inputDialog *widdles.InputDialog confirmDialog *widdles.ConfirmDialog width, height int initialized bool } func NewBrowseScreen(u *Ui) *browseScreen { return &browseScreen{ ui: u, screenMode: BS_ScreenSplit, screenModeIsAuto: true, inputDialog: widdles.NewInputDialog("Edit", ""), confirmDialog: widdles.NewConfirmDialog("Are you sure?", ""), } } func (s *browseScreen) Init() wandle.Cmd { if s.initialized { return nil } s.width, s.height = 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, s.width/2, s.height-6) s.treePane.SetVisible(true) 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: s.width/2 + 2, y: 2, width: s.width / 2, height: s.height - 6, } s.detailPane.SetVisible(true) s.detailPane.Init() s.treePane.SetDetailPane(s.detailPane) s.status = widdles.NewText("Press '?' for help", 0, (s.height - 1), s.width, 1) s.inputDialog.Init() s.confirmDialog.Init() s.setScreenToAuto() 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(s.width, s.height) s.initialized = true // If this is an empty database, force a bucket entry if len(s.db.GetBuckets()) == 0 { return s.insertBucket(nil) } 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 cmd := s.inputDialog.Update(msg); cmd != nil { return cmd } if cmd := s.confirmDialog.Update(msg); cmd != nil { return cmd } var cmds []wandle.Cmd cmds = append(cmds, s.treePane.Update(msg)) cmds = append(cmds, s.detailPane.Update(msg)) switch msg.Type { case termbox.EventKey: switch { case msg.Ch == '?': cmds = append(cmds, wandle.SwitchScreenCmd(NewAboutScreen(s.ui))) case msg.Ch == 'q' || msg.Key == termbox.KeyCtrlC: return wandle.Quit case msg.Ch == 'x': cmds = append(cmds, s.exportValue) case msg.Ch == 'X': cmds = append(cmds, s.exportJSON) case msg.Ch == 'v': // Toggle through screen view modes cmds = append(cmds, s.toggleScreenMode()) case msg.Ch == 'V': // Set screen mode to Auto cmds = append(cmds, s.setScreenToAuto()) return nil // TODO: External editor //case msg.Ch == 'E': // return s.startEditor 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) cmds = append(cmds, 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) cmds = append(cmds, wandle.SwitchScreenCmd(NewBrowseScreen(s.ui))) } } case termbox.EventResize: s.resizeWindow(msg.Width, msg.Height) } return wandle.Batch(cmds...) } func (s *browseScreen) toggleScreenMode() wandle.Cmd { switch s.screenMode { case BS_ScreenSplit: return s.setScreenToFullTree case BS_ScreenFullTree: return s.setScreenToFullDetail case BS_ScreenFullDetail: return s.setScreenToSplit default: return s.setScreenToAuto() } } func (s *browseScreen) setScreenToAuto() wandle.Cmd { s.screenModeIsAuto = true s.setStatus("Setting screen mode to Auto", time.Second) if s.width >= 80 { return s.setScreenToSplit } else { return s.setScreenToFullTree } } func (s *browseScreen) setScreenToSplit() wandle.Msg { s.screenMode = BS_ScreenSplit s.setStatus("Setting screen mode to Split", time.Second) s.treePane.SetVisible(true) s.treePane.SetWidth(s.width / 2) s.detailPane.SetX(s.width/2 + 1) s.detailPane.SetWidth(s.width / 2) return nil } func (s *browseScreen) setScreenToFullTree() wandle.Msg { s.screenMode = BS_ScreenFullTree s.setStatus("Setting screen mode to Full Tree", time.Second) s.treePane.SetVisible(true) s.treePane.SetWidth(s.width) s.detailPane.SetVisible(false) return nil } func (s *browseScreen) setScreenToFullDetail() wandle.Msg { s.screenMode = BS_ScreenFullDetail s.setStatus("Setting screen mode to Full Detail", time.Second) s.treePane.SetVisible(false) s.detailPane.SetVisible(true) s.detailPane.SetX(0) s.detailPane.SetWidth(s.width) return nil } func (s *browseScreen) resizeWindow(w, h int) { s.width, s.height = w, h lw := w dlgWidth := 2 if lw > 80 { lw = lw / 2 dlgWidth = 4 } if s.screenModeIsAuto { s.setScreenToAuto() } else { switch s.screenMode { case BS_ScreenSplit: s.setScreenToSplit() case BS_ScreenFullTree: s.setScreenToFullTree() case BS_ScreenFullDetail: s.setScreenToFullDetail() } } // Re-build Tree pane s.treePane.SetHeight(h - 4) // Re-build Right Pane buffer s.detailPane.SetHeight(h - 2) // Re-build the input dialog 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) { s.drawHeader(style) s.treePane.View(style) s.detailPane.View(style) if s.treePane.IsVisible() && s.detailPane.IsVisible() { termbox.SetCell(s.width/2, 1, '╤', style.Foreground, style.Background) wandle.Fill('│', s.width/2, 2, s.width/2, s.height-3, style) termbox.SetCell(s.width/2, s.height-2, '╧', style.Foreground, style.Background) } wandle.Fill('═', 0, s.height-2, s.width, s.height-2, style) 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) { headerStringLen := func(fileName string) int { return len("boltbrowser") + len(fileName) + 1 } headerFileName := s.dbPath if headerStringLen(headerFileName) > s.width { headerFileName = filepath.Base(headerFileName) } headerString := "boltbrowser" + ": " + headerFileName count := ((s.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, s.width, 1, style) } func (s *browseScreen) setStatus(status string, timeout time.Duration) { s.status.SetText(status) s.status.ClearStyle() s.statusIdx++ idx := s.statusIdx if timeout > 0 { time.AfterFunc(timeout, func() { if idx == s.statusIdx { s.setStatus("", -1) s.ui.wandle.Send(BS_CmdRefresh) } }) } } func (s *browseScreen) setErrorStatus(status string, timeout time.Duration) { s.status.SetStyle(wandle.NewStyle( termbox.RGBToAttribute(uint8(255), uint8(0), uint8(0)), termbox.RGBToAttribute(uint8(0), uint8(0), uint8(0)), ).Blink(true)) s.status.SetText(status) s.statusIdx++ idx := s.statusIdx if timeout > 0 { time.AfterFunc(timeout, func() { if idx == s.statusIdx { s.setStatus("", -1) s.status.ClearStyle() 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.setErrorStatus("! Pair key cannot be empty", time.Second*5) } 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.setErrorStatus("! Error inserting pair.", time.Second*5) } 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.setErrorStatus("! Bucket key cannot be empty", time.Second*5) } else { path := []string{} if bucket != nil { path = bucket.GetPath() } if err := s.db.InsertBucket(path, key); err != nil { s.setErrorStatus("! Error inserting bucket", time.Second*5) } 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("Edit pair key for '%s'", pair.GetKey())) s.inputDialog.SetValue(pair.GetKey()) s.inputDialog.SetMessage("") s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setErrorStatus("! No value given, did you mean to (d)elete?", time.Second*5) } else if err := s.db.UpdatePairKey(s.treePane.currentPath, v); err != nil { s.setErrorStatus("! Error changing pair key", time.Second*5) } 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("Edit pair value for '%s'", pair.GetKey())) s.inputDialog.SetValue(pair.GetValue()) s.inputDialog.SetMessage("") s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setErrorStatus("! No value given, did you mean to (d)elete?", time.Second*5) } else if err := s.treePane.db.UpdatePairValue(s.treePane.currentPath, v); err != nil { s.setErrorStatus("! Error updating pair value.", time.Second*5) } 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.SetMessage("") s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setErrorStatus("! A Bucket has to have a name.", time.Second*5) } else if err := s.db.RenameBucket(s.treePane.currentPath, v); err != nil { s.setErrorStatus("! Error renaming bucket.", time.Second*5) } 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.setErrorStatus("! Error deleting item.", time.Second*5) } 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.setErrorStatus(fmt.Sprintf("! Error deleting %s.", itemType), time.Second*5) } 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.setErrorStatus("! Couldn't do string export on "+path[len(path)-1]+" (did you mean 'X'?)", time.Second*5) return nil } s.inputDialog.SetTitle(fmt.Sprintf("Export value of '%s' to:", b.GetName())) s.inputDialog.SetMessage("") s.inputDialog.SetValue("") s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setErrorStatus("! Must give a file name to export to.", time.Second*5) } else { if err := s.db.ExportValue(s.treePane.GetCurrentPath(), v); err != nil { s.setErrorStatus("! Error Exporting Value", time.Second*5) } } return nil }) s.inputDialog.Show() return nil } func (s *browseScreen) exportJSON() wandle.Msg { b, p, e := s.treePane.GetSelected() if e != nil { s.setErrorStatus("! Error getting value to export", time.Second*5) 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.SetMessage("") s.inputDialog.SetValue("") s.inputDialog.SetOkCommand(func() wandle.Msg { v := s.inputDialog.GetValue() if v == "" { s.setErrorStatus("! Must give a file name to export to.", time.Second*5) } else { if err := s.db.ExportJSON(s.treePane.GetCurrentPath(), v); err != nil { s.setErrorStatus("! Error Exporting JSON", time.Second*5) } } 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 } */