Skip to content

Commit 5547665

Browse files
committed
feat: Allow hover to file
1 parent d9ad4db commit 5547665

File tree

9 files changed

+149
-61
lines changed

9 files changed

+149
-61
lines changed

src/cmd/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,9 @@ func Run(content embed.FS) {
120120

121121
func spfAppAction(_ context.Context, c *cli.Command) error {
122122
// If no args are called along with "spf" use current dir
123-
firstFilePanelDirs := []string{""}
123+
firstPanelPaths := []string{""}
124124
if c.Args().Present() {
125-
firstFilePanelDirs = c.Args().Slice()
125+
firstPanelPaths = c.Args().Slice()
126126
}
127127

128128
variable.UpdateVarFromCliArgs(c)
@@ -131,7 +131,7 @@ func spfAppAction(_ context.Context, c *cli.Command) error {
131131

132132
firstUse := checkFirstUse()
133133

134-
p := tea.NewProgram(internal.InitialModel(firstFilePanelDirs, firstUse),
134+
p := tea.NewProgram(internal.InitialModel(firstPanelPaths, firstUse),
135135
tea.WithAltScreen(), tea.WithMouseCellMotion())
136136
if _, err := p.Run(); err != nil {
137137
utils.PrintfAndExitf("Alas, there's been an error: %v", err)

src/internal/config_function.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
// themes) setted up. Processes input directories and returns toggle states.
2626

2727
// This is the only usecase of named returns, distinguish between multiple return values
28-
func initialConfig(firstFilePanelDirs []string) (toggleDotFile bool, //nolint: nonamedreturns // See above
28+
func initialConfig(firstPanelPaths []string) (toggleDotFile bool, //nolint: nonamedreturns // See above
2929
toggleFooter bool, zClient *zoxidelib.Client) {
3030
// Open log stream
3131
file, err := os.OpenFile(variable.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
@@ -80,9 +80,9 @@ func initialConfig(firstFilePanelDirs []string) (toggleDotFile bool, //nolint: n
8080
}
8181
}
8282

83-
updateFirstFilePanelDirs(firstFilePanelDirs, cwd, zClient)
83+
updateFirstFilePanelPaths(firstPanelPaths, cwd, zClient)
8484

85-
slog.Debug("Directory configuration", "cwd", cwd, "start_directories", firstFilePanelDirs)
85+
slog.Debug("Directory configuration", "cwd", cwd, "start_paths", firstPanelPaths)
8686
printRuntimeInfo()
8787

8888
toggleDotFile = utils.ReadBoolFile(variable.ToggleDotFile, false)
@@ -91,27 +91,27 @@ func initialConfig(firstFilePanelDirs []string) (toggleDotFile bool, //nolint: n
9191
return toggleDotFile, toggleFooter, zClient
9292
}
9393

94-
func updateFirstFilePanelDirs(firstFilePanelDirs []string, cwd string, zClient *zoxidelib.Client) {
95-
for i := range firstFilePanelDirs {
96-
if firstFilePanelDirs[i] == "" {
97-
firstFilePanelDirs[i] = common.Config.DefaultDirectory
94+
func updateFirstFilePanelPaths(firstPanelPaths []string, cwd string, zClient *zoxidelib.Client) {
95+
for i := range firstPanelPaths {
96+
if firstPanelPaths[i] == "" {
97+
firstPanelPaths[i] = common.Config.DefaultDirectory
9898
}
99-
originalPath := firstFilePanelDirs[i]
100-
firstFilePanelDirs[i] = utils.ResolveAbsPath(cwd, firstFilePanelDirs[i])
101-
if _, err := os.Stat(firstFilePanelDirs[i]); err != nil {
99+
originalPath := firstPanelPaths[i]
100+
firstPanelPaths[i] = utils.ResolveAbsPath(cwd, firstPanelPaths[i])
101+
if _, err := os.Stat(firstPanelPaths[i]); err != nil {
102102
slog.Error("cannot get stats for firstFilePanelDir", "error", err)
103103
// In case the path provided did not exist, use zoxide query
104104
// else, fallback to home dir
105105
if common.Config.ZoxideSupport && zClient != nil {
106106
path, err := attemptZoxideForInitPath(originalPath, zClient)
107107
if err != nil {
108108
slog.Error("Zoxide query error", "originalPath", originalPath, "error", err)
109-
firstFilePanelDirs[i] = variable.HomeDir
109+
firstPanelPaths[i] = variable.HomeDir
110110
} else {
111-
firstFilePanelDirs[i] = path
111+
firstPanelPaths[i] = path
112112
}
113113
} else {
114-
firstFilePanelDirs[i] = variable.HomeDir
114+
firstPanelPaths[i] = variable.HomeDir
115115
}
116116
}
117117
}

src/internal/default_config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ import (
2020
// TODO: Move the configuration parameters to a ModelConfig struct.
2121
// Something like `RendererConfig` struct for `Renderer` struct in ui/renderer package
2222
func defaultModelConfig(toggleDotFile, toggleFooter, firstUse bool,
23-
firstFilePanelDirs []string, zClient *zoxidelib.Client) *model {
23+
firstPanelPaths []string, zClient *zoxidelib.Client) *model {
2424
return &model{
2525
filePanelFocusIndex: 0,
2626
focusPanel: nonePanelFocus,
2727
processBarModel: processbar.New(),
2828
sidebarModel: sidebar.New(),
2929
fileMetaData: metadata.New(),
3030
fileModel: fileModel{
31-
filePanels: filePanelSlice(firstFilePanelDirs),
31+
filePanels: filePanelSlice(firstPanelPaths),
3232
filePreview: preview.New(),
3333
width: 10,
3434
},

src/internal/handle_panel_up_down.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ package internal
22

33
// ======================================== File panel controller ========================================
44

5+
func (panel *filePanel) scrollToCursor(mainPanelHeight int) {
6+
if panel.cursor < 0 || panel.cursor >= len(panel.element) {
7+
panel.cursor = 0
8+
panel.render = 0
9+
return
10+
}
11+
12+
renderCount := panelElementHeight(mainPanelHeight)
13+
if panel.cursor < panel.render {
14+
panel.render = max(0, panel.cursor-renderCount+1)
15+
} else if panel.cursor > panel.render+renderCount-1 {
16+
panel.render = panel.cursor - renderCount + 1
17+
}
18+
}
19+
520
// Control file panel list up
621
func (panel *filePanel) listUp(mainPanelHeight int) {
722
if len(panel.element) == 0 {

src/internal/model.go

Lines changed: 61 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ var (
3838
// is passed to tea.NewProgram() which accepts tea.Model
3939
// Either way type 'model' is not exported, so there is not way main package can
4040
// be aware of it, and use it directly
41-
func InitialModel(firstFilePanelDirs []string, firstUseCheck bool) tea.Model {
42-
toggleDotFile, toggleFooter, zClient := initialConfig(firstFilePanelDirs)
43-
return defaultModelConfig(toggleDotFile, toggleFooter, firstUseCheck, firstFilePanelDirs, zClient)
41+
func InitialModel(firstPanelPaths []string, firstUseCheck bool) tea.Model {
42+
toggleDotFile, toggleFooter, zClient := initialConfig(firstPanelPaths)
43+
return defaultModelConfig(toggleDotFile, toggleFooter, firstUseCheck, firstPanelPaths, zClient)
4444
}
4545

4646
// Init function to be called by Bubble tea framework, sets windows title,
@@ -696,50 +696,75 @@ func getMaxW(s string) int {
696696
// Render and update file panel items. Check for changes and updates in files and
697697
// folders in the current directory.
698698
func (m *model) getFilePanelItems() {
699-
focusPanel := m.fileModel.filePanels[m.filePanelFocusIndex]
700-
for i, filePanel := range m.fileModel.filePanels {
701-
var fileElement []element
699+
focusPanel := &m.fileModel.filePanels[m.filePanelFocusIndex]
700+
for i := range m.fileModel.filePanels {
701+
filePanel := &m.fileModel.filePanels[i]
702702
nowTime := time.Now()
703-
// Check last time each element was updated, if less then 3 seconds ignore
704-
if !filePanel.isFocused && nowTime.Sub(filePanel.lastTimeGetElement) < 3*time.Second {
705-
// TODO : revisit this. This feels like a duct tape solution of an actual
706-
// deep rooted problem. This feels very hacky.
707-
if !m.updatedToggleDotFile {
708-
continue
703+
if !m.shouldSkipPanelUpdate(filePanel, focusPanel, nowTime) {
704+
// Load elements for this panel (with/without search filter)
705+
filePanel.element = m.getElementsForPanel(filePanel)
706+
// Update file panel list
707+
filePanel.lastTimeGetElement = nowTime
708+
709+
// For hover to file on first time loading
710+
if filePanel.targetFile != "" {
711+
filePanel.applyTargetFileCursor()
709712
}
710713
}
714+
// Due to applyTargetFileCursor, cursor might go out of range
715+
filePanel.scrollToCursor(m.mainPanelHeight)
716+
}
711717

712-
focusPanelReRender := false
718+
m.updatedToggleDotFile = false
719+
}
713720

714-
if len(focusPanel.element) > 0 {
715-
if filepath.Dir(focusPanel.element[0].location) != focusPanel.location {
716-
focusPanelReRender = true
717-
}
718-
} else {
719-
focusPanelReRender = true
721+
// Helper to decide whether to skip updating a panel this tick.
722+
func (m *model) shouldSkipPanelUpdate(filePanel *filePanel, focusPanel *filePanel, nowTime time.Time) bool {
723+
// Throttle non-focused panels unless dotfile toggle changed
724+
if !filePanel.isFocused && nowTime.Sub(filePanel.lastTimeGetElement) < 3*time.Second {
725+
if !m.updatedToggleDotFile {
726+
return true
720727
}
728+
}
721729

722-
reRenderTime := int(float64(len(filePanel.element)) / 100)
730+
focusPanelReRender := focusPanel.needsReRender()
731+
reRenderTime := int(float64(len(filePanel.element)) / 100)
732+
if filePanel.isFocused && !focusPanelReRender &&
733+
nowTime.Sub(filePanel.lastTimeGetElement) < time.Duration(reRenderTime)*time.Second {
734+
return true
735+
}
736+
return false
737+
}
723738

724-
if filePanel.isFocused && !focusPanelReRender &&
725-
nowTime.Sub(filePanel.lastTimeGetElement) < time.Duration(reRenderTime)*time.Second {
726-
continue
727-
}
739+
// Checks whether the focus panel directory changed and forces a re-render.
740+
func (panel *filePanel) needsReRender() bool {
741+
if len(panel.element) > 0 {
742+
return filepath.Dir(panel.element[0].location) != panel.location
743+
}
744+
return true
745+
}
728746

729-
// Get file names based on search bar filter
730-
if filePanel.searchBar.Value() != "" {
731-
fileElement = returnDirElementBySearchString(filePanel.location, m.toggleDotFile,
732-
filePanel.searchBar.Value(), filePanel.sortOptions.data)
733-
} else {
734-
fileElement = returnDirElement(filePanel.location, m.toggleDotFile, filePanel.sortOptions.data)
735-
}
736-
// Update file panel list
737-
filePanel.element = fileElement
738-
m.fileModel.filePanels[i].element = fileElement
739-
m.fileModel.filePanels[i].lastTimeGetElement = nowTime
747+
// Retrieves elements for a panel based on search bar value and sort options.
748+
func (m *model) getElementsForPanel(filePanel *filePanel) []element {
749+
if filePanel.searchBar.Value() != "" {
750+
return returnDirElementBySearchString(
751+
filePanel.location,
752+
m.toggleDotFile,
753+
filePanel.searchBar.Value(),
754+
filePanel.sortOptions.data,
755+
)
740756
}
757+
return returnDirElement(filePanel.location, m.toggleDotFile, filePanel.sortOptions.data)
758+
}
741759

742-
m.updatedToggleDotFile = false
760+
// Applies targetFile cursor positioning, if configured for the panel.
761+
func (panel *filePanel) applyTargetFileCursor() {
762+
for idx, el := range panel.element {
763+
if el.name == panel.targetFile {
764+
panel.cursor = idx
765+
}
766+
}
767+
panel.targetFile = ""
743768
}
744769

745770
// Close superfile application. Cd into the current dir if CdOnQuit on and save

src/internal/model_navigation_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,6 @@ func TestFilePanelNavigation(t *testing.T) {
124124
m := defaultTestModel(tt.startDir)
125125
m.getFocusedFilePanel().cursor = tt.startCursor
126126
m.getFocusedFilePanel().render = tt.startRender
127-
m.getFocusedFilePanel().searchBar.SetValue("asdf")
128127
for _, s := range tt.keyInput {
129128
TeaUpdate(m, utils.TeaRuneKeyMsg(s))
130129
}

src/internal/model_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"path/filepath"
88
"testing"
99

10+
tea "github.com/charmbracelet/bubbletea"
11+
1012
"github.com/stretchr/testify/assert"
1113
"github.com/stretchr/testify/require"
1214

@@ -95,6 +97,41 @@ func TestBasic(t *testing.T) {
9597
})
9698
}
9799

100+
func TestInitialFilePathPositionsCursorWindow(t *testing.T) {
101+
curTestDir := t.TempDir()
102+
dir1 := filepath.Join(curTestDir, "dir1")
103+
104+
utils.SetupDirectories(t, curTestDir, dir1)
105+
106+
var file7 string
107+
var file2 string
108+
for i := range 10 {
109+
f := filepath.Join(dir1, fmt.Sprintf("file%d.txt", i))
110+
utils.SetupFiles(t, f)
111+
if i == 7 {
112+
file7 = f
113+
}
114+
if i == 2 {
115+
file2 = f
116+
}
117+
}
118+
119+
m := defaultTestModel(dir1, file2, file7)
120+
// View port of 5
121+
TeaUpdate(m, tea.WindowSizeMsg{Width: common.MinimumWidth, Height: 10})
122+
// Uncomment below to understand the distribution
123+
// t.Logf("Heights : %d [%d - [%d] %d]\n", m.fullHeight, m.footerHeight, m.mainPanelHeight,
124+
// panelElementHeight(m.mainPanelHeight))
125+
require.Len(t, m.fileModel.filePanels, 3)
126+
assert.Equal(t, dir1, m.fileModel.filePanels[0].location)
127+
assert.Equal(t, file2, m.fileModel.filePanels[1].getSelectedItem().location)
128+
assert.Equal(t, 2, m.fileModel.filePanels[1].cursor)
129+
assert.Equal(t, 0, m.fileModel.filePanels[1].render)
130+
assert.Equal(t, file7, m.fileModel.filePanels[2].getSelectedItem().location)
131+
assert.Equal(t, 7, m.fileModel.filePanels[2].cursor)
132+
assert.Equal(t, 3, m.fileModel.filePanels[2].render)
133+
}
134+
98135
func TestQuit(t *testing.T) {
99136
// Test
100137
// 1 - Normal quit

src/internal/type.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ type filePanel struct {
167167
renaming bool
168168
searchBar textinput.Model
169169
lastTimeGetElement time.Time
170+
targetFile string // filename to position cursor on after load
170171
}
171172

172173
// Sort options

src/internal/type_utils.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package internal
22

33
import (
44
"fmt"
5+
"os"
6+
"path/filepath"
57

68
"github.com/yorukot/superfile/src/internal/common"
79
"github.com/yorukot/superfile/src/internal/utils"
@@ -36,21 +38,29 @@ func (m *model) validateLayout() error {
3638

3739
// ================ filepanel
3840

39-
func filePanelSlice(dir []string) []filePanel {
40-
res := make([]filePanel, len(dir))
41-
for i := range dir {
41+
func filePanelSlice(paths []string) []filePanel {
42+
res := make([]filePanel, len(paths))
43+
for i := range paths {
4244
// Making the first panel as the focussed
4345
isFocus := i == 0
44-
res[i] = defaultFilePanel(dir[i], isFocus)
46+
res[i] = defaultFilePanel(paths[i], isFocus)
4547
}
4648
return res
4749
}
4850

49-
func defaultFilePanel(dir string, focused bool) filePanel {
51+
func defaultFilePanel(path string, focused bool) filePanel {
52+
targetFile := ""
53+
panelPath := path
54+
// If path refers to a file, switch to its parent and remember the filename
55+
if stat, err := os.Stat(panelPath); err == nil && !stat.IsDir() {
56+
targetFile = filepath.Base(panelPath)
57+
panelPath = filepath.Dir(panelPath)
58+
}
59+
5060
return filePanel{
5161
render: 0,
5262
cursor: 0,
53-
location: dir,
63+
location: panelPath,
5464
sortOptions: sortOptionsModel{
5565
width: 20,
5666
height: 4,
@@ -69,6 +79,7 @@ func defaultFilePanel(dir string, focused bool) filePanel {
6979
isFocused: focused,
7080
directoryRecords: make(map[string]directoryRecord),
7181
searchBar: common.GenerateSearchBar(),
82+
targetFile: targetFile,
7283
}
7384
}
7485

0 commit comments

Comments
 (0)