1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 13:16:47 +00:00

feat(analyze): add multi-select for batch file operations (#140)

- 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 <sizk@users.noreply.github.com>
This commit is contained in:
Sizk
2025-12-21 22:10:06 +08:00
committed by Tw93
parent 6d087b3b12
commit 74d05ed9aa
4 changed files with 361 additions and 60 deletions

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ temp/
# AI Assistant Instructions # AI Assistant Instructions
.claude/ .claude/
.gemini/ .gemini/
.kiro/
CLAUDE.md CLAUDE.md
GEMINI.md GEMINI.md
.cursorrules .cursorrules

View File

@@ -4,6 +4,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic" "sync/atomic"
tea "github.com/charmbracelet/bubbletea" 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) { func deletePathWithProgress(root string, counter *int64) (int64, error) {
var count int64 var count int64
var firstErr error var firstErr error

View File

@@ -111,6 +111,8 @@ type model struct {
overviewScanningSet map[string]bool // Track which paths are currently being scanned overviewScanningSet map[string]bool // Track which paths are currently being scanned
width int // Terminal width width int // Terminal width
height int // Terminal height 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 { func (m model) inOverviewMode() bool {
@@ -177,6 +179,8 @@ func newModel(path string, isOverview bool) model {
overviewCurrentPath: &overviewCurrentPath, overviewCurrentPath: &overviewCurrentPath,
overviewSizeCache: make(map[string]int64), overviewSizeCache: make(map[string]int64),
overviewScanningSet: make(map[string]bool), overviewScanningSet: make(map[string]bool),
multiSelected: make(map[int]bool),
largeMultiSelected: make(map[int]bool),
} }
// In overview mode, create shortcut entries // In overview mode, create shortcut entries
@@ -392,6 +396,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case deleteProgressMsg: case deleteProgressMsg:
if msg.done { if msg.done {
m.deleting = false m.deleting = false
// Clear multi-selection after delete
m.multiSelected = make(map[int]bool)
m.largeMultiSelected = make(map[int]bool)
if msg.err != nil { if msg.err != nil {
m.status = fmt.Sprintf("Failed to delete: %v", msg.err) m.status = fmt.Sprintf("Failed to delete: %v", msg.err)
} else { } else {
@@ -522,20 +529,48 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "delete", "backspace": case "delete", "backspace":
// Confirm delete - start async deletion // 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.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 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": case "esc", "q":
// Cancel delete with ESC or Q // Cancel delete with ESC or Q
m.status = "Cancelled" m.status = "Cancelled"
@@ -682,13 +717,58 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.showLargeFiles { if m.showLargeFiles {
m.largeSelected = 0 m.largeSelected = 0
m.largeOffset = 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": case "o":
// Open selected entry // Open selected entries (multi-select aware)
if m.showLargeFiles { if m.showLargeFiles {
if len(m.largeFiles) > 0 { 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) { go func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
@@ -696,20 +776,52 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}(selected.Path) }(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.Name) 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": case "f", "F":
// Reveal selected entry in Finder // Reveal selected entries in Finder (multi-select aware)
if m.showLargeFiles { if m.showLargeFiles {
if len(m.largeFiles) > 0 { 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) { go func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
@@ -717,32 +829,103 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}(selected.Path) }(selected.Path)
m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) 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": case " ":
// Delete selected file or directory // Toggle multi-select with spacebar
if m.showLargeFiles { if m.showLargeFiles {
if len(m.largeFiles) > 0 { if len(m.largeFiles) > 0 {
selected := m.largeFiles[m.largeSelected] if m.largeMultiSelected == nil {
m.deleteConfirm = true m.largeMultiSelected = make(map[int]bool)
m.deleteTarget = &dirEntry{ }
Name: selected.Name, if m.largeMultiSelected[m.largeSelected] {
Path: selected.Path, delete(m.largeMultiSelected, m.largeSelected)
Size: selected.Size, } else {
IsDir: false, 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() { } else if len(m.entries) > 0 && !m.inOverviewMode() {
selected := m.entries[m.selected] if m.multiSelected == nil {
m.deleteConfirm = true m.multiSelected = make(map[int]bool)
m.deleteTarget = &selected }
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 return m, nil
@@ -784,6 +967,9 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
m.status = "Scanning..." m.status = "Scanning..."
m.scanning = true m.scanning = true
m.isOverview = false 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 // Reset scan counters for new scan
atomic.StoreInt64(m.filesScanned, 0) atomic.StoreInt64(m.filesScanned, 0)

View File

@@ -129,16 +129,27 @@ func (m model) View() string {
nameColor := "" nameColor := ""
sizeColor := colorGray sizeColor := colorGray
numColor := "" 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 { if idx == m.largeSelected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameColor = colorCyan if !isMultiSelected {
nameColor = colorCyan
}
sizeColor = colorCyan sizeColor = colorCyan
numColor = colorCyan numColor = colorCyan
} }
size := humanizeBytes(file.Size) size := humanizeBytes(file.Size)
bar := coloredProgressBar(file.Size, maxLargeSize, 0) bar := coloredProgressBar(file.Size, maxLargeSize, 0)
fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n", fmt.Fprintf(&b, "%s%s %s%2d.%s %s | 📄 %s%s%s %s%10s%s\n",
entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) entryPrefix, selectIcon, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset)
} }
} }
} else { } else {
@@ -278,14 +289,28 @@ func (m model) View() string {
sizeColor = colorGray 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 // Keep chart columns aligned even when arrow is shown
entryPrefix := " " entryPrefix := " "
nameSegment := fmt.Sprintf("%s %s", icon, paddedName) nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
if nameColor != "" {
nameSegment = fmt.Sprintf("%s%s %s%s", nameColor, icon, paddedName, colorReset)
}
numColor := "" numColor := ""
percentColor := "" percentColor := ""
if idx == m.selected { if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) 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 numColor = colorCyan
percentColor = colorCyan percentColor = colorCyan
sizeColor = colorCyan sizeColor = colorCyan
@@ -309,12 +334,12 @@ func (m model) View() string {
} }
if hintLabel == "" { if hintLabel == "" {
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s\n",
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset) nameSegment, sizeColor, size, colorReset)
} else { } else {
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s %s\n",
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset, hintLabel) 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) fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset)
} }
} else if m.showLargeFiles { } 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 { } else {
largeFileCount := len(m.largeFiles) largeFileCount := len(m.largeFiles)
if largeFileCount > 0 { selectCount := len(m.multiSelected)
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) 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 { } 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 { if m.deleteConfirm && m.deleteTarget != nil {
fmt.Fprintln(&b) fmt.Fprintln(&b)
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress ⌫ again | ESC cancel%s\n", // Show multi-selection delete info if applicable
colorRed, colorReset, var deleteCount int
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), var totalDeleteSize int64
colorGray, colorReset) 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() return b.String()
} }