tui/tui.go

Summary

Maintainability
A
3 hrs
Test Coverage
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)
}