From 74d05ed9aa7bf74eb52970d3696d6a2d88c91d36 Mon Sep 17 00:00:00 2001 From: Sizk Date: Sun, 21 Dec 2025 22:10:06 +0800 Subject: [PATCH] feat(analyze): add multi-select for batch file operations (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add spacebar to toggle selection on files/directories - Support batch delete for multiple selected items - Support batch open (O) and reveal in Finder (F) for selections - Show selection count and total size in status bar - Display selection indicator (● selected, ○ unselected) - Clear selections when navigating directories or switching views Authored-by: Sizk --- .gitignore | 1 + cmd/analyze/delete.go | 49 ++++++++ cmd/analyze/main.go | 274 +++++++++++++++++++++++++++++++++++------- cmd/analyze/view.go | 97 ++++++++++++--- 4 files changed, 361 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index 84b6dc2..a585795 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ temp/ # AI Assistant Instructions .claude/ .gemini/ +.kiro/ CLAUDE.md GEMINI.md .cursorrules diff --git a/cmd/analyze/delete.go b/cmd/analyze/delete.go index bad3c2d..b8adc91 100644 --- a/cmd/analyze/delete.go +++ b/cmd/analyze/delete.go @@ -4,6 +4,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "sync/atomic" tea "github.com/charmbracelet/bubbletea" @@ -21,6 +22,54 @@ func deletePathCmd(path string, counter *int64) tea.Cmd { } } +// deleteMultiplePathsCmd deletes multiple paths and returns combined results +func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd { + return func() tea.Msg { + var totalCount int64 + var errors []string + + for _, path := range paths { + count, err := deletePathWithProgress(path, counter) + totalCount += count + if err != nil { + errors = append(errors, err.Error()) + } + } + + var resultErr error + if len(errors) > 0 { + resultErr = &multiDeleteError{errors: errors} + } + + // Return empty path to trigger full refresh since multiple items were deleted + return deleteProgressMsg{ + done: true, + err: resultErr, + count: totalCount, + path: "", // Empty path signals multiple deletions + } + } +} + +// multiDeleteError holds multiple deletion errors +type multiDeleteError struct { + errors []string +} + +func (e *multiDeleteError) Error() string { + if len(e.errors) == 1 { + return e.errors[0] + } + return strings.Join(e.errors[:min(3, len(e.errors))], "; ") +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + func deletePathWithProgress(root string, counter *int64) (int64, error) { var count int64 var firstErr error diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 707e4f8..56e05f7 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -111,6 +111,8 @@ type model struct { overviewScanningSet map[string]bool // Track which paths are currently being scanned width int // Terminal width height int // Terminal height + multiSelected map[int]bool // Track multi-selected items by index + largeMultiSelected map[int]bool // Track multi-selected large files by index } func (m model) inOverviewMode() bool { @@ -177,6 +179,8 @@ func newModel(path string, isOverview bool) model { overviewCurrentPath: &overviewCurrentPath, overviewSizeCache: make(map[string]int64), overviewScanningSet: make(map[string]bool), + multiSelected: make(map[int]bool), + largeMultiSelected: make(map[int]bool), } // In overview mode, create shortcut entries @@ -392,6 +396,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case deleteProgressMsg: if msg.done { m.deleting = false + // Clear multi-selection after delete + m.multiSelected = make(map[int]bool) + m.largeMultiSelected = make(map[int]bool) if msg.err != nil { m.status = fmt.Sprintf("Failed to delete: %v", msg.err) } else { @@ -522,20 +529,48 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "delete", "backspace": // Confirm delete - start async deletion - if m.deleteTarget != nil { - m.deleteConfirm = false - m.deleting = true - var deleteCount int64 - m.deleteCount = &deleteCount - targetPath := m.deleteTarget.Path - targetName := m.deleteTarget.Name - m.deleteTarget = nil - m.status = fmt.Sprintf("Deleting %s...", targetName) - return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) - } m.deleteConfirm = false + m.deleting = true + var deleteCount int64 + m.deleteCount = &deleteCount + + // Collect paths to delete (multi-select or single) + var pathsToDelete []string + if m.showLargeFiles { + if len(m.largeMultiSelected) > 0 { + for idx := range m.largeMultiSelected { + if idx < len(m.largeFiles) { + pathsToDelete = append(pathsToDelete, m.largeFiles[idx].Path) + } + } + } else if m.deleteTarget != nil { + pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) + } + } else { + if len(m.multiSelected) > 0 { + for idx := range m.multiSelected { + if idx < len(m.entries) { + pathsToDelete = append(pathsToDelete, m.entries[idx].Path) + } + } + } else if m.deleteTarget != nil { + pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) + } + } + m.deleteTarget = nil - return m, nil + if len(pathsToDelete) == 0 { + m.deleting = false + m.status = "Nothing to delete" + return m, nil + } + + if len(pathsToDelete) == 1 { + m.status = fmt.Sprintf("Deleting %s...", filepath.Base(pathsToDelete[0])) + } else { + m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) + } + return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd()) case "esc", "q": // Cancel delete with ESC or Q m.status = "Cancelled" @@ -682,13 +717,58 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.showLargeFiles { m.largeSelected = 0 m.largeOffset = 0 + m.largeMultiSelected = make(map[int]bool) + } else { + m.multiSelected = make(map[int]bool) } + // Reset status when switching views + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } case "o": - // Open selected entry + // Open selected entries (multi-select aware) if m.showLargeFiles { if len(m.largeFiles) > 0 { - selected := m.largeFiles[m.largeSelected] + // Check for multi-selection first + if len(m.largeMultiSelected) > 0 { + count := len(m.largeMultiSelected) + for idx := range m.largeMultiSelected { + if idx < len(m.largeFiles) { + path := m.largeFiles[idx].Path + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", p).Run() + }(path) + } + } + m.status = fmt.Sprintf("Opening %d items...", count) + } else { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", path).Run() + }(selected.Path) + m.status = fmt.Sprintf("Opening %s...", selected.Name) + } + } + } else if len(m.entries) > 0 { + // Check for multi-selection first + if len(m.multiSelected) > 0 { + count := len(m.multiSelected) + for idx := range m.multiSelected { + if idx < len(m.entries) { + path := m.entries[idx].Path + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", p).Run() + }(path) + } + } + m.status = fmt.Sprintf("Opening %d items...", count) + } else { + selected := m.entries[m.selected] go func(path string) { ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) defer cancel() @@ -696,20 +776,52 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }(selected.Path) m.status = fmt.Sprintf("Opening %s...", selected.Name) } - } else if len(m.entries) > 0 { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Opening %s...", selected.Name) } case "f", "F": - // Reveal selected entry in Finder + // Reveal selected entries in Finder (multi-select aware) if m.showLargeFiles { if len(m.largeFiles) > 0 { - selected := m.largeFiles[m.largeSelected] + // Check for multi-selection first + if len(m.largeMultiSelected) > 0 { + count := len(m.largeMultiSelected) + for idx := range m.largeMultiSelected { + if idx < len(m.largeFiles) { + path := m.largeFiles[idx].Path + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", "-R", p).Run() + }(path) + } + } + m.status = fmt.Sprintf("Showing %d items in Finder...", count) + } else { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", "-R", path).Run() + }(selected.Path) + m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) + } + } + } else if len(m.entries) > 0 { + // Check for multi-selection first + if len(m.multiSelected) > 0 { + count := len(m.multiSelected) + for idx := range m.multiSelected { + if idx < len(m.entries) { + path := m.entries[idx].Path + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", "-R", p).Run() + }(path) + } + } + m.status = fmt.Sprintf("Showing %d items in Finder...", count) + } else { + selected := m.entries[m.selected] go func(path string) { ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) defer cancel() @@ -717,32 +829,103 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }(selected.Path) m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) } - } else if len(m.entries) > 0 { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) } - case "delete", "backspace": - // Delete selected file or directory + case " ": + // Toggle multi-select with spacebar if m.showLargeFiles { if len(m.largeFiles) > 0 { - selected := m.largeFiles[m.largeSelected] - m.deleteConfirm = true - m.deleteTarget = &dirEntry{ - Name: selected.Name, - Path: selected.Path, - Size: selected.Size, - IsDir: false, + if m.largeMultiSelected == nil { + m.largeMultiSelected = make(map[int]bool) + } + if m.largeMultiSelected[m.largeSelected] { + delete(m.largeMultiSelected, m.largeSelected) + } else { + m.largeMultiSelected[m.largeSelected] = true + } + // Update status to show selection count + count := len(m.largeMultiSelected) + if count > 0 { + var totalSize int64 + for idx := range m.largeMultiSelected { + if idx < len(m.largeFiles) { + totalSize += m.largeFiles[idx].Size + } + } + m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) + } else { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } } } else if len(m.entries) > 0 && !m.inOverviewMode() { - selected := m.entries[m.selected] - m.deleteConfirm = true - m.deleteTarget = &selected + if m.multiSelected == nil { + m.multiSelected = make(map[int]bool) + } + if m.multiSelected[m.selected] { + delete(m.multiSelected, m.selected) + } else { + m.multiSelected[m.selected] = true + } + // Update status to show selection count + count := len(m.multiSelected) + if count > 0 { + var totalSize int64 + for idx := range m.multiSelected { + if idx < len(m.entries) { + totalSize += m.entries[idx].Size + } + } + m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) + } else { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } + } + case "delete", "backspace": + // Delete selected file(s) or directory(ies) + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + // Check for multi-selection first + if len(m.largeMultiSelected) > 0 { + m.deleteConfirm = true + // Set deleteTarget to first selected for display purposes + for idx := range m.largeMultiSelected { + if idx < len(m.largeFiles) { + selected := m.largeFiles[idx] + m.deleteTarget = &dirEntry{ + Name: selected.Name, + Path: selected.Path, + Size: selected.Size, + IsDir: false, + } + break + } + } + } else { + selected := m.largeFiles[m.largeSelected] + m.deleteConfirm = true + m.deleteTarget = &dirEntry{ + Name: selected.Name, + Path: selected.Path, + Size: selected.Size, + IsDir: false, + } + } + } + } else if len(m.entries) > 0 && !m.inOverviewMode() { + // Check for multi-selection first + if len(m.multiSelected) > 0 { + m.deleteConfirm = true + // Set deleteTarget to first selected for display purposes + for idx := range m.multiSelected { + if idx < len(m.entries) { + m.deleteTarget = &m.entries[idx] + break + } + } + } else { + selected := m.entries[m.selected] + m.deleteConfirm = true + m.deleteTarget = &selected + } } } return m, nil @@ -784,6 +967,9 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { m.status = "Scanning..." m.scanning = true m.isOverview = false + // Clear multi-selection when entering new directory + m.multiSelected = make(map[int]bool) + m.largeMultiSelected = make(map[int]bool) // Reset scan counters for new scan atomic.StoreInt64(m.filesScanned, 0) diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index bd52a8b..f346550 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -129,16 +129,27 @@ func (m model) View() string { nameColor := "" sizeColor := colorGray numColor := "" + + // Check if this item is multi-selected + isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[idx] + selectIcon := "○" + if isMultiSelected { + selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset) + nameColor = colorGreen + } + if idx == m.largeSelected { entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) - nameColor = colorCyan + if !isMultiSelected { + nameColor = colorCyan + } sizeColor = colorCyan numColor = colorCyan } size := humanizeBytes(file.Size) bar := coloredProgressBar(file.Size, maxLargeSize, 0) - fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n", - entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) + fmt.Fprintf(&b, "%s%s %s%2d.%s %s | 📄 %s%s%s %s%10s%s\n", + entryPrefix, selectIcon, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) } } } else { @@ -278,14 +289,28 @@ func (m model) View() string { sizeColor = colorGray } + // Check if this item is multi-selected + isMultiSelected := m.multiSelected != nil && m.multiSelected[idx] + selectIcon := "○" + nameColor := "" + if isMultiSelected { + selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset) + nameColor = colorGreen + } + // Keep chart columns aligned even when arrow is shown entryPrefix := " " nameSegment := fmt.Sprintf("%s %s", icon, paddedName) + if nameColor != "" { + nameSegment = fmt.Sprintf("%s%s %s%s", nameColor, icon, paddedName, colorReset) + } numColor := "" percentColor := "" if idx == m.selected { entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) - nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) + if !isMultiSelected { + nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) + } numColor = colorCyan percentColor = colorCyan sizeColor = colorCyan @@ -309,12 +334,12 @@ func (m model) View() string { } if hintLabel == "" { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, + fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s\n", + entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, nameSegment, sizeColor, size, colorReset) } else { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, + fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", + entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, nameSegment, sizeColor, size, colorReset, hintLabel) } } @@ -331,21 +356,61 @@ func (m model) View() string { fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset) } } else if m.showLargeFiles { - fmt.Fprintf(&b, "%s↑↓← | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset) + selectCount := len(m.largeMultiSelected) + if selectCount > 0 { + fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del(%d) | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset) + } else { + fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset) + } } else { largeFileCount := len(m.largeFiles) - if largeFileCount > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) + selectCount := len(m.multiSelected) + if selectCount > 0 { + if largeFileCount > 0 { + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | T Top(%d) | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset) + } else { + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | Q Quit%s\n", colorGray, selectCount, colorReset) + } } else { - fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) + if largeFileCount > 0 { + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) + } else { + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) + } } } if m.deleteConfirm && m.deleteTarget != nil { fmt.Fprintln(&b) - fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress ⌫ again | ESC cancel%s\n", - colorRed, colorReset, - m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), - colorGray, colorReset) + // Show multi-selection delete info if applicable + var deleteCount int + var totalDeleteSize int64 + if m.showLargeFiles && len(m.largeMultiSelected) > 0 { + deleteCount = len(m.largeMultiSelected) + for idx := range m.largeMultiSelected { + if idx < len(m.largeFiles) { + totalDeleteSize += m.largeFiles[idx].Size + } + } + } else if !m.showLargeFiles && len(m.multiSelected) > 0 { + deleteCount = len(m.multiSelected) + for idx := range m.multiSelected { + if idx < len(m.entries) { + totalDeleteSize += m.entries[idx].Size + } + } + } + + if deleteCount > 1 { + fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress ⌫ again | ESC cancel%s\n", + colorRed, colorReset, + deleteCount, humanizeBytes(totalDeleteSize), + colorGray, colorReset) + } else { + fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress ⌫ again | ESC cancel%s\n", + colorRed, colorReset, + m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), + colorGray, colorReset) + } } return b.String() }