diff --git a/CONTEXT.md b/CONTEXT.md index def7b5e1b..c61b2b6cf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -79,3 +79,26 @@ What a bar is measured against. Two modes, applying to every bar alike: Rows the user has ignored are excluded from the denominator and draw an empty bar. + +## Projection + +A way of coloring (and, for one projection, regrouping) the web UI treemap +other than by location. Area always stays the item's size; a projection only +changes what the color says. + +## Cleanup candidate + +An item that is likely safe to remove to free space, because a tool can +regenerate or re-download it. A directory that is a candidate is a unit: the +items inside it are not candidates of their own. + +## Category + +What kind of cleanup candidate an item is: build artifact, package cache, app +cache, SDK or toolchain, ML model, installer or archive, or git-ignored. + +## Confidence + +How safe removing a cleanup candidate is: **safe** (regenerated on demand) or +**review** (probably unneeded, but may hold the only copy of something or need +its own tool to be removed cleanly). diff --git a/README.md b/README.md index 92fcc93fa..9a1ef0945 100644 --- a/README.md +++ b/README.md @@ -203,13 +203,62 @@ its own reveal and delete buttons that act on that item; deletion asks for confirmation with the same dialog. The deletion dialog can suppress further confirmations for the current browser session, for both the treemap and the table, so with "permanent" chosen a single click on a row's delete button -deletes immediately. The `--no-delete` flag and +deletes immediately. Deletion runs in the background: the item is drawn +hatched right away, more items can be deleted meanwhile, and a failed deletion +puts the item back with the reason. A deleted item stays in place, +cross-hatched, so the layout does not jump; the directory is reloaded when you +open another one. Opening another directory waits until the running deletions +finish. The `--no-delete` flag and filtered-scan deletion restrictions apply to the web UI as well as the terminal UI. Reveal remains available with `--no-delete` because it does not modify scanned data. To deliberately allow deletion while filters are active, set `GDU_ALLOW_DELETE_WITH_FILTER=1`; the displayed tree may then omit files that are still present on disk. +### Cleanup projections + +The treemap's projection selector (or `P`) recolors it to help free space +without touching anything important. Area always stays size. + +- **Location** - the default coloring by top-level directory. +- **Cleanup** - everything grey except cleanup candidates: strong orange for + *safe* ones (regenerated by their tool, e.g. `node_modules` next to + `package.json`, `target` next to `Cargo.toml`, `__pycache__`, the Go, Gradle, + Cargo, npm, uv and Homebrew caches, app caches) + and muted gold for ones to *review first* (downloaded ML models, installed + toolchains such as `~/.rustup` or `~/.nvm`, installers, + virtualenvs, `dist`/`build`, git-ignored files). A directory is tinted by the + share of candidates inside it, including files too small to draw. +- **Age** - colored by the latest change inside an item. +- **By category** - candidates within the current directory regrouped by + category, so cache space scattered over many small directories shows up as + one block. + +The legend shows how much each category takes within the current directory; +click a category to single it out: its items are drawn in the category's color +and outlined, everything else fades (`Esc` shows all again). Hovering a candidate +shows what it is and how to remove it cleanly (e.g. `ollama rm` for Ollama +models). Build-like directories only count next to a project marker, and +anything ignored by a repository's `.gitignore` is flagged for review. +Candidates are only highlighted: deleting stays a deliberate action. On macOS, +"move to trash" uses the system `trash` command, so items can be put back from +Finder. + +Rules can be extended and paths excluded in the config file; user rules are +checked before the built-in ones: + +```yaml +cleanup: + exclude: + - ~/work/current-project + rules: + - category: build-artifact # build-artifact, package-cache, app-cache, + confidence: safe # toolchain, ml-model, installer, git-ignored + dir: .gradle # or file: "*.ext", or path: "~/some/path" + markers: [build.gradle] # optional sibling that must exist + hint: Rebuilt by Gradle +``` + By default the server binds to `localhost` on a random free port. Use `--web-listen` (or the `web.listen` config option) to pin a fixed address. diff --git a/cmd/gdu/app/app.go b/cmd/gdu/app/app.go index 0ac99fb08..298255513 100644 --- a/cmd/gdu/app/app.go +++ b/cmd/gdu/app/app.go @@ -20,6 +20,7 @@ import ( "github.com/dundee/gdu/v5/build" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/cleanup" "github.com/dundee/gdu/v5/pkg/device" gfs "github.com/dundee/gdu/v5/pkg/fs" "github.com/dundee/gdu/v5/pkg/timefilter" @@ -119,6 +120,9 @@ type Flags struct { BrowseParentDirs bool `yaml:"browse-parent-dirs"` Web bool `yaml:"-"` WebConfig WebConfig `yaml:"web"` + // Cleanup holds user rules and exclusions for the web UI's cleanup + // projections. + Cleanup cleanup.Config `yaml:"cleanup"` } // WebConfig defines the web UI options that can be set from the config file. @@ -496,6 +500,9 @@ func (a *App) createUI(outputAttributes gfs.JSONAttributes) (UI, error) { if a.Flags.TrashCommand != "" { webUI.SetTrashCommand(a.Flags.TrashCommand) } + if err := webUI.SetCleanupConfig(a.Flags.Cleanup); err != nil { + return nil, fmt.Errorf("cleanup config: %w", err) + } ui = webUI case a.Flags.OutputFile != "": var output io.Writer diff --git a/cmd/gdu/app/app_test.go b/cmd/gdu/app/app_test.go index 8958ba6b8..544d6e067 100644 --- a/cmd/gdu/app/app_test.go +++ b/cmd/gdu/app/app_test.go @@ -14,11 +14,13 @@ import ( "time" log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/internal/testdev" "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/cleanup" "github.com/dundee/gdu/v5/pkg/device" gfs "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" @@ -872,6 +874,45 @@ func TestWebFlagCreatesWebUIAndHonorsNoDelete(t *testing.T) { assert.ErrorContains(t, err, "binding") } +func TestWebFlagRejectsInvalidCleanupRule(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + _, err := runApp( + &Flags{ + LogFile: "/dev/null", + Web: true, + Cleanup: cleanup.Config{Rules: []cleanup.Rule{{Category: "junk", Dir: "x"}}}, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.ErrorContains(t, err, `cleanup config: cleanup rule 1: unknown category "junk"`) +} + +func TestCleanupConfigFromYAML(t *testing.T) { + var f Flags + err := yaml.Unmarshal([]byte(` +cleanup: + exclude: [~/work] + rules: + - category: build-artifact + confidence: safe + dir: .gradle + markers: [build.gradle] +`), &f) + assert.NoError(t, err) + assert.Equal(t, cleanup.Config{ + Exclude: []string{"~/work"}, + Rules: []cleanup.Rule{{ + Category: cleanup.BuildArtifact, Confidence: cleanup.Safe, + Dir: ".gradle", Markers: []string{"build.gradle"}, + }}, + }, f.Cleanup) +} + func TestListDevices(t *testing.T) { fin := testdir.CreateTestDir() defer fin() diff --git a/docs/adr/0003-macos-trash-uses-system-trash-command.md b/docs/adr/0003-macos-trash-uses-system-trash-command.md new file mode 100644 index 000000000..25e7e7c2c --- /dev/null +++ b/docs/adr/0003-macos-trash-uses-system-trash-command.md @@ -0,0 +1,41 @@ +# 3. The macOS trash runs the system trash command + +Date: 2026-10-19 + +## Status + +Accepted + +## Context + +The built-in trash targets the FreeDesktop trash, which Finder ignores, so on +darwin it was a stub that returned "not supported". ADR 0002 left macOS users a +workaround (`--trash-command`) and noted that probing for a trash CLI remained +open. + +The web UI's cleanup projections make moving many items to the trash a common +action, so it has to work without configuration on the platform most of its +users run. + +Current macOS ships `/usr/bin/trash`, which moves items into the Finder trash, +where they can be put back. + +## Decision + +On darwin, `MoveItemToTrash` runs `/usr/bin/trash ` directly, without a +shell. `trash` does not understand `--`, but the path is always absolute, so it +can never be read as an option. + +As with the trash command, the tree is updated from the filesystem: the item is +dropped only when its path is really gone. + +When `/usr/bin/trash` does not exist, the error points the user at +`trash-command`, which still takes precedence when set. + +## Consequences + +- `D` in the terminal UI and "Move to trash" in the web UI work on macOS out of + the box, and trashed items can be restored from Finder. +- Older macOS releases without `/usr/bin/trash` keep the previous behavior, an + error, with a hint towards the workaround. +- No Objective-C bridge or cgo is needed. diff --git a/go.mod b/go.mod index 1810544da..f39f304cc 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/ebitengine/purego v0.10.2 github.com/fatih/color v1.19.0 github.com/gdamore/tcell/v2 v2.13.10 + github.com/go-git/go-git/v5 v5.19.3 github.com/h2non/filetype v1.1.3 github.com/maruel/natural v1.3.0 github.com/mattn/go-isatty v0.0.24 @@ -27,11 +28,14 @@ require ( github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/flatbuffers v25.9.23+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -44,8 +48,10 @@ require ( go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/term v0.45.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/go.sum b/go.sum index bea4a2f41..c743815f7 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,12 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE= +github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= +github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6M4= +github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -36,6 +42,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -96,6 +104,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -112,8 +122,8 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -133,6 +143,8 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= diff --git a/pkg/cleanup/classify.go b/pkg/cleanup/classify.go new file mode 100644 index 000000000..96a7d196f --- /dev/null +++ b/pkg/cleanup/classify.go @@ -0,0 +1,329 @@ +package cleanup + +import ( + "fmt" + "path/filepath" + "strings" + "sync" + + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" +) + +// Config is the user-provided part of the classification, read from the +// "cleanup" section of the gdu config file. +type Config struct { + // Exclude lists paths that are never candidates, nor anything below them. + Exclude []string `yaml:"exclude"` + // Rules are evaluated before DefaultRules. + Rules []Rule `yaml:"rules"` +} + +// Classifier assigns categories to items. It is immutable and safe for +// concurrent use. +type Classifier struct { + rules []Rule + excludes []string +} + +// NewClassifier validates the user's config and combines it with +// DefaultRules. home expands "~/" in paths. +func NewClassifier(cfg Config, home string) (*Classifier, error) { + c := &Classifier{} + for i := range cfg.Rules { + r := cfg.Rules[i] + if r.Confidence == "" { + r.Confidence = Review + } + if err := r.validate(); err != nil { + return nil, fmt.Errorf("cleanup rule %d: %w", i+1, err) + } + c.rules = append(c.rules, r) + } + c.rules = append(c.rules, DefaultRules...) + for i := range c.rules { + c.rules[i].compile(home) + } + for _, path := range cfg.Exclude { + c.excludes = append(c.excludes, filepath.Clean(expandHome(path, home))) + } + return c, nil +} + +// NewDefaultClassifier applies DefaultRules only. +func NewDefaultClassifier(home string) *Classifier { + c, err := NewClassifier(Config{}, home) + if err != nil { + // only user rules are validated, so this cannot happen + panic(err) + } + return c +} + +func (r *Rule) compile(home string) { + if r.Path != "" { + r.Path = filepath.Clean(expandHome(r.Path, home)) + } + if r.Under != "" { + r.Under = filepath.Clean(expandHome(r.Under, home)) + } +} + +// Match is the classification of one item. +type Match struct { + Category Category + Confidence Confidence + Hint string + // Inherited is set for items inside a matched directory: they are + // candidates as part of it, not on their own. + Inherited bool +} + +// Amount is an amount of space in both metrics. +type Amount struct { + Usage int64 `json:"usage"` + Size int64 `json:"size"` +} + +// Totals is the space taken by each category. +type Totals map[Category]Amount + +func (t Totals) add(other Totals) Totals { + if len(other) == 0 { + return t + } + if t == nil { + t = make(Totals, len(other)) + } + for c, a := range other { + sum := t[c] + sum.Usage += a.Usage + sum.Size += a.Size + t[c] = sum + } + return t +} + +// Result holds the classification of one tree. Items are keyed by identity, so +// a Result goes stale once the tree changes, except for removals reported via +// Forget. It is safe for concurrent use. +type Result struct { + mu sync.RWMutex + matches map[fs.Item]Match + totals map[fs.Item]Totals +} + +// Match returns the classification of an item. Items inside a matched +// directory inherit its classification, with Inherited set, so they still +// show as candidates once the user opens the directory. +func (r *Result) Match(it fs.Item) (Match, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + return r.matchLocked(it) +} + +func (r *Result) matchLocked(it fs.Item) (Match, bool) { + if m, ok := r.matches[it]; ok { + return m, true + } + for p := it.GetParent(); p != nil; p = p.GetParent() { + if m, ok := r.matches[p]; ok { + m.Inherited = true + return m, true + } + } + return Match{}, false +} + +// Totals returns the space taken by each category within the item, including +// the item itself when it is a match. It is empty for items with no +// candidates. The returned map must not be modified. +func (r *Result) Totals(it fs.Item) Totals { + r.mu.RLock() + defer r.mu.RUnlock() + return r.totalsLocked(it) +} + +// Forget updates the result for it having been removed from the tree: its +// space is subtracted from every ancestor, which is far cheaper than +// classifying the whole tree again. Ancestors are found through GetParent, so +// the removed item must keep its parent link. +func (r *Result) Forget(it fs.Item) { + r.mu.Lock() + defer r.mu.Unlock() + + removed := r.totalsLocked(it) + // entries of its descendants are unreachable from now on and left in place + delete(r.matches, it) + delete(r.totals, it) + if len(removed) == 0 { + return + } + for p := it.GetParent(); p != nil; p = p.GetParent() { + old := r.totals[p] + // copy, since earlier callers of Totals may still hold the old map + left := make(Totals, len(old)) + for c, a := range old { + sub := removed[c] + a.Usage -= sub.Usage + a.Size -= sub.Size + if a.Usage > 0 || a.Size > 0 { + left[c] = a + } + } + if len(left) == 0 { + delete(r.totals, p) + } else { + r.totals[p] = left + } + } +} + +func (r *Result) totalsLocked(it fs.Item) Totals { + if t, ok := r.totals[it]; ok { + return t + } + // a directory holding candidates has an entry, so only matched items + // (and their descendants) get here with a match + if m, ok := r.matchLocked(it); ok { + return Totals{m.Category: {Usage: it.GetUsage(), Size: it.GetSize()}} + } + return nil +} + +// Analyze classifies the whole tree under root and aggregates the space of +// every category per directory, including items too small for a frontend to +// show. +func (c *Classifier) Analyze(root fs.Item) *Result { + w := &walker{ + c: c, + result: &Result{ + matches: make(map[fs.Item]Match), + totals: make(map[fs.Item]Totals), + }, + } + w.walk(root, root.GetPath(), nil) + return w.result +} + +type walker struct { + c *Classifier + result *Result + repos []*repo +} + +func (w *walker) walk(it fs.Item, path string, siblings map[string]struct{}) Totals { + if w.c.excluded(path) { + return nil + } + if m, ok := w.classify(it, path, siblings); ok { + w.result.matches[it] = m + return w.result.totalsLocked(it) + } + if !it.IsDir() { + return nil + } + + var children []fs.Item + names := make(map[string]struct{}) + for child := range it.GetFilesLocked(fs.SortByName, fs.SortAsc) { + children = append(children, child) + names[child.GetName()] = struct{}{} + } + + popRepo := w.enterDir(path, names) + defer popRepo() + + // children of the virtual top level dir are scanned roots: they carry + // their own absolute path, and the virtual dir has none + virtual := analyze.IsVirtualRootDir(it) + var totals Totals + for _, child := range children { + childPath := filepath.Join(path, child.GetName()) + if virtual { + childPath = child.GetPath() + } + totals = totals.add(w.walk(child, childPath, names)) + } + if len(totals) > 0 { + w.result.totals[it] = totals + } + return totals +} + +func (w *walker) classify(it fs.Item, path string, siblings map[string]struct{}) (Match, bool) { + ignored := w.gitIgnored(path, it.IsDir()) + for i := range w.c.rules { + r := &w.c.rules[i] + if !r.matches(it, path, siblings) { + continue + } + m := Match{Category: r.Category, Confidence: r.Confidence, Hint: r.Hint} + if ignored { + // git-ignored output of a recognized tool is regenerable + m.Confidence = Safe + } + return m, true + } + if ignored { + return Match{Category: GitIgnored, Confidence: Review, Hint: "Ignored by .gitignore; may still be local data"}, true + } + return Match{}, false +} + +func (r *Rule) matches(it fs.Item, path string, siblings map[string]struct{}) bool { + name := it.GetName() + switch { + case r.Dir != "": + if !it.IsDir() || !globMatch(r.Dir, name) { + return false + } + case r.File != "": + if it.IsDir() || !globMatch(r.File, name) { + return false + } + default: + if !globMatch(r.Path, path) { + return false + } + } + if r.Under != "" && !isBelow(path, r.Under) { + return false + } + if len(r.Markers) == 0 { + return true + } + for _, marker := range r.Markers { + if _, ok := siblings[marker]; ok { + return true + } + if !strings.ContainsAny(marker, "*?[") { + continue + } + for sibling := range siblings { + if globMatch(marker, sibling) { + return true + } + } + } + return false +} + +func globMatch(pattern, name string) bool { + ok, err := filepath.Match(pattern, name) + return err == nil && ok +} + +func (c *Classifier) excluded(path string) bool { + for _, ex := range c.excludes { + if path == ex || isBelow(path, ex) { + return true + } + } + return false +} + +// isBelow reports whether path is strictly inside dir. +func isBelow(path, dir string) bool { + return strings.HasPrefix(path, dir+string(filepath.Separator)) +} diff --git a/pkg/cleanup/classify_test.go b/pkg/cleanup/classify_test.go new file mode 100644 index 000000000..0f74ba73d --- /dev/null +++ b/pkg/cleanup/classify_test.go @@ -0,0 +1,344 @@ +package cleanup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" +) + +const home = "/home/u" + +// node describes a test tree: a file when children is nil, a directory +// otherwise. A directory's usage is the sum of its children. +type node struct { + name string + usage int64 + children []node +} + +func dir(name string, children ...node) node { return node{name: name, children: children} } +func file(name string, usage int64) node { return node{name: name, usage: usage} } + +// build creates an in-memory tree rooted at basePath/root.name. +func build(basePath string, root node) *analyze.Dir { + d := &analyze.Dir{File: &analyze.File{Name: root.name}, BasePath: basePath} + fill(d, root.children) + return d +} + +func fill(d *analyze.Dir, children []node) { + for _, c := range children { + var item fs.Item + if c.children != nil { + sub := &analyze.Dir{File: &analyze.File{Name: c.name, Parent: d}} + fill(sub, c.children) + item = sub + } else { + item = &analyze.File{Name: c.name, Usage: c.usage, Size: c.usage, Parent: d} + } + d.Files = append(d.Files, item) + d.Usage += item.GetUsage() + d.Size += item.GetSize() + } +} + +func child(t *testing.T, it fs.Item, names ...string) fs.Item { + t.Helper() + for _, name := range names { + var found fs.Item + for c := range it.GetFiles(fs.SortByName, fs.SortAsc) { + if c.GetName() == name { + found = c + } + } + require.NotNil(t, found, "child %q of %s", name, it.GetPath()) + it = found + } + return it +} + +func newClassifier(t *testing.T, cfg Config) *Classifier { + t.Helper() + c, err := NewClassifier(cfg, home) + require.NoError(t, err) + return c +} + +func TestMarkerRequired(t *testing.T) { + root := build("/home", dir("u", + dir("app", file("package.json", 1), dir("node_modules", file("x.js", 100))), + dir("loose", dir("node_modules", file("y.js", 50))), + )) + res := newClassifier(t, Config{}).Analyze(root) + + m, ok := res.Match(child(t, root, "app", "node_modules")) + require.True(t, ok) + assert.Equal(t, BuildArtifact, m.Category) + assert.Equal(t, Safe, m.Confidence) + + _, ok = res.Match(child(t, root, "loose", "node_modules")) + assert.False(t, ok, "node_modules without package.json is not a candidate") +} + +func TestUnitMatchIsInheritedByDescendants(t *testing.T) { + root := build("/home", dir("u", + dir("app", file("package.json", 1), dir("node_modules", dir("pkg", file("model.gguf", 10)))), + )) + res := newClassifier(t, Config{}).Analyze(root) + + // the model inside node_modules is part of the build artifact, not a + // model of its own, and is not counted twice + inner := child(t, root, "app", "node_modules", "pkg", "model.gguf") + m, ok := res.Match(inner) + require.True(t, ok) + assert.Equal(t, Match{Category: BuildArtifact, Confidence: Safe, Hint: m.Hint, Inherited: true}, m) + assert.Equal(t, Totals{BuildArtifact: {Usage: 10, Size: 10}}, res.Totals(inner)) + assert.Equal(t, Totals{BuildArtifact: {Usage: 10, Size: 10}}, res.Totals(child(t, root, "app"))) + + own, ok := res.Match(child(t, root, "app", "node_modules")) + require.True(t, ok) + assert.False(t, own.Inherited) + + _, ok = res.Match(child(t, root, "app", "package.json")) + assert.False(t, ok) +} + +func TestPathRulesAndPrecedence(t *testing.T) { + root := build("/home", dir("u", + dir(".cache", dir("huggingface", file("w", 30)), dir("thumbs", file("t", 5))), + dir("Downloads", file("a.zip", 7), file("setup.dmg", 9)), + dir("src", file("b.zip", 3)), + )) + res := newClassifier(t, Config{}).Analyze(root) + + tests := []struct { + path []string + category Category + matched bool + }{ + {[]string{".cache", "huggingface"}, MLModel, true}, + {[]string{".cache", "thumbs"}, AppCache, true}, + {[]string{"Downloads", "a.zip"}, Installer, true}, + {[]string{"Downloads", "setup.dmg"}, Installer, true}, + {[]string{"src", "b.zip"}, "", false}, + } + for _, tt := range tests { + m, ok := res.Match(child(t, root, tt.path...)) + assert.Equal(t, tt.matched, ok, tt.path) + assert.Equal(t, tt.category, m.Category, tt.path) + } + _, ok := res.Match(child(t, root, ".cache")) + assert.False(t, ok, "~/.cache itself is not a unit, its children are") +} + +func TestTotalsAggregateMixedDirectory(t *testing.T) { + root := build("/home", dir("u", + dir("p", file("package.json", 1), dir("node_modules", file("x", 100)), file("notes.txt", 20)), + dir("m", file("a.gguf", 40), file("b.safetensors", 60)), + file("doc.txt", 5), + )) + res := newClassifier(t, Config{}).Analyze(root) + + assert.Equal(t, Totals{ + BuildArtifact: {Usage: 100, Size: 100}, + MLModel: {Usage: 100, Size: 100}, + }, res.Totals(root)) + assert.Equal(t, Totals{MLModel: {Usage: 40, Size: 40}}, res.Totals(child(t, root, "m", "a.gguf"))) + assert.Nil(t, res.Totals(child(t, root, "doc.txt"))) +} + +func TestExclusionsAndUserRules(t *testing.T) { + root := build("/home", dir("u", + dir("work", file("package.json", 1), dir("node_modules", file("x", 100))), + dir("data", dir("weights", file("w", 10))), + )) + res := newClassifier(t, Config{ + Exclude: []string{"~/work"}, + Rules: []Rule{{Category: MLModel, Dir: "weights", Hint: "mine"}}, + }).Analyze(root) + + _, ok := res.Match(child(t, root, "work", "node_modules")) + assert.False(t, ok) + + m, ok := res.Match(child(t, root, "data", "weights")) + require.True(t, ok) + assert.Equal(t, Match{Category: MLModel, Confidence: Review, Hint: "mine"}, m) +} + +func TestInvalidRules(t *testing.T) { + tests := map[string]Rule{ + "unknown category": {Category: "junk", Dir: "x"}, + "unknown confidence": {Category: MLModel, Confidence: "maybe", Dir: "x"}, + "no matcher": {Category: MLModel}, + "two matchers": {Category: MLModel, Dir: "x", File: "y"}, + "bad glob": {Category: MLModel, File: "["}, + } + for name, rule := range tests { + t.Run(name, func(t *testing.T) { + _, err := NewClassifier(Config{Rules: []Rule{rule}}, home) + assert.Error(t, err) + }) + } +} + +func TestVirtualRootChildrenUseOwnPaths(t *testing.T) { + a := build("/home/u", dir("a", file("package.json", 1), dir("node_modules", file("x", 4)))) + b := build("/home/u", dir("b", file("big.iso", 8))) + root := analyze.CreateVirtualRootDir(a, b) + res := newClassifier(t, Config{}).Analyze(root) + + assert.Equal(t, Totals{ + BuildArtifact: {Usage: 4, Size: 4}, + Installer: {Usage: 8, Size: 8}, + }, res.Totals(root)) +} + +func TestGitIgnoreSignal(t *testing.T) { + base := t.TempDir() + repoDir := filepath.Join(base, "repo") + require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "sub"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte("# c\nout/\n*.log\n!keep.log\ndist\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "sub", ".gitignore"), []byte("local/\n"), 0o644)) + + root := build(base, dir("repo", + dir(".git", file("HEAD", 1)), + file(".gitignore", 1), + file("package.json", 1), + dir("out", file("o", 10)), + file("app.log", 2), + file("keep.log", 3), + dir("dist", file("d", 4)), + dir("sub", file(".gitignore", 1), dir("local", file("l", 5))), + dir("src", file("main.go", 6)), + )) + res := newClassifier(t, Config{}).Analyze(root) + + tests := []struct { + path []string + matched bool + category Category + confidence Confidence + }{ + {[]string{"out"}, true, GitIgnored, Review}, + {[]string{"app.log"}, true, GitIgnored, Review}, + {[]string{"keep.log"}, false, "", ""}, + // a rule match that is also git-ignored is upgraded to safe + {[]string{"dist"}, true, BuildArtifact, Safe}, + {[]string{"sub", "local"}, true, GitIgnored, Review}, + {[]string{"src"}, false, "", ""}, + {[]string{".git"}, false, "", ""}, + } + for _, tt := range tests { + m, ok := res.Match(child(t, root, tt.path...)) + assert.Equal(t, tt.matched, ok, tt.path) + assert.Equal(t, tt.category, m.Category, tt.path) + assert.Equal(t, tt.confidence, m.Confidence, tt.path) + } +} + +func TestNoRepoNoGitIgnoreSignal(t *testing.T) { + base := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(base, "plain"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(base, "plain", ".gitignore"), []byte("out/\n"), 0o644)) + + root := build(base, dir("plain", file(".gitignore", 1), dir("out", file("o", 10)))) + res := newClassifier(t, Config{}).Analyze(root) + + _, ok := res.Match(child(t, root, "out")) + assert.False(t, ok) +} + +func TestForgetSubtractsFromAncestors(t *testing.T) { + root := build("/home", dir("u", + dir("p", file("package.json", 1), dir("node_modules", file("x", 100)), file("a.gguf", 40)), + file("b.gguf", 60), + )) + res := newClassifier(t, Config{}).Analyze(root) + before := res.Totals(root) + + modules := child(t, root, "p", "node_modules") + res.Forget(modules) + + _, ok := res.Match(modules) + assert.False(t, ok) + assert.Equal(t, Totals{MLModel: {Usage: 40, Size: 40}}, res.Totals(child(t, root, "p"))) + assert.Equal(t, Totals{MLModel: {Usage: 100, Size: 100}}, res.Totals(root)) + // maps handed out earlier are not modified + assert.Equal(t, int64(100), before[BuildArtifact].Usage) + + res.Forget(child(t, root, "p", "a.gguf")) + assert.Nil(t, res.Totals(child(t, root, "p"))) + + // forgetting an item without candidates changes nothing + res.Forget(child(t, root, "p", "package.json")) + assert.Equal(t, Totals{MLModel: {Usage: 60, Size: 60}}, res.Totals(root)) +} + +func TestPathRulesUnderVirtualRoot(t *testing.T) { + // scanning several paths puts them under the virtual top level dir, whose + // own name must not leak into the paths path rules are matched against + goDir := build(home, dir("go", dir("pkg", dir("mod", file("m", 30))))) + gradle := build(home, dir(".gradle", dir("caches", file("c", 20)))) + root := analyze.CreateVirtualRootDir(goDir, gradle) + res := newClassifier(t, Config{}).Analyze(root) + + assert.Equal(t, Totals{PackageCache: {Usage: 50, Size: 50}}, res.Totals(root)) + m, ok := res.Match(child(t, goDir, "pkg", "mod")) + require.True(t, ok) + assert.Equal(t, PackageCache, m.Category) +} + +func TestDefaultCacheAndToolchainRules(t *testing.T) { + root := build("/home", dir("u", + dir(".cache", dir("uv", file("u", 1)), dir("thumbs", file("t", 1))), + dir(".gradle", dir("wrapper", dir("dists", file("d", 1))), dir("caches", file("c", 1))), + dir(".rustup", dir("toolchains", file("r", 1))), + dir(".bun", dir("install", dir("global", file("package.json", 1), dir("node_modules", file("n", 1))))), + dir("Library", dir("Caches", dir("go-build", file("g", 1)))), + dir("app", dir("App.xcodeproj", file("p", 1)), dir("DerivedData", file("d", 1))), + )) + res := newClassifier(t, Config{}).Analyze(root) + + tests := []struct { + path []string + category Category + }{ + {[]string{".cache", "uv"}, PackageCache}, + {[]string{".cache", "thumbs"}, AppCache}, + {[]string{".gradle", "wrapper", "dists"}, PackageCache}, + {[]string{".gradle", "caches"}, PackageCache}, + {[]string{".rustup", "toolchains"}, Toolchain}, + // global installs are not build artifacts despite their package.json + {[]string{".bun", "install", "global"}, Toolchain}, + {[]string{"Library", "Caches", "go-build"}, PackageCache}, + // glob marker: next to *.xcodeproj + {[]string{"app", "DerivedData"}, BuildArtifact}, + } + for _, tt := range tests { + m, ok := res.Match(child(t, root, tt.path...)) + assert.True(t, ok, tt.path) + assert.Equal(t, tt.category, m.Category, tt.path) + } +} + +func TestForgetInsideMatchedDirectory(t *testing.T) { + root := build("/home", dir("u", + dir(".cache", dir("uv", dir("archive", file("a", 30), file("b", 20)))), + )) + res := newClassifier(t, Config{}).Analyze(root) + require.Equal(t, Totals{PackageCache: {Usage: 50, Size: 50}}, res.Totals(root)) + + archive := child(t, root, ".cache", "uv", "archive").(*analyze.Dir) + a := child(t, archive, "a") + res.Forget(a) + archive.RemoveFile(a) + + assert.Equal(t, Totals{PackageCache: {Usage: 20, Size: 20}}, res.Totals(root)) + assert.Equal(t, Totals{PackageCache: {Usage: 20, Size: 20}}, res.Totals(child(t, root, ".cache", "uv"))) +} diff --git a/pkg/cleanup/gitignore.go b/pkg/cleanup/gitignore.go new file mode 100644 index 000000000..d420ee1db --- /dev/null +++ b/pkg/cleanup/gitignore.go @@ -0,0 +1,90 @@ +package cleanup + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5/plumbing/format/gitignore" +) + +// repo is a git work tree found during the walk, with the patterns of the +// .gitignore files seen so far on the current path from its root. +type repo struct { + root string + patterns []gitignore.Pattern +} + +// enterDir is called before walking a directory's children. It starts a new +// repo when the directory holds .git and loads its .gitignore. The returned +// func undoes both once the directory is done. +func (w *walker) enterDir(path string, names map[string]struct{}) func() { + pushed := false + if _, ok := names[".git"]; ok { + w.repos = append(w.repos, &repo{root: path}) + pushed = true + } + r := w.currentRepo() + if r == nil { + return func() {} + } + before := len(r.patterns) + if _, ok := names[".gitignore"]; ok { + r.patterns = append(r.patterns, readGitignore(filepath.Join(path, ".gitignore"), r.relParts(path))...) + } + return func() { + r.patterns = r.patterns[:before] + if pushed { + w.repos = w.repos[:len(w.repos)-1] + } + } +} + +func (w *walker) currentRepo() *repo { + if len(w.repos) == 0 { + return nil + } + return w.repos[len(w.repos)-1] +} + +func (w *walker) gitIgnored(path string, isDir bool) bool { + r := w.currentRepo() + if r == nil || len(r.patterns) == 0 || path == r.root { + return false + } + parts := r.relParts(path) + if len(parts) == 0 || parts[0] == ".git" { + return false + } + return gitignore.NewMatcher(r.patterns).Match(parts, isDir) +} + +func (r *repo) relParts(path string) []string { + rel, err := filepath.Rel(r.root, path) + if err != nil || rel == "." { + return nil + } + return strings.Split(filepath.ToSlash(rel), "/") +} + +// readGitignore parses one .gitignore file. A file that cannot be read adds +// no patterns: the signal is a hint, not a requirement. +func readGitignore(path string, domain []string) []gitignore.Pattern { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var patterns []gitignore.Pattern + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" { + continue + } + patterns = append(patterns, gitignore.ParsePattern(line, domain)) + } + return patterns +} diff --git a/pkg/cleanup/rules.go b/pkg/cleanup/rules.go new file mode 100644 index 000000000..8058d8c3e --- /dev/null +++ b/pkg/cleanup/rules.go @@ -0,0 +1,268 @@ +// Package cleanup classifies scanned items into cleanup categories, such as +// build artifacts, package caches or downloaded models, so a frontend can draw +// attention to space that is likely regenerable. It never deletes anything. +package cleanup + +import ( + "errors" + "fmt" + "path/filepath" + "strings" +) + +// Category groups cleanup candidates by what they are. +type Category string + +// Known categories. +const ( + BuildArtifact Category = "build-artifact" + PackageCache Category = "package-cache" + AppCache Category = "app-cache" + Toolchain Category = "toolchain" + MLModel Category = "ml-model" + Installer Category = "installer" + GitIgnored Category = "git-ignored" +) + +// Confidence tells how safe removing a candidate is. +type Confidence string + +// Known confidence levels. +const ( + // Safe candidates are regenerated by their tool on demand. + Safe Confidence = "safe" + // Review candidates are probably unneeded but may hold the only copy of + // something, or need a dedicated tool to be removed cleanly. + Review Confidence = "review" +) + +// CategoryInfo describes a category for display. +type CategoryInfo struct { + ID Category `json:"id"` + Label string `json:"label"` +} + +// Categories lists all categories in display order. +var Categories = []CategoryInfo{ + {ID: BuildArtifact, Label: "Build artifacts"}, + {ID: PackageCache, Label: "Package caches"}, + {ID: AppCache, Label: "App caches"}, + {ID: Toolchain, Label: "SDKs & toolchains"}, + {ID: MLModel, Label: "ML models"}, + {ID: Installer, Label: "Installers & archives"}, + {ID: GitIgnored, Label: "Git-ignored"}, +} + +// Rule matches items of one category. Exactly one of Dir, File and Path is +// set. A directory matched by a rule is a unit: its descendants are not +// classified on their own. +type Rule struct { + Category Category `yaml:"category"` + Confidence Confidence `yaml:"confidence"` + // Dir is a glob matched against the base name of directories. + Dir string `yaml:"dir"` + // File is a glob matched against the base name of files. + File string `yaml:"file"` + // Path is a glob matched against the absolute path of any item; a + // leading "~/" stands for the home directory. + Path string `yaml:"path"` + // Under restricts the rule to items below this directory ("~/" expands). + Under string `yaml:"under"` + // Markers, when set, require at least one sibling with one of these names, + // e.g. node_modules only counts as a build artifact next to package.json. + Markers []string `yaml:"markers"` + // Hint is shown to the user next to the candidate. + Hint string `yaml:"hint"` +} + +var errNoMatcher = errors.New("exactly one of dir, file or path must be set") + +func (r *Rule) validate() error { + if !knownCategory(r.Category) { + return fmt.Errorf("unknown category %q", r.Category) + } + if r.Confidence != Safe && r.Confidence != Review { + return fmt.Errorf("unknown confidence %q", r.Confidence) + } + set := 0 + for _, pattern := range []string{r.Dir, r.File, r.Path} { + if pattern == "" { + continue + } + set++ + if _, err := filepath.Match(pattern, ""); err != nil { + return fmt.Errorf("pattern %q: %w", pattern, err) + } + } + if set != 1 { + return errNoMatcher + } + return nil +} + +func knownCategory(c Category) bool { + for _, info := range Categories { + if info.ID == c { + return true + } + } + return false +} + +// expandHome replaces a leading "~/" (or a bare "~") with home. +func expandHome(path, home string) string { + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, path[2:]) + } + return path +} + +const downloadsDir = "~/Downloads" + +var ( + jsMarkers = []string{"package.json"} + pythonMarkers = []string{"pyproject.toml", "requirements.txt", "setup.py", "setup.cfg", "Pipfile", "tox.ini"} + zigMarkers = []string{"build.zig"} + gradleMarkers = []string{"build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"} + buildMarkers = []string{ + "package.json", "CMakeLists.txt", "Makefile", "pyproject.toml", "setup.py", + "build.gradle", "build.gradle.kts", "meson.build", + } +) + +const ( + dirTarget = "target" + hintPnpm = "pnpm store, `pnpm store prune`" + hintYarn = "Yarn cache, `yarn cache clean`" + hintAppCache = "Apps rebuild caches; quit the app first" +) + +// DefaultRules are evaluated after the user's rules. Path rules ending in "/*" +// make each child its own unit, so a more specific rule (Hugging Face inside +// ~/.cache) can still win for one child. A path rule never matches an +// ancestor of a more specific one, so the outer one is listed per child. +var DefaultRules = []Rule{ + // ML models: re-downloadable, but large and sometimes only removable + // cleanly through their tool. + {Category: MLModel, Confidence: Review, Path: "~/.ollama/models", Hint: "Remove models with `ollama rm`; deleting blobs by hand breaks Ollama"}, + {Category: MLModel, Confidence: Review, Path: "~/.cache/huggingface", Hint: "Prune with `hf cache delete`"}, + {Category: MLModel, Confidence: Review, Path: "~/.lmstudio/models", Hint: "Re-downloadable from LM Studio"}, + {Category: MLModel, Confidence: Review, Path: "~/.cache/lm-studio/models", Hint: "Re-downloadable from LM Studio"}, + {Category: MLModel, Confidence: Review, Path: "~/.cache/torch", Hint: "PyTorch hub cache, re-downloaded on use"}, + {Category: MLModel, Confidence: Review, File: "*.gguf"}, + {Category: MLModel, Confidence: Review, File: "*.safetensors"}, + {Category: MLModel, Confidence: Review, File: "*.onnx"}, + {Category: MLModel, Confidence: Review, File: "*.ckpt"}, + {Category: MLModel, Confidence: Review, File: "*.pth"}, + + // Package manager and build caches, refilled on the next build. + {Category: PackageCache, Confidence: Safe, Path: "~/go/pkg/mod", Hint: "Go module cache, `go clean -modcache`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/Caches/go-build", Hint: "Go build cache, `go clean -cache`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cache/go-build", Hint: "Go build cache, `go clean -cache`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cargo/registry", Hint: "Cargo registry cache"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cargo/git", Hint: "Cargo git checkouts"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.gradle/caches", Hint: "Gradle cache"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.gradle/wrapper/dists", Hint: "Gradle distributions, downloaded again by the wrapper"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.gradle/daemon", Hint: "Gradle daemon logs"}, + {Category: PackageCache, Confidence: Review, Path: "~/.m2/repository", Hint: "Maven repository; may hold locally installed artifacts"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.npm/_cacache", Hint: "npm cache, `npm cache clean --force`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.bun/install/cache", Hint: "Bun cache, `bun pm cache rm`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.nvm/.cache", Hint: "Downloaded Node archives, `nvm cache clear`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.npm/_npx", Hint: "npx cache, filled again on the next npx run"}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/pnpm/store", Hint: hintPnpm}, + {Category: PackageCache, Confidence: Safe, Path: "~/.local/share/pnpm/store", Hint: hintPnpm}, + {Category: PackageCache, Confidence: Safe, Path: "~/.pnpm-store", Hint: hintPnpm}, + {Category: PackageCache, Confidence: Safe, Path: "~/.yarn/berry/cache", Hint: hintYarn}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cache/uv", Hint: "uv cache, `uv cache prune`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cache/pip", Hint: "pip cache, `pip cache purge`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cache/pypoetry", Hint: "Poetry cache, `poetry cache clear`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cache/yarn", Hint: hintYarn}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/Caches/pip", Hint: "pip cache, `pip cache purge`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/Caches/pypoetry", Hint: "Poetry cache, `poetry cache clear`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/Caches/Homebrew", Hint: "Homebrew cache, `brew cleanup`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/Caches/Yarn", Hint: hintYarn}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/Caches/pnpm", Hint: "pnpm cache"}, + {Category: PackageCache, Confidence: Safe, Path: "~/Library/Caches/CocoaPods", Hint: "CocoaPods cache, `pod cache clean --all`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.cocoapods/repos", Hint: "CocoaPods specs, fetched again by `pod install`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.pub-cache", Hint: "Dart/Flutter packages, `dart pub cache clean`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.konan", Hint: "Kotlin/Native toolchain cache"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.composer/cache", Hint: "Composer cache, `composer clear-cache`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/miniconda3/pkgs", Hint: "conda package cache, `conda clean --all`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/anaconda3/pkgs", Hint: "conda package cache, `conda clean --all`"}, + {Category: PackageCache, Confidence: Safe, Path: "~/.stack/pantry", Hint: "Stack package index"}, + + // Installed SDKs and toolchains: re-installable, but probably in use. + // Global package installs are listed here so the node_modules inside + // them, next to a package.json, is not taken for a build artifact. + {Category: Toolchain, Confidence: Review, Path: "~/.bun/install/global", Hint: "Globally installed Bun packages, `bun remove -g`"}, + {Category: Toolchain, Confidence: Review, Path: "~/.rustup/toolchains", Hint: "Remove unused ones with `rustup toolchain uninstall`"}, + {Category: Toolchain, Confidence: Review, Path: "~/.nvm/versions", Hint: "Remove unused Node versions with `nvm uninstall`"}, + {Category: Toolchain, Confidence: Review, Path: "~/.pyenv/versions", Hint: "Remove unused Pythons with `pyenv uninstall`"}, + {Category: Toolchain, Confidence: Review, Path: "~/.sdkman/candidates", Hint: "Remove unused SDKs with `sdk uninstall`"}, + {Category: Toolchain, Confidence: Review, Path: "~/.ghcup/ghc", Hint: "Remove unused GHCs with `ghcup rm`"}, + {Category: Toolchain, Confidence: Review, Path: "~/.local/share/uv/python", Hint: "Remove unused Pythons with `uv python uninstall`"}, + {Category: Toolchain, Confidence: Review, Path: "~/Library/Android/sdk/system-images", Hint: "Emulator images, re-downloadable with the SDK manager"}, + {Category: Toolchain, Confidence: Review, Path: "~/.android/avd", Hint: "Android emulators; their data is lost"}, + {Category: Toolchain, Confidence: Safe, Path: "~/Library/Developer/Xcode/iOS DeviceSupport", Hint: "Re-created when a device connects"}, + {Category: Toolchain, Confidence: Review, Path: "~/Library/Developer/Xcode/Archives", Hint: "App archives; keep the ones you may need to symbolicate"}, + {Category: Toolchain, Confidence: Review, Path: "~/Library/Developer/CoreSimulator/Devices", Hint: "Simulators, `xcrun simctl delete unavailable`"}, + + // Application caches. + {Category: AppCache, Confidence: Safe, Path: "~/Library/Caches/*", Hint: hintAppCache}, + {Category: AppCache, Confidence: Safe, Path: "~/.cache/*", Hint: hintAppCache}, + {Category: AppCache, Confidence: Safe, Path: "~/Library/Developer/CoreSimulator/Caches", Hint: hintAppCache}, + {Category: BuildArtifact, Confidence: Safe, Path: "~/Library/Developer/Xcode/DerivedData", Hint: "Xcode rebuilds it"}, + + // Project build artifacts, recognized by a project marker next to them. + {Category: BuildArtifact, Confidence: Safe, Dir: "node_modules", Markers: jsMarkers, Hint: "Restored by your package manager's install"}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".next", Markers: jsMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".nuxt", Markers: jsMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".svelte-kit", Markers: jsMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".turbo", Markers: jsMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".parcel-cache", Markers: jsMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".angular", Markers: []string{"angular.json"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".expo", Markers: jsMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: "bower_components", Markers: []string{"bower.json"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: dirTarget, Markers: []string{"Cargo.toml"}, Hint: "`cargo clean`"}, + {Category: BuildArtifact, Confidence: Safe, Dir: dirTarget, Markers: []string{"pom.xml"}, Hint: "`mvn clean`"}, + {Category: BuildArtifact, Confidence: Safe, Dir: dirTarget, Markers: []string{"build.sbt"}, Hint: "`sbt clean`"}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".gradle", Markers: gradleMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".cxx", Markers: gradleMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: "Pods", Markers: []string{"Podfile"}, Hint: "Restored by `pod install`"}, + {Category: BuildArtifact, Confidence: Safe, Dir: "DerivedData", Markers: []string{"*.xcodeproj"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".dart_tool", Markers: []string{"pubspec.yaml"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".zig-cache", Markers: zigMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: "zig-cache", Markers: zigMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: "zig-out", Markers: zigMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".stack-work", Markers: []string{"stack.yaml"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: "dist-newstyle", Markers: []string{"cabal.project", "*.cabal"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: "_build", Markers: []string{"mix.exs", "dune-project", "rebar.config"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: "deps", Markers: []string{"mix.exs"}, Hint: "Restored by `mix deps.get`"}, + {Category: BuildArtifact, Confidence: Safe, Dir: "elm-stuff", Markers: []string{"elm.json"}}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".terraform", Markers: []string{".terraform.lock.hcl"}, Hint: "Restored by `terraform init`"}, + {Category: BuildArtifact, Confidence: Safe, Dir: "CMakeFiles", Markers: []string{"CMakeCache.txt"}}, + {Category: BuildArtifact, Confidence: Review, Dir: ".venv", Markers: pythonMarkers, Hint: "Recreate the virtualenv after removing"}, + {Category: BuildArtifact, Confidence: Review, Dir: "venv", Markers: pythonMarkers, Hint: "Recreate the virtualenv after removing"}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".tox", Markers: pythonMarkers}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".nox", Markers: []string{"noxfile.py"}}, + {Category: BuildArtifact, Confidence: Review, Dir: "dist", Markers: buildMarkers, Hint: "Build output; check it is not committed"}, + {Category: BuildArtifact, Confidence: Review, Dir: "build", Markers: buildMarkers, Hint: "Build output; check it is not committed"}, + {Category: BuildArtifact, Confidence: Safe, Dir: "__pycache__"}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".pytest_cache"}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".mypy_cache"}, + {Category: BuildArtifact, Confidence: Safe, Dir: ".ruff_cache"}, + + // Installers and downloaded archives. + {Category: Installer, Confidence: Review, File: "*.dmg"}, + {Category: Installer, Confidence: Review, File: "*.pkg"}, + {Category: Installer, Confidence: Review, File: "*.iso"}, + {Category: Installer, Confidence: Review, File: "*.zip", Under: downloadsDir}, + {Category: Installer, Confidence: Review, File: "*.tar.gz", Under: downloadsDir}, + {Category: Installer, Confidence: Review, File: "*.tgz", Under: downloadsDir}, + {Category: Installer, Confidence: Review, File: "*.tar.xz", Under: downloadsDir}, + {Category: Installer, Confidence: Review, File: "*.7z", Under: downloadsDir}, + {Category: Installer, Confidence: Review, File: "*.rar", Under: downloadsDir}, +} diff --git a/pkg/remove/trash_darwin.go b/pkg/remove/trash_darwin.go index 6810ed337..5d22b4945 100644 --- a/pkg/remove/trash_darwin.go +++ b/pkg/remove/trash_darwin.go @@ -3,13 +3,40 @@ package remove import ( + "bytes" + "errors" "fmt" + "os" "github.com/dundee/gdu/v5/pkg/fs" ) -// MoveItemToTrash is not supported on macOS. The XDG trash location is ignored -// by Finder, so use permanent delete (d) or empty (e) instead. +// macOSTrashBin is the trash CLI shipped with macOS. It moves items into the +// Finder trash, so they can be put back from there. +var macOSTrashBin = "/usr/bin/trash" + +// MoveItemToTrash moves the item into the Finder trash using the system trash +// command. The path is passed as an argument without a shell; it is absolute, +// so it can never be taken for an option. The tree is updated only when the +// path is really gone, as for TrashCommand. func MoveItemToTrash(dir, item fs.Item) error { - return fmt.Errorf("move to trash is not supported on macOS") + if _, err := os.Stat(macOSTrashBin); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s not found; set trash-command to a trash CLI instead", macOSTrashBin) + } + + absPath, err := trashCommandOS.abs(item.GetPath()) + if err != nil { + return err + } + + var stderr bytes.Buffer + if err := trashCommandOS.run(macOSTrashBin, []string{absPath}, trashCommandOS.environ(), &stderr); err != nil { + return commandError(macOSTrashBin, err, stderr.String()) + } + if _, err := trashCommandOS.lstat(absPath); err == nil { + return nil + } + + dir.RemoveFile(item) + return nil } diff --git a/pkg/remove/trash_darwin_test.go b/pkg/remove/trash_darwin_test.go index 466077434..234dc9cfd 100644 --- a/pkg/remove/trash_darwin_test.go +++ b/pkg/remove/trash_darwin_test.go @@ -3,15 +3,67 @@ package remove import ( + "io" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dundee/gdu/v5/pkg/analyze" ) -func TestMoveItemToTrashUnsupportedOnDarwin(t *testing.T) { - err := MoveItemToTrash(nil, nil) +func darwinTrashFixture(t *testing.T) (*analyze.Dir, *analyze.File, string) { + t.Helper() + base := t.TempDir() + path := filepath.Join(base, "-rf file") + require.NoError(t, os.WriteFile(path, []byte("x"), 0o600)) + + dir := &analyze.Dir{File: &analyze.File{Name: filepath.Base(base)}, BasePath: filepath.Dir(base)} + item := &analyze.File{Name: "-rf file", Parent: dir} + dir.Files = append(dir.Files, item) + return dir, item, path +} + +func TestMoveItemToTrashRunsSystemTrash(t *testing.T) { + dir, item, path := darwinTrashFixture(t) + + var gotArgv0 string + var gotArgv []string + orig := trashCommandOS.run + trashCommandOS.run = func(argv0 string, argv, _ []string, _ io.Writer) error { + gotArgv0, gotArgv = argv0, argv + return os.Remove(path) + } + defer func() { trashCommandOS.run = orig }() + + require.NoError(t, MoveItemToTrash(dir, item)) + assert.Equal(t, macOSTrashBin, gotArgv0) + assert.Equal(t, []string{path}, gotArgv) + assert.Empty(t, dir.Files) +} + +func TestMoveItemToTrashKeepsItemThatIsStillThere(t *testing.T) { + dir, item, _ := darwinTrashFixture(t) + + orig := trashCommandOS.run + trashCommandOS.run = func(string, []string, []string, io.Writer) error { return nil } + defer func() { trashCommandOS.run = orig }() + + require.NoError(t, MoveItemToTrash(dir, item)) + assert.Len(t, dir.Files, 1) +} + +func TestMoveItemToTrashMissingBinary(t *testing.T) { + dir, item, _ := darwinTrashFixture(t) + + orig := macOSTrashBin + macOSTrashBin = filepath.Join(t.TempDir(), "no-trash") + defer func() { macOSTrashBin = orig }() + err := MoveItemToTrash(dir, item) require.Error(t, err) - assert.Contains(t, err.Error(), "not supported on macOS") + assert.Contains(t, err.Error(), "trash-command") + assert.Len(t, dir.Files, 1) } diff --git a/webui/cleanup.go b/webui/cleanup.go new file mode 100644 index 000000000..da705f789 --- /dev/null +++ b/webui/cleanup.go @@ -0,0 +1,193 @@ +package webui + +import ( + "cmp" + "net/http" + "os" + "slices" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/pkg/cleanup" + "github.com/dundee/gdu/v5/pkg/fs" +) + +// pivotPathPrefix marks the synthetic category nodes of a pivoted tree. Such a +// path never resolves through findNode, so actions on it are refused. +const pivotPathPrefix = "category:" + +// cleanupCache memoizes the classification of the current tree. It is valid +// while the root and the tree generation it was computed for are current. +type cleanupCache struct { + mu sync.Mutex + root fs.Item + gen uint64 + res *cleanup.Result +} + +// userHomeDir returns the home directory "~/" in cleanup rules stands for, or +// "" when it is unknown, in which case home-relative rules match nothing. +func userHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + log.Printf("webui: cleanup rules relative to home are disabled: %s", err) + return "" + } + return home +} + +// SetCleanupConfig replaces the default classification with one that also +// applies the user's rules and exclusions. +func (ui *UI) SetCleanupConfig(cfg cleanup.Config) error { + classifier, err := cleanup.NewClassifier(cfg, userHomeDir()) + if err != nil { + return err + } + ui.classifier = classifier + return nil +} + +// cleanupResult returns the classification of the current tree, computing it +// on first use after a scan or a deletion. It is nil while scanning. +func (ui *UI) cleanupResult() *cleanup.Result { + ui.mu.RLock() + root, scanning := ui.topDir, ui.scanning + ui.mu.RUnlock() + if root == nil || scanning { + return nil + } + + gen := ui.treeGen.Load() + c := &ui.cleanup + c.mu.Lock() + defer c.mu.Unlock() + if c.res != nil && c.root == root && c.gen == gen { + return c.res + } + c.root, c.gen, c.res = root, gen, ui.classifier.Analyze(root) + return c.res +} + +// treeChanged invalidates everything derived from the tree. +func (ui *UI) treeChanged() { + ui.treeGen.Add(1) +} + +// itemRemoved updates the cached classification for one item that was removed +// from the tree, instead of invalidating it. +func (ui *UI) itemRemoved(it fs.Item) { + c := &ui.cleanup + c.mu.Lock() + defer c.mu.Unlock() + if c.res != nil && c.gen == ui.treeGen.Load() { + c.res.Forget(it) + } +} + +type categoryJSON struct { + cleanup.CategoryInfo + cleanup.Amount +} + +// categoriesResponse is the payload of GET /api/v1/categories: the space each +// category takes within the requested directory. +type categoriesResponse struct { + Total cleanup.Amount `json:"total"` + Categories []categoryJSON `json:"categories"` +} + +func (ui *UI) handleCategories(w http.ResponseWriter, r *http.Request) { + node, err := ui.findNode(r.URL.Query().Get("path")) + if err != nil { + writeNodeError(w, err) + return + } + res := ui.cleanupResult() + if res == nil { + writeError(w, http.StatusServiceUnavailable, "classification is unavailable while scanning") + return + } + + totals := res.Totals(node) + resp := categoriesResponse{ + Total: cleanup.Amount{Usage: node.GetUsage(), Size: node.GetSize()}, + Categories: make([]categoryJSON, 0, len(cleanup.Categories)), + } + for _, info := range cleanup.Categories { + resp.Categories = append(resp.Categories, categoryJSON{CategoryInfo: info, Amount: totals[info.ID]}) + } + writeJSON(w, http.StatusOK, resp) +} + +// buildPivotTree regroups the candidates below it by category: a synthetic +// node per category holds the matched items (the topmost ones, which are +// units) with their real paths, largest first. Category nodes report the full +// category total even when the node budget cuts off their smallest items. +func buildPivotTree(it fs.Item, res *cleanup.Result) treeJSON { + root := treeJSON{nodeJSON: toNodeJSON(it, res), Children: []*treeJSON{}} + totals := res.Totals(it) + + units := make(map[cleanup.Category][]fs.Item) + collectUnits(it, res, units) + + type candidate struct { + category cleanup.Category + item fs.Item + } + var all []candidate + for category, items := range units { + for _, item := range items { + all = append(all, candidate{category, item}) + } + } + slices.SortFunc(all, func(a, b candidate) int { + return cmp.Compare(b.item.GetUsage(), a.item.GetUsage()) + }) + if len(all) > maxTreeNodes { + all = all[:maxTreeNodes] + } + + nodes := make(map[cleanup.Category]*treeJSON) + for _, info := range cleanup.Categories { + amount, ok := totals[info.ID] + if !ok { + continue + } + node := &treeJSON{ + nodeJSON: nodeJSON{ + Name: info.Label, + Path: pivotPathPrefix + string(info.ID), + IsDir: true, + Size: amount.Size, + Usage: amount.Usage, + ItemCount: int64(len(units[info.ID])), + Category: info.ID, + }, + Children: []*treeJSON{}, + } + nodes[info.ID] = node + root.Children = append(root.Children, node) + } + for _, c := range all { + node := nodes[c.category] + child := &treeJSON{nodeJSON: toNodeJSON(c.item, res), Children: []*treeJSON{}} + node.Children = append(node.Children, child) + } + return root +} + +// collectUnits gathers the matched items below it, descending only into +// directories that hold candidates. +func collectUnits(it fs.Item, res *cleanup.Result, units map[cleanup.Category][]fs.Item) { + if m, ok := res.Match(it); ok { + units[m.Category] = append(units[m.Category], it) + return + } + if !it.IsDir() || len(res.Totals(it)) == 0 { + return + } + for child := range it.GetFilesLocked(fs.SortBySize, fs.SortDesc) { + collectUnits(child, res, units) + } +} diff --git a/webui/cleanup_test.go b/webui/cleanup_test.go new file mode 100644 index 000000000..eba98d669 --- /dev/null +++ b/webui/cleanup_test.go @@ -0,0 +1,220 @@ +package webui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dundee/gdu/v5/pkg/cleanup" +) + +// makeCleanupTree creates a project with build artifacts and a model file: +// +// root/app/package.json +// root/app/node_modules/lib.js +// root/models/a.gguf +// root/docs/readme.txt +func makeCleanupTree(t *testing.T) string { + t.Helper() + root := t.TempDir() + write := func(rel string, size int) { + path := filepath.Join(root, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, make([]byte, size), 0o600)) + } + write("app/package.json", 16) + write("app/node_modules/lib.js", 8192) + write("models/a.gguf", 16384) + write("docs/readme.txt", 4096) + return root +} + +func getJSON(t *testing.T, srv *httptest.Server, path string, out any) int { + t.Helper() + resp, err := http.Get(srv.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + if out != nil && resp.StatusCode == http.StatusOK { + require.NoError(t, json.NewDecoder(resp.Body).Decode(out)) + } + return resp.StatusCode +} + +func categoryAmounts(resp categoriesResponse) map[cleanup.Category]cleanup.Amount { + out := make(map[cleanup.Category]cleanup.Amount) + for _, c := range resp.Categories { + out[c.ID] = c.Amount + } + return out +} + +func TestCategoriesEndpointScopesToPath(t *testing.T) { + ui := newTestUI() + root := makeCleanupTree(t) + scan(t, ui, root) + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + var all categoriesResponse + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/categories", &all)) + amounts := categoryAmounts(all) + assert.Len(t, all.Categories, len(cleanup.Categories)) + assert.Equal(t, int64(8192), amounts[cleanup.BuildArtifact].Size) + assert.Equal(t, int64(16384), amounts[cleanup.MLModel].Size) + assert.Greater(t, all.Total.Size, amounts[cleanup.MLModel].Size+amounts[cleanup.BuildArtifact].Size) + + var models categoriesResponse + require.Equal(t, http.StatusOK, + getJSON(t, srv, "/api/v1/categories?path="+url.QueryEscape(filepath.Join(root, "models")), &models)) + amounts = categoryAmounts(models) + assert.Equal(t, int64(16384), amounts[cleanup.MLModel].Size) + assert.Zero(t, amounts[cleanup.BuildArtifact].Size) + + assert.Equal(t, http.StatusNotFound, + getJSON(t, srv, "/api/v1/categories?path="+url.QueryEscape(filepath.Join(root, "missing")), nil)) +} + +func TestCategoriesEndpointUnavailableWhileScanning(t *testing.T) { + ui := newTestUI() + scan(t, ui, makeCleanupTree(t)) + ui.mu.Lock() + ui.scanning = true + ui.mu.Unlock() + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + assert.Equal(t, http.StatusServiceUnavailable, getJSON(t, srv, "/api/v1/categories", nil)) + assert.Equal(t, http.StatusServiceUnavailable, getJSON(t, srv, "/api/v1/tree?pivot=category", nil)) +} + +func TestTreeEndpointAnnotatesCandidates(t *testing.T) { + ui := newTestUI() + root := makeCleanupTree(t) + scan(t, ui, root) + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + var tree treeJSON + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/tree", &tree)) + assert.Equal(t, int64(16384), tree.CategoryTotals[cleanup.MLModel].Size) + + byName := func(n *treeJSON, name string) *treeJSON { + for _, c := range n.Children { + if c.Name == name { + return c + } + } + t.Fatalf("child %q missing", name) + return nil + } + modules := byName(byName(&tree, "app"), "node_modules") + assert.Equal(t, cleanup.BuildArtifact, modules.Category) + assert.Equal(t, cleanup.Safe, modules.Confidence) + assert.NotEmpty(t, modules.Hint) + + // items inside a matched directory inherit it, without repeating the hint + lib := byName(modules, "lib.js") + assert.Equal(t, cleanup.BuildArtifact, lib.Category) + assert.Empty(t, lib.Hint) + + docs := byName(&tree, "docs") + assert.Empty(t, docs.Category) + assert.Empty(t, docs.CategoryTotals) +} + +func TestPivotTreeGroupsCandidatesByCategory(t *testing.T) { + ui := newTestUI() + root := makeCleanupTree(t) + scan(t, ui, root) + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + var tree treeJSON + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/tree?pivot=category", &tree)) + assert.Equal(t, root, tree.Path) + require.Len(t, tree.Children, 2) + + // categories come in display order + build, models := tree.Children[0], tree.Children[1] + assert.Equal(t, pivotPathPrefix+string(cleanup.BuildArtifact), build.Path) + assert.Equal(t, int64(8192), build.Size) + require.Len(t, build.Children, 1) + assert.Equal(t, filepath.Join(root, "app", "node_modules"), build.Children[0].Path) + + assert.Equal(t, cleanup.MLModel, models.Category) + require.Len(t, models.Children, 1) + assert.Equal(t, filepath.Join(root, "models", "a.gguf"), models.Children[0].Path) + + // scoped to a subdirectory, only its candidates remain + var scoped treeJSON + require.Equal(t, http.StatusOK, + getJSON(t, srv, "/api/v1/tree?pivot=category&path="+url.QueryEscape(filepath.Join(root, "app")), &scoped)) + require.Len(t, scoped.Children, 1) + assert.Equal(t, cleanup.BuildArtifact, scoped.Children[0].Category) +} + +func TestPivotCategoryNodesAreNotActionable(t *testing.T) { + ui := newTestUI() + scan(t, ui, makeCleanupTree(t)) + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + req, err := http.NewRequest(http.MethodDelete, + srv.URL+"/api/v1/nodes?path="+url.QueryEscape(pivotPathPrefix+string(cleanup.MLModel)), nil) + require.NoError(t, err) + req.Header.Set("X-GDU-Action", ui.actionToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestClassificationUpdatedAfterDelete(t *testing.T) { + ui := newTestUI() + root := makeCleanupTree(t) + scan(t, ui, root) + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + var before categoriesResponse + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/categories", &before)) + require.NotZero(t, categoryAmounts(before)[cleanup.MLModel].Size) + cached := ui.cleanupResult() + + req, err := http.NewRequest(http.MethodDelete, + srv.URL+"/api/v1/nodes?path="+url.QueryEscape(filepath.Join(root, "models", "a.gguf")), nil) + require.NoError(t, err) + req.Header.Set("X-GDU-Action", ui.actionToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + var after categoriesResponse + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/categories", &after)) + assert.Zero(t, categoryAmounts(after)[cleanup.MLModel].Size) + assert.NotZero(t, categoryAmounts(after)[cleanup.BuildArtifact].Size) + assert.Same(t, cached, ui.cleanupResult(), "a deletion updates the classification instead of recomputing it") +} + +func TestSetCleanupConfigAppliesExclusions(t *testing.T) { + ui := newTestUI() + root := makeCleanupTree(t) + require.NoError(t, ui.SetCleanupConfig(cleanup.Config{Exclude: []string{filepath.Join(root, "app")}})) + scan(t, ui, root) + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + var resp categoriesResponse + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/categories", &resp)) + assert.Zero(t, categoryAmounts(resp)[cleanup.BuildArtifact].Size) + + assert.Error(t, ui.SetCleanupConfig(cleanup.Config{Rules: []cleanup.Rule{{Category: "junk", Dir: "x"}}})) +} diff --git a/webui/dist/assets/index-BHjKKSt8.js b/webui/dist/assets/index-BHjKKSt8.js deleted file mode 100644 index 8a2ef40d1..000000000 --- a/webui/dist/assets/index-BHjKKSt8.js +++ /dev/null @@ -1,8 +0,0 @@ -var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports);(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var t=e((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function ee(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{n.exports=t()})),r=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m){if(n(c)!==null)m=!0,oe(x);else{var t=n(l);t!==null&&E(b,t.startTime-e)}}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!T());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&E(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,ee=5,te=-1;function T(){return!(e.unstable_now()-tee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,E(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,oe(x))),r},e.unstable_shouldYield=T,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),i=e(((e,t)=>{t.exports=r()})),a=e((e=>{var t=n(),r=i();function a(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),d=Object.prototype.hasOwnProperty,f=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return d.call(m,e)?!0:d.call(p,e)?!1:f.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` -`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{de=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?ue(e):``}function pe(e){switch(e.tag){case 5:return ue(e.type);case 16:return ue(`Lazy`);case 13:return ue(`Suspense`);case 19:return ue(`SuspenseList`);case 0:case 2:case 15:return e=fe(e.type,!1),e;case 11:return e=fe(e.type.render,!1),e;case 1:return e=fe(e.type,!0),e;default:return``}}function me(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case te:return`Fragment`;case ee:return`Portal`;case ne:return`Profiler`;case T:return`StrictMode`;case oe:return`Suspense`;case E:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case ie:return(e.displayName||`Context`)+`.Consumer`;case re:return(e._context.displayName||`Context`)+`.Provider`;case ae:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case D:return t=e.displayName||null,t===null?me(e.type)||`Memo`:t;case O:t=e._payload,e=e._init;try{return me(e(t))}catch{}}return null}function he(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return me(t);case 8:return t===T?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function ge(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function _e(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function ve(e){var t=_e(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ye(e){e._valueTracker||=ve(e)}function be(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=_e(e)?e.checked?`true`:`false`:e.value),e=r,e!==n&&(t.setValue(e),!0)}function xe(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function Se(e,t){var n=t.checked;return A({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ce(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=ge(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function we(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function Te(e,t){we(e,t);var n=ge(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?De(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&De(e,t.type,ge(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ee(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function De(e,t,n){(t!==`number`||xe(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var Oe=Array.isArray;function ke(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Ie.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Re(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var ze={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Be=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(ze).forEach(function(e){Be.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),ze[t]=ze[e]})});function Ve(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||ze.hasOwnProperty(e)&&ze[e]?(``+t).trim():t+`px`}function He(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Ve(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Ue=A({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function We(e,t){if(t){if(Ue[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(a(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(a(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(a(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(a(62))}}function Ge(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Ke=null;function qe(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Je=null,Ye=null,Xe=null;function Ze(e){if(e=Yi(e)){if(typeof Je!=`function`)throw Error(a(280));var t=e.stateNode;t&&(t=Zi(t),Je(e.stateNode,e.type,t))}}function Qe(e){Ye?Xe?Xe.push(e):Xe=[e]:Ye=e}function $e(){if(Ye){var e=Ye,t=Xe;if(Xe=Ye=null,Ze(e),t)for(e=0;e>>=0,e===0?32:31-(Ft(e)/It|0)|0}var Rt=64,zt=4194304;function Bt(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Vt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=Bt(a))):r=Bt(s)}else o=n&~i,o===0?a!==0&&(r=Bt(a)):r=Bt(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function qt(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Pt(t),e[t]=n}function Jt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=lr),fr=` `,pr=!1;function mr(e,t){switch(e){case`keyup`:return sr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function hr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var gr=!1;function _r(e,t){switch(e){case`compositionend`:return hr(t);case`keypress`:return t.which===32?(pr=!0,fr):null;case`textInput`:return e=t.data,e===fr&&pr?null:e;default:return null}}function vr(e,t){if(gr)return e===`compositionend`||!cr&&mr(e,t)?(e=Mn(),jn=An=kn=null,gr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Br(n)}}function Hr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Hr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ur(){for(var e=window,t=xe();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=xe(e.document)}return t}function Wr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Gr(e){var t=Ur(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Hr(n.ownerDocument.documentElement,n)){if(r!==null&&Wr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Vr(n,a);var o=Vr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,qr=null,Jr=null,Yr=null,Xr=!1;function Zr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Xr||qr==null||qr!==xe(r)||(r=qr,`selectionStart`in r&&Wr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Yr&&zr(Yr,r)||(Yr=r,r=Si(Jr,`onSelect`),0$i||(e.current=Qi[$i],Qi[$i]=null,$i--)}function F(e,t){$i++,Qi[$i]=e.current,e.current=t}var ta={},I=ea(ta),na=ea(!1),ra=ta;function ia(e,t){var n=e.type.contextTypes;if(!n)return ta;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function aa(e){return e=e.childContextTypes,e!=null}function oa(){P(na),P(I)}function sa(e,t,n){if(I.current!==ta)throw Error(a(168));F(I,t),F(na,n)}function ca(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!=`function`)return n;for(var i in r=r.getChildContext(),r)if(!(i in t))throw Error(a(108,he(e)||`Unknown`,i));return A({},n,r)}function la(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||ta,ra=I.current,F(I,e),F(na,na.current),!0}function ua(e,t,n){var r=e.stateNode;if(!r)throw Error(a(169));n?(e=ca(e,t,ra),r.__reactInternalMemoizedMergedChildContext=e,P(na),P(I),F(I,e)):P(na),F(na,n)}var da=null,fa=!1,pa=!1;function ma(e){da===null?da=[e]:da.push(e)}function ha(e){fa=!0,ma(e)}function ga(){if(!pa&&da!==null){pa=!0;var e=0,t=M;try{var n=da;for(M=1;e>=o,i-=o,wa=1<<32-Pt(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),L&&Ea(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),L&&Ea(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return L&&Ea(i,g),u}for(h=r(i,h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),L&&Ea(i,g),u}function _(e,r,a,o){if(typeof a==`object`&&a&&a.type===te&&a.key===null&&(a=a.props.children),typeof a==`object`&&a){switch(a.$$typeof){case w:a:{for(var c=a.key,l=r;l!==null;){if(l.key===c){if(c=a.type,c===te){if(l.tag===7){n(e,l.sibling),r=i(l,a.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===O&&Ga(c)===l.type){n(e,l.sibling),r=i(l,a.props),r.ref=Ua(e,l,a),r.return=e,e=r;break a}n(e,l);break}t(e,l),l=l.sibling}a.type===te?(r=Zl(a.props.children,e.mode,o,a.key),r.return=e,e=r):(o=Xl(a.type,a.key,a.props,null,e.mode,o),o.ref=Ua(e,r,a),o.return=e,e=o)}return s(e);case ee:a:{for(l=a.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===a.containerInfo&&r.stateNode.implementation===a.implementation){n(e,r.sibling),r=i(r,a.children||[]),r.return=e,e=r;break a}n(e,r);break}t(e,r),r=r.sibling}r=eu(a,e.mode,o),r.return=e,e=r}return s(e);case O:return l=a._init,_(e,r,l(a._payload),o)}if(Oe(a))return h(e,r,a,o);if(k(a))return g(e,r,a,o);Wa(e,a)}return typeof a==`string`&&a!==``||typeof a==`number`?(a=``+a,r!==null&&r.tag===6?(n(e,r.sibling),r=i(r,a),r.return=e,e=r):(n(e,r),r=$l(a,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var qa=Ka(!0),Ja=Ka(!1),Ya=ea(null),Xa=null,Za=null,Qa=null;function $a(){Qa=Za=Xa=null}function eo(e){var t=Ya.current;P(Ya),e._currentValue=t}function to(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function no(e,t){Xa=e,Qa=Za=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Vs=!0),e.firstContext=null)}function ro(e){var t=e._currentValue;if(Qa!==e){if(e={context:e,memoizedValue:t,next:null},Za===null){if(Xa===null)throw Error(a(308));Za=e,Xa.dependencies={lanes:0,firstContext:e}}else Za=Za.next=e}return t}var io=null;function ao(e){io===null?io=[e]:io.push(e)}function oo(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,ao(t)):(n.next=i.next,i.next=n),t.interleaved=n,so(e,r)}function so(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var co=!1;function lo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function uo(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function fo(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function po(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,J&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,so(e,n)}return i=r.interleaved,i===null?(t.next=t,ao(r)):(t.next=i.next,i.next=t),r.interleaved=t,so(e,n)}function mo(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Yt(e,n)}}function ho(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function go(e,t,n,r){var i=e.updateQueue;co=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=A({},d,f);break a;case 2:co=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Yc|=o,e.lanes=o,e.memoizedState=d}}function _o(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=jo.transition;jo.transition={};try{e(!1),t()}finally{M=n,jo.transition=r}}function ms(){return Vo().memoizedState}function hs(e,t,n){var r=ml(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},_s(e))vs(t,n);else if(n=oo(e,t,n,r),n!==null){var i=pl();hl(n,e,r,i),ys(n,t,r)}}function gs(e,t,n){var r=ml(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(_s(e))vs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Rr(s,o)){var c=t.interleaved;c===null?(i.next=i,ao(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=oo(e,t,i,r),n!==null&&(i=pl(),hl(n,e,r,i),ys(n,t,r))}}function _s(e){var t=e.alternate;return e===z||t!==null&&t===z}function vs(e,t){Po=No=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ys(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Yt(e,n)}}var bs={readContext:ro,useCallback:H,useContext:H,useEffect:H,useImperativeHandle:H,useInsertionEffect:H,useLayoutEffect:H,useMemo:H,useReducer:H,useRef:H,useState:H,useDebugValue:H,useDeferredValue:H,useTransition:H,useMutableSource:H,useSyncExternalStore:H,useId:H,unstable_isNewReconciler:!1},xs={readContext:ro,useCallback:function(e,t){return Bo().memoizedState=[e,t===void 0?null:t],e},useContext:ro,useEffect:rs,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),ts(4194308,4,ss.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ts(4194308,4,e,t)},useInsertionEffect:function(e,t){return ts(4,2,e,t)},useMemo:function(e,t){var n=Bo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Bo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=hs.bind(null,z,e),[r.memoizedState,e]},useRef:function(e){var t=Bo();return e={current:e},t.memoizedState=e},useState:Qo,useDebugValue:ls,useDeferredValue:function(e){return Bo().memoizedState=e},useTransition:function(){var e=Qo(!1),t=e[0];return e=ps.bind(null,e[1]),Bo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=z,i=Bo();if(L){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),Y===null)throw Error(a(349));Mo&30||qo(r,t,n)}i.memoizedState=n;var o={value:n,getSnapshot:t};return i.queue=o,rs(Yo.bind(null,r,o,e),[e]),r.flags|=2048,$o(9,Jo.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=Bo(),t=Y.identifierPrefix;if(L){var n=Ta,r=wa;n=(r&~(1<<32-Pt(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=Fo++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof r.is==`string`?e=c.createElement(n,{is:r.is}):(e=c.createElement(n),n===`select`&&(c=e,r.multiple?c.multiple=!0:r.size&&(c.size=r.size))):e=c.createElementNS(e,n),e[Hi]=t,e[Ui]=r,uc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Ge(n,r),n){case`dialog`:N(`cancel`,e),N(`close`,e),i=r;break;case`iframe`:case`object`:case`embed`:N(`load`,e),i=r;break;case`video`:case`audio`:for(i=0;itl&&(t.flags|=128,r=!0,pc(o,!1),t.lanes=4194304)}}else{if(!r){if(e=Do(c),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),pc(o,!0),o.tail===null&&o.tailMode===`hidden`&&!c.alternate&&!L)return W(t),null}else 2*j()-o.renderingStartTime>tl&&n!==1073741824&&(t.flags|=128,r=!0,pc(o,!1),t.lanes=4194304)}o.isBackwards?(c.sibling=t.child,t.child=c):(n=o.last,n===null?t.child=c:n.sibling=c,o.last=c)}return o.tail===null?(W(t),null):(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=j(),t.sibling=null,n=R.current,F(R,r?n&1|2:n&1),t);case 22:case 23:return Tl(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?Kc&1073741824&&(W(t),t.subtreeFlags&6&&(t.flags|=8192)):W(t),null;case 24:return null;case 25:return null}throw Error(a(156,t.tag))}function hc(e,t){switch(ka(t),t.tag){case 1:return aa(t.type)&&oa(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return wo(),P(na),P(I),ko(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Eo(t),null;case 13:if(P(R),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));Ba()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return P(R),null;case 4:return wo(),null;case 10:return eo(t.type._context),null;case 22:case 23:return Tl(),null;case 24:return null;default:return null}}var gc=!1,G=!1,_c=typeof WeakSet==`function`?WeakSet:Set,K=null;function vc(e,t){var n=e.ref;if(n!==null){if(typeof n==`function`)try{n(null)}catch(n){$(e,t,n)}else n.current=null}}function yc(e,t,n){try{n()}catch(n){$(e,t,n)}}var bc=!1;function xc(e,t){if(Ai=Sn,e=Ur(),Wr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||i!==0&&f.nodeType!==3||(c=s+i),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===i&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(ji={focusedElem:e,selectionRange:n},Sn=!1,K=t;K!==null;)if(t=K,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,K=e;else for(;K!==null;){t=K;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:ws(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(a(163))}}catch(e){$(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,K=e;break}K=t.return}return h=bc,bc=!1,h}function Sc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&yc(t,n,a)}i=i.next}while(i!==r)}}function Cc(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function wc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function Tc(e){var t=e.alternate;t!==null&&(e.alternate=null,Tc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Hi],delete t[Ui],delete t[Gi],delete t[Ki],delete t[qi])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Ec(e){return e.tag===5||e.tag===3||e.tag===4}function Dc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Ec(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Oc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ki));else if(r!==4&&(e=e.child,e!==null))for(Oc(e,t,n),e=e.sibling;e!==null;)Oc(e,t,n),e=e.sibling}function kc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(kc(e,t,n),e=e.sibling;e!==null;)kc(e,t,n),e=e.sibling}var q=null,Ac=!1;function jc(e,t,n){for(n=n.child;n!==null;)Mc(e,t,n),n=n.sibling}function Mc(e,t,n){if(Mt&&typeof Mt.onCommitFiberUnmount==`function`)try{Mt.onCommitFiberUnmount(jt,n)}catch{}switch(n.tag){case 5:G||vc(n,t);case 6:var r=q,i=Ac;q=null,jc(e,t,n),q=r,Ac=i,q!==null&&(Ac?(e=q,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):q.removeChild(n.stateNode));break;case 18:q!==null&&(Ac?(e=q,n=n.stateNode,e.nodeType===8?Ri(e.parentNode,n):e.nodeType===1&&Ri(e,n),bn(e)):Ri(q,n.stateNode));break;case 4:r=q,i=Ac,q=n.stateNode.containerInfo,Ac=!0,jc(e,t,n),q=r,Ac=i;break;case 0:case 11:case 14:case 15:if(!G&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&yc(n,t,o),i=i.next}while(i!==r)}jc(e,t,n);break;case 1:if(!G&&(vc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){$(n,t,e)}jc(e,t,n);break;case 21:jc(e,t,n);break;case 22:n.mode&1?(G=(r=G)||n.memoizedState!==null,jc(e,t,n),G=r):jc(e,t,n);break;default:jc(e,t,n)}}function Nc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new _c),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function Pc(e,t){var n=t.deletions;if(n!==null)for(var r=0;ri&&(i=s),r&=~o}if(r=i,r=j()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Hc(r/1960))-r,10e?16:e,sl===null)var r=!1;else{if(e=sl,sl=null,cl=0,J&6)throw Error(a(331));var i=J;for(J|=4,K=e.current;K!==null;){var o=K,s=o.child;if(K.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lj()-el?El(e,0):Zc|=n),gl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=zt,zt<<=1,!(zt&130023424)&&(zt=4194304)):t=1);var n=pl();e=so(e,t),e!==null&&(qt(e,t,n),gl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(a(314))}r!==null&&r.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null){if(e.memoizedProps!==t.pendingProps||na.current)Vs=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Vs=!1,lc(e,t,n);Vs=!!(e.flags&131072)}}else Vs=!1,L&&t.flags&1048576&&Da(t,ba,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;sc(e,t),e=t.pendingProps;var i=ia(t,I.current);no(t,n),i=Ro(null,t,r,e,i,n);var o=zo();return t.flags|=1,typeof i==`object`&&i&&typeof i.render==`function`&&i.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,aa(r)?(o=!0,la(t)):o=!1,t.memoizedState=i.state!==null&&i.state!==void 0?i.state:null,lo(t),i.updater=Es,t.stateNode=i,i._reactInternals=t,As(t,r,e,n),t=Ys(null,t,r,!0,o,n)):(t.tag=0,L&&o&&Oa(t),U(null,t,i,n),t=t.child),t;case 16:r=t.elementType;a:{switch(sc(e,t),e=t.pendingProps,i=r._init,r=i(r._payload),t.type=r,i=t.tag=Jl(r),e=ws(r,e),i){case 0:t=qs(null,t,r,e,n);break a;case 1:t=Js(null,t,r,e,n);break a;case 11:t=Hs(null,t,r,e,n);break a;case 14:t=Us(null,t,r,ws(r.type,e),n);break a}throw Error(a(306,r,``))}return t;case 0:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:ws(r,i),qs(e,t,r,i,n);case 1:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:ws(r,i),Js(e,t,r,i,n);case 3:a:{if(Xs(t),e===null)throw Error(a(387));r=t.pendingProps,o=t.memoizedState,i=o.element,uo(e,t),go(t,r,null,n);var s=t.memoizedState;if(r=s.element,o.isDehydrated){if(o={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){i=js(Error(a(423)),t),t=Zs(e,t,r,n,i);break a}if(r!==i){i=js(Error(a(424)),t),t=Zs(e,t,r,n,i);break a}for(ja=zi(t.stateNode.containerInfo.firstChild),Aa=t,L=!0,Ma=null,n=Ja(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(Ba(),r===i){t=cc(e,t,n);break a}U(e,t,r,n)}t=t.child}return t;case 5:return To(t),e===null&&Ia(t),r=t.type,i=t.pendingProps,o=e===null?null:e.memoizedProps,s=i.children,Mi(r,i)?s=null:o!==null&&Mi(r,o)&&(t.flags|=32),Ks(e,t),U(e,t,s,n),t.child;case 6:return e===null&&Ia(t),null;case 13:return ec(e,t,n);case 4:return Co(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=qa(t,null,r,n):U(e,t,r,n),t.child;case 11:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:ws(r,i),Hs(e,t,r,i,n);case 7:return U(e,t,t.pendingProps,n),t.child;case 8:return U(e,t,t.pendingProps.children,n),t.child;case 12:return U(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(r=t.type._context,i=t.pendingProps,o=t.memoizedProps,s=i.value,F(Ya,r._currentValue),r._currentValue=s,o!==null){if(Rr(o.value,s)){if(o.children===i.children&&!na.current){t=cc(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===r){if(o.tag===1){l=fo(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),to(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(a(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),to(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}}U(e,t,i.children,n),t=t.child}return t;case 9:return i=t.type,r=t.pendingProps.children,no(t,n),i=ro(i),r=r(i),t.flags|=1,U(e,t,r,n),t.child;case 14:return r=t.type,i=ws(r,t.pendingProps),i=ws(r.type,i),Us(e,t,r,i,n);case 15:return Ws(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:ws(r,i),sc(e,t),t.tag=1,aa(r)?(e=!0,la(t)):e=!1,no(t,n),Os(t,r,i),As(t,r,i,n),Ys(null,t,r,!0,e,n);case 19:return oc(e,t,n);case 22:return Gs(e,t,n)}throw Error(a(156,t.tag))};function Wl(e,t){return xt(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===ae)return 11;if(e===D)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,r,i,o){var s=2;if(r=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case te:return Zl(n.children,i,o,t);case T:s=8,i|=8;break;case ne:return e=Kl(12,n,t,i|2),e.elementType=ne,e.lanes=o,e;case oe:return e=Kl(13,n,t,i),e.elementType=oe,e.lanes=o,e;case E:return e=Kl(19,n,t,i),e.elementType=E,e.lanes=o,e;case se:return Ql(n,i,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case re:s=10;break a;case ie:s=9;break a;case ae:s=11;break a;case D:s=14;break a;case O:s=16,r=null;break a}throw Error(a(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,i),t.elementType=e,t.type=r,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=se,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Kt(0),this.expirationTimes=Kt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Kt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},lo(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=a()})),s=e((e=>{var t=o();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),c=n(),l=s();async function u(e){let t=`request failed: ${e.status}`;try{let n=await e.json();n.error&&(t=n.error)}catch{}return Object.assign(Error(t),{status:e.status})}async function d(e){let t=await fetch(e);if(!t.ok)throw await u(t);return await t.json()}function f(){return d(`api/v1/status`)}function p(e,t,n){return d(`api/v1/nodes?${new URLSearchParams({path:e,sort:t,order:n}).toString()}`)}function m(e){return d(`api/v1/tree?${new URLSearchParams({path:e}).toString()}`)}async function h(e,t,n){let r=await fetch(e,{...n,headers:{"X-GDU-Action":t,...n.headers}});if(!r.ok)throw await u(r)}function g(e,t){return h(`api/v1/reveal`,t,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({path:e})})}function _(e,t,n){let r=new URLSearchParams({path:e});return n===`trash`&&r.set(`mode`,`trash`),h(`api/v1/nodes?${r.toString()}`,t,{method:`DELETE`})}function v(e){let t=new EventSource(`api/v1/events`);return t.onmessage=t=>{try{e(JSON.parse(t.data))}catch{}},()=>t.close()}var y=[`#2479d0`,`#e67100`,`#3fb27f`,`#d0454c`,`#9b59b6`,`#f1c40f`,`#1abc9c`,`#e84393`,`#5f8fd0`,`#c0862d`,`#7f8c8d`,`#16a085`],b=`#4a5568`;function x(e){return y[e%y.length]}function S(e,t){return Math.max(t?e.size:e.usage,0)}function C(e,t){let n=new Map;return[...e].sort((e,n)=>S(n,t)-S(e,t)).forEach((e,t)=>{n.set(e.path,t<11?x(t):b)}),n}function w(e,t){let n=e.map(e=>({node:e,value:S(e,t)})).filter(e=>e.value>0).sort((e,t)=>t.value-e.value),r=n.reduce((e,t)=>e+t.value,0),i=n.slice(0,11),a=n.slice(11),o=i.map((e,t)=>({key:e.node.path,label:e.node.name,value:e.value,color:x(t),node:e.node}));return a.length>0&&o.push({key:`__other__`,label:`Other (${a.length})`,value:a.reduce((e,t)=>e+t.value,0),color:b}),{slices:o,total:r}}function ee(e){let t=(0,c.useRef)(e);return(0,c.useEffect)(()=>{t.current=e},[e]),t}var te=e((e=>{var t=n(),r=Symbol.for(`react.element`),i=Symbol.for(`react.fragment`),a=Object.prototype.hasOwnProperty,o=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,s={key:!0,ref:!0,__self:!0,__source:!0};function c(e,t,n){var i,c={},l=null,u=null;for(i in n!==void 0&&(l=``+n),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)a.call(t,i)&&!s.hasOwnProperty(i)&&(c[i]=t[i]);if(e&&e.defaultProps)for(i in t=e.defaultProps,t)c[i]===void 0&&(c[i]=t[i]);return{$$typeof:r,type:e,key:l,ref:u,props:c,_owner:o.current}}e.Fragment=i,e.jsx=c,e.jsxs=c})),T=e(((e,t)=>{t.exports=te()}))(),ne=`gdu.actionToken`,re=`gdu.skipDeleteConfirm`;function ie(e){try{return window.sessionStorage.getItem(e)}catch{return null}}function ae(e,t){try{t===null?window.sessionStorage.removeItem(e):window.sessionStorage.setItem(e,t)}catch{}}var oe=(0,c.createContext)(null);function E(){let e=(0,c.useContext)(oe);if(!e)throw Error(`useGduModel must be used within GduModelProvider`);return e}function D({model:e,children:t}){return(0,T.jsx)(oe.Provider,{value:e,children:t})}function O(){let[e]=(0,c.useState)(()=>ie(ne)??``),[t,n]=(0,c.useState)(null),[r,i]=(0,c.useState)(null),[a,o]=(0,c.useState)(null),[s,l]=(0,c.useState)(`size`),[u,d]=(0,c.useState)(`desc`),[m,h]=(0,c.useState)(null),[g,_]=(0,c.useState)(`donut`),[y,b]=(0,c.useState)(null),[x,S]=(0,c.useState)(null),[te,T]=(0,c.useState)(!1),[oe,E]=(0,c.useState)(null),[D,O]=(0,c.useState)(null),[se,ce]=(0,c.useState)(()=>{let e=ie(re);return e===`trash`||e===`permanent`?e:null}),k=ee(r);(0,c.useEffect)(()=>(f().then(n).catch(()=>void 0),v(n)),[]),(0,c.useEffect)(()=>{t&&m===null&&h(t.showApparentSize)},[t,m]),(0,c.useEffect)(()=>{t?.state===`done`&&r===null&&t.rootPath&&i(t.rootPath)},[t,r]),(0,c.useEffect)(()=>{if(r===null)return;let e=!1;return p(r,s,u).then(t=>{e||(o(t),S(null))}).catch(t=>{e||S(t instanceof Error?t.message:String(t))}),()=>{e=!0}},[r,s,u,t?.state]),(0,c.useEffect)(()=>{let e=e=>{let t=e.target;t&&[`INPUT`,`TEXTAREA`,`SELECT`].includes(t.tagName)||e.key===`?`&&(e.preventDefault(),T(!0))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[]);let A=m??t?.showApparentSize??!1,le=t?.useSIPrefix??!1,ue=a?.children??[],{total:de}=(0,c.useMemo)(()=>w(ue,A),[ue,A]),fe=(0,c.useMemo)(()=>C(ue,A),[ue,A]),pe=(0,c.useCallback)(e=>{e===s?d(e=>e===`asc`?`desc`:`asc`):(l(e),d(e===`name`?`asc`:`desc`))},[s]),me=(0,c.useCallback)(e=>{i(e),b(null)},[]),he=(0,c.useCallback)(e=>{e.isDir&&me(e.path)},[me]);return{status:t,actionToken:e,currentPath:r,nodeResp:a,children:ue,effectiveApparent:A,useSIPrefix:le,total:de,colorMap:fe,sort:s,order:u,onSortChange:pe,view:g,toggleView:(0,c.useCallback)(()=>{_(e=>e===`donut`?`treemap`:`donut`),b(null)},[]),toggleApparent:(0,c.useCallback)(()=>{h(e=>!(e??t?.showApparentSize??!1))},[t?.showApparentSize]),hoveredPath:y,setHoveredPath:b,loadError:x,setLoadError:S,handleSelect:he,navigateToPath:me,refreshNode:(0,c.useCallback)(async()=>{if(r===null)throw Error(`no current path`);let e=r,t=await p(e,s,u);return k.current===e&&(o(t),S(null)),t},[r,s,u]),showHelp:te,setShowHelp:T,treeRoot:oe,treePath:D,setTree:(0,c.useCallback)((e,t)=>{O(e),E(t)},[]),clearTree:(0,c.useCallback)(()=>{O(null),E(null)},[]),skipDeleteConfirm:se,setSkipDeleteConfirm:(0,c.useCallback)(e=>{ce(e),ae(re,e)},[])}}var se=[``,`Ki`,`Mi`,`Gi`,`Ti`,`Pi`,`Ei`],ce=[``,`k`,`M`,`G`,`T`,`P`,`E`];function k(e,t=!1){let n=t?1e3:1024,r=t?ce:se,i=Math.abs(e),a=0;for(;i>=n&&a0),c=`${r[a]}B`;return`${o}${i.toFixed(s)} ${c}`}function A(e){return e.toLocaleString(`en-US`)}function le(e){return e?new Date(e*1e3).toLocaleString(void 0,{year:`numeric`,month:`short`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`}):``}function ue(e,t){if(t<=0)return 0;let n=e/t*100;return n<0?0:n>100?100:n}function de({breadcrumbs:e,onNavigate:t}){return(0,T.jsx)(`nav`,{className:`breadcrumbs`,"aria-label":`Path`,children:e.map((n,r)=>{let i=r===e.length-1;return(0,T.jsxs)(`span`,{className:`crumb`,children:[i?(0,T.jsx)(`span`,{className:`crumb-current`,children:n.name||n.path}):(0,T.jsx)(`button`,{type:`button`,className:`crumb-link`,onClick:()=>t(n),children:n.name||n.path}),!i&&(0,T.jsx)(`span`,{className:`crumb-sep`,children:`/`})]},n.path)})})}function fe(){let{status:e,nodeResp:t,total:n,useSIPrefix:r,view:i,toggleView:a,effectiveApparent:o,toggleApparent:s,handleSelect:c}=E();return(0,T.jsxs)(`header`,{className:`app-header`,children:[(0,T.jsxs)(`div`,{className:`app-title`,children:[(0,T.jsx)(`span`,{className:`logo`,children:`gdu`}),t&&(0,T.jsx)(de,{breadcrumbs:t.breadcrumbs,onNavigate:c})]}),(0,T.jsxs)(`div`,{className:`app-actions`,children:[(0,T.jsx)(`span`,{className:`total`,children:k(n,r)}),(0,T.jsx)(`button`,{type:`button`,className:`toggle`,onClick:a,title:`Toggle between donut and treemap charts`,children:i===`donut`?`Treemap`:`Donut`}),(0,T.jsx)(`button`,{type:`button`,className:`toggle`,onClick:s,title:`Toggle between disk usage and apparent size`,children:o?`Apparent size`:`Disk usage`}),e.state===`scanning`&&(0,T.jsx)(`span`,{className:`scanning-badge`,children:`scanning…`})]})]})}var pe=240,me=pe/2,he=90,ge=34,_e=2*Math.PI*he;function ve({children:e,apparent:t,useSIPrefix:n,hoveredPath:r,onHover:i,onSelect:a}){let{slices:o,total:s}=(0,c.useMemo)(()=>w(e,t),[e,t]),l=o.find(e=>e.node?.path===r)??null,u=l?l.label:k(s,n),d=l?`${k(l.value,n)} · ${ue(l.value,s).toFixed(1)}%`:`${o.length} item${o.length===1?``:`s`}`,f=0;return(0,T.jsxs)(`div`,{className:`donut`,role:`img`,"aria-label":`Directory size breakdown`,children:[(0,T.jsx)(`svg`,{viewBox:`0 0 ${pe} ${pe}`,width:`100%`,height:`100%`,children:(0,T.jsxs)(`g`,{transform:`rotate(-90 ${me} ${me})`,children:[(0,T.jsx)(`circle`,{cx:me,cy:me,r:he,fill:`none`,stroke:`var(--track)`,strokeWidth:ge}),s>0&&o.map(e=>{let t=e.value/s,n=t*_e,o=-f*_e;f+=t;let c=e.node?e.node.path:`__other__`,l=r!==null&&e.node?.path===r,u=r!==null&&!l,d=e.node?.isDir??!1;return(0,T.jsx)(`circle`,{className:`donut-slice`,cx:me,cy:me,r:he,fill:`none`,stroke:e.color,strokeWidth:l?40:ge,strokeDasharray:`${n} ${_e-n}`,strokeDashoffset:o,opacity:u?.35:1,style:{cursor:d?`pointer`:`default`},onMouseEnter:()=>e.node&&i(e.node.path),onMouseLeave:()=>i(null),onClick:()=>{e.node&&e.node.isDir&&a(e.node)}},c)})]})}),(0,T.jsxs)(`div`,{className:`donut-center`,children:[(0,T.jsx)(`div`,{className:`donut-center-primary`,title:u,children:u}),(0,T.jsx)(`div`,{className:`donut-center-secondary`,children:d})]})]})}function ye(){let{children:e,effectiveApparent:t,useSIPrefix:n,hoveredPath:r,setHoveredPath:i,handleSelect:a}=E();return(0,T.jsx)(`section`,{className:`chart-panel`,children:(0,T.jsx)(ve,{children:e,apparent:t,useSIPrefix:n,hoveredPath:r,onHover:i,onSelect:a})})}var be=`Actions are unavailable: this page has no action token. Reopen the URL gdu printed, or restart gdu.`;function xe(e={}){let{refreshExtra:t,onRefreshed:n}=e,{actionToken:r,status:i,skipDeleteConfirm:a,setHoveredPath:o,navigateToPath:s,refreshNode:l,clearTree:u,setLoadError:d}=E(),[f,p]=(0,c.useState)(null),[m,h]=(0,c.useState)(!1),[v,y]=(0,c.useState)(!1),b=(0,c.useCallback)(async()=>{let[e]=await Promise.all([l(),t?t():Promise.resolve(u())]);if(n?.(),o(null),e.children.length===0&&e.breadcrumbs.length>1){let t=e.breadcrumbs[e.breadcrumbs.length-2];s(t.path)}},[l,t,u,n,o,s]),x=(0,c.useCallback)(async(e,t)=>{if(!v){if(!r){p(null),d(be);return}y(!0),p(null);try{await _(e.path,r,t),await b(),d(null)}catch(e){if(typeof e==`object`&&e&&`status`in e&&e.status===404)try{await b()}catch{}d(e instanceof Error?e.message:String(e))}finally{y(!1)}}},[v,b,d,r]),S=(0,c.useCallback)(async e=>{if(!v){if(!r){d(be);return}y(!0);try{await g(e.path,r),d(null)}catch(e){d(e instanceof Error?e.message:String(e))}finally{y(!1)}}},[v,d,r]);return{actionPending:v,deleteCandidate:f,skipDeleteChoice:m,setSkipDeleteChoice:h,requestDelete:(0,c.useCallback)(e=>{if(!(v||!i.deleteAllowed)){if(a){x(e,a);return}h(!1),p(e)}},[v,x,a,i.deleteAllowed]),cancelDelete:(0,c.useCallback)(()=>p(null),[]),performDelete:x,reveal:S}}var Se=[{key:`name`,label:`Name`,numeric:!1},{key:`size`,label:`Size`,numeric:!0},{key:`itemCount`,label:`Items`,numeric:!0},{key:`mtime`,label:`Modified`,numeric:!0}];function Ce({children:e,colorMap:t,apparent:n,useSIPrefix:r,total:i,sort:a,order:o,onSortChange:s,hoveredPath:l,onHover:u,onSelect:d,onReveal:f,onDelete:p,canDelete:m,actionPending:h}){let g=(0,c.useMemo)(()=>e.reduce((e,t)=>Math.max(e,S(t,n)),0),[e,n]);return(0,T.jsxs)(`table`,{className:`file-table`,children:[(0,T.jsx)(`thead`,{children:(0,T.jsxs)(`tr`,{children:[Se.map(e=>(0,T.jsxs)(`th`,{className:e.numeric?`num`:``,onClick:()=>s(e.key),children:[e.label,a===e.key&&(0,T.jsx)(`span`,{className:`sort-arrow`,children:o===`asc`?` ▲`:` ▼`})]},e.key)),(0,T.jsx)(`th`,{className:`actions-col`,"aria-label":`Actions`})]})}),(0,T.jsxs)(`tbody`,{children:[e.map(e=>{let i=S(e,n),a=g>0?i/g*100:0,o=t.get(e.path)??`var(--other)`,s=e.path===l;return(0,T.jsxs)(`tr`,{className:s?`hovered`:``,onMouseEnter:()=>u(e.path),onMouseLeave:()=>u(null),onClick:()=>e.isDir&&d(e),style:{cursor:e.isDir?`pointer`:`default`},children:[(0,T.jsxs)(`td`,{className:`name-cell`,children:[(0,T.jsx)(`span`,{className:`swatch`,style:{backgroundColor:o}}),(0,T.jsxs)(`span`,{className:`name`,children:[e.isDir?`📁`:`📄`,` `,e.name,e.flag===`!`&&(0,T.jsx)(`span`,{className:`flag-error`,title:`Access error`,children:` !`})]}),(0,T.jsx)(`span`,{className:`bar`,style:{width:`${a}%`,backgroundColor:o}})]}),(0,T.jsx)(`td`,{className:`num`,children:k(i,r)}),(0,T.jsx)(`td`,{className:`num`,children:A(e.itemCount)}),(0,T.jsx)(`td`,{className:`num muted`,children:le(e.mtime)}),(0,T.jsxs)(`td`,{className:`actions-cell`,onClick:e=>e.stopPropagation(),children:[(0,T.jsx)(`button`,{type:`button`,className:`row-action`,"aria-label":`Reveal ${e.name}`,title:`Reveal in file manager`,disabled:h,onClick:()=>f(e),children:`📂`}),m&&(0,T.jsx)(`button`,{type:`button`,className:`row-action`,"aria-label":`Delete ${e.name}`,title:`Delete`,disabled:h,onClick:()=>p(e),children:`🗑`})]})]},e.path)}),e.length===0&&(0,T.jsx)(`tr`,{children:(0,T.jsx)(`td`,{colSpan:5,className:`empty`,children:`Empty directory`})})]}),(0,T.jsx)(`tfoot`,{children:(0,T.jsxs)(`tr`,{children:[(0,T.jsxs)(`td`,{className:`muted`,children:[e.length,` items`]}),(0,T.jsx)(`td`,{className:`num`,children:k(i,r)}),(0,T.jsx)(`td`,{className:`num muted`,colSpan:3,children:ue(i,i)>0?`100%`:``})]})})]})}function we({titleId:e,title:t,onClose:n,className:r,children:i}){let a=(0,c.useRef)(null);return(0,c.useEffect)(()=>{let e=e=>{if(e.key===`Escape`){n();return}if(e.key!==`ArrowLeft`&&e.key!==`ArrowRight`)return;let t=Array.from(a.current?.querySelectorAll(`button:not(:disabled)`)??[]),r=t.indexOf(document.activeElement);r!==-1&&(e.preventDefault(),t[(r+(e.key===`ArrowRight`?1:-1)+t.length)%t.length]?.focus())};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[n]),(0,T.jsx)(`div`,{className:`modal-backdrop`,children:(0,T.jsxs)(`section`,{ref:a,className:`modal ${r??``}`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":e,children:[(0,T.jsx)(`h2`,{id:e,children:t}),i]})})}function Te({actions:e}){let{effectiveApparent:t,useSIPrefix:n,setSkipDeleteConfirm:r}=E(),{deleteCandidate:i,skipDeleteChoice:a,setSkipDeleteChoice:o,actionPending:s,cancelDelete:c,performDelete:l}=e;return i?(0,T.jsxs)(we,{titleId:`delete-title`,title:`Delete item`,onClose:c,children:[(0,T.jsx)(`p`,{children:`Move the selected item to the trash, or delete it permanently right away.`}),(0,T.jsx)(`code`,{className:`modal-path`,children:i.path}),(0,T.jsx)(`span`,{className:`muted`,children:k(t?i.size:i.usage,n)}),(0,T.jsxs)(`label`,{className:`modal-checkbox`,children:[(0,T.jsx)(`input`,{type:`checkbox`,checked:a,onChange:e=>o(e.target.checked)}),`Do not ask again this session`]}),(0,T.jsxs)(`div`,{className:`modal-actions`,children:[(0,T.jsx)(`button`,{type:`button`,autoFocus:!0,onClick:c,children:`Cancel`}),(0,T.jsx)(`button`,{type:`button`,disabled:s,onClick:()=>{a&&r(`trash`),l(i,`trash`)},children:`Move to Trash`}),(0,T.jsx)(`button`,{type:`button`,className:`danger`,disabled:s,onClick:()=>{a&&r(`permanent`),l(i,`permanent`)},children:`Delete Permanently`})]})]}):null}function Ee(){let{status:e,children:t,colorMap:n,effectiveApparent:r,useSIPrefix:i,total:a,sort:o,order:s,onSortChange:c,hoveredPath:l,setHoveredPath:u,handleSelect:d}=E(),f=xe();return(0,T.jsxs)(`section`,{className:`table-panel`,children:[(0,T.jsx)(Ce,{children:t,colorMap:n,apparent:r,useSIPrefix:i,total:a,sort:o,order:s,onSortChange:c,hoveredPath:l,onHover:u,onSelect:d,onReveal:e=>void f.reveal(e),onDelete:f.requestDelete,canDelete:e.deleteAllowed,actionPending:f.actionPending}),(0,T.jsx)(Te,{actions:f})]})}function De(e){var t=0,n=e.children,r=n&&n.length;if(!r)t=1;else for(;--r>=0;)t+=n[r].value;e.value=t}function Oe(){return this.eachAfter(De)}function ke(e,t){let n=-1;for(let r of this)e.call(t,r,++n,this);return this}function Ae(e,t){for(var n=this,r=[n],i,a,o=-1;n=r.pop();)if(e.call(t,n,++o,this),i=n.children)for(a=i.length-1;a>=0;--a)r.push(i[a]);return this}function je(e,t){for(var n=this,r=[n],i=[],a,o,s,c=-1;n=r.pop();)if(i.push(n),a=n.children)for(o=0,s=a.length;o=0;)n+=r[i].value;t.value=n})}function Pe(e){return this.eachBefore(function(t){t.children&&t.children.sort(e)})}function Fe(e){for(var t=this,n=Ie(t,e),r=[t];t!==n;)t=t.parent,r.push(t);for(var i=r.length;e!==n;)r.splice(i,0,e),e=e.parent;return r}function Ie(e,t){if(e===t)return e;var n=e.ancestors(),r=t.ancestors(),i=null;for(e=n.pop(),t=r.pop();e===t;)i=e,e=n.pop(),t=r.pop();return i}function Le(){for(var e=this,t=[e];e=e.parent;)t.push(e);return t}function Re(){return Array.from(this)}function ze(){var e=[];return this.eachBefore(function(t){t.children||e.push(t)}),e}function Be(){var e=this,t=[];return e.each(function(n){n!==e&&t.push({source:n.parent,target:n})}),t}function*Ve(){var e=this,t,n=[e],r,i,a;do for(t=n.reverse(),n=[];e=t.pop();)if(yield e,r=e.children)for(i=0,a=r.length;i=0;--s)i.push(a=o[s]=new Je(o[s])),a.parent=r,a.depth=r.depth+1;return n.eachBefore(qe)}function Ue(){return He(this).eachBefore(Ke)}function We(e){return e.children}function Ge(e){return Array.isArray(e)?e[1]:null}function Ke(e){e.data.value!==void 0&&(e.value=e.data.value),e.data=e.data.data}function qe(e){var t=0;do e.height=t;while((e=e.parent)&&e.height<++t)}function Je(e){this.data=e,this.depth=this.height=0,this.parent=null}Je.prototype=He.prototype={constructor:Je,count:Oe,each:ke,eachAfter:je,eachBefore:Ae,find:Me,sum:Ne,sort:Pe,path:Fe,ancestors:Le,descendants:Re,leaves:ze,links:Be,copy:Ue,[Symbol.iterator]:Ve};function Ye(e){if(typeof e!=`function`)throw Error();return e}function Xe(){return 0}function Ze(e){return function(){return e}}function Qe(e){e.x0=Math.round(e.x0),e.y0=Math.round(e.y0),e.x1=Math.round(e.x1),e.y1=Math.round(e.y1)}function $e(e,t,n,r,i){for(var a=e.children,o,s=-1,c=a.length,l=e.value&&(r-t)/e.value;++sv&&(v=l),S=g*g*x,y=Math.max(v/S,S/_),y>b){g-=l;break}b=y}o.push(c={value:g,dice:p1?t:1)},n})(tt);function it(){var e=rt,t=!1,n=1,r=1,i=[0],a=Xe,o=Xe,s=Xe,c=Xe,l=Xe;function u(e){return e.x0=e.y0=0,e.x1=n,e.y1=r,e.eachBefore(d),i=[0],t&&e.eachBefore(Qe),e}function d(t){var n=i[t.depth],r=t.x0+n,u=t.y0+n,d=t.x1-n,f=t.y1-n;de+t.value,0)||r<=0||i<=0)return[];let a=He({children:e.map(({value:e,item:t})=>({value:e,item:t}))}).sum(e=>e.value??0);return it().size([r,i]).padding(0)(a).leaves().map(e=>({item:e.data.item,x:t+e.x0,y:n+e.y0,w:e.x1-e.x0,h:e.y1-e.y0}))}function ot(e,t){let n=[],r=(e,i,a,o,s)=>{if(n.length>=t.maxRects)return;let c=Math.min(t.inset,i.w/2,i.h/2),l={x:i.x+c,y:i.y+c,w:Math.max(i.w-c*2,0),h:Math.max(i.h-c*2,0)};if(l.w<=0||l.h<=0)return;let u=e.children.map(e=>({child:e,value:Math.max(S(e,t.apparent),1)})).sort((e,t)=>t.value-e.value),d=u.reduce((e,t)=>e+t.value,0),f=S(e,t.apparent),p=Math.max(Math.max(f,d)-d,0),m=u.map(({child:e,value:t})=>({value:t,item:e}));p>0&&m.push({value:p,item:null});let h=at(m,l.x,l.y,l.w,l.h);for(let e of h){if(n.length>=t.maxRects)break;let i=e.item;if(i===null||e.w*e.h0&&r(i,e,a+1,[...o,i.path],c)}};return r(e,{x:0,y:0,w:t.width,h:t.height},0,[e.path],null),n}var st=300,ct=200,lt=24,ut=2e3,dt=2;function ft({root:e,apparent:t,useSIPrefix:n,colorMap:r,hoveredPath:i,selectedPath:a,onHover:o,onSelect:s,onOpen:l}){let u=(0,c.useRef)(null),[d,f]=(0,c.useState)({width:st,height:ct});(0,c.useEffect)(()=>{let e=u.current;if(!e||typeof ResizeObserver>`u`)return;let t=0,n=new ResizeObserver(([e])=>{cancelAnimationFrame(t),t=requestAnimationFrame(()=>{let{width:t,height:n}=e.contentRect;t>0&&n>0&&f({width:t,height:n})})});return n.observe(e),()=>{cancelAnimationFrame(t),n.disconnect()}},[]);let p=(0,c.useMemo)(()=>ot(e,{width:d.width,height:d.height,apparent:t,minArea:lt,maxRects:ut,inset:dt}),[t,e,d]),m=S(e,t),{hoveredNode:h,outlinedPaths:g}=(0,c.useMemo)(()=>{let t=p.find(e=>e.node.path===i)??null,n=i===e.path?e:t?.node??null;return{hoveredNode:n,outlinedPaths:new Set(t?[...t.ancestorPaths,t.node.path]:n?[e.path]:[])}},[p,i,e]),_=h?.path===e.path;return(0,T.jsxs)(`div`,{ref:u,className:`treemap ${h===null?``:`hovering`}`,role:`img`,"aria-label":`Directory size treemap`,children:[(0,T.jsxs)(`svg`,{viewBox:`0 0 ${d.width} ${d.height}`,preserveAspectRatio:`none`,width:`100%`,height:`100%`,children:[(0,T.jsx)(`defs`,{children:(0,T.jsxs)(`linearGradient`,{id:`treemap-shine`,x1:`0`,y1:`0`,x2:`1`,y2:`1`,children:[(0,T.jsx)(`stop`,{offset:`0`,stopColor:`#fff`,stopOpacity:`0.28`}),(0,T.jsx)(`stop`,{offset:`0.48`,stopColor:`#fff`,stopOpacity:`0.04`}),(0,T.jsx)(`stop`,{offset:`1`,stopColor:`#000`,stopOpacity:`0.3`})]})}),(0,T.jsx)(`rect`,{className:`treemap-root ${_?`hovered`:``}`,width:d.width,height:d.height,fill:b,stroke:_?`#fff`:`transparent`,strokeWidth:_?1.5:0,onMouseEnter:()=>o(e.path),onMouseLeave:()=>o(null)}),p.map(e=>{let{node:c}=e,u=c.path===i,d=!u&&g.has(c.path),f=c.path===a,p=h!==null&&!g.has(c.path),m=!c.isDir&&e.w>34&&e.h>16,_=m&&e.w>60&&e.h>30,v=Math.max(e.w-.5,0),y=Math.max(e.h-.5,0);return(0,T.jsxs)(`g`,{className:`treemap-tile ${u?`hovered`:``} ${d?`ancestor`:``} ${f?`selected`:``}`,role:`button`,"aria-label":`${c.name}, ${k(S(c,t),n)}`,tabIndex:0,style:{cursor:`pointer`},opacity:p?.42:1,onMouseEnter:()=>o(c.path),onMouseLeave:()=>o(null),onClick:e=>{e.stopPropagation(),s(c)},onDoubleClick:t=>{t.stopPropagation();let n=c.isDir?c.path:e.ancestorPaths[e.ancestorPaths.length-1];n&&l(n)},children:[(0,T.jsx)(`rect`,{x:e.x,y:e.y,width:v,height:y,fill:r.get(e.colorPath)??`#4a5568`,stroke:u||d?`#fff`:f?`#55b6ff`:`rgba(0,0,0,0.35)`,strokeWidth:u?1.5:d||f?1.1:.4}),(0,T.jsx)(`rect`,{className:`treemap-shine`,x:e.x,y:e.y,width:v,height:y,fill:`url(#treemap-shine)`}),m&&(0,T.jsx)(`foreignObject`,{x:e.x+1.5,y:e.y+1,width:Math.max(e.w-3,0),height:Math.max(e.h-2,0),children:(0,T.jsxs)(`div`,{className:`treemap-label`,children:[(0,T.jsx)(`span`,{className:`treemap-label-name`,children:c.name}),_&&(0,T.jsx)(`span`,{className:`treemap-label-size`,children:k(S(c,t),n)})]})})]},c.path)})]}),h&&(0,T.jsxs)(`div`,{className:`treemap-tooltip`,children:[(0,T.jsx)(`b`,{children:h.name}),(0,T.jsx)(`span`,{children:h.path}),(0,T.jsxs)(`span`,{children:[k(S(h,t),n),` ·`,` `,ue(S(h,t),m).toFixed(1),`%`,h.isDir?` · ${A(h.itemCount)} item${h.itemCount===1?``:`s`}`:``]})]})]})}function pt(){let{currentPath:e,effectiveApparent:t,useSIPrefix:n,colorMap:r,hoveredPath:i,setHoveredPath:a,navigateToPath:o,status:s,view:l,treeRoot:u,treePath:d,setTree:f,clearTree:p}=E(),[h,g]=(0,c.useState)(!1),[_,v]=(0,c.useState)(null),[y,b]=(0,c.useState)(null);(0,c.useEffect)(()=>{b(null)},[e]);let x=ee(e);(0,c.useEffect)(()=>{if(l!==`treemap`||e===null||d===e)return;let t=!1;return g(!0),v(null),m(e).then(n=>{t||f(e,n)}).catch(e=>{t||v(e instanceof Error?e.message:String(e))}).finally(()=>{t||g(!1)}),()=>{t=!0}},[e,d,l,f]);let S=xe({refreshExtra:(0,c.useCallback)(async()=>{if(e===null)return;let t=e;try{let e=await m(t);x.current===t&&(f(t,e),v(null))}catch(e){x.current===t&&(v(e instanceof Error?e.message:String(e)),p())}},[e,f,p]),onRefreshed:(0,c.useCallback)(()=>b(null),[])}),{deleteCandidate:C,reveal:w,requestDelete:te}=S,ne=(0,c.useCallback)(async()=>{y&&await w(y)},[w,y]),re=(0,c.useCallback)(()=>{y&&te(y)},[te,y]);return(0,c.useEffect)(()=>{let e=e=>{let t=e.target;t&&[`INPUT`,`TEXTAREA`,`SELECT`].includes(t.tagName)||C||(e.code===`KeyO`&&y?(e.preventDefault(),ne()):(e.code===`KeyD`||e.key===`Delete`)&&y&&s.deleteAllowed&&(e.preventDefault(),re()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[C,re,ne,y,s.deleteAllowed]),(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`section`,{className:`chart-panel`,children:u&&d===e?(0,T.jsx)(ft,{root:u,apparent:t,useSIPrefix:n,colorMap:r,hoveredPath:i,selectedPath:y?.path??null,onHover:a,onSelect:b,onOpen:o}):(0,T.jsxs)(`div`,{className:`treemap-loading`,children:[h&&(0,T.jsx)(`span`,{className:`spinner compact`}),(0,T.jsx)(`span`,{children:_?`Treemap unavailable`:`Loading treemap…`})]})}),(0,T.jsx)(Te,{actions:S})]})}function mt(){let{showHelp:e,setShowHelp:t,view:n}=E();return e?(0,T.jsxs)(we,{titleId:`shortcuts-title`,title:`Keyboard shortcuts`,className:`shortcuts-modal`,onClose:()=>t(!1),children:[(0,T.jsxs)(`dl`,{className:`shortcuts`,children:[n===`treemap`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`dt`,{children:`O`}),(0,T.jsx)(`dd`,{children:`Reveal selected item`}),(0,T.jsx)(`dt`,{children:`D / Delete`}),(0,T.jsx)(`dd`,{children:`Delete selected item`}),(0,T.jsx)(`dt`,{children:`Click`}),(0,T.jsx)(`dd`,{children:`Select a treemap item`}),(0,T.jsx)(`dt`,{children:`Double-click`}),(0,T.jsx)(`dd`,{children:`Open a directory`})]}),n===`donut`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`dt`,{children:`Row buttons`}),(0,T.jsx)(`dd`,{children:`Reveal or delete an item in the table`})]}),(0,T.jsx)(`dt`,{children:`?`}),(0,T.jsx)(`dd`,{children:`Show this help`}),(0,T.jsx)(`dt`,{children:`Esc`}),(0,T.jsx)(`dd`,{children:`Close a dialog`})]}),(0,T.jsx)(`div`,{className:`modal-actions`,children:(0,T.jsx)(`button`,{type:`button`,autoFocus:!0,onClick:()=>t(!1),children:`Close`})})]}):null}function ht({progress:e,useSIPrefix:t}){return(0,T.jsxs)(`div`,{className:`scanning`,children:[(0,T.jsx)(`div`,{className:`spinner`}),(0,T.jsx)(`h2`,{children:`Scanning…`}),(0,T.jsxs)(`div`,{className:`scan-stats`,children:[(0,T.jsxs)(`span`,{children:[A(e.itemCount),` items`]}),(0,T.jsx)(`span`,{children:k(e.totalUsage,t)})]}),(0,T.jsx)(`div`,{className:`scan-current`,title:e.currentItem,children:e.currentItem})]})}function gt(){let e=O(),{status:t,currentPath:n,nodeResp:r,useSIPrefix:i,loadError:a,view:o}=e;return t?t.state===`error`?(0,T.jsxs)(`div`,{className:`error-screen`,children:[(0,T.jsx)(`h2`,{children:`Scan failed`}),(0,T.jsx)(`p`,{children:t.error??`Unknown error`})]}):n===null||t.state===`scanning`&&!r?(0,T.jsx)(ht,{progress:t.progress,useSIPrefix:i}):(0,T.jsx)(D,{model:{...e,status:t,currentPath:n},children:(0,T.jsxs)(`div`,{className:`app`,children:[(0,T.jsx)(fe,{}),a&&(0,T.jsx)(`div`,{className:`banner error`,children:a}),(0,T.jsx)(`main`,{className:`content ${o===`treemap`?`treemap-view`:``}`,children:o===`donut`?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(ye,{}),(0,T.jsx)(Ee,{})]}):(0,T.jsx)(pt,{})}),(0,T.jsx)(mt,{})]})}):(0,T.jsx)(`div`,{className:`loading`,children:`Connecting…`})}var _t=document.getElementById(`root`);if(!_t)throw Error(`root element not found`);(0,l.createRoot)(_t).render((0,T.jsx)(c.StrictMode,{children:(0,T.jsx)(gt,{})})); \ No newline at end of file diff --git a/webui/dist/assets/index-BsoSVSyE.css b/webui/dist/assets/index-BsoSVSyE.css deleted file mode 100644 index 65e42fc75..000000000 --- a/webui/dist/assets/index-BsoSVSyE.css +++ /dev/null @@ -1 +0,0 @@ -:root{--bg:#14171c;--bg-panel:#1b1f27;--bg-header:#2479d0;--bg-row-hover:#232a35;--text:#e6e9ef;--text-muted:#8b94a3;--border:#2a303b;--track:#232833;--other:#4a5568;--accent:#2479d0;--danger:#e63946;--danger-hover:#ff5262;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px}.app{flex-direction:column;height:100%;display:flex}.app-header{background:var(--bg-panel);border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;gap:16px;padding:10px 16px;display:flex}.app-title{align-items:center;gap:14px;min-width:0;display:flex}.logo{color:var(--accent);letter-spacing:.5px;font-size:18px;font-weight:700}.app-actions{align-items:center;gap:12px;display:flex}.total{font-variant-numeric:tabular-nums;font-weight:600}.toggle{background:var(--track);color:var(--text);border:1px solid var(--border);cursor:pointer;border-radius:6px;padding:6px 12px}.toggle:hover{border-color:var(--accent)}.scanning-badge{color:var(--text-muted);font-style:italic}.breadcrumbs{flex-wrap:wrap;align-items:center;gap:2px;min-width:0;display:flex;overflow:hidden}.crumb{white-space:nowrap;align-items:center;gap:2px;display:inline-flex}.crumb-link{color:var(--accent);cursor:pointer;background:0 0;border:none;padding:2px 4px;font-size:14px}.crumb-link:hover{text-decoration:underline}.crumb-current{color:var(--text);padding:2px 4px;font-weight:600}.crumb-sep{color:var(--text-muted)}.content{flex:1;grid-template-columns:minmax(280px,360px) 1fr;gap:16px;min-height:0;padding:16px;display:grid}.chart-panel{background:var(--bg-panel);border:1px solid var(--border);border-radius:10px;justify-content:center;align-items:center;padding:16px;display:flex}.content.treemap-view{grid-template-columns:1fr}.treemap-view .chart-panel{min-height:0;padding:8px}.treemap-view .treemap{aspect-ratio:auto;border-radius:6px;height:100%;overflow:hidden}.treemap-loading{min-height:0;color:var(--text-muted);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:10px;display:flex}.spinner.compact{border-width:3px;width:26px;height:26px}.table-panel{background:var(--bg-panel);border:1px solid var(--border);border-radius:10px;min-height:0;overflow:auto}.donut{aspect-ratio:1;width:100%;max-width:320px;position:relative}.donut-slice{transition:stroke-dasharray .6s,stroke-dashoffset .6s,stroke-width .15s,opacity .15s}.donut-center{pointer-events:none;text-align:center;flex-direction:column;justify-content:center;align-items:center;padding:0 20%;display:flex;position:absolute;inset:0}.donut-center-primary{text-overflow:ellipsis;white-space:nowrap;max-width:100%;font-size:18px;font-weight:700;overflow:hidden}.donut-center-secondary{color:var(--text-muted);margin-top:4px}.treemap{aspect-ratio:3/2;outline:0 solid #0000;width:100%;transition:outline-color .15s;position:relative}.treemap.hovering{outline-offset:-2px;outline:2px solid #fffc}.treemap-tile{transition:opacity .15s}.treemap-shine{pointer-events:none}.treemap-label{color:#fff;text-shadow:0 1px 1px #0000008c;pointer-events:none;flex-direction:column;justify-content:flex-start;width:100%;height:100%;font-size:9px;line-height:1.2;display:flex;overflow:hidden}.treemap-label-name{text-overflow:ellipsis;white-space:nowrap;font-weight:500;overflow:hidden}.treemap-label-size{opacity:.72;margin-top:1px;font-size:8px}.treemap-tooltip{border:1px solid var(--border);pointer-events:none;background:#1b1f27f0;border-radius:6px;flex-direction:column;gap:2px;max-width:calc(100% - 16px);padding:5px 8px;display:flex;position:absolute;bottom:8px;left:8px}.treemap-tooltip span{color:var(--text-muted);font-size:11px}.treemap-tooltip b{font-size:12px;font-weight:600}.file-table{border-collapse:collapse;width:100%}.file-table th,.file-table td{text-align:left;border-bottom:1px solid var(--border);padding:8px 12px}.file-table thead th{background:var(--bg-panel);cursor:pointer;-webkit-user-select:none;user-select:none;color:var(--text-muted);z-index:1;font-weight:600;position:sticky;top:0}.file-table th.num,.file-table td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.file-table tbody tr:hover,.file-table tbody tr.hovered{background:var(--bg-row-hover)}.name-cell{min-width:240px;max-width:0;position:relative}.name-cell .swatch{vertical-align:middle;border-radius:2px;width:10px;height:10px;margin-right:8px;display:inline-block}.name-cell .name{z-index:1;position:relative}.name-cell .bar{opacity:.85;border-radius:2px;height:3px;transition:width .4s;position:absolute;bottom:3px;left:12px}.file-table th.actions-col{cursor:default}.file-table td.actions-cell{white-space:nowrap;text-align:right;cursor:default;padding:4px 8px}.row-action{cursor:pointer;background:0 0;border:1px solid #0000;border-radius:6px;padding:2px 6px;font-size:14px;line-height:1.4}.row-action:hover:not(:disabled),.row-action:focus-visible{background:var(--bg-row-hover);border-color:var(--border)}.row-action:disabled{opacity:.4;cursor:default}.sort-arrow{color:var(--accent)}.muted{color:var(--text-muted)}.flag-error{color:#d0454c;font-weight:700}.empty{text-align:center;color:var(--text-muted);padding:24px}.file-table tfoot td{background:var(--bg-panel);border-top:1px solid var(--border);font-weight:600;position:sticky;bottom:0}.loading,.scanning,.error-screen{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:12px;height:100%;padding:24px;display:flex}.scan-stats{font-variant-numeric:tabular-nums;gap:20px;font-size:16px;display:flex}.scan-current{color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;max-width:80%;overflow:hidden}.spinner{border:4px solid var(--track);border-top-color:var(--accent);border-radius:50%;width:42px;height:42px;animation:.9s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.banner.error{color:#f0a5a5;background:#3a1d1f;border-radius:6px;margin:0 16px;padding:8px 16px}.modal-backdrop{z-index:10;background:#000000a8;justify-content:center;align-items:center;padding:24px;display:flex;position:fixed;inset:0}.modal{border:1px solid var(--border);background:var(--bg-panel);border-radius:10px;flex-direction:column;gap:12px;width:min(480px,100%);padding:22px;display:flex;box-shadow:0 18px 60px #00000073}.modal h2,.modal p{margin:0}.modal-actions{justify-content:flex-end;gap:8px;margin-top:6px;display:flex}.modal button{border:1px solid var(--border);background:var(--track);color:var(--text);cursor:pointer;border-radius:6px;padding:7px 12px}.modal button.danger{border-color:var(--danger);background:var(--danger);color:#fff;font-weight:600}.modal button.danger:hover:not(:disabled){background:var(--danger-hover);border-color:var(--danger-hover)}.modal button.danger:disabled{opacity:.6;cursor:default}.shortcuts{grid-template-columns:max-content 1fr;gap:8px 18px;margin:0;display:grid}.shortcuts dt{color:var(--accent);font-weight:700}.shortcuts dd{margin:0}@media (width<=760px){.content{grid-template-columns:1fr}} diff --git a/webui/dist/assets/index-C_AS_vfL.js b/webui/dist/assets/index-C_AS_vfL.js new file mode 100644 index 000000000..21ba82eca --- /dev/null +++ b/webui/dist/assets/index-C_AS_vfL.js @@ -0,0 +1,9 @@ +var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports);(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var t=e((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function T(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{n.exports=t()})),r=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m){if(n(c)!==null)m=!0,ne(x);else{var t=n(l);t!==null&&A(b,t.startTime-e)}}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&A(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,A(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ne(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),i=e(((e,t)=>{t.exports=r()})),a=e((e=>{var t=n(),r=i();function a(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),d=Object.prototype.hasOwnProperty,f=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return d.call(m,e)?!0:d.call(p,e)?!1:f.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` +`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{se=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?P(e):``}function le(e){switch(e.tag){case 5:return P(e.type);case 16:return P(`Lazy`);case 13:return P(`Suspense`);case 19:return P(`SuspenseList`);case 0:case 2:case 15:return e=ce(e.type,!1),e;case 11:return e=ce(e.type.render,!1),e;case 1:return e=ce(e.type,!0),e;default:return``}}function ue(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case E:return`Fragment`;case T:return`Portal`;case O:return`Profiler`;case D:return`StrictMode`;case ne:return`Suspense`;case A:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case te:return(e.displayName||`Context`)+`.Consumer`;case ee:return(e._context.displayName||`Context`)+`.Provider`;case k:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case j:return t=e.displayName||null,t===null?ue(e.type)||`Memo`:t;case M:t=e._payload,e=e._init;try{return ue(e(t))}catch{}}return null}function de(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return ue(t);case 8:return t===D?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function fe(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function pe(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function me(e){var t=pe(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function he(e){e._valueTracker||=me(e)}function ge(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=pe(e)?e.checked?`true`:`false`:e.value),e=r,e!==n&&(t.setValue(e),!0)}function _e(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function ve(e,t){var n=t.checked;return N({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ye(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=fe(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function be(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function xe(e,t){be(e,t);var n=fe(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?Ce(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&Ce(e,t.type,fe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Se(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function Ce(e,t,n){(t!==`number`||_e(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var we=Array.isArray;function Te(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Me.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Pe(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Fe={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Ie=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Fe).forEach(function(e){Ie.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Fe[t]=Fe[e]})});function Le(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Fe.hasOwnProperty(e)&&Fe[e]?(``+t).trim():t+`px`}function Re(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Le(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var ze=N({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Be(e,t){if(t){if(ze[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(a(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(a(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(a(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(a(62))}}function Ve(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var He=null;function Ue(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var We=null,Ge=null,Ke=null;function qe(e){if(e=Gi(e)){if(typeof We!=`function`)throw Error(a(280));var t=e.stateNode;t&&(t=qi(t),We(e.stateNode,e.type,t))}}function Je(e){Ge?Ke?Ke.push(e):Ke=[e]:Ge=e}function Ye(){if(Ge){var e=Ge,t=Ke;if(Ke=Ge=null,qe(e),t)for(e=0;e>>=0,e===0?32:31-(jt(e)/Mt|0)|0}var Pt=64,Ft=4194304;function It(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Lt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=It(a))):r=It(s)}else o=n&~i,o===0?a!==0&&(r=It(a)):r=It(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Ut(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-At(t),e[t]=n}function Wt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=ar),cr=` `,lr=!1;function ur(e,t){switch(e){case`keyup`:return rr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function dr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var fr=!1;function pr(e,t){switch(e){case`compositionend`:return dr(t);case`keypress`:return t.which===32?(lr=!0,cr):null;case`textInput`:return e=t.data,e===cr&&lr?null:e;default:return null}}function mr(e,t){if(fr)return e===`compositionend`||!ir&&ur(e,t)?(e=On(),Dn=En=Tn=null,fr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Ir(n)}}function Rr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Rr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function zr(){for(var e=window,t=_e();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=_e(e.document)}return t}function Br(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Vr(e){var t=zr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Rr(n.ownerDocument.documentElement,n)){if(r!==null&&Br(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Lr(n,a);var o=Lr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Ur=null,Wr=null,Gr=null,Kr=!1;function qr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Kr||Ur==null||Ur!==_e(r)||(r=Ur,`selectionStart`in r&&Br(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Gr&&Fr(Gr,r)||(Gr=r,r=vi(Wr,`onSelect`),0Yi||(e.current=Ji[Yi],Ji[Yi]=null,Yi--)}function z(e,t){Yi++,Ji[Yi]=e.current,e.current=t}var Zi={},B=Xi(Zi),Qi=Xi(!1),$i=Zi;function ea(e,t){var n=e.type.contextTypes;if(!n)return Zi;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function ta(e){return e=e.childContextTypes,e!=null}function na(){R(Qi),R(B)}function ra(e,t,n){if(B.current!==Zi)throw Error(a(168));z(B,t),z(Qi,n)}function ia(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!=`function`)return n;for(var i in r=r.getChildContext(),r)if(!(i in t))throw Error(a(108,de(e)||`Unknown`,i));return N({},n,r)}function aa(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Zi,$i=B.current,z(B,e),z(Qi,Qi.current),!0}function oa(e,t,n){var r=e.stateNode;if(!r)throw Error(a(169));n?(e=ia(e,t,$i),r.__reactInternalMemoizedMergedChildContext=e,R(Qi),R(B),z(B,e)):R(Qi),z(Qi,n)}var sa=null,ca=!1,la=!1;function ua(e){sa===null?sa=[e]:sa.push(e)}function da(e){ca=!0,ua(e)}function fa(){if(!la&&sa!==null){la=!0;var e=0,t=I;try{var n=sa;for(I=1;e>=o,i-=o,ba=1<<32-At(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),V&&Sa(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),V&&Sa(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return V&&Sa(i,g),u}for(h=r(i,h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),V&&Sa(i,g),u}function _(e,r,a,o){if(typeof a==`object`&&a&&a.type===E&&a.key===null&&(a=a.props.children),typeof a==`object`&&a){switch(a.$$typeof){case w:a:{for(var c=a.key,l=r;l!==null;){if(l.key===c){if(c=a.type,c===E){if(l.tag===7){n(e,l.sibling),r=i(l,a.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===M&&Va(c)===l.type){n(e,l.sibling),r=i(l,a.props),r.ref=za(e,l,a),r.return=e,e=r;break a}n(e,l);break}t(e,l),l=l.sibling}a.type===E?(r=Zl(a.props.children,e.mode,o,a.key),r.return=e,e=r):(o=Xl(a.type,a.key,a.props,null,e.mode,o),o.ref=za(e,r,a),o.return=e,e=o)}return s(e);case T:a:{for(l=a.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===a.containerInfo&&r.stateNode.implementation===a.implementation){n(e,r.sibling),r=i(r,a.children||[]),r.return=e,e=r;break a}n(e,r);break}t(e,r),r=r.sibling}r=eu(a,e.mode,o),r.return=e,e=r}return s(e);case M:return l=a._init,_(e,r,l(a._payload),o)}if(we(a))return h(e,r,a,o);if(ae(a))return g(e,r,a,o);Ba(e,a)}return typeof a==`string`&&a!==``||typeof a==`number`?(a=``+a,r!==null&&r.tag===6?(n(e,r.sibling),r=i(r,a),r.return=e,e=r):(n(e,r),r=$l(a,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Ua=Ha(!0),Wa=Ha(!1),Ga=Xi(null),Ka=null,qa=null,Ja=null;function Ya(){Ja=qa=Ka=null}function Xa(e){var t=Ga.current;R(Ga),e._currentValue=t}function Za(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Qa(e,t){Ka=e,Ja=qa=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Rs=!0),e.firstContext=null)}function $a(e){var t=e._currentValue;if(Ja!==e){if(e={context:e,memoizedValue:t,next:null},qa===null){if(Ka===null)throw Error(a(308));qa=e,Ka.dependencies={lanes:0,firstContext:e}}else qa=qa.next=e}return t}var eo=null;function to(e){eo===null?eo=[e]:eo.push(e)}function no(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,to(t)):(n.next=i.next,i.next=n),t.interleaved=n,ro(e,r)}function ro(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var io=!1;function ao(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function oo(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function so(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function co(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,J&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,ro(e,n)}return i=r.interleaved,i===null?(t.next=t,to(r)):(t.next=i.next,i.next=t),r.interleaved=t,ro(e,n)}function lo(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gt(e,n)}}function uo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function fo(e,t,n,r){var i=e.updateQueue;io=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=N({},d,f);break a;case 2:io=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Yc|=o,e.lanes=o,e.memoizedState=d}}function po(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Do.transition;Do.transition={};try{e(!1),t()}finally{I=n,Do.transition=r}}function ds(){return Ro().memoizedState}function fs(e,t,n){var r=ml(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},ms(e))hs(t,n);else if(n=no(e,t,n,r),n!==null){var i=pl();hl(n,e,r,i),gs(n,t,r)}}function ps(e,t,n){var r=ml(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(ms(e))hs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Pr(s,o)){var c=t.interleaved;c===null?(i.next=i,to(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=no(e,t,i,r),n!==null&&(i=pl(),hl(n,e,r,i),gs(n,t,r))}}function ms(e){var t=e.alternate;return e===U||t!==null&&t===U}function hs(e,t){Ao=ko=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function gs(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gt(e,n)}}var _s={readContext:$a,useCallback:No,useContext:No,useEffect:No,useImperativeHandle:No,useInsertionEffect:No,useLayoutEffect:No,useMemo:No,useReducer:No,useRef:No,useState:No,useDebugValue:No,useDeferredValue:No,useTransition:No,useMutableSource:No,useSyncExternalStore:No,useId:No,unstable_isNewReconciler:!1},vs={readContext:$a,useCallback:function(e,t){return Lo().memoizedState=[e,t===void 0?null:t],e},useContext:$a,useEffect:es,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Qo(4194308,4,is.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Qo(4194308,4,e,t)},useInsertionEffect:function(e,t){return Qo(4,2,e,t)},useMemo:function(e,t){var n=Lo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Lo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=fs.bind(null,U,e),[r.memoizedState,e]},useRef:function(e){var t=Lo();return e={current:e},t.memoizedState=e},useState:Yo,useDebugValue:os,useDeferredValue:function(e){return Lo().memoizedState=e},useTransition:function(){var e=Yo(!1),t=e[0];return e=us.bind(null,e[1]),Lo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=U,i=Lo();if(V){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),Y===null)throw Error(a(349));Oo&30||Wo(r,t,n)}i.memoizedState=n;var o={value:n,getSnapshot:t};return i.queue=o,es(Ko.bind(null,r,o,e),[e]),r.flags|=2048,Xo(9,Go.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=Lo(),t=Y.identifierPrefix;if(V){var n=xa,r=ba;n=(r&~(1<<32-At(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=jo++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof r.is==`string`?e=c.createElement(n,{is:r.is}):(e=c.createElement(n),n===`select`&&(c=e,r.multiple?c.multiple=!0:r.size&&(c.size=r.size))):e=c.createElementNS(e,n),e[Ri]=t,e[zi]=r,cc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Ve(n,r),n){case`dialog`:L(`cancel`,e),L(`close`,e),i=r;break;case`iframe`:case`object`:case`embed`:L(`load`,e),i=r;break;case`video`:case`audio`:for(i=0;itl&&(t.flags|=128,r=!0,dc(o,!1),t.lanes=4194304)}}else{if(!r){if(e=Co(c),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),dc(o,!0),o.tail===null&&o.tailMode===`hidden`&&!c.alternate&&!V)return fc(t),null}else 2*F()-o.renderingStartTime>tl&&n!==1073741824&&(t.flags|=128,r=!0,dc(o,!1),t.lanes=4194304)}o.isBackwards?(c.sibling=t.child,t.child=c):(n=o.last,n===null?t.child=c:n.sibling=c,o.last=c)}return o.tail===null?(fc(t),null):(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=F(),t.sibling=null,n=H.current,z(H,r?n&1|2:n&1),t);case 22:case 23:return Tl(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?Kc&1073741824&&(fc(t),t.subtreeFlags&6&&(t.flags|=8192)):fc(t),null;case 24:return null;case 25:return null}throw Error(a(156,t.tag))}function mc(e,t){switch(Ta(t),t.tag){case 1:return ta(t.type)&&na(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return bo(),R(Qi),R(B),To(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return So(t),null;case 13:if(R(H),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));Ia()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return R(H),null;case 4:return bo(),null;case 10:return Xa(t.type._context),null;case 22:case 23:return Tl(),null;case 24:return null;default:return null}}var hc=!1,gc=!1,_c=typeof WeakSet==`function`?WeakSet:Set,K=null;function vc(e,t){var n=e.ref;if(n!==null){if(typeof n==`function`)try{n(null)}catch(n){$(e,t,n)}else n.current=null}}function yc(e,t,n){try{n()}catch(n){$(e,t,n)}}var bc=!1;function xc(e,t){if(Ei=vn,e=zr(),Br(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||i!==0&&f.nodeType!==3||(c=s+i),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===i&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Di={focusedElem:e,selectionRange:n},vn=!1,K=t;K!==null;)if(t=K,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,K=e;else for(;K!==null;){t=K;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:xs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(a(163))}}catch(e){$(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,K=e;break}K=t.return}return h=bc,bc=!1,h}function Sc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&yc(t,n,a)}i=i.next}while(i!==r)}}function Cc(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function wc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function Tc(e){var t=e.alternate;t!==null&&(e.alternate=null,Tc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ri],delete t[zi],delete t[Vi],delete t[Hi],delete t[Ui])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Ec(e){return e.tag===5||e.tag===3||e.tag===4}function Dc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Ec(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Oc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Ti));else if(r!==4&&(e=e.child,e!==null))for(Oc(e,t,n),e=e.sibling;e!==null;)Oc(e,t,n),e=e.sibling}function kc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(kc(e,t,n),e=e.sibling;e!==null;)kc(e,t,n),e=e.sibling}var q=null,Ac=!1;function jc(e,t,n){for(n=n.child;n!==null;)Mc(e,t,n),n=n.sibling}function Mc(e,t,n){if(Ot&&typeof Ot.onCommitFiberUnmount==`function`)try{Ot.onCommitFiberUnmount(Dt,n)}catch{}switch(n.tag){case 5:gc||vc(n,t);case 6:var r=q,i=Ac;q=null,jc(e,t,n),q=r,Ac=i,q!==null&&(Ac?(e=q,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):q.removeChild(n.stateNode));break;case 18:q!==null&&(Ac?(e=q,n=n.stateNode,e.nodeType===8?Pi(e.parentNode,n):e.nodeType===1&&Pi(e,n),gn(e)):Pi(q,n.stateNode));break;case 4:r=q,i=Ac,q=n.stateNode.containerInfo,Ac=!0,jc(e,t,n),q=r,Ac=i;break;case 0:case 11:case 14:case 15:if(!gc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&yc(n,t,o),i=i.next}while(i!==r)}jc(e,t,n);break;case 1:if(!gc&&(vc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){$(n,t,e)}jc(e,t,n);break;case 21:jc(e,t,n);break;case 22:n.mode&1?(gc=(r=gc)||n.memoizedState!==null,jc(e,t,n),gc=r):jc(e,t,n);break;default:jc(e,t,n)}}function Nc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new _c),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function Pc(e,t){var n=t.deletions;if(n!==null)for(var r=0;ri&&(i=s),r&=~o}if(r=i,r=F()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Hc(r/1960))-r,10e?16:e,sl===null)var r=!1;else{if(e=sl,sl=null,cl=0,J&6)throw Error(a(331));var i=J;for(J|=4,K=e.current;K!==null;){var o=K,s=o.child;if(K.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lF()-el?El(e,0):Zc|=n),gl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=Ft,Ft<<=1,!(Ft&130023424)&&(Ft=4194304)):t=1);var n=pl();e=ro(e,t),e!==null&&(Ut(e,t,n),gl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(a(314))}r!==null&&r.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null){if(e.memoizedProps!==t.pendingProps||Qi.current)Rs=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Rs=!1,sc(e,t,n);Rs=!!(e.flags&131072)}}else Rs=!1,V&&t.flags&1048576&&Ca(t,ga,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;ac(e,t),e=t.pendingProps;var i=ea(t,B.current);Qa(t,n),i=Fo(null,t,r,e,i,n);var o=Io();return t.flags|=1,typeof i==`object`&&i&&typeof i.render==`function`&&i.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ta(r)?(o=!0,aa(t)):o=!1,t.memoizedState=i.state!==null&&i.state!==void 0?i.state:null,ao(t),i.updater=Cs,t.stateNode=i,i._reactInternals=t,Ds(t,r,e,n),t=qs(null,t,r,!0,o,n)):(t.tag=0,V&&o&&wa(t),zs(null,t,i,n),t=t.child),t;case 16:r=t.elementType;a:{switch(ac(e,t),e=t.pendingProps,i=r._init,r=i(r._payload),t.type=r,i=t.tag=Jl(r),e=xs(r,e),i){case 0:t=Gs(null,t,r,e,n);break a;case 1:t=Ks(null,t,r,e,n);break a;case 11:t=Bs(null,t,r,e,n);break a;case 14:t=Vs(null,t,r,xs(r.type,e),n);break a}throw Error(a(306,r,``))}return t;case 0:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:xs(r,i),Gs(e,t,r,i,n);case 1:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:xs(r,i),Ks(e,t,r,i,n);case 3:a:{if(Js(t),e===null)throw Error(a(387));r=t.pendingProps,o=t.memoizedState,i=o.element,oo(e,t),fo(t,r,null,n);var s=t.memoizedState;if(r=s.element,o.isDehydrated){if(o={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){i=Os(Error(a(423)),t),t=Ys(e,t,r,n,i);break a}if(r!==i){i=Os(Error(a(424)),t),t=Ys(e,t,r,n,i);break a}for(Da=Fi(t.stateNode.containerInfo.firstChild),Ea=t,V=!0,Oa=null,n=Wa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(Ia(),r===i){t=oc(e,t,n);break a}zs(e,t,r,n)}t=t.child}return t;case 5:return xo(t),e===null&&Ma(t),r=t.type,i=t.pendingProps,o=e===null?null:e.memoizedProps,s=i.children,Oi(r,i)?s=null:o!==null&&Oi(r,o)&&(t.flags|=32),Ws(e,t),zs(e,t,s,n),t.child;case 6:return e===null&&Ma(t),null;case 13:return Qs(e,t,n);case 4:return yo(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Ua(t,null,r,n):zs(e,t,r,n),t.child;case 11:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:xs(r,i),Bs(e,t,r,i,n);case 7:return zs(e,t,t.pendingProps,n),t.child;case 8:return zs(e,t,t.pendingProps.children,n),t.child;case 12:return zs(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(r=t.type._context,i=t.pendingProps,o=t.memoizedProps,s=i.value,z(Ga,r._currentValue),r._currentValue=s,o!==null){if(Pr(o.value,s)){if(o.children===i.children&&!Qi.current){t=oc(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===r){if(o.tag===1){l=so(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Za(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(a(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Za(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}}zs(e,t,i.children,n),t=t.child}return t;case 9:return i=t.type,r=t.pendingProps.children,Qa(t,n),i=$a(i),r=r(i),t.flags|=1,zs(e,t,r,n),t.child;case 14:return r=t.type,i=xs(r,t.pendingProps),i=xs(r.type,i),Vs(e,t,r,i,n);case 15:return Hs(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:xs(r,i),ac(e,t),t.tag=1,ta(r)?(e=!0,aa(t)):e=!1,Qa(t,n),Ts(t,r,i),Ds(t,r,i,n),qs(null,t,r,!0,e,n);case 19:return ic(e,t,n);case 22:return Us(e,t,n)}throw Error(a(156,t.tag))};function Wl(e,t){return _t(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===k)return 11;if(e===j)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,r,i,o){var s=2;if(r=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case E:return Zl(n.children,i,o,t);case D:s=8,i|=8;break;case O:return e=Kl(12,n,t,i|2),e.elementType=O,e.lanes=o,e;case ne:return e=Kl(13,n,t,i),e.elementType=ne,e.lanes=o,e;case A:return e=Kl(19,n,t,i),e.elementType=A,e.lanes=o,e;case re:return Ql(n,i,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case ee:s=10;break a;case te:s=9;break a;case k:s=11;break a;case j:s=14;break a;case M:s=16,r=null;break a}throw Error(a(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,i),t.elementType=e,t.type=r,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=re,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ht(0),this.expirationTimes=Ht(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ht(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ao(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=a()})),s=e((e=>{var t=o();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),c=n(),l=s();async function u(e){let t=`request failed: ${e.status}`;try{let n=await e.json();n.error&&(t=n.error)}catch{}return Object.assign(Error(t),{status:e.status})}async function d(e){let t=await fetch(e);if(!t.ok)throw await u(t);return await t.json()}function f(){return d(`api/v1/status`)}function p(e,t,n){return d(`api/v1/nodes?${new URLSearchParams({path:e,sort:t,order:n}).toString()}`)}function m(e,t=!1){let n=new URLSearchParams({path:e});return t&&n.set(`pivot`,`category`),d(`api/v1/tree?${n.toString()}`)}function h(e){return d(`api/v1/categories?${new URLSearchParams({path:e}).toString()}`)}async function g(e,t,n){let r=await fetch(e,{...n,headers:{"X-GDU-Action":t,...n.headers}});if(!r.ok)throw await u(r)}function _(e,t){return g(`api/v1/reveal`,t,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({path:e})})}function v(e,t,n){let r=new URLSearchParams({path:e});return n===`trash`&&r.set(`mode`,`trash`),g(`api/v1/nodes?${r.toString()}`,t,{method:`DELETE`})}function y(e){let t=new EventSource(`api/v1/events`);return t.onmessage=t=>{try{e(JSON.parse(t.data))}catch{}},()=>t.close()}var b=[`#2479d0`,`#e67100`,`#3fb27f`,`#d0454c`,`#9b59b6`,`#f1c40f`,`#1abc9c`,`#e84393`,`#5f8fd0`,`#c0862d`,`#7f8c8d`,`#16a085`],x=`#4a5568`;function S(e){return b[e%b.length]}function C(e,t){return Math.max(t?e.size:e.usage,0)}function w(e,t){let n=new Map;return[...e].sort((e,n)=>C(n,t)-C(e,t)).forEach((e,t)=>{n.set(e.path,t<11?S(t):x)}),n}function T(e,t){let n=e.map(e=>({node:e,value:C(e,t)})).filter(e=>e.value>0).sort((e,t)=>t.value-e.value),r=n.reduce((e,t)=>e+t.value,0),i=n.slice(0,11),a=n.slice(11),o=i.map((e,t)=>({key:e.node.path,label:e.node.name,value:e.value,color:S(t),node:e.node}));return a.length>0&&o.push({key:`__other__`,label:`Other (${a.length})`,value:a.reduce((e,t)=>e+t.value,0),color:x}),{slices:o,total:r}}function E(e){let t=(0,c.useRef)(e);return(0,c.useEffect)(()=>{t.current=e},[e]),t}var D=e((e=>{var t=n(),r=Symbol.for(`react.element`),i=Symbol.for(`react.fragment`),a=Object.prototype.hasOwnProperty,o=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,s={key:!0,ref:!0,__self:!0,__source:!0};function c(e,t,n){var i,c={},l=null,u=null;for(i in n!==void 0&&(l=``+n),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)a.call(t,i)&&!s.hasOwnProperty(i)&&(c[i]=t[i]);if(e&&e.defaultProps)for(i in t=e.defaultProps,t)c[i]===void 0&&(c[i]=t[i]);return{$$typeof:r,type:e,key:l,ref:u,props:c,_owner:o.current}}e.Fragment=i,e.jsx=c,e.jsxs=c})),O=e(((e,t)=>{t.exports=D()}))(),ee=`gdu.actionToken`,te=`gdu.skipDeleteConfirm`;function k(e){try{return window.sessionStorage.getItem(e)}catch{return null}}function ne(e,t){try{t===null?window.sessionStorage.removeItem(e):window.sessionStorage.setItem(e,t)}catch{}}function A(e,t){if(e.size===0)return null;let n=e.get(t);if(n)return n.state;for(let[n,{state:r}]of e){let e=t[n.length];if(t.startsWith(n)&&(e===`/`||e===`\\`))return r}return null}function j(e,t){let n=0;for(let[r,{state:i,node:a}]of e){if(i!==`deleted`)continue;let o=r.slice(0,Math.max(r.lastIndexOf(`/`),r.lastIndexOf(`\\`)));o&&A(e,o)===`deleted`||(n+=t?a.size:a.usage)}return n}var M=(0,c.createContext)(null);function re(){let e=(0,c.useContext)(M);if(!e)throw Error(`useGduModel must be used within GduModelProvider`);return e}function ie({model:e,children:t}){return(0,O.jsx)(M.Provider,{value:e,children:t})}function ae(){let[e]=(0,c.useState)(()=>k(ee)??``),[t,n]=(0,c.useState)(null),[r,i]=(0,c.useState)(null),[a,o]=(0,c.useState)(null),[s,l]=(0,c.useState)(`size`),[u,d]=(0,c.useState)(`desc`),[m,h]=(0,c.useState)(null),[g,_]=(0,c.useState)(`donut`),[v,b]=(0,c.useState)(`location`),[x,S]=(0,c.useState)(null),[C,D]=(0,c.useState)(null),[O,A]=(0,c.useState)(!1),[j,M]=(0,c.useState)(null),[re,ie]=(0,c.useState)(null),[ae,N]=(0,c.useState)(()=>new Map),[oe,P]=(0,c.useState)(0),se=(0,c.useRef)(0),ce=(0,c.useRef)(!1),[le,ue]=(0,c.useState)(0),de=(0,c.useRef)([]),[fe,pe]=(0,c.useState)(()=>{let e=k(te);return e===`trash`||e===`permanent`?e:null}),me=E(r);(0,c.useEffect)(()=>(f().then(n).catch(()=>void 0),y(n)),[]),(0,c.useEffect)(()=>{t&&m===null&&h(t.showApparentSize)},[t,m]),(0,c.useEffect)(()=>{t?.state===`done`&&r===null&&t.rootPath&&i(t.rootPath)},[t,r]),(0,c.useEffect)(()=>{if(r===null)return;let e=!1;return p(r,s,u).then(t=>{e||(o(t),D(null))}).catch(t=>{e||D(t instanceof Error?t.message:String(t))}),()=>{e=!0}},[r,s,u,t?.state,le]),(0,c.useEffect)(()=>{let e=e=>{let t=e.target;t&&[`INPUT`,`TEXTAREA`,`SELECT`].includes(t.tagName)||e.key===`?`&&(e.preventDefault(),A(!0))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[]);let he=m??t?.showApparentSize??!1,ge=t?.useSIPrefix??!1,_e=a?.children??[],{total:ve}=(0,c.useMemo)(()=>T(_e,he),[_e,he]),ye=(0,c.useMemo)(()=>w(_e,he),[_e,he]),be=(0,c.useCallback)(e=>{e===s?d(e=>e===`asc`?`desc`:`asc`):(l(e),d(e===`name`?`asc`:`desc`))},[s]),xe=(0,c.useCallback)(e=>{se.current>0||(ce.current&&(ce.current=!1,N(new Map),ie(null),M(null),ue(e=>e+1)),i(e),S(null))},[]),Se=(0,c.useCallback)(e=>{se.current+=1,ce.current=!0,P(se.current),N(t=>new Map(t).set(e.path,{state:`pending`,node:e}))},[]),Ce=(0,c.useCallback)((e,t)=>{if(se.current=Math.max(se.current-1,0),P(se.current),N(n=>{let r=new Map(n),i=r.get(e);return t===null?i&&r.set(e,{...i,state:`deleted`}):r.delete(e),r}),t!==null&&de.current.push(t),se.current>0)return null;let n=de.current;return de.current=[],n},[]),we=(0,c.useCallback)(e=>{e.isDir&&xe(e.path)},[xe]);return{status:t,actionToken:e,currentPath:r,nodeResp:a,children:_e,effectiveApparent:he,useSIPrefix:ge,total:ve,colorMap:ye,sort:s,order:u,onSortChange:be,view:g,toggleView:(0,c.useCallback)(()=>{_(e=>e===`donut`?`treemap`:`donut`),S(null)},[]),projection:v,setProjection:b,toggleApparent:(0,c.useCallback)(()=>{h(e=>!(e??t?.showApparentSize??!1))},[t?.showApparentSize]),hoveredPath:x,setHoveredPath:S,loadError:C,setLoadError:D,handleSelect:we,navigateToPath:xe,refreshNode:(0,c.useCallback)(async()=>{if(r===null)throw Error(`no current path`);let e=r,t=await p(e,s,u);return me.current===e&&(o(t),D(null)),t},[r,s,u]),showHelp:O,setShowHelp:A,treeRoot:j,treePath:re,setTree:(0,c.useCallback)((e,t)=>{ie(e),M(t)},[]),skipDeleteConfirm:fe,setSkipDeleteConfirm:(0,c.useCallback)(e=>{pe(e),ne(te,e)},[]),deletions:ae,deletionsInFlight:oe,beginDeletion:Se,settleDeletion:Ce}}var N=[``,`Ki`,`Mi`,`Gi`,`Ti`,`Pi`,`Ei`],oe=[``,`k`,`M`,`G`,`T`,`P`,`E`];function P(e,t=!1){let n=t?1e3:1024,r=t?oe:N,i=Math.abs(e),a=0;for(;i>=n&&a0),c=`${r[a]}B`;return`${o}${i.toFixed(s)} ${c}`}function se(e){return e.toLocaleString(`en-US`)}function ce(e){return e?new Date(e*1e3).toLocaleString(void 0,{year:`numeric`,month:`short`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`}):``}function le(e,t){if(t<=0)return 0;let n=e/t*100;return n<0?0:n>100?100:n}function ue({breadcrumbs:e,onNavigate:t}){return(0,O.jsx)(`nav`,{className:`breadcrumbs`,"aria-label":`Path`,children:e.map((n,r)=>{let i=r===e.length-1;return(0,O.jsxs)(`span`,{className:`crumb`,children:[i?(0,O.jsx)(`span`,{className:`crumb-current`,children:n.name||n.path}):(0,O.jsx)(`button`,{type:`button`,className:`crumb-link`,onClick:()=>t(n),children:n.name||n.path}),!i&&(0,O.jsx)(`span`,{className:`crumb-sep`,children:`/`})]},n.path)})})}var de=[{id:`location`,label:`Location`},{id:`cleanup`,label:`Cleanup`},{id:`age`,label:`Age`},{id:`categories`,label:`By category`}],fe=`category:`;function pe(e){return e.path.startsWith(fe)}var me=`#353b47`,he=`#f08c00`,ge=`#c9a25e`,_e={"build-artifact":b[1],"package-cache":b[5],"app-cache":b[6],toolchain:b[9],"ml-model":b[4],installer:b[0],"git-ignored":b[10]};function ve(e,t){return t?e.size:e.usage}function ye(e,t){let n=0;for(let r of Object.values(e.categoryTotals??{}))r&&(n+=t?r.size:r.usage);return n}function be(e,t){let n=ve(e,t);return n<=0?0:Math.min(ye(e,t)/n,1)}function xe(e,t,n){let r=(e,t)=>parseInt(e.slice(1+t*2,3+t*2),16);return`#${[0,1,2].map(i=>Math.round(r(e,i)+(r(t,i)-r(e,i))*n).toString(16).padStart(2,`0`)).join(``)}`}function Se(e,t){if(e.confidence===`safe`)return he;if(e.confidence===`review`)return ge;let n=be(e,t);return n>0?xe(me,he,.25+n*.5):me}var Ce=86400,we=[{maxDays:30,label:`< 1 month`,color:me},{maxDays:182,label:`< 6 months`,color:`#5b5440`},{maxDays:365,label:`< 1 year`,color:`#9a7330`},{maxDays:1/0,label:`older`,color:he}];function Te(e,t){if(e<=0)return me;let n=(t-e)/Ce;return(we.find(e=>n({category:e,bytes:n?t?n.size:n.usage:0})).filter(e=>e.bytes>0).sort((e,t)=>t.bytes-e.bytes);if(a.length===0)return null;let o=a.slice(0,2).map(e=>`${P(e.bytes,n)} ${(r.get(e.category)??e.category).toLowerCase()}`);return a.length>2&&o.push(`+${a.length-2} more`),`${o.join(`, `)} · ${Ee(e.mtime,i)}`}function Oe(e,t){let n=e.categoryTotals?.[t];return e.category===t||n!==void 0&&(n.usage>0||n.size>0)}function ke(){let{status:e,nodeResp:t,total:n,useSIPrefix:r,view:i,toggleView:a,projection:o,setProjection:s,effectiveApparent:c,toggleApparent:l,handleSelect:u,deletionsInFlight:d,deletions:f}=re(),p=j(f,c);return(0,O.jsxs)(`header`,{className:`app-header`,children:[(0,O.jsxs)(`div`,{className:`app-title`,children:[(0,O.jsx)(`span`,{className:`logo`,children:`gdu`}),t&&(0,O.jsx)(ue,{breadcrumbs:t.breadcrumbs,onNavigate:u})]}),(0,O.jsxs)(`div`,{className:`app-actions`,children:[(0,O.jsx)(`span`,{className:`total`,children:P(n,r)}),(0,O.jsx)(`button`,{type:`button`,className:`toggle`,onClick:a,title:`Toggle between donut and treemap charts`,children:i===`donut`?`Treemap`:`Donut`}),i===`treemap`&&(0,O.jsx)(`select`,{className:`toggle`,"aria-label":`Treemap projection`,title:`Color the treemap by location, cleanup candidates or age (P)`,value:o,onChange:e=>s(e.target.value),children:de.map(e=>(0,O.jsx)(`option`,{value:e.id,children:e.label},e.id))}),(0,O.jsx)(`button`,{type:`button`,className:`toggle`,onClick:l,title:`Toggle between disk usage and apparent size`,children:c?`Apparent size`:`Disk usage`}),e.state===`scanning`&&(0,O.jsx)(`span`,{className:`scanning-badge`,children:`scanning…`}),d>0&&(0,O.jsxs)(`span`,{className:`scanning-badge`,role:`status`,title:`Opening other directories is paused until the deletions finish`,children:[`deleting `,d,`…`]}),p>0&&(0,O.jsxs)(`span`,{className:`freed-badge`,title:`Freed by deletions in this directory; sizes update when you open another directory`,children:[`freed `,P(p,r)]})]})]})}var Ae=240,je=Ae/2,Me=90,Ne=34,Pe=2*Math.PI*Me;function Fe({children:e,apparent:t,useSIPrefix:n,hoveredPath:r,onHover:i,onSelect:a}){let{slices:o,total:s}=(0,c.useMemo)(()=>T(e,t),[e,t]),l=o.find(e=>e.node?.path===r)??null,u=l?l.label:P(s,n),d=l?`${P(l.value,n)} · ${le(l.value,s).toFixed(1)}%`:`${o.length} item${o.length===1?``:`s`}`,f=0;return(0,O.jsxs)(`div`,{className:`donut`,role:`img`,"aria-label":`Directory size breakdown`,children:[(0,O.jsx)(`svg`,{viewBox:`0 0 ${Ae} ${Ae}`,width:`100%`,height:`100%`,children:(0,O.jsxs)(`g`,{transform:`rotate(-90 ${je} ${je})`,children:[(0,O.jsx)(`circle`,{cx:je,cy:je,r:Me,fill:`none`,stroke:`var(--track)`,strokeWidth:Ne}),s>0&&o.map(e=>{let t=e.value/s,n=t*Pe,o=-f*Pe;f+=t;let c=e.node?e.node.path:`__other__`,l=r!==null&&e.node?.path===r,u=r!==null&&!l,d=e.node?.isDir??!1;return(0,O.jsx)(`circle`,{className:`donut-slice`,cx:je,cy:je,r:Me,fill:`none`,stroke:e.color,strokeWidth:l?40:Ne,strokeDasharray:`${n} ${Pe-n}`,strokeDashoffset:o,opacity:u?.35:1,style:{cursor:d?`pointer`:`default`},onMouseEnter:()=>e.node&&i(e.node.path),onMouseLeave:()=>i(null),onClick:()=>{e.node&&e.node.isDir&&a(e.node)}},c)})]})}),(0,O.jsxs)(`div`,{className:`donut-center`,children:[(0,O.jsx)(`div`,{className:`donut-center-primary`,title:u,children:u}),(0,O.jsx)(`div`,{className:`donut-center-secondary`,children:d})]})]})}function Ie(){let{children:e,effectiveApparent:t,useSIPrefix:n,hoveredPath:r,setHoveredPath:i,handleSelect:a}=re();return(0,O.jsx)(`section`,{className:`chart-panel`,children:(0,O.jsx)(Fe,{children:e,apparent:t,useSIPrefix:n,hoveredPath:r,onHover:i,onSelect:a})})}var Le=`Actions are unavailable: this page has no action token. Reopen the URL gdu printed, or restart gdu.`;function Re(){let{actionToken:e,status:t,skipDeleteConfirm:n,setLoadError:r,deletions:i,beginDeletion:a,settleDeletion:o}=re(),[s,l]=(0,c.useState)(null),[u,d]=(0,c.useState)(!1),[f,p]=(0,c.useState)(!1),m=(0,c.useCallback)(async(t,n)=>{if(l(null),!e){r(Le);return}if(A(i,t.path))return;a(t);let s=null;try{await v(t.path,e,n)}catch(e){s=`Could not delete ${t.name}: ${e instanceof Error?e.message:String(e)}`}let c=o(t.path,s);c&&c.length>0?r(c.join(` +`)):s&&r(s)},[e,i,a,o,r]),h=(0,c.useCallback)(async t=>{if(!f){if(!e){r(Le);return}p(!0);try{await _(t.path,e),r(null)}catch(e){r(e instanceof Error?e.message:String(e))}finally{p(!1)}}},[f,r,e]);return{actionPending:f,deleteCandidate:s,skipDeleteChoice:u,setSkipDeleteChoice:d,requestDelete:(0,c.useCallback)(e=>{if(!(!t.deleteAllowed||A(i,e.path))){if(n){m(e,n);return}d(!1),l(e)}},[i,m,n,t.deleteAllowed]),cancelDelete:(0,c.useCallback)(()=>l(null),[]),performDelete:m,reveal:h}}var ze=[{key:`name`,label:`Name`,numeric:!1},{key:`size`,label:`Size`,numeric:!0},{key:`itemCount`,label:`Items`,numeric:!0},{key:`mtime`,label:`Modified`,numeric:!0}];function Be({children:e,colorMap:t,apparent:n,useSIPrefix:r,total:i,sort:a,order:o,onSortChange:s,hoveredPath:l,onHover:u,onSelect:d,onReveal:f,onDelete:p,canDelete:m,actionPending:h,deletions:g}){let _=(0,c.useMemo)(()=>e.reduce((e,t)=>Math.max(e,C(t,n)),0),[e,n]);return(0,O.jsxs)(`table`,{className:`file-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[ze.map(e=>(0,O.jsxs)(`th`,{className:e.numeric?`num`:``,onClick:()=>s(e.key),children:[e.label,a===e.key&&(0,O.jsx)(`span`,{className:`sort-arrow`,children:o===`asc`?` ▲`:` ▼`})]},e.key)),(0,O.jsx)(`th`,{className:`actions-col`,"aria-label":`Actions`})]})}),(0,O.jsxs)(`tbody`,{children:[e.map(e=>{let i=C(e,n),a=_>0?i/_*100:0,o=t.get(e.path)??`var(--other)`,s=e.path===l,c=g.get(e.path)?.state,v=c!==void 0;return(0,O.jsxs)(`tr`,{className:`${s?`hovered`:``} ${c??``}`,"aria-busy":v||void 0,onMouseEnter:()=>u(e.path),onMouseLeave:()=>u(null),onClick:()=>e.isDir&&d(e),style:{cursor:e.isDir?`pointer`:`default`},children:[(0,O.jsxs)(`td`,{className:`name-cell`,children:[(0,O.jsx)(`span`,{className:`swatch`,style:{backgroundColor:o}}),(0,O.jsxs)(`span`,{className:`name`,children:[e.isDir?`📁`:`📄`,` `,e.name,e.flag===`!`&&(0,O.jsx)(`span`,{className:`flag-error`,title:`Access error`,children:` !`}),c&&(0,O.jsxs)(`span`,{className:`muted`,children:[` · `,c===`pending`?`deleting…`:`deleted`]})]}),(0,O.jsx)(`span`,{className:`bar`,style:{width:`${a}%`,backgroundColor:o}})]}),(0,O.jsx)(`td`,{className:`num`,children:P(i,r)}),(0,O.jsx)(`td`,{className:`num`,children:se(e.itemCount)}),(0,O.jsx)(`td`,{className:`num muted`,children:ce(e.mtime)}),(0,O.jsxs)(`td`,{className:`actions-cell`,onClick:e=>e.stopPropagation(),children:[(0,O.jsx)(`button`,{type:`button`,className:`row-action`,"aria-label":`Reveal ${e.name}`,title:`Reveal in file manager`,disabled:h,onClick:()=>f(e),children:`📂`}),m&&(0,O.jsx)(`button`,{type:`button`,className:`row-action`,"aria-label":`Delete ${e.name}`,title:`Delete`,disabled:v,onClick:()=>p(e),children:`🗑`})]})]},e.path)}),e.length===0&&(0,O.jsx)(`tr`,{children:(0,O.jsx)(`td`,{colSpan:5,className:`empty`,children:`Empty directory`})})]}),(0,O.jsx)(`tfoot`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsxs)(`td`,{className:`muted`,children:[e.length,` items`]}),(0,O.jsx)(`td`,{className:`num`,children:P(i,r)}),(0,O.jsx)(`td`,{className:`num muted`,colSpan:3,children:le(i,i)>0?`100%`:``})]})})]})}function Ve({titleId:e,title:t,onClose:n,className:r,children:i}){let a=(0,c.useRef)(null);return(0,c.useEffect)(()=>{let e=e=>{if(e.key===`Escape`){n();return}if(e.key!==`ArrowLeft`&&e.key!==`ArrowRight`)return;let t=Array.from(a.current?.querySelectorAll(`button:not(:disabled)`)??[]),r=t.indexOf(document.activeElement);r!==-1&&(e.preventDefault(),t[(r+(e.key===`ArrowRight`?1:-1)+t.length)%t.length]?.focus())};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[n]),(0,O.jsx)(`div`,{className:`modal-backdrop`,children:(0,O.jsxs)(`section`,{ref:a,className:`modal ${r??``}`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":e,children:[(0,O.jsx)(`h2`,{id:e,children:t}),i]})})}function He({actions:e}){let{effectiveApparent:t,useSIPrefix:n,setSkipDeleteConfirm:r}=re(),{deleteCandidate:i,skipDeleteChoice:a,setSkipDeleteChoice:o,actionPending:s,cancelDelete:c,performDelete:l}=e;return i?(0,O.jsxs)(Ve,{titleId:`delete-title`,title:`Delete item`,onClose:c,children:[(0,O.jsx)(`p`,{children:`Move the selected item to the trash, or delete it permanently right away.`}),(0,O.jsx)(`code`,{className:`modal-path`,children:i.path}),(0,O.jsx)(`span`,{className:`muted`,children:P(t?i.size:i.usage,n)}),(0,O.jsxs)(`label`,{className:`modal-checkbox`,children:[(0,O.jsx)(`input`,{type:`checkbox`,checked:a,onChange:e=>o(e.target.checked)}),`Do not ask again this session`]}),(0,O.jsxs)(`div`,{className:`modal-actions`,children:[(0,O.jsx)(`button`,{type:`button`,autoFocus:!0,onClick:c,children:`Cancel`}),(0,O.jsx)(`button`,{type:`button`,disabled:s,onClick:()=>{a&&r(`trash`),l(i,`trash`)},children:`Move to Trash`}),(0,O.jsx)(`button`,{type:`button`,className:`danger`,disabled:s,onClick:()=>{a&&r(`permanent`),l(i,`permanent`)},children:`Delete Permanently`})]})]}):null}function Ue(){let{status:e,children:t,colorMap:n,effectiveApparent:r,useSIPrefix:i,total:a,sort:o,order:s,onSortChange:c,hoveredPath:l,setHoveredPath:u,handleSelect:d,deletions:f}=re(),p=Re();return(0,O.jsxs)(`section`,{className:`table-panel`,children:[(0,O.jsx)(Be,{children:t,colorMap:n,apparent:r,useSIPrefix:i,total:a,sort:o,order:s,onSortChange:c,hoveredPath:l,onHover:u,onSelect:d,onReveal:e=>void p.reveal(e),onDelete:p.requestDelete,canDelete:e.deleteAllowed,actionPending:p.actionPending,deletions:f}),(0,O.jsx)(He,{actions:p})]})}function We(e){var t=0,n=e.children,r=n&&n.length;if(!r)t=1;else for(;--r>=0;)t+=n[r].value;e.value=t}function Ge(){return this.eachAfter(We)}function Ke(e,t){let n=-1;for(let r of this)e.call(t,r,++n,this);return this}function qe(e,t){for(var n=this,r=[n],i,a,o=-1;n=r.pop();)if(e.call(t,n,++o,this),i=n.children)for(a=i.length-1;a>=0;--a)r.push(i[a]);return this}function Je(e,t){for(var n=this,r=[n],i=[],a,o,s,c=-1;n=r.pop();)if(i.push(n),a=n.children)for(o=0,s=a.length;o=0;)n+=r[i].value;t.value=n})}function Ze(e){return this.eachBefore(function(t){t.children&&t.children.sort(e)})}function Qe(e){for(var t=this,n=$e(t,e),r=[t];t!==n;)t=t.parent,r.push(t);for(var i=r.length;e!==n;)r.splice(i,0,e),e=e.parent;return r}function $e(e,t){if(e===t)return e;var n=e.ancestors(),r=t.ancestors(),i=null;for(e=n.pop(),t=r.pop();e===t;)i=e,e=n.pop(),t=r.pop();return i}function et(){for(var e=this,t=[e];e=e.parent;)t.push(e);return t}function tt(){return Array.from(this)}function nt(){var e=[];return this.eachBefore(function(t){t.children||e.push(t)}),e}function rt(){var e=this,t=[];return e.each(function(n){n!==e&&t.push({source:n.parent,target:n})}),t}function*it(){var e=this,t,n=[e],r,i,a;do for(t=n.reverse(),n=[];e=t.pop();)if(yield e,r=e.children)for(i=0,a=r.length;i=0;--s)i.push(a=o[s]=new dt(o[s])),a.parent=r,a.depth=r.depth+1;return n.eachBefore(ut)}function ot(){return at(this).eachBefore(lt)}function st(e){return e.children}function ct(e){return Array.isArray(e)?e[1]:null}function lt(e){e.data.value!==void 0&&(e.value=e.data.value),e.data=e.data.data}function ut(e){var t=0;do e.height=t;while((e=e.parent)&&e.height<++t)}function dt(e){this.data=e,this.depth=this.height=0,this.parent=null}dt.prototype=at.prototype={constructor:dt,count:Ge,each:Ke,eachAfter:Je,eachBefore:qe,find:Ye,sum:Xe,sort:Ze,path:Qe,ancestors:et,descendants:tt,leaves:nt,links:rt,copy:ot,[Symbol.iterator]:it};function ft(e){if(typeof e!=`function`)throw Error();return e}function pt(){return 0}function mt(e){return function(){return e}}function ht(e){e.x0=Math.round(e.x0),e.y0=Math.round(e.y0),e.x1=Math.round(e.x1),e.y1=Math.round(e.y1)}function gt(e,t,n,r,i){for(var a=e.children,o,s=-1,c=a.length,l=e.value&&(r-t)/e.value;++sv&&(v=l),S=g*g*x,y=Math.max(v/S,S/_),y>b){g-=l;break}b=y}o.push(c={value:g,dice:p1?t:1)},n})(vt);function F(){var e=bt,t=!1,n=1,r=1,i=[0],a=pt,o=pt,s=pt,c=pt,l=pt;function u(e){return e.x0=e.y0=0,e.x1=n,e.y1=r,e.eachBefore(d),i=[0],t&&e.eachBefore(ht),e}function d(t){var n=i[t.depth],r=t.x0+n,u=t.y0+n,d=t.x1-n,f=t.y1-n;de+t.value,0)||r<=0||i<=0)return[];let a=at({children:e.map(({value:e,item:t})=>({value:e,item:t}))}).sum(e=>e.value??0);return F().size([r,i]).padding(0)(a).leaves().map(e=>({item:e.data.item,x:t+e.x0,y:n+e.y0,w:e.x1-e.x0,h:e.y1-e.y0}))}function St(e,t){let n=[],r=(e,i,a,o,s)=>{if(n.length>=t.maxRects)return;let c=Math.min(t.inset,i.w/2,i.h/2),l={x:i.x+c,y:i.y+c,w:Math.max(i.w-c*2,0),h:Math.max(i.h-c*2,0)};if(l.w<=0||l.h<=0)return;let u=e.children.map(e=>({child:e,value:Math.max(C(e,t.apparent),1)})).sort((e,t)=>t.value-e.value),d=u.reduce((e,t)=>e+t.value,0),f=C(e,t.apparent),p=Math.max(Math.max(f,d)-d,0),m=u.map(({child:e,value:t})=>({value:t,item:e}));p>0&&m.push({value:p,item:null});let h=xt(m,l.x,l.y,l.w,l.h);for(let e of h){if(n.length>=t.maxRects)break;let i=e.item;if(i===null||e.w*e.h0&&r(i,e,a+1,[...o,i.path],c)}};return r(e,{x:0,y:0,w:t.width,h:t.height},0,[e.path],null),n}var Ct=`#ffe066`,wt=300,Tt=200,Et=24,Dt=2e3,Ot=2;function kt({root:e,apparent:t,useSIPrefix:n,colorMap:r,hoveredPath:i,selectedPath:a,onHover:o,onSelect:s,onOpen:l,colorFor:u,focus:d,focusColor:f,tooltipDetail:p,deletions:m,dimOnHover:h=!0}){let g=(0,c.useRef)(null),[_,v]=(0,c.useState)({width:wt,height:Tt});(0,c.useEffect)(()=>{let e=g.current;if(!e||typeof ResizeObserver>`u`)return;let t=0,n=new ResizeObserver(([e])=>{cancelAnimationFrame(t),t=requestAnimationFrame(()=>{let{width:t,height:n}=e.contentRect;t>0&&n>0&&v({width:t,height:n})})});return n.observe(e),()=>{cancelAnimationFrame(t),n.disconnect()}},[]);let y=(0,c.useMemo)(()=>St(e,{width:_.width,height:_.height,apparent:t,minArea:Et,maxRects:Dt,inset:Ot}),[t,e,_]),b=C(e,t),{hoveredNode:S,outlinedPaths:w}=(0,c.useMemo)(()=>{let t=y.find(e=>e.node.path===i)??null,n=i===e.path?e:t?.node??null;return{hoveredNode:n,outlinedPaths:new Set(t?[...t.ancestorPaths,t.node.path]:n?[e.path]:[])}},[y,i,e]),T=S?.path===e.path,E=(0,c.useMemo)(()=>d?new Map(y.map(e=>[e.node.path,d(e.node)])):null,[d,y]);return(0,O.jsxs)(`div`,{ref:g,className:`treemap ${S===null?``:`hovering`}`,role:`img`,"aria-label":`Directory size treemap`,children:[(0,O.jsxs)(`svg`,{viewBox:`0 0 ${_.width} ${_.height}`,preserveAspectRatio:`none`,width:`100%`,height:`100%`,children:[(0,O.jsxs)(`defs`,{children:[(0,O.jsxs)(`pattern`,{id:`treemap-pending`,width:`6`,height:`6`,patternUnits:`userSpaceOnUse`,patternTransform:`rotate(45)`,children:[(0,O.jsx)(`rect`,{width:`6`,height:`6`,fill:`#14171c`}),(0,O.jsx)(`rect`,{width:`2`,height:`6`,fill:`#3a4150`})]}),(0,O.jsxs)(`pattern`,{id:`treemap-deleted`,width:`6`,height:`6`,patternUnits:`userSpaceOnUse`,patternTransform:`rotate(45)`,children:[(0,O.jsx)(`rect`,{width:`6`,height:`6`,fill:`#14171c`}),(0,O.jsx)(`rect`,{width:`1.5`,height:`6`,fill:`#4a5262`}),(0,O.jsx)(`rect`,{width:`6`,height:`1.5`,fill:`#4a5262`})]}),(0,O.jsxs)(`linearGradient`,{id:`treemap-shine`,x1:`0`,y1:`0`,x2:`1`,y2:`1`,children:[(0,O.jsx)(`stop`,{offset:`0`,stopColor:`#fff`,stopOpacity:`0.28`}),(0,O.jsx)(`stop`,{offset:`0.48`,stopColor:`#fff`,stopOpacity:`0.04`}),(0,O.jsx)(`stop`,{offset:`1`,stopColor:`#000`,stopOpacity:`0.3`})]})]}),(0,O.jsx)(`rect`,{className:`treemap-root ${T?`hovered`:``}`,width:_.width,height:_.height,fill:x,stroke:T?`#fff`:`transparent`,strokeWidth:T?1.5:0,onMouseEnter:()=>o(e.path),onMouseLeave:()=>o(null)}),y.map(e=>{let{node:c}=e,d=c.path===i,p=!d&&w.has(c.path),g=c.path===a,_=m?A(m,c.path):null,v=E?.get(c.path)??null,y=e.ancestorPaths[e.ancestorPaths.length-1],b=v===`match`&&E?.get(y)!==`match`,x=h&&S!==null&&!w.has(c.path),T=v===`none`?.14:x?.42:1,D=!c.isDir&&e.w>34&&e.h>16,ee=D&&e.w>60&&e.h>30,te=Math.max(e.w-.5,0),k=Math.max(e.h-.5,0);return(0,O.jsxs)(`g`,{className:`treemap-tile ${d?`hovered`:``} ${p?`ancestor`:``} ${g?`selected`:``} ${_??``}`,"aria-busy":_===`pending`||void 0,"data-deletion":_??void 0,"data-focus":v??void 0,role:`button`,"aria-label":`${c.name}, ${P(C(c,t),n)}`,tabIndex:0,style:{cursor:`pointer`},opacity:T,onMouseEnter:()=>o(c.path),onMouseLeave:()=>o(null),onClick:e=>{e.stopPropagation(),s(c)},onDoubleClick:t=>{t.stopPropagation();let n=c.isDir?c.path:e.ancestorPaths[e.ancestorPaths.length-1];n&&l(n)},children:[(0,O.jsx)(`rect`,{x:e.x,y:e.y,width:te,height:k,fill:_?`url(#treemap-${_})`:v===`match`&&f?f:u?u(e):r.get(e.colorPath)??`#4a5568`,stroke:d||p?`#fff`:g?`#55b6ff`:b?Ct:`rgba(0,0,0,0.35)`,strokeWidth:d?1.5:b?2:p||g?1.1:.4}),!_&&(0,O.jsx)(`rect`,{className:`treemap-shine`,x:e.x,y:e.y,width:te,height:k,fill:`url(#treemap-shine)`}),D&&(0,O.jsx)(`foreignObject`,{x:e.x+1.5,y:e.y+1,width:Math.max(e.w-3,0),height:Math.max(e.h-2,0),children:(0,O.jsxs)(`div`,{className:`treemap-label`,children:[(0,O.jsx)(`span`,{className:`treemap-label-name`,children:c.name}),ee&&(0,O.jsx)(`span`,{className:`treemap-label-size`,children:P(C(c,t),n)})]})})]},c.path)})]}),S&&(0,O.jsxs)(`div`,{className:`treemap-tooltip`,children:[(0,O.jsx)(`b`,{children:S.name}),(0,O.jsx)(`span`,{children:S.path}),(0,O.jsxs)(`span`,{children:[P(C(S,t),n),` ·`,` `,le(C(S,t),b).toFixed(1),`%`,S.isDir?` · ${se(S.itemCount)} item${S.itemCount===1?``:`s`}`:``]}),m&&A(m,S.path)&&(0,O.jsx)(`span`,{className:`treemap-tooltip-detail`,children:A(m,S.path)===`pending`?`Deleting…`:`Deleted; the view updates when you open another directory`}),p?.(S)&&(0,O.jsx)(`span`,{className:`treemap-tooltip-detail`,children:p(S)})]})]})}function At({color:e}){return(0,O.jsx)(`span`,{className:`legend-swatch`,style:{background:e}})}function jt({projection:e,categories:t,apparent:n,useSIPrefix:r,filter:i,onFilter:a}){if(e===`location`)return null;if(e===`age`)return(0,O.jsxs)(`div`,{className:`legend`,"aria-label":`Age legend`,children:[(0,O.jsx)(`span`,{className:`legend-title`,children:`Last change`}),we.map(e=>(0,O.jsxs)(`span`,{className:`legend-key`,children:[(0,O.jsx)(At,{color:e.color}),e.label]},e.label))]});if(!t)return(0,O.jsx)(`div`,{className:`legend legend-loading`,children:`Classifying…`});let o=n?t.total.size:t.total.usage,s=t.categories.map(e=>({...e,bytes:n?e.size:e.usage})).filter(e=>e.bytes>0),c=s.reduce((e,t)=>e+t.bytes,0);return(0,O.jsxs)(`div`,{className:`legend`,"aria-label":`Cleanup legend`,children:[s.length===0?(0,O.jsx)(`span`,{className:`legend-title`,children:`No cleanup candidates here`}):(0,O.jsxs)(`span`,{className:`legend-title`,children:[`Candidates: `,P(c,r),` (`,le(c,o).toFixed(0),`%)`]}),e===`cleanup`&&s.length>0&&(0,O.jsxs)(O.Fragment,{children:[(0,O.jsxs)(`span`,{className:`legend-key`,children:[(0,O.jsx)(At,{color:`#f08c00`}),`safe`]}),(0,O.jsxs)(`span`,{className:`legend-key`,children:[(0,O.jsx)(At,{color:`#c9a25e`}),`review first`]})]}),s.map(e=>{let t=i===e.id,n=_e[e.id];return(0,O.jsxs)(`button`,{type:`button`,className:`legend-chip ${t?`active`:``}`,"aria-pressed":t,title:t?`Show all categories (Esc)`:`Highlight ${e.label.toLowerCase()}`,style:t?{borderColor:n,boxShadow:`inset 0 0 0 1px ${n}`}:void 0,onClick:()=>a(t?null:e.id),children:[(0,O.jsx)(At,{color:n}),e.label,` · `,P(e.bytes,r),` · `,le(e.bytes,o).toFixed(0),`%`]},e.id)}),i&&(0,O.jsx)(`button`,{type:`button`,className:`legend-clear`,onClick:()=>a(null),children:`Show all`})]})}function Mt(){let{currentPath:e,effectiveApparent:t,useSIPrefix:n,colorMap:r,hoveredPath:i,setHoveredPath:a,navigateToPath:o,status:s,view:l,treeRoot:u,treePath:d,setTree:f,projection:p,setProjection:g,deletions:_}=re(),[v,y]=(0,c.useState)(!1),[b,x]=(0,c.useState)(null),[S,C]=(0,c.useState)(null),[w,T]=(0,c.useState)(null),[E,D]=(0,c.useState)(null),ee=p===`categories`,te=p===`cleanup`||ee,k=ee?fe+e:e;(0,c.useEffect)(()=>{C(null),D(null)},[e]),(0,c.useEffect)(()=>{if(l!==`treemap`||!te||e===null)return;let t=!1;return T(null),h(e).then(e=>{t||T(e)}).catch(()=>void 0),()=>{t=!0}},[e,te,l]),(0,c.useEffect)(()=>{if(l!==`treemap`||e===null||d===k)return;let t=!1;return y(!0),x(null),m(e,ee).then(e=>{t||f(k,e)}).catch(e=>{t||x(e instanceof Error?e.message:String(e))}).finally(()=>{t||y(!1)}),()=>{t=!0}},[e,d,k,ee,l,f]);let ne=Re();(0,c.useEffect)(()=>{S&&A(_,S.path)&&C(null)},[_,S]);let{deleteCandidate:j,reveal:M,requestDelete:ie}=ne,ae=(0,c.useCallback)(async()=>{S&&await M(S)},[M,S]),N=(0,c.useCallback)(()=>{S&&ie(S)},[ie,S]);(0,c.useEffect)(()=>{let e=e=>{let t=e.target;if(!(t&&[`INPUT`,`TEXTAREA`,`SELECT`].includes(t.tagName))&&!j){if(e.key===`Escape`&&E)e.preventDefault(),D(null);else if(e.code===`KeyP`&&!e.metaKey&&!e.ctrlKey){e.preventDefault();let t=de.findIndex(e=>e.id===p);g(de[(t+1)%de.length].id)}else e.code===`KeyO`&&S?(e.preventDefault(),ae()):(e.code===`KeyD`||e.key===`Delete`)&&S&&s.deleteAllowed&&(e.preventDefault(),N())}};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[j,N,ae,S,s.deleteAllowed,p,g,E]);let oe=(0,c.useMemo)(()=>new Map((w?.categories??[]).map(e=>[e.id,e.label])),[w]),P=(0,c.useMemo)(()=>Date.now()/1e3,[u]),se=(0,c.useMemo)(()=>{switch(p){case`cleanup`:return e=>Se(e.node,t);case`age`:return e=>Te(e.node.mtime,P);case`categories`:return e=>_e[e.colorPath.slice(fe.length)]??`#353b47`;default:return}},[p,t,P]),ce=(0,c.useMemo)(()=>{if(!(!te||!E))return e=>pe(e)?e.category===E?`contains`:`none`:e.category===E?`match`:Oe(e,E)?`contains`:`none`},[te,E]),le=(0,c.useMemo)(()=>{if(p!==`location`)return p===`age`?e=>Ee(e.mtime,P):e=>pe(e)?null:[De(e,t,n,oe,P),e.hint].filter(Boolean).join(` — `)||null},[p,t,n,oe,P]),ue=(0,c.useCallback)(e=>{!pe(e)&&!A(_,e.path)&&C(e)},[_]),me=(0,c.useCallback)(e=>{e.startsWith(`category:`)||o(e)},[o]);return(0,O.jsxs)(O.Fragment,{children:[(0,O.jsxs)(`section`,{className:`chart-panel ${p===`location`?``:`with-legend`}`,children:[(0,O.jsx)(jt,{projection:p,categories:w,apparent:t,useSIPrefix:n,filter:E,onFilter:D}),u&&d===k?(0,O.jsx)(kt,{root:u,apparent:t,useSIPrefix:n,colorMap:r,hoveredPath:i,selectedPath:S?.path??null,onHover:a,onSelect:ue,onOpen:me,colorFor:se,focus:ce,focusColor:E?_e[E]:void 0,tooltipDetail:le,deletions:_,dimOnHover:p===`location`}):(0,O.jsxs)(`div`,{className:`treemap-loading`,children:[v&&(0,O.jsx)(`span`,{className:`spinner compact`}),(0,O.jsx)(`span`,{children:b?`Treemap unavailable`:`Loading treemap…`})]})]}),(0,O.jsx)(He,{actions:ne})]})}function Nt(){let{showHelp:e,setShowHelp:t,view:n}=re();return e?(0,O.jsxs)(Ve,{titleId:`shortcuts-title`,title:`Keyboard shortcuts`,className:`shortcuts-modal`,onClose:()=>t(!1),children:[(0,O.jsxs)(`dl`,{className:`shortcuts`,children:[n===`treemap`&&(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(`dt`,{children:`O`}),(0,O.jsx)(`dd`,{children:`Reveal selected item`}),(0,O.jsx)(`dt`,{children:`D / Delete`}),(0,O.jsx)(`dd`,{children:`Delete selected item`}),(0,O.jsx)(`dt`,{children:`Click`}),(0,O.jsx)(`dd`,{children:`Select a treemap item`}),(0,O.jsx)(`dt`,{children:`Double-click`}),(0,O.jsx)(`dd`,{children:`Open a directory`}),(0,O.jsx)(`dt`,{children:`P`}),(0,O.jsx)(`dd`,{children:`Switch projection: location, cleanup candidates (safe / review first), age of the last change, or candidates grouped by category`})]}),n===`donut`&&(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(`dt`,{children:`Row buttons`}),(0,O.jsx)(`dd`,{children:`Reveal or delete an item in the table`})]}),(0,O.jsx)(`dt`,{children:`?`}),(0,O.jsx)(`dd`,{children:`Show this help`}),(0,O.jsx)(`dt`,{children:`Esc`}),(0,O.jsx)(`dd`,{children:`Close a dialog, or show all categories again`})]}),(0,O.jsx)(`div`,{className:`modal-actions`,children:(0,O.jsx)(`button`,{type:`button`,autoFocus:!0,onClick:()=>t(!1),children:`Close`})})]}):null}function Pt({progress:e,useSIPrefix:t}){return(0,O.jsxs)(`div`,{className:`scanning`,children:[(0,O.jsx)(`div`,{className:`spinner`}),(0,O.jsx)(`h2`,{children:`Scanning…`}),(0,O.jsxs)(`div`,{className:`scan-stats`,children:[(0,O.jsxs)(`span`,{children:[se(e.itemCount),` items`]}),(0,O.jsx)(`span`,{children:P(e.totalUsage,t)})]}),(0,O.jsx)(`div`,{className:`scan-current`,title:e.currentItem,children:e.currentItem})]})}function Ft(){let e=ae(),{status:t,currentPath:n,nodeResp:r,useSIPrefix:i,loadError:a,view:o}=e;return t?t.state===`error`?(0,O.jsxs)(`div`,{className:`error-screen`,children:[(0,O.jsx)(`h2`,{children:`Scan failed`}),(0,O.jsx)(`p`,{children:t.error??`Unknown error`})]}):n===null||t.state===`scanning`&&!r?(0,O.jsx)(Pt,{progress:t.progress,useSIPrefix:i}):(0,O.jsx)(ie,{model:{...e,status:t,currentPath:n},children:(0,O.jsxs)(`div`,{className:`app`,children:[(0,O.jsx)(ke,{}),a&&(0,O.jsx)(`div`,{className:`banner error`,children:a}),(0,O.jsx)(`main`,{className:`content ${o===`treemap`?`treemap-view`:``}`,children:o===`donut`?(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(Ie,{}),(0,O.jsx)(Ue,{})]}):(0,O.jsx)(Mt,{})}),(0,O.jsx)(Nt,{})]})}):(0,O.jsx)(`div`,{className:`loading`,children:`Connecting…`})}var It=document.getElementById(`root`);if(!It)throw Error(`root element not found`);(0,l.createRoot)(It).render((0,O.jsx)(c.StrictMode,{children:(0,O.jsx)(Ft,{})})); \ No newline at end of file diff --git a/webui/dist/assets/index-ca1UlPXD.css b/webui/dist/assets/index-ca1UlPXD.css new file mode 100644 index 000000000..fcee65098 --- /dev/null +++ b/webui/dist/assets/index-ca1UlPXD.css @@ -0,0 +1 @@ +:root{--bg:#14171c;--bg-panel:#1b1f27;--bg-header:#2479d0;--bg-row-hover:#232a35;--text:#e6e9ef;--text-muted:#8b94a3;--border:#2a303b;--track:#232833;--other:#4a5568;--accent:#2479d0;--danger:#e63946;--danger-hover:#ff5262;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px}.app{flex-direction:column;height:100%;display:flex}.app-header{background:var(--bg-panel);border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;gap:16px;padding:10px 16px;display:flex}.app-title{align-items:center;gap:14px;min-width:0;display:flex}.logo{color:var(--accent);letter-spacing:.5px;font-size:18px;font-weight:700}.app-actions{align-items:center;gap:12px;display:flex}.total{font-variant-numeric:tabular-nums;font-weight:600}.toggle{background:var(--track);color:var(--text);border:1px solid var(--border);cursor:pointer;border-radius:6px;padding:6px 12px}.toggle:hover{border-color:var(--accent)}.freed-badge{color:#7bd88f;font-weight:600}.scanning-badge{color:var(--text-muted);font-style:italic}.breadcrumbs{flex-wrap:wrap;align-items:center;gap:2px;min-width:0;display:flex;overflow:hidden}.crumb{white-space:nowrap;align-items:center;gap:2px;display:inline-flex}.crumb-link{color:var(--accent);cursor:pointer;background:0 0;border:none;padding:2px 4px;font-size:14px}.crumb-link:hover{text-decoration:underline}.crumb-current{color:var(--text);padding:2px 4px;font-weight:600}.crumb-sep{color:var(--text-muted)}.content{flex:1;grid-template-columns:minmax(280px,360px) 1fr;gap:16px;min-height:0;padding:16px;display:grid}.chart-panel{background:var(--bg-panel);border:1px solid var(--border);border-radius:10px;justify-content:center;align-items:center;padding:16px;display:flex}.content.treemap-view{grid-template-columns:1fr}.treemap-view .chart-panel{min-height:0;padding:8px}.treemap-view .treemap{aspect-ratio:auto;border-radius:6px;height:100%;overflow:hidden}.treemap-loading{min-height:0;color:var(--text-muted);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:10px;display:flex}.spinner.compact{border-width:3px;width:26px;height:26px}.table-panel{background:var(--bg-panel);border:1px solid var(--border);border-radius:10px;min-height:0;overflow:auto}.donut{aspect-ratio:1;width:100%;max-width:320px;position:relative}.donut-slice{transition:stroke-dasharray .6s,stroke-dashoffset .6s,stroke-width .15s,opacity .15s}.donut-center{pointer-events:none;text-align:center;flex-direction:column;justify-content:center;align-items:center;padding:0 20%;display:flex;position:absolute;inset:0}.donut-center-primary{text-overflow:ellipsis;white-space:nowrap;max-width:100%;font-size:18px;font-weight:700;overflow:hidden}.donut-center-secondary{color:var(--text-muted);margin-top:4px}.treemap{aspect-ratio:3/2;outline:0 solid #0000;width:100%;transition:outline-color .15s;position:relative}.treemap.hovering{outline-offset:-2px;outline:2px solid #fffc}.treemap-tile{transition:opacity .15s}.treemap-shine{pointer-events:none}.treemap-label{color:#fff;text-shadow:0 1px 1px #0000008c;pointer-events:none;flex-direction:column;justify-content:flex-start;width:100%;height:100%;font-size:9px;line-height:1.2;display:flex;overflow:hidden}.treemap-label-name{text-overflow:ellipsis;white-space:nowrap;font-weight:500;overflow:hidden}.treemap-label-size{opacity:.72;margin-top:1px;font-size:8px}.treemap-tooltip{background:var(--bg-panel);border:1px solid var(--border);pointer-events:none;border-radius:6px;flex-direction:column;gap:2px;max-width:calc(100% - 16px);padding:5px 8px;display:flex;position:absolute;bottom:8px;left:8px;box-shadow:0 2px 12px #0000008c}.treemap-tooltip span{color:var(--text-muted);font-size:11px}.treemap-tooltip .treemap-tooltip-detail{color:var(--text)}.file-table tr.pending td,.file-table tr.deleted td{opacity:.45}.file-table tr.deleted .name{text-decoration:line-through}.treemap-tile.pending{cursor:progress!important}.treemap-tile.deleted{cursor:default!important}.chart-panel.with-legend{flex-direction:column;align-items:stretch;gap:8px}.chart-panel.with-legend .treemap,.chart-panel.with-legend .treemap-loading{flex:1;min-height:0}.legend{color:var(--text-muted);flex-wrap:wrap;align-items:center;gap:6px 12px;font-size:12px;display:flex}.legend-title{color:var(--text);font-weight:600}.legend-key{align-items:center;gap:5px;display:inline-flex}.legend-swatch{border-radius:2px;flex:none;width:10px;height:10px}.legend-chip{background:var(--track);color:var(--text);border:1px solid var(--border);font:inherit;cursor:pointer;border-radius:999px;align-items:center;gap:5px;padding:3px 10px;display:inline-flex}.legend-chip:hover,.legend-chip.active{border-color:var(--accent)}.legend-chip.active{background:var(--bg-row-hover);font-weight:600}.legend-clear{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:none;padding:3px 4px}.treemap-tooltip b{font-size:12px;font-weight:600}.file-table{border-collapse:collapse;width:100%}.file-table th,.file-table td{text-align:left;border-bottom:1px solid var(--border);padding:8px 12px}.file-table thead th{background:var(--bg-panel);cursor:pointer;-webkit-user-select:none;user-select:none;color:var(--text-muted);z-index:1;font-weight:600;position:sticky;top:0}.file-table th.num,.file-table td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.file-table tbody tr:hover,.file-table tbody tr.hovered{background:var(--bg-row-hover)}.name-cell{min-width:240px;max-width:0;position:relative}.name-cell .swatch{vertical-align:middle;border-radius:2px;width:10px;height:10px;margin-right:8px;display:inline-block}.name-cell .name{z-index:1;position:relative}.name-cell .bar{opacity:.85;border-radius:2px;height:3px;transition:width .4s;position:absolute;bottom:3px;left:12px}.file-table th.actions-col{cursor:default}.file-table td.actions-cell{white-space:nowrap;text-align:right;cursor:default;padding:4px 8px}.row-action{cursor:pointer;background:0 0;border:1px solid #0000;border-radius:6px;padding:2px 6px;font-size:14px;line-height:1.4}.row-action:hover:not(:disabled),.row-action:focus-visible{background:var(--bg-row-hover);border-color:var(--border)}.row-action:disabled{opacity:.4;cursor:default}.sort-arrow{color:var(--accent)}.muted{color:var(--text-muted)}.flag-error{color:#d0454c;font-weight:700}.empty{text-align:center;color:var(--text-muted);padding:24px}.file-table tfoot td{background:var(--bg-panel);border-top:1px solid var(--border);font-weight:600;position:sticky;bottom:0}.loading,.scanning,.error-screen{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:12px;height:100%;padding:24px;display:flex}.scan-stats{font-variant-numeric:tabular-nums;gap:20px;font-size:16px;display:flex}.scan-current{color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;max-width:80%;overflow:hidden}.spinner{border:4px solid var(--track);border-top-color:var(--accent);border-radius:50%;width:42px;height:42px;animation:.9s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.banner.error{color:#f0a5a5;white-space:pre-line;background:#3a1d1f;border-radius:6px;margin:0 16px;padding:8px 16px}.modal-backdrop{z-index:10;background:#000000a8;justify-content:center;align-items:center;padding:24px;display:flex;position:fixed;inset:0}.modal{border:1px solid var(--border);background:var(--bg-panel);border-radius:10px;flex-direction:column;gap:12px;width:min(480px,100%);padding:22px;display:flex;box-shadow:0 18px 60px #00000073}.modal h2,.modal p{margin:0}.modal-actions{justify-content:flex-end;gap:8px;margin-top:6px;display:flex}.modal button{border:1px solid var(--border);background:var(--track);color:var(--text);cursor:pointer;border-radius:6px;padding:7px 12px}.modal button.danger{border-color:var(--danger);background:var(--danger);color:#fff;font-weight:600}.modal button.danger:hover:not(:disabled){background:var(--danger-hover);border-color:var(--danger-hover)}.modal button.danger:disabled{opacity:.6;cursor:default}.shortcuts{grid-template-columns:max-content 1fr;gap:8px 18px;margin:0;display:grid}.shortcuts dt{color:var(--accent);font-weight:700}.shortcuts dd{margin:0}@media (width<=760px){.content{grid-template-columns:1fr}} diff --git a/webui/dist/index.html b/webui/dist/index.html index da62ed446..372085ce8 100644 --- a/webui/dist/index.html +++ b/webui/dist/index.html @@ -23,8 +23,8 @@ history.replaceState(history.state, '', url); })(); - - + +
diff --git a/webui/frontend/src/App.test.tsx b/webui/frontend/src/App.test.tsx index af35e6386..717b74835 100644 --- a/webui/frontend/src/App.test.tsx +++ b/webui/frontend/src/App.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; import * as api from './api'; import type { NodeResponse, Status, TreeNode } from './types'; +import { CATEGORY_COLORS } from './cleanup'; vi.mock('./api'); @@ -99,7 +100,7 @@ describe('chart view', () => { fireEvent.click(toggle); await waitFor(() => expect(screen.getByRole('button', { name: 'Donut' })).toBeTruthy()); - await waitFor(() => expect(api.fetchTree).toHaveBeenCalledWith(root)); + await waitFor(() => expect(api.fetchTree).toHaveBeenCalledWith(root, false)); expect(screen.getByRole('img', { name: 'Directory size treemap' })).toBeTruthy(); expect(screen.getByRole('button', { name: /nested\.bin/ })).toBeTruthy(); expect(screen.queryByRole('table')).toBeNull(); @@ -167,12 +168,25 @@ describe('treemap actions', () => { await waitFor(() => expect(api.deleteNode).toHaveBeenCalledWith(`${root}/file.txt`, 'test-token', 'permanent'), ); - await waitFor(() => expect(api.fetchTree).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('data-deletion')).toBe( + 'deleted', + ), + ); - fireEvent.click(await screen.findByRole('button', { name: /file\.txt/ })); + // a deleted item can no longer be selected or deleted again + fireEvent.click(screen.getByRole('button', { name: /file\.txt/ })); + fireEvent.keyDown(window, { code: 'KeyD' }); + expect(api.deleteNode).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: /nested\.bin/ })); fireEvent.keyDown(window, { code: 'KeyD' }); await waitFor(() => expect(api.deleteNode).toHaveBeenCalledTimes(2)); - expect(api.deleteNode).toHaveBeenLastCalledWith(`${root}/file.txt`, 'test-token', 'permanent'); + expect(api.deleteNode).toHaveBeenLastCalledWith( + `${root}/folder/nested.bin`, + 'test-token', + 'permanent', + ); expect(screen.queryByRole('dialog', { name: 'Delete item' })).toBeNull(); firstRender.unmount(); @@ -301,7 +315,7 @@ describe('treemap actions', () => { await waitFor(() => expect(api.fetchNode).toHaveBeenLastCalledWith(`${root}/folder`, 'size', 'desc'), ); - await waitFor(() => expect(api.fetchTree).toHaveBeenLastCalledWith(`${root}/folder`)); + await waitFor(() => expect(api.fetchTree).toHaveBeenLastCalledWith(`${root}/folder`, false)); fireEvent.keyDown(window, { code: 'KeyD' }); expect(screen.queryByRole('dialog', { name: 'Delete item' })).toBeNull(); expect(api.deleteNode).not.toHaveBeenCalled(); @@ -316,7 +330,7 @@ describe('treemap actions', () => { await waitFor(() => expect(api.fetchNode).toHaveBeenLastCalledWith(`${root}/folder`, 'size', 'desc'), ); - await waitFor(() => expect(api.fetchTree).toHaveBeenLastCalledWith(`${root}/folder`)); + await waitFor(() => expect(api.fetchTree).toHaveBeenLastCalledWith(`${root}/folder`, false)); }); it('does not offer deletion when the backend disables it', async () => { @@ -334,15 +348,8 @@ describe('treemap actions', () => { expect(api.deleteNode).not.toHaveBeenCalled(); }); - it('invalidates the stale treemap instead of leaving it displayed when the post-delete refresh fails', async () => { + it('keeps the layout after a deletion and reloads on navigation', async () => { vi.mocked(api.deleteNode).mockResolvedValue(undefined); - // First call (initial load) succeeds; every call after that (the - // post-delete refresh, and the automatic retry it unblocks by - // invalidating the cache) fails, so the failure is not masked by an - // immediate successful retry. - vi.mocked(api.fetchTree) - .mockResolvedValueOnce(treeResponse) - .mockRejectedValue(new Error('tree refresh failed')); render(); fireEvent.click(await screen.findByRole('button', { name: 'Treemap' })); @@ -352,66 +359,20 @@ describe('treemap actions', () => { fireEvent.click(screen.getByRole('button', { name: 'Delete Permanently' })); await waitFor(() => - expect(api.deleteNode).toHaveBeenCalledWith(`${root}/file.txt`, 'test-token', 'permanent'), - ); - await screen.findByText('Treemap unavailable'); - expect(screen.queryByRole('img', { name: 'Directory size treemap' })).toBeNull(); - }); - - it('navigates up to the parent directory when the last item in it is deleted', async () => { - const folderPath = `${root}/folder`; - const onlyChild = { - name: 'onlyfile.bin', - path: `${folderPath}/onlyfile.bin`, - isDir: false, - size: 5, - usage: 5, - itemCount: 1, - mtime: 0, - }; - const folderNodeResponse: NodeResponse = { - node: nodeResponse.children[0], - breadcrumbs: [nodeResponse.node, nodeResponse.children[0]], - children: [onlyChild], - }; - const emptyFolderNodeResponse: NodeResponse = { ...folderNodeResponse, children: [] }; - const folderTreeResponse: TreeNode = { - ...folderNodeResponse.node, - children: [{ ...onlyChild, children: [] }], - }; - - let folderEmptied = false; - vi.mocked(api.fetchNode).mockImplementation((path) => - Promise.resolve( - path === folderPath - ? folderEmptied - ? emptyFolderNodeResponse - : folderNodeResponse - : nodeResponse, + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('data-deletion')).toBe( + 'deleted', ), ); - vi.mocked(api.fetchTree).mockImplementation((path) => - Promise.resolve(path === folderPath ? folderTreeResponse : treeResponse), - ); - vi.mocked(api.deleteNode).mockImplementation(async () => { - folderEmptied = true; - }); - - render(); - fireEvent.click(await screen.findByRole('button', { name: 'Treemap' })); - fireEvent.doubleClick(await screen.findByRole('button', { name: /folder/ })); - await waitFor(() => expect(api.fetchNode).toHaveBeenLastCalledWith(folderPath, 'size', 'desc')); - - fireEvent.click(await screen.findByRole('button', { name: /onlyfile\.bin/ })); - fireEvent.keyDown(window, { code: 'KeyD' }); - fireEvent.click(await screen.findByRole('button', { name: 'Delete Permanently' })); + expect(api.fetchTree).toHaveBeenCalledTimes(1); + expect(api.fetchNode).toHaveBeenCalledTimes(1); - await waitFor(() => - expect(api.fetchNode).toHaveBeenLastCalledWith(root, 'size', 'desc'), - ); + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + await waitFor(() => expect(api.fetchTree).toHaveBeenLastCalledWith(`${root}/folder`, false)); + // the marks belong to the directory they were made in + expect(screen.queryByRole('button', { name: /file\.txt/ })?.getAttribute('data-deletion') ?? null).toBeNull(); }); - it('refreshes the current directory when a selected item disappeared', async () => { + it('puts the item back when it already disappeared', async () => { vi.mocked(api.deleteNode).mockRejectedValue( Object.assign(new Error('node not found'), { status: 404 }), ); @@ -422,8 +383,9 @@ describe('treemap actions', () => { fireEvent.keyDown(window, { code: 'KeyD' }); fireEvent.click(screen.getByRole('button', { name: 'Delete Permanently' })); - await waitFor(() => expect(api.fetchNode).toHaveBeenCalledTimes(2)); - expect(await screen.findByText('node not found')).toBeTruthy(); + expect(await screen.findByText('Could not delete file.txt: node not found')).toBeTruthy(); + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('data-deletion')).toBeNull(); + expect(api.fetchNode).toHaveBeenCalledTimes(1); }); }); @@ -467,7 +429,7 @@ describe('table actions', () => { await waitFor(() => expect(api.deleteNode).toHaveBeenCalledWith(`${root}/file.txt`, 'test-token', 'trash'), ); - await waitFor(() => expect(api.fetchNode).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('· deleted', { exact: false })).toBeTruthy(); expect(screen.queryByRole('dialog', { name: 'Delete item' })).toBeNull(); }); @@ -503,14 +465,14 @@ describe('table actions', () => { fireEvent.click(screen.getByRole('button', { name: 'Move to Trash' })); await waitFor(() => expect(api.deleteNode).toHaveBeenCalledTimes(1)); - fireEvent.click(await screen.findByRole('button', { name: 'Delete file.txt' })); + fireEvent.click(await screen.findByRole('button', { name: 'Delete folder' })); expect(screen.queryByRole('dialog', { name: 'Delete item' })).toBeNull(); await waitFor(() => expect(api.deleteNode).toHaveBeenCalledTimes(2)); - expect(api.deleteNode).toHaveBeenLastCalledWith(`${root}/file.txt`, 'test-token', 'trash'); + expect(api.deleteNode).toHaveBeenLastCalledWith(`${root}/folder`, 'test-token', 'trash'); }); - it('invalidates the cached treemap after a delete in the table', async () => { + it('shows a deletion made in the table on the cached treemap', async () => { vi.mocked(api.deleteNode).mockResolvedValue(undefined); render(); @@ -524,7 +486,12 @@ describe('table actions', () => { await waitFor(() => expect(api.deleteNode).toHaveBeenCalledTimes(1)); fireEvent.click(await screen.findByRole('button', { name: 'Treemap' })); - await waitFor(() => expect(api.fetchTree).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('data-deletion')).toBe( + 'deleted', + ), + ); + expect(api.fetchTree).toHaveBeenCalledTimes(1); }); it('shows the error when the delete fails', async () => { @@ -534,7 +501,7 @@ describe('table actions', () => { fireEvent.click(await screen.findByRole('button', { name: 'Delete file.txt' })); fireEvent.click(screen.getByRole('button', { name: 'Move to Trash' })); - expect(await screen.findByText('deletion is disabled')).toBeTruthy(); + expect(await screen.findByText('Could not delete file.txt: deletion is disabled')).toBeTruthy(); }); it('hides delete but keeps reveal when the backend disables deletion', async () => { @@ -561,3 +528,248 @@ describe('table actions', () => { expect(api.revealNode).not.toHaveBeenCalled(); }); }); + +describe('projections', () => { + const categoriesResponse = { + total: { usage: 100, size: 100 }, + categories: [{ id: 'ml-model' as const, label: 'ML models', usage: 50, size: 50 }], + }; + const pivotResponse: TreeNode = { + ...nodeResponse.node, + children: [ + { + name: 'ML models', + path: 'category:ml-model', + isDir: true, + size: 50, + usage: 50, + itemCount: 1, + mtime: 0, + category: 'ml-model', + children: [ + { + name: 'nested.bin', + path: `${root}/folder/nested.bin`, + isDir: false, + size: 50, + usage: 50, + itemCount: 1, + mtime: 0, + category: 'ml-model', + confidence: 'review', + children: [], + }, + ], + }, + ], + }; + + beforeEach(() => { + vi.mocked(api.fetchCategories).mockResolvedValue(categoriesResponse); + }); + + async function openTreemap() { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Treemap' })); + await screen.findByRole('img', { name: 'Directory size treemap' }); + } + + it('loads category totals for the current directory in the cleanup projection', async () => { + await openTreemap(); + expect(api.fetchCategories).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByRole('combobox', { name: 'Treemap projection' }), { + target: { value: 'cleanup' }, + }); + await waitFor(() => expect(api.fetchCategories).toHaveBeenCalledWith(root)); + expect(await screen.findByRole('button', { name: /ML models · 50 B · 50%/ })).toBeTruthy(); + + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + await waitFor(() => expect(api.fetchCategories).toHaveBeenLastCalledWith(`${root}/folder`)); + }); + + it('loads the pivoted tree and keeps category nodes out of actions', async () => { + vi.mocked(api.fetchTree).mockImplementation((_path, pivot) => + Promise.resolve(pivot ? pivotResponse : treeResponse), + ); + await openTreemap(); + + fireEvent.change(screen.getByRole('combobox', { name: 'Treemap projection' }), { + target: { value: 'categories' }, + }); + await waitFor(() => expect(api.fetchTree).toHaveBeenLastCalledWith(root, true)); + const group = await screen.findByRole('button', { name: /^ML models, / }); + + fireEvent.click(group); + fireEvent.keyDown(window, { code: 'KeyD' }); + expect(screen.queryByRole('dialog', { name: 'Delete item' })).toBeNull(); + + fireEvent.doubleClick(group); + expect(api.fetchNode).toHaveBeenCalledTimes(1); + + // a real candidate inside the group stays actionable + fireEvent.click(screen.getByRole('button', { name: /nested\.bin/ })); + fireEvent.keyDown(window, { code: 'KeyD' }); + expect(await screen.findByRole('dialog', { name: 'Delete item' })).toBeTruthy(); + }); + + it('fades the rest of the map on hover only in the location projection', async () => { + await openTreemap(); + const file = screen.getByRole('button', { name: /file\.txt/ }); + const folder = screen.getByRole('button', { name: /folder/ }); + + fireEvent.mouseEnter(file); + expect(folder.getAttribute('opacity')).toBe('0.42'); + fireEvent.mouseLeave(file); + + fireEvent.change(screen.getByRole('combobox', { name: 'Treemap projection' }), { + target: { value: 'cleanup' }, + }); + fireEvent.mouseEnter(file); + expect(folder.getAttribute('opacity')).toBe('1'); + }); + + it('singles out the filtered category on the map', async () => { + const tagged: TreeNode = { + ...treeResponse, + children: [ + { + ...treeResponse.children[0], + categoryTotals: { 'ml-model': { usage: 50, size: 50 } }, + children: [ + { + ...treeResponse.children[0].children[0], + category: 'ml-model', + confidence: 'review', + }, + ], + }, + treeResponse.children[1], + ], + }; + vi.mocked(api.fetchTree).mockResolvedValue(tagged); + await openTreemap(); + fireEvent.change(screen.getByRole('combobox', { name: 'Treemap projection' }), { + target: { value: 'cleanup' }, + }); + fireEvent.click(await screen.findByRole('button', { name: /ML models · 50 B/ })); + + const nested = screen.getByRole('button', { name: /nested\.bin/ }); + const folder = screen.getByRole('button', { name: /folder/ }); + const file = screen.getByRole('button', { name: /file\.txt/ }); + expect(nested.getAttribute('data-focus')).toBe('match'); + expect(nested.querySelector('rect')?.getAttribute('fill')).toBe(CATEGORY_COLORS['ml-model']); + expect(nested.querySelector('rect')?.getAttribute('stroke')).toBe('#ffe066'); + expect(folder.getAttribute('data-focus')).toBe('contains'); + expect(folder.getAttribute('opacity')).toBe('1'); + expect(file.getAttribute('data-focus')).toBe('none'); + expect(file.getAttribute('opacity')).toBe('0.14'); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(file.getAttribute('data-focus')).toBeNull(); + expect(file.getAttribute('opacity')).toBe('1'); + }); + + it('cycles projections with P', async () => { + await openTreemap(); + const select = screen.getByRole('combobox', { name: 'Treemap projection' }) as HTMLSelectElement; + expect(select.value).toBe('location'); + + fireEvent.keyDown(window, { code: 'KeyP' }); + expect(select.value).toBe('cleanup'); + fireEvent.keyDown(window, { code: 'KeyP' }); + expect(select.value).toBe('age'); + expect(screen.getByText('Last change')).toBeTruthy(); + }); +}); + +describe('optimistic deletion', () => { + function deferred() { + let resolve!: () => void; + let reject!: (err: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + beforeEach(() => { + window.sessionStorage.setItem('gdu.skipDeleteConfirm', 'trash'); + }); + + async function openTreemap() { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Treemap' })); + await screen.findByRole('img', { name: 'Directory size treemap' }); + } + + function deleteTile(name: RegExp) { + fireEvent.click(screen.getByRole('button', { name })); + fireEvent.keyDown(window, { code: 'KeyD' }); + } + + it('marks items at once and runs deletions in parallel without reloading', async () => { + const first = deferred(); + const second = deferred(); + vi.mocked(api.deleteNode) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + await openTreemap(); + + deleteTile(/file\.txt/); + deleteTile(/nested\.bin/); + + expect(api.deleteNode).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('aria-busy')).toBe('true'); + expect(screen.getByRole('status').textContent).toBe('deleting 2…'); + + // navigation waits until every request has finished + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + expect(api.fetchNode).toHaveBeenCalledTimes(1); + + first.resolve(); + await waitFor(() => expect(screen.getByRole('status').textContent).toBe('deleting 1…')); + expect(api.fetchNode).toHaveBeenCalledTimes(1); + + second.resolve(); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('data-deletion')).toBe('deleted'); + // file.txt (30 B) and nested.bin (50 B) + expect(screen.getByText('freed 80 B')).toBeTruthy(); + expect(api.fetchNode).toHaveBeenCalledTimes(1); + expect(api.fetchTree).toHaveBeenCalledTimes(1); + + // with every request done, navigation works again and reloads + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + await waitFor(() => expect(api.fetchNode).toHaveBeenCalledTimes(2)); + }); + + it('puts a failed item back and says why', async () => { + const pending = deferred(); + vi.mocked(api.deleteNode).mockReturnValueOnce(pending.promise); + await openTreemap(); + + deleteTile(/file\.txt/); + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('aria-busy')).toBe('true'); + + pending.reject(new Error('permission denied')); + expect(await screen.findByText('Could not delete file.txt: permission denied')).toBeTruthy(); + await waitFor(() => + expect(screen.getByRole('button', { name: /file\.txt/ }).getAttribute('aria-busy')).toBeNull(), + ); + }); + + it('does not delete an item twice', async () => { + vi.mocked(api.deleteNode).mockReturnValue(deferred().promise); + await openTreemap(); + + deleteTile(/folder/); + // the nested file is inside the folder being deleted + fireEvent.click(screen.getByRole('button', { name: /nested\.bin/ })); + fireEvent.keyDown(window, { code: 'KeyD' }); + deleteTile(/folder/); + + expect(api.deleteNode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/frontend/src/api.ts b/webui/frontend/src/api.ts index e47eb7225..7a0321c40 100644 --- a/webui/frontend/src/api.ts +++ b/webui/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { NodeResponse, SortKey, SortOrder, Status, TreeNode } from './types'; +import type { CategoriesResponse, NodeResponse, SortKey, SortOrder, Status, TreeNode } from './types'; async function responseError(res: Response): Promise { let message = `request failed: ${res.status}`; @@ -34,11 +34,23 @@ export function fetchNode( return getJSON(`api/v1/nodes?${params.toString()}`); } -export function fetchTree(path: string): Promise { +// fetchTree loads the recursive tree below path. With pivot, the tree is +// regrouped by cleanup category: one synthetic node per category (its path +// starts with PIVOT_PATH_PREFIX, see cleanup.ts) holding the candidates with +// their real paths. +export function fetchTree(path: string, pivot = false): Promise { const params = new URLSearchParams({ path }); + if (pivot) { + params.set('pivot', 'category'); + } return getJSON(`api/v1/tree?${params.toString()}`); } +export function fetchCategories(path: string): Promise { + const params = new URLSearchParams({ path }); + return getJSON(`api/v1/categories?${params.toString()}`); +} + // requestAction sends a mutating request carrying the per-process action // token the server printed into this page's own URL (see // GduModel.actionToken in model.tsx). The server rejects the request unless diff --git a/webui/frontend/src/cleanup.test.ts b/webui/frontend/src/cleanup.test.ts new file mode 100644 index 000000000..1beeb8aaf --- /dev/null +++ b/webui/frontend/src/cleanup.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { Node } from './types'; +import { + AGE_BUCKETS, + NEUTRAL_COLOR, + REVIEW_COLOR, + SAFE_COLOR, + ageColor, + ageText, + candidateShare, + candidateSummary, + cleanupColor, + containsCategory, + isPivotNode, +} from './cleanup'; + +const DAY = 24 * 60 * 60; +const now = 1_800_000_000; + +function node(overrides: Partial = {}): Node { + return { + name: 'n', + path: '/n', + isDir: true, + size: 1000, + usage: 2000, + itemCount: 1, + mtime: now, + ...overrides, + }; +} + +describe('cleanupColor', () => { + it('colors candidates by confidence', () => { + expect(cleanupColor(node({ confidence: 'safe' }), false)).toBe(SAFE_COLOR); + expect(cleanupColor(node({ confidence: 'review' }), false)).toBe(REVIEW_COLOR); + }); + + it('keeps items without candidates neutral', () => { + expect(cleanupColor(node(), false)).toBe(NEUTRAL_COLOR); + }); + + it('tints a directory more the larger its candidate share', () => { + const little = cleanupColor(node({ categoryTotals: { 'app-cache': { usage: 200, size: 100 } } }), false); + const lots = cleanupColor(node({ categoryTotals: { 'app-cache': { usage: 1800, size: 900 } } }), false); + expect(little).not.toBe(NEUTRAL_COLOR); + expect(lots).not.toBe(little); + // red channel grows towards SAFE_COLOR's + expect(parseInt(lots.slice(1, 3), 16)).toBeGreaterThan(parseInt(little.slice(1, 3), 16)); + }); +}); + +describe('candidateShare', () => { + it('sums categories in the active metric and caps at 1', () => { + const n = node({ + categoryTotals: { + 'build-artifact': { usage: 500, size: 100 }, + 'ml-model': { usage: 500, size: 2000 }, + }, + }); + expect(candidateShare(n, false)).toBe(0.5); + expect(candidateShare(n, true)).toBe(1); + expect(candidateShare(node({ usage: 0 }), false)).toBe(0); + }); +}); + +describe('ageColor and ageText', () => { + it('maps the latest change to buckets', () => { + expect(ageColor(now - 5 * DAY, now)).toBe(AGE_BUCKETS[0].color); + expect(ageColor(now - 90 * DAY, now)).toBe(AGE_BUCKETS[1].color); + expect(ageColor(now - 300 * DAY, now)).toBe(AGE_BUCKETS[2].color); + expect(ageColor(now - 900 * DAY, now)).toBe(AGE_BUCKETS[3].color); + expect(ageColor(0, now)).toBe(NEUTRAL_COLOR); + }); + + it('describes age coarsely', () => { + expect(ageText(now, now)).toBe('changed today'); + expect(ageText(now - DAY, now)).toBe('unchanged for 1 day'); + expect(ageText(now - 125 * DAY, now)).toBe('unchanged for 4 months'); + expect(ageText(now - 800 * DAY, now)).toBe('unchanged for 2 years'); + }); +}); + +describe('candidateSummary', () => { + const labels = new Map([ + ['build-artifact', 'Build artifacts'], + ['ml-model', 'ML models'], + ['app-cache', 'App caches'], + ] as const); + + it('lists the largest categories with age', () => { + const n = node({ + mtime: now - 125 * DAY, + categoryTotals: { + 'build-artifact': { usage: 2048, size: 2048 }, + 'ml-model': { usage: 4096, size: 4096 }, + 'app-cache': { usage: 1024, size: 1024 }, + }, + }); + expect(candidateSummary(n, false, false, new Map(labels), now)).toBe( + '4.0 KiB ml models, 2.0 KiB build artifacts, +1 more · unchanged for 4 months', + ); + }); + + it('is empty without candidates', () => { + expect(candidateSummary(node(), false, false, new Map(), now)).toBeNull(); + }); +}); + +describe('containsCategory and isPivotNode', () => { + it('matches own category or contained totals', () => { + expect(containsCategory(node({ category: 'ml-model' }), 'ml-model')).toBe(true); + expect(containsCategory(node({ categoryTotals: { 'ml-model': { usage: 1, size: 1 } } }), 'ml-model')).toBe(true); + expect(containsCategory(node({ categoryTotals: { 'ml-model': { usage: 1, size: 1 } } }), 'installer')).toBe(false); + }); + + it('recognizes synthetic category nodes', () => { + expect(isPivotNode(node({ path: 'category:ml-model' }))).toBe(true); + expect(isPivotNode(node())).toBe(false); + }); +}); diff --git a/webui/frontend/src/cleanup.ts b/webui/frontend/src/cleanup.ts new file mode 100644 index 000000000..d0a95765b --- /dev/null +++ b/webui/frontend/src/cleanup.ts @@ -0,0 +1,154 @@ +// Projections color the treemap by something other than location, to draw +// attention to space that is likely safe to free. Area always stays size. +import type { Category, Node } from './types'; +import { formatSize } from './format'; +import { PALETTE } from './colors'; + +export type Projection = 'location' | 'cleanup' | 'age' | 'categories'; + +export const PROJECTIONS: Array<{ id: Projection; label: string }> = [ + { id: 'location', label: 'Location' }, + { id: 'cleanup', label: 'Cleanup' }, + { id: 'age', label: 'Age' }, + { id: 'categories', label: 'By category' }, +]; + +// Path prefix of the synthetic category nodes of a pivoted tree +// (pivotPathPrefix in webui/cleanup.go). They are not real items. +export const PIVOT_PATH_PREFIX = 'category:'; + +export function isPivotNode(node: Node): boolean { + return node.path.startsWith(PIVOT_PATH_PREFIX); +} + +// Everything that is not a candidate recedes into this grey, so candidates +// stand out by contrast rather than by a rainbow of hues. +export const NEUTRAL_COLOR = '#353b47'; +export const SAFE_COLOR = '#f08c00'; +export const REVIEW_COLOR = '#c9a25e'; + +export const CATEGORY_COLORS: Record = { + 'build-artifact': PALETTE[1], + 'package-cache': PALETTE[5], + 'app-cache': PALETTE[6], + toolchain: PALETTE[9], + 'ml-model': PALETTE[4], + installer: PALETTE[0], + 'git-ignored': PALETTE[10], +}; + +function amountOf(node: Node, apparent: boolean): number { + return apparent ? node.size : node.usage; +} + +// candidateBytes is the space taken by candidates within node. +export function candidateBytes(node: Node, apparent: boolean): number { + let sum = 0; + for (const amount of Object.values(node.categoryTotals ?? {})) { + if (amount) { + sum += apparent ? amount.size : amount.usage; + } + } + return sum; +} + +// candidateShare is the fraction (0..1) of node taken by candidates. +export function candidateShare(node: Node, apparent: boolean): number { + const total = amountOf(node, apparent); + if (total <= 0) { + return 0; + } + return Math.min(candidateBytes(node, apparent) / total, 1); +} + +function mix(from: string, to: string, t: number): string { + const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16); + const out = [0, 1, 2].map((i) => + Math.round(channel(from, i) + (channel(to, i) - channel(from, i)) * t) + .toString(16) + .padStart(2, '0'), + ); + return `#${out.join('')}`; +} + +// cleanupColor colors a candidate by confidence; a directory holding +// candidates is tinted by their share, so its frame shows how much of it is +// reclaimable even where its children are too small to draw. +export function cleanupColor(node: Node, apparent: boolean): string { + if (node.confidence === 'safe') { + return SAFE_COLOR; + } + if (node.confidence === 'review') { + return REVIEW_COLOR; + } + const share = candidateShare(node, apparent); + return share > 0 ? mix(NEUTRAL_COLOR, SAFE_COLOR, 0.25 + share * 0.5) : NEUTRAL_COLOR; +} + +const DAY = 24 * 60 * 60; + +export const AGE_BUCKETS: Array<{ maxDays: number; label: string; color: string }> = [ + { maxDays: 30, label: '< 1 month', color: NEUTRAL_COLOR }, + { maxDays: 182, label: '< 6 months', color: '#5b5440' }, + { maxDays: 365, label: '< 1 year', color: '#9a7330' }, + { maxDays: Infinity, label: 'older', color: SAFE_COLOR }, +]; + +// ageColor colors by the latest change within the item: the longer nothing +// changed, the stronger the color. +export function ageColor(mtime: number, now: number): string { + if (mtime <= 0) { + return NEUTRAL_COLOR; + } + const days = (now - mtime) / DAY; + return (AGE_BUCKETS.find((b) => days < b.maxDays) ?? AGE_BUCKETS[AGE_BUCKETS.length - 1]).color; +} + +// ageText describes how long ago the item last changed, coarsely. +export function ageText(mtime: number, now: number): string { + const days = Math.floor((now - mtime) / DAY); + if (mtime <= 0 || days < 1) { + return 'changed today'; + } + if (days < 60) { + return `unchanged for ${days} day${days === 1 ? '' : 's'}`; + } + if (days < 730) { + return `unchanged for ${Math.floor(days / 30)} months`; + } + return `unchanged for ${Math.floor(days / 365)} years`; +} + +// candidateSummary is the tooltip line for an item holding candidates, e.g. +// "14 GB build artifacts · unchanged for 4 months". +export function candidateSummary( + node: Node, + apparent: boolean, + useSIPrefix: boolean, + labels: Map, + now: number, +): string | null { + const entries = Object.entries(node.categoryTotals ?? {}) + .map(([category, amount]) => ({ + category: category as Category, + bytes: amount ? (apparent ? amount.size : amount.usage) : 0, + })) + .filter((e) => e.bytes > 0) + .sort((a, b) => b.bytes - a.bytes); + if (entries.length === 0) { + return null; + } + const parts = entries + .slice(0, 2) + .map((e) => `${formatSize(e.bytes, useSIPrefix)} ${(labels.get(e.category) ?? e.category).toLowerCase()}`); + if (entries.length > 2) { + parts.push(`+${entries.length - 2} more`); + } + return `${parts.join(', ')} · ${ageText(node.mtime, now)}`; +} + +// containsCategory reports whether a legend filter keeps node highlighted. +export function containsCategory(node: Node, category: Category): boolean { + const amount = node.categoryTotals?.[category]; + return node.category === category || (amount !== undefined && (amount.usage > 0 || amount.size > 0)); +} diff --git a/webui/frontend/src/components/CategoryLegend.test.tsx b/webui/frontend/src/components/CategoryLegend.test.tsx new file mode 100644 index 000000000..68e86cbf3 --- /dev/null +++ b/webui/frontend/src/components/CategoryLegend.test.tsx @@ -0,0 +1,105 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CategoriesResponse } from '../types'; +import { CategoryLegend } from './CategoryLegend'; + +const categories: CategoriesResponse = { + total: { usage: 10240, size: 10240 }, + categories: [ + { id: 'build-artifact', label: 'Build artifacts', usage: 2048, size: 2048 }, + { id: 'ml-model', label: 'ML models', usage: 3072, size: 3072 }, + { id: 'installer', label: 'Installers & archives', usage: 0, size: 0 }, + ], +}; + +afterEach(cleanup); + +describe('CategoryLegend', () => { + it('shows reclaimable totals and only present categories', () => { + render( + undefined} + />, + ); + expect(screen.getByText('Candidates: 5.0 KiB (50%)')).toBeTruthy(); + expect(screen.getByRole('button', { name: /Build artifacts · 2.0 KiB · 20%/ })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /Installers/ })).toBeNull(); + expect(screen.getByText('safe')).toBeTruthy(); + }); + + it('toggles the category filter', () => { + const onFilter = vi.fn(); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /ML models/ })); + expect(onFilter).toHaveBeenLastCalledWith('ml-model'); + + rerender( + , + ); + const chip = screen.getByRole('button', { name: /ML models/ }); + expect(chip.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(chip); + expect(onFilter).toHaveBeenLastCalledWith(null); + }); + + it('says when a directory has no candidates', () => { + render( + undefined} + />, + ); + expect(screen.getByText('No cleanup candidates here')).toBeTruthy(); + }); + + it('explains the age projection and renders nothing for location', () => { + const { container, rerender } = render( + undefined} + />, + ); + expect(screen.getByText('older')).toBeTruthy(); + + rerender( + undefined} + />, + ); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/webui/frontend/src/components/CategoryLegend.tsx b/webui/frontend/src/components/CategoryLegend.tsx new file mode 100644 index 000000000..e8ecd0a79 --- /dev/null +++ b/webui/frontend/src/components/CategoryLegend.tsx @@ -0,0 +1,107 @@ +import type { CategoriesResponse, Category } from '../types'; +import { formatSize, percent } from '../format'; +import { + AGE_BUCKETS, + CATEGORY_COLORS, + REVIEW_COLOR, + SAFE_COLOR, + type Projection, +} from '../cleanup'; + +interface CategoryLegendProps { + projection: Projection; + categories: CategoriesResponse | null; + apparent: boolean; + useSIPrefix: boolean; + filter: Category | null; + onFilter: (category: Category | null) => void; +} + +function Swatch({ color }: { color: string }) { + return ; +} + +// CategoryLegend explains the active projection and, for the cleanup ones, +// lists the space each category takes within the current directory. Clicking +// a category highlights only the tiles holding it. +export function CategoryLegend({ + projection, + categories, + apparent, + useSIPrefix, + filter, + onFilter, +}: CategoryLegendProps) { + if (projection === 'location') { + return null; + } + if (projection === 'age') { + return ( +
+ Last change + {AGE_BUCKETS.map((b) => ( + + + {b.label} + + ))} +
+ ); + } + if (!categories) { + return
Classifying…
; + } + + const total = apparent ? categories.total.size : categories.total.usage; + const present = categories.categories + .map((c) => ({ ...c, bytes: apparent ? c.size : c.usage })) + .filter((c) => c.bytes > 0); + const reclaimable = present.reduce((sum, c) => sum + c.bytes, 0); + + return ( +
+ {present.length === 0 ? ( + No cleanup candidates here + ) : ( + + Candidates: {formatSize(reclaimable, useSIPrefix)} ({percent(reclaimable, total).toFixed(0)}%) + + )} + {projection === 'cleanup' && present.length > 0 && ( + <> + + + safe + + + + review first + + + )} + {present.map((c) => { + const active = filter === c.id; + const color = CATEGORY_COLORS[c.id]; + return ( + + ); + })} + {filter && ( + + )} +
+ ); +} diff --git a/webui/frontend/src/components/FileTable.tsx b/webui/frontend/src/components/FileTable.tsx index e4044ac1f..f6dc95114 100644 --- a/webui/frontend/src/components/FileTable.tsx +++ b/webui/frontend/src/components/FileTable.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import type { Node, SortKey, SortOrder } from '../types'; +import type { Deletion } from '../model'; import { metricValue } from '../slices'; import { formatCount, formatMtime, formatSize, percent } from '../format'; @@ -20,8 +21,11 @@ interface FileTableProps { // Delete is hidden entirely (not just disabled) when the server does not // allow it; reveal stays available. canDelete: boolean; - // True while an action request is in flight; disables the buttons. + // True while a reveal request is in flight; disables the reveal buttons. actionPending: boolean; + // Items being deleted in the background, or already deleted; their rows + // are drawn faded until the directory is reloaded. + deletions: ReadonlyMap; } const COLUMNS: { key: SortKey; label: string; numeric: boolean }[] = [ @@ -47,6 +51,7 @@ export function FileTable({ onDelete, canDelete, actionPending, + deletions, }: FileTableProps) { const maxValue = useMemo( () => children.reduce((max, n) => Math.max(max, metricValue(n, apparent)), 0), @@ -78,11 +83,14 @@ export function FileTable({ const barWidth = maxValue > 0 ? (value / maxValue) * 100 : 0; const color = colorMap.get(node.path) ?? 'var(--other)'; const isHovered = node.path === hoveredPath; + const deletion = deletions.get(node.path)?.state; + const isDeleting = deletion !== undefined; return ( onHover(node.path)} onMouseLeave={() => onHover(null)} onClick={() => node.isDir && onSelect(node)} @@ -93,6 +101,9 @@ export function FileTable({ {node.isDir ? '📁' : '📄'} {node.name} {node.flag === '!' && !} + {deletion && ( + · {deletion === 'pending' ? 'deleting…' : 'deleted'} + )} @@ -116,7 +127,7 @@ export function FileTable({ className="row-action" aria-label={`Delete ${node.name}`} title="Delete" - disabled={actionPending} + disabled={isDeleting} onClick={() => onDelete(node)} > 🗑 diff --git a/webui/frontend/src/components/FileTableView.tsx b/webui/frontend/src/components/FileTableView.tsx index fd9495491..df5471624 100644 --- a/webui/frontend/src/components/FileTableView.tsx +++ b/webui/frontend/src/components/FileTableView.tsx @@ -17,6 +17,7 @@ export function FileTableView() { hoveredPath, setHoveredPath, handleSelect, + deletions, } = useGduModel(); const actions = useItemActions(); @@ -38,6 +39,7 @@ export function FileTableView() { onDelete={actions.requestDelete} canDelete={status.deleteAllowed} actionPending={actions.actionPending} + deletions={deletions} /> diff --git a/webui/frontend/src/components/Header.tsx b/webui/frontend/src/components/Header.tsx index 7aa003cbd..687432f59 100644 --- a/webui/frontend/src/components/Header.tsx +++ b/webui/frontend/src/components/Header.tsx @@ -1,10 +1,25 @@ -import { useGduModel } from '../model'; +import { freedBytes, useGduModel } from '../model'; import { formatSize } from '../format'; import { Breadcrumbs } from './Breadcrumbs'; +import { PROJECTIONS, type Projection } from '../cleanup'; export function Header() { - const { status, nodeResp, total, useSIPrefix, view, toggleView, effectiveApparent, toggleApparent, handleSelect } = - useGduModel(); + const { + status, + nodeResp, + total, + useSIPrefix, + view, + toggleView, + projection, + setProjection, + effectiveApparent, + toggleApparent, + handleSelect, + deletionsInFlight, + deletions, + } = useGduModel(); + const freed = freedBytes(deletions, effectiveApparent); return (
@@ -22,6 +37,21 @@ export function Header() { > {view === 'donut' ? 'Treemap' : 'Donut'} + {view === 'treemap' && ( + + )}
); diff --git a/webui/frontend/src/components/HelpModal.tsx b/webui/frontend/src/components/HelpModal.tsx index 3fc0f7945..1adeadbbd 100644 --- a/webui/frontend/src/components/HelpModal.tsx +++ b/webui/frontend/src/components/HelpModal.tsx @@ -25,6 +25,11 @@ export function HelpModal() {
Select a treemap item
Double-click
Open a directory
+
P
+
+ Switch projection: location, cleanup candidates (safe / review first), age of the + last change, or candidates grouped by category +
)} {view === 'donut' && ( @@ -36,7 +41,7 @@ export function HelpModal() {
?
Show this help
Esc
-
Close a dialog
+
Close a dialog, or show all categories again
diff --git a/webui/frontend/src/components/TreeMapView.tsx b/webui/frontend/src/components/TreeMapView.tsx index 9dda61f6b..474189c4a 100644 --- a/webui/frontend/src/components/TreeMapView.tsx +++ b/webui/frontend/src/components/TreeMapView.tsx @@ -1,11 +1,25 @@ -import { useCallback, useEffect, useState } from 'react'; -import type { Node } from '../types'; -import { fetchTree } from '../api'; -import { useGduModel } from '../model'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { CategoriesResponse, Category, Node } from '../types'; +import { fetchCategories, fetchTree } from '../api'; +import { deletionStateOf, useGduModel } from '../model'; import { TreeMap } from './TreeMap'; import { DeleteConfirmModal } from './DeleteConfirmModal'; -import { useLatest } from '../useLatest'; import { useItemActions } from '../useItemActions'; +import { CategoryLegend } from './CategoryLegend'; +import type { TreeLayoutRect } from './treemap/treeLayout'; +import type { TileFocus } from './TreeMap'; +import { + CATEGORY_COLORS, + NEUTRAL_COLOR, + PIVOT_PATH_PREFIX, + PROJECTIONS, + ageColor, + ageText, + candidateSummary, + cleanupColor, + containsCategory, + isPivotNode, +} from '../cleanup'; // TreeMapView owns everything specific to the treemap: fetching (and // caching, via the model) the recursive tree, node selection, and the @@ -25,37 +39,62 @@ export function TreeMapView() { treeRoot, treePath, setTree, - clearTree, + projection, + setProjection, + deletions, } = useGduModel(); const [treeLoading, setTreeLoading] = useState(false); const [treeError, setTreeError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); + const [categories, setCategories] = useState(null); + const [categoryFilter, setCategoryFilter] = useState(null); + + const pivot = projection === 'categories'; + const showsCategories = projection === 'cleanup' || pivot; + // The pivoted tree is cached under its own key, so switching projections + // back and forth does not mix up the two shapes of the same directory. + const treeKey = pivot ? PIVOT_PATH_PREFIX + currentPath : currentPath; // Reset selection whenever the directory changes. useEffect(() => { setSelectedNode(null); + setCategoryFilter(null); }, [currentPath]); - // Tracks the latest currentPath so an in-flight refreshTree() call (e.g. - // one started before breadcrumb navigation) can tell its result is stale - // once it resolves. - const currentPathRef = useLatest(currentPath); + // Category totals are scoped to the current directory. + useEffect(() => { + if (view !== 'treemap' || !showsCategories || currentPath === null) { + return; + } + let cancelled = false; + setCategories(null); + fetchCategories(currentPath) + .then((resp) => { + if (!cancelled) { + setCategories(resp); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [currentPath, showsCategories, view]); // Load (and cache, via the model) the recursive tree for the current // directory. Cached in the model rather than here so toggling back to this // view for the same path does not re-fetch. useEffect(() => { - if (view !== 'treemap' || currentPath === null || treePath === currentPath) { + if (view !== 'treemap' || currentPath === null || treePath === treeKey) { return; } let cancelled = false; setTreeLoading(true); setTreeError(null); - fetchTree(currentPath) + fetchTree(currentPath, pivot) .then((root) => { if (!cancelled) { - setTree(currentPath, root); + setTree(treeKey, root); } }) .catch((err: unknown) => { @@ -71,34 +110,16 @@ export function TreeMapView() { return () => { cancelled = true; }; - }, [currentPath, treePath, view, setTree]); - - const refreshTree = useCallback(async () => { - if (currentPath === null) { - return; - } - const path = currentPath; - try { - const root = await fetchTree(path); - if (currentPathRef.current === path) { - setTree(path, root); - setTreeError(null); - } - } catch (err: unknown) { - // Invalidate the cache instead of leaving the pre-mutation treeRoot/ - // treePath in place: otherwise they would still match currentPath and - // the stale tree would keep rendering as if it were up to date, with - // treeError never shown. - if (currentPathRef.current === path) { - setTreeError(err instanceof Error ? err.message : String(err)); - clearTree(); - } - } - }, [currentPath, setTree, clearTree]); + }, [currentPath, treePath, treeKey, pivot, view, setTree]); - const clearSelection = useCallback(() => setSelectedNode(null), []); + const actions = useItemActions(); - const actions = useItemActions({ refreshExtra: refreshTree, onRefreshed: clearSelection }); + // A deleted item stays on screen, but can no longer be acted on. + useEffect(() => { + if (selectedNode && deletionStateOf(deletions, selectedNode.path)) { + setSelectedNode(null); + } + }, [deletions, selectedNode]); const { deleteCandidate, reveal, requestDelete: requestDeleteNode } = actions; const revealSelected = useCallback(async () => { @@ -122,7 +143,14 @@ export function TreeMapView() { if (deleteCandidate) { return; } - if (event.code === 'KeyO' && selectedNode) { + if (event.key === 'Escape' && categoryFilter) { + event.preventDefault(); + setCategoryFilter(null); + } else if (event.code === 'KeyP' && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); + const index = PROJECTIONS.findIndex((p) => p.id === projection); + setProjection(PROJECTIONS[(index + 1) % PROJECTIONS.length].id); + } else if (event.code === 'KeyO' && selectedNode) { event.preventDefault(); void revealSelected(); } else if ( @@ -140,12 +168,101 @@ export function TreeMapView() { }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [deleteCandidate, requestDelete, revealSelected, selectedNode, status.deleteAllowed]); + }, [ + deleteCandidate, + requestDelete, + revealSelected, + selectedNode, + status.deleteAllowed, + projection, + setProjection, + categoryFilter, + ]); + + const labels = useMemo( + () => new Map((categories?.categories ?? []).map((c) => [c.id, c.label])), + [categories], + ); + const now = useMemo(() => Date.now() / 1000, [treeRoot]); + + const colorFor = useMemo(() => { + switch (projection) { + case 'cleanup': + return (rect: TreeLayoutRect) => cleanupColor(rect.node, effectiveApparent); + case 'age': + return (rect: TreeLayoutRect) => ageColor(rect.node.mtime, now); + case 'categories': + return (rect: TreeLayoutRect) => { + const category = rect.colorPath.slice(PIVOT_PATH_PREFIX.length) as Category; + return CATEGORY_COLORS[category] ?? NEUTRAL_COLOR; + }; + default: + return undefined; + } + }, [projection, effectiveApparent, now]); + + const focus = useMemo(() => { + if (!showsCategories || !categoryFilter) { + return undefined; + } + return (node: Node): TileFocus => { + if (isPivotNode(node)) { + return node.category === categoryFilter ? 'contains' : 'none'; + } + if (node.category === categoryFilter) { + return 'match'; + } + return containsCategory(node, categoryFilter) ? 'contains' : 'none'; + }; + }, [showsCategories, categoryFilter]); + + const tooltipDetail = useMemo(() => { + if (projection === 'location') { + return undefined; + } + if (projection === 'age') { + return (node: Node) => ageText(node.mtime, now); + } + return (node: Node) => { + if (isPivotNode(node)) { + return null; + } + const summary = candidateSummary(node, effectiveApparent, useSIPrefix, labels, now); + return [summary, node.hint].filter(Boolean).join(' — ') || null; + }; + }, [projection, effectiveApparent, useSIPrefix, labels, now]); + + // Synthetic category nodes of the pivoted tree are not items: they can be + // neither selected for an action nor opened. + const selectNode = useCallback( + (node: Node) => { + if (!isPivotNode(node) && !deletionStateOf(deletions, node.path)) { + setSelectedNode(node); + } + }, + [deletions], + ); + const openPath = useCallback( + (path: string) => { + if (!path.startsWith(PIVOT_PATH_PREFIX)) { + navigateToPath(path); + } + }, + [navigateToPath], + ); return ( <> -
- {treeRoot && treePath === currentPath ? ( +
+ + {treeRoot && treePath === treeKey ? ( ) : (
diff --git a/webui/frontend/src/index.css b/webui/frontend/src/index.css index 5fa7d5a21..c1477d6f3 100644 --- a/webui/frontend/src/index.css +++ b/webui/frontend/src/index.css @@ -87,6 +87,11 @@ body { border-color: var(--accent); } +.freed-badge { + color: #7bd88f; + font-weight: 600; +} + .scanning-badge { color: var(--text-muted); font-style: italic; @@ -288,11 +293,12 @@ body { position: absolute; left: 8px; bottom: 8px; - background: rgba(27, 31, 39, 0.94); + background: var(--bg-panel); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; pointer-events: none; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.55); display: flex; flex-direction: column; gap: 2px; @@ -304,6 +310,98 @@ body { font-size: 11px; } +.treemap-tooltip .treemap-tooltip-detail { + color: var(--text); +} + +.file-table tr.pending td, +.file-table tr.deleted td { + opacity: 0.45; +} + +.file-table tr.deleted .name { + text-decoration: line-through; +} + +.treemap-tile.pending { + cursor: progress !important; +} + +.treemap-tile.deleted { + cursor: default !important; +} + +.chart-panel.with-legend { + flex-direction: column; + align-items: stretch; + gap: 8px; +} + +.chart-panel.with-legend .treemap, +.chart-panel.with-legend .treemap-loading { + flex: 1; + min-height: 0; +} + +.legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + font-size: 12px; + color: var(--text-muted); +} + +.legend-title { + color: var(--text); + font-weight: 600; +} + +.legend-key { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + flex: none; +} + +.legend-chip { + display: inline-flex; + align-items: center; + gap: 5px; + background: var(--track); + color: var(--text); + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 10px; + font: inherit; + cursor: pointer; +} + +.legend-chip:hover, +.legend-chip.active { + border-color: var(--accent); +} + +.legend-chip.active { + background: var(--bg-row-hover); + font-weight: 600; +} + +.legend-clear { + background: none; + border: none; + color: var(--accent); + font: inherit; + cursor: pointer; + padding: 3px 4px; +} + .treemap-tooltip b { font-size: 12px; font-weight: 600; @@ -484,6 +582,7 @@ body { padding: 8px 16px; margin: 0 16px; border-radius: 6px; + white-space: pre-line; } .modal-backdrop { diff --git a/webui/frontend/src/model.test.ts b/webui/frontend/src/model.test.ts new file mode 100644 index 000000000..476f38409 --- /dev/null +++ b/webui/frontend/src/model.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import type { Node } from './types'; +import { deletionStateOf, freedBytes, type Deletion } from './model'; + +function entry(path: string, usage: number, state: Deletion['state'] = 'deleted'): [string, Deletion] { + const node: Node = { name: path, path, isDir: true, size: usage / 2, usage, itemCount: 1, mtime: 0 }; + return [path, { state, node }]; +} + +describe('deletionStateOf', () => { + it('covers the item and everything inside it, but not name prefixes', () => { + const deletions = new Map([entry('/a/b', 1, 'pending')]); + expect(deletionStateOf(deletions, '/a/b')).toBe('pending'); + expect(deletionStateOf(deletions, '/a/b/c')).toBe('pending'); + expect(deletionStateOf(deletions, '/a/bc')).toBeNull(); + }); +}); + +describe('freedBytes', () => { + it('sums confirmed deletions in the active metric', () => { + const deletions = new Map([entry('/a', 100), entry('/b', 40), entry('/c', 999, 'pending')]); + expect(freedBytes(deletions, false)).toBe(140); + expect(freedBytes(deletions, true)).toBe(70); + }); + + it('counts a directory once when something inside it was deleted first', () => { + const deletions = new Map([entry('/a/inner', 30), entry('/a', 100)]); + expect(freedBytes(deletions, false)).toBe(100); + }); +}); diff --git a/webui/frontend/src/model.tsx b/webui/frontend/src/model.tsx index 670005686..6f849fb2d 100644 --- a/webui/frontend/src/model.tsx +++ b/webui/frontend/src/model.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from 'react'; @@ -11,6 +12,7 @@ import type { Node, NodeResponse, SortKey, SortOrder, Status, TreeNode } from '. import { fetchNode, fetchStatus, subscribeStatus, type DeleteMode } from './api'; import { colorMapFor, computeSlices } from './slices'; import { useLatest } from './useLatest'; +import type { Projection } from './cleanup'; export type ChartView = 'donut' | 'treemap'; @@ -63,6 +65,10 @@ export interface GduModel { onSortChange: (key: SortKey) => void; view: ChartView; toggleView: () => void; + // projection is how the treemap is colored (and, for "categories", shaped). + // Kept here so it survives toggling the view. + projection: Projection; + setProjection: (projection: Projection) => void; toggleApparent: () => void; hoveredPath: string | null; setHoveredPath: (path: string | null) => void; @@ -79,17 +85,74 @@ export interface GduModel { treeRoot: TreeNode | null; treePath: string | null; setTree: (path: string, root: TreeNode | null) => void; - // Invalidates the cached tree (clears both treeRoot and treePath) so a - // failed refresh does not leave a stale tree displayed as current, and the - // loader effect above treats the path as not-yet-loaded, eligible for a - // retry. - clearTree: () => void; // "Do not ask again this session" for delete confirmation, remembering // which of the two delete modes to repeat without asking. Lives here // (rather than in TreeMapView) so it survives the view toggling // donut/treemap, which unmounts TreeMapView and would otherwise reset it. skipDeleteConfirm: DeleteMode | null; setSkipDeleteConfirm: (mode: DeleteMode | null) => void; + // Optimistic deletion: items in the current directory whose deletion is + // running ("pending") or done ("deleted"). Deleted items keep their place + // and are only marked, so the layout does not jump under the cursor; the + // directory is reloaded once the user navigates. Lives here so every view + // sees the same state. + deletions: ReadonlyMap; + // Requests still running. Navigation is blocked while it is non-zero, so + // every result lands in the directory it was started from. + deletionsInFlight: number; + beginDeletion: (node: Node) => void; + // settleDeletion records a finished request; a failed one (error set) is + // dropped from deletions so its item is drawn normally again. Once no + // request is running it returns the errors of the whole batch, otherwise + // null. + settleDeletion: (path: string, error: string | null) => string[] | null; +} + +export type DeletionState = 'pending' | 'deleted'; + +export interface Deletion { + state: DeletionState; + node: Node; +} + +// deletionStateOf tells whether path, or a directory containing it, is being +// or has been deleted. +export function deletionStateOf( + deletions: ReadonlyMap, + path: string, +): DeletionState | null { + if (deletions.size === 0) { + return null; + } + const own = deletions.get(path); + if (own) { + return own.state; + } + for (const [deleted, { state }] of deletions) { + const separator = path[deleted.length]; + if (path.startsWith(deleted) && (separator === '/' || separator === '\\')) { + return state; + } + } + return null; +} + +// freedBytes is the space freed by the confirmed deletions. An item deleted +// after something inside it was is counted once, with the size it had when +// the user deleted it. +export function freedBytes(deletions: ReadonlyMap, apparent: boolean): number { + let total = 0; + for (const [path, { state, node }] of deletions) { + if (state !== 'deleted') { + continue; + } + const parent = path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))); + if (parent && deletionStateOf(deletions, parent) === 'deleted') { + continue; + } + total += apparent ? node.size : node.usage; + } + return total; } const GduModelContext = createContext(null); @@ -121,11 +184,20 @@ export function useGduModelState() { const [order, setOrder] = useState('desc'); const [apparent, setApparent] = useState(null); const [view, setView] = useState('donut'); + const [projection, setProjection] = useState('location'); const [hoveredPath, setHoveredPath] = useState(null); const [loadError, setLoadError] = useState(null); const [showHelp, setShowHelp] = useState(false); const [treeRoot, setTreeRoot] = useState(null); const [treePath, setTreePath] = useState(null); + const [deletions, setDeletions] = useState>(() => new Map()); + const [deletionsInFlight, setDeletionsInFlight] = useState(0); + const inFlightRef = useRef(0); + // Set once anything was deleted here: the shown data is stale from then on + // and is reloaded on the next navigation. + const staleRef = useRef(false); + const [reloadKey, setReloadKey] = useState(0); + const deletionErrorsRef = useRef([]); const [skipDeleteConfirm, setSkipDeleteConfirmState] = useState(() => { const mode = readSessionStorage(deleteModeStorageKey); return mode === 'trash' || mode === 'permanent' ? mode : null; @@ -178,7 +250,7 @@ export function useGduModelState() { return () => { cancelled = true; }; - }, [currentPath, sort, order, status?.state]); + }, [currentPath, sort, order, status?.state, reloadKey]); // Global "?" shortcut to open the help overlay; works from any view. useEffect(() => { @@ -222,10 +294,53 @@ export function useGduModelState() { ); const navigateToPath = useCallback((path: string) => { + if (inFlightRef.current > 0) { + return; + } + if (staleRef.current) { + staleRef.current = false; + setDeletions(new Map()); + setTreePath(null); + setTreeRoot(null); + // reload even when navigating to the directory already shown + setReloadKey((k) => k + 1); + } setCurrentPath(path); setHoveredPath(null); }, []); + const beginDeletion = useCallback((node: Node) => { + inFlightRef.current += 1; + staleRef.current = true; + setDeletionsInFlight(inFlightRef.current); + setDeletions((prev) => new Map(prev).set(node.path, { state: 'pending', node })); + }, []); + + const settleDeletion = useCallback((path: string, error: string | null) => { + inFlightRef.current = Math.max(inFlightRef.current - 1, 0); + setDeletionsInFlight(inFlightRef.current); + setDeletions((prev) => { + const next = new Map(prev); + const entry = next.get(path); + if (error !== null) { + next.delete(path); + } else if (entry) { + next.set(path, { ...entry, state: 'deleted' }); + } + return next; + }); + if (error !== null) { + deletionErrorsRef.current.push(error); + } + if (inFlightRef.current > 0) { + return null; + } + const errors = deletionErrorsRef.current; + deletionErrorsRef.current = []; + return errors; + }, []); + + const handleSelect = useCallback( (node: Node) => { if (node.isDir) { @@ -262,11 +377,6 @@ export function useGduModelState() { setTreeRoot(root); }, []); - const clearTree = useCallback(() => { - setTreePath(null); - setTreeRoot(null); - }, []); - const setSkipDeleteConfirm = useCallback((mode: DeleteMode | null) => { setSkipDeleteConfirmState(mode); writeSessionStorage(deleteModeStorageKey, mode); @@ -287,6 +397,8 @@ export function useGduModelState() { onSortChange, view, toggleView, + projection, + setProjection, toggleApparent, hoveredPath, setHoveredPath, @@ -300,8 +412,11 @@ export function useGduModelState() { treeRoot, treePath, setTree, - clearTree, skipDeleteConfirm, setSkipDeleteConfirm, + deletions, + deletionsInFlight, + beginDeletion, + settleDeletion, }; } diff --git a/webui/frontend/src/types.ts b/webui/frontend/src/types.ts index 9e35fe6c8..5df56d660 100644 --- a/webui/frontend/src/types.ts +++ b/webui/frontend/src/types.ts @@ -9,6 +9,39 @@ export interface Node { itemCount: number; mtime: number; flag?: string; + // Set when the item is a cleanup candidate on its own (see pkg/cleanup). + category?: Category; + confidence?: Confidence; + hint?: string; + // Space taken by cleanup candidates within the item, per category. + categoryTotals?: Partial>; +} + +export type Category = + | 'build-artifact' + | 'package-cache' + | 'app-cache' + | 'toolchain' + | 'ml-model' + | 'installer' + | 'git-ignored'; + +export type Confidence = 'safe' | 'review'; + +export interface Amount { + usage: number; + size: number; +} + +export interface CategoryTotal extends Amount { + id: Category; + label: string; +} + +// GET /api/v1/categories: candidates within one directory. +export interface CategoriesResponse { + total: Amount; + categories: CategoryTotal[]; } export interface TreeNode extends Node { diff --git a/webui/frontend/src/useItemActions.ts b/webui/frontend/src/useItemActions.ts index edaafcce8..bf299ae21 100644 --- a/webui/frontend/src/useItemActions.ts +++ b/webui/frontend/src/useItemActions.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import type { Node } from './types'; import { deleteNode, revealNode, type DeleteMode } from './api'; -import { useGduModel } from './model'; +import { deletionStateOf, useGduModel } from './model'; // Shown instead of firing a request that is certain to come back as a bare // 403. The token is missing whenever this tab never received one - the page @@ -10,16 +10,9 @@ import { useGduModel } from './model'; const noActionTokenMessage = 'Actions are unavailable: this page has no action token. Reopen the URL gdu printed, or restart gdu.'; -export interface ItemActionsOptions { - // Refreshes view-specific data after a mutation. Defaults to invalidating - // the cached recursive tree, so the treemap re-fetches the next time it is - // shown. The treemap passes a function that re-fetches it right away. - refreshExtra?: () => Promise; - // Runs after a successful refresh, e.g. to clear a view-local selection. - onRefreshed?: () => void; -} - export interface ItemActions { + // actionPending is true while a reveal runs. Deletions never block: they + // are optimistic, see performDelete. actionPending: boolean; deleteCandidate: Node | null; skipDeleteChoice: boolean; @@ -28,6 +21,10 @@ export interface ItemActions { // opted out of confirmation this session, otherwise opens the modal. requestDelete: (node: Node) => void; cancelDelete: () => void; + // performDelete marks the item as being deleted right away and sends the + // request in the background, so more items can be deleted meanwhile. A + // deleted item stays in place, marked, until the user navigates; a failed + // deletion puts the item back and reports why. performDelete: (node: Node, mode: DeleteMode) => Promise; reveal: (node: Node) => Promise; } @@ -35,69 +32,46 @@ export interface ItemActions { // useItemActions is the delete/reveal workflow shared by every view that can // act on a single item (the treemap and the file table). The confirmation // modal is rendered by DeleteConfirmModal from the state returned here. -export function useItemActions(options: ItemActionsOptions = {}): ItemActions { - const { refreshExtra, onRefreshed } = options; +export function useItemActions(): ItemActions { const { actionToken, status, skipDeleteConfirm, - setHoveredPath, - navigateToPath, - refreshNode, - clearTree, setLoadError, + deletions, + beginDeletion, + settleDeletion, } = useGduModel(); const [deleteCandidate, setDeleteCandidate] = useState(null); const [skipDeleteChoice, setSkipDeleteChoice] = useState(false); const [actionPending, setActionPending] = useState(false); - const refreshAfterMutation = useCallback(async () => { - const [nodeResp] = await Promise.all([ - refreshNode(), - refreshExtra ? refreshExtra() : Promise.resolve(clearTree()), - ]); - onRefreshed?.(); - setHoveredPath(null); - // Deleting the last item leaves the current directory empty: staying - // put would show a blank chart with no way back except the breadcrumbs, - // so step up to the parent directory instead. - if (nodeResp.children.length === 0 && nodeResp.breadcrumbs.length > 1) { - const parent = nodeResp.breadcrumbs[nodeResp.breadcrumbs.length - 2]; - navigateToPath(parent.path); - } - }, [refreshNode, refreshExtra, clearTree, onRefreshed, setHoveredPath, navigateToPath]); - const performDelete = useCallback( async (node: Node, mode: DeleteMode) => { - if (actionPending) { - return; - } + setDeleteCandidate(null); if (!actionToken) { - setDeleteCandidate(null); setLoadError(noActionTokenMessage); return; } - setActionPending(true); - setDeleteCandidate(null); + if (deletionStateOf(deletions, node.path)) { + return; + } + beginDeletion(node); + let error: string | null = null; try { await deleteNode(node.path, actionToken, mode); - await refreshAfterMutation(); - setLoadError(null); } catch (err: unknown) { - if (typeof err === 'object' && err !== null && 'status' in err && err.status === 404) { - try { - await refreshAfterMutation(); - } catch { - // Keep the original action error; the regular loader can retry later. - } - } - setLoadError(err instanceof Error ? err.message : String(err)); - } finally { - setActionPending(false); + error = `Could not delete ${node.name}: ${err instanceof Error ? err.message : String(err)}`; + } + const errors = settleDeletion(node.path, error); + if (errors && errors.length > 0) { + setLoadError(errors.join('\n')); + } else if (error) { + setLoadError(error); } }, - [actionPending, refreshAfterMutation, setLoadError, actionToken], + [actionToken, deletions, beginDeletion, settleDeletion, setLoadError], ); const reveal = useCallback( @@ -124,7 +98,7 @@ export function useItemActions(options: ItemActionsOptions = {}): ItemActions { const requestDelete = useCallback( (node: Node) => { - if (actionPending || !status.deleteAllowed) { + if (!status.deleteAllowed || deletionStateOf(deletions, node.path)) { return; } if (skipDeleteConfirm) { @@ -134,7 +108,7 @@ export function useItemActions(options: ItemActionsOptions = {}): ItemActions { setSkipDeleteChoice(false); setDeleteCandidate(node); }, - [actionPending, performDelete, skipDeleteConfirm, status.deleteAllowed], + [deletions, performDelete, skipDeleteConfirm, status.deleteAllowed], ); const cancelDelete = useCallback(() => setDeleteCandidate(null), []); diff --git a/webui/handlers.go b/webui/handlers.go index b5b51f6b2..b5a5df596 100644 --- a/webui/handlers.go +++ b/webui/handlers.go @@ -104,7 +104,7 @@ func (ui *UI) handleNodes(w http.ResponseWriter, r *http.Request) { } sortBy, order := parseSort(r.URL.Query().Get("sort"), r.URL.Query().Get("order")) - writeJSON(w, http.StatusOK, buildNodeResponse(node, sortBy, order)) + writeJSON(w, http.StatusOK, buildNodeResponse(node, sortBy, order, ui.cleanupResult())) } func (ui *UI) handleTree(w http.ResponseWriter, r *http.Request) { @@ -113,7 +113,16 @@ func (ui *UI) handleTree(w http.ResponseWriter, r *http.Request) { writeNodeError(w, err) return } - writeJSON(w, http.StatusOK, buildTree(node)) + res := ui.cleanupResult() + if r.URL.Query().Get("pivot") == "category" { + if res == nil { + writeError(w, http.StatusServiceUnavailable, "classification is unavailable while scanning") + return + } + writeJSON(w, http.StatusOK, buildPivotTree(node, res)) + return + } + writeJSON(w, http.StatusOK, buildTree(node, res)) } // deleteDisabledReason reports why deletion is currently refused given the @@ -170,9 +179,15 @@ func (ui *UI) handleDeleteNode(w http.ResponseWriter, r *http.Request) { removeFunc = ui.trasher } if err := removeFunc(parent, node); err != nil { + // a failed removal may still have removed part of the tree + ui.treeChanged() writeError(w, http.StatusInternalServerError, err.Error()) return } + // a trash command may succeed and leave the item in place + if current, ok := childByName(parent, node.GetName()); !ok || current != node { + ui.itemRemoved(node) + } w.WriteHeader(http.StatusNoContent) } diff --git a/webui/multipath_test.go b/webui/multipath_test.go index 9c310db33..487c07ba1 100644 --- a/webui/multipath_test.go +++ b/webui/multipath_test.go @@ -42,7 +42,7 @@ func TestAnalyzePathsGroupsRootsUnderVirtualDir(t *testing.T) { if err != nil { t.Fatalf("findNode(virtual root): %v", err) } - resp := buildNodeResponse(node, fs.SortBySize, fs.SortDesc) + resp := buildNodeResponse(node, fs.SortBySize, fs.SortDesc, nil) if len(resp.Children) != 2 { t.Fatalf("virtual root has %d children, want 2", len(resp.Children)) } diff --git a/webui/server.go b/webui/server.go index 8f7171dae..db0a2ed01 100644 --- a/webui/server.go +++ b/webui/server.go @@ -69,6 +69,7 @@ func (ui *UI) routes() http.Handler { mux.HandleFunc("/api/v1/status", ui.handleStatus) mux.HandleFunc("GET /api/v1/nodes", ui.handleNodes) mux.HandleFunc("GET /api/v1/tree", ui.handleTree) + mux.HandleFunc("GET /api/v1/categories", ui.handleCategories) mux.HandleFunc("DELETE /api/v1/nodes", ui.requireLocalAction(ui.handleDeleteNode)) mux.HandleFunc("POST /api/v1/reveal", ui.requireLocalAction(ui.handleReveal)) mux.HandleFunc("/api/v1/devices", ui.handleDevices) diff --git a/webui/tree.go b/webui/tree.go index ebdccbe57..dc01d9465 100644 --- a/webui/tree.go +++ b/webui/tree.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/cleanup" "github.com/dundee/gdu/v5/pkg/fs" ) @@ -29,6 +30,14 @@ type nodeJSON struct { ItemCount int64 `json:"itemCount"` Mtime int64 `json:"mtime"` Flag string `json:"flag,omitempty"` + + // Category, Confidence and Hint are set when the item is a cleanup + // candidate on its own; CategoryTotals is the space candidates take + // within it. + Category cleanup.Category `json:"category,omitempty"` + Confidence cleanup.Confidence `json:"confidence,omitempty"` + Hint string `json:"hint,omitempty"` + CategoryTotals cleanup.Totals `json:"categoryTotals,omitempty"` } // nodeResponse is the payload of GET /api/v1/nodes: the node itself, its @@ -48,12 +57,14 @@ type treeJSON struct { Children []*treeJSON `json:"children"` } -func toNodeJSON(it fs.Item) nodeJSON { +// toNodeJSON converts an item, adding its cleanup classification when res is +// not nil. +func toNodeJSON(it fs.Item, res *cleanup.Result) nodeJSON { flag := "" if f := it.GetFlag(); f != ' ' && f != 0 { flag = string(f) } - return nodeJSON{ + n := nodeJSON{ // scanned roots are labelled with their absolute path, since their base // names collide when the roots come from different parent directories Name: analyze.ItemDisplayName(it.GetParent(), it), @@ -65,6 +76,18 @@ func toNodeJSON(it fs.Item) nodeJSON { Mtime: it.GetMtime().Unix(), Flag: flag, } + if res != nil { + if m, ok := res.Match(it); ok { + n.Category = m.Category + n.Confidence = m.Confidence + // the hint is about the matched directory, shown on it alone + if !m.Inherited { + n.Hint = m.Hint + } + } + n.CategoryTotals = res.Totals(it) + } + return n } // findNode locates an item by absolute path, descending from the scanned root. @@ -281,15 +304,15 @@ func childByName(parent fs.Item, name string) (fs.Item, bool) { // buildNodeResponse assembles a node, its breadcrumbs, and immediate children, // sorted as requested. -func buildNodeResponse(it fs.Item, sortBy fs.SortBy, order fs.SortOrder) nodeResponse { +func buildNodeResponse(it fs.Item, sortBy fs.SortBy, order fs.SortOrder, res *cleanup.Result) nodeResponse { resp := nodeResponse{ - Node: toNodeJSON(it), + Node: toNodeJSON(it, res), Breadcrumbs: breadcrumbs(it), Children: []nodeJSON{}, } if it.IsDir() { for child := range it.GetFilesLocked(sortBy, order) { - resp.Children = append(resp.Children, toNodeJSON(child)) + resp.Children = append(resp.Children, toNodeJSON(child, res)) } } return resp @@ -332,8 +355,8 @@ func (q *expansionQueue) Pop() any { return last } -func buildTree(it fs.Item) treeJSON { - root := &treeJSON{nodeJSON: toNodeJSON(it), Children: []*treeJSON{}} +func buildTree(it fs.Item, res *cleanup.Result) treeJSON { + root := &treeJSON{nodeJSON: toNodeJSON(it, res), Children: []*treeJSON{}} if !it.IsDir() { return *root } @@ -347,7 +370,7 @@ func buildTree(it fs.Item) treeJSON { break } remaining-- - childJSON := &treeJSON{nodeJSON: toNodeJSON(child), Children: []*treeJSON{}} + childJSON := &treeJSON{nodeJSON: toNodeJSON(child, res), Children: []*treeJSON{}} next.slot.Children = append(next.slot.Children, childJSON) if child.IsDir() { heap.Push(queue, pendingExpansion{item: child, slot: childJSON}) @@ -362,7 +385,7 @@ func buildTree(it fs.Item) treeJSON { func breadcrumbs(it fs.Item) []nodeJSON { var chain []nodeJSON for cur := it; cur != nil; cur = cur.GetParent() { - chain = append(chain, toNodeJSON(cur)) + chain = append(chain, toNodeJSON(cur, nil)) } // reverse so the root comes first for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { diff --git a/webui/webui.go b/webui/webui.go index 037ccbabe..59957b2bd 100644 --- a/webui/webui.go +++ b/webui/webui.go @@ -7,12 +7,14 @@ import ( "encoding/json" "io" "sync" + "sync/atomic" "time" 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/cleanup" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/dundee/gdu/v5/pkg/remove" @@ -50,6 +52,12 @@ type UI struct { noDelete bool trasher func(fs.Item, fs.Item) error + classifier *cleanup.Classifier + cleanup cleanupCache + // treeGen counts changes to the tree that keep its root, such as a + // deletion or a rescan of a subdirectory. + treeGen atomic.Uint64 + hub *hub } @@ -95,6 +103,7 @@ func CreateUI( linkedItems: make(fs.HardLinkedItems), hub: newHub(), } + ui.classifier = cleanup.NewDefaultClassifier(userHomeDir()) if listenAddr == "" { ui.listenAddr = "localhost:0" } @@ -162,6 +171,7 @@ func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { } ui.scanning = false ui.mu.Unlock() + ui.treeChanged() close(scanDone) ui.hub.publish(ui.statusJSON()) diff --git a/webui/webui_test.go b/webui/webui_test.go index 566056d84..f394c6b94 100644 --- a/webui/webui_test.go +++ b/webui/webui_test.go @@ -1087,15 +1087,15 @@ func TestToNodeJSONFlag(t *testing.T) { // A file with a meaningful flag exposes it in the JSON payload. flagged := &analyze.File{Name: "denied", Flag: '!', Parent: parent} - assert.Equal(t, "!", toNodeJSON(flagged).Flag) + assert.Equal(t, "!", toNodeJSON(flagged, nil).Flag) // A blank flag (space) is omitted. blank := &analyze.File{Name: "ok", Flag: ' ', Parent: parent} - assert.Empty(t, toNodeJSON(blank).Flag) + assert.Empty(t, toNodeJSON(blank, nil).Flag) // A zero flag is also omitted. zero := &analyze.File{Name: "zero", Parent: parent} - assert.Empty(t, toNodeJSON(zero).Flag) + assert.Empty(t, toNodeJSON(zero, nil).Flag) } // TestToNodeJSONItemCount pins the wire format to the displayed item count, so @@ -1105,13 +1105,13 @@ func TestToNodeJSONItemCount(t *testing.T) { // A directory reports what it contains, not counting itself. dir := &analyze.Dir{File: &analyze.File{Name: "sub", Parent: parent}, ItemCount: 4} - if got := toNodeJSON(dir).ItemCount; got != 3 { + if got := toNodeJSON(dir, nil).ItemCount; got != 3 { t.Errorf("dir itemCount = %d, want 3", got) } // A file counts itself. file := &analyze.File{Name: "leaf", Parent: parent} - if got := toNodeJSON(file).ItemCount; got != 1 { + if got := toNodeJSON(file, nil).ItemCount; got != 1 { t.Errorf("file itemCount = %d, want 1", got) } }