From 00cee250751b9fd1de8d63a0579b9d6de7905b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lilian=20J=C3=B3nsd=C3=B3ttir?= Date: Wed, 19 Jun 2024 15:55:01 -0700 Subject: [PATCH] add interactive tables for all commands --- go.mod | 9 + go.sum | 21 +++ internal/dirs/dirs.go | 21 +++ internal/files/files.go | 40 ---- internal/tables/tables.go | 386 ++++++++++++++++++++++++++++++++++++++ internal/trash/trash.go | 42 +---- main.go | 161 ++++++++++++---- 7 files changed, 563 insertions(+), 117 deletions(-) create mode 100644 internal/dirs/dirs.go create mode 100644 internal/tables/tables.go diff --git a/go.mod b/go.mod index 3dd41e2..17c4d13 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.22.3 require ( github.com/adrg/xdg v0.4.0 + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.11.0 github.com/charmbracelet/log v0.4.0 github.com/dustin/go-humanize v1.0.1 @@ -17,16 +19,23 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 0ea46dd..d38563c 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,18 @@ github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,12 +34,22 @@ github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -57,15 +73,20 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= diff --git a/internal/dirs/dirs.go b/internal/dirs/dirs.go new file mode 100644 index 0000000..55d797f --- /dev/null +++ b/internal/dirs/dirs.go @@ -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 +} diff --git a/internal/files/files.go b/internal/files/files.go index ed409e4..46bb055 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -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 diff --git a/internal/tables/tables.go b/internal/tables/tables.go new file mode 100644 index 0000000..00162fb --- /dev/null +++ b/internal/tables/tables.go @@ -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 +} diff --git a/internal/trash/trash.go b/internal/trash/trash.go index b254037..ffbe500 100644 --- a/internal/trash/trash.go +++ b/internal/trash/trash.go @@ -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 } diff --git a/main.go b/main.go index 29f22ee..1252cc2 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,16 @@ package main import ( - "bufio" + "bytes" "fmt" "os" "path/filepath" "slices" - "strings" "time" "git.burning.moe/celediel/gt/internal/files" "git.burning.moe/celediel/gt/internal/filter" + "git.burning.moe/celediel/gt/internal/tables" "git.burning.moe/celediel/gt/internal/trash" "github.com/adrg/xdg" @@ -33,6 +33,7 @@ var ( workdir, ogdir cli.Path recursive bool termwidth int + termheight int trashDir = filepath.Join(xdg.DataHome, "Trash") @@ -48,11 +49,13 @@ var ( } } - w, _, e := term.GetSize(int(os.Stdout.Fd())) + w, h, e := term.GetSize(int(os.Stdout.Fd())) if e != nil { w = 80 + h = 24 } termwidth = w + termheight = h return } @@ -86,10 +89,23 @@ var ( return nil } - fls.Show(termwidth) - if confirm(fmt.Sprintf("trash these %d files?", len(fls))) { - tfs := make([]string, 0, len(fls)) - for _, file := range fls { + indices, err := tables.FilesTable(fls, termwidth, termheight, false, !f.Blank()) + if err != nil { + return err + } + + var selected files.Files + for _, i := range indices { + selected = append(selected, fls[i]) + } + + if len(selected) <= 0 { + return nil + } + + if confirm(fmt.Sprintf("trash %d selected files?", len(selected))) { + tfs := make([]string, 0, len(selected)) + for _, file := range selected { log.Debugf("gonna trash %s", file.Filename()) tfs = append(tfs, file.Filename()) } @@ -98,15 +114,50 @@ var ( if err != nil { return err } - log.Printf("trashed %d files", trashed) + fmt.Printf("trashed %d files\n", trashed) } else { - log.Info("not gonna do it") + fmt.Printf("not doing anything\n") return nil } return nil }, } + action = func(ctx *cli.Context) error { + var ( + fls trash.Infos + indicies []int + err error + ) + + if f == nil { + f, err = filter.New(o, b, a, g, p, ung, unp, ctx.Args().Slice()...) + } + if err != nil { + return err + } + + fls, err = trash.FindFiles(trashDir, ogdir, f) + if err != nil { + return err + } + + if len(fls) <= 0 { + log.Printf("no files to show") + return nil + } + + indicies, err = tables.InfoTable(fls, termwidth, termheight, false, true) + if err != nil { + return err + } + + for _, i := range indicies { + log.Printf("gonna do something with %s", fls[i].Name()) + } + return nil + } + do_list = &cli.Command{ Name: "list", Aliases: []string{"ls"}, @@ -117,7 +168,7 @@ var ( log.Debugf("searching in directory %s for files", trashDir) // look for files - files, err := trash.FindFiles(trashDir, ogdir, f) + fls, err := trash.FindFiles(trashDir, ogdir, f) var msg string if f.Blank() { @@ -126,7 +177,7 @@ var ( msg = "no files to show" } - if len(files) == 0 { + if len(fls) == 0 { fmt.Println(msg) return nil } else if err != nil { @@ -134,9 +185,9 @@ var ( } // display them - files.Show(termwidth) + _, err = tables.InfoTable(fls, termwidth, termheight, true, false) - return nil + return err }, } @@ -150,24 +201,37 @@ var ( log.Debugf("searching in directory %s for files", trashDir) // look for files - files, err := trash.FindFiles(trashDir, ogdir, f) - if len(files) == 0 { + fls, err := trash.FindFiles(trashDir, ogdir, f) + if len(fls) == 0 { fmt.Println("no files to restore") return nil } else if err != nil { return err } - files.Show(termwidth) - if confirm(fmt.Sprintf("restore these %d files?", len(files))) { + indices, err := tables.InfoTable(fls, termwidth, termheight, false, !f.Blank()) + if err != nil { + return err + } + + var selected trash.Infos + for _, i := range indices { + selected = append(selected, fls[i]) + } + + if len(selected) <= 0 { + return nil + } + + if confirm(fmt.Sprintf("restore %d selected files?", len(selected))) { log.Info("doing the thing") - restored, err := trash.Restore(files) + restored, err := trash.Restore(selected) if err != nil { return fmt.Errorf("restored %d files before error %s", restored, err) } - log.Printf("restored %d files\n", restored) + fmt.Printf("restored %d files\n", restored) } else { - log.Info("not gonna do it") + fmt.Printf("not doing anything\n") } return nil @@ -181,25 +245,38 @@ var ( Flags: slices.Concat(alreadyintrash_flags, filter_flags), Before: before_commands, Action: func(ctx *cli.Context) error { - files, err := trash.FindFiles(trashDir, ogdir, f) - if len(files) == 0 { + fls, err := trash.FindFiles(trashDir, ogdir, f) + if len(fls) == 0 { fmt.Println("no files to clean") return nil } else if err != nil { return err } - files.Show(termwidth) - if confirm(fmt.Sprintf("remove these %d files permanently from the trash?", len(files))) && - confirm(fmt.Sprintf("really remove all %d of these files permanently from the trash forever??", len(files))) { + indices, err := tables.InfoTable(fls, termwidth, termheight, false, !f.Blank()) + if err != nil { + return err + } + + var selected trash.Infos + for _, i := range indices { + selected = append(selected, fls[i]) + } + + if len(selected) <= 0 { + return nil + } + + if confirm(fmt.Sprintf("remove %d selected files permanently from the trash?", len(selected))) && + confirm(fmt.Sprintf("really remove all these %d selected files permanently from the trash forever??", len(selected))) { log.Info("gonna remove some files forever") - removed, err := trash.Remove(files) + removed, err := trash.Remove(selected) if err != nil { return fmt.Errorf("removed %d files before error %s", removed, err) } - log.Printf("removed %d files\n", removed) + fmt.Printf("removed %d files\n", removed) } else { - log.Printf("left %d files alone", len(files)) + fmt.Printf("not doing anything\n") } return nil }, @@ -294,6 +371,7 @@ func main() { Version: appversion, Before: before_all, After: after, + Action: action, Commands: []*cli.Command{do_trash, do_list, do_restore, do_clean}, Flags: global_flags, } @@ -303,16 +381,27 @@ func main() { } } -func confirm(s string) bool { - r := bufio.NewReader(os.Stdin) - fmt.Printf("%s [y/n]: ", s) - got, err := r.ReadString('\n') +func confirm(prompt string) bool { + // TODO: handle errors better + // switch stdin into 'raw' mode + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) if err != nil { - log.Fatal(err) + panic(err) } - if len(got) < 2 { + defer func() { + if err := term.Restore(int(os.Stdin.Fd()), oldState); err != nil { + panic(err) + } + }() + + fmt.Printf("%s [y/n]: ", prompt) + + // read one byte from stdin + b := make([]byte, 1) + _, err = os.Stdin.Read(b) + if err != nil { return false - } else { - return strings.ToLower(strings.TrimSpace(got))[0] == 'y' } + + return bytes.ToLower(b)[0] == 'y' }