Skip to content

Commit 160d948

Browse files
authored
fix(browse): refresh correct column after async document copy (#54)
The copiedMsg handler re-derived its refresh target from the live m.activeColumn. Navigation keys are not gated on m.loading, so moving columns during the async copy round-trip left activeColumn pointing at an unrelated column by the time copiedMsg arrived — refreshing the wrong Miller column (or none). Capture the originating collection path in copiedMsg at copy time and match it to a column in the handler via a new copyRefreshIndex helper, instead of reading live state. If that column was navigated away, refresh nothing. Adds a unit test exercising the race (active column moved / origin column removed) and asserts collectionPath in the integration tests. Also drops the unused fromDocView parameter from executeCopy and the now-dead m.copyFromDocView field that only fed it. Co-authored-by: Gustavo Hoirisch <355877+gugahoi@users.noreply.github.com>
1 parent 5977a3a commit 160d948

6 files changed

Lines changed: 156 additions & 36 deletions

File tree

pkg/cmd/browse/command.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -473,18 +473,15 @@ func cmdCopy(m *Model, args []string) (string, error) {
473473

474474
col := m.columns[m.activeColumn]
475475
var src string
476-
var fromDocView bool
477476

478477
if col.isDoc {
479478
src = col.path
480-
fromDocView = true
481479
} else {
482480
item := m.getSelectedItem()
483481
if item == nil || !item.isDoc {
484482
return "", fmt.Errorf("select a document to copy")
485483
}
486484
src = item.path
487-
fromDocView = false
488485
}
489486

490487
dst := ""
@@ -497,9 +494,8 @@ func cmdCopy(m *Model, args []string) (string, error) {
497494
}
498495

499496
m.copySrc = src
500-
m.copyFromDocView = fromDocView
501497
m.loading = true
502-
m.pendingCmd = executeCopy(m.client, src, dst, fromDocView)
498+
m.pendingCmd = executeCopy(m.client, src, dst)
503499
return "", nil
504500
}
505501

pkg/cmd/browse/copy.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ import (
1313
)
1414

1515
// copiedMsg is emitted once the source document's field data has been copied
16-
// into a new document. newPath is the path of the freshly created document and
17-
// hadSubcollections reports whether the source had subcollections (which are
18-
// not copied) so the caller can surface a note.
16+
// into a new document. newPath is the path of the freshly created document,
17+
// collectionPath is the collection the new sibling now lives in (captured at
18+
// copy time so the handler can refresh the correct column even if the user
19+
// navigated during the async copy), and hadSubcollections reports whether the
20+
// source had subcollections (which are not copied) so the caller can surface a
21+
// note.
1922
type copiedMsg struct {
2023
newPath string
24+
collectionPath string
2125
hadSubcollections bool
2226
}
2327

@@ -33,7 +37,7 @@ type copyRefusedMsg struct {
3337
// otherwise dst is used and the copy is refused if a document already exists
3438
// there. Only top-level field data is copied — subcollections are left behind.
3539
// The source is never modified.
36-
func executeCopy(client *firestore.Client, src, dst string, fromDocView bool) tea.Cmd {
40+
func executeCopy(client *firestore.Client, src, dst string) tea.Cmd {
3741
return func() tea.Msg {
3842
ctx := context.Background()
3943
srcRef := client.Doc(strings.TrimPrefix(src, "/"))
@@ -64,7 +68,8 @@ func executeCopy(client *firestore.Client, src, dst string, fromDocView bool) te
6468
if _, err := dstRef.Set(ctx, snap.Data()); err != nil {
6569
return errorMsg{err: fmt.Errorf("failed to write copy: %w", err)}
6670
}
67-
return copiedMsg{newPath: parent + "/" + dstRef.ID, hadSubcollections: hadSub}
71+
newPath := parent + "/" + dstRef.ID
72+
return copiedMsg{newPath: newPath, collectionPath: parentCollectionPath(newPath), hadSubcollections: hadSub}
6873
}
6974

7075
// Explicit destination — refuse if something is already there.
@@ -75,7 +80,8 @@ func executeCopy(client *firestore.Client, src, dst string, fromDocView bool) te
7580
}
7681
return errorMsg{err: fmt.Errorf("failed to write copy: %w", err)}
7782
}
78-
return copiedMsg{newPath: strings.Trim(dst, "/"), hadSubcollections: hadSub}
83+
newPath := strings.Trim(dst, "/")
84+
return copiedMsg{newPath: newPath, collectionPath: parentCollectionPath(newPath), hadSubcollections: hadSub}
7985
}
8086
}
8187

