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

292 lines
8.4 KiB
Go

package gitctl
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
type detailKind int
const (
detailRepo detailKind = iota
detailNS
detailKey
)
type detailModel struct {
kind detailKind
repo repoRow
ns nsRow
key sshKeyRow
grants []grantRow
list list.Model
loaded bool
permissions bool
err string
w, h int
}
func newNSDetail(ns nsRow, w, h int) detailModel {
d := detailModel{kind: detailNS, ns: ns, w: w, h: h}
d.list = newListModel(nil, w, listHeight(h))
return d
}
func newRepoDetail(repo repoRow, w, h int) detailModel {
d := detailModel{kind: detailRepo, repo: repo, w: w, h: h}
d.list = newListModel(nil, w, listHeight(h))
return d
}
func newKeyDetail(key sshKeyRow, w, h int) detailModel {
d := detailModel{kind: detailKey, key: key, w: w, h: h}
d.list = newListModel(nil, w, listHeight(h))
return d
}
func (d detailModel) resize(w, h int) detailModel {
d.w, d.h = w, h
d.list.SetSize(w, listHeight(h))
return d
}
func (d detailModel) withGrants(grants []grantRow, errStr string) detailModel {
d.grants = grants
d.err = errStr
d.loaded = true
d.list = newListModel(grantItems(grants), d.w, listHeight(d.h))
return d
}
func (d detailModel) selectedGrant() (grantRow, bool) {
item := d.list.SelectedItem()
if item == nil {
return grantRow{}, false
}
g, ok := item.(grantItem)
return g.g, ok
}
func (d detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
if d.permissions && d.list.FilterState() == list.Filtering {
var cmd tea.Cmd
d.list, cmd = d.list.Update(msg)
return d, cmd
}
k, ok := msg.(tea.KeyMsg)
if !ok {
var cmd tea.Cmd
d.list, cmd = d.list.Update(msg)
return d, cmd
}
switch k.String() {
case "ctrl+c", "q":
return d, tea.Quit
case "esc":
if d.permissions && d.list.FilterState() == list.FilterApplied {
d.list.ResetFilter()
return d, nil
}
if d.permissions && d.kind != detailKey {
d.permissions = false
return d, nil
}
return d, func() tea.Msg { return detailBackMsg{} }
}
if !d.permissions && d.kind != detailKey && (k.String() == "a" || k.String() == "enter") {
d.permissions = true
return d, nil
}
if cmd, handled := d.handleQuickKey(k); handled {
return d, cmd
}
if d.permissions || d.kind == detailKey {
var cmd tea.Cmd
d.list, cmd = d.list.Update(msg)
return d, cmd
}
return d, nil
}
func (d detailModel) handleQuickKey(k tea.KeyMsg) (tea.Cmd, bool) {
path := d.repo.Ns + "/" + d.repo.Name
switch d.kind {
case detailNS:
if !d.permissions {
switch k.String() {
case "m":
return emitOp(opRequest{a: opMoveNS(d.ns.Name), ret: "list"}), true
case "d":
return emitOp(opRequest{a: opDeleteNS(d.ns.Name), ret: "list"}), true
}
return nil, false
}
switch k.String() {
case "g":
return emitOp(opRequest{a: opGrantSSHKey(), prefill: map[string]string{"target": d.ns.Name}, focus: 1, ret: "detail"}), true
case "p":
return emitOp(opRequest{a: opGrantPAT(), prefill: map[string]string{"target": d.ns.Name}, focus: 1, ret: "detail"}), true
case "c":
return emitOp(opRequest{a: opChangePassword(d.ns.Name), ret: "detail"}), true
case "u":
if !d.ns.HasPassword {
return nil, true
}
return emitOp(opRequest{a: opRemovePassword(d.ns.Name), ret: "detail"}), true
case "x":
g, ok := d.selectedGrant()
if !ok {
return nil, true
}
return emitOp(opRequest{a: opACLDelete(g.AclID), ret: "detail"}), true
case "r":
g, ok := d.selectedGrant()
if !ok {
return nil, true
}
return emitOp(opRequest{a: opRotatePerm(g), ret: "detail"}), true
}
case detailRepo:
if !d.permissions {
switch k.String() {
case "v":
return emitOp(opRequest{a: opToggleRepoVisibility(d.repo), ret: "detail"}), true
case "d":
return emitOp(opRequest{a: opDeleteRepo(d.repo.Ns, d.repo.Name), ret: "list"}), true
case "m":
return emitOp(opRequest{a: opMoveRepo(path), focus: 0, ret: "list"}), true
}
return nil, false
}
switch k.String() {
case "g":
return emitOp(opRequest{a: opGrantSSHKey(), prefill: map[string]string{"target": path}, focus: 1, ret: "detail"}), true
case "p":
return emitOp(opRequest{a: opGrantPAT(), prefill: map[string]string{"target": path}, focus: 1, ret: "detail"}), true
case "x":
g, ok := d.selectedGrant()
if !ok {
return nil, true
}
return emitOp(opRequest{a: opACLDelete(g.AclID), ret: "detail"}), true
case "r":
g, ok := d.selectedGrant()
if !ok {
return nil, true
}
return emitOp(opRequest{a: opRotatePerm(g), ret: "detail"}), true
}
case detailKey:
switch k.String() {
case "a":
line, err := reconstructAuthorizedLine(d.key.PublicKey)
if err != nil {
return nil, true
}
return emitOp(opRequest{a: opGrantSSHKey(), prefill: map[string]string{"key": line}, focus: 0, ret: "detail"}), true
case "x":
g, ok := d.selectedGrant()
if !ok {
return nil, true
}
return emitOp(opRequest{a: opACLDelete(g.AclID), ret: "detail"}), true
case "r":
g, ok := d.selectedGrant()
if !ok {
return nil, true
}
return emitOp(opRequest{a: opRotatePerm(g), ret: "detail"}), true
}
}
return nil, false
}
func (d detailModel) View() string {
if !d.permissions && d.kind != detailKey {
return d.infoView()
}
var b strings.Builder
switch d.kind {
case detailNS:
b.WriteString(detailTitleStyle.Render("gitctl · Namespace Permissions · " + d.ns.Name))
b.WriteString("\n\n " + fieldLabelStyle.Render(fmt.Sprintf("Grants (%d):", len(d.grants))) + "\n")
case detailRepo:
b.WriteString(detailTitleStyle.Render("gitctl · Repo Permissions · " + d.repo.Ns + "/" + d.repo.Name))
b.WriteString("\n\n " + fieldLabelStyle.Render(fmt.Sprintf("Grants (%d):", len(d.grants))) + "\n")
case detailKey:
c := d.key.Comment
if c == "" {
c = "(no comment)"
}
b.WriteString(detailTitleStyle.Render("gitctl · SSH Key · " + c))
b.WriteString("\n")
last := "-"
if d.key.LastUsedAt != nil {
last = fmtTime(*d.key.LastUsedAt)
}
b.WriteString(" " + fieldLabelStyle.Render("type:") + " " + d.key.KeyType +
" " + fieldLabelStyle.Render("fingerprint:") + " " + shortFP(d.key.Fingerprint) +
" " + fieldLabelStyle.Render("last used:") + " " + last)
b.WriteString("\n\n " + fieldLabelStyle.Render(fmt.Sprintf("Grants (%d):", len(d.grants))) + "\n")
}
b.WriteString(d.grantsBody())
b.WriteString("\n\n " + hintStyle.Render(d.hintLine()))
return frame.Render(b.String())
}
func (d detailModel) infoView() string {
var b strings.Builder
switch d.kind {
case detailNS:
password := "not configured"
if d.ns.HasPassword {
password = "enabled"
}
b.WriteString(detailTitleStyle.Render("gitctl · Namespace · " + d.ns.Name))
b.WriteString("\n\n " + fieldLabelStyle.Render("repos:") + fmt.Sprintf(" %d", d.ns.RepoCount))
b.WriteString(" " + fieldLabelStyle.Render("password:") + " " + password)
b.WriteString("\n " + fieldLabelStyle.Render("created:") + " " + fmtTime(d.ns.Created))
b.WriteString(" " + fieldLabelStyle.Render("updated:") + " " + fmtTime(d.ns.Updated))
b.WriteString("\n\n " + hintStyle.Render("a/enter permissions · m rename · d delete · esc back"))
case detailRepo:
visibility := "public"
if d.repo.IsPrivate {
visibility = "private"
}
b.WriteString(detailTitleStyle.Render("gitctl · Repo · " + d.repo.Ns + "/" + d.repo.Name))
b.WriteString("\n\n " + fieldLabelStyle.Render("visibility:") + " " + visibility)
b.WriteString("\n " + fieldLabelStyle.Render("created:") + " " + fmtTime(d.repo.Created))
b.WriteString(" " + fieldLabelStyle.Render("updated:") + " " + fmtTime(d.repo.Updated))
b.WriteString("\n\n " + hintStyle.Render("a/enter permissions · v private/public · m move · d delete · esc back"))
}
return frame.Render(b.String())
}
func (d detailModel) grantsBody() string {
if d.err != "" {
return " " + errStyle.Render("✗ "+d.err)
}
if !d.loaded {
return " " + hintStyle.Render("loading…")
}
if len(d.grants) == 0 {
return " " + hintStyle.Render("(no access grants)")
}
return d.list.View()
}
func (d detailModel) hintLine() string {
switch d.kind {
case detailNS:
return "g add ssh · p add pat · c set password · u remove password\n x revoke · r permission · / search · esc details"
case detailRepo:
return "g add ssh · p add pat · x revoke · r permission · / search · esc details"
case detailKey:
return "a grant · x revoke · r permission · / search · esc back"
}
return "esc back"
}