package ui import ( "fmt" "sort" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "git.morlana.online/f.weber/vault-tui/internal/vault" ) func (m *Model) loadMounts() tea.Cmd { m.loading = true return func() tea.Msg { mounts, err := m.svc.Mounts(m.ctx) sort.Slice(mounts, func(i, j int) bool { return mounts[i].Path < mounts[j].Path }) return mountsMsg{mounts: mounts, err: err} } } func (m *Model) loadListing(mount vault.Mount, path string) tea.Cmd { m.loading = true return func() tea.Msg { l, err := m.svc.KV.List(m.ctx, mount, path) l.Path = path return listMsg{listing: l, mount: mount, path: path, err: err} } } // listPaneOuter is the outer (border-inclusive) size of the panel the list // renders in: the full body on sizeTiny/sizeCompact, or the left column // once a side detail panel appears at sizeWide. func (m *Model) listPaneOuter() (w, h int) { if m.lay.class == sizeWide { return m.lay.mainW, m.lay.bodyH } return m.lay.bodyW, m.lay.bodyH } // resizeList re-applies the current layout's dimensions to the list — the // single place list.SetSize is called, so every WindowSizeMsg and every // list (re)build stays in sync with the same math. func (m *Model) resizeList() { if !m.listReady { return } ow, oh := m.listPaneOuter() iw, ih := panelInner(ow, oh) ih-- // panel title line if ih < 3 { ih = 3 } if iw < 10 { iw = 10 } m.list.SetSize(iw, ih) } // setListItems fills the list with items, building it on first use and // otherwise updating in place — SetItems (not a fresh list.New) is what // lets a refresh survive with the active filter and, via listSelect, // the selected row intact. func (m *Model) setListItems(items []entryItem) tea.Cmd { litems := make([]list.Item, len(items)) for i, it := range items { litems[i] = it } if !m.listReady { ow, oh := m.listPaneOuter() iw, ih := panelInner(ow, oh) ih-- if ih < 3 { ih = 3 } if iw < 10 { iw = 10 } m.list = list.New(litems, entryDelegate{styles: m.styles}, iw, ih) m.list.SetShowStatusBar(false) m.list.SetShowTitle(false) m.list.SetShowHelp(false) m.list.DisableQuitKeybindings() m.list.Styles = m.styles.ListStyles() m.listReady = true return nil } cmd := m.list.SetItems(litems) restored := false if m.listSelect != "" { for i, it := range items { if it.label == m.listSelect { m.list.Select(i) restored = true break } } } if !restored { // SetItems doesn't touch the cursor, so switching to a shorter list // (e.g. drilling into a directory) can otherwise leave it pointing // past the end, and SelectedItem starts returning nil. m.list.Select(0) } m.resizeList() return cmd } func (m *Model) updateBrowser(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case mountsMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } cmd := m.setListItems(mountItems(msg.mounts)) return m, cmd case listMsg: m.loading = false if msg.err != nil { return m, func() tea.Msg { return errMsg{err: msg.err} } } cmd := m.setListItems(listingItems(msg.mount, msg.listing)) return m, cmd case tea.KeyPressMsg: // While the list's own filter editor has focus, every keystroke is // text input for the filter — none of our single-letter global // bindings (h for Back, l for Enter, ...) may intercept it, or // typing e.g. "testpath" would pop the screen on its embedded "h". if !m.listReady || m.list.SettingFilter() { break } switch { case key.Matches(msg, m.keys.Back): return m.popBrowser() case key.Matches(msg, m.keys.Refresh): return m, m.reloadTop() case key.Matches(msg, m.keys.Enter): return m.openSelected() case key.Matches(msg, m.keys.New): if !m.a.Settings.ReadOnly && m.top() == scrBrowser { return m.newSecret() } return m, nil case key.Matches(msg, m.keys.Quit): m.quitting = true return m, tea.Quit } } if !m.listReady { return m, nil } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) if it, ok := m.list.SelectedItem().(entryItem); ok { m.listSelect = it.label } return m, cmd } func (m *Model) reloadTop() tea.Cmd { f := m.currentFrame() switch f.scr { case scrMounts: return m.loadMounts() case scrBrowser: return m.loadListing(f.mount, f.path) } return nil } func (m *Model) popBrowser() (tea.Model, tea.Cmd) { if len(m.stack) <= 1 { return m, nil } m.pop() m.listSelect = "" return m, m.reloadTop() } func (m *Model) openSelected() (tea.Model, tea.Cmd) { sel, ok := m.list.SelectedItem().(entryItem) if !ok { return m, nil } if sel.unsupported { return m, m.notify(toastWarn, "%s (%s) is not a supported engine", sel.label, sel.mount.Type) } m.listSelect = "" if sel.isMount { m.push(frame{scr: scrBrowser, mount: sel.mount, path: ""}) return m, m.loadListing(sel.mount, "") } if sel.isDir { m.push(frame{scr: scrBrowser, mount: sel.mount, path: sel.relPath}) return m, m.loadListing(sel.mount, sel.relPath) } m.push(frame{scr: scrSecret, mount: sel.mount, path: sel.relPath}) return m, m.loadSecret(sel.mount, sel.relPath, 0) } // detailPreview is the sizeWide side panel's content: whatever we already // know about the highlighted row, without issuing another Vault read. func (m *Model) detailPreview() string { sel, ok := m.list.SelectedItem().(entryItem) if !ok { return m.styles.EmptyState.Render("nothing selected") } var lines []string switch { case sel.isMount: lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path)) lines = append(lines, m.styles.KeyCell.Render("engine")+" "+m.styles.ValueCell.Render(engineLabel(sel.mount))) if sel.mount.Description != "" { lines = append(lines, "", sel.mount.Description) } if sel.mount.Local { lines = append(lines, "", m.styles.Badge.Render("local")) } if sel.unsupported { lines = append(lines, "", m.styles.WarnText.Render(symWarn+" unsupported engine: "+sel.mount.Type)) } case sel.isDir: lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path+sel.relPath)) lines = append(lines, m.styles.ValueCell.Render("directory — press enter to browse")) default: lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path+sel.relPath)) lines = append(lines, m.styles.ValueCell.Render("secret — press enter to open")) } return lipgloss.JoinVertical(lipgloss.Left, lines...) } func emptyBrowserMessage(f frame, readOnly bool) string { if f.scr == scrMounts { return "no mounts visible to this token" } if readOnly { return "nothing here yet" } return "nothing here yet — press n to create a secret" } func (m *Model) viewBrowser() string { f := m.currentFrame() title := "Mounts" if f.scr == scrBrowser { title = f.mount.Path + f.path } count := 0 if m.listReady { count = len(m.list.Items()) } header := m.styles.PanelTitle.Render(title) + " " + m.styles.PanelSubtle.Render(fmt.Sprintf("%d item(s)", count)) var body string switch { case m.loading || !m.listReady: body = m.spin.View() + " loading…" case count == 0: body = m.styles.EmptyState.Render(emptyBrowserMessage(f, m.a.Settings.ReadOnly)) default: body = m.list.View() } mainContent := lipgloss.JoinVertical(lipgloss.Left, header, body) ow, oh := m.listPaneOuter() panel := renderPanel(m.styles.PanelActive, ow, oh, mainContent) if m.lay.class != sizeWide || !m.listReady || count == 0 { return panel } side := renderPanel(m.styles.SidePanel, m.lay.sideW, m.lay.bodyH, m.detailPreview()) return lipgloss.JoinHorizontal(lipgloss.Top, panel, " ", side) }