@@ -90,3 +96,25 @@ func parentCollectionPath(docPath string) string {
9096
}
9197
return p[:idx]
9298
}
99+
100+
// copyRefreshIndex returns the index of the collection column that should be
101+
// refreshed after a copy completes, identified by the collection path captured
102+
// in copiedMsg at copy-initiation time. It deliberately does NOT consult
103+
// m.activeColumn: the user may have navigated during the async copy, so the
104+
// live active column can no longer be trusted to point at the originating
105+
// collection. Returns -1 when no current column matches (e.g. the column was
106+
// removed via navigation), in which case nothing should be refreshed.
107+
func (m Model) copyRefreshIndex(collectionPath string) int {
108+
if collectionPath == "" {
109+
return -1
110+
}
111+
for idx, col := range m.columns {
112+
if col.isDoc {
113+
continue
114+
}
115+
if strings.TrimPrefix(col.path, "/") == collectionPath {
116+
return idx
117+
}
118+
}
119+
return -1
120+
}

pkg/cmd/browse/copy_integration_test.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,17 @@ func TestExecuteCopy_AutoID(t *testing.T) {
1919
t.Fatalf("seed: %v", err)
2020
}
2121

22-
msg := executeCopy(client, src, "", false)()
22+
msg := executeCopy(client, src, "")()
2323
copied, ok := msg.(copiedMsg)
2424
if !ok {
2525
t.Fatalf("expected copiedMsg, got %#v", msg)
2626
}
2727
if copied.newPath == src {
2828
t.Fatalf("auto copy reused source path %s", src)
2929
}
30+
if copied.collectionPath != "copy_users" {
31+
t.Errorf("collectionPath = %q, want %q", copied.collectionPath, "copy_users")
32+
}
3033
if copied.hadSubcollections {
3134
t.Error("hadSubcollections = true, want false")
3235
}
@@ -55,14 +58,17 @@ func TestExecuteCopy_ExplicitID(t *testing.T) {
5558
t.Fatalf("seed: %v", err)
5659
}
5760

58-
msg := executeCopy(client, src, dst, false)()
61+
msg := executeCopy(client, src, dst)()
5962
copied, ok := msg.(copiedMsg)
6063
if !ok {
6164
t.Fatalf("expected copiedMsg, got %#v", msg)
6265
}
6366
if copied.newPath != dst {
6467
t.Errorf("newPath = %q, want %q", copied.newPath, dst)
6568
}
69+
if copied.collectionPath != "copy_users" {
70+
t.Errorf("collectionPath = %q, want %q", copied.collectionPath, "copy_users")
71+
}
6672

