tui/tui.go
package tui
import (
"io"
"os"
"os/signal"
"runtime"
"sync"
"syscall"
"time"
"golang.org/x/exp/slices"
log "github.com/sirupsen/logrus"
"github.com/dundee/gdu/v5/internal/common"
"github.com/dundee/gdu/v5/pkg/analyze"
"github.com/dundee/gdu/v5/pkg/device"
"github.com/dundee/gdu/v5/pkg/fs"
"github.com/dundee/gdu/v5/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
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
}
// Option is optional function customizing the bahaviour 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)
var textColor, textBgColor tcell.Color
if ui.UseColors {
textColor = tcell.NewRGBColor(0, 0, 0)
textBgColor = tcell.NewRGBColor(36, 121, 208)
} else {
textColor = tcell.NewRGBColor(0, 0, 0)
textBgColor = tcell.NewRGBColor(255, 255, 255)
}
ui.header = tview.NewTextView()
ui.header.SetText(" gdu ~ Use arrow keys to navigate, press ? for help ")
ui.header.SetTextColor(textColor)
ui.header.SetBackgroundColor(textBgColor)
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(textColor)
ui.footerLabel.SetBackgroundColor(textBgColor)
ui.footerLabel.SetText(" No items to display. ")
ui.footer = tview.NewFlex()
ui.footer.AddItem(ui.footerLabel, 0, 1, false)
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)
ui.pages = tview.NewPages().
AddPage("background", ui.grid, true, true)
ui.pages.SetBackgroundColor(tcell.ColorDefault)
ui.app.SetRoot(ui.pages, true)
return ui
}
// SetSelectedTextColor sets the color for the highighted selected text
func (ui *UI) SetSelectedTextColor(color tcell.Color) {
ui.selectedTextColor = color
}
// SetSelectedBackgroundColor sets the color for the highighted selected text
func (ui *UI) SetSelectedBackgroundColor(color tcell.Color) {
ui.selectedBackgroundColor = 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)
}