From d973349869481c9f7a89d273925483be241f52e8 Mon Sep 17 00:00:00 2001 From: Jabberwocky238 <7176656@qq.com> Date: Thu, 30 Jul 2026 06:12:07 -0400 Subject: [PATCH] redesign gitctl repository workspace --- cmd/gitctl/lists.go | 23 +++++ cmd/gitctl/model.go | 235 +++++++++++++++++++++++++++++++++----------- cmd/gitctl/style.go | 41 ++++---- 3 files changed, 221 insertions(+), 78 deletions(-) diff --git a/cmd/gitctl/lists.go b/cmd/gitctl/lists.go index 8607b2a..e84d6e7 100644 --- a/cmd/gitctl/lists.go +++ b/cmd/gitctl/lists.go @@ -132,6 +132,29 @@ func newListModel(items []list.Item, width, height int) list.Model { return l } +func newNavigationListModel(items []list.Item, width, height int) list.Model { + d := list.NewDefaultDelegate() + d.ShowDescription = false + d.SetHeight(1) + d.SetSpacing(0) + d.Styles.SelectedTitle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(colorAccent). + Foreground(colorAccent).Bold(true). + Padding(0, 0, 0, 1) + d.Styles.NormalTitle = lipgloss.NewStyle().Padding(0, 0, 0, 2) + + l := list.New(items, d, width, height) + l.SetShowTitle(false) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetShowPagination(true) + l.DisableQuitKeybindings() + l.KeyMap.ShowFullHelp.SetEnabled(false) + l.KeyMap.CloseFullHelp.SetEnabled(false) + return l +} + func repoItems(repos []repoRow) []list.Item { items := make([]list.Item, len(repos)) for i := range repos { diff --git a/cmd/gitctl/model.go b/cmd/gitctl/model.go index d0bfdef..e227e45 100644 --- a/cmd/gitctl/model.go +++ b/cmd/gitctl/model.go @@ -2,10 +2,14 @@ package gitctl import ( "context" + "fmt" + "net" + "net/url" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" v1 "simplegit/gitrpc/v1" ) @@ -24,10 +28,8 @@ var screenTabs = []struct { s screen nm string }{ - {1, scrStatus, "Status"}, - {2, scrNS, "NS"}, - {3, scrRepos, "Repos"}, - {4, scrAccess, "Access"}, + {1, scrRepos, "Repos"}, + {2, scrNS, "Namespaces"}, } type overlay int @@ -134,8 +136,8 @@ type model struct { } 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) + m := model{manage: c, target: target, screen: scrRepos, overlay: ovList, listLoading: true} + m.list = newNavigationListModel(nil, 76, 16) return m } @@ -154,7 +156,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height switch m.overlay { case ovList: - m.list.SetSize(listWidth(m.width), listHeight(m.height)) + m.list.SetSize(navigationListWidth(m.width), navigationListHeight(m.height)) case ovDetail: m.detail = m.detail.resize(listWidth(m.width), m.height) case ovForm: @@ -179,7 +181,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.listErr = "" m.repos = msg.repos } - m.list = newListModel(repoItems(m.repos), listWidth(m.width), listHeight(m.height)) + m.list = newNavigationListModel(repoItems(m.repos), navigationListWidth(m.width), navigationListHeight(m.height)) return m, nil case nsLoadedMsg: m.listLoading = false @@ -190,7 +192,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.listErr = "" m.users = msg.users } - m.list = newListModel(nsItems(m.users), listWidth(m.width), listHeight(m.height)) + m.list = newNavigationListModel(nsItems(m.users), navigationListWidth(m.width), navigationListHeight(m.height)) return m, nil case sshKeysLoadedMsg: m.listLoading = false @@ -201,7 +203,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.listErr = "" m.sshKeys = msg.keys } - m.list = newListModel(sshKeyItems(m.sshKeys), listWidth(m.width), listHeight(m.height)) + m.list = newNavigationListModel(sshKeyItems(m.sshKeys), navigationListWidth(m.width), navigationListHeight(m.height)) return m, nil case localKeysLoadedMsg: m.listLoading = false @@ -212,7 +214,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.listErr = "" m.localKeys = msg.keys } - m.list = newListModel(localKeyItems(m.localKeys), listWidth(m.width), listHeight(m.height)) + m.list = newNavigationListModel(localKeyItems(m.localKeys), navigationListWidth(m.width), navigationListHeight(m.height)) return m, nil case grantsLoadedMsg: m.listLoading = false @@ -223,7 +225,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.listErr = "" m.grants = msg.grants } - m.list = newListModel(grantItems(m.grants), listWidth(m.width), listHeight(m.height)) + m.list = newNavigationListModel(grantItems(m.grants), navigationListWidth(m.width), navigationListHeight(m.height)) return m, nil case detailGrantsLoadedMsg: errStr := "" @@ -324,23 +326,6 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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) @@ -353,7 +338,7 @@ func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.switchTab(-1) case "right": return m.switchTab(1) - case "1", "2", "3", "4": + case "1", "2": return m.switchScreen(screenTabs[int(msg.Runes[0]-'1')].s) case "esc": if m.list.FilterState() == list.FilterApplied { @@ -364,14 +349,11 @@ func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.drillIn() case "n": return m.newAction() - case "x": - if m.screen == scrAccess { - return m.deleteSelectedGrant() - } + case "v", "m", "d": + return m.selectedItemAction(msg.String()) case "r": - if m.screen == scrAccess { - return m.rotateSelectedGrant() - } + m.listLoading = true + return m, m.loadScreenCmd(m.screen) } var cmd tea.Cmd @@ -379,12 +361,39 @@ func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +func (m model) selectedItemAction(key string) (tea.Model, tea.Cmd) { + item := m.list.SelectedItem() + if item == nil { + return m, nil + } + switch item := item.(type) { + case repoItem: + path := item.repo.Ns + "/" + item.repo.Name + switch key { + case "v": + return m.applyOp(opRequest{a: opToggleRepoVisibility(item.repo), ret: "list"}) + case "m": + return m.applyOp(opRequest{a: opMoveRepo(path), ret: "list"}) + case "d": + return m.applyOp(opRequest{a: opDeleteRepo(item.repo.Ns, item.repo.Name), ret: "list"}) + } + case nsItem: + switch key { + case "m": + return m.applyOp(opRequest{a: opMoveNS(item.ns.Name), ret: "list"}) + case "d": + return m.applyOp(opRequest{a: opDeleteNS(item.ns.Name), ret: "list"}) + } + } + return m, nil +} + 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)) + m.list = newNavigationListModel(nil, navigationListWidth(m.width), navigationListHeight(m.height)) return m, m.loadScreenCmd(s) } @@ -589,17 +598,110 @@ func (m model) View() string { 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("\n") + b.WriteString(hintStyle.Render(strings.Repeat("─", max(rootContentWidth(m.width), 1)))) + b.WriteString("\n") + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, m.navigationPane(), " ", m.detailPane())) + b.WriteString("\n") b.WriteString(hintStyle.Render(" " + m.hintLine())) - return frame.Render(b.String()) + return fullScreenFrame(m.width, m.height).Render(b.String()) +} + +func (m model) navigationPane() string { + content := m.tabBar() + "\n\n" + m.listBody() + return navigationPaneStyle.Width(navigationPaneWidth(m.width) - 2). + Height(workspaceHeight(m.height) - 2).Render(content) +} + +func (m model) detailPane() string { + return detailPaneStyle.Width(detailPaneWidth(m.width) - 2). + Height(workspaceHeight(m.height) - 2).Render(m.selectedDetail()) +} + +func (m model) selectedDetail() string { + item := m.list.SelectedItem() + if item == nil { + if m.listLoading { + return hintStyle.Render("Loading details...") + } + return hintStyle.Render("No selection") + } + + var b strings.Builder + switch item := item.(type) { + case repoItem: + visibility := "Public" + if item.repo.IsPrivate { + visibility = "Private" + } + b.WriteString(detailEyebrowStyle.Render("REPOSITORY")) + b.WriteString("\n") + b.WriteString(detailNameStyle.Render(item.repo.Ns + "/" + item.repo.Name)) + b.WriteString("\n\n") + b.WriteString(detailField("Visibility", visibility)) + b.WriteString(detailField("Namespace", item.repo.Ns)) + b.WriteString(detailField("Created", fmtTime(item.repo.Created))) + b.WriteString(detailField("Updated", fmtTime(item.repo.Updated))) + httpURL, sshURL := m.repoCloneURLs(item.repo) + b.WriteString("\n") + b.WriteString(detailURLField("HTTP URL", httpURL)) + b.WriteString(detailURLField("SSH URL", sshURL)) + case nsItem: + password := "Not configured" + if item.ns.HasPassword { + password = "Enabled" + } + b.WriteString(detailEyebrowStyle.Render("NAMESPACE")) + b.WriteString("\n") + b.WriteString(detailNameStyle.Render(item.ns.Name)) + b.WriteString("\n\n") + b.WriteString(detailField("Repositories", fmt.Sprintf("%d", item.ns.RepoCount))) + b.WriteString(detailField("Password", password)) + b.WriteString(detailField("Created", fmtTime(item.ns.Created))) + b.WriteString(detailField("Updated", fmtTime(item.ns.Updated))) + } + return b.String() +} + +func detailField(label, value string) string { + return detailFieldLabelStyle.Render(label) + " " + fieldValueStyle.Render(value) + "\n" +} + +func detailURLField(label, value string) string { + if value == "" { + value = "Not available" + } + return detailFieldLabelStyle.Render(label) + " " + cloneURLStyle.Render(value) + "\n" +} + +func (m model) repoCloneURLs(repo repoRow) (httpURL, sshURL string) { + if m.status == nil { + return "", "" + } + target, err := url.Parse(m.target) + if err != nil || target.Hostname() == "" { + return "", "" + } + host := target.Hostname() + path := repo.Ns + "/" + repo.Name + ".git" + if port := listenPort(m.status.RpcAddr); port != "" { + httpURL = "http://" + net.JoinHostPort(host, port) + "/" + path + } + if port := listenPort(m.status.SshAddr); port != "" { + sshURL = "ssh://git@" + net.JoinHostPort(host, port) + "/" + path + } + return httpURL, sshURL +} + +func listenPort(addr string) string { + if addr == "" { + return "" + } + _, port, err := net.SplitHostPort(addr) + if err != nil { + return "" + } + return port } func (m model) listBody() string { @@ -650,25 +752,42 @@ func (m model) tabBar() string { var b strings.Builder for _, t := range screenTabs { if t.s == m.screen { - b.WriteString(" " + tabActiveStyle.Render("▸ "+t.nm) + " ") + b.WriteString(tabActiveStyle.Render(t.nm) + " ") } else { - b.WriteString(" " + tabStyle.Render(" "+t.nm) + " ") + 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" - } + return "↑↓ select · / search · enter permissions · n new · r refresh · ←/→ switch · q quit" } +func rootContentWidth(w int) int { return max(w-6, 1) } + +func workspaceWidth(w int) int { return rootContentWidth(w) } + +func workspaceHeight(h int) int { return max(h-7, 12) } + +func navigationPaneWidth(w int) int { + total := workspaceWidth(w) + if total < 60 { + return max(total/3, 16) + } + width := total / 4 + if width < 24 { + width = 24 + } + return min(width, total-36) +} + +func detailPaneWidth(w int) int { return workspaceWidth(w) - navigationPaneWidth(w) - 1 } + +func navigationListWidth(w int) int { return max(navigationPaneWidth(w)-4, 20) } + +func navigationListHeight(h int) int { return max(workspaceHeight(h)-6, 3) } + func (m model) viewConfirm() string { var b strings.Builder b.WriteString(warnStyle.Render("⚠ " + m.pending.label)) diff --git a/cmd/gitctl/style.go b/cmd/gitctl/style.go index 19dcee5..d47be13 100644 --- a/cmd/gitctl/style.go +++ b/cmd/gitctl/style.go @@ -1,9 +1,3 @@ - - - - - - package gitctl import "github.com/charmbracelet/lipgloss" @@ -15,14 +9,18 @@ var ( colorDim = lipgloss.Color("245") colorAccent = lipgloss.Color("39") - frame = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorDim). Padding(0, 1) - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) + navigationPaneStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, true, false, false). + BorderForeground(lipgloss.Color("238")). + Padding(1, 1) + detailPaneStyle = lipgloss.NewStyle().Padding(1, 2) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) statusOK = lipgloss.NewStyle().Foreground(colorGreen).Bold(true) statusBad = lipgloss.NewStyle().Foreground(colorRed).Bold(true) @@ -32,7 +30,6 @@ var ( selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) hintStyle = lipgloss.NewStyle().Foreground(colorDim) - labelStyle = lipgloss.NewStyle().Foreground(colorDim).Width(12) focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent).Width(12) inputStyle = lipgloss.NewStyle().Foreground(colorYellow) @@ -41,29 +38,33 @@ var ( permActiveStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) errStyle = lipgloss.NewStyle().Foreground(colorRed) - resultOKStyle = lipgloss.NewStyle().Foreground(colorGreen).Bold(true) resultInfoStyle = lipgloss.NewStyle().Foreground(colorYellow).Bold(true) resultErrStyle = lipgloss.NewStyle().Foreground(colorRed).Bold(true) warnStyle = lipgloss.NewStyle().Foreground(colorYellow).Bold(true) - - tabStyle = lipgloss.NewStyle().Foreground(colorDim) - tabActiveStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) - + tabStyle = lipgloss.NewStyle().Foreground(colorDim).Padding(0, 1) + tabActiveStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent). + Background(lipgloss.Color("235")).Padding(0, 1) badgeOK = lipgloss.NewStyle().Foreground(colorGreen).Bold(true) badgeWarn = lipgloss.NewStyle().Foreground(colorYellow) badgeDim = lipgloss.NewStyle().Foreground(colorDim) - - detailTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) - fieldLabelStyle = lipgloss.NewStyle().Foreground(colorDim) - fieldValueStyle = lipgloss.NewStyle() - actionKeyStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) - + detailTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) + detailEyebrowStyle = lipgloss.NewStyle().Bold(true).Foreground(colorDim) + detailNameStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) + detailFieldLabelStyle = lipgloss.NewStyle().Foreground(colorDim).Width(14) + cloneURLStyle = lipgloss.NewStyle().Foreground(colorGreen) + fieldLabelStyle = lipgloss.NewStyle().Foreground(colorDim) + fieldValueStyle = lipgloss.NewStyle() + actionKeyStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent) permPillRead = lipgloss.NewStyle().Foreground(colorDim) permPillWrite = lipgloss.NewStyle().Foreground(colorYellow).Bold(true) permPillAdmin = lipgloss.NewStyle().Foreground(colorGreen).Bold(true) ) + +func fullScreenFrame(width, height int) lipgloss.Style { + return frame.Width(max(width-4, 1)).Height(max(height-4, 1)) +}