diff --git a/CONTEXT.md b/CONTEXT.md index def7b5e1b..2fb59a341 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -79,3 +79,36 @@ 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 show its category, but they are not candidates of their own, +its space is counted once, and the "By category" projection lists only the +unit. A matched directory that holds an excluded path, or a path a more +specific rule matches, is split instead: its children become the units, so +deleting any candidate never takes an excluded item with it. + +## Classification + +Finding the cleanup candidates of the scanned tree and the space they take +per directory. It runs in the background once a scan ends or the tree +changes; the cleanup projections fill in when it is done. + +## 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/CONTRIBUTING.md b/CONTRIBUTING.md index 2be729812..62f97df68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,8 +19,8 @@ make install-dev-dependencies ## Project layout - `cmd/gdu` — CLI entrypoint and flag wiring. -- `pkg/` — reusable libraries: `analyze`, `device`, `fs`, `path`, `remove`, - `timefilter`, `annex`. +- `pkg/` — reusable libraries: `analyze`, `cleanup`, `device`, `fs`, `path`, + `remove`, `timefilter`, `annex`. - `stdout/`, `report/`, `tui/` — output backends (non-interactive stdout, JSON export/import, and the interactive terminal UI). - `webui/` — the optional React web UI (compiled output is committed). diff --git a/README.md b/README.md index 92fcc93fa..7747254fc 100644 --- a/README.md +++ b/README.md @@ -203,13 +203,55 @@ 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. You can keep browsing while deletions run; an item that is +being deleted cannot be opened. 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 (ignored +`dist`/`build` output counts as safe). Candidates are found in the background +after a scan, while the header shows "classifying…". They are only +highlighted: deleting stays a deliberate action. + +Paths can be excluded and rules added in the `cleanup` section of the config +file, see [configuration.md](configuration.md#cleanupexclude): + +```yaml +cleanup: + exclude: [~/work/current-project] +``` + 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/configuration.md b/configuration.md index d92b330bf..19ac4ca2b 100644 --- a/configuration.md +++ b/configuration.md @@ -212,7 +212,7 @@ Further details: * When the command fails, its exit status and standard error output are shown in an error dialog and the item stays in the listing. * When the command succeeds but the item is still present on disk, the item stays in the listing as well. Only the selected item is refreshed, so an item moved elsewhere by the command is not picked up at its new location. * `no-delete` disables the `D` key regardless of this option. -* Also used by the web UI's "Move to Trash" action, under the same contract. +* Also used by the web UI's "Move to Trash" action, under the same contract. The web UI reports an item the command left in place as an error. * Not supported on Windows, which has no POSIX shell to evaluate the command in. #### `browse-parent-dirs` @@ -232,6 +232,48 @@ Whether the web UI opens in the default browser on start. Enabled by default. Th Override the command used to open the browser (the URL is appended as the final argument), e.g. `firefox --new-window`. When empty (the default), the operating system's default handler is used. +#### `cleanup.exclude` + +Paths the web UI's cleanup projections never mark as candidates, nor anything below them. A leading `~/` stands for the home directory. + +A matched directory that holds an excluded path is not offered as a whole: its other children are marked instead, so deleting a highlighted candidate never takes an excluded item with it. Excluding a path does not protect it from a deliberate deletion. + +```yaml +cleanup: + exclude: + - ~/work/current-project +``` + +#### `cleanup.rules` + +Extra rules for the web UI's cleanup projections, checked before the built-in ones. The first rule that matches an item decides its category. Each rule has: + +* `category` - required, one of `build-artifact`, `package-cache`, `app-cache`, `toolchain`, `ml-model`, `installer` and `git-ignored`. +* `confidence` - `safe` (regenerated on demand) or `review` (probably unneeded, but check first). Defaults to `review`. +* Exactly one of: + * `dir` - a glob matched against the name of directories, e.g. `.gradle`. + * `file` - a glob matched against the name of files, e.g. `*.gguf`. + * `path` - a glob matched against the absolute path of any item, e.g. `~/.cache/my-tool`. A leading `~/` stands for the home directory, and `*` does not match `/`. +* `under` - only match items below this directory, e.g. `~/Downloads`. +* `markers` - only match when the item has a sibling with one of these names, e.g. `package.json` for `node_modules`. Markers may be globs, e.g. `*.xcodeproj`. +* `hint` - shown with the candidate, e.g. how to remove it cleanly. + +A matched directory is a unit: the items inside it show its category but are not candidates of their own. When an excluded path or the path of a more specific `path` rule lies below it, it is split instead and its children become units, so the more specific rule wins where it matches. + +Items no rule matches but a repository's `.gitignore` ignores are `git-ignored` candidates to review. + +```yaml +cleanup: + rules: + - category: build-artifact + confidence: safe + dir: .gradle + markers: [build.gradle, build.gradle.kts] + hint: Rebuilt by Gradle + - category: ml-model + path: ~/models/* +``` + #### `style.selected-row.text-color` 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..85c49e8ea --- /dev/null +++ b/pkg/cleanup/classify.go @@ -0,0 +1,479 @@ +package cleanup + +import ( + "fmt" + "path/filepath" + "slices" + "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 + // pathRules are the segments of every path rule's pattern, see pinnedBelow. + pathRules [][]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) + if c.rules[i].Path != "" { + c.pathRules = append(c.pathRules, splitPath(c.rules[i].Path)) + } + } + 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 + c *Classifier + matches map[fs.Item]Match + totals map[fs.Item]Totals + // splits holds the match of each split directory, which its children got + // unless a rule matched them on their own. + splits map[fs.Item]Match +} + +// 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. When it was a project marker, a .gitignore +// or a .git directory, the classification of its siblings may depend on it, so +// they are classified 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) + delete(r.splits, it) + parent := it.GetParent() + r.addToAncestors(parent, removed, -1) + if parent != nil && r.c.affectsSiblings(it.GetName()) { + r.reclassifyChildren(parent) + } +} + +// addToAncestors adds sign times delta to the totals of from and everything +// above it. +func (r *Result) addToAncestors(from fs.Item, delta Totals, sign int64) { + if len(delta) == 0 { + return + } + for p := from; p != nil; p = p.GetParent() { + // copy, since earlier callers of Totals may still hold the old map + next := make(Totals, len(r.totals[p])+len(delta)) + for c, a := range r.totals[p] { + next[c] = a + } + for c, d := range delta { + a := next[c] + a.Usage += sign * d.Usage + a.Size += sign * d.Size + if a.Usage > 0 || a.Size > 0 { + next[c] = a + } else { + delete(next, c) + } + } + if len(next) == 0 { + delete(r.totals, p) + } else { + r.totals[p] = next + } + } +} + +// reclassifyChildren classifies everything below dir again and passes the +// change of its totals on to its ancestors. +func (r *Result) reclassifyChildren(dir fs.Item) { + if _, ok := r.matchLocked(dir); ok { + // nothing inside a unit is classified on its own + return + } + old := r.totals[dir] + for child := range dir.GetFilesLocked(fs.SortByName, fs.SortAsc) { + r.clear(child) + } + delete(r.totals, dir) + + w := &walker{c: r.c, result: r} + // rebuild the .gitignore context the walk had when it reached dir + var ancestors []fs.Item + for p := dir.GetParent(); p != nil && !analyze.IsVirtualRootDir(p); p = p.GetParent() { + ancestors = append(ancestors, p) + } + for _, a := range slices.Backward(ancestors) { + w.enterDir(a.GetPath(), childNames(a)) + } + var carried *Match + if m, ok := r.splits[dir]; ok { + carried = &m + } + totals := w.walkChildren(dir, dir.GetPath(), carried) + + r.addToAncestors(dir.GetParent(), old, -1) + r.addToAncestors(dir.GetParent(), totals, 1) +} + +// clear drops the entries of it and everything below it. +func (r *Result) clear(it fs.Item) { + _, hasTotals := r.totals[it] + _, split := r.splits[it] + delete(r.matches, it) + delete(r.totals, it) + delete(r.splits, it) + // only directories the walk went into have entries below them + if !hasTotals && !split { + return + } + for child := range it.GetFilesLocked(fs.SortByName, fs.SortAsc) { + r.clear(child) + } +} + +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{ + c: c, + matches: make(map[fs.Item]Match), + totals: make(map[fs.Item]Totals), + splits: make(map[fs.Item]Match), + }, + } + w.walk(root, root.GetPath(), nil, nil) + return w.result +} + +type walker struct { + c *Classifier + result *Result + repos []*repo +} + +// walk classifies it and everything below it. carried is the match of a +// split directory above it, which it gets unless a rule matches it on its own. +func (w *walker) walk(it fs.Item, path string, siblings map[string]struct{}, carried *Match) Totals { + if w.c.excluded(path) { + return nil + } + m, ok := w.classify(it, path, siblings) + if !ok && carried != nil { + m, ok = *carried, true + } + if ok { + if !it.IsDir() || !w.c.pinnedBelow(path) { + w.result.matches[it] = m + return w.result.totalsLocked(it) + } + // Deleting it as a unit would take an excluded path or one a more + // specific rule has a say about with it, so it is split: its + // children become units of their own. + w.result.splits[it] = m + carried = &m + } + if !it.IsDir() { + return nil + } + return w.walkChildren(it, path, carried) +} + +// walkChildren walks the children of the directory it and records their +// totals as its own. +func (w *walker) walkChildren(it fs.Item, path string, carried *Match) Totals { + 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, carried)) + } + 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 && r.safeWhenIgnored { + // git-ignored build output is not committed, so it 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 +} + +// affectsSiblings reports whether an item of this name decides how its +// siblings are classified: a project marker, a .gitignore or a repository. +func (c *Classifier) affectsSiblings(name string) bool { + if name == ".gitignore" || name == ".git" { + return true + } + for i := range c.rules { + for _, marker := range c.rules[i].Markers { + if globMatch(marker, name) { + return true + } + } + } + return false +} + +func childNames(dir fs.Item) map[string]struct{} { + names := make(map[string]struct{}) + for child := range dir.GetFilesLocked(fs.SortByName, fs.SortAsc) { + names[child.GetName()] = struct{}{} + } + return names +} + +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 +} + +// pinnedBelow reports whether something strictly below the directory at path +// is excluded or may be matched by a path rule, so the directory must not be a +// unit. Path rules are compared segment by segment, so globs pin too. +func (c *Classifier) pinnedBelow(path string) bool { + for _, ex := range c.excludes { + if isBelow(ex, path) { + return true + } + } + segments := splitPath(path) + for _, pattern := range c.pathRules { + if len(pattern) > len(segments) && segmentsMatch(pattern[:len(segments)], segments) { + return true + } + } + return false +} + +func segmentsMatch(patterns, segments []string) bool { + for i, pattern := range patterns { + if !globMatch(pattern, segments[i]) { + return false + } + } + return true +} + +func splitPath(path string) []string { + return strings.Split(filepath.Clean(path), string(filepath.Separator)) +} + +// 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..b03cea72e --- /dev/null +++ b/pkg/cleanup/classify_test.go @@ -0,0 +1,531 @@ +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.venv\n*.gguf\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), + file("pyproject.toml", 1), + dir(".venv", file("v", 7)), + file("model.gguf", 8), + 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, "", ""}, + // build output that is also git-ignored is upgraded to safe + {[]string{"dist"}, true, BuildArtifact, Safe}, + // other matches keep their confidence: being ignored does not make + // them regenerable + {[]string{".venv"}, true, BuildArtifact, Review}, + {[]string{"model.gguf"}, true, MLModel, Review}, + {[]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) +} + +// remove takes it out of the tree and reports it, as a deletion does. +func remove(res *Result, it fs.Item) { + it.GetParent().(*analyze.Dir).RemoveFile(it) + res.Forget(it) +} + +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("notes.txt", 2)), + file("b.gguf", 60), + )) + res := newClassifier(t, Config{}).Analyze(root) + before := res.Totals(root) + + modules := child(t, root, "p", "node_modules") + remove(res, 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) + + remove(res, child(t, root, "p", "a.gguf")) + assert.Nil(t, res.Totals(child(t, root, "p"))) + + // forgetting an item without candidates changes nothing + remove(res, child(t, root, "p", "notes.txt")) + assert.Equal(t, Totals{MLModel: {Usage: 60, Size: 60}}, res.Totals(root)) +} + +func TestForgetMarkerReclassifiesSiblings(t *testing.T) { + root := build("/home", dir("u", + dir("p", file("package.json", 1), dir("node_modules", file("x", 100)), file("a.gguf", 40)), + )) + res := newClassifier(t, Config{}).Analyze(root) + before := res.Totals(root) + + remove(res, child(t, root, "p", "package.json")) + + _, ok := res.Match(child(t, root, "p", "node_modules")) + assert.False(t, ok, "node_modules without package.json is no build artifact") + assert.Equal(t, Totals{MLModel: {Usage: 40, Size: 40}}, res.Totals(child(t, root, "p"))) + assert.Equal(t, Totals{MLModel: {Usage: 40, Size: 40}}, res.Totals(root)) + assert.Equal(t, int64(100), before[BuildArtifact].Usage) +} + +func TestForgetGitignoreReclassifiesBelowIt(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("*.log\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "sub", ".gitignore"), []byte("out/\n"), 0o644)) + + root := build(base, dir("repo", + dir(".git", file("HEAD", 1)), + file(".gitignore", 1), + dir("sub", file(".gitignore", 1), dir("out", dir("o", file("x", 10))), file("a.log", 2)), + )) + res := newClassifier(t, Config{}).Analyze(root) + require.Equal(t, Totals{GitIgnored: {Usage: 12, Size: 12}}, res.Totals(root)) + + gitignore := child(t, root, "sub", ".gitignore") + require.NoError(t, os.Remove(filepath.Join(repoDir, "sub", ".gitignore"))) + remove(res, gitignore) + + _, ok := res.Match(child(t, root, "sub", "out")) + assert.False(t, ok) + m, ok := res.Match(child(t, root, "sub", "a.log")) + require.True(t, ok, "patterns of the .gitignore files above still apply") + assert.Equal(t, GitIgnored, m.Category) + assert.Equal(t, Totals{GitIgnored: {Usage: 2, Size: 2}}, res.Totals(root)) +} + +func TestForgetInsideUnitKeepsClassification(t *testing.T) { + root := build("/home", dir("u", + dir("p", file("package.json", 1), dir("node_modules", dir("dep", file("package.json", 1), file("i", 9)))), + )) + res := newClassifier(t, Config{}).Analyze(root) + + remove(res, child(t, root, "p", "node_modules", "dep", "package.json")) + + m, ok := res.Match(child(t, root, "p", "node_modules")) + require.True(t, ok) + assert.Equal(t, BuildArtifact, m.Category) + assert.Equal(t, Totals{BuildArtifact: {Usage: 9, Size: 9}}, res.Totals(root)) +} + +func TestForgetMarkerInsideSplitDirectory(t *testing.T) { + root := build("/home", dir("u", + dir(".cache", dir("lm-studio", + dir("models", file("m", 50)), + dir("app", file("package.json", 1), dir("node_modules", file("n", 7))), + )), + )) + res := newClassifier(t, Config{}).Analyze(root) + require.Equal(t, Totals{MLModel: {Usage: 50, Size: 50}, AppCache: {Usage: 8, Size: 8}}, res.Totals(root)) + + // app is a unit carried from the split lm-studio directory, so nothing in + // it is classified on its own and a marker inside it changes nothing + remove(res, child(t, root, ".cache", "lm-studio", "app", "package.json")) + assert.Equal(t, Totals{MLModel: {Usage: 50, Size: 50}, AppCache: {Usage: 7, Size: 7}}, 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"))) +} + +func TestMoreSpecificPathRuleSplitsUnit(t *testing.T) { + root := build("/home", dir("u", + dir(".cache", dir("lm-studio", + dir("models", file("m.gguf", 50)), + dir("logs", file("l", 3)), + file("state.json", 2), + )), + )) + res := newClassifier(t, Config{}).Analyze(root) + + _, ok := res.Match(child(t, root, ".cache", "lm-studio")) + assert.False(t, ok, "a directory holding a more specific rule's path is not a unit") + + m, ok := res.Match(child(t, root, ".cache", "lm-studio", "models")) + require.True(t, ok) + assert.Equal(t, MLModel, m.Category) + assert.Equal(t, Review, m.Confidence) + assert.False(t, m.Inherited) + + for _, name := range []string{"logs", "state.json"} { + m, ok := res.Match(child(t, root, ".cache", "lm-studio", name)) + require.True(t, ok, name) + assert.Equal(t, Match{Category: AppCache, Confidence: Safe, Hint: hintAppCache}, m, name) + } + assert.Equal(t, Totals{ + MLModel: {Usage: 50, Size: 50}, + AppCache: {Usage: 5, Size: 5}, + }, res.Totals(child(t, root, ".cache", "lm-studio"))) +} + +func TestExclusionSplitsUnit(t *testing.T) { + root := build("/home", dir("u", + dir("Library", dir("Caches", dir("foo", + dir("keep", file("k", 40)), + dir("tmp", dir("deep", file("t", 6))), + ))), + )) + res := newClassifier(t, Config{Exclude: []string{"~/Library/Caches/foo/keep"}}).Analyze(root) + + _, ok := res.Match(child(t, root, "Library", "Caches", "foo")) + assert.False(t, ok) + + keep := child(t, root, "Library", "Caches", "foo", "keep") + _, ok = res.Match(keep) + assert.False(t, ok, "an excluded path inside a matched directory is no candidate") + _, ok = res.Match(child(t, keep, "k")) + assert.False(t, ok, "nor is anything below it") + + m, ok := res.Match(child(t, root, "Library", "Caches", "foo", "tmp")) + require.True(t, ok) + assert.Equal(t, AppCache, m.Category) + assert.False(t, m.Inherited) + + assert.Equal(t, Totals{AppCache: {Usage: 6, Size: 6}}, res.Totals(root)) +} + +func TestGlobPathRuleSplitsUnit(t *testing.T) { + root := build("/home", dir("u", + dir("projects", dir("a", + file("package.json", 1), + dir("node_modules", dir("models", file("w", 20)), dir("lib", file("l", 4))), + )), + )) + res := newClassifier(t, Config{ + Rules: []Rule{{Category: MLModel, Path: "~/projects/*/node_modules/models"}}, + }).Analyze(root) + + modules := child(t, root, "projects", "a", "node_modules") + _, ok := res.Match(modules) + assert.False(t, ok) + + m, ok := res.Match(child(t, modules, "models")) + require.True(t, ok) + assert.Equal(t, MLModel, m.Category) + + m, ok = res.Match(child(t, modules, "lib")) + require.True(t, ok) + assert.Equal(t, BuildArtifact, m.Category) +} + +func TestForgetMarkerInSplitDirectoryCarriesItsMatch(t *testing.T) { + root := build("/home", dir("u", + dir(".cache", dir("lm-studio", + dir("models", file("m", 50)), + file("package.json", 1), + dir("node_modules", file("n", 7)), + )), + )) + res := newClassifier(t, Config{}).Analyze(root) + modules := child(t, root, ".cache", "lm-studio", "node_modules") + m, _ := res.Match(modules) + require.Equal(t, BuildArtifact, m.Category) + + remove(res, child(t, root, ".cache", "lm-studio", "package.json")) + + m, ok := res.Match(modules) + require.True(t, ok) + assert.Equal(t, AppCache, m.Category, "without its marker it gets the split directory's match") + assert.Equal(t, Totals{MLModel: {Usage: 50, Size: 50}, AppCache: {Usage: 7, Size: 7}}, res.Totals(root)) +} 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..7523e7763 --- /dev/null +++ b/pkg/cleanup/rules.go @@ -0,0 +1,274 @@ +// 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. When an excluded path or a more specific path rule +// lies below it, it is split instead: its children become units, with its +// classification unless a rule of their own matches them. +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"` + + // safeWhenIgnored upgrades a review match to safe when it is git-ignored, + // for build output whose only doubt is whether it is committed. + safeWhenIgnored bool +} + +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. Deeper ones (LM Studio models inside +// ~/.cache/lm-studio) win because the unit above them is split. +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", safeWhenIgnored: true}, + {Category: BuildArtifact, Confidence: Review, Dir: "build", Markers: buildMarkers, Hint: "Build output; check it is not committed", safeWhenIgnored: true}, + {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/webui/cleanup.go b/webui/cleanup.go new file mode 100644 index 000000000..15eeff318 --- /dev/null +++ b/webui/cleanup.go @@ -0,0 +1,297 @@ +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" +) + +// categoryNodePrefix marks the synthetic category nodes of the "By category" +// projection's tree. Such a path never resolves through findNode, so actions +// on it are refused. +const categoryNodePrefix = "category:" + +// cleanupCache holds the classification of the current tree, which is +// computed in the background. 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 + running bool +} + +// 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 +} + +// currentTree returns the tree to classify and its generation; ok is false +// while there is none or a scan runs. +func (ui *UI) currentTree() (root fs.Item, gen uint64, ok bool) { + ui.mu.RLock() + root, scanning := ui.topDir, ui.scanning + ui.mu.RUnlock() + return root, ui.treeGen.Load(), root != nil && !scanning +} + +// cleanupResult returns the classification of the current tree. It is nil +// while scanning and while the classification is computed, which this starts +// if needed. +func (ui *UI) cleanupResult() *cleanup.Result { + root, gen, ok := ui.currentTree() + if !ok { + return nil + } + c := &ui.cleanup + c.mu.Lock() + if c.res != nil && c.root == root && c.gen == gen { + defer c.mu.Unlock() + return c.res + } + c.mu.Unlock() + ui.classify() + return nil +} + +// requireCleanupResult is cleanupResult for requests that cannot be answered +// without it: when there is none, it responds 503 and returns nil. +func (ui *UI) requireCleanupResult(w http.ResponseWriter) *cleanup.Result { + res := ui.cleanupResult() + if res == nil { + msg := "classification is still running" + if _, _, ok := ui.currentTree(); !ok { + msg = "classification is unavailable while scanning" + } + writeError(w, http.StatusServiceUnavailable, msg) + } + return res +} + +// classify starts classifying the current tree in the background, unless its +// classification is cached or already being computed. +func (ui *UI) classify() { + c := &ui.cleanup + c.mu.Lock() + root, gen, ok := ui.currentTree() + if !ok || c.running || (c.res != nil && c.root == root && c.gen == gen) { + c.mu.Unlock() + return + } + c.running = true + ui.classifying.Store(true) + c.mu.Unlock() + + ui.hub.publish(ui.statusJSON()) + go ui.runClassification() +} + +// runClassification classifies the current tree until the result is cached. A +// walk that overlapped a deletion or another change of the tree is discarded, +// since it may have seen the tree before or after the change. +func (ui *UI) runClassification() { + c := &ui.cleanup + for { + c.mu.Lock() + root, gen, ok := ui.currentTree() + seq := ui.removalSeq.Load() + if !ok || seq%2 != 0 { + // the end of the scan or deletion starts classification again + ui.stopClassifyingLocked() + return + } + c.mu.Unlock() + + res := ui.classifier.Analyze(root) + + c.mu.Lock() + nowRoot, nowGen, ok := ui.currentTree() + if ok && nowRoot == root && nowGen == gen && ui.removalSeq.Load() == seq { + c.root, c.gen, c.res = root, gen, res + ui.stopClassifyingLocked() + return + } + c.mu.Unlock() + } +} + +// stopClassifyingLocked ends a classification run. It is called with the +// cache locked and unlocks it. +func (ui *UI) stopClassifyingLocked() { + ui.cleanup.running = false + ui.classifying.Store(false) + ui.cleanup.mu.Unlock() + ui.hub.publish(ui.statusJSON()) +} + +// treeChanged invalidates everything derived from the tree and classifies it +// again. +func (ui *UI) treeChanged() { + ui.treeGen.Add(1) + ui.classify() +} + +// removeNode runs removeFunc and updates the classification for the removal. +// It reports whether the item is gone from the tree. +func (ui *UI) removeNode(removeFunc func(fs.Item, fs.Item) error, parent, node fs.Item) (bool, error) { + ui.removalSeq.Add(1) + defer func() { + ui.removalSeq.Add(1) + // a classification the deletion interrupted starts again + ui.classify() + }() + + if err := removeFunc(parent, node); err != nil { + // a failed removal may still have removed part of the tree + ui.treeChanged() + return false, err + } + // a trash command may succeed and leave the item in place + if current, ok := childByName(parent, node.GetName()); ok && current == node { + return false, nil + } + ui.itemRemoved(node) + return true, nil +} + +// 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.requireCleanupResult(w) + if res == nil { + 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) +} + +// buildCategoryTree 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 buildCategoryTree(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: categoryNodePrefix + 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..8766cf350 --- /dev/null +++ b/webui/cleanup_test.go @@ -0,0 +1,246 @@ +package webui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "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?projection=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 TestTreeByCategoryGroupsCandidates(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?projection=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, categoryNodePrefix+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?projection=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 TestCategoryNodesAreNotActionable(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(categoryNodePrefix+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"}}})) +} + +func TestClassificationRunsInBackground(t *testing.T) { + ui := newTestUI() + scan(t, ui, makeCleanupTree(t)) + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + assert.False(t, ui.buildStatus().Classifying) + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/categories", nil)) + + // while a deletion runs, a walk may see the tree before or after it, so + // classification waits for the deletion to end + ui.removalSeq.Add(1) + ui.treeChanged() + require.Eventually(t, func() bool { return !ui.buildStatus().Classifying }, 5*time.Second, 10*time.Millisecond) + assert.Nil(t, ui.cleanupResult()) + assert.Equal(t, http.StatusServiceUnavailable, getJSON(t, srv, "/api/v1/categories", nil)) + var node nodeResponse + require.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/nodes", &node), "browsing does not wait for it") + assert.Empty(t, node.Node.CategoryTotals) + + ui.removalSeq.Add(1) + ui.classify() + waitClassified(t, ui) + assert.Equal(t, http.StatusOK, getJSON(t, srv, "/api/v1/categories", nil)) +} diff --git a/webui/dist/assets/index-B8cyjTHU.js b/webui/dist/assets/index-B8cyjTHU.js new file mode 100644 index 000000000..95f6ae520 --- /dev/null +++ b/webui/dist/assets/index-B8cyjTHU.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,j(x);else{var t=n(l);t!==null&&te(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&&te(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,te(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,j(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:``)?oe(e):``}function le(e){switch(e.tag){case 5:return oe(e.type);case 16:return oe(`Lazy`);case 13:return oe(`Suspense`);case 19:return oe(`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 ee:return`Profiler`;case D:return`StrictMode`;case j:return`Suspense`;case te:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case k:return(e.displayName||`Context`)+`.Consumer`;case O:return(e._context.displayName||`Context`)+`.Provider`;case A:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case M:return t=e.displayName||null,t===null?ue(e.type)||`Memo`:t;case N: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 F(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 _e(e,t){var n=t.checked;return P({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ve(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 ye(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function be(e,t){ye(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`)?Se(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&Se(e,t.type,fe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function xe(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 Se(e,t,n){(t!==`number`||F(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var Ce=Array.isArray;function we(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=je.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Ne(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Pe={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},Fe=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Pe).forEach(function(e){Fe.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Pe[t]=Pe[e]})});function Ie(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Pe.hasOwnProperty(e)&&Pe[e]?(``+t).trim():t+`px`}function Le(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Ie(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Re=P({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 ze(e,t){if(t){if(Re[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 Be(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 Ve=null;function He(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ue=null,We=null,Ge=null;function Ke(e){if(e=Wi(e)){if(typeof Ue!=`function`)throw Error(a(280));var t=e.stateNode;t&&(t=Ki(t),Ue(e.stateNode,e.type,t))}}function qe(e){We?Ge?Ge.push(e):Ge=[e]:We=e}function Je(){if(We){var e=We,t=Ge;if(Ge=We=null,Ke(e),t)for(e=0;e>>=0,e===0?32:31-(At(e)/jt|0)|0}var Nt=64,Pt=4194304;function Ft(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 It(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=Ft(a))):r=Ft(s)}else o=n&~i,o===0?a!==0&&(r=Ft(a)):r=Ft(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 Ht(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-kt(t),e[t]=n}function Ut(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=ir),sr=` `,cr=!1;function lr(e,t){switch(e){case`keyup`:return nr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function ur(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var dr=!1;function fr(e,t){switch(e){case`compositionend`:return ur(t);case`keypress`:return t.which===32?(cr=!0,sr):null;case`textInput`:return e=t.data,e===sr&&cr?null:e;default:return null}}function pr(e,t){if(dr)return e===`compositionend`||!rr&&lr(e,t)?(e=Dn(),En=Tn=wn=null,dr=!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=Fr(n)}}function Lr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Lr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Rr(){for(var e=window,t=F();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=F(e.document)}return t}function zr(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 Br(e){var t=Rr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Lr(n.ownerDocument.documentElement,n)){if(r!==null&&zr(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=Ir(n,a);var o=Ir(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,Hr=null,Ur=null,Wr=null,Gr=!1;function Kr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Gr||Hr==null||Hr!==F(r)||(r=Hr,`selectionStart`in r&&zr(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}),Wr&&Pr(Wr,r)||(Wr=r,r=_i(Ur,`onSelect`),0Ji||(e.current=qi[Ji],qi[Ji]=null,Ji--)}function B(e,t){Ji++,qi[Ji]=e.current,e.current=t}var Xi={},Zi=Yi(Xi),Qi=Yi(!1),$i=Xi;function ea(e,t){var n=e.type.contextTypes;if(!n)return Xi;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(){z(Qi),z(Zi)}function ra(e,t,n){if(Zi.current!==Xi)throw Error(a(168));B(Zi,t),B(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 P({},n,r)}function aa(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Xi,$i=Zi.current,B(Zi,e),B(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,z(Qi),z(Zi),B(Zi,e)):z(Qi),B(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=L;try{var n=sa;for(L=1;e>=o,i-=o,ba=1<<32-kt(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===N&&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 N:return l=a._init,_(e,r,l(a._payload),o)}if(Ce(a))return h(e,r,a,o);if(ie(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=Yi(null),Ka=null,qa=null,Ja=null;function Ya(){Ja=qa=Ka=null}function Xa(e){var t=Ga.current;z(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,Wt(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=P({},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{L=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,Nr(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,Wt(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-kt(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[Li]=t,e[Ri]=r,cc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Be(n,r),n){case`dialog`:R(`cancel`,e),R(`close`,e),i=r;break;case`iframe`:case`object`:case`embed`:R(`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*I()-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=I(),t.sibling=null,n=H.current,B(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(),z(Qi),z(Zi),To(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return So(t),null;case 13:if(z(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 z(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(Ti=_n,e=Rr(),zr(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(Ei={focusedElem:e,selectionRange:n},_n=!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[Li],delete t[Ri],delete t[Bi],delete t[Vi],delete t[Hi])),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=wi));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(Dt&&typeof Dt.onCommitFiberUnmount==`function`)try{Dt.onCommitFiberUnmount(Et,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?Ni(e.parentNode,n):e.nodeType===1&&Ni(e,n),hn(e)):Ni(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=I()-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;lI()-el?El(e,0):Zc|=n),gl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=Pt,Pt<<=1,!(Pt&130023424)&&(Pt=4194304)):t=1);var n=pl();e=ro(e,t),e!==null&&(Ht(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,Zi.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=Pi(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,Di(r,i)?s=null:o!==null&&Di(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,B(Ga,r._currentValue),r._currentValue=s,o!==null){if(Nr(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 gt(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===A)return 11;if(e===M)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 ee:return e=Kl(12,n,t,i|2),e.elementType=ee,e.lanes=o,e;case j:return e=Kl(13,n,t,i),e.elementType=j,e.lanes=o,e;case te:return e=Kl(19,n,t,i),e.elementType=te,e.lanes=o,e;case ne:return Ql(n,i,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case O:s=10;break a;case k:s=9;break a;case A:s=11;break a;case M:s=14;break a;case N: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=ne,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=Vt(0),this.expirationTimes=Vt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Vt(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(`projection`,`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=[``,`Ki`,`Mi`,`Gi`,`Ti`,`Pi`,`Ei`],ee=[``,`k`,`M`,`G`,`T`,`P`,`E`];function O(e,t=!1){let n=t?1e3:1024,r=t?ee:D,i=Math.abs(e),a=0;for(;i>=n&&a0),c=`${r[a]}B`;return`${o}${i.toFixed(s)} ${c}`}function k(e){return e.toLocaleString(`en-US`)}function A(e){return e?new Date(e*1e3).toLocaleString(void 0,{year:`numeric`,month:`short`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`}):``}function j(e,t){if(t<=0)return 0;let n=e/t*100;return n<0?0:n>100?100:n}var te=[{id:`location`,label:`Location`},{id:`cleanup`,label:`Cleanup`},{id:`age`,label:`Age`},{id:`category`,label:`By category`}],M=`category:`;function N(e){return e.path.startsWith(M)}var ne=`#353b47`,re=`#f08c00`,ie=`#c9a25e`,P={"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 ae(e,t){return t?e.size:e.usage}function oe(e,t){let n=0;for(let r of Object.values(e.categoryTotals??{}))r&&(n+=ae(r,t));return n}function se(e,t){let n=ae(e,t);return n<=0?0:Math.min(oe(e,t)/n,1)}function ce(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 le(e,t){if(e.confidence===`safe`)return re;if(e.confidence===`review`)return ie;let n=se(e,t);return n>0?ce(ne,re,.25+n*.5):ne}var ue=86400,de=[{maxDays:30,label:`< 1 month`,color:ne},{maxDays:182,label:`< 6 months`,color:`#5b5440`},{maxDays:365,label:`< 1 year`,color:`#9a7330`},{maxDays:1/0,label:`older`,color:re}];function fe(e,t){if(e<=0)return ne;let n=(t-e)/ue;return(de.find(e=>n({category:e,bytes:n?ae(n,t):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=>`${O(e.bytes,n)} ${(r.get(e.category)??e.category).toLowerCase()}`);return a.length>2&&o.push(`+${a.length-2} more`),`${o.join(`, `)} · ${pe(e.mtime,i)}`}function he(e,t){let n=e.categoryTotals?.[t];return e.category===t||n!==void 0&&(n.usage>0||n.size>0)}var ge=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})),F=e(((e,t)=>{t.exports=ge()}))(),_e=`gdu.actionToken`,ve=`gdu.skipDeleteConfirm`;function ye(e){try{return window.sessionStorage.getItem(e)}catch{return null}}function be(e,t){try{t===null?window.sessionStorage.removeItem(e):window.sessionStorage.setItem(e,t)}catch{}}function xe(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 Se(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&&xe(e,o)===`deleted`||(n+=ae(a,t))}return n}var Ce=(0,c.createContext)(null);function we(){let e=(0,c.useContext)(Ce);if(!e)throw Error(`useGduModel must be used within GduModelProvider`);return e}function Te({model:e,children:t}){return(0,F.jsx)(Ce.Provider,{value:e,children:t})}function Ee(){let[e]=(0,c.useState)(()=>ye(_e)??``),[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),[ee,O]=(0,c.useState)(!1),[k,A]=(0,c.useState)(null),[j,te]=(0,c.useState)(null),[M,N]=(0,c.useState)(()=>new Map),[ne,re]=(0,c.useState)(0),ie=(0,c.useRef)(0),P=(0,c.useRef)(!1),ae=E(M),[oe,se]=(0,c.useState)(0),ce=(0,c.useRef)([]),[le,ue]=(0,c.useState)(()=>{let e=ye(ve);return e===`trash`||e===`permanent`?e:null}),de=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,oe]);let fe=t?.classifying??!1,pe=(0,c.useRef)(fe);(0,c.useEffect)(()=>{pe.current&&!fe&&(te(null),se(e=>e+1)),pe.current=fe},[fe]),(0,c.useEffect)(()=>{let e=e=>{let t=e.target;t&&[`INPUT`,`TEXTAREA`,`SELECT`].includes(t.tagName)||e.key===`?`&&(e.preventDefault(),O(!0))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[]);let me=m??t?.showApparentSize??!1,he=t?.useSIPrefix??!1,ge=a?.children??[],{total:F}=(0,c.useMemo)(()=>T(ge,me),[ge,me]),Se=(0,c.useMemo)(()=>w(ge,me),[ge,me]),Ce=(0,c.useCallback)(e=>{e===s?d(e=>e===`asc`?`desc`:`asc`):(l(e),d(e===`name`?`asc`:`desc`))},[s]),we=(0,c.useCallback)(e=>{ae.current&&xe(ae.current,e)||(P.current&&(P.current=ie.current>0,N(e=>new Map([...e].filter(([,e])=>e.state===`pending`))),te(null),A(null),se(e=>e+1)),i(e),S(null))},[ae]),Te=(0,c.useCallback)(e=>{ie.current+=1,P.current=!0,re(ie.current),N(t=>new Map(t).set(e.path,{state:`pending`,node:e}))},[]),Ee=(0,c.useCallback)((e,t)=>{if(ie.current=Math.max(ie.current-1,0),re(ie.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&&(P.current=!0),t!==null&&ce.current.push(t),ie.current>0)return null;let n=ce.current;return ce.current=[],n},[]),De=(0,c.useCallback)(e=>{e.isDir&&we(e.path)},[we]);return{status:t,actionToken:e,currentPath:r,nodeResp:a,children:ge,effectiveApparent:me,useSIPrefix:he,total:F,colorMap:Se,sort:s,order:u,onSortChange:Ce,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:De,navigateToPath:we,refreshNode:(0,c.useCallback)(async()=>{if(r===null)throw Error(`no current path`);let e=r,t=await p(e,s,u);return de.current===e&&(o(t),D(null)),t},[r,s,u]),showHelp:ee,setShowHelp:O,treeRoot:k,treePath:j,setTree:(0,c.useCallback)((e,t)=>{te(e),A(t)},[]),skipDeleteConfirm:le,setSkipDeleteConfirm:(0,c.useCallback)(e=>{ue(e),be(ve,e)},[]),deletions:M,deletionsInFlight:ne,beginDeletion:Te,settleDeletion:Ee}}function De({breadcrumbs:e,onNavigate:t}){return(0,F.jsx)(`nav`,{className:`breadcrumbs`,"aria-label":`Path`,children:e.map((n,r)=>{let i=r===e.length-1;return(0,F.jsxs)(`span`,{className:`crumb`,children:[i?(0,F.jsx)(`span`,{className:`crumb-current`,children:n.name||n.path}):(0,F.jsx)(`button`,{type:`button`,className:`crumb-link`,onClick:()=>t(n),children:n.name||n.path}),!i&&(0,F.jsx)(`span`,{className:`crumb-sep`,children:`/`})]},n.path)})})}function Oe(){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}=we(),p=Se(f,c);return(0,F.jsxs)(`header`,{className:`app-header`,children:[(0,F.jsxs)(`div`,{className:`app-title`,children:[(0,F.jsx)(`span`,{className:`logo`,children:`gdu`}),t&&(0,F.jsx)(De,{breadcrumbs:t.breadcrumbs,onNavigate:u})]}),(0,F.jsxs)(`div`,{className:`app-actions`,children:[(0,F.jsx)(`span`,{className:`total`,children:O(n,r)}),(0,F.jsx)(`button`,{type:`button`,className:`toggle`,onClick:a,title:`Toggle between donut and treemap charts`,children:i===`donut`?`Treemap`:`Donut`}),i===`treemap`&&(0,F.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:te.map(e=>(0,F.jsx)(`option`,{value:e.id,children:e.label},e.id))}),(0,F.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,F.jsx)(`span`,{className:`scanning-badge`,children:`scanning…`}),e.classifying&&(0,F.jsx)(`span`,{className:`scanning-badge`,role:`status`,title:`Looking for cleanup candidates; the cleanup projections fill in when it is done`,children:`classifying…`}),d>0&&(0,F.jsxs)(`span`,{className:`scanning-badge`,role:`status`,title:`Deleting in the background; you can keep browsing`,children:[`deleting `,d,`…`]}),p>0&&(0,F.jsxs)(`span`,{className:`freed-badge`,title:`Freed by deletions in this directory; sizes update when you open another directory`,children:[`freed `,O(p,r)]})]})]})}var ke=240,Ae=ke/2,je=90,Me=34,Ne=2*Math.PI*je;function Pe({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:O(s,n),d=l?`${O(l.value,n)} · ${j(l.value,s).toFixed(1)}%`:`${o.length} item${o.length===1?``:`s`}`,f=0;return(0,F.jsxs)(`div`,{className:`donut`,role:`img`,"aria-label":`Directory size breakdown`,children:[(0,F.jsx)(`svg`,{viewBox:`0 0 ${ke} ${ke}`,width:`100%`,height:`100%`,children:(0,F.jsxs)(`g`,{transform:`rotate(-90 ${Ae} ${Ae})`,children:[(0,F.jsx)(`circle`,{cx:Ae,cy:Ae,r:je,fill:`none`,stroke:`var(--track)`,strokeWidth:Me}),s>0&&o.map(e=>{let t=e.value/s,n=t*Ne,o=-f*Ne;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,F.jsx)(`circle`,{className:`donut-slice`,cx:Ae,cy:Ae,r:je,fill:`none`,stroke:e.color,strokeWidth:l?40:Me,strokeDasharray:`${n} ${Ne-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,F.jsxs)(`div`,{className:`donut-center`,children:[(0,F.jsx)(`div`,{className:`donut-center-primary`,title:u,children:u}),(0,F.jsx)(`div`,{className:`donut-center-secondary`,children:d})]})]})}function Fe(){let{children:e,effectiveApparent:t,useSIPrefix:n,hoveredPath:r,setHoveredPath:i,handleSelect:a}=we();return(0,F.jsx)(`section`,{className:`chart-panel`,children:(0,F.jsx)(Pe,{children:e,apparent:t,useSIPrefix:n,hoveredPath:r,onHover:i,onSelect:a})})}var Ie=`Actions are unavailable: this page has no action token. Reopen the URL gdu printed, or restart gdu.`;function Le(){let{actionToken:e,status:t,skipDeleteConfirm:n,setLoadError:r,deletions:i,beginDeletion:a,settleDeletion:o}=we(),[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(Ie);return}if(xe(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(Ie);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||xe(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 Re=[{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 ze({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,F.jsxs)(`table`,{className:`file-table`,children:[(0,F.jsx)(`thead`,{children:(0,F.jsxs)(`tr`,{children:[Re.map(e=>(0,F.jsxs)(`th`,{className:e.numeric?`num`:``,onClick:()=>s(e.key),children:[e.label,a===e.key&&(0,F.jsx)(`span`,{className:`sort-arrow`,children:o===`asc`?` ▲`:` ▼`})]},e.key)),(0,F.jsx)(`th`,{className:`actions-col`,"aria-label":`Actions`})]})}),(0,F.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,F.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,F.jsxs)(`td`,{className:`name-cell`,children:[(0,F.jsx)(`span`,{className:`swatch`,style:{backgroundColor:o}}),(0,F.jsxs)(`span`,{className:`name`,children:[e.isDir?`📁`:`📄`,` `,e.name,e.flag===`!`&&(0,F.jsx)(`span`,{className:`flag-error`,title:`Access error`,children:` !`}),c&&(0,F.jsxs)(`span`,{className:`muted`,children:[` · `,c===`pending`?`deleting…`:`deleted`]})]}),(0,F.jsx)(`span`,{className:`bar`,style:{width:`${a}%`,backgroundColor:o}})]}),(0,F.jsx)(`td`,{className:`num`,children:O(i,r)}),(0,F.jsx)(`td`,{className:`num`,children:k(e.itemCount)}),(0,F.jsx)(`td`,{className:`num muted`,children:A(e.mtime)}),(0,F.jsxs)(`td`,{className:`actions-cell`,onClick:e=>e.stopPropagation(),children:[(0,F.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,F.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,F.jsx)(`tr`,{children:(0,F.jsx)(`td`,{colSpan:5,className:`empty`,children:`Empty directory`})})]}),(0,F.jsx)(`tfoot`,{children:(0,F.jsxs)(`tr`,{children:[(0,F.jsxs)(`td`,{className:`muted`,children:[e.length,` items`]}),(0,F.jsx)(`td`,{className:`num`,children:O(i,r)}),(0,F.jsx)(`td`,{className:`num muted`,colSpan:3,children:j(i,i)>0?`100%`:``})]})})]})}function Be({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,F.jsx)(`div`,{className:`modal-backdrop`,children:(0,F.jsxs)(`section`,{ref:a,className:`modal ${r??``}`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":e,children:[(0,F.jsx)(`h2`,{id:e,children:t}),i]})})}function Ve({actions:e}){let{effectiveApparent:t,useSIPrefix:n,setSkipDeleteConfirm:r}=we(),{deleteCandidate:i,skipDeleteChoice:a,setSkipDeleteChoice:o,actionPending:s,cancelDelete:c,performDelete:l}=e;return i?(0,F.jsxs)(Be,{titleId:`delete-title`,title:`Delete item`,onClose:c,children:[(0,F.jsx)(`p`,{children:`Move the selected item to the trash, or delete it permanently right away.`}),(0,F.jsx)(`code`,{className:`modal-path`,children:i.path}),(0,F.jsx)(`span`,{className:`muted`,children:O(t?i.size:i.usage,n)}),(0,F.jsxs)(`label`,{className:`modal-checkbox`,children:[(0,F.jsx)(`input`,{type:`checkbox`,checked:a,onChange:e=>o(e.target.checked)}),`Do not ask again this session`]}),(0,F.jsxs)(`div`,{className:`modal-actions`,children:[(0,F.jsx)(`button`,{type:`button`,autoFocus:!0,onClick:c,children:`Cancel`}),(0,F.jsx)(`button`,{type:`button`,disabled:s,onClick:()=>{a&&r(`trash`),l(i,`trash`)},children:`Move to Trash`}),(0,F.jsx)(`button`,{type:`button`,className:`danger`,disabled:s,onClick:()=>{a&&r(`permanent`),l(i,`permanent`)},children:`Delete Permanently`})]})]}):null}function He(){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}=we(),p=Le();return(0,F.jsxs)(`section`,{className:`table-panel`,children:[(0,F.jsx)(ze,{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,F.jsx)(Ve,{actions:p})]})}function Ue(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 We(){return this.eachAfter(Ue)}function Ge(e,t){let n=-1;for(let r of this)e.call(t,r,++n,this);return this}function Ke(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 qe(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 Xe(e){return this.eachBefore(function(t){t.children&&t.children.sort(e)})}function Ze(e){for(var t=this,n=Qe(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 Qe(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 $e(){for(var e=this,t=[e];e=e.parent;)t.push(e);return t}function et(){return Array.from(this)}function tt(){var e=[];return this.eachBefore(function(t){t.children||e.push(t)}),e}function nt(){var e=this,t=[];return e.each(function(n){n!==e&&t.push({source:n.parent,target:n})}),t}function*rt(){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 ut(o[s])),a.parent=r,a.depth=r.depth+1;return n.eachBefore(lt)}function at(){return it(this).eachBefore(ct)}function ot(e){return e.children}function st(e){return Array.isArray(e)?e[1]:null}function ct(e){e.data.value!==void 0&&(e.value=e.data.value),e.data=e.data.data}function lt(e){var t=0;do e.height=t;while((e=e.parent)&&e.height<++t)}function ut(e){this.data=e,this.depth=this.height=0,this.parent=null}ut.prototype=it.prototype={constructor:ut,count:We,each:Ge,eachAfter:qe,eachBefore:Ke,find:Je,sum:Ye,sort:Xe,path:Ze,ancestors:$e,descendants:et,leaves:tt,links:nt,copy:at,[Symbol.iterator]:rt};function dt(e){if(typeof e!=`function`)throw Error();return e}function ft(){return 0}function pt(e){return function(){return e}}function mt(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 ht(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})(_t);function I(){var e=yt,t=!1,n=1,r=1,i=[0],a=ft,o=ft,s=ft,c=ft,l=ft;function u(e){return e.x0=e.y0=0,e.x1=n,e.y1=r,e.eachBefore(d),i=[0],t&&e.eachBefore(mt),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=it({children:e.map(({value:e,item:t})=>({value:e,item:t}))}).sum(e=>e.value??0);return I().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 xt(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=bt(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=`#ffe066`,Ct=300,wt=200,Tt=24,Et=2e3,Dt=2;function Ot({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:Ct,height:wt});(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)(()=>xt(e,{width:_.width,height:_.height,apparent:t,minArea:Tt,maxRects:Et,inset:Dt}),[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,F.jsxs)(`div`,{ref:g,className:`treemap ${S===null?``:`hovering`}`,role:`img`,"aria-label":`Directory size treemap`,children:[(0,F.jsxs)(`svg`,{viewBox:`0 0 ${_.width} ${_.height}`,preserveAspectRatio:`none`,width:`100%`,height:`100%`,children:[(0,F.jsxs)(`defs`,{children:[(0,F.jsxs)(`pattern`,{id:`treemap-pending`,width:`6`,height:`6`,patternUnits:`userSpaceOnUse`,patternTransform:`rotate(45)`,children:[(0,F.jsx)(`rect`,{width:`6`,height:`6`,fill:`#14171c`}),(0,F.jsx)(`rect`,{width:`2`,height:`6`,fill:`#3a4150`})]}),(0,F.jsxs)(`pattern`,{id:`treemap-deleted`,width:`6`,height:`6`,patternUnits:`userSpaceOnUse`,patternTransform:`rotate(45)`,children:[(0,F.jsx)(`rect`,{width:`6`,height:`6`,fill:`#14171c`}),(0,F.jsx)(`rect`,{width:`1.5`,height:`6`,fill:`#4a5262`}),(0,F.jsx)(`rect`,{width:`6`,height:`1.5`,fill:`#4a5262`})]}),(0,F.jsxs)(`linearGradient`,{id:`treemap-shine`,x1:`0`,y1:`0`,x2:`1`,y2:`1`,children:[(0,F.jsx)(`stop`,{offset:`0`,stopColor:`#fff`,stopOpacity:`0.28`}),(0,F.jsx)(`stop`,{offset:`0.48`,stopColor:`#fff`,stopOpacity:`0.04`}),(0,F.jsx)(`stop`,{offset:`1`,stopColor:`#000`,stopOpacity:`0.3`})]})]}),(0,F.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?xe(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,k=Math.max(e.w-.5,0),A=Math.max(e.h-.5,0);return(0,F.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}, ${O(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,F.jsx)(`rect`,{x:e.x,y:e.y,width:k,height:A,fill:_?`url(#treemap-${_})`:v===`match`&&f?f:u?u(e):r.get(e.colorPath)??`#4a5568`,stroke:d||p?`#fff`:g?`#55b6ff`:b?St:`rgba(0,0,0,0.35)`,strokeWidth:d?1.5:b?2:p||g?1.1:.4}),!_&&(0,F.jsx)(`rect`,{className:`treemap-shine`,x:e.x,y:e.y,width:k,height:A,fill:`url(#treemap-shine)`}),D&&(0,F.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,F.jsxs)(`div`,{className:`treemap-label`,children:[(0,F.jsx)(`span`,{className:`treemap-label-name`,children:c.name}),ee&&(0,F.jsx)(`span`,{className:`treemap-label-size`,children:O(C(c,t),n)})]})})]},c.path)})]}),S&&(0,F.jsxs)(`div`,{className:`treemap-tooltip`,children:[(0,F.jsx)(`b`,{children:S.name}),(0,F.jsx)(`span`,{children:S.path}),(0,F.jsxs)(`span`,{children:[O(C(S,t),n),` ·`,` `,j(C(S,t),b).toFixed(1),`%`,S.isDir?` · ${k(S.itemCount)} item${S.itemCount===1?``:`s`}`:``]}),m&&xe(m,S.path)&&(0,F.jsx)(`span`,{className:`treemap-tooltip-detail`,children:xe(m,S.path)===`pending`?`Deleting…`:`Deleted; the view updates when you open another directory`}),p?.(S)&&(0,F.jsx)(`span`,{className:`treemap-tooltip-detail`,children:p(S)})]})]})}function kt({color:e}){return(0,F.jsx)(`span`,{className:`legend-swatch`,style:{background:e}})}function At({projection:e,categories:t,apparent:n,useSIPrefix:r,filter:i,onFilter:a}){if(e===`location`)return null;if(e===`age`)return(0,F.jsxs)(`div`,{className:`legend`,"aria-label":`Age legend`,children:[(0,F.jsx)(`span`,{className:`legend-title`,children:`Last change`}),de.map(e=>(0,F.jsxs)(`span`,{className:`legend-key`,children:[(0,F.jsx)(kt,{color:e.color}),e.label]},e.label))]});if(!t)return(0,F.jsx)(`div`,{className:`legend legend-loading`,children:`Classifying…`});let o=ae(t.total,n),s=t.categories.map(e=>({...e,bytes:ae(e,n)})).filter(e=>e.bytes>0),c=s.reduce((e,t)=>e+t.bytes,0);return(0,F.jsxs)(`div`,{className:`legend`,"aria-label":`Cleanup legend`,children:[s.length===0?(0,F.jsx)(`span`,{className:`legend-title`,children:`No cleanup candidates here`}):(0,F.jsxs)(`span`,{className:`legend-title`,children:[`Candidates: `,O(c,r),` (`,j(c,o).toFixed(0),`%)`]}),e===`cleanup`&&s.length>0&&(0,F.jsxs)(F.Fragment,{children:[(0,F.jsxs)(`span`,{className:`legend-key`,children:[(0,F.jsx)(kt,{color:`#f08c00`}),`safe`]}),(0,F.jsxs)(`span`,{className:`legend-key`,children:[(0,F.jsx)(kt,{color:`#c9a25e`}),`review first`]})]}),s.map(e=>{let t=i===e.id,n=P[e.id];return(0,F.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,F.jsx)(kt,{color:n}),e.label,` · `,O(e.bytes,r),` · `,j(e.bytes,o).toFixed(0),`%`]},e.id)}),i&&(0,F.jsx)(`button`,{type:`button`,className:`legend-clear`,onClick:()=>a(null),children:`Show all`})]})}function jt(){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:_}=we(),[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===`category`,O=p===`cleanup`||ee,k=ee?M+e:e;(0,c.useEffect)(()=>{C(null),D(null)},[e]);let A=s.classifying;(0,c.useEffect)(()=>{if(l!==`treemap`||!O||e===null)return;let t=!1;if(T(null),!A)return h(e).then(e=>{t||T(e)}).catch(()=>void 0),()=>{t=!0}},[e,O,l,A]),(0,c.useEffect)(()=>{if(l!==`treemap`||e===null||d===k||ee&&A)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,A,l,f]);let j=Le();(0,c.useEffect)(()=>{S&&xe(_,S.path)&&C(null)},[_,S]);let{deleteCandidate:ne,reveal:re,requestDelete:ie}=j,ae=(0,c.useCallback)(async()=>{S&&await re(S)},[re,S]),oe=(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))&&!ne){if(e.key===`Escape`&&E)e.preventDefault(),D(null);else if(e.code===`KeyP`&&!e.metaKey&&!e.ctrlKey){e.preventDefault();let t=te.findIndex(e=>e.id===p);g(te[(t+1)%te.length].id)}else e.code===`KeyO`&&S?(e.preventDefault(),ae()):(e.code===`KeyD`||e.key===`Delete`)&&S&&s.deleteAllowed&&(e.preventDefault(),oe())}};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[ne,oe,ae,S,s.deleteAllowed,p,g,E]);let se=(0,c.useMemo)(()=>new Map((w?.categories??[]).map(e=>[e.id,e.label])),[w]),ce=(0,c.useMemo)(()=>Date.now()/1e3,[u]),ue=(0,c.useMemo)(()=>{switch(p){case`cleanup`:return e=>le(e.node,t);case`age`:return e=>fe(e.node.mtime,ce);case`category`:return e=>P[e.colorPath.slice(M.length)]??`#353b47`;default:return}},[p,t,ce]),de=(0,c.useMemo)(()=>{if(!(!O||!E))return e=>N(e)?e.category===E?`contains`:`none`:e.category===E?`match`:he(e,E)?`contains`:`none`},[O,E]),ge=(0,c.useMemo)(()=>{if(p!==`location`)return p===`age`?e=>pe(e.mtime,ce):e=>N(e)?null:[me(e,t,n,se,ce),e.hint].filter(Boolean).join(` — `)||null},[p,t,n,se,ce]),_e=(0,c.useCallback)(e=>{!N(e)&&!xe(_,e.path)&&C(e)},[_]),ve=(0,c.useCallback)(e=>{e.startsWith(`category:`)||o(e)},[o]);return(0,F.jsxs)(F.Fragment,{children:[(0,F.jsxs)(`section`,{className:`chart-panel ${p===`location`?``:`with-legend`}`,children:[(0,F.jsx)(At,{projection:p,categories:w,apparent:t,useSIPrefix:n,filter:E,onFilter:D}),u&&d===k?(0,F.jsx)(Ot,{root:u,apparent:t,useSIPrefix:n,colorMap:r,hoveredPath:i,selectedPath:S?.path??null,onHover:a,onSelect:_e,onOpen:ve,colorFor:ue,focus:de,focusColor:E?P[E]:void 0,tooltipDetail:ge,deletions:_,dimOnHover:p===`location`}):(0,F.jsxs)(`div`,{className:`treemap-loading`,children:[v&&(0,F.jsx)(`span`,{className:`spinner compact`}),(0,F.jsx)(`span`,{children:b?`Treemap unavailable`:ee&&A?`Classifying…`:`Loading treemap…`})]})]}),(0,F.jsx)(Ve,{actions:j})]})}function Mt(){let{showHelp:e,setShowHelp:t,view:n}=we();return e?(0,F.jsxs)(Be,{titleId:`shortcuts-title`,title:`Keyboard shortcuts`,className:`shortcuts-modal`,onClose:()=>t(!1),children:[(0,F.jsxs)(`dl`,{className:`shortcuts`,children:[n===`treemap`&&(0,F.jsxs)(F.Fragment,{children:[(0,F.jsx)(`dt`,{children:`O`}),(0,F.jsx)(`dd`,{children:`Reveal selected item`}),(0,F.jsx)(`dt`,{children:`D / Delete`}),(0,F.jsx)(`dd`,{children:`Delete selected item`}),(0,F.jsx)(`dt`,{children:`Click`}),(0,F.jsx)(`dd`,{children:`Select a treemap item`}),(0,F.jsx)(`dt`,{children:`Double-click`}),(0,F.jsx)(`dd`,{children:`Open a directory`}),(0,F.jsx)(`dt`,{children:`P`}),(0,F.jsx)(`dd`,{children:`Switch projection: location, cleanup candidates (safe / review first), age of the last change, or candidates grouped by category`})]}),n===`donut`&&(0,F.jsxs)(F.Fragment,{children:[(0,F.jsx)(`dt`,{children:`Row buttons`}),(0,F.jsx)(`dd`,{children:`Reveal or delete an item in the table`})]}),(0,F.jsx)(`dt`,{children:`?`}),(0,F.jsx)(`dd`,{children:`Show this help`}),(0,F.jsx)(`dt`,{children:`Esc`}),(0,F.jsx)(`dd`,{children:`Close a dialog, or show all categories again`})]}),(0,F.jsx)(`div`,{className:`modal-actions`,children:(0,F.jsx)(`button`,{type:`button`,autoFocus:!0,onClick:()=>t(!1),children:`Close`})})]}):null}function Nt({progress:e,useSIPrefix:t}){return(0,F.jsxs)(`div`,{className:`scanning`,children:[(0,F.jsx)(`div`,{className:`spinner`}),(0,F.jsx)(`h2`,{children:`Scanning…`}),(0,F.jsxs)(`div`,{className:`scan-stats`,children:[(0,F.jsxs)(`span`,{children:[k(e.itemCount),` items`]}),(0,F.jsx)(`span`,{children:O(e.totalUsage,t)})]}),(0,F.jsx)(`div`,{className:`scan-current`,title:e.currentItem,children:e.currentItem})]})}function Pt(){let e=Ee(),{status:t,currentPath:n,nodeResp:r,useSIPrefix:i,loadError:a,view:o}=e;return t?t.state===`error`?(0,F.jsxs)(`div`,{className:`error-screen`,children:[(0,F.jsx)(`h2`,{children:`Scan failed`}),(0,F.jsx)(`p`,{children:t.error??`Unknown error`})]}):n===null||t.state===`scanning`&&!r?(0,F.jsx)(Nt,{progress:t.progress,useSIPrefix:i}):(0,F.jsx)(Te,{model:{...e,status:t,currentPath:n},children:(0,F.jsxs)(`div`,{className:`app`,children:[(0,F.jsx)(Oe,{}),a&&(0,F.jsx)(`div`,{className:`banner error`,children:a}),(0,F.jsx)(`main`,{className:`content ${o===`treemap`?`treemap-view`:``}`,children:o===`donut`?(0,F.jsxs)(F.Fragment,{children:[(0,F.jsx)(Fe,{}),(0,F.jsx)(He,{})]}):(0,F.jsx)(jt,{})}),(0,F.jsx)(Mt,{})]})}):(0,F.jsx)(`div`,{className:`loading`,children:`Connecting…`})}var Ft=document.getElementById(`root`);if(!Ft)throw Error(`root element not found`);(0,l.createRoot)(Ft).render((0,F.jsx)(c.StrictMode,{children:(0,F.jsx)(Pt,{})})); \ No newline at end of file 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-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..e32b697c4 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..883fe3562 100644 --- a/webui/frontend/src/App.test.tsx +++ b/webui/frontend/src/App.test.tsx @@ -1,8 +1,9 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; 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'); @@ -15,6 +16,7 @@ const status: Status = { showRelativeSize: false, useSIPrefix: false, deleteAllowed: true, + classifying: false, }; const nodeResponse: NodeResponse = { node: { @@ -99,7 +101,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 +169,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 +316,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 +331,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 +349,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 +360,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 +384,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 +430,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 +466,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 +487,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 +502,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 +529,301 @@ 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 categoryTreeResponse: 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 tree grouped by category and keeps category nodes out of actions', async () => { + vi.mocked(api.fetchTree).mockImplementation((_path, byCategory) => + Promise.resolve(byCategory ? categoryTreeResponse : treeResponse), + ); + await openTreemap(); + + fireEvent.change(screen.getByRole('combobox', { name: 'Treemap projection' }), { + target: { value: 'category' }, + }); + 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('shows that classification runs and reloads once it is done', async () => { + let publish: (status: Status) => void = () => undefined; + vi.mocked(api.subscribeStatus).mockImplementation((onStatus) => { + publish = onStatus; + return () => undefined; + }); + vi.mocked(api.fetchStatus).mockResolvedValue({ ...status, classifying: true }); + await openTreemap(); + expect(screen.getByRole('status').textContent).toBe('classifying…'); + + fireEvent.change(screen.getByRole('combobox', { name: 'Treemap projection' }), { + target: { value: 'category' }, + }); + expect(screen.getByText('Classifying…', { selector: '.treemap-loading span' })).toBeTruthy(); + expect(api.fetchCategories).not.toHaveBeenCalled(); + expect(api.fetchTree).toHaveBeenCalledTimes(1); + + act(() => publish(status)); + await waitFor(() => expect(api.fetchTree).toHaveBeenLastCalledWith(root, true)); + await waitFor(() => expect(api.fetchCategories).toHaveBeenCalledWith(root)); + expect(api.fetchNode).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('status')).toBeNull(); + }); + + 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…'); + + 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); + + // navigation reloads + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + await waitFor(() => expect(api.fetchNode).toHaveBeenCalledTimes(2)); + }); + + it('keeps browsing while a deletion runs and keeps its item marked', async () => { + const pending = deferred(); + vi.mocked(api.deleteNode).mockReturnValueOnce(pending.promise); + await openTreemap(); + + deleteTile(/nested\.bin/); + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + await waitFor(() => expect(api.fetchNode).toHaveBeenLastCalledWith(`${root}/folder`, 'size', 'desc')); + await waitFor(() => expect(api.fetchTree).toHaveBeenCalledTimes(2)); + expect(await screen.findByRole('status')).toBeTruthy(); + // the reloaded data still holds the item until the deletion is done + await waitFor(() => + expect(screen.getByRole('button', { name: /nested\.bin/ }).getAttribute('aria-busy')).toBe('true'), + ); + + pending.resolve(); + await waitFor(() => + expect(screen.getByRole('button', { name: /nested\.bin/ }).getAttribute('data-deletion')).toBe('deleted'), + ); + // the data is stale again, so the next navigation reloads + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + await waitFor(() => expect(api.fetchTree).toHaveBeenCalledTimes(3)); + }); + + it('does not open an item that is being deleted', async () => { + vi.mocked(api.deleteNode).mockReturnValue(deferred().promise); + await openTreemap(); + + deleteTile(/folder/); + fireEvent.doubleClick(screen.getByRole('button', { name: /folder/ })); + expect(api.fetchNode).toHaveBeenCalledTimes(1); + }); + + 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..860cde5d7 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 byCategory, it is the +// tree of the "By category" projection: one synthetic node per cleanup +// category (its path starts with CATEGORY_NODE_PREFIX, see cleanup.ts) +// holding the candidates with their real paths. +export function fetchTree(path: string, byCategory = false): Promise { const params = new URLSearchParams({ path }); + if (byCategory) { + params.set('projection', '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..304e1271d --- /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, + isCategoryNode, +} 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 isCategoryNode', () => { + 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(isCategoryNode(node({ path: 'category:ml-model' }))).toBe(true); + expect(isCategoryNode(node())).toBe(false); + }); +}); diff --git a/webui/frontend/src/cleanup.ts b/webui/frontend/src/cleanup.ts new file mode 100644 index 000000000..a85df6d4d --- /dev/null +++ b/webui/frontend/src/cleanup.ts @@ -0,0 +1,156 @@ +// 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 { Amount, Category, Node } from './types'; +import { formatSize } from './format'; +import { PALETTE } from './colors'; + +export type Projection = 'location' | 'cleanup' | 'age' | 'category'; + +export const PROJECTIONS: Array<{ id: Projection; label: string }> = [ + { id: 'location', label: 'Location' }, + { id: 'cleanup', label: 'Cleanup' }, + { id: 'age', label: 'Age' }, + { id: 'category', label: 'By category' }, +]; + +// Path prefix of the synthetic category nodes of the tree grouped by category +// (categoryNodePrefix in webui/cleanup.go). They are not real items. +export const CATEGORY_NODE_PREFIX = 'category:'; + +export function isCategoryNode(node: Node): boolean { + return node.path.startsWith(CATEGORY_NODE_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], +}; + +// amountValue picks the shown metric of an amount (or of a node, which has +// both): apparent size or disk usage. +export function amountValue(amount: Amount, apparent: boolean): number { + return apparent ? amount.size : amount.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 += amountValue(amount, apparent); + } + } + return sum; +} + +// candidateShare is the fraction (0..1) of node taken by candidates. +export function candidateShare(node: Node, apparent: boolean): number { + const total = amountValue(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 ? amountValue(amount, apparent) : 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..a0ac9eadb --- /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..0a243edd9 --- /dev/null +++ b/webui/frontend/src/components/CategoryLegend.tsx @@ -0,0 +1,108 @@ +import type { CategoriesResponse, Category } from '../types'; +import { formatSize, percent } from '../format'; +import { + AGE_BUCKETS, + CATEGORY_COLORS, + REVIEW_COLOR, + SAFE_COLOR, + amountValue, + 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 = amountValue(categories.total, apparent); + const present = categories.categories + .map((c) => ({ ...c, bytes: amountValue(c, apparent) })) + .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..4a75bdef8 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..706026c53 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, + CATEGORY_NODE_PREFIX, + PROJECTIONS, + ageColor, + ageText, + candidateSummary, + cleanupColor, + containsCategory, + isCategoryNode, +} 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,67 @@ 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 byCategory = projection === 'category'; + const showsCategories = projection === 'cleanup' || byCategory; + // The tree grouped by category 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 = byCategory ? CATEGORY_NODE_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. While the + // classification runs, the legend says so instead. + const classifying = status.classifying; + useEffect(() => { + if (view !== 'treemap' || !showsCategories || currentPath === null) { + return; + } + let cancelled = false; + setCategories(null); + if (classifying) { + return; + } + fetchCategories(currentPath) + .then((resp) => { + if (!cancelled) { + setCategories(resp); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [currentPath, showsCategories, view, classifying]); // 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 || (byCategory && classifying)) { return; } let cancelled = false; setTreeLoading(true); setTreeError(null); - fetchTree(currentPath) + fetchTree(currentPath, byCategory) .then((root) => { if (!cancelled) { - setTree(currentPath, root); + setTree(treeKey, root); } }) .catch((err: unknown) => { @@ -71,34 +115,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, byCategory, classifying, 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 +148,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 +173,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 'category': + return (rect: TreeLayoutRect) => { + const category = rect.colorPath.slice(CATEGORY_NODE_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 (isCategoryNode(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 (isCategoryNode(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 tree grouped by category are not items: they can be + // neither selected for an action nor opened. + const selectNode = useCallback( + (node: Node) => { + if (!isCategoryNode(node) && !deletionStateOf(deletions, node.path)) { + setSelectedNode(node); + } + }, + [deletions], + ); + const openPath = useCallback( + (path: string) => { + if (!path.startsWith(CATEGORY_NODE_PREFIX)) { + navigateToPath(path); + } + }, + [navigateToPath], + ); return ( <> -
- {treeRoot && treePath === currentPath ? ( +
+ + {treeRoot && treePath === treeKey ? ( ) : (
{treeLoading && } - {treeError ? 'Treemap unavailable' : 'Loading treemap…'} + + {treeError ? 'Treemap unavailable' : byCategory && classifying ? 'Classifying…' : 'Loading treemap…'} +
)}
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..eb2e812f6 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 { amountValue, 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 whose deletion is running ("pending") or was + // done in the current directory ("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. Navigating meanwhile keeps their items marked + // wherever they show up; an item being deleted cannot be opened. + 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 += amountValue(node, apparent); + } + return total; } const GduModelContext = createContext(null); @@ -121,11 +184,21 @@ 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: the shown data is stale from then on and + // is reloaded on the next navigation. + const staleRef = useRef(false); + const deletionsRef = useLatest(deletions); + 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 +251,19 @@ export function useGduModelState() { return () => { cancelled = true; }; - }, [currentPath, sort, order, status?.state]); + }, [currentPath, sort, order, status?.state, reloadKey]); + + // Data loaded while the classification ran lacks cleanup candidates: + // reload it once the classification is done. + const classifying = status?.classifying ?? false; + const wasClassifyingRef = useRef(classifying); + useEffect(() => { + if (wasClassifyingRef.current && !classifying) { + setTreePath(null); + setReloadKey((k) => k + 1); + } + wasClassifyingRef.current = classifying; + }, [classifying]); // Global "?" shortcut to open the help overlay; works from any view. useEffect(() => { @@ -222,10 +307,59 @@ export function useGduModelState() { ); const navigateToPath = useCallback((path: string) => { + // the contents of an item being deleted are going away + if (deletionsRef.current && deletionStateOf(deletionsRef.current, path)) { + return; + } + if (staleRef.current) { + // running deletions stay marked in the reloaded data, which is stale + // again once they finish + staleRef.current = inFlightRef.current > 0; + setDeletions((prev) => new Map([...prev].filter(([, d]) => d.state === 'pending'))); + setTreePath(null); + setTreeRoot(null); + // reload even when navigating to the directory already shown + setReloadKey((k) => k + 1); + } setCurrentPath(path); setHoveredPath(null); + }, [deletionsRef]); + + 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) { + staleRef.current = true; + } + 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 +396,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 +416,8 @@ export function useGduModelState() { onSortChange, view, toggleView, + projection, + setProjection, toggleApparent, hoveredPath, setHoveredPath, @@ -300,8 +431,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..36889a136 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 { @@ -38,6 +71,9 @@ export interface Status { showRelativeSize: boolean; useSIPrefix: boolean; deleteAllowed: boolean; + // Set while cleanup candidates are being classified, after a scan or a + // change of the tree. Cleanup data is missing from responses meanwhile. + classifying: boolean; } export type SortKey = 'size' | 'name' | 'itemCount' | 'mtime'; 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..d4931ee33 100644 --- a/webui/handlers.go +++ b/webui/handlers.go @@ -23,6 +23,9 @@ type statusResponse struct { ShowRelativeSize bool `json:"showRelativeSize"` UseSIPrefix bool `json:"useSIPrefix"` DeleteAllowed bool `json:"deleteAllowed"` + // Classifying is set while cleanup candidates are being classified, which + // starts once a scan is done. + Classifying bool `json:"classifying"` } type progressJSON struct { @@ -77,6 +80,7 @@ func (ui *UI) buildStatus() statusResponse { ShowApparentSize: ui.ShowApparentSize, ShowRelativeSize: ui.ShowRelativeSize, UseSIPrefix: ui.UseSIPrefix, + Classifying: ui.classifying.Load(), } reason, _ := ui.deleteDisabledReason(ui.noDelete, ui.scanning) resp.DeleteAllowed = reason == "" @@ -104,7 +108,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 +117,15 @@ func (ui *UI) handleTree(w http.ResponseWriter, r *http.Request) { writeNodeError(w, err) return } - writeJSON(w, http.StatusOK, buildTree(node)) + if r.URL.Query().Get("projection") == "category" { + res := ui.requireCleanupResult(w) + if res == nil { + return + } + writeJSON(w, http.StatusOK, buildCategoryTree(node, res)) + return + } + writeJSON(w, http.StatusOK, buildTree(node, ui.cleanupResult())) } // deleteDisabledReason reports why deletion is currently refused given the @@ -169,10 +181,16 @@ func (ui *UI) handleDeleteNode(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("mode") == deleteModeTrash { removeFunc = ui.trasher } - if err := removeFunc(parent, node); err != nil { + removed, err := ui.removeNode(removeFunc, parent, node) + if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + if !removed { + // a trash command may succeed and leave the item in place + writeError(w, http.StatusInternalServerError, "the item is still there after moving it to the trash") + return + } 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..bf1524e12 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,17 @@ 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 + // removalSeq is odd while a deletion runs, from before the tree changes + // until the classification has been updated for it. + removalSeq atomic.Uint64 + // classifying is set while the classification runs in the background. + classifying atomic.Bool + hub *hub } @@ -95,6 +108,7 @@ func CreateUI( linkedItems: make(fs.HardLinkedItems), hub: newHub(), } + ui.classifier = cleanup.NewDefaultClassifier(userHomeDir()) if listenAddr == "" { ui.listenAddr = "localhost:0" } @@ -162,6 +176,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()) @@ -211,6 +226,7 @@ func (ui *UI) AnalyzePaths(paths []string) error { ui.topDirPath = dir.GetPath() ui.scanning = false ui.mu.Unlock() + ui.treeChanged() ui.hub.publish(ui.statusJSON()) }() @@ -239,6 +255,7 @@ func (ui *UI) ReadAnalysis(input io.Reader) error { ui.topDirPath = dir.GetPath() ui.scanning = false ui.mu.Unlock() + ui.treeChanged() return nil } @@ -259,6 +276,7 @@ func (ui *UI) ReadFromStorage(storagePath, path string) error { ui.topDirPath = dir.GetPath() ui.scanning = false ui.mu.Unlock() + ui.treeChanged() return nil } diff --git a/webui/webui_test.go b/webui/webui_test.go index 566056d84..391557c44 100644 --- a/webui/webui_test.go +++ b/webui/webui_test.go @@ -47,12 +47,19 @@ func makeTree(t *testing.T) string { return root } +// scan analyzes path and waits until the scan and the classification of its +// cleanup candidates are done. func scan(t *testing.T, ui *UI, path string) { t.Helper() require.NoError(t, ui.AnalyzePath(path, nil)) + waitClassified(t, ui) +} + +func waitClassified(t *testing.T, ui *UI) { + t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - if ui.buildStatus().State == "done" { + if ui.buildStatus().State == "done" && ui.cleanupResult() != nil { return } time.Sleep(10 * time.Millisecond) @@ -306,6 +313,35 @@ func TestDeleteNodeEndpointReturnsErrorWhenRemoveFuncFails(t *testing.T) { assert.NoError(t, err, "file must still exist after a failed delete") } +func TestDeleteNodeEndpointReportsItemLeftInPlaceByTrash(t *testing.T) { + ui := newTestUI() + root := makeTree(t) + scan(t, ui, root) + + // like a trash command that exits 0 without moving the item + ui.trasher = func(fs.Item, fs.Item) error { return nil } + + srv := httptest.NewServer(ui.routes()) + defer srv.Close() + + path := filepath.Join(root, "big.bin") + req, err := http.NewRequest( + http.MethodDelete, srv.URL+"/api/v1/nodes?path="+url.QueryEscape(path)+"&mode=trash", 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.StatusInternalServerError, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "still there") + _, err = ui.findNode(path) + assert.NoError(t, err, "the item stays in the tree") +} + func TestDeleteEndpointRejectsChangedParent(t *testing.T) { ui := newTestUI() root := makeTree(t) @@ -1087,15 +1123,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 +1141,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) } }