Skip to content

Commit 576d286

Browse files
greynewellclaude
andcommitted
feat(watch): show syncing indicator when incremental update starts
Feature requested by @JustSuperHuman. Adds an OnSyncing callback to DaemonConfig, called just before the incremental API call so users see immediate feedback when a file change is detected. The Watch command implements it as: ↻ Syncing N file(s)... followed by the existing ✓ Updated line once the response arrives. Also introduces an analyzeClient interface on Daemon so the callback ordering can be tested without a real HTTP server. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fbaeaeb commit 576d286

File tree

3 files changed

+124
-1
lines changed

3 files changed

+124
-1
lines changed

internal/shards/daemon.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,22 @@ type DaemonConfig struct {
2929
LogFunc func(string, ...interface{})
3030
// OnReady is called once after the initial generate completes.
3131
OnReady func(GraphStats)
32+
// OnSyncing is called when an incremental API call starts, before the
33+
// response arrives. n is the number of changed files being processed.
34+
OnSyncing func(n int)
3235
// OnUpdate is called after each incremental update completes.
3336
OnUpdate func(GraphStats)
3437
}
3538

39+
// analyzeClient is the subset of api.Client used by the daemon.
40+
type analyzeClient interface {
41+
AnalyzeShards(ctx context.Context, zipPath, idempotencyKey string, previousDomains []api.PreviousDomain) (*api.ShardIR, error)
42+
}
43+
3644
// Daemon watches for file changes and keeps shards fresh.
3745
type Daemon struct {
3846
cfg DaemonConfig
39-
client *api.Client
47+
client analyzeClient
4048
cache *Cache
4149
logf func(string, ...interface{})
4250

@@ -251,6 +259,10 @@ func (d *Daemon) incrementalUpdate(ctx context.Context, changedFiles []string) {
251259
}
252260
defer os.Remove(zipPath)
253261

262+
if d.cfg.OnSyncing != nil {
263+
d.cfg.OnSyncing(len(changedFiles))
264+
}
265+
254266
ir, err := d.client.AnalyzeShards(ctx, zipPath, "incremental-"+idemKey[:8], nil)
255267
if err != nil {
256268
d.logf("Incremental API error: %v", err)

internal/shards/daemon_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package shards
22

33
import (
4+
"context"
45
"strings"
56
"testing"
67

@@ -862,3 +863,103 @@ func TestNewUUID_Version4Bits(t *testing.T) {
862863
t.Errorf("expected version nibble '4' at start of 3rd group, got %q", parts[2][0])
863864
}
864865
}
866+
867+
// ── OnSyncing callback ────────────────────────────────────────────────────────
868+
869+
// mockAnalyzeClient is a minimal analyzeClient that returns a fixed ShardIR.
870+
type mockAnalyzeClient struct {
871+
result *api.ShardIR
872+
err error
873+
called int
874+
}
875+
876+
func (m *mockAnalyzeClient) AnalyzeShards(_ context.Context, _, _ string, _ []api.PreviousDomain) (*api.ShardIR, error) {
877+
m.called++
878+
return m.result, m.err
879+
}
880+
881+
// orderClient records the call order of OnSyncing vs AnalyzeShards.
882+
type orderClient struct {
883+
inner analyzeClient
884+
onCall func()
885+
}
886+
887+
func (o *orderClient) AnalyzeShards(ctx context.Context, zipPath, key string, prev []api.PreviousDomain) (*api.ShardIR, error) {
888+
if o.onCall != nil {
889+
o.onCall()
890+
}
891+
return o.inner.AnalyzeShards(ctx, zipPath, key, prev)
892+
}
893+
894+
func TestOnSyncing_CalledWithCorrectFileCount(t *testing.T) {
895+
var gotN int
896+
d := &Daemon{
897+
cfg: DaemonConfig{
898+
RepoDir: t.TempDir(),
899+
OnSyncing: func(n int) {
900+
gotN = n
901+
},
902+
},
903+
client: &mockAnalyzeClient{result: buildIR(nil, nil)},
904+
cache: NewCache(),
905+
ir: buildIR(nil, nil),
906+
logf: func(string, ...interface{}) {},
907+
}
908+
d.incrementalUpdate(context.Background(), []string{"a.go", "b.go", "c.go"})
909+
if gotN != 3 {
910+
t.Errorf("OnSyncing got n=%d, want 3", gotN)
911+
}
912+
}
913+
914+
func TestOnSyncing_CalledBeforeAPIResponse(t *testing.T) {
915+
seq := 0
916+
syncingAt := 0
917+
apiAt := 0
918+
919+
mock := &mockAnalyzeClient{result: buildIR(nil, nil)}
920+
d := &Daemon{
921+
cfg: DaemonConfig{
922+
RepoDir: t.TempDir(),
923+
OnSyncing: func(int) {
924+
seq++
925+
syncingAt = seq
926+
},
927+
},
928+
client: &orderClient{
929+
inner: mock,
930+
onCall: func() {
931+
seq++
932+
apiAt = seq
933+
},
934+
},
935+
cache: NewCache(),
936+
ir: buildIR(nil, nil),
937+
logf: func(string, ...interface{}) {},
938+
}
939+
d.incrementalUpdate(context.Background(), []string{"a.go"})
940+
941+
if syncingAt == 0 {
942+
t.Fatal("OnSyncing was never called")
943+
}
944+
if apiAt == 0 {
945+
t.Fatal("AnalyzeShards was never called")
946+
}
947+
if syncingAt >= apiAt {
948+
t.Errorf("OnSyncing (seq %d) should fire before AnalyzeShards (seq %d)", syncingAt, apiAt)
949+
}
950+
}
951+
952+
func TestOnSyncing_NilSafe(t *testing.T) {
953+
// OnSyncing=nil should not panic.
954+
d := &Daemon{
955+
cfg: DaemonConfig{
956+
RepoDir: t.TempDir(),
957+
OnSyncing: nil,
958+
},
959+
client: &mockAnalyzeClient{result: buildIR(nil, nil)},
960+
cache: NewCache(),
961+
ir: buildIR(nil, nil),
962+
logf: func(string, ...interface{}) {},
963+
}
964+
d.incrementalUpdate(context.Background(), []string{"a.go"})
965+
}

internal/shards/handler.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,16 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption
263263
line += fmt.Sprintf(" %s(%s)%s\n\n", ansiDim, src, ansiReset)
264264
fmt.Print(line)
265265
},
266+
OnSyncing: func(n int) {
267+
noun := "file"
268+
if n != 1 {
269+
noun = "files"
270+
}
271+
fmt.Printf(" %s↻%s Syncing %s%d %s%s...\n",
272+
ansiDim, ansiReset,
273+
ansiBold, n, noun, ansiReset,
274+
)
275+
},
266276
OnUpdate: func(s GraphStats) {
267277
fmt.Printf(" %s✓%s Updated — %s%d files%s · %s%d functions%s · %s%d relationships%s\n",
268278
ansiGreen, ansiReset,

0 commit comments

Comments
 (0)