diff --git a/bed/CHANGELOG.md b/bed/CHANGELOG.md new file mode 100644 index 0000000..17ea498 --- /dev/null +++ b/bed/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog +## [v0.2.8](https://b612.me/apps/b612/bed/compare/v0.2.7..v0.2.8) (2024-12-01) +* Refactor drawing command line and completion candidates. +* Fix jump back action not to crash when the buffer is edited. + +## [v0.2.7](https://b612.me/apps/b612/bed/compare/v0.2.6..v0.2.7) (2024-10-20) +* Support environment variable expansion in the command line. +* Implement `:cd`, `:chdir`, `:pwd` commands to change the working directory. +* Improve command line completion for command name and environment variables. +* Recognize file name argument and bang for `:wq` command. + +## [v0.2.6](https://b612.me/apps/b612/bed/compare/v0.2.5..v0.2.6) (2024-10-08) +* Support reading from standard input. +* Implement command line history. + +## [v0.2.5](https://b612.me/apps/b612/bed/compare/v0.2.4..v0.2.5) (2024-05-03) +* Require Go 1.22. + +## [v0.2.4](https://b612.me/apps/b612/bed/compare/v0.2.3..v0.2.4) (2023-09-30) +* Require Go 1.21. + +## [v0.2.3](https://b612.me/apps/b612/bed/compare/v0.2.2..v0.2.3) (2022-12-25) +* Fix crash on window moving commands on the last window. + +## [v0.2.2](https://b612.me/apps/b612/bed/compare/v0.2.1..v0.2.2) (2021-09-14) +* Add `:only` command to make the current window the only one. +* Reduce memory allocations on rendering. +* Release `arm64` artifacts. + +## [v0.2.1](https://b612.me/apps/b612/bed/compare/v0.2.0..v0.2.1) (2020-12-29) +* Add `:{count}%` to go to the position by percentage in the file. +* Add `:{count}go[to]` command to go to the specific line. + +## [v0.2.0](https://b612.me/apps/b612/bed/compare/v0.1.0..v0.2.0) (2020-04-10) +* Add `:cquit` command. + +## [v0.1.0](https://b612.me/apps/b612/bed/compare/8239ec4..v0.1.0) (2020-01-25) +* Initial implementation. diff --git a/bed/LICENSE b/bed/LICENSE new file mode 100644 index 0000000..49b0fc3 --- /dev/null +++ b/bed/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017-2024 itchyny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bed/Makefile b/bed/Makefile new file mode 100644 index 0000000..17d83a2 --- /dev/null +++ b/bed/Makefile @@ -0,0 +1,64 @@ +BIN := bed +VERSION := $$(make -s show-version) +VERSION_PATH := cmd/$(BIN) +CURRENT_REVISION = $(shell git rev-parse --short HEAD) +BUILD_LDFLAGS = "-s -w -X main.revision=$(CURRENT_REVISION)" +GOBIN ?= $(shell go env GOPATH)/bin + +.PHONY: all +all: build + +.PHONY: build +build: + go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) ./cmd/$(BIN) + +.PHONY: install +install: + go install -ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) + +.PHONY: show-version +show-version: $(GOBIN)/gobump + @gobump show -r "$(VERSION_PATH)" + +$(GOBIN)/gobump: + @go install github.com/x-motemen/gobump/cmd/gobump@latest + +.PHONY: cross +cross: $(GOBIN)/goxz CREDITS + goxz -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) + +$(GOBIN)/goxz: + go install github.com/Songmu/goxz/cmd/goxz@latest + +CREDITS: $(GOBIN)/gocredits go.sum + go mod tidy + gocredits -w . + +$(GOBIN)/gocredits: + go install github.com/Songmu/gocredits/cmd/gocredits@latest + +.PHONY: test +test: build + go test -v -race -timeout 30s ./... + +.PHONY: lint +lint: $(GOBIN)/staticcheck + go vet ./... + staticcheck -checks all,-ST1000 ./... + +$(GOBIN)/staticcheck: + go install honnef.co/go/tools/cmd/staticcheck@latest + +.PHONY: clean +clean: + rm -rf $(BIN) goxz CREDITS + go clean + +.PHONY: bump +bump: $(GOBIN)/gobump + test -z "$$(git status --porcelain || echo .)" + test "$$(git branch --show-current)" = "main" + @gobump up -w "$(VERSION_PATH)" + git commit -am "bump up version to $(VERSION)" + git tag "v$(VERSION)" + git push --atomic origin main tag "v$(VERSION)" diff --git a/bed/README.md b/bed/README.md new file mode 100644 index 0000000..d80fe6c --- /dev/null +++ b/bed/README.md @@ -0,0 +1,81 @@ +# bed +[![CI Status](https://b612.me/apps/b612/bed/actions/workflows/ci.yaml/badge.svg?branch=main)](https://b612.me/apps/b612/bed/actions?query=branch:main) +[![Go Report Card](https://goreportcard.com/badge/b612.me/apps/b612/bed)](https://goreportcard.com/report/b612.me/apps/b612/bed) +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://b612.me/apps/b612/bed/blob/main/LICENSE) +[![release](https://img.shields.io/github/release/itchyny/bed/all.svg)](https://b612.me/apps/b612/bed/releases) +[![pkg.go.dev](https://pkg.go.dev/badge/b612.me/apps/b612/bed)](https://pkg.go.dev/b612.me/apps/b612/bed) + +Binary editor written in Go + +## Screenshot +![bed command screenshot](https://user-images.githubusercontent.com/375258/38499347-2f71306c-3c42-11e8-926e-1782b0bc73f3.png) + +## Motivation +I wanted to create a binary editor with Vim-like user interface, which runs in terminals, fast, and is portable. +I have always been interested in various binary formats and I wanted to create my own editor to handle them. +I also wanted to learn how a binary editor can handle large files and allow users to edit them interactively. + +While creating this binary editor, I leaned a lot about programming in Go language. +I spent a lot of time writing the core logic of buffer implementation of the editor. +It was a great learning experience for me and a lot of fun. + +## Installation +### Homebrew + +```sh +brew install bed +``` + +### Build from source + +```bash +go install b612.me/apps/b612/bed/cmd/bed@latest +``` + +## Features + +- Basic byte editing +- Large file support +- Command line interface +- Window splitting +- Partial writing +- Text searching +- Undo and redo + +### Commands and keyboard shortcuts +This binary editor is influenced by the Vim editor. + +- File operations + - `:edit`, `:enew`, `:new`, `:vnew`, `:only` +- Current working directory + - `:cd`, `:chdir`, `:pwd` +- Quit and save + - `:quit`, `ZQ`, `:qall`, `:write`, + `:wq`, `ZZ`, `:xit`, `:xall`, `:cquit` +- Window operations + - `:wincmd [nohjkltbpHJKL]`, `[nohjkltbpHJKL]` +- Cursor motions + - `h`, `j`, `k`, `l`, `w`, `b`, `^`, `0`, `$`, + ``, ``, ``, ``, + `G`, `gg`, `:{count}`, `:{count}goto`, `:{count}%`, + `H`, `M`, `L`, `zt`, `zz`, `z.`, `zb`, `z-`, + `` (toggle focus between hex and text views) +- Mode operations + - `i`, `I`, `a`, `A`, `v`, `r`, `R`, `` +- Inspect and edit + - `gb` (binary), `gd` (decimal), `x` (delete), `X` (delete backward), + `d` (delete selection), `y` (copy selection), `p`, `P` (paste), + `<` (left shift), `>` (right shift), `` (increment), `` (decrement) +- Undo and redo + - `:undo`, `u`, `:redo`, `` +- Search + - `/`, `?`, `n`, `N`, `` (abort) + +## Bug Tracker +Report bug at [Issues・itchyny/bed - GitHub](https://b612.me/apps/b612/bed/issues). + +## Author +itchyny () + +## License +This software is released under the MIT License, see LICENSE. diff --git a/bed/buffer/buffer.go b/bed/buffer/buffer.go new file mode 100644 index 0000000..1a40eef --- /dev/null +++ b/bed/buffer/buffer.go @@ -0,0 +1,502 @@ +package buffer + +import ( + "errors" + "io" + "math" + "slices" + "sync" +) + +// Buffer represents a buffer. +type Buffer struct { + rrs []readerRange + index int64 + mu *sync.Mutex + bytes []byte + offset int64 +} + +type readAtSeeker interface { + io.ReaderAt + io.Seeker +} + +type readerRange struct { + r readAtSeeker + min int64 + max int64 + diff int64 +} + +// NewBuffer creates a new buffer. +func NewBuffer(r readAtSeeker) *Buffer { + return &Buffer{ + rrs: []readerRange{{r: r, min: 0, max: math.MaxInt64, diff: 0}}, + index: 0, + mu: new(sync.Mutex), + } +} + +// Read reads bytes. +func (b *Buffer) Read(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.read(p) +} + +func (b *Buffer) read(p []byte) (i int, err error) { + index := b.index + for _, rr := range b.rrs { + if b.index < rr.min { + break + } + if b.index >= rr.max { + continue + } + m := int(min(int64(len(p)-i), rr.max-b.index)) + var k int + if k, err = rr.r.ReadAt(p[i:i+m], b.index+rr.diff); err != nil && k == 0 { + break + } + err = nil + b.index += int64(m) + i += k + } + if len(b.bytes) > 0 { + j, k := max(b.offset-index, 0), max(index-b.offset, 0) + if j < int64(len(p)) && k < int64(len(b.bytes)) { + if cnt := copy(p[j:], b.bytes[k:]); i < int(j)+cnt { + i = int(j) + cnt + } + } + } + return +} + +// Seek sets the offset. +func (b *Buffer) Seek(offset int64, whence int) (int64, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.seek(offset, whence) +} + +func (b *Buffer) seek(offset int64, whence int) (int64, error) { + var index int64 + switch whence { + case io.SeekStart: + index = offset + case io.SeekCurrent: + index = b.index + offset + case io.SeekEnd: + var l int64 + var err error + if l, err = b.len(); err != nil { + return 0, err + } + index = l + offset + default: + return 0, errors.New("buffer.Buffer.Seek: invalid whence") + } + if index < 0 { + return 0, errors.New("buffer.Buffer.Seek: negative position") + } + b.index = index + return index, nil +} + +// Len returns the total size of the buffer. +func (b *Buffer) Len() (int64, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.len() +} + +func (b *Buffer) len() (int64, error) { + rr := b.rrs[len(b.rrs)-1] + l, err := rr.r.Seek(0, io.SeekEnd) + if err != nil { + return 0, err + } + return max(l-rr.diff, b.offset+int64(len(b.bytes))), nil +} + +// ReadAt reads bytes at the specific offset. +func (b *Buffer) ReadAt(p []byte, offset int64) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if _, err := b.seek(offset, io.SeekStart); err != nil { + return 0, err + } + return b.read(p) +} + +// EditedIndices returns the indices of edited regions. +func (b *Buffer) EditedIndices() []int64 { + b.mu.Lock() + defer b.mu.Unlock() + eis := make([]int64, 0, len(b.rrs)) + for _, rr := range b.rrs { + switch rr.r.(type) { + case *bytesReader, constReader: + // constReader can be adjacent to another bytesReader or constReader. + if l := len(eis); l > 0 && eis[l-1] == rr.min { + eis[l-1] = rr.max + continue + } + eis = append(eis, rr.min, rr.max) + } + } + if len(b.bytes) > 0 { + eis = insertInterval(eis, b.offset, b.offset+int64(len(b.bytes))) + } + return eis +} + +func insertInterval(xs []int64, start, end int64) []int64 { + i, fi := slices.BinarySearch(xs, start) + j, fj := slices.BinarySearch(xs, end) + if i%2 == 0 { + if i == j && !fi && !fj { + return slices.Insert(xs, i, start, end) + } + xs[i] = start + i++ + } + if j%2 == 0 { + if fj { + j++ + } else { + j-- + xs[j] = end + } + } + return slices.Delete(xs, i, j) +} + +// Clone the buffer. +func (b *Buffer) Clone() *Buffer { + b.mu.Lock() + defer b.mu.Unlock() + newBuf := new(Buffer) + newBuf.rrs = make([]readerRange, len(b.rrs)) + for i, rr := range b.rrs { + newBuf.rrs[i] = readerRange{rr.r, rr.min, rr.max, rr.diff} + } + newBuf.index = b.index + newBuf.mu = new(sync.Mutex) + newBuf.bytes = slices.Clone(b.bytes) + newBuf.offset = b.offset + return newBuf +} + +// Copy a part of the buffer. +func (b *Buffer) Copy(start, end int64) *Buffer { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + newBuf := new(Buffer) + rrs := make([]readerRange, 0, len(b.rrs)+1) + index := start + for _, rr := range b.rrs { + if index < rr.min || index >= end { + break + } + if index >= rr.max { + continue + } + size := min(end-index, rr.max-index) + rrs = append(rrs, readerRange{rr.r, index - start, index - start + size, rr.diff + start}) + index += size + } + newBuf.rrs = append(rrs, readerRange{newBytesReader(nil), index - start, math.MaxInt64, -index + start}) + newBuf.cleanup() + newBuf.index = 0 + newBuf.mu = new(sync.Mutex) + return newBuf +} + +// Cut a part of the buffer. +func (b *Buffer) Cut(start, end int64) { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + rrs := make([]readerRange, 0, len(b.rrs)+1) + var index, max int64 + for _, rr := range b.rrs { + if start >= rr.max { + rrs = append(rrs, rr) + index = rr.max + continue + } + if end <= rr.min { + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - rr.min + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + rr.min}) + index = max + continue + } + if start >= rr.min { + max = start + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff}) + index = max + } + if end < rr.max { + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - end + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff + end - index}) + index = max + } + } + if index != math.MaxInt64 { + rrs = append(rrs, readerRange{newBytesReader(nil), index, math.MaxInt64, -index}) + } + b.rrs = rrs + b.index = 0 + b.cleanup() +} + +// Paste a buffer into a buffer. +func (b *Buffer) Paste(offset int64, c *Buffer) { + b.mu.Lock() + c.mu.Lock() + defer b.mu.Unlock() + defer c.mu.Unlock() + b.flush() + rrs := make([]readerRange, 0, len(b.rrs)+len(c.rrs)+1) + var index, max int64 + for _, rr := range b.rrs { + if offset >= rr.max { + rrs = append(rrs, rr) + continue + } + if offset < rr.min { + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - rr.min + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + rr.min}) + index = max + continue + } + rrs = append(rrs, readerRange{rr.r, rr.min, offset, rr.diff}) + index = offset + for _, rr := range c.rrs { + if rr.max == math.MaxInt64 { + l, _ := rr.r.Seek(0, io.SeekEnd) + max = l + index + } else { + max = rr.max - rr.min + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + rr.min}) + index = max + } + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - offset + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + offset}) + index = max + } + b.rrs = rrs + b.cleanup() +} + +// Insert inserts a byte at the specific position. +func (b *Buffer) Insert(offset int64, c byte) { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + for i, rr := range b.rrs { + if offset > rr.max { + continue + } + var r *bytesReader + var ok bool + if rr.max != math.MaxInt64 { + if r, ok = rr.r.(*bytesReader); ok { + r = r.clone() + r.insert(offset+rr.diff, c) + b.rrs[i], i = readerRange{r, rr.min, rr.max + 1, rr.diff}, i+1 + } + } + if !ok { + b.rrs = append(b.rrs, readerRange{}, readerRange{}) + copy(b.rrs[i+2:], b.rrs[i:]) + b.rrs[i], i = readerRange{rr.r, rr.min, offset, rr.diff}, i+1 + b.rrs[i], i = readerRange{newBytesReader([]byte{c}), offset, offset + 1, -offset}, i+1 + b.rrs[i].min = offset + } + for ; i < len(b.rrs); i++ { + b.rrs[i].min++ + if b.rrs[i].max != math.MaxInt64 { + b.rrs[i].max++ + } + b.rrs[i].diff-- + } + b.cleanup() + return + } + panic("buffer.Buffer.Insert: unreachable") +} + +// Replace replaces a byte at the specific position. +// This method does not overwrite the reader ranges, +// but just append the byte to the temporary byte slice +// in order to cancel the replacement with backspace key. +func (b *Buffer) Replace(offset int64, c byte) { + b.mu.Lock() + defer b.mu.Unlock() + if b.offset+int64(len(b.bytes)) != offset { + b.flush() + } + if len(b.bytes) == 0 { + b.offset = offset + } + b.bytes = append(b.bytes, c) +} + +// UndoReplace removes the last byte of the replacing byte slice. +func (b *Buffer) UndoReplace(offset int64) { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.bytes) > 0 && b.offset+int64(len(b.bytes))-1 == offset { + b.bytes = b.bytes[:len(b.bytes)-1] + } +} + +// ReplaceIn replaces bytes within a specific range. +func (b *Buffer) ReplaceIn(start, end int64, c byte) { + b.mu.Lock() + defer b.mu.Unlock() + rrs := make([]readerRange, 0, len(b.rrs)+1) + for _, rr := range b.rrs { + if rr.max <= start || end <= rr.min { + rrs = append(rrs, rr) + continue + } + if start > rr.min { + rrs = append(rrs, readerRange{rr.r, rr.min, start, rr.diff}) + } + if start >= rr.min { + rrs = append(rrs, readerRange{constReader(c), start, end, -start}) + } + if end < rr.max { + rrs = append(rrs, readerRange{rr.r, end, rr.max, rr.diff}) + } + } + b.rrs = rrs + b.cleanup() +} + +// Flush temporary bytes. +func (b *Buffer) Flush() { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() +} + +func (b *Buffer) flush() { + if len(b.bytes) == 0 { + return + } + rrs := make([]readerRange, 0, len(b.rrs)+1) + end := b.offset + int64(len(b.bytes)) + for _, rr := range b.rrs { + if b.offset >= rr.max || end <= rr.min { + rrs = append(rrs, rr) + continue + } + if b.offset >= rr.min { + if rr.min < b.offset { + rrs = append(rrs, readerRange{rr.r, rr.min, b.offset, rr.diff}) + } + rrs = append(rrs, readerRange{newBytesReader(b.bytes), b.offset, end, -b.offset}) + } + if rr.max == math.MaxInt64 { + l, _ := rr.r.Seek(0, io.SeekEnd) + if l-rr.diff <= end { + rrs = append(rrs, readerRange{newBytesReader(nil), end, math.MaxInt64, -end}) + continue + } + } + if end < rr.max { + rrs = append(rrs, readerRange{rr.r, end, rr.max, rr.diff}) + } + } + b.rrs = rrs + b.offset = 0 + b.bytes = nil + b.cleanup() +} + +// Delete deletes a byte at the specific position. +func (b *Buffer) Delete(offset int64) { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + for i, rr := range b.rrs { + if offset >= rr.max { + continue + } + if r, ok := rr.r.(*bytesReader); ok { + r = r.clone() + r.delete(offset + rr.diff) + b.rrs[i] = readerRange{r, rr.min, rr.max - 1, rr.diff} + } else { + b.rrs = append(b.rrs, readerRange{}) + copy(b.rrs[i+1:], b.rrs[i:]) + b.rrs[i] = readerRange{rr.r, rr.min, offset, rr.diff} + b.rrs[i+1] = readerRange{rr.r, offset + 1, rr.max, rr.diff} + } + for i++; i < len(b.rrs); i++ { + b.rrs[i].min-- + if b.rrs[i].max != math.MaxInt64 { + b.rrs[i].max-- + } + b.rrs[i].diff++ + } + b.cleanup() + return + } + panic("buffer.Buffer.Delete: unreachable") +} + +func (b *Buffer) cleanup() { + for i := 0; i < len(b.rrs); i++ { + if rr := b.rrs[i]; rr.min == rr.max { + b.rrs = slices.Delete(b.rrs, i, i+1) + } + } + for i := len(b.rrs) - 1; i > 0; i-- { + rr1, rr2 := b.rrs[i-1], b.rrs[i] + switch r1 := rr1.r.(type) { + case constReader: + if r1 == rr2.r { + b.rrs[i-1].max = rr2.max + b.rrs = slices.Delete(b.rrs, i, i+1) + } + case *bytesReader: + if r2, ok := rr2.r.(*bytesReader); ok { + bs := make([]byte, int(rr1.max-rr1.min)+len(r2.bs)-int(rr2.min+rr2.diff)) + copy(bs, r1.bs[rr1.min+rr1.diff:rr1.max+rr1.diff]) + copy(bs[rr1.max-rr1.min:], r2.bs[rr2.min+rr2.diff:]) + b.rrs[i-1] = readerRange{newBytesReader(bs), rr1.min, rr2.max, -rr1.min} + b.rrs = slices.Delete(b.rrs, i, i+1) + } + default: + if r1 == rr2.r && rr1.diff == rr2.diff && rr1.max == rr2.min { + b.rrs[i-1].max = rr2.max + b.rrs = slices.Delete(b.rrs, i, i+1) + } + } + } +} diff --git a/bed/buffer/buffer_test.go b/bed/buffer/buffer_test.go new file mode 100644 index 0000000..d830b6c --- /dev/null +++ b/bed/buffer/buffer_test.go @@ -0,0 +1,712 @@ +package buffer + +import ( + "io" + "math" + "reflect" + "slices" + "strings" + "testing" +) + +func TestBufferEmpty(t *testing.T) { + b := NewBuffer(strings.NewReader("")) + + p := make([]byte, 10) + n, err := b.Read(p) + if err != io.EOF { + t.Errorf("err should be EOF but got: %v", err) + } + if n != 0 { + t.Errorf("n should be 0 but got: %d", n) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != 0 { + t.Errorf("l should be 0 but got: %d", l) + } +} + +func TestBuffer(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + p := make([]byte, 8) + n, err := b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "01234567"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != 16 { + t.Errorf("l should be 16 but got: %d", l) + } + + _, err = b.Seek(4, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err = b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "456789ab"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + _, err = b.Seek(-4, io.SeekCurrent) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err = b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "89abcdef"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + _, err = b.Seek(-4, io.SeekEnd) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err = b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 4 { + t.Errorf("n should be 4 but got: %d", n) + } + if expected := "cdefcdef"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + n, err = b.ReadAt(p, 7) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "789abcde"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + n, err = b.ReadAt(p, -1) + if err == nil { + t.Errorf("err should not be nil but got: %v", err) + } + if n != 0 { + t.Errorf("n should be 0 but got: %d", n) + } +} + +func TestBufferClone(t *testing.T) { + b0 := NewBuffer(strings.NewReader("0123456789abcdef")) + b1 := b0.Clone() + + bufferEqual := func(b0 *Buffer, b1 *Buffer) bool { + if b0.index != b1.index || len(b0.rrs) != len(b1.rrs) { + return false + } + for i := range len(b0.rrs) { + if b0.rrs[i].min != b1.rrs[i].min || b0.rrs[i].max != b1.rrs[i].max || + b0.rrs[i].diff != b1.rrs[i].diff { + return false + } + switch r0 := b0.rrs[i].r.(type) { + case *bytesReader: + switch r1 := b1.rrs[i].r.(type) { + case *bytesReader: + if !reflect.DeepEqual(r0.bs, r1.bs) || r0.index != r1.index { + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + default: + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + case *strings.Reader: + switch r1 := b1.rrs[i].r.(type) { + case *strings.Reader: + if r0 != r1 { + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + default: + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + default: + t.Logf("buffer differs: %+v, %+v", b0.rrs[i].r, b1.rrs[i].r) + return false + } + } + return true + } + + if !bufferEqual(b1, b0) { + t.Errorf("Buffer#Clone should be %+v but got %+v", b0, b1) + } + + b1.Insert(4, 0x40) + if bufferEqual(b1, b0) { + t.Errorf("Buffer should not be equal: %+v, %+v", b0, b1) + } + + b2 := b1.Clone() + if !bufferEqual(b2, b1) { + t.Errorf("Buffer#Clone should be %+v but got %+v", b1, b2) + } + + b2.Replace(4, 0x40) + b2.Flush() + if !bufferEqual(b2, b1) { + t.Errorf("Buffer should be equal: %+v, %+v", b1, b2) + } + + b2.Replace(5, 0x40) + b2.Flush() + if bufferEqual(b2, b1) { + t.Errorf("Buffer should not be equal: %+v, %+v", b1, b2) + } +} + +func TestBufferCopy(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.Replace(3, 0x41) + b.Replace(4, 0x42) + b.Replace(5, 0x43) + b.Replace(9, 0x43) + b.Replace(10, 0x44) + b.Replace(11, 0x45) + b.Replace(12, 0x46) + b.Replace(14, 0x47) + testCases := []struct { + start, end int64 + expected string + }{ + {0, 16, "012ABC678CDEFdGf"}, + {0, 15, "012ABC678CDEFdG"}, + {1, 12, "12ABC678CDE"}, + {4, 14, "BC678CDEFd"}, + {2, 10, "2ABC678C"}, + {4, 10, "BC678C"}, + {2, 7, "2ABC6"}, + {5, 10, "C678C"}, + {7, 11, "78CD"}, + {8, 10, "8C"}, + {14, 20, "Gf"}, + {9, 9, ""}, + {10, 8, ""}, + } + for _, testCase := range testCases { + got := b.Copy(testCase.start, testCase.end) + p := make([]byte, 17) + _, _ = got.Read(p) + if !strings.HasPrefix(string(p), testCase.expected+"\x00") { + t.Errorf("Copy(%d, %d) should clone %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + got.Insert(0, 0x48) + got.Insert(int64(len(testCase.expected)+1), 0x49) + p = make([]byte, 19) + _, _ = got.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "H"+testCase.expected+"I\x00") { + t.Errorf("Copy(%d, %d) should clone %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + } +} + +func TestBufferCut(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.Replace(3, 0x41) + b.Replace(4, 0x42) + b.Replace(5, 0x43) + b.Replace(9, 0x43) + b.Replace(10, 0x44) + b.Replace(11, 0x45) + b.Replace(12, 0x46) + b.Replace(14, 0x47) + testCases := []struct { + start, end int64 + expected string + }{ + {0, 0, "012ABC678CDEFdGf"}, + {0, 4, "BC678CDEFdGf"}, + {0, 7, "78CDEFdGf"}, + {0, 10, "DEFdGf"}, + {0, 16, ""}, + {0, 20, ""}, + {3, 4, "012BC678CDEFdGf"}, + {3, 6, "012678CDEFdGf"}, + {3, 11, "012EFdGf"}, + {6, 10, "012ABCDEFdGf"}, + {6, 14, "012ABCGf"}, + {6, 15, "012ABCf"}, + {6, 17, "012ABC"}, + {8, 10, "012ABC67DEFdGf"}, + {8, 10, "012ABC67DEFdGf"}, + {10, 8, "012ABC678CDEFdGf"}, + } + for _, testCase := range testCases { + got := b.Clone() + got.Cut(testCase.start, testCase.end) + p := make([]byte, 17) + _, _ = got.Read(p) + if !strings.HasPrefix(string(p), testCase.expected+"\x00") { + t.Errorf("Cut(%d, %d) should result into %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + got.Insert(0, 0x48) + got.Insert(int64(len(testCase.expected)+1), 0x49) + p = make([]byte, 19) + _, _ = got.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "H"+testCase.expected+"I\x00") { + t.Errorf("Cut(%d, %d) should result into %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + } +} + +func TestBufferPaste(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + c := b.Copy(3, 13) + b.Paste(5, c) + p := make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected := "012343456789abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } + c.Replace(5, 0x41) + c.Insert(6, 0x42) + c.Insert(7, 0x43) + b.Paste(10, c) + p = make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected = "012343456734567ABC9abc89abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } + b.Cut(11, 14) + b.Paste(13, c) + b.Replace(13, 0x44) + p = make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected = "012343456737AD4567ABC9abcBC9abc89abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } + b.Insert(14, 0x45) + p = make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected = "012343456737ADE4567ABC9abcBC9abc89abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } +} + +func TestBufferInsert(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + index int64 + b byte + offset int64 + expected string + len int64 + }{ + {0, 0x39, 0, "90123456", 17}, + {0, 0x38, 0, "89012345", 18}, + {4, 0x37, 0, "89017234", 19}, + {8, 0x30, 3, "17234056", 20}, + {9, 0x31, 3, "17234015", 21}, + {9, 0x32, 4, "72340215", 22}, + {23, 0x39, 19, "def9\x00\x00\x00\x00", 23}, + {23, 0x38, 19, "def89\x00\x00\x00", 24}, + } + + for _, test := range tests { + b.Insert(test.index, test.b) + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if expected := len(strings.TrimRight(test.expected, "\x00")); n != expected { + t.Errorf("n should be %d but got: %d", expected, n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{0, 2, 4, 5, 8, 11, 23, 25}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if len(b.rrs) != 8 { + t.Errorf("len(b.rrs) should be 8 but got: %d", len(b.rrs)) + } +} + +func TestBufferReplace(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + index int64 + b byte + offset int64 + expected string + len int64 + }{ + {0, 0x39, 0, "91234567", 16}, + {0, 0x38, 0, "81234567", 16}, + {1, 0x37, 0, "87234567", 16}, + {5, 0x30, 0, "87234067", 16}, + {4, 0x31, 0, "87231067", 16}, + {3, 0x30, 0, "87201067", 16}, + {2, 0x31, 0, "87101067", 16}, + {15, 0x30, 8, "89abcde0", 16}, + {16, 0x31, 9, "9abcde01", 17}, + {2, 0x39, 0, "87901067", 17}, + {17, 0x32, 10, "abcde012", 18}, + } + + for _, test := range tests { + b.Replace(test.index, test.b) + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{0, 6, 15, math.MaxInt64}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if len(b.rrs) != 3 { + t.Errorf("len(b.rrs) should be 3 but got: %d", len(b.rrs)) + } + + { + b.Replace(3, 0x39) + b.Replace(4, 0x38) + b.Replace(5, 0x37) + b.Replace(6, 0x36) + b.Replace(7, 0x35) + p := make([]byte, 8) + if _, err := b.ReadAt(p, 2); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "99876589"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + b.UndoReplace(7) + b.UndoReplace(6) + p = make([]byte, 8) + if _, err := b.ReadAt(p, 2); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "99876789"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + b.UndoReplace(5) + b.UndoReplace(4) + b.Flush() + b.UndoReplace(3) + b.UndoReplace(2) + p = make([]byte, 8) + if _, err := b.ReadAt(p, 2); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "99106789"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + + eis := b.EditedIndices() + if expected := []int64{0, 6, 15, math.MaxInt64}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + } + + { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.Replace(16, 0x30) + b.Replace(10, 0x30) + p := make([]byte, 8) + if _, err := b.ReadAt(p, 9); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "90bcdef0"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + + l, _ := b.Len() + if expected := int64(17); l != expected { + t.Errorf("l should be %d but got: %d", expected, l) + } + + eis := b.EditedIndices() + if expected := []int64{10, 11, 16, math.MaxInt64}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + } +} + +func TestBufferReplaceIn(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + start int64 + end int64 + b byte + offset int64 + expected string + len int64 + }{ + {1, 2, 0x39, 0, "09234567", 16}, + {0, 6, 0x38, 0, "88888867", 16}, + {1, 3, 0x37, 0, "87788867", 16}, + {5, 7, 0x30, 0, "87788007", 16}, + {2, 6, 0x31, 0, "87111107", 16}, + {3, 4, 0x30, 0, "87101107", 16}, + {14, 15, 0x30, 8, "89abcd0f", 16}, + {15, 16, 0x30, 8, "89abcd00", 16}, + {1, 5, 0x39, 0, "89999107", 16}, + } + + for _, test := range tests { + b.ReplaceIn(test.start, test.end, test.b) + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{0, 7, 14, 16}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if expected := 7; len(b.rrs) != expected { + t.Errorf("len(b.rrs) should be %d but got: %d", expected, len(b.rrs)) + } + + { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.ReplaceIn(16, 17, 0x30) + b.ReplaceIn(10, 11, 0x30) + p := make([]byte, 8) + if _, err := b.ReadAt(p, 9); err != io.EOF { + t.Errorf("err should be io.EOF but got: %v", err) + } + if expected := "90bcdef0"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + + l, _ := b.Len() + if expected := int64(16); l != expected { + t.Errorf("l should be %d but got: %d", expected, l) + } + + eis := b.EditedIndices() + if expected := []int64{10, 11, 16, 17}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + } +} + +func TestBufferDelete(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + index int64 + b byte + offset int64 + expected string + len int64 + }{ + {4, 0x00, 0, "01235678", 15}, + {3, 0x00, 0, "01256789", 14}, + {6, 0x00, 0, "0125679a", 13}, + {0, 0x00, 0, "125679ab", 12}, + {4, 0x39, 0, "1256979a", 13}, + {5, 0x38, 0, "12569879", 14}, + {3, 0x00, 0, "1259879a", 13}, + {4, 0x00, 0, "125979ab", 12}, + {3, 0x00, 0, "12579abc", 11}, + {8, 0x39, 4, "9abc9def", 12}, + {8, 0x38, 4, "9abc89de", 13}, + {8, 0x00, 4, "9abc9def", 12}, + {8, 0x00, 4, "9abcdef\x00", 11}, + } + + for _, test := range tests { + if test.b == 0x00 { + b.Delete(test.index) + } else { + b.Insert(test.index, test.b) + } + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if expected := len(strings.TrimRight(test.expected, "\x00")); n != expected { + t.Errorf("n should be %d but got: %d", expected, n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if len(b.rrs) != 4 { + t.Errorf("len(b.rrs) should be 4 but got: %d", len(b.rrs)) + } +} + +func TestInsertInterval(t *testing.T) { + tests := []struct { + intervals []int64 + newInterval []int64 + expected []int64 + }{ + {[]int64{}, []int64{10, 20}, []int64{10, 20}}, + {[]int64{10, 20}, []int64{0, 5}, []int64{0, 5, 10, 20}}, + {[]int64{10, 20}, []int64{5, 15}, []int64{5, 20}}, + {[]int64{10, 20}, []int64{15, 17}, []int64{10, 20}}, + {[]int64{10, 20}, []int64{15, 25}, []int64{10, 25}}, + {[]int64{10, 20}, []int64{25, 30}, []int64{10, 20, 25, 30}}, + {[]int64{10, 20, 30, 40}, []int64{0, 5}, []int64{0, 5, 10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 10}, []int64{5, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 15}, []int64{5, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 20}, []int64{5, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 25}, []int64{5, 25, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 30}, []int64{5, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 45}, []int64{5, 45}}, + {[]int64{10, 20, 30, 40}, []int64{10, 20}, []int64{10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{10, 30}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{15, 45}, []int64{10, 45}}, + {[]int64{10, 20, 30, 40}, []int64{15, 25}, []int64{10, 25, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{15, 30}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{15, 35}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{20, 25}, []int64{10, 25, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{20, 30}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{25, 30}, []int64{10, 20, 25, 40}}, + {[]int64{10, 20, 30, 40}, []int64{30, 30}, []int64{10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{35, 37}, []int64{10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{40, 50}, []int64{10, 20, 30, 50}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{45, 47}, []int64{10, 20, 30, 40, 45, 47, 50, 60, 70, 80}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{35, 65}, []int64{10, 20, 30, 65, 70, 80}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{25, 55}, []int64{10, 20, 25, 60, 70, 80}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{75, 90}, []int64{10, 20, 30, 40, 50, 60, 70, 90}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{0, 100}, []int64{0, 100}}, + } + for _, test := range tests { + got := insertInterval(slices.Clone(test.intervals), test.newInterval[0], test.newInterval[1]) + if !reflect.DeepEqual(got, test.expected) { + t.Errorf("insertInterval(%+v, %d, %d) should be %+v but got: %+v", + test.intervals, test.newInterval[0], test.newInterval[1], test.expected, got) + } + } +} diff --git a/bed/buffer/bytes.go b/bed/buffer/bytes.go new file mode 100644 index 0000000..c181946 --- /dev/null +++ b/bed/buffer/bytes.go @@ -0,0 +1,66 @@ +package buffer + +import ( + "errors" + "io" + "slices" +) + +type bytesReader struct { + bs []byte + index int64 +} + +func newBytesReader(bs []byte) *bytesReader { + return &bytesReader{bs: bs, index: 0} +} + +// Read implements the io.Reader interface. +func (r *bytesReader) Read(b []byte) (n int, err error) { + if r.index >= int64(len(r.bs)) { + return 0, io.EOF + } + n = copy(b, r.bs[r.index:]) + r.index += int64(n) + return +} + +// Seek implements the io.Seeker interface. +func (r *bytesReader) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + r.index = offset + case io.SeekCurrent: + r.index += offset + case io.SeekEnd: + r.index = int64(len(r.bs)) + offset + } + return r.index, nil +} + +// ReadAt implements the io.ReaderAt interface. +func (r *bytesReader) ReadAt(b []byte, offset int64) (n int, err error) { + if offset < 0 { + return 0, errors.New("buffer.bytesReader.ReadAt: negative offset") + } + if offset >= int64(len(r.bs)) { + return 0, io.EOF + } + n = copy(b, r.bs[offset:]) + if n < len(b) { + err = io.EOF + } + return +} + +func (r *bytesReader) insert(offset int64, b byte) { + r.bs = slices.Insert(r.bs, int(offset), b) +} + +func (r *bytesReader) delete(offset int64) { + r.bs = slices.Delete(r.bs, int(offset), int(offset+1)) +} + +func (r *bytesReader) clone() *bytesReader { + return newBytesReader(slices.Clone(r.bs)) +} diff --git a/bed/buffer/const.go b/bed/buffer/const.go new file mode 100644 index 0000000..7955ec6 --- /dev/null +++ b/bed/buffer/const.go @@ -0,0 +1,21 @@ +package buffer + +type constReader byte + +// Read implements the io.Reader interface. +func (r constReader) Read(b []byte) (int, error) { + for i := range b { + b[i] = byte(r) + } + return len(b), nil +} + +// Seek implements the io.Seeker interface. +func (constReader) Seek(int64, int) (int64, error) { + return 0, nil +} + +// ReadAt implements the io.ReaderAt interface. +func (r constReader) ReadAt(b []byte, _ int64) (int, error) { + return r.Read(b) +} diff --git a/bed/cmd/bed/bed.go b/bed/cmd/bed/bed.go new file mode 100644 index 0000000..9b034af --- /dev/null +++ b/bed/cmd/bed/bed.go @@ -0,0 +1,75 @@ +package bed + +import ( + "fmt" + "os" + "runtime" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "b612.me/apps/b612/bed/cmdline" + "b612.me/apps/b612/bed/editor" + "b612.me/apps/b612/bed/tui" + "b612.me/apps/b612/bed/window" +) + +const ( + name = "bed" + version = "0.2.8" + revision = "HEAD" +) + +var Cmd = &cobra.Command{ + Use: "bed [文件路径]", + Short: "基于 Go 开发的二进制文件编辑器", + Long: `二进制文件编辑器 bed - 支持直接编辑二进制文件的命令行工具 + +支持功能: + - 十六进制查看/编辑 + - 文件差异对比 + - 多窗口操作 + - 快速跳转地址`, + Version: fmt.Sprintf("%s (修订版本: %s/%s)", version, revision, runtime.Version()), + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runEditor(args) + }, +} + +func init() { + Cmd.SetVersionTemplate(`{{printf "%s 版本信息:" .Name}}{{.Version}}` + "\n") + Cmd.Flags().BoolP("version", "v", false, "显示版本信息") +} + +func runEditor(args []string) error { + editor := editor.NewEditor( + tui.NewTui(), + window.NewManager(), + cmdline.NewCmdline(), + ) + + if err := editor.Init(); err != nil { + return fmt.Errorf("编辑器初始化失败: %w", err) + } + + switch { + case len(args) > 0 && args[0] != "-": // 处理文件参数 + if err := editor.Open(args[0]); err != nil { + return fmt.Errorf("无法打开文件: %w", err) + } + case term.IsTerminal(int(os.Stdin.Fd())): // 交互模式 + if err := editor.OpenEmpty(); err != nil { + return fmt.Errorf("创建空白文档失败: %w", err) + } + default: // 从标准输入读取 + if err := editor.Read(os.Stdin); err != nil { + return fmt.Errorf("读取输入流失败: %w", err) + } + } + + defer editor.Close() + return editor.Run() +} diff --git a/bed/cmdline/cmdline.go b/bed/cmdline/cmdline.go new file mode 100644 index 0000000..7e1036d --- /dev/null +++ b/bed/cmdline/cmdline.go @@ -0,0 +1,244 @@ +package cmdline + +import ( + "slices" + "sync" + "unicode" + + "b612.me/apps/b612/bed/event" +) + +// Cmdline implements editor.Cmdline +type Cmdline struct { + cmdline []rune + cursor int + completor *completor + typ rune + historyIndex int + history []string + histories map[bool][]string + eventCh chan<- event.Event + cmdlineCh <-chan event.Event + redrawCh chan<- struct{} + mu *sync.Mutex +} + +// NewCmdline creates a new Cmdline. +func NewCmdline() *Cmdline { + return &Cmdline{ + completor: newCompletor(&filesystem{}, &environment{}), + histories: map[bool][]string{false: {}, true: {}}, + mu: new(sync.Mutex), + } +} + +// Init initializes the Cmdline. +func (c *Cmdline) Init(eventCh chan<- event.Event, cmdlineCh <-chan event.Event, redrawCh chan<- struct{}) { + c.eventCh, c.cmdlineCh, c.redrawCh = eventCh, cmdlineCh, redrawCh +} + +// Run the cmdline. +func (c *Cmdline) Run() { + for e := range c.cmdlineCh { + c.mu.Lock() + switch e.Type { + case event.StartCmdlineCommand: + c.start(':', e.Arg) + case event.StartCmdlineSearchForward: + c.start('/', "") + case event.StartCmdlineSearchBackward: + c.start('?', "") + case event.ExitCmdline: + c.clear() + case event.CursorUp: + c.cursorUp() + case event.CursorDown: + c.cursorDown() + case event.CursorLeft: + c.cursorLeft() + case event.CursorRight: + c.cursorRight() + case event.CursorHead: + c.cursorHead() + case event.CursorEnd: + c.cursorEnd() + case event.BackspaceCmdline: + c.backspace() + case event.DeleteCmdline: + c.deleteRune() + case event.DeleteWordCmdline: + c.deleteWord() + case event.ClearToHeadCmdline: + c.clearToHead() + case event.ClearCmdline: + c.clear() + case event.Rune: + c.insert(e.Rune) + case event.CompleteForwardCmdline: + c.complete(true) + c.redrawCh <- struct{}{} + c.mu.Unlock() + continue + case event.CompleteBackCmdline: + c.complete(false) + c.redrawCh <- struct{}{} + c.mu.Unlock() + continue + case event.ExecuteCmdline: + if c.execute() { + c.mu.Unlock() + continue + } + default: + c.mu.Unlock() + continue + } + c.completor.clear() + c.mu.Unlock() + c.redrawCh <- struct{}{} + } +} + +func (c *Cmdline) cursorUp() { + if c.historyIndex--; c.historyIndex >= 0 { + c.cmdline = []rune(c.history[c.historyIndex]) + c.cursor = len(c.cmdline) + } else { + c.clear() + c.historyIndex = -1 + } +} + +func (c *Cmdline) cursorDown() { + if c.historyIndex++; c.historyIndex < len(c.history) { + c.cmdline = []rune(c.history[c.historyIndex]) + c.cursor = len(c.cmdline) + } else { + c.clear() + c.historyIndex = len(c.history) + } +} + +func (c *Cmdline) cursorLeft() { + c.cursor = max(0, c.cursor-1) +} + +func (c *Cmdline) cursorRight() { + c.cursor = min(len(c.cmdline), c.cursor+1) +} + +func (c *Cmdline) cursorHead() { + c.cursor = 0 +} + +func (c *Cmdline) cursorEnd() { + c.cursor = len(c.cmdline) +} + +func (c *Cmdline) backspace() { + if c.cursor > 0 { + c.cmdline = slices.Delete(c.cmdline, c.cursor-1, c.cursor) + c.cursor-- + return + } + if len(c.cmdline) == 0 { + c.eventCh <- event.Event{Type: event.ExitCmdline} + } +} + +func (c *Cmdline) deleteRune() { + if c.cursor < len(c.cmdline) { + c.cmdline = slices.Delete(c.cmdline, c.cursor, c.cursor+1) + } +} + +func (c *Cmdline) deleteWord() { + i := c.cursor + for i > 0 && unicode.IsSpace(c.cmdline[i-1]) { + i-- + } + if i > 0 { + isk := isKeyword(c.cmdline[i-1]) + for i > 0 && isKeyword(c.cmdline[i-1]) == isk && !unicode.IsSpace(c.cmdline[i-1]) { + i-- + } + } + c.cmdline = slices.Delete(c.cmdline, i, c.cursor) + c.cursor = i +} + +func isKeyword(c rune) bool { + return unicode.IsDigit(c) || unicode.IsLetter(c) || c == '_' +} + +func (c *Cmdline) start(typ rune, arg string) { + c.typ = typ + c.cmdline = []rune(arg) + c.cursor = len(c.cmdline) + c.history = c.histories[typ == ':'] + c.historyIndex = len(c.history) +} + +func (c *Cmdline) clear() { + c.cmdline = []rune{} + c.cursor = 0 +} + +func (c *Cmdline) clearToHead() { + c.cmdline = slices.Delete(c.cmdline, 0, c.cursor) + c.cursor = 0 +} + +func (c *Cmdline) insert(ch rune) { + if unicode.IsPrint(ch) { + c.cmdline = slices.Insert(c.cmdline, c.cursor, ch) + c.cursor++ + } +} + +func (c *Cmdline) complete(forward bool) { + c.cmdline = []rune(c.completor.complete(string(c.cmdline), forward)) + c.cursor = len(c.cmdline) +} + +func (c *Cmdline) execute() (finish bool) { + defer c.saveHistory() + switch c.typ { + case ':': + cmd, r, bang, _, _, arg, err := parse(string(c.cmdline)) + if err != nil { + c.eventCh <- event.Event{Type: event.Error, Error: err} + } else if cmd.name != "" { + c.eventCh <- event.Event{Type: cmd.eventType, Range: r, CmdName: cmd.name, Bang: bang, Arg: arg} + finish = cmd.eventType == event.QuitAll || cmd.eventType == event.QuitErr + } + case '/': + c.eventCh <- event.Event{Type: event.ExecuteSearch, Arg: string(c.cmdline), Rune: '/'} + case '?': + c.eventCh <- event.Event{Type: event.ExecuteSearch, Arg: string(c.cmdline), Rune: '?'} + default: + panic("cmdline.Cmdline.execute: unreachable") + } + return +} + +func (c *Cmdline) saveHistory() { + cmdline := string(c.cmdline) + if cmdline == "" { + return + } + for i, h := range c.history { + if h == cmdline { + c.history = slices.Delete(c.history, i, i+1) + break + } + } + c.histories[c.typ == ':'] = append(c.history, cmdline) +} + +// Get returns the current state of cmdline. +func (c *Cmdline) Get() ([]rune, int, []string, int) { + c.mu.Lock() + defer c.mu.Unlock() + return c.cmdline, c.cursor, c.completor.results, c.completor.index +} diff --git a/bed/cmdline/cmdline_test.go b/bed/cmdline/cmdline_test.go new file mode 100644 index 0000000..a84349c --- /dev/null +++ b/bed/cmdline/cmdline_test.go @@ -0,0 +1,832 @@ +package cmdline + +import ( + "reflect" + "runtime" + "strings" + "testing" + + "b612.me/apps/b612/bed/event" +) + +func TestNewCmdline(t *testing.T) { + c := NewCmdline() + cmdline, cursor, _, _ := c.Get() + if len(cmdline) != 0 { + t.Errorf("cmdline should be empty but got %v", cmdline) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineRun(t *testing.T) { + c := NewCmdline() + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + go c.Run() + events := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.CursorLeft}, + {Type: event.CursorRight}, + {Type: event.CursorHead}, + {Type: event.CursorEnd}, + {Type: event.BackspaceCmdline}, + {Type: event.DeleteCmdline}, + {Type: event.DeleteWordCmdline}, + {Type: event.ClearToHeadCmdline}, + {Type: event.ClearCmdline}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.ExecuteCmdline}, + {Type: event.StartCmdlineCommand}, + {Type: event.ExecuteCmdline}, + } + go func() { + for _, e := range events { + cmdlineCh <- e + } + }() + for range len(events) - 3 { + <-redrawCh + } + e := <-eventCh + if e.Type != event.Error { + t.Errorf("cmdline should emit Error event but got %v", e) + } + cmdline, cursor, _, _ := c.Get() + if expected := "te"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 2 { + t.Errorf("cursor should be 2 but got %v", cursor) + } + for range 3 { + <-redrawCh + } + cmdline, _, _, _ = c.Get() + if expected := ""; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } +} + +func TestCmdlineCursorMotion(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + _, cursor, _, _ = c.Get() + if cursor != 4 { + t.Errorf("cursor should be 4 but got %v", cursor) + } + + for range 10 { + c.cursorLeft() + } + _, cursor, _, _ = c.Get() + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } + + c.cursorRight() + _, cursor, _, _ = c.Get() + if cursor != 1 { + t.Errorf("cursor should be 1 but got %v", cursor) + } + + for range 10 { + c.cursorRight() + } + _, cursor, _, _ = c.Get() + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorHead() + _, cursor, _, _ = c.Get() + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } + + c.cursorEnd() + _, cursor, _, _ = c.Get() + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } +} + +func TestCmdlineCursorBackspaceDelete(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + c.backspace() + + cmdline, cursor, _, _ = c.Get() + if expected := "abce"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } + + c.deleteRune() + + cmdline, cursor, _, _ = c.Get() + if expected := "abc"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } + + c.deleteRune() + + cmdline, cursor, _, _ = c.Get() + if expected := "abc"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } + + c.cursorLeft() + c.cursorLeft() + c.backspace() + c.backspace() + + cmdline, cursor, _, _ = c.Get() + if expected := "bc"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineCursorDeleteWord(t *testing.T) { + c := NewCmdline() + for _, ch := range "abcde" { + c.insert(ch) + } + + c.cursorLeft() + c.cursorLeft() + c.deleteWord() + + cmdline, cursor, _, _ := c.Get() + if expected := "de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } + + for _, ch := range "x0z!123 " { + c.insert(ch) + } + c.cursorLeft() + c.deleteWord() + + cmdline, cursor, _, _ = c.Get() + if expected := "x0z! de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 4 { + t.Errorf("cursor should be 4 but got %v", cursor) + } + + c.deleteWord() + + cmdline, cursor, _, _ = c.Get() + if expected := "x0z de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } +} + +func TestCmdlineCursorClear(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + c.clear() + + cmdline, cursor, _, _ = c.Get() + if expected := ""; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineCursorClearToHead(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + c.cursorLeft() + c.clearToHead() + + cmdline, cursor, _, _ = c.Get() + if expected := "de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineCursorInsert(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + + c.cursorLeft() + c.cursorLeft() + c.backspace() + c.insert('x') + c.insert('y') + + cmdline, cursor, _, _ := c.Get() + if expected := "abxyde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 4 { + t.Errorf("cursor should be 4 but got %v", cursor) + } +} + +func TestCmdlineQuit(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"exi", "exi[t]"}, + {"quit", "q[uit]"}, + {"q", "q[uit]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.Quit { + t.Errorf("cmdline should emit quit event with %q", cmd.cmd) + } + if e.Bang { + t.Errorf("cmdline should emit quit event without bang") + } + } +} + +func TestCmdlineForceQuit(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"exit!", "exi[t]"}, + {"q!", "q[uit]"}, + {"quit!", "q[uit]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.Quit { + t.Errorf("cmdline should emit quit event with %q", cmd.cmd) + } + if !e.Bang { + t.Errorf("cmdline should emit quit event with bang") + } + } +} + +func TestCmdlineExecuteQuitAll(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"qall", "qa[ll]"}, + {"qa", "qa[ll]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.QuitAll { + t.Errorf("cmdline should emit QuitAll event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteQuitErr(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"cquit", "cq[uit]"}, + {"cq", "cq[uit]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.QuitErr { + t.Errorf("cmdline should emit QuitErr event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteWrite(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"w", "w[rite]"}, + {" : : write sample.txt", "w[rite]"}, + {"'<,'>write sample.txt", "w[rite]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.Write { + t.Errorf("cmdline should emit Write event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteWriteQuit(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"wq", "wq"}, + {"x", "x[it]"}, + {"xit", "x[it]"}, + {"xa", "xa[ll]"}, + {"xall", "xa[ll]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.WriteQuit { + t.Errorf("cmdline should emit WriteQuit event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteGoto(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + pos event.Position + typ event.Type + }{ + {" : : $ ", event.End{}, event.CursorGoto}, + {" :123456789 ", event.Absolute{Offset: 123456789}, event.CursorGoto}, + {" +16777216 ", event.Relative{Offset: 16777216}, event.CursorGoto}, + {" -256 ", event.Relative{Offset: -256}, event.CursorGoto}, + {" : 0x123456789abcdef ", event.Absolute{Offset: 0x123456789abcdef}, event.CursorGoto}, + {" 0xfedcba ", event.Absolute{Offset: 0xfedcba}, event.CursorGoto}, + {" +0x44ef ", event.Relative{Offset: 0x44ef}, event.CursorGoto}, + {" -0xff ", event.Relative{Offset: -0xff}, event.CursorGoto}, + {"10go", event.Absolute{Offset: 10}, event.CursorGoto}, + {"+10 got", event.Relative{Offset: 10}, event.CursorGoto}, + {"$-10 goto", event.End{Offset: -10}, event.CursorGoto}, + {"10%", event.Absolute{Offset: 10}, event.CursorGoto}, + {"+10%", event.Relative{Offset: 10}, event.CursorGoto}, + {"$-10%", event.End{Offset: -10}, event.CursorGoto}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + expected := "goto" + if strings.HasSuffix(cmd.cmd, "%") { + expected = "%" + } else if strings.Contains(cmd.cmd, "go") { + expected = "go[to]" + } + if e.CmdName != expected { + t.Errorf("cmdline should report command name %q but got %q", expected, e.CmdName) + } + if !reflect.DeepEqual(e.Range.From, cmd.pos) { + t.Errorf("cmdline should report command with position %#v but got %#v", cmd.pos, e.Range.From) + } + if e.Type != cmd.typ { + t.Errorf("cmdline should emit %d but got %d with %q", cmd.typ, e.Type, cmd.cmd) + } + } +} + +func TestCmdlineComplete(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := NewCmdline() + c.completor = newCompletor(&mockFilesystem{}, nil) + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + waitCh := make(chan struct{}) + go c.Run() + go func() { + cmdlineCh <- event.Event{Type: event.StartCmdlineCommand} + cmdlineCh <- event.Event{Type: event.Rune, Rune: 'e'} + cmdlineCh <- event.Event{Type: event.Rune, Rune: ' '} + cmdlineCh <- event.Event{Type: event.Rune, Rune: '/'} + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.CompleteBackCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.CursorEnd} + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.ExecuteCmdline} + }() + for range 5 { + <-redrawCh + } + cmdline, cursor, _, _ := c.Get() + if expected := "e /bin/"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 7 { + t.Errorf("cursor should be 7 but got %v", cursor) + } + waitCh <- struct{}{} + <-redrawCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /tmp/"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 7 { + t.Errorf("cursor should be 7 but got %v", cursor) + } + waitCh <- struct{}{} + <-redrawCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /bin/"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 7 { + t.Errorf("cursor should be 7 but got %v", cursor) + } + waitCh <- struct{}{} + <-redrawCh + <-redrawCh + <-redrawCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /bin/echo"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 11 { + t.Errorf("cursor should be 11 but got %v", cursor) + } + waitCh <- struct{}{} + go func() { <-redrawCh }() + e := <-eventCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /bin/echo"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 11 { + t.Errorf("cursor should be 11 but got %v", cursor) + } + if e.Type != event.Edit { + t.Errorf("cmdline should emit Edit event but got %v", e) + } + if expected := "/bin/echo"; e.Arg != expected { + t.Errorf("cmdline should emit event with arg %q but got %v", expected, e) + } +} + +func TestCmdlineSearch(t *testing.T) { + c := NewCmdline() + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + waitCh := make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + defer func() { + close(eventCh) + close(cmdlineCh) + close(redrawCh) + }() + go c.Run() + events1 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 't'}, + {Type: event.CursorLeft}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 's'}, + {Type: event.ExecuteCmdline}, + } + events2 := []event.Event{ + {Type: event.StartCmdlineSearchBackward}, + {Type: event.Rune, Rune: 'x'}, + {Type: event.Rune, Rune: 'y'}, + {Type: event.Rune, Rune: 'z'}, + {Type: event.ExecuteCmdline}, + } + go func() { + for _, e := range events1 { + cmdlineCh <- e + } + <-waitCh + for _, e := range events2 { + cmdlineCh <- e + } + }() + for range len(events1) - 1 { + <-redrawCh + } + e := <-eventCh + <-redrawCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + if e.Rune != '/' { + t.Errorf("cmdline should emit search event with Rune %q but got %q", '/', e.Rune) + } + waitCh <- struct{}{} + for range len(events2) - 1 { + <-redrawCh + } + e = <-eventCh + <-redrawCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "xyz"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + if e.Rune != '?' { + t.Errorf("cmdline should emit search event with Rune %q but got %q", '?', e.Rune) + } +} + +func TestCmdlineHistory(t *testing.T) { + c := NewCmdline() + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + go c.Run() + events0 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.Rune, Rune: 'n'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 'w'}, + {Type: event.ExecuteCmdline}, + } + events1 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.Rune, Rune: 'v'}, + {Type: event.Rune, Rune: 'n'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 'w'}, + {Type: event.ExecuteCmdline}, + } + events2 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events3 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.CursorDown}, + {Type: event.ExecuteCmdline}, + } + events4 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events5 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 's'}, + {Type: event.Rune, Rune: 't'}, + {Type: event.ExecuteCmdline}, + } + events6 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.CursorUp}, + {Type: event.CursorDown}, + {Type: event.Rune, Rune: 'n'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 'w'}, + {Type: event.ExecuteCmdline}, + } + events7 := []event.Event{ + {Type: event.StartCmdlineSearchBackward}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events8 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events9 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + go func() { + for _, events := range [][]event.Event{ + events0, events1, events2, events3, events4, + events5, events6, events7, events8, events9, + } { + for _, e := range events { + cmdlineCh <- e + } + } + }() + for range len(events0) - 1 { + <-redrawCh + } + e := <-eventCh + if e.Type != event.New { + t.Errorf("cmdline should emit New event but got %v", e) + } + for range len(events1) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.Vnew { + t.Errorf("cmdline should emit Vnew event but got %v", e) + } + for range len(events2) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.Vnew { + t.Errorf("cmdline should emit Vnew event but got %v", e) + } + for range len(events3) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.New { + t.Errorf("cmdline should emit New event but got %v", e.Type) + } + for range len(events4) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.New { + t.Errorf("cmdline should emit New event but got %v", e.Type) + } + for range len(events5) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + for range len(events6) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "new"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + for range len(events7) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + for range len(events8) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.Vnew { + t.Errorf("cmdline should emit Vnew event but got %v", e.Type) + } + for range len(events9) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + <-redrawCh +} diff --git a/bed/cmdline/command.go b/bed/cmdline/command.go new file mode 100644 index 0000000..64be297 --- /dev/null +++ b/bed/cmdline/command.go @@ -0,0 +1,57 @@ +package cmdline + +import "b612.me/apps/b612/bed/event" + +type command struct { + name string + fullname string + eventType event.Type + rangeType rangeType +} + +type rangeType int + +const ( + rangeEmpty rangeType = 1 << iota + rangeCount + rangeBoth +) + +func (rt rangeType) allows(r *event.Range) bool { + switch { + case r == nil: + return rt&rangeEmpty != 0 + case r.To == nil: + return rt&rangeCount != 0 + default: + return rt&rangeBoth != 0 + } +} + +var commands = []command{ + {"e[dit]", "edit", event.Edit, rangeEmpty}, + {"ene[w]", "enew", event.Enew, rangeEmpty}, + {"new", "new", event.New, rangeEmpty}, + {"vne[w]", "vnew", event.Vnew, rangeEmpty}, + {"on[ly]", "only", event.Only, rangeEmpty}, + {"winc[md]", "wincmd", event.Wincmd, rangeEmpty}, + + {"go[to]", "goto", event.CursorGoto, rangeCount}, + {"%", "%", event.CursorGoto, rangeCount}, + + {"u[ndo]", "undo", event.Undo, rangeEmpty}, + {"red[o]", "redo", event.Redo, rangeEmpty}, + + {"pw[d]", "pwd", event.Pwd, rangeEmpty}, + {"cd", "cd", event.Chdir, rangeEmpty}, + {"chd[ir]", "chdir", event.Chdir, rangeEmpty}, + {"exi[t]", "exit", event.Quit, rangeEmpty}, + {"q[uit]", "quit", event.Quit, rangeEmpty}, + {"qa[ll]", "qall", event.QuitAll, rangeEmpty}, + {"quita[ll]", "quitall", event.QuitAll, rangeEmpty}, + {"cq[uit]", "cquit", event.QuitErr, rangeEmpty}, + {"w[rite]", "write", event.Write, rangeEmpty | rangeBoth}, + {"wq", "wq", event.WriteQuit, rangeEmpty | rangeBoth}, + {"x[it]", "xit", event.WriteQuit, rangeEmpty | rangeBoth}, + {"xa[ll]", "xall", event.WriteQuit, rangeEmpty | rangeBoth}, +} diff --git a/bed/cmdline/completor.go b/bed/cmdline/completor.go new file mode 100644 index 0000000..54f510d --- /dev/null +++ b/bed/cmdline/completor.go @@ -0,0 +1,266 @@ +package cmdline + +import ( + "os" + "path/filepath" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "b612.me/apps/b612/bed/event" +) + +type completor struct { + fs fs + env env + command bool + target string + arg string + results []string + index int +} + +func newCompletor(fs fs, env env) *completor { + return &completor{fs: fs, env: env} +} + +func (c *completor) complete(cmdline string, forward bool) string { + cmd, r, _, name, prefix, arg, _ := parse(cmdline) + if name == "" || c.command || + !hasSuffixFunc(prefix, unicode.IsSpace) && cmd.fullname != name { + cmdline = c.completeCommand(cmdline, name, prefix, r, forward) + if c.results != nil { + return cmdline + } + prefix = cmdline + } + switch cmd.eventType { + case event.Edit, event.New, event.Vnew, event.Write, event.WriteQuit: + return c.completeFilepath(cmdline, prefix, arg, forward, false) + case event.Chdir: + return c.completeFilepath(cmdline, prefix, arg, forward, true) + case event.Wincmd: + return c.completeWincmd(cmdline, prefix, arg, forward) + default: + return cmdline + } +} + +func (c *completor) completeNext(prefix string, forward bool) string { + if len(c.results) == 0 { + return c.target + } + if forward { + c.index = (c.index+2)%(len(c.results)+1) - 1 + } else { + c.index = (c.index+len(c.results)+1)%(len(c.results)+1) - 1 + } + if c.index < 0 { + return c.target + } + if len(c.results) == 1 { + defer c.clear() + } + return prefix + c.arg + c.results[c.index] +} + +func (c *completor) completeCommand( + cmdline, name, prefix string, r *event.Range, forward bool, +) string { + prefix = prefix[:len(prefix)-len(name)] + if c.results == nil { + c.command, c.target, c.index = true, cmdline, -1 + c.arg, c.results = "", listCommandNames(name, r) + } + return c.completeNext(prefix, forward) +} + +func listCommandNames(name string, r *event.Range) []string { + var targets []string + for _, cmd := range commands { + if strings.HasPrefix(cmd.fullname, name) && cmd.rangeType.allows(r) { + targets = append(targets, cmd.fullname) + } + } + slices.Sort(targets) + return targets +} + +func (c *completor) completeFilepath( + cmdline, prefix, arg string, forward, dirOnly bool, +) string { + if !hasSuffixFunc(prefix, unicode.IsSpace) { + prefix += " " + } + if c.results == nil { + c.command, c.target, c.index = false, cmdline, -1 + c.arg, c.results = c.listFileNames(arg, dirOnly) + } + return c.completeNext(prefix, forward) +} + +const separator = string(filepath.Separator) + +func (c *completor) listFileNames(arg string, dirOnly bool) (string, []string) { + var targets []string + path, simplify := c.expandPath(arg) + if strings.HasPrefix(arg, "$") && !strings.Contains(arg, separator) { + base := strings.ToLower(arg[1:]) + for _, env := range c.env.List() { + name, value, ok := strings.Cut(env, "=") + if !ok { + continue + } + if !strings.HasPrefix(strings.ToLower(name), base) { + continue + } + if !filepath.IsAbs(value) { + continue + } + fi, err := c.fs.Stat(value) + if err != nil { + continue + } + if fi.IsDir() { + name += separator + } else if dirOnly { + continue + } + targets = append(targets, "$"+name) + } + slices.Sort(targets) + return "", targets + } + if arg != "" && !strings.HasSuffix(arg, separator) && + (!strings.HasSuffix(arg, ".") || strings.HasSuffix(arg, "..")) { + if stat, err := c.fs.Stat(path); err == nil && stat.IsDir() { + return "", []string{arg + separator} + } + } + if strings.HasSuffix(arg, separator) || strings.HasSuffix(arg, separator+".") { + path += separator + } + dir, base := filepath.Dir(path), strings.ToLower(filepath.Base(path)) + if arg == "" { + base = "" + } else if strings.HasSuffix(path, separator) { + if strings.HasSuffix(arg, separator+".") { + base = "." + } else { + base = "" + } + } + f, err := c.fs.Open(dir) + if err != nil { + return arg, nil + } + defer f.Close() + fileInfos, err := f.Readdir(1024) + if err != nil { + return arg, nil + } + for _, fileInfo := range fileInfos { + name := fileInfo.Name() + if !strings.HasPrefix(strings.ToLower(name), base) { + continue + } + isDir := fileInfo.IsDir() + if !isDir && fileInfo.Mode()&os.ModeSymlink != 0 { + fileInfo, err := c.fs.Stat(filepath.Join(dir, name)) + if err != nil { + continue + } + isDir = fileInfo.IsDir() + } + if isDir { + name += separator + } else if dirOnly { + continue + } + targets = append(targets, name) + } + slices.SortFunc(targets, func(p, q string) int { + ps, pd := p[len(p)-1] == filepath.Separator, p[0] == '.' + qs, qd := q[len(q)-1] == filepath.Separator, q[0] == '.' + switch { + case ps && !qs: + return 1 + case !ps && qs: + return -1 + case pd && !qd: + return 1 + case !pd && qd: + return -1 + default: + return strings.Compare(p, q) + } + }) + if simplify != nil { + arg = simplify(dir) + separator + } else if !strings.HasPrefix(arg, "."+separator) && dir == "." { + arg = "" + } else if arg = dir; !strings.HasSuffix(arg, separator) { + arg += separator + } + return arg, targets +} + +func (c *completor) expandPath(path string) (string, func(string) string) { + switch { + case strings.HasPrefix(path, "~"): + if name, rest, _ := strings.Cut(path[1:], separator); name != "" { + user, err := c.fs.GetUser(name) + if err != nil { + return path, nil + } + return filepath.Join(user.HomeDir, rest), func(path string) string { + return filepath.Join("~"+user.Username, strings.TrimPrefix(path, user.HomeDir)) + } + } + homedir, err := c.fs.UserHomeDir() + if err != nil { + return path, nil + } + return filepath.Join(homedir, path[1:]), func(path string) string { + return filepath.Join("~", strings.TrimPrefix(path, homedir)) + } + case strings.HasPrefix(path, "$"): + name, rest, _ := strings.Cut(path[1:], separator) + value := strings.TrimRight(c.env.Get(name), separator) + if value == "" { + return path, nil + } + return filepath.Join(value, rest), func(path string) string { + return filepath.Join("$"+name, strings.TrimPrefix(path, value)) + } + default: + return path, nil + } +} + +func (c *completor) completeWincmd( + cmdline, prefix, arg string, forward bool, +) string { + if !hasSuffixFunc(prefix, unicode.IsSpace) { + prefix += " " + } + if c.results == nil { + if arg != "" { + return cmdline + } + c.command, c.target, c.arg, c.index = false, cmdline, "", -1 + c.results = strings.Split("nohjkltbpHJKL", "") + } + return c.completeNext(prefix, forward) +} + +func (c *completor) clear() { + c.command, c.target, c.arg = false, "", "" + c.results, c.index = nil, 0 +} + +func hasSuffixFunc(s string, f func(rune) bool) bool { + r, size := utf8.DecodeLastRuneInString(s) + return size > 0 && f(r) +} diff --git a/bed/cmdline/completor_test.go b/bed/cmdline/completor_test.go new file mode 100644 index 0000000..bba8f6e --- /dev/null +++ b/bed/cmdline/completor_test.go @@ -0,0 +1,566 @@ +package cmdline + +import ( + "path/filepath" + "runtime" + "slices" + "testing" +) + +func TestCompletorCompleteCommand(t *testing.T) { + c := newCompletor(nil, nil) + cmdline := c.complete("", true) + if expected := "cd"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + if expected := "edit"; !slices.Contains(c.results, expected) { + t.Errorf("completion results should contain %q but got %v", expected, c.results) + } + if expected := "goto"; slices.Contains(c.results, expected) { + t.Errorf("completion results should not contain %q but got %v", expected, c.results) + } + if expected := "write"; !slices.Contains(c.results, expected) { + t.Errorf("completion results should contain %q but got %v", expected, c.results) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "edit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + for range 4 { + cmdline = c.complete(cmdline, false) + } + if expected := ""; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + for range 3 { + cmdline = c.complete(cmdline, false) + } + if expected := "write"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = c.complete(": :\t", true) + if expected := ": :\tcd"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = c.complete(": : cq", true) + if expected := ": : cquit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = c.complete("e", false) + if expected := "exit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + cmdline = c.complete(cmdline, true) + if expected := "e"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + cmdline = c.complete(cmdline, true) + if expected := "edit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + cmdline = c.complete(cmdline, false) + if expected := "e"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = "p" + for _, expected := range []string{"pwd", "pwd"} { + cmdline = c.complete(cmdline, true) + if cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + } + + c.clear() + cmdline = "10" + for _, command := range []string{"%", "goto", ""} { + cmdline = c.complete(cmdline, true) + if expected := "10" + command; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + } + + c.clear() + cmdline = "10,20" + for _, command := range []string{"wq", "write", "xall", "xit", ""} { + cmdline = c.complete(cmdline, true) + if expected := "10,20" + command; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + } + + c.clear() + cmdline = c.complete("not", true) + if expected := "not"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if len(c.results) != 0 { + t.Errorf("completion results should be empty but got %v", c.results) + } +} + +func TestCompletorCompleteFilepath(t *testing.T) { + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("new", true) + if expected := "new CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if expected := "README.md"; !slices.Contains(c.results, expected) { + t.Errorf("completion results should contain %q but got %v", expected, c.results) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "new .gitignore"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 3 { + t.Errorf("completion index should be %d but got %d", 3, c.index) + } + + for range 4 { + cmdline = c.complete(cmdline, true) + } + if expected := "new editor" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 7 { + t.Errorf("completion index should be %d but got %d", 7, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "new"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "new CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, false) + if expected := "new"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "new README.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 2 { + t.Errorf("completion index should be %d but got %d", 2, c.index) + } + + c.clear() + cmdline = c.complete("w change", true) + if expected := "w CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("wq .", true) + if expected := "wq .gitignore"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("new not", true) + if expected := "new not"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new not"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("edit", true) + if expected := "edit CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "edit"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathLeadingDot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("edit .", true) + if expected := "edit .gitignore"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("edit ./r", true) + if expected := "edit ./README.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("cd ..", true) + if expected := "cd ../"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathKeepPrefix(t *testing.T) { + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete(" : : : new \tB", true) + if expected := " : : : new \tbuffer" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := " : : : new \tB"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := " : : : new \tbuild" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + for range 2 { + cmdline = c.complete(cmdline, false) + } + if expected := " : : : new \tB"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + c.clear() + cmdline = c.complete(" : cd\u3000", true) + if expected := " : cd\u3000buffer" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathHomedir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("vnew ~/", true) + if expected := "vnew ~/example.txt"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "vnew ~/"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "vnew ~/.vimrc"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "vnew ~/Library/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 4 { + t.Errorf("completion index should be %d but got %d", 4, c.index) + } + + for range 2 { + cmdline = c.complete(cmdline, true) + } + if expected := "vnew ~/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + c.clear() + cmdline = c.complete("cd ~user/", true) + if expected := "cd ~user/Documents/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "cd ~user/"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathHomedirDot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("vnew ~/.", false) + if expected := "vnew ~/.zshrc"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "vnew ~/."; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "vnew ~/."; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } +} + +func TestCompletorCompleteFilepathEnviron(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + + c := newCompletor(&mockFilesystem{}, &mockEnvironment{}) + cmdline := c.complete("e $h", true) + if expected := "e $HOME/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("e $HOME/", true) + if expected := "e $HOME/example.txt"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "e $HOME/"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("cd $h", true) + if expected := "cd $HOME/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("cd $HOME/", true) + if expected := "cd $HOME/Documents/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("e /", true) + if expected := "e /bin/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "e /"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "e /tmp/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + cmdline = c.complete(cmdline, false) + c.clear() + cmdline = c.complete(cmdline, true) + if expected := "e /bin/cp"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathChdir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("cd ", false) + if expected := "cd editor/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "cd "; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 3 { + t.Errorf("completion index should be %d but got %d", 3, c.index) + } + + c.clear() + cmdline = c.complete("cd ~/", false) + if expected := "cd ~/Pictures/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 2 { + t.Errorf("completion index should be %d but got %d", 2, c.index) + } + + c.clear() + cmdline = c.complete("cd /", true) + if expected := "cd /bin/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteWincmd(t *testing.T) { + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("winc", true) + if expected := "wincmd n"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + for range 7 { + cmdline = c.complete(cmdline, true) + } + if expected := "wincmd b"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 7 { + t.Errorf("completion index should be %d but got %d", 7, c.index) + } + + for range 7 { + cmdline = c.complete(cmdline, true) + } + if expected := "wincmd n"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, false) + if expected := "wincmd"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + c.clear() + cmdline = c.complete("winc j", true) + if expected := "winc j"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} diff --git a/bed/cmdline/environment.go b/bed/cmdline/environment.go new file mode 100644 index 0000000..3a51400 --- /dev/null +++ b/bed/cmdline/environment.go @@ -0,0 +1,18 @@ +package cmdline + +import "os" + +type env interface { + Get(string) string + List() []string +} + +type environment struct{} + +func (*environment) Get(key string) string { + return os.Getenv(key) +} + +func (*environment) List() []string { + return os.Environ() +} diff --git a/bed/cmdline/environment_test.go b/bed/cmdline/environment_test.go new file mode 100644 index 0000000..e65dfbb --- /dev/null +++ b/bed/cmdline/environment_test.go @@ -0,0 +1,14 @@ +package cmdline + +type mockEnvironment struct{} + +func (*mockEnvironment) Get(key string) string { + if key == "HOME" { + return mockHomeDir + } + return "" +} + +func (*mockEnvironment) List() []string { + return []string{"HOME=" + mockHomeDir} +} diff --git a/bed/cmdline/filesystem.go b/bed/cmdline/filesystem.go new file mode 100644 index 0000000..bdbfa64 --- /dev/null +++ b/bed/cmdline/filesystem.go @@ -0,0 +1,36 @@ +package cmdline + +import ( + "os" + "os/user" +) + +type fs interface { + Open(string) (file, error) + Stat(string) (os.FileInfo, error) + GetUser(string) (*user.User, error) + UserHomeDir() (string, error) +} + +type file interface { + Close() error + Readdir(int) ([]os.FileInfo, error) +} + +type filesystem struct{} + +func (*filesystem) Open(path string) (file, error) { + return os.Open(path) +} + +func (*filesystem) Stat(path string) (os.FileInfo, error) { + return os.Stat(path) +} + +func (*filesystem) GetUser(name string) (*user.User, error) { + return user.Lookup(name) +} + +func (*filesystem) UserHomeDir() (string, error) { + return os.UserHomeDir() +} diff --git a/bed/cmdline/filesystem_test.go b/bed/cmdline/filesystem_test.go new file mode 100644 index 0000000..c8943ce --- /dev/null +++ b/bed/cmdline/filesystem_test.go @@ -0,0 +1,118 @@ +package cmdline + +import ( + "os" + "os/user" + "time" +) + +const mockHomeDir = "/home/user" + +type mockFilesystem struct{} + +func (*mockFilesystem) Open(path string) (file, error) { + return &mockFile{path}, nil +} + +func (*mockFilesystem) Stat(path string) (os.FileInfo, error) { + return &mockFileInfo{ + name: path, + isDir: path == mockHomeDir || path == "..", + }, nil +} + +func (*mockFilesystem) GetUser(name string) (*user.User, error) { + return &user.User{Username: name, HomeDir: mockHomeDir}, nil +} + +func (*mockFilesystem) UserHomeDir() (string, error) { + return mockHomeDir, nil +} + +type mockFile struct { + path string +} + +func (*mockFile) Close() error { + return nil +} + +func createFileInfoList(infos []*mockFileInfo) []os.FileInfo { + fileInfos := make([]os.FileInfo, len(infos)) + for i, info := range infos { + fileInfos[i] = info + } + return fileInfos +} + +func (f *mockFile) Readdir(_ int) ([]os.FileInfo, error) { + if f.path == "." { + return createFileInfoList([]*mockFileInfo{ + {"CHANGELOG.md", false}, + {"README.md", false}, + {"Makefile", false}, + {".gitignore", false}, + {"editor", true}, + {"cmdline", true}, + {"buffer", true}, + {"build", true}, + }), nil + } + if f.path == mockHomeDir { + return createFileInfoList([]*mockFileInfo{ + {"Documents", true}, + {"Pictures", true}, + {"Library", true}, + {".vimrc", false}, + {".zshrc", false}, + {"example.txt", false}, + }), nil + } + if f.path == "/" { + return createFileInfoList([]*mockFileInfo{ + {"bin", true}, + {"tmp", true}, + {"var", true}, + {"usr", true}, + }), nil + } + if f.path == "/bin" { + return createFileInfoList([]*mockFileInfo{ + {"cp", false}, + {"echo", false}, + {"rm", false}, + {"ls", false}, + {"kill", false}, + }), nil + } + return nil, nil +} + +type mockFileInfo struct { + name string + isDir bool +} + +func (fi *mockFileInfo) Name() string { + return fi.name +} + +func (fi *mockFileInfo) IsDir() bool { + return fi.isDir +} + +func (*mockFileInfo) Size() int64 { + return 0 +} + +func (*mockFileInfo) Mode() os.FileMode { + return os.FileMode(0x1ed) +} + +func (*mockFileInfo) ModTime() time.Time { + return time.Time{} +} + +func (*mockFileInfo) Sys() any { + return nil +} diff --git a/bed/cmdline/parse.go b/bed/cmdline/parse.go new file mode 100644 index 0000000..b55c26f --- /dev/null +++ b/bed/cmdline/parse.go @@ -0,0 +1,55 @@ +package cmdline + +import ( + "errors" + "strings" + "unicode" + + "b612.me/apps/b612/bed/event" +) + +func parse(src string) (cmd command, r *event.Range, + bang bool, name, prefix, arg string, err error) { + prefix, arg = cutPrefixFunc(src, func(r rune) bool { + return unicode.IsSpace(r) || r == ':' + }) + if arg == "" { + return + } + r, arg = event.ParseRange(arg) + name, arg = cutPrefixFunc(arg, func(r rune) bool { + return !unicode.IsSpace(r) + }) + name, bang = strings.CutSuffix(name, "!") + prefix = src[:len(src)-len(arg)] + if name == "" { + // To jump by byte offset, name should not be "go[to]". + cmd = command{name: "goto", eventType: event.CursorGoto} + return + } + for _, cmd = range commands { + if matchCommand(cmd.name, name) { + arg = strings.TrimLeftFunc(arg, unicode.IsSpace) + prefix = src[:len(src)-len(arg)] + return + } + } + cmd, err = command{}, errors.New("unknown command: "+name) + return +} + +func cutPrefixFunc(src string, f func(rune) bool) (string, string) { + for i, r := range src { + if !f(r) { + return src[:i], src[i:] + } + } + return src, "" +} + +func matchCommand(cmd, name string) bool { + prefix, rest, _ := strings.Cut(cmd, "[") + abbr, _, _ := strings.Cut(rest, "]") + return strings.HasPrefix(name, prefix) && + strings.HasPrefix(abbr, name[len(prefix):]) +} diff --git a/bed/editor/cmdline.go b/bed/editor/cmdline.go new file mode 100644 index 0000000..7ab2dc9 --- /dev/null +++ b/bed/editor/cmdline.go @@ -0,0 +1,10 @@ +package editor + +import "b612.me/apps/b612/bed/event" + +// Cmdline defines the required cmdline interface for the editor. +type Cmdline interface { + Init(chan<- event.Event, <-chan event.Event, chan<- struct{}) + Run() + Get() ([]rune, int, []string, int) +} diff --git a/bed/editor/editor.go b/bed/editor/editor.go new file mode 100644 index 0000000..cb4efad --- /dev/null +++ b/bed/editor/editor.go @@ -0,0 +1,345 @@ +package editor + +import ( + "errors" + "fmt" + "io" + "strconv" + "strings" + "sync" + + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +// Editor is the main struct for this command. +type Editor struct { + ui UI + wm Manager + cmdline Cmdline + mode mode.Mode + prevMode mode.Mode + searchTarget string + searchMode rune + prevEventType event.Type + buffer *buffer.Buffer + err error + errtyp int + cmdEventCh chan event.Event + wmEventCh chan event.Event + uiEventCh chan event.Event + redrawCh chan struct{} + cmdlineCh chan event.Event + quitCh chan struct{} + mu *sync.Mutex +} + +// NewEditor creates a new editor. +func NewEditor(ui UI, wm Manager, cmdline Cmdline) *Editor { + return &Editor{ + ui: ui, + wm: wm, + cmdline: cmdline, + mode: mode.Normal, + prevMode: mode.Normal, + } +} + +// Init initializes the editor. +func (e *Editor) Init() error { + e.cmdEventCh = make(chan event.Event) + e.wmEventCh = make(chan event.Event) + e.uiEventCh = make(chan event.Event) + e.redrawCh = make(chan struct{}) + e.cmdlineCh = make(chan event.Event) + e.cmdline.Init(e.cmdEventCh, e.cmdlineCh, e.redrawCh) + e.quitCh = make(chan struct{}) + e.wm.Init(e.wmEventCh, e.redrawCh) + e.mu = new(sync.Mutex) + return nil +} + +func (e *Editor) listen() error { + var wg sync.WaitGroup + errCh := make(chan error, 1) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-e.redrawCh: + _ = e.redraw() + case <-e.quitCh: + return + } + } + }() + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case ev := <-e.wmEventCh: + if redraw, finish, err := e.emit(ev); redraw { + e.redrawCh <- struct{}{} + } else if finish { + close(e.quitCh) + errCh <- err + } + case <-e.quitCh: + return + } + } + }() + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case ev := <-e.cmdEventCh: + if redraw, finish, err := e.emit(ev); redraw { + e.redrawCh <- struct{}{} + } else if finish { + close(e.quitCh) + errCh <- err + } + case ev := <-e.uiEventCh: + if redraw, finish, err := e.emit(ev); redraw { + e.redrawCh <- struct{}{} + } else if finish { + close(e.quitCh) + errCh <- err + } + case <-e.quitCh: + return + } + } + }() + wg.Wait() + select { + case err := <-errCh: + return err + default: + return nil + } +} + +type quitErr struct { + code int +} + +func (err *quitErr) Error() string { + return "exit with " + strconv.Itoa(err.code) +} + +func (err *quitErr) ExitCode() int { + return err.code +} + +func (e *Editor) emit(ev event.Event) (redraw, finish bool, err error) { + e.mu.Lock() + if ev.Type != event.Redraw { + e.prevEventType = ev.Type + } + switch ev.Type { + case event.QuitAll: + if ev.Arg != "" { + e.err, e.errtyp = errors.New("too many arguments for "+ev.CmdName), state.MessageError + redraw = true + } else { + finish = true + } + case event.QuitErr: + args := strings.Fields(ev.Arg) + if len(args) > 1 { + e.err, e.errtyp = errors.New("too many arguments for "+ev.CmdName), state.MessageError + redraw = true + } else if len(args) > 0 { + n, er := strconv.Atoi(args[0]) + if er != nil { + e.err, e.errtyp = fmt.Errorf("invalid argument for %s: %w", ev.CmdName, er), state.MessageError + redraw = true + } else { + err = &quitErr{n} + finish = true + } + } else { + err = &quitErr{1} + finish = true + } + case event.Suspend: + e.mu.Unlock() + if err := suspend(e); err != nil { + e.mu.Lock() + e.err, e.errtyp = err, state.MessageError + e.mu.Unlock() + } + redraw = true + return + case event.Info: + e.err, e.errtyp = ev.Error, state.MessageInfo + redraw = true + case event.Error: + e.err, e.errtyp = ev.Error, state.MessageError + redraw = true + case event.Redraw: + width, height := e.ui.Size() + e.wm.Resize(width, height-1) + redraw = true + case event.Copied: + e.mode, e.prevMode = mode.Normal, e.mode + if ev.Buffer != nil { + e.buffer = ev.Buffer + if l, err := e.buffer.Len(); err != nil { + e.err, e.errtyp = err, state.MessageError + } else { + e.err, e.errtyp = fmt.Errorf("%[1]d (0x%[1]x) bytes %[2]s", l, ev.Arg), state.MessageInfo + } + } + redraw = true + case event.Pasted: + e.err, e.errtyp = fmt.Errorf("%[1]d (0x%[1]x) bytes pasted", ev.Count), state.MessageInfo + redraw = true + default: + switch ev.Type { + case event.StartInsert, event.StartInsertHead, event.StartAppend, event.StartAppendEnd: + e.mode, e.prevMode = mode.Insert, e.mode + case event.StartReplaceByte, event.StartReplace: + e.mode, e.prevMode = mode.Replace, e.mode + case event.ExitInsert: + e.mode, e.prevMode = mode.Normal, e.mode + case event.StartVisual: + e.mode, e.prevMode = mode.Visual, e.mode + case event.ExitVisual: + e.mode, e.prevMode = mode.Normal, e.mode + case event.StartCmdlineCommand: + if e.mode == mode.Visual { + ev.Arg = "'<,'>" + } else if ev.Count > 0 { + ev.Arg = ".,.+" + strconv.FormatInt(ev.Count-1, 10) + } + e.mode, e.prevMode = mode.Cmdline, e.mode + e.err = nil + case event.StartCmdlineSearchForward: + e.mode, e.prevMode = mode.Search, e.mode + e.err = nil + e.searchMode = '/' + case event.StartCmdlineSearchBackward: + e.mode, e.prevMode = mode.Search, e.mode + e.err = nil + e.searchMode = '?' + case event.ExitCmdline: + e.mode, e.prevMode = mode.Normal, e.mode + case event.ExecuteCmdline: + m := mode.Normal + if e.mode == mode.Search { + m = e.prevMode + } + e.mode, e.prevMode = m, e.mode + case event.ExecuteSearch: + e.searchTarget, e.searchMode = ev.Arg, ev.Rune + case event.NextSearch: + ev.Arg, ev.Rune, e.err = e.searchTarget, e.searchMode, nil + case event.PreviousSearch: + ev.Arg, ev.Rune, e.err = e.searchTarget, e.searchMode, nil + case event.Paste, event.PastePrev: + if e.buffer == nil { + e.mu.Unlock() + return + } + ev.Buffer = e.buffer + } + if e.mode == mode.Cmdline || e.mode == mode.Search || + ev.Type == event.ExitCmdline || ev.Type == event.ExecuteCmdline { + e.mu.Unlock() + e.cmdlineCh <- ev + } else { + if event.ScrollUp <= ev.Type && ev.Type <= event.SwitchFocus { + e.prevMode, e.err = e.mode, nil + } + ev.Mode = e.mode + width, height := e.ui.Size() + e.wm.Resize(width, height-1) + e.mu.Unlock() + e.wm.Emit(ev) + } + return + } + e.mu.Unlock() + return +} + +// Open opens a new file. +func (e *Editor) Open(name string) error { + return e.wm.Open(name) +} + +// OpenEmpty creates a new window. +func (e *Editor) OpenEmpty() error { + return e.wm.Open("") +} + +// Read [io.Reader] and creates a new window. +func (e *Editor) Read(r io.Reader) error { + return e.wm.Read(r) +} + +// Run the editor. +func (e *Editor) Run() error { + if err := e.ui.Init(e.uiEventCh); err != nil { + return err + } + if err := e.redraw(); err != nil { + return err + } + go e.ui.Run(defaultKeyManagers()) + go e.cmdline.Run() + return e.listen() +} + +func (e *Editor) redraw() (err error) { + e.mu.Lock() + defer e.mu.Unlock() + var s state.State + var windowIndex int + s.WindowStates, s.Layout, windowIndex, err = e.wm.State() + if err != nil { + return err + } + if s.WindowStates[windowIndex] == nil { + return errors.New("index out of windows") + } + s.WindowStates[windowIndex].Mode = e.mode + s.Mode, s.PrevMode, s.Error, s.ErrorType = e.mode, e.prevMode, e.err, e.errtyp + if s.Mode != mode.Visual && s.PrevMode != mode.Visual { + for _, ws := range s.WindowStates { + ws.VisualStart = -1 + } + } + s.Cmdline, s.CmdlineCursor, s.CompletionResults, s.CompletionIndex = e.cmdline.Get() + if e.mode == mode.Search || e.prevEventType == event.ExecuteSearch { + s.SearchMode = e.searchMode + } else if e.prevEventType == event.NextSearch { + s.SearchMode, s.Cmdline = e.searchMode, []rune(e.searchTarget) + } else if e.prevEventType == event.PreviousSearch { + if e.searchMode == '/' { + s.SearchMode, s.Cmdline = '?', []rune(e.searchTarget) + } else { + s.SearchMode, s.Cmdline = '/', []rune(e.searchTarget) + } + } + return e.ui.Redraw(s) +} + +// Close terminates the editor. +func (e *Editor) Close() error { + close(e.cmdEventCh) + close(e.wmEventCh) + close(e.uiEventCh) + close(e.redrawCh) + close(e.cmdlineCh) + e.wm.Close() + return e.ui.Close() +} diff --git a/bed/editor/editor_test.go b/bed/editor/editor_test.go new file mode 100644 index 0000000..71ed9eb --- /dev/null +++ b/bed/editor/editor_test.go @@ -0,0 +1,883 @@ +package editor + +import ( + "fmt" + "os" + "reflect" + "runtime" + "strings" + "testing" + + "b612.me/apps/b612/bed/cmdline" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" + "b612.me/apps/b612/bed/window" +) + +type testUI struct { + eventCh chan<- event.Event + initCh chan struct{} + redrawCh chan struct{} +} + +func newTestUI() *testUI { + return &testUI{ + initCh: make(chan struct{}), + redrawCh: make(chan struct{}), + } +} + +func (ui *testUI) Init(eventCh chan<- event.Event) error { + ui.eventCh = eventCh + go func() { defer close(ui.initCh); <-ui.redrawCh }() + return nil +} + +func (*testUI) Run(map[mode.Mode]*key.Manager) {} + +func (*testUI) Size() (int, int) { return 90, 20 } + +func (ui *testUI) Redraw(state.State) error { + ui.redrawCh <- struct{}{} + return nil +} + +func (*testUI) Close() error { return nil } + +func (ui *testUI) Emit(e event.Event) { + <-ui.initCh + ui.eventCh <- e + switch e.Type { + case event.ExecuteCmdline, event.NextSearch, event.PreviousSearch: + <-ui.redrawCh + } + <-ui.redrawCh +} + +func createTemp(dir, str string) (*os.File, error) { + f, err := os.CreateTemp(dir, "") + if err != nil { + return nil, err + } + if str != "" { + if _, err = f.WriteString(str); err != nil { + return nil, err + } + } + if err = f.Close(); err != nil { + return nil, err + } + if str == "" { + if err = os.Remove(f.Name()); err != nil { + return nil, err + } + } + return f, nil +} + +func TestEditorOpenEmptyWriteQuit(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.Increment, Count: 13}) + ui.Emit(event.Event{Type: event.Decrement, Count: 6}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name()}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "1 (0x1) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "\x07"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorOpenWriteQuit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartInsert}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '8'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '0'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '0'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'f'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'a'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorLeft}) + ui.Emit(event.Event{Type: event.Decrement}) + ui.Emit(event.Event{Type: event.StartInsertHead}) + ui.Emit(event.Event{Type: event.Rune, Rune: '1'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorEnd}) + ui.Emit(event.Event{Type: event.Delete}) + ui.Emit(event.Event{Type: event.WriteQuit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "\x12\x48\xff"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorOpenQuitBang(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartInsert}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '8'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.Quit}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err, expected := editor.err, "you have unsaved changes in [No Name], "+ + "add ! to force :quit"; err == nil || !strings.HasSuffix(err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorOpenWriteQuitBang(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "ab") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.SwitchFocus}) + ui.Emit(event.Event{Type: event.StartAppendEnd}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.WriteQuit, Arg: f.Name() + ".out", Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "ab"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + bs, err = os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "abc"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorReadWriteQuit(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + r := strings.NewReader("Hello, world!") + if err := editor.Read(r); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.WriteQuit, Arg: f.Name()}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello, world!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorWritePartial(t *testing.T) { + str := "Hello, world! こんにちは、世界!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + for _, testCase := range []struct { + cmdRange string + count int + expected string + }{ + {"", 41, str}, + {"-10,$+10", 41, str}, + {"10,25", 16, str[10:26]}, + {".+3+3+3+5+5 , .+0xa-0x6", 16, str[4:20]}, + {"$-20,.+28", 9, str[20:29]}, + } { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + fout, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func(name string) { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + for _, c := range testCase.cmdRange + "w " + name { + ui.Emit(event.Event{Type: event.Rune, Rune: c}) + } + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }(fout.Name()) + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := fmt.Sprintf("%[1]d (0x%[1]x) bytes written", testCase.count); editor.err == nil || + !strings.Contains(editor.err.Error(), expected) { + t.Errorf("err should be contain %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(fout.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if string(bs) != testCase.expected { + t.Errorf("file contents should be %q but got %q", testCase.expected, string(bs)) + } + } +} + +func TestEditorWriteVisualSelection(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.CursorNext, Count: 4}) + ui.Emit(event.Event{Type: event.StartVisual}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 5}) + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'w'}) + ui.Emit(event.Event{Type: event.Rune, Rune: ' '}) + for _, ch := range f.Name() + ".out" { + ui.Emit(event.Event{Type: event.Rune, Rune: ch}) + } + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "6 (0x6) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "o, wor"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorWriteUndo(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "abc") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Undo}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "2 (0x2) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "abc"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + bs, err = os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "bc"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorSearch(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "abcdefabcdefabcdef") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineSearchForward}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'e'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'f'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Nop}) // wait for redraw + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.PreviousSearch}) + ui.Emit(event.Event{Type: event.NextSearch}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.StartCmdlineSearchBackward}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'b'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Nop}) // wait for redraw + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.PreviousSearch}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "14 (0xe) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "abcdfacdfacdef"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorCmdlineCursorGoto(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: '6'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out1"}) + ui.Emit(event.Event{Type: event.Undo}) + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '0'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '%'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.DeletePrevByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out2"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out1") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello,world!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + bs, err = os.ReadFile(f.Name() + ".out2") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello, wrld!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorCmdlineQuit(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'q'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'u'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'i'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 't'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorCmdlineQuitAll(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'q'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'a'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'l'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'l'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorCmdlineQuitErr(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'q'}) + ui.Emit(event.Event{Type: event.Rune, Rune: ' '}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + }() + if err, expected := editor.Run(), (&quitErr{42}); !reflect.DeepEqual(expected, err) { + t.Errorf("err should be %v but got: %v", expected, err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorReplace(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.StartReplace}) + ui.Emit(event.Event{Type: event.SwitchFocus}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'a'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'b'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'd'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'e'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorLeft, Count: 5}) + ui.Emit(event.Event{Type: event.StartReplaceByte}) + ui.Emit(event.Event{Type: event.SwitchFocus}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.StartReplace}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '3'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '5'}) + ui.Emit(event.Event{Type: event.Backspace}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorEnd}) + ui.Emit(event.Event{Type: event.StartReplace}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '6'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '8'}) + ui.Emit(event.Event{Type: event.Backspace}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorHead}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "13 (0xd) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "earcrsterldvw"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorCopyCutPaste(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.StartVisual}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 5}) + ui.Emit(event.Event{Type: event.Copy}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 3}) + ui.Emit(event.Event{Type: event.Paste}) + ui.Emit(event.Event{Type: event.CursorPrev, Count: 2}) + ui.Emit(event.Event{Type: event.StartVisual}) + ui.Emit(event.Event{Type: event.CursorPrev, Count: 5}) + ui.Emit(event.Event{Type: event.Cut}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 5}) + ui.Emit(event.Event{Type: event.PastePrev}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "19 (0x13) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hell w woo,llo,rld!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorShowBinary(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.ShowBinary}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "01001000"; editor.err == nil || editor.err.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorShowDecimal(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.ShowDecimal}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "72"; editor.err == nil || editor.err.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorShift(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.ShiftLeft, Count: 1}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 7}) + ui.Emit(event.Event{Type: event.ShiftRight, Count: 3}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "13 (0xd) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "\x90ello, \x0eorld!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorChdir(t *testing.T) { + dir, err := os.Getwd() + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.Pwd}) + ui.Emit(event.Event{Type: event.Chdir, Arg: "../"}) + ui.Emit(event.Event{Type: event.Chdir, Arg: "-"}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err == nil || err.Error() != dir { + t.Errorf("err should be %q but got: %v", dir, err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} diff --git a/bed/editor/key.go b/bed/editor/key.go new file mode 100644 index 0000000..dc40660 --- /dev/null +++ b/bed/editor/key.go @@ -0,0 +1,198 @@ +package editor + +import ( + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/mode" +) + +func defaultKeyManagers() map[mode.Mode]*key.Manager { + kms := make(map[mode.Mode]*key.Manager) + km := defaultNormalAndVisual() + km.Register(event.Quit, "c-w", "q") + km.Register(event.Quit, "c-w", "c-q") + km.Register(event.Quit, "c-w", "c") + km.RegisterBang(event.Quit, "Z", "Q") + km.Register(event.WriteQuit, "Z", "Z") + km.Register(event.Suspend, "c-z") + + km.Register(event.JumpTo, "\x1d") + km.Register(event.JumpBack, "c-t") + km.Register(event.DeleteByte, "x") + km.Register(event.DeleteByte, "delete") + km.Register(event.DeletePrevByte, "X") + km.Register(event.Increment, "c-a") + km.Register(event.Increment, "+") + km.Register(event.Decrement, "c-x") + km.Register(event.Decrement, "-") + km.Register(event.ShiftLeft, "<") + km.Register(event.ShiftRight, ">") + km.Register(event.ShowBinary, "g", "b") + km.Register(event.ShowDecimal, "g", "d") + + km.Register(event.Paste, "p") + km.Register(event.PastePrev, "P") + + km.Register(event.StartInsert, "i") + km.Register(event.StartInsertHead, "I") + km.Register(event.StartAppend, "a") + km.Register(event.StartAppendEnd, "A") + km.Register(event.StartReplace, "R") + + km.Register(event.Undo, "u") + km.Register(event.Redo, "c-r") + + km.Register(event.StartVisual, "v") + + km.Register(event.New, "c-w", "n") + km.Register(event.New, "c-w", "c-n") + km.Register(event.Only, "c-w", "o") + km.Register(event.Only, "c-w", "c-o") + km.Register(event.Alternative, "\x1e") + km.Register(event.FocusWindowDown, "c-w", "down") + km.Register(event.FocusWindowDown, "c-w", "c-j") + km.Register(event.FocusWindowDown, "c-w", "j") + km.Register(event.FocusWindowUp, "c-w", "up") + km.Register(event.FocusWindowUp, "c-w", "c-k") + km.Register(event.FocusWindowUp, "c-w", "k") + km.Register(event.FocusWindowLeft, "c-w", "left") + km.Register(event.FocusWindowLeft, "c-w", "c-h") + km.Register(event.FocusWindowLeft, "c-w", "backspace") + km.Register(event.FocusWindowLeft, "c-w", "h") + km.Register(event.FocusWindowRight, "c-w", "right") + km.Register(event.FocusWindowRight, "c-w", "c-l") + km.Register(event.FocusWindowRight, "c-w", "l") + km.Register(event.FocusWindowTopLeft, "c-w", "t") + km.Register(event.FocusWindowTopLeft, "c-w", "c-t") + km.Register(event.FocusWindowBottomRight, "c-w", "b") + km.Register(event.FocusWindowBottomRight, "c-w", "c-b") + km.Register(event.FocusWindowPrevious, "c-w", "p") + km.Register(event.FocusWindowPrevious, "c-w", "c-p") + km.Register(event.MoveWindowTop, "c-w", "K") + km.Register(event.MoveWindowBottom, "c-w", "J") + km.Register(event.MoveWindowLeft, "c-w", "H") + km.Register(event.MoveWindowRight, "c-w", "L") + kms[mode.Normal] = km + + km = key.NewManager(false) + km.Register(event.ExitInsert, "escape") + km.Register(event.ExitInsert, "c-c") + km.Register(event.CursorUp, "up") + km.Register(event.CursorDown, "down") + km.Register(event.CursorLeft, "left") + km.Register(event.CursorRight, "right") + km.Register(event.CursorUp, "c-p") + km.Register(event.CursorDown, "c-n") + km.Register(event.CursorPrev, "c-b") + km.Register(event.CursorNext, "c-f") + km.Register(event.PageUp, "pgup") + km.Register(event.PageDown, "pgdn") + km.Register(event.PageTop, "home") + km.Register(event.PageEnd, "end") + km.Register(event.Backspace, "backspace") + km.Register(event.Backspace, "backspace2") + km.Register(event.Delete, "delete") + km.Register(event.SwitchFocus, "tab") + km.Register(event.SwitchFocus, "backtab") + kms[mode.Insert] = km + kms[mode.Replace] = km + + km = defaultNormalAndVisual() + km.Register(event.ExitVisual, "escape") + km.Register(event.ExitVisual, "c-c") + km.Register(event.ExitVisual, "v") + km.Register(event.SwitchVisualEnd, "o") + km.Register(event.SwitchVisualEnd, "O") + + km.Register(event.Copy, "y") + km.Register(event.Cut, "x") + km.Register(event.Cut, "d") + km.Register(event.Cut, "delete") + kms[mode.Visual] = km + + km = key.NewManager(false) + km.Register(event.CursorUp, "up") + km.Register(event.CursorDown, "down") + km.Register(event.CursorLeft, "left") + km.Register(event.CursorRight, "right") + km.Register(event.CursorUp, "c-p") + km.Register(event.CursorDown, "c-n") + km.Register(event.CursorLeft, "c-b") + km.Register(event.CursorRight, "c-f") + km.Register(event.CursorHead, "home") + km.Register(event.CursorHead, "c-a") + km.Register(event.CursorEnd, "end") + km.Register(event.CursorEnd, "c-e") + km.Register(event.BackspaceCmdline, "c-h") + km.Register(event.BackspaceCmdline, "backspace") + km.Register(event.BackspaceCmdline, "backspace2") + km.Register(event.DeleteCmdline, "delete") + km.Register(event.DeleteWordCmdline, "c-w") + km.Register(event.ClearToHeadCmdline, "c-u") + km.Register(event.ClearCmdline, "c-k") + km.Register(event.ExitCmdline, "escape") + km.Register(event.ExitCmdline, "c-c") + km.Register(event.CompleteForwardCmdline, "tab") + km.Register(event.CompleteBackCmdline, "backtab") + km.Register(event.ExecuteCmdline, "enter") + km.Register(event.ExecuteCmdline, "c-j") + km.Register(event.ExecuteCmdline, "c-m") + kms[mode.Cmdline] = km + kms[mode.Search] = km + return kms +} + +func defaultNormalAndVisual() *key.Manager { + km := key.NewManager(true) + km.Register(event.CursorUp, "up") + km.Register(event.CursorDown, "down") + km.Register(event.CursorLeft, "left") + km.Register(event.CursorRight, "right") + km.Register(event.PageUp, "pgup") + km.Register(event.PageDown, "pgdn") + km.Register(event.PageTop, "home") + km.Register(event.PageEnd, "end") + km.Register(event.CursorUp, "k") + km.Register(event.CursorDown, "j") + km.Register(event.CursorLeft, "h") + km.Register(event.CursorRight, "l") + km.Register(event.CursorPrev, "b") + km.Register(event.CursorPrev, "backspace") + km.Register(event.CursorPrev, "backspace2") + km.Register(event.CursorNext, "w") + km.Register(event.CursorNext, " ") + km.Register(event.CursorHead, "0") + km.Register(event.CursorHead, "^") + km.Register(event.CursorEnd, "$") + km.Register(event.ScrollUp, "c-y") + km.Register(event.ScrollDown, "c-e") + km.Register(event.ScrollTop, "z", "t") + km.Register(event.ScrollTopHead, "z", "enter") + km.Register(event.ScrollMiddle, "z", "z") + km.Register(event.ScrollMiddleHead, "z", ".") + km.Register(event.ScrollBottom, "z", "b") + km.Register(event.ScrollBottomHead, "z", "-") + km.Register(event.WindowTop, "H") + km.Register(event.WindowMiddle, "M") + km.Register(event.WindowBottom, "L") + + km.Register(event.PageUp, "c-b") + km.Register(event.PageDown, "c-f") + km.Register(event.PageUpHalf, "c-u") + km.Register(event.PageDownHalf, "c-d") + km.Register(event.PageTop, "g", "g") + km.Register(event.PageEnd, "G") + + km.Register(event.SwitchFocus, "tab") + km.Register(event.SwitchFocus, "backtab") + + km.Register(event.StartCmdlineSearchForward, "/") + km.Register(event.StartCmdlineSearchBackward, "?") + km.Register(event.NextSearch, "n") + km.Register(event.PreviousSearch, "N") + km.Register(event.AbortSearch, "c-c") + + km.Register(event.StartCmdlineCommand, ":") + km.Register(event.StartReplaceByte, "r") + return km +} diff --git a/bed/editor/manager.go b/bed/editor/manager.go new file mode 100644 index 0000000..1ef47a2 --- /dev/null +++ b/bed/editor/manager.go @@ -0,0 +1,21 @@ +package editor + +import ( + "io" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/state" +) + +// Manager defines the required window manager interface for the editor. +type Manager interface { + Init(chan<- event.Event, chan<- struct{}) + Open(string) error + Read(io.Reader) error + SetSize(int, int) + Resize(int, int) + Emit(event.Event) + State() (map[int]*state.WindowState, layout.Layout, int, error) + Close() +} diff --git a/bed/editor/suspend_linux.go b/bed/editor/suspend_linux.go new file mode 100644 index 0000000..456ed33 --- /dev/null +++ b/bed/editor/suspend_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package editor + +import "syscall" + +func suspend(e *Editor) error { + if err := e.ui.Close(); err != nil { + return err + } + pid, tid := syscall.Getpid(), syscall.Gettid() + if err := syscall.Tgkill(pid, tid, syscall.SIGSTOP); err != nil { + return err + } + if err := e.ui.Init(e.uiEventCh); err != nil { + return err + } + if err := e.redraw(); err != nil { + return err + } + go e.ui.Run(defaultKeyManagers()) + return nil +} diff --git a/bed/editor/suspend_unix.go b/bed/editor/suspend_unix.go new file mode 100644 index 0000000..9cebca0 --- /dev/null +++ b/bed/editor/suspend_unix.go @@ -0,0 +1,23 @@ +//go:build !windows && !linux + +package editor + +import "syscall" + +func suspend(e *Editor) error { + if err := e.ui.Close(); err != nil { + return err + } + pid := syscall.Getpid() + if err := syscall.Kill(pid, syscall.SIGSTOP); err != nil { + return err + } + if err := e.ui.Init(e.uiEventCh); err != nil { + return err + } + if err := e.redraw(); err != nil { + return err + } + go e.ui.Run(defaultKeyManagers()) + return nil +} diff --git a/bed/editor/suspend_windows.go b/bed/editor/suspend_windows.go new file mode 100644 index 0000000..5b0001e --- /dev/null +++ b/bed/editor/suspend_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package editor + +func suspend(_ *Editor) error { + return nil +} diff --git a/bed/editor/ui.go b/bed/editor/ui.go new file mode 100644 index 0000000..a5497ac --- /dev/null +++ b/bed/editor/ui.go @@ -0,0 +1,17 @@ +package editor + +import ( + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +// UI defines the required user interface for the editor. +type UI interface { + Init(chan<- event.Event) error + Run(map[mode.Mode]*key.Manager) + Size() (int, int) + Redraw(state.State) error + Close() error +} diff --git a/bed/event/event.go b/bed/event/event.go new file mode 100644 index 0000000..e9fbd95 --- /dev/null +++ b/bed/event/event.go @@ -0,0 +1,141 @@ +package event + +import ( + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/mode" +) + +// Event represents the event emitted by UI. +type Event struct { + Type Type + Range *Range + Count int64 + Rune rune + CmdName string + Bang bool + Arg string + Error error + Mode mode.Mode + Buffer *buffer.Buffer +} + +// Type ... +type Type int + +// Event types +const ( + Nop Type = iota + Redraw + + CursorUp + CursorDown + CursorLeft + CursorRight + CursorPrev + CursorNext + CursorHead + CursorEnd + CursorGoto + ScrollUp + ScrollDown + ScrollTop + ScrollTopHead + ScrollMiddle + ScrollMiddleHead + ScrollBottom + ScrollBottomHead + PageUp + PageDown + PageUpHalf + PageDownHalf + PageTop + PageEnd + WindowTop + WindowMiddle + WindowBottom + JumpTo + JumpBack + + DeleteByte + DeletePrevByte + Increment + Decrement + ShiftLeft + ShiftRight + SwitchFocus + ShowBinary + ShowDecimal + + StartInsert + StartInsertHead + StartAppend + StartAppendEnd + StartReplaceByte + StartReplace + + ExitInsert + Backspace + Delete + Rune + + Undo + Redo + + StartVisual + SwitchVisualEnd + ExitVisual + + Copy + Cut + Copied + Paste + PastePrev + Pasted + + StartCmdlineCommand + StartCmdlineSearchForward + StartCmdlineSearchBackward + BackspaceCmdline + DeleteCmdline + DeleteWordCmdline + ClearToHeadCmdline + ClearCmdline + ExitCmdline + CompleteForwardCmdline + CompleteBackCmdline + ExecuteCmdline + ExecuteSearch + NextSearch + PreviousSearch + AbortSearch + + Edit + Enew + New + Vnew + Only + Alternative + Wincmd + FocusWindowUp + FocusWindowDown + FocusWindowLeft + FocusWindowRight + FocusWindowTopLeft + FocusWindowBottomRight + FocusWindowPrevious + MoveWindowTop + MoveWindowBottom + MoveWindowLeft + MoveWindowRight + + Pwd + Chdir + Suspend + Quit + QuitAll + QuitErr + Write + WriteQuit + Info + Error +) diff --git a/bed/event/parse.go b/bed/event/parse.go new file mode 100644 index 0000000..fcdb946 --- /dev/null +++ b/bed/event/parse.go @@ -0,0 +1,96 @@ +package event + +import ( + "strings" + "unicode" +) + +// ParseRange parses a Range. +func ParseRange(src string) (*Range, string) { + var from, to Position + from, src = parsePosition(src) + if from == nil { + return nil, src + } + var ok bool + if src, ok = strings.CutPrefix(src, ","); !ok { + return &Range{From: from}, src + } + to, src = parsePosition(src) + return &Range{From: from, To: to}, src +} + +func parsePosition(src string) (Position, string) { + var pos Position + var offset int64 + src = strings.TrimLeftFunc(src, unicode.IsSpace) + if src == "" { + return nil, src + } + switch src[0] { + case '.': + src = src[1:] + fallthrough + case '-', '+': + pos = Relative{} + case '$': + pos = End{} + src = src[1:] + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + offset, src = parseNum(src) + pos = Absolute{offset} + case '\'': + if len(src) == 1 { + return nil, src + } + switch src[1] { + case '<': + pos = VisualStart{} + case '>': + pos = VisualEnd{} + default: + return nil, src + } + src = src[2:] + default: + return nil, src + } + for src != "" { + src = strings.TrimLeftFunc(src, unicode.IsSpace) + if src == "" { + break + } + sign := int64(1) + switch src[0] { + case '-': + sign = -1 + fallthrough + case '+': + offset, src = parseNum(src[1:]) + pos = pos.add(sign * offset) + default: + return pos, src + } + } + return pos, src +} + +func parseNum(src string) (int64, string) { + offset, radix, ishex := int64(0), int64(10), false + if src, ishex = strings.CutPrefix(src, "0x"); ishex { + radix = 16 + } + for src != "" { + c := src[0] + switch { + case '0' <= c && c <= '9': + offset = offset*radix + int64(c-'0') + case ('A' <= c && c <= 'F' || 'a' <= c && c <= 'f') && ishex: + offset = offset*radix + int64(c|('a'-'A')-'a'+10) + default: + return offset, src + } + src = src[1:] + } + return offset, src +} diff --git a/bed/event/parse_test.go b/bed/event/parse_test.go new file mode 100644 index 0000000..71e2c7e --- /dev/null +++ b/bed/event/parse_test.go @@ -0,0 +1,42 @@ +package event + +import ( + "reflect" + "testing" +) + +func TestParseRange(t *testing.T) { + testCases := []struct { + target string + expected *Range + rest string + }{ + {"", nil, ""}, + {"e", nil, "e"}, + {" ", nil, ""}, + {"$", &Range{End{}, nil}, ""}, + {" $-72 , $-36 ", &Range{End{-72}, End{-36}}, ""}, + {"32", &Range{Absolute{32}, nil}, ""}, + {"+32", &Range{Relative{32}, nil}, ""}, + {"-32", &Range{Relative{-32}, nil}, ""}, + {"1024,4096", &Range{Absolute{1024}, Absolute{4096}}, ""}, + {"1+2+3+4+5+6+7+8+9,0xa+0xb+0xc+0xD+0xE+0xF", &Range{Absolute{45}, Absolute{75}}, ""}, + {"10d", &Range{Absolute{10}, nil}, "d"}, + {"0x12G", &Range{Absolute{0x12}, nil}, "G"}, + {"0x10fag", &Range{Absolute{0x10fa}, nil}, "g"}, + {".-100,.+100", &Range{Relative{-100}, Relative{100}}, ""}, + {"'", nil, "'"}, + {"' ", nil, "' "}, + {"'<", &Range{VisualStart{}, nil}, ""}, + {"'>", &Range{VisualEnd{}, nil}, ""}, + {" '< , '> write", &Range{VisualStart{}, VisualEnd{}}, "write"}, + {" '<+0x10 , '>-10w", &Range{VisualStart{0x10}, VisualEnd{-10}}, "w"}, + } + for _, testCase := range testCases { + got, rest := ParseRange(testCase.target) + if !reflect.DeepEqual(got, testCase.expected) || rest != testCase.rest { + t.Errorf("ParseRange(%q) should return\n\t%#v, %q\nbut got\n\t%#v, %q", + testCase.target, testCase.expected, testCase.rest, got, rest) + } + } +} diff --git a/bed/event/range.go b/bed/event/range.go new file mode 100644 index 0000000..697408f --- /dev/null +++ b/bed/event/range.go @@ -0,0 +1,45 @@ +package event + +// Range of event +type Range struct { + From Position + To Position +} + +// Position ... +type Position interface{ add(int64) Position } + +// Absolute is the absolute position of the buffer. +type Absolute struct{ Offset int64 } + +func (p Absolute) add(offset int64) Position { + return Absolute{p.Offset + offset} +} + +// Relative is the relative position of the buffer. +type Relative struct{ Offset int64 } + +func (p Relative) add(offset int64) Position { + return Relative{p.Offset + offset} +} + +// End is the end of the buffer. +type End struct{ Offset int64 } + +func (p End) add(offset int64) Position { + return End{p.Offset + offset} +} + +// VisualStart is the start position of visual selection. +type VisualStart struct{ Offset int64 } + +func (p VisualStart) add(offset int64) Position { + return VisualStart{p.Offset + offset} +} + +// VisualEnd is the end position of visual selection. +type VisualEnd struct{ Offset int64 } + +func (p VisualEnd) add(offset int64) Position { + return VisualEnd{p.Offset + offset} +} diff --git a/bed/history/history.go b/bed/history/history.go new file mode 100644 index 0000000..97ff170 --- /dev/null +++ b/bed/history/history.go @@ -0,0 +1,56 @@ +package history + +import "b612.me/apps/b612/bed/buffer" + +// History manages the buffer history. +type History struct { + entries []*historyEntry + index int +} + +type historyEntry struct { + buffer *buffer.Buffer + offset int64 + cursor int64 + tick uint64 +} + +// NewHistory creates a new history manager. +func NewHistory() *History { + return &History{index: -1} +} + +// Push a new buffer to the history. +func (h *History) Push(buffer *buffer.Buffer, offset, cursor int64, tick uint64) { + newEntry := &historyEntry{buffer.Clone(), offset, cursor, tick} + if len(h.entries)-1 > h.index { + h.index++ + h.entries[h.index] = newEntry + h.entries = h.entries[:h.index+1] + } else { + h.entries = append(h.entries, newEntry) + h.index++ + } +} + +// Undo the history. +func (h *History) Undo() (*buffer.Buffer, int, int64, int64, uint64) { + if h.index < 0 { + return nil, h.index, 0, 0, 0 + } + if h.index > 0 { + h.index-- + } + e := h.entries[h.index] + return e.buffer.Clone(), h.index, e.offset, e.cursor, e.tick +} + +// Redo the history. +func (h *History) Redo() (*buffer.Buffer, int64, int64, uint64) { + if h.index == len(h.entries)-1 || h.index < 0 { + return nil, 0, 0, 0 + } + h.index++ + e := h.entries[h.index] + return e.buffer.Clone(), e.offset, e.cursor, e.tick +} diff --git a/bed/history/history_test.go b/bed/history/history_test.go new file mode 100644 index 0000000..73bf21a --- /dev/null +++ b/bed/history/history_test.go @@ -0,0 +1,99 @@ +package history + +import ( + "strings" + "testing" + + "b612.me/apps/b612/bed/buffer" +) + +func TestHistoryUndo(t *testing.T) { + history := NewHistory() + b, index, offset, cursor, tick := history.Undo() + if b != nil { + t.Errorf("history.Undo should return nil buffer but got %v", b) + } + if index != -1 { + t.Errorf("history.Undo should return index -1 but got %d", index) + } + if offset != 0 { + t.Errorf("history.Undo should return offset 0 but got %d", offset) + } + if cursor != 0 { + t.Errorf("history.Undo should return cursor 0 but got %d", cursor) + } + if tick != 0 { + t.Errorf("history.Undo should return tick 0 but got %d", tick) + } + + buffer1 := buffer.NewBuffer(strings.NewReader("test1")) + history.Push(buffer1, 2, 1, 1) + + buffer2 := buffer.NewBuffer(strings.NewReader("test2")) + history.Push(buffer2, 3, 2, 2) + + buf := make([]byte, 8) + b, index, offset, cursor, tick = history.Undo() + if b == nil { + t.Fatalf("history.Undo should return buffer but got nil") + } + _, err := b.Read(buf) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "test1\x00\x00\x00"; string(buf) != expected { + t.Errorf("buf should be %q but got %q", expected, string(buf)) + } + if index != 0 { + t.Errorf("history.Undo should return index 0 but got %d", index) + } + if offset != 2 { + t.Errorf("history.Undo should return offset 2 but got %d", offset) + } + if cursor != 1 { + t.Errorf("history.Undo should return cursor 1 but got %d", cursor) + } + if tick != 1 { + t.Errorf("history.Undo should return tick 1 but got %d", tick) + } + + buf = make([]byte, 8) + b, offset, cursor, tick = history.Redo() + if b == nil { + t.Fatalf("history.Redo should return buffer but got nil") + } + _, err = b.Read(buf) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "test2\x00\x00\x00"; string(buf) != expected { + t.Errorf("buf should be %q but got %q", expected, string(buf)) + } + if offset != 3 { + t.Errorf("history.Redo should return offset 3 but got %d", offset) + } + if cursor != 2 { + t.Errorf("history.Redo should return cursor 2 but got %d", cursor) + } + if tick != 2 { + t.Errorf("history.Redo should return cursor 2 but got %d", tick) + } + + history.Undo() + buffer3 := buffer.NewBuffer(strings.NewReader("test2")) + history.Push(buffer3, 3, 2, 3) + + b, offset, cursor, tick = history.Redo() + if b != nil { + t.Errorf("history.Redo should return nil buffer but got %v", b) + } + if offset != 0 { + t.Errorf("history.Redo should return offset 0 but got %d", offset) + } + if cursor != 0 { + t.Errorf("history.Redo should return cursor 0 but got %d", cursor) + } + if tick != 0 { + t.Errorf("history.Redo should return tick 0 but got %d", tick) + } +} diff --git a/bed/key/key.go b/bed/key/key.go new file mode 100644 index 0000000..5802e52 --- /dev/null +++ b/bed/key/key.go @@ -0,0 +1,91 @@ +package key + +import ( + "strconv" + + "b612.me/apps/b612/bed/event" +) + +// Key represents one keyboard stroke. +type Key string + +type keyEvent struct { + keys []Key + event event.Type + bang bool +} + +const ( + keysEq = iota + keysPending + keysNeq +) + +func (ke keyEvent) cmp(ks []Key) int { + if len(ke.keys) < len(ks) { + return keysNeq + } + for i, k := range ke.keys { + if i >= len(ks) { + return keysPending + } + if k != ks[i] { + return keysNeq + } + } + return keysEq +} + +// Manager holds the key mappings and current key sequence. +type Manager struct { + keys []Key + events []keyEvent + count bool +} + +// NewManager creates a new Manager. +func NewManager(count bool) *Manager { + return &Manager{count: count} +} + +// Register adds a new key mapping. +func (km *Manager) Register(eventType event.Type, keys ...Key) { + km.events = append(km.events, keyEvent{keys, eventType, false}) +} + +// RegisterBang adds a new key mapping with bang. +func (km *Manager) RegisterBang(eventType event.Type, keys ...Key) { + km.events = append(km.events, keyEvent{keys, eventType, true}) +} + +// Press checks the new key down event. +func (km *Manager) Press(k Key) event.Event { + km.keys = append(km.keys, k) + for i := range len(km.keys) { + keys := km.keys[i:] + var count int64 + if km.count { + numStr := "" + for j, k := range keys { + if len(k) == 1 && ('1' <= k[0] && k[0] <= '9' || k[0] == '0' && j > 0) { + numStr += string(k) + } else { + break + } + } + keys = keys[len(numStr):] + count, _ = strconv.ParseInt(numStr, 10, 64) + } + for _, ke := range km.events { + switch ke.cmp(keys) { + case keysPending: + return event.Event{Type: event.Nop} + case keysEq: + km.keys = nil + return event.Event{Type: ke.event, Count: count, Bang: ke.bang} + } + } + } + km.keys = nil + return event.Event{Type: event.Nop} +} diff --git a/bed/key/key_test.go b/bed/key/key_test.go new file mode 100644 index 0000000..3a04ecf --- /dev/null +++ b/bed/key/key_test.go @@ -0,0 +1,71 @@ +package key + +import ( + "testing" + + "b612.me/apps/b612/bed/event" +) + +func TestKeyManagerPress(t *testing.T) { + km := NewManager(true) + km.Register(event.CursorUp, "k") + e := km.Press("k") + if e.Type != event.CursorUp { + t.Errorf("pressing k should emit event.CursorUp but got: %d", e.Type) + } + e = km.Press("j") + if e.Type != event.Nop { + t.Errorf("pressing j should be nop but got: %d", e.Type) + } +} + +func TestKeyManagerPressMulti(t *testing.T) { + km := NewManager(true) + km.Register(event.CursorUp, "k", "k", "j") + km.Register(event.CursorDown, "k", "j", "j") + km.Register(event.CursorDown, "j", "k", "k") + e := km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k should be nop but got: %d", e.Type) + } + e = km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k twice should be nop but got: %d", e.Type) + } + e = km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k three times should be nop but got: %d", e.Type) + } + e = km.Press("j") + if e.Type != event.CursorUp { + t.Errorf("pressing kkj should emit event.CursorUp but got: %d", e.Type) + } +} + +func TestKeyManagerPressCount(t *testing.T) { + km := NewManager(true) + km.Register(event.CursorUp, "k", "j") + e := km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k should be nop but got: %d", e.Type) + } + e = km.Press("3") + if e.Type != event.Nop { + t.Errorf("pressing 3 should be nop but got: %d", e.Type) + } + e = km.Press("7") + if e.Type != event.Nop { + t.Errorf("pressing 7 should be nop but got: %d", e.Type) + } + e = km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k should be nop but got: %d", e.Type) + } + e = km.Press("j") + if e.Type != event.CursorUp { + t.Errorf("pressing 37kj should emit event.CursorUp but got: %d", e.Type) + } + if e.Count != 37 { + t.Errorf("pressing 37kj should emit event.CursorUp with count 37 but got: %d", e.Count) + } +} diff --git a/bed/layout/layout.go b/bed/layout/layout.go new file mode 100644 index 0000000..1df376c --- /dev/null +++ b/bed/layout/layout.go @@ -0,0 +1,500 @@ +package layout + +// Layout represents the window layout. +type Layout interface { + isLayout() + Collect() map[int]Window + Replace(int) Layout + Resize(int, int, int, int) Layout + LeftMargin() int + TopMargin() int + Width() int + Height() int + SplitTop(int) Layout + SplitBottom(int) Layout + SplitLeft(int) Layout + SplitRight(int) Layout + Count() (int, int) + Activate(int) Layout + ActivateFirst() Layout + ActiveWindow() Window + Lookup(func(Window) bool) Window + Close() Layout +} + +// Window holds the window index and it is active or not. +type Window struct { + Index int + Active bool + left int + top int + width int + height int +} + +// NewLayout creates a new Layout from a window index. +func NewLayout(index int) Layout { + return Window{Index: index, Active: true} +} + +func (Window) isLayout() {} + +// Collect returns all the Window. +func (l Window) Collect() map[int]Window { + return map[int]Window{l.Index: l} +} + +// Replace the active window with new window index. +func (l Window) Replace(index int) Layout { + if l.Active { + // revive:disable-next-line:modifies-value-receiver + l.Index = index + } + return l +} + +// Resize recalculates the position. +func (l Window) Resize(left, top, width, height int) Layout { + // revive:disable-next-line:modifies-value-receiver + l.left, l.top, l.width, l.height = left, top, width, height + return l +} + +// LeftMargin returns the left margin. +func (l Window) LeftMargin() int { + return l.left +} + +// TopMargin returns the top margin. +func (l Window) TopMargin() int { + return l.top +} + +// Width returns the width. +func (l Window) Width() int { + return l.width +} + +// Height returns the height. +func (l Window) Height() int { + return l.height +} + +// SplitTop splits the layout and opens a new window to the top. +func (l Window) SplitTop(index int) Layout { + if !l.Active { + return l + } + return Horizontal{ + Top: Window{Index: index, Active: true}, + Bottom: Window{Index: l.Index, Active: false}, + } +} + +// SplitBottom splits the layout and opens a new window to the bottom. +func (l Window) SplitBottom(index int) Layout { + if !l.Active { + return l + } + return Horizontal{ + Top: Window{Index: l.Index, Active: false}, + Bottom: Window{Index: index, Active: true}, + } +} + +// SplitLeft splits the layout and opens a new window to the left. +func (l Window) SplitLeft(index int) Layout { + if !l.Active { + return l + } + return Vertical{ + Left: Window{Index: index, Active: true}, + Right: Window{Index: l.Index, Active: false}, + } +} + +// SplitRight splits the layout and opens a new window to the right. +func (l Window) SplitRight(index int) Layout { + if !l.Active { + return l + } + return Vertical{ + Left: Window{Index: l.Index, Active: false}, + Right: Window{Index: index, Active: true}, + } +} + +// Count returns the width and height counts. +func (Window) Count() (int, int) { + return 1, 1 +} + +// Activate the specific window layout. +func (l Window) Activate(i int) Layout { + // revive:disable-next-line:modifies-value-receiver + l.Active = l.Index == i + return l +} + +// ActivateFirst the first layout. +func (l Window) ActivateFirst() Layout { + // revive:disable-next-line:modifies-value-receiver + l.Active = true + return l +} + +// ActiveWindow returns the active window. +func (l Window) ActiveWindow() Window { + if l.Active { + return l + } + return Window{Index: -1} +} + +// Lookup search for the window meets the condition. +func (l Window) Lookup(cond func(Window) bool) Window { + if cond(l) { + return l + } + return Window{Index: -1} +} + +// Close the active layout. +func (l Window) Close() Layout { + if l.Active { + panic("Active Window should not be closed") + } + return l +} + +// Horizontal holds two layout horizontally. +type Horizontal struct { + Top Layout + Bottom Layout + left int + top int + width int + height int +} + +func (Horizontal) isLayout() {} + +// Collect returns all the Window. +func (l Horizontal) Collect() map[int]Window { + m := l.Top.Collect() + for i, l := range l.Bottom.Collect() { + m[i] = l + } + return m +} + +// Replace the active window with new window index. +func (l Horizontal) Replace(index int) Layout { + return Horizontal{ + Top: l.Top.Replace(index), + Bottom: l.Bottom.Replace(index), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// Resize recalculates the position. +func (l Horizontal) Resize(left, top, width, height int) Layout { + _, h1 := l.Top.Count() + _, h2 := l.Bottom.Count() + topHeight := height * h1 / (h1 + h2) + return Horizontal{ + Top: l.Top.Resize(left, top, width, topHeight), + Bottom: l.Bottom.Resize(left, top+topHeight, width, height-topHeight), + left: left, + top: top, + width: width, + height: height, + } +} + +// LeftMargin returns the left margin. +func (l Horizontal) LeftMargin() int { + return l.left +} + +// TopMargin returns the top margin. +func (l Horizontal) TopMargin() int { + return l.top +} + +// Width returns the width. +func (l Horizontal) Width() int { + return l.width +} + +// Height returns the height. +func (l Horizontal) Height() int { + return l.height +} + +// SplitTop splits the layout and opens a new window to the top. +func (l Horizontal) SplitTop(index int) Layout { + return Horizontal{ + Top: l.Top.SplitTop(index), + Bottom: l.Bottom.SplitTop(index), + } +} + +// SplitBottom splits the layout and opens a new window to the bottom. +func (l Horizontal) SplitBottom(index int) Layout { + return Horizontal{ + Top: l.Top.SplitBottom(index), + Bottom: l.Bottom.SplitBottom(index), + } +} + +// SplitLeft splits the layout and opens a new window to the left. +func (l Horizontal) SplitLeft(index int) Layout { + return Horizontal{ + Top: l.Top.SplitLeft(index), + Bottom: l.Bottom.SplitLeft(index), + } +} + +// SplitRight splits the layout and opens a new window to the right. +func (l Horizontal) SplitRight(index int) Layout { + return Horizontal{ + Top: l.Top.SplitRight(index), + Bottom: l.Bottom.SplitRight(index), + } +} + +// Count returns the width and height counts. +func (l Horizontal) Count() (int, int) { + w1, h1 := l.Top.Count() + w2, h2 := l.Bottom.Count() + return max(w1, w2), h1 + h2 +} + +// Activate the specific window layout. +func (l Horizontal) Activate(i int) Layout { + return Horizontal{ + Top: l.Top.Activate(i), + Bottom: l.Bottom.Activate(i), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActivateFirst the first layout. +func (l Horizontal) ActivateFirst() Layout { + return Horizontal{ + Top: l.Top.ActivateFirst(), + Bottom: l.Bottom, + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActiveWindow returns the active window. +func (l Horizontal) ActiveWindow() Window { + if layout := l.Top.ActiveWindow(); layout.Index >= 0 { + return layout + } + return l.Bottom.ActiveWindow() +} + +// Lookup search for the window meets the condition. +func (l Horizontal) Lookup(cond func(Window) bool) Window { + if layout := l.Top.Lookup(cond); layout.Index >= 0 { + return layout + } + return l.Bottom.Lookup(cond) +} + +// Close the active layout. +func (l Horizontal) Close() Layout { + if m, ok := l.Top.(Window); ok { + if m.Active { + return l.Bottom.ActivateFirst() + } + } + if m, ok := l.Bottom.(Window); ok { + if m.Active { + return l.Top.ActivateFirst() + } + } + return Horizontal{ + Top: l.Top.Close(), + Bottom: l.Bottom.Close(), + } +} + +// Vertical holds two layout vertically. +type Vertical struct { + Left Layout + Right Layout + left int + top int + width int + height int +} + +func (Vertical) isLayout() {} + +// Collect returns all the Window. +func (l Vertical) Collect() map[int]Window { + m := l.Left.Collect() + for i, l := range l.Right.Collect() { + m[i] = l + } + return m +} + +// Replace the active window with new window index. +func (l Vertical) Replace(index int) Layout { + return Vertical{ + Left: l.Left.Replace(index), + Right: l.Right.Replace(index), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// Resize recalculates the position. +func (l Vertical) Resize(left, top, width, height int) Layout { + w1, _ := l.Left.Count() + w2, _ := l.Right.Count() + leftWidth := width * w1 / (w1 + w2) + return Vertical{ + Left: l.Left.Resize(left, top, leftWidth, height), + Right: l.Right.Resize( + min(left+leftWidth+1, left+width), top, + max(width-leftWidth-1, 0), height), + left: left, + top: top, + width: width, + height: height, + } +} + +// LeftMargin returns the left margin. +func (l Vertical) LeftMargin() int { + return l.left +} + +// TopMargin returns the top margin. +func (l Vertical) TopMargin() int { + return l.top +} + +// Width returns the width. +func (l Vertical) Width() int { + return l.width +} + +// Height returns the height. +func (l Vertical) Height() int { + return l.height +} + +// SplitTop splits the layout and opens a new window to the top. +func (l Vertical) SplitTop(index int) Layout { + return Vertical{ + Left: l.Left.SplitTop(index), + Right: l.Right.SplitTop(index), + } +} + +// SplitBottom splits the layout and opens a new window to the bottom. +func (l Vertical) SplitBottom(index int) Layout { + return Vertical{ + Left: l.Left.SplitBottom(index), + Right: l.Right.SplitBottom(index), + } +} + +// SplitLeft splits the layout and opens a new window to the left. +func (l Vertical) SplitLeft(index int) Layout { + return Vertical{ + Left: l.Left.SplitLeft(index), + Right: l.Right.SplitLeft(index), + } +} + +// SplitRight splits the layout and opens a new window to the right. +func (l Vertical) SplitRight(index int) Layout { + return Vertical{ + Left: l.Left.SplitRight(index), + Right: l.Right.SplitRight(index), + } +} + +// Count returns the width and height counts. +func (l Vertical) Count() (int, int) { + w1, h1 := l.Left.Count() + w2, h2 := l.Right.Count() + return w1 + w2, max(h1, h2) +} + +// Activate the specific window layout. +func (l Vertical) Activate(i int) Layout { + return Vertical{ + Left: l.Left.Activate(i), + Right: l.Right.Activate(i), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActivateFirst the first layout. +func (l Vertical) ActivateFirst() Layout { + return Vertical{ + Left: l.Left.ActivateFirst(), + Right: l.Right, + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActiveWindow returns the active window. +func (l Vertical) ActiveWindow() Window { + if layout := l.Left.ActiveWindow(); layout.Index >= 0 { + return layout + } + return l.Right.ActiveWindow() +} + +// Lookup search for the window meets the condition. +func (l Vertical) Lookup(cond func(Window) bool) Window { + if layout := l.Left.Lookup(cond); layout.Index >= 0 { + return layout + } + return l.Right.Lookup(cond) +} + +// Close the active layout. +func (l Vertical) Close() Layout { + if m, ok := l.Left.(Window); ok { + if m.Active { + return l.Right.ActivateFirst() + } + } + if m, ok := l.Right.(Window); ok { + if m.Active { + return l.Left.ActivateFirst() + } + } + return Vertical{ + Left: l.Left.Close(), + Right: l.Right.Close(), + } +} diff --git a/bed/layout/layout_test.go b/bed/layout/layout_test.go new file mode 100644 index 0000000..181dbcf --- /dev/null +++ b/bed/layout/layout_test.go @@ -0,0 +1,307 @@ +package layout + +import ( + "reflect" + "testing" +) + +func TestLayout(t *testing.T) { + layout := NewLayout(0) + + layout = layout.SplitTop(1) + layout = layout.SplitLeft(2) + layout = layout.SplitBottom(3) + layout = layout.SplitRight(4) + + var expected Layout + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false}, + Bottom: Vertical{ + Left: Window{Index: 3, Active: false}, + Right: Window{Index: 4, Active: true}, + }, + }, + Right: Window{Index: 1, Active: false}, + }, + Bottom: Window{Index: 0, Active: false}, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + w, h := layout.Count() + if w != 3 { + t.Errorf("layout width be %d but got %d", 3, w) + } + if h != 3 { + t.Errorf("layout height be %d but got %d", 3, h) + } + + layout = layout.Resize(0, 0, 15, 15) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 10, height: 5}, + Bottom: Vertical{ + Left: Window{Index: 3, Active: false, left: 0, top: 5, width: 5, height: 5}, + Right: Window{Index: 4, Active: true, left: 6, top: 5, width: 4, height: 5}, + left: 0, + top: 5, + width: 10, + height: 5, + }, + left: 0, + top: 0, + width: 10, + height: 10, + }, + Right: Window{Index: 1, Active: false, left: 11, top: 0, width: 4, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + expectedWindow := Window{Index: 1, Active: false, left: 11, top: 0, width: 4, height: 10} + got := layout.Lookup(func(l Window) bool { return l.Index == 1 }) + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("Lookup(Index == 1) should be %+v but got %+v", expectedWindow, got) + } + + if got.LeftMargin() != 11 { + t.Errorf("LeftMargin() should be %d but got %d", 11, got.LeftMargin()) + } + if got.TopMargin() != 0 { + t.Errorf("TopMargin() should be %d but got %d", 0, got.TopMargin()) + } + if got.Width() != 4 { + t.Errorf("Width() should be %d but got %d", 4, got.Width()) + } + if got.Height() != 10 { + t.Errorf("Height() should be %d but got %d", 10, got.Height()) + } + + expectedWindow = Window{Index: 3, Active: false, left: 0, top: 5, width: 5, height: 5} + got = layout.Lookup(func(l Window) bool { return l.Index == 3 }) + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("Lookup(Index == 3) should be %+v but got %+v", expectedWindow, got) + } + + if got.LeftMargin() != 0 { + t.Errorf("LeftMargin() should be %d but got %d", 0, got.LeftMargin()) + } + if got.TopMargin() != 5 { + t.Errorf("TopMargin() should be %d but got %d", 5, got.TopMargin()) + } + if got.Width() != 5 { + t.Errorf("Width() should be %d but got %d", 5, got.Width()) + } + if got.Height() != 5 { + t.Errorf("Height() should be %d but got %d", 5, got.Height()) + } + + expectedWindow = Window{Index: -1} + got = layout.Lookup(func(l Window) bool { return l.Index == 5 }) + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("Lookup(Index == 5) should be %+v but got %+v", expectedWindow, got) + } + + expectedWindow = Window{Index: 4, Active: true, left: 6, top: 5, width: 4, height: 5} + got = layout.ActiveWindow() + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("ActiveWindow() should be %+v but got %+v", expectedWindow, got) + } + + expectedMap := map[int]Window{ + 0: {Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + 1: {Index: 1, Active: false, left: 11, top: 0, width: 4, height: 10}, + 2: {Index: 2, Active: false, left: 0, top: 0, width: 10, height: 5}, + 3: {Index: 3, Active: false, left: 0, top: 5, width: 5, height: 5}, + 4: {Index: 4, Active: true, left: 6, top: 5, width: 4, height: 5}, + } + + if !reflect.DeepEqual(layout.Collect(), expectedMap) { + t.Errorf("Collect should be %+v but got %+v", expectedMap, layout.Collect()) + } + + layout = layout.Close().Resize(0, 0, 15, 15) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + Bottom: Window{Index: 3, Active: true, left: 0, top: 5, width: 7, height: 5}, + left: 0, + top: 0, + width: 7, + height: 10, + }, + Right: Window{Index: 1, Active: false, left: 8, top: 0, width: 7, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + if layout.LeftMargin() != 0 { + t.Errorf("LeftMargin() should be %d but layout %d", 0, layout.LeftMargin()) + } + if layout.TopMargin() != 0 { + t.Errorf("TopMargin() should be %d but layout %d", 0, layout.TopMargin()) + } + if layout.Width() != 15 { + t.Errorf("Width() should be %d but layout %d", 15, layout.Width()) + } + if layout.Height() != 15 { + t.Errorf("Height() should be %d but layout %d", 15, layout.Height()) + } + + expectedMap = map[int]Window{ + 0: {Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + 1: {Index: 1, Active: false, left: 8, top: 0, width: 7, height: 10}, + 2: {Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + 3: {Index: 3, Active: true, left: 0, top: 5, width: 7, height: 5}, + } + + if !reflect.DeepEqual(layout.Collect(), expectedMap) { + t.Errorf("Collect should be %+v but got %+v", expectedMap, layout.Collect()) + } + + w, h = layout.Count() + if w != 2 { + t.Errorf("layout width be %d but got %d", 3, w) + } + if h != 3 { + t.Errorf("layout height be %d but got %d", 3, h) + } + + layout = layout.Replace(5) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + Bottom: Window{Index: 5, Active: true, left: 0, top: 5, width: 7, height: 5}, + left: 0, + top: 0, + width: 7, + height: 10, + }, + Right: Window{Index: 1, Active: false, left: 8, top: 0, width: 7, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + layout = layout.Activate(1) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + Bottom: Window{Index: 5, Active: false, left: 0, top: 5, width: 7, height: 5}, + left: 0, + top: 0, + width: 7, + height: 10, + }, + Right: Window{Index: 1, Active: true, left: 8, top: 0, width: 7, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + layout = Vertical{ + Left: Window{Index: 6, Active: false}, + Right: layout, + }.SplitLeft(7).SplitTop(8).Resize(0, 0, 15, 10) + + expected = Vertical{ + Left: Window{Index: 6, Active: false, left: 0, top: 0, width: 3, height: 10}, + Right: Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 4, top: 0, width: 3, height: 3}, + Bottom: Window{Index: 5, Active: false, left: 4, top: 3, width: 3, height: 3}, + left: 4, top: 0, width: 3, height: 6, + }, + Right: Vertical{ + Left: Horizontal{ + Top: Window{Index: 8, Active: true, left: 8, top: 0, width: 3, height: 3}, + Bottom: Window{Index: 7, Active: false, left: 8, top: 3, width: 3, height: 3}, + left: 8, top: 0, width: 3, height: 6, + }, + Right: Window{Index: 1, Active: false, left: 12, top: 0, width: 3, height: 6}, + left: 8, top: 0, width: 7, height: 6, + }, + left: 4, top: 0, width: 11, height: 6, + }, + Bottom: Window{Index: 0, Active: false, left: 4, top: 6, width: 11, height: 4}, + left: 4, top: 0, width: 11, height: 10, + }, + left: 0, top: 0, width: 15, height: 10, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + if layout.LeftMargin() != 0 { + t.Errorf("LeftMargin() should be %d but layout %d", 0, layout.LeftMargin()) + } + if layout.TopMargin() != 0 { + t.Errorf("TopMargin() should be %d but layout %d", 0, layout.TopMargin()) + } + if layout.Width() != 15 { + t.Errorf("Width() should be %d but layout %d", 15, layout.Width()) + } + if layout.Height() != 10 { + t.Errorf("Height() should be %d but layout %d", 10, layout.Height()) + } +} diff --git a/bed/mode/mode.go b/bed/mode/mode.go new file mode 100644 index 0000000..d16ee7e --- /dev/null +++ b/bed/mode/mode.go @@ -0,0 +1,14 @@ +package mode + +// Mode ... +type Mode int + +// Modes +const ( + Normal Mode = iota + Insert + Replace + Visual + Cmdline + Search +) diff --git a/bed/searcher/pattern.go b/bed/searcher/pattern.go new file mode 100644 index 0000000..91d45c8 --- /dev/null +++ b/bed/searcher/pattern.go @@ -0,0 +1,155 @@ +package searcher + +import ( + "errors" + "unicode/utf8" +) + +func patternToTarget(pattern string) ([]byte, error) { + if len(pattern) > 3 && pattern[0] == '0' { + switch pattern[1] { + case 'x', 'X': + return decodeHexLiteral(pattern) + case 'b', 'B': + return decodeBinLiteral(pattern) + } + } + return unescapePattern(pattern), nil +} + +func decodeHexLiteral(pattern string) ([]byte, error) { + bs := make([]byte, 0, len(pattern)/2+1) + var c byte + var lower bool + for i := 2; i < len(pattern); i++ { + if !isHex(pattern[i]) { + return nil, errors.New("invalid hex pattern: " + pattern) + } + c = c<<4 | hexToDigit(pattern[i]) + if lower { + bs = append(bs, c) + c = 0 + } + lower = !lower + } + if lower { + bs = append(bs, c<<4) + } + return bs, nil +} + +func decodeBinLiteral(pattern string) ([]byte, error) { + bs := make([]byte, 0, len(pattern)/16+1) + var c byte + var bits int + for i := 2; i < len(pattern); i++ { + if !isBin(pattern[i]) { + return nil, errors.New("invalid bin pattern: " + pattern) + } + c = c<<1 | hexToDigit(pattern[i]) + bits++ + if bits == 8 { + bits = 0 + bs = append(bs, c) + c = 0 + } + } + if bits > 0 { + bs = append(bs, c<= 0 { + return base + int64(i), nil + } + return -1, nil +} + +func (s *Searcher) backward() (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + target, err := patternToTarget(s.pattern) + if err != nil { + return -1, err + } + base := max(0, s.cursor-int64(loadSize)) + size := int(min(int64(loadSize), s.cursor)) + n, err := s.r.ReadAt(s.bytes[:size], base) + if err != nil && err != io.EOF { + return -1, err + } + if n == 0 { + return -1, errNotFound(s.pattern) + } + if s.cursor == int64(n) { + s.cursor = 0 + } else { + s.cursor = base + int64(len(target)-1) + } + i := bytes.LastIndex(s.bytes[:n], target) + if i >= 0 { + return base + int64(i), nil + } + return -1, nil +} + +func (s *Searcher) loop(f func() (int64, error), ch chan<- any) { + if s.loopCh != nil { + close(s.loopCh) + } + loopCh := make(chan struct{}) + s.loopCh = loopCh + go func() { + defer close(ch) + for { + select { + case <-loopCh: + return + case <-time.After(10 * time.Millisecond): + idx, err := f() + if err != nil { + ch <- err + return + } + if idx >= 0 { + ch <- idx + return + } + } + } + }() +} + +// Abort the searching. +func (s *Searcher) Abort() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.loopCh != nil { + close(s.loopCh) + s.loopCh = nil + return errors.New("search is aborted") + } + return nil +} diff --git a/bed/searcher/searcher_test.go b/bed/searcher/searcher_test.go new file mode 100644 index 0000000..10c8d99 --- /dev/null +++ b/bed/searcher/searcher_test.go @@ -0,0 +1,181 @@ +package searcher + +import ( + "strings" + "testing" +) + +func TestSearcher(t *testing.T) { + testCases := []struct { + name string + str string + cursor int64 + pattern string + forward bool + expected int64 + err error + }{ + { + name: "search forward", + str: "abcde", + cursor: 0, + pattern: "cd", + forward: true, + expected: 2, + }, + { + name: "search forward but not found", + str: "abcde", + cursor: 2, + pattern: "cd", + forward: true, + err: errNotFound("cd"), + }, + { + name: "search backward", + str: "abcde", + cursor: 4, + pattern: "bc", + forward: false, + expected: 1, + }, + { + name: "search backward but not found", + str: "abcde", + cursor: 0, + pattern: "ba", + forward: true, + err: errNotFound("ba"), + }, + { + name: "search large target forward", + str: strings.Repeat(" ", 10*1024*1024+100) + "abcde", + cursor: 102, + pattern: "bcd", + forward: true, + expected: 10*1024*1024 + 101, + }, + { + name: "search large target forward but not found", + str: strings.Repeat(" ", 10*1024*1024+100) + "abcde", + cursor: 102, + pattern: "cba", + forward: true, + err: errNotFound("cba"), + }, + { + name: "search large target backward", + str: "abcde" + strings.Repeat(" ", 10*1024*1024), + cursor: 10*1024*1024 + 2, + pattern: "bcd", + forward: false, + expected: 1, + }, + { + name: "search large target backward but not found", + str: "abcde" + strings.Repeat(" ", 10*1024*1024), + cursor: 10*1024*1024 + 2, + pattern: "cba", + forward: false, + err: errNotFound("cba"), + }, + { + name: "search hex", + str: "\x13\x24\x35\x46\x57\x68", + cursor: 0, + pattern: `\x35\x46\x57`, + forward: true, + expected: 2, + }, + { + name: "search nul", + str: "\x06\x07\x08\x00\x09\x10\x11", + cursor: 0, + pattern: `\0`, + forward: true, + expected: 3, + }, + { + name: "search bell and bs", + str: "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x09\x0a", + cursor: 0, + pattern: `\a\b\v`, + forward: true, + expected: 7, + }, + { + name: "search tab", + str: "\x06\x07\x08\x09\x10\x11", + cursor: 0, + pattern: `\t`, + forward: true, + expected: 3, + }, + { + name: "search escape character", + str: `ab\cd\\e`, + cursor: 0, + pattern: `\\\`, + forward: true, + expected: 5, + }, + { + name: "search unicode", + str: "\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf", + cursor: 0, + pattern: `\u3061\u306F`, + forward: true, + expected: 9, + }, + { + name: "search unicode in supplementary multilingual plane", + str: "\U0001F604\U0001F606\U0001F60E\U0001F60D\U0001F642", + cursor: 0, + pattern: `\U0001F60E\U0001F60D`, + forward: true, + expected: 8, + }, + { + name: "search hex literal", + str: "\x16\x27\x38\x49\x50\x61", + cursor: 0, + pattern: `0x38495`, + forward: true, + expected: 2, + }, + { + name: "search bin literal", + str: "\x16\x27\x38\x48\x50\x61", + cursor: 0, + pattern: `0b0011100001001`, + forward: true, + expected: 2, + }, + { + name: "search text starting with 0", + str: "432101234", + cursor: 0, + pattern: `0123`, + forward: true, + expected: 4, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + s := NewSearcher(strings.NewReader(testCase.str)) + ch := s.Search(testCase.cursor, testCase.pattern, testCase.forward) + switch x := (<-ch).(type) { + case error: + if testCase.err == nil { + t.Error(x) + } else if x != testCase.err { + t.Errorf("Error should be %v but got %v", testCase.err, x) + } + case int64: + if x != testCase.expected { + t.Errorf("Search result should be %d but got %d", testCase.expected, x) + } + } + }) + } +} diff --git a/bed/state/state.go b/bed/state/state.go new file mode 100644 index 0000000..f60d63b --- /dev/null +++ b/bed/state/state.go @@ -0,0 +1,45 @@ +package state + +import ( + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" +) + +// State holds the state of the editor to display the user interface. +type State struct { + Mode mode.Mode + PrevMode mode.Mode + WindowStates map[int]*WindowState + Layout layout.Layout + Cmdline []rune + CmdlineCursor int + CompletionResults []string + CompletionIndex int + SearchMode rune + Error error + ErrorType int +} + +// WindowState holds the state of one window. +type WindowState struct { + Name string + Modified bool + Width int + Offset int64 + Cursor int64 + Bytes []byte + Size int + Length int64 + Mode mode.Mode + Pending bool + PendingByte byte + VisualStart int64 + EditedIndices []int64 + FocusText bool +} + +// Message types +const ( + MessageInfo = iota + MessageError +) diff --git a/bed/tui/key.go b/bed/tui/key.go new file mode 100644 index 0000000..3716fe1 --- /dev/null +++ b/bed/tui/key.go @@ -0,0 +1,71 @@ +package tui + +import ( + "github.com/gdamore/tcell" + + "b612.me/apps/b612/bed/key" +) + +func eventToKey(event *tcell.EventKey) key.Key { + if key, ok := keyMap[event.Key()]; ok { + return key + } + return key.Key(event.Rune()) +} + +var keyMap = map[tcell.Key]key.Key{ + tcell.KeyF1: key.Key("f1"), + tcell.KeyF2: key.Key("f2"), + tcell.KeyF3: key.Key("f3"), + tcell.KeyF4: key.Key("f4"), + tcell.KeyF5: key.Key("f5"), + tcell.KeyF6: key.Key("f6"), + tcell.KeyF7: key.Key("f7"), + tcell.KeyF8: key.Key("f8"), + tcell.KeyF9: key.Key("f9"), + tcell.KeyF10: key.Key("f10"), + tcell.KeyF11: key.Key("f11"), + tcell.KeyF12: key.Key("f12"), + + tcell.KeyInsert: key.Key("insert"), + tcell.KeyDelete: key.Key("delete"), + tcell.KeyHome: key.Key("home"), + tcell.KeyEnd: key.Key("end"), + tcell.KeyPgUp: key.Key("pgup"), + tcell.KeyPgDn: key.Key("pgdn"), + + tcell.KeyUp: key.Key("up"), + tcell.KeyDown: key.Key("down"), + tcell.KeyLeft: key.Key("left"), + tcell.KeyRight: key.Key("right"), + + tcell.KeyCtrlA: key.Key("c-a"), + tcell.KeyCtrlB: key.Key("c-b"), + tcell.KeyCtrlC: key.Key("c-c"), + tcell.KeyCtrlD: key.Key("c-d"), + tcell.KeyCtrlE: key.Key("c-e"), + tcell.KeyCtrlF: key.Key("c-f"), + tcell.KeyCtrlG: key.Key("c-g"), + tcell.KeyBackspace: key.Key("backspace"), + tcell.KeyTab: key.Key("tab"), + tcell.KeyBacktab: key.Key("backtab"), + tcell.KeyCtrlJ: key.Key("c-j"), + tcell.KeyCtrlK: key.Key("c-k"), + tcell.KeyCtrlL: key.Key("c-l"), + tcell.KeyEnter: key.Key("enter"), + tcell.KeyCtrlN: key.Key("c-n"), + tcell.KeyCtrlO: key.Key("c-o"), + tcell.KeyCtrlP: key.Key("c-p"), + tcell.KeyCtrlQ: key.Key("c-q"), + tcell.KeyCtrlR: key.Key("c-r"), + tcell.KeyCtrlS: key.Key("c-s"), + tcell.KeyCtrlT: key.Key("c-t"), + tcell.KeyCtrlU: key.Key("c-u"), + tcell.KeyCtrlV: key.Key("c-v"), + tcell.KeyCtrlW: key.Key("c-w"), + tcell.KeyCtrlX: key.Key("c-x"), + tcell.KeyCtrlY: key.Key("c-y"), + tcell.KeyCtrlZ: key.Key("c-z"), + tcell.KeyEsc: key.Key("escape"), + tcell.KeyBackspace2: key.Key("backspace2"), +} diff --git a/bed/tui/region.go b/bed/tui/region.go new file mode 100644 index 0000000..df8badd --- /dev/null +++ b/bed/tui/region.go @@ -0,0 +1,20 @@ +package tui + +import "b612.me/apps/b612/bed/layout" + +type region struct { + left, top, height, width int +} + +func fromLayout(l layout.Layout) region { + return region{ + left: l.LeftMargin(), + top: l.TopMargin(), + height: l.Height(), + width: l.Width(), + } +} + +func (r region) valid() bool { + return 0 <= r.left && 0 <= r.top && 0 < r.height && 0 < r.width +} diff --git a/bed/tui/text_drawer.go b/bed/tui/text_drawer.go new file mode 100644 index 0000000..4e61b13 --- /dev/null +++ b/bed/tui/text_drawer.go @@ -0,0 +1,63 @@ +package tui + +import ( + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" +) + +type textDrawer struct { + top, left, offset int + region region + screen tcell.Screen +} + +func (d *textDrawer) setString(str string, style tcell.Style) { + top := d.region.top + d.top + left := d.region.left + d.left + d.offset + right := d.region.left + d.region.width + for _, c := range str { + w := runewidth.RuneWidth(c) + if left+w > right { + break + } + if left+w == right && c != ' ' { + if int(style)&int(tcell.AttrReverse) != 0 { + d.screen.SetContent(left, top, ' ', nil, style) + } + break + } + d.screen.SetContent(left, top, c, nil, style) + left += w + } +} + +func (d *textDrawer) setByte(b byte, style tcell.Style) { + top := d.region.top + d.top + left := d.region.left + d.left + d.offset + d.screen.SetContent(left, top, rune(b), nil, style) +} + +func (d *textDrawer) setTop(top int) *textDrawer { + d.top = top + return d +} + +func (d *textDrawer) addTop(diff int) *textDrawer { + d.top += diff + return d +} + +func (d *textDrawer) setLeft(left int) *textDrawer { + d.left = left + return d +} + +func (d *textDrawer) addLeft(diff int) *textDrawer { + d.left += diff + return d +} + +func (d *textDrawer) setOffset(offset int) *textDrawer { + d.offset = offset + return d +} diff --git a/bed/tui/tui.go b/bed/tui/tui.go new file mode 100644 index 0000000..c714b64 --- /dev/null +++ b/bed/tui/tui.go @@ -0,0 +1,196 @@ +package tui + +import ( + "bytes" + "strings" + "sync" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +// Tui implements UI +type Tui struct { + eventCh chan<- event.Event + mode mode.Mode + screen tcell.Screen + waitCh chan struct{} + mu *sync.Mutex +} + +// NewTui creates a new Tui. +func NewTui() *Tui { + return &Tui{mu: new(sync.Mutex)} +} + +// Init initializes the Tui. +func (ui *Tui) Init(eventCh chan<- event.Event) (err error) { + ui.mu.Lock() + defer ui.mu.Unlock() + ui.eventCh = eventCh + ui.mode = mode.Normal + if ui.screen, err = tcell.NewScreen(); err != nil { + return + } + ui.waitCh = make(chan struct{}) + return ui.screen.Init() +} + +// Run the Tui. +func (ui *Tui) Run(kms map[mode.Mode]*key.Manager) { + for { + e := ui.screen.PollEvent() + switch ev := e.(type) { + case *tcell.EventKey: + var e event.Event + if km, ok := kms[ui.getMode()]; ok { + e = km.Press(eventToKey(ev)) + } + if e.Type != event.Nop { + ui.eventCh <- e + } else { + ui.eventCh <- event.Event{Type: event.Rune, Rune: ev.Rune()} + } + case *tcell.EventResize: + if ui.eventCh != nil { + ui.eventCh <- event.Event{Type: event.Redraw} + } + case nil: + close(ui.waitCh) + return + } + } +} + +func (ui *Tui) getMode() mode.Mode { + ui.mu.Lock() + defer ui.mu.Unlock() + return ui.mode +} + +// Size returns the size for the screen. +func (ui *Tui) Size() (int, int) { + return ui.screen.Size() +} + +// Redraw redraws the state. +func (ui *Tui) Redraw(s state.State) error { + ui.mu.Lock() + defer ui.mu.Unlock() + ui.mode = s.Mode + ui.screen.Clear() + ui.drawWindows(s.WindowStates, s.Layout) + ui.drawCmdline(s) + ui.screen.Show() + return nil +} + +func (ui *Tui) setLine(line, offset int, str string, style tcell.Style) { + for _, c := range str { + ui.screen.SetContent(offset, line, c, nil, style) + offset += runewidth.RuneWidth(c) + } +} + +func (ui *Tui) drawWindows(windowStates map[int]*state.WindowState, l layout.Layout) { + switch l := l.(type) { + case layout.Window: + r := fromLayout(l) + if ws, ok := windowStates[l.Index]; ok && r.valid() { + ui.newTuiWindow(r).drawWindow(ws, + l.Active && ui.mode != mode.Cmdline && ui.mode != mode.Search) + } + case layout.Horizontal: + ui.drawWindows(windowStates, l.Top) + ui.drawWindows(windowStates, l.Bottom) + case layout.Vertical: + ui.drawWindows(windowStates, l.Left) + ui.drawWindows(windowStates, l.Right) + ui.drawVerticalSplit(fromLayout(l.Left)) + } +} + +func (ui *Tui) newTuiWindow(region region) *tuiWindow { + return &tuiWindow{region: region, screen: ui.screen} +} + +func (ui *Tui) drawVerticalSplit(region region) { + for i := range region.height { + ui.setLine(region.top+i, region.left+region.width, "|", tcell.StyleDefault.Reverse(true)) + } +} + +func (ui *Tui) drawCmdline(s state.State) { + var cmdline string + style := tcell.StyleDefault + width, height := ui.Size() + switch { + case s.Error != nil: + cmdline = s.Error.Error() + if s.ErrorType == state.MessageInfo { + style = style.Foreground(tcell.ColorYellow) + } else { + style = style.Foreground(tcell.ColorRed) + } + case s.Mode == mode.Cmdline: + if len(s.CompletionResults) > 0 { + ui.drawCompletionResults(s.CompletionResults, s.CompletionIndex, width, height) + } + ui.screen.ShowCursor(1+runewidth.StringWidth(string(s.Cmdline[:s.CmdlineCursor])), height-1) + fallthrough + case s.PrevMode == mode.Cmdline && len(s.Cmdline) > 0: + cmdline = ":" + string(s.Cmdline) + case s.Mode == mode.Search: + ui.screen.ShowCursor(1+runewidth.StringWidth(string(s.Cmdline[:s.CmdlineCursor])), height-1) + fallthrough + case s.SearchMode != '\x00': + cmdline = string(s.SearchMode) + string(s.Cmdline) + default: + return + } + ui.setLine(height-1, 0, cmdline, style) +} + +func (ui *Tui) drawCompletionResults(results []string, index, width, height int) { + var line bytes.Buffer + var left, right int + for i, result := range results { + size := runewidth.StringWidth(result) + 2 + if i <= index { + left, right = right, right+size + if right > width { + line.Reset() + left, right = 0, size + } + } else if right < width { + right += size + } else { + break + } + line.WriteString(" ") + line.WriteString(result) + line.WriteString(" ") + } + line.WriteString(strings.Repeat(" ", max(width-right, 0))) + ui.setLine(height-2, 0, line.String(), tcell.StyleDefault.Reverse(true)) + if index >= 0 { + ui.setLine(height-2, left, " "+results[index]+" ", + tcell.StyleDefault.Foreground(tcell.ColorGrey).Reverse(true)) + } +} + +// Close terminates the Tui. +func (ui *Tui) Close() error { + ui.mu.Lock() + defer ui.mu.Unlock() + ui.eventCh = nil + ui.screen.Fini() + <-ui.waitCh + return nil +} diff --git a/bed/tui/tui_test.go b/bed/tui/tui_test.go new file mode 100644 index 0000000..d354d15 --- /dev/null +++ b/bed/tui/tui_test.go @@ -0,0 +1,415 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + "github.com/gdamore/tcell" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +func (ui *Tui) initForTest(eventCh chan<- event.Event, screen tcell.SimulationScreen) (err error) { + ui.eventCh = eventCh + ui.mode = mode.Normal + ui.screen = screen + ui.waitCh = make(chan struct{}) + return ui.screen.Init() +} + +func mockKeyManager() map[mode.Mode]*key.Manager { + kms := make(map[mode.Mode]*key.Manager) + km := key.NewManager(true) + km.Register(event.Quit, "Z", "Q") + km.Register(event.CursorDown, "j") + kms[mode.Normal] = km + return kms +} + +func getContents(screen tcell.SimulationScreen) string { + width, _ := screen.Size() + cells, _, _ := screen.GetContents() + var runes []rune + for i, cell := range cells { + runes = append(runes, cell.Runes...) + if (i+1)%width == 0 { + runes = append(runes, '\n') + } + } + return string(runes) +} + +func shouldContain(t *testing.T, screen tcell.SimulationScreen, expected []string) { + got := getContents(screen) + for _, str := range expected { + if !strings.Contains(got, str) { + t.Errorf("screen should contain %q but got\n%v", str, got) + } + } +} + +func TestTuiRun(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(90, 20) + go ui.Run(mockKeyManager()) + + screen.InjectKey(tcell.KeyRune, 'Z', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, 'Q', tcell.ModNone) + e := <-eventCh + if e.Type != event.Rune { + t.Errorf("pressing Z should emit event.Rune but got: %+v", e) + } + e = <-eventCh + if e.Type != event.Quit { + t.Errorf("pressing ZQ should emit event.Quit but got: %+v", e) + } + screen.InjectKey(tcell.KeyRune, '7', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, '0', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, '9', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, 'j', tcell.ModNone) + e = <-eventCh + e = <-eventCh + e = <-eventCh + e = <-eventCh + if e.Type != event.CursorDown { + t.Errorf("pressing 709j should emit event.CursorDown but got: %+v", e) + } + if e.Count != 709 { + t.Errorf("pressing 709j should emit event with count %d but got: %+v", 709, e) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiEmpty(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(90, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "", + Modified: false, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte(strings.Repeat("\x00", 16*(height-1))), + Size: 16 * (height - 1), + Length: 0, + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f | ", + " 000000 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " 000010 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " 000020 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " 000100 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " [No name] : 0x00 : '\\x00' 0/0 : 0x000000/0x000000 : 0.00%", + }) + + x, y, visible := screen.GetCursor() + if x != 10 || y != 1 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 10, 1, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiScrollBar(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(90, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "", + Modified: true, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte(strings.Repeat("a", 16*(height-1))), + Size: 16 * (height - 1), + Length: int64(16 * (height - 1) * 3), + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f | ", + " 000000 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa # ", + " 000050 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa # ", + " 000060 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa | ", + " 000100 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa | ", + " [No name] : + : 0x61 : 'a' 0/912 : 0x000000/0x000390 : 0.00%", + }) + + x, y, visible := screen.GetCursor() + if x != 10 || y != 1 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 10, 1, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiHorizontalSplit(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(110, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "test0", + Modified: false, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 0." + strings.Repeat("\x00", 110*10)), + Size: 110 * 10, + Length: 600, + Mode: mode.Normal, + }, + 1: { + Name: "test1", + Modified: false, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 1." + strings.Repeat(" ", 110*10)), + Size: 110 * 10, + Length: 800, + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).SplitBottom(1).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " 000000 | 54 65 73 74 20 77 69 6e 64 6f 77 20 30 2e 00 00 | Test window 0... #", + " 000010 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " test0 : 0x54 : 'T' 0/600 : 0x000000/0x000258 : 0.00%", + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f | ", + " 000000 | 54 65 73 74 20 77 69 6e 64 6f 77 20 31 2e 20 20 | Test window 1. #", + " 000010 | 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | #", + " test1 : 0x54 : 'T' 0/800 : 0x000000/0x000320 : 0.00%", + }) + + x, y, visible := screen.GetCursor() + if x != 10 || y != 10 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 10, 10, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiVerticalSplit(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(110, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "test0", + Modified: false, + Width: 8, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 0." + strings.Repeat("\x00", 55*19)), + Size: 55 * 19, + Length: 600, + Mode: mode.Normal, + }, + 1: { + Name: "test1", + Modified: false, + Width: 8, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 1." + strings.Repeat(" ", 54*19)), + Size: 54 * 19, + Length: 800, + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).SplitRight(1).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " | 0 1 2 3 4 5 6 7 | | | 0 1 2 3 4 5 6 7 |", + " 000000 | 54 65 73 74 20 77 69 6e | Test win # | 000000 | 54 65 73 74 20 77 69 6e | Test win #", + " 000008 | 64 6f 77 20 30 2e 00 00 | dow 0... # | 000008 | 64 6f 77 20 31 2e 20 20 | dow 1. #", + " 000010 | 00 00 00 00 00 00 00 00 | ........ # | 000010 | 20 20 20 20 20 20 20 20 | #", + " test0 : 0x54 : 'T' 0/600 : 0x000000/0x000258 : 0.00% | test1 : 0x54 : 'T' 0/800 : 0x000000/0x000320 : 0.00", + }) + + x, y, visible := screen.GetCursor() + if x != 66 || y != 1 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 66, 1, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiCmdline(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(20, 15) + getCmdline := func() string { + cells, _, _ := screen.GetContents() + var runes []rune + for _, cell := range cells[20*14:] { + runes = append(runes, cell.Runes...) + } + return string(runes) + } + go ui.Run(mockKeyManager()) + + s := state.State{ + Mode: mode.Cmdline, + Cmdline: []rune("vnew test"), + CmdlineCursor: 9, + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + got, expected := getCmdline(), ":vnew test " + if !strings.HasPrefix(got, expected) { + t.Errorf("cmdline should start with %q but got %q", expected, got) + } + + s = state.State{ + Mode: mode.Normal, + Error: errors.New("error"), + Cmdline: []rune("vnew test"), + CmdlineCursor: 9, + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + got, expected = getCmdline(), "error " + if !strings.HasPrefix(got, expected) { + t.Errorf("cmdline should start with %q but got %q", expected, got) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiCmdlineCompletionCandidates(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(20, 15) + go ui.Run(mockKeyManager()) + + s := state.State{ + Mode: mode.Cmdline, + Cmdline: []rune("new test2"), + CmdlineCursor: 9, + CompletionResults: []string{"test1", "test2", "test3", "test9/", "/bin/ls"}, + CompletionIndex: 1, + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " test1 test2 test3", + ":new test2", + }) + + s.CompletionIndex += 2 + s.Cmdline = []rune("new test9/") + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " test3 test9/ /bin", + ":new test9/", + }) + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} diff --git a/bed/tui/tui_window.go b/bed/tui/tui_window.go new file mode 100644 index 0000000..83ff07f --- /dev/null +++ b/bed/tui/tui_window.go @@ -0,0 +1,225 @@ +package tui + +import ( + "cmp" + "fmt" + + "github.com/gdamore/tcell" + + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +type tuiWindow struct { + region region + screen tcell.Screen +} + +func (ui *tuiWindow) getTextDrawer() *textDrawer { + return &textDrawer{region: ui.region, screen: ui.screen} +} + +func (ui *tuiWindow) setCursor(line, offset int) { + ui.screen.ShowCursor(ui.region.left+offset, ui.region.top+line) +} + +func offsetStyleWidth(s *state.WindowState) int { + threshold := int64(0xfffff) + for i := range 10 { + if s.Length <= threshold { + return 6 + i + } + threshold = (threshold << 4) | 0x0f + } + return 16 +} + +func (ui *tuiWindow) drawWindow(s *state.WindowState, active bool) { + height, width := ui.region.height-2, s.Width + cursorPos := int(s.Cursor - s.Offset) + cursorLine := cursorPos / width + offsetStyleWidth := offsetStyleWidth(s) + eis := s.EditedIndices + for 0 < len(eis) && eis[1] <= s.Offset { + eis = eis[2:] + } + editedColor := tcell.ColorLightSeaGreen + d := ui.getTextDrawer() + var k int + for i := range height { + d.addTop(1).setLeft(0).setOffset(0) + d.setString( + fmt.Sprintf(" %0*x", offsetStyleWidth, s.Offset+int64(i*width)), + tcell.StyleDefault.Bold(i == cursorLine), + ) + d.setLeft(offsetStyleWidth + 3) + for j := range width { + b, style := byte(0), tcell.StyleDefault + if s.Pending && i*width+j == cursorPos { + b, style = s.PendingByte, tcell.StyleDefault.Foreground(editedColor) + if s.Mode != mode.Replace { + k-- + } + } else if k >= s.Size { + if k == cursorPos { + d.setOffset(3*j+1).setByte(' ', tcell.StyleDefault.Underline(!active || s.FocusText)) + d.setOffset(3*width+j+3).setByte(' ', tcell.StyleDefault.Underline(!active || !s.FocusText)) + } + k++ + continue + } else { + b = s.Bytes[k] + pos := int64(k) + s.Offset + if 0 < len(eis) && eis[0] <= pos && pos < eis[1] { + style = tcell.StyleDefault.Foreground(editedColor) + } else if 0 < len(eis) && eis[1] <= pos { + eis = eis[2:] + } + if s.VisualStart >= 0 && s.Cursor < s.Length && + (s.VisualStart <= pos && pos <= s.Cursor || + s.Cursor <= pos && pos <= s.VisualStart) { + style = style.Underline(true) + } + } + style1, style2 := style, style + if i*width+j == cursorPos { + style1 = style1.Reverse(active && !s.FocusText).Bold( + !active || s.FocusText).Underline(!active || s.FocusText) + style2 = style2.Reverse(active && s.FocusText).Bold( + !active || !s.FocusText).Underline(!active || !s.FocusText) + } + d.setOffset(3*j+1).setByte(hex[b>>4], style1) + d.setOffset(3*j+2).setByte(hex[b&0x0f], style1) + d.setOffset(3*width+j+3).setByte(prettyByte(b), style2) + k++ + } + d.setOffset(-2).setByte(' ', tcell.StyleDefault) + d.setOffset(-1).setByte('|', tcell.StyleDefault) + d.setOffset(0).setByte(' ', tcell.StyleDefault) + d.addLeft(3*width).setByte(' ', tcell.StyleDefault) + d.setOffset(1).setByte('|', tcell.StyleDefault) + d.setOffset(2).setByte(' ', tcell.StyleDefault) + } + i := int(s.Cursor % int64(width)) + if active { + if s.FocusText { + ui.setCursor(cursorLine+1, 3*width+i+6+offsetStyleWidth) + } else if s.Pending { + ui.setCursor(cursorLine+1, 3*i+5+offsetStyleWidth) + } else { + ui.setCursor(cursorLine+1, 3*i+4+offsetStyleWidth) + } + } + ui.drawHeader(s, offsetStyleWidth) + ui.drawScrollBar(s, height, 4*width+7+offsetStyleWidth) + ui.drawFooter(s, offsetStyleWidth) +} + +const hex = "0123456789abcdef" + +func (ui *tuiWindow) drawHeader(s *state.WindowState, offsetStyleWidth int) { + style := tcell.StyleDefault.Underline(true) + d := ui.getTextDrawer().setLeft(-1) + cursor := int(s.Cursor % int64(s.Width)) + for range offsetStyleWidth + 2 { + d.addLeft(1).setByte(' ', style) + } + d.addLeft(1).setByte('|', style) + for i := range s.Width { + d.addLeft(1).setByte(' ', style) + d.addLeft(1).setByte(" 123456789abcdef"[i>>4], style.Bold(cursor == i)) + d.addLeft(1).setByte(hex[i&0x0f], style.Bold(cursor == i)) + } + d.addLeft(1).setByte(' ', style) + d.addLeft(1).setByte('|', style) + for range s.Width + 3 { + d.addLeft(1).setByte(' ', style) + } +} + +func (ui *tuiWindow) drawScrollBar(s *state.WindowState, height, left int) { + stateSize := s.Size + if s.Cursor+1 == s.Length && s.Cursor == s.Offset+int64(s.Size) { + stateSize++ + } + total := int64((stateSize + s.Width - 1) / s.Width) + length := max((s.Length+int64(s.Width)-1)/int64(s.Width), 1) + size := max(total*total/length, 1) + pad := (total*total + length - length*size - 1) / max(total-size+1, 1) + top := (s.Offset / int64(s.Width) * total) / (length - pad) + d := ui.getTextDrawer().setLeft(left) + for i := range height { + var b byte + if int(top) <= i && i < int(top+size) { + b = '#' + } else { + b = '|' + } + d.addTop(1).setByte(b, tcell.StyleDefault) + } +} + +func (ui *tuiWindow) drawFooter(s *state.WindowState, offsetStyleWidth int) { + var modified string + if s.Modified { + modified = " : +" + } + b := s.Bytes[int(s.Cursor-s.Offset)] + left := fmt.Sprintf(" %s%s%s : 0x%02x : '%s'", + prettyMode(s.Mode), cmp.Or(s.Name, "[No name]"), modified, b, prettyRune(b)) + right := fmt.Sprintf("%[1]d/%[2]d : 0x%0[3]*[1]x/0x%0[3]*[2]x : %.2[4]f%% ", + s.Cursor, s.Length, offsetStyleWidth, float64(s.Cursor*100)/float64(max(s.Length, 1))) + line := fmt.Sprintf("%s %*s", left, max(ui.region.width-len(left)-2, 0), right) + ui.getTextDrawer().setTop(ui.region.height-1).setString(line, tcell.StyleDefault.Reverse(true)) +} + +func prettyByte(b byte) byte { + switch { + case 0x20 <= b && b < 0x7f: + return b + default: + return 0x2e + } +} + +func prettyRune(b byte) string { + switch b { + case 0x07: + return "\\a" + case 0x08: + return "\\b" + case 0x09: + return "\\t" + case 0x0a: + return "\\n" + case 0x0b: + return "\\v" + case 0x0c: + return "\\f" + case 0x0d: + return "\\r" + case 0x27: + return "\\'" + default: + if b < 0x20 { + return fmt.Sprintf("\\x%02x", b) + } else if b < 0x7f { + return string(rune(b)) + } else { + return fmt.Sprintf("\\u%04x", b) + } + } +} + +func prettyMode(m mode.Mode) string { + switch m { + case mode.Insert: + return "[INSERT] " + case mode.Replace: + return "[REPLACE] " + case mode.Visual: + return "[VISUAL] " + default: + return "" + } +} diff --git a/bed/window/manager.go b/bed/window/manager.go new file mode 100644 index 0000000..4c7de65 --- /dev/null +++ b/bed/window/manager.go @@ -0,0 +1,701 @@ +package window + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/bits" + "math/rand" + "os" + "os/exec" + "os/signal" + "os/user" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/state" +) + +// Manager manages the windows and files. +type Manager struct { + width int + height int + windows []*window + layout layout.Layout + mu *sync.Mutex + windowIndex int + prevWindowIndex int + prevDir string + files map[string]file + eventCh chan<- event.Event + redrawCh chan<- struct{} +} + +type file struct { + path string + file *os.File + perm os.FileMode +} + +// NewManager creates a new Manager. +func NewManager() *Manager { + return &Manager{} +} + +// Init initializes the Manager. +func (m *Manager) Init(eventCh chan<- event.Event, redrawCh chan<- struct{}) { + m.eventCh, m.redrawCh = eventCh, redrawCh + m.mu, m.files = new(sync.Mutex), make(map[string]file) +} + +// Open a new window. +func (m *Manager) Open(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + window, err := m.open(name) + if err != nil { + return err + } + return m.init(window) +} + +// Read opens a new window from [io.Reader]. +func (m *Manager) Read(r io.Reader) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.read(r) +} + +func (m *Manager) init(window *window) error { + m.addWindow(window) + m.layout = layout.NewLayout(m.windowIndex).Resize(0, 0, m.width, m.height) + return nil +} + +func (m *Manager) addWindow(window *window) { + for i, w := range m.windows { + if w == window { + m.windowIndex, m.prevWindowIndex = i, m.windowIndex + return + } + } + m.windows = append(m.windows, window) + m.windowIndex, m.prevWindowIndex = len(m.windows)-1, m.windowIndex +} + +func (m *Manager) open(name string) (*window, error) { + if name == "" { + window, err := newWindow(bytes.NewReader(nil), "", "", m.eventCh, m.redrawCh) + if err != nil { + return nil, err + } + return window, nil + } + if name == "#" { + return m.windows[m.prevWindowIndex], nil + } + if strings.HasPrefix(name, "#") { + index, err := strconv.Atoi(name[1:]) + if err != nil || index <= 0 || len(m.windows) < index { + return nil, errors.New("invalid window index: " + name) + } + return m.windows[index-1], nil + } + name, err := expandBacktick(name) + if err != nil { + return nil, err + } + path, err := expandPath(name) + if err != nil { + return nil, err + } + r, err := m.openFile(path, name) + if err != nil { + return nil, err + } + return newWindow(r, path, filepath.Base(path), m.eventCh, m.redrawCh) +} + +func (m *Manager) openFile(path, name string) (readAtSeeker, error) { + fi, err := os.Stat(path) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + return bytes.NewReader(nil), nil + } else if fi.IsDir() { + return nil, errors.New(name + " is a directory") + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + m.addFile(path, f, fi) + return f, nil +} + +func expandBacktick(name string) (string, error) { + if len(name) <= 2 || name[0] != '`' || name[len(name)-1] != '`' { + return name, nil + } + name = strings.TrimSpace(name[1 : len(name)-1]) + xs := strings.Fields(name) + if len(xs) < 1 { + return name, nil + } + out, err := exec.Command(xs[0], xs[1:]...).Output() + if err != nil { + return name, err + } + return strings.TrimSpace(string(out)), nil +} + +func expandPath(path string) (string, error) { + switch { + case strings.HasPrefix(path, "~"): + if name, rest, _ := strings.Cut(path[1:], string(filepath.Separator)); name != "" { + user, err := user.Lookup(name) + if err != nil { + return path, nil + } + return filepath.Join(user.HomeDir, rest), nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, path[1:]), nil + case strings.HasPrefix(path, "$"): + name, rest, _ := strings.Cut(path[1:], string(filepath.Separator)) + value := os.Getenv(name) + if value == "" { + return path, nil + } + return filepath.Join(value, rest), nil + default: + return filepath.Abs(path) + } +} + +func (m *Manager) read(r io.Reader) error { + bs, err := func() ([]byte, error) { + r, stop := newReader(r) + defer stop() + return io.ReadAll(r) + }() + if err != nil { + return err + } + window, err := newWindow(bytes.NewReader(bs), "", "", m.eventCh, m.redrawCh) + if err != nil { + return err + } + return m.init(window) +} + +type reader struct { + io.Reader + abort chan os.Signal +} + +func newReader(r io.Reader) (*reader, func()) { + done := make(chan struct{}) + abort := make(chan os.Signal, 1) + signal.Notify(abort, os.Interrupt) + go func() { + select { + case <-time.After(time.Second): + fmt.Fprint(os.Stderr, "Reading stdin took more than 1 second, press to abort...") + case <-done: + } + }() + return &reader{r, abort}, func() { + signal.Stop(abort) + close(abort) + close(done) + } +} + +func (r *reader) Read(p []byte) (int, error) { + select { + case <-r.abort: + return 0, io.EOF + default: + } + return r.Reader.Read(p) +} + +// SetSize sets the size of the screen. +func (m *Manager) SetSize(width, height int) { + m.width, m.height = width, height +} + +// Resize sets the size of the screen. +func (m *Manager) Resize(width, height int) { + if m.width != width || m.height != height { + m.mu.Lock() + defer m.mu.Unlock() + m.width, m.height = width, height + m.layout = m.layout.Resize(0, 0, width, height) + } +} + +// Emit an event to the current window. +func (m *Manager) Emit(e event.Event) { + switch e.Type { + case event.Edit: + if err := m.edit(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Enew: + if err := m.enew(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.New: + if err := m.newWindow(e, false); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Vnew: + if err := m.newWindow(e, true); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Only: + if err := m.only(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Alternative: + m.alternative(e) + m.eventCh <- event.Event{Type: event.Redraw} + case event.Wincmd: + if e.Arg == "" { + m.eventCh <- event.Event{Type: event.Error, + Error: errors.New("an argument is required for " + e.CmdName)} + } else if err := m.wincmd(e.Arg); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowDown: + if err := m.wincmd("j"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowUp: + if err := m.wincmd("k"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowLeft: + if err := m.wincmd("h"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowRight: + if err := m.wincmd("l"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowTopLeft: + if err := m.wincmd("t"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowBottomRight: + if err := m.wincmd("b"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowPrevious: + if err := m.wincmd("p"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowTop: + if err := m.wincmd("K"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowBottom: + if err := m.wincmd("J"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowLeft: + if err := m.wincmd("H"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowRight: + if err := m.wincmd("L"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Pwd: + if e.Arg != "" { + m.eventCh <- event.Event{Type: event.Error, Error: errors.New("too many arguments for " + e.CmdName)} + break + } + fallthrough + case event.Chdir: + if dir, err := m.chdir(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Info, Error: errors.New(dir)} + } + case event.Quit: + if err := m.quit(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } + case event.Write: + if name, n, err := m.write(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Info, + Error: fmt.Errorf("%s: %[2]d (0x%[2]x) bytes written", name, n)} + } + case event.WriteQuit: + if _, _, err := m.write(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else if err := m.quit(event.Event{Bang: e.Bang}); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } + default: + m.windows[m.windowIndex].emit(e) + } +} + +func (m *Manager) edit(e event.Event) error { + m.mu.Lock() + defer m.mu.Unlock() + name := e.Arg + if name == "" { + name = m.windows[m.windowIndex].path + } + window, err := m.open(name) + if err != nil { + return err + } + m.addWindow(window) + m.layout = m.layout.Replace(m.windowIndex) + return nil +} + +func (m *Manager) enew(e event.Event) error { + if e.Arg != "" { + return errors.New("too many arguments for " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + window, err := m.open("") + if err != nil { + return err + } + m.addWindow(window) + m.layout = m.layout.Replace(m.windowIndex) + return nil +} + +func (m *Manager) newWindow(e event.Event, vertical bool) error { + m.mu.Lock() + defer m.mu.Unlock() + window, err := m.open(e.Arg) + if err != nil { + return err + } + m.addWindow(window) + if vertical { + m.layout = m.layout.SplitLeft(m.windowIndex).Resize(0, 0, m.width, m.height) + } else { + m.layout = m.layout.SplitTop(m.windowIndex).Resize(0, 0, m.width, m.height) + } + return nil +} + +func (m *Manager) only(e event.Event) error { + if e.Arg != "" { + return errors.New("too many arguments for " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + if !e.Bang { + for windowIndex, w := range m.layout.Collect() { + if window := m.windows[windowIndex]; !w.Active && window.changedTick != window.savedChangedTick { + return errors.New("you have unsaved changes in " + window.getName() + ", add ! to force :only") + } + } + } + m.layout = layout.NewLayout(m.windowIndex).Resize(0, 0, m.width, m.height) + return nil +} + +func (m *Manager) alternative(e event.Event) { + m.mu.Lock() + defer m.mu.Unlock() + if e.Count == 0 { + m.windowIndex, m.prevWindowIndex = m.prevWindowIndex, m.windowIndex + } else if 0 < e.Count && e.Count <= int64(len(m.windows)) { + m.windowIndex, m.prevWindowIndex = int(e.Count)-1, m.windowIndex + } + m.layout = m.layout.Replace(m.windowIndex) +} + +func (m *Manager) wincmd(arg string) error { + switch arg { + case "n": + return m.newWindow(event.Event{}, false) + case "o": + return m.only(event.Event{}) + case "l": + m.focus(func(x, y layout.Window) bool { + return x.LeftMargin()+x.Width()+1 == y.LeftMargin() && + y.TopMargin() <= x.TopMargin() && + x.TopMargin() < y.TopMargin()+y.Height() + }) + case "h": + m.focus(func(x, y layout.Window) bool { + return y.LeftMargin()+y.Width()+1 == x.LeftMargin() && + y.TopMargin() <= x.TopMargin() && + x.TopMargin() < y.TopMargin()+y.Height() + }) + case "k": + m.focus(func(x, y layout.Window) bool { + return y.TopMargin()+y.Height() == x.TopMargin() && + y.LeftMargin() <= x.LeftMargin() && + x.LeftMargin() < y.LeftMargin()+y.Width() + }) + case "j": + m.focus(func(x, y layout.Window) bool { + return x.TopMargin()+x.Height() == y.TopMargin() && + y.LeftMargin() <= x.LeftMargin() && + x.LeftMargin() < y.LeftMargin()+y.Width() + }) + case "t": + m.focus(func(_, y layout.Window) bool { + return y.LeftMargin() == 0 && y.TopMargin() == 0 + }) + case "b": + m.focus(func(_, y layout.Window) bool { + return m.layout.LeftMargin()+m.layout.Width() == y.LeftMargin()+y.Width() && + m.layout.TopMargin()+m.layout.Height() == y.TopMargin()+y.Height() + }) + case "p": + m.focus(func(_, y layout.Window) bool { + return y.Index == m.prevWindowIndex + }) + case "K": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Horizontal{Top: x, Bottom: y} + }) + case "J": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Horizontal{Top: y, Bottom: x} + }) + case "H": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Vertical{Left: x, Right: y} + }) + case "L": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Vertical{Left: y, Right: x} + }) + default: + return errors.New("Invalid argument for wincmd: " + arg) + } + return nil +} + +func (m *Manager) focus(search func(layout.Window, layout.Window) bool) { + m.mu.Lock() + defer m.mu.Unlock() + activeWindow := m.layout.ActiveWindow() + newWindow := m.layout.Lookup(func(l layout.Window) bool { + return search(activeWindow, l) + }) + if newWindow.Index >= 0 { + m.windowIndex, m.prevWindowIndex = newWindow.Index, m.windowIndex + m.layout = m.layout.Activate(m.windowIndex) + } +} + +func (m *Manager) move(modifier func(layout.Window, layout.Layout) layout.Layout) { + m.mu.Lock() + defer m.mu.Unlock() + w, h := m.layout.Count() + if w != 1 || h != 1 { + activeWindow := m.layout.ActiveWindow() + m.layout = modifier(activeWindow, m.layout.Close()).Activate( + activeWindow.Index).Resize(0, 0, m.width, m.height) + } +} + +func (m *Manager) chdir(e event.Event) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if e.Arg == "-" && m.prevDir == "" { + return "", errors.New("no previous working directory") + } + dir, err := os.Getwd() + if err != nil { + return "", err + } + if e.Arg == "" { + return dir, nil + } + if e.Arg != "-" { + dir, m.prevDir = e.Arg, dir + } else { + dir, m.prevDir = m.prevDir, dir + } + if dir, err = expandPath(dir); err != nil { + return "", err + } + if err = os.Chdir(dir); err != nil { + return "", err + } + return os.Getwd() +} + +func (m *Manager) quit(e event.Event) error { + if e.Arg != "" { + return errors.New("too many arguments for " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + window := m.windows[m.windowIndex] + if window.changedTick != window.savedChangedTick && !e.Bang { + return errors.New("you have unsaved changes in " + window.getName() + ", add ! to force :quit") + } + w, h := m.layout.Count() + if w == 1 && h == 1 { + m.eventCh <- event.Event{Type: event.QuitAll} + } else { + m.layout = m.layout.Close().Resize(0, 0, m.width, m.height) + m.windowIndex, m.prevWindowIndex = m.layout.ActiveWindow().Index, m.windowIndex + m.eventCh <- event.Event{Type: event.Redraw} + } + return nil +} + +func (m *Manager) write(e event.Event) (string, int64, error) { + if e.Range != nil && e.Arg == "" { + return "", 0, errors.New("cannot overwrite partially with " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + window := m.windows[m.windowIndex] + var path string + name := e.Arg + if name == "" { + if window.name == "" { + return "", 0, errors.New("no file name") + } + path, name = window.path, window.name + } else { + var err error + path, err = expandPath(name) + if err != nil { + return "", 0, err + } + } + if runtime.GOOS == "windows" && m.opened(path) { + return "", 0, errors.New("cannot overwrite the original file on Windows") + } + if window.path == "" && window.name == "" { + window.setPathName(path, filepath.Base(path)) + } + tmpf, err := os.OpenFile( + path+"-"+strconv.FormatUint(rand.Uint64(), 36), + os.O_RDWR|os.O_CREATE|os.O_EXCL, m.filePerm(path), + ) //#nosec G404 + if err != nil { + return "", 0, err + } + defer os.Remove(tmpf.Name()) + n, err := window.writeTo(e.Range, tmpf) + if err != nil { + _ = tmpf.Close() + return "", 0, err + } + if err = tmpf.Close(); err != nil { + return "", 0, err + } + if window.path == path { + window.savedChangedTick = window.changedTick + } + return name, n, os.Rename(tmpf.Name(), path) +} + +func (m *Manager) addFile(path string, f *os.File, fi os.FileInfo) { + m.files[path] = file{path: path, file: f, perm: fi.Mode().Perm()} +} + +func (m *Manager) opened(path string) bool { + _, ok := m.files[path] + return ok +} + +func (m *Manager) filePerm(path string) os.FileMode { + if f, ok := m.files[path]; ok { + return f.perm + } + return os.FileMode(0o644) +} + +// State returns the state of the windows. +func (m *Manager) State() (map[int]*state.WindowState, layout.Layout, int, error) { + m.mu.Lock() + defer m.mu.Unlock() + layouts := m.layout.Collect() + states := make(map[int]*state.WindowState, len(m.windows)) + for i, window := range m.windows { + if l, ok := layouts[i]; ok { + var err error + if states[i], err = window.state( + hexWindowWidth(l.Width()), max(l.Height()-2, 1), + ); err != nil { + return nil, m.layout, 0, err + } + } + } + return states, m.layout, m.windowIndex, nil +} + +func hexWindowWidth(width int) int { + width = min(max((width-18)/4, 4), 256) + return width & (0b11 << (bits.Len(uint(width)) - 2)) +} + +// Close the Manager. +func (m *Manager) Close() { + for _, f := range m.files { + _ = f.file.Close() + } +} diff --git a/bed/window/manager_test.go b/bed/window/manager_test.go new file mode 100644 index 0000000..f2e0d03 --- /dev/null +++ b/bed/window/manager_test.go @@ -0,0 +1,708 @@ +package window + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" +) + +func createTemp(dir, contents string) (*os.File, error) { + f, err := os.CreateTemp(dir, "") + if err != nil { + return nil, err + } + if _, err = f.WriteString(contents); err != nil { + return nil, err + } + if err = f.Close(); err != nil { + return nil, err + } + return f, nil +} + +func TestManagerOpenEmpty(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + ev := <-eventCh + if ev.Type != event.Error { + t.Errorf("event type should be %d but got: %d", event.Error, ev.Type) + } + if expected := "no file name"; ev.Error.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + }() + wm.SetSize(110, 20) + if err := wm.Open(""); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := ""; ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 0 { + t.Errorf("size should be %d but got %d", 0, ws.Size) + } + if ws.Length != int64(0) { + t.Errorf("Length should be %d but got %d", int64(0), ws.Length) + } + if expected := "\x00"; !strings.HasPrefix(string(ws.Bytes), expected) { + t.Errorf("Bytes should start with %q but got %q", expected, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Write}) + <-waitCh + wm.Close() +} + +func TestManagerOpenStates(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + str := "Hello, world! こんにちは、世界!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := wm.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := filepath.Base(f.Name()); ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 41 { + t.Errorf("size should be %d but got %d", 41, ws.Size) + } + if ws.Length != int64(41) { + t.Errorf("Length should be %d but got %d", int64(41), ws.Length) + } + if !strings.HasPrefix(string(ws.Bytes), str) { + t.Errorf("Bytes should start with %q but got %q", str, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerOpenNonExistsWrite(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 16 { + <-redrawCh + } + if ev := <-eventCh; ev.Type != event.QuitAll { + t.Errorf("event type should be %d but got: %d", event.QuitAll, ev.Type) + } + }() + wm.SetSize(110, 20) + fname := filepath.Join(t.TempDir(), "test") + if err := wm.Open(fname); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + _, _, _, _ = wm.State() + str := "Hello, world!" + wm.Emit(event.Event{Type: event.StartInsert}) + wm.Emit(event.Event{Type: event.SwitchFocus}) + for _, c := range str { + wm.Emit(event.Event{Type: event.Rune, Rune: c, Mode: mode.Insert}) + } + wm.Emit(event.Event{Type: event.ExitInsert}) + wm.Emit(event.Event{Type: event.WriteQuit}) + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := filepath.Base(fname); ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 13 { + t.Errorf("size should be %d but got %d", 13, ws.Size) + } + if ws.Length != int64(13) { + t.Errorf("Length should be %d but got %d", int64(13), ws.Length) + } + if !strings.HasPrefix(string(ws.Bytes), str) { + t.Errorf("Bytes should start with %q but got %q", str, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(fname) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if string(bs) != str { + t.Errorf("file contents should be %q but got %q", str, string(bs)) + } + <-waitCh + wm.Close() +} + +func TestManagerOpenExpandBacktick(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + cmd, name := "`which ls`", "ls" + if runtime.GOOS == "windows" { + cmd, name = "`where ping`", "PING.EXE" + } + if err := wm.Open(cmd); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if ws.Name != name { + t.Errorf("name should be %q but got %q", name, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size == 0 { + t.Errorf("size should not be %d but got %d", 0, ws.Size) + } + if ws.Length == 0 { + t.Errorf("length should not be %d but got %d", 0, ws.Length) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerOpenExpandHomedir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + str := "Hello, world!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + home := os.Getenv("HOME") + t.Cleanup(func() { + if err := os.Setenv("HOME", home); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + }) + if err := os.Setenv("HOME", filepath.Dir(f.Name())); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + for i, prefix := range []string{"~/", "$HOME/"} { + if err := wm.Open(prefix + filepath.Base(f.Name())); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if windowIndex != i { + t.Errorf("windowIndex should be %d but got %d", i, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := filepath.Base(f.Name()); ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if !strings.HasPrefix(string(ws.Bytes), str) { + t.Errorf("Bytes should start with %q but got %q", str, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + } + wm.Close() +} + +func TestManagerOpenChdirWrite(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + f, err := createTemp(t.TempDir(), "Hello") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + ev := <-eventCh + if ev.Type != event.Info { + t.Errorf("event type should be %d but got: %d", event.Info, ev.Type) + } + dir, err := filepath.EvalSymlinks(filepath.Dir(f.Name())) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := dir; ev.Error.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + ev = <-eventCh + if ev.Type != event.Info { + t.Errorf("event type should be %d but got: %d", event.Info, ev.Type) + } + if expected := filepath.Dir(dir); ev.Error.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + for range 11 { + <-redrawCh + } + ev = <-eventCh + if ev.Type != event.Info { + t.Errorf("event type should be %d but got: %d", event.Info, ev.Type) + } + if expected := "13 (0xd) bytes written"; !strings.HasSuffix(ev.Error.Error(), expected) { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + }() + wm.SetSize(110, 20) + wm.Emit(event.Event{Type: event.Chdir, Arg: filepath.Dir(f.Name())}) + if err := wm.Open(filepath.Base(f.Name())); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + _, _, windowIndex, _ := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + wm.Emit(event.Event{Type: event.Chdir, Arg: "../"}) + wm.Emit(event.Event{Type: event.StartAppendEnd}) + wm.Emit(event.Event{Type: event.SwitchFocus}) + for _, c := range ", world!" { + wm.Emit(event.Event{Type: event.Rune, Rune: c, Mode: mode.Insert}) + } + wm.Emit(event.Event{Type: event.ExitInsert}) + wm.Emit(event.Event{Type: event.Write}) + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello, world!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + <-waitCh + wm.Close() +} + +func TestManagerOpenDirectory(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + dir := t.TempDir() + if err := wm.Open(dir); err != nil { + if expected := dir + " is a directory"; err.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, err) + } + } else { + t.Errorf("err should not be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerRead(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + r := strings.NewReader("Hello, world!") + if err := wm.Read(r); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if ws.Name != "" { + t.Errorf("name should be %q but got %q", "", ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 13 { + t.Errorf("size should be %d but got %d", 13, ws.Size) + } + if ws.Length != int64(13) { + t.Errorf("Length should be %d but got %d", int64(13), ws.Length) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerOnly(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 4 { + <-eventCh + } + }() + wm.SetSize(110, 20) + if err := wm.Open(""); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Vnew}) + wm.Emit(event.Event{Type: event.Vnew}) + wm.Emit(event.Event{Type: event.FocusWindowRight}) + wm.Resize(110, 20) + + _, got, _, _ := wm.State() + expected := layout.NewLayout(0).SplitLeft(1).SplitLeft(2). + Activate(1).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.Only}) + wm.Resize(110, 20) + _, got, _, _ = wm.State() + expected = layout.NewLayout(1).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + <-waitCh + wm.Close() +} + +func TestManagerAlternative(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 9 { + <-eventCh + } + }() + wm.SetSize(110, 20) + + if err := os.Chdir(os.TempDir()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := wm.Open("bed-test-manager-alternative-1"); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := wm.Open("bed-test-manager-alternative-2"); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + if err := wm.Open("bed-test-manager-alternative-3"); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + _, _, windowIndex, _ = wm.State() + if expected := 2; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ = wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ = wm.State() + if expected := 2; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + if err := wm.Open("bed-test-manager-alternative-4"); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + _, _, windowIndex, _ = wm.State() + if expected := 3; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative, Count: 2}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative, Count: 4}) + _, _, windowIndex, _ = wm.State() + if expected := 3; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Edit, Arg: "#2"}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Edit, Arg: "#4"}) + _, _, windowIndex, _ = wm.State() + if expected := 3; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Edit, Arg: "#"}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + <-waitCh + wm.Close() +} + +func TestManagerWincmd(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 17 { + <-eventCh + } + }() + wm.SetSize(110, 20) + if err := wm.Open(""); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Wincmd, Arg: "n"}) + wm.Emit(event.Event{Type: event.Wincmd, Arg: "n"}) + wm.Emit(event.Event{Type: event.Wincmd, Arg: "n"}) + wm.Emit(event.Event{Type: event.MoveWindowLeft}) + wm.Emit(event.Event{Type: event.FocusWindowRight}) + wm.Emit(event.Event{Type: event.FocusWindowBottomRight}) + wm.Emit(event.Event{Type: event.MoveWindowRight}) + wm.Emit(event.Event{Type: event.FocusWindowLeft}) + wm.Emit(event.Event{Type: event.MoveWindowTop}) + wm.Resize(110, 20) + + _, got, _, _ := wm.State() + expected := layout.NewLayout(2).SplitBottom(0).SplitLeft(1). + SplitLeft(3).Activate(2).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.FocusWindowDown}) + wm.Emit(event.Event{Type: event.FocusWindowRight}) + wm.Emit(event.Event{Type: event.Quit}) + _, got, _, _ = wm.State() + expected = layout.NewLayout(2).SplitBottom(0).SplitLeft(3).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.Wincmd, Arg: "o"}) + _, got, _, _ = wm.State() + expected = layout.NewLayout(3).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.MoveWindowLeft}) + wm.Emit(event.Event{Type: event.MoveWindowRight}) + wm.Emit(event.Event{Type: event.MoveWindowTop}) + wm.Emit(event.Event{Type: event.MoveWindowBottom}) + wm.Resize(110, 20) + _, got, _, _ = wm.State() + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + <-waitCh + wm.Close() +} + +func TestManagerCopyCutPaste(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + str := "Hello, world!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.SetSize(110, 20) + if err := wm.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + _, _, _, _ = wm.State() + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + <-redrawCh + <-redrawCh + <-redrawCh + waitCh <- struct{}{} + ev := <-eventCh + if ev.Type != event.Copied { + t.Errorf("event type should be %d but got: %d", event.Copied, ev.Type) + } + if ev.Buffer == nil { + t.Errorf("Buffer should not be nil but got: %#v", ev) + } + if expected := "yanked"; ev.Arg != expected { + t.Errorf("Arg should be %q but got: %q", expected, ev.Arg) + } + p := make([]byte, 20) + _, _ = ev.Buffer.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "lo, worl") { + t.Errorf("buffer string should be %q but got: %q", "", string(p)) + } + waitCh <- struct{}{} + <-redrawCh + <-redrawCh + waitCh <- struct{}{} + ev = <-eventCh + if ev.Type != event.Copied { + t.Errorf("event type should be %d but got: %d", event.Copied, ev.Type) + } + if ev.Buffer == nil { + t.Errorf("Buffer should not be nil but got: %#v", ev) + } + if expected := "deleted"; ev.Arg != expected { + t.Errorf("Arg should be %q but got: %q", expected, ev.Arg) + } + p = make([]byte, 20) + _, _ = ev.Buffer.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "lo, wo") { + t.Errorf("buffer string should be %q but got: %q", "", string(p)) + } + windowStates, _, windowIndex, _ := wm.State() + ws, ok := windowStates[windowIndex] + if !ok { + t.Errorf("windowStates should contain %d but got: %v", windowIndex, windowStates) + return + } + if ws.Length != int64(7) { + t.Errorf("Length should be %d but got %d", int64(7), ws.Length) + } + if expected := "Helrld!"; !strings.HasPrefix(string(ws.Bytes), expected) { + t.Errorf("Bytes should start with %q but got %q", expected, string(ws.Bytes)) + } + waitCh <- struct{}{} + <-redrawCh + waitCh <- struct{}{} + ev = <-eventCh + if ev.Type != event.Pasted { + t.Errorf("event type should be %d but got: %d", event.Pasted, ev.Type) + } + if ev.Count != 18 { + t.Errorf("Count should be %d but got: %d", 18, ev.Count) + } + windowStates, _, _, _ = wm.State() + ws = windowStates[0] + if ws.Length != int64(25) { + t.Errorf("Length should be %d but got %d", int64(25), ws.Length) + } + if expected := "Hefoobarfoobarfoobarlrld!"; !strings.HasPrefix(string(ws.Bytes), expected) { + t.Errorf("Bytes should start with %q but got %q", expected, string(ws.Bytes)) + } + }() + wm.Emit(event.Event{Type: event.CursorNext, Mode: mode.Normal, Count: 3}) + wm.Emit(event.Event{Type: event.StartVisual}) + wm.Emit(event.Event{Type: event.CursorNext, Mode: mode.Visual, Count: 7}) + <-waitCh + wm.Emit(event.Event{Type: event.Copy}) + <-waitCh + wm.Emit(event.Event{Type: event.StartVisual}) + wm.Emit(event.Event{Type: event.CursorNext, Mode: mode.Visual, Count: 5}) + <-waitCh + wm.Emit(event.Event{Type: event.Cut}) + <-waitCh + wm.Emit(event.Event{Type: event.CursorPrev, Mode: mode.Normal, Count: 2}) + <-waitCh + wm.Emit(event.Event{Type: event.Paste, Buffer: buffer.NewBuffer(strings.NewReader("foobar")), Count: 3}) + <-waitCh + wm.Close() +} diff --git a/bed/window/window.go b/bed/window/window.go new file mode 100644 index 0000000..4c75fd7 --- /dev/null +++ b/bed/window/window.go @@ -0,0 +1,1070 @@ +package window + +import ( + "cmp" + "errors" + "fmt" + "io" + "strconv" + "sync" + "unicode" + "unicode/utf8" + + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/history" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/searcher" + "b612.me/apps/b612/bed/state" +) + +type window struct { + buffer *buffer.Buffer + changedTick uint64 + prevChanged bool + maxChangedTick uint64 + savedChangedTick uint64 + history *history.History + searcher *searcher.Searcher + searchTick uint64 + path string + name string + height int64 + width int64 + offset int64 + cursor int64 + length int64 + stack []position + append bool + replaceByte bool + extending bool + pending bool + pendingByte byte + visualStart int64 + focusText bool + buf []byte + buf1 [1]byte + redrawCh chan<- struct{} + eventCh chan<- event.Event + mu *sync.Mutex +} + +type position struct { + cursor int64 + offset int64 +} + +type readAtSeeker interface { + io.ReaderAt + io.Seeker +} + +func newWindow( + r readAtSeeker, path, name string, + eventCh chan<- event.Event, redrawCh chan<- struct{}, +) (*window, error) { + buffer := buffer.NewBuffer(r) + length, err := buffer.Len() + if err != nil { + return nil, err + } + history := history.NewHistory() + history.Push(buffer, 0, 0, 0) + return &window{ + buffer: buffer, + history: history, + searcher: searcher.NewSearcher(r), + path: path, + name: name, + length: length, + visualStart: -1, + redrawCh: redrawCh, + eventCh: eventCh, + mu: new(sync.Mutex), + }, nil +} + +func (w *window) setSize(width, height int) { + w.width, w.height = int64(width), int64(height) + w.offset = w.offset / w.width * w.width + if w.cursor < w.offset { + w.offset = w.cursor / w.width * w.width + } else if w.cursor >= w.offset+w.height*w.width { + w.offset = (w.cursor - w.height*w.width + w.width) / w.width * w.width + } + w.offset = min( + w.offset, + max(w.length-1-w.height*w.width+w.width, 0)/w.width*w.width, + ) +} + +func (w *window) emit(e event.Event) { + var newEvent event.Event + w.mu.Lock() + offset, cursor, changedTick := w.offset, w.cursor, w.changedTick + switch e.Type { + case event.CursorUp: + w.cursorUp(e.Count) + case event.CursorDown: + w.cursorDown(e.Count) + case event.CursorLeft: + w.cursorLeft(e.Count) + case event.CursorRight: + w.cursorRight(e.Mode, e.Count) + case event.CursorPrev: + w.cursorPrev(e.Count) + case event.CursorNext: + w.cursorNext(e.Mode, e.Count) + case event.CursorHead: + w.cursorHead(e.Count) + case event.CursorEnd: + w.cursorEnd(e.Count) + case event.CursorGoto: + w.cursorGoto(e) + case event.ScrollUp: + w.scrollUp(e.Count) + case event.ScrollDown: + w.scrollDown(e.Count) + case event.ScrollTop: + w.scrollTop(e.Count) + case event.ScrollTopHead: + w.scrollTopHead(e.Count) + case event.ScrollMiddle: + w.scrollMiddle(e.Count) + case event.ScrollMiddleHead: + w.scrollMiddleHead(e.Count) + case event.ScrollBottom: + w.scrollBottom(e.Count) + case event.ScrollBottomHead: + w.scrollBottomHead(e.Count) + case event.PageUp: + w.pageUp() + case event.PageDown: + w.pageDown() + case event.PageUpHalf: + w.pageUpHalf() + case event.PageDownHalf: + w.pageDownHalf() + case event.PageTop: + w.pageTop() + case event.PageEnd: + w.pageEnd() + case event.WindowTop: + w.windowTop(e.Count) + case event.WindowMiddle: + w.windowMiddle() + case event.WindowBottom: + w.windowBottom(e.Count) + case event.JumpTo: + w.jumpTo() + case event.JumpBack: + w.jumpBack() + + case event.DeleteByte: + newEvent = event.Event{Type: event.Copied, Buffer: w.deleteBytes(e.Count), Arg: "deleted"} + case event.DeletePrevByte: + newEvent = event.Event{Type: event.Copied, Buffer: w.deletePrevBytes(e.Count), Arg: "deleted"} + case event.Increment: + w.increment(e.Count) + case event.Decrement: + w.decrement(e.Count) + case event.ShiftLeft: + w.shiftLeft(e.Count) + case event.ShiftRight: + w.shiftRight(e.Count) + case event.ShowBinary: + if str := w.showBinary(); str != "" { + newEvent = event.Event{Type: event.Info, Error: errors.New(str)} + } + case event.ShowDecimal: + if str := w.showDecimal(); str != "" { + newEvent = event.Event{Type: event.Info, Error: errors.New(str)} + } + + case event.StartInsert: + w.startInsert() + case event.StartInsertHead: + w.startInsertHead() + case event.StartAppend: + w.startAppend() + case event.StartAppendEnd: + w.startAppendEnd() + case event.StartReplaceByte: + w.startReplaceByte() + case event.StartReplace: + w.startReplace() + case event.ExitInsert: + w.exitInsert() + case event.Rune: + if w.insertRune(e.Mode, e.Rune) { + newEvent = event.Event{Type: event.ExitInsert} + } + case event.Backspace: + w.backspace(e.Mode) + case event.Delete: + w.deleteByte() + case event.StartVisual: + w.startVisual() + case event.SwitchVisualEnd: + w.switchVisualEnd() + case event.ExitVisual: + w.exitVisual() + case event.SwitchFocus: + w.focusText = !w.focusText + if w.pending { + w.pending = false + w.pendingByte = '\x00' + } + case event.Undo: + if e.Mode != mode.Normal { + panic("event.Undo should be emitted under normal mode") + } + w.undo(e.Count) + case event.Redo: + if e.Mode != mode.Normal { + panic("event.Undo should be emitted under normal mode") + } + w.redo(e.Count) + case event.Copy: + newEvent = event.Event{Type: event.Copied, Buffer: w.copy(), Arg: "yanked"} + case event.Cut: + newEvent = event.Event{Type: event.Copied, Buffer: w.cut(), Arg: "deleted"} + case event.Paste, event.PastePrev: + newEvent = event.Event{Type: event.Pasted, Count: w.paste(e)} + case event.ExecuteSearch: + w.search(e.Arg, e.Rune == '/') + case event.NextSearch: + w.search(e.Arg, e.Rune == '/') + case event.PreviousSearch: + w.search(e.Arg, e.Rune != '/') + case event.AbortSearch: + w.abortSearch() + default: + w.mu.Unlock() + return + } + changed := changedTick != w.changedTick + if e.Type != event.Undo && e.Type != event.Redo { + if (e.Mode == mode.Normal || e.Mode == mode.Visual) && changed || e.Type == event.ExitInsert && w.prevChanged { + w.history.Push(w.buffer, w.offset, w.cursor, w.changedTick) + } else if e.Mode != mode.Normal && e.Mode != mode.Visual && w.prevChanged && !changed && + event.CursorUp <= e.Type && e.Type <= event.JumpBack { + w.history.Push(w.buffer, offset, cursor, w.changedTick) + } + } + w.prevChanged = changed + w.mu.Unlock() + if newEvent.Type == event.Nop { + w.redrawCh <- struct{}{} + } else { + w.eventCh <- newEvent + } +} + +func (w *window) readByte(offset int64) (byte, error) { + n, err := w.buffer.ReadAt(w.buf1[:], offset) + if err != nil && err != io.EOF { + return 0, err + } + if n == 0 { + return 0, io.EOF + } + return w.buf1[0], nil +} + +func (w *window) readBytes(offset int64, l int) (int, []byte, error) { + var reused bool + if l <= cap(w.buf) { + w.buf, reused = w.buf[:l], true + } else { + w.buf = make([]byte, l) + } + n, err := w.buffer.ReadAt(w.buf, offset) + if err != nil && err != io.EOF { + return 0, w.buf, err + } + if reused { + for i := n; i < len(w.buf); i++ { + w.buf[i] = 0 + } + } + return n, w.buf, nil +} + +func (w *window) writeTo(r *event.Range, dst io.Writer) (int64, error) { + w.mu.Lock() + defer w.mu.Unlock() + var from, to int64 + if r == nil { + from, to = 0, w.length-1 + } else { + var err error + if from, err = w.positionToOffset(r.From); err != nil { + return 0, err + } + if to, err = w.positionToOffset(r.To); err != nil { + return 0, err + } + if from > to { + from, to = to, from + } + } + return io.Copy(dst, io.NewSectionReader(w.buffer, from, to-from+1)) +} + +func (w *window) positionToOffset(pos event.Position) (int64, error) { + var offset int64 + switch pos := pos.(type) { + case event.Absolute: + offset = pos.Offset + case event.Relative: + offset = w.cursor + pos.Offset + case event.End: + offset = max(w.length, 1) - 1 + pos.Offset + case event.VisualStart: + if w.visualStart < 0 { + return 0, errors.New("no visual selection found") + } + // TODO: save visualStart after exiting visual mode + offset = w.visualStart + pos.Offset + case event.VisualEnd: + if w.visualStart < 0 { + return 0, errors.New("no visual selection found") + } + offset = w.cursor + pos.Offset + default: + return 0, errors.New("invalid range") + } + return max(min(offset, max(w.length, 1)-1), 0), nil +} + +func (w *window) state(width, height int) (*state.WindowState, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.setSize(width, height) + n, bytes, err := w.readBytes(w.offset, int(w.height*w.width)) + if err != nil { + return nil, err + } + return &state.WindowState{ + Name: w.name, + Modified: w.changedTick != w.savedChangedTick, + Width: int(w.width), + Offset: w.offset, + Cursor: w.cursor, + Bytes: bytes, + Size: n, + Length: w.length, + Pending: w.pending, + PendingByte: w.pendingByte, + VisualStart: w.visualStart, + EditedIndices: w.buffer.EditedIndices(), + FocusText: w.focusText, + }, nil +} + +func (w *window) updateTick() { + w.maxChangedTick++ + w.changedTick = w.maxChangedTick +} + +func (w *window) insert(offset int64, c byte) { + w.buffer.Insert(offset, c) + w.updateTick() +} + +func (w *window) replace(offset int64, c byte) { + w.buffer.Replace(offset, c) + w.updateTick() +} + +func (w *window) undoReplace(offset int64) { + w.buffer.UndoReplace(offset) + w.updateTick() +} + +func (w *window) replaceIn(start, end int64, c byte) { + w.buffer.ReplaceIn(start, end, c) + w.updateTick() +} + +func (w *window) delete(offset int64) { + w.buffer.Delete(offset) + w.updateTick() +} + +func (w *window) undo(count int64) { + for range max(count, 1) { + buffer, _, offset, cursor, tick := w.history.Undo() + if buffer == nil { + return + } + w.buffer, w.offset, w.cursor, w.changedTick = buffer, offset, cursor, tick + w.length, _ = w.buffer.Len() + } +} + +func (w *window) redo(count int64) { + for range max(count, 1) { + buffer, offset, cursor, tick := w.history.Redo() + if buffer == nil { + return + } + w.buffer, w.offset, w.cursor, w.changedTick = buffer, offset, cursor, tick + w.length, _ = w.buffer.Len() + } +} + +func (w *window) cursorUp(count int64) { + w.cursor -= min(max(count, 1), w.cursor/w.width) * w.width + if w.append && w.extending && w.cursor < w.length-1 { + w.append = false + w.extending = false + if w.length > 0 { + w.length-- + } + } +} + +func (w *window) cursorDown(count int64) { + w.cursor += min( + min( + max(count, 1), + (max(w.length, 1)-1)/w.width-w.cursor/w.width, + )*w.width, + max(w.length, 1)-1-w.cursor) +} + +func (w *window) cursorLeft(count int64) { + w.cursor -= min(max(count, 1), w.cursor%w.width) + if w.append && w.extending && w.cursor < w.length-1 { + w.append = false + w.extending = false + if w.length > 0 { + w.length-- + } + } +} + +func (w *window) cursorRight(m mode.Mode, count int64) { + if m != mode.Insert { + w.cursor += min( + min(max(count, 1), w.width-1-w.cursor%w.width), + max(w.length, 1)-1-w.cursor, + ) + } else if !w.extending { + w.cursor += min( + min(max(count, 1), w.width-1-w.cursor%w.width), + w.length-w.cursor, + ) + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } + } +} + +func (w *window) cursorPrev(count int64) { + w.cursor -= min(max(count, 1), w.cursor) + if w.append && w.extending && w.cursor != w.length { + w.append = false + w.extending = false + if w.length > 0 { + w.length-- + } + } +} + +func (w *window) cursorNext(m mode.Mode, count int64) { + if m != mode.Insert { + w.cursor += min(max(count, 1), max(w.length, 1)-1-w.cursor) + } else if !w.extending { + w.cursor += min(max(count, 1), w.length-w.cursor) + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } + } +} + +func (w *window) cursorHead(_ int64) { + w.cursor -= w.cursor % w.width +} + +func (w *window) cursorEnd(count int64) { + w.cursor = min( + (w.cursor/w.width+max(count, 1))*w.width-1, + max(w.length, 1)-1, + ) +} + +func (w *window) cursorGoto(e event.Event) { + if e.Range != nil { + if e.Range.To != nil { + w.cursorGotoPos(e.Range.To, e.CmdName) + } else if e.Range.From != nil { + w.cursorGotoPos(e.Range.From, e.CmdName) + } + } +} + +func (w *window) cursorGotoPos(pos event.Position, cmdName string) { + switch cmdName { + case "go[to]": + switch p := pos.(type) { + case event.Absolute: + pos = event.Absolute{Offset: p.Offset * w.width} + case event.Relative: + pos = event.Relative{Offset: p.Offset * w.width} + case event.End: + pos = event.End{Offset: p.Offset * w.width} + case event.VisualStart: + pos = event.VisualStart{Offset: p.Offset * w.width} + case event.VisualEnd: + pos = event.VisualEnd{Offset: p.Offset * w.width} + } + case "%": + switch p := pos.(type) { + case event.Absolute: + pos = event.Absolute{Offset: p.Offset * w.length / 100} + case event.Relative: + pos = event.Relative{Offset: p.Offset * w.length / 100} + case event.End: + pos = event.End{Offset: p.Offset * w.length / 100} + case event.VisualStart: + pos = event.VisualStart{Offset: p.Offset * w.length / 100} + case event.VisualEnd: + pos = event.VisualEnd{Offset: p.Offset * w.length / 100} + } + } + if offset, err := w.positionToOffset(pos); err == nil { + w.cursor = offset + if w.cursor < w.offset { + w.offset = (max(w.cursor/w.width, w.height/2) - w.height/2) * w.width + } else if w.cursor >= w.offset+w.height*w.width { + h := (max(w.length, 1)+w.width-1)/w.width - w.height + w.offset = min((w.cursor-w.height*w.width+w.width)/w.width+w.height/2, h) * w.width + } + } +} + +func (w *window) scrollUp(count int64) { + w.offset -= min(max(count, 1), w.offset/w.width) * w.width + if w.cursor >= w.offset+w.height*w.width { + w.cursor -= ((w.cursor-w.offset-w.height*w.width)/w.width + 1) * w.width + } +} + +func (w *window) scrollDown(count int64) { + h := max((max(w.length, 1)+w.width-1)/w.width-w.height, 0) + w.offset += min(max(count, 1), h-w.offset/w.width) * w.width + if w.cursor < w.offset { + w.cursor += min( + (w.offset-w.cursor+w.width-1)/w.width*w.width, + max(w.length, 1)-1-w.cursor, + ) + } +} + +func (w *window) scrollTop(count int64) { + if count > 0 { + w.cursor = min( + min( + count*w.width+w.cursor%w.width, + (max(w.length, 1)-1)/w.width*w.width+w.cursor%w.width, + ), + max(w.length, 1)-1, + ) + } + w.offset = w.cursor / w.width * w.width +} + +func (w *window) scrollTopHead(count int64) { + w.cursorHead(0) + w.scrollTop(count) +} + +func (w *window) scrollMiddle(count int64) { + if count > 0 { + w.cursor = min( + min( + count*w.width+w.cursor%w.width, + (max(w.length, 1)-1)/w.width*w.width+w.cursor%w.width, + ), + max(w.length, 1)-1, + ) + } + w.offset = max(w.cursor/w.width-w.height/2, 0) * w.width +} + +func (w *window) scrollMiddleHead(count int64) { + w.cursorHead(0) + w.scrollMiddle(count) +} + +func (w *window) scrollBottom(count int64) { + if count > 0 { + w.cursor = min( + min( + count*w.width+w.cursor%w.width, + (max(w.length, 1)-1)/w.width*w.width+w.cursor%w.width, + ), + max(w.length, 1)-1, + ) + } + w.offset = max(w.cursor/w.width-w.height, 0) * w.width +} + +func (w *window) scrollBottomHead(count int64) { + w.cursorHead(0) + w.scrollBottom(count) +} + +func (w *window) pageUp() { + w.offset = max(w.offset-(w.height-2)*w.width, 0) + if w.offset == 0 { + w.cursor = 0 + } else if w.cursor >= w.offset+w.height*w.width { + w.cursor = w.offset + (w.height-1)*w.width + } +} + +func (w *window) pageDown() { + offset := max(((w.length+w.width-1)/w.width-w.height)*w.width, 0) + w.offset = min(w.offset+(w.height-2)*w.width, offset) + if w.cursor < w.offset { + w.cursor = w.offset + } else if w.offset == offset { + w.cursor = ((max(w.length, 1)+w.width-1)/w.width - 1) * w.width + } +} + +func (w *window) pageUpHalf() { + w.offset = max(w.offset-max(w.height/2, 1)*w.width, 0) + if w.offset == 0 { + w.cursor = 0 + } else if w.cursor >= w.offset+w.height*w.width { + w.cursor = w.offset + (w.height-1)*w.width + } +} + +func (w *window) pageDownHalf() { + offset := max(((w.length+w.width-1)/w.width-w.height)*w.width, 0) + w.offset = min(w.offset+max(w.height/2, 1)*w.width, offset) + if w.cursor < w.offset { + w.cursor = w.offset + } else if w.offset == offset { + w.cursor = ((max(w.length, 1)+w.width-1)/w.width - 1) * w.width + } +} + +func (w *window) pageTop() { + w.offset = 0 + w.cursor = 0 +} + +func (w *window) pageEnd() { + w.offset = max(((w.length+w.width-1)/w.width-w.height)*w.width, 0) + w.cursor = ((max(w.length, 1)+w.width-1)/w.width - 1) * w.width +} + +func (w *window) windowTop(count int64) { + w.cursor = (w.offset/w.width + min( + min(max(count, 1)-1, (w.length-w.offset)/w.width), + max(w.height, 1)-1, + )) * w.width +} + +func (w *window) windowMiddle() { + h := min((w.length-w.offset)/w.width, max(w.height, 1)-1) + w.cursor = (w.offset/w.width + h/2) * w.width +} + +func (w *window) windowBottom(count int64) { + h := min((w.length-w.offset)/w.width, max(w.height, 1)-1) + w.cursor = (w.offset/w.width + h - min(h, max(count, 1)-1)) * w.width +} + +func (w *window) jumpTo() { + i := min(w.cursor, 16) + _, bytes, err := w.readBytes(w.cursor-i, 32) + if err != nil { + return + } + for ; i >= 0; i-- { + if !unicode.IsDigit(rune(bytes[i])) { + bytes = bytes[i+1:] + break + } + } + for i := 0; i < len(bytes); i++ { + if !unicode.IsDigit(rune(bytes[i])) { + bytes = bytes[:i] + break + } + } + offset, _ := strconv.ParseInt(string(bytes), 10, 64) + if offset <= 0 || w.length <= offset { + return + } + w.stack = append(w.stack, position{cursor: w.cursor, offset: w.offset}) + w.cursor = offset + w.offset = max(offset-offset%w.width-max(w.height/3, 0)*w.width, 0) +} + +func (w *window) jumpBack() { + if len(w.stack) == 0 { + return + } + if pos := w.stack[len(w.stack)-1]; pos.cursor < w.length { + w.cursor, w.offset = pos.cursor, pos.offset + } + w.stack = w.stack[:len(w.stack)-1] +} + +func (w *window) deleteBytes(count int64) *buffer.Buffer { + if w.length == 0 { + return nil + } + count = min(max(count, 1), w.length-w.cursor) + b := w.buffer.Copy(w.cursor, w.cursor+count) + w.buffer.Cut(w.cursor, w.cursor+count) + w.length, _ = w.buffer.Len() + w.cursor = min(w.cursor, max(w.length, 1)-1) + w.updateTick() + return b +} + +func (w *window) deletePrevBytes(count int64) *buffer.Buffer { + if w.cursor == 0 { + return nil + } + count = min(max(count, 1), w.cursor) + b := w.buffer.Copy(w.cursor-count, w.cursor) + w.buffer.Cut(w.cursor-count, w.cursor) + w.length, _ = w.buffer.Len() + w.cursor -= count + w.updateTick() + return b +} + +func (w *window) increment(count int64) { + b, err := w.readByte(w.cursor) + if err != nil && err != io.EOF { + return + } + w.replace(w.cursor, b+byte(max(count, 1))) + if w.length == 0 { + w.length++ + } +} + +func (w *window) decrement(count int64) { + b, err := w.readByte(w.cursor) + if err != nil && err != io.EOF { + return + } + w.replace(w.cursor, b-byte(max(count, 1))) + if w.length == 0 { + w.length++ + } +} + +func (w *window) shiftLeft(count int64) { + b, err := w.readByte(w.cursor) + if err != nil && err != io.EOF { + return + } + w.replace(w.cursor, b<>byte(max(count, 1))) + if w.length == 0 { + w.length++ + } +} + +func (w *window) showBinary() string { + b, err := w.readByte(w.cursor) + if err != nil { + return "" + } + return fmt.Sprintf("%08b", b) +} + +func (w *window) showDecimal() string { + b, err := w.readByte(w.cursor) + if err != nil { + return "" + } + return strconv.FormatInt(int64(b), 10) +} + +func (w *window) startInsert() { + w.append = false + w.extending = false + w.pending = false + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } +} + +func (w *window) startInsertHead() { + w.cursorHead(0) + w.append = false + w.extending = false + w.pending = false + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } +} + +func (w *window) startAppend() { + w.append = true + w.extending = false + w.pending = false + if w.length > 0 { + w.cursor++ + } + if w.cursor == w.length { + w.extending = true + w.length++ + } +} + +func (w *window) startAppendEnd() { + w.cursorEnd(0) + w.startAppend() +} + +func (w *window) startReplaceByte() { + w.replaceByte = true + w.append = false + w.extending = false + w.pending = false +} + +func (w *window) startReplace() { + w.replaceByte = false + w.append = true + w.extending = false + w.pending = false +} + +func (w *window) exitInsert() { + w.pending = false + if w.append { + if w.extending && w.length > 0 { + w.length-- + } + if w.cursor > 0 { + w.cursor-- + } + w.replaceByte = false + w.append = false + w.extending = false + w.pending = false + } + w.buffer.Flush() +} + +func (w *window) insertRune(m mode.Mode, ch rune) (exitInsert bool) { + if m == mode.Insert || m == mode.Replace { + if w.focusText { + var buf [4]byte + n := utf8.EncodeRune(buf[:], ch) + for i := range n { + exitInsert = exitInsert || w.insertByte(m, byte(buf[i]>>4)) + exitInsert = exitInsert || w.insertByte(m, byte(buf[i]&0x0f)) + } + } else if '0' <= ch && ch <= '9' { + exitInsert = w.insertByte(m, byte(ch-'0')) + } else if 'a' <= ch && ch <= 'f' { + exitInsert = w.insertByte(m, byte(ch-'a'+0x0a)) + } + } + return +} + +func (w *window) insertByte(m mode.Mode, b byte) bool { + if w.pending { + switch m { + case mode.Insert: + w.insert(w.cursor, w.pendingByte|b) + w.cursor++ + w.length++ + case mode.Replace: + if w.visualStart >= 0 && w.replaceByte { + start, end := w.visualStart, w.cursor + if start > end { + start, end = end, start + } + w.replaceIn(start, end+1, w.pendingByte|b) + w.visualStart = -1 + return true + } + w.replace(w.cursor, w.pendingByte|b) + if w.length == 0 { + w.length++ + } + if w.replaceByte { + w.exitInsert() + return true + } + w.cursor++ + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } + } + w.pending = false + w.pendingByte = '\x00' + } else { + w.pending = true + w.pendingByte = b << 4 + } + return false +} + +func (w *window) backspace(m mode.Mode) { + if w.pending { + w.pending = false + w.pendingByte = '\x00' + } else if m == mode.Replace { + if w.cursor > 0 { + w.cursor-- + w.undoReplace(w.cursor) + } + } else if w.cursor > 0 { + w.delete(w.cursor - 1) + w.cursor-- + w.length-- + } +} + +func (w *window) deleteByte() { + if w.length == 0 { + return + } + w.delete(w.cursor) + w.length-- + if w.cursor == w.length && w.cursor > 0 { + w.cursor-- + } +} + +func (w *window) startVisual() { + w.visualStart = w.cursor +} + +func (w *window) switchVisualEnd() { + if w.visualStart < 0 { + panic("window#switchVisualEnd should be called in visual mode") + } + w.cursor, w.visualStart = w.visualStart, w.cursor +} + +func (w *window) exitVisual() { + w.visualStart = -1 +} + +func (w *window) copy() *buffer.Buffer { + if w.visualStart < 0 { + panic("window#copy should be called in visual mode") + } + start, end := w.visualStart, w.cursor + if start > end { + start, end = end, start + } + if end == w.length { + return nil + } + w.visualStart = -1 + w.cursor = start + return w.buffer.Copy(start, end+1) +} + +func (w *window) cut() *buffer.Buffer { + if w.visualStart < 0 { + panic("window#cut should be called in visual mode") + } + start, end := w.visualStart, w.cursor + if start > end { + start, end = end, start + } + if end == w.length { + return nil + } + w.visualStart = -1 + b := w.buffer.Copy(start, end+1) + w.buffer.Cut(start, end+1) + w.length, _ = w.buffer.Len() + w.cursor = min(start, max(w.length, 1)-1) + w.updateTick() + return b +} + +func (w *window) paste(e event.Event) int64 { + count := max(e.Count, 1) + pos := w.cursor + if e.Type != event.PastePrev { + pos = min(w.cursor+1, w.length) + } + for range count { + w.buffer.Paste(pos, e.Buffer) + } + l, _ := e.Buffer.Len() + w.length, _ = w.buffer.Len() + w.cursor = min(max(pos+l*count-1, 0), max(w.length, 1)-1) + w.updateTick() + return l * count +} + +func (w *window) search(str string, forward bool) { + if w.searchTick != w.changedTick { + w.searcher.Abort() + w.searcher = searcher.NewSearcher(w.buffer) + w.searchTick = w.changedTick + } + ch := w.searcher.Search(w.cursor, str, forward) + go func() { + switch x := (<-ch).(type) { + case error: + w.eventCh <- event.Event{Type: event.Info, Error: x} + case int64: + w.mu.Lock() + w.cursor = x + w.mu.Unlock() + w.redrawCh <- struct{}{} + } + }() +} + +func (w *window) abortSearch() { + if err := w.searcher.Abort(); err != nil { + w.eventCh <- event.Event{Type: event.Info, Error: err} + } +} + +func (w *window) setPathName(path, name string) { + w.path, w.name = path, name +} + +func (w *window) getName() string { + return cmp.Or(w.name, "[No Name]") +} diff --git a/bed/window/window_test.go b/bed/window/window_test.go new file mode 100644 index 0000000..da24afa --- /dev/null +++ b/bed/window/window_test.go @@ -0,0 +1,1870 @@ +package window + +import ( + "bytes" + "math" + "reflect" + "strings" + "testing" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/mode" +) + +func TestWindowState(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + + if expected := "test"; s.Name != expected { + t.Errorf("state.Name should be %q but got %q", expected, s.Name) + } + + if s.Width != width { + t.Errorf("state.Width should be %d but got %d", width, s.Width) + } + + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + if expected := 13; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + + if expected := int64(13); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + if !reflect.DeepEqual(s.EditedIndices, []int64{}) { + t.Errorf("state.EditedIndices should be empty but got %v", s.EditedIndices) + } + + expected := []byte("Hello, world!" + strings.Repeat("\x00", height*width-13)) + if !reflect.DeepEqual(s.Bytes, expected) { + t.Errorf("s.Bytes should be %q but got %q", expected, s.Bytes) + } +} + +func TestWindowEmptyState(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + + if expected := "test"; s.Name != expected { + t.Errorf("state.Name should be %q but got %q", expected, s.Name) + } + + if s.Width != width { + t.Errorf("state.Width should be %d but got %d", width, s.Width) + } + + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + if expected := 0; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + if !reflect.DeepEqual(s.EditedIndices, []int64{}) { + t.Errorf("state.EditedIndices should be empty but got %v", s.EditedIndices) + } + + expected := []byte(strings.Repeat("\x00", height*width)) + if !reflect.DeepEqual(s.Bytes, expected) { + t.Errorf("s.Bytes should be %q but got %q", expected, s.Bytes) + } + + window.scrollDown(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } +} + +func TestWindowCursorMotions(t *testing.T) { + r := strings.NewReader(strings.Repeat("Hello, world!", 100)) + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorDown(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorDown(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 2; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorUp(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorDown(10) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 11; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 2; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + if expected := " world!"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.cursorRight(mode.Normal, 3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width)*11 + 3; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorRight(mode.Normal, 20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width)*12 - 1; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorLeft(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width)*12 - 4; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorLeft(20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 11; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorPrev(154) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(22); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if s.Offset != int64(width) { + t.Errorf("s.Offset should be %d but got %d", width, s.Offset) + } + + window.cursorNext(mode.Normal, 200) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(222); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 4; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorHead(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1296); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorEnd(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorUp(20) + window.cursorEnd(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(991); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 61; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorEnd(11) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1151); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 62; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(30) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorPrev(2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 81; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorRight(mode.Normal, 1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorUp(2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowTop(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowTop(7) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(96); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowTop(20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(144); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowMiddle() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(64); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowBottom(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(144); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowBottom(7) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(48); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowBottom(20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorGotoPos(event.Absolute{Offset: 0}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 50}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(50); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 100}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(100); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Relative{Offset: -10}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(90); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 30}, "%") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(390); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Relative{Offset: 30}, "%") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(780); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.End{Offset: -30}, "%") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(909); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 30}, "go[to]") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(480); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Relative{Offset: 30}, "go[to]") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(960); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.End{Offset: -30}, "go[to]") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(819); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowScreenMotions(t *testing.T) { + r := strings.NewReader(strings.Repeat("Hello, world!", 100)) + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.pageDown() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(128); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(128); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageDownHalf() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(208); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(208); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollDown(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(224); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(224); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollUp(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(224); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(208); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollDown(30) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(688); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(688); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollUp(30) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(352); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(208); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageUpHalf() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(272); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(128); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageUp() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageEnd() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1296); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageTop() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 5) + window.scrollTop(5) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(85); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(80); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(4) + window.scrollTop(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(149); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(144); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollTopHead(10) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(160); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(160); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 5) + window.scrollMiddle(12) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(197); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(112); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollMiddleHead(15) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(240); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(160); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 5) + window.scrollBottom(12) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(197); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(48); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(8) + window.scrollBottom(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(325); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(176); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollBottomHead(10) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(160); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(16); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } +} + +func TestWindowDeleteBytes(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.deleteBytes(0) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, d!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, \x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(6); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteByte() + window.deleteByte() + window.deleteByte() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hell\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteBytes(0) + window.deleteBytes(0) + window.deleteBytes(0) + window.deleteBytes(0) + window.deleteBytes(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } +} + +func TestWindowDeletePrevBytes(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 5) + window.deletePrevBytes(0) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hell, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(4); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deletePrevBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "H, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deletePrevBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ", world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowIncrementDecrement(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.increment(0) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Iello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.increment(1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "1ello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.increment(math.MaxInt64) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "0ello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.decrement(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "/ello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.decrement(1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Gello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.decrement(math.MaxInt64) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.cursorNext(mode.Normal, 7) + window.increment(1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, _orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } +} + +func TestWindowIncrementDecrementEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := 0; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window.increment(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x01\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := 1; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window, err = newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.decrement(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\xff\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := 1; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } +} + +func TestWindowInsertByte(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 1 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.startInsert() + + window.insertByte(mode.Insert, 0x04) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := true; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x40'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + window.insertByte(mode.Insert, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, Jworld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(14); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window.exitInsert() + window.startAppendEnd() + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0b) + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0c) + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0d) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "M\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(18); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(16); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } +} + +func TestWindowInsertEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startInsert() + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0a) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "J\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(2); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "J\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowInsertHead(t *testing.T) { + r := strings.NewReader(strings.Repeat("Hello, world!", 2)) + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.pageEnd() + window.startInsertHead() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(16); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!Hel:lo, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(27); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(17); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowInsertHeadEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startInsertHead() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0a) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "J\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowAppend(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.startAppend() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(8); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0a) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, w:orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(14); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(8); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorNext(mode.Normal, 10) + window.startAppend() + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0A) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, w:orld!:\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(15); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(14); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowAppendEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startAppend() + window.exitInsert() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.startAppend() + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0a) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.startAppendEnd() + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0b) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":;\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(2); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplaceByte(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.startReplaceByte() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, :orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(13); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplaceByteEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startReplaceByte() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplace(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 10) + window.startReplace() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(10); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, wor:d!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(13); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(11); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0b) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0c) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0d) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0e) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, wor:;<=>\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(15); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(14); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplaceEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startReplace() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0b) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":;\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(2); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowInsertByte2(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startInsert() + window.insertByte(mode.Insert, 0x00) + window.insertByte(mode.Insert, 0x01) + window.insertByte(mode.Insert, 0x02) + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x05) + window.insertByte(mode.Insert, 0x06) + window.insertByte(mode.Insert, 0x07) + window.insertByte(mode.Insert, 0x08) + window.insertByte(mode.Insert, 0x09) + window.insertByte(mode.Insert, 0x0a) + window.insertByte(mode.Insert, 0x0b) + window.insertByte(mode.Insert, 0x0c) + window.insertByte(mode.Insert, 0x0d) + window.insertByte(mode.Insert, 0x0e) + window.insertByte(mode.Insert, 0x0f) + window.exitInsert() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x01\x23\x45\x67\x89\xab\xcd\xef\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } +} + +func TestWindowBackspace(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 5) + window.startInsert() + window.backspace(mode.Insert) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hell, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + window.backspace(mode.Insert) + window.backspace(mode.Insert) + window.backspace(mode.Insert) + window.backspace(mode.Insert) + window.backspace(mode.Insert) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ", world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } +} + +func TestWindowBackspacePending(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 5) + window.startInsert() + window.insertByte(mode.Insert, 0x03) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := true; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x30'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + window.backspace(mode.Insert) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } +} + +func TestWindowEventRune(t *testing.T) { + width, height := 16, 10 + redrawCh := make(chan struct{}) + window, err := newWindow(strings.NewReader(""), "test", "test", nil, redrawCh) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + str := "48723fffab" + go func() { + defer close(redrawCh) + window.emit(event.Event{Type: event.StartInsert}) + for _, r := range str { + window.emit(event.Event{Type: event.Rune, Rune: r, Mode: mode.Insert}) + } + }() + <-redrawCh + for range str { + <-redrawCh + } + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x48\x72\x3f\xff\xab\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + <-redrawCh +} + +func TestWindowEventRuneText(t *testing.T) { + width, height := 16, 10 + redrawCh := make(chan struct{}) + window, err := newWindow(strings.NewReader(""), "test", "test", nil, redrawCh) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + str := "Hello, World!\nこんにちは、世界!\n鰰は魚の一種" + go func() { + defer close(redrawCh) + window.emit(event.Event{Type: event.SwitchFocus}) + window.emit(event.Event{Type: event.StartInsert}) + for _, r := range str { + window.emit(event.Event{Type: event.Rune, Rune: r, Mode: mode.Insert}) + } + }() + <-redrawCh + <-redrawCh + for range str { + <-redrawCh + } + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := str + "\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + <-redrawCh +} + +func TestWindowEventUndoRedo(t *testing.T) { + width, height := 16, 10 + redrawCh, waitCh := make(chan struct{}), make(chan struct{}) + window, err := newWindow(strings.NewReader("Hello, world!"), "test", "test", nil, redrawCh) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + go func() { + defer func() { + close(redrawCh) + close(waitCh) + }() + window.emit(event.Event{Type: event.Undo}) + window.emit(event.Event{Type: event.SwitchFocus}) + window.emit(event.Event{Type: event.StartAppend, Mode: mode.Insert}) + + <-waitCh + window.emit(event.Event{Type: event.Rune, Rune: 'x', Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'y', Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'z', Mode: mode.Insert}) + window.emit(event.Event{Type: event.ExitInsert}) + + <-waitCh + window.emit(event.Event{Type: event.StartInsert, Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'x', Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'y', Mode: mode.Insert}) + window.emit(event.Event{Type: event.CursorLeft, Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'z', Mode: mode.Insert}) + window.emit(event.Event{Type: event.ExitInsert}) + + <-waitCh + window.emit(event.Event{Type: event.Undo, Count: 2}) + window.emit(event.Event{Type: event.StartInsert, Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'w', Mode: mode.Insert}) + + <-waitCh + window.emit(event.Event{Type: event.ExitInsert}) + window.emit(event.Event{Type: event.Undo}) + + <-waitCh + window.emit(event.Event{Type: event.Redo, Count: 2}) + }() + + for range 3 { + <-redrawCh + } + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 4 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxyzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 6 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxyxzyzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(5); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 3 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxywzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(4); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 2 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxyzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + <-redrawCh + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxywzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(4); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + <-redrawCh + <-waitCh +} + +func TestWindowWriteTo(t *testing.T) { + r := strings.NewReader("Hello, world!") + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(20, 10) + window.cursorNext(mode.Normal, 3) + window.startVisual() + window.cursorNext(mode.Normal, 7) + for _, testCase := range []struct { + r *event.Range + expected string + }{ + {nil, "Hello, world!"}, + {&event.Range{From: event.VisualStart{}, To: event.VisualEnd{}}, "lo, worl"}, + } { + b := new(bytes.Buffer) + n, err := window.writeTo(testCase.r, b) + if expected := int64(len(testCase.expected)); n != expected { + t.Errorf("writeTo should return %d but got: %d", expected, n) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if b.String() != testCase.expected { + t.Errorf("window should write %q with range %+v but got %q", testCase.expected, testCase.r, b.String()) + } + } +} diff --git a/gdu/.gitignore b/gdu/.gitignore new file mode 100644 index 0000000..0e51ce0 --- /dev/null +++ b/gdu/.gitignore @@ -0,0 +1,6 @@ +/.vscode +/.idea +/coverage.txt +/dist +/test_dir +/vendor \ No newline at end of file diff --git a/gdu/.golangci.yml b/gdu/.golangci.yml new file mode 100644 index 0000000..e5e9a87 --- /dev/null +++ b/gdu/.golangci.yml @@ -0,0 +1,122 @@ +linters-settings: + errcheck: + check-blank: true + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + # While we agree with this rule, right now it would break too many + # projects. So, we disable it by default. + - name: unused-parameter + disabled: true + gocyclo: + min-complexity: 25 + dupl: + threshold: 100 + goconst: + min-len: 3 + min-occurrences: 3 + lll: + line-length: 160 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - whyNoLint + funlen: + lines: 500 + statements: 50 + govet: + enable: + - shadow + +linters: + disable-all: true + enable: + - bodyclose + - dogsled + - errcheck + - errorlint + - exhaustive + - exportloopref + - funlen + - goconst + - gocritic + - gocyclo + - gofmt + - goimports + - revive + - gosimple + - govet + - ineffassign + - lll + - nakedret + - staticcheck + - typecheck + - unparam + - unused + - whitespace + +issues: + exclude: + # We allow error shadowing + - 'declaration of "err" shadows declaration at' + + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - errcheck + - gosec + - funlen + - gocritic + - gochecknoglobals # Globals in test files are tolerated. + - goconst # Repeated consts in test files are tolerated. + # This rule is buggy and breaks on our `///Block` lines. Disable for now. + - linters: + - gocritic + text: "commentFormatting: put a space" + # This rule incorrectly flags nil references after assert.Assert(t, x != nil) + - path: _test\.go + text: "SA5011" + linters: + - staticcheck + - linters: + - lll + source: "^//go:generate " + - linters: + - lll + - gocritic + path: \.resolvers\.go + source: '^func \(r \*[a-zA-Z]+Resolvers\) ' + +output: + formats: + - format: colored-line-number + sort-results: true diff --git a/gdu/.tool-versions b/gdu/.tool-versions new file mode 100644 index 0000000..e0217ba --- /dev/null +++ b/gdu/.tool-versions @@ -0,0 +1 @@ +golang 1.23.3 diff --git a/gdu/Dockerfile b/gdu/Dockerfile new file mode 100644 index 0000000..07b7d42 --- /dev/null +++ b/gdu/Dockerfile @@ -0,0 +1,15 @@ +FROM docker.io/library/golang:1.23 as builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN make build-static + +FROM scratch + +COPY --from=builder /app/dist/gdu /opt/gdu + +ENTRYPOINT ["/opt/gdu"] \ No newline at end of file diff --git a/gdu/INSTALL.md b/gdu/INSTALL.md new file mode 100644 index 0000000..27c026d --- /dev/null +++ b/gdu/INSTALL.md @@ -0,0 +1,142 @@ +# Installation + +[Arch Linux](https://archlinux.org/packages/extra/x86_64/gdu/): + + pacman -S gdu + +[Debian](https://packages.debian.org/bullseye/gdu): + + apt install gdu + +[Ubuntu](https://launchpad.net/~daniel-milde/+archive/ubuntu/gdu) + + add-apt-repository ppa:daniel-milde/gdu + apt-get update + apt-get install gdu + +[NixOS](https://search.nixos.org/packages?channel=unstable&show=gdu&query=gdu): + + nix-env -iA nixos.gdu + +[Homebrew](https://formulae.brew.sh/formula/gdu): + + brew install -f gdu + # gdu will be installed as `gdu-go` to avoid conflicts with coreutils + gdu-go + +[Snap](https://snapcraft.io/gdu-disk-usage-analyzer): + + snap install gdu-disk-usage-analyzer + snap connect gdu-disk-usage-analyzer:mount-observe :mount-observe + snap connect gdu-disk-usage-analyzer:system-backup :system-backup + snap alias gdu-disk-usage-analyzer.gdu gdu + +[Binenv](https://github.com/devops-works/binenv) + + binenv install gdu + +[Go](https://pkg.go.dev/github.com/dundee/gdu): + + go install b612.me/apps/b612/gdu/cmd/gdu@latest + +[Winget](https://github.com/microsoft/winget-pkgs/tree/master/manifests/d/dundee/gdu) (for Windows users): + + winget install gdu + +You can either run it as `gdu_windows_amd64.exe` or +* add an alias with `Doskey`. +* add `alias gdu="gdu_windows_amd64.exe"` to your `~/.bashrc` file if using Git Bash to run it as `gdu`. + +You might need to restart your terminal. + +[Scoop](https://github.com/ScoopInstaller/Main/blob/master/bucket/gdu.json): + + scoop install gdu + +[X-cmd](https://www.x-cmd.com/start/) + + x env use gdu + +## [COPR builds](https://copr.fedorainfracloud.org/coprs/faramirza/gdu/) +COPR Builds exist for the the following Linux Distros. + +[How to enable a CORP Repo](https://docs.pagure.org/copr.copr/how_to_enable_repo.html) + +Amazon Linux 2023: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/amazonlinux-2023-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 7: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-7-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 8: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-8-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 9: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-9-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +Fedora 38: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +Fedora 39: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` diff --git a/gdu/LICENSE.md b/gdu/LICENSE.md new file mode 100644 index 0000000..3d3b99f --- /dev/null +++ b/gdu/LICENSE.md @@ -0,0 +1,8 @@ +Copyright 2020-2021 Daniel Milde + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/gdu/Makefile b/gdu/Makefile new file mode 100644 index 0000000..5699d58 --- /dev/null +++ b/gdu/Makefile @@ -0,0 +1,159 @@ +NAME := gdu +MAJOR_VER := v5 +PACKAGE := github.com/dundee/$(NAME)/$(MAJOR_VER) +CMD_GDU := cmd/gdu +VERSION := $(shell git describe --tags 2>/dev/null) +NAMEVER := $(NAME)-$(subst v,,$(VERSION)) +DATE := $(shell date +'%Y-%m-%d') +GOFLAGS ?= -buildmode=pie -trimpath -mod=readonly -modcacherw -pgo=default.pgo +GOFLAGS_STATIC ?= -trimpath -mod=readonly -modcacherw -pgo=default.pgo +LDFLAGS := -s -w -extldflags '-static' \ + -X '$(PACKAGE)/build.Version=$(VERSION)' \ + -X '$(PACKAGE)/build.User=$(shell id -u -n)' \ + -X '$(PACKAGE)/build.Time=$(shell LC_ALL=en_US.UTF-8 date)' +TAR := tar +ifeq ($(shell uname -s),Darwin) + TAR := gtar # brew install gnu-tar +endif + +all: clean tarball build-all build-docker man clean-uncompressed-dist shasums + +run: + go run $(PACKAGE)/$(CMD_GDU) + +vendor: go.mod go.sum + go mod vendor + +tarball: vendor + -mkdir dist + $(TAR) czf dist/$(NAMEVER).tgz --transform "s,^,$(NAMEVER)/," --exclude dist --exclude test_dir --exclude coverage.txt * + +build: + @echo "Version: " $(VERSION) + mkdir -p dist + GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) + +build-static: + @echo "Version: " $(VERSION) + mkdir -p dist + GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) + +build-docker: + @echo "Version: " $(VERSION) + docker build . --tag ghcr.io/dundee/gdu:$(VERSION) + +build-all: + @echo "Version: " $(VERSION) + -mkdir dist + -CGO_ENABLED=0 gox \ + -os="darwin" \ + -arch="amd64 arm64" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + -CGO_ENABLED=0 gox \ + -os="windows" \ + -arch="amd64" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + -CGO_ENABLED=0 gox \ + -os="linux freebsd netbsd openbsd" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64-x $(PACKAGE)/$(CMD_GDU) + GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64_static $(PACKAGE)/$(CMD_GDU) + + CGO_ENABLED=0 GOOS=linux GOARM=5 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv5l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv6l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv7l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_arm64 $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_android_arm64 $(PACKAGE)/$(CMD_GDU) + + cd dist; for file in gdu_linux_* gdu_darwin_* gdu_netbsd_* gdu_openbsd_* gdu_freebsd_* gdu_android_*; do tar czf $$file.tgz $$file; done + cd dist; for file in gdu_windows_*; do zip $$file.zip $$file; done + +gdu.1: gdu.1.md + sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md + pandoc gdu.1.date.md -s -t man > gdu.1 + rm -f gdu.1.date.md + +man: gdu.1 + cp gdu.1 dist + cd dist; tar czf gdu.1.tgz gdu.1 + +show-man: + sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md + pandoc gdu.1.date.md -s -t man | man -l - + +test: + gotestsum + +coverage: + gotestsum -- -race -coverprofile=coverage.txt -covermode=atomic ./... + +coverage-html: coverage + go tool cover -html=coverage.txt + +gobench: + go test -bench=. $(PACKAGE)/pkg/analyze + +heap-profile: + go tool pprof -web http://localhost:6060/debug/pprof/heap + +pgo: + wget -O cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30 + go tool pprof -proto cpu.pprof default.pgo > merged.pprof + mv merged.pprof default.pgo + +trace: + wget -O trace.out http://localhost:6060/debug/pprof/trace?seconds=30 + gotraceui ./trace.out + +benchmark: + sudo cpupower frequency-set -g performance + hyperfine --export-markdown=bench-cold.md \ + --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \ + --ignore-failure \ + 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ + 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ + 'gdu -npc ~' 'gdu -gnpc ~' 'gdu -npc --use-storage ~' + hyperfine --export-markdown=bench-warm.md \ + --warmup 5 \ + --ignore-failure \ + 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ + 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ + 'gdu -npc ~' 'gdu -gnpc ~' 'gdu -npc --use-storage ~' + sudo cpupower frequency-set -g schedutil + +lint: + golangci-lint run -c .golangci.yml + +clean: + go mod tidy + -rm coverage.txt + -rm -r test_dir + -rm -r vendor + -rm -r dist + +clean-uncompressed-dist: + find dist -type f -not -name '*.tgz' -not -name '*.zip' -delete + +shasums: + cd dist; sha256sum * > sha256sums.txt + cd dist; gpg --sign --armor --detach-sign sha256sums.txt + +release: + gh release create -t "gdu $(VERSION)" $(VERSION) ./dist/* + +install-dev-dependencies: + go install gotest.tools/gotestsum@latest + go install github.com/mitchellh/gox@latest + go install honnef.co/go/gotraceui/cmd/gotraceui@master + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +.PHONY: run build build-static build-all test gobench benchmark coverage coverage-html clean clean-uncompressed-dist man show-man release diff --git a/gdu/README.md b/gdu/README.md new file mode 100644 index 0000000..db2b90a --- /dev/null +++ b/gdu/README.md @@ -0,0 +1,308 @@ +# go DiskUsage() + +Gdu + +[![Codecov](https://codecov.io/gh/dundee/gdu/branch/master/graph/badge.svg)](https://codecov.io/gh/dundee/gdu) +[![Go Report Card](https://goreportcard.com/badge/github.com/dundee/gdu)](https://goreportcard.com/report/github.com/dundee/gdu) +[![Maintainability](https://api.codeclimate.com/v1/badges/30d793274607f599e658/maintainability)](https://codeclimate.com/github/dundee/gdu/maintainability) +[![CodeScene Code Health](https://codescene.io/projects/13129/status-badges/code-health)](https://codescene.io/projects/13129) + +Pretty fast disk usage analyzer written in Go. + +Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. +However HDDs work as well, but the performance gain is not so huge. + +[![asciicast](https://asciinema.org/a/382738.svg)](https://asciinema.org/a/382738) + + + Packaging status + + +## Installation + +Head for the [releases page](https://github.com/dundee/gdu/releases) and download the binary for your system. + +Using curl: + + curl -L https://github.com/dundee/gdu/releases/latest/download/gdu_linux_amd64.tgz | tar xz + chmod +x gdu_linux_amd64 + mv gdu_linux_amd64 /usr/bin/gdu + +See the [installation page](./INSTALL.md) for other ways how to install Gdu to your system. + +Or you can use Gdu directly via Docker: + + docker run --rm --init --interactive --tty --privileged --volume /:/mnt/root ghcr.io/dundee/gdu /mnt/root + +## Usage + +``` + gdu [flags] [directory_to_scan] + +Flags: + --config-file string Read config from file (default is $HOME/.gdu.yaml) + -g, --const-gc Enable memory garbage collection during analysis with constant level set by GOGC + --enable-profiling Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ + -L, --follow-symlinks Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) + -h, --help help for gdu + -i, --ignore-dirs strings Paths to ignore (separated by comma). Can be absolute or relative to current directory (default [/proc,/dev,/sys,/run]) + -I, --ignore-dirs-pattern strings Path patterns to ignore (separated by comma) + -X, --ignore-from string Read path patterns to ignore from file + -f, --input-file string Import analysis from JSON file + -l, --log-file string Path to a logfile (default "/dev/null") + -m, --max-cores int Set max cores that Gdu will use. 12 cores available (default 12) + -c, --no-color Do not use colorized output + -x, --no-cross Do not cross filesystem boundaries + --no-delete Do not allow deletions + -H, --no-hidden Ignore hidden directories (beginning with dot) + --no-mouse Do not use mouse + --no-prefix Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + -p, --no-progress Do not show progress in non-interactive mode + -u, --no-unicode Do not use Unicode symbols (for size bar) + -n, --non-interactive Do not run in interactive mode + -o, --output-file string Export all info into file as JSON + -r, --read-from-storage Read analysis data from persistent key-value storage + --sequential Use sequential scanning (intended for rotating HDDs) + -a, --show-apparent-size Show apparent size + -d, --show-disks Show all mounted disks + -C, --show-item-count Show number of items in directory + -M, --show-mtime Show latest mtime of items in directory + -B, --show-relative-size Show relative size + --si Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + --storage-path string Path to persistent key-value storage directory (default "/tmp/badger") + -s, --summarize Show only a total in non-interactive mode + -t, --top int Show only top X largest files in non-interactive mode + --use-storage Use persistent key-value storage for analysis data (experimental) + -v, --version Print version + --write-config Write current configuration to file (default is $HOME/.gdu.yaml) + +Basic list of actions in interactive mode (show help modal for more): + ↑ or k Move cursor up + ↓ or j Move cursor down + → or Enter or l Go to highlighted directory + ← or h Go to parent directory + d Delete the selected file or directory + e Empty the selected directory + n Sort by name + s Sort by size + c Show number of items in directory + ? Show help modal +``` + +## Examples + + gdu # analyze current dir + gdu -a # show apparent size instead of disk usage + gdu --no-delete # prevent write operations + gdu # analyze given dir + gdu -d # show all mounted disks + gdu -l ./gdu.log # write errors to log file + gdu -i /sys,/proc / # ignore some paths + gdu -I '.*[abc]+' # ignore paths by regular pattern + gdu -X ignore_file / # ignore paths by regular patterns from file + gdu -c / # use only white/gray/black colors + + gdu -n / # only print stats, do not start interactive mode + gdu -np / # do not show progress, useful when using its output in a script + gdu -nps /some/dir # show only total usage for given dir + gdu -nt 10 / # show top 10 largest files + gdu / > file # write stats to file, do not start interactive mode + + gdu -o- / | gzip -c >report.json.gz # write all info to JSON file for later analysis + zcat report.json.gz | gdu -f- # read analysis from file + + GOGC=10 gdu -g --use-storage / # use persistent key-value storage for saving analysis data + gdu -r / # read saved analysis data from persistent key-value storage + +## Modes + +Gdu has three modes: interactive (default), non-interactive and export. + +Non-interactive mode is started automatically when TTY is not detected (using [go-isatty](https://github.com/mattn/go-isatty)), for example if the output is being piped to a file, or it can be started explicitly by using a flag. + +Export mode (flag `-o`) outputs all usage data as JSON, which can be later opened using the `-f` flag. + +Hard links are counted only once. + +## File flags + +Files and directories may be prefixed by a one-character +flag with following meaning: + +* `!` An error occurred while reading this directory. + +* `.` An error occurred while reading a subdirectory, size may be not correct. + +* `@` File is symlink or socket. + +* `H` Same file was already counted (hard link). + +* `e` Directory is empty. + +## Configuration file + +Gdu can read (and write) YAML configuration file. + +`$HOME/.config/gdu/gdu.yaml` and `$HOME/.gdu.yaml` are checked for the presence of the config file by default. + +See the [full list of all configuration options](configuration). + +### Examples + +* To configure gdu to permanently run in gray-scale color mode: + +``` +echo "no-color: true" >> ~/.gdu.yaml +``` + +* To set default sorting in configuration file: + +``` +sorting: + by: name // size, name, itemCount, mtime + order: desc +``` + +* To configure gdu to set CWD variable when browsing directories: + +``` +echo "change-cwd: true" >> ~/.gdu.yaml +``` + +* To save the current configuration + +``` +gdu --write-config +``` + +## Styling + +There are wide options for how terminals can be colored. +Some gdu primitives (like basic text) adapt to different color schemas, but the selected/highlighted row does not. + +If the default look is not sufficient, it can be changed in configuration file, e.g.: + +``` +style: + selected-row: + text-color: black + background-color: "#ff0000" +``` + +## Deletion in background and in parallel (experimental) + +Gdu can delete items in the background, thus not blocking the UI for additional work. +To enable: + +``` +echo "delete-in-background: true" >> ~/.gdu.yaml +``` + +Directory items can be also deleted in parallel, which might increase the speed of deletion. +To enable: + +``` +echo "delete-in-parallel: true" >> ~/.gdu.yaml +``` + +## Memory usage + +### Automatic balancing + +Gdu tries to balance performance and memory usage. + +When less memory is used by gdu than the total free memory of the host, +then Garbage Collection is disabled during the analysis phase completely to gain maximum speed. + +Otherwise GC is enabled. +The more memory is used and the less memory is free, the more often will the GC happen. + +### Manual memory usage control + +If you want manual control over Garbage Collection, you can use `--const-gc` / `-g` flag. +It will run Garbage Collection during the analysis phase with constant level of aggressiveness. +As a result, the analysis will be about 25% slower and will consume about 30% less memory. +To change the level, you can set the `GOGC` environment variable to specify how often the garbage collection will happen. +Lower value (than 100) means GC will run more often. Higher means less often. Negative number will stop GC. + +Example running gdu with constant GC, but not so aggressive as default: + +``` +GOGC=200 gdu -g / +``` + +## Saving analysis data to persistent key-value storage (experimental) + +Gdu can store the analysis data to persistent key-value storage instead of just memory. +Gdu will run much slower (approx 10x) but it should use much less memory (when using small GOGC as well). +Gdu can also reopen with the saved data. +Currently only BadgerDB is supported as the key-value storage (embedded). + +``` +GOGC=10 gdu -g --use-storage / # saves analysis data to key-value storage +gdu -r / # reads just saved data, does not run analysis again +``` + +## Running tests + + make install-dev-dependencies + make test + +## Profiling + +Gdu can collect profiling data when the `--enable-profiling` flag is set. +The data are provided via embedded http server on URL `http://localhost:6060/debug/pprof/`. + +You can then use e.g. `go tool pprof -web http://localhost:6060/debug/pprof/heap` +to open the heap profile as SVG image in your web browser. + +## Benchmarks + +Benchmarks were performed on 50G directory (100k directories, 400k files) on 500 GB SSD using [hyperfine](https://github.com/sharkdp/hyperfine). +See `benchmark` target in [Makefile](Makefile) for more info. + +### Cold cache + +Filesystem cache was cleared using `sync; echo 3 | sudo tee /proc/sys/vm/drop_caches`. + +| Command | Mean [s] | Min [s] | Max [s] | Relative | +|:---|---:|---:|---:|---:| +| `diskus ~` | 3.126 ± 0.020 | 3.087 | 3.155 | 1.00 | +| `gdu -npc ~` | 3.132 ± 0.019 | 3.111 | 3.173 | 1.00 ± 0.01 | +| `gdu -gnpc ~` | 3.136 ± 0.012 | 3.112 | 3.155 | 1.00 ± 0.01 | +| `pdu ~` | 3.657 ± 0.013 | 3.641 | 3.677 | 1.17 ± 0.01 | +| `dust -d0 ~` | 3.933 ± 0.144 | 3.849 | 4.213 | 1.26 ± 0.05 | +| `dua ~` | 3.994 ± 0.073 | 3.827 | 4.134 | 1.28 ± 0.02 | +| `gdu -npc --use-storage ~` | 12.812 ± 0.078 | 12.644 | 12.912 | 4.10 ± 0.04 | +| `du -hs ~` | 14.120 ± 0.213 | 13.969 | 14.703 | 4.52 ± 0.07 | +| `duc index ~` | 14.567 ± 0.080 | 14.385 | 14.657 | 4.66 ± 0.04 | +| `ncdu -0 -o /dev/null ~` | 14.963 ± 0.254 | 14.759 | 15.637 | 4.79 ± 0.09 | + +### Warm cache + +| Command | Mean [ms] | Min [ms] | Max [ms] | Relative | +|:---|---:|---:|---:|---:| +| `pdu ~` | 226.6 ± 3.7 | 219.6 | 231.2 | 1.00 | +| `diskus ~` | 227.7 ± 5.2 | 221.6 | 239.9 | 1.00 ± 0.03 | +| `dust -d0 ~` | 400.1 ± 7.1 | 386.7 | 409.4 | 1.77 ± 0.04 | +| `dua ~` | 444.9 ± 2.4 | 442.4 | 448.9 | 1.96 ± 0.03 | +| `gdu -npc ~` | 451.3 ± 3.8 | 445.9 | 458.5 | 1.99 ± 0.04 | +| `gdu -gnpc ~` | 516.1 ± 6.7 | 503.1 | 527.5 | 2.28 ± 0.05 | +| `du -hs ~` | 905.0 ± 3.9 | 901.2 | 913.4 | 3.99 ± 0.07 | +| `duc index ~` | 1053.0 ± 5.1 | 1046.2 | 1064.1 | 4.65 ± 0.08 | +| `ncdu -0 -o /dev/null ~` | 1653.9 ± 5.7 | 1645.9 | 1663.0 | 7.30 ± 0.12 | +| `gdu -npc --use-storage ~` | 9754.9 ± 688.7 | 8403.8 | 10427.4 | 43.04 ± 3.12 | + +## Alternatives + +* [ncdu](https://dev.yorhel.nl/ncdu) - NCurses based tool written in pure `C` (LTS) or `zig` (Stable) +* [godu](https://github.com/viktomas/godu) - Analyzer with a carousel like user interface +* [dua](https://github.com/Byron/dua-cli) - Tool written in `Rust` with interface similar to gdu (and ncdu) +* [diskus](https://github.com/sharkdp/diskus) - Very simple but very fast tool written in `Rust` +* [duc](https://duc.zevv.nl/) - Collection of tools with many possibilities for inspecting and visualising disk usage +* [dust](https://github.com/bootandy/dust) - Tool written in `Rust` showing tree like structures of disk usage +* [pdu](https://github.com/KSXGitHub/parallel-disk-usage) - Tool written in `Rust` showing tree like structures of disk usage + +## Notes + +[HDD icon created by Nikita Golubev - Flaticon](https://www.flaticon.com/free-icons/hdd) \ No newline at end of file diff --git a/gdu/build/build.go b/gdu/build/build.go new file mode 100644 index 0000000..0fa5725 --- /dev/null +++ b/gdu/build/build.go @@ -0,0 +1,16 @@ +package build + +import "b612.me/apps/b612/version" + +// Version stores the current version of the app +var Version = version.Version + +// Time of the build +var Time string + +// User who built it +var User string + +// RootPathPrefix stores path to be prepended to given absolute path +// e.g. /var/lib/snapd/hostfs for snap +var RootPathPrefix = "" diff --git a/gdu/cmd/gdu/app/app.go b/gdu/cmd/gdu/app/app.go new file mode 100644 index 0000000..9aa7fb7 --- /dev/null +++ b/gdu/cmd/gdu/app/app.go @@ -0,0 +1,473 @@ +package app + +import ( + "fmt" + "io" + "io/fs" + "net/http" + "net/http/pprof" + "os" + "path/filepath" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/build" + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + gfs "b612.me/apps/b612/gdu/pkg/fs" + "b612.me/apps/b612/gdu/report" + "b612.me/apps/b612/gdu/stdout" + "b612.me/apps/b612/gdu/tui" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// UI is common interface for both terminal UI and text output +type UI interface { + ListDevices(getter device.DevicesInfoGetter) error + AnalyzePath(path string, parentDir gfs.Item) error + ReadAnalysis(input io.Reader) error + ReadFromStorage(storagePath, path string) error + SetIgnoreDirPaths(paths []string) + SetIgnoreDirPatterns(paths []string) error + SetIgnoreFromFile(ignoreFile string) error + SetIgnoreHidden(value bool) + SetFollowSymlinks(value bool) + SetShowAnnexedSize(value bool) + SetAnalyzer(analyzer common.Analyzer) + StartUILoop() error +} + +// Flags define flags accepted by Run +type Flags struct { + CfgFile string `yaml:"-"` + LogFile string `yaml:"log-file"` + InputFile string `yaml:"input-file"` + OutputFile string `yaml:"output-file"` + IgnoreDirs []string `yaml:"ignore-dirs"` + IgnoreDirPatterns []string `yaml:"ignore-dir-patterns"` + IgnoreFromFile string `yaml:"ignore-from-file"` + MaxCores int `yaml:"max-cores"` + SequentialScanning bool `yaml:"sequential-scanning"` + ShowDisks bool `yaml:"-"` + ShowApparentSize bool `yaml:"show-apparent-size"` + ShowRelativeSize bool `yaml:"show-relative-size"` + ShowAnnexedSize bool `yaml:"show-annexed-size"` + ShowVersion bool `yaml:"-"` + ShowItemCount bool `yaml:"show-item-count"` + ShowMTime bool `yaml:"show-mtime"` + NoColor bool `yaml:"no-color"` + NoMouse bool `yaml:"no-mouse"` + NonInteractive bool `yaml:"non-interactive"` + NoProgress bool `yaml:"no-progress"` + NoUnicode bool `yaml:"no-unicode"` + NoCross bool `yaml:"no-cross"` + NoHidden bool `yaml:"no-hidden"` + NoDelete bool `yaml:"no-delete"` + FollowSymlinks bool `yaml:"follow-symlinks"` + Profiling bool `yaml:"profiling"` + ConstGC bool `yaml:"const-gc"` + UseStorage bool `yaml:"use-storage"` + StoragePath string `yaml:"storage-path"` + ReadFromStorage bool `yaml:"read-from-storage"` + Summarize bool `yaml:"summarize"` + Top int `yaml:"top"` + UseSIPrefix bool `yaml:"use-si-prefix"` + NoPrefix bool `yaml:"no-prefix"` + WriteConfig bool `yaml:"-"` + ChangeCwd bool `yaml:"change-cwd"` + DeleteInBackground bool `yaml:"delete-in-background"` + DeleteInParallel bool `yaml:"delete-in-parallel"` + Style Style `yaml:"style"` + Sorting Sorting `yaml:"sorting"` +} + +// Style define style config +type Style struct { + SelectedRow ColorStyle `yaml:"selected-row"` + ProgressModal ProgressModalOpts `yaml:"progress-modal"` + UseOldSizeBar bool `yaml:"use-old-size-bar"` + Footer FooterColorStyle `yaml:"footer"` + Header HeaderColorStyle `yaml:"header"` + ResultRow ResultRowColorStyle `yaml:"result-row"` +} + +// ProgressModalOpts defines options for progress modal +type ProgressModalOpts struct { + CurrentItemNameMaxLen int `yaml:"current-item-path-max-len"` +} + +// ColorStyle defines styling of some item +type ColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` +} + +// FooterColorStyle defines styling of footer +type FooterColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` + NumberColor string `yaml:"number-color"` +} + +// HeaderColorStyle defines styling of header +type HeaderColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` + Hidden bool `yaml:"hidden"` +} + +// ResultRowColorStyle defines styling of result row +type ResultRowColorStyle struct { + NumberColor string `yaml:"number-color"` + DirectoryColor string `yaml:"directory-color"` +} + +// Sorting defines default sorting of items +type Sorting struct { + By string `yaml:"by"` + Order string `yaml:"order"` +} + +// App defines the main application +type App struct { + Args []string + Flags *Flags + Istty bool + Writer io.Writer + TermApp common.TermApplication + Screen tcell.Screen + Getter device.DevicesInfoGetter + PathChecker func(string) (fs.FileInfo, error) +} + +func init() { + http.DefaultServeMux = http.NewServeMux() +} + +// Run starts gdu main logic +func (a *App) Run() error { + var ui UI + + if a.Flags.ShowVersion { + fmt.Fprintln(a.Writer, "Version:\t", build.Version) + fmt.Fprintln(a.Writer, "Built time:\t", build.Time) + fmt.Fprintln(a.Writer, "Built user:\t", build.User) + return nil + } + + log.Printf("Runtime flags: %+v", *a.Flags) + + if a.Flags.NoPrefix && a.Flags.UseSIPrefix { + return fmt.Errorf("--no-prefix and --si cannot be used at once") + } + + path := a.getPath() + path, err := filepath.Abs(path) + if err != nil { + return err + } + + ui, err = a.createUI() + if err != nil { + return err + } + + if a.Flags.UseStorage { + ui.SetAnalyzer(analyze.CreateStoredAnalyzer(a.Flags.StoragePath)) + } + if a.Flags.SequentialScanning { + ui.SetAnalyzer(analyze.CreateSeqAnalyzer()) + } + if a.Flags.FollowSymlinks { + ui.SetFollowSymlinks(true) + } + if a.Flags.ShowAnnexedSize { + ui.SetShowAnnexedSize(true) + } + if err := a.setNoCross(path); err != nil { + return err + } + + ui.SetIgnoreDirPaths(a.Flags.IgnoreDirs) + + if len(a.Flags.IgnoreDirPatterns) > 0 { + if err := ui.SetIgnoreDirPatterns(a.Flags.IgnoreDirPatterns); err != nil { + return err + } + } + + if a.Flags.IgnoreFromFile != "" { + if err := ui.SetIgnoreFromFile(a.Flags.IgnoreFromFile); err != nil { + return err + } + } + + if a.Flags.NoHidden { + ui.SetIgnoreHidden(true) + } + + a.setMaxProcs() + + if err := a.runAction(ui, path); err != nil { + return err + } + + return ui.StartUILoop() +} + +func (a *App) getPath() string { + if len(a.Args) == 1 { + return a.Args[0] + } + return "." +} + +func (a *App) setMaxProcs() { + if a.Flags.MaxCores < 1 || a.Flags.MaxCores > runtime.NumCPU() { + return + } + + runtime.GOMAXPROCS(a.Flags.MaxCores) + + // runtime.GOMAXPROCS(n) with n < 1 doesn't change current setting so we use it to check current value + log.Printf("Max cores set to %d", runtime.GOMAXPROCS(0)) +} + +func (a *App) createUI() (UI, error) { + var ui UI + + switch { + case a.Flags.OutputFile != "": + var output io.Writer + var err error + if a.Flags.OutputFile == "-" { + output = os.Stdout + } else { + output, err = os.OpenFile(a.Flags.OutputFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return nil, fmt.Errorf("opening output file: %w", err) + } + } + ui = report.CreateExportUI( + a.Writer, + output, + !a.Flags.NoColor && a.Istty, + !a.Flags.NoProgress && a.Istty, + a.Flags.ConstGC, + a.Flags.UseSIPrefix, + ) + case a.Flags.NonInteractive || !a.Istty: + stdoutUI := stdout.CreateStdoutUI( + a.Writer, + !a.Flags.NoColor && a.Istty, + !a.Flags.NoProgress && a.Istty, + a.Flags.ShowApparentSize, + a.Flags.ShowRelativeSize, + a.Flags.Summarize, + a.Flags.ConstGC, + a.Flags.UseSIPrefix, + a.Flags.NoPrefix, + a.Flags.Top, + ) + if a.Flags.NoUnicode { + stdoutUI.UseOldProgressRunes() + } + ui = stdoutUI + default: + opts := a.getOptions() + + ui = tui.CreateUI( + a.TermApp, + a.Screen, + os.Stdout, + !a.Flags.NoColor, + a.Flags.ShowApparentSize, + a.Flags.ShowRelativeSize, + a.Flags.ConstGC, + a.Flags.UseSIPrefix, + opts..., + ) + + if !a.Flags.NoColor { + tview.Styles.TitleColor = tcell.NewRGBColor(27, 161, 227) + } else { + tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(150, 150, 150) + } + tview.Styles.BorderColor = tcell.ColorDefault + } + + return ui, nil +} + +func (a *App) getOptions() []tui.Option { + var opts []tui.Option + + if a.Flags.Style.SelectedRow.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetSelectedTextColor(tcell.GetColor(a.Flags.Style.SelectedRow.TextColor)) + }) + } + if a.Flags.Style.SelectedRow.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetSelectedBackgroundColor(tcell.GetColor(a.Flags.Style.SelectedRow.BackgroundColor)) + }) + } + if a.Flags.Style.Footer.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterTextColor(a.Flags.Style.Footer.TextColor) + }) + } + if a.Flags.Style.Footer.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterBackgroundColor(a.Flags.Style.Footer.BackgroundColor) + }) + } + if a.Flags.Style.Footer.NumberColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterNumberColor(a.Flags.Style.Footer.NumberColor) + }) + } + if a.Flags.Style.Header.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderTextColor(a.Flags.Style.Header.TextColor) + }) + } + if a.Flags.Style.Header.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderBackgroundColor(a.Flags.Style.Header.BackgroundColor) + }) + } + if a.Flags.Style.Header.Hidden { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderHidden() + }) + } + if a.Flags.Style.ResultRow.NumberColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetResultRowNumberColor(a.Flags.Style.ResultRow.NumberColor) + }) + } + if a.Flags.Style.ResultRow.DirectoryColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetResultRowDirectoryColor(a.Flags.Style.ResultRow.DirectoryColor) + }) + } + if a.Flags.Style.ProgressModal.CurrentItemNameMaxLen > 0 { + opts = append(opts, func(ui *tui.UI) { + ui.SetCurrentItemNameMaxLen(a.Flags.Style.ProgressModal.CurrentItemNameMaxLen) + }) + } + if a.Flags.Style.UseOldSizeBar || a.Flags.NoUnicode { + opts = append(opts, func(ui *tui.UI) { + ui.UseOldSizeBar() + }) + } + if a.Flags.Sorting.Order != "" || a.Flags.Sorting.By != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetDefaultSorting(a.Flags.Sorting.By, a.Flags.Sorting.Order) + }) + } + if a.Flags.ChangeCwd { + opts = append(opts, func(ui *tui.UI) { + ui.SetChangeCwdFn(os.Chdir) + }) + } + if a.Flags.ShowItemCount { + opts = append(opts, func(ui *tui.UI) { + ui.SetShowItemCount() + }) + } + if a.Flags.ShowMTime { + opts = append(opts, func(ui *tui.UI) { + ui.SetShowMTime() + }) + } + if a.Flags.NoDelete { + opts = append(opts, func(ui *tui.UI) { + ui.SetNoDelete() + }) + } + if a.Flags.DeleteInBackground { + opts = append(opts, func(ui *tui.UI) { + ui.SetDeleteInBackground() + }) + } + if a.Flags.DeleteInParallel { + opts = append(opts, func(ui *tui.UI) { + ui.SetDeleteInParallel() + }) + } + return opts +} + +func (a *App) setNoCross(path string) error { + if a.Flags.NoCross { + mounts, err := a.Getter.GetMounts() + if err != nil { + return fmt.Errorf("loading mount points: %w", err) + } + paths := device.GetNestedMountpointsPaths(path, mounts) + log.Printf("Ignoring mount points: %s", strings.Join(paths, ", ")) + a.Flags.IgnoreDirs = append(a.Flags.IgnoreDirs, paths...) + } + return nil +} + +func (a *App) runAction(ui UI, path string) error { + if a.Flags.Profiling { + go func() { + http.HandleFunc("/debug/pprof/", pprof.Index) + http.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + http.HandleFunc("/debug/pprof/profile", pprof.Profile) + http.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + http.HandleFunc("/debug/pprof/trace", pprof.Trace) + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + } + + switch { + case a.Flags.ShowDisks: + if err := ui.ListDevices(a.Getter); err != nil { + return fmt.Errorf("loading mount points: %w", err) + } + case a.Flags.InputFile != "": + var input io.Reader + var err error + if a.Flags.InputFile == "-" { + input = os.Stdin + } else { + input, err = os.OpenFile(a.Flags.InputFile, os.O_RDONLY, 0o600) + if err != nil { + return fmt.Errorf("opening input file: %w", err) + } + } + + if err := ui.ReadAnalysis(input); err != nil { + return fmt.Errorf("reading analysis: %w", err) + } + case a.Flags.ReadFromStorage: + ui.SetAnalyzer(analyze.CreateStoredAnalyzer(a.Flags.StoragePath)) + if err := ui.ReadFromStorage(a.Flags.StoragePath, path); err != nil { + return fmt.Errorf("reading from storage (%s): %w", a.Flags.StoragePath, err) + } + default: + if build.RootPathPrefix != "" { + path = build.RootPathPrefix + path + } + + _, err := a.PathChecker(path) + if err != nil { + return err + } + + log.Printf("Analyzing path: %s", path) + if err := ui.AnalyzePath(path, nil); err != nil { + return fmt.Errorf("scanning dir: %w", err) + } + } + return nil +} diff --git a/gdu/cmd/gdu/app/app_linux_test.go b/gdu/cmd/gdu/app/app_linux_test.go new file mode 100644 index 0000000..e1e4826 --- /dev/null +++ b/gdu/cmd/gdu/app/app_linux_test.go @@ -0,0 +1,123 @@ +//go:build linux +// +build linux + +package app + +import ( + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testdev" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func TestNoCrossWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoCross: true}, + []string{"test_dir"}, + false, + device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, + ) + + assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) + assert.Empty(t, out) +} + +func TestListDevicesWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + _, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + false, + device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, + ) + + assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) +} + +func TestOutputFileError(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", OutputFile: "/xyzxyz"}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestUseStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + const storagePath = "/tmp/badger-test" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", UseStorage: true, StoragePath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestReadFromStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + storagePath := "/tmp/badger-test4" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", UseStorage: true, StoragePath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + assert.Contains(t, out, "nested") + assert.Nil(t, err) + + out, err = runApp( + &Flags{LogFile: "/dev/null", ReadFromStorage: true, StoragePath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestReadFromStorageWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + _, err := runApp( + &Flags{LogFile: "/dev/null", ReadFromStorage: true, StoragePath: "/tmp/badger-xxx"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.ErrorContains(t, err, "Key not found") +} diff --git a/gdu/cmd/gdu/app/app_test.go b/gdu/cmd/gdu/app/app_test.go new file mode 100644 index 0000000..6184e4f --- /dev/null +++ b/gdu/cmd/gdu/app/app_test.go @@ -0,0 +1,566 @@ +package app + +import ( + "bytes" + "os" + "runtime" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/internal/testdev" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestVersion(t *testing.T) { + out, err := runApp( + &Flags{ShowVersion: true}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "Version:\t development") + assert.Nil(t, err) +} + +func TestAnalyzePath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestSequentialScanning(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", SequentialScanning: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestFollowSymlinks(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", FollowSymlinks: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestShowAnnexedSize(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowAnnexedSize: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathProfiling(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", Profiling: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithIgnoring(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreDirPatterns: []string{"/[abc]+"}, + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithIgnoringPatternError(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreDirPatterns: []string{"[[["}, + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, out, "") + assert.NotNil(t, err) +} + +func TestAnalyzePathWithIgnoringFromNotExistingFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreFromFile: "file", + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, out, "") + assert.NotNil(t, err) +} + +func TestAnalyzePathWithGui(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null"}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithGuiNoColor(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoColor: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiShowMTimeAndItemCount(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowItemCount: true, ShowMTime: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiNoDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoDelete: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiDeleteInParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DeleteInParallel: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithGuiBackgroundDeletion(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DeleteInBackground: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithDefaultSorting(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + Sorting: Sorting{ + By: "name", + Order: "asc", + }, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithStyle(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + Style: Style{ + SelectedRow: ColorStyle{ + TextColor: "black", + BackgroundColor: "red", + }, + ProgressModal: ProgressModalOpts{ + CurrentItemNameMaxLen: 10, + }, + Footer: FooterColorStyle{ + TextColor: "black", + BackgroundColor: "red", + NumberColor: "white", + }, + Header: HeaderColorStyle{ + TextColor: "black", + BackgroundColor: "red", + Hidden: true, + }, + ResultRow: ResultRowColorStyle{ + NumberColor: "orange", + DirectoryColor: "blue", + }, + UseOldSizeBar: true, + }, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathNoUnicode(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + NoUnicode: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithExport(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + defer func() { + os.Remove("output.json") + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", OutputFile: "output.json"}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEmpty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithChdir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + ChangeCwd: true, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestReadAnalysisFromFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/test.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEmpty(t, out) + assert.Contains(t, out, "main.go") + assert.Nil(t, err) +} + +func TestReadWrongAnalysisFromFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/wrong.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "Array of maps not found") +} + +func TestWrongCombinationOfPrefixes(t *testing.T) { + out, err := runApp( + &Flags{NoPrefix: true, UseSIPrefix: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "cannot be used at once") +} + +func TestReadWrongAnalysisFromNotExistingFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "xxx.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "no such file or directory") +} + +func TestAnalyzePathWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := bytes.NewBufferString("") + + app := App{ + Flags: &Flags{LogFile: "/dev/null"}, + Args: []string{"xxx"}, + Istty: false, + Writer: buff, + TermApp: testapp.CreateMockedApp(false), + Getter: testdev.DevicesInfoGetterMock{}, + PathChecker: os.Stat, + } + err := app.Run() + + assert.Equal(t, "", strings.TrimSpace(buff.String())) + assert.Contains(t, err.Error(), "no such file or directory") +} + +func TestNoCross(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoCross: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestListDevices(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "Device") + assert.Nil(t, err) +} + +func TestListDevicesToFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + defer func() { + os.Remove("output.json") + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true, OutputFile: "output.json"}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, "", out) + assert.Contains(t, err.Error(), "not supported") +} + +func TestListDevicesWithGui(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Nil(t, err) + assert.Empty(t, out) +} + +func TestMaxCores(t *testing.T) { + _, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: 1}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, 1, runtime.GOMAXPROCS(0)) + assert.Nil(t, err) +} + +func TestMaxCoresHighEdge(t *testing.T) { + if runtime.NumCPU() < 2 { + t.Skip("Skipping on a single core CPU") + } + out, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: runtime.NumCPU() + 1}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestMaxCoresLowEdge(t *testing.T) { + if runtime.NumCPU() < 2 { + t.Skip("Skipping on a single core CPU") + } + out, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: -100}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) + assert.Empty(t, out) + assert.Nil(t, err) +} + +// nolint: unparam // Why: it's used in linux tests +func runApp(flags *Flags, args []string, istty bool, getter device.DevicesInfoGetter) (string, error) { + buff := bytes.NewBufferString("") + + app := App{ + Flags: flags, + Args: args, + Istty: istty, + Writer: buff, + TermApp: testapp.CreateMockedApp(false), + Getter: getter, + PathChecker: testdir.MockedPathChecker, + } + err := app.Run() + + return strings.TrimSpace(buff.String()), err +} diff --git a/gdu/cmd/gdu/main.go b/gdu/cmd/gdu/main.go new file mode 100644 index 0000000..760cc7d --- /dev/null +++ b/gdu/cmd/gdu/main.go @@ -0,0 +1,245 @@ +package gdu + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-isatty" + "github.com/rivo/tview" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "b612.me/apps/b612/gdu/cmd/gdu/app" + "b612.me/apps/b612/gdu/pkg/device" +) + +var ( + af *app.Flags + configErr error +) + +var Cmd = &cobra.Command{ + Use: "gdu [directory_to_scan]", + Short: "一款使用 Go 语言编写的快速磁盘空间分析工具。", + Long: `一款使用 Go 语言编写的快速磁盘空间分析工具。 + +Gdu 主要针对 SSD 固态硬盘设计,能够充分利用并行处理优势。虽然也支持机械硬盘(HDD)使用,但性能提升效果不如前者显著。 +`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + RunE: runE, +} + +func init() { + af = &app.Flags{} + flags := Cmd.Flags() + flags.StringVar(&af.CfgFile, "config-file", "", "从配置文件读取(默认为 $HOME/.gdu.yaml)") + flags.StringVarP(&af.LogFile, "log-file", "l", "/dev/null", "日志文件路径") + flags.StringVarP(&af.OutputFile, "output-file", "o", "", "将所有信息导出为JSON文件") + flags.StringVarP(&af.InputFile, "input-file", "f", "", "从JSON文件导入分析数据") + flags.IntVarP(&af.MaxCores, "max-cores", "m", runtime.NumCPU(), fmt.Sprintf("设置Gdu使用的最大核心数。当前可用%d个核心", runtime.NumCPU())) + flags.BoolVar(&af.SequentialScanning, "sequential", false, "使用顺序扫描(适用于机械硬盘HDD)") + flags.BoolVarP(&af.ShowVersion, "version", "v", false, "打印版本信息") + + flags.StringSliceVarP(&af.IgnoreDirs, "ignore-dirs", "i", []string{"/proc", "/dev", "/sys", "/run"}, + "需要忽略的路径(逗号分隔),可为绝对路径或相对于当前目录的路径") + flags.StringSliceVarP(&af.IgnoreDirPatterns, "ignore-dirs-pattern", "I", []string{}, + "需要忽略的路径模式(逗号分隔)") + flags.StringVarP(&af.IgnoreFromFile, "ignore-from", "X", "", + "从文件中读取需要忽略的路径模式") + flags.BoolVarP(&af.NoHidden, "no-hidden", "H", false, "忽略隐藏目录(以点号开头的目录)") + flags.BoolVarP( + &af.FollowSymlinks, "follow-symlinks", "L", false, + "跟踪文件的符号链接,显示链接指向文件的大小(不跟踪目录符号链接)", + ) + flags.BoolVarP( + &af.ShowAnnexedSize, "show-annexed-size", "A", false, + "对git-annex文件显示表观大小(当文件未本地存储时,实际磁盘占用为零)", + ) + flags.BoolVarP(&af.NoCross, "no-cross", "x", false, "不跨越文件系统边界") + flags.BoolVarP(&af.ConstGC, "const-gc", "g", false, "启用恒定级别的内存垃圾回收(由GOGC参数控制)") + flags.BoolVar(&af.Profiling, "enable-profiling", false, "启用性能分析数据收集(访问地址 http://localhost:6060/debug/pprof/)") + + flags.BoolVar(&af.UseStorage, "use-storage", false, "使用持久化键值存储分析数据(实验性功能)") + flags.StringVar(&af.StoragePath, "storage-path", "/tmp/badger", "持久化键值存储目录路径") + flags.BoolVarP(&af.ReadFromStorage, "read-from-storage", "r", false, "从持久化键值存储读取分析数据") + + flags.BoolVarP(&af.ShowDisks, "show-disks", "d", false, "显示所有已挂载磁盘") + flags.BoolVarP(&af.ShowApparentSize, "show-apparent-size", "a", false, "显示表观大小") + flags.BoolVarP(&af.ShowRelativeSize, "show-relative-size", "B", false, "显示相对大小") + flags.BoolVarP(&af.NoColor, "no-color", "c", false, "禁用彩色输出") + flags.BoolVarP(&af.ShowItemCount, "show-item-count", "C", false, "显示目录内项目数量") + flags.BoolVarP(&af.ShowMTime, "show-mtime", "M", false, "显示目录内项目最新修改时间") + flags.BoolVarP(&af.NonInteractive, "non-interactive", "n", false, "使用非交互模式") + flags.BoolVarP(&af.NoProgress, "no-progress", "p", false, "非交互模式下不显示进度条") + flags.BoolVarP(&af.NoUnicode, "no-unicode", "u", false, "禁用Unicode符号(用于大小进度条)") + flags.BoolVarP(&af.Summarize, "summarize", "s", false, "非交互模式下仅显示统计总数") + flags.IntVarP(&af.Top, "top", "t", 0, "非交互模式下仅显示前X个最大文件") + flags.BoolVar(&af.UseSIPrefix, "si", false, "使用十进制SI单位(kB/MB/GB)而非二进制单位(KiB/MiB/GiB)") + flags.BoolVar(&af.NoPrefix, "no-prefix", false, "非交互模式下显示原始数值(无单位前缀)") + flags.BoolVar(&af.NoMouse, "no-mouse", false, "禁用鼠标支持") + flags.BoolVar(&af.NoDelete, "no-delete", false, "禁止删除操作") + flags.BoolVar(&af.WriteConfig, "write-config", false, "将当前配置写入文件(默认为 $HOME/.gdu.yaml)") + + initConfig() + setDefaults() +} + +func initConfig() { + setConfigFilePath() + data, err := os.ReadFile(af.CfgFile) + if err != nil { + configErr = err + return // config file does not exist, return + } + + configErr = yaml.Unmarshal(data, &af) +} + +func setDefaults() { + if af.Style.Footer.BackgroundColor == "" { + af.Style.Footer.BackgroundColor = "#2479D0" + } + if af.Style.Footer.TextColor == "" { + af.Style.Footer.TextColor = "#000000" + } + if af.Style.Footer.NumberColor == "" { + af.Style.Footer.NumberColor = "#FFFFFF" + } + if af.Style.Header.BackgroundColor == "" { + af.Style.Header.BackgroundColor = "#2479D0" + } + if af.Style.Header.TextColor == "" { + af.Style.Header.TextColor = "#000000" + } + if af.Style.ResultRow.NumberColor == "" { + af.Style.ResultRow.NumberColor = "#e67100" + } + if af.Style.ResultRow.DirectoryColor == "" { + af.Style.ResultRow.DirectoryColor = "#3498db" + } +} + +func setConfigFilePath() { + command := strings.Join(os.Args, " ") + if strings.Contains(command, "--config-file") { + re := regexp.MustCompile("--config-file[= ]([^ ]+)") + parts := re.FindStringSubmatch(command) + + if len(parts) > 1 { + af.CfgFile = parts[1] + return + } + } + setDefaultConfigFilePath() +} + +func setDefaultConfigFilePath() { + home, err := os.UserHomeDir() + if err != nil { + configErr = err + return + } + + path := filepath.Join(home, ".config", "gdu", "gdu.yaml") + if _, err := os.Stat(path); err == nil { + af.CfgFile = path + return + } + + af.CfgFile = filepath.Join(home, ".gdu.yaml") +} + +func runE(command *cobra.Command, args []string) error { + var ( + termApp *tview.Application + screen tcell.Screen + err error + ) + + if af.WriteConfig { + data, err := yaml.Marshal(af) + if err != nil { + return fmt.Errorf("Error marshaling config file: %w", err) + } + if af.CfgFile == "" { + setDefaultConfigFilePath() + } + err = os.WriteFile(af.CfgFile, data, 0o600) + if err != nil { + return fmt.Errorf("Error writing config file %s: %w", af.CfgFile, err) + } + } + + if runtime.GOOS == "windows" && af.LogFile == "/dev/null" { + af.LogFile = "nul" + } + + var f *os.File + if af.LogFile == "-" { + f = os.Stdout + } else { + f, err = os.OpenFile(af.LogFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("opening log file: %w", err) + } + defer func() { + cerr := f.Close() + if cerr != nil { + panic(cerr) + } + }() + } + log.SetOutput(f) + + if configErr != nil { + log.Printf("Error reading config file: %s", configErr.Error()) + } + + istty := isatty.IsTerminal(os.Stdout.Fd()) + + // we are not able to analyze disk usage on Windows and Plan9 + if runtime.GOOS == "windows" || runtime.GOOS == "plan9" { + af.ShowApparentSize = true + } + + if !af.ShowVersion && !af.NonInteractive && istty && af.OutputFile == "" { + screen, err = tcell.NewScreen() + if err != nil { + return fmt.Errorf("Error creating screen: %w", err) + } + defer screen.Clear() + defer screen.Fini() + + termApp = tview.NewApplication() + termApp.SetScreen(screen) + + if !af.NoMouse { + termApp.EnableMouse(true) + } + } + + a := app.App{ + Flags: af, + Args: args, + Istty: istty, + Writer: os.Stdout, + TermApp: termApp, + Screen: screen, + Getter: device.Getter, + PathChecker: os.Stat, + } + return a.Run() +} + +func main() { + if err := Cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/gdu/codecov.yml b/gdu/codecov.yml new file mode 100644 index 0000000..c9ef2c2 --- /dev/null +++ b/gdu/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + target: auto + threshold: 2% + informational: true + patch: + default: + informational: true \ No newline at end of file diff --git a/gdu/configuration.md b/gdu/configuration.md new file mode 100644 index 0000000..ac96992 --- /dev/null +++ b/gdu/configuration.md @@ -0,0 +1,195 @@ +# YAML file configuration options + +Gdu provides an additional set of configuration options to the usual command line options. + +You can get the full list of all possible options by running: + +``` +gdu --write-config +``` + +This will create file `$HOME/.gdu.yaml` with all the options set to default values. + +Let's go through them one by one: + +#### `log-file` + +Path to a logfile (default "/dev/null") + +#### `input-file` + +Import analysis from JSON file + +#### `output-file` + +Export all info into file as JSON + +#### `ignore-dirs` + +Paths to ignore (separated by comma). Can be absolute (like `/proc`) or relative to the current working directory (like `node_modules`). Default values are [/proc,/dev,/sys,/run]. + +#### `ignore-dir-patterns` + +Path patterns to ignore (separated by comma). Patterns can be absolute or relative to the current working directory. + +#### `ignore-from-file` + +Read path patterns to ignore from file. Patterns can be absolute or relative to the current working directory. + +#### `max-cores` + +Set max cores that Gdu will use. + +#### `sequential-scanning` + +Use sequential scanning (intended for rotating HDDs) + +#### `show-apparent-size` + +Show apparent size + +#### `show-relative-size` + +Show relative size + +#### `show-item-count` + +Show number of items in directory + +#### `no-color` + +Do not use colorized output + +#### `no-mouse` + +Do not use mouse + +#### `non-interactive` + +Do not run in interactive mode + +#### `no-progress` + +Do not show progress in non-interactive mode + +#### `no-cross` + +Do not cross filesystem boundaries + +#### `no-hidden` + +Ignore hidden directories (beginning with dot) + +#### `no-delete` + +Do not allow deletions + +#### `follow-symlinks` + +Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) + +#### `profiling` + +Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ +#### `const-gc` + +Enable memory garbage collection during analysis with constant level set by GOGC + +#### `use-storage` + +Use persistent key-value storage for analysis data (experimental) + +#### `storage-path` + +Path to persistent key-value storage directory (default is /tmp/badger) + +#### `read-from-storage` + +Read analysis data from persistent key-value storage + +#### `summarize` + +Show only a total in non-interactive mode + +#### `use-si-prefix` + +Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + +#### `no-prefix` + +Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + +#### `change-cwd` + +Set CWD variable when browsing directories + +#### `delete-in-background` + +Delete items in the background, not blocking the UI from work + +#### `delete-in-parallel` + +Delete items in parallel, which might increase the speed of deletion + +#### `style.selected-row.text-color` + +Color of text for the selected row + +#### `style.selected-row.background-color` + +Background color for the selected row + +#### `style.progress-modal.current-item-path-max-len` + +Maximum length of file path for the current item in progress bar. +When the length is reached, the path is shortened with "/.../". + +#### `style.use-old-size-bar` + +Show size bar without Unicode symbols. + +#### `style.footer.text-color` + +Color of text for footer bar + +#### `style.footer.background-color` + +Background color for footer bar + +#### `style.footer.number-color` + +Color of numbers displayed in the footer + +#### `style.header.text-color` + +Color of text for header bar + +#### `style.header.background-color` + +Background color for header bar + +#### `style.header.hidden` + +Hide the header bar + +#### `style.result-row.number-color` + +Color of numbers in result rows + +#### `style.result-row.directory-color` + +Color of directory names in result rows + +#### `sorting.by` + +Sort items. Possible values: +* name - name of the item +* size - usage or apparent size +* itemCount - number of items in the folder tree +* mtime - modification time + +#### `sorting.order` + +Set sorting order. Possible values: +* asc - ascending order +* desc - descending order \ No newline at end of file diff --git a/gdu/default.pgo b/gdu/default.pgo new file mode 100644 index 0000000..dd55ccf Binary files /dev/null and b/gdu/default.pgo differ diff --git a/gdu/docs/run-books.md b/gdu/docs/run-books.md new file mode 100644 index 0000000..61901c7 --- /dev/null +++ b/gdu/docs/run-books.md @@ -0,0 +1,13 @@ +# Release process + +1. update usage in README.md and gdu.1.md +1. `make show-man` +1. `make man` +1. commit the changes +1. tag new version with `-sa` +1. `make` +1. `git push --tags` +1. `git push` +1. `make release` +1. update `gdu.spec` +1. Release snapcraft, AUR, ... diff --git a/gdu/gdu.1 b/gdu/gdu.1 new file mode 100644 index 0000000..03ac528 --- /dev/null +++ b/gdu/gdu.1 @@ -0,0 +1,123 @@ +.\" Automatically generated by Pandoc 3.1.11.1 +.\" +.TH "gdu" "1" "2024\-12\-30" "" "" +.SH NAME +gdu \- Pretty fast disk usage analyzer written in Go +.SH SYNOPSIS +\f[B]gdu [flags] [directory_to_scan]\f[R] +.SH DESCRIPTION +Pretty fast disk usage analyzer written in Go. +.PP +Gdu is intended primarily for SSD disks where it can fully utilize +parallel processing. +However HDDs work as well, but the performance gain is not so huge. +.SH OPTIONS +\f[B]\-h\f[R], \f[B]\-\-help\f[R][=false] help for gdu +.PP +\f[B]\-i\f[R], \f[B]\-\-ignore\-dirs\f[R]=[/proc,/dev,/sys,/run] +Absolute paths to ignore (separated by comma) +.PP +\f[B]\-I\f[R], \f[B]\-\-ignore\-dirs\-pattern\f[R] Absolute path +patterns to ignore (separated by comma) +.PP +\f[B]\-X\f[R], \f[B]\-\-ignore\-from\f[R] Read absolute path patterns to +ignore from file +.PP +\f[B]\-l\f[R], \f[B]\-\-log\-file\f[R]=\[dq]/dev/null\[dq] Path to a +logfile +.PP +\f[B]\-m\f[R], \f[B]\-\-max\-cores\f[R] Set max cores that Gdu will use. +.PP +\f[B]\-c\f[R], \f[B]\-\-no\-color\f[R][=false] Do not use colorized +output +.PP +\f[B]\-x\f[R], \f[B]\-\-no\-cross\f[R][=false] Do not cross filesystem +boundaries +.PP +\f[B]\-H\f[R], \f[B]\-\-no\-hidden\f[R][=false] Ignore hidden +directories (beginning with dot) +.PP +\f[B]\-L\f[R], \f[B]\-\-follow\-symlinks\f[R][=false] Follow symlinks +for files, i.e.\ show the size of the file to which symlink points to +(symlinks to directories are not followed) +.PP +\f[B]\-n\f[R], \f[B]\-\-non\-interactive\f[R][=false] Do not run in +interactive mode +.PP +\f[B]\-p\f[R], \f[B]\-\-no\-progress\f[R][=false] Do not show progress +in non\-interactive mode +.PP +\f[B]\-u\f[R], \f[B]\-\-no\-unicode\f[R][=false] Do not use Unicode +symbols (for size bar) +.PP +\f[B]\-s\f[R], \f[B]\-\-summarize\f[R][=false] Show only a total in +non\-interactive mode +.PP +\f[B]\-t\f[R], \f[B]\-\-top\f[R][=0] Show only top X largest files in +non\-interactive mode +.PP +\f[B]\-d\f[R], \f[B]\-\-show\-disks\f[R][=false] Show all mounted disks +.PP +\f[B]\-a\f[R], \f[B]\-\-show\-apparent\-size\f[R][=false] Show apparent +size +.PP +\f[B]\-C\f[R], \f[B]\-\-show\-item\-count\f[R][=false] Show number of +items in directory +.PP +\f[B]\-M\f[R], \f[B]\-\-show\-mtime\f[R][=false] Show latest mtime of +items in directory +.PP +\f[B]\-\-si\f[R][=false] Show sizes with decimal SI prefixes (kB, MB, +GB) instead of binary prefixes (KiB, MiB, GiB) +.PP +\f[B]\-\-no\-prefix\f[R][=false] Show sizes as raw numbers without any +prefixes (SI or binary) in non\-interactive mode +.PP +\f[B]\-\-no\-mouse\f[R][=false] Do not use mouse +.PP +\f[B]\-\-no\-delete\f[R][=false] Do not allow deletions +.PP +\f[B]\-f\f[R], \f[B]\-\-input\-file\f[R] Import analysis from JSON file. +If the file is \[dq]\-\[dq], read from standard input. +.PP +\f[B]\-o\f[R], \f[B]\-\-output\-file\f[R] Export all info into file as +JSON. +If the file is \[dq]\-\[dq], write to standard output. +.PP +\f[B]\-\-config\-file\f[R]=\[dq]$HOME/.gdu.yaml\[dq] Read config from +file +.PP +\f[B]\-\-write\-config\f[R][=false] Write current configuration to file +(default is $HOME/.gdu.yaml) +.PP +\f[B]\-g\f[R], \f[B]\-\-const\-gc\f[R][=false] Enable memory garbage +collection during analysis with constant level set by GOGC +.PP +\f[B]\-\-enable\-profiling\f[R][=false] Enable collection of profiling +data and provide it on http://localhost:6060/debug/pprof/ +.PP +\f[B]\-\-use\-storage\f[R][=false] Use persistent key\-value storage for +analysis data (experimental) +.PP +\f[B]\-r\f[R], \f[B]\-\-read\-from\-storage\f[R][=false] Read analysis +data from persistent key\-value storage +.PP +\f[B]\-v\f[R], \f[B]\-\-version\f[R][=false] Print version +.SH FILE FLAGS +Files and directories may be prefixed by a one\-character flag with +following meaning: +.TP +\f[B]!\f[R] +An error occurred while reading this directory. +.TP +\f[B].\f[R] +An error occurred while reading a subdirectory, size may be not correct. +.TP +\f[B]\[at]\f[R] +File is symlink or socket. +.TP +\f[B]H\f[R] +Same file was already counted (hard link). +.TP +\f[B]e\f[R] +Directory is empty. diff --git a/gdu/gdu.1.md b/gdu/gdu.1.md new file mode 100644 index 0000000..cd70d6a --- /dev/null +++ b/gdu/gdu.1.md @@ -0,0 +1,120 @@ +--- +date: {{date}} +section: 1 +title: gdu +--- + +# NAME + +gdu - Pretty fast disk usage analyzer written in Go + +# SYNOPSIS + +**gdu \[flags\] \[directory_to_scan\]** + +# DESCRIPTION + +Pretty fast disk usage analyzer written in Go. + +Gdu is intended primarily for SSD disks where it can fully utilize +parallel processing. However HDDs work as well, but the performance gain +is not so huge. + +# OPTIONS + +**-h**, **\--help**\[=false\] help for gdu + +**-i**, **\--ignore-dirs**=\[/proc,/dev,/sys,/run\] + Paths to ignore (separated by comma). + Supports both absolute and relative paths. + +**-I**, **\--ignore-dirs-pattern** + Path patterns to ignore (separated by comma). + Supports both absolute and relative path patterns. + +**-X**, **\--ignore-from** + Read path patterns to ignore from file. + Supports both absolute and relative path patterns. + +**-l**, **\--log-file**=\"/dev/null\" Path to a logfile + +**-m**, **\--max-cores** Set max cores that Gdu will use. + +**-c**, **\--no-color**\[=false\] Do not use colorized output + +**-x**, **\--no-cross**\[=false\] Do not cross filesystem boundaries + +**-H**, **\--no-hidden**\[=false\] Ignore hidden directories (beginning with dot) + +**-L**, **\--follow-symlinks**\[=false\] Follow symlinks for files, i.e. show the +size of the file to which symlink points to (symlinks to directories are not followed) + +**-n**, **\--non-interactive**\[=false\] Do not run in interactive mode + +**-p**, **\--no-progress**\[=false\] Do not show progress in +non-interactive mode + +**-u**, **\--no-unicode**\[=false\] Do not use Unicode symbols (for size bar) + +**-s**, **\--summarize**\[=false\] Show only a total in non-interactive mode + +**-t**, **\--top**\[=0\] Show only top X largest files in non-interactive mode + +**-d**, **\--show-disks**\[=false\] Show all mounted disks + +**-a**, **\--show-apparent-size**\[=false\] Show apparent size + +**-C**, **\--show-item-count**\[=false\] Show number of items in directory + +**-M**, **\--show-mtime**\[=false\] Show latest mtime of items in directory + +**\--si**\[=false\] Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + +**\--no-prefix**\[=false\] Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + +**\--no-mouse**\[=false\] Do not use mouse + +**\--no-delete**\[=false\] Do not allow deletions + +**-f**, **\--input-file** Import analysis from JSON file. If the file is \"-\", read from standard input. + +**-o**, **\--output-file** Export all info into file as JSON. If the file is \"-\", write to standard output. + +**\--config-file**=\"$HOME/.gdu.yaml\" Read config from file + +**\--write-config**\[=false\] Write current configuration to file (default is $HOME/.gdu.yaml) + +**-g**, **\--const-gc**\[=false\] Enable memory garbage collection during analysis with constant level set by GOGC + +**\--enable-profiling**\[=false\] Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ + +**\--use-storage**\[=false\] Use persistent key-value storage for analysis data (experimental) + +**-r**, **\--read-from-storage**\[=false\] Read analysis data from persistent key-value storage + +**-v**, **\--version**\[=false\] Print version + +# FILE FLAGS + +Files and directories may be prefixed by a one-character +flag with following meaning: + +**!** + +: An error occurred while reading this directory. + +**.** + +: An error occurred while reading a subdirectory, size may be not correct. + +**\@** + +: File is symlink or socket. + +**H** + +: Same file was already counted (hard link). + +**e** + +: Directory is empty. diff --git a/gdu/gdu.png b/gdu/gdu.png new file mode 100644 index 0000000..e2716d9 Binary files /dev/null and b/gdu/gdu.png differ diff --git a/gdu/gdu.spec b/gdu/gdu.spec new file mode 100644 index 0000000..86e2c1b --- /dev/null +++ b/gdu/gdu.spec @@ -0,0 +1,194 @@ +Name: gdu +Version: 5.30.1 +Release: 2 +Summary: Pretty fast disk usage analyzer written in Go + +License: MIT +URL: https://github.com/dundee/gdu + +Source0: https://github.com/dundee/gdu/archive/refs/tags/v%{version}.tar.gz + +BuildRequires: golang +BuildRequires: systemd-rpm-macros +BuildRequires: git + +Provides: %{name} = %{version} + +%description +Pretty fast disk usage analyzer written in Go. + +%global debug_package %{nil} + +%prep +%autosetup -n %{name}-%{version} + +%build +export GOINSECURE=go.opencensus.io +GO111MODULE=on CGO_ENABLED=0 go build \ +-trimpath \ +-buildmode=pie \ +-mod=readonly \ +-modcacherw \ +-ldflags \ +"-s -w \ +-X 'b612.me/apps/b612/gdu/build.Version=v%{version}' \ +-X 'b612.me/apps/b612/gdu/build.User=$(id -u -n)' \ +-X 'b612.me/apps/b612/gdu/build.Time=$(LC_ALL=en_US.UTF-8 date)'" \ +-o %{name} b612.me/apps/b612/gdu/cmd/gdu + +%install +rm -rf $RPM_BUILD_ROOT +install -Dpm 0755 %{name} %{buildroot}%{_bindir}/%{name} +install -Dpm 0755 %{name}.1 $RPM_BUILD_ROOT%{_mandir}/man1/gdu.1 + +%check + +%post + +%preun + +%files +%{_bindir}/gdu +%{_mandir}/man1/gdu.1.gz + +%changelog +* Tue Feb 4 2025 - Danie de Jager - 5.30.1-2 +- fix: set "GOINSECURE=go.opencensus.io" +* Mon Dec 30 2024 Daniel Milde - 5.30.1-1 +- fix: set default colors when config file does not exist +* Mon Dec 30 2024 Daniel Milde - 5.30.0-1 +- feat: show top largest files using -t or --top option in #391 +- feat: introduce more style options in #396 +* Mon Jun 17 2024 Daniel Milde - 5.29.0-1 +- feat: support for reading gzip, bzip2 and xz files by @dundee in #363 +- feat: add --show-mtime (-M) option by @dundee in #350 +- feat: add option --no-unicode to disable unicode symbols by @dundee in #362 +- fix: division by zero error in formatFileRow by @xroberx in #359 +* Sun Apr 21 2024 Danie de Jager - 5.28.0-1 +- feat: delete/empty items in background by @dundee in #336 +- feat: add --show-item-count (-C) option by @ramgp in #332 +- feat: add --no-delete option by @ramgp in #333 +- feat: ignore item by pressing I by @dundee in #345 +- feat: delete directory items in parallel by @dundee in #340 +- feat: add --sequential option for sequential scanning by @dundee in #322 +* Sun Feb 18 2024 Danie de Jager - 5.27.0-1 +- feat: export in interactive mode by @kadogo in #298 +- feat: handle vim-style navigation in confirmation by @samihda in #283 +- fix: panic with Interface Conversion Nil Error by @ShivamB25 in #274 +- fix: Enter key properly working when reading analysis from file by @dundee in #312 +- fix: check if type matches for selected device by @dundee in #318 +- ci: package gdu in docker container by @rare-magma in #313 +- ci: add values for building gdu with tito by @daniejstriata in #288 +- ci: change Winget Releaser job to ubuntu-latest by @sitiom in #271 +* Tue Feb 13 2024 Danie de Jager - 5.26.0-1 +- feat: use key-value store for analysis data in #297 +- feat: use profile-guided optimization in #286 +* Fri Dec 1 2023 Danie de Jager - 5.25.0-2 +- Improved SPEC to build on AL2023. +* Tue Jun 6 2023 Danie de Jager - 5.25.0-1 +- feat: use unicode block elements in size bar in #255 +* Thu Jun 1 2023 Danie de Jager - 5.24.0-1 +- feat: add ctrl+z for job control by @yurenchen000 in #250 +- feat: upgrade dependencies by @dundee in #252 +* Thu May 11 2023 Danie de Jager - 5.23.0-2 +- Compiled with golang 1.19.9 +* Tue Apr 11 2023 Danie de Jager - 5.23.0-1 +- feat: added configuration option to change CWD when browsing directories by @leapfog in #230 +- fix: do not show help modal when confirm modal is already opened by @dundee in #237 +* Mon Feb 6 2023 Danie de Jager - 5.22.0-1 +- feat: added option to follow symlinks in #206 +- fix: ignore mouse events when modal is opened in #205 +- Updated SPEC file used for rpm creation by @daniejstriata in #198 +* Mon Jan 9 2023 Danie de Jager - 5.21.1-2 +- updated SPEC file to support builds on Fedora +* Mon Jan 9 2023 Danie de Jager - 5.21.1-1 +- fix: correct open command for Win +* Wed Jan 4 2023 Danie de Jager - 5.21.0-1 +- feat: mark multiple items for deletion by @dundee in #193 +- feat: move cursor to next row when marked by @dundee in #194 +- Use GNU tar on Darwin to fix build error by @sryze in #188 +* Mon Oct 24 2022 Danie de Jager - 5.20.0-1 +- feat: set default sorting using config option +- feat: open file or directory in external program +- fix: check reference type +* Wed Sep 28 2022 Danie de Jager - 5.19.0-1 +- feat: upgrade all dependencies +- feat: bump go version to 1.18 +- feat: format negative numbers correctly +- feat: try to read config from ~/.config/gdu/gdu.yaml first +- test: export formatting +- docs: config file default locations +* Sun Sep 18 2022 Danie de Jager - 5.18.1-1 +- fix: correct config file option regex +- fix: read non-default config file properly in #175 +- feat: crop current item path to 70 chars in #173 +- feat: show elapsed time in progress modal +- feat: configuration option for setting maximum length of the path for current item in the progress modal in #174 +* Tue Sep 13 2022 Danie de Jager - 5.17.1-1 +- fix: nul log file for Windows (#171) +- fix: increase the vertical size of the progress modal (#172) +- feat: added possibility to change text and background color of the selected row by @dundee in #170 +* Thu Sep 8 2022 Danie de Jager - 5.16.0-1 +- feat: support for reading (and writing) configuration to YAML file +- feat: initial mouse support by @dundee in #165 +- add mtime for Windows by @mcoret in #157 +- openbsd fixes by @dundee in #164 +* Wed Aug 10 2022 Danie de Jager - 5.15.0-1 +- feat: show sizes as raw numbers without prefixes by @dundee in #147 +- feat: natural sorting by @dundee in #156 +- fix: honor --summarize when reading analysis by @Riatre in #149 +- fix: upgrade dependencies by @phanirithvij in #153 +- ci: generate release tarballs with vendor directory by @CyberTailor in #148 +* Mon Jul 18 2022 Danie de Jager - 5.14.0-2 +* Thu May 26 2022 Danie de Jager - 5.14.0-1 +- sort items by name if usage/size/count is the same (#143) +* Mon Feb 21 2022 Danie de Jager - 5.13.2 +- able to go back to devices list from analyzed directory +* Thu Feb 10 2022 Danie de Jager - 5.13.1 +- properly count only the first hard link size on a rescan +- do not panic if path does not start with a slash +* Sat Jan 29 2022 Danie de Jager - 5.13.0-1 +- lower memory usage +- possibility to toggle between bar graph relative to the size of the directory or the biggest file +- added option --si for showing sizes with decimal SI prefixes +- fixed freeze when r key binding is being hold +* Tue Dec 14 2021 Danie de Jager - 5.12.1-1 +- Bump to 5.12.1-1 +- fixed listing devices on NetBSD +- escape file names (#111) +- fixed filtering +* Fri Dec 3 2021 Danie de Jager - 5.12.0-1 +- Bump to 5.12.0-1 +* Fri Dec 3 2021 Danie de Jager - 5.11.0-2 +- Compile with go 1.17.4 +* Sun Nov 28 2021 Danie de Jager - 5.11.0-1 +- Bump to 5.11.0 +* Tue Nov 23 2021 Danie de Jager - 5.10.1-1 +- Bump to 5.10.1 +* Wed Nov 10 2021 Danie de Jager - 5.10.0-1 +- Bump to 5.10.01 +* Mon Oct 25 2021 Danie de Jager - 5.9.0-1 +- Bump to 5.9.0 +* Mon Sep 27 2021 Danie de Jager - 5.8.1-2 +- Remove pandoc requirement. +* Sun Sep 26 2021 Danie de Jager - 5.8.1-1 +- Bump to 5.8.1 +* Thu Sep 23 2021 Danie de Jager - 5.8.0-2 +- Bump to 5.8.0 +* Tue Sep 7 2021 Danie de Jager - 5.7.0-1 +- Bump to 5.7.0 +* Sat Aug 28 2021 Danie de Jager - 5.6.2-1 +- Bump to 5.6.2 +- Compiled with go 1.17 +* Fri Aug 27 2021 Danie de Jager - 5.6.1-1 +- Bump to 5.6.1 +* Mon Aug 23 2021 Danie de Jager - 5.6.0-1 +- Bump to 5.6.0 +* Fri Aug 13 2021 Danie de Jager - 5.5.0-2 +- Compiled with go 1.16.7 +* Mon Aug 2 2021 Danie de Jager - 5.5.0-1 +- Bump to 5.5.0 +* Mon Jul 26 2021 Danie de Jager - 5.4.0-1 +- Bump to 5.4.0 +* Thu Jul 22 2021 Danie de Jager - 5.3.0-2 +- First release diff --git a/gdu/internal/common/analyze.go b/gdu/internal/common/analyze.go new file mode 100644 index 0000000..aacc508 --- /dev/null +++ b/gdu/internal/common/analyze.go @@ -0,0 +1,23 @@ +package common + +import "b612.me/apps/b612/gdu/pkg/fs" + +// CurrentProgress struct +type CurrentProgress struct { + CurrentItemName string + ItemCount int + TotalSize int64 +} + +// ShouldDirBeIgnored whether path should be ignored +type ShouldDirBeIgnored func(name, path string) bool + +// Analyzer is type for dir analyzing function +type Analyzer interface { + AnalyzeDir(path string, ignore ShouldDirBeIgnored, constGC bool) fs.Item + SetFollowSymlinks(bool) + SetShowAnnexedSize(bool) + GetProgressChan() chan CurrentProgress + GetDone() SignalGroup + ResetProgress() +} diff --git a/gdu/internal/common/app.go b/gdu/internal/common/app.go new file mode 100644 index 0000000..19570f1 --- /dev/null +++ b/gdu/internal/common/app.go @@ -0,0 +1,21 @@ +package common + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// TermApplication is interface for the terminal UI app +type TermApplication interface { + Run() error + Stop() + Suspend(f func()) bool + SetRoot(root tview.Primitive, fullscreen bool) *tview.Application + SetFocus(p tview.Primitive) *tview.Application + SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *tview.Application + SetMouseCapture( + capture func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction), + ) *tview.Application + QueueUpdateDraw(f func()) *tview.Application + SetBeforeDrawFunc(func(screen tcell.Screen) bool) *tview.Application +} diff --git a/gdu/internal/common/ignore.go b/gdu/internal/common/ignore.go new file mode 100644 index 0000000..1971630 --- /dev/null +++ b/gdu/internal/common/ignore.go @@ -0,0 +1,152 @@ +package common + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" + + log "github.com/sirupsen/logrus" +) + +// CreateIgnorePattern creates one pattern from all path patterns +func CreateIgnorePattern(paths []string) (*regexp.Regexp, error) { + var err error + + for i, path := range paths { + if _, err = regexp.Compile(path); err != nil { + return nil, err + } + if !filepath.IsAbs(path) { + absPath, err := filepath.Abs(path) + if err == nil { + paths = append(paths, absPath) + } + } else { + relPath, err := filepath.Rel("/", path) + if err == nil { + paths = append(paths, relPath) + } + } + paths[i] = "(" + path + ")" + } + + ignore := `^` + strings.Join(paths, "|") + `$` + return regexp.Compile(ignore) +} + +// SetIgnoreDirPaths sets paths to ignore +func (ui *UI) SetIgnoreDirPaths(paths []string) { + log.Printf("Ignoring dirs %s", strings.Join(paths, ", ")) + ui.IgnoreDirPaths = make(map[string]struct{}, len(paths)*2) + for _, path := range paths { + ui.IgnoreDirPaths[path] = struct{}{} + if !filepath.IsAbs(path) { + if absPath, err := filepath.Abs(path); err == nil { + ui.IgnoreDirPaths[absPath] = struct{}{} + } + } else { + if relPath, err := filepath.Rel("/", path); err == nil { + ui.IgnoreDirPaths[relPath] = struct{}{} + } + } + } +} + +// SetIgnoreDirPatterns sets regular patterns of dirs to ignore +func (ui *UI) SetIgnoreDirPatterns(paths []string) error { + var err error + log.Printf("Ignoring dir patterns %s", strings.Join(paths, ", ")) + ui.IgnoreDirPathPatterns, err = CreateIgnorePattern(paths) + return err +} + +// SetIgnoreFromFile sets regular patterns of dirs to ignore +func (ui *UI) SetIgnoreFromFile(ignoreFile string) error { + var err error + var paths []string + log.Printf("Reading ignoring dir patterns from file '%s'", ignoreFile) + + file, err := os.Open(ignoreFile) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + paths = append(paths, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return err + } + + ui.IgnoreDirPathPatterns, err = CreateIgnorePattern(paths) + return err +} + +// SetIgnoreHidden sets flags if hidden dirs should be ignored +func (ui *UI) SetIgnoreHidden(value bool) { + log.Printf("Ignoring hidden dirs") + ui.IgnoreHidden = value +} + +// ShouldDirBeIgnored returns true if given path should be ignored +func (ui *UI) ShouldDirBeIgnored(name, path string) bool { + _, shouldIgnore := ui.IgnoreDirPaths[path] + if shouldIgnore { + log.Printf("Directory %s ignored", path) + } + return shouldIgnore +} + +// ShouldDirBeIgnoredUsingPattern returns true if given path should be ignored +func (ui *UI) ShouldDirBeIgnoredUsingPattern(name, path string) bool { + shouldIgnore := ui.IgnoreDirPathPatterns.MatchString(path) + if shouldIgnore { + log.Printf("Directory %s ignored", path) + } + return shouldIgnore +} + +// IsHiddenDir returns if the dir name begins with dot +func (ui *UI) IsHiddenDir(name, path string) bool { + shouldIgnore := name[0] == '.' + if shouldIgnore { + log.Printf("Directory %s ignored", path) + } + return shouldIgnore +} + +// CreateIgnoreFunc returns function for detecting if dir should be ignored +// nolint: gocyclo // Why: This function is a switch statement that is not too complex +func (ui *UI) CreateIgnoreFunc() ShouldDirBeIgnored { + switch { + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns == nil && !ui.IgnoreHidden: + return ui.ShouldDirBeIgnored + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns != nil && !ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnored(name, path) || ui.ShouldDirBeIgnoredUsingPattern(name, path) + } + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns != nil && ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnored(name, path) || ui.ShouldDirBeIgnoredUsingPattern(name, path) || ui.IsHiddenDir(name, path) + } + case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns != nil && ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnoredUsingPattern(name, path) || ui.IsHiddenDir(name, path) + } + case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns != nil && !ui.IgnoreHidden: + return ui.ShouldDirBeIgnoredUsingPattern + case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns == nil && ui.IgnoreHidden: + return ui.IsHiddenDir + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns == nil && ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnored(name, path) || ui.IsHiddenDir(name, path) + } + default: + return func(name, path string) bool { return false } + } +} diff --git a/gdu/internal/common/ignore_test.go b/gdu/internal/common/ignore_test.go new file mode 100644 index 0000000..b366780 --- /dev/null +++ b/gdu/internal/common/ignore_test.go @@ -0,0 +1,206 @@ +package common_test + +import ( + "os" + "path/filepath" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/common" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestCreateIgnorePattern(t *testing.T) { + re, err := common.CreateIgnorePattern([]string{"[abc]+"}) + + assert.Nil(t, err) + assert.True(t, re.MatchString("aa")) +} + +func TestCreateIgnorePatternWithErr(t *testing.T) { + re, err := common.CreateIgnorePattern([]string{"[[["}) + + assert.NotNil(t, err) + assert.Nil(t, re) +} + +func TestEmptyIgnore(t *testing.T) { + ui := &common.UI{} + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.False(t, shouldBeIgnored("abc", "/abc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAbsPath(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByPattern(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("aaa", "/aaa")) + assert.True(t, shouldBeIgnored("aaa", "/aaabc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreFromFile(t *testing.T) { + file, err := os.OpenFile("ignore", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + panic(err) + } + defer file.Close() + + if _, err := file.WriteString("/aaa\n"); err != nil { + panic(err) + } + if _, err := file.WriteString("/aaabc\n"); err != nil { + panic(err) + } + if _, err := file.WriteString("/[abd]+\n"); err != nil { + panic(err) + } + + ui := &common.UI{} + err = ui.SetIgnoreFromFile("ignore") + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("aaa", "/aaa")) + assert.True(t, shouldBeIgnored("aaabc", "/aaabc")) + assert.True(t, shouldBeIgnored("aaabd", "/aaabd")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreFromNotExistingFile(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreFromFile("xxx") + assert.NotNil(t, err) +} + +func TestIgnoreHidden(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAbsPathAndHidden(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAbsPathAndPattern(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.True(t, shouldBeIgnored("aabc", "/aabc")) + assert.True(t, shouldBeIgnored("ccc", "/ccc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByPatternAndHidden(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abbc", "/abbc")) + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAll(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.True(t, shouldBeIgnored("aabc", "/aabc")) + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByRelativePath(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"test_dir/abc"}) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "test_dir/abc")) + absPath, err := filepath.Abs("test_dir/abc") + assert.Nil(t, err) + assert.True(t, shouldBeIgnored("abc", absPath)) + assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) +} + +func TestIgnoreByRelativePattern(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreDirPatterns([]string{"test_dir/[abc]+"}) + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "test_dir/abc")) + absPath, err := filepath.Abs("test_dir/abc") + assert.Nil(t, err) + assert.True(t, shouldBeIgnored("abc", absPath)) + assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) +} + +func TestIgnoreFromFileWithRelativePaths(t *testing.T) { + file, err := os.OpenFile("ignore", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + panic(err) + } + defer file.Close() + + if _, err := file.WriteString("test_dir/aaa\n"); err != nil { + panic(err) + } + if _, err := file.WriteString("node_modules/[^/]+\n"); err != nil { + panic(err) + } + + ui := &common.UI{} + err = ui.SetIgnoreFromFile("ignore") + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("aaa", "test_dir/aaa")) + absPath, err := filepath.Abs("test_dir/aaa") + assert.Nil(t, err) + assert.True(t, shouldBeIgnored("aaa", absPath)) + assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) +} diff --git a/gdu/internal/common/signal.go b/gdu/internal/common/signal.go new file mode 100644 index 0000000..448947c --- /dev/null +++ b/gdu/internal/common/signal.go @@ -0,0 +1,11 @@ +package common + +type SignalGroup chan struct{} + +func (s SignalGroup) Wait() { + <-s +} + +func (s SignalGroup) Broadcast() { + close(s) +} diff --git a/gdu/internal/common/ui.go b/gdu/internal/common/ui.go new file mode 100644 index 0000000..8f93039 --- /dev/null +++ b/gdu/internal/common/ui.go @@ -0,0 +1,74 @@ +package common + +import ( + "regexp" + "strconv" +) + +// UI struct +type UI struct { + Analyzer Analyzer + IgnoreDirPaths map[string]struct{} + IgnoreDirPathPatterns *regexp.Regexp + IgnoreHidden bool + UseColors bool + UseSIPrefix bool + ShowProgress bool + ShowApparentSize bool + ShowRelativeSize bool + ConstGC bool +} + +// SetAnalyzer sets analyzer instance +func (ui *UI) SetAnalyzer(a Analyzer) { + ui.Analyzer = a +} + +// SetFollowSymlinks sets whether symlinks to files should be followed +func (ui *UI) SetFollowSymlinks(v bool) { + ui.Analyzer.SetFollowSymlinks(v) +} + +// SetShowAnnexedSize sets whether to use annexed size of git-annex files +func (ui *UI) SetShowAnnexedSize(v bool) { + ui.Analyzer.SetShowAnnexedSize(v) +} + +// binary multiplies prefixes (IEC) +const ( + _ float64 = 1 << (10 * iota) + Ki + Mi + Gi + Ti + Pi + Ei +) + +// SI prefixes +const ( + K float64 = 1e3 + M float64 = 1e6 + G float64 = 1e9 + T float64 = 1e12 + P float64 = 1e15 + E float64 = 1e18 +) + +// FormatNumber returns number as a string with thousands separator +func FormatNumber(n int64) string { + in := []byte(strconv.FormatInt(n, 10)) + + var out []byte + if i := len(in) % 3; i != 0 { + if out, in = append(out, in[:i]...), in[i:]; len(in) > 0 { + out = append(out, ',') + } + } + for len(in) > 0 { + if out, in = append(out, in[:3]...), in[3:]; len(in) > 0 { + out = append(out, ',') + } + } + return string(out) +} diff --git a/gdu/internal/common/ui_test.go b/gdu/internal/common/ui_test.go new file mode 100644 index 0000000..1bbb3cd --- /dev/null +++ b/gdu/internal/common/ui_test.go @@ -0,0 +1,68 @@ +package common + +import ( + "testing" + + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestFormatNumber(t *testing.T) { + res := FormatNumber(1234567890) + assert.Equal(t, "1,234,567,890", res) +} + +func TestSetFollowSymlinks(t *testing.T) { + ui := UI{ + Analyzer: &MockedAnalyzer{}, + } + ui.SetFollowSymlinks(true) + + assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).FollowSymlinks) +} + +func TestSetShowAnnexedSize(t *testing.T) { + ui := UI{ + Analyzer: &MockedAnalyzer{}, + } + ui.SetShowAnnexedSize(true) + + assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).ShowAnnexedSize) +} + +type MockedAnalyzer struct { + FollowSymlinks bool + ShowAnnexedSize bool +} + +// AnalyzeDir returns dir with files with different size exponents +func (a *MockedAnalyzer) AnalyzeDir( + path string, ignore ShouldDirBeIgnored, enableGC bool, +) fs.Item { + return nil +} + +// GetProgressChan returns always Done +func (a *MockedAnalyzer) GetProgressChan() chan CurrentProgress { + return make(chan CurrentProgress) +} + +// GetDone returns always Done +func (a *MockedAnalyzer) GetDone() SignalGroup { + c := make(SignalGroup) + defer c.Broadcast() + return c +} + +// ResetProgress does nothing +func (a *MockedAnalyzer) ResetProgress() {} + +// SetFollowSymlinks does nothing +func (a *MockedAnalyzer) SetFollowSymlinks(v bool) { + a.FollowSymlinks = v +} + +// SetShowAnnexedSize does nothing +func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) { + a.ShowAnnexedSize = v +} diff --git a/gdu/internal/testanalyze/analyze.go b/gdu/internal/testanalyze/analyze.go new file mode 100644 index 0000000..9159489 --- /dev/null +++ b/gdu/internal/testanalyze/analyze.go @@ -0,0 +1,105 @@ +package testanalyze + +import ( + "errors" + "time" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" + "b612.me/apps/b612/gdu/pkg/remove" +) + +// MockedAnalyzer returns dir with files with different size exponents +type MockedAnalyzer struct{} + +// AnalyzeDir returns dir with files with different size exponents +func (a *MockedAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, enableGC bool, +) fs.Item { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Usage: 1e12 + 1, + Size: 1e12 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + }, + BasePath: ".", + ItemCount: 12, + } + dir2 := &analyze.Dir{ + File: &analyze.File{ + Name: "aaa", + Usage: 1e12 + 1, + Size: 1e12 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 27, 0, time.UTC), + Parent: dir, + }, + } + dir3 := &analyze.Dir{ + File: &analyze.File{ + Name: "bbb", + Usage: 1e9 + 1, + Size: 1e9 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 26, 0, time.UTC), + Parent: dir, + }, + } + dir4 := &analyze.Dir{ + File: &analyze.File{ + Name: "ccc", + Usage: 1e6 + 1, + Size: 1e6 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 25, 0, time.UTC), + Parent: dir, + }, + } + file := &analyze.File{ + Name: "ddd", + Usage: 1e3 + 1, + Size: 1e3 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + Parent: dir, + } + dir.Files = fs.Files{dir2, dir3, dir4, file} + + return dir +} + +// GetProgressChan returns always Done +func (a *MockedAnalyzer) GetProgressChan() chan common.CurrentProgress { + return make(chan common.CurrentProgress) +} + +// GetDone returns always Done +func (a *MockedAnalyzer) GetDone() common.SignalGroup { + c := make(common.SignalGroup) + defer c.Broadcast() + return c +} + +// ResetProgress does nothing +func (a *MockedAnalyzer) ResetProgress() {} + +// SetFollowSymlinks does nothing +func (a *MockedAnalyzer) SetFollowSymlinks(v bool) {} + +// SetShowAnnexedSize does nothing +func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) {} + +// ItemFromDirWithErr returns error +func ItemFromDirWithErr(dir, file fs.Item) error { + return errors.New("Failed") +} + +// ItemFromDirWithSleep returns error +func ItemFromDirWithSleep(dir, file fs.Item) error { + time.Sleep(time.Millisecond * 600) + return remove.ItemFromDir(dir, file) +} + +// ItemFromDirWithSleepAndErr returns error +func ItemFromDirWithSleepAndErr(dir, file fs.Item) error { + time.Sleep(time.Millisecond * 600) + return errors.New("Failed") +} diff --git a/gdu/internal/testapp/app.go b/gdu/internal/testapp/app.go new file mode 100644 index 0000000..33a31fd --- /dev/null +++ b/gdu/internal/testapp/app.go @@ -0,0 +1,105 @@ +package testapp + +import ( + "errors" + "sync" + + "b612.me/apps/b612/gdu/internal/common" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// CreateSimScreen returns tcell.SimulationScreen +func CreateSimScreen() tcell.SimulationScreen { + screen := tcell.NewSimulationScreen("UTF-8") + return screen +} + +// CreateTestAppWithSimScreen returns app with simulation screen for tests +func CreateTestAppWithSimScreen(width, height int) (*tview.Application, tcell.SimulationScreen) { + app := tview.NewApplication() + screen := CreateSimScreen() + app.SetScreen(screen) + screen.SetSize(width, height) + return app, screen +} + +// MockedApp is tview.Application with mocked methods +type MockedApp struct { + FailRun bool + updateDraws []func() + BeforeDraws []func(screen tcell.Screen) bool + mutex *sync.Mutex +} + +// CreateMockedApp returns app with simulation screen for tests +func CreateMockedApp(failRun bool) common.TermApplication { + app := &MockedApp{ + FailRun: failRun, + updateDraws: make([]func(), 0, 1), + BeforeDraws: make([]func(screen tcell.Screen) bool, 0, 1), + mutex: &sync.Mutex{}, + } + return app +} + +// Run does nothing +func (app *MockedApp) Run() error { + if app.FailRun { + return errors.New("Fail") + } + + return nil +} + +// Stop does nothing +func (app *MockedApp) Stop() {} + +// Suspend runs given function +func (app *MockedApp) Suspend(f func()) bool { + f() + return true +} + +// SetRoot does nothing +func (app *MockedApp) SetRoot(root tview.Primitive, fullscreen bool) *tview.Application { + return nil +} + +// SetFocus does nothing +func (app *MockedApp) SetFocus(p tview.Primitive) *tview.Application { + return nil +} + +// SetInputCapture does nothing +func (app *MockedApp) SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *tview.Application { + return nil +} + +// SetMouseCapture does nothing +func (app *MockedApp) SetMouseCapture( + capture func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction), +) *tview.Application { + return nil +} + +// QueueUpdateDraw does nothing +func (app *MockedApp) QueueUpdateDraw(f func()) *tview.Application { + app.mutex.Lock() + app.updateDraws = append(app.updateDraws, f) + app.mutex.Unlock() + return nil +} + +// QueueUpdateDraw does nothing +func (app *MockedApp) GetUpdateDraws() []func() { + app.mutex.Lock() + defer app.mutex.Unlock() + return app.updateDraws +} + +// SetBeforeDrawFunc does nothing +func (app *MockedApp) SetBeforeDrawFunc(f func(screen tcell.Screen) bool) *tview.Application { + app.BeforeDraws = append(app.BeforeDraws, f) + return nil +} diff --git a/gdu/internal/testdata/test.json b/gdu/internal/testdata/test.json new file mode 100644 index 0000000..192e1d2 --- /dev/null +++ b/gdu/internal/testdata/test.json @@ -0,0 +1,7 @@ +[1,2,{"progname":"gdu","progver":"development","timestamp":1626807263}, +[{"name":"/home/gdu"}, +[{"name":"app"}, +{"name":"app.go","asize":4638,"dsize":8192}, +{"name":"app_linux_test.go","asize":1410,"dsize":4096}, +{"name":"app_test.go","asize":4974,"dsize":8192}], +{"name":"main.go","asize":3205,"dsize":4096}]] diff --git a/gdu/internal/testdata/wrong.json b/gdu/internal/testdata/wrong.json new file mode 100644 index 0000000..8adb9bb --- /dev/null +++ b/gdu/internal/testdata/wrong.json @@ -0,0 +1 @@ +[1,2,3,4] diff --git a/gdu/internal/testdev/dev.go b/gdu/internal/testdev/dev.go new file mode 100644 index 0000000..74a9458 --- /dev/null +++ b/gdu/internal/testdev/dev.go @@ -0,0 +1,18 @@ +package testdev + +import "b612.me/apps/b612/gdu/pkg/device" + +// DevicesInfoGetterMock is mock of DevicesInfoGetter +type DevicesInfoGetterMock struct { + Devices device.Devices +} + +// GetDevicesInfo returns mocked devices +func (t DevicesInfoGetterMock) GetDevicesInfo() (device.Devices, error) { + return t.Devices, nil +} + +// GetMounts returns all mounted filesystems from /proc/mounts +func (t DevicesInfoGetterMock) GetMounts() (device.Devices, error) { + return t.Devices, nil +} diff --git a/gdu/internal/testdir/test_dir.go b/gdu/internal/testdir/test_dir.go new file mode 100644 index 0000000..0793921 --- /dev/null +++ b/gdu/internal/testdir/test_dir.go @@ -0,0 +1,30 @@ +package testdir + +import ( + "io/fs" + "os" +) + +// CreateTestDir creates test dir structure +func CreateTestDir() func() { + if err := os.MkdirAll("test_dir/nested/subnested", os.ModePerm); err != nil { + panic(err) + } + if err := os.WriteFile("test_dir/nested/subnested/file", []byte("hello"), 0o600); err != nil { + panic(err) + } + if err := os.WriteFile("test_dir/nested/file2", []byte("go"), 0o600); err != nil { + panic(err) + } + return func() { + err := os.RemoveAll("test_dir") + if err != nil { + panic(err) + } + } +} + +// MockedPathChecker is mocked os.Stat, returns (nil, nil) +func MockedPathChecker(path string) (fs.FileInfo, error) { + return nil, nil +} diff --git a/gdu/pkg/analyze/dir_linux-openbsd.go b/gdu/pkg/analyze/dir_linux-openbsd.go new file mode 100644 index 0000000..9f8346a --- /dev/null +++ b/gdu/pkg/analyze/dir_linux-openbsd.go @@ -0,0 +1,32 @@ +//go:build linux || openbsd +// +build linux openbsd + +package analyze + +import ( + "os" + "syscall" + "time" +) + +const devBSize = 512 + +func setPlatformSpecificAttrs(file *File, f os.FileInfo) { + if stat, ok := f.Sys().(*syscall.Stat_t); ok { + file.Usage = stat.Blocks * devBSize + file.Mtime = time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)) + + if stat.Nlink > 1 { + file.Mli = stat.Ino + } + } +} + +func setDirPlatformSpecificAttrs(dir *Dir, path string) { + var stat syscall.Stat_t + if err := syscall.Stat(path, &stat); err != nil { + return + } + + dir.Mtime = time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)) +} diff --git a/gdu/pkg/analyze/dir_linux_test.go b/gdu/pkg/analyze/dir_linux_test.go new file mode 100644 index 0000000..fd939c1 --- /dev/null +++ b/gdu/pkg/analyze/dir_linux_test.go @@ -0,0 +1,65 @@ +//go:build linux +// +build linux + +package analyze + +import ( + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, "test_dir", dir.GetName()) + assert.Equal(t, 2, dir.ItemCount) + assert.Equal(t, '.', dir.GetFlag()) + + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} + +func TestSeqErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, "test_dir", dir.GetName()) + assert.Equal(t, 2, dir.ItemCount) + assert.Equal(t, '.', dir.GetFlag()) + + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} diff --git a/gdu/pkg/analyze/dir_other.go b/gdu/pkg/analyze/dir_other.go new file mode 100644 index 0000000..18456ff --- /dev/null +++ b/gdu/pkg/analyze/dir_other.go @@ -0,0 +1,23 @@ +//go:build windows || plan9 +// +build windows plan9 + +package analyze + +import ( + "os" + "syscall" + "time" +) + +func setPlatformSpecificAttrs(file *File, f os.FileInfo) { + stat := f.Sys().(*syscall.Win32FileAttributeData) + file.Mtime = time.Unix(0, stat.LastWriteTime.Nanoseconds()) +} + +func setDirPlatformSpecificAttrs(dir *Dir, path string) { + stat, err := os.Stat(path) + if err != nil { + return + } + dir.Mtime = stat.ModTime() +} diff --git a/gdu/pkg/analyze/dir_test.go b/gdu/pkg/analyze/dir_test.go new file mode 100644 index 0000000..3905c00 --- /dev/null +++ b/gdu/pkg/analyze/dir_test.go @@ -0,0 +1,243 @@ +package analyze + +import ( + "os" + "sort" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzeDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + + progress := <-analyzer.GetProgressChan() + assert.GreaterOrEqual(t, progress.TotalSize, int64(0)) + + analyzer.GetDone().Wait() + analyzer.ResetProgress() + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, 5, dir.ItemCount) + assert.True(t, dir.IsDir()) + + // test dir tree + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "subnested", dir.Files[0].(*Dir).Files[1].GetName()) + + // test file + assert.Equal(t, "file2", dir.Files[0].(*Dir).Files[0].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[0].GetSize()) + + assert.Equal( + t, "file", dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetName(), + ) + assert.Equal( + t, int64(5), dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetSize(), + ) + + // test parent link + assert.Equal( + t, + "test_dir", + dir.Files[0].(*Dir). + Files[1].(*Dir). + Files[0]. + GetParent(). + GetParent(). + GetParent(). + GetName(), + ) +} + +func TestIgnoreDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return true }, false, + ).(*Dir) + + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, 1, dir.ItemCount) +} + +func TestFlags(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(28+4096*4), dir.Size) + assert.Equal(t, 7, dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(21), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestHardlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, int64(7+4096*3), dir.Size) // file2 and file3 are counted just once for size + assert.Equal(t, 6, dir.ItemCount) // but twice for item count + + // test file3 + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, 'H', dir.Files[0].(*Dir).Files[1].GetFlag()) +} + +func TestFollowSymlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("./file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(9+4096*4), dir.Size) + assert.Equal(t, 7, dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, ' ', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestGitAnnexSymlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink( + ".git/annex/objects/qx/qX/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4", + "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + analyzer.SetFollowSymlinks(true) + analyzer.SetShowAnnexedSize(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(967858083+7+4096*4), dir.Size) + assert.Equal(t, 7, dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(967858083), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestBrokenSymlinkSkipped(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("xxx", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(7+4096*4), dir.Size) + assert.Equal(t, 6, dir.ItemCount) + + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} + +func BenchmarkAnalyzeDir(b *testing.B) { + fin := testdir.CreateTestDir() + defer fin() + + b.ResetTimer() + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) +} diff --git a/gdu/pkg/analyze/dir_unix.go b/gdu/pkg/analyze/dir_unix.go new file mode 100644 index 0000000..88f5afa --- /dev/null +++ b/gdu/pkg/analyze/dir_unix.go @@ -0,0 +1,32 @@ +//go:build darwin || netbsd || freebsd +// +build darwin netbsd freebsd + +package analyze + +import ( + "os" + "syscall" + "time" +) + +const devBSize = 512 + +func setPlatformSpecificAttrs(file *File, f os.FileInfo) { + if stat, ok := f.Sys().(*syscall.Stat_t); ok { + file.Usage = stat.Blocks * devBSize + file.Mtime = time.Unix(int64(stat.Mtimespec.Sec), int64(stat.Mtimespec.Nsec)) + + if stat.Nlink > 1 { + file.Mli = stat.Ino + } + } +} + +func setDirPlatformSpecificAttrs(dir *Dir, path string) { + var stat syscall.Stat_t + if err := syscall.Stat(path, &stat); err != nil { + return + } + + dir.Mtime = time.Unix(int64(stat.Mtimespec.Sec), int64(stat.Mtimespec.Nsec)) +} diff --git a/gdu/pkg/analyze/encode.go b/gdu/pkg/analyze/encode.go new file mode 100644 index 0000000..3db9932 --- /dev/null +++ b/gdu/pkg/analyze/encode.go @@ -0,0 +1,101 @@ +package analyze + +import ( + "encoding/json" + "io" + "strconv" +) + +// EncodeJSON writes JSON representation of dir +func (f *Dir) EncodeJSON(writer io.Writer, topLevel bool) error { + buff := make([]byte, 0, 20) + + buff = append(buff, []byte(`[{"name":`)...) + + if topLevel { + if err := addString(&buff, f.GetPath()); err != nil { + return err + } + } else { + if err := addString(&buff, f.GetName()); err != nil { + return err + } + } + + if !f.GetMtime().IsZero() { + buff = append(buff, []byte(`,"mtime":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetMtime().Unix(), 10))...) + } + + buff = append(buff, '}') + if f.Files.Len() > 0 { + buff = append(buff, ',') + } + buff = append(buff, '\n') + + if _, err := writer.Write(buff); err != nil { + return err + } + + for i, item := range f.Files { + if i > 0 { + if _, err := writer.Write([]byte(",\n")); err != nil { + return err + } + } + err := item.EncodeJSON(writer, false) + if err != nil { + return err + } + } + + if _, err := writer.Write([]byte("]")); err != nil { + return err + } + return nil +} + +// EncodeJSON writes JSON representation of file +func (f *File) EncodeJSON(writer io.Writer, topLevel bool) error { + buff := make([]byte, 0, 20) + + buff = append(buff, []byte(`{"name":`)...) + if err := addString(&buff, f.GetName()); err != nil { + return err + } + if f.GetSize() > 0 { + buff = append(buff, []byte(`,"asize":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetSize(), 10))...) + } + if f.GetUsage() > 0 { + buff = append(buff, []byte(`,"dsize":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetUsage(), 10))...) + } + if !f.GetMtime().IsZero() { + buff = append(buff, []byte(`,"mtime":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetMtime().Unix(), 10))...) + } + + if f.Flag == '@' { + buff = append(buff, []byte(`,"notreg":true`)...) + } + if f.Flag == 'H' { + buff = append(buff, []byte(`,"ino":`+strconv.FormatUint(f.Mli, 10)+`,"hlnkc":true`)...) + } + + buff = append(buff, '}') + + if _, err := writer.Write(buff); err != nil { + return err + } + return nil +} + +func addString(buff *[]byte, val string) error { + b, err := json.Marshal(val) + if err != nil { + return err + } + *buff = append(*buff, b...) + return err +} diff --git a/gdu/pkg/analyze/encode_test.go b/gdu/pkg/analyze/encode_test.go new file mode 100644 index 0000000..9e6d91a --- /dev/null +++ b/gdu/pkg/analyze/encode_test.go @@ -0,0 +1,68 @@ +package analyze + +import ( + "bytes" + "testing" + "time" + + "b612.me/apps/b612/gdu/pkg/fs" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestEncode(t *testing.T) { + dir := &Dir{ + File: &File{ + Name: "test_dir", + Size: 10, + Usage: 18, + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + }, + ItemCount: 4, + BasePath: ".", + } + + subdir := &Dir{ + File: &File{ + Name: "nested", + Size: 9, + Usage: 14, + Parent: dir, + }, + ItemCount: 3, + } + file := &File{ + Name: "file2", + Size: 3, + Usage: 4, + Parent: subdir, + } + file2 := &File{ + Name: "file", + Size: 5, + Usage: 6, + Parent: subdir, + Flag: '@', + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + } + file3 := &File{ + Name: "file3", + Mli: 1234, + Flag: 'H', + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file, file2, file3} + + var buff bytes.Buffer + err := dir.EncodeJSON(&buff, true) + + assert.Nil(t, err) + assert.Contains(t, buff.String(), `"name":"nested"`) + assert.Contains(t, buff.String(), `"mtime":1629333600`) + assert.Contains(t, buff.String(), `"ino":1234`) + assert.Contains(t, buff.String(), `"hlnkc":true`) +} diff --git a/gdu/pkg/analyze/file.go b/gdu/pkg/analyze/file.go new file mode 100644 index 0000000..1821f06 --- /dev/null +++ b/gdu/pkg/analyze/file.go @@ -0,0 +1,256 @@ +package analyze + +import ( + "path/filepath" + "sync" + "time" + + "b612.me/apps/b612/gdu/pkg/fs" +) + +// File struct +type File struct { + Mtime time.Time + Parent fs.Item + Name string + Size int64 + Usage int64 + Mli uint64 + Flag rune +} + +// GetName returns name of dir +func (f *File) GetName() string { + return f.Name +} + +// IsDir returns false for file +func (f *File) IsDir() bool { + return false +} + +// GetParent returns parent dir +func (f *File) GetParent() fs.Item { + return f.Parent +} + +// SetParent sets parent dir +func (f *File) SetParent(parent fs.Item) { + f.Parent = parent +} + +// GetPath returns absolute Get of the file +func (f *File) GetPath() string { + return filepath.Join(f.Parent.GetPath(), f.Name) +} + +// GetFlag returns flag of the file +func (f *File) GetFlag() rune { + return f.Flag +} + +// GetSize returns size of the file +func (f *File) GetSize() int64 { + return f.Size +} + +// GetUsage returns usage of the file +func (f *File) GetUsage() int64 { + return f.Usage +} + +// GetMtime returns mtime of the file +func (f *File) GetMtime() time.Time { + return f.Mtime +} + +// GetType returns name type of item +func (f *File) GetType() string { + if f.Flag == '@' { + return "Other" + } + return "File" +} + +// GetItemCount returns 1 for file +func (f *File) GetItemCount() int { + return 1 +} + +// GetMultiLinkedInode returns inode number of multilinked file +func (f *File) GetMultiLinkedInode() uint64 { + return f.Mli +} + +func (f *File) alreadyCounted(linkedItems fs.HardLinkedItems) bool { + mli := f.Mli + counted := false + if mli > 0 { + f.Flag = 'H' + if _, ok := linkedItems[mli]; ok { + counted = true + } + linkedItems[mli] = append(linkedItems[mli], f) + } + return counted +} + +// GetItemStats returns 1 as count of items, apparent usage and real usage of this file +func (f *File) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount int, size, usage int64) { + if f.alreadyCounted(linkedItems) { + return 1, 0, 0 + } + return 1, f.GetSize(), f.GetUsage() +} + +// UpdateStats does nothing on file +func (f *File) UpdateStats(linkedItems fs.HardLinkedItems) {} + +// GetFiles returns all files in directory +func (f *File) GetFiles() fs.Files { + return fs.Files{} +} + +// GetFilesLocked returns all files in directory +func (f *File) GetFilesLocked() fs.Files { + return f.GetFiles() +} + +// RLock panics on file +func (f *File) RLock() func() { + panic("SetFiles should not be called on file") +} + +// SetFiles panics on file +func (f *File) SetFiles(files fs.Files) { + panic("SetFiles should not be called on file") +} + +// AddFile panics on file +func (f *File) AddFile(item fs.Item) { + panic("AddFile should not be called on file") +} + +// RemoveFile panics on file +func (f *File) RemoveFile(item fs.Item) { + panic("RemoveFile should not be called on file") +} + +// Dir struct +type Dir struct { + *File + BasePath string + Files fs.Files + ItemCount int + m sync.RWMutex +} + +// AddFile add item to files +func (f *Dir) AddFile(item fs.Item) { + f.Files = append(f.Files, item) +} + +// GetFiles returns all files in directory +func (f *Dir) GetFiles() fs.Files { + return f.Files +} + +// GetFilesLocked returns all files in directory +// It is safe to call this function from multiple goroutines +func (f *Dir) GetFilesLocked() fs.Files { + f.m.RLock() + defer f.m.RUnlock() + return f.GetFiles()[:] +} + +// SetFiles sets files in directory +func (f *Dir) SetFiles(files fs.Files) { + f.Files = files +} + +// GetType returns name type of item +func (f *Dir) GetType() string { + return "Directory" +} + +// GetItemCount returns number of files in dir +func (f *Dir) GetItemCount() int { + f.m.RLock() + defer f.m.RUnlock() + return f.ItemCount +} + +// IsDir returns true for dir +func (f *Dir) IsDir() bool { + return true +} + +// GetPath returns absolute path of the file +func (f *Dir) GetPath() string { + if f.BasePath != "" { + return filepath.Join(f.BasePath, f.Name) + } + if f.Parent != nil { + return filepath.Join(f.Parent.GetPath(), f.Name) + } + return f.Name +} + +// GetItemStats returns item count, apparent usage and real usage of this dir +func (f *Dir) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount int, size, usage int64) { + f.UpdateStats(linkedItems) + return f.ItemCount, f.GetSize(), f.GetUsage() +} + +// UpdateStats recursively updates size and item count +func (f *Dir) UpdateStats(linkedItems fs.HardLinkedItems) { + totalSize := int64(4096) + totalUsage := int64(4096) + var itemCount int + for _, entry := range f.GetFiles() { + count, size, usage := entry.GetItemStats(linkedItems) + totalSize += size + totalUsage += usage + itemCount += count + + if entry.GetMtime().After(f.Mtime) { + f.Mtime = entry.GetMtime() + } + + switch entry.GetFlag() { + case '!', '.': + if f.Flag != '!' { + f.Flag = '.' + } + } + } + f.ItemCount = itemCount + 1 + f.Size = totalSize + f.Usage = totalUsage +} + +// RemoveFile removes item from dir, updates size and item count +func (f *Dir) RemoveFile(item fs.Item) { + f.m.Lock() + defer f.m.Unlock() + + f.SetFiles(f.GetFiles().Remove(item)) + + cur := f + for { + cur.ItemCount -= item.GetItemCount() + cur.Size -= item.GetSize() + cur.Usage -= item.GetUsage() + + if cur.Parent == nil { + break + } + cur = cur.Parent.(*Dir) + } +} + +// RLock read locks dir +func (f *Dir) RLock() func() { + f.m.RLock() + return f.m.RUnlock +} diff --git a/gdu/pkg/analyze/file_test.go b/gdu/pkg/analyze/file_test.go new file mode 100644 index 0000000..5c6dd0a --- /dev/null +++ b/gdu/pkg/analyze/file_test.go @@ -0,0 +1,337 @@ +package analyze + +import ( + "testing" + "time" + + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestIsDir(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + } + dir.Files = fs.Files{file} + + assert.True(t, dir.IsDir()) + assert.False(t, file.IsDir()) +} + +func TestGetType(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + Flag: ' ', + } + file2 := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + Flag: '@', + } + dir.Files = fs.Files{file, file2} + + assert.Equal(t, "Directory", dir.GetType()) + assert.Equal(t, "File", file.GetType()) + assert.Equal(t, "Other", file2.GetType()) +} + +func TestFind(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + i, _ := dir.Files.IndexOf(file) + assert.Equal(t, 0, i) + i, _ = dir.Files.IndexOf(file2) + assert.Equal(t, 1, i) +} + +func TestRemove(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + dir.Files = dir.Files.Remove(file) + + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, file2, dir.Files[0]) +} + +func TestRemoveByName(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + Usage: 8, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Usage: 4, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + dir.Files = dir.Files.RemoveByName("yyy") + + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, file2, dir.Files[0]) +} + +func TestRemoveNotInDir(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + Usage: 8, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Usage: 4, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Usage: 4, + } + dir.Files = fs.Files{file} + + _, ok := dir.Files.IndexOf(file2) + assert.Equal(t, false, ok) + + dir.Files = dir.Files.Remove(file2) + + assert.Equal(t, 1, len(dir.Files)) +} + +func TestRemoveByNameNotInDir(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + Usage: 8, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Usage: 4, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Usage: 4, + } + dir.Files = fs.Files{file} + + _, ok := dir.Files.IndexOf(file2) + assert.Equal(t, false, ok) + + dir.Files = dir.Files.RemoveByName("zzz") + + assert.Equal(t, 1, len(dir.Files)) +} + +func TestUpdateStats(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 1, + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + }, + ItemCount: 1, + } + + file := &File{ + Name: "yyy", + Size: 2, + Mtime: time.Date(2021, 8, 19, 0, 41, 0, 0, time.UTC), + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Mtime: time.Date(2021, 8, 19, 0, 42, 0, 0, time.UTC), + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + dir.UpdateStats(nil) + + assert.Equal(t, int64(4096+5), dir.Size) + assert.Equal(t, 42, dir.GetMtime().Minute()) +} + +func TestGetMultiLinkedInode(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + + assert.Equal(t, uint64(5), file.GetMultiLinkedInode()) +} + +func TestGetPathWithoutLeadingSlash(t *testing.T) { + dir := &Dir{ + File: &File{ + Name: "C:\\", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "", + } + + assert.Equal(t, "C:\\", dir.GetPath()) +} + +func TestSetParent(t *testing.T) { + dir := &Dir{ + File: &File{ + Name: "root", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "/", + } + file := &File{ + Name: "xxx", + Mli: 5, + } + file.SetParent(dir) + + assert.Equal(t, "root", file.GetParent().GetName()) +} + +func TestGetFiles(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + dir := &Dir{ + File: &File{ + Name: "root", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "/", + Files: fs.Files{file}, + } + + assert.Equal(t, file.Name, dir.GetFiles()[0].GetName()) + assert.Equal(t, fs.Files{}, file.GetFiles()) +} + +func TestGetFilesLocked(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + dir := &Dir{ + File: &File{ + Name: "root", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "/", + Files: fs.Files{file}, + } + + unlock := dir.RLock() + defer unlock() + files := dir.GetFiles() + locked := dir.GetFilesLocked() + files = files.Remove(file) + assert.NotEqual(t, &files, &locked) +} + +func TestSetFilesPanicsOnFile(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + assert.Panics(t, func() { + file.SetFiles(fs.Files{file}) + }) +} + +func TestAddFilePanicsOnFile(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + assert.Panics(t, func() { + file.AddFile(file) + }) +} diff --git a/gdu/pkg/analyze/memory.go b/gdu/pkg/analyze/memory.go new file mode 100644 index 0000000..fdb1c57 --- /dev/null +++ b/gdu/pkg/analyze/memory.go @@ -0,0 +1,63 @@ +package analyze + +import ( + "runtime" + "runtime/debug" + "time" + + "github.com/pbnjay/memory" + log "github.com/sirupsen/logrus" +) + +// set GC percentage according to memory usage and system free memory +func manageMemoryUsage(c <-chan struct{}) { + disabledGC := true + + for { + select { + case <-c: + return + default: + } + + time.Sleep(time.Second) + + rebalanceGC(&disabledGC) + } +} + +/* +Try to balance performance and memory consumption. + +When less memory is used by gdu than the total free memory of the host, +Garbage Collection is disabled during the analysis phase at all. + +Otherwise GC is enabled. +The more memory is used and the less memory is free, +the more often will the GC happen. +*/ +func rebalanceGC(disabledGC *bool) { + memStats := runtime.MemStats{} + runtime.ReadMemStats(&memStats) + free := memory.FreeMemory() + + // we use less memory than is free, disable GC + if memStats.Alloc < free { + if !*disabledGC { + log.Printf( + "disabling GC, alloc: %d, free: %d", memStats.Alloc, free, + ) + debug.SetGCPercent(-1) + *disabledGC = true + } + } else { + // the more memory we use and the less memory is free, the more aggressive the GC will be + gcPercent := int(100 / float64(memStats.Alloc) * float64(free)) + log.Printf( + "setting GC percent to %d, alloc: %d, free: %d", + gcPercent, memStats.Alloc, free, + ) + debug.SetGCPercent(gcPercent) + *disabledGC = false + } +} diff --git a/gdu/pkg/analyze/memory_test.go b/gdu/pkg/analyze/memory_test.go new file mode 100644 index 0000000..50fd8e5 --- /dev/null +++ b/gdu/pkg/analyze/memory_test.go @@ -0,0 +1,27 @@ +package analyze + +import ( + "runtime" + "runtime/debug" + "testing" + + "github.com/pbnjay/memory" + "github.com/stretchr/testify/assert" +) + +func TestRebalanceGC(t *testing.T) { + memStats := runtime.MemStats{} + runtime.ReadMemStats(&memStats) + free := memory.FreeMemory() + + disabledGC := false + rebalanceGC(&disabledGC) + + if free > memStats.Alloc { + assert.True(t, disabledGC) + assert.Equal(t, -1, debug.SetGCPercent(100)) + } else { + assert.False(t, disabledGC) + assert.Greater(t, 0, debug.SetGCPercent(-1)) + } +} diff --git a/gdu/pkg/analyze/parallel.go b/gdu/pkg/analyze/parallel.go new file mode 100644 index 0000000..e758eed --- /dev/null +++ b/gdu/pkg/analyze/parallel.go @@ -0,0 +1,227 @@ +package analyze + +import ( + "os" + "path/filepath" + "runtime" + "runtime/debug" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/fs" + log "github.com/sirupsen/logrus" +) + +var concurrencyLimit = make(chan struct{}, 3*runtime.GOMAXPROCS(0)) + +// ParallelAnalyzer implements Analyzer +type ParallelAnalyzer struct { + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + followSymlinks bool + gitAnnexedSize bool +} + +// CreateAnalyzer returns Analyzer +func CreateAnalyzer() *ParallelAnalyzer { + return &ParallelAnalyzer{ + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + } +} + +// SetFollowSymlinks sets whether symlink to files should be followed +func (a *ParallelAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +// SetShowAnnexedSize sets whether to use annexed size of git-annex files +func (a *ParallelAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// GetProgressChan returns channel for getting progress +func (a *ParallelAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns channel for checking when analysis is done +func (a *ParallelAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +// ResetProgress returns progress +func (a *ParallelAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) + a.wait = (&WaitGroup{}).Init() +} + +// AnalyzeDir analyzes given path +func (a *ParallelAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, constGC bool, +) fs.Item { + if !constGC { + defer debug.SetGCPercent(debug.SetGCPercent(-1)) + go manageMemoryUsage(a.doneChan) + } + + a.ignoreDir = ignore + + go a.updateProgress() + dir := a.processDir(path) + + dir.BasePath = filepath.Dir(path) + a.wait.Wait() + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return dir +} + +func (a *ParallelAnalyzer) processDir(path string) *Dir { + var ( + file *File + err error + totalSize int64 + info os.FileInfo + subDirChan = make(chan *Dir) + dirCount int + ) + + a.wait.Add(1) + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + dir := &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: getDirFlag(err, len(files)), + }, + ItemCount: 1, + Files: make(fs.Files, 0, len(files)), + } + setDirPlatformSpecificAttrs(dir, path) + + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + dirCount++ + + go func(entryPath string) { + concurrencyLimit <- struct{}{} + subdir := a.processDir(entryPath) + subdir.Parent = dir + + subDirChan <- subdir + <-concurrencyLimit + }(entryPath) + } else { + info, err = f.Info() + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { + infoF, err := followSymlink(entryPath, a.gitAnnexedSize) + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if infoF != nil { + info = infoF + } + } + + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: dir, + } + setPlatformSpecificAttrs(file, info) + + totalSize += info.Size() + + dir.AddFile(file) + } + } + + go func() { + var sub *Dir + + for i := 0; i < dirCount; i++ { + sub = <-subDirChan + dir.AddFile(sub) + } + + a.wait.Done() + }() + + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: len(files), + TotalSize: totalSize, + } + return dir +} + +func (a *ParallelAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} + +func getDirFlag(err error, items int) rune { + switch { + case err != nil: + return '!' + case items == 0: + return 'e' + default: + return ' ' + } +} + +func getFlag(f os.FileInfo) rune { + if f.Mode()&os.ModeSymlink != 0 || f.Mode()&os.ModeSocket != 0 { + return '@' + } + return ' ' +} diff --git a/gdu/pkg/analyze/sequential.go b/gdu/pkg/analyze/sequential.go new file mode 100644 index 0000000..fc4811c --- /dev/null +++ b/gdu/pkg/analyze/sequential.go @@ -0,0 +1,185 @@ +package analyze + +import ( + "os" + "path/filepath" + "runtime/debug" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/fs" + log "github.com/sirupsen/logrus" +) + +// SequentialAnalyzer implements Analyzer +type SequentialAnalyzer struct { + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + followSymlinks bool + gitAnnexedSize bool +} + +// CreateSeqAnalyzer returns Analyzer +func CreateSeqAnalyzer() *SequentialAnalyzer { + return &SequentialAnalyzer{ + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + } +} + +// SetFollowSymlinks sets whether symlink to files should be followed +func (a *SequentialAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +// SetShowAnnexedSize sets whether to use annexed size of git-annex files +func (a *SequentialAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// GetProgressChan returns channel for getting progress +func (a *SequentialAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns channel for checking when analysis is done +func (a *SequentialAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +// ResetProgress returns progress +func (a *SequentialAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) +} + +// AnalyzeDir analyzes given path +func (a *SequentialAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, constGC bool, +) fs.Item { + if !constGC { + defer debug.SetGCPercent(debug.SetGCPercent(-1)) + go manageMemoryUsage(a.doneChan) + } + + a.ignoreDir = ignore + + go a.updateProgress() + dir := a.processDir(path) + + dir.BasePath = filepath.Dir(path) + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return dir +} + +func (a *SequentialAnalyzer) processDir(path string) *Dir { + var ( + file *File + err error + totalSize int64 + info os.FileInfo + dirCount int + ) + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + dir := &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: getDirFlag(err, len(files)), + }, + ItemCount: 1, + Files: make(fs.Files, 0, len(files)), + } + setDirPlatformSpecificAttrs(dir, path) + + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + dirCount++ + + subdir := a.processDir(entryPath) + subdir.Parent = dir + dir.AddFile(subdir) + } else { + info, err = f.Info() + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { + infoF, err := followSymlink(entryPath, a.gitAnnexedSize) + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if infoF != nil { + info = infoF + } + } + + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: dir, + } + setPlatformSpecificAttrs(file, info) + + totalSize += info.Size() + + dir.AddFile(file) + } + } + + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: len(files), + TotalSize: totalSize, + } + return dir +} + +func (a *SequentialAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} diff --git a/gdu/pkg/analyze/sequential_test.go b/gdu/pkg/analyze/sequential_test.go new file mode 100644 index 0000000..a689a15 --- /dev/null +++ b/gdu/pkg/analyze/sequential_test.go @@ -0,0 +1,206 @@ +package analyze + +import ( + "os" + "sort" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzeDirSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + + progress := <-analyzer.GetProgressChan() + assert.GreaterOrEqual(t, progress.TotalSize, int64(0)) + + analyzer.GetDone().Wait() + analyzer.ResetProgress() + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, 5, dir.ItemCount) + assert.True(t, dir.IsDir()) + + // test dir tree + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "subnested", dir.Files[0].(*Dir).Files[1].GetName()) + + // test file + assert.Equal(t, "file2", dir.Files[0].(*Dir).Files[0].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[0].GetSize()) + + assert.Equal( + t, "file", dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetName(), + ) + assert.Equal( + t, int64(5), dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetSize(), + ) + + // test parent link + assert.Equal( + t, + "test_dir", + dir.Files[0].(*Dir). + Files[1].(*Dir). + Files[0]. + GetParent(). + GetParent(). + GetParent(). + GetName(), + ) +} + +func TestIgnoreDirSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateSeqAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return true }, false, + ).(*Dir) + + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, 1, dir.ItemCount) +} + +func TestFlagsSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(28+4096*4), dir.Size) + assert.Equal(t, 7, dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(21), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestHardlinkSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, int64(7+4096*3), dir.Size) // file2 and file3 are counted just once for size + assert.Equal(t, 6, dir.ItemCount) // but twice for item count + + // test file3 + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, 'H', dir.Files[0].(*Dir).Files[1].GetFlag()) +} + +func TestFollowSymlinkSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("./file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(9+4096*4), dir.Size) + assert.Equal(t, 7, dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, ' ', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestBrokenSymlinkSkippedSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("xxx", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(7+4096*4), dir.Size) + assert.Equal(t, 6, dir.ItemCount) + + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} + +func BenchmarkAnalyzeDirSeq(b *testing.B) { + fin := testdir.CreateTestDir() + defer fin() + + b.ResetTimer() + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) +} diff --git a/gdu/pkg/analyze/sort_test.go b/gdu/pkg/analyze/sort_test.go new file mode 100644 index 0000000..449373c --- /dev/null +++ b/gdu/pkg/analyze/sort_test.go @@ -0,0 +1,193 @@ +package analyze + +import ( + "sort" + "testing" + "time" + + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestSortByUsage(t *testing.T) { + files := fs.Files{ + &File{ + Usage: 1, + }, + &File{ + Usage: 2, + }, + &File{ + Usage: 3, + }, + } + + sort.Sort(sort.Reverse(files)) + + assert.Equal(t, int64(3), files[0].GetUsage()) + assert.Equal(t, int64(2), files[1].GetUsage()) + assert.Equal(t, int64(1), files[2].GetUsage()) +} + +func TestStableSortByUsage(t *testing.T) { + files := fs.Files{ + &File{ + Name: "aaa", + Usage: 1, + }, + &File{ + Name: "bbb", + Usage: 1, + }, + &File{ + Name: "ccc", + Usage: 3, + }, + } + + sort.Sort(sort.Reverse(files)) + + assert.Equal(t, "ccc", files[0].GetName()) + assert.Equal(t, "bbb", files[1].GetName()) + assert.Equal(t, "aaa", files[2].GetName()) +} + +func TestSortByUsageAsc(t *testing.T) { + files := fs.Files{ + &File{ + Size: 1, + }, + &File{ + Size: 2, + }, + &File{ + Size: 3, + }, + } + + sort.Sort(files) + + assert.Equal(t, int64(1), files[0].GetSize()) + assert.Equal(t, int64(2), files[1].GetSize()) + assert.Equal(t, int64(3), files[2].GetSize()) +} + +func TestSortBySize(t *testing.T) { + files := fs.Files{ + &File{ + Size: 1, + }, + &File{ + Size: 2, + }, + &File{ + Size: 3, + }, + } + + sort.Sort(sort.Reverse(fs.ByApparentSize(files))) + + assert.Equal(t, int64(3), files[0].GetSize()) + assert.Equal(t, int64(2), files[1].GetSize()) + assert.Equal(t, int64(1), files[2].GetSize()) +} + +func TestSortBySizeAsc(t *testing.T) { + files := fs.Files{ + &File{ + Size: 1, + }, + &File{ + Size: 2, + }, + &File{ + Size: 3, + }, + } + + sort.Sort(fs.ByApparentSize(files)) + + assert.Equal(t, int64(1), files[0].GetSize()) + assert.Equal(t, int64(2), files[1].GetSize()) + assert.Equal(t, int64(3), files[2].GetSize()) +} + +func TestSortByItemCount(t *testing.T) { + files := fs.Files{ + &Dir{ + ItemCount: 1, + }, + &Dir{ + ItemCount: 2, + }, + &Dir{ + ItemCount: 3, + }, + } + + sort.Sort(sort.Reverse(fs.ByItemCount(files))) + + assert.Equal(t, 3, files[0].GetItemCount()) + assert.Equal(t, 2, files[1].GetItemCount()) + assert.Equal(t, 1, files[2].GetItemCount()) +} + +func TestSortByName(t *testing.T) { + files := fs.Files{ + &File{ + Name: "aa", + }, + &File{ + Name: "bb", + }, + &File{ + Name: "cc", + }, + } + + sort.Sort(sort.Reverse(fs.ByName(files))) + + assert.Equal(t, "cc", files[0].GetName()) + assert.Equal(t, "bb", files[1].GetName()) + assert.Equal(t, "aa", files[2].GetName()) +} + +func TestNaturalSortByNameAsc(t *testing.T) { + files := fs.Files{ + &File{ + Name: "aa3", + }, + &File{ + Name: "aa20", + }, + &File{ + Name: "aa100", + }, + } + + sort.Sort(fs.ByName(files)) + + assert.Equal(t, "aa3", files[0].GetName()) + assert.Equal(t, "aa20", files[1].GetName()) + assert.Equal(t, "aa100", files[2].GetName()) +} + +func TestSortByMtime(t *testing.T) { + files := fs.Files{ + &File{ + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + }, + &File{ + Mtime: time.Date(2021, 8, 19, 0, 41, 0, 0, time.UTC), + }, + &File{ + Mtime: time.Date(2021, 8, 19, 0, 42, 0, 0, time.UTC), + }, + } + + sort.Sort(sort.Reverse(fs.ByMtime(files))) + + assert.Equal(t, 42, files[0].GetMtime().Minute()) + assert.Equal(t, 41, files[1].GetMtime().Minute()) + assert.Equal(t, 40, files[2].GetMtime().Minute()) +} diff --git a/gdu/pkg/analyze/storage.go b/gdu/pkg/analyze/storage.go new file mode 100644 index 0000000..a561767 --- /dev/null +++ b/gdu/pkg/analyze/storage.go @@ -0,0 +1,142 @@ +package analyze + +import ( + "bytes" + "encoding/gob" + "path/filepath" + "sync" + + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/dgraph-io/badger/v3" + "github.com/pkg/errors" +) + +func init() { + gob.RegisterName("analyze.StoredDir", &StoredDir{}) + gob.RegisterName("analyze.Dir", &Dir{}) + gob.RegisterName("analyze.File", &File{}) + gob.RegisterName("analyze.ParentDir", &ParentDir{}) +} + +// DefaultStorage is a default instance of badger storage +var DefaultStorage *Storage + +// Storage represents a badger storage +type Storage struct { + db *badger.DB + storagePath string + topDir string + m sync.RWMutex + counter int + counterM sync.Mutex +} + +// NewStorage returns new instance of badger storage +func NewStorage(storagePath, topDir string) *Storage { + st := &Storage{ + storagePath: storagePath, + topDir: topDir, + } + DefaultStorage = st + return st +} + +// GetTopDir returns top directory +func (s *Storage) GetTopDir() string { + return s.topDir +} + +// IsOpen returns true if badger DB is open +func (s *Storage) IsOpen() bool { + s.m.RLock() + defer s.m.RUnlock() + return s.db != nil +} + +// Open opens badger DB +func (s *Storage) Open() func() { + options := badger.DefaultOptions(s.storagePath) + options.Logger = nil + db, err := badger.Open(options) + if err != nil { + panic(err) + } + s.db = db + + return func() { + s.db.Close() + s.db = nil + } +} + +// StoreDir saves item info into badger DB +func (s *Storage) StoreDir(dir fs.Item) error { + s.checkCount() + s.m.RLock() + defer s.m.RUnlock() + + return s.db.Update(func(txn *badger.Txn) error { + b := &bytes.Buffer{} + enc := gob.NewEncoder(b) + err := enc.Encode(dir) + if err != nil { + return errors.Wrap(err, "encoding dir value") + } + + return txn.Set([]byte(dir.GetPath()), b.Bytes()) + }) +} + +// LoadDir saves item info into badger DB +func (s *Storage) LoadDir(dir fs.Item) error { + s.checkCount() + s.m.RLock() + defer s.m.RUnlock() + + return s.db.View(func(txn *badger.Txn) error { + path := dir.GetPath() + item, err := txn.Get([]byte(path)) + if err != nil { + return errors.Wrap(err, "reading stored value for path: "+path) + } + return item.Value(func(val []byte) error { + b := bytes.NewBuffer(val) + dec := gob.NewDecoder(b) + return dec.Decode(dir) + }) + }) +} + +// GetDirForPath returns Dir for given path +func (s *Storage) GetDirForPath(path string) (fs.Item, error) { + dirPath := filepath.Dir(path) + name := filepath.Base(path) + dir := &StoredDir{ + &Dir{ + File: &File{ + Name: name, + }, + BasePath: dirPath, + }, + nil, + sync.Mutex{}, + } + err := s.LoadDir(dir) + if err != nil { + return nil, err + } + return dir, nil +} + +func (s *Storage) checkCount() { + s.counterM.Lock() + defer s.counterM.Unlock() + s.counter++ + if s.counter >= 10000 { + s.m.Lock() + defer s.m.Unlock() + s.counter = 0 + s.db.Close() + s.Open() + } +} diff --git a/gdu/pkg/analyze/stored.go b/gdu/pkg/analyze/stored.go new file mode 100644 index 0000000..2d8fd21 --- /dev/null +++ b/gdu/pkg/analyze/stored.go @@ -0,0 +1,398 @@ +package analyze + +import ( + "io" + "os" + "path/filepath" + "runtime/debug" + "sync" + "time" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/fs" + log "github.com/sirupsen/logrus" +) + +// StoredAnalyzer implements Analyzer +type StoredAnalyzer struct { + storage *Storage + storagePath string + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + followSymlinks bool + gitAnnexedSize bool +} + +// CreateStoredAnalyzer returns Analyzer +func CreateStoredAnalyzer(storagePath string) *StoredAnalyzer { + return &StoredAnalyzer{ + storagePath: storagePath, + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + } +} + +// GetProgressChan returns channel for getting progress +func (a *StoredAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns channel for checking when analysis is done +func (a *StoredAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +func (a *StoredAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +func (a *StoredAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// ResetProgress returns progress +func (a *StoredAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) + a.wait = (&WaitGroup{}).Init() +} + +// AnalyzeDir analyzes given path +func (a *StoredAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, constGC bool, +) fs.Item { + if !constGC { + defer debug.SetGCPercent(debug.SetGCPercent(-1)) + go manageMemoryUsage(a.doneChan) + } + + a.storage = NewStorage(a.storagePath, path) + closeFn := a.storage.Open() + defer func() { + // nasty hack to close storage after all goroutines are done + // Wait returns immediately if value is 0 + // few last goroutines might still start after that + time.Sleep(1 * time.Second) + closeFn() + }() + + a.ignoreDir = ignore + + go a.updateProgress() + dir := a.processDir(path) + + a.wait.Wait() + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return dir +} + +func (a *StoredAnalyzer) processDir(path string) *StoredDir { + var ( + file *File + err error + totalSize int64 + info os.FileInfo + dirCount int + ) + + a.wait.Add(1) + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + dir := &StoredDir{ + Dir: &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: getDirFlag(err, len(files)), + }, + BasePath: filepath.Dir(path), + ItemCount: 1, + Files: make(fs.Files, 0, len(files)), + }, + } + parent := &ParentDir{Path: path} + + setDirPlatformSpecificAttrs(dir.Dir, path) + + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + dirCount++ + + subdir := &StoredDir{ + &Dir{ + File: &File{ + Name: name, + }, + BasePath: path, + }, + nil, + sync.Mutex{}, + } + dir.AddFile(subdir) + + go func(entryPath string) { + concurrencyLimit <- struct{}{} + a.processDir(entryPath) + <-concurrencyLimit + }(entryPath) + } else { + info, err = f.Info() + if err != nil { + log.Print(err.Error()) + continue + } + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: parent, + } + setPlatformSpecificAttrs(file, info) + + totalSize += info.Size() + + dir.AddFile(file) + } + } + + err = a.storage.StoreDir(dir) + if err != nil { + log.Print(err.Error()) + } + + a.wait.Done() + + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: len(files), + TotalSize: totalSize, + } + return dir +} + +func (a *StoredAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} + +// StoredDir implements Dir item stored on disk +type StoredDir struct { + *Dir + cachedFiles fs.Files + dbLock sync.Mutex +} + +// GetParent returns parent dir +func (f *StoredDir) GetParent() fs.Item { + if DefaultStorage.GetTopDir() == f.GetPath() { + return nil + } + + if !DefaultStorage.IsOpen() { + closeFn := DefaultStorage.Open() + defer closeFn() + } + + dir, err := DefaultStorage.GetDirForPath(f.BasePath) + if err != nil { + log.Print(err.Error()) + } + return dir +} + +// GetFiles returns files in directory +// If files are already cached, return them +// Otherwise load them from storage +func (f *StoredDir) GetFiles() fs.Files { + if f.cachedFiles != nil { + return f.cachedFiles + } + + if !DefaultStorage.IsOpen() { + f.dbLock.Lock() + defer f.dbLock.Unlock() + closeFn := DefaultStorage.Open() + defer closeFn() + } + + var files fs.Files + for _, file := range f.Files { + if file.IsDir() { + dir := &StoredDir{ + &Dir{ + File: &File{ + Name: file.GetName(), + }, + BasePath: f.GetPath(), + }, + nil, + sync.Mutex{}, + } + + err := DefaultStorage.LoadDir(dir) + if err != nil { + log.Print(err.Error()) + } + files = append(files, dir) + } else { + files = append(files, file) + } + } + + f.cachedFiles = files + return files +} + +// SetFiles sets files in directory +func (f *StoredDir) SetFiles(files fs.Files) { + f.Files = files +} + +// RemoveFile removes file from stored directory +// It also updates size and item count of parent directories +func (f *StoredDir) RemoveFile(item fs.Item) { + if !DefaultStorage.IsOpen() { + f.dbLock.Lock() + defer f.dbLock.Unlock() + closeFn := DefaultStorage.Open() + defer closeFn() + } + + f.SetFiles(f.GetFiles().Remove(item)) + f.cachedFiles = nil + + cur := f + for { + cur.ItemCount -= item.GetItemCount() + cur.Size -= item.GetSize() + cur.Usage -= item.GetUsage() + + err := DefaultStorage.StoreDir(cur) + if err != nil { + log.Print(err.Error()) + } + + parent := cur.GetParent() + if parent == nil { + break + } + cur = parent.(*StoredDir) + } +} + +// GetItemStats returns item count, apparent usage and real usage of this dir +func (f *StoredDir) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount int, size, usage int64) { + f.UpdateStats(linkedItems) + return f.ItemCount, f.GetSize(), f.GetUsage() +} + +// UpdateStats recursively updates size and item count +func (f *StoredDir) UpdateStats(linkedItems fs.HardLinkedItems) { + if !DefaultStorage.IsOpen() { + closeFn := DefaultStorage.Open() + defer closeFn() + } + + totalSize := int64(4096) + totalUsage := int64(4096) + var itemCount int + f.cachedFiles = nil + for _, entry := range f.GetFiles() { + count, size, usage := entry.GetItemStats(linkedItems) + totalSize += size + totalUsage += usage + itemCount += count + + if entry.GetMtime().After(f.Mtime) { + f.Mtime = entry.GetMtime() + } + + switch entry.GetFlag() { + case '!', '.': + if f.Flag != '!' { + f.Flag = '.' + } + } + } + f.cachedFiles = nil + f.ItemCount = itemCount + 1 + f.Size = totalSize + f.Usage = totalUsage + err := DefaultStorage.StoreDir(f) + if err != nil { + log.Print(err.Error()) + } +} + +// ParentDir represents parent directory of single file +// It is used to get path to parent directory of a file +type ParentDir struct { + Path string +} + +func (p *ParentDir) GetPath() string { + return p.Path +} +func (p *ParentDir) GetName() string { panic("must not be called") } +func (p *ParentDir) GetFlag() rune { panic("must not be called") } +func (p *ParentDir) IsDir() bool { panic("must not be called") } +func (p *ParentDir) GetSize() int64 { panic("must not be called") } +func (p *ParentDir) GetType() string { panic("must not be called") } +func (p *ParentDir) GetUsage() int64 { panic("must not be called") } +func (p *ParentDir) GetMtime() time.Time { panic("must not be called") } +func (p *ParentDir) GetItemCount() int { panic("must not be called") } +func (p *ParentDir) GetParent() fs.Item { panic("must not be called") } +func (p *ParentDir) SetParent(fs.Item) { panic("must not be called") } +func (p *ParentDir) GetMultiLinkedInode() uint64 { panic("must not be called") } +func (p *ParentDir) EncodeJSON(writer io.Writer, topLevel bool) error { panic("must not be called") } +func (p *ParentDir) UpdateStats(linkedItems fs.HardLinkedItems) { panic("must not be called") } +func (p *ParentDir) AddFile(fs.Item) { panic("must not be called") } +func (p *ParentDir) GetFiles() fs.Files { panic("must not be called") } +func (p *ParentDir) GetFilesLocked() fs.Files { panic("must not be called") } +func (p *ParentDir) RLock() func() { panic("must not be called") } +func (p *ParentDir) SetFiles(fs.Files) { panic("must not be called") } +func (p *ParentDir) RemoveFile(item fs.Item) { panic("must not be called") } +func (p *ParentDir) GetItemStats( + linkedItems fs.HardLinkedItems, +) (itemCount int, size, usage int64) { + panic("must not be called") +} diff --git a/gdu/pkg/analyze/stored_test.go b/gdu/pkg/analyze/stored_test.go new file mode 100644 index 0000000..cab758a --- /dev/null +++ b/gdu/pkg/analyze/stored_test.go @@ -0,0 +1,310 @@ +package analyze + +import ( + "bytes" + "encoding/gob" + "fmt" + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestEncDec(t *testing.T) { + var d fs.Item = &StoredDir{ + Dir: &Dir{ + File: &File{ + Name: "xxx", + }, + BasePath: "/yyy", + }, + } + + b := &bytes.Buffer{} + enc := gob.NewEncoder(b) + err := enc.Encode(d) + assert.NoError(t, err) + + var x fs.Item = &StoredDir{} + dec := gob.NewDecoder(b) + err = dec.Decode(x) + assert.NoError(t, err) + + fmt.Println(d, x) + assert.Equal(t, d.GetName(), x.GetName()) +} + +func TestStoredAnalyzer(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + a := CreateStoredAnalyzer("/tmp/badger") + dir := a.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*StoredDir) + + a.GetDone().Wait() + + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, 5, dir.ItemCount) + assert.True(t, dir.IsDir()) + + // test dir tree + assert.Equal(t, "nested", dir.GetFiles()[0].GetName()) + assert.Equal(t, "subnested", dir.GetFiles()[0].(*StoredDir).GetFiles()[1].GetName()) + + // test file + assert.Equal(t, "file2", dir.GetFiles()[0].(*StoredDir).GetFiles()[0].GetName()) + assert.Equal(t, int64(2), dir.GetFiles()[0].(*StoredDir).GetFiles()[0].GetSize()) + assert.Equal(t, int64(4096), dir.GetFiles()[0].(*StoredDir).GetFiles()[0].GetUsage()) + + assert.Equal( + t, "file", dir.GetFiles()[0].(*StoredDir).GetFiles()[1].(*StoredDir).GetFiles()[0].GetName(), + ) + assert.Equal( + t, int64(5), dir.GetFiles()[0].(*StoredDir).GetFiles()[1].(*StoredDir).GetFiles()[0].GetSize(), + ) +} + +func TestRemoveStoredFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + a := CreateStoredAnalyzer("/tmp/badger") + dir := a.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*StoredDir) + + a.GetDone().Wait() + a.ResetProgress() + + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, 5, dir.ItemCount) + assert.True(t, dir.IsDir()) + + subdir := dir.GetFiles()[0].(*StoredDir) + subdir.RemoveFile(subdir.GetFiles()[0]) + + closeFn := DefaultStorage.Open() + defer closeFn() + stored, err := DefaultStorage.GetDirForPath("test_dir") + assert.NoError(t, err) + + assert.Equal(t, 4, stored.GetItemCount()) + assert.Equal(t, int64(5+4096*3), stored.GetSize()) + + file := stored.GetFiles()[0].GetFiles()[0].GetFiles()[0] + assert.Equal(t, false, file.IsDir()) + assert.Equal(t, "file", file.GetName()) + assert.Equal(t, "test_dir/nested/subnested", file.GetParent().GetPath()) +} + +func TestParentDirGetNamePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetName() +} + +func TestParentDirGetFlagPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetFlag() +} + +func TestParentDirIsDirPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.IsDir() +} + +func TestParentDirGetSizePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetSize() +} + +func TestParentDirGetTypePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetType() +} + +func TestParentDirGetUsagePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetUsage() +} + +func TestParentDirGetMtimePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetMtime() +} + +func TestParentDirGetItemCountPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetItemCount() +} + +func TestParentDirGetParentPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetParent() +} + +func TestParentDirSetParentPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.SetParent(nil) +} + +func TestParentDirGetMultiLinkedInodePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetMultiLinkedInode() +} + +func TestParentDirEncodeJSONPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + err := dir.EncodeJSON(nil, false) + assert.NoError(t, err) +} + +func TestParentDirUpdateStatsPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.UpdateStats(nil) +} + +func TestParentDirAddFilePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.AddFile(nil) +} + +func TestParentDirGetFilesPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetFiles() +} + +func TestParentDirGetFilesLockedPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetFilesLocked() +} + +func TestParentDirRLockPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.RLock() +} + +func TestParentDirSetFilesPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.SetFiles(nil) +} + +func TestParentDirRemoveFilePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.RemoveFile(nil) +} + +func TestParentDirGetItemStatsPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetItemStats(nil) +} diff --git a/gdu/pkg/analyze/symlink.go b/gdu/pkg/analyze/symlink.go new file mode 100644 index 0000000..bf7635c --- /dev/null +++ b/gdu/pkg/analyze/symlink.go @@ -0,0 +1,40 @@ +package analyze + +import ( + "os" + "path/filepath" + "strings" + + "b612.me/apps/b612/gdu/pkg/annex" +) + +func followSymlink(path string, gitAnnexedSize bool) (tInfo os.FileInfo, err error) { + target, err := filepath.EvalSymlinks(path) + if err != nil { + target, err = os.Readlink(path) + if err != nil { + return nil, err + } + if gitAnnexedSize && strings.Contains(target, ".git/annex/objects") { + tInfo, err = os.Lstat(path) + if err != nil { + return nil, err + } + + name := filepath.Base(target) + tInfo = annex.AnnexedFileInfo(tInfo, name) + return tInfo, nil + } + } + + tInfo, err = os.Lstat(target) + if err != nil { + return nil, err + } + + if tInfo.IsDir() { + return nil, nil + } + + return tInfo, nil +} diff --git a/gdu/pkg/analyze/symlink_test.go b/gdu/pkg/analyze/symlink_test.go new file mode 100644 index 0000000..88c779b --- /dev/null +++ b/gdu/pkg/analyze/symlink_test.go @@ -0,0 +1,42 @@ +package analyze + +import ( + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestFollowSymlinkErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink( + ".git/annex/objects/qx/qX/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4", + "test_dir/nested/file3") + assert.Nil(t, err) + + err = os.Symlink( + "test_dir/nested", + "test_dir/some_dir") + assert.Nil(t, err) + + _, err = followSymlink("xxx", false) + assert.ErrorContains(t, err, "no such file or directory") + + _, err = followSymlink("test_dir/nested/file3", false) + assert.ErrorContains(t, err, "no such file or directory") + + _, err = followSymlink("test_dir/nested/file3", true) + assert.NoError(t, err) + + res, err := followSymlink("test_dir/some_dir", true) + assert.Equal(t, nil, res) + assert.NoError(t, err) +} diff --git a/gdu/pkg/analyze/top.go b/gdu/pkg/analyze/top.go new file mode 100644 index 0000000..05fd2de --- /dev/null +++ b/gdu/pkg/analyze/top.go @@ -0,0 +1,48 @@ +package analyze + +import ( + "sort" + + "b612.me/apps/b612/gdu/pkg/fs" +) + +// TopList is a list of top largest files +type TopList struct { + Count int + Items fs.Files + MinSize int64 +} + +// NewTopList creates new TopList +func NewTopList(count int) *TopList { + return &TopList{Count: count} +} + +// Add adds file to the list +func (tl *TopList) Add(file fs.Item) { + if file.GetSize() > tl.MinSize || len(tl.Items) < tl.Count { + tl.Items = append(tl.Items, file) + sort.Sort(fs.ByApparentSize(tl.Items)) + if len(tl.Items) > tl.Count { + tl.Items = tl.Items[1:] + } + tl.MinSize = tl.Items[0].GetSize() + } +} + +func CollectTopFiles(dir fs.Item, count int) fs.Files { + topList := NewTopList(count) + walkDir(dir, topList) + sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) + return topList.Items +} + +func walkDir(dir fs.Item, topList *TopList) { + for _, item := range dir.GetFiles() { + if item.IsDir() { + walkDir(item, topList) + } else { + topList.Add(item) + } + } +} diff --git a/gdu/pkg/analyze/top_test.go b/gdu/pkg/analyze/top_test.go new file mode 100644 index 0000000..7105ff6 --- /dev/null +++ b/gdu/pkg/analyze/top_test.go @@ -0,0 +1,69 @@ +package analyze + +import ( + "sort" + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestCollectTopFiles2(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ) + + topFiles := CollectTopFiles(dir, 2) + assert.Equal(t, 2, len(topFiles)) + assert.Equal(t, "file", topFiles[0].GetName()) + assert.Equal(t, int64(5), topFiles[0].GetSize()) + assert.Equal(t, "file2", topFiles[1].GetName()) + assert.Equal(t, int64(2), topFiles[1].GetSize()) +} + +func TestCollectTopFiles1(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ) + + topFiles := CollectTopFiles(dir, 1) + assert.Equal(t, 1, len(topFiles)) + assert.Equal(t, "file", topFiles[0].GetName()) + assert.Equal(t, int64(5), topFiles[0].GetSize()) +} + +func TestAdd2(t *testing.T) { + topList := NewTopList(2) + topList.Add(&File{Size: 1, Name: "file1"}) + topList.Add(&File{Size: 5, Name: "file5"}) + topList.Add(&File{Size: 2, Name: "file2"}) + + sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) + + assert.Equal(t, 2, len(topList.Items)) + assert.Equal(t, "file5", topList.Items[0].GetName()) + assert.Equal(t, "file2", topList.Items[1].GetName()) +} + +func TestAdd3(t *testing.T) { + topList := NewTopList(3) + topList.Add(&File{Size: 5, Name: "file5"}) + topList.Add(&File{Size: 1, Name: "file1"}) + topList.Add(&File{Size: 2, Name: "file2"}) + topList.Add(&File{Size: 4, Name: "file4"}) + topList.Add(&File{Size: 3, Name: "file3"}) + + sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) + + assert.Equal(t, 3, len(topList.Items)) + assert.Equal(t, "file5", topList.Items[0].GetName()) + assert.Equal(t, "file4", topList.Items[1].GetName()) + assert.Equal(t, "file3", topList.Items[2].GetName()) +} diff --git a/gdu/pkg/analyze/wait.go b/gdu/pkg/analyze/wait.go new file mode 100644 index 0000000..f81b577 --- /dev/null +++ b/gdu/pkg/analyze/wait.go @@ -0,0 +1,49 @@ +package analyze + +import "sync" + +// A WaitGroup waits for a collection of goroutines to finish. +// In contrast to sync.WaitGroup Add method can be called from a goroutine. +type WaitGroup struct { + wait sync.Mutex + value int + access sync.Mutex +} + +// Init prepares the WaitGroup for usage, locks +func (s *WaitGroup) Init() *WaitGroup { + s.wait.Lock() + return s +} + +// Add increments value +func (s *WaitGroup) Add(value int) { + s.access.Lock() + s.value += value + s.access.Unlock() +} + +// Done decrements the value by one, if value is 0, lock is released +func (s *WaitGroup) Done() { + s.access.Lock() + s.value-- + s.check() + s.access.Unlock() +} + +// Wait blocks until value is 0 +func (s *WaitGroup) Wait() { + s.access.Lock() + isValue := s.value > 0 + s.access.Unlock() + if isValue { + s.wait.Lock() + } +} + +func (s *WaitGroup) check() { + if s.value == 0 { + s.wait.TryLock() + s.wait.Unlock() + } +} diff --git a/gdu/pkg/annex/annex.go b/gdu/pkg/annex/annex.go new file mode 100644 index 0000000..ceda847 --- /dev/null +++ b/gdu/pkg/annex/annex.go @@ -0,0 +1,65 @@ +package annex + +import ( + "fmt" + "io/fs" + "log" + "strconv" + "strings" +) + +// SizeFromKey returns size from git-annex key. +func SizeFromKey(name string) (int64, error) { + nameParts := strings.SplitN(name, "--", 2) + backendKVs := nameParts[0] + backendKVParts := strings.Split(backendKVs, "-") + + if len(backendKVParts) < 2 { + return 0, fmt.Errorf("key is is missing backend") + } + + for _, p := range backendKVParts[1:] { + if p == "" || p[0] != 's' { + continue + } + + size, err := strconv.ParseInt(p[1:], 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse size: %w", err) + } + + return size, nil + } + + return 0, fmt.Errorf("size not found in key") +} + +// AnnexedFileInfo returns a new FileInfo with size from git-annex key. +func AnnexedFileInfo(fi fs.FileInfo, name string) *FileInfo { + size, err := SizeFromKey(name) + if err != nil { + log.Print(err.Error()) + return &FileInfo{FileInfo: fi} + } + + afi := &FileInfo{ + FileInfo: fi, + size: size, + } + + return afi +} + +var _ fs.FileInfo = (*FileInfo)(nil) + +// FileInfo is a wrapper around fs.FileInfo to overwrite the size. +type FileInfo struct { + fs.FileInfo + + size int64 +} + +// Length in bytes for regular files; system-dependent for others +func (fi *FileInfo) Size() int64 { + return int64(fi.size) +} diff --git a/gdu/pkg/annex/annex_test.go b/gdu/pkg/annex/annex_test.go new file mode 100644 index 0000000..5b8c072 --- /dev/null +++ b/gdu/pkg/annex/annex_test.go @@ -0,0 +1,39 @@ +package annex + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAnnexedFileInfo(t *testing.T) { + fi := &FileInfo{} + fi = AnnexedFileInfo(fi, "SHA256E-s967858083--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + + assert.Equal(t, int64(967858083), fi.Size()) +} + +func TestAnnexedFileInfoErr(t *testing.T) { + fi := &FileInfo{} + fi = AnnexedFileInfo(fi, "xxx") + + assert.Equal(t, int64(0), fi.Size()) +} + +func TestSizeFromKeyErr(t *testing.T) { + _, err := SizeFromKey("xxx") + assert.Error(t, err) + assert.ErrorContains(t, err, "key is is missing backend") + + _, err = SizeFromKey("SHA256E-sXXX--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to parse size") + + _, err = SizeFromKey("SHA256E-s--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to parse size") + + _, err = SizeFromKey("SHA256E-a-b-c--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + assert.Error(t, err) + assert.ErrorContains(t, err, "size not found in key") +} diff --git a/gdu/pkg/device/dev.go b/gdu/pkg/device/dev.go new file mode 100644 index 0000000..21991ca --- /dev/null +++ b/gdu/pkg/device/dev.go @@ -0,0 +1,56 @@ +package device + +import "strings" + +// Device struct +type Device struct { + Name string + MountPoint string + Fstype string + Size int64 + Free int64 +} + +// GetUsage returns used size of device +func (d Device) GetUsage() int64 { + return d.Size - d.Free +} + +// DevicesInfoGetter is type for GetDevicesInfo function +type DevicesInfoGetter interface { + GetMounts() (Devices, error) + GetDevicesInfo() (Devices, error) +} + +// Devices if slice of Device items +type Devices []*Device + +// ByUsedSize sorts devices by used size +type ByUsedSize Devices + +func (f ByUsedSize) Len() int { return len(f) } +func (f ByUsedSize) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByUsedSize) Less(i, j int) bool { + return f[i].GetUsage() < f[j].GetUsage() +} + +// ByName sorts devices by device name +type ByName Devices + +func (f ByName) Len() int { return len(f) } +func (f ByName) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByName) Less(i, j int) bool { + return f[i].Name < f[j].Name +} + +// GetNestedMountpointsPaths returns paths of nested mount points +func GetNestedMountpointsPaths(path string, mounts Devices) []string { + paths := make([]string, 0, len(mounts)) + + for _, mount := range mounts { + if strings.HasPrefix(mount.MountPoint, path) && mount.MountPoint != path { + paths = append(paths, mount.MountPoint) + } + } + return paths +} diff --git a/gdu/pkg/device/dev_bsd.go b/gdu/pkg/device/dev_bsd.go new file mode 100644 index 0000000..2e008b5 --- /dev/null +++ b/gdu/pkg/device/dev_bsd.go @@ -0,0 +1,75 @@ +//go:build netbsd || openbsd +// +build netbsd openbsd + +package device + +import ( + "bufio" + "bytes" + "errors" + "io" + "os/exec" + "regexp" + "strings" +) + +// BSDDevicesInfoGetter returns info for Darwin devices +type BSDDevicesInfoGetter struct { + MountCmd string +} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} + +// GetMounts returns all mounted filesystems from output of /sbin/mount +func (t BSDDevicesInfoGetter) GetMounts() (Devices, error) { + out, err := exec.Command(t.MountCmd).Output() + if err != nil { + return nil, err + } + + rdr := bytes.NewReader(out) + + return readMountOutput(rdr) +} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) +func (t BSDDevicesInfoGetter) GetDevicesInfo() (Devices, error) { + mounts, err := t.GetMounts() + if err != nil { + return nil, err + } + + return processMounts(mounts, false) +} + +func readMountOutput(rdr io.Reader) (Devices, error) { + mounts := Devices{} + + scanner := bufio.NewScanner(rdr) + for scanner.Scan() { + line := scanner.Text() + + re := regexp.MustCompile("^(.*) on (/.*) type (.*) \\(([^)]+)\\)$") + parts := re.FindAllStringSubmatch(line, -1) + + if len(parts) < 1 { + return nil, errors.New("Cannot parse mount output") + } + + fstype := strings.TrimSpace(strings.Split(parts[0][3], ",")[0]) + + device := &Device{ + Name: parts[0][1], + MountPoint: parts[0][2], + Fstype: fstype, + } + mounts = append(mounts, device) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return mounts, nil +} diff --git a/gdu/pkg/device/dev_bsd_test.go b/gdu/pkg/device/dev_bsd_test.go new file mode 100644 index 0000000..3b6f40b --- /dev/null +++ b/gdu/pkg/device/dev_bsd_test.go @@ -0,0 +1,22 @@ +//go:build freebsd || openbsd || netbsd || darwin +// +build freebsd openbsd netbsd darwin + +package device + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDevicesInfo(t *testing.T) { + getter := BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} + devices, _ := getter.GetDevicesInfo() + assert.IsType(t, Devices{}, devices) +} + +func TestGetDevicesInfoFail(t *testing.T) { + getter := BSDDevicesInfoGetter{MountCmd: "/nonexistent"} + _, err := getter.GetDevicesInfo() + assert.Equal(t, "fork/exec /nonexistent: no such file or directory", err.Error()) +} diff --git a/gdu/pkg/device/dev_freebsd_darwin_other.go b/gdu/pkg/device/dev_freebsd_darwin_other.go new file mode 100644 index 0000000..215462e --- /dev/null +++ b/gdu/pkg/device/dev_freebsd_darwin_other.go @@ -0,0 +1,100 @@ +//go:build freebsd || darwin +// +build freebsd darwin + +package device + +import ( + "bufio" + "bytes" + "errors" + "io" + "os/exec" + "regexp" + "strings" + + "golang.org/x/sys/unix" +) + +// BSDDevicesInfoGetter returns info for Darwin devices +type BSDDevicesInfoGetter struct { + MountCmd string +} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} + +// GetMounts returns all mounted filesystems from output of /sbin/mount +func (t BSDDevicesInfoGetter) GetMounts() (Devices, error) { + out, err := exec.Command(t.MountCmd).Output() + if err != nil { + return nil, err + } + + rdr := bytes.NewReader(out) + + return readMountOutput(rdr) +} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) +func (t BSDDevicesInfoGetter) GetDevicesInfo() (Devices, error) { + mounts, err := t.GetMounts() + if err != nil { + return nil, err + } + + return processMounts(mounts, false) +} + +func readMountOutput(rdr io.Reader) (Devices, error) { + mounts := Devices{} + + scanner := bufio.NewScanner(rdr) + for scanner.Scan() { + line := scanner.Text() + + re := regexp.MustCompile(`^(.*) on (/.*) \(([^)]+)\)$`) + parts := re.FindAllStringSubmatch(line, -1) + + if len(parts) < 1 { + return nil, errors.New("Cannot parse mount output") + } + + fstype := strings.TrimSpace(strings.Split(parts[0][3], ",")[0]) + + device := &Device{ + Name: parts[0][1], + MountPoint: parts[0][2], + Fstype: fstype, + } + mounts = append(mounts, device) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return mounts, nil +} + +func processMounts(mounts Devices, ignoreErrors bool) (Devices, error) { + devices := Devices{} + + for _, mount := range mounts { + if !strings.HasPrefix(mount.Name, "/dev") && mount.Fstype != "zfs" { + continue + } + + info := &unix.Statfs_t{} + err := unix.Statfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, err + } + + mount.Size = int64(info.Bsize) * int64(info.Blocks) + mount.Free = int64(info.Bsize) * int64(info.Bavail) + + devices = append(devices, mount) + } + + return devices, nil +} diff --git a/gdu/pkg/device/dev_freebsd_darwin_test.go b/gdu/pkg/device/dev_freebsd_darwin_test.go new file mode 100644 index 0000000..2cf0077 --- /dev/null +++ b/gdu/pkg/device/dev_freebsd_darwin_test.go @@ -0,0 +1,44 @@ +//go:build freebsd || darwin +// +build freebsd darwin + +package device + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZfsMountsShown(t *testing.T) { + mounts, _ := readMountOutput(strings.NewReader(`/dev/ada0p2 on / (ufs, local, soft-updates) +devfs on /dev (devfs) +tmpfs on /tmp (tmpfs, local) +fdescfs on /dev/fd (fdescfs) +procfs on /proc (procfs, local) +t on /t (zfs, local, nfsv4acls) +t/db on /t/db (zfs, local, nfsv4acls) +t/vm on /t/vm (zfs, local, nfsv4acls) +t/log/pflog on /var/log/pflog (zfs, local, nfsv4acls) +t/log on /t/log (zfs, local, nfsv4acls) +devfs on /compat/linux/dev (devfs) +fdescfs on /compat/linux/dev/fd (fdescfs) +tmpfs on /compat/linux/dev/shm (tmpfs, local) +map -hosts on /net (autofs) +argon:/usr/src on /usr/src (nfs) +argon:/usr/obj on /usr/obj (nfs)`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 6) + assert.Nil(t, err) +} + +func TestMountsWithSpace(t *testing.T) { + mounts, err := readMountOutput(strings.NewReader( + `//inglor@vault.lan/volatile on /Users/inglor/Mountpoints/volatile (vault.lan) (smbfs, nodev, nosuid, mounted by inglor)`, + )) + assert.Equal(t, "//inglor@vault.lan/volatile", mounts[0].Name) + assert.Equal(t, "/Users/inglor/Mountpoints/volatile (vault.lan)", mounts[0].MountPoint) + assert.Equal(t, "smbfs", mounts[0].Fstype) + assert.Nil(t, err) +} diff --git a/gdu/pkg/device/dev_linux.go b/gdu/pkg/device/dev_linux.go new file mode 100644 index 0000000..116b011 --- /dev/null +++ b/gdu/pkg/device/dev_linux.go @@ -0,0 +1,104 @@ +package device + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/sys/unix" +) + +// LinuxDevicesInfoGetter returns info for Linux devices +type LinuxDevicesInfoGetter struct { + MountsPath string +} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = LinuxDevicesInfoGetter{MountsPath: "/proc/mounts"} + +// GetMounts returns all mounted filesystems from /proc/mounts +func (t LinuxDevicesInfoGetter) GetMounts() (Devices, error) { + file, err := os.Open(t.MountsPath) + if err != nil { + return nil, err + } + + devices, err := readMountsFile(file) + if err != nil { + if cerr := file.Close(); cerr != nil { + return nil, fmt.Errorf("%w; %s", err, cerr.Error()) + } + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + return devices, nil +} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) +func (t LinuxDevicesInfoGetter) GetDevicesInfo() (Devices, error) { + mounts, err := t.GetMounts() + if err != nil { + return nil, err + } + + return processMounts(mounts, false) +} + +func readMountsFile(file io.Reader) (Devices, error) { + mounts := Devices{} + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + + device := &Device{ + Name: parts[0], + MountPoint: unescapeString(parts[1]), + Fstype: parts[2], + } + mounts = append(mounts, device) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return mounts, nil +} + +func processMounts(mounts Devices, ignoreErrors bool) (Devices, error) { + devices := Devices{} + + for _, mount := range mounts { + if strings.Contains(mount.MountPoint, "/snap/") { + continue + } + + if strings.HasPrefix(mount.Name, "/dev") || + mount.Fstype == "zfs" || + mount.Fstype == "nfs" || + mount.Fstype == "nfs4" { + info := &unix.Statfs_t{} + err := unix.Statfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, err + } + + mount.Size = int64(info.Bsize) * int64(info.Blocks) + mount.Free = int64(info.Bsize) * int64(info.Bavail) + + devices = append(devices, mount) + } + } + + return devices, nil +} + +func unescapeString(str string) string { + return strings.ReplaceAll(str, "\\040", " ") +} diff --git a/gdu/pkg/device/dev_linux_test.go b/gdu/pkg/device/dev_linux_test.go new file mode 100644 index 0000000..72e0908 --- /dev/null +++ b/gdu/pkg/device/dev_linux_test.go @@ -0,0 +1,72 @@ +//go:build linux +// +build linux + +package device + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDevicesInfo(t *testing.T) { + getter := LinuxDevicesInfoGetter{MountsPath: "/proc/mounts"} + devices, _ := getter.GetDevicesInfo() + assert.IsType(t, Devices{}, devices) +} + +func TestGetDevicesInfoFail(t *testing.T) { + getter := LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"} + _, err := getter.GetDevicesInfo() + assert.Equal(t, "open /xxxyyy: no such file or directory", err.Error()) +} + +func TestSnapMountsNotShown(t *testing.T) { + mounts, _ := readMountsFile(strings.NewReader(`/dev/loop4 /var/lib/snapd/snap/core18/1944 squashfs ro,nodev,relatime 0 0 +/dev/loop3 /var/lib/snapd/snap/core20/904 squashfs ro,nodev,relatime 0 0 +/dev/nvme0n1p1 /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 1) + assert.Nil(t, err) +} + +func TestZfsMountsShown(t *testing.T) { + mounts, _ := readMountsFile(strings.NewReader(`rootpool/opt /opt zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/usr/local /usr/local zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/home/root /root zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/usr/games /usr/games zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/home /home zfs rw,nodev,relatime,xattr,posixacl 0 0 +/dev/loop4 /var/lib/snapd/snap/core18/1944 squashfs ro,nodev,relatime 0 0 +/dev/loop3 /var/lib/snapd/snap/core20/904 squashfs ro,nodev,relatime 0 0 +/dev/nvme0n1p1 /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 6) + assert.Nil(t, err) +} + +func TestNfsMountsShown(t *testing.T) { + // nolint: lll // Why: Test data + mounts, _ := readMountsFile(strings.NewReader(`host1:/dir1/ /mnt/dir1 nfs4 rw,nosuid,nodev,noatime,nodiratime,vers=4.2,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=192.168.1.1,fsc,local_lock=none,addr=192.168.1.2 0 0 +host2:/dir2/ /mnt/dir2 nfs rw,relatime,vers=3,rsize=524288,wsize=524288,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,mountaddr=192.168.1.3,mountvers=3,mountport=38081,mountproto=udp,fsc,local_lock=none,addr=192.168.1.4 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 2) + assert.Equal(t, "host1:/dir1/", devices[0].Name) + assert.Equal(t, "/mnt/dir1", devices[0].MountPoint) + assert.Nil(t, err) +} + +func TestMountsWithSpaces(t *testing.T) { + // nolint: lll // Why: Test data + mounts, _ := readMountsFile(strings.NewReader(`host1:/dir1/ /mnt/dir\040with\040spaces nfs4 rw,nosuid,nodev,noatime,nodiratime,vers=4.2,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=192.168.1.1,fsc,local_lock=none,addr=192.168.1.2 0 0 +host2:/dir2/ /mnt/dir2 nfs rw,relatime,vers=3,rsize=524288,wsize=524288,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,mountaddr=192.168.1.3,mountvers=3,mountport=38081,mountproto=udp,fsc,local_lock=none,addr=192.168.1.4 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 2) + assert.Equal(t, "host1:/dir1/", devices[0].Name) + assert.Equal(t, "/mnt/dir with spaces", devices[0].MountPoint) + assert.Nil(t, err) +} diff --git a/gdu/pkg/device/dev_netbsd.go b/gdu/pkg/device/dev_netbsd.go new file mode 100644 index 0000000..8809846 --- /dev/null +++ b/gdu/pkg/device/dev_netbsd.go @@ -0,0 +1,31 @@ +//go:build netbsd +// +build netbsd + +package device + +import ( + "strings" + + "golang.org/x/sys/unix" +) + +func processMounts(mounts Devices, ignoreErrors bool) (Devices, error) { + devices := Devices{} + + for _, mount := range mounts { + if strings.HasPrefix(mount.Name, "/dev") || mount.Fstype == "zfs" { + info := &unix.Statvfs_t{} + err := unix.Statvfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, err + } + + mount.Size = int64(info.Bsize) * int64(info.Blocks) + mount.Free = int64(info.Bsize) * int64(info.Bavail) + + devices = append(devices, mount) + } + } + + return devices, nil +} diff --git a/gdu/pkg/device/dev_openbsd.go b/gdu/pkg/device/dev_openbsd.go new file mode 100644 index 0000000..2875a8d --- /dev/null +++ b/gdu/pkg/device/dev_openbsd.go @@ -0,0 +1,32 @@ +//go:build openbsd +// +build openbsd + +package device + +import ( + "fmt" + "strings" + + "golang.org/x/sys/unix" +) + +func processMounts(mounts Devices, ignoreErrors bool) (Devices, error) { + devices := Devices{} + + for _, mount := range mounts { + if strings.HasPrefix(mount.Name, "/dev") || mount.Fstype == "zfs" { + info := &unix.Statfs_t{} + err := unix.Statfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, fmt.Errorf("getting stats for mount point: \"%s\", %w", mount.MountPoint, err) + } + + mount.Size = int64(info.F_bsize) * int64(info.F_blocks) + mount.Free = int64(info.F_bsize) * int64(info.F_bavail) + + devices = append(devices, mount) + } + } + + return devices, nil +} diff --git a/gdu/pkg/device/dev_other.go b/gdu/pkg/device/dev_other.go new file mode 100644 index 0000000..2f3751a --- /dev/null +++ b/gdu/pkg/device/dev_other.go @@ -0,0 +1,22 @@ +//go:build windows || plan9 +// +build windows plan9 + +package device + +import "errors" + +// OtherDevicesInfoGetter returns info for other devices +type OtherDevicesInfoGetter struct{} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = OtherDevicesInfoGetter{} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices +func (t OtherDevicesInfoGetter) GetDevicesInfo() (Devices, error) { + return nil, errors.New("Only Linux platform is supported for listing devices") +} + +// GetMounts returns all mounted filesystems +func (t OtherDevicesInfoGetter) GetMounts() (Devices, error) { + return nil, errors.New("Only Linux platform is supported for listing mount points") +} diff --git a/gdu/pkg/device/dev_test.go b/gdu/pkg/device/dev_test.go new file mode 100644 index 0000000..2ce2673 --- /dev/null +++ b/gdu/pkg/device/dev_test.go @@ -0,0 +1,73 @@ +package device + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNested(t *testing.T) { + item := &Device{ + MountPoint: "/xxx", + } + nested := &Device{ + MountPoint: "/xxx/yyy", + } + notNested := &Device{ + MountPoint: "/zzz/yyy", + } + + mounts := Devices{item, nested, notNested} + + mountsNested := GetNestedMountpointsPaths("/xxx", mounts) + + assert.Len(t, mountsNested, 1) + assert.Equal(t, "/xxx/yyy", mountsNested[0]) +} + +func TestSortByName(t *testing.T) { + item := &Device{ + Name: "/xxx", + } + nested := &Device{ + Name: "/xxx/yyy", + } + notNested := &Device{ + Name: "/zzz/yyy", + } + + devices := Devices{item, nested, notNested} + + sort.Sort(sort.Reverse(ByName(devices))) + + assert.Equal(t, "/zzz/yyy", devices[0].Name) + assert.Equal(t, "/xxx/yyy", devices[1].Name) + assert.Equal(t, "/xxx", devices[2].Name) +} + +func TestSortByUsedSize(t *testing.T) { + item := &Device{ + Name: "xxx", + Size: 1e12, + Free: 1e3, + } + nested := &Device{ + Name: "yyy", + Size: 1e12, + Free: 1e6, + } + notNested := &Device{ + Name: "zzz", + Size: 1e12, + Free: 1e12, + } + + devices := Devices{item, nested, notNested} + + sort.Sort(ByUsedSize(devices)) + + assert.Equal(t, "zzz", devices[0].Name) + assert.Equal(t, "yyy", devices[1].Name) + assert.Equal(t, "xxx", devices[2].Name) +} diff --git a/gdu/pkg/fs/file.go b/gdu/pkg/fs/file.go new file mode 100644 index 0000000..9b83aa2 --- /dev/null +++ b/gdu/pkg/fs/file.go @@ -0,0 +1,133 @@ +package fs + +import ( + "io" + "time" + + "github.com/maruel/natural" +) + +// Item is a FS item (file or dir) +type Item interface { + GetPath() string + GetName() string + GetFlag() rune + IsDir() bool + GetSize() int64 + GetType() string + GetUsage() int64 + GetMtime() time.Time + GetItemCount() int + GetParent() Item + SetParent(Item) + GetMultiLinkedInode() uint64 + EncodeJSON(writer io.Writer, topLevel bool) error + GetItemStats(linkedItems HardLinkedItems) (itemCount int, size, usage int64) + UpdateStats(linkedItems HardLinkedItems) + AddFile(Item) + GetFiles() Files + GetFilesLocked() Files + SetFiles(Files) + RemoveFile(Item) + RLock() func() +} + +// Files - slice of pointers to File +type Files []Item + +// HardLinkedItems maps inode number to array of all hard linked items +type HardLinkedItems map[uint64]Files + +// IndexOf searches File in Files and returns its index +func (f Files) IndexOf(file Item) (int, bool) { + for i, item := range f { + if item == file { + return i, true + } + } + return 0, false +} + +// FindByName searches name in Files and returns its index +func (f Files) FindByName(name string) (int, bool) { + for i, item := range f { + if item.GetName() == name { + return i, true + } + } + return 0, false +} + +// Remove removes File from Files +func (f Files) Remove(file Item) Files { + index, ok := f.IndexOf(file) + if !ok { + return f + } + return append(f[:index], f[index+1:]...) +} + +// RemoveByName removes File from Files +func (f Files) RemoveByName(name string) Files { + index, ok := f.FindByName(name) + if !ok { + return f + } + return append(f[:index], f[index+1:]...) +} + +func (f Files) Len() int { return len(f) } +func (f Files) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f Files) Less(i, j int) bool { + if f[i].GetUsage() != f[j].GetUsage() { + return f[i].GetUsage() < f[j].GetUsage() + } + // if usage is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} + +// ByApparentSize sorts files by apparent size +type ByApparentSize Files + +func (f ByApparentSize) Len() int { return len(f) } +func (f ByApparentSize) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByApparentSize) Less(i, j int) bool { + if f[i].GetSize() != f[j].GetSize() { + return f[i].GetSize() < f[j].GetSize() + } + // if size is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} + +// ByItemCount sorts files by item count +type ByItemCount Files + +func (f ByItemCount) Len() int { return len(f) } +func (f ByItemCount) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByItemCount) Less(i, j int) bool { + if f[i].GetItemCount() != f[j].GetItemCount() { + return f[i].GetItemCount() < f[j].GetItemCount() + } + // if item count is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} + +// ByName sorts files by name +type ByName Files + +func (f ByName) Len() int { return len(f) } +func (f ByName) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByName) Less(i, j int) bool { return natural.Less(f[i].GetName(), f[j].GetName()) } + +// ByMtime sorts files by name +type ByMtime Files + +func (f ByMtime) Len() int { return len(f) } +func (f ByMtime) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByMtime) Less(i, j int) bool { + if !f[i].GetMtime().Equal(f[j].GetMtime()) { + return f[i].GetMtime().Before(f[j].GetMtime()) + } + // if item count is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} diff --git a/gdu/pkg/path/path.go b/gdu/pkg/path/path.go new file mode 100644 index 0000000..92714cc --- /dev/null +++ b/gdu/pkg/path/path.go @@ -0,0 +1,26 @@ +package path + +import "strings" + +// ShortenPath removes the last but one path components to fit into maxLen +func ShortenPath(path string, maxLen int) string { + if len(path) <= maxLen { + return path + } + + res := "" + parts := strings.SplitAfter(path, "/") + curLen := len(parts[len(parts)-1]) // count length of last part for start + + for _, part := range parts[:len(parts)-1] { + curLen += len(part) + if curLen > maxLen { + res += ".../" + break + } + res += part + } + + res += parts[len(parts)-1] + return res +} diff --git a/gdu/pkg/path/path_test.go b/gdu/pkg/path/path_test.go new file mode 100644 index 0000000..9598e28 --- /dev/null +++ b/gdu/pkg/path/path_test.go @@ -0,0 +1,15 @@ +package path + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShortenPath(t *testing.T) { + assert.Equal(t, "/root", ShortenPath("/root", 10)) + assert.Equal(t, "/home/.../foo", ShortenPath("/home/dundee/foo", 10)) + assert.Equal(t, "/home/dundee/foo", ShortenPath("/home/dundee/foo", 50)) + assert.Equal(t, "/home/dundee/.../bar.txt", ShortenPath("/home/dundee/foo/bar.txt", 20)) + assert.Equal(t, "/home/.../bar.txt", ShortenPath("/home/dundee/foo/bar.txt", 15)) +} diff --git a/gdu/pkg/remove/parallel.go b/gdu/pkg/remove/parallel.go new file mode 100644 index 0000000..08dbde3 --- /dev/null +++ b/gdu/pkg/remove/parallel.go @@ -0,0 +1,62 @@ +package remove + +import ( + "os" + "runtime" + "sync" + + "b612.me/apps/b612/gdu/pkg/fs" +) + +var concurrencyLimit = make(chan struct{}, 3*runtime.GOMAXPROCS(0)) + +// ItemFromDirParallel removes item from dir +func ItemFromDirParallel(dir, item fs.Item) error { + if !item.IsDir() { + return ItemFromDir(dir, item) + } + errChan := make(chan error, 1) // we show only first error + var wait sync.WaitGroup + + // remove all files in the directory in parallel + for _, file := range item.GetFilesLocked() { + if !file.IsDir() { + continue + } + + wait.Add(1) + go func(itemPath string) { + concurrencyLimit <- struct{}{} + defer func() { <-concurrencyLimit }() + + err := os.RemoveAll(itemPath) + if err != nil { + select { + // write error to channel if it's empty + case errChan <- err: + default: + } + } + wait.Done() + }(file.GetPath()) + } + + wait.Wait() + + // check if there was an error + select { + case err := <-errChan: + return err + default: + } + + // remove the directory itself + err := os.RemoveAll(item.GetPath()) + if err != nil { + return err + } + + // update parent directory + dir.RemoveFile(item) + return nil +} diff --git a/gdu/pkg/remove/parallel_linux_test.go b/gdu/pkg/remove/parallel_linux_test.go new file mode 100644 index 0000000..19d481a --- /dev/null +++ b/gdu/pkg/remove/parallel_linux_test.go @@ -0,0 +1,67 @@ +//go:build linux +// +build linux + +package remove + +import ( + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestItemFromDirParallelWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + }, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Parent: dir, + }, + } + + err = ItemFromDirParallel(dir, subdir) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestItemFromDirParallelWithErr2(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested/subnested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested/subnested", 0o755) + assert.Nil(t, err) + }() + + analyzer := analyze.CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*analyze.Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + subdir := dir.Files[0].(*analyze.Dir) + + err = ItemFromDirParallel(dir, subdir) + assert.Contains(t, err.Error(), "permission denied") +} diff --git a/gdu/pkg/remove/parallel_test.go b/gdu/pkg/remove/parallel_test.go new file mode 100644 index 0000000..7aff50c --- /dev/null +++ b/gdu/pkg/remove/parallel_test.go @@ -0,0 +1,69 @@ +package remove + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" +) + +func TestRemoveFileParallel(t *testing.T) { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "yyy", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := ItemFromDirParallel(subdir, file) + assert.Nil(t, err) + + assert.Equal(t, 0, len(subdir.Files)) + assert.Equal(t, 1, subdir.ItemCount) + assert.Equal(t, int64(1), subdir.Size) + assert.Equal(t, int64(4), subdir.Usage) + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, 2, dir.ItemCount) + assert.Equal(t, int64(2), dir.Size) +} + +func TestRemoveDirParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := analyze.CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ).(*analyze.Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + subdir := dir.Files[0].(*analyze.Dir) + + err := ItemFromDirParallel(dir, subdir) + assert.Nil(t, err) +} diff --git a/gdu/pkg/remove/remove.go b/gdu/pkg/remove/remove.go new file mode 100644 index 0000000..0865ce9 --- /dev/null +++ b/gdu/pkg/remove/remove.go @@ -0,0 +1,49 @@ +package remove + +import ( + "os" + + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" +) + +// ItemFromDir removes item from dir +func ItemFromDir(dir, item fs.Item) error { + err := os.RemoveAll(item.GetPath()) + if err != nil { + return err + } + + dir.RemoveFile(item) + return nil +} + +// EmptyFileFromDir empty file from dir +func EmptyFileFromDir(dir, file fs.Item) error { + err := os.Truncate(file.GetPath(), 0) + if err != nil { + return err + } + + cur := dir.(*analyze.Dir) + for { + cur.Size -= file.GetSize() + cur.Usage -= file.GetUsage() + + if cur.Parent == nil { + break + } + cur = cur.Parent.(*analyze.Dir) + } + + dir.SetFiles(dir.GetFiles().Remove(file)) + newFile := &analyze.File{ + Name: file.GetName(), + Flag: file.GetFlag(), + Size: 0, + Parent: dir, + } + dir.AddFile(newFile) + + return nil +} diff --git a/gdu/pkg/remove/remove_linux_test.go b/gdu/pkg/remove/remove_linux_test.go new file mode 100644 index 0000000..85c415d --- /dev/null +++ b/gdu/pkg/remove/remove_linux_test.go @@ -0,0 +1,42 @@ +//go:build linux +// +build linux + +package remove + +import ( + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "github.com/stretchr/testify/assert" +) + +func TestRemoveFileWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + }, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Parent: dir, + }, + } + + err = ItemFromDir(dir, subdir) + assert.Contains(t, err.Error(), "permission denied") +} diff --git a/gdu/pkg/remove/remove_test.go b/gdu/pkg/remove/remove_test.go new file mode 100644 index 0000000..8297823 --- /dev/null +++ b/gdu/pkg/remove/remove_test.go @@ -0,0 +1,130 @@ +package remove + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" +) + +func TestTruncateFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "file2", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := EmptyFileFromDir(subdir, file) + + assert.Nil(t, err) + assert.Equal(t, 1, len(subdir.Files)) + assert.Equal(t, 2, subdir.ItemCount) + assert.Equal(t, int64(1), subdir.Size) + assert.Equal(t, int64(4), subdir.Usage) + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, 3, dir.ItemCount) + assert.Equal(t, int64(2), dir.Size) +} + +func TestRemoveFile(t *testing.T) { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "yyy", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := ItemFromDir(subdir, file) + assert.Nil(t, err) + + assert.Equal(t, 0, len(subdir.Files)) + assert.Equal(t, 1, subdir.ItemCount) + assert.Equal(t, int64(1), subdir.Size) + assert.Equal(t, int64(4), subdir.Usage) + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, 2, dir.ItemCount) + assert.Equal(t, int64(2), dir.Size) +} + +func TestTruncateFileWithErr(t *testing.T) { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "yyy", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := EmptyFileFromDir(subdir, file) + + assert.Contains(t, err.Error(), "no such file or directory") +} diff --git a/gdu/report/export.go b/gdu/report/export.go new file mode 100644 index 0000000..6b7b67b --- /dev/null +++ b/gdu/report/export.go @@ -0,0 +1,264 @@ +package report + +import ( + "bytes" + "errors" + "fmt" + "io" + "math" + "os" + "sort" + "strconv" + "sync" + "time" + + "b612.me/apps/b612/gdu/build" + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/fatih/color" +) + +// UI struct +type UI struct { + *common.UI + output io.Writer + exportOutput io.Writer + red *color.Color + orange *color.Color + writtenChan chan struct{} +} + +// CreateExportUI creates UI for stdout +func CreateExportUI( + output io.Writer, + exportOutput io.Writer, + useColors bool, + showProgress bool, + constGC bool, + useSIPrefix bool, +) *UI { + ui := &UI{ + UI: &common.UI{ + ShowProgress: showProgress, + Analyzer: analyze.CreateAnalyzer(), + ConstGC: constGC, + UseSIPrefix: useSIPrefix, + }, + output: output, + exportOutput: exportOutput, + writtenChan: make(chan struct{}), + } + ui.red = color.New(color.FgRed).Add(color.Bold) + ui.orange = color.New(color.FgYellow).Add(color.Bold) + + if !useColors { + color.NoColor = true + } + + return ui +} + +// StartUILoop stub +func (ui *UI) StartUILoop() error { + return nil +} + +// ListDevices lists mounted devices and shows their disk usage +func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { + return errors.New("Exporting devices list is not supported") +} + +// ReadAnalysis reads analysis report from JSON file +func (ui *UI) ReadAnalysis(input io.Reader) error { + return errors.New("Reading analysis is not possible while exporting") +} + +// ReadFromStorage reads analysis data from persistent key-value storage +func (ui *UI) ReadFromStorage(storagePath, path string) error { + storage := analyze.NewStorage(storagePath, path) + closeFn := storage.Open() + defer closeFn() + + dir, err := storage.GetDirForPath(path) + if err != nil { + return err + } + + var waitWritten sync.WaitGroup + if ui.ShowProgress { + waitWritten.Add(1) + go func() { + defer waitWritten.Done() + ui.updateProgress() + }() + } + + return ui.exportDir(dir, &waitWritten) +} + +// AnalyzePath analyzes recursively disk usage in given path +func (ui *UI) AnalyzePath(path string, _ fs.Item) error { + var ( + dir fs.Item + wait sync.WaitGroup + waitWritten sync.WaitGroup + ) + + if ui.ShowProgress { + waitWritten.Add(1) + go func() { + defer waitWritten.Done() + ui.updateProgress() + }() + } + + wait.Add(1) + go func() { + defer wait.Done() + dir = ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.ConstGC) + dir.UpdateStats(make(fs.HardLinkedItems, 10)) + }() + + wait.Wait() + + return ui.exportDir(dir, &waitWritten) +} + +func (ui *UI) exportDir(dir fs.Item, waitWritten *sync.WaitGroup) error { + sort.Sort(sort.Reverse(dir.GetFiles())) + + var ( + buff bytes.Buffer + err error + ) + + buff.Write([]byte(`[1,2,{"progname":"gdu","progver":"`)) + buff.Write([]byte(build.Version)) + buff.Write([]byte(`","timestamp":`)) + buff.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10))) + buff.Write([]byte("},\n")) + + if err := dir.EncodeJSON(&buff, true); err != nil { + return err + } + if _, err = buff.Write([]byte("]\n")); err != nil { + return err + } + if _, err = buff.WriteTo(ui.exportOutput); err != nil { + return err + } + + if f, ok := ui.exportOutput.(*os.File); ok { + err = f.Close() + if err != nil { + return err + } + } + + if ui.ShowProgress { + ui.writtenChan <- struct{}{} + waitWritten.Wait() + } + + return nil +} + +func (ui *UI) updateProgress() { + waitingForWrite := false + + emptyRow := "\r" + for j := 0; j < 100; j++ { + emptyRow += " " + } + + progressRunes := []rune(`⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧`) + + progressChan := ui.Analyzer.GetProgressChan() + doneChan := ui.Analyzer.GetDone() + + var progress common.CurrentProgress + + i := 0 + for { + fmt.Fprint(ui.output, emptyRow) + + select { + case progress = <-progressChan: + case <-doneChan: + fmt.Fprint(ui.output, "\r") + waitingForWrite = true + case <-ui.writtenChan: + fmt.Fprint(ui.output, "\r") + return + default: + } + + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + + if waitingForWrite { + fmt.Fprint(ui.output, "Writing output file...") + } else { + fmt.Fprint(ui.output, "Scanning... Total items: "+ + ui.red.Sprint(common.FormatNumber(int64(progress.ItemCount)))+ + " size: "+ + ui.formatSize(progress.TotalSize)) + } + + time.Sleep(100 * time.Millisecond) + i++ + i %= 10 + } +} + +func (ui *UI) formatSize(size int64) string { + if ui.UseSIPrefix { + return ui.formatWithDecPrefix(size) + } + return ui.formatWithBinPrefix(size) +} + +func (ui *UI) formatWithBinPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.Ei: + return ui.orange.Sprintf("%.1f", fsize/common.Ei) + " EiB" + case asize >= common.Pi: + return ui.orange.Sprintf("%.1f", fsize/common.Pi) + " PiB" + case asize >= common.Ti: + return ui.orange.Sprintf("%.1f", fsize/common.Ti) + " TiB" + case asize >= common.Gi: + return ui.orange.Sprintf("%.1f", fsize/common.Gi) + " GiB" + case asize >= common.Mi: + return ui.orange.Sprintf("%.1f", fsize/common.Mi) + " MiB" + case asize >= common.Ki: + return ui.orange.Sprintf("%.1f", fsize/common.Ki) + " KiB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} + +func (ui *UI) formatWithDecPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.E: + return ui.orange.Sprintf("%.1f", fsize/common.E) + " EB" + case asize >= common.P: + return ui.orange.Sprintf("%.1f", fsize/common.P) + " PB" + case asize >= common.T: + return ui.orange.Sprintf("%.1f", fsize/common.T) + " TB" + case asize >= common.G: + return ui.orange.Sprintf("%.1f", fsize/common.G) + " GB" + case asize >= common.M: + return ui.orange.Sprintf("%.1f", fsize/common.M) + " MB" + case asize >= common.K: + return ui.orange.Sprintf("%.1f", fsize/common.K) + " kB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} diff --git a/gdu/report/export_linux_test.go b/gdu/report/export_linux_test.go new file mode 100644 index 0000000..c01ede1 --- /dev/null +++ b/gdu/report/export_linux_test.go @@ -0,0 +1,56 @@ +//go:build linux +// +build linux + +package report + +import ( + "bytes" + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "github.com/stretchr/testify/assert" +) + +func TestReadFromStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + const storagePath = "/tmp/badger-test2" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + ui.SetAnalyzer(analyze.CreateStoredAnalyzer(storagePath)) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.ReadFromStorage(storagePath, "test_dir") + + assert.Nil(t, err) + assert.Contains(t, reportOutput.String(), `"name":"nested"`) +} + +func TestReadFromStorageWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + const storagePath = "/tmp/badger-test3" + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, false, false, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.ReadFromStorage(storagePath, "test_dir") + + assert.ErrorContains(t, err, "Key not found") +} diff --git a/gdu/report/export_test.go b/gdu/report/export_test.go new file mode 100644 index 0000000..27bb168 --- /dev/null +++ b/gdu/report/export_test.go @@ -0,0 +1,133 @@ +package report + +import ( + "bytes" + "os" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzePath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, false, false, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, reportOutput.String(), `"name":"nested"`) +} + +func TestAnalyzePathWithProgress(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, true, true, true, true) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, reportOutput.String(), `"name":"nested"`) +} + +func TestShowDevices(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false, false) + err := ui.ListDevices(device.Getter) + + assert.Contains(t, err.Error(), "not supported") +} + +func TestReadAnalysisWhileExporting(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false, false) + err := ui.ReadAnalysis(output) + + assert.Contains(t, err.Error(), "not possible while exporting") +} + +func TestExportToFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + reportOutput, err := os.OpenFile("output.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + assert.Nil(t, err) + defer func() { + os.Remove("output.json") + }() + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err = ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + assert.Nil(t, err) + + reportOutput, err = os.OpenFile("output.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + _, err = reportOutput.Seek(0, 0) + assert.Nil(t, err) + buff := make([]byte, 200) + _, err = reportOutput.Read(buff) + assert.Nil(t, err) + + assert.Contains(t, string(buff), `"name":"nested"`) +} + +func TestFormatSize(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false, false) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "KiB") + assert.Contains(t, ui.formatSize(1<<20+1), "MiB") + assert.Contains(t, ui.formatSize(1<<30+1), "GiB") + assert.Contains(t, ui.formatSize(1<<40+1), "TiB") + assert.Contains(t, ui.formatSize(1<<50+1), "PiB") + assert.Contains(t, ui.formatSize(1<<60+1), "EiB") + assert.Contains(t, ui.formatSize(-1<<10-1), "KiB") +} + +func TestFormatSizeDec(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false, true) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "kB") + assert.Contains(t, ui.formatSize(1<<20+1), "MB") + assert.Contains(t, ui.formatSize(1<<30+1), "GB") + assert.Contains(t, ui.formatSize(1<<40+1), "TB") + assert.Contains(t, ui.formatSize(1<<50+1), "PB") + assert.Contains(t, ui.formatSize(1<<60+1), "EB") + assert.Contains(t, ui.formatSize(-1<<10-1), "kB") +} diff --git a/gdu/report/import.go b/gdu/report/import.go new file mode 100644 index 0000000..8a14099 --- /dev/null +++ b/gdu/report/import.go @@ -0,0 +1,109 @@ +package report + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "time" + + "b612.me/apps/b612/gdu/pkg/analyze" +) + +// ReadAnalysis reads analysis report from JSON file and returns directory item +func ReadAnalysis(input io.Reader) (*analyze.Dir, error) { + var data interface{} + + var buff bytes.Buffer + if _, err := buff.ReadFrom(input); err != nil { + return nil, err + } + if err := json.Unmarshal(buff.Bytes(), &data); err != nil { + return nil, err + } + + dataArray, ok := data.([]interface{}) + if !ok { + return nil, errors.New("JSON file does not contain top level array") + } + if len(dataArray) < 4 { + return nil, errors.New("Top level array must have at least 4 items") + } + + items, ok := dataArray[3].([]interface{}) + if !ok { + return nil, errors.New("Array of maps not found in the top level array on 4th position") + } + + return processDir(items) +} + +func processDir(items []interface{}) (*analyze.Dir, error) { + dir := &analyze.Dir{ + File: &analyze.File{ + Flag: ' ', + }, + } + dirMap, ok := items[0].(map[string]interface{}) + if !ok { + return nil, errors.New("Directory item is not a map") + } + name, ok := dirMap["name"].(string) + if !ok { + return nil, errors.New("Directory name is not a string") + } + if mtime, ok := dirMap["mtime"].(float64); ok { + dir.Mtime = time.Unix(int64(mtime), 0) + } + + slashPos := strings.LastIndex(name, "/") + if slashPos > -1 { + dir.Name = name[slashPos+1:] + dir.BasePath = name[:slashPos+1] + } else { + dir.Name = name + } + + for _, v := range items[1:] { + switch item := v.(type) { + case map[string]interface{}: + file := &analyze.File{} + file.Name = item["name"].(string) + + if asize, ok := item["asize"].(float64); ok { + file.Size = int64(asize) + } + if dsize, ok := item["dsize"].(float64); ok { + file.Usage = int64(dsize) + } + if mtime, ok := item["mtime"].(float64); ok { + file.Mtime = time.Unix(int64(mtime), 0) + } + if _, ok := item["notreg"].(bool); ok { + file.Flag = '@' + } else { + file.Flag = ' ' + } + if mli, ok := item["ino"].(float64); ok { + file.Mli = uint64(mli) + } + if _, ok := item["hlnkc"].(bool); ok { + file.Flag = 'H' + } + + file.Parent = dir + + dir.AddFile(file) + case []interface{}: + subdir, err := processDir(item) + if err != nil { + return nil, err + } + subdir.Parent = dir + dir.AddFile(subdir) + } + } + + return dir, nil +} diff --git a/gdu/report/import_test.go b/gdu/report/import_test.go new file mode 100644 index 0000000..c4a148a --- /dev/null +++ b/gdu/report/import_test.go @@ -0,0 +1,111 @@ +package report + +import ( + "bytes" + "errors" + "testing" + + "b612.me/apps/b612/gdu/pkg/analyze" + log "github.com/sirupsen/logrus" + + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestReadAnalysis(t *testing.T) { + buff := bytes.NewBuffer([]byte(` + [1,2,{"progname":"gdu","progver":"development","timestamp":1626806293}, + [{"name":"/home/xxx","mtime":1629333600}, + {"name":"gdu.json","asize":33805233,"dsize":33808384}, + {"name":"sock","notreg":true}, + [{"name":"app"}, + {"name":"app.go","asize":4638,"dsize":8192}, + {"name":"app_linux_test.go","asize":1410,"dsize":4096}, + {"name":"app_linux_test2.go","ino":1234,"hlnkc":true,"asize":1410,"dsize":4096}, + {"name":"app_test.go","asize":4974,"dsize":8192}], + {"name":"main.go","asize":3205,"dsize":4096,"mtime":1629333600}]] + `)) + + dir, err := ReadAnalysis(buff) + + assert.Nil(t, err) + assert.Equal(t, "xxx", dir.GetName()) + assert.Equal(t, "/home/xxx", dir.GetPath()) + assert.Equal(t, 2021, dir.GetMtime().Year()) + assert.Equal(t, 2021, dir.Files[3].GetMtime().Year()) + alt2 := dir.Files[2].(*analyze.Dir).Files[2].(*analyze.File) + assert.Equal(t, "app_linux_test2.go", alt2.Name) + assert.Equal(t, uint64(1234), alt2.Mli) + assert.Equal(t, 'H', alt2.Flag) +} + +func TestReadAnalysisWithEmptyInput(t *testing.T) { + buff := bytes.NewBuffer([]byte(``)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "unexpected end of JSON input", err.Error()) +} + +func TestReadAnalysisWithEmptyDict(t *testing.T) { + buff := bytes.NewBuffer([]byte(`{}`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "JSON file does not contain top level array", err.Error()) +} + +func TestReadFromBrokenInput(t *testing.T) { + _, err := ReadAnalysis(&BrokenInput{}) + + assert.Equal(t, "IO error", err.Error()) +} + +func TestReadAnalysisWithEmptyArray(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "Top level array must have at least 4 items", err.Error()) +} + +func TestReadAnalysisWithWrongContent(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,4]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "Array of maps not found in the top level array on 4th position", err.Error()) +} + +func TestReadAnalysisWithEmptyDirContent(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,[{}]]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "Directory name is not a string", err.Error()) +} + +func TestReadAnalysisWithWrongDirItem(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,[1, 2, 3]]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "Directory item is not a map", err.Error()) +} + +func TestReadAnalysisWithWrongSubdirItem(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,[{"name":"xxx"}, [1,2,3]]]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "Directory item is not a map", err.Error()) +} + +type BrokenInput struct{} + +func (i *BrokenInput) Read(p []byte) (n int, err error) { + return 0, errors.New("IO error") +} diff --git a/gdu/snapcraft.yaml b/gdu/snapcraft.yaml new file mode 100644 index 0000000..58ddf85 --- /dev/null +++ b/gdu/snapcraft.yaml @@ -0,0 +1,30 @@ +name: gdu-disk-usage-analyzer +version: git +summary: Pretty fast disk usage analyzer written in Go. +description: | + Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. + However HDDs work as well, but the performance gain is not so huge. +confinement: strict +base: core20 +parts: + gdu: + plugin: go + source: . + override-build: | + GO111MODULE=on CGO_ENABLED=0 go build \ + -buildmode=pie -trimpath -mod=readonly -modcacherw \ + -ldflags \ + "-s -w \ + -X 'b612.me/apps/b612/gdu/build.Version=$(git describe)' \ + -X 'b612.me/apps/b612/gdu/build.User=$(id -u -n)' \ + -X 'b612.me/apps/b612/gdu/build.Time=$(LC_ALL=en_US.UTF-8 date)' \ + -X 'b612.me/apps/b612/gdu/build.RootPathPrefix=/var/lib/snapd/hostfs'" \ + -o $SNAPCRAFT_PART_INSTALL/gdu \ + b612.me/apps/b612/gdu/cmd/gdu + $SNAPCRAFT_PART_INSTALL/gdu -v +apps: + gdu: + command: gdu + plugs: + - mount-observe + - system-backup diff --git a/gdu/stdout/stdout.go b/gdu/stdout/stdout.go new file mode 100644 index 0000000..4b847e9 --- /dev/null +++ b/gdu/stdout/stdout.go @@ -0,0 +1,494 @@ +package stdout + +import ( + "fmt" + "io" + "math" + "runtime" + "sort" + "sync" + "time" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + "b612.me/apps/b612/gdu/pkg/fs" + "b612.me/apps/b612/gdu/report" + "github.com/fatih/color" +) + +// UI struct +type UI struct { + *common.UI + output io.Writer + red *color.Color + orange *color.Color + blue *color.Color + summarize bool + noPrefix bool + top int +} + +var ( + progressRunes = []rune(`⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧`) + progressRunesOld = []rune(`-\\|/`) + progressRunesCount = len(progressRunes) +) + +// CreateStdoutUI creates UI for stdout +func CreateStdoutUI( + output io.Writer, + useColors bool, + showProgress bool, + showApparentSize bool, + showRelativeSize bool, + summarize bool, + constGC bool, + useSIPrefix bool, + noPrefix bool, + top int, +) *UI { + ui := &UI{ + UI: &common.UI{ + UseColors: useColors, + ShowProgress: showProgress, + ShowApparentSize: showApparentSize, + ShowRelativeSize: showRelativeSize, + Analyzer: analyze.CreateAnalyzer(), + ConstGC: constGC, + UseSIPrefix: useSIPrefix, + }, + output: output, + summarize: summarize, + noPrefix: noPrefix, + top: top, + } + + ui.red = color.New(color.FgRed).Add(color.Bold) + ui.orange = color.New(color.FgYellow).Add(color.Bold) + ui.blue = color.New(color.FgBlue).Add(color.Bold) + + if !useColors { + color.NoColor = true + } + + return ui +} + +func (ui *UI) UseOldProgressRunes() { + progressRunes = progressRunesOld + progressRunesCount = len(progressRunes) +} + +// StartUILoop stub +func (ui *UI) StartUILoop() error { + return nil +} + +// ListDevices lists mounted devices and shows their disk usage +func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { + devices, err := getter.GetDevicesInfo() + if err != nil { + return err + } + + maxDeviceNameLength := maxInt(maxLength( + devices, + func(device *device.Device) string { return device.Name }, + ), len("Devices")) + + var sizeLength, percentLength int + if ui.UseColors { + sizeLength = 20 + percentLength = 16 + } else { + sizeLength = 9 + percentLength = 5 + } + + lineFormat := fmt.Sprintf( + "%%%ds %%%ds %%%ds %%%ds %%%ds %%s\n", + maxDeviceNameLength, + sizeLength, + sizeLength, + sizeLength, + percentLength, + ) + + fmt.Fprintf( + ui.output, + fmt.Sprintf("%%%ds %%9s %%9s %%9s %%5s %%s\n", maxDeviceNameLength), + "Device", + "Size", + "Used", + "Free", + "Used%", + "Mount point", + ) + + for _, device := range devices { + usedPercent := math.Round(float64(device.Size-device.Free) / float64(device.Size) * 100) + + fmt.Fprintf( + ui.output, + lineFormat, + device.Name, + ui.formatSize(device.Size), + ui.formatSize(device.Size-device.Free), + ui.formatSize(device.Free), + ui.red.Sprintf("%.f%%", usedPercent), + device.MountPoint) + } + + return nil +} + +// AnalyzePath analyzes recursively disk usage in given path +func (ui *UI) AnalyzePath(path string, _ fs.Item) error { + var ( + dir fs.Item + wait sync.WaitGroup + updateStatsDone chan struct{} + ) + updateStatsDone = make(chan struct{}, 1) + + if ui.ShowProgress { + wait.Add(1) + go func() { + defer wait.Done() + ui.updateProgress(updateStatsDone) + }() + } + + wait.Add(1) + go func() { + defer wait.Done() + dir = ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.ConstGC) + dir.UpdateStats(make(fs.HardLinkedItems, 10)) + updateStatsDone <- struct{}{} + }() + + wait.Wait() + + switch { + case ui.top > 0: + ui.printTopFiles(dir) + case ui.summarize: + ui.printTotalItem(dir) + default: + ui.showDir(dir) + } + + return nil +} + +// ReadFromStorage reads analysis data from persistent key-value storage +func (ui *UI) ReadFromStorage(storagePath, path string) error { + storage := analyze.NewStorage(storagePath, path) + closeFn := storage.Open() + defer closeFn() + + dir, err := storage.GetDirForPath(path) + if err != nil { + return err + } + + switch { + case ui.top > 0: + ui.printTopFiles(dir) + case ui.summarize: + ui.printTotalItem(dir) + default: + ui.showDir(dir) + } + return nil +} + +func (ui *UI) showDir(dir fs.Item) { + sort.Sort(sort.Reverse(dir.GetFiles())) + + for _, file := range dir.GetFiles() { + ui.printItem(file) + } +} + +func (ui *UI) printTopFiles(file fs.Item) { + collected := analyze.CollectTopFiles(file, ui.top) + for _, file := range collected { + ui.printItemPath(file) + } +} + +func (ui *UI) printTotalItem(file fs.Item) { + var lineFormat string + if ui.UseColors { + lineFormat = "%20s %s\n" + } else { + lineFormat = "%9s %s\n" + } + + var size int64 + if ui.ShowApparentSize { + size = file.GetSize() + } else { + size = file.GetUsage() + } + + fmt.Fprintf( + ui.output, + lineFormat, + ui.formatSize(size), + file.GetName(), + ) +} + +func (ui *UI) printItem(file fs.Item) { + var lineFormat string + if ui.UseColors { + lineFormat = "%s %20s %s\n" + } else { + lineFormat = "%s %9s %s\n" + } + + var size int64 + if ui.ShowApparentSize { + size = file.GetSize() + } else { + size = file.GetUsage() + } + + if file.IsDir() { + fmt.Fprintf(ui.output, + lineFormat, + string(file.GetFlag()), + ui.formatSize(size), + ui.blue.Sprint("/"+file.GetName())) + } else { + fmt.Fprintf(ui.output, + lineFormat, + string(file.GetFlag()), + ui.formatSize(size), + file.GetName()) + } +} + +func (ui *UI) printItemPath(file fs.Item) { + var lineFormat string + if ui.UseColors { + lineFormat = "%20s %s\n" + } else { + lineFormat = "%9s %s\n" + } + + var size int64 + if ui.ShowApparentSize { + size = file.GetSize() + } else { + size = file.GetUsage() + } + + fmt.Fprintf(ui.output, + lineFormat, + ui.formatSize(size), + file.GetPath()) +} + +// ReadAnalysis reads analysis report from JSON file +func (ui *UI) ReadAnalysis(input io.Reader) error { + var ( + dir *analyze.Dir + wait sync.WaitGroup + err error + doneChan chan struct{} + ) + + if ui.ShowProgress { + wait.Add(1) + doneChan = make(chan struct{}) + go func() { + defer wait.Done() + ui.showReadingProgress(doneChan) + }() + } + + wait.Add(1) + go func() { + defer wait.Done() + dir, err = report.ReadAnalysis(input) + if err != nil { + if ui.ShowProgress { + doneChan <- struct{}{} + } + return + } + runtime.GC() + + dir.UpdateStats(make(fs.HardLinkedItems, 10)) + + if ui.ShowProgress { + doneChan <- struct{}{} + } + }() + + wait.Wait() + + if err != nil { + return err + } + + if ui.summarize { + ui.printTotalItem(dir) + } else { + ui.showDir(dir) + } + + return nil +} + +func (ui *UI) showReadingProgress(doneChan chan struct{}) { + emptyRow := "\r" + for j := 0; j < 40; j++ { + emptyRow += " " + } + + i := 0 + for { + fmt.Fprint(ui.output, emptyRow) + + select { + case <-doneChan: + fmt.Fprint(ui.output, "\r") + return + default: + } + + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + fmt.Fprint(ui.output, "Reading analysis from file...") + + time.Sleep(100 * time.Millisecond) + i++ + i %= progressRunesCount + } +} + +func (ui *UI) updateProgress(updateStatsDone <-chan struct{}) { + emptyRow := "\r" + for j := 0; j < 100; j++ { + emptyRow += " " + } + + progressChan := ui.Analyzer.GetProgressChan() + analysisDoneChan := ui.Analyzer.GetDone() + + var progress common.CurrentProgress + + i := 0 + for { + fmt.Fprint(ui.output, emptyRow) + + select { + case progress = <-progressChan: + case <-analysisDoneChan: + for { + fmt.Fprint(ui.output, emptyRow) + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + fmt.Fprint(ui.output, "Calculating disk usage...") + time.Sleep(100 * time.Millisecond) + i++ + i %= progressRunesCount + + select { + case <-updateStatsDone: + fmt.Fprint(ui.output, emptyRow) + fmt.Fprint(ui.output, "\r") + return + default: + } + } + } + + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + + fmt.Fprint(ui.output, "Scanning... Total items: "+ + ui.red.Sprint(common.FormatNumber(int64(progress.ItemCount)))+ + " size: "+ + ui.formatSize(progress.TotalSize)) + + time.Sleep(100 * time.Millisecond) + i++ + i %= progressRunesCount + } +} + +func (ui *UI) formatSize(size int64) string { + if ui.noPrefix { + return ui.orange.Sprintf("%d", size) + } + if ui.UseSIPrefix { + return ui.formatWithDecPrefix(size) + } + return ui.formatWithBinPrefix(size) +} + +func (ui *UI) formatWithBinPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.Ei: + return ui.orange.Sprintf("%.1f", fsize/common.Ei) + " EiB" + case asize >= common.Pi: + return ui.orange.Sprintf("%.1f", fsize/common.Pi) + " PiB" + case asize >= common.Ti: + return ui.orange.Sprintf("%.1f", fsize/common.Ti) + " TiB" + case asize >= common.Gi: + return ui.orange.Sprintf("%.1f", fsize/common.Gi) + " GiB" + case asize >= common.Mi: + return ui.orange.Sprintf("%.1f", fsize/common.Mi) + " MiB" + case asize >= common.Ki: + return ui.orange.Sprintf("%.1f", fsize/common.Ki) + " KiB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} + +func (ui *UI) formatWithDecPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.E: + return ui.orange.Sprintf("%.1f", fsize/common.E) + " EB" + case asize >= common.P: + return ui.orange.Sprintf("%.1f", fsize/common.P) + " PB" + case asize >= common.T: + return ui.orange.Sprintf("%.1f", fsize/common.T) + " TB" + case asize >= common.G: + return ui.orange.Sprintf("%.1f", fsize/common.G) + " GB" + case asize >= common.M: + return ui.orange.Sprintf("%.1f", fsize/common.M) + " MB" + case asize >= common.K: + return ui.orange.Sprintf("%.1f", fsize/common.K) + " kB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} + +func maxLength(list []*device.Device, keyGetter func(*device.Device) string) int { + maxLen := 0 + var s string + for _, item := range list { + s = keyGetter(item) + if len(s) > maxLen { + maxLen = len(s) + } + } + return maxLen +} + +func maxInt(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/gdu/stdout/stdout_linux_test.go b/gdu/stdout/stdout_linux_test.go new file mode 100644 index 0000000..a2c4a5d --- /dev/null +++ b/gdu/stdout/stdout_linux_test.go @@ -0,0 +1,28 @@ +//go:build linux +// +build linux + +package stdout + +import ( + "bytes" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestShowDevicesWithErr(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"} + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0) + err := ui.ListDevices(getter) + + assert.Contains(t, err.Error(), "no such file") +} diff --git a/gdu/stdout/stdout_test.go b/gdu/stdout/stdout_test.go new file mode 100644 index 0000000..100169f --- /dev/null +++ b/gdu/stdout/stdout_test.go @@ -0,0 +1,314 @@ +package stdout + +import ( + "bytes" + "os" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testdev" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzePath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, true, false, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "nested") +} + +func TestShowSummary(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir") +} + +func TestShowSummaryBw(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir") +} + +func TestShowTop(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false, 2) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir/nested/subnested/file") + assert.Contains(t, output.String(), "test_dir/nested/file2") +} + +func TestShowTopBw(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 2) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir/nested/subnested/file") + assert.Contains(t, output.String(), "test_dir/nested/file2") +} + +func TestAnalyzeSubdir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir/nested", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "file2") +} + +func TestAnalyzePathWithColors(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, false, false, false, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir/nested", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "subnested") +} + +func TestAnalyzePathWoUnicode(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false, 0) + ui.UseOldProgressRunes() + err := ui.AnalyzePath("test_dir/nested", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "subnested") +} + +func TestItemRows(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + err := ui.AnalyzePath("test_dir", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "KiB") +} + +func TestAnalyzePathWithProgress(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "nested") +} + +func TestShowDevices(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "Device") + assert.Contains(t, output.String(), "xxx") +} + +func TestShowDevicesWithColor(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "Device") + assert.Contains(t, output.String(), "xxx") +} + +func TestReadAnalysisWithColor(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) + err = ui.ReadAnalysis(input) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "main.go") +} + +func TestReadAnalysisBw(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false, 0) + err = ui.ReadAnalysis(input) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "main.go") +} + +func TestReadAnalysisWithWrongFile(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/wrong.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) + err = ui.ReadAnalysis(input) + + assert.NotNil(t, err) +} + +func TestReadAnalysisWithSummarize(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 0) + err = ui.ReadAnalysis(input) + + assert.Nil(t, err) + assert.Contains(t, output.String(), " gdu\n") +} + +func TestMaxInt(t *testing.T) { + assert.Equal(t, 5, maxInt(2, 5)) + assert.Equal(t, 4, maxInt(4, 2)) +} + +func TestFormatSize(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "KiB") + assert.Contains(t, ui.formatSize(1<<20+1), "MiB") + assert.Contains(t, ui.formatSize(1<<30+1), "GiB") + assert.Contains(t, ui.formatSize(1<<40+1), "TiB") + assert.Contains(t, ui.formatSize(1<<50+1), "PiB") + assert.Contains(t, ui.formatSize(1<<60+1), "EiB") +} + +func TestFormatSizeDec(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, true, false, 0) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "kB") + assert.Contains(t, ui.formatSize(1<<20+1), "MB") + assert.Contains(t, ui.formatSize(1<<30+1), "GB") + assert.Contains(t, ui.formatSize(1<<40+1), "TB") + assert.Contains(t, ui.formatSize(1<<50+1), "PB") + assert.Contains(t, ui.formatSize(1<<60+1), "EB") +} + +func TestFormatSizeRaw(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, true, true, 0) + + assert.Equal(t, ui.formatSize(1), "1") + assert.Equal(t, ui.formatSize(1<<10+1), "1025") + assert.Equal(t, ui.formatSize(1<<20+1), "1048577") + assert.Equal(t, ui.formatSize(1<<30+1), "1073741825") + assert.Equal(t, ui.formatSize(1<<40+1), "1099511627777") + assert.Equal(t, ui.formatSize(1<<50+1), "1125899906842625") + assert.Equal(t, ui.formatSize(1<<60+1), "1152921504606846977") +} + +// func printBuffer(buff *bytes.Buffer) { +// for i, x := range buff.String() { +// println(i, string(x)) +// } +// } + +func getDevicesInfoMock() device.DevicesInfoGetter { + item := &device.Device{ + Name: "xxx", + } + + mock := testdev.DevicesInfoGetterMock{} + mock.Devices = []*device.Device{item} + return mock +} diff --git a/gdu/tui/actions.go b/gdu/tui/actions.go new file mode 100644 index 0000000..031aa14 --- /dev/null +++ b/gdu/tui/actions.go @@ -0,0 +1,407 @@ +package tui + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "runtime/debug" + "strconv" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "b612.me/apps/b612/gdu/build" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + "b612.me/apps/b612/gdu/pkg/fs" + "b612.me/apps/b612/gdu/report" +) + +const ( + defaultLinesCount = 500 + linesThreshold = 20 + + actionEmpty = "empty" + actionDelete = "delete" + + actingEmpty = "emptying" + actingDelete = "deleting" +) + +// ListDevices lists mounted devices and shows their disk usage +func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { + var err error + ui.getter = getter + ui.devices, err = getter.GetDevicesInfo() + if err != nil { + return err + } + + ui.showDevices() + + return nil +} + +// AnalyzePath analyzes recursively disk usage for given path +func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { + ui.progress = tview.NewTextView().SetText("Scanning...") + ui.progress.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + ui.progress.SetTitle(" Scanning... ") + ui.progress.SetDynamicColors(true) + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(ui.progress, 8, 1, false). + AddItem(nil, 0, 1, false), 0, 50, false). + AddItem(nil, 0, 1, false) + + ui.pages.AddPage("progress", flex, true, true) + + go ui.updateProgress() + + go func() { + defer debug.FreeOSMemory() + currentDir := ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.ConstGC) + + if parentDir != nil { + currentDir.SetParent(parentDir) + parentDir.SetFiles(parentDir.GetFiles().RemoveByName(currentDir.GetName())) + parentDir.AddFile(currentDir) + } else { + ui.topDirPath = path + ui.topDir = currentDir + } + + ui.topDir.UpdateStats(ui.linkedItems) + + ui.app.QueueUpdateDraw(func() { + ui.currentDir = currentDir + ui.showDir() + ui.pages.RemovePage("progress") + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() + + return nil +} + +// ReadAnalysis reads analysis report from JSON file +func (ui *UI) ReadAnalysis(input io.Reader) error { + ui.progress = tview.NewTextView().SetText("Reading analysis from file...") + ui.progress.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + ui.progress.SetTitle(" Reading... ") + ui.progress.SetDynamicColors(true) + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 10, 1, false). + AddItem(ui.progress, 8, 1, false). + AddItem(nil, 10, 1, false), 0, 50, false). + AddItem(nil, 0, 1, false) + + ui.pages.AddPage("progress", flex, true, true) + + go func() { + var err error + ui.currentDir, err = report.ReadAnalysis(input) + if err != nil { + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage("progress") + ui.showErr("Error reading file", err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + runtime.GC() + + ui.topDirPath = ui.currentDir.GetPath() + ui.topDir = ui.currentDir + + links := make(fs.HardLinkedItems, 10) + ui.topDir.UpdateStats(links) + + ui.app.QueueUpdateDraw(func() { + ui.showDir() + ui.pages.RemovePage("progress") + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() + + return nil +} + +// ReadFromStorage reads analysis data from persistent key-value storage +func (ui *UI) ReadFromStorage(storagePath, path string) error { + storage := analyze.NewStorage(storagePath, path) + closeFn := storage.Open() + defer closeFn() + + dir, err := storage.GetDirForPath(path) + if err != nil { + return err + } + + ui.currentDir = dir + ui.topDirPath = ui.currentDir.GetPath() + ui.topDir = ui.currentDir + + ui.showDir() + return nil +} + +func (ui *UI) delete(shouldEmpty bool) { + if len(ui.markedRows) > 0 { + ui.deleteMarked(shouldEmpty) + } else { + ui.deleteSelected(shouldEmpty) + } +} + +func (ui *UI) deleteSelected(shouldEmpty bool) { + row, column := ui.table.GetSelection() + selectedItem := ui.table.GetCell(row, column).GetReference().(fs.Item) + + if ui.deleteInBackground { + ui.queueForDeletion([]fs.Item{selectedItem}, shouldEmpty) + return + } + + var action, acting string + if shouldEmpty { + action = actionEmpty + acting = actingEmpty + } else { + action = actionDelete + acting = actingDelete + } + modal := tview.NewModal().SetText( + // nolint: staticcheck // Why: fixed string + strings.Title(acting) + + " " + + tview.Escape(selectedItem.GetName()) + + "...", + ) + ui.pages.AddPage(acting, modal, true, true) + + var currentDir fs.Item + var deleteItems []fs.Item + if shouldEmpty && selectedItem.IsDir() { + currentDir = selectedItem.(*analyze.Dir) + for _, file := range currentDir.GetFiles() { + deleteItems = append(deleteItems, file) + } + } else { + currentDir = ui.currentDir + deleteItems = append(deleteItems, selectedItem) + } + + var deleteFun func(fs.Item, fs.Item) error + if shouldEmpty && !selectedItem.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + go func() { + for _, item := range deleteItems { + if err := deleteFun(currentDir, item); err != nil { + msg := "Can't " + action + " " + tview.Escape(selectedItem.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + x, y := ui.table.GetOffset() + ui.showDir() + ui.table.Select(min(row, ui.table.GetRowCount()-1), 0) + ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() +} + +func (ui *UI) showInfo() { + if ui.currentDir == nil { + return + } + + var content, numberColor string + row, column := ui.table.GetSelection() + selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) + + if ui.UseColors { + numberColor = fmt.Sprintf( + "[%s::b]", + ui.resultRow.NumberColor, + ) + } else { + numberColor = defaultColorBold + } + + linesCount := 12 + + text := tview.NewTextView().SetDynamicColors(true) + text.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + text.SetBorderColor(tcell.ColorDefault) + text.SetTitle(" Item info ") + + content += "[::b]Name:[::-] " + content += tview.Escape(selectedFile.GetName()) + "\n" + content += "[::b]Path:[::-] " + content += tview.Escape( + strings.TrimPrefix(selectedFile.GetPath(), build.RootPathPrefix), + ) + "\n" + content += "[::b]Type:[::-] " + selectedFile.GetType() + "\n\n" + + content += " [::b]Disk usage:[::-] " + content += numberColor + ui.formatSize(selectedFile.GetUsage(), false, true) + content += fmt.Sprintf(" (%s%d[-::] B)", numberColor, selectedFile.GetUsage()) + "\n" + content += "[::b]Apparent size:[::-] " + content += numberColor + ui.formatSize(selectedFile.GetSize(), false, true) + content += fmt.Sprintf(" (%s%d[-::] B)", numberColor, selectedFile.GetSize()) + "\n" + + if selectedFile.GetMultiLinkedInode() > 0 { + linkedItems := ui.linkedItems[selectedFile.GetMultiLinkedInode()] + linesCount += 2 + len(linkedItems) + content += "\nHard-linked files:\n" + for _, linkedItem := range linkedItems { + content += "\t" + linkedItem.GetPath() + "\n" + } + } + + text.SetText(content) + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(text, linesCount, 1, false). + AddItem(nil, 0, 1, false), 80, 1, false). + AddItem(nil, 0, 1, false) + + ui.pages.AddPage("info", flex, true, true) +} + +func (ui *UI) openItem() { + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + openBinary := "xdg-open" + + switch runtime.GOOS { + case "darwin": + openBinary = "open" + case "windows": + openBinary = "explorer" + } + + cmd := exec.Command(openBinary, selectedFile.GetPath()) + err := cmd.Start() + if err != nil { + ui.showErr("Error opening", err) + } +} + +func (ui *UI) confirmExport() *tview.Form { + form := tview.NewForm(). + AddInputField("File name", "export.json", 30, nil, func(v string) { + ui.exportName = v + }). + AddButton("Export", ui.exportAnalysis). + SetButtonsAlign(tview.AlignCenter) + form.SetBorder(true). + SetTitle(" Export data to JSON "). + SetInputCapture(func(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyEsc { + ui.pages.RemovePage("export") + ui.app.SetFocus(ui.table) + return nil + } + return key + }) + flex := modal(form, 50, 7) + ui.pages.AddPage("export", flex, true, true) + ui.app.SetFocus(form) + return form +} + +func (ui *UI) exportAnalysis() { + ui.pages.RemovePage("export") + + text := tview.NewTextView().SetText("Export in progress...").SetTextAlign(tview.AlignCenter) + text.SetBorder(true).SetTitle(" Export data to JSON ") + flex := modal(text, 50, 3) + ui.pages.AddPage("exporting", flex, true, true) + + go func() { + var err error + defer ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage("exporting") + if err == nil { + ui.app.SetFocus(ui.table) + } + }) + if ui.done != nil { + defer func() { + ui.done <- struct{}{} + }() + } + + var buff bytes.Buffer + + buff.Write([]byte(`[1,2,{"progname":"gdu","progver":"`)) + buff.Write([]byte(build.Version)) + buff.Write([]byte(`","timestamp":`)) + buff.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10))) + buff.Write([]byte("},\n")) + + file, err := os.Create(ui.exportName) + if err != nil { + ui.showErrFromGo("Error creating file", err) + return + } + + if err = ui.topDir.EncodeJSON(&buff, true); err != nil { + ui.showErrFromGo("Error encoding JSON", err) + return + } + + if _, err = buff.Write([]byte("]\n")); err != nil { + ui.showErrFromGo("Error writing to buffer", err) + return + } + if _, err = buff.WriteTo(file); err != nil { + ui.showErrFromGo("Error writing to file", err) + return + } + }() +} diff --git a/gdu/tui/actions_linux_test.go b/gdu/tui/actions_linux_test.go new file mode 100644 index 0000000..32a8183 --- /dev/null +++ b/gdu/tui/actions_linux_test.go @@ -0,0 +1,25 @@ +//go:build linux +// +build linux + +package tui + +import ( + "bytes" + "testing" + + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func TestShowDevicesWithError(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"} + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + err := ui.ListDevices(getter) + + assert.Contains(t, err.Error(), "no such file") +} diff --git a/gdu/tui/actions_test.go b/gdu/tui/actions_test.go new file mode 100644 index 0000000..b92e3df --- /dev/null +++ b/gdu/tui/actions_test.go @@ -0,0 +1,510 @@ +package tui + +import ( + "bytes" + "errors" + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/stretchr/testify/assert" +) + +func TestShowDevices(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.table.Draw(simScreen) + simScreen.Show() + + b, _, _ := simScreen.GetContents() + + text := []byte("Device name") + for i, r := range b[0:11] { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestShowDevicesBW(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.table.Draw(simScreen) + simScreen.Show() + + b, _, _ := simScreen.GetContents() + + text := []byte("Device name") + for i, r := range b[0:11] { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestDeviceSelected(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.UseOldSizeBar() + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + assert.Equal(t, 3, ui.table.GetRowCount()) + + ui.deviceItemSelected(1, 0) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") +} + +func TestNilDeviceSelected(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.UseOldSizeBar() + ui.SetIgnoreDirPaths([]string{"/xxx"}) + + ui.deviceItemSelected(1, 0) + + assert.Equal(t, 0, ui.table.GetRowCount()) +} + +func TestAnalyzePath(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") +} + +func TestAnalyzePathBW(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") +} + +func TestAnalyzePathWithParentDir(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, true, true, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.topDir = parentDir + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", parentDir) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, parentDir, ui.currentDir.GetParent()) + + assert.Equal(t, 5, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") +} + +func TestReadAnalysis(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false, false) + ui.done = make(chan struct{}) + + err = ui.ReadAnalysis(input) + assert.Nil(t, err) + + <-ui.done // wait for reading + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "gdu", ui.currentDir.GetName()) +} + +func TestReadAnalysisWithWrongFile(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + input, err := os.OpenFile("../internal/testdata/wrong.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.done = make(chan struct{}) + + err = ui.ReadAnalysis(input) + assert.Nil(t, err) + + <-ui.done // wait for reading + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestViewDirContents(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + res := ui.showFile() // selected item is dir, do nothing + assert.Nil(t, res) +} + +func TestViewFileWithoutCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + + res := ui.showFile() // no current directory + assert.Nil(t, res) +} + +func TestViewContentsOfNotExistingFile(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(3, 0) + + selectedFile := ui.table.GetCell(3, 0).GetReference().(fs.Item) + assert.Equal(t, "ddd", selectedFile.GetName()) + + res := ui.showFile() + assert.Nil(t, res) +} + +func TestViewFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + + file := ui.showFile() + assert.True(t, ui.pages.HasPage("file")) + + event := file.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'j', 0)) + assert.Equal(t, 'j', event.Rune()) +} + +func TestChangeCwd(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + cwd := "" + + opt := func(ui *UI) { + ui.SetChangeCwdFn(func(p string) error { + cwd = p + return nil + }) + } + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false, opt) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, cwd, "test_dir/nested/subnested") +} + +func TestChangeCwdWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + cwd := "" + + opt := func(ui *UI) { + ui.SetChangeCwdFn(func(p string) error { + cwd = p + return errors.New("failed") + }) + } + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false, opt) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, cwd, "test_dir/nested/subnested") +} + +func TestShowInfo(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} + +func TestShowInfoBW(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} + +func TestShowInfoWithHardlinks(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + nested := ui.currentDir.GetFiles()[0].(*analyze.Dir) + subnested := nested.Files[1].(*analyze.Dir) + file := subnested.Files[0].(*analyze.File) + file2 := nested.Files[0].(*analyze.File) + file.Mli = 1 + file2.Mli = 1 + + ui.currentDir.UpdateStats(ui.linkedItems) + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} + +func TestShowInfoWithoutCurrentDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + + // pressing `i` will do nothing + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + assert.False(t, ui.pages.HasPage("info")) +} + +func TestExitViewFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + + file := ui.showFile() + + assert.True(t, ui.pages.HasPage("file")) + + file.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("file")) +} diff --git a/gdu/tui/background.go b/gdu/tui/background.go new file mode 100644 index 0000000..d0bcdcf --- /dev/null +++ b/gdu/tui/background.go @@ -0,0 +1,100 @@ +package tui + +import ( + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/rivo/tview" +) + +func (ui *UI) queueForDeletion(items []fs.Item, shouldEmpty bool) { + go func() { + for _, item := range items { + ui.deleteQueue <- deleteQueueItem{item: item, shouldEmpty: shouldEmpty} + } + }() + + ui.markedRows = make(map[int]struct{}) +} + +func (ui *UI) deleteWorker() { + defer func() { + if r := recover(); r != nil { + ui.app.Stop() + panic(r) + } + }() + + for item := range ui.deleteQueue { + ui.deleteItem(item.item, item.shouldEmpty) + } +} + +func (ui *UI) deleteItem(item fs.Item, shouldEmpty bool) { + ui.increaseActiveWorkers() + defer ui.decreaseActiveWorkers() + + var action, acting string + if shouldEmpty { + action = actionEmpty + } else { + action = actionDelete + } + + var deleteFun func(fs.Item, fs.Item) error + if shouldEmpty && !item.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + + var parentDir fs.Item + var deleteItems []fs.Item + if shouldEmpty && item.IsDir() { + parentDir = item.(*analyze.Dir) + for _, file := range item.GetFilesLocked() { + deleteItems = append(deleteItems, file) + } + } else { + parentDir = ui.currentDir + deleteItems = append(deleteItems, item) + } + + for _, toDelete := range deleteItems { + if err := deleteFun(parentDir, toDelete); err != nil { + msg := "Can't " + action + " " + tview.Escape(toDelete.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + + if item.GetParent().GetPath() == ui.currentDir.GetPath() { + ui.app.QueueUpdateDraw(func() { + row, _ := ui.table.GetSelection() + x, y := ui.table.GetOffset() + ui.showDir() + ui.table.Select(min(row, ui.table.GetRowCount()-1), 0) + ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) + }) + } + if ui.done != nil { + ui.done <- struct{}{} + } +} + +func (ui *UI) increaseActiveWorkers() { + ui.workersMut.Lock() + defer ui.workersMut.Unlock() + ui.activeWorkers++ +} + +func (ui *UI) decreaseActiveWorkers() { + ui.workersMut.Lock() + defer ui.workersMut.Unlock() + ui.activeWorkers-- +} diff --git a/gdu/tui/exec.go b/gdu/tui/exec.go new file mode 100644 index 0000000..24c3e11 --- /dev/null +++ b/gdu/tui/exec.go @@ -0,0 +1,18 @@ +package tui + +import ( + "os" + "os/exec" +) + +// Execute runs given bin path via exec.Command call +func Execute(argv0 string, argv, envv []string) error { + cmd := exec.Command(argv0, argv...) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = envv + + return cmd.Run() +} diff --git a/gdu/tui/exec_other.go b/gdu/tui/exec_other.go new file mode 100644 index 0000000..cc4bad9 --- /dev/null +++ b/gdu/tui/exec_other.go @@ -0,0 +1,48 @@ +//go:build !windows +// +build !windows + +package tui + +import ( + "os" + "os/signal" + "syscall" +) + +func getShellBin() string { + shellbin, ok := os.LookupEnv("SHELL") + if !ok { + shellbin = "/bin/bash" + } + return shellbin +} + +func (ui *UI) spawnShell() { + if ui.currentDir == nil { + return + } + + ui.app.Suspend(func() { + if err := os.Chdir(ui.currentDirPath); err != nil { + ui.showErr("Error changing directory", err) + return + } + + if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil { + ui.showErr("Error executing shell", err) + } + }) +} + +func stopProcess() error { + // chan for signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGCONT) + defer signal.Stop(sigChan) + + err := syscall.Kill(syscall.Getpid(), syscall.SIGTSTP) + // wait continue + <-sigChan + + return err +} diff --git a/gdu/tui/exec_test.go b/gdu/tui/exec_test.go new file mode 100644 index 0000000..72393ae --- /dev/null +++ b/gdu/tui/exec_test.go @@ -0,0 +1,13 @@ +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExecute(t *testing.T) { + err := Execute("true", []string{}, []string{}) + + assert.Nil(t, err) +} diff --git a/gdu/tui/exec_windows.go b/gdu/tui/exec_windows.go new file mode 100644 index 0000000..93eb31b --- /dev/null +++ b/gdu/tui/exec_windows.go @@ -0,0 +1,33 @@ +package tui + +import ( + "os" +) + +func getShellBin() string { + shellbin, ok := os.LookupEnv("COMSPEC") + if !ok { + shellbin = "C:\\WINDOWS\\System32\\cmd.exe" + } + return shellbin +} + +func (ui *UI) spawnShell() { + if ui.currentDir == nil { + return + } + + ui.app.Stop() + + if err := os.Chdir(ui.currentDirPath); err != nil { + ui.showErr("Error changing directory", err) + return + } + if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil { + ui.showErr("Error executing shell", err) + } +} + +func stopProcess() error { + return nil +} diff --git a/gdu/tui/export_test.go b/gdu/tui/export_test.go new file mode 100644 index 0000000..54d835f --- /dev/null +++ b/gdu/tui/export_test.go @@ -0,0 +1,203 @@ +package tui + +import ( + "bytes" + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestConfirmExport(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'E', 0)) + + assert.True(t, ui.pages.HasPage("export")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'n', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyEnter, 0, 0)) + + assert.True(t, ui.pages.HasPage("export")) +} + +func TestExportAnalysis(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.exportAnalysis() + + assert.True(t, ui.pages.HasPage("exporting")) + + <-ui.done + + assert.FileExists(t, "export.json") + err := os.Remove("export.json") + assert.NoError(t, err) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } +} + +func TestExportAnalysisEsc(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + form := ui.confirmExport() + formInputFn := form.GetInputCapture() + + assert.True(t, ui.pages.HasPage("export")) + + formInputFn(tcell.NewEventKey(tcell.KeyEsc, 0, 0)) + + assert.False(t, ui.pages.HasPage("export")) +} + +func TestExportAnalysisWithName(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + form := ui.confirmExport() + // formInputFn := form.GetInputCapture() + item := form.GetFormItemByLabel("File name") + inputFn := item.(*tview.InputField).InputHandler() + + // send 'n' to input + inputFn(tcell.NewEventKey(tcell.KeyRune, 'n', 0), nil) + assert.Equal(t, "export.jsonn", ui.exportName) + + assert.True(t, ui.pages.HasPage("export")) + + form.GetButton(0).InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), nil) + + assert.True(t, ui.pages.HasPage("exporting")) + + <-ui.done + + assert.FileExists(t, "export.jsonn") + err := os.Remove("export.jsonn") + assert.NoError(t, err) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } +} + +func TestExportAnalysisWithoutRights(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + _, err := os.Create("export.json") + assert.NoError(t, err) + err = os.Chmod("export.json", 0) + assert.NoError(t, err) + defer func() { + err = os.Chmod("export.json", 0o755) + assert.Nil(t, err) + err = os.Remove("export.json") + assert.NoError(t, err) + }() + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.exportAnalysis() + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) +} diff --git a/gdu/tui/filter.go b/gdu/tui/filter.go new file mode 100644 index 0000000..d273e97 --- /dev/null +++ b/gdu/tui/filter.go @@ -0,0 +1,56 @@ +package tui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) hideFilterInput() { + ui.filterValue = "" + ui.footer.Clear() + ui.footer.AddItem(ui.footerLabel, 0, 1, false) + ui.app.SetFocus(ui.table) + ui.filteringInput = nil + ui.filtering = false +} + +func (ui *UI) showFilterInput() { + if ui.currentDir == nil { + return + } + + if ui.filteringInput == nil { + ui.markedRows = make(map[int]struct{}) + + ui.filteringInput = tview.NewInputField() + + if !ui.UseColors { + ui.filteringInput.SetFieldBackgroundColor( + tcell.NewRGBColor(100, 100, 100), + ) + ui.filteringInput.SetFieldTextColor( + tcell.NewRGBColor(255, 255, 255), + ) + } + + ui.filteringInput.SetChangedFunc(func(text string) { + ui.filterValue = text + ui.showDir() + }) + ui.filteringInput.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyESC { + ui.hideFilterInput() + ui.showDir() + } else { + ui.app.SetFocus(ui.table) + ui.filtering = false + } + }) + + ui.footer.Clear() + ui.footer.AddItem(ui.filteringInput, 0, 1, true) + ui.footer.AddItem(ui.footerLabel, 0, 5, false) + } + ui.app.SetFocus(ui.filteringInput) + ui.filtering = true +} diff --git a/gdu/tui/filter_test.go b/gdu/tui/filter_test.go new file mode 100644 index 0000000..dc4963c --- /dev/null +++ b/gdu/tui/filter_test.go @@ -0,0 +1,143 @@ +package tui + +import ( + "bytes" + "testing" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/internal/testdir" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestFiltering(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // mark the item for deletion + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + assert.Equal(t, 1, len(ui.markedRows)) + + ui.showFilterInput() + ui.filterValue = "" + ui.showDir() + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") // nothing is filtered + // marking should be dropped after sorting + assert.Equal(t, 0, len(ui.markedRows)) + + ui.filterValue = "aa" + ui.showDir() + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") // shows only cccc + + ui.hideFilterInput() + ui.showDir() + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") // filtering reset +} + +func TestFilteringWithoutCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + + ui.showFilterInput() + + assert.False(t, ui.filtering) +} + +func TestSwitchToTable(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '/', 0)) // open filtering input + handler := ui.filteringInput.InputHandler() + handler(tcell.NewEventKey(tcell.KeyRune, 'n', 0), func(p tview.Primitive) {}) + handler(tcell.NewEventKey(tcell.KeyRune, 'e', 0), func(p tview.Primitive) {}) + handler(tcell.NewEventKey(tcell.KeyRune, 's', 0), func(p tview.Primitive) {}) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // we are filtering, should do nothing + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "nested") + + handler( + tcell.NewEventKey(tcell.KeyTAB, ' ', 0), func(p tview.Primitive) {}, + ) // switch focus to table + ui.keyPressed(tcell.NewEventKey(tcell.KeyTAB, ' ', 0)) // switch back to input + handler( + tcell.NewEventKey(tcell.KeyEnter, ' ', 0), func(p tview.Primitive) {}, + ) // switch back to table + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // open nested dir + + assert.Contains(t, ui.table.GetCell(1, 0).Text, "subnested") + assert.Empty(t, ui.filterValue) // filtering reset +} + +func TestExitFiltering(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '/', 0)) // open filtering input + handler := ui.filteringInput.InputHandler() + ui.filterValue = "xxx" + ui.showDir() + + assert.Equal(t, ui.table.GetCell(0, 0).Text, "") // nothing is filtered + + handler( + tcell.NewEventKey(tcell.KeyEsc, ' ', 0), func(p tview.Primitive) {}, + ) // exit filtering + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "nested") + assert.Empty(t, ui.filterValue) // filtering reset +} diff --git a/gdu/tui/format.go b/gdu/tui/format.go new file mode 100644 index 0000000..6ef71ca --- /dev/null +++ b/gdu/tui/format.go @@ -0,0 +1,184 @@ +package tui + +import ( + "fmt" + "math" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/rivo/tview" +) + +const ( + blackOnWhite = "[black:white:-]" + whiteOnBlack = "[white:black:-]" + + defaultColor = "[-::]" + defaultColorBold = "[::b]" +) + +func (ui *UI) formatFileRow(item fs.Item, maxUsage, maxSize int64, marked, ignored bool) string { + part := 0 + if !ignored { + if ui.ShowApparentSize { + if size := item.GetSize(); size > 0 { + part = int(float64(size) / float64(maxSize) * 100.0) + } + } else { + if usage := item.GetUsage(); usage > 0 { + part = int(float64(usage) / float64(maxUsage) * 100.0) + } + } + } + + row := string(item.GetFlag()) + + numberColor := fmt.Sprintf( + "[%s::b]", + ui.resultRow.NumberColor, + ) + + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + + if ui.ShowApparentSize { + row += fmt.Sprintf("%15s", ui.formatSize(item.GetSize(), false, true)) + } else { + row += fmt.Sprintf("%15s", ui.formatSize(item.GetUsage(), false, true)) + } + + if ui.useOldSizeBar { + row += " " + getUsageGraphOld(part) + " " + } else { + row += getUsageGraph(part) + } + + if ui.showItemCount { + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + row += fmt.Sprintf("%11s ", ui.formatCount(item.GetItemCount())) + } + + if ui.showMtime { + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + row += fmt.Sprintf( + "%s "+defaultColor, + item.GetMtime().Format("2006-01-02 15:04:05"), + ) + } + + if len(ui.markedRows) > 0 { + if marked { + row += string('✓') + } else { + row += " " + } + row += " " + } + + if item.IsDir() { + if ui.UseColors && !marked && !ignored { + row += fmt.Sprintf("[%s::b]/", ui.resultRow.DirectoryColor) + } else { + row += defaultColorBold + "/" + } + } + row += tview.Escape(item.GetName()) + return row +} + +func (ui *UI) formatSize(size int64, reverseColor, transparentBg bool) string { + var color string + if reverseColor { + if ui.UseColors { + color = fmt.Sprintf( + "[%s:%s:-]", + ui.footerTextColor, + ui.footerBackgroundColor, + ) + } else { + color = blackOnWhite + } + } else { + if transparentBg { + color = defaultColor + } else { + color = whiteOnBlack + } + } + + if ui.UseSIPrefix { + return formatWithDecPrefix(size, color) + } + return formatWithBinPrefix(float64(size), color) +} + +func (ui *UI) formatCount(count int) string { + row := "" + color := defaultColor + count64 := float64(count) + + switch { + case count64 >= common.G: + row += fmt.Sprintf("%.1f%sG", float64(count)/float64(common.G), color) + case count64 >= common.M: + row += fmt.Sprintf("%.1f%sM", float64(count)/float64(common.M), color) + case count64 >= common.K: + row += fmt.Sprintf("%.1f%sk", float64(count)/float64(common.K), color) + default: + row += fmt.Sprintf("%d%s", count, color) + } + return row +} + +func formatWithBinPrefix(fsize float64, color string) string { + asize := math.Abs(fsize) + + switch { + case asize >= common.Ei: + return fmt.Sprintf("%.1f%s EiB", fsize/common.Ei, color) + case asize >= common.Pi: + return fmt.Sprintf("%.1f%s PiB", fsize/common.Pi, color) + case asize >= common.Ti: + return fmt.Sprintf("%.1f%s TiB", fsize/common.Ti, color) + case asize >= common.Gi: + return fmt.Sprintf("%.1f%s GiB", fsize/common.Gi, color) + case asize >= common.Mi: + return fmt.Sprintf("%.1f%s MiB", fsize/common.Mi, color) + case asize >= common.Ki: + return fmt.Sprintf("%.1f%s KiB", fsize/common.Ki, color) + default: + return fmt.Sprintf("%d%s B", int64(fsize), color) + } +} + +func formatWithDecPrefix(size int64, color string) string { + fsize := float64(size) + asize := math.Abs(fsize) + switch { + case asize >= common.E: + return fmt.Sprintf("%.1f%s EB", fsize/common.E, color) + case asize >= common.P: + return fmt.Sprintf("%.1f%s PB", fsize/common.P, color) + case asize >= common.T: + return fmt.Sprintf("%.1f%s TB", fsize/common.T, color) + case asize >= common.G: + return fmt.Sprintf("%.1f%s GB", fsize/common.G, color) + case asize >= common.M: + return fmt.Sprintf("%.1f%s MB", fsize/common.M, color) + case asize >= common.K: + return fmt.Sprintf("%.1f%s kB", fsize/common.K, color) + default: + return fmt.Sprintf("%d%s B", size, color) + } +} diff --git a/gdu/tui/format_test.go b/gdu/tui/format_test.go new file mode 100644 index 0000000..1ce154a --- /dev/null +++ b/gdu/tui/format_test.go @@ -0,0 +1,175 @@ +package tui + +import ( + "bytes" + "testing" + + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/pkg/analyze" + "github.com/stretchr/testify/assert" +) + +func TestFormatSize(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + + assert.Equal(t, "1[white:black:-] B", ui.formatSize(1, false, false)) + assert.Equal(t, "1.0[white:black:-] KiB", ui.formatSize(1<<10, false, false)) + assert.Equal(t, "1.0[white:black:-] MiB", ui.formatSize(1<<20, false, false)) + assert.Equal(t, "1.0[white:black:-] GiB", ui.formatSize(1<<30, false, false)) + assert.Equal(t, "1.0[white:black:-] TiB", ui.formatSize(1<<40, false, false)) + assert.Equal(t, "1.0[white:black:-] PiB", ui.formatSize(1<<50, false, false)) + assert.Equal(t, "1.0[white:black:-] EiB", ui.formatSize(1<<60, false, false)) + assert.Equal(t, "-1.0[white:black:-] KiB", ui.formatSize(-1<<10, false, false)) +} + +func TestFormatSizeDec(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, true) + + assert.Equal(t, "1[white:black:-] B", ui.formatSize(1, false, false)) + assert.Equal(t, "1.0[white:black:-] kB", ui.formatSize(1<<10, false, false)) + assert.Equal(t, "1.0[white:black:-] MB", ui.formatSize(1<<20, false, false)) + assert.Equal(t, "1.1[white:black:-] GB", ui.formatSize(1<<30, false, false)) + assert.Equal(t, "1.1[white:black:-] TB", ui.formatSize(1<<40, false, false)) + assert.Equal(t, "1.1[white:black:-] PB", ui.formatSize(1<<50, false, false)) + assert.Equal(t, "1.2[white:black:-] EB", ui.formatSize(1<<60, false, false)) + assert.Equal(t, "-1.0[white:black:-] kB", ui.formatSize(-1<<10, false, false)) +} + +func TestFormatCount(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + + assert.Equal(t, "1[-::]", ui.formatCount(1)) + assert.Equal(t, "1.0[-::]k", ui.formatCount(1<<10)) + assert.Equal(t, "1.0[-::]M", ui.formatCount(1<<20)) + assert.Equal(t, "1.1[-::]G", ui.formatCount(1<<30)) +} + +func TestEscapeName(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa [red] bbb", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "Aaa [red[] bbb") +} + +func TestMarked(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.markedRows[0] = struct{}{} + ui.useOldSizeBar = true + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), true, false), "✓ Aaa") + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "[##########] Aaa") +} + +func TestIgnored(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.ignoredRows[0] = struct{}{} + ui.useOldSizeBar = true + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, true), "[ ] Aaa") + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "[##########] Aaa") +} + +func TestSizeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "██████████▏Aaa") +} + +func TestOldSizeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.markedRows[0] = struct{}{} + ui.useOldSizeBar = true + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 20, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, dir.GetUsage(), dir.GetSize(), false, false), "[##### ] Aaa") +} diff --git a/gdu/tui/keys.go b/gdu/tui/keys.go new file mode 100644 index 0000000..5dbfbb1 --- /dev/null +++ b/gdu/tui/keys.go @@ -0,0 +1,350 @@ +package tui + +import ( + "fmt" + + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey { + if ui.handleCtrlZ(key) == nil { + return nil + } + + if ui.pages.HasPage("file") || ui.pages.HasPage("export") { + return key // send event to primitive + } + if ui.filtering { + return key + } + + key = ui.handleClosingModals(key) + if key == nil { + return nil + } + key = ui.handleInfoPageEvents(key) + if key == nil { + return nil + } + key = ui.handleQuit(key) + if key == nil { + return nil + } + + if ui.pages.HasPage("confirm") { + return ui.handleConfirmation(key) + } + + if ui.pages.HasPage("progress") || + ui.pages.HasPage("deleting") || + ui.pages.HasPage("emptying") { + return key + } + + key = ui.handleHelp(key) + if key == nil { + return nil + } + + if ui.pages.HasPage("help") { + return key + } + + key = ui.handleShell(key) + if key == nil { + return nil + } + + key = ui.handleLeftRight(key) + if key == nil { + return nil + } + + key = ui.handleFiltering(key) + if key == nil { + return nil + } + + return ui.handleMainActions(key) +} + +func (ui *UI) handleClosingModals(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyEsc || key.Rune() == 'q' { + if ui.pages.HasPage("help") { + ui.pages.RemovePage("help") + ui.app.SetFocus(ui.table) + return nil + } + if ui.pages.HasPage("info") { + ui.pages.RemovePage("info") + ui.app.SetFocus(ui.table) + return nil + } + } + return key +} + +func (ui *UI) handleConfirmation(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == 'h' { + return tcell.NewEventKey(tcell.KeyLeft, 0, 0) + } + if key.Rune() == 'l' { + return tcell.NewEventKey(tcell.KeyRight, 0, 0) + } + return key +} + +func (ui *UI) handleInfoPageEvents(key *tcell.EventKey) *tcell.EventKey { + if ui.pages.HasPage("info") { + switch key.Rune() { + case 'i': + ui.pages.RemovePage("info") + ui.app.SetFocus(ui.table) + return nil + case '?': + return nil + } + + if key.Key() == tcell.KeyUp || + key.Key() == tcell.KeyDown || + key.Rune() == 'j' || + key.Rune() == 'k' { + row, column := ui.table.GetSelection() + if (key.Key() == tcell.KeyUp || key.Rune() == 'k') && row > 0 { + row-- + } else if (key.Key() == tcell.KeyDown || key.Rune() == 'j') && + row+1 < ui.table.GetRowCount() { + row++ + } + ui.table.Select(row, column) + } + ui.showInfo() // refresh file info after any change + } + return key +} + +// handle ctrl+z job control +func (ui *UI) handleCtrlZ(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyCtrlZ { + ui.app.Suspend(func() { + termApp := ui.app.(*tview.Application) + termApp.Lock() + defer termApp.Unlock() + + err := stopProcess() + if err != nil { + ui.showErr("Error sending STOP signal", err) + } + }) + return nil + } + + return key +} + +func (ui *UI) handleQuit(key *tcell.EventKey) *tcell.EventKey { + switch key.Rune() { + case 'Q': + ui.app.Stop() + fmt.Fprintf(ui.output, "%s\n", ui.currentDirPath) + return nil + case 'q': + ui.app.Stop() + return nil + } + return key +} + +func (ui *UI) handleHelp(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == '?' { + if ui.pages.HasPage("help") { + ui.pages.RemovePage("help") + ui.app.SetFocus(ui.table) + return nil + } + ui.showHelp() + return nil + } + return key +} + +func (ui *UI) handleShell(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == 'b' { + ui.spawnShell() + return nil + } + return key +} + +func (ui *UI) handleLeftRight(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == 'h' || key.Key() == tcell.KeyLeft { + ui.handleLeft() + return nil + } + + if key.Rune() == 'l' || key.Key() == tcell.KeyRight { + ui.handleRight() + return nil + } + return key +} + +func (ui *UI) handleFiltering(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyTab && ui.filteringInput != nil { + ui.filtering = true + ui.app.SetFocus(ui.filteringInput) + return nil + } + return key +} + +// nolint: funlen // Why: there's a lot of options to handle +func (ui *UI) handleMainActions(key *tcell.EventKey) *tcell.EventKey { + switch key.Rune() { + case 'd': + ui.handleDelete(false) + case 'e': + ui.handleDelete(true) + case 'v': + ui.showFile() + case 'o': + ui.openItem() + case 'i': + ui.showInfo() + case 'a': + ui.ShowApparentSize = !ui.ShowApparentSize + if ui.currentDir != nil { + row, column := ui.table.GetSelection() + ui.showDir() + ui.table.Select(row, column) + } + case 'B': + ui.ShowRelativeSize = !ui.ShowRelativeSize + if ui.currentDir != nil { + row, column := ui.table.GetSelection() + ui.showDir() + ui.table.Select(row, column) + } + case 'c': + ui.showItemCount = !ui.showItemCount + if ui.currentDir != nil { + row, column := ui.table.GetSelection() + ui.showDir() + ui.table.Select(row, column) + } + case 'm': + ui.showMtime = !ui.showMtime + if ui.currentDir != nil { + row, column := ui.table.GetSelection() + ui.showDir() + ui.table.Select(row, column) + } + case 'r': + if ui.currentDir != nil { + ui.rescanDir() + } + case 'E': + ui.confirmExport() + return nil + case 's': + ui.setSorting("size") + case 'C': + ui.setSorting("itemCount") + case 'n': + ui.setSorting("name") + case 'M': + ui.setSorting("mtime") + case '/': + ui.showFilterInput() + return nil + case ' ': + ui.handleMark() + case 'I': + ui.ignoreItem() + } + return key +} + +func (ui *UI) handleLeft() { + if ui.currentDirPath == ui.topDirPath { + if ui.devices != nil { + ui.currentDir = nil + err := ui.ListDevices(ui.getter) + if err != nil { + ui.showErr("Error listing devices", err) + } + } + return + } + if ui.currentDir != nil { + ui.fileItemSelected(0, 0) + } +} + +func (ui *UI) handleRight() { + row, column := ui.table.GetSelection() + if ui.currentDirPath != ui.topDirPath && row == 0 { // do not select /.. + return + } + + if ui.currentDir != nil { + ui.fileItemSelected(row, column) + } else { + ui.deviceItemSelected(row, column) + } +} + +func (ui *UI) handleDelete(shouldEmpty bool) { + if ui.currentDir == nil { + return + } + // do not allow deleting parent dir + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + if ui.askBeforeDelete { + ui.confirmDeletion(shouldEmpty) + } else { + ui.delete(shouldEmpty) + } +} + +func (ui *UI) handleMark() { + if ui.currentDir == nil { + return + } + // do not allow deleting parent dir + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + ui.fileItemMarked(row) +} + +func (ui *UI) ignoreItem() { + if ui.currentDir == nil { + return + } + // do not allow ignoring parent dir + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + if _, ok := ui.ignoredRows[row]; ok { + delete(ui.ignoredRows, row) + } else { + ui.ignoredRows[row] = struct{}{} + } + ui.showDir() + // select next row if possible + ui.table.Select(min(row+1, ui.table.GetRowCount()-1), 0) +} diff --git a/gdu/tui/keys_test.go b/gdu/tui/keys_test.go new file mode 100644 index 0000000..368f524 --- /dev/null +++ b/gdu/tui/keys_test.go @@ -0,0 +1,1056 @@ +package tui + +import ( + "bytes" + "errors" + "testing" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestShowHelp(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) + + assert.True(t, ui.pages.HasPage("help")) +} + +func TestCloseHelp(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.showHelp() + + assert.True(t, ui.pages.HasPage("help")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyEsc, 'q', 0)) + + assert.False(t, ui.pages.HasPage("help")) +} + +func TestCloseHelpWithQuestionMark(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.showHelp() + + assert.True(t, ui.pages.HasPage("help")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) + + assert.False(t, ui.pages.HasPage("help")) +} + +func TestKeyWhileDeleting(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + modal := tview.NewModal().SetText("Deleting...") + ui.pages.AddPage("deleting", modal, true, true) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyEnter, ' ', 0)) + assert.Equal(t, tcell.KeyEnter, key.Key()) +} + +func TestLeftRightKeyWhileConfirm(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + modal := tview.NewModal().SetText("Really?") + ui.pages.AddPage("confirm", modal, true, true) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 0, 0)) + assert.Equal(t, tcell.KeyLeft, key.Key()) + key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 0, 0)) + assert.Equal(t, tcell.KeyRight, key.Key()) + key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'h', 0)) + assert.Equal(t, tcell.KeyLeft, key.Key()) + key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'l', 0)) + assert.Equal(t, tcell.KeyRight, key.Key()) +} + +func TestMoveLeftRight(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // try /.. first + + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.table.Select(1, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, "subnested", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) +} + +func TestMoveRightOnDevice(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.SetIgnoreDirPaths([]string{}) + err := ui.ListDevices(getDevicesInfoMock()) + assert.Nil(t, err) + + ui.table.Select(1, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + // go back to list of devices + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + + assert.Nil(t, ui.currentDir) + assert.Equal(t, "/dev/root", ui.table.GetCell(1, 0).GetReference().(*device.Device).Name) +} + +func TestStop(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.Nil(t, key) +} + +func TestStopWithPrintingPath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false, false) + + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'Q', 0)) + assert.Nil(t, key) + + assert.Equal(t, "test_dir\n", buff.String()) +} + +func TestSpawnShell(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return nil + } + + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.True(t, called) +} + +func TestSpawnShellWithoutDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return nil + } + + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.False(t, called) +} + +func TestSpawnShellWithWrongDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return nil + } + + ui.done = make(chan struct{}) + ui.currentDir = &analyze.Dir{} + ui.currentDirPath = "/xxxxx" + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.False(t, called) + assert.True(t, ui.pages.HasPage("error")) +} + +func TestSpawnShellWithError(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return errors.New("wrong shell") + } + + ui.done = make(chan struct{}) + ui.currentDir = &analyze.Dir{} + ui.currentDirPath = "." + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.True(t, called) + assert.True(t, ui.pages.HasPage("error")) +} + +func TestShowConfirm(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(1, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + assert.True(t, ui.pages.HasPage("confirm")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) + + assert.False(t, ui.pages.HasPage("help")) +} + +func TestDeleteEmpty(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + assert.NotNil(t, key) +} + +func TestMarkEmpty(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + assert.NotNil(t, key) +} + +func TestIgnoreEmpty(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) + assert.NotNil(t, key) +} + +func TestDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteWithNoDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.SetNoDelete() + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteMarked(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteParent(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + assert.DirExists(t, "test_dir/nested") +} + +func TestMarkParent(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + assert.Equal(t, len(ui.markedRows), 0) +} + +func TestIgnoreParent(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) + + assert.Equal(t, len(ui.ignoredRows), 0) +} + +func TestEmptyDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + +func TestMarkedEmptyDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + +func TestIgnoreDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested + assert.Equal(t, 3, ui.table.GetRowCount()) + + ui.table.Select(1, 0) // subnested + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) // ignore subnested + + row, _ := ui.table.GetSelection() + assert.Equal(t, 2, row) // selection moves to next row + + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) // unignore subnested +} + +func TestEmptyFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested + + ui.table.Select(2, 0) // file2 + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.DirExists(t, "test_dir/nested/subnested") +} + +func TestMarkedEmptyFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested + + ui.table.Select(2, 0) // file2 + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.DirExists(t, "test_dir/nested/subnested") +} + +func TestSortByApparentSize(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'a', 0)) + + assert.True(t, ui.ShowApparentSize) +} + +func TestShowFileCount(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'c', 0)) + + assert.True(t, ui.showItemCount) +} + +func TestShowFileCountBW(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'c', 0)) + + assert.True(t, ui.showItemCount) +} + +func TestShowMtime(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'm', 0)) + + assert.True(t, ui.showMtime) +} + +func TestShowMtimeBW(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'm', 0)) + + assert.True(t, ui.showMtime) +} + +func TestShowRelativeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.False(t, ui.ShowRelativeSize) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'B', 0)) + + assert.True(t, ui.ShowRelativeSize) +} + +func TestRescan(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'r', 0)) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, parentDir, ui.currentDir.GetParent()) + + assert.Equal(t, 5, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") +} + +func TestSorting(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(1, 0) + // mark the item for deletion + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + assert.Equal(t, 1, len(ui.markedRows)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 's', 0)) + assert.Equal(t, "size", ui.sortBy) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'C', 0)) + assert.Equal(t, "itemCount", ui.sortBy) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'n', 0)) + assert.Equal(t, "name", ui.sortBy) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'M', 0)) + assert.Equal(t, "mtime", ui.sortBy) + + // marking should be dropped after sorting + assert.Equal(t, 0, len(ui.markedRows)) +} + +func TestShowFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) +} + +func TestShowInfoAndMoveAround(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'k', 0)) // move up + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'j', 0)) // move down + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'k', 0)) // move up + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) // does nothing + + assert.True(t, ui.pages.HasPage("info")) // we can still see info page + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} diff --git a/gdu/tui/marked.go b/gdu/tui/marked.go new file mode 100644 index 0000000..1714c4f --- /dev/null +++ b/gdu/tui/marked.go @@ -0,0 +1,147 @@ +package tui + +import ( + "strconv" + "strings" + + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) fileItemMarked(row int) { + if _, ok := ui.markedRows[row]; ok { + delete(ui.markedRows, row) + } else { + ui.markedRows[row] = struct{}{} + } + ui.showDir() + // select next row if possible + ui.table.Select(min(row+1, ui.table.GetRowCount()-1), 0) +} + +func (ui *UI) deleteMarked(shouldEmpty bool) { + var action, acting string + if shouldEmpty { + action = actionEmpty + acting = actingEmpty + } else { + action = actionDelete + acting = actingDelete + } + + var currentDir fs.Item + var markedItems []fs.Item + for row := range ui.markedRows { + item := ui.table.GetCell(row, 0).GetReference().(fs.Item) + markedItems = append(markedItems, item) + } + + if ui.deleteInBackground { + ui.queueForDeletion(markedItems, shouldEmpty) + return + } + + modal := tview.NewModal() + ui.pages.AddPage(acting, modal, true, true) + + currentRow, _ := ui.table.GetSelection() + + var deleteFun func(fs.Item, fs.Item) error + + go func() { + for _, one := range markedItems { + ui.app.QueueUpdateDraw(func() { + modal.SetText( + // nolint: staticcheck // Why: fixed string + strings.Title(acting) + + " " + + tview.Escape(one.GetName()) + + "...", + ) + }) + + if shouldEmpty && !one.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + + var deleteItems []fs.Item + if shouldEmpty && one.IsDir() { + currentDir = one.(*analyze.Dir) + for _, file := range currentDir.GetFiles() { + deleteItems = append(deleteItems, file) + } + } else { + currentDir = ui.currentDir + deleteItems = append(deleteItems, one) + } + + for _, item := range deleteItems { + if err := deleteFun(currentDir, item); err != nil { + msg := "Can't " + action + " " + tview.Escape(one.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + } + + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.markedRows = make(map[int]struct{}) + x, y := ui.table.GetOffset() + ui.showDir() + ui.table.Select(min(currentRow, ui.table.GetRowCount()-1), 0) + ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() +} + +func (ui *UI) confirmDeletionMarked(shouldEmpty bool) { + var action string + if shouldEmpty { + action = actionEmpty + } else { + action = actionDelete + } + + modal := tview.NewModal(). + SetText( + "Are you sure you want to " + + action + " [::b]" + + strconv.Itoa(len(ui.markedRows)) + + "[::-] items?", + ). + AddButtons([]string{"yes", "no", "don't ask me again"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonIndex { + case 2: + ui.askBeforeDelete = false + fallthrough + case 0: + ui.deleteMarked(shouldEmpty) + } + ui.pages.RemovePage("confirm") + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } else { + modal.SetBackgroundColor(tcell.ColorBlack) + } + modal.SetBorderColor(tcell.ColorDefault) + + ui.pages.AddPage("confirm", modal, true, true) +} diff --git a/gdu/tui/marked_test.go b/gdu/tui/marked_test.go new file mode 100644 index 0000000..052401d --- /dev/null +++ b/gdu/tui/marked_test.go @@ -0,0 +1,22 @@ +package tui + +import ( + "testing" + + "b612.me/apps/b612/gdu/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestItemMarked(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + ui.fileItemMarked(1) + assert.Equal(t, ui.markedRows, map[int]struct{}{1: {}}) + + ui.fileItemMarked(1) + assert.Equal(t, ui.markedRows, map[int]struct{}{}) +} diff --git a/gdu/tui/mouse.go b/gdu/tui/mouse.go new file mode 100644 index 0000000..04e9185 --- /dev/null +++ b/gdu/tui/mouse.go @@ -0,0 +1,49 @@ +package tui + +import ( + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) onMouse(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) { + if event == nil { + return nil, action + } + + if ui.pages.HasPage("confirm") || + ui.pages.HasPage("progress") || + ui.pages.HasPage("deleting") || + ui.pages.HasPage("emptying") || + ui.pages.HasPage("help") { + return nil, action + } + + // nolint: exhaustive // Why: we don't need to handle all mouse events + switch action { + case tview.MouseLeftDoubleClick: + row, column := ui.table.GetSelection() + if ui.currentDirPath != ui.topDirPath && row == 0 { + ui.handleLeft() + } else { + selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) + if selectedFile.IsDir() { + ui.handleRight() + } else { + ui.showFile() + } + } + return nil, action + case tview.MouseScrollUp, tview.MouseScrollDown: + row, column := ui.table.GetSelection() + if action == tview.MouseScrollUp && row > 0 { + row-- + } else if action == tview.MouseScrollDown && row+1 < ui.table.GetRowCount() { + row++ + } + ui.table.Select(row, column) + return nil, action + } + + return event, action +} diff --git a/gdu/tui/mouse_test.go b/gdu/tui/mouse_test.go new file mode 100644 index 0000000..6fe2e33 --- /dev/null +++ b/gdu/tui/mouse_test.go @@ -0,0 +1,126 @@ +package tui + +import ( + "bytes" + "testing" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestDoubleClick(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.table.Select(0, 0) + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + // show file content + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + selectedFile := ui.table.GetCell(2, 0).GetReference().(fs.Item) + assert.Equal(t, selectedFile.GetName(), "file2") + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) + assert.True(t, ui.pages.HasPage("file")) +} + +func TestScroll(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollDown) + row, _ := ui.table.GetSelection() + assert.Equal(t, row, 1) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollUp) + row, _ = ui.table.GetSelection() + assert.Equal(t, row, 0) +} + +func TestScrollWhenPageOpened(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // open confirm dialog + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollDown) + row, _ := ui.table.GetSelection() + // scrolling does nothing + assert.Equal(t, 0, row) +} + +func TestEmptyEvent(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + event, action := ui.onMouse(nil, tview.MouseMove) + assert.True(t, event == nil) + assert.Equal(t, action, tview.MouseMove) +} + +func TestMouseMove(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + event, action := ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseMove) + assert.True(t, event != nil) + assert.Equal(t, action, tview.MouseMove) +} diff --git a/gdu/tui/progress.go b/gdu/tui/progress.go new file mode 100644 index 0000000..59562a7 --- /dev/null +++ b/gdu/tui/progress.go @@ -0,0 +1,53 @@ +package tui + +import ( + "time" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/path" +) + +func (ui *UI) updateProgress() { + color := "[white:black:b]" + if ui.UseColors { + color = "[red:black:b]" + } + + progressChan := ui.Analyzer.GetProgressChan() + doneChan := ui.Analyzer.GetDone() + + var progress common.CurrentProgress + start := time.Now() + + for { + select { + case progress = <-progressChan: + case <-doneChan: + ui.app.QueueUpdateDraw(func() { + ui.progress.SetTitle(" Finalizing... ") + ui.progress.SetText("Calculating disk usage...") + }) + return + } + + func(itemCount int, totalSize int64, currentItem string) { + delta := time.Since(start).Round(time.Second) + + ui.app.QueueUpdateDraw(func() { + ui.progress.SetText("Total items: " + + color + + common.FormatNumber(int64(itemCount)) + + "[white:black:-], size: " + + color + + ui.formatSize(totalSize, false, false) + + "[white:black:-], elapsed time: " + + color + + delta.String() + + "[white:black:-]\nCurrent item: [white:black:b]" + + path.ShortenPath(currentItem, ui.currentItemNameMaxLen)) + }) + }(progress.ItemCount, progress.TotalSize, progress.CurrentItemName) + + time.Sleep(100 * time.Millisecond) + } +} diff --git a/gdu/tui/show.go b/gdu/tui/show.go new file mode 100644 index 0000000..bfb6468 --- /dev/null +++ b/gdu/tui/show.go @@ -0,0 +1,332 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/build" +) + +const helpText = ` [::b]上/下, k/j [white:black:-]光标上/下移动 + [::b]pgup/pgdn, g/G [white:black:-]光标跳转至顶部/底部 + [::b]enter, 右, l [white:black:-]进入目录/设备 + [::b]左, h [white:black:-]返回上级目录 + + [::b]r [white:black:-]重新扫描当前目录 + [::b]E [white:black:-]导出分析数据为JSON文件 + [::b]/ [white:black:-]按名称搜索项目 + [::b]a [white:black:-]切换磁盘用量与表观大小显示 + [::b]B [white:black:-]切换进度条对齐方式(最大文件/目录) + [::b]c [white:black:-]显示/隐藏文件数量统计 + [::b]m [white:black:-]显示/隐藏最新修改时间 + [::b]b [white:black:-]在当前目录打开Shell + [::b]q [white:black:-]退出程序 + [::b]Q [white:black:-]退出并输出当前目录路径 + +光标所在项目操作: + [::b]d [white:black:-]删除文件或目录 + [::b]e [white:black:-]清空文件或目录 + [::b]space [white:black:-]标记待删除文件/目录 + [::b]I [white:black:-]忽略当前文件或目录 + [::b]v [white:black:-]查看文件内容 + [::b]o [white:black:-]使用外部程序打开 + [::b]i [white:black:-]显示项目详细信息 + +排序方式(再次按键切换升序/降序): + [::b]n [white:black:-]按名称排序 + [::b]s [white:black:-]按大小排序 + [::b]C [white:black:-]按文件数量排序 + [::b]M [white:black:-]按修改时间排序` + +// nolint: funlen // Why: complex function +func (ui *UI) showDir() { + var ( + totalUsage int64 + totalSize int64 + maxUsage int64 + maxSize int64 + itemCount int + ) + + ui.currentDirPath = ui.currentDir.GetPath() + + if ui.changeCwdFn != nil { + err := ui.changeCwdFn(ui.currentDirPath) + if err != nil { + log.Printf("error setting cwd: %s", err.Error()) + } + log.Printf("changing cwd to %s", ui.currentDirPath) + } + + ui.currentDirLabel.SetText("[::b] --- " + + tview.Escape( + strings.TrimPrefix(ui.currentDirPath, build.RootPathPrefix), + ) + + " ---").SetDynamicColors(true) + + ui.table.Clear() + + rowIndex := 0 + if ui.currentDirPath != ui.topDirPath { + prefix := " " + if len(ui.markedRows) > 0 { + prefix += " " + } + + cell := tview.NewTableCell(prefix + "[::b]/..") + cell.SetReference(ui.currentDir.GetParent()) + cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault)) + ui.table.SetCell(0, 0, cell) + rowIndex++ + } + + ui.sortItems() + + unlock := ui.currentDir.RLock() + defer unlock() + + i := rowIndex + maxUsage = 0 + maxSize = 0 + for _, item := range ui.currentDir.GetFiles() { + if _, ignored := ui.ignoredRows[i]; ignored { + i++ + continue + } + + if ui.ShowRelativeSize { + if item.GetUsage() > maxUsage { + maxUsage = item.GetUsage() + } + if item.GetSize() > maxSize { + maxSize = item.GetSize() + } + } else { + maxSize += item.GetSize() + maxUsage += item.GetUsage() + } + i++ + } + + for i, item := range ui.currentDir.GetFiles() { + if ui.filterValue != "" && !strings.Contains( + strings.ToLower(item.GetName()), + strings.ToLower(ui.filterValue), + ) { + continue + } + + _, ignored := ui.ignoredRows[rowIndex] + + if !ignored { + totalUsage += item.GetUsage() + totalSize += item.GetSize() + itemCount += item.GetItemCount() + } + + _, marked := ui.markedRows[rowIndex] + cell := tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize, marked, ignored)) + cell.SetReference(ui.currentDir.GetFiles()[i]) + + switch { + case ignored: + cell.SetStyle(tcell.Style{}.Foreground(tview.Styles.SecondaryTextColor)) + case marked: + cell.SetStyle(tcell.Style{}.Foreground(tview.Styles.PrimaryTextColor)) + cell.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) + default: + cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault)) + } + + ui.table.SetCell(rowIndex, 0, cell) + rowIndex++ + } + + var footerNumberColor, footerTextColor string + if ui.UseColors { + footerNumberColor = fmt.Sprintf( + "[%s:%s:b]", + ui.footerNumberColor, + ui.footerBackgroundColor, + ) + footerTextColor = fmt.Sprintf( + "[%s:%s:-]", + ui.footerTextColor, + ui.footerBackgroundColor, + ) + } else { + footerNumberColor = "[black:white:b]" + footerTextColor = blackOnWhite + } + + selected := "" + if len(ui.markedRows) > 0 { + selected = " Selected items: " + footerNumberColor + + strconv.Itoa(len(ui.markedRows)) + footerTextColor + } + + ui.footerLabel.SetText( + selected + footerTextColor + + " Total disk usage: " + + footerNumberColor + + ui.formatSize(totalUsage, true, false) + + " Apparent size: " + + footerNumberColor + + ui.formatSize(totalSize, true, false) + + " Items: " + footerNumberColor + strconv.Itoa(itemCount) + + footerTextColor + + " Sorting by: " + ui.sortBy + " " + ui.sortOrder) + + ui.table.Select(0, 0) + ui.table.ScrollToBeginning() + + if !ui.filtering { + ui.app.SetFocus(ui.table) + } +} + +func (ui *UI) showDevices() { + var totalUsage int64 + + ui.table.Clear() + ui.table.SetCell(0, 0, tview.NewTableCell("Device name").SetSelectable(false)) + ui.table.SetCell(0, 1, tview.NewTableCell("Size").SetSelectable(false)) + ui.table.SetCell(0, 2, tview.NewTableCell("Used").SetSelectable(false)) + ui.table.SetCell(0, 3, tview.NewTableCell("Used part").SetSelectable(false)) + ui.table.SetCell(0, 4, tview.NewTableCell("Free").SetSelectable(false)) + ui.table.SetCell(0, 5, tview.NewTableCell("Mount point").SetSelectable(false)) + + var textColor, sizeColor string + if ui.UseColors { + textColor = "[#3498db:-:b]" + sizeColor = "[#edb20a:-:b]" + } else { + textColor = "[white:-:b]" + sizeColor = "[white:-:b]" + } + + ui.sortDevices() + + for i, device := range ui.devices { + totalUsage += device.GetUsage() + ui.table.SetCell(i+1, 0, tview.NewTableCell(textColor+device.Name).SetReference(ui.devices[i])) + ui.table.SetCell(i+1, 1, tview.NewTableCell(ui.formatSize(device.Size, false, true))) + ui.table.SetCell(i+1, 2, tview.NewTableCell(sizeColor+ui.formatSize(device.Size-device.Free, false, true))) + ui.table.SetCell(i+1, 3, tview.NewTableCell(getDeviceUsagePart(device, ui.useOldSizeBar))) + ui.table.SetCell(i+1, 4, tview.NewTableCell(ui.formatSize(device.Free, false, true))) + ui.table.SetCell(i+1, 5, tview.NewTableCell(textColor+device.MountPoint).SetReference(ui.devices[i])) + } + + var footerNumberColor, footerTextColor string + if ui.UseColors { + footerNumberColor = fmt.Sprintf( + "[%s:%s:b]", + ui.footerNumberColor, + ui.footerBackgroundColor, + ) + footerTextColor = fmt.Sprintf( + "[%s:%s:-]", + ui.footerTextColor, + ui.footerBackgroundColor, + ) + } else { + footerNumberColor = "[black:white:b]" + footerTextColor = blackOnWhite + } + + ui.footerLabel.SetText( + " Total usage: " + + footerNumberColor + + ui.formatSize(totalUsage, true, false) + + footerTextColor + + " Sorting by: " + ui.sortBy + " " + ui.sortOrder) + + ui.table.Select(1, 0) + ui.table.SetSelectedFunc(ui.deviceItemSelected) + + if ui.topDirPath != "" { + for i, device := range ui.devices { + if device.MountPoint == ui.topDirPath { + ui.table.Select(i+1, 0) + break + } + } + } +} + +func (ui *UI) showErr(msg string, err error) { + modal := tview.NewModal(). + SetText(msg + ": " + err.Error()). + AddButtons([]string{"ok"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + ui.pages.RemovePage("error") + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } + + ui.pages.AddPage("error", modal, true, true) + ui.app.SetFocus(modal) +} + +func (ui *UI) showErrFromGo(msg string, err error) { + ui.app.QueueUpdateDraw(func() { + ui.showErr(msg, err) + }) +} + +func (ui *UI) showHelp() { + text := tview.NewTextView().SetDynamicColors(true) + text.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + text.SetBorderColor(tcell.ColorDefault) + text.SetTitle(" gdu help ") + text.SetScrollable(true) + + formattedHelpText := ui.formatHelpTextFor() + text.SetText(formattedHelpText) + + maxHeight := strings.Count(formattedHelpText, "\n") + 7 + _, height := ui.screen.Size() + if height > maxHeight { + height = maxHeight + } + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(text, height, 1, false). + AddItem(nil, 0, 1, false), 80, 1, false). + AddItem(nil, 0, 1, false) + + ui.help = flex + ui.pages.AddPage("help", flex, true, true) + ui.app.SetFocus(text) +} + +func (ui *UI) formatHelpTextFor() string { + lines := strings.Split(helpText, "\n") + + for i, line := range lines { + if ui.UseColors { + lines[i] = strings.ReplaceAll( + strings.ReplaceAll(line, defaultColorBold, "[red]"), + whiteOnBlack, + "[white]", + ) + } + + if ui.noDelete && (strings.Contains(line, "Empty file or directory") || + strings.Contains(line, "Delete file or directory")) { + lines[i] += " (disabled)" + } + } + + return strings.Join(lines, "\n") +} diff --git a/gdu/tui/show_file.go b/gdu/tui/show_file.go new file mode 100644 index 0000000..9eb22dc --- /dev/null +++ b/gdu/tui/show_file.go @@ -0,0 +1,146 @@ +package tui + +import ( + "bufio" + "compress/bzip2" + "compress/gzip" + "io" + "os" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/h2non/filetype" + "github.com/h2non/filetype/matchers" + "github.com/pkg/errors" + "github.com/rivo/tview" + "github.com/ulikunitz/xz" + + "b612.me/apps/b612/gdu/build" + "b612.me/apps/b612/gdu/pkg/fs" +) + +func (ui *UI) showFile() *tview.TextView { + if ui.currentDir == nil { + return nil + } + + row, column := ui.table.GetSelection() + selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) + if selectedFile.IsDir() { + return nil + } + + path := selectedFile.GetPath() + f, err := os.Open(path) + if err != nil { + ui.showErr("Error opening file", err) + return nil + } + scanner, err := getScanner(f) + if err != nil { + ui.showErr("Error reading file", err) + return nil + } + + totalLines := 0 + + file := tview.NewTextView() + ui.currentDirLabel.SetText("[::b] --- " + + strings.TrimPrefix(path, build.RootPathPrefix) + + " ---").SetDynamicColors(true) + + readNextPart := func(linesCount int) int { + var err error + readLines := 0 + for scanner.Scan() && readLines <= linesCount { + _, err = file.Write(scanner.Bytes()) + if err != nil { + ui.showErr("Error reading file", err) + return 0 + } + _, err = file.Write([]byte("\n")) + if err != nil { + ui.showErr("Error reading file", err) + return 0 + } + readLines++ + } + return readLines + } + totalLines += readNextPart(defaultLinesCount) + + file.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Rune() == 'q' || event.Key() == tcell.KeyESC { + err = f.Close() + if err != nil { + ui.showErr("Error closing file", err) + return event + } + ui.currentDirLabel.SetText("[::b] --- " + + strings.TrimPrefix(ui.currentDirPath, build.RootPathPrefix) + + " ---").SetDynamicColors(true) + ui.pages.RemovePage("file") + ui.app.SetFocus(ui.table) + return event + } + + if event.Rune() == 'j' || event.Rune() == 'G' || + event.Key() == tcell.KeyDown || event.Key() == tcell.KeyPgDn { + _, _, _, height := file.GetInnerRect() + row, _ := file.GetScrollOffset() + if height+row > totalLines-linesThreshold { + totalLines += readNextPart(defaultLinesCount) + } + } + return event + }) + + grid := tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) + grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(file, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.footerLabel, 3, 0, 1, 1, 0, 0, false) + + ui.pages.HidePage("background") + ui.pages.AddPage("file", grid, true, true) + + return file +} + +func getScanner(f io.ReadSeeker) (scanner *bufio.Scanner, err error) { + // We only have to pass the file header = first 261 bytes + head := make([]byte, 261) + if _, err = f.Read(head); err != nil { + return nil, errors.Wrap(err, "error reading file header") + } + + if pos, err := f.Seek(0, 0); pos != 0 || err != nil { + return nil, errors.Wrap(err, "error seeking file") + } + scanner = bufio.NewScanner(f) + + typ, err := filetype.Match(head) + if err != nil { + return nil, errors.Wrap(err, "error matching file type") + } + + switch typ.MIME.Value { + case matchers.TypeGz.MIME.Value: + r, err := gzip.NewReader(f) + if err != nil { + return nil, errors.Wrap(err, "error creating gzip reader") + } + scanner = bufio.NewScanner(r) + case matchers.TypeBz2.MIME.Value: + r := bzip2.NewReader(f) + scanner = bufio.NewScanner(r) + case matchers.TypeXz.MIME.Value: + r, err := xz.NewReader(f) + if err != nil { + return nil, errors.Wrap(err, "error creating xz reader") + } + scanner = bufio.NewScanner(r) + } + + return scanner, nil +} diff --git a/gdu/tui/show_file_test.go b/gdu/tui/show_file_test.go new file mode 100644 index 0000000..6cc87eb --- /dev/null +++ b/gdu/tui/show_file_test.go @@ -0,0 +1,89 @@ +package tui + +import ( + "bytes" + "compress/gzip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/ulikunitz/xz" +) + +func TestGetScannerForEmptyString(t *testing.T) { + r := bytes.NewReader([]byte{}) + _, err := getScanner(r) + assert.ErrorContains(t, err, "EOF") +} + +func TestGetScannerForPlainString(t *testing.T) { + r := bytes.NewReader([]byte("hello")) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello", s.Text()) + assert.Equal(t, nil, s.Err()) +} + +func TestGetScannerForGzipped(t *testing.T) { + b := bytes.NewBuffer([]byte{}) + w := gzip.NewWriter(b) + + _, err := w.Write([]byte("hello world")) + assert.Nil(t, err) + + err = w.Close() + assert.Nil(t, err) + + r := bytes.NewReader(b.Bytes()) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello world", s.Text()) + assert.Equal(t, nil, s.Err()) +} + +func TestGetScannerForBzipped(t *testing.T) { + r := bytes.NewReader([]byte{ + // bzip2 header + 0x42, 0x5A, 0x68, 0x39, + // bzip2 compressed data: "hello" + 0x31, 0x41, 0x59, 0x26, + 0x53, 0x59, 0xC1, 0xC0, + 0x80, 0xE2, 0x00, 0x00, + 0x01, 0x41, 0x00, 0x00, + 0x10, 0x02, 0x44, 0xA0, + 0x00, 0x30, 0xCD, 0x00, + 0xC3, 0x46, 0x29, 0x97, + 0x17, 0x72, 0x45, 0x38, + 0x50, 0x90, 0xC1, 0xC0, + 0x80, 0xE2, + }) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello", s.Text()) + assert.Equal(t, nil, s.Err()) +} + +func TestGetScannerForXzipped(t *testing.T) { + b := bytes.NewBuffer([]byte{}) + w, err := xz.NewWriter(b) + assert.Nil(t, err) + + _, err = w.Write([]byte("hello world")) + assert.Nil(t, err) + + err = w.Close() + assert.Nil(t, err) + + r := bytes.NewReader(b.Bytes()) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello world", s.Text()) + assert.Equal(t, nil, s.Err()) +} diff --git a/gdu/tui/sort.go b/gdu/tui/sort.go new file mode 100644 index 0000000..7e5b118 --- /dev/null +++ b/gdu/tui/sort.go @@ -0,0 +1,105 @@ +package tui + +import ( + "sort" + + "b612.me/apps/b612/gdu/pkg/device" + "b612.me/apps/b612/gdu/pkg/fs" +) + +const ( + nameSortKey = "name" + sizeSortKey = "size" + itemCountSortKey = "itemCount" + mtimeSortKey = "mtime" + + ascOrder = "asc" + descOrder = "desc" +) + +// SetDefaultSorting sets the default sorting +func (ui *UI) SetDefaultSorting(by, order string) { + if by != "" { + ui.defaultSortBy = by + } + if order == ascOrder || order == descOrder { + ui.defaultSortOrder = order + } +} + +func (ui *UI) setSorting(newOrder string) { + ui.markedRows = make(map[int]struct{}) + + if newOrder == ui.sortBy { + if ui.sortOrder == ascOrder { + ui.sortOrder = descOrder + } else { + ui.sortOrder = ascOrder + } + } else { + ui.sortBy = newOrder + ui.sortOrder = ascOrder + } + + if ui.currentDir != nil { + ui.showDir() + } else if ui.devices != nil && (newOrder == sizeSortKey || newOrder == nameSortKey) { + ui.showDevices() + } +} + +func (ui *UI) sortItems() { + if ui.sortBy == sizeSortKey { + if ui.ShowApparentSize { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(fs.ByApparentSize(ui.currentDir.GetFiles()))) + } else { + sort.Sort(fs.ByApparentSize(ui.currentDir.GetFiles())) + } + } else { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(ui.currentDir.GetFiles())) + } else { + sort.Sort(ui.currentDir.GetFiles()) + } + } + } + if ui.sortBy == itemCountSortKey { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(fs.ByItemCount(ui.currentDir.GetFiles()))) + } else { + sort.Sort(fs.ByItemCount(ui.currentDir.GetFiles())) + } + } + if ui.sortBy == nameSortKey { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(fs.ByName(ui.currentDir.GetFiles()))) + } else { + sort.Sort(fs.ByName(ui.currentDir.GetFiles())) + } + } + if ui.sortBy == mtimeSortKey { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(fs.ByMtime(ui.currentDir.GetFiles()))) + } else { + sort.Sort(fs.ByMtime(ui.currentDir.GetFiles())) + } + } +} + +func (ui *UI) sortDevices() { + if ui.sortBy == sizeSortKey { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(device.ByUsedSize(ui.devices))) + } else { + sort.Sort(device.ByUsedSize(ui.devices)) + } + } + if ui.sortBy == nameSortKey { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(device.ByName(ui.devices))) + } else { + sort.Sort(device.ByName(ui.devices)) + } + } +} diff --git a/gdu/tui/sort_test.go b/gdu/tui/sort_test.go new file mode 100644 index 0000000..720bf5e --- /dev/null +++ b/gdu/tui/sort_test.go @@ -0,0 +1,207 @@ +package tui + +import ( + "bytes" + "testing" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testapp" + "github.com/stretchr/testify/assert" +) + +func TestAnalyzeByApparentSize(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "desc", true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestSortByApparentSizeAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "asc", true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ccc") +} + +func TestAnalyzeBySize(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestSortBySizeAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ccc") +} + +func TestAnalyzeByName(t *testing.T) { + ui := getAnalyzedPathWithSorting("name", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") +} + +func TestAnalyzeByNameAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("name", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestAnalyzeByItemCount(t *testing.T) { + ui := getAnalyzedPathWithSorting("itemCount", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") +} + +func TestAnalyzeByItemCountAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("itemCount", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestAnalyzeByMtime(t *testing.T) { + ui := getAnalyzedPathWithSorting("mtime", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestAnalyzeByMtimeAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("mtime", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") +} + +func TestSetSorting(t *testing.T) { + ui := getAnalyzedPathWithSorting("itemCount", "asc", false) + + ui.setSorting("name") + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "asc", ui.sortOrder) + ui.setSorting("name") + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "desc", ui.sortOrder) + ui.setSorting("name") + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "asc", ui.sortOrder) +} + +func TestSetDEfaultSorting(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + var opts []Option + opts = append(opts, func(ui *UI) { + ui.SetDefaultSorting("name", "asc") + }) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false, opts...) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + + if err := ui.AnalyzePath("test_dir", nil); err != nil { + panic(err) + } + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "asc", ui.sortOrder) +} + +func TestSortDevicesByName(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.setSorting("name") // sort by name asc + assert.Equal(t, "/dev/boot", ui.devices[0].Name) + + ui.setSorting("name") // sort by name desc + assert.Equal(t, "/dev/root", ui.devices[0].Name) +} + +func TestSortDevicesByUsedSize(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.setSorting("size") // sort by used size asc + assert.Equal(t, "/dev/boot", ui.devices[0].Name) + + ui.setSorting("size") // sort by used size desc + assert.Equal(t, "/dev/root", ui.devices[0].Name) +} + +func getAnalyzedPathWithSorting(sortBy string, sortOrder string, apparentSize bool) *UI { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, apparentSize, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.sortBy = sortBy + ui.sortOrder = sortOrder + if err := ui.AnalyzePath("test_dir", nil); err != nil { + panic(err) + } + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + return ui +} diff --git a/gdu/tui/status.go b/gdu/tui/status.go new file mode 100644 index 0000000..ff4c062 --- /dev/null +++ b/gdu/tui/status.go @@ -0,0 +1,84 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) toggleStatusBar(show bool) { + var textColor, textBgColor tcell.Color + if ui.UseColors { + textColor = tcell.NewRGBColor(0, 0, 0) + textBgColor = tcell.NewRGBColor(36, 121, 208) + } else { + textColor = tcell.NewRGBColor(0, 0, 0) + textBgColor = tcell.NewRGBColor(255, 255, 255) + } + + ui.grid.Clear() + + ui.statusMut.Lock() + defer ui.statusMut.Unlock() + + if show { + ui.status = tview.NewTextView().SetDynamicColors(true) + ui.status.SetTextColor(textColor) + ui.status.SetBackgroundColor(textBgColor) + + ui.grid.SetRows(1, 1, 0, 1, 1) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.status, 3, 0, 1, 1, 0, 0, false). + AddItem(ui.footer, 4, 0, 1, 1, 0, 0, false) + return + } + ui.status = nil + ui.grid.SetRows(1, 1, 0, 1) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) +} + +func (ui *UI) updateStatusWorker() { + for { + ui.updateStatus() + time.Sleep(500 * time.Millisecond) + } +} + +func (ui *UI) updateStatus() { + ui.workersMut.Lock() + cnt := ui.activeWorkers + ui.workersMut.Unlock() + + ui.statusMut.RLock() + status := ui.status + ui.statusMut.RUnlock() + + if cnt == 0 && status == nil { + return + } + + if cnt > 0 && status == nil { + ui.app.QueueUpdateDraw(func() { + ui.toggleStatusBar(true) + }) + } else if cnt == 0 { + ui.app.QueueUpdateDraw(func() { + ui.toggleStatusBar(false) + }) + return + } + + ui.app.QueueUpdateDraw(func() { + msg := fmt.Sprintf(" Active background deletions: %d", cnt) + ui.statusMut.RLock() + ui.status.SetText(msg) + ui.statusMut.RUnlock() + }) +} diff --git a/gdu/tui/tui.go b/gdu/tui/tui.go new file mode 100644 index 0000000..eb2f499 --- /dev/null +++ b/gdu/tui/tui.go @@ -0,0 +1,473 @@ +package tui + +import ( + "io" + "os" + "os/signal" + "runtime" + "sync" + "syscall" + "time" + + "golang.org/x/exp/slices" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + "b612.me/apps/b612/gdu/pkg/fs" + "b612.me/apps/b612/gdu/pkg/remove" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// UI struct +type UI struct { + *common.UI + app common.TermApplication + screen tcell.Screen + output io.Writer + grid *tview.Grid + header *tview.TextView + footer *tview.Flex + footerLabel *tview.TextView + currentDirLabel *tview.TextView + pages *tview.Pages + progress *tview.TextView + status *tview.TextView + help *tview.Flex + table *tview.Table + filteringInput *tview.InputField + currentDir fs.Item + devices []*device.Device + topDir fs.Item + topDirPath string + currentDirPath string + askBeforeDelete bool + showItemCount bool + showMtime bool + filtering bool + filterValue string + sortBy string + sortOrder string + done chan struct{} + remover func(fs.Item, fs.Item) error + emptier func(fs.Item, fs.Item) error + getter device.DevicesInfoGetter + exec func(argv0 string, argv []string, envv []string) error + changeCwdFn func(string) error + linkedItems fs.HardLinkedItems + selectedTextColor tcell.Color + selectedBackgroundColor tcell.Color + footerTextColor string + footerBackgroundColor string + footerNumberColor string + headerTextColor string + headerBackgroundColor string + headerHidden bool + resultRow ResultRow + currentItemNameMaxLen int + useOldSizeBar bool + defaultSortBy string + defaultSortOrder string + ignoredRows map[int]struct{} + markedRows map[int]struct{} + exportName string + noDelete bool + deleteInBackground bool + deleteQueue chan deleteQueueItem + activeWorkers int + workersMut sync.Mutex + statusMut sync.RWMutex + deleteWorkersCount int +} + +type deleteQueueItem struct { + item fs.Item + shouldEmpty bool +} + +// ResultRow is a struct for a row in the result table +type ResultRow struct { + NumberColor string + DirectoryColor string +} + +// Option is optional function customizing the behaviour of UI +type Option func(ui *UI) + +// CreateUI creates the whole UI app +func CreateUI( + app common.TermApplication, + screen tcell.Screen, + output io.Writer, + useColors bool, + showApparentSize bool, + showRelativeSize bool, + constGC bool, + useSIPrefix bool, + opts ...Option, +) *UI { + ui := &UI{ + UI: &common.UI{ + UseColors: useColors, + ShowApparentSize: showApparentSize, + ShowRelativeSize: showRelativeSize, + Analyzer: analyze.CreateAnalyzer(), + ConstGC: constGC, + UseSIPrefix: useSIPrefix, + }, + app: app, + screen: screen, + output: output, + askBeforeDelete: true, + showItemCount: false, + remover: remove.ItemFromDir, + emptier: remove.EmptyFileFromDir, + exec: Execute, + linkedItems: make(fs.HardLinkedItems, 10), + selectedTextColor: tview.Styles.TitleColor, + selectedBackgroundColor: tview.Styles.MoreContrastBackgroundColor, + currentItemNameMaxLen: 70, + defaultSortBy: "size", + defaultSortOrder: "desc", + ignoredRows: make(map[int]struct{}), + markedRows: make(map[int]struct{}), + exportName: "export.json", + noDelete: false, + deleteQueue: make(chan deleteQueueItem, 1000), + deleteWorkersCount: 3 * runtime.GOMAXPROCS(0), + } + for _, o := range opts { + o(ui) + } + + ui.resetSorting() + + app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { + screen.Clear() + return false + }) + + ui.app.SetInputCapture(ui.keyPressed) + ui.app.SetMouseCapture(ui.onMouse) + + ui.header = tview.NewTextView() + ui.header.SetText(" gdu ~ Use arrow keys to navigate, press ? for help ") + ui.header.SetTextColor(tcell.GetColor(ui.headerTextColor)) + ui.header.SetBackgroundColor(tcell.GetColor(ui.headerBackgroundColor)) + + ui.currentDirLabel = tview.NewTextView() + ui.currentDirLabel.SetTextColor(tcell.ColorDefault) + ui.currentDirLabel.SetBackgroundColor(tcell.ColorDefault) + + ui.table = tview.NewTable().SetSelectable(true, false) + ui.table.SetBackgroundColor(tcell.ColorDefault) + ui.table.SetSelectedFunc(ui.fileItemSelected) + + if ui.UseColors { + ui.table.SetSelectedStyle(tcell.Style{}. + Foreground(ui.selectedTextColor). + Background(ui.selectedBackgroundColor).Bold(true)) + } else { + ui.table.SetSelectedStyle(tcell.Style{}. + Foreground(tcell.ColorWhite). + Background(tcell.ColorGray).Bold(true)) + } + + ui.footerLabel = tview.NewTextView().SetDynamicColors(true) + ui.footerLabel.SetTextColor(tcell.GetColor(ui.footerTextColor)) + ui.footerLabel.SetBackgroundColor(tcell.GetColor(ui.footerBackgroundColor)) + ui.footerLabel.SetText(" No items to display. ") + + ui.footer = tview.NewFlex() + ui.footer.AddItem(ui.footerLabel, 0, 1, false) + + ui.createGrid() + + ui.pages = tview.NewPages(). + AddPage("background", ui.grid, true, true) + ui.pages.SetBackgroundColor(tcell.ColorDefault) + + ui.app.SetRoot(ui.pages, true) + + return ui +} + +// createGrid creates the main grid layout +func (ui *UI) createGrid() { + if ui.headerHidden { + ui.grid = tview.NewGrid().SetRows(1, 0, 1).SetColumns(0) + ui.grid.AddItem(ui.currentDirLabel, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 1, 0, 1, 1, 0, 0, true). + AddItem(ui.footer, 2, 0, 1, 1, 0, 0, false) + } else { + ui.grid = tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) + } +} + +// SetSelectedTextColor sets the color for the highlighted selected text +func (ui *UI) SetSelectedTextColor(color tcell.Color) { + ui.selectedTextColor = color +} + +// SetSelectedBackgroundColor sets the color for the highlighted selected text +func (ui *UI) SetSelectedBackgroundColor(color tcell.Color) { + ui.selectedBackgroundColor = color +} + +// SetFooterTextColor sets the color for the footer text +func (ui *UI) SetFooterTextColor(color string) { + ui.footerTextColor = color +} + +// SetFooterBackgroundColor sets the color for the footer background +func (ui *UI) SetFooterBackgroundColor(color string) { + ui.footerBackgroundColor = color +} + +// SetFooterNumberColor sets the color for the footer number +func (ui *UI) SetFooterNumberColor(color string) { + ui.footerNumberColor = color +} + +// SetHeaderTextColor sets the color for the header text +func (ui *UI) SetHeaderTextColor(color string) { + ui.headerTextColor = color +} + +// SetHeaderBackgroundColor sets the color for the header background +func (ui *UI) SetHeaderBackgroundColor(color string) { + ui.headerBackgroundColor = color +} + +// SetHeaderHidden sets the flag to hide the header +func (ui *UI) SetHeaderHidden() { + ui.headerHidden = true +} + +// SetResultRowDirectoryColor sets the color for the result row directory +func (ui *UI) SetResultRowDirectoryColor(color string) { + ui.resultRow.DirectoryColor = color +} + +// SetResultRowNumberColor sets the color for the result row number +func (ui *UI) SetResultRowNumberColor(color string) { + ui.resultRow.NumberColor = color +} + +// SetCurrentItemNameMaxLen sets the maximum length of the path of the currently processed item +// to be shown in the progress modal +func (ui *UI) SetCurrentItemNameMaxLen(maxLen int) { + ui.currentItemNameMaxLen = maxLen +} + +// UseOldSizeBar uses the old size bar (# chars) instead of the new one (unicode block elements) +func (ui *UI) UseOldSizeBar() { + ui.useOldSizeBar = true +} + +// SetChangeCwdFn sets function that can be used to change current working dir +// during dir browsing +func (ui *UI) SetChangeCwdFn(fn func(string) error) { + ui.changeCwdFn = fn +} + +// SetDeleteInParallel sets the flag to delete files in parallel +func (ui *UI) SetDeleteInParallel() { + ui.remover = remove.ItemFromDirParallel +} + +// StartUILoop starts tview application +func (ui *UI) StartUILoop() error { + go func() { + c := make(chan os.Signal, 1) + signal.Notify( + c, + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGQUIT, + syscall.SIGILL, + syscall.SIGTRAP, + syscall.SIGABRT, + syscall.SIGPIPE, + syscall.SIGTERM, + ) + s := <-c + log.Printf("Got signal: %s", s) + ui.app.QueueUpdateDraw(func() { + ui.app.Stop() + }) + }() + + return ui.app.Run() +} + +// SetShowItemCount sets the flag to show number of items in directory +func (ui *UI) SetShowItemCount() { + ui.showItemCount = true +} + +// SetShowMTime sets the flag to show last modification time of items in directory +func (ui *UI) SetShowMTime() { + ui.showMtime = true +} + +// SetNoDelete disables all write operations +func (ui *UI) SetNoDelete() { + ui.noDelete = true +} + +// SetDeleteInBackground sets the flag to delete files in background +func (ui *UI) SetDeleteInBackground() { + ui.deleteInBackground = true + + for i := 0; i < ui.deleteWorkersCount; i++ { + go ui.deleteWorker() + } + go ui.updateStatusWorker() +} + +func (ui *UI) resetSorting() { + ui.sortBy = ui.defaultSortBy + ui.sortOrder = ui.defaultSortOrder +} + +func (ui *UI) rescanDir() { + ui.Analyzer.ResetProgress() + ui.linkedItems = make(fs.HardLinkedItems) + err := ui.AnalyzePath(ui.currentDirPath, ui.currentDir.GetParent()) + if err != nil { + ui.showErr("Error rescanning path", err) + } +} + +func (ui *UI) fileItemSelected(row, column int) { + if ui.currentDir == nil { + return // Add this check to handle nil case + } + + selectedDirCell := ui.table.GetCell(row, column) + + // Check if the selectedDirCell is nil before using it + if selectedDirCell == nil || selectedDirCell.GetReference() == nil { + return + } + + selectedDir := selectedDirCell.GetReference().(fs.Item) + if selectedDir == nil || !selectedDir.IsDir() { + return + } + + origDir := ui.currentDir + ui.currentDir = selectedDir + ui.hideFilterInput() + ui.markedRows = make(map[int]struct{}) + ui.ignoredRows = make(map[int]struct{}) + ui.showDir() + + if origDir.GetParent() != nil && selectedDir.GetName() == origDir.GetParent().GetName() { + index := slices.IndexFunc( + ui.currentDir.GetFiles(), + func(v fs.Item) bool { + return v.GetName() == origDir.GetName() + }, + ) + if ui.currentDir.GetPath() != ui.topDir.GetPath() { + index++ + } + ui.table.Select(index, 0) + } +} + +func (ui *UI) deviceItemSelected(row, column int) { + var err error + selectedDevice, ok := ui.table.GetCell(row, column).GetReference().(*device.Device) + if !ok { + return + } + + paths := device.GetNestedMountpointsPaths(selectedDevice.MountPoint, ui.devices) + ui.IgnoreDirPathPatterns, err = common.CreateIgnorePattern(paths) + if err != nil { + log.Printf("Creating path patterns for other devices failed: %s", paths) + } + + ui.resetSorting() + + ui.Analyzer.ResetProgress() + ui.linkedItems = make(fs.HardLinkedItems) + err = ui.AnalyzePath(selectedDevice.MountPoint, nil) + if err != nil { + ui.showErr("Error analyzing device", err) + } +} + +func (ui *UI) confirmDeletion(shouldEmpty bool) { + if ui.noDelete { + previousHeaderText := ui.header.GetText(false) + + // show feedback to user + ui.header.SetText(" Deletion is disabled!") + + go func() { + time.Sleep(2 * time.Second) + ui.app.QueueUpdateDraw(func() { + ui.header.Clear() + ui.header.SetText(previousHeaderText) + }) + }() + + return + } + + if len(ui.markedRows) > 0 { + ui.confirmDeletionMarked(shouldEmpty) + } else { + ui.confirmDeletionSelected(shouldEmpty) + } +} + +func (ui *UI) confirmDeletionSelected(shouldEmpty bool) { + row, column := ui.table.GetSelection() + selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) + var action string + if shouldEmpty { + action = "empty" + } else { + action = "delete" + } + modal := tview.NewModal(). + SetText( + "Are you sure you want to " + + action + + " \"" + + tview.Escape(selectedFile.GetName()) + + "\"?", + ). + AddButtons([]string{"yes", "no", "don't ask me again"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonIndex { + case 2: + ui.askBeforeDelete = false + fallthrough + case 0: + ui.deleteSelected(shouldEmpty) + } + ui.pages.RemovePage("confirm") + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } else { + modal.SetBackgroundColor(tcell.ColorBlack) + } + modal.SetBorderColor(tcell.ColorDefault) + + ui.pages.AddPage("confirm", modal, true, true) +} diff --git a/gdu/tui/tui_test.go b/gdu/tui/tui_test.go new file mode 100644 index 0000000..2bd96a7 --- /dev/null +++ b/gdu/tui/tui_test.go @@ -0,0 +1,850 @@ +package tui + +import ( + "bytes" + "errors" + "fmt" + "os" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/testanalyze" + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/internal/testdev" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + "b612.me/apps/b612/gdu/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestFooter(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(15, 15) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 4096, + }, + BasePath: ".", + ItemCount: 2, + } + + file := &analyze.File{ + Name: "yyy", + Size: 2, + Usage: 4096, + Parent: dir, + } + dir.Files = fs.Files{file} + + ui.currentDir = dir + ui.showDir() + ui.pages.HidePage("progress") + + ui.footerLabel.Draw(simScreen) + simScreen.Show() + + b, _, _ := simScreen.GetContents() + + printScreen(simScreen) + + text := []byte(" Total disk usage: 4.0 KiB Apparent size: 2 B Items: 1") + for i, r := range b { + if i >= len(text) { + break + } + assert.Equal(t, string(text[i]), string(r.Bytes[0]), fmt.Sprintf("Index: %d", i)) + } +} + +func TestUpdateProgress(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + done := ui.Analyzer.GetDone() + done.Broadcast() + ui.updateProgress() + assert.True(t, true) +} + +func TestHelp(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + ui.showHelp() + + assert.True(t, ui.pages.HasPage("help")) + + ui.help.Draw(simScreen) + simScreen.Show() + + // printScreen(simScreen) + + b, _, _ := simScreen.GetContents() + + cells := b[507 : 507+9] + + text := []byte("directory") + for i, r := range cells { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestHelpBw(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.showHelp() + ui.help.Draw(simScreen) + simScreen.Show() + + // printScreen(simScreen) + + b, _, _ := simScreen.GetContents() + + cells := b[507 : 507+9] + + text := []byte("directory") + for i, r := range cells { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestAppRun(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + err := ui.StartUILoop() + + assert.Nil(t, err) +} + +func TestAppRunWithErr(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + err := ui.StartUILoop() + + assert.Equal(t, "Fail", err.Error()) +} + +func TestRescanDir(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + ui.rescanDir() + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, parentDir, ui.currentDir.GetParent()) + + assert.Equal(t, 5, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") +} + +func TestDirSelected(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, false, false) + ui.done = make(chan struct{}) + + ui.fileItemSelected(0, 0) + + assert.Equal(t, 3, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "subnested") +} + +func TestFileSelected(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.fileItemSelected(3, 0) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") +} + +func TestSelectedWithoutCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + ui.fileItemSelected(1, 0) + + assert.Nil(t, ui.currentDir) +} + +func TestBeforeDraw(t *testing.T) { + screen := tcell.NewSimulationScreen("UTF-8") + err := screen.Init() + + assert.Nil(t, err) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, screen, &bytes.Buffer{}, false, true, false, false, false) + + for _, f := range ui.app.(*testapp.MockedApp).BeforeDraws { + assert.False(t, f(screen)) + } +} + +func TestIgnorePaths(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetIgnoreDirPaths([]string{"/aaa", "/bbb"}) + + assert.True(t, ui.ShouldDirBeIgnored("aaa", "/aaa")) + assert.True(t, ui.ShouldDirBeIgnored("bbb", "/bbb")) + assert.False(t, ui.ShouldDirBeIgnored("ccc", "/ccc")) +} + +func TestConfirmDeletion(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionBW(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmEmpty(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletion(true) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmEmptyMarked(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(true) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionMarked(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionMarkedBW(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestDeleteSelected(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInParallel() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.remover = testanalyze.ItemFromDirWithSleep + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackgroundAndParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.remover = testanalyze.ItemFromDirWithSleep + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + ui.SetDeleteInParallel() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackgroundBW(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestEmptyDirInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(true) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + +func TestEmptyFileInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + ui.table.Select(2, 0) + + ui.deleteSelected(true) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.FileExists(t, "test_dir/nested/file2") + + f, err := os.Open("test_dir/nested/file2") + assert.Nil(t, err) + info, err := f.Stat() + assert.Nil(t, err) + assert.Equal(t, int64(0), info.Size()) +} + +func TestDeleteSelectedWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.remover = testanalyze.ItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.delete(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackgroundWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + ui.remover = testanalyze.ItemFromDirWithSleepAndErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.delete(false) + + <-ui.done + + // change the status + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // wait for status to be removed + time.Sleep(500 * time.Millisecond) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteMarkedWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.remover = testanalyze.ItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + ui.markedRows[0] = struct{}{} + + ui.deleteMarked(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteMarkedInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + + ui.markedRows[1] = struct{}{} // subnested + ui.markedRows[2] = struct{}{} // file2 + + ui.deleteMarked(false) + + <-ui.done // wait for deletion of subnested + <-ui.done // wait for deletion of file2 + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") + assert.NoFileExists(t, "test_dir/nested/file2") +} + +func TestDeleteMarkedInBackgroundWithStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetAnalyzer(analyze.CreateStoredAnalyzer("/tmp/badger")) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + + ui.markedRows[1] = struct{}{} // subnested + ui.markedRows[2] = struct{}{} // file2 + + ui.deleteMarked(false) + + <-ui.done // wait for deletion of subnested + <-ui.done // wait for deletion of file2 + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") + assert.NoFileExists(t, "test_dir/nested/file2") +} + +func TestDeleteMarkedInBackgroundWithStorageAndParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetAnalyzer(analyze.CreateStoredAnalyzer("/tmp/badger")) + ui.SetDeleteInBackground() + ui.SetDeleteInParallel() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + + ui.markedRows[1] = struct{}{} // subnested + ui.markedRows[2] = struct{}{} // file2 + + ui.deleteMarked(false) + + <-ui.done // wait for deletion of subnested + <-ui.done // wait for deletion of file2 + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") + assert.NoFileExists(t, "test_dir/nested/file2") +} + +func TestDeleteMarkedInBackgroundWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + ui.remover = testanalyze.ItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + ui.markedRows[0] = struct{}{} + + ui.deleteMarked(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestShowErr(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false) + + ui.showErr("Something went wrong", errors.New("error")) + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestShowErrBW(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.showErr("Something went wrong", errors.New("error")) + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestMin(t *testing.T) { + assert.Equal(t, 2, min(2, 5)) + assert.Equal(t, 3, min(4, 3)) +} + +func TestSetStyles(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + opts := []Option{} + opts = append(opts, func(ui *UI) { + ui.SetHeaderHidden() + }) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false, opts...) + + ui.SetSelectedBackgroundColor(tcell.ColorRed) + ui.SetSelectedTextColor(tcell.ColorRed) + ui.SetFooterTextColor("red") + ui.SetFooterBackgroundColor("red") + ui.SetFooterNumberColor("red") + ui.SetHeaderTextColor("red") + ui.SetHeaderBackgroundColor("red") + ui.SetResultRowDirectoryColor("red") + ui.SetResultRowNumberColor("red") + + assert.Equal(t, ui.selectedBackgroundColor, tcell.ColorRed) + assert.Equal(t, ui.selectedTextColor, tcell.ColorRed) + assert.Equal(t, ui.footerTextColor, "red") + assert.Equal(t, ui.footerBackgroundColor, "red") + assert.Equal(t, ui.footerNumberColor, "red") + assert.Equal(t, ui.headerTextColor, "red") + assert.Equal(t, ui.headerBackgroundColor, "red") + assert.Equal(t, ui.headerHidden, true) + assert.Equal(t, ui.resultRow.DirectoryColor, "red") + assert.Equal(t, ui.resultRow.NumberColor, "red") +} + +func TestSetCurrentItemNameMaxLen(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetCurrentItemNameMaxLen(5) + + assert.Equal(t, ui.currentItemNameMaxLen, 5) +} + +func TestUseOldSizeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.UseOldSizeBar() + + assert.Equal(t, ui.useOldSizeBar, true) +} + +func TestSetShowItemCount(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetShowItemCount() + + assert.Equal(t, ui.showItemCount, true) +} + +func TestSetShowMTime(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetShowMTime() + + assert.Equal(t, ui.showMtime, true) +} + +func TestNoDelete(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetNoDelete() + + assert.Equal(t, ui.noDelete, true) +} + +// nolint: deadcode,unused // Why: for debugging +func printScreen(simScreen tcell.SimulationScreen) { + b, _, _ := simScreen.GetContents() + + for i, r := range b { + if string(r.Bytes) != " " { + println(i, string(r.Bytes)) + } + } +} + +func getDevicesInfoMock() device.DevicesInfoGetter { + item := &device.Device{ + Name: "/dev/root", + MountPoint: "test_dir", + Size: 1e12, + Free: 1e6, + } + item2 := &device.Device{ + Name: "/dev/boot", + MountPoint: "/boot", + Size: 1e6, + Free: 1e3, + } + + mock := testdev.DevicesInfoGetterMock{} + mock.Devices = []*device.Device{item, item2} + return mock +} + +func getAnalyzedPathMockedApp(t *testing.T, useColors, apparentSize, mockedAnalyzer bool) *UI { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, useColors, apparentSize, false, false, false) + + if mockedAnalyzer { + ui.Analyzer = &testanalyze.MockedAnalyzer{} + } + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + return ui +} diff --git a/gdu/tui/utils.go b/gdu/tui/utils.go new file mode 100644 index 0000000..5d24402 --- /dev/null +++ b/gdu/tui/utils.go @@ -0,0 +1,71 @@ +package tui + +import ( + "b612.me/apps/b612/gdu/pkg/device" + "github.com/rivo/tview" +) + +var ( + barFullRune = "\u2588" + barPartRunes = map[int]string{ + 0: " ", + 1: "\u258F", + 2: "\u258E", + 3: "\u258D", + 4: "\u258C", + 5: "\u258B", + 6: "\u258A", + 7: "\u2589", + } +) + +func getDeviceUsagePart(item *device.Device, useOld bool) string { + part := int(float64(item.Size-item.Free) / float64(item.Size) * 100.0) + if useOld { + return getUsageGraphOld(part) + } + return getUsageGraph(part) +} + +func getUsageGraph(part int) string { + graph := " " + whole := part / 10 + for i := 0; i < whole; i++ { + graph += barFullRune + } + partWidth := (part % 10) * 8 / 10 + if part < 100 { + graph += barPartRunes[partWidth] + } + + for i := 0; i < 10-whole-1; i++ { + graph += " " + } + + graph += "\u258F" + return graph +} + +func getUsageGraphOld(part int) string { + part /= 10 + graph := "[" + for i := 0; i < 10; i++ { + if part > i { + graph += "#" + } else { + graph += " " + } + } + graph += "]" + return graph +} + +func modal(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 1, true). + AddItem(nil, 0, 1, false), width, 1, true). + AddItem(nil, 0, 1, false) +} diff --git a/gdu/tui/utils_test.go b/gdu/tui/utils_test.go new file mode 100644 index 0000000..3ed960f --- /dev/null +++ b/gdu/tui/utils_test.go @@ -0,0 +1,31 @@ +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetUsageGraph(t *testing.T) { + assert.Equal(t, " \u258F", getUsageGraph(0)) + assert.Equal(t, " █ \u258F", getUsageGraph(10)) + assert.Equal(t, " ██ \u258F", getUsageGraph(20)) + assert.Equal(t, " ███ \u258F", getUsageGraph(30)) + assert.Equal(t, " ████ \u258F", getUsageGraph(40)) + assert.Equal(t, " █████ \u258F", getUsageGraph(50)) + assert.Equal(t, " ██████ \u258F", getUsageGraph(60)) + assert.Equal(t, " ███████ \u258F", getUsageGraph(70)) + assert.Equal(t, " ████████ \u258F", getUsageGraph(80)) + assert.Equal(t, " █████████ \u258F", getUsageGraph(90)) + assert.Equal(t, " ██████████\u258F", getUsageGraph(100)) + + assert.Equal(t, " █ \u258F", getUsageGraph(11)) + assert.Equal(t, " █▏ \u258F", getUsageGraph(12)) + assert.Equal(t, " █▎ \u258F", getUsageGraph(13)) + assert.Equal(t, " █▍ \u258F", getUsageGraph(14)) + assert.Equal(t, " █▌ \u258F", getUsageGraph(15)) + assert.Equal(t, " █▌ \u258F", getUsageGraph(16)) + assert.Equal(t, " █▋ \u258F", getUsageGraph(17)) + assert.Equal(t, " █▊ \u258F", getUsageGraph(18)) + assert.Equal(t, " █▉ \u258F", getUsageGraph(19)) +} diff --git a/go.mod b/go.mod index bda6a28..e5e6a5a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module b612.me/apps/b612 -go 1.20 +go 1.22.4 require ( b612.me/astro v0.0.4 @@ -16,29 +16,44 @@ require ( b612.me/starssh v0.0.2 b612.me/startext v0.0.0-20220314043758-22c6d5e5b1cd b612.me/wincmd v0.0.4 + github.com/dgraph-io/badger/v3 v3.2103.2 github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 github.com/emersion/go-smtp v0.20.2 + github.com/fatih/color v1.16.0 github.com/florianl/go-nfqueue/v2 v2.0.0 + github.com/gdamore/tcell v1.4.0 github.com/gdamore/tcell/v2 v2.7.1 github.com/go-acme/lego/v4 v4.16.1 github.com/goftp/file-driver v0.0.0-20180502053751-5d604a0fc0c9 github.com/goftp/server v0.0.0-20200708154336-f64f7c2d8a42 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/google/gopacket v1.1.19 + github.com/h2non/filetype v1.1.3 github.com/huin/goupnp v1.3.0 github.com/inconshreveable/mousetrap v1.1.0 + github.com/maruel/natural v1.1.0 + github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-runewidth v0.0.16 github.com/miekg/dns v1.1.58 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 + github.com/pkg/errors v0.9.1 github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/shirou/gopsutil/v4 v4.24.10 + github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.9.0 github.com/things-go/go-socks5 v0.0.5 + github.com/tobert/pcstat v0.0.2 + github.com/ulikunitz/xz v0.5.12 github.com/vbauerster/mpb/v8 v8.8.3 - golang.org/x/crypto v0.26.0 - golang.org/x/net v0.28.0 - golang.org/x/sys v0.26.0 - golang.org/x/term v0.23.0 + golang.org/x/crypto v0.32.0 + golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 + golang.org/x/net v0.32.0 + golang.org/x/sys v0.29.0 + golang.org/x/term v0.28.0 + gopkg.in/yaml.v3 v3.0.1 software.sslmate.com/src/go-pkcs12 v0.4.0 ) @@ -54,15 +69,26 @@ require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.1.1 // indirect github.com/cloudflare/cloudflare-go v0.86.0 // indirect github.com/cpu/goacmedns v0.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgraph-io/ristretto v0.1.0 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect github.com/ebitengine/purego v0.8.1 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect - github.com/gdamore/encoding v1.0.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect github.com/go-jose/go-jose/v4 v4.0.1 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/snappy v0.0.3 // indirect + github.com/google/flatbuffers v1.12.1 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.3.1 // indirect @@ -72,17 +98,19 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.12.3 // indirect github.com/kr/fs v0.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/sftp v1.13.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect @@ -91,11 +119,13 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opencensus.io v0.24.0 // indirect golang.org/x/image v0.6.0 // indirect golang.org/x/mod v0.17.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index df3ad71..487e66d 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,7 @@ b612.me/win32api v0.0.2 h1:5PwvPR5fYs3a/v+LjYdtRif+5Q04zRGLTVxmCYNjCpA= b612.me/win32api v0.0.2/go.mod h1:sj66sFJDKElEjOR+0YhdSW6b4kq4jsXu4T5/Hnpyot0= b612.me/wincmd v0.0.4 h1:fv9p1V8mw2HdUjaoZBWZy0T41JftueyLxAuch1MgtdI= b612.me/wincmd v0.0.4/go.mod h1:o3yPoE+DpVPHGKl/q1WT1C8OaIVwHEnpeNgMFqzlwD8= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 h1:8kDqDngH+DmVBiCtIjCFTGa7MBnsIOkF9IccInFEbjk= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= @@ -37,28 +38,54 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aov github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0 h1:8iR6OLffWWorFdzL2JFCab5xpD8VKEE2DUBBl+HNTDY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0/go.mod h1:copqlcjMWc/wgQ1N2fzsJFQxDdqKGg1EQt8T5wJMOGE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2/go.mod h1:FbdwsQ2EzwvXxOPcMFYO8ogEc9uMMIj3YkmCdXdAFmk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0 h1:rR8ZW79lE/ppfXTfiYSnMFv5EzmVuY4pfZWIkscIJ64= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0/go.mod h1:y2zXtLSMM/X5Mfawq0lOftpWn3f4V6OCsRdINsvWBPI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0 h1:ECsQtyERDVz3NP3kvDOTLvbQhqWp/x9EsGKtb4ogUr8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0/go.mod h1:s1tW/At+xHqjNFvWU4G0c0Qv33KOhvbGNj0RCTQDV8s= github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 h1:OBhqkivkhkMqLPymWEppkm7vgPQY2XsHoEkaMQ0AdZY= github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 h1:J45/QHgrzUdqe/Vco/Vxk0wRvdS2nKUxmf/zLgvfass= github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/cloudflare-go v0.86.0 h1:jEKN5VHNYNYtfDL2lUFLTRo+nOVNPFxpXTstVx0rqHI= github.com/cloudflare/cloudflare-go v0.86.0/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpu/goacmedns v0.1.1 h1:DM3H2NiN2oam7QljgGY5ygy4yDXhK5Z4JUnqaugs2C4= github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8= +github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M= +github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI= +github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE= github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 h1:m62nsMU279qRD9PQSWD1l66kmkXzuYcnVJqL4XLeV2M= @@ -69,11 +96,20 @@ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1X github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.20.2 h1:peX42Qnh5Q0q3vrAnRy43R/JwTnnv75AebxbkTL7Ia4= github.com/emersion/go-smtp v0.20.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= -github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/florianl/go-nfqueue/v2 v2.0.0 h1:NTCxS9b0GSbHkWv1a7oOvZn679fsyDkaSkRvOYpQ9Oo= github.com/florianl/go-nfqueue/v2 v2.0.0/go.mod h1:M2tBLIj62QpwqjwV0qfcjqGOqP3qiTuXr2uSRBXH9Qk= -github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= +github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= github.com/gdamore/tcell/v2 v2.7.1 h1:TiCcmpWHiAU7F0rA2I3S2Y4mmLmO9KHxJ7E1QhYzQbc= github.com/gdamore/tcell/v2 v2.7.1/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/go-acme/lego/v4 v4.16.1 h1:JxZ93s4KG0jL27rZ30UsIgxap6VGzKuREsSkkyzeoCQ= @@ -88,12 +124,46 @@ github.com/goftp/file-driver v0.0.0-20180502053751-5d604a0fc0c9 h1:cC0Hbb+18DJ4i github.com/goftp/file-driver v0.0.0-20180502053751-5d604a0fc0c9/go.mod h1:GpOj6zuVBG3Inr9qjEnuVTgBlk2lZ1S9DcoFiXWyKss= github.com/goftp/server v0.0.0-20200708154336-f64f7c2d8a42 h1:JdOp2qR5PF4O75tzHeqrwnDDv8oHDptWyTbyYS4fD8E= github.com/goftp/server v0.0.0-20200708154336-f64f7c2d8a42/go.mod h1:k/SS6VWkxY7dHPhoMQ8IdRu8L4lQtmGbhyXGg+vCnXE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -102,18 +172,26 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= +github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jlaffaye/ftp v0.1.0 h1:DLGExl5nBoSFoNshAUHwXAezXwXBvFdx7/qwhucWNSE= @@ -128,19 +206,34 @@ github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2C github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/maruel/natural v1.1.0 h1:2z1NgP/Vae+gYrtC0VuvrTJ6U35OuyUqDdfluLqMWuQ= +github.com/maruel/natural v1.1.0/go.mod h1:eFVhYCcUOfZFxXoDZam8Ktya72wa79fNC3lc/leA0DQ= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -150,6 +243,8 @@ github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -158,14 +253,20 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg= github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE= github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -173,18 +274,39 @@ github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM= github.com/shirou/gopsutil/v4 v4.24.10/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490 h1:mmz27tVi2r70JYnm5y0Zk8w0Qzsx+vfUw3oqSyrEfP8= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490 h1:g9SWTaTy/rEuhMErC2jWq9Qt5ci+jBYSvXnJsLq4adg= @@ -195,13 +317,26 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tobert/pcstat v0.0.2 h1:1aOSyiB9/ZK5UEtpoYsWvHzisQ9+zEhRDgg9GR8TwZs= +github.com/tobert/pcstat v0.0.2/go.mod h1:CErbIEQ//zQ/J9RCvS1COdm41lz9wsWFN5kG+flisjI= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbauerster/mpb/v8 v8.8.3 h1:dTOByGoqwaTJYPubhVz3lO5O6MK553XVgUo33LdnNsQ= github.com/vbauerster/mpb/v8 v8.8.3/go.mod h1:JfCCrtcMsJwP6ZwMn9e5LMnNyp3TVNpUWWkN+nd4EWk= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220313003712-b769efc7c000/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -209,20 +344,36 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -231,29 +382,45 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -262,8 +429,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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= @@ -273,8 +440,9 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -285,13 +453,20 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= @@ -300,16 +475,49 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/hcache/formats.go b/hcache/formats.go new file mode 100644 index 0000000..3f41a89 --- /dev/null +++ b/hcache/formats.go @@ -0,0 +1,306 @@ +//go:build linux + +package hcache + +/* + * Copyright 2015 Albert P. Tobey @AlTobey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * pcstat.go - page cache stat + * + * uses the mincore(2) syscall to find out which pages (almost always 4k) + * of a file are currently cached in memory + * + */ + +import ( + "encoding/json" + "fmt" + pcstat "github.com/tobert/pcstat/pkg" + "log" + "os" + "strings" +) + +type PcStatusList []pcstat.PcStatus + +func (a PcStatusList) Len() int { + return len(a) +} +func (a PcStatusList) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} +func (a PcStatusList) Less(i, j int) bool { + return a[j].Cached < a[i].Cached +} + +func (stats PcStatusList) FormatUnicode() { + maxName := stats.maxNameLen() + + // create horizontal grid line + pad := strings.Repeat("─", maxName+2) + top := fmt.Sprintf("┌%s┬────────────────┬─────────────┬────────────────┬─────────────┬─────────┐", pad) + hr := fmt.Sprintf("├%s┼────────────────┼─────────────┼────────────────┼─────────────┼─────────┤", pad) + bot := fmt.Sprintf("└%s┴────────────────┴─────────────┴────────────────┴─────────────┴─────────┘", pad) + + var size_sum, page_sum, cached_page_sum, cached_size, cached_size_sum int64 + + fmt.Println(top) + + // -nohdr may be chosen to save 2 lines of precious vertical space + if !nohdrFlag { + pad = strings.Repeat(" ", maxName-4) + fmt.Printf("│ Name%s │ Size │ Pages │ Cached Size │ Cached Pages│ Percent │\n", pad) + fmt.Println(hr) + } + + for _, pcs := range stats { + pad = strings.Repeat(" ", maxName-len(pcs.Name)) + + // The cache is counted through the page,can't count accurately. + // Here cached file size is calculated by pcs.Size and pcs.Percent, + // so it is not completely accurate, but it has reference value. + cached_size = int64(float64(pcs.Size) * pcs.Percent / 100) + + // %-7.3f was chosen to make it easy to scan the percentages vertically + // I tried a few different formats only this one kept the decimals aligned + fmt.Printf("│ %s%s │ %-15s│ %-12d│ %-15s│ %-12d│ %-7.3f │\n", + pcs.Name, pad, ConvertUnit(pcs.Size), pcs.Pages, ConvertUnit(cached_size), pcs.Cached, pcs.Percent) + + size_sum += pcs.Size + page_sum += int64(pcs.Pages) + cached_page_sum += int64(pcs.Cached) + cached_size_sum += cached_size + } + + fmt.Println(hr) + pad = strings.Repeat(" ", maxName-len("Sum")) + fmt.Printf("│ %s%s │ %-15s│ %-12d│ %-15s│ %-12d│ %-7.3f │\n", + "Sum", pad, ConvertUnit(size_sum), page_sum, ConvertUnit(cached_size_sum), cached_page_sum, (float64(cached_page_sum)/float64(page_sum))*100.00) + fmt.Println(bot) +} + +func (stats PcStatusList) FormatText() { + maxName := stats.maxNameLen() + + // create horizontal grid line + pad := strings.Repeat("-", maxName+2) + top := fmt.Sprintf("+%s+----------------+-------------+----------------+-------------+---------+", pad) + hr := fmt.Sprintf("|%s+----------------+-------------+----------------+-------------+---------|", pad) + bot := fmt.Sprintf("+%s+----------------+-------------+----------------+-------------+---------+", pad) + var size_sum, page_sum, cached_page_sum, cached_size, cached_size_sum int64 + + fmt.Println(top) + + // -nohdr may be chosen to save 2 lines of precious vertical space + if !nohdrFlag { + pad = strings.Repeat(" ", maxName-4) + fmt.Printf("| Name%s | Size │ Pages │ Cached Size │ Cached Pages│ Percent │\n", pad) + fmt.Println(hr) + } + + for _, pcs := range stats { + pad = strings.Repeat(" ", maxName-len(pcs.Name)) + cached_size = int64(float64(pcs.Size) * pcs.Percent / 100) + + // %-7.3f was chosen to make it easy to scan the percentages vertically + // I tried a few different formats only this one kept the decimals aligned + fmt.Printf("| %s%s | %-15s| %-12d| %-15s| %-12d| %-7.3f |\n", + pcs.Name, pad, ConvertUnit(pcs.Size), pcs.Pages, ConvertUnit(cached_size), pcs.Cached, pcs.Percent) + + size_sum += pcs.Size + page_sum += int64(pcs.Pages) + cached_page_sum += int64(pcs.Cached) + cached_size_sum += cached_size + } + + fmt.Println(hr) + pad = strings.Repeat(" ", maxName-len("Sum")) + fmt.Printf("│ %s%s │ %-15s│ %-12d│ %-15s│ %-12d│ %-7.3f │\n", + "Sum", pad, ConvertUnit(size_sum), page_sum, ConvertUnit(cached_size_sum), cached_page_sum, (float64(cached_page_sum)/float64(page_sum))*100.00) + fmt.Println(bot) +} + +func (stats PcStatusList) FormatPlain() { + maxName := stats.maxNameLen() + + var size_sum, page_sum, cached_page_sum, cached_size, cached_size_sum int64 + + // -nohdr may be chosen to save 2 lines of precious vertical space + if !nohdrFlag { + pad := strings.Repeat(" ", maxName-4) + fmt.Printf("Name%s Size Pages Cached Size Cached Pages Percent\n", pad) + } + + for _, pcs := range stats { + pad := strings.Repeat(" ", maxName-len(pcs.Name)) + cached_size = int64(float64(pcs.Size) * pcs.Percent / 100) + + // %-7.3f was chosen to make it easy to scan the percentages vertically + // I tried a few different formats only this one kept the decimals aligned + fmt.Printf("%s%s %-15s %-12d %-15s %-12d %-7.3f\n", + pcs.Name, pad, ConvertUnit(pcs.Size), pcs.Pages, ConvertUnit(cached_size), pcs.Cached, pcs.Percent) + + size_sum += pcs.Size + page_sum += int64(pcs.Pages) + cached_page_sum += int64(pcs.Cached) + cached_size_sum += cached_size + } + + pad := strings.Repeat(" ", maxName-len("Sum")) + fmt.Printf("%s%s %-15s %-12d %-15s %-12d %-7.3f\n", + "Sum", pad, ConvertUnit(size_sum), page_sum, ConvertUnit(cached_size_sum), cached_page_sum, (float64(cached_page_sum)/float64(page_sum))*100.00) +} + +func (stats PcStatusList) FormatTerse() { + + if !nohdrFlag { + fmt.Println("name,size,timestamp,mtime,pages,cached,percent") + } + for _, pcs := range stats { + time := pcs.Timestamp.Unix() + mtime := pcs.Mtime.Unix() + fmt.Printf("%s,%d,%d,%d,%d,%d,%g\n", + pcs.Name, pcs.Size, time, mtime, pcs.Pages, pcs.Cached, pcs.Percent) + } +} + +func (stats PcStatusList) FormatJson(clearpps bool) { + // clear the per-page status when requested + // emits an empty "status": [] field in the JSON when disabled, but NBD. + if clearpps { + for i := range stats { + stats[i].PPStat = nil + } + } + + b, err := json.Marshal(stats) + if err != nil { + log.Fatalf("JSON formatting failed: %s\n", err) + } + os.Stdout.Write(b) + fmt.Println("") +} + +// references: +// http://www.unicode.org/charts/PDF/U2580.pdf +// https://github.com/puppetlabs/mcollective-puppet-agent/blob/master/application/puppet.rb#L143 +// https://github.com/holman/spark +func (stats PcStatusList) FormatHistogram() { + ws := getwinsize() + maxName := stats.maxNameLen() + + // block elements are wider than characters, so only use 1/2 the available columns + buckets := (int(ws.ws_col)-maxName)/2 - 10 + + for _, pcs := range stats { + pad := strings.Repeat(" ", maxName-len(pcs.Name)) + fmt.Printf("%s%s % 8d ", pcs.Name, pad, pcs.Pages) + + // when there is enough room display on/off for every page + if buckets > pcs.Pages { + for _, v := range pcs.PPStat { + if v { + fmt.Print("\u2588") // full block = 100% + } else { + fmt.Print("\u2581") // lower 1/8 block + } + } + } else { + bsz := pcs.Pages / buckets + fbsz := float64(bsz) + total := 0.0 + for i, v := range pcs.PPStat { + if v { + total++ + } + + if (i+1)%bsz == 0 { + avg := total / fbsz + if total == 0 { + fmt.Print("\u2581") // lower 1/8 block = 0 + } else if avg < 0.16 { + fmt.Print("\u2582") // lower 2/8 block + } else if avg < 0.33 { + fmt.Print("\u2583") // lower 3/8 block + } else if avg < 0.50 { + fmt.Print("\u2584") // lower 4/8 block + } else if avg < 0.66 { + fmt.Print("\u2585") // lower 5/8 block + } else if avg < 0.83 { + fmt.Print("\u2586") // lower 6/8 block + } else if avg < 1.00 { + fmt.Print("\u2587") // lower 7/8 block + } else { + fmt.Print("\u2588") // full block = 100% + } + + total = 0 + } + } + } + fmt.Println("") + } +} + +/* + + // convert long paths to their basename with the -bname flag + // this overwrites the original filename in pcs but it doesn't matter since + // it's not used to access the file again -- and should not be! + if bnameFlag { + pcs.Name = path.Base(fname) + } +*/ + +// maxNameLen returns the len of longest filename in the stat list +// if the bnameFlag is set, this will return the max basename len +func (stats PcStatusList) maxNameLen() int { + var maxName int + for _, pcs := range stats { + if len(pcs.Name) > maxName { + maxName = len(pcs.Name) + } + } + + if maxName < 5 { + maxName = 5 + } + return maxName +} + +// define some const unit +// convert origin size data to a friendly readable string. +func ConvertUnit(byteSize int64) string { + const KB int64 = 1024 + const MB int64 = 1024 * KB + const GB int64 = 1024 * MB + const TB int64 = 1024 * GB + const PB int64 = 1024 * TB + + switch { + case byteSize >= PB: + return fmt.Sprintf("%.3fP", (float64(byteSize) / float64(PB))) + case byteSize >= TB: + return fmt.Sprintf("%.3fT", (float64(byteSize) / float64(TB))) + case byteSize >= GB: + return fmt.Sprintf("%.3fG", (float64(byteSize) / float64(GB))) + case byteSize >= MB: + return fmt.Sprintf("%.3fM", (float64(byteSize) / float64(MB))) + case byteSize >= KB: + return fmt.Sprintf("%.3fK", (float64(byteSize) / float64(KB))) + default: + return fmt.Sprintf("%dB", byteSize) + } +} diff --git a/hcache/main.go b/hcache/main.go new file mode 100644 index 0000000..b3cba80 --- /dev/null +++ b/hcache/main.go @@ -0,0 +1,216 @@ +//go:build linux + +package hcache + +/* + * Copyright 2014-2015 Albert P. Tobey @AlTobey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * pcstat.go - page cache stat + * + * uses the mincore(2) syscall to find out which pages (almost always 4k) + * of a file are currently cached in memory + * + */ + +import ( + "b612.me/apps/b612/hcache/pkg/utils" + "bufio" + "fmt" + "github.com/spf13/cobra" + "log" + "os" + "path" + "sort" + "strings" + + pcstat "github.com/tobert/pcstat/pkg" +) + +var ( + pidFlag, topFlag int + terseFlag, nohdrFlag, jsonFlag, unicodeFlag bool + plainFlag, ppsFlag, histoFlag, bnameFlag bool +) + +func init() { + // TODO: error on useless/broken combinations + Cmd.Flags().IntVarP(&pidFlag, "pid", "p", 0, "show all open maps for the given pid") + Cmd.Flags().IntVarP(&topFlag, "top", "t", 0, "show top x cached files in descending order") + Cmd.Flags().BoolVarP(&terseFlag, "terse", "T", false, "show terse output") + Cmd.Flags().BoolVarP(&nohdrFlag, "nohdr", "n", false, "omit the header from terse & text output") + Cmd.Flags().BoolVarP(&jsonFlag, "json", "j", false, "return data in JSON format") + Cmd.Flags().BoolVarP(&unicodeFlag, "unicode", "u", false, "return data with unicode box characters") + Cmd.Flags().BoolVarP(&plainFlag, "plain", "P", false, "return data with no box characters") + Cmd.Flags().BoolVarP(&ppsFlag, "pps", "S", false, "include the per-page status in JSON output") + Cmd.Flags().BoolVarP(&histoFlag, "histo", "H", false, "print a simple histogram instead of raw data") + Cmd.Flags().BoolVarP(&bnameFlag, "bname", "B", false, "convert paths to basename to narrow the output") +} + +var Cmd = &cobra.Command{ + Use: "hcache", + Short: "Page cache status", + Long: `hcache - Page cache status`, + Run: func(cmd *cobra.Command, args []string) { + run(args) + }, +} + +func uniqueSlice(slice *[]string) { + found := make(map[string]bool) + total := 0 + for i, val := range *slice { + if _, ok := found[val]; !ok { + found[val] = true + (*slice)[total] = (*slice)[i] + total++ + } + } + + *slice = (*slice)[:total] +} + +func getStatsFromFiles(files []string) PcStatusList { + + stats := make(PcStatusList, 0, len(files)) + for _, fname := range files { + status, err := pcstat.GetPcStatus(fname) + if err != nil { + log.Printf("skipping %q: %v", fname, err) + continue + } + + // convert long paths to their basename with the -bname flag + // this overwrites the original filename in pcs but it doesn't matter since + // it's not used to access the file again -- and should not be! + if bnameFlag { + status.Name = path.Base(fname) + } + + stats = append(stats, status) + } + return stats +} + +func formatStats(stats PcStatusList) { + if jsonFlag { + stats.FormatJson(!ppsFlag) + } else if terseFlag { + stats.FormatTerse() + } else if histoFlag { + stats.FormatHistogram() + } else if unicodeFlag { + stats.FormatUnicode() + } else if plainFlag { + stats.FormatPlain() + } else { + stats.FormatText() + } +} + +func top(top int) { + p, err := utils.Processes() + if err != nil { + log.Fatalf("err: %s", err) + } + + if len(p) <= 0 { + log.Fatal("Cannot find any process.") + } + + results := make([]utils.Process, 0, 50) + + for _, p1 := range p { + if p1.RSS() != 0 { + results = append(results, p1) + } + } + + var files []string + + for _, process := range results { + pcstat.SwitchMountNs(process.Pid()) + maps := getPidMaps(process.Pid()) + files = append(files, maps...) + } + + uniqueSlice(&files) + + stats := getStatsFromFiles(files) + + sort.Sort(PcStatusList(stats)) + topStats := stats[:top] + formatStats(topStats) +} + +func run(files []string) { + + if topFlag != 0 { + top(topFlag) + os.Exit(0) + } + + if pidFlag != 0 { + pcstat.SwitchMountNs(pidFlag) + maps := getPidMaps(pidFlag) + files = append(files, maps...) + } + + // all non-flag arguments are considered to be filenames + // this works well with shell globbing + // file order is preserved throughout this program + if len(files) == 0 { + os.Exit(1) + } + + stats := getStatsFromFiles(files) + sort.Sort(PcStatusList(stats)) + formatStats(stats) +} + +func getPidMaps(pid int) []string { + fname := fmt.Sprintf("/proc/%d/maps", pid) + + f, err := os.Open(fname) + if err != nil { + log.Fatalf("could not open '%s' for read: %v", fname, err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + + // use a map to help avoid duplicates + maps := make(map[string]bool) + + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + if len(parts) == 6 && strings.HasPrefix(parts[5], "/") { + // found something that looks like a file + maps[parts[5]] = true + } + } + + if err := scanner.Err(); err != nil { + log.Fatalf("reading '%s' failed: %s", fname, err) + } + + // convert back to a list + out := make([]string, 0, len(maps)) + for key := range maps { + out = append(out, key) + } + + return out +} diff --git a/hcache/nolinux.go b/hcache/nolinux.go new file mode 100644 index 0000000..4dc4f3e --- /dev/null +++ b/hcache/nolinux.go @@ -0,0 +1,15 @@ +//go:build !linux + +package hcache + +import "github.com/spf13/cobra" + +var Cmd = &cobra.Command{ + Use: "hcache", + Short: "Page cache status", + Long: `hcache - Page cache status`, + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + + }, +} diff --git a/hcache/pkg/utils/process.go b/hcache/pkg/utils/process.go new file mode 100644 index 0000000..fd3d691 --- /dev/null +++ b/hcache/pkg/utils/process.go @@ -0,0 +1,54 @@ +// ps provides an API for finding and listing processes in a platform-agnostic +// way. +// +// NOTE: If you're reading these docs online via GoDocs or some other system, +// you might only see the Unix docs. This project makes heavy use of +// platform-specific implementations. We recommend reading the source if you +// are interested. +package utils + +// Process is the generic interface that is implemented on every platform +// and provides common operations for processes. +type Process interface { + // Pid is the process ID for this process. + Pid() int + + // PPid is the parent process ID for this process. + PPid() int + + RSS() int + + // Executable name running this process. This is not a path to the + // executable. + Executable() string +} + +type ProcessSlice []Process + +func (a ProcessSlice) Len() int { + return len(a) +} +func (a ProcessSlice) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} +func (a ProcessSlice) Less(i, j int) bool { + return a[j].RSS() < a[i].RSS() +} + +// Processes returns all processes. +// +// This of course will be a point-in-time snapshot of when this method was +// called. Some operating systems don't provide snapshot capability of the +// process table, in which case the process table returned might contain +// ephemeral entities that happened to be running when this was called. +func Processes() ([]Process, error) { + return processes() +} + +// FindProcess looks up a single process by pid. +// +// Process will be nil and error will be nil if a matching process is +// not found. +func FindProcess(pid int) (Process, error) { + return FindProcess(pid) +} diff --git a/hcache/pkg/utils/process_linux.go b/hcache/pkg/utils/process_linux.go new file mode 100644 index 0000000..26622ff --- /dev/null +++ b/hcache/pkg/utils/process_linux.go @@ -0,0 +1,40 @@ +// +build linux + +package utils + +import ( + "fmt" + "io/ioutil" + "strconv" + "strings" +) + +// Refresh reloads all the data associated with this process. +func (p *UnixProcess) Refresh() error { + statPath := fmt.Sprintf("/proc/%d/stat", p.pid) + dataBytes, err := ioutil.ReadFile(statPath) + if err != nil { + return err + } + + // First, parse out the image name + data := string(dataBytes) + binStart := strings.IndexRune(data, '(') + 1 + binEnd := strings.IndexRune(data[binStart:], ')') + p.binary = data[binStart : binStart+binEnd] + + stats := strings.Split(data, " ") + // http://man7.org/linux/man-pages/man5/proc.5.html + p.rss, err = strconv.Atoi(stats[23]) + + // Move past the image name and start parsing the rest + data = data[binStart+binEnd+2:] + _, err = fmt.Sscanf(data, + "%c %d %d %d", + &p.state, + &p.ppid, + &p.pgrp, + &p.sid) + + return err +} diff --git a/hcache/pkg/utils/process_unix.go b/hcache/pkg/utils/process_unix.go new file mode 100644 index 0000000..f629374 --- /dev/null +++ b/hcache/pkg/utils/process_unix.go @@ -0,0 +1,106 @@ +// +build linux solaris + +package utils + +import ( + "fmt" + "io" + "os" + "strconv" +) + +// UnixProcess is an implementation of Process that contains Unix-specific +// fields and information. +type UnixProcess struct { + pid int + ppid int + state rune + pgrp int + sid int + rss int + + binary string +} + +func (p *UnixProcess) Pid() int { + return p.pid +} + +func (p *UnixProcess) PPid() int { + return p.ppid +} + +func (p *UnixProcess) RSS() int { + return p.rss +} + +func (p *UnixProcess) Executable() string { + return p.binary +} + +func findProcess(pid int) (Process, error) { + dir := fmt.Sprintf("/proc/%d", pid) + _, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err + } + + return newUnixProcess(pid) +} + +func processes() ([]Process, error) { + d, err := os.Open("/proc") + if err != nil { + return nil, err + } + defer d.Close() + + results := make([]Process, 0, 50) + for { + fis, err := d.Readdir(10) + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + for _, fi := range fis { + // We only care about directories, since all pids are dirs + if !fi.IsDir() { + continue + } + + // We only care if the name starts with a numeric + name := fi.Name() + if name[0] < '0' || name[0] > '9' { + continue + } + + // From this point forward, any errors we just ignore, because + // it might simply be that the process doesn't exist anymore. + pid, err := strconv.ParseInt(name, 10, 0) + if err != nil { + continue + } + + p, err := newUnixProcess(int(pid)) + if err != nil { + continue + } + + results = append(results, p) + } + } + + return results, nil +} + +func newUnixProcess(pid int) (*UnixProcess, error) { + p := &UnixProcess{pid: pid} + return p, p.Refresh() +} diff --git a/hcache/winsize.go b/hcache/winsize.go new file mode 100644 index 0000000..a51a1b9 --- /dev/null +++ b/hcache/winsize.go @@ -0,0 +1,50 @@ +//go:build linux + +package hcache + +/* + * Copyright 2015 Albert P. Tobey @AlTobey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * pcstat.go - page cache stat + * + * uses the mincore(2) syscall to find out which pages (almost always 4k) + * of a file are currently cached in memory + * + */ + +import ( + "log" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +// adapted from https://groups.google.com/d/msg/golang-nuts/8d4pOPmSL9Q/H6WUqbGNELEJ +type winsize struct { + ws_row, ws_col uint16 + ws_xpixel, ws_ypixel uint16 +} + +func getwinsize() winsize { + ws := winsize{} + _, _, err := unix.Syscall(syscall.SYS_IOCTL, + uintptr(0), uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(&ws))) + if err != 0 { + log.Fatalf("TIOCGWINSZ failed to get terminal size: %s\n", err) + } + return ws +} diff --git a/main.go b/main.go index a28863b..1ef956a 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "b612.me/apps/b612/base64" "b612.me/apps/b612/base85" "b612.me/apps/b612/base91" + "b612.me/apps/b612/bed/cmd/bed" "b612.me/apps/b612/calc" "b612.me/apps/b612/cert" "b612.me/apps/b612/detach" @@ -14,8 +15,10 @@ import ( "b612.me/apps/b612/dfinder" "b612.me/apps/b612/dns" "b612.me/apps/b612/ftp" + "b612.me/apps/b612/gdu/cmd/gdu" "b612.me/apps/b612/generate" "b612.me/apps/b612/hash" + "b612.me/apps/b612/hcache" "b612.me/apps/b612/httpreverse" "b612.me/apps/b612/httproxy" "b612.me/apps/b612/httpserver" @@ -56,7 +59,7 @@ func init() { base64.Cmd, base85.Cmd, base91.Cmd, attach.Cmd, detach.Cmd, df.Cmd, dfinder.Cmd, ftp.Cmd, generate.Cmd, hash.Cmd, image.Cmd, merge.Cmd, search.Cmd, split.Cmd, vic.Cmd, calc.Cmd, net.Cmd, rmt.Cmds, rmt.Cmdc, keygen.Cmd, dns.Cmd, whois.Cmd, socks5.Cmd, httproxy.Cmd, smtpserver.Cmd, smtpclient.Cmd, - cert.Cmd, aes.Cmd, tls.Cmd, mget.Cmd, tcpkill.Cmd, tcm.Cmd, astro.CmdCal, astro.Cmd, nmon.Cmd) + cert.Cmd, aes.Cmd, tls.Cmd, mget.Cmd, tcpkill.Cmd, tcm.Cmd, astro.CmdCal, astro.Cmd, nmon.Cmd, hcache.Cmd, gdu.Cmd, bed.Cmd) } func main() { diff --git a/mget/wget.go b/mget/wget.go index e5d1ac0..3864faf 100644 --- a/mget/wget.go +++ b/mget/wget.go @@ -259,6 +259,8 @@ func (w *Mget) Run() error { } if !w.NoWriteRedo { if len(r) == 0 { + w.Lock() + defer w.Unlock() if staros.Exists(w.Tareget + ".bgrd") { return os.Remove(w.Tareget + ".bgrd") } diff --git a/tcpkill/cmd.go b/tcpkill/cmd.go index c42fcf1..5705fb5 100644 --- a/tcpkill/cmd.go +++ b/tcpkill/cmd.go @@ -19,6 +19,7 @@ func init() { Cmd.Flags().IntVarP(&tck.RstNumbers, "rst-numbers", "n", 3, "RST包数量") Cmd.Flags().BoolVarP(&tck.WaitMode, "wait", "w", false, "等待模式") Cmd.Flags().StringVarP(&tck.KillType, "kill-type", "t", "both", "RST通知类型,both=都通知 target=目标地址通知 reverse=来源地址通知") + Cmd.Flags().Int32SliceVar(&tck.Pids, "pid", []int32{}, "匹配进程PID") if runtime.GOOS != "windows" { Cmd.Flags().BoolVarP(&tck.AutoIptables, "auto-iptables", "a", true, "自动设置iptables") Cmd.Flags().IntVarP(&tck.NFQNums, "nfq-nums", "q", 0, "NFQ队列号") diff --git a/tcpkill/tcpkill.go b/tcpkill/tcpkill.go index 731c61a..0bfb096 100644 --- a/tcpkill/tcpkill.go +++ b/tcpkill/tcpkill.go @@ -58,6 +58,14 @@ func (t *TCPKill) Match(info netm.ConnectionStat) bool { if _, ok := t.matchConns.Load(key(info)); ok { return true } + if t.Pids != nil && len(t.Pids) > 0 { + for _, pid := range t.Pids { + if info.Pid == pid { + return true + } + } + return false + } if t.SrcIP == "" && t.SrcPort == 0 && t.DstIP == "" && t.DstPort == 0 { if t.Status != "" && info.Status != "PCAP" { return true diff --git a/tcpkill/tcpkill_unix.go b/tcpkill/tcpkill_unix.go index 5c1f7e0..942924d 100644 --- a/tcpkill/tcpkill_unix.go +++ b/tcpkill/tcpkill_unix.go @@ -26,6 +26,7 @@ import ( type TCPKill struct { NFQNums int AutoIptables bool + Pids []int32 SrcIP string SrcPort int DstIP string diff --git a/tcpkill/tcpkill_windows.go b/tcpkill/tcpkill_windows.go index aa73071..4f5a4f5 100644 --- a/tcpkill/tcpkill_windows.go +++ b/tcpkill/tcpkill_windows.go @@ -28,6 +28,7 @@ type TCPKill struct { BPF string Eth string Host string + Pids []int32 SrcIP string SrcPort int DstIP string diff --git a/version/version.go b/version/version.go index 3d8b6d0..d54f645 100644 --- a/version/version.go +++ b/version/version.go @@ -1,3 +1,3 @@ package version -var Version = "2.1.0.beta.17" +var Version = "2.1.0.beta.18"