package ui import ( "fmt" "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "git.morlana.online/f.weber/vault-tui/internal/vault" ) // --- editor: create/update a KV secret ----------------------------------- func (m *Model) newSecret() (tea.Model, tea.Cmd) { f := m.currentFrame() m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, true, nil m.editName = m.newTextInput(fieldSpec{label: "secret name"}) cmd := m.editName.Focus() m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})} m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})} m.editFocus = -1 // -1 == the name field m.editSnap = m.editSnapshot() m.push(frame{scr: scrEditor, mount: f.mount, path: f.path}) return m, cmd } func (m *Model) editSelected() (tea.Model, tea.Cmd) { if m.secret == nil { return m, nil } f := m.currentFrame() m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, false, m.secret m.editKeys, m.editVals = nil, nil for _, k := range sortedKeys(m.secret.Data) { m.editKeys = append(m.editKeys, m.textInputWithValue("key", k)) m.editVals = append(m.editVals, m.textInputWithValue("value", fmt.Sprint(m.secret.Data[k]))) } if len(m.editKeys) == 0 { m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})} m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})} } m.editFocus = 0 cmd := m.editKeys[0].Focus() m.editSnap = m.editSnapshot() m.push(frame{scr: scrEditor, mount: f.mount, path: f.path}) return m, cmd } // editSnapshot serializes the editor's current field values so esc can // tell whether anything actually changed before asking to discard. func (m *Model) editSnapshot() string { var b strings.Builder b.WriteString(m.editName.Value()) for i := range m.editKeys { b.WriteByte(0) b.WriteString(m.editKeys[i].Value()) b.WriteByte('=') b.WriteString(m.editVals[i].Value()) } return b.String() } func (m *Model) updateEditor(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case writeAckMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } toastCmd := m.notify(toastSuccess, "saved (version %d)", msg.ack.Version) savedPath := msg.ack.Path m.pop() if m.top() == scrSecret { return m, tea.Batch(toastCmd, m.loadSecret(m.editMount, savedPath, 0)) } return m, tea.Batch(toastCmd, m.reloadTop()) case tea.KeyPressMsg: switch msg.String() { case "esc": if m.editSnapshot() != m.editSnap { m.confirm = &confirmSpec{ title: "Discard changes?", body: "Unsaved edits to this secret will be lost.", danger: true, onConfirm: func() tea.Cmd { m.pop() return nil }, } return m, nil } m.pop() return m, nil case "ctrl+a": m.editKeys = append(m.editKeys, m.newTextInput(fieldSpec{label: "key"})) m.editVals = append(m.editVals, m.newTextInput(fieldSpec{label: "value"})) return m, nil case "ctrl+x": return m, m.removeCurrentField() case "ctrl+s": return m.saveEditor() case "tab": return m, m.moveEditFocus(1) case "shift+tab": return m, m.moveEditFocus(-1) } return m.updateEditorInput(msg) } return m.updateEditorInput(msg) } // focus encoding: -1 = name field (new-secret only), 2*i = key[i], 2*i+1 = val[i] func (m *Model) moveEditFocus(delta int) tea.Cmd { maxIdx := len(m.editKeys)*2 - 1 m.blurEdit() if m.editIsNew { m.editFocus += delta if m.editFocus < -1 { m.editFocus = maxIdx } else if m.editFocus > maxIdx { m.editFocus = -1 } } else { if maxIdx < 0 { return nil } m.editFocus = (m.editFocus + delta + maxIdx + 1) % (maxIdx + 1) } return m.focusEdit() } func (m *Model) removeCurrentField() tea.Cmd { if m.editFocus < 0 || len(m.editKeys) == 0 { return nil } i := m.editFocus / 2 if len(m.editKeys) <= 1 { m.editKeys[0] = m.newTextInput(fieldSpec{label: "key"}) m.editVals[0] = m.newTextInput(fieldSpec{label: "value"}) m.editFocus = 0 return m.editKeys[0].Focus() } m.editKeys = append(m.editKeys[:i], m.editKeys[i+1:]...) m.editVals = append(m.editVals[:i], m.editVals[i+1:]...) if m.editFocus >= len(m.editKeys)*2 { m.editFocus = len(m.editKeys)*2 - 1 } return m.focusEdit() } func (m *Model) blurEdit() { if m.editFocus == -1 { m.editName.Blur() return } i, isKey := m.editFocus/2, m.editFocus%2 == 0 if i >= len(m.editKeys) { return } if isKey { m.editKeys[i].Blur() } else { m.editVals[i].Blur() } } func (m *Model) focusEdit() tea.Cmd { if m.editFocus == -1 { return m.editName.Focus() } i, isKey := m.editFocus/2, m.editFocus%2 == 0 if i >= len(m.editKeys) { return nil } if isKey { return m.editKeys[i].Focus() } return m.editVals[i].Focus() } // updateEditorInput forwards msg to whichever field is focused — every // message type, not just key presses, so a textinput's own async commands // (cursor blink, paste) actually reach it instead of being dropped by the // screen dispatch above. func (m *Model) updateEditorInput(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if m.editFocus == -1 { m.editName, cmd = m.editName.Update(msg) return m, cmd } if len(m.editKeys) == 0 { return m, nil } i, isKey := m.editFocus/2, m.editFocus%2 == 0 if i >= len(m.editKeys) { return m, nil } if isKey { m.editKeys[i], cmd = m.editKeys[i].Update(msg) } else { m.editVals[i], cmd = m.editVals[i].Update(msg) } return m, cmd } func (m *Model) saveEditor() (tea.Model, tea.Cmd) { data := map[string]interface{}{} for i := range m.editKeys { k := m.editKeys[i].Value() if k == "" { continue } data[k] = m.editVals[i].Value() } path := m.editPath if m.editIsNew { name := m.editName.Value() if name == "" { return m, m.notify(toastWarn, "secret name is required") } path = m.editPath + name } useCAS := m.a.Settings.RequireCAS && m.editMount.Kind == vault.EngineKVv2 cas := 0 if m.editSecret != nil { cas = m.editSecret.Version } m.loading = true mount := m.editMount // Deliberately NOT mutating m.editPath here: on a failed write (CAS // mismatch, permission denied, ...) the editor screen stays open so the // user can retry, and a premature mutation would double-concatenate the // name on a second "new secret" save attempt. writeAckMsg's handler uses // ack.Path — the path Write actually succeeded at — instead. return m, func() tea.Msg { ack, err := m.svc.KV.Write(m.ctx, mount, path, data, useCAS, cas) return writeAckMsg{ack: ack, err: err} } } // editFieldWidths splits the panel's inner width into key/value columns — // side by side from sizeCompact up, stacked on sizeTiny. func (m *Model) editFieldWidths(iw int) (keyColW, valColW int) { if m.lay.class == sizeTiny { return iw, iw } keyColW = iw / 3 if keyColW < 16 { keyColW = 16 } valColW = iw - keyColW - 2 if valColW < 12 { valColW = 12 } return } func (m *Model) viewEditor() string { ow, oh := m.lay.bodyW, m.lay.bodyH iw, _ := panelInner(ow, oh) tiny := m.lay.class == sizeTiny keyColW, valColW := m.editFieldWidths(iw) rows := []string{m.styles.PanelTitle.Render("Edit " + m.editMount.Path + m.editPath), ""} if m.editIsNew { label := " name" if m.editFocus == -1 { label = m.styles.CrumbActive.Render(symChevronR + " name") } m.editName.SetWidth(max(iw-2, 8)) rows = append(rows, label, " "+m.editName.View(), "") } const lbl = 8 // widest label ("▸ value") is 7 cells; +1 keeps a gap before the input's own prompt for i := range m.editKeys { kFocused, vFocused := m.editFocus == 2*i, m.editFocus == 2*i+1 kLabel, vLabel := padRight("key", lbl), padRight("value", lbl) if kFocused { kLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" key", lbl)) } if vFocused { vLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" value", lbl)) } m.editKeys[i].SetWidth(max(keyColW-lbl-1, 6)) m.editVals[i].SetWidth(max(valColW-lbl-1, 6)) keyCell := kLabel + m.editKeys[i].View() valCell := vLabel + m.editVals[i].View() if tiny { rows = append(rows, keyCell, valCell, "") } else { rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, padRight(keyCell, keyColW+lbl+2), valCell)) } } if !tiny { rows = append(rows, "") } return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, rows...)) }