From cce785f371aa459538d962bd2162ec330cde5ee1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 14:17:13 -0700 Subject: [PATCH] feat: close clusters locally from tui --- internal/cli/tui.go | 86 ++++++++++++++++++++++++++++++++++ internal/cli/tui_test.go | 99 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/internal/cli/tui.go b/internal/cli/tui.go index c834588..359c607 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -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 { diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 198f6ad..4e46ad9 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -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"))