Skip to content

Commit e4a87df

Browse files
authored
fix: Zoxide improvements and 1.4.0-rc2 (#1105)
- #1102 - #1104 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Revamped Zoxide modal: asynchronous live search, scored results, highlighted selection, scroll indicators, responsive sizing, and a text-input accessor. - Bug Fixes - Ignore alphanumeric keys to prevent accidental navigation while typing, avoid applying stale results, and improve cursor/render bounds and path handling. - Tests - Added extensive zoxide unit and integration tests for model, UI, rendering, navigation, and utilities; streamlined test helpers. - Chores - Bumped prerelease/version identifiers and converted minimum size values to constants. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents b69ac02 + a668b8b commit e4a87df

File tree

17 files changed

+846
-182
lines changed

17 files changed

+846
-182
lines changed

release/release.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env -S bash -euo pipefail
22

33
projectName="superfile"
4-
version="v1.4.0-rc1"
4+
version="v1.4.0-rc2"
55
osList=("darwin" "linux" "windows")
66
archList=("amd64" "arm64")
77
mkdir dist

src/config/fixed_variable.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const (
1515
CurrentVersion = "v1.4.0"
1616
// Allowing pre-releases with non production version
1717
// Set this to "" for production releases
18-
PreReleaseSuffix = "-rc1"
18+
PreReleaseSuffix = "-rc2"
1919

2020
// This gives most recent non-prerelease, non-draft release
2121
LatestVersionURL = "https://api.github.com/repos/yorukot/superfile/releases/latest"

src/internal/common/predefined_variable.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const TrashWarnContent = "This operation will move file or directory to trash ca
2020
const PermanentDeleteWarnTitle = "Are you sure you want to completely delete"
2121
const PermanentDeleteWarnContent = "This operation cannot be undone and your data will be completely lost."
2222

23-
var (
23+
const (
2424
MinimumHeight = 24
2525
MinimumWidth = 60
2626

src/internal/key_function.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func (m *model) mainKey(msg string) tea.Cmd { //nolint: gocyclo,cyclop,funlen //
105105
case slices.Contains(common.Hotkeys.OpenSPFPrompt, msg):
106106
m.promptModal.Open(false)
107107
case slices.Contains(common.Hotkeys.OpenZoxide, msg):
108-
m.zoxideModal.Open()
108+
return m.zoxideModal.Open()
109109

110110
case slices.Contains(common.Hotkeys.OpenHelpMenu, msg):
111111
m.openHelpMenu()

src/internal/model.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/charmbracelet/x/ansi"
2424

2525
variable "github.com/yorukot/superfile/src/config"
26+
zoxideui "github.com/yorukot/superfile/src/internal/ui/zoxide"
2627
stringfunction "github.com/yorukot/superfile/src/pkg/string_function"
2728
)
2829

@@ -85,6 +86,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8586
m.handleMouseMsg(msg)
8687
case tea.KeyMsg:
8788
inputCmd = m.handleKeyInput(msg)
89+
90+
// Has to handle zoxide messages separately as they could be generated via
91+
// zoxide update commands, or batched commands from textinput
92+
// Cannot do it like processbar messages
93+
case zoxideui.UpdateMsg:
94+
slog.Debug("Got ModelUpdate message", "id", msg.GetReqID())
95+
gotModelUpdateMsg = true
96+
updateCmd = msg.Apply(&m.zoxideModal)
8897
case ModelUpdateMessage:
8998
// TODO: Some of these updates messages should trigger filePanel state update
9099
// For example a success message for delete operation
@@ -411,6 +420,7 @@ func (m *model) handleKeyInput(msg tea.KeyMsg) tea.Cmd {
411420
func (m *model) updateFilePanelsState(msg tea.Msg) tea.Cmd {
412421
focusPanel := &m.fileModel.filePanels[m.filePanelFocusIndex]
413422
var cmd tea.Cmd
423+
var action common.ModelAction
414424
switch {
415425
case m.firstTextInput:
416426
m.firstTextInput = false
@@ -421,15 +431,11 @@ func (m *model) updateFilePanelsState(msg tea.Msg) tea.Cmd {
421431
case m.typingModal.open:
422432
m.typingModal.textInput, cmd = m.typingModal.textInput.Update(msg)
423433
case m.promptModal.IsOpen():
424-
// *cmd is a non-name, and cannot be used on left of :=
425-
var action common.ModelAction
426-
// Taking returned cmd is necessary for blinking
427434
// TODO : Separate this to a utility
428435
cwdLocation := m.fileModel.filePanels[m.filePanelFocusIndex].location
429436
action, cmd = m.promptModal.HandleUpdate(msg, cwdLocation)
430437
m.applyPromptModalAction(action)
431438
case m.zoxideModal.IsOpen():
432-
var action common.ModelAction
433439
action, cmd = m.zoxideModal.HandleUpdate(msg)
434440
m.applyZoxideModalAction(action)
435441
}

src/internal/model_test.go

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8-
"runtime"
98
"testing"
109

11-
zoxidelib "github.com/lazysegtree/go-zoxide"
1210
"github.com/stretchr/testify/assert"
1311
"github.com/stretchr/testify/require"
1412

@@ -247,72 +245,3 @@ func TestChooserFile(t *testing.T) {
247245
})
248246
}
249247
}
250-
251-
func TestZoxide(t *testing.T) {
252-
zoxideDataDir := t.TempDir()
253-
zClient, err := zoxidelib.New(zoxidelib.WithDataDir(zoxideDataDir))
254-
if err != nil {
255-
if runtime.GOOS != utils.OsLinux {
256-
t.Skipf("Skipping zoxide tests in non-Linux because zoxide client cannot be initialized")
257-
} else {
258-
t.Fatalf("zoxide initialization failed")
259-
}
260-
}
261-
262-
originalZoxideSupport := common.Config.ZoxideSupport
263-
defer func() {
264-
common.Config.ZoxideSupport = originalZoxideSupport
265-
}()
266-
267-
curTestDir := filepath.Join(testDir, "TestZoxide")
268-
dir1 := filepath.Join(curTestDir, "dir1")
269-
dir2 := filepath.Join(curTestDir, "dir2")
270-
dir3 := filepath.Join(curTestDir, "dir3")
271-
utils.SetupDirectories(t, curTestDir, dir1, dir2, dir3)
272-
273-
t.Run("Zoxide tracking and navigation", func(t *testing.T) {
274-
common.Config.ZoxideSupport = true
275-
m := defaultTestModelWithZClient(zClient, dir1)
276-
277-
err := m.updateCurrentFilePanelDir(dir2)
278-
require.NoError(t, err, "Failed to navigate to dir2")
279-
assert.Equal(t, dir2, m.getFocusedFilePanel().location, "Should be in dir2 after navigation")
280-
281-
err = m.updateCurrentFilePanelDir(dir3)
282-
require.NoError(t, err, "Failed to navigate to dir3")
283-
assert.Equal(t, dir3, m.getFocusedFilePanel().location, "Should be in dir3 after navigation")
284-
285-
TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenZoxide[0]))
286-
assert.True(t, m.zoxideModal.IsOpen(), "Zoxide modal should open when pressing 'z' key")
287-
288-
// Type "dir2" to search for it
289-
for _, char := range "dir2" {
290-
TeaUpdate(m, utils.TeaRuneKeyMsg(string(char)))
291-
}
292-
293-
results := m.zoxideModal.GetResults()
294-
assert.GreaterOrEqual(t, len(results), 1, "Should have at least 1 directory found by zoxide UI search")
295-
296-
resultPaths := make([]string, len(results))
297-
for i, result := range results {
298-
resultPaths[i] = result.Path
299-
}
300-
assert.Contains(t, resultPaths, dir2, "dir2 should be found by zoxide UI search")
301-
302-
// Press enter to navigate to dir2
303-
TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.ConfirmTyping[0]))
304-
assert.False(t, m.zoxideModal.IsOpen(), "Zoxide modal should close after navigation")
305-
assert.Equal(t, dir2, m.getFocusedFilePanel().location, "Should navigate back to dir2 after zoxide selection")
306-
})
307-
308-
t.Run("Zoxide disabled shows no results", func(t *testing.T) {
309-
common.Config.ZoxideSupport = false
310-
m := defaultTestModelWithZClient(zClient, dir1)
311-
312-
TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenZoxide[0]))
313-
assert.True(t, m.zoxideModal.IsOpen(), "Zoxide modal should open even when ZoxideSupport is disabled")
314-
315-
results := m.zoxideModal.GetResults()
316-
assert.Empty(t, results, "Zoxide modal should show no results when ZoxideSupport is disabled")
317-
})
318-
}

