Skip to content

Commit abdaaf5

Browse files
committed
fix: Linter errors
1 parent 3b99503 commit abdaaf5

File tree

1 file changed

+331
-0
lines changed

1 file changed

+331
-0
lines changed

plan.md

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
# Plan: Make Zoxide Queries Async
2+
3+
## Problem
4+
Currently, zoxide queries are synchronous and block the UI thread on every keystroke:
5+
- `updateSuggestions()` in `src/internal/ui/zoxide/model.go:109-135` calls `m.zClient.QueryAll()` directly
6+
- This happens in `handleNormalKeyInput()` which is called from `HandleUpdate()` on every key press
7+
- Causes UI lag when typing quickly or when zoxide has many entries
8+
9+
## Solution Overview
10+
Follow the ProcessBar pattern: UI package defines its own message type, internal package wraps it in a `ModelUpdateMessage` implementation.
11+
12+
## Architecture Pattern (from ProcessBar)
13+
14+
ProcessBar demonstrates this pattern:
15+
1. `processbar.UpdateMsg` - message type in UI package with `Apply(m *Model)` method
16+
2. `ProcessBarUpdateMsg` - wrapper in internal package implementing `ModelUpdateMessage`
17+
3. `processCmdToTeaCmd()` - helper that wraps processbar.Cmd to return ProcessBarUpdateMsg
18+
4. Flow: processbar returns Cmd → wrapped → goroutine → processbar.UpdateMsg → wrapped in ProcessBarUpdateMsg → routed via ModelUpdateMessage
19+
20+
## Detailed Implementation Steps
21+
22+
### 1. Define Message Type in Zoxide Package
23+
24+
**File:** `src/internal/ui/zoxide/type.go`
25+
26+
Add new types after the `Model` struct:
27+
28+
```go
29+
// Cmd is a function that returns an UpdateMsg
30+
type Cmd func() UpdateMsg
31+
32+
// UpdateMsg represents an async query result
33+
type UpdateMsg struct {
34+
query string
35+
results []zoxidelib.Result
36+
reqID int
37+
}
38+
39+
func NewUpdateMsg(query string, results []zoxidelib.Result, reqID int) UpdateMsg {
40+
return UpdateMsg{
41+
query: query,
42+
results: results,
43+
reqID: reqID,
44+
}
45+
}
46+
47+
func (msg UpdateMsg) GetReqID() int {
48+
return msg.reqID
49+
}
50+
```
51+
52+
Add `Apply()` method in new file `src/internal/ui/zoxide/update.go`:
53+
54+
```go
55+
package zoxide
56+
57+
import "log/slog"
58+
59+
// Apply updates the zoxide modal with query results
60+
func (msg UpdateMsg) Apply(m *Model) Cmd {
61+
// Ignore stale results - only apply if query matches current input
62+
currentQuery := m.textInput.Value()
63+
if msg.query != currentQuery {
64+
slog.Debug("Ignoring stale zoxide query result",
65+
"msgQuery", msg.query,
66+
"currentQuery", currentQuery,
67+
"id", msg.reqID)
68+
return nil
69+
}
70+
71+
slog.Debug("Applying zoxide query results",
72+
"query", msg.query,
73+
"resultCount", len(msg.results),
74+
"id", msg.reqID)
75+
76+
m.results = msg.results
77+
m.cursor = 0
78+
m.renderIndex = 0
79+
80+
return nil
81+
}
82+
```
83+
84+
Add request counter to Model in `type.go`:
85+
86+
```go
87+
type Model struct {
88+
// ... existing fields ...
89+
90+
// Request tracking for async queries
91+
reqCnt int
92+
}
93+
```
94+
95+
### 2. Create Async Query Command
96+
97+
**File:** `src/internal/ui/zoxide/model.go`
98+
99+
Add method to generate async query command:
100+
101+
```go
102+
func (m *Model) GetQueryCmd(query string) Cmd {
103+
if m.zClient == nil || !common.Config.ZoxideSupport {
104+
return nil
105+
}
106+
107+
reqID := m.reqCnt
108+
m.reqCnt++
109+
110+
slog.Debug("Submitting zoxide query request", "query", query, "id", reqID)
111+
112+
return func() UpdateMsg {
113+
queryFields := strings.Fields(query)
114+
results, err := m.zClient.QueryAll(queryFields...)
115+
if err != nil {
116+
slog.Debug("Zoxide query failed", "query", query, "error", err, "id", reqID)
117+
return NewUpdateMsg(query, []zoxidelib.Result{}, reqID)
118+
}
119+
return NewUpdateMsg(query, results, reqID)
120+
}
121+
}
122+
```
123+
124+
### 3. Create Wrapper Message in Internal Package
125+
126+
**File:** `src/internal/model_msg.go`
127+
128+
Add after `NotifyModalUpdateMsg`:
129+
130+
```go
131+
type ZoxideUpdateMsg struct {
132+
BaseMessage
133+
134+
zMsg zoxide.UpdateMsg
135+
}
136+
137+
func NewZoxideUpdateMsg(zMsg zoxide.UpdateMsg) ZoxideUpdateMsg {
138+
return ZoxideUpdateMsg{
139+
zMsg: zMsg,
140+
BaseMessage: BaseMessage{
141+
reqID: zMsg.GetReqID(),
142+
},
143+
}
144+
}
145+
146+
func (msg ZoxideUpdateMsg) ApplyToModel(m *model) tea.Cmd {
147+
cmd := msg.zMsg.Apply(&m.zoxideModal)
148+
return zoxideCmdToTeaCmd(cmd)
149+
}
150+
```
151+
152+
### 4. Create Command Wrapper Helper
153+
154+
**File:** `src/internal/function.go`
155+
156+
Add after `processCmdToTeaCmd()`:
157+
158+
```go
159+
func zoxideCmdToTeaCmd(cmd zoxide.Cmd) tea.Cmd {
160+
if cmd == nil {
161+
return nil
162+
}
163+
return func() tea.Msg {
164+
updateMsg := cmd()
165+
return NewZoxideUpdateMsg(updateMsg)
166+
}
167+
}
168+
```
169+
170+
Add import:
171+
```go
172+
import (
173+
// ... existing imports ...
174+
zoxideui "github.com/yorukot/superfile/src/internal/ui/zoxide"
175+
)
176+
```
177+
178+
Use alias `zoxideui` to avoid conflict with zoxidelib.
179+
180+
### 5. Update Input Handling to Return Async Command
181+
182+
**File:** `src/internal/ui/zoxide/model.go`
183+
184+
Modify `handleNormalKeyInput()` method (around line 99-107):
185+
186+
```go
187+
func (m *Model) handleNormalKeyInput(msg tea.KeyMsg) tea.Cmd {
188+
var cmd tea.Cmd
189+
m.textInput, cmd = m.textInput.Update(msg)
190+
191+
// Return async query command instead of blocking
192+
return m.GetQueryCmd(m.textInput.Value())
193+
}
194+
```
195+
196+
Remove the `updateSuggestions()` method entirely - no longer needed.
197+
198+
Update `HandleUpdate()` to handle `UpdateMsg`:
199+
200+
```go
201+
func (m *Model) HandleUpdate(msg tea.Msg) (common.ModelAction, tea.Cmd) {
202+
slog.Debug("zoxide.Model HandleUpdate()", "msg", msg,
203+
"textInput", m.textInput.Value(),
204+
"cursorBlink", m.textInput.Cursor.Blink)
205+
var action common.ModelAction
206+
action = common.NoAction{}
207+
var cmd tea.Cmd
208+
if !m.IsOpen() {
209+
slog.Error("HandleUpdate called on closed zoxide")
210+
return action, cmd
211+
}
212+
213+
switch msg := msg.(type) {
214+
case UpdateMsg:
215+
// Handle async query results
216+
cmd = msg.Apply(m)
217+
return action, cmd
218+
case tea.KeyMsg:
219+
// ... existing key handling ...
220+
}
221+
// ... rest of method
222+
}
223+
```
224+
225+
### 6. Update Open() to Return Async Command
226+
227+
**File:** `src/internal/ui/zoxide/utils.go`
228+
229+
Change `Open()` signature and implementation:
230+
231+
```go
232+
func (m *Model) Open() Cmd {
233+
m.open = true
234+
m.justOpened = true
235+
m.textInput.SetValue("")
236+
_ = m.textInput.Focus()
237+
238+
// Return async command for initial query instead of blocking
239+
return m.GetQueryCmd("")
240+
}
241+
```
242+
243+
### 7. Update Main Model to Wrap Zoxide Commands
244+
245+
**File:** `src/internal/model.go`
246+
247+
Update `handleKeyInput()` where zoxide modal is opened (around line 433):
248+
249+
```go
250+
case m.zoxideModal.IsOpen():
251+
var action common.ModelAction
252+
action, cmd = m.zoxideModal.HandleUpdate(msg)
253+
m.applyZoxideModalAction(action)
254+
// Wrap zoxide cmd to convert to tea.Cmd
255+
return zoxideCmdToTeaCmd(cmd)
256+
```
257+
258+
**File:** `src/internal/key_function.go`
259+
260+
Update where zoxide modal is opened (search for `OpenZoxide`):
261+
262+
```go
263+
case slices.Contains(common.Hotkeys.OpenZoxide, keyPressed):
264+
zCmd := m.zoxideModal.Open()
265+
return zoxideCmdToTeaCmd(zCmd)
266+
```
267+
268+
## Testing Strategy
269+
270+
1. **Unit Tests** (`src/internal/ui/zoxide/model_test.go`)
271+
- Test `GetQueryCmd()` returns correct UpdateMsg
272+
- Test `Apply()` updates model state correctly
273+
- Test stale query results are ignored
274+
- Test request ID tracking
275+
276+
2. **Integration Test** (manual with tmux)
277+
```bash
278+
# Build and run
279+
make build
280+
tmux new-session -d -s spf_test
281+
tmux send-keys -t spf_test "./bin/spf" Enter
282+
sleep 1
283+
284+
# Open zoxide modal
285+
tmux send-keys -t spf_test "z"
286+
sleep 0.5
287+
288+
# Type quickly to test async behavior
289+
tmux send-keys -t spf_test "home"
290+
291+
# Capture output to verify results appear
292+
tmux capture-pane -t spf_test -p
293+
294+
# Cleanup
295+
tmux send-keys -t spf_test "Escape"
296+
tmux kill-session -t spf_test
297+
```
298+
299+
3. **Performance Test**
300+
- Verify no UI lag when typing quickly
301+
- Test with large zoxide database (many entries)
302+
- Verify stale results don't override newer ones
303+
304+
## Edge Cases to Handle
305+
306+
1. **Stale results**: Query for "home" completes after query for "homedir" - ignore old results
307+
2. **Modal closed**: Don't apply results if modal was closed while query was in flight
308+
3. **Nil client**: GetQueryCmd() should return nil if zClient is nil
309+
4. **Empty results**: Handle empty result sets gracefully
310+
311+
## Files to Modify
312+
313+
1. `src/internal/ui/zoxide/type.go` - Add UpdateMsg, Cmd types, reqCnt field
314+
2. `src/internal/ui/zoxide/update.go` - NEW FILE - Add Apply() method
315+
3. `src/internal/ui/zoxide/model.go` - Add GetQueryCmd(), modify handleNormalKeyInput(), remove updateSuggestions(), handle UpdateMsg in HandleUpdate()
316+
4. `src/internal/ui/zoxide/utils.go` - Change Open() to return Cmd
317+
5. `src/internal/model_msg.go` - Add ZoxideUpdateMsg wrapper
318+
6. `src/internal/function.go` - Add zoxideCmdToTeaCmd() helper
319+
7. `src/internal/model.go` - Wrap zoxide commands in handleKeyInput()
320+
8. `src/internal/key_function.go` - Wrap zoxide Open() command
321+
322+
## Success Criteria
323+
324+
- [ ] No blocking calls to QueryAll() in UI thread
325+
- [ ] Query results update asynchronously
326+
- [ ] Stale results are ignored
327+
- [ ] No UI lag when typing quickly
328+
- [ ] Unit tests pass: `go test ./src/internal/ui/zoxide/...`
329+
- [ ] All tests pass: `go test ./...`
330+
- [ ] Linter passes: `golangci-lint run`
331+
- [ ] Binary builds successfully: `CGO_ENABLED=0 go build -o ./bin/spf`

0 commit comments

Comments
 (0)