474 lines
13 KiB
Go
474 lines
13 KiB
Go
package tui
|
|
|
|
import (
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"runtime"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/exp/slices"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"b612.me/apps/b612/gdu/internal/common"
|
|
"b612.me/apps/b612/gdu/pkg/analyze"
|
|
"b612.me/apps/b612/gdu/pkg/device"
|
|
"b612.me/apps/b612/gdu/pkg/fs"
|
|
"b612.me/apps/b612/gdu/pkg/remove"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
)
|
|
|
|
// UI struct
|
|
type UI struct {
|
|
*common.UI
|
|
app common.TermApplication
|
|
screen tcell.Screen
|
|
output io.Writer
|
|
grid *tview.Grid
|
|
header *tview.TextView
|
|
footer *tview.Flex
|
|
footerLabel *tview.TextView
|
|
currentDirLabel *tview.TextView
|
|
pages *tview.Pages
|
|
progress *tview.TextView
|
|
status *tview.TextView
|
|
help *tview.Flex
|
|
table *tview.Table
|
|
filteringInput *tview.InputField
|
|
currentDir fs.Item
|
|
devices []*device.Device
|
|
topDir fs.Item
|
|
topDirPath string
|
|
currentDirPath string
|
|
askBeforeDelete bool
|
|
showItemCount bool
|
|
showMtime bool
|
|
filtering bool
|
|
filterValue string
|
|
sortBy string
|
|
sortOrder string
|
|
done chan struct{}
|
|
remover func(fs.Item, fs.Item) error
|
|
emptier func(fs.Item, fs.Item) error
|
|
getter device.DevicesInfoGetter
|
|
exec func(argv0 string, argv []string, envv []string) error
|
|
changeCwdFn func(string) error
|
|
linkedItems fs.HardLinkedItems
|
|
selectedTextColor tcell.Color
|
|
selectedBackgroundColor tcell.Color
|
|
footerTextColor string
|
|
footerBackgroundColor string
|
|
footerNumberColor string
|
|
headerTextColor string
|
|
headerBackgroundColor string
|
|
headerHidden bool
|
|
resultRow ResultRow
|
|
currentItemNameMaxLen int
|
|
useOldSizeBar bool
|
|
defaultSortBy string
|
|
defaultSortOrder string
|
|
ignoredRows map[int]struct{}
|
|
markedRows map[int]struct{}
|
|
exportName string
|
|
noDelete bool
|
|
deleteInBackground bool
|
|
deleteQueue chan deleteQueueItem
|
|
activeWorkers int
|
|
workersMut sync.Mutex
|
|
statusMut sync.RWMutex
|
|
deleteWorkersCount int
|
|
}
|
|
|
|
type deleteQueueItem struct {
|
|
item fs.Item
|
|
shouldEmpty bool
|
|
}
|
|
|
|
// ResultRow is a struct for a row in the result table
|
|
type ResultRow struct {
|
|
NumberColor string
|
|
DirectoryColor string
|
|
}
|
|
|
|
// Option is optional function customizing the behaviour of UI
|
|
type Option func(ui *UI)
|
|
|
|
// CreateUI creates the whole UI app
|
|
func CreateUI(
|
|
app common.TermApplication,
|
|
screen tcell.Screen,
|
|
output io.Writer,
|
|
useColors bool,
|
|
showApparentSize bool,
|
|
showRelativeSize bool,
|
|
constGC bool,
|
|
useSIPrefix bool,
|
|
opts ...Option,
|
|
) *UI {
|
|
ui := &UI{
|
|
UI: &common.UI{
|
|
UseColors: useColors,
|
|
ShowApparentSize: showApparentSize,
|
|
ShowRelativeSize: showRelativeSize,
|
|
Analyzer: analyze.CreateAnalyzer(),
|
|
ConstGC: constGC,
|
|
UseSIPrefix: useSIPrefix,
|
|
},
|
|
app: app,
|
|
screen: screen,
|
|
output: output,
|
|
askBeforeDelete: true,
|
|
showItemCount: false,
|
|
remover: remove.ItemFromDir,
|
|
emptier: remove.EmptyFileFromDir,
|
|
exec: Execute,
|
|
linkedItems: make(fs.HardLinkedItems, 10),
|
|
selectedTextColor: tview.Styles.TitleColor,
|
|
selectedBackgroundColor: tview.Styles.MoreContrastBackgroundColor,
|
|
currentItemNameMaxLen: 70,
|
|
defaultSortBy: "size",
|
|
defaultSortOrder: "desc",
|
|
ignoredRows: make(map[int]struct{}),
|
|
markedRows: make(map[int]struct{}),
|
|
exportName: "export.json",
|
|
noDelete: false,
|
|
deleteQueue: make(chan deleteQueueItem, 1000),
|
|
deleteWorkersCount: 3 * runtime.GOMAXPROCS(0),
|
|
}
|
|
for _, o := range opts {
|
|
o(ui)
|
|
}
|
|
|
|
ui.resetSorting()
|
|
|
|
app.SetBeforeDrawFunc(func(screen tcell.Screen) bool {
|
|
screen.Clear()
|
|
return false
|
|
})
|
|
|
|
ui.app.SetInputCapture(ui.keyPressed)
|
|
ui.app.SetMouseCapture(ui.onMouse)
|
|
|
|
ui.header = tview.NewTextView()
|
|
ui.header.SetText(" gdu ~ Use arrow keys to navigate, press ? for help ")
|
|
ui.header.SetTextColor(tcell.GetColor(ui.headerTextColor))
|
|
ui.header.SetBackgroundColor(tcell.GetColor(ui.headerBackgroundColor))
|
|
|
|
ui.currentDirLabel = tview.NewTextView()
|
|
ui.currentDirLabel.SetTextColor(tcell.ColorDefault)
|
|
ui.currentDirLabel.SetBackgroundColor(tcell.ColorDefault)
|
|
|
|
ui.table = tview.NewTable().SetSelectable(true, false)
|
|
ui.table.SetBackgroundColor(tcell.ColorDefault)
|
|
ui.table.SetSelectedFunc(ui.fileItemSelected)
|
|
|
|
if ui.UseColors {
|
|
ui.table.SetSelectedStyle(tcell.Style{}.
|
|
Foreground(ui.selectedTextColor).
|
|
Background(ui.selectedBackgroundColor).Bold(true))
|
|
} else {
|
|
ui.table.SetSelectedStyle(tcell.Style{}.
|
|
Foreground(tcell.ColorWhite).
|
|
Background(tcell.ColorGray).Bold(true))
|
|
}
|
|
|
|
ui.footerLabel = tview.NewTextView().SetDynamicColors(true)
|
|
ui.footerLabel.SetTextColor(tcell.GetColor(ui.footerTextColor))
|
|
ui.footerLabel.SetBackgroundColor(tcell.GetColor(ui.footerBackgroundColor))
|
|
ui.footerLabel.SetText(" No items to display. ")
|
|
|
|
ui.footer = tview.NewFlex()
|
|
ui.footer.AddItem(ui.footerLabel, 0, 1, false)
|
|
|
|
ui.createGrid()
|
|
|
|
ui.pages = tview.NewPages().
|
|
AddPage("background", ui.grid, true, true)
|
|
ui.pages.SetBackgroundColor(tcell.ColorDefault)
|
|
|
|
ui.app.SetRoot(ui.pages, true)
|
|
|
|
return ui
|
|
}
|
|
|
|
// createGrid creates the main grid layout
|
|
func (ui *UI) createGrid() {
|
|
if ui.headerHidden {
|
|
ui.grid = tview.NewGrid().SetRows(1, 0, 1).SetColumns(0)
|
|
ui.grid.AddItem(ui.currentDirLabel, 0, 0, 1, 1, 0, 0, false).
|
|
AddItem(ui.table, 1, 0, 1, 1, 0, 0, true).
|
|
AddItem(ui.footer, 2, 0, 1, 1, 0, 0, false)
|
|
} else {
|
|
ui.grid = tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0)
|
|
ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false).
|
|
AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false).
|
|
AddItem(ui.table, 2, 0, 1, 1, 0, 0, true).
|
|
AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false)
|
|
}
|
|
}
|
|
|
|
// SetSelectedTextColor sets the color for the highlighted selected text
|
|
func (ui *UI) SetSelectedTextColor(color tcell.Color) {
|
|
ui.selectedTextColor = color
|
|
}
|
|
|
|
// SetSelectedBackgroundColor sets the color for the highlighted selected text
|
|
func (ui *UI) SetSelectedBackgroundColor(color tcell.Color) {
|
|
ui.selectedBackgroundColor = color
|
|
}
|
|
|
|
// SetFooterTextColor sets the color for the footer text
|
|
func (ui *UI) SetFooterTextColor(color string) {
|
|
ui.footerTextColor = color
|
|
}
|
|
|
|
// SetFooterBackgroundColor sets the color for the footer background
|
|
func (ui *UI) SetFooterBackgroundColor(color string) {
|
|
ui.footerBackgroundColor = color
|
|
}
|
|
|
|
// SetFooterNumberColor sets the color for the footer number
|
|
func (ui *UI) SetFooterNumberColor(color string) {
|
|
ui.footerNumberColor = color
|
|
}
|
|
|
|
// SetHeaderTextColor sets the color for the header text
|
|
func (ui *UI) SetHeaderTextColor(color string) {
|
|
ui.headerTextColor = color
|
|
}
|
|
|
|
// SetHeaderBackgroundColor sets the color for the header background
|
|
func (ui *UI) SetHeaderBackgroundColor(color string) {
|
|
ui.headerBackgroundColor = color
|
|
}
|
|
|
|
// SetHeaderHidden sets the flag to hide the header
|
|
func (ui *UI) SetHeaderHidden() {
|
|
ui.headerHidden = true
|
|
}
|
|
|
|
// SetResultRowDirectoryColor sets the color for the result row directory
|
|
func (ui *UI) SetResultRowDirectoryColor(color string) {
|
|
ui.resultRow.DirectoryColor = color
|
|
}
|
|
|
|
// SetResultRowNumberColor sets the color for the result row number
|
|
func (ui *UI) SetResultRowNumberColor(color string) {
|
|
ui.resultRow.NumberColor = color
|
|
}
|
|
|
|
// SetCurrentItemNameMaxLen sets the maximum length of the path of the currently processed item
|
|
// to be shown in the progress modal
|
|
func (ui *UI) SetCurrentItemNameMaxLen(maxLen int) {
|
|
ui.currentItemNameMaxLen = maxLen
|
|
}
|
|
|
|
// UseOldSizeBar uses the old size bar (# chars) instead of the new one (unicode block elements)
|
|
func (ui *UI) UseOldSizeBar() {
|
|
ui.useOldSizeBar = true
|
|
}
|
|
|
|
// SetChangeCwdFn sets function that can be used to change current working dir
|
|
// during dir browsing
|
|
func (ui *UI) SetChangeCwdFn(fn func(string) error) {
|
|
ui.changeCwdFn = fn
|
|
}
|
|
|
|
// SetDeleteInParallel sets the flag to delete files in parallel
|
|
func (ui *UI) SetDeleteInParallel() {
|
|
ui.remover = remove.ItemFromDirParallel
|
|
}
|
|
|
|
// StartUILoop starts tview application
|
|
func (ui *UI) StartUILoop() error {
|
|
go func() {
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(
|
|
c,
|
|
syscall.SIGHUP,
|
|
syscall.SIGINT,
|
|
syscall.SIGQUIT,
|
|
syscall.SIGILL,
|
|
syscall.SIGTRAP,
|
|
syscall.SIGABRT,
|
|
syscall.SIGPIPE,
|
|
syscall.SIGTERM,
|
|
)
|
|
s := <-c
|
|
log.Printf("Got signal: %s", s)
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.app.Stop()
|
|
})
|
|
}()
|
|
|
|
return ui.app.Run()
|
|
}
|
|
|
|
// SetShowItemCount sets the flag to show number of items in directory
|
|
func (ui *UI) SetShowItemCount() {
|
|
ui.showItemCount = true
|
|
}
|
|
|
|
// SetShowMTime sets the flag to show last modification time of items in directory
|
|
func (ui *UI) SetShowMTime() {
|
|
ui.showMtime = true
|
|
}
|
|
|
|
// SetNoDelete disables all write operations
|
|
func (ui *UI) SetNoDelete() {
|
|
ui.noDelete = true
|
|
}
|
|
|
|
// SetDeleteInBackground sets the flag to delete files in background
|
|
func (ui *UI) SetDeleteInBackground() {
|
|
ui.deleteInBackground = true
|
|
|
|
for i := 0; i < ui.deleteWorkersCount; i++ {
|
|
go ui.deleteWorker()
|
|
}
|
|
go ui.updateStatusWorker()
|
|
}
|
|
|
|
func (ui *UI) resetSorting() {
|
|
ui.sortBy = ui.defaultSortBy
|
|
ui.sortOrder = ui.defaultSortOrder
|
|
}
|
|
|
|
func (ui *UI) rescanDir() {
|
|
ui.Analyzer.ResetProgress()
|
|
ui.linkedItems = make(fs.HardLinkedItems)
|
|
err := ui.AnalyzePath(ui.currentDirPath, ui.currentDir.GetParent())
|
|
if err != nil {
|
|
ui.showErr("Error rescanning path", err)
|
|
}
|
|
}
|
|
|
|
func (ui *UI) fileItemSelected(row, column int) {
|
|
if ui.currentDir == nil {
|
|
return // Add this check to handle nil case
|
|
}
|
|
|
|
selectedDirCell := ui.table.GetCell(row, column)
|
|
|
|
// Check if the selectedDirCell is nil before using it
|
|
if selectedDirCell == nil || selectedDirCell.GetReference() == nil {
|
|
return
|
|
}
|
|
|
|
selectedDir := selectedDirCell.GetReference().(fs.Item)
|
|
if selectedDir == nil || !selectedDir.IsDir() {
|
|
return
|
|
}
|
|
|
|
origDir := ui.currentDir
|
|
ui.currentDir = selectedDir
|
|
ui.hideFilterInput()
|
|
ui.markedRows = make(map[int]struct{})
|
|
ui.ignoredRows = make(map[int]struct{})
|
|
ui.showDir()
|
|
|
|
if origDir.GetParent() != nil && selectedDir.GetName() == origDir.GetParent().GetName() {
|
|
index := slices.IndexFunc(
|
|
ui.currentDir.GetFiles(),
|
|
func(v fs.Item) bool {
|
|
return v.GetName() == origDir.GetName()
|
|
},
|
|
)
|
|
if ui.currentDir.GetPath() != ui.topDir.GetPath() {
|
|
index++
|
|
}
|
|
ui.table.Select(index, 0)
|
|
}
|
|
}
|
|
|
|
func (ui *UI) deviceItemSelected(row, column int) {
|
|
var err error
|
|
selectedDevice, ok := ui.table.GetCell(row, column).GetReference().(*device.Device)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
paths := device.GetNestedMountpointsPaths(selectedDevice.MountPoint, ui.devices)
|
|
ui.IgnoreDirPathPatterns, err = common.CreateIgnorePattern(paths)
|
|
if err != nil {
|
|
log.Printf("Creating path patterns for other devices failed: %s", paths)
|
|
}
|
|
|
|
ui.resetSorting()
|
|
|
|
ui.Analyzer.ResetProgress()
|
|
ui.linkedItems = make(fs.HardLinkedItems)
|
|
err = ui.AnalyzePath(selectedDevice.MountPoint, nil)
|
|
if err != nil {
|
|
ui.showErr("Error analyzing device", err)
|
|
}
|
|
}
|
|
|
|
func (ui *UI) confirmDeletion(shouldEmpty bool) {
|
|
if ui.noDelete {
|
|
previousHeaderText := ui.header.GetText(false)
|
|
|
|
// show feedback to user
|
|
ui.header.SetText(" Deletion is disabled!")
|
|
|
|
go func() {
|
|
time.Sleep(2 * time.Second)
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.header.Clear()
|
|
ui.header.SetText(previousHeaderText)
|
|
})
|
|
}()
|
|
|
|
return
|
|
}
|
|
|
|
if len(ui.markedRows) > 0 {
|
|
ui.confirmDeletionMarked(shouldEmpty)
|
|
} else {
|
|
ui.confirmDeletionSelected(shouldEmpty)
|
|
}
|
|
}
|
|
|
|
func (ui *UI) confirmDeletionSelected(shouldEmpty bool) {
|
|
row, column := ui.table.GetSelection()
|
|
selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item)
|
|
var action string
|
|
if shouldEmpty {
|
|
action = "empty"
|
|
} else {
|
|
action = "delete"
|
|
}
|
|
modal := tview.NewModal().
|
|
SetText(
|
|
"Are you sure you want to " +
|
|
action +
|
|
" \"" +
|
|
tview.Escape(selectedFile.GetName()) +
|
|
"\"?",
|
|
).
|
|
AddButtons([]string{"yes", "no", "don't ask me again"}).
|
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
switch buttonIndex {
|
|
case 2:
|
|
ui.askBeforeDelete = false
|
|
fallthrough
|
|
case 0:
|
|
ui.deleteSelected(shouldEmpty)
|
|
}
|
|
ui.pages.RemovePage("confirm")
|
|
})
|
|
|
|
if !ui.UseColors {
|
|
modal.SetBackgroundColor(tcell.ColorGray)
|
|
} else {
|
|
modal.SetBackgroundColor(tcell.ColorBlack)
|
|
}
|
|
modal.SetBorderColor(tcell.ColorDefault)
|
|
|
|
ui.pages.AddPage("confirm", modal, true, true)
|
|
}
|