src/internal/model_zoxide_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package internal
2+
3+
import (
4+
"path/filepath"
5+
"runtime"
6+
"testing"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
zoxidelib "github.com/lazysegtree/go-zoxide"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/yorukot/superfile/src/internal/common"
14+
"github.com/yorukot/superfile/src/internal/utils"
15+
)
16+
17+
func setupProgAndOpenZoxide(t *testing.T, zClient *zoxidelib.Client, dir string) *TeaProg {
18+
t.Helper()
19+
common.Config.ZoxideSupport = true
20+
m := defaultTestModelWithZClient(zClient, dir)
21+
p := NewTestTeaProgWithEventLoop(t, m)
22+
23+
p.SendKey(common.Hotkeys.OpenZoxide[0])
24+
assert.Eventually(t, func() bool {
25+
return p.getModel().zoxideModal.IsOpen()
26+
}, DefaultTestTimeout, DefaultTestTick, "Zoxide modal should open")
27+
return p
28+
}
29+
30+
func updateCurrentFilePanelDirOfTestModel(t *testing.T, p *TeaProg, dir string) {
31+
err := p.getModel().updateCurrentFilePanelDir(dir)
32+
require.NoError(t, err, "Failed to navigate to %s", dir)
33+
assert.Equal(t, dir, p.getModel().getFocusedFilePanel().location, "Should be in %s after navigation", dir)
34+
}
35+
36+
func TestZoxide(t *testing.T) {
37+
zoxideDataDir := t.TempDir()
38+
zClient, err := zoxidelib.New(zoxidelib.WithDataDir(zoxideDataDir))
39+
if err != nil {
40+
if runtime.GOOS != utils.OsLinux {
41+
t.Skipf("Skipping zoxide tests in non-Linux because zoxide client cannot be initialized")
42+
} else {
43+
t.Fatalf("zoxide initialization failed")
44+
}
45+
}
46+
47+
originalZoxideSupport := common.Config.ZoxideSupport
48+
defer func() {
49+
common.Config.ZoxideSupport = originalZoxideSupport
50+
}()
51+
52+
curTestDir := filepath.Join(testDir, "TestZoxide")
53+
dir1 := filepath.Join(curTestDir, "dir1")
54+
dir2 := filepath.Join(curTestDir, "dir2")
55+
dir3 := filepath.Join(curTestDir, "dir3")
56+
multiSpaceDir := filepath.Join(curTestDir, "test dir")
57+
utils.SetupDirectories(t, curTestDir, dir1, dir2, dir3, multiSpaceDir)
58+
59+
t.Run("Zoxide tracking and navigation", func(t *testing.T) {
60+
p := setupProgAndOpenZoxide(t, zClient, dir1)
61+
updateCurrentFilePanelDirOfTestModel(t, p, dir2)
62+
updateCurrentFilePanelDirOfTestModel(t, p, dir3)
63+
64+
p.SendKey("dir2")
65+
assert.Eventually(t, func() bool {
66+
results := p.getModel().zoxideModal.GetResults()
67+
return len(results) == 1 && results[0].Path == dir2
68+
}, DefaultTestTimeout, DefaultTestTick, "dir2 should be found by zoxide UI search")
69+
70+
// Press enter to navigate to dir2
71+
p.SendKey(common.Hotkeys.ConfirmTyping[0])
72+
assert.Eventually(t, func() bool {
73+
return !p.getModel().zoxideModal.IsOpen()
74+
}, DefaultTestTimeout, DefaultTestTick, "Zoxide modal should close after navigation")
75+
assert.Equal(t, dir2, p.getModel().getFocusedFilePanel().location,
76+
"Should navigate back to dir2 after zoxide selection")
77+
})
78+
79+
t.Run("Zoxide disabled shows no results", func(t *testing.T) {
80+
common.Config.ZoxideSupport = false
81+
m := defaultTestModelWithZClient(zClient, dir1)
82+
83+
TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenZoxide[0]))
84+
assert.True(t, m.zoxideModal.IsOpen(), "Zoxide modal should open even when ZoxideSupport is disabled")
85+
86+
results := m.zoxideModal.GetResults()
87+
assert.Empty(t, results, "Zoxide modal should show no results when ZoxideSupport is disabled")
88+
})
89+
90+
t.Run("Zoxide modal size on window resize", func(t *testing.T) {
91+
p := setupProgAndOpenZoxide(t, zClient, dir1)
92+
93+
initialWidth := p.getModel().zoxideModal.GetWidth()
94+
initialMaxHeight := p.getModel().zoxideModal.GetMaxHeight()
95+
96+
p.SendDirectly(tea.WindowSizeMsg{Width: 2 * DefaultTestModelWidth, Height: 2 * DefaultTestModelHeight})
97+
98+
updatedWidth := p.getModel().zoxideModal.GetWidth()
99+
updatedMaxHeight := p.getModel().zoxideModal.GetMaxHeight()
100+
assert.Greater(t, updatedWidth, initialWidth, "Width should increase with larger window")
101+
assert.Greater(t, updatedMaxHeight, initialMaxHeight, "MaxHeight should increase with larger window")
102+
})
103+
104+
t.Run("Zoxide 'z' key suppression on open", func(t *testing.T) {
105+
p := setupProgAndOpenZoxide(t, zClient, dir1)
106+
assert.Empty(t, p.getModel().zoxideModal.GetTextInputValue(),
107+
"The 'z' key should not be added to textInput")
108+
p.SendKeyDirectly("abc")
109+
assert.Equal(t, "abc", p.getModel().zoxideModal.GetTextInputValue())
110+
})
111+
112+
t.Run("Multi-space directory name navigation", func(t *testing.T) {
113+
p := setupProgAndOpenZoxide(t, zClient, dir1)
114+
115+
updateCurrentFilePanelDirOfTestModel(t, p, multiSpaceDir)
116+
updateCurrentFilePanelDirOfTestModel(t, p, dir1)
117+
118+
p.SendKey(filepath.Base(multiSpaceDir))
119+
assert.Eventually(t, func() bool {
120+
results := p.getModel().zoxideModal.GetResults()
121+
for _, result := range results {
122+
if result.Path == multiSpaceDir {
123+
return true
124+
}
125+
}
126+
return false
127+
}, DefaultTestTimeout, DefaultTestTick, "Multi-space directory should be found by zoxide")
128+
129+
// Reset textinput via Close-Open
130+
p.getModel().zoxideModal.Close()
131+
p.getModel().zoxideModal.Open()
132+
p.SendKey("di r 1")
133+
assert.Eventually(t, func() bool {
134+
results := p.getModel().zoxideModal.GetResults()
135+
for _, result := range results {
136+
if result.Path == dir1 {
137+
return true
138+
}
139+
}
140+
return false
141+
}, DefaultTestTimeout, DefaultTestTick, "dir1 should be found by zoxide")
142+
})
143+
144+
t.Run("Zoxide escape key closes modal", func(t *testing.T) {
145+
p := setupProgAndOpenZoxide(t, zClient, dir1)
146+
p.SendKeyDirectly(common.Hotkeys.CancelTyping[0])
147+
assert.False(t, p.getModel().zoxideModal.IsOpen(),
148+
"Zoxide modal should close on escape key")
149+
})
150+
}

