package ui import ( "fmt" "sort" "strings" "time" "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) loadSecret(mount vault.Mount, path string, version int) tea.Cmd { m.loading = true return func() tea.Msg { sec, err := m.svc.KV.Read(m.ctx, mount, path, version) return secretMsg{secret: sec, mount: mount, path: path, err: err} } } func sortedKeys(data map[string]interface{}) []string { keys := make([]string, 0, len(data)) for k := range data { keys = append(keys, k) } sort.Strings(keys) return keys } func (m *Model) resizeSecretViewport() { if m.secret == nil { return } iw, ih := panelInner(m.lay.bodyW, m.lay.bodyH) ih -= 2 // title + metadata line if ih < 1 { ih = 1 } m.secretVP.SetWidth(iw) m.secretVP.SetHeight(ih) } func (m *Model) updateSecret(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case secretMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } if msg.secret == nil { m.pop() return m, m.notify(toastWarn, "%s%s: secret not found (already deleted?)", msg.mount.Path, msg.path) } m.secret = msg.secret m.secretKeys = sortedKeys(msg.secret.Data) m.secretMask = make(map[string]bool, len(m.secretKeys)) for _, k := range m.secretKeys { m.secretMask[k] = !m.a.Settings.MaskValues } if m.secretCursor >= len(m.secretKeys) { m.secretCursor = 0 } m.resizeSecretViewport() return m, nil case deleteAckMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } m.secret = nil _, popCmd := m.popBrowser() return m, tea.Batch(m.notify(toastSuccess, "%v: %s", msg.ack.Op, msg.ack.Path), popCmd) case clipboardClearMsg: if msg.seq == m.clipSeq { return m, tea.SetClipboard("") } return m, nil case tea.KeyPressMsg: switch { case key.Matches(msg, m.keys.Back): m.secret = nil return m.popBrowser() case key.Matches(msg, m.keys.Quit): // Safe to bind bare "q" here (unlike the editor/auth-form // screens): the secret detail screen owns no text input. m.quitting = true return m, tea.Quit case key.Matches(msg, m.keys.Up): if m.secretCursor > 0 { m.secretCursor-- } return m, nil case key.Matches(msg, m.keys.Down): if m.secretCursor < len(m.secretKeys)-1 { m.secretCursor++ } return m, nil case key.Matches(msg, m.keys.ToggleMask): m.toggleRowMask() return m, nil case key.Matches(msg, m.keys.ToggleMaskAll): m.toggleAllMask() return m, nil case key.Matches(msg, m.keys.Versions): return m.openVersions() case key.Matches(msg, m.keys.Edit): if !m.a.Settings.ReadOnly { return m.editSelected() } case key.Matches(msg, m.keys.Delete): if !m.a.Settings.ReadOnly { return m.confirmDelete(false) } case key.Matches(msg, m.keys.Destroy): if !m.a.Settings.ReadOnly { return m.confirmDelete(true) } case key.Matches(msg, m.keys.CopyValue): return m, m.copySelectedValue() } } return m, nil } func (m *Model) toggleRowMask() { if len(m.secretKeys) == 0 { return } k := m.secretKeys[m.secretCursor] m.secretMask[k] = !m.secretMask[k] } func (m *Model) toggleAllMask() { any := false for _, k := range m.secretKeys { if m.secretMask[k] { any = true break } } for _, k := range m.secretKeys { m.secretMask[k] = !any } } func (m *Model) copySelectedValue() tea.Cmd { if m.secret == nil || len(m.secretKeys) == 0 { return nil } k := m.secretKeys[m.secretCursor] v := fmt.Sprint(m.secret.Data[k]) cmds := []tea.Cmd{tea.SetClipboard(v), m.notify(toastSuccess, "copied %s", k)} if d := m.a.Settings.ClipboardClear; d > 0 { m.clipSeq++ seq := m.clipSeq cmds = append(cmds, tea.Tick(d, func(time.Time) tea.Msg { return clipboardClearMsg{seq: seq} })) } return tea.Batch(cmds...) } // confirmTypedName decides whether a destructive confirmation requires the // user to type the path back (irreversible actions) or just y/enter. func confirmTypedName(danger bool, path string) string { if danger { return path } return "" } func (m *Model) confirmDelete(destroy bool) (tea.Model, tea.Cmd) { f := m.currentFrame() mount, path := f.mount, f.path op := vault.OpSoftDelete danger := destroy title := "Delete " + path + "?" body := "This soft-deletes the current version. It can be undeleted." var versions []int // nil => current version, for the ops that support that shorthand if mount.Kind != vault.EngineKVv2 { op = vault.OpDeleteV1 danger = true body = "This permanently deletes the secret. This cannot be undone." } else if destroy { op = vault.OpDestroy title = "Destroy " + path + "?" body = "This permanently destroys the current version's data. This cannot be undone." // Unlike soft-delete, Vault's destroy endpoint has no "current // version" shorthand — it always requires explicit version // numbers — so the version being viewed must be passed along. if m.secret != nil { versions = []int{m.secret.Version} } } action := func() tea.Cmd { m.loading = true return func() tea.Msg { ack, err := m.svc.KV.Delete(m.ctx, mount, path, op, versions) return deleteAckMsg{ack: ack, err: err} } } if !m.a.Settings.ConfirmDestructive { return m, action() } m.confirm = &confirmSpec{ title: title, body: body, danger: danger, typeToConf: confirmTypedName(danger, path), onConfirm: action, } return m, nil } // secretMetaLine renders the version/status line shown between the title // and the key/value table. func (m *Model) secretMetaLine() string { if m.secret == nil || m.secret.Meta == nil { return "" } meta := m.secret.Meta parts := []string{fmt.Sprintf("version %d", m.secret.Version), "created " + humanTime(meta.CreatedTime)} if meta.Destroyed { parts = append(parts, m.styles.ErrorText.Render(symDestroyed+" destroyed")) } else if meta.DeletionTime != "" { parts = append(parts, m.styles.WarnText.Render(symDeleted+" deleted")) } return m.styles.PanelSubtle.Render(strings.Join(parts, " • ")) } // secretRows renders the key/value table fed to secretVP, one row per // sorted key with the selected row highlighted full-width. func (m *Model) secretRows(width int) string { if len(m.secretKeys) == 0 { return m.styles.EmptyState.Render("(no data)") } keyW := 0 for _, k := range m.secretKeys { if w := lipgloss.Width(k); w > keyW { keyW = w } } if keyW > width/3 { keyW = width / 3 } if keyW < 4 { keyW = 4 } valW := width - keyW - 2 if valW < 4 { valW = 4 } lines := make([]string, len(m.secretKeys)) for i, k := range m.secretKeys { v := fmt.Sprint(m.secret.Data[k]) if !m.secretMask[k] { v = maskValue(v, m.styles.MaskChar) } row := padRight(truncate(k, keyW), keyW) + " " + truncate(v, valW) if i == m.secretCursor { lines[i] = m.styles.RowSelected.Render(padRight(row, width)) } else { lines[i] = m.styles.KeyCell.Render(padRight(truncate(k, keyW), keyW)) + " " + m.styles.ValueCell.Render(truncate(v, valW)) } } return strings.Join(lines, "\n") } func (m *Model) viewSecret() string { f := m.currentFrame() ow, oh := m.lay.bodyW, m.lay.bodyH title := m.styles.PanelTitle.Render(f.mount.Path + f.path) if m.loading || m.secret == nil { body := lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…") return renderPanel(m.styles.PanelActive, ow, oh, body) } iw, _ := panelInner(ow, oh) m.resizeSecretViewport() m.secretVP.SetContent(m.secretRows(iw)) m.secretVP.EnsureVisible(m.secretCursor, 0, 0) lines := []string{title} if meta := m.secretMetaLine(); meta != "" { lines = append(lines, meta) } lines = append(lines, m.secretVP.View()) return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, lines...)) }