6773
// Both source and copy exist with the same data.
6874
if _, err := client.Doc(src).Get(ctx); err != nil {
@@ -90,7 +96,7 @@ func TestExecuteCopy_RefusesExistingDestination(t *testing.T) {
9096
t.Fatalf("seed dst: %v", err)
9197
}
9298

93-
msg := executeCopy(client, src, dst, false)()
99+
msg := executeCopy(client, src, dst)()
94100
if _, ok := msg.(copyRefusedMsg); !ok {
95101
t.Fatalf("expected copyRefusedMsg for existing destination, got %#v", msg)
96102
}
@@ -119,7 +125,7 @@ func TestExecuteCopy_PhantomDocument(t *testing.T) {
119125

120126
// A phantom source has no field data, so copy is refused with a transient
121127
// message rather than a sticky error.
122-
msg := executeCopy(client, src, "", false)()
128+
msg := executeCopy(client, src, "")()
123129
if _, ok := msg.(copyRefusedMsg); !ok {
124130
t.Fatalf("expected copyRefusedMsg for phantom document, got %#v", msg)
125131
}
@@ -138,7 +144,7 @@ func TestExecuteCopy_IgnoresSubcollections(t *testing.T) {
138144
t.Fatalf("seed subcollection: %v", err)
139145
}
140146

141-
msg := executeCopy(client, src, dst, false)()
147+
msg := executeCopy(client, src, dst)()
142148
copied, ok := msg.(copiedMsg)
143149
if !ok {
144150
t.Fatalf("expected copiedMsg, got %#v", msg)

pkg/cmd/browse/copy_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package browse
2+
3+
import "testing"
4+
5+
// TestCopyRefreshIndex verifies that the post-copy refresh target is resolved
6+
// from the collection path captured at copy time, NOT from the live
7+
// activeColumn. This is the regression guard for the race where the user
8+
// navigates during an in-flight async copy: by the time copiedMsg arrives,
9+
// activeColumn no longer points at the originating collection.
10+
func TestCopyRefreshIndex(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
columns []Column
14+
activeColumn int
15+
collectionPath string
16+
want int
17+
}{
18+
{
19+
name: "origin collection found regardless of active column",
20+
columns: []Column{
21+
{path: "users", isDoc: false},
22+
{path: "users/alice", isDoc: true},
23+
{path: "users/alice/orders", isDoc: false},
24+
},
25+
// User navigated two columns to the right during the copy.
26+
activeColumn: 2,
27+
collectionPath: "users",
28+
want: 0,
29+
},
30+
{
31+
name: "origin column removed by navigation -> no refresh",
32+
columns: []Column{
33+
{path: "products", isDoc: false},
34+
},
35+
// The originating "users" collection is no longer on screen.
36+
activeColumn: 0,
37+
collectionPath: "users",
38+
want: -1,
39+
},
40+
{
41+
name: "matches collection column, not same-path document column",
42+
columns: []Column{
43+
{path: "users", isDoc: false},
44+
{path: "users/alice", isDoc: true},
45+
},
46+
activeColumn: 1,
47+
collectionPath: "users",
48+
want: 0,
49+
},
50+
{
51+
name: "tolerates leading slash on column path",
52+
columns: []Column{
53+
{path: "/users", isDoc: false},
54+
},
55+
activeColumn: 0,
56+
collectionPath: "users",
57+
want: 0,
58+
},
59+
{
60+
name: "empty collection path -> no refresh",
61+
columns: []Column{
62+
{path: "users", isDoc: false},
63+
},
64+
activeColumn: 0,
65+
collectionPath: "",
66+
want: -1,
67+
},
68+
{
69+
name: "nested subcollection origin",
70+
columns: []Column{
71+
{path: "users", isDoc: false},
72+
{path: "users/alice", isDoc: true},
73+
{path: "users/alice/orders", isDoc: false},
74+
},
75+
// Copy of a doc inside the orders subcollection; active column has
76+
// since moved back to the root.
77+
activeColumn: 0,
78+
collectionPath: "users/alice/orders",
79+
want: 2,
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
m := Model{
86+
columns: tt.columns,
87+
activeColumn: tt.activeColumn,
88+
}
89+
if got := m.copyRefreshIndex(tt.collectionPath); got != tt.want {
90+
t.Errorf("copyRefreshIndex(%q) = %d, want %d", tt.collectionPath, got, tt.want)
91+
}
92+
})
93+
}
94+
}

pkg/cmd/browse/model.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
type Mode int
1515

1616
const (
17-
ModeNormal Mode = iota
17+
ModeNormal Mode = iota
1818
ModeVisual
1919
ModeCommand
2020
)
@@ -60,8 +60,8 @@ type Model struct {
6060
err error
6161

6262
// Mode system
63-
mode Mode
64-
overlay Overlay
63+
mode Mode
64+
overlay Overlay
6565
textInput textinput.Model
6666

6767
// Command mode
@@ -107,8 +107,8 @@ type Model struct {
107107
previewPending string // path of pending debounced fetch
108108

109109
// Delete confirmation
110-
deletePath string // Path of document pending deletion
111-
deleteFromDocView bool // Whether delete was initiated from a document column
110+
deletePath string // Path of document pending deletion
111+
deleteFromDocView bool // Whether delete was initiated from a document column
112112
bulkDeletePaths []string // Paths for bulk delete in visual mode
113113

114114
// Rename / move
@@ -117,8 +117,7 @@ type Model struct {
117117
renameFromDocView bool // Whether rename was initiated from a document column
118118

119119
// Copy / duplicate
120-
copySrc string // Source path of document being copied
121-
copyFromDocView bool // Whether copy was initiated from a document column
120+
copySrc string // Source path of document being copied
122121

123122
// Filter
124123
filterInput textinput.Model

pkg/cmd/browse/update.go

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
380380
m.statusMsg = status
381381
m.statusMsgTime = time.Now()
382382

383-
// The new sibling lives in the collection column. When copying from a
384-
// document column, that collection is one column to the left; the source
385-
// still exists, so the column is kept (no removeLastColumn).
386-
refreshIdx := m.activeColumn
387-
if refreshIdx < len(m.columns) && m.columns[refreshIdx].isDoc {
388-
refreshIdx--
389-
}
390-
if refreshIdx >= 0 && refreshIdx < len(m.columns) {
383+
// Refresh the column showing the originating collection so the new
384+
// sibling appears. The target is matched against the collection path
385+
// captured in the message at copy time — never re-derived from
386+
// m.activeColumn, which may have moved if the user navigated during the
387+
// async copy. If that column is no longer present, refresh nothing.
388+
refreshIdx := m.copyRefreshIndex(msg.collectionPath)
389+
if refreshIdx >= 0 {
391390
col := m.columns[refreshIdx]
392391
sortField, sortDir := m.getSortParams(col.path)
393392
m.loading = true
@@ -771,7 +770,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
771770

772771
if col.isDoc {
773772
srcPath = col.path
774-
m.copyFromDocView = true
775773
} else {
776774
item := m.getSelectedItem()
777775
if item == nil || !item.isDoc {
@@ -780,7 +778,6 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
780778
return m, clearStatusAfterDelay()
781779
}
782780
srcPath = item.path
783-
m.copyFromDocView = false
784781
}
785782

786783
m.copySrc = srcPath
@@ -938,10 +935,10 @@ func (m Model) handleOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
938935
dst = resolved
939936
}
940937

941-
src, fromDocView := m.copySrc, m.copyFromDocView
938+
src := m.copySrc
942939
m.copySrc = ""
943940
m.loading = true
944-
return m, executeCopy(m.client, src, dst, fromDocView)
941+
return m, executeCopy(m.client, src, dst)
945942
case "esc":
946943
m.overlay = OverlayNone
947944
m.textInput.Blur()
@@ -1022,7 +1019,7 @@ func (m Model) handleOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
10221019
func (m Model) applySortAndClose() (tea.Model, tea.Cmd) {
10231020
// Get selected field (text input takes priority)
10241021
field := m.sortDialog.getSelectedField()
1025-
1022+
10261023
if field == "" {
10271024
m.statusMsg = "No field selected"
10281025
m.statusMsgTime = time.Now()

0 commit comments

Comments
 (0)