package ui import ( "fmt" "strconv" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "git.morlana.online/f.weber/vault-tui/internal/vault" ) func (m *Model) loadVersions(mount vault.Mount, path string) tea.Cmd { m.loading = true return func() tea.Msg { vs, err := m.svc.KV.Versions(m.ctx, mount, path) return versionsMsg{versions: vs, err: err} } } func (m *Model) openVersions() (tea.Model, tea.Cmd) { f := m.currentFrame() if f.mount.Kind != vault.EngineKVv2 { return m, m.notify(toastWarn, "version history is only available for KV v2") } m.versionsCursor = 0 m.push(frame{scr: scrVersions, mount: f.mount, path: f.path}) return m, m.loadVersions(f.mount, f.path) } func (m *Model) updateVersions(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case versionsMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } m.versions = msg.versions if m.versionsCursor >= len(m.versions) { m.versionsCursor = 0 } return m, nil case rollbackAckMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } f := m.currentFrame() return m, tea.Batch( m.notify(toastSuccess, "rolled back — created version %d", msg.ack.Version), m.loadVersions(f.mount, f.path), ) case undeleteAckMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } f := m.currentFrame() return m, tea.Batch(m.notify(toastSuccess, "undeleted"), m.loadVersions(f.mount, f.path)) case tea.KeyPressMsg: switch { case key.Matches(msg, m.keys.Back): m.pop() return m, m.reloadSecretAfterVersions() case key.Matches(msg, m.keys.Quit): m.quitting = true return m, tea.Quit case key.Matches(msg, m.keys.Up): if m.versionsCursor > 0 { m.versionsCursor-- } case key.Matches(msg, m.keys.Down): if m.versionsCursor < len(m.versions)-1 { m.versionsCursor++ } case key.Matches(msg, m.keys.Enter): return m.viewSelectedVersion() case key.Matches(msg, m.keys.Rollback): if !m.a.Settings.ReadOnly { return m.confirmRollback() } case key.Matches(msg, m.keys.Undelete): if !m.a.Settings.ReadOnly { return m.confirmUndelete() } } } return m, nil } func (m *Model) reloadSecretAfterVersions() tea.Cmd { f := m.currentFrame() if f.scr == scrSecret { return m.loadSecret(f.mount, f.path, 0) } return nil } func (m *Model) viewSelectedVersion() (tea.Model, tea.Cmd) { if len(m.versions) == 0 { return m, nil } v := m.versions[m.versionsCursor] f := m.currentFrame() m.push(frame{scr: scrSecret, mount: f.mount, path: f.path}) return m, m.loadSecret(f.mount, f.path, v.Version) } func (m *Model) confirmRollback() (tea.Model, tea.Cmd) { if len(m.versions) == 0 { return m, nil } v := m.versions[m.versionsCursor] f := m.currentFrame() mount, path := f.mount, f.path action := func() tea.Cmd { m.loading = true return func() tea.Msg { ack, err := m.svc.KV.Rollback(m.ctx, mount, path, v.Version) return rollbackAckMsg{ack: ack, err: err} } } if !m.a.Settings.ConfirmDestructive { return m, action() } m.confirm = &confirmSpec{ title: fmt.Sprintf("Roll back to version %d?", v.Version), body: "Creates a new version with version " + strconv.Itoa(v.Version) + "'s data. No existing version is deleted.", onConfirm: action, } return m, nil } func (m *Model) confirmUndelete() (tea.Model, tea.Cmd) { if len(m.versions) == 0 { return m, nil } v := m.versions[m.versionsCursor] if v.DeletionTime == "" && !v.Destroyed { return m, m.notify(toastInfo, "version %d is not deleted", v.Version) } if v.Destroyed { return m, m.notify(toastWarn, "version %d was destroyed — it cannot be undeleted", v.Version) } f := m.currentFrame() mount, path := f.mount, f.path action := func() tea.Cmd { m.loading = true return func() tea.Msg { ack, err := m.svc.KV.Delete(m.ctx, mount, path, vault.OpUndelete, []int{v.Version}) return undeleteAckMsg{ack: ack, err: err} } } if !m.a.Settings.ConfirmDestructive { return m, action() } m.confirm = &confirmSpec{ title: fmt.Sprintf("Undelete version %d?", v.Version), body: "Restores this version so it can be read again.", onConfirm: action, } return m, nil } func (m *Model) viewVersions() string { f := m.currentFrame() ow, oh := m.lay.bodyW, m.lay.bodyH iw, ih := panelInner(ow, oh) title := m.styles.PanelTitle.Render("Versions of " + f.mount.Path + f.path) if m.loading { return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…")) } if len(m.versions) == 0 { return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, title, "", m.styles.EmptyState.Render("no version history"))) } header := m.styles.TableHeader.Render(padRight("", 3) + padRight("version", 10) + padRight("created", 22) + "status") rowsH := ih - 2 // title + header if rowsH < 1 { rowsH = 1 } top := m.versionsCursor - rowsH/2 if top < 0 { top = 0 } if top > len(m.versions)-rowsH { top = len(m.versions) - rowsH } if top < 0 { top = 0 } bottom := min(top+rowsH, len(m.versions)) rows := make([]string, 0, bottom-top) for i := top; i < bottom; i++ { v := m.versions[i] state := m.styles.SuccessText.Render(symOK + " active") if v.Destroyed { state = m.styles.ErrorText.Render(symDestroyed + " destroyed") } else if v.DeletionTime != "" { state = m.styles.WarnText.Render(symDeleted + " deleted") } marker := " " if i == 0 { marker = m.styles.SuccessText.Render(symCurrent) + " " } line := marker + padRight(fmt.Sprintf("v%d", v.Version), 9) + padRight(humanTime(v.CreatedTime), 22) + state if i == m.versionsCursor { line = m.styles.RowSelected.Render(padRight(line, iw)) } rows = append(rows, line) } body := lipgloss.JoinVertical(lipgloss.Left, title, header, lipgloss.JoinVertical(lipgloss.Left, rows...)) return renderPanel(m.styles.PanelActive, ow, oh, body) }