mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:04:42 +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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user