feat: a status pane, like lazygit's: version, updates, accounts, files and links
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1c8d69d957
commit
036f32d9ac
16 files changed
+379
-90
No files matched your search
+67
-25
@@ -18,7 +18,6 @@ import (
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/clipboard"
|
||||
@@ -40,6 +39,9 @@ const (
|
||||
paneContainers
|
||||
paneSecrets
|
||||
paneDetail
|
||||
// paneStatus is on top of the left panes; after the right pane in
|
||||
// the tab order (status.go).
|
||||
paneStatus
|
||||
paneCount
|
||||
)
|
||||
|
||||
@@ -69,6 +71,10 @@ type Options struct {
|
||||
ClipboardClear time.Duration
|
||||
// OpenURL opens a web page; defaults to the system browser.
|
||||
OpenURL func(url string) error
|
||||
// ConfigFiles and StatePath are where lazysecrets reads its config
|
||||
// and keeps its state, for the status pane.
|
||||
ConfigFiles []string
|
||||
StatePath string
|
||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||
// run only.
|
||||
CacheFile string
|
||||
@@ -174,11 +180,15 @@ type Model struct {
|
||||
now func() time.Time
|
||||
// The cache file, whether the cache changed since it was saved, and
|
||||
// when each provider's Sources list was loaded (persist.go).
|
||||
cachePath string
|
||||
cacheDirty bool
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
cachePath string
|
||||
// configFiles and statePath are for the status pane (status.go).
|
||||
configFiles []string
|
||||
statePath string
|
||||
statusW int // the status pane's width: the left column's, zoomed or not
|
||||
cacheDirty bool
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
|
||||
// A pending clear of a copied value, counted down in the footer.
|
||||
// clipToken identifies the latest copy so older ticks are ignored.
|
||||
@@ -438,6 +448,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.spinning = true
|
||||
|
||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
||||
m.configFiles, m.statePath = opts.ConfigFiles, opts.StatePath
|
||||
if m.cachePath != "" {
|
||||
m.loadCacheFile()
|
||||
if len(m.sourceResults) > 0 {
|
||||
@@ -802,7 +813,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
case key.Matches(msg, k.Bottom):
|
||||
m.jump(false)
|
||||
case key.Matches(msg, k.NextPane):
|
||||
m.focus = (m.focus + 1) % paneCount
|
||||
// The four main panes, round; the status pane (0) leads into them.
|
||||
m.focus = (m.focus + 1) % paneStatus
|
||||
if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
|
||||
m.focus = paneDetail // an account's row: nothing between
|
||||
}
|
||||
@@ -810,7 +822,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
if m.focus == paneDetail {
|
||||
m.focus = m.lastLeft // back to what it describes
|
||||
} else {
|
||||
m.focus = (m.focus + paneCount - 1) % paneCount
|
||||
m.focus = (m.focus + paneStatus - 1) % paneStatus
|
||||
}
|
||||
case key.Matches(msg, k.Pane1):
|
||||
m.focus = paneSources
|
||||
@@ -820,6 +832,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
m.focus = paneSecrets
|
||||
case key.Matches(msg, k.Pane4):
|
||||
m.focus = paneDetail
|
||||
case key.Matches(msg, k.Pane0):
|
||||
m.focus = paneStatus
|
||||
case m.focus == paneStatus && key.Matches(msg, k.Enter):
|
||||
m.focus = paneDetail // its rows are on the right
|
||||
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
|
||||
// folded or unfolded a provider group
|
||||
case key.Matches(msg, k.Enter):
|
||||
@@ -877,8 +893,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
case key.Matches(msg, k.Back):
|
||||
if f := m.focusedFilter(); f != nil && f.HasFilter() {
|
||||
f.ClearFilter()
|
||||
} else if m.versionsOf != nil && m.focus >= paneSecrets {
|
||||
} else if m.versionsOf != nil && (m.focus == paneSecrets || m.focus == paneDetail) {
|
||||
m.closeVersions()
|
||||
} else if m.focus == paneStatus {
|
||||
m.focus = paneSources
|
||||
} else if m.focus > paneSources {
|
||||
m.focus--
|
||||
}
|
||||
@@ -1484,14 +1502,17 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
||||
// copyValue copies the selected object's value, fetching it without
|
||||
// revealing it if it isn't shown already.
|
||||
func (m *Model) copyValue() tea.Cmd {
|
||||
if m.focus == paneDetail && m.fields.Shown() {
|
||||
if f, ok := m.fields.Selected(); ok {
|
||||
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
|
||||
return m.copyText(a.CopyLabel, a.Copy, false)
|
||||
}
|
||||
o, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
|
||||
return m.copyText(a.CopyLabel, a.Copy, false)
|
||||
}
|
||||
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() {
|
||||
return m.copyText(fieldName(f), f.Value, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if v, ok := m.detail.Value(); ok {
|
||||
@@ -1544,6 +1565,25 @@ func (m *Model) cancelClipClear() {
|
||||
func (m *Model) openSelected() tea.Cmd {
|
||||
var name, url string
|
||||
var used func()
|
||||
switch {
|
||||
case m.focus == paneDetail && m.fields.Shown():
|
||||
f, _ := m.fields.Selected()
|
||||
if f.URL == "" {
|
||||
m.flash = warning("nothing to open in the browser here")
|
||||
return nil
|
||||
}
|
||||
name, url, used = f.Label, f.URL, func() {}
|
||||
default:
|
||||
name, url, used = m.openTarget()
|
||||
}
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
return m.openPage(name, url, used)
|
||||
}
|
||||
|
||||
// openTarget is the focused pane's selected item, for the browser.
|
||||
func (m *Model) openTarget() (name, url string, used func()) {
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
s, _ := m.selectedSource()
|
||||
@@ -1555,9 +1595,11 @@ func (m *Model) openSelected() tea.Cmd {
|
||||
o, _ := m.detail.Secret()
|
||||
name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
|
||||
}
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
return name, url, used
|
||||
}
|
||||
|
||||
// openPage opens url in the browser, or copies it over SSH.
|
||||
func (m *Model) openPage(name, url string, used func()) tea.Cmd {
|
||||
if url == "" {
|
||||
m.flash = warning("no web page for " + name)
|
||||
return nil
|
||||
@@ -1617,7 +1659,7 @@ func (m Model) footerView() string {
|
||||
} else if l != nil && l.Filtering() {
|
||||
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
||||
} else {
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
|
||||
}
|
||||
var lines []string
|
||||
if !m.clipDeadline.IsZero() {
|
||||
@@ -1627,12 +1669,6 @@ func (m Model) footerView() string {
|
||||
if m.flash.text != "" {
|
||||
lines = append(lines, m.flashLine(m.flash))
|
||||
}
|
||||
// The version at the right end, one column in, like lazygit.
|
||||
if v := m.versionLabel(); v != "" {
|
||||
up := m.updateLabel() // a newer release, in colour
|
||||
gap := max(1, m.w-1-ansi.StringWidth(help)-ansi.StringWidth(v+up)-1)
|
||||
help += strings.Repeat(" ", gap) + m.st.Muted.Render(v) + m.st.Notice.Render(up) + " "
|
||||
}
|
||||
return strings.Join(append(lines, " "+help), "\n")
|
||||
}
|
||||
|
||||
@@ -1702,6 +1738,12 @@ func (m Model) problemText(providerID, err string) string {
|
||||
// loginFor is the sign-in L runs: the selected account's, if it needs
|
||||
// one, else the last one asked for.
|
||||
func (m Model) loginFor() *provider.AuthError {
|
||||
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() && f.Provider != "" {
|
||||
var auth *provider.AuthError
|
||||
if res := m.sourceResults[f.Provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
|
||||
return auth
|
||||
}
|
||||
}
|
||||
if r, ok := m.sources.Selected(); ok {
|
||||
var auth *provider.AuthError
|
||||
if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
|
||||
|
||||
Reference in new issue
Block a user