Files
simplegit/cmd/gitctl/model.go
T
2026-07-29 06:51:43 -04:00

718 lines
15 KiB
Go

package gitctl
import (
"context"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
v1 "simplegit/gitrpc/v1"
)
type screen int
const (
scrStatus screen = iota
scrNS
scrRepos
scrAccess
)
var screenTabs = []struct {
n int
s screen
nm string
}{
{1, scrStatus, "Status"},
{2, scrNS, "NS"},
{3, scrRepos, "Repos"},
{4, scrAccess, "Access"},
}
type overlay int
const (
ovList overlay = iota
ovDetail
ovForm
ovConfirm
ovRunning
ovResult
)
type resultMsg struct {
summary string
err error
}
type readyMsg struct {
pingOK bool
status *v1.StatusResponse
statusErr error
db *dbReader
}
type reposLoadedMsg struct {
repos []repoRow
err error
}
type nsLoadedMsg struct {
users []nsRow
err error
}
type sshKeysLoadedMsg struct {
keys []sshKeyRow
err error
}
type localKeysLoadedMsg struct {
keys []localKey
err error
}
type grantsLoadedMsg struct {
grants []grantRow
err error
}
type detailGrantsLoadedMsg struct {
grants []grantRow
err error
}
type statusRefreshMsg struct {
status *v1.StatusResponse
err error
}
type opRequest struct {
a action
prefill map[string]string
focus int
ret string
}
type opRequestMsg struct{ req opRequest }
type detailBackMsg struct{}
func emitOp(req opRequest) tea.Cmd {
return func() tea.Msg { return opRequestMsg{req: req} }
}
type model struct {
manage *manageClient
target string
width, height int
ready bool
pingOK bool
status *v1.StatusResponse
statusErr error
db *dbReader
screen screen
overlay overlay
list list.Model
listLoading bool
listErr string
repos []repoRow
users []nsRow
sshKeys []sshKeyRow
localKeys []localKey
grants []grantRow
detail detailModel
form formModel
pending action
pendingVals map[string]string
pendingPerm string
pendingReturn string
preOverlay overlay
result resultMsg
}
func newModel(c *manageClient, target string) model {
m := model{manage: c, target: target, screen: scrStatus, overlay: ovList, listLoading: true}
m.list = newListModel(nil, 76, 16)
return m
}
func (m model) Init() tea.Cmd {
mg := m.manage
return func() tea.Msg {
ok := mg.ping()
st, err := mg.Status(context.Background())
return readyMsg{pingOK: ok, status: st, statusErr: err, db: newDBReader(mg)}
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
switch m.overlay {
case ovList:
m.list.SetSize(listWidth(m.width), listHeight(m.height))
case ovDetail:
m.detail = m.detail.resize(listWidth(m.width), m.height)
case ovForm:
f, _ := m.form.Update(msg)
m.form = f
}
return m, nil
case readyMsg:
m.ready = true
m.pingOK = msg.pingOK
m.status = msg.status
m.statusErr = msg.statusErr
m.db = msg.db
m.listLoading = true
return m, m.loadScreenCmd(m.screen)
case reposLoadedMsg:
m.listLoading = false
if msg.err != nil {
m.listErr = errText(msg.err)
m.repos = nil
} else {
m.listErr = ""
m.repos = msg.repos
}
m.list = newListModel(repoItems(m.repos), listWidth(m.width), listHeight(m.height))
return m, nil
case nsLoadedMsg:
m.listLoading = false
if msg.err != nil {
m.listErr = errText(msg.err)
m.users = nil
} else {
m.listErr = ""
m.users = msg.users
}
m.list = newListModel(nsItems(m.users), listWidth(m.width), listHeight(m.height))
return m, nil
case sshKeysLoadedMsg:
m.listLoading = false
if msg.err != nil {
m.listErr = errText(msg.err)
m.sshKeys = nil
} else {
m.listErr = ""
m.sshKeys = msg.keys
}
m.list = newListModel(sshKeyItems(m.sshKeys), listWidth(m.width), listHeight(m.height))
return m, nil
case localKeysLoadedMsg:
m.listLoading = false
if msg.err != nil {
m.listErr = errText(msg.err)
m.localKeys = nil
} else {
m.listErr = ""
m.localKeys = msg.keys
}
m.list = newListModel(localKeyItems(m.localKeys), listWidth(m.width), listHeight(m.height))
return m, nil
case grantsLoadedMsg:
m.listLoading = false
if msg.err != nil {
m.listErr = errText(msg.err)
m.grants = nil
} else {
m.listErr = ""
m.grants = msg.grants
}
m.list = newListModel(grantItems(m.grants), listWidth(m.width), listHeight(m.height))
return m, nil
case detailGrantsLoadedMsg:
errStr := ""
if msg.err != nil {
errStr = errText(msg.err)
}
m.detail = m.detail.withGrants(msg.grants, errStr)
return m, nil
case statusRefreshMsg:
m.statusErr = msg.err
if msg.err == nil {
m.status = msg.status
}
return m, nil
case opRequestMsg:
return m.applyOp(msg.req)
case submitMsg:
m.pending = msg.action
m.pendingVals = msg.values
m.pendingPerm = msg.perm
if msg.action.destructive {
m.overlay = ovConfirm
return m, nil
}
m.overlay = ovRunning
return m, m.executeCmd()
case backMsg:
m.overlay = m.cancelReturn()
return m, nil
case detailBackMsg:
m.overlay = ovList
return m, m.loadScreenCmd(m.screen)
case resultMsg:
m.result = msg
if msg.err == nil && m.detail.kind == detailNS {
switch m.pending.label {
case "Change password":
m.detail.ns.HasPassword = true
case "Remove password":
m.detail.ns.HasPassword = false
}
}
if msg.err == nil && m.detail.kind == detailRepo {
switch m.pending.label {
case "Set repo private":
m.detail.repo.IsPrivate = true
case "Set repo public":
m.detail.repo.IsPrivate = false
}
}
m.overlay = ovResult
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch m.overlay {
case ovList:
return m.handleListKey(msg)
case ovDetail:
var cmd tea.Cmd
m.detail, cmd = m.detail.Update(msg)
return m, cmd
case ovForm:
f, cmd := m.form.Update(msg)
m.form = f
return m, cmd
case ovConfirm:
switch msg.String() {
case "enter":
m.overlay = ovRunning
return m, m.executeCmd()
case "esc":
m.overlay = m.cancelReturn()
return m, nil
}
return m, nil
case ovResult:
if msg.String() == "q" {
return m, tea.Quit
}
if m.pendingReturn == "detail" {
m.overlay = ovDetail
return m, m.loadDetailGrantsCmd(m.detail)
}
m.overlay = ovList
return m, m.loadScreenCmd(m.screen)
}
return m, nil
}
func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.screen == scrStatus {
switch msg.String() {
case "q":
return m, tea.Quit
case "r":
return m, loadStatusCmd(m.manage)
case "left":
return m.switchTab(-1)
case "right":
return m.switchTab(1)
case "1", "2", "3", "4":
return m.switchScreen(screenTabs[int(msg.Runes[0]-'1')].s)
}
return m, nil
}
if m.list.FilterState() == list.Filtering {
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
switch msg.String() {
case "q":
return m, tea.Quit
case "left":
return m.switchTab(-1)
case "right":
return m.switchTab(1)
case "1", "2", "3", "4":
return m.switchScreen(screenTabs[int(msg.Runes[0]-'1')].s)
case "esc":
if m.list.FilterState() == list.FilterApplied {
m.list.ResetFilter()
}
return m, nil
case "enter":
return m.drillIn()
case "n":
return m.newAction()
case "x":
if m.screen == scrAccess {
return m.deleteSelectedGrant()
}
case "r":
if m.screen == scrAccess {
return m.rotateSelectedGrant()
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m model) switchScreen(s screen) (tea.Model, tea.Cmd) {
m.screen = s
m.overlay = ovList
m.listLoading = true
m.listErr = ""
m.list = newListModel(nil, listWidth(m.width), listHeight(m.height))
return m, m.loadScreenCmd(s)
}
func (m model) switchTab(dir int) (tea.Model, tea.Cmd) {
n := len(screenTabs)
idx := 0
for i, t := range screenTabs {
if t.s == m.screen {
idx = i
break
}
}
return m.switchScreen(screenTabs[(idx+dir+n)%n].s)
}
func (m model) drillIn() (tea.Model, tea.Cmd) {
item := m.list.SelectedItem()
if item == nil {
return m, nil
}
switch m.screen {
case scrNS:
ni := item.(nsItem)
m.detail = newNSDetail(ni.ns, listWidth(m.width), m.height)
m.overlay = ovDetail
return m, m.loadDetailGrantsCmd(m.detail)
case scrRepos:
ri := item.(repoItem)
m.detail = newRepoDetail(ri.repo, listWidth(m.width), m.height)
m.overlay = ovDetail
return m, m.loadDetailGrantsCmd(m.detail)
}
return m, nil
}
func (m model) newAction() (tea.Model, tea.Cmd) {
if m.screen == scrNS {
return m.applyOp(opRequest{a: opCreateNS(), focus: 0, ret: "list"})
}
if m.screen == scrRepos {
return m.applyOp(opRequest{a: opCreateRepo(), focus: 0, ret: "list"})
}
return m, nil
}
func (m model) deleteSelectedGrant() (tea.Model, tea.Cmd) {
item := m.list.SelectedItem()
if item == nil {
return m, nil
}
g := item.(grantItem).g
return m.applyOp(opRequest{a: opACLDelete(g.AclID), ret: "list"})
}
func (m model) rotateSelectedGrant() (tea.Model, tea.Cmd) {
item := m.list.SelectedItem()
if item == nil {
return m, nil
}
g := item.(grantItem).g
return m.applyOp(opRequest{a: opRotatePerm(g), ret: "list"})
}
func (m model) applyOp(req opRequest) (tea.Model, tea.Cmd) {
m.pending = req.a
m.pendingReturn = req.ret
m.preOverlay = m.overlay
if len(req.a.fields) > 0 {
f, cmd := newForm(req.a, m.width, req.prefill, req.focus)
m.form = f
m.overlay = ovForm
return m, cmd
}
m.pendingVals = nil
m.pendingPerm = ""
if req.a.destructive {
m.overlay = ovConfirm
return m, nil
}
m.overlay = ovRunning
return m, m.executeCmd()
}
func (m model) cancelReturn() overlay {
if m.preOverlay == ovDetail {
return ovDetail
}
return ovList
}
func (m model) executeCmd() tea.Cmd {
a := m.pending
vals := m.pendingVals
perm := m.pendingPerm
b := &backend{manage: m.manage, db: m.db}
return func() tea.Msg {
summary, err := a.run(context.Background(), b, vals, perm)
return resultMsg{summary: summary, err: err}
}
}
func (m model) loadScreenCmd(s screen) tea.Cmd {
db := m.db
mg := m.manage
switch s {
case scrNS:
return func() tea.Msg {
users, err := db.listUsers()
return nsLoadedMsg{users: users, err: err}
}
case scrRepos:
return func() tea.Msg {
repos, err := db.listRepos()
return reposLoadedMsg{repos: repos, err: err}
}
case scrAccess:
return func() tea.Msg {
grants, err := db.listAllGrants()
return grantsLoadedMsg{grants: grants, err: err}
}
case scrStatus:
return loadStatusCmd(mg)
}
return nil
}
func (m model) loadDetailGrantsCmd(d detailModel) tea.Cmd {
db := m.db
switch d.kind {
case detailNS:
return func() tea.Msg {
grants, err := db.grantsForNS(d.ns.Name)
return detailGrantsLoadedMsg{grants: grants, err: err}
}
case detailRepo:
return func() tea.Msg {
grants, err := db.grantsForRepo(d.repo.Ns, d.repo.Name)
return detailGrantsLoadedMsg{grants: grants, err: err}
}
case detailKey:
return func() tea.Msg {
grants, err := db.grantsForKey(d.key.ID)
return detailGrantsLoadedMsg{grants: grants, err: err}
}
}
return nil
}
func loadStatusCmd(mg *manageClient) tea.Cmd {
return func() tea.Msg {
st, err := mg.Status(context.Background())
return statusRefreshMsg{status: st, err: err}
}
}
func registeredFingerprints(db *dbReader) map[string]bool {
out := map[string]bool{}
if db == nil {
return out
}
keys, err := db.listSSHKeys()
if err != nil {
return out
}
for _, k := range keys {
out[k.Fingerprint] = true
}
return out
}
func listWidth(w int) int {
if x := w - 4; x >= 20 {
return x
}
return 20
}
func listHeight(h int) int {
if x := h - 8; x >= 3 {
return x
}
return 3
}
func (m model) View() string {
switch m.overlay {
case ovDetail:
return m.detail.View()
case ovForm:
return m.form.View()
case ovConfirm:
return m.viewConfirm()
case ovRunning:
return m.viewRunning()
case ovResult:
return m.viewResult()
}
return m.viewScreen()
}
func (m model) viewScreen() string {
var b strings.Builder
b.WriteString(m.headerLine())
b.WriteString("\n\n")
b.WriteString(m.tabBar())
b.WriteString("\n\n")
if m.screen == scrStatus {
b.WriteString(m.statusBody())
} else {
b.WriteString(m.listBody())
}
b.WriteString("\n\n")
b.WriteString(hintStyle.Render(" " + m.hintLine()))
return frame.Render(b.String())
}
func (m model) listBody() string {
if m.listLoading {
return " " + hintStyle.Render("loading…")
}
if m.listErr != "" {
return " " + errStyle.Render("✗ "+m.listErr)
}
if len(m.list.Items()) == 0 {
return " " + hintStyle.Render("(none)")
}
return m.list.View()
}
func (m model) statusBody() string {
if m.statusErr != nil {
return " " + errStyle.Render("✗ "+errText(m.statusErr))
}
if m.status == nil {
return " " + hintStyle.Render("loading…")
}
return indent(formatStatus(m.status))
}
func (m model) headerLine() string {
dot := hintStyle.Render("○")
if m.ready {
if m.pingOK {
dot = statusOK.Render("●")
} else {
dot = statusBad.Render("○")
}
}
dbDot := hintStyle.Render("db ○")
if m.ready {
if m.db != nil {
dbDot = statusOK.Render("db ●")
} else {
dbDot = statusBad.Render("db ○")
}
}
return titleStyle.Render("gitctl · ManageService") + " " +
hintStyle.Render(m.target) + " " + dot + " " + dbDot
}
func (m model) tabBar() string {
var b strings.Builder
for _, t := range screenTabs {
if t.s == m.screen {
b.WriteString(" " + tabActiveStyle.Render("▸ "+t.nm) + " ")
} else {
b.WriteString(" " + tabStyle.Render(" "+t.nm) + " ")
}
}
return strings.TrimRight(b.String(), " ")
}
func (m model) hintLine() string {
switch m.screen {
case scrStatus:
return "r refresh · ←/→ or 1-4 switch · q quit"
case scrAccess:
return "↑↓ · / search · x revoke · r permission · ←/→ or 1-4 · q quit"
default:
return "↑↓ · / search · enter open · n new · ←/→ or 1-4 · q quit"
}
}
func (m model) viewConfirm() string {
var b strings.Builder
b.WriteString(warnStyle.Render("⚠ " + m.pending.label))
b.WriteString("\n\n")
if m.pending.confirm != "" {
b.WriteString(" " + m.pending.confirm + "\n")
}
for _, fld := range m.pending.fields {
v := m.pendingVals[fld.name]
if fld.secret {
v = strings.Repeat("•", len(v))
}
b.WriteString(" " + labelStyle.Render(fld.name+":") + " " + v + "\n")
}
if m.pending.hasPerm {
b.WriteString(" " + labelStyle.Render("perm:") + " " + m.pendingPerm + "\n")
}
b.WriteString("\n " + hintStyle.Render("enter to confirm · esc to cancel"))
return frame.Render(b.String())
}
func (m model) viewRunning() string {
return frame.Render(titleStyle.Render("gitctl · "+m.pending.label) + "\n\n " +
hintStyle.Render("⏳ working..."))
}
func (m model) viewResult() string {
var b strings.Builder
b.WriteString(titleStyle.Render("gitctl · " + m.pending.label))
b.WriteString("\n\n ")
if m.result.err != nil {
b.WriteString(resultErrStyle.Render("✗ " + errText(m.result.err)))
} else {
lines := strings.Split(m.result.summary, "\n")
b.WriteString(resultOKStyle.Render("✓ " + lines[0]))
for _, l := range lines[1:] {
b.WriteString("\n " + l)
}
}
b.WriteString("\n\n " + hintStyle.Render("any key to continue"))
return frame.Render(b.String())
}
func indent(s string) string {
return " " + strings.ReplaceAll(s, "\n", "\n ")
}