718 lines
15 KiB
Go
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 ")
|
|
}
|