diff --git a/README.md b/README.md index 434a333..cf067fc 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ go install github.com/alx99/sail/cmd/sail@latest - [x] Customizable keybindings - [x] *Sail* into directories - [x] Delete files (*partial* support) +- [x] Select files - [ ] Move files - [ ] Copy files - [ ] Rename files @@ -65,6 +66,7 @@ settings: out: "," go_home: "~" delete: "d" + select: " " ``` ### Using sail as a cd replacement diff --git a/internal/config/config.go b/internal/config/config.go index 9b198c2..f2b6f34 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type Keymap struct { NavOut string `yaml:"out"` NavHome string `yaml:"go_home"` Delete string `yaml:"delete"` + Select string `yaml:"select"` } // GetConfig reads, pareses and returns the configuration @@ -41,6 +42,7 @@ func GetConfig() (Config, error) { NavOut: ",", NavHome: "~", Delete: "d", + Select: " ", }, }, } diff --git a/internal/models/main_model.go b/internal/models/main_model.go index 48c9e5c..665b02f 100644 --- a/internal/models/main_model.go +++ b/internal/models/main_model.go @@ -17,7 +17,11 @@ import ( const defaultMaxRows = 10 -var pathAnimDuration = 250 * time.Millisecond +var ( + pathAnimDuration = 250 * time.Millisecond + white = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")) + red = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) +) type dirLoaded struct { path string @@ -38,6 +42,7 @@ type Model struct { files []fs.DirEntry // current files in that directory cursor position // cursor cachedDirSelections map[string]string // cached file names for directories + selectedFiles map[string]any // selected files maxRows int // the maximum number of rows to display lastError error // last error that occurred @@ -51,6 +56,7 @@ func NewMain(cwd string, cfg config.Config) Model { cfg: cfg, maxRows: defaultMaxRows, cachedDirSelections: make(map[string]string, 100), + selectedFiles: make(map[string]any, 100), sb: strings.Builder{}, } } @@ -130,19 +136,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }, m.loadDir(m.cwd), ) - if len(m.files) > 0 { - // we optimistically believe that the file will be deleted - delete(m.cachedDirSelections, m.cwd) - - return m, sequentially( - func() tea.Msg { - return osi.RemoveAll(path.Join(m.cwd, m.files[m.cursorOffset()].Name())) - }, - m.loadDir(m.cwd), - ) + + case m.cfg.Settings.Keymap.Select: + if len(m.files) <= 0 { + return m, nil + } + + fName := path.Join(m.cwd, m.files[m.cursorOffset()].Name()) + if _, ok := m.selectedFiles[fName]; ok { + delete(m.selectedFiles, fName) + log.Debug().Msgf("Deselected %s", fName) + } else { + m.selectedFiles[fName] = nil + log.Debug().Msgf("Selected %s", fName) + } + + prevCursor := m.cursor + m = m.goDown() + if prevCursor != m.cursor { + return m, nil } - return m, nil + // If the cursor did not move, it HAS to mean we are at the + // end of a row. Try to move to the next column + if m.cursorOffset()+1 < len(m.files) { + m.setCursor(0, m.cursor.c+1) + } else { + // here we MUST be at the end of the list + m.setCursor(0, 0) + } + return m, nil } case tea.WindowSizeMsg: var fName string @@ -233,7 +256,7 @@ func (m Model) View() string { if m.prevCWD != "" { if strings.HasPrefix(m.prevCWD, m.cwd) && len(m.cwd) < len(m.prevCWD) { - m.sb.WriteString(m.cwd + lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")).Render(strings.TrimPrefix(m.prevCWD+"/", m.cwd))) + m.sb.WriteString(m.cwd + red.Render(strings.TrimPrefix(m.prevCWD+"/", m.cwd))) } else if strings.HasPrefix(m.cwd, m.prevCWD) && len(m.cwd) > len(m.prevCWD) { // some eye candy; directories end with a slash if m.prevCWD != "/" { @@ -264,29 +287,36 @@ func (m Model) View() string { for row := range len(grid) { for col, f := range grid[row] { if m.cursor.r == row && m.cursor.c == col { - m.sb.WriteString(">") + m.sb.WriteString(white.Render(">")) } - extraPadding := 0 + rightPad := 0 // only pad if the column is not the last column if col < len(grid[row]) { - extraPadding = maxColNameLen[col] - len(f.Name()) + 2 + // +3 because we want at least one space between the file name and the next column + // and we can get +2 extra characters before the name (cursor + selection) + rightPad = maxColNameLen[col] - len(f.Name()) + 3 if m.cursor.r == row && m.cursor.c == col { - extraPadding-- + rightPad-- } } + if m.isSelected(f.Name()) { + m.sb.WriteString(white.Render(">")) + rightPad-- + } + m.sb.WriteString(util.GetStyle(f). - PaddingRight(extraPadding). + PaddingRight(rightPad). Render(f.Name())) } m.sb.WriteString("\n") if row == len(grid)-1 { if m.lastError != nil { - m.sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")).Render(m.lastError.Error())) + m.sb.WriteString(red.Render(m.lastError.Error())) } } } @@ -309,8 +339,8 @@ func (m Model) logCursor() { } func (m *Model) setCursor(r, c int) { - m.cursor.c = r - m.cursor.r = c + m.cursor.c = c + m.cursor.r = r } // trySelectFile tries to select a file by name or sets the cursor to the first file @@ -321,7 +351,7 @@ func (m *Model) trySelectFile(fName string) { }) if index != -1 { - m.setCursor(index/m.maxRows, index%m.maxRows) + m.setCursor(index%m.maxRows, index/m.maxRows) } else { m.setCursor(0, 0) } @@ -349,6 +379,11 @@ func (m Model) goDown() Model { return m } +func (m Model) isSelected(name string) bool { + _, ok := m.selectedFiles[path.Join(m.cwd, name)] + return ok +} + // sequentially produces a command that sequentially executes the given // commands. // The tea.Msg returned is the first non-nil message returned by a Cmd. diff --git a/internal/models/main_model_test.go b/internal/models/main_model_test.go index 4d6c7ee..30090a8 100644 --- a/internal/models/main_model_test.go +++ b/internal/models/main_model_test.go @@ -39,6 +39,7 @@ func TestModel_Update(t *testing.T) { maxRows int sb strings.Builder lastError error + selectedFiles map[string]any } type args struct { msg tea.Msg @@ -594,6 +595,120 @@ func TestModel_Update(t *testing.T) { }, }, }, + { + name: "Select file", + fields: fields{ + cfg: config.Config{ + Settings: config.Settings{Keymap: config.Keymap{Select: " "}}, + }, + cwd: "/test", + files: []fs.DirEntry{ + dirEntry{name: "file1", isDir: false}, + }, + maxRows: 3, + selectedFiles: map[string]any{}, + }, + args: args{ + msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}, + }, + wantFunc: func(m Model) Model { + m.selectedFiles = map[string]any{ + "/test/file1": nil, + } + return m + }, + }, + { + name: "Select file last file (wrap around)", + fields: fields{ + cfg: config.Config{ + Settings: config.Settings{Keymap: config.Keymap{Select: " "}}, + }, + cwd: "/test", + files: []fs.DirEntry{ + dirEntry{name: "file1", isDir: false}, + dirEntry{name: "file2", isDir: false}, + }, + cursor: position{c: 1}, + maxRows: 1, + selectedFiles: map[string]any{}, + }, + args: args{ + msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}, + }, + wantFunc: func(m Model) Model { + m.cursor = position{} + m.selectedFiles = map[string]any{ + "/test/file2": nil, + } + return m + }, + }, + { + name: "Select file last file (wrap to next col)", + fields: fields{ + cfg: config.Config{ + Settings: config.Settings{Keymap: config.Keymap{Select: " "}}, + }, + cwd: "/test", + files: []fs.DirEntry{ + dirEntry{name: "file1", isDir: false}, + dirEntry{name: "file2", isDir: false}, + }, + maxRows: 1, + selectedFiles: map[string]any{}, + }, + args: args{ + msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}, + }, + wantFunc: func(m Model) Model { + m.cursor = position{c: 1} + m.selectedFiles = map[string]any{ + "/test/file1": nil, + } + return m + }, + }, + { + name: "Deselect file (next row move)", + fields: fields{ + cfg: config.Config{ + Settings: config.Settings{Keymap: config.Keymap{Select: " "}}, + }, + cwd: "/test", + files: []fs.DirEntry{ + dirEntry{name: "file1", isDir: false}, + dirEntry{name: "file2", isDir: false}, + }, + maxRows: 2, + selectedFiles: map[string]any{"/test/file1": nil}, + }, + args: args{ + msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}, + }, + wantFunc: func(m Model) Model { + m.cursor = position{r: 1} + m.selectedFiles = map[string]any{} + return m + }, + }, + { + name: "Select file (no files)", + fields: fields{ + cfg: config.Config{ + Settings: config.Settings{Keymap: config.Keymap{Select: " "}}, + }, + cwd: "/test", + files: []fs.DirEntry{}, + maxRows: 2, + }, + args: args{ + msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}, + }, + wantFunc: func(m Model) Model { + return m + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -606,6 +721,7 @@ func TestModel_Update(t *testing.T) { maxRows: tt.fields.maxRows, sb: tt.fields.sb, lastError: tt.fields.lastError, + selectedFiles: tt.fields.selectedFiles, } if mock, ok := tt.mocks.fs.(mockOS); ok {