Files
MujicaUI/archive_viewer.go
T

198 lines
5.9 KiB
Go

package mujica
import (
"cmp"
"fmt"
"log/slog"
"maps"
"math"
"path"
"slices"
"strings"
"github.com/egoist/mygo/ui"
)
// ArchiveEntry describes an archive member with a slash-separated Path.
type ArchiveEntry struct {
Path string
Dir bool
// Size is the unpacked size; Packed the compressed size.
Size, Packed int64
}
// ArchiveViewerState preserves folders, selection, sort and indexed entries.
type ArchiveViewerState struct {
table TreeTableState[string]
index archiveIndex
data *ArchiveEntry
n int
revision uint64
}
// archiveIndex is the tree built from the entries.
type archiveIndex struct {
byPath map[string]*ArchiveEntry
kids map[string][]string
roots []string
// sums totals a folder's files.
sums map[string][2]int64
files int
size, pack int64
}
// ArchiveViewerOptions configure an ArchiveViewer.
type ArchiveViewerOptions struct {
// Revision changes when entries are edited in place.
Revision uint64
// Status shows the caller's listing in progress or failed.
Status DataStatus
Error string
}
// ArchiveViewerResult is the viewer and the entry opened this frame.
type ArchiveViewerResult struct {
Element *ui.Element
Opened *ArchiveEntry
Retried bool
}
// ArchiveViewer virtualizes archive entries with sizes and compression ratios.
func ArchiveViewer(c *ui.Context, entries []ArchiveEntry, s *ArchiveViewerState, opts ArchiveViewerOptions) ArchiveViewerResult {
if s == nil {
panic("mujica: ArchiveViewer needs a state")
}
if s.revision != opts.Revision {
s.data = nil
s.index.byPath = nil
s.revision = opts.Revision
}
archiveBuild(entries, s)
ix := &s.index
u := Density(c).Unit()
size := func(p string) (int64, int64) {
if e := ix.byPath[p]; e != nil && !e.Dir {
return e.Size, e.Packed
}
t := ix.sums[p]
return t[0], t[1]
}
cell := func(text string) { codeMono(c, text).TextColor(Tokens(c).TextMuted) }
var res ArchiveViewerResult
res.Element = ui.Column(c).MinHeight(0).Gap(u * 2)
if res.Element.IsDisabled() {
copy := *s
copy.table.Selection = filesSelectionCopy(s.table.Selection)
copy.table.tree.outline.Open = filesSelectionCopy(s.table.tree.outline.Open)
copy.table.Layout = ui.TableLayout{Order: slices.Clone(s.table.Layout.Order), Widths: maps.Clone(s.table.Layout.Widths)}
copy.table.tree.outline.List.Columns = copy.table.Layout
s = &copy
}
res.Element.Children(func() {
codeCaption(c, fmt.Sprintf(msg(c, "archive_viewer.summary"), ix.files, processListBytes(uint64(ix.size)),
processListBytes(uint64(ix.pack)), archiveRatio(ix.size, ix.pack)))
v := TreeTable(c, &s.table, TreeTableOptions[string]{
Roots: ix.roots, Children: func(p string) []string { return ix.kids[p] },
Label: func(p string) string { return path.Base(p) }, Status: opts.Status, Error: opts.Error,
Empty: msg(c, "archive_viewer.empty"),
Columns: []TreeColumn[string]{
{ID: "name", Title: msg(c, "archive_viewer.name"), Compare: strings.Compare, Cell: func(p string) {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
FileIcon(c, p, FileIconOptions{Folder: ix.kids[p] != nil})
ui.Text(c, path.Base(p)).SingleLine()
})
}},
{ID: "size", Title: msg(c, "archive_viewer.size"), Width: 96, Align: ui.End,
Compare: func(a, b string) int { x, _ := size(a); y, _ := size(b); return cmp.Compare(x, y) },
Cell: func(p string) { n, _ := size(p); cell(processListBytes(uint64(n))) }},
{ID: "packed", Title: msg(c, "archive_viewer.packed"), Width: 96, Align: ui.End,
Cell: func(p string) { _, n := size(p); cell(processListBytes(uint64(n))) }},
{ID: "ratio", Title: msg(c, "archive_viewer.ratio"), Width: 72, Align: ui.End,
Cell: func(p string) { a, b := size(p); cell(archiveRatio(a, b)) }},
},
})
v.Element.Grow(1)
res.Retried = v.Retried()
if p, ok := s.table.Selected(); ok && v.Submitted() && !res.Element.IsDisabled() {
if e := ix.byPath[p]; e != nil && !e.Dir {
open := *e
res.Opened = &open
slog.Debug("mujica: archive_viewer open")
}
}
})
return res
}
// archiveRatio formats the saved-size percentage.
func archiveRatio(size, packed int64) string {
if size <= 0 {
return "—"
}
return fmt.Sprintf("%.0f%%", 100*(1-float64(packed)/float64(size)))
}
// archiveBuild caches entries and implied parent directories.
func archiveBuild(entries []ArchiveEntry, s *ArchiveViewerState) {
var first *ArchiveEntry
if len(entries) > 0 {
first = &entries[0]
}
if s.data == first && s.n == len(entries) && s.index.byPath != nil {
return
}
s.data, s.n = first, len(entries)
ix := archiveIndex{byPath: map[string]*ArchiveEntry{}, kids: map[string][]string{}, sums: map[string][2]int64{}}
var addDir func(p string)
link := func(p string) {
parent := path.Dir(p)
if parent == "." {
ix.roots = append(ix.roots, p)
return
}
addDir(parent)
ix.kids[parent] = append(ix.kids[parent], p)
}
addDir = func(p string) {
if e := ix.byPath[p]; e != nil && !e.Dir {
panic("mujica: ArchiveEntry file used as a folder: " + p)
}
if _, ok := ix.kids[p]; ok {
return
}
ix.kids[p] = []string{}
link(p)
}
for i := range entries {
e := &entries[i]
p := strings.Trim(e.Path, "/")
if p == "" || e.Size < 0 || e.Packed < 0 {
panic("mujica: ArchiveEntry needs a Path and sizes not negative")
}
if _, dup := ix.byPath[p]; dup {
panic("mujica: duplicate ArchiveEntry " + p)
}
if !e.Dir && ix.kids[p] != nil {
panic("mujica: ArchiveEntry folder used as a file: " + p)
}
ix.byPath[p] = e
if e.Dir {
addDir(p)
continue
}
link(p)
ix.files++
if e.Size > math.MaxInt64-ix.size || e.Packed > math.MaxInt64-ix.pack {
panic("mujica: ArchiveEntry totals overflow")
}
ix.size += e.Size
ix.pack += e.Packed
for d := path.Dir(p); d != "."; d = path.Dir(d) {
t := ix.sums[d]
ix.sums[d] = [2]int64{t[0] + e.Size, t[1] + e.Packed}
}
}
s.index = ix
slog.Debug("mujica: archive_viewer index", "files", ix.files)
}