add interactive tables for all commands
This commit is contained in:
parent
c72b82f542
commit
00cee25075
7 changed files with 563 additions and 117 deletions
21
internal/dirs/dirs.go
Normal file
21
internal/dirs/dirs.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package dirs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnExpand unexpands some directory shortcuts
|
||||
//
|
||||
// $HOME -> ~
|
||||
func UnExpand(dir string) (outdir string) {
|
||||
var (
|
||||
home = os.Getenv("HOME")
|
||||
)
|
||||
|
||||
outdir = filepath.Clean(dir)
|
||||
outdir = strings.ReplaceAll(outdir, home, "~")
|
||||
|
||||
return
|
||||
}
|
|
@ -2,19 +2,13 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.burning.moe/celediel/gt/internal/filter"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
|
@ -33,40 +27,6 @@ func (f File) Modified() time.Time { return f.modified }
|
|||
func (f File) Filesize() int64 { return f.filesize }
|
||||
func (f File) IsDir() bool { return f.isdir }
|
||||
|
||||
func (fls Files) Table(width int) string {
|
||||
// sort newest on top
|
||||
slices.SortStableFunc(fls, SortByModifiedReverse)
|
||||
|
||||
data := [][]string{}
|
||||
for _, file := range fls {
|
||||
var t, b string
|
||||
t = humanize.Time(file.modified)
|
||||
if file.isdir {
|
||||
b = strings.Repeat("─", 3)
|
||||
} else {
|
||||
b = humanize.Bytes(uint64(file.filesize))
|
||||
}
|
||||
data = append(data, []string{
|
||||
file.name,
|
||||
file.path,
|
||||
t,
|
||||
b,
|
||||
})
|
||||
}
|
||||
t := table.New().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
|
||||
Width(width).
|
||||
Headers("filename", "path", "modified", "size").
|
||||
Rows(data...)
|
||||
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
|
||||
func (fls Files) Show(width int) {
|
||||
fmt.Println(fls.Table(width))
|
||||
}
|
||||
|
||||
func Find(dir string, recursive bool, f *filter.Filter) (files Files, err error) {
|
||||
if dir == "." || dir == "" {
|
||||
var d string
|
||||
|
|
386
internal/tables/tables.go
Normal file
386
internal/tables/tables.go
Normal file
|
@ -0,0 +1,386 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.burning.moe/celediel/gt/internal/dirs"
|
||||
"git.burning.moe/celediel/gt/internal/files"
|
||||
"git.burning.moe/celediel/gt/internal/trash"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
const (
|
||||
uncheck string = "☐"
|
||||
check string = "☑"
|
||||
space string = " "
|
||||
woffset int = 13 // why this number, I don't know
|
||||
hoffset int = 5
|
||||
|
||||
// TODO: make these configurable or something
|
||||
borderbg string = "5"
|
||||
hoveritembg string = "13"
|
||||
black string = "0"
|
||||
darkblack string = "8"
|
||||
white string = "7"
|
||||
darkgray string = "15"
|
||||
)
|
||||
|
||||
var (
|
||||
style = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(borderbg))
|
||||
regulartext = lipgloss.NewStyle().
|
||||
Padding(0, 2)
|
||||
darktext = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(darkgray))
|
||||
darkertext = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(darkblack))
|
||||
darkesttext = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(black))
|
||||
)
|
||||
|
||||
type model struct {
|
||||
table table.Model
|
||||
keys keyMap
|
||||
selected []int
|
||||
readonly bool
|
||||
termheight int
|
||||
}
|
||||
|
||||
// TODO: reconcile trash.Info and files.File into an interface so I can shorten this up
|
||||
|
||||
func newInfosModel(is trash.Infos, width, height int, readonly, preselected bool) model {
|
||||
var (
|
||||
fwidth int = int(math.Round(float64(width-woffset) * 0.4))
|
||||
owidth int = int(math.Round(float64(width-woffset) * 0.2))
|
||||
dwidth int = int(math.Round(float64(width-woffset) * 0.25))
|
||||
swidth int = int(math.Round(float64(width-woffset) * 0.12))
|
||||
cwidth int = int(math.Round(float64(width-woffset) * 0.03))
|
||||
theight int = min(height-hoffset, len(is))
|
||||
|
||||
m = model{
|
||||
keys: defaultKeyMap(),
|
||||
readonly: readonly,
|
||||
termheight: height,
|
||||
}
|
||||
)
|
||||
slices.SortStableFunc(is, trash.SortByTrashedReverse)
|
||||
|
||||
rows := []table.Row{}
|
||||
for j, i := range is {
|
||||
var t, b string
|
||||
t = humanize.Time(i.Trashed())
|
||||
if i.IsDir() {
|
||||
b = strings.Repeat("─", 3)
|
||||
} else {
|
||||
b = humanize.Bytes(uint64(i.Filesize()))
|
||||
}
|
||||
r := table.Row{
|
||||
i.Name(),
|
||||
dirs.UnExpand(filepath.Dir(i.OGPath())),
|
||||
t,
|
||||
b,
|
||||
}
|
||||
|
||||
if !m.readonly {
|
||||
r = append(r, getCheck(preselected))
|
||||
}
|
||||
if preselected {
|
||||
m.selected = append(m.selected, j)
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
|
||||
columns := []table.Column{
|
||||
{Title: "filename", Width: fwidth},
|
||||
{Title: "original path", Width: owidth},
|
||||
{Title: "deleted", Width: dwidth},
|
||||
{Title: "size", Width: swidth},
|
||||
}
|
||||
if !m.readonly {
|
||||
columns = append(columns, table.Column{Title: uncheck, Width: cwidth})
|
||||
} else {
|
||||
columns[0].Width += cwidth
|
||||
}
|
||||
|
||||
m.table = createTable(columns, rows, theight, m.readonlyOnePage())
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func newFilesModel(fs files.Files, width, height int, readonly, preselected bool) model {
|
||||
var (
|
||||
fwidth int = int(math.Round(float64(width-woffset) * 0.4))
|
||||
owidth int = int(math.Round(float64(width-woffset) * 0.2))
|
||||
dwidth int = int(math.Round(float64(width-woffset) * 0.25))
|
||||
swidth int = int(math.Round(float64(width-woffset) * 0.12))
|
||||
cwidth int = int(math.Round(float64(width-woffset) * 0.03))
|
||||
theight int = min(height-hoffset, len(fs))
|
||||
|
||||
m = model{
|
||||
keys: defaultKeyMap(),
|
||||
readonly: readonly,
|
||||
}
|
||||
)
|
||||
|
||||
slices.SortStableFunc(fs, files.SortByModifiedReverse)
|
||||
|
||||
rows := []table.Row{}
|
||||
for j, f := range fs {
|
||||
var t, b string
|
||||
t = humanize.Time(f.Modified())
|
||||
if f.IsDir() {
|
||||
b = strings.Repeat("─", 3)
|
||||
} else {
|
||||
b = humanize.Bytes(uint64(f.Filesize()))
|
||||
}
|
||||
r := table.Row{
|
||||
f.Name(),
|
||||
dirs.UnExpand(f.Path()),
|
||||
t,
|
||||
b,
|
||||
}
|
||||
|
||||
if !m.readonly {
|
||||
r = append(r, getCheck(preselected))
|
||||
}
|
||||
if preselected {
|
||||
m.selected = append(m.selected, j)
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
|
||||
columns := []table.Column{
|
||||
{Title: "filename", Width: fwidth},
|
||||
{Title: "path", Width: owidth},
|
||||
{Title: "modified", Width: dwidth},
|
||||
{Title: "size", Width: swidth},
|
||||
}
|
||||
if !m.readonly {
|
||||
columns = append(columns, table.Column{Title: uncheck, Width: cwidth})
|
||||
} else {
|
||||
columns[0].Width += cwidth
|
||||
}
|
||||
|
||||
m.table = createTable(columns, rows, theight, m.readonlyOnePage())
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
type keyMap struct {
|
||||
mark key.Binding
|
||||
doit key.Binding
|
||||
quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
return keyMap{
|
||||
mark: key.NewBinding(
|
||||
key.WithKeys(space),
|
||||
key.WithHelp("space", "toggle selected"),
|
||||
),
|
||||
doit: key.NewBinding(
|
||||
key.WithKeys("enter", "y"),
|
||||
key.WithHelp("enter/y", "confirm"),
|
||||
),
|
||||
quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q/^c", "quit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
if m.readonlyOnePage() {
|
||||
return tea.Quit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.mark):
|
||||
m.select_item(m.table.SelectedRow(), m.table.Cursor())
|
||||
case key.Matches(msg, m.keys.doit):
|
||||
if !m.readonly {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case key.Matches(msg, m.keys.quit):
|
||||
m.selected = []int{}
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
// pass events along to the table
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() (out string) {
|
||||
var (
|
||||
n string
|
||||
panels []string = []string{
|
||||
style.Render(m.table.View()),
|
||||
}
|
||||
)
|
||||
|
||||
if m.readonlyOnePage() {
|
||||
n = "\n"
|
||||
} else {
|
||||
panels = append(panels, m.footer())
|
||||
}
|
||||
|
||||
out = lipgloss.JoinVertical(lipgloss.Top, panels...)
|
||||
return out + n
|
||||
}
|
||||
|
||||
func (m model) readonlyOnePage() bool {
|
||||
return m.readonly && m.termheight > m.table.Height()
|
||||
}
|
||||
|
||||
func (m model) showHelp() string {
|
||||
// TODO: maybe use bubbletea built in help
|
||||
var keys []string = []string{
|
||||
fmt.Sprintf("%s %s", darktext.Render(m.keys.quit.Help().Key), darkertext.Render(m.keys.quit.Help().Desc)),
|
||||
}
|
||||
if !m.readonly {
|
||||
keys = append([]string{
|
||||
fmt.Sprintf("%s %s", darktext.Render(m.keys.mark.Help().Key), darkertext.Render(m.keys.mark.Help().Desc)),
|
||||
fmt.Sprintf("%s %s", darktext.Render(m.keys.doit.Help().Key), darkertext.Render(m.keys.doit.Help().Desc)),
|
||||
}, keys...)
|
||||
}
|
||||
return strings.Join(keys, darkesttext.Render(" • "))
|
||||
}
|
||||
|
||||
func (m model) footer() string {
|
||||
return regulartext.Render(m.showHelp())
|
||||
}
|
||||
|
||||
// select_item selects an item, and returns its current selected state
|
||||
func (m *model) select_item(row table.Row, index int) (selected bool) {
|
||||
if m.readonly {
|
||||
return false
|
||||
}
|
||||
|
||||
// select the thing
|
||||
if slices.Contains(m.selected, index) {
|
||||
// already selected
|
||||
m.selected = slices.DeleteFunc(m.selected, func(other int) bool { return index == other })
|
||||
selected = false
|
||||
} else {
|
||||
// not selected
|
||||
m.selected = append(m.selected, index)
|
||||
selected = true
|
||||
}
|
||||
|
||||
// update the rows with the state
|
||||
rows := m.table.Rows()
|
||||
rows[index] = table.Row{
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
getCheck(selected),
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func InfoTable(is trash.Infos, width, height int, readonly, preselected bool) ([]int, error) {
|
||||
if endmodel, err := tea.NewProgram(newInfosModel(is, width, height, readonly, preselected)).Run(); err != nil {
|
||||
return []int{}, err
|
||||
} else {
|
||||
m, ok := endmodel.(model)
|
||||
if ok {
|
||||
return m.selected, nil
|
||||
} else {
|
||||
return []int{}, fmt.Errorf("model isn't the right type??")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FilesTable(fs files.Files, width, height int, readonly, preselected bool) ([]int, error) {
|
||||
if endmodel, err := tea.NewProgram(newFilesModel(fs, width, height, readonly, preselected)).Run(); err != nil {
|
||||
return []int{}, err
|
||||
} else {
|
||||
m, ok := endmodel.(model)
|
||||
if ok {
|
||||
return m.selected, nil
|
||||
} else {
|
||||
return []int{}, fmt.Errorf("model isn't the right type??")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getCheck(selected bool) (ourcheck string) {
|
||||
if selected {
|
||||
ourcheck = check
|
||||
} else {
|
||||
ourcheck = uncheck
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func createTable(columns []table.Column, rows []table.Row, height int, readonlyonepage bool) table.Model {
|
||||
t := table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(height),
|
||||
)
|
||||
t.KeyMap = fixTableKeymap()
|
||||
if readonlyonepage {
|
||||
style := makeStyle()
|
||||
style.Selected = style.Selected.
|
||||
Foreground(lipgloss.NoColor{}).
|
||||
Background(lipgloss.NoColor{}).
|
||||
Bold(false)
|
||||
|
||||
t.SetStyles(style)
|
||||
} else {
|
||||
t.SetStyles(makeStyle())
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func fixTableKeymap() table.KeyMap {
|
||||
t := table.DefaultKeyMap()
|
||||
|
||||
// remove spacebar from default page down keybind, but keep the rest
|
||||
t.PageDown.SetKeys(
|
||||
slices.DeleteFunc(t.PageDown.Keys(), func(s string) bool {
|
||||
return s == space
|
||||
})...,
|
||||
)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func makeStyle() table.Styles {
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(black)).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color(white)).
|
||||
Background(lipgloss.Color(hoveritembg)).
|
||||
Bold(false)
|
||||
|
||||
return s
|
||||
}
|
|
@ -3,19 +3,14 @@
|
|||
package trash
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.burning.moe/celediel/gt/internal/filter"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dustin/go-humanize"
|
||||
"gitlab.com/tymonx/go-formatter/formatter"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
@ -49,41 +44,6 @@ func (i Info) Trashed() time.Time { return i.trashed }
|
|||
func (i Info) Filesize() int64 { return i.filesize }
|
||||
func (i Info) IsDir() bool { return i.isdir }
|
||||
|
||||
func (is Infos) Table(width int) string {
|
||||
|
||||
// sort newest on top
|
||||
slices.SortStableFunc(is, SortByTrashedReverse)
|
||||
out := [][]string{}
|
||||
for _, file := range is {
|
||||
var t, b string
|
||||
t = humanize.Time(file.trashed)
|
||||
if file.isdir {
|
||||
b = strings.Repeat("─", 3)
|
||||
} else {
|
||||
b = humanize.Bytes(uint64(file.filesize))
|
||||
}
|
||||
out = append(out, []string{
|
||||
file.name,
|
||||
filepath.Dir(file.ogpath),
|
||||
t,
|
||||
b,
|
||||
})
|
||||
}
|
||||
|
||||
t := table.New().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
|
||||
Width(width).
|
||||
Headers("filename", "original path", "deleted", "size").
|
||||
Rows(out...)
|
||||
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
|
||||
func (is Infos) Show(width int) {
|
||||
fmt.Println(is.Table(width))
|
||||
}
|
||||
|
||||
func FindFiles(trashdir, ogdir string, f *filter.Filter) (files Infos, outerr error) {
|
||||
outerr = filepath.WalkDir(trashdir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
|
@ -103,6 +63,7 @@ func FindFiles(trashdir, ogdir string, f *filter.Filter) (files Infos, outerr er
|
|||
}
|
||||
if s := c.Section(trash_info_sec); s != nil {
|
||||
basepath := s.Key(trash_info_path).String()
|
||||
|
||||
filename := filepath.Base(basepath)
|
||||
// maybe this is kind of a HACK
|
||||
trashedpath := strings.Replace(strings.Replace(path, "info", "files", 1), trash_info_ext, "", 1)
|
||||
|
@ -153,7 +114,6 @@ func Restore(files []Info) (restored int, err error) {
|
|||
}
|
||||
restored++
|
||||
}
|
||||
fmt.Printf("restored %d files\n", restored)
|
||||
return restored, err
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue