This commit is contained in:
兔子 2025-04-26 19:33:14 +08:00
parent 1879810c9e
commit db851fbcb1
187 changed files with 29490 additions and 22 deletions

38
bed/CHANGELOG.md Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,81 @@
# bed
[![CI Status](https://b612.me/apps/b612/bed/actions/workflows/ci.yaml/badge.svg?branch=main)](https://b612.me/apps/b612/bed/actions?query=branch:main)
[![Go Report Card](https://goreportcard.com/badge/b612.me/apps/b612/bed)](https://goreportcard.com/report/b612.me/apps/b612/bed)
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://b612.me/apps/b612/bed/blob/main/LICENSE)
[![release](https://img.shields.io/github/release/itchyny/bed/all.svg)](https://b612.me/apps/b612/bed/releases)
[![pkg.go.dev](https://pkg.go.dev/badge/b612.me/apps/b612/bed)](https://pkg.go.dev/b612.me/apps/b612/bed)
Binary editor written in Go
## Screenshot
![bed command screenshot](https://user-images.githubusercontent.com/375258/38499347-2f71306c-3c42-11e8-926e-1782b0bc73f3.png)
## Motivation
I wanted to create a binary editor with Vim-like user interface, which runs in terminals, fast, and is portable.
I have always been interested in various binary formats and I wanted to create my own editor to handle them.
I also wanted to learn how a binary editor can handle large files and allow users to edit them interactively.
While creating this binary editor, I leaned a lot about programming in Go language.
I spent a lot of time writing the core logic of buffer implementation of the editor.
It was a great learning experience for me and a lot of fun.
## Installation
### Homebrew
```sh
brew install bed
```
### Build from source
```bash
go install b612.me/apps/b612/bed/cmd/bed@latest
```
## Features
- Basic byte editing
- Large file support
- Command line interface
- Window splitting
- Partial writing
- Text searching
- Undo and redo
### Commands and keyboard shortcuts
This binary editor is influenced by the Vim editor.
- File operations
- `:edit`, `:enew`, `:new`, `:vnew`, `:only`
- Current working directory
- `:cd`, `:chdir`, `:pwd`
- Quit and save
- `:quit`, `ZQ`, `:qall`, `:write`,
`:wq`, `ZZ`, `:xit`, `:xall`, `:cquit`
- Window operations
- `:wincmd [nohjkltbpHJKL]`, `<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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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)
}
}

View 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()
}

View 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
View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}

View 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
}

View 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
}

View File

@ -0,0 +1,7 @@
//go:build windows
package editor
func suspend(_ *Editor) error {
return nil
}

17
bed/editor/ui.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1870
bed/window/window_test.go Normal file

File diff suppressed because it is too large Load Diff

6
gdu/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/.vscode
/.idea
/coverage.txt
/dist
/test_dir
/vendor

122
gdu/.golangci.yml Normal file
View 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
View File

@ -0,0 +1 @@
golang 1.23.3

15
gdu/Dockerfile Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,308 @@
# go DiskUsage()
<img src="./gdu.png" alt="Gdu " width="200" align="right">
[![Codecov](https://codecov.io/gh/dundee/gdu/branch/master/graph/badge.svg)](https://codecov.io/gh/dundee/gdu)
[![Go Report Card](https://goreportcard.com/badge/github.com/dundee/gdu)](https://goreportcard.com/report/github.com/dundee/gdu)
[![Maintainability](https://api.codeclimate.com/v1/badges/30d793274607f599e658/maintainability)](https://codeclimate.com/github/dundee/gdu/maintainability)
[![CodeScene Code Health](https://codescene.io/projects/13129/status-badges/code-health)](https://codescene.io/projects/13129)
Pretty fast disk usage analyzer written in Go.
Gdu is intended primarily for SSD disks where it can fully utilize parallel processing.
However HDDs work as well, but the performance gain is not so huge.
[![asciicast](https://asciinema.org/a/382738.svg)](https://asciinema.org/a/382738)
<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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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

Binary file not shown.

13
gdu/docs/run-books.md Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

194
gdu/gdu.spec Normal file
View 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

View 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()
}

View 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
}

View 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 }
}
}

View 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"))
}

View 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
View 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)
}

View 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
}

View 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
View 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
View 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
View File

@ -0,0 +1 @@
[1,2,3,4]

View 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
}

View 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
}

View 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))
}

View 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())
}

View 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
View 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))
}

View 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
View 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
}

View 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
View 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
}

View 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
View 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
}
}

View 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
View 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 ' '
}

View 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