update
This commit is contained in:
parent
1879810c9e
commit
db851fbcb1
38
bed/CHANGELOG.md
Normal file
38
bed/CHANGELOG.md
Normal file
@ -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.
|
21
bed/LICENSE
Normal file
21
bed/LICENSE
Normal file
@ -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.
|
64
bed/Makefile
Normal file
64
bed/Makefile
Normal file
@ -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)"
|
81
bed/README.md
Normal file
81
bed/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# bed
|
||||
[](https://b612.me/apps/b612/bed/actions?query=branch:main)
|
||||
[](https://goreportcard.com/report/b612.me/apps/b612/bed)
|
||||
[](https://b612.me/apps/b612/bed/blob/main/LICENSE)
|
||||
[](https://b612.me/apps/b612/bed/releases)
|
||||
[](https://pkg.go.dev/b612.me/apps/b612/bed)
|
||||
|
||||
Binary editor written in Go
|
||||
|
||||
## Screenshot
|
||||

|
||||
|
||||
## 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]`, `<C-w>[nohjkltbpHJKL]`
|
||||
- Cursor motions
|
||||
- `h`, `j`, `k`, `l`, `w`, `b`, `^`, `0`, `$`,
|
||||
`<C-[fb]>`, `<C-[du]>`, `<C-[ey]>`, `<C-[np]>`,
|
||||
`G`, `gg`, `:{count}`, `:{count}goto`, `:{count}%`,
|
||||
`H`, `M`, `L`, `zt`, `zz`, `z.`, `zb`, `z-`,
|
||||
`<TAB>` (toggle focus between hex and text views)
|
||||
- Mode operations
|
||||
- `i`, `I`, `a`, `A`, `v`, `r`, `R`, `<ESC>`
|
||||
- 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), `<C-a>` (increment), `<C-x>` (decrement)
|
||||
- Undo and redo
|
||||
- `:undo`, `u`, `:redo`, `<C-r>`
|
||||
- Search
|
||||
- `/`, `?`, `n`, `N`, `<C-c>` (abort)
|
||||
|
||||
## Bug Tracker
|
||||
Report bug at [Issues・itchyny/bed - GitHub](https://b612.me/apps/b612/bed/issues).
|
||||
|
||||
## Author
|
||||
itchyny (<https://github.com/itchyny>)
|
||||
|
||||
## License
|
||||
This software is released under the MIT License, see LICENSE.
|
502
bed/buffer/buffer.go
Normal file
502
bed/buffer/buffer.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
712
bed/buffer/buffer_test.go
Normal file
712
bed/buffer/buffer_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
66
bed/buffer/bytes.go
Normal file
66
bed/buffer/bytes.go
Normal file
@ -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))
|
||||
}
|
21
bed/buffer/const.go
Normal file
21
bed/buffer/const.go
Normal file
@ -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)
|
||||
}
|
75
bed/cmd/bed/bed.go
Normal file
75
bed/cmd/bed/bed.go
Normal file
@ -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()
|
||||
}
|
244
bed/cmdline/cmdline.go
Normal file
244
bed/cmdline/cmdline.go
Normal file
@ -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
|
||||
}
|
832
bed/cmdline/cmdline_test.go
Normal file
832
bed/cmdline/cmdline_test.go
Normal file
@ -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
|
||||
}
|
57
bed/cmdline/command.go
Normal file
57
bed/cmdline/command.go
Normal file
@ -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},
|
||||
}
|
266
bed/cmdline/completor.go
Normal file
266
bed/cmdline/completor.go
Normal file
@ -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)
|
||||
}
|
566
bed/cmdline/completor_test.go
Normal file
566
bed/cmdline/completor_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
18
bed/cmdline/environment.go
Normal file
18
bed/cmdline/environment.go
Normal file
@ -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()
|
||||
}
|
14
bed/cmdline/environment_test.go
Normal file
14
bed/cmdline/environment_test.go
Normal file
@ -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}
|
||||
}
|
36
bed/cmdline/filesystem.go
Normal file
36
bed/cmdline/filesystem.go
Normal file
@ -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()
|
||||
}
|
118
bed/cmdline/filesystem_test.go
Normal file
118
bed/cmdline/filesystem_test.go
Normal file
@ -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
|
||||
}
|
55
bed/cmdline/parse.go
Normal file
55
bed/cmdline/parse.go
Normal file
@ -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):])
|
||||
}
|
10
bed/editor/cmdline.go
Normal file
10
bed/editor/cmdline.go
Normal file
@ -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)
|
||||
}
|
345
bed/editor/editor.go
Normal file
345
bed/editor/editor.go
Normal file
@ -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()
|
||||
}
|
883
bed/editor/editor_test.go
Normal file
883
bed/editor/editor_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
198
bed/editor/key.go
Normal file
198
bed/editor/key.go
Normal file
@ -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
|
||||
}
|
21
bed/editor/manager.go
Normal file
21
bed/editor/manager.go
Normal file
@ -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()
|
||||
}
|
23
bed/editor/suspend_linux.go
Normal file
23
bed/editor/suspend_linux.go
Normal file
@ -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
|
||||
}
|
23
bed/editor/suspend_unix.go
Normal file
23
bed/editor/suspend_unix.go
Normal file
@ -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
|
||||
}
|
7
bed/editor/suspend_windows.go
Normal file
7
bed/editor/suspend_windows.go
Normal file
@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package editor
|
||||
|
||||
func suspend(_ *Editor) error {
|
||||
return nil
|
||||
}
|
17
bed/editor/ui.go
Normal file
17
bed/editor/ui.go
Normal file
@ -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
|
||||
}
|
141
bed/event/event.go
Normal file
141
bed/event/event.go
Normal file
@ -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
|
||||
)
|
96
bed/event/parse.go
Normal file
96
bed/event/parse.go
Normal file
@ -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
|
||||
}
|
42
bed/event/parse_test.go
Normal file
42
bed/event/parse_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
45
bed/event/range.go
Normal file
45
bed/event/range.go
Normal file
@ -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}
|
||||
}
|
56
bed/history/history.go
Normal file
56
bed/history/history.go
Normal file
@ -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
|
||||
}
|
99
bed/history/history_test.go
Normal file
99
bed/history/history_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
91
bed/key/key.go
Normal file
91
bed/key/key.go
Normal file
@ -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}
|
||||
}
|
71
bed/key/key_test.go
Normal file
71
bed/key/key_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
500
bed/layout/layout.go
Normal file
500
bed/layout/layout.go
Normal file
@ -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(),
|
||||
}
|
||||
}
|
307
bed/layout/layout_test.go
Normal file
307
bed/layout/layout_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
14
bed/mode/mode.go
Normal file
14
bed/mode/mode.go
Normal file
@ -0,0 +1,14 @@
|
||||
package mode
|
||||
|
||||
// Mode ...
|
||||
type Mode int
|
||||
|
||||
// Modes
|
||||
const (
|
||||
Normal Mode = iota
|
||||
Insert
|
||||
Replace
|
||||
Visual
|
||||
Cmdline
|
||||
Search
|
||||
)
|
155
bed/searcher/pattern.go
Normal file
155
bed/searcher/pattern.go
Normal file
@ -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<<uint(8-bits))
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
func unescapePattern(pattern string) []byte {
|
||||
var escape bool
|
||||
var buf [4]byte
|
||||
bs := make([]byte, 0, len(pattern))
|
||||
for i := 0; i < len(pattern); i++ {
|
||||
b := pattern[i]
|
||||
if escape {
|
||||
switch b {
|
||||
case '0':
|
||||
bs = append(bs, 0)
|
||||
case 'a':
|
||||
bs = append(bs, '\a')
|
||||
case 'b':
|
||||
bs = append(bs, '\b')
|
||||
case 'f':
|
||||
bs = append(bs, '\f')
|
||||
case 'n':
|
||||
bs = append(bs, '\n')
|
||||
case 'r':
|
||||
bs = append(bs, '\r')
|
||||
case 't':
|
||||
bs = append(bs, '\t')
|
||||
case 'v':
|
||||
bs = append(bs, '\v')
|
||||
case 'x', 'u', 'U':
|
||||
var n int
|
||||
switch b {
|
||||
case 'x':
|
||||
n = 2
|
||||
case 'u':
|
||||
n = 4
|
||||
case 'U':
|
||||
n = 8
|
||||
}
|
||||
appended := true
|
||||
var c rune
|
||||
if i+n < len(pattern) {
|
||||
for k := 1; k <= n; k++ {
|
||||
if !isHex(pattern[i+k]) {
|
||||
appended = false
|
||||
break
|
||||
}
|
||||
c = c<<4 | rune(hexToDigit(pattern[i+k]))
|
||||
}
|
||||
if appended {
|
||||
if b == 'x' {
|
||||
bs = append(bs, byte(c))
|
||||
} else {
|
||||
n := utf8.EncodeRune(buf[:], c)
|
||||
bs = append(bs, buf[:n]...)
|
||||
}
|
||||
i += n
|
||||
}
|
||||
}
|
||||
if !appended {
|
||||
bs = append(bs, b)
|
||||
}
|
||||
default:
|
||||
bs = append(bs, b)
|
||||
}
|
||||
escape = false
|
||||
} else if b == '\\' {
|
||||
escape = true
|
||||
} else {
|
||||
bs = append(bs, b)
|
||||
}
|
||||
}
|
||||
if escape {
|
||||
bs = append(bs, '\\')
|
||||
}
|
||||
return bs
|
||||
}
|
||||
|
||||
func isHex(b byte) bool {
|
||||
return '0' <= b && b <= '9' || 'A' <= b && b <= 'F' || 'a' <= b && b <= 'f'
|
||||
}
|
||||
|
||||
func hexToDigit(b byte) byte {
|
||||
switch {
|
||||
case '0' <= b && b <= '9':
|
||||
return b - '0'
|
||||
case 'A' <= b && b <= 'F':
|
||||
return b - 'A' + 10
|
||||
case 'a' <= b && b <= 'f':
|
||||
return b - 'a' + 10
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func isBin(b byte) bool {
|
||||
return b == '0' || b == '1'
|
||||
}
|
143
bed/searcher/searcher.go
Normal file
143
bed/searcher/searcher.go
Normal file
@ -0,0 +1,143 @@
|
||||
package searcher
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const loadSize = 1024 * 1024
|
||||
|
||||
// Searcher represents a searcher.
|
||||
type Searcher struct {
|
||||
r io.ReaderAt
|
||||
bytes []byte
|
||||
loopCh chan struct{}
|
||||
cursor int64
|
||||
pattern string
|
||||
mu *sync.Mutex
|
||||
}
|
||||
|
||||
// NewSearcher creates a new searcher.
|
||||
func NewSearcher(r io.ReaderAt) *Searcher {
|
||||
return &Searcher{r: r, mu: new(sync.Mutex)}
|
||||
}
|
||||
|
||||
type errNotFound string
|
||||
|
||||
func (err errNotFound) Error() string {
|
||||
return "pattern not found: " + string(err)
|
||||
}
|
||||
|
||||
// Search the pattern.
|
||||
func (s *Searcher) Search(cursor int64, pattern string, forward bool) <-chan any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.bytes == nil {
|
||||
s.bytes = make([]byte, loadSize)
|
||||
}
|
||||
s.cursor, s.pattern = cursor, pattern
|
||||
ch := make(chan any)
|
||||
if forward {
|
||||
s.loop(s.forward, ch)
|
||||
} else {
|
||||
s.loop(s.backward, ch)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (s *Searcher) forward() (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
target, err := patternToTarget(s.pattern)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
base := s.cursor + 1
|
||||
n, err := s.r.ReadAt(s.bytes, base)
|
||||
if err != nil && err != io.EOF {
|
||||
return -1, err
|
||||
}
|
||||
if n == 0 {
|
||||
return -1, errNotFound(s.pattern)
|
||||
}
|
||||
if err == io.EOF {
|
||||
s.cursor += int64(n)
|
||||
} else {
|
||||
s.cursor += int64(n - len(target) + 1)
|
||||
}
|
||||
i := bytes.Index(s.bytes[:n], target)
|
||||
if i >= 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
|
||||
}
|
181
bed/searcher/searcher_test.go
Normal file
181
bed/searcher/searcher_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
45
bed/state/state.go
Normal file
45
bed/state/state.go
Normal file
@ -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
|
||||
)
|
71
bed/tui/key.go
Normal file
71
bed/tui/key.go
Normal file
@ -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"),
|
||||
}
|
20
bed/tui/region.go
Normal file
20
bed/tui/region.go
Normal file
@ -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
|
||||
}
|
63
bed/tui/text_drawer.go
Normal file
63
bed/tui/text_drawer.go
Normal file
@ -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
|
||||
}
|
196
bed/tui/tui.go
Normal file
196
bed/tui/tui.go
Normal file
@ -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
|
||||
}
|
415
bed/tui/tui_test.go
Normal file
415
bed/tui/tui_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
225
bed/tui/tui_window.go
Normal file
225
bed/tui/tui_window.go
Normal file
@ -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 ""
|
||||
}
|
||||
}
|
701
bed/window/manager.go
Normal file
701
bed/window/manager.go
Normal file
@ -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 <C-c> 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()
|
||||
}
|
||||
}
|
708
bed/window/manager_test.go
Normal file
708
bed/window/manager_test.go
Normal file
@ -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()
|
||||
}
|
1070
bed/window/window.go
Normal file
1070
bed/window/window.go
Normal file
File diff suppressed because it is too large
Load Diff
1870
bed/window/window_test.go
Normal file
1870
bed/window/window_test.go
Normal file
File diff suppressed because it is too large
Load Diff
6
gdu/.gitignore
vendored
Normal file
6
gdu/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/.vscode
|
||||
/.idea
|
||||
/coverage.txt
|
||||
/dist
|
||||
/test_dir
|
||||
/vendor
|
122
gdu/.golangci.yml
Normal file
122
gdu/.golangci.yml
Normal file
@ -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
|
1
gdu/.tool-versions
Normal file
1
gdu/.tool-versions
Normal file
@ -0,0 +1 @@
|
||||
golang 1.23.3
|
15
gdu/Dockerfile
Normal file
15
gdu/Dockerfile
Normal file
@ -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"]
|
142
gdu/INSTALL.md
Normal file
142
gdu/INSTALL.md
Normal file
@ -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
|
||||
```
|
8
gdu/LICENSE.md
Normal file
8
gdu/LICENSE.md
Normal file
@ -0,0 +1,8 @@
|
||||
Copyright 2020-2021 Daniel Milde <daniel@milde.cz>
|
||||
|
||||
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.
|
||||
|
159
gdu/Makefile
Normal file
159
gdu/Makefile
Normal file
@ -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
|
308
gdu/README.md
Normal file
308
gdu/README.md
Normal file
@ -0,0 +1,308 @@
|
||||
# go DiskUsage()
|
||||
|
||||
<img src="./gdu.png" alt="Gdu " width="200" align="right">
|
||||
|
||||
[](https://codecov.io/gh/dundee/gdu)
|
||||
[](https://goreportcard.com/report/github.com/dundee/gdu)
|
||||
[](https://codeclimate.com/github/dundee/gdu/maintainability)
|
||||
[](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.
|
||||
|
||||
[](https://asciinema.org/a/382738)
|
||||
|
||||
<a href="https://repology.org/project/gdu/versions">
|
||||
<img src="https://repology.org/badge/vertical-allrepos/gdu.svg" alt="Packaging status" align="right">
|
||||
</a>
|
||||
|
||||
## 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 <some_dir_to_analyze> # analyze given dir
|
||||
gdu -d # show all mounted disks
|
||||
gdu -l ./gdu.log <some_dir> # 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)
|
16
gdu/build/build.go
Normal file
16
gdu/build/build.go
Normal file
@ -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 = ""
|
473
gdu/cmd/gdu/app/app.go
Normal file
473
gdu/cmd/gdu/app/app.go
Normal file
@ -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
|
||||
}
|
123
gdu/cmd/gdu/app/app_linux_test.go
Normal file
123
gdu/cmd/gdu/app/app_linux_test.go
Normal file
@ -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")
|
||||
}
|
566
gdu/cmd/gdu/app/app_test.go
Normal file
566
gdu/cmd/gdu/app/app_test.go
Normal file
@ -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
|
||||
}
|
245
gdu/cmd/gdu/main.go
Normal file
245
gdu/cmd/gdu/main.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
10
gdu/codecov.yml
Normal file
10
gdu/codecov.yml
Normal file
@ -0,0 +1,10 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 2%
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
195
gdu/configuration.md
Normal file
195
gdu/configuration.md
Normal file
@ -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
|
BIN
gdu/default.pgo
Normal file
BIN
gdu/default.pgo
Normal file
Binary file not shown.
13
gdu/docs/run-books.md
Normal file
13
gdu/docs/run-books.md
Normal file
@ -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, ...
|
123
gdu/gdu.1
Normal file
123
gdu/gdu.1
Normal file
@ -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.
|
120
gdu/gdu.1.md
Normal file
120
gdu/gdu.1.md
Normal file
@ -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.
|
BIN
gdu/gdu.png
Normal file
BIN
gdu/gdu.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 129 KiB |
194
gdu/gdu.spec
Normal file
194
gdu/gdu.spec
Normal file
@ -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
|
23
gdu/internal/common/analyze.go
Normal file
23
gdu/internal/common/analyze.go
Normal file
@ -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()
|
||||
}
|
21
gdu/internal/common/app.go
Normal file
21
gdu/internal/common/app.go
Normal file
@ -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
|
||||
}
|
152
gdu/internal/common/ignore.go
Normal file
152
gdu/internal/common/ignore.go
Normal file
@ -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 }
|
||||
}
|
||||
}
|
206
gdu/internal/common/ignore_test.go
Normal file
206
gdu/internal/common/ignore_test.go
Normal file
@ -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"))
|
||||
}
|
11
gdu/internal/common/signal.go
Normal file
11
gdu/internal/common/signal.go
Normal file
@ -0,0 +1,11 @@
|
||||
package common
|
||||
|
||||
type SignalGroup chan struct{}
|
||||
|
||||
func (s SignalGroup) Wait() {
|
||||
<-s
|
||||
}
|
||||
|
||||
func (s SignalGroup) Broadcast() {
|
||||
close(s)
|
||||
}
|
74
gdu/internal/common/ui.go
Normal file
74
gdu/internal/common/ui.go
Normal file
@ -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)
|
||||
}
|
68
gdu/internal/common/ui_test.go
Normal file
68
gdu/internal/common/ui_test.go
Normal file
@ -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
|
||||
}
|
105
gdu/internal/testanalyze/analyze.go
Normal file
105
gdu/internal/testanalyze/analyze.go
Normal file
@ -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")
|
||||
}
|
105
gdu/internal/testapp/app.go
Normal file
105
gdu/internal/testapp/app.go
Normal file
@ -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
|
||||
}
|
7
gdu/internal/testdata/test.json
vendored
Normal file
7
gdu/internal/testdata/test.json
vendored
Normal file
@ -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}]]
|
1
gdu/internal/testdata/wrong.json
vendored
Normal file
1
gdu/internal/testdata/wrong.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
[1,2,3,4]
|
18
gdu/internal/testdev/dev.go
Normal file
18
gdu/internal/testdev/dev.go
Normal file
@ -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
|
||||
}
|
30
gdu/internal/testdir/test_dir.go
Normal file
30
gdu/internal/testdir/test_dir.go
Normal file
@ -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
|
||||
}
|
32
gdu/pkg/analyze/dir_linux-openbsd.go
Normal file
32
gdu/pkg/analyze/dir_linux-openbsd.go
Normal file
@ -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))
|
||||
}
|
65
gdu/pkg/analyze/dir_linux_test.go
Normal file
65
gdu/pkg/analyze/dir_linux_test.go
Normal file
@ -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())
|
||||
}
|
23
gdu/pkg/analyze/dir_other.go
Normal file
23
gdu/pkg/analyze/dir_other.go
Normal file
@ -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()
|
||||
}
|
243
gdu/pkg/analyze/dir_test.go
Normal file
243
gdu/pkg/analyze/dir_test.go
Normal file
@ -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))
|
||||
}
|
32
gdu/pkg/analyze/dir_unix.go
Normal file
32
gdu/pkg/analyze/dir_unix.go
Normal file
@ -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))
|
||||
}
|
101
gdu/pkg/analyze/encode.go
Normal file
101
gdu/pkg/analyze/encode.go
Normal file
@ -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
|
||||
}
|
68
gdu/pkg/analyze/encode_test.go
Normal file
68
gdu/pkg/analyze/encode_test.go
Normal file
@ -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`)
|
||||
}
|
256
gdu/pkg/analyze/file.go
Normal file
256
gdu/pkg/analyze/file.go
Normal file
@ -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
|
||||
}
|
337
gdu/pkg/analyze/file_test.go
Normal file
337
gdu/pkg/analyze/file_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
63
gdu/pkg/analyze/memory.go
Normal file
63
gdu/pkg/analyze/memory.go
Normal file
@ -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
|
||||
}
|
||||
}
|
27
gdu/pkg/analyze/memory_test.go
Normal file
27
gdu/pkg/analyze/memory_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
227
gdu/pkg/analyze/parallel.go
Normal file
227
gdu/pkg/analyze/parallel.go
Normal file
@ -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 ' '
|
||||
}
|
185
gdu/pkg/analyze/sequential.go
Normal file
185
gdu/pkg/analyze/sequential.go
Normal file
@ -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:
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user