feat: close clusters locally from tui

This commit is contained in:
Vincent Koc 2026-04-27 14:17:13 -07:00
parent ba175deae5
commit cce785f371
No known key found for this signature in database
2 changed files with 185 additions and 0 deletions

View File

@ -1060,6 +1060,12 @@ func (m *clusterBrowserModel) openActionMenu() {
tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"},
tuiMenuItem{label: "Copy cluster summary", action: "copy-cluster"},
)
cluster, _ := m.selectedCluster()
if cluster.Status == "closed" || cluster.ClosedAt != "" {
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Reopen cluster locally...", action: "reopen-cluster-confirm"})
} else {
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Close cluster locally...", action: "close-cluster-confirm"})
}
if m.hasDetail {
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy member list", action: "copy-member-list"})
}
@ -1262,6 +1268,18 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
m.status = "Copied representative URL"
}
return true
case "close-cluster-confirm":
m.openCloseClusterMenu()
return false
case "close-cluster-local":
m.closeSelectedClusterLocally()
return true
case "reopen-cluster-confirm":
m.openReopenClusterMenu()
return false
case "reopen-cluster-local":
m.reopenSelectedClusterLocally()
return true
case "load-neighbors":
m.loadSelectedThreadNeighbors(10, 0.2)
return true
@ -1643,6 +1661,38 @@ func (m *clusterBrowserModel) openReopenThreadMenu() {
m.status = fmt.Sprintf("Confirm local reopen for #%d", thread.Number)
}
func (m *clusterBrowserModel) openCloseClusterMenu() {
cluster, ok := m.selectedCluster()
if !ok {
m.status = "No selected cluster"
return
}
m.menuTitle = "Close Cluster"
m.menuItems = []tuiMenuItem{
{label: fmt.Sprintf("Close cluster C%d locally", cluster.ID), action: "close-cluster-local"},
{label: "Back to actions", action: "back-to-actions"},
}
m.menuIndex = 0
m.menuOff = 0
m.status = fmt.Sprintf("Confirm local close for cluster C%d", cluster.ID)
}
func (m *clusterBrowserModel) openReopenClusterMenu() {
cluster, ok := m.selectedCluster()
if !ok {
m.status = "No selected cluster"
return
}
m.menuTitle = "Reopen Cluster"
m.menuItems = []tuiMenuItem{
{label: fmt.Sprintf("Reopen cluster C%d locally", cluster.ID), action: "reopen-cluster-local"},
{label: "Back to actions", action: "back-to-actions"},
}
m.menuIndex = 0
m.menuOff = 0
m.status = fmt.Sprintf("Confirm local reopen for cluster C%d", cluster.ID)
}
func (m *clusterBrowserModel) closeSelectedThreadLocally() {
thread, ok := m.selectedThread()
if !ok {
@ -1680,6 +1730,42 @@ func (m *clusterBrowserModel) reopenSelectedThreadLocally() {
m.status = fmt.Sprintf("Reopened #%d locally", thread.Number)
}
func (m *clusterBrowserModel) closeSelectedClusterLocally() {
cluster, ok := m.selectedCluster()
if !ok {
m.status = "No selected cluster"
return
}
if m.store == nil || m.repoID == 0 {
m.status = "Local cluster close unavailable for this view"
return
}
if err := m.store.CloseClusterLocally(m.ctx, m.repoID, cluster.ID, "TUI manual close"); err != nil {
m.status = err.Error()
return
}
m.refreshFromStore()
m.status = fmt.Sprintf("Closed cluster C%d locally", cluster.ID)
}
func (m *clusterBrowserModel) reopenSelectedClusterLocally() {
cluster, ok := m.selectedCluster()
if !ok {
m.status = "No selected cluster"
return
}
if m.store == nil || m.repoID == 0 {
m.status = "Local cluster reopen unavailable for this view"
return
}
if err := m.store.ReopenClusterLocally(m.ctx, m.repoID, cluster.ID); err != nil {
m.status = err.Error()
return
}
m.refreshFromStore()
m.status = fmt.Sprintf("Reopened cluster C%d locally", cluster.ID)
}
func (m clusterBrowserModel) menuVisibleCount() int {
height := m.detailView.Height
if height <= 0 {

View File

@ -1419,6 +1419,105 @@ func TestTUIReopenThreadLocallyRestoresThread(t *testing.T) {
}
}
func TestTUICloseClusterLocallyHidesCluster(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-04-27T00:00:00Z"})
if err != nil {
t.Fatalf("repo: %v", err)
}
if err := seedTUICluster(ctx, st, repoID, 52, 502, "close cluster"); err != nil {
t.Fatalf("seed cluster: %v", err)
}
clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"})
if err != nil {
t.Fatalf("clusters: %v", err)
}
model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{
Repository: "openclaw/openclaw",
Sort: "recent",
HideClosed: true,
MinSize: 1,
Clusters: clusters,
})
model.openActionMenu()
if menuLabelIndex(model.menuItems, "Close cluster locally...") < 0 {
t.Fatalf("action menu missing cluster close: %+v", model.menuItems)
}
model.runAction("close-cluster-confirm")
if model.menuTitle != "Close Cluster" || !strings.Contains(model.menuItems[0].label, "Close cluster C52 locally") {
t.Fatalf("close cluster confirmation menu = %q %+v", model.menuTitle, model.menuItems)
}
model.runAction("close-cluster-local")
if model.status != "Closed cluster C52 locally" {
t.Fatalf("close cluster status = %q", model.status)
}
if len(model.payload.Clusters) != 0 {
t.Fatalf("locally closed cluster should be hidden, got %#v", model.payload.Clusters)
}
}
func TestTUIReopenClusterLocallyRestoresCluster(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-04-27T00:00:00Z"})
if err != nil {
t.Fatalf("repo: %v", err)
}
if err := seedTUICluster(ctx, st, repoID, 53, 503, "reopen cluster"); err != nil {
t.Fatalf("seed cluster: %v", err)
}
if err := st.CloseClusterLocally(ctx, repoID, 53, "test close"); err != nil {
t.Fatalf("close cluster: %v", err)
}
clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: true, MinSize: 1, Limit: 20, Sort: "recent"})
if err != nil {
t.Fatalf("clusters: %v", err)
}
model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{
Repository: "openclaw/openclaw",
Sort: "recent",
MinSize: 1,
Clusters: clusters,
})
model.openActionMenu()
if menuLabelIndex(model.menuItems, "Reopen cluster locally...") < 0 {
t.Fatalf("action menu missing cluster reopen: %+v", model.menuItems)
}
if menuLabelIndex(model.menuItems, "Close cluster locally...") >= 0 {
t.Fatalf("closed cluster should not offer close again: %+v", model.menuItems)
}
model.runAction("reopen-cluster-confirm")
if model.menuTitle != "Reopen Cluster" || !strings.Contains(model.menuItems[0].label, "Reopen cluster C53 locally") {
t.Fatalf("reopen cluster confirmation menu = %q %+v", model.menuTitle, model.menuItems)
}
model.runAction("reopen-cluster-local")
if model.status != "Reopened cluster C53 locally" {
t.Fatalf("reopen cluster status = %q", model.status)
}
clusters, err = st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"})
if err != nil {
t.Fatalf("list reopened clusters: %v", err)
}
if len(clusters) != 1 || clusters[0].ClosedAt != "" {
t.Fatalf("reopened cluster should be visible, got %#v", clusters)
}
}
func TestTUIRepositoryPickerKeepsCurrentRepoVisible(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))