From 5e1dce608208fa1332ccee7cbaa50aba0fb84041 Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Sat, 18 Apr 2026 04:42:35 +0900 Subject: [PATCH] update:multi selector --- internal/pages/file_action.go | 3 +- internal/tui/tui.go | 29 ++++---- internal/tui/update.go | 131 ++++++++++++++++++++++++++++++++-- internal/tui/view.go | 20 +++++- 4 files changed, 160 insertions(+), 23 deletions(-) diff --git a/internal/pages/file_action.go b/internal/pages/file_action.go index a53f6e0..a01dba3 100644 --- a/internal/pages/file_action.go +++ b/internal/pages/file_action.go @@ -2,7 +2,8 @@ package pages type FileActionPageMsg struct { ServerName string - Filename string + Filename string // single-file mode + Filenames []string // multi-file mode } func FileActionItems() []MenuItem { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9f7caba..f7fb2a5 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -39,17 +39,19 @@ type TUIInterface struct { WindowWidth int WindowHeight int // server actions page - ActiveServer string - LocalDir string // user's cwd, destination for received files - StorageFiles []string - StorageLoading bool - StorageErr error - FileSelected int // cursor within StorageFiles - FileFocused bool // true = ↑↓ drives file list, false = action menu - FileScrollOff int // first visible row in StorageFiles list - FileViewHeight int // available visible rows for StorageFiles list + ActiveServer string + LocalDir string // user's cwd, destination for received files + StorageFiles []string + StorageLoading bool + StorageErr error + FileSelected int // cursor within StorageFiles + FileFocused bool // true = ↑↓ drives file list, false = action menu + FileScrollOff int // first visible row in StorageFiles list + FileViewHeight int // available visible rows for StorageFiles list + FileMultiSelect map[int]bool // selected rows in StorageFiles // file action page ActiveFile string + ActiveFiles []string FileOpLoading bool FileOpErr error FileOpSuccess string @@ -65,10 +67,11 @@ type TUIInterface struct { func NewTUIInterface(store *services.ServicesStore, localDir string) TUIInterface { return TUIInterface{ - Services: store, - Page: pageHome, - MenuItems: pages.HomeMenuItems(), - LocalDir: localDir, + Services: store, + Page: pageHome, + MenuItems: pages.HomeMenuItems(), + LocalDir: localDir, + FileMultiSelect: make(map[int]bool), } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 2160b85..1bdf059 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "path/filepath" "sort" "strings" @@ -73,6 +74,62 @@ func deleteFileCmd(store *services.ServicesStore, serverName, filename string) t } } +func getFilesCmd(store *services.ServicesStore, serverName string, filenames []string, destDir string) tea.Cmd { + return func() tea.Msg { + storage, err := store.NewStorageService(serverName) + if err != nil { + return fileOpMsg{op: "get", err: err} + } + + success := 0 + var failed []string + for _, name := range filenames { + if err := storage.Get(name, destDir); err != nil { + failed = append(failed, fmt.Sprintf("%s: %v", name, err)) + continue + } + success++ + } + + if len(failed) > 0 { + if success == 0 { + return fileOpMsg{op: "get", err: fmt.Errorf("download failed for %d file(s): %s", len(failed), strings.Join(failed, "; "))} + } + return fileOpMsg{op: "get", err: fmt.Errorf("downloaded %d/%d file(s), failed %d: %s", success, len(filenames), len(failed), strings.Join(failed, "; "))} + } + + return fileOpMsg{op: "get", success: fmt.Sprintf("✓ Downloaded %d file(s)", success)} + } +} + +func deleteFilesCmd(store *services.ServicesStore, serverName string, filenames []string) tea.Cmd { + return func() tea.Msg { + storage, err := store.NewStorageService(serverName) + if err != nil { + return fileOpMsg{op: "delete", err: err} + } + + success := 0 + var failed []string + for _, name := range filenames { + if err := storage.Delete(name); err != nil { + failed = append(failed, fmt.Sprintf("%s: %v", name, err)) + continue + } + success++ + } + + if len(failed) > 0 { + if success == 0 { + return fileOpMsg{op: "delete", err: fmt.Errorf("delete failed for %d file(s): %s", len(failed), strings.Join(failed, "; "))} + } + return fileOpMsg{op: "delete", err: fmt.Errorf("deleted %d/%d file(s), failed %d: %s", success, len(filenames), len(failed), strings.Join(failed, "; "))} + } + + return fileOpMsg{op: "delete", success: fmt.Sprintf("✓ Deleted %d file(s)", success)} + } +} + type cleanAllMsg struct { err error } @@ -160,6 +217,28 @@ func (m TUIInterface) nextSelectable(from, dir int) int { return from } +func (m TUIInterface) selectedStorageFiles() []string { + if len(m.FileMultiSelect) == 0 || len(m.StorageFiles) == 0 { + return nil + } + idxs := make([]int, 0, len(m.FileMultiSelect)) + for idx, selected := range m.FileMultiSelect { + if !selected { + continue + } + if idx < 0 || idx >= len(m.StorageFiles) { + continue + } + idxs = append(idxs, idx) + } + sort.Ints(idxs) + files := make([]string, 0, len(idxs)) + for _, idx := range idxs { + files = append(files, m.StorageFiles[idx]) + } + return files +} + func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -195,6 +274,9 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.FileFocused = true m.FileViewHeight = m.fileListHeight() m.FileScrollOff = 0 + m.FileMultiSelect = make(map[int]bool) + m.ActiveFiles = nil + m.ActiveFile = "" m.StorageFiles = nil m.StorageErr = nil m.StorageLoading = true @@ -202,7 +284,16 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pages.FileActionPageMsg: m.Page = pageFileAction - m.ActiveFile = msg.Filename + targets := msg.Filenames + if len(targets) == 0 && msg.Filename != "" { + targets = []string{msg.Filename} + } + m.ActiveFiles = append([]string(nil), targets...) + if len(m.ActiveFiles) > 0 { + m.ActiveFile = m.ActiveFiles[0] + } else { + m.ActiveFile = "" + } m.MenuItems = pages.FileActionItems() m.Selected = 0 m.FileOpLoading = false @@ -305,6 +396,7 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.StorageFiles = msg.files m.StorageErr = msg.err m.FileSelected = 0 + m.FileMultiSelect = make(map[int]bool) m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, 0) return m, nil @@ -454,12 +546,27 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C } } + case "space": + if m.FileFocused && len(m.StorageFiles) > 0 { + if m.FileMultiSelect == nil { + m.FileMultiSelect = make(map[int]bool) + } + idx := m.FileSelected + m.FileMultiSelect[idx] = !m.FileMultiSelect[idx] + if !m.FileMultiSelect[idx] { + delete(m.FileMultiSelect, idx) + } + } + case "enter": if m.FileFocused && len(m.StorageFiles) > 0 { - file := m.StorageFiles[m.FileSelected] + targets := m.selectedStorageFiles() + if len(targets) == 0 { + targets = []string{m.StorageFiles[m.FileSelected]} + } server := m.ActiveServer return m, func() tea.Msg { - return pages.FileActionPageMsg{ServerName: server, Filename: file} + return pages.FileActionPageMsg{ServerName: server, Filenames: targets} } } server := m.ActiveServer @@ -502,18 +609,30 @@ func (m TUIInterface) updateFileAction(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) } case "enter": server := m.ActiveServer - file := m.ActiveFile + targets := m.ActiveFiles + if len(targets) == 0 && m.ActiveFile != "" { + targets = []string{m.ActiveFile} + } + if len(targets) == 0 { + return m, nil + } switch m.MenuItems[m.Selected].Key { case "get": m.FileOpLoading = true m.FileOpErr = nil m.FileOpSuccess = "" - return m, getFileCmd(m.Services, server, file, m.LocalDir) + if len(targets) == 1 { + return m, getFileCmd(m.Services, server, targets[0], m.LocalDir) + } + return m, getFilesCmd(m.Services, server, targets, m.LocalDir) case "delete": m.FileOpLoading = true m.FileOpErr = nil m.FileOpSuccess = "" - return m, deleteFileCmd(m.Services, server, file) + if len(targets) == 1 { + return m, deleteFileCmd(m.Services, server, targets[0]) + } + return m, deleteFilesCmd(m.Services, server, targets) } case "ctrl+c": m.Quitting = true diff --git a/internal/tui/view.go b/internal/tui/view.go index f7ee7a2..683e8d9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -38,6 +38,9 @@ func (m TUIInterface) subtitle() string { } return "Server" case pageFileAction: + if len(m.ActiveFiles) > 1 { + return fmt.Sprintf("%d files selected", len(m.ActiveFiles)) + } return m.ActiveFile case pageSend: return "Send File" @@ -116,7 +119,9 @@ func (m TUIInterface) View() tea.View { footerSep() + footerHint("↑↓", "navigate") + footerSep() + - footerHint("enter", "select") + + footerHint("space", "select") + + footerSep() + + footerHint("enter", "actions") + footerSep() + footerHint("esc", "back") case pageFileAction: @@ -236,7 +241,12 @@ func (m TUIInterface) viewServerActions() string { for i := start; i < end; i++ { f := m.StorageFiles[i] active := m.FileFocused && i == m.FileSelected - fileRows = append(fileRows, styles.FileItemStyle(active).Render(f)) + checked := m.FileMultiSelect[i] + mark := "[ ] " + if checked { + mark = "[✓] " + } + fileRows = append(fileRows, styles.FileItemStyle(active).Render(mark+f)) } } fileList := lipgloss.JoinVertical(lipgloss.Left, fileRows...) @@ -254,7 +264,11 @@ func (m TUIInterface) viewServerActions() string { } func (m TUIInterface) viewFileAction() string { - filenameLabel := styles.FilenameLabelStyle.Render(m.ActiveFile) + label := m.ActiveFile + if len(m.ActiveFiles) > 1 { + label = fmt.Sprintf("%d file(s) selected", len(m.ActiveFiles)) + } + filenameLabel := styles.FilenameLabelStyle.Render(label) var menuRows []string for i, item := range m.MenuItems {