Skip to content

Commit 1258160

Browse files
akoclaude
andcommitted
feat(marketplace): live progress indicator during search scan
A rare keyword search scans much of the catalog and can take tens of seconds on a slow link — long enough to look hung. Search now reports progress: the client gained an optional OnProgress(scanned) callback (invoked after each batch), and `marketplace search` wires it to a "Searching marketplace… N items scanned" line on stderr. Gated: shown only when stderr is an interactive terminal and output isn't --json, so pipes, CI, and JSON consumers stay clean. The line is cleared before results are printed. Tests: OnProgress fires monotonically and totals the items scanned; the CLI gate returns no-op for --json and non-terminal writers. PTY smoke-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b7ff0d5 commit 1258160

4 files changed

Lines changed: 99 additions & 0 deletions

File tree

cmd/mxcli/cmd_marketplace.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"encoding/json"
88
"fmt"
9+
"io"
910
"os"
1011
"path/filepath"
1112
"strings"
@@ -14,6 +15,7 @@ import (
1415
"github.com/mendixlabs/mxcli/internal/auth"
1516
"github.com/mendixlabs/mxcli/internal/marketplace"
1617
"github.com/spf13/cobra"
18+
"golang.org/x/term"
1719
)
1820

1921
var marketplaceCmd = &cobra.Command{
@@ -207,6 +209,15 @@ func runMarketplaceSearch(cmd *cobra.Command, args []string) error {
207209
if err != nil {
208210
return err
209211
}
212+
213+
// The Content API has no server-side search, so a rare query scans much of
214+
// the catalog. Show progress on an interactive terminal so it doesn't look
215+
// hung. Skipped for --json and non-terminal stderr (pipes, CI).
216+
stderr := cmd.ErrOrStderr()
217+
if progressDone := startSearchProgress(client, stderr, asJSON); progressDone != nil {
218+
defer progressDone()
219+
}
220+
210221
list, err := client.Search(cmd.Context(), query, limit)
211222
if err != nil {
212223
return err
@@ -217,6 +228,25 @@ func runMarketplaceSearch(cmd *cobra.Command, args []string) error {
217228
return renderContentTable(cmd, list.Items)
218229
}
219230

231+
// startSearchProgress wires a live "scanning" indicator on client.OnProgress
232+
// when stderr is an interactive terminal and output isn't JSON. It returns a
233+
// cleanup func that clears the progress line, or nil when no progress is shown.
234+
func startSearchProgress(client *marketplace.Client, stderr io.Writer, asJSON bool) func() {
235+
if asJSON {
236+
return nil
237+
}
238+
f, ok := stderr.(*os.File)
239+
if !ok || !term.IsTerminal(int(f.Fd())) {
240+
return nil
241+
}
242+
client.OnProgress = func(scanned int) {
243+
fmt.Fprintf(stderr, "\rSearching marketplace… %d items scanned", scanned)
244+
}
245+
return func() {
246+
fmt.Fprint(stderr, "\r\033[K") // clear the progress line
247+
}
248+
}
249+
220250
func runMarketplaceInfo(cmd *cobra.Command, args []string) error {
221251
contentID, err := parseContentID(args[0])
222252
if err != nil {

cmd/mxcli/cmd_marketplace_download_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package main
44

55
import (
6+
"bytes"
67
"fmt"
78
"net/http"
89
"os"
@@ -13,6 +14,28 @@ import (
1314
"github.com/mendixlabs/mxcli/internal/marketplace"
1415
)
1516

17+
func TestStartSearchProgress_Gating(t *testing.T) {
18+
t.Run("nil for --json", func(t *testing.T) {
19+
c := marketplace.New(http.DefaultClient)
20+
if startSearchProgress(c, os.Stderr, true) != nil {
21+
t.Error("expected no progress for --json output")
22+
}
23+
if c.OnProgress != nil {
24+
t.Error("OnProgress must stay unset for --json")
25+
}
26+
})
27+
t.Run("nil for non-terminal writer", func(t *testing.T) {
28+
c := marketplace.New(http.DefaultClient)
29+
// A bytes.Buffer is not an *os.File, so it's never an interactive terminal.
30+
if startSearchProgress(c, &bytes.Buffer{}, false) != nil {
31+
t.Error("expected no progress for a non-terminal writer (pipe/buffer)")
32+
}
33+
if c.OnProgress != nil {
34+
t.Error("OnProgress must stay unset for a non-terminal writer")
35+
}
36+
})
37+
}
38+
1639
func TestResolveVersion(t *testing.T) {
1740
versions := []marketplace.Version{
1841
{VersionNumber: "7.0.3", DownloadURL: "u3"},

internal/marketplace/client.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ const (
3838
type Client struct {
3939
httpClient *http.Client
4040
baseURL string
41+
42+
// OnProgress, if set, is called after each batch of a keyword Search with
43+
// the cumulative number of catalog items scanned so far. Used by the CLI to
44+
// show progress during a long client-side scan. Called from Search's
45+
// goroutine (not the fetch workers), so it needs no synchronisation.
46+
OnProgress func(scanned int)
4147
}
4248

4349
// New returns a marketplace client bound to the given HTTP client.
@@ -83,6 +89,7 @@ func (c *Client) Search(ctx context.Context, query string, limit int) (*ContentL
8389
// (near-full scan) is a handful of round-trips instead of ~23 sequential
8490
// ones. Stops at `limit` matches or end-of-catalog (a short page).
8591
var matched []Content
92+
scanned := 0
8693
for page := 0; page < maxSearchPages; {
8794
batch := searchConcurrency
8895
if page == 0 {
@@ -100,10 +107,14 @@ func (c *Client) Search(ctx context.Context, query string, limit int) (*ContentL
100107
endReached := false
101108
for _, items := range pages {
102109
matched = append(matched, filterItems(items, query)...)
110+
scanned += len(items)
103111
if len(items) < pageSize {
104112
endReached = true
105113
}
106114
}
115+
if c.OnProgress != nil {
116+
c.OnProgress(scanned)
117+
}
107118
if limit > 0 && len(matched) >= limit {
108119
matched = matched[:limit]
109120
break

internal/marketplace/client_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,41 @@ func TestSearch_PaginatesPastFirstPage(t *testing.T) {
178178
}
179179
}
180180

181+
// TestSearch_OnProgress: the progress callback fires after each batch with a
182+
// monotonic, cumulative count of items scanned, ending at the total scanned.
183+
func TestSearch_OnProgress(t *testing.T) {
184+
client, _ := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
185+
switch r.URL.Query().Get("offset") {
186+
case "0":
187+
_, _ = w.Write([]byte(contentPage(1, 100, ""))) // full page
188+
case "100":
189+
_, _ = w.Write([]byte(contentPage(200, 100, ""))) // full page
190+
case "200":
191+
_, _ = w.Write([]byte(contentPage(400, 30, ""))) // short page -> end
192+
default:
193+
_, _ = w.Write([]byte(`{"items":[]}`))
194+
}
195+
})
196+
197+
var calls []int
198+
client.OnProgress = func(scanned int) { calls = append(calls, scanned) }
199+
200+
if _, err := client.Search(context.Background(), "nomatch", 20); err != nil {
201+
t.Fatal(err)
202+
}
203+
if len(calls) == 0 {
204+
t.Fatal("OnProgress was never called")
205+
}
206+
for i := 1; i < len(calls); i++ {
207+
if calls[i] < calls[i-1] {
208+
t.Errorf("progress not monotonic: %v", calls)
209+
}
210+
}
211+
if got := calls[len(calls)-1]; got != 230 { // 100 + 100 + 30
212+
t.Errorf("final scanned = %d, want 230 (total items returned)", got)
213+
}
214+
}
215+
181216
// TestSearch_FirstPageAlone: when enough matches appear on the first (full)
182217
// page, search stops there — a single request — without firing the concurrent
183218
// follow-on batch. This keeps the common case fast.

0 commit comments

Comments
 (0)