src/internal/test_utils.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,24 @@ import (
1717

1818
const DefaultTestTick = 10 * time.Millisecond
1919
const DefaultTestTimeout = time.Second
20+
const DefaultTestModelWidth = 2 * common.MinimumWidth
21+
const DefaultTestModelHeight = 2 * common.MinimumHeight
2022

2123
// -------------------- Model setup utils
2224

2325
func defaultTestModel(dirs ...string) *model {
2426
m := defaultModelConfig(false, false, false, dirs, nil)
25-
m.disableMetadata = true
26-
TeaUpdate(m, tea.WindowSizeMsg{Width: 2 * common.MinimumWidth, Height: 2 * common.MinimumHeight})
27-
return m
27+
return setModelParamsForTest(m)
2828
}
2929

3030
func defaultTestModelWithZClient(zClient *zoxidelib.Client, dirs ...string) *model {
3131
m := defaultModelConfig(false, false, false, dirs, zClient)
32+
return setModelParamsForTest(m)
33+
}
34+
35+
func setModelParamsForTest(m *model) *model {
3236
m.disableMetadata = true
33-
TeaUpdate(m, tea.WindowSizeMsg{Width: 2 * common.MinimumWidth, Height: 2 * common.MinimumHeight})
37+
TeaUpdate(m, tea.WindowSizeMsg{Width: DefaultTestModelWidth, Height: DefaultTestModelHeight})
3438
return m
3539
}
3640

0 commit comments

Comments
 (0)