package ui import ( "context" "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "git.morlana.online/f.weber/vault-tui/internal/auth" "git.morlana.online/f.weber/vault-tui/internal/cli" "git.morlana.online/f.weber/vault-tui/internal/token" "git.morlana.online/f.weber/vault-tui/internal/ui/keys" "git.morlana.online/f.weber/vault-tui/internal/ui/theme" "git.morlana.online/f.weber/vault-tui/internal/vault" ) type screen int const ( scrAuth screen = iota scrMounts scrBrowser scrSecret scrEditor scrVersions ) // frame is one entry in the navigation stack. Not every field is used by // every screen kind; see the per-screen handlers in the other files in // this package. type frame struct { scr screen mount vault.Mount path string // relative path: browser's current dir, or the secret path } // confirmSpec describes a pending destructive action, rendered as a // centered overlay (see overlay.go) rather than appended below the // current screen. type confirmSpec struct { title string body string danger bool typeToConf string // if non-empty, the user must type this text to confirm input string onConfirm func() tea.Cmd } // authState holds everything the auth screen needs across its three // sub-phases (method picker -> credential form -> waiting on a // multi-step login such as OIDC or Okta). type authState struct { phase int // 0=picker 1=form 2=waiting methods []auth.Method cursor int method auth.Method mount string inputs []textInputField focus int events chan auth.Event cancel context.CancelFunc authURL string status []string err error } type textInputField struct { field auth.Field input textInput } // profileState drives the profile-switch overlay (screen_profile.go). type profileState struct { open bool names []string cursor int } type Model struct { ctx context.Context a *cli.App svc *vault.Service styles *theme.Styles keys *keys.KeyMap help help.Model spin spinner.Model lay layout loading bool toast *toast toastSeq int stack []frame auth authState list list.Model listReady bool listSelect string // remembered selected item's label, restored after a reload secret *vault.Secret secretMask map[string]bool secretKeys []string // sorted cache of secret.Data's keys, rebuilt on load secretCursor int secretVP viewport.Model clipSeq int editName textInput editKeys []textInput editVals []textInput editFocus int editIsNew bool editMount vault.Mount editPath string editSecret *vault.Secret editSnap string // serialized snapshot at open, for the unsaved-changes guard editConfirm bool // pending "discard unsaved changes?" on esc versions []vault.VersionMeta versionsCursor int confirm *confirmSpec profile profileState helpOpen bool tokenInfo *token.Info quitting bool } func newModel(ctx context.Context, a *cli.App) (*Model, error) { isDark := theme.IsDark(a.File.Theme, a.Settings.Appearance) mono := theme.NoColor(a.Settings.NoColor) styles := theme.Build(a.File.Theme, isDark, mono) km := keys.Default() if err := km.Apply(a.File.Keys); err != nil { return nil, err } if a.Settings.ReadOnly { km.DisableWrites() } sp := spinner.New(spinner.WithSpinner(spinner.MiniDot)) hp := help.New() hp.Styles = styles.HelpStyles() m := &Model{ ctx: ctx, a: a, svc: a.Service(), styles: styles, keys: km, help: hp, spin: sp, secretMask: map[string]bool{}, } return m, nil } func (m *Model) Init() tea.Cmd { return tea.Batch(m.startAuth(), m.spin.Tick, tickCmd()) } func tickCmd() tea.Cmd { return tea.Tick(30*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m *Model) top() screen { if len(m.stack) == 0 { return scrAuth } return m.stack[len(m.stack)-1].scr } func (m *Model) push(f frame) { m.stack = append(m.stack, f) } func (m *Model) pop() (frame, bool) { if len(m.stack) == 0 { return frame{}, false } f := m.stack[len(m.stack)-1] m.stack = m.stack[:len(m.stack)-1] return f, true } func (m *Model) currentFrame() frame { if len(m.stack) == 0 { return frame{scr: scrMounts} } return m.stack[len(m.stack)-1] } // overlayActive reports whether anything should render on top of the // current screen instead of routing input to it. func (m *Model) overlayActive() bool { return m.confirm != nil || m.profile.open || m.helpOpen } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.lay = computeLayout(msg.Width, msg.Height) m.help.SetWidth(m.lay.w - 2) m.resizeList() m.resizeSecretViewport() return m, nil case tea.KeyPressMsg: if key.Matches(msg, m.keys.ForceQuit) { m.quitting = true return m, tea.Quit } if m.confirm != nil { return m.updateConfirm(msg) } if m.profile.open { return m.updateProfilePicker(msg) } if m.helpOpen { return m.updateHelp(msg) } if !m.textInputActive() { if key.Matches(msg, m.keys.Help) { m.helpOpen = true return m, nil } if key.Matches(msg, m.keys.Profiles) && m.top() != scrAuth && len(m.a.File.Profiles) > 1 { return m.openProfilePicker() } if key.Matches(msg, m.keys.Logout) && m.top() != scrAuth { return m.confirmLogout() } } case tickMsg: cmds := []tea.Cmd{tickCmd()} if m.a.Client.Token() != "" { cmds = append(cmds, m.refreshTokenInfo()) } return m, tea.Batch(cmds...) case tokenInfoMsg: if msg.err == nil { m.tokenInfo = msg.info } return m, nil case toastExpireMsg: m.clearExpiredToast(msg) return m, nil case logoutDoneMsg: if msg.err != nil { return m, m.notify(toastErr, "logout: %v", msg.err) } m.a.Client.ClearToken() m.tokenInfo = nil return m, tea.Batch(m.notify(toastSuccess, "logged out"), m.startAuth()) case errMsg: m.loading = false return m, m.notify(toastErr, "%v", msg.err) case statusMsg: return m, m.notify(toastInfo, "%s", msg.text) case spinner.TickMsg: var cmd tea.Cmd m.spin, cmd = m.spin.Update(msg) return m, cmd } switch m.top() { case scrAuth: return m.updateAuth(msg) case scrMounts, scrBrowser: return m.updateBrowser(msg) case scrSecret: return m.updateSecret(msg) case scrEditor: return m.updateEditor(msg) case scrVersions: return m.updateVersions(msg) } return m, nil } func (m *Model) View() tea.View { if m.lay.tooSmall { v := tea.NewView(m.viewTooSmall()) v.AltScreen = true v.WindowTitle = "vault-tui" return v } var body string var sh screenHelp switch m.top() { case scrAuth: body, sh = m.viewAuth(), m.keys.AuthHelp() case scrMounts, scrBrowser: body, sh = m.viewBrowser(), m.keys.BrowserHelp() case scrSecret: body, sh = m.viewSecret(), m.keys.SecretHelp() case scrEditor: body, sh = m.viewEditor(), m.keys.EditorHelp() case scrVersions: body, sh = m.viewVersions(), m.keys.VersionsHelp() } content := lipgloss.JoinVertical(lipgloss.Left, m.viewHeader(), fit(body, m.lay.w, m.lay.bodyH), m.viewFooter(sh)) if m.confirm != nil { content = m.overlay(content, m.viewConfirm()) } if m.profile.open { content = m.overlay(content, m.viewProfilePicker()) } if m.helpOpen { content = m.overlay(content, m.viewHelp()) } v := tea.NewView(content) v.AltScreen = true v.WindowTitle = "vault-tui — " + m.a.Settings.Profile return v } func (m *Model) refreshTokenInfo() tea.Cmd { return func() tea.Msg { info, err := token.Lookup(m.ctx, m.a.Client) return tokenInfoMsg{info: info, err: err} } }