From f6ca50ed1ea02f1146cc39892c9dcf6db255afe7 Mon Sep 17 00:00:00 2001 From: ZZY <2450266535@qq.com> Date: Mon, 10 Feb 2025 16:26:13 +0800 Subject: [PATCH] init --- .gitignore | 31 ++++++ README.md | 3 + go.mod | 15 +++ go.sum | 50 ++++++++++ goncdu.go | 28 ++++++ node/manager.go | 95 +++++++++++++++++++ node/node.go | 104 ++++++++++++++++++++ node/saved.go | 151 +++++++++++++++++++++++++++++ node/saved_test.go | 26 +++++ node/scan.go | 193 +++++++++++++++++++++++++++++++++++++ node/scan_test.go | 78 +++++++++++++++ node/utils.go | 26 +++++ tui/tui.go | 230 +++++++++++++++++++++++++++++++++++++++++++++ tui/utils.go | 56 +++++++++++ 14 files changed, 1086 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 goncdu.go create mode 100644 node/manager.go create mode 100644 node/node.go create mode 100644 node/saved.go create mode 100644 node/saved_test.go create mode 100644 node/scan.go create mode 100644 node/scan_test.go create mode 100644 node/utils.go create mode 100644 tui/tui.go create mode 100644 tui/utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..267727e --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + + +# consumer ignore +# +.vscode +main_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..96adadc --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# goncdu + +The `goncdu` repository is a Go language implementation inspired by `ncdu`, designed to scan directories and display the results in a terminal interface. It provides a simple and interactive way to analyze disk usage. Below is a detailed overview of the repository contents and its functionality. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4c2bc7e --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module git.zzyxyz.com/zzy/goncdu + +go 1.23.6 + +require ( + github.com/gdamore/encoding v1.0.0 // indirect + github.com/gdamore/tcell/v2 v2.7.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..796e6ae --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.1 h1:TiCcmpWHiAU7F0rA2I3S2Y4mmLmO9KHxJ7E1QhYzQbc= +github.com/gdamore/tcell/v2 v2.7.1/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE= +github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/goncdu.go b/goncdu.go new file mode 100644 index 0000000..58add94 --- /dev/null +++ b/goncdu.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + + "git.zzyxyz.com/zzy/goncdu/node" + "git.zzyxyz.com/zzy/goncdu/tui" +) + +func main() { + argc := len(os.Args) + manager := node.NewNodeManager() + if argc == 1 { + // using mouse to click start + goto START + } + + // arguments to start tui + manager.SetRootPath(os.Args[1]) + // if !manager.SetRootPath(os.Args[1]) { + // if res := manager.SetRootPath("."); !res { + // fmt.Println("Invalid `.` Path") + // return + // } + // } +START: + tui.ShowMain(manager) +} diff --git a/node/manager.go b/node/manager.go new file mode 100644 index 0000000..487f973 --- /dev/null +++ b/node/manager.go @@ -0,0 +1,95 @@ +package node + +import ( + "errors" + "sort" + "time" +) + +type NodeManager struct { + root *PathNode + current *PathNode + scan_time time.Duration +} + +func NewNodeManager() *NodeManager { + root := NewPathNode("") + return &NodeManager{ + root: root, + current: root, + scan_time: time.Duration(0), + } +} + +func (npm *NodeManager) VaildRoot() bool { + return npm.root.VaildPath() +} + +func (npm *NodeManager) SetRootPath(path string) bool { + npm.root.path = path + return npm.root.VaildPath() +} + +func (npm *NodeManager) GetScanTime() time.Duration { + return npm.scan_time +} + +func (npm *NodeManager) SaveToFile() error { + return npm.root.ToJSON("saved.json.gz", JSON_GZ) +} + +func (npm *NodeManager) LoadFromFile() error { + return npm.root.FromJSON("saved.json.gz", JSON_GZ) +} + +func (npm *NodeManager) Scan() error { + if !npm.VaildRoot() { + return errors.New("root path not vaild") + } + npm.root.ClearWithoutFlush() + start := time.Now() + err := npm.root.FastChanIterScanNode(0) + if err != nil { + return err + } + npm.current = npm.root + npm.scan_time = time.Since(start) + return nil +} + +func (npm *NodeManager) GetChildrenWithSorted() []*PathNode { + nodes := npm.current.GetChildren() + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].size > nodes[j].size { + return true + } else if nodes[i].size < nodes[j].size { + return false + } + return nodes[i].path > nodes[j].path + }) + return nodes +} + +func (npm *NodeManager) GetCurrentNode() *PathNode { + return npm.current +} + +func (npm *NodeManager) GoIn(pathName string) error { + res := npm.current.GetChild(pathName) + if res == nil { + return errors.New("children not found") + } + if res.node_type != DIR { + return errors.New("not a directory") + } + npm.current = res + return nil +} + +func (npm *NodeManager) GoOut() error { + if npm.current.parent == nil { + return errors.New("you can't go out of root") + } + npm.current = npm.current.parent + return nil +} diff --git a/node/node.go b/node/node.go new file mode 100644 index 0000000..716b8a7 --- /dev/null +++ b/node/node.go @@ -0,0 +1,104 @@ +package node + +import ( + "os" +) + +// PathNodeType 表示路径节点类型 +type PathNodeType int + +const ( + DIR PathNodeType = iota + FILE + UNKNOWN + + // Stat + NOSCAN +) + +// PathNode 表示目录树节点 +type PathNode struct { + path string + children map[string]*PathNode + parent *PathNode + size int64 + count int + node_type PathNodeType +} + +func NewPathNode(path string) *PathNode { + return &PathNode{ + path: path, + children: make(map[string]*PathNode), + parent: nil, + size: 0, + count: 0, + node_type: NOSCAN, + } +} + +func (pn *PathNode) GetPath() string { + return pn.path +} + +func (pn *PathNode) GetSize() int64 { + return pn.size +} + +func (pn *PathNode) GetSizeByFormat() string { + return FormatSize(pn.size) +} + +func (pn *PathNode) GetCount() int { + return pn.count +} + +func (pn *PathNode) GetType() PathNodeType { + return pn.node_type +} + +func (pn *PathNode) GetChildren() []*PathNode { + children := make([]*PathNode, 0, len(pn.children)) + for _, child := range pn.children { + children = append(children, child) + } + return children +} + +func (pn *PathNode) GetChild(path string) *PathNode { + return pn.children[path] +} + +func (pn *PathNode) VaildPath() bool { + stat, err := os.Lstat(pn.path) + if err != nil { + return false + } + if stat.IsDir() { + return true + } + return false +} + +// linkChild 添加子节点 +func (pn *PathNode) linkChild(child *PathNode) { + child.parent = pn + pn.children[child.path] = child +} + +func (pn *PathNode) flushNode(diff_size int64, diff_count int) { + pn.size += diff_size + pn.count += diff_count + if pn.parent == nil { + return + } + pn.parent.flushNode(diff_size, diff_count) +} + +func (pn *PathNode) ClearWithoutFlush() { + for key, child := range pn.children { + child.ClearWithoutFlush() + child.parent = nil + delete(pn.children, key) + } +} diff --git a/node/saved.go b/node/saved.go new file mode 100644 index 0000000..aa592ae --- /dev/null +++ b/node/saved.go @@ -0,0 +1,151 @@ +package node + +import ( + "compress/gzip" + "encoding/json" + "errors" + "io" + "os" +) + +type StoreType int + +const ( + JSON_GZ StoreType = iota + JSON_NO_IDENT + JSON_IDENT +) + +// MarshalJSON 实现自定义序列化 +func (pn *PathNode) MarshalJSON() ([]byte, error) { + type Alias PathNode + return json.Marshal(&struct { + *Alias + Path string `json:"path"` + Children map[string]*PathNode `json:"children"` + Count int `json:"count"` + Node_type PathNodeType `json:"type"` + Size int64 `json:"size"` + }{ + Alias: (*Alias)(pn), + Path: pn.path, + Children: pn.children, + Count: pn.count, + Size: pn.size, + Node_type: pn.node_type, + }) +} + +// UnmarshalJSON 实现自定义反序列化 +func (pn *PathNode) UnmarshalJSON(data []byte) error { + type Alias PathNode + temp := &struct { + *Alias + Path string `json:"path"` + Children map[string]*PathNode `json:"children"` + Count int `json:"count"` + Node_type PathNodeType `json:"type"` + Size int64 `json:"size"` + }{ + Alias: (*Alias)(pn), + } + + if err := json.Unmarshal(data, &temp); err != nil { + return err + } + + pn.path = temp.Path + pn.children = temp.Children + pn.count = temp.Count + pn.size = temp.Size + pn.node_type = temp.Node_type + return nil +} + +// ToJSON 将 PathNode 序列化为 JSON 字符串 +func (pn *PathNode) ToJSON(savedPath string, savedType StoreType) error { + f, err := os.Create(savedPath) + if err != nil { + return err + } + defer f.Close() + + switch savedType { + case JSON_GZ: + data, err := json.Marshal(pn) + if err != nil { + return err + } + gz := gzip.NewWriter(f) + defer gz.Close() + if _, err := gz.Write(data); err != nil { + return err + } + case JSON_NO_IDENT: + data, err := json.Marshal(pn) + if err != nil { + return err + } + if _, err = f.Write(data); err != nil { + return err + } + case JSON_IDENT: + data, err := json.MarshalIndent(pn, "", " ") + if err != nil { + return err + } + if _, err = f.Write(data); err != nil { + return err + } + } + return nil +} + +// FromJSON 从 JSON 字符串反序列化为 PathNode +func (pn *PathNode) FromJSON(savedPath string, savedType StoreType) error { + if pn.parent != nil { + return errors.New("PathNode.Parent should be nil") + } + + f, err := os.Open(savedPath) + if err != nil { + return err + } + defer f.Close() + + var jsonData []byte + switch savedType { + case JSON_GZ: + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + if jsonData, err = io.ReadAll(gzr); err != nil { + return err + } + default: + // case JSON_IDENT: + // case JSON_NO_IDENT: + if jsonData, err = io.ReadAll(f); err != nil { + return err + } + } + + pn.ClearWithoutFlush() + if err := json.Unmarshal([]byte(jsonData), pn); err != nil { + return err + } + + // RestoreParentLinks 恢复 Parent 关系 + var restoreParentLinks func(pn *PathNode) + restoreParentLinks = func(pn *PathNode) { + for _, child := range pn.children { + child.parent = pn + restoreParentLinks(child) + } + } + + restoreParentLinks(pn) + return nil +} diff --git a/node/saved_test.go b/node/saved_test.go new file mode 100644 index 0000000..3ebc6b3 --- /dev/null +++ b/node/saved_test.go @@ -0,0 +1,26 @@ +package node + +import ( + "os" + "reflect" + "testing" +) + +func TestJSON_GZ(t *testing.T) { + jsonName := "saved.json.gz" + tmpnode := NewPathNode(".") + tmpnode.IterScanNode() + if err := tmpnode.ToJSON(jsonName, JSON_GZ); err != nil { + t.Fatal(err) + } + + readNode := NewPathNode("") + if err := readNode.FromJSON(jsonName, JSON_GZ); err != nil { + t.Fatal(err) + } + os.Remove(jsonName) + + if !reflect.DeepEqual(tmpnode, readNode) { + t.Fatal("not equal") + } +} diff --git a/node/scan.go b/node/scan.go new file mode 100644 index 0000000..64b4c7c --- /dev/null +++ b/node/scan.go @@ -0,0 +1,193 @@ +package node + +import ( + "os" + "runtime" + "sync" +) + +func (pn *PathNode) ScanPath() error { + stat, err := os.Lstat(pn.path) + var nodeType PathNodeType + + if err != nil { + // return fmt.Errorf("error os.Lstat: [%w]", err) + goto ERR_UNKNOWN + } + + if stat.IsDir() { + entries, err := os.ReadDir(pn.path) + if err != nil { + nodeType = UNKNOWN + } else { + for _, entry := range entries { + childNode := NewPathNode(pn.path + "/" + entry.Name()) + pn.linkChild(childNode) + } + nodeType = DIR + } + } else if stat.Mode().IsRegular() { + nodeType = FILE + } else { + nodeType = UNKNOWN + } + + pn.node_type = nodeType + pn.size = stat.Size() + pn.count = 1 + return nil +ERR_UNKNOWN: + pn.count = 1 + pn.node_type = UNKNOWN + return nil +} + +func (pn *PathNode) IterScanNode() error { + err := pn.ScanPath() + if err != nil { + return err + } + + if pn.node_type == DIR { + for _, entry := range pn.children { + err = entry.IterScanNode() + if err != nil { + return err + } + } + pn.count-- + pn.flushNode(0, 1) + } else { + pn.flushNode(pn.size, pn.count) + } + return nil +} + +func (pn *PathNode) FastIterScanNode() error { + var wg sync.WaitGroup + var nodeMu sync.Mutex + errChan := make(chan error) + // queue := make(chan *PathNode) + maxWorker := runtime.NumCPU() + curWorker := 1 + var wkMu sync.Mutex + + var worker func(node *PathNode, isWorker bool) + worker = func(node *PathNode, isWorker bool) { + err := node.ScanPath() + if err != nil { + errChan <- err + return + } + + if node.node_type == DIR { + for _, entry := range node.children { + wkMu.Lock() + if curWorker < maxWorker { + curWorker++ + wkMu.Unlock() + wg.Add(1) + go worker(entry, true) + } else { + wkMu.Unlock() + worker(entry, false) + } + } + } + + if node.parent != nil { + nodeMu.Lock() + if node.node_type == DIR { + node.parent.flushNode(0, 1) + } else { + node.parent.flushNode(node.size, node.count) + } + nodeMu.Unlock() + } + + if isWorker { + wkMu.Lock() + curWorker-- + wkMu.Unlock() + wg.Done() + } + } + + wg.Add(1) + go worker(pn, true) + + go func() { + wg.Wait() + close(errChan) + }() + + for err := range errChan { + return err + } + + return nil +} + +func (pn *PathNode) FastChanIterScanNode(maxWorker int) error { + var wg sync.WaitGroup + errChan := make(chan error) + if maxWorker <= 0 { + maxWorker = runtime.NumCPU() + } + nodeCh := make(chan bool, 1) + defer close(nodeCh) + + wkCh := make(chan bool, maxWorker) + defer close(wkCh) + + var worker func(node *PathNode, isWorker bool) + worker = func(node *PathNode, isWorker bool) { + err := node.ScanPath() + if err != nil { + errChan <- err + return + } + + if node.node_type == DIR { + for _, entry := range node.children { + select { + case wkCh <- true: + wg.Add(1) + go worker(entry, true) + default: + worker(entry, false) + } + } + } + + if node.parent != nil { + nodeCh <- true + if node.node_type == DIR { + node.parent.flushNode(0, 1) + } else { + node.parent.flushNode(node.size, node.count) + } + <-nodeCh + } + + if isWorker { + <-wkCh + wg.Done() + } + } + + wg.Add(1) + wkCh <- true + go worker(pn, true) + + go func() { + wg.Wait() + close(errChan) + }() + + for err := range errChan { + return err + } + + return nil +} diff --git a/node/scan_test.go b/node/scan_test.go new file mode 100644 index 0000000..324c279 --- /dev/null +++ b/node/scan_test.go @@ -0,0 +1,78 @@ +package node + +import ( + "os" + "testing" +) + +// 辅助函数:创建临时文件系统 +func setupTestFileSystem(t *testing.T) (string, *PathNode) { + tempDir, err := os.MkdirTemp("", "test-scan") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // 创建子目录和文件 + subDir := tempDir + "/subdir" + err = os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create sub directory: %v", err) + } + + tempFile, err := os.CreateTemp(subDir, "testfile") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + tempFile.Close() + + // 创建 PathNode + pn := NewPathNode(tempDir) + + return tempDir, pn +} + +// 辅助函数:验证扫描结果 +func verifyScanResult(t *testing.T, pn *PathNode) { + if pn.node_type != DIR { + t.Errorf("Expected node type DIR, got %v", pn.node_type) + } + if pn.count != 3 { // 1 for the subdir, 1 for the file + t.Errorf("Expected count 3, got %v", pn.count) + } +} + +func TestIterScanNode(t *testing.T) { + tempDir, pn := setupTestFileSystem(t) + defer os.RemoveAll(tempDir) + + err := pn.IterScanNode() + if err != nil { + t.Errorf("IterScanNode failed: %v", err) + } + + verifyScanResult(t, pn) +} + +func TestFastIterScanNode(t *testing.T) { + tempDir, pn := setupTestFileSystem(t) + defer os.RemoveAll(tempDir) + + err := pn.FastIterScanNode() + if err != nil { + t.Errorf("FastIterScanNode failed: %v", err) + } + + verifyScanResult(t, pn) +} + +func TestFastChanIterScanNode(t *testing.T) { + tempDir, pn := setupTestFileSystem(t) + defer os.RemoveAll(tempDir) + + err := pn.FastChanIterScanNode(0) // 使用默认的 maxWorker + if err != nil { + t.Errorf("FastChanIterScanNode failed: %v", err) + } + + verifyScanResult(t, pn) +} diff --git a/node/utils.go b/node/utils.go new file mode 100644 index 0000000..228e88e --- /dev/null +++ b/node/utils.go @@ -0,0 +1,26 @@ +package node + +import "fmt" + +// BytesToHumanReadable 将字节数转换为人类可读的字符串 +func BytesToHumanReadable(num float64, suffix string) string { + for _, unit := range []string{"", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"} { + if num < 1024 { + return fmt.Sprintf("%3.1f %s%s", float64(num), unit, suffix) + } + num /= 1024 + } + return fmt.Sprintf("%.1f Yi%s", float64(num), suffix) +} + +// FormatSize 格式化大小为人类可读的格式 +func FormatSize(size int64) string { + res := BytesToHumanReadable(float64(size), "B") //.PadLeft(10) + return fmt.Sprintf("%10s", res) +} + +func (pn *PathNode) ShowChildrenSimple() { + for _, child := range pn.children { + fmt.Printf("%d %s %s %d\n", child.node_type, FormatSize(child.size), child.path, child.count) + } +} diff --git a/tui/tui.go b/tui/tui.go new file mode 100644 index 0000000..3de50b0 --- /dev/null +++ b/tui/tui.go @@ -0,0 +1,230 @@ +// go get github.com/rivo/tview@master +package tui + +import ( + "errors" + "fmt" + "strings" + + node "git.zzyxyz.com/zzy/goncdu/node" + "git.zzyxyz.com/zzy/goncdu/utils" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type UI struct { + app *tview.Application + main *tview.Frame + msg string + err string + list *tview.List + node *node.NodeManager + nodes []*node.PathNode + inputPath *tview.InputField +} + +func NewUI(n *node.NodeManager) *UI { + list := tview.NewList() + list.ShowSecondaryText(false). + SetTitleAlign(tview.AlignLeft). + SetTitleColor(tcell.ColorWhite) + frame := tview.NewFrame(list). + SetBorders(0, 0, 0, 0, 1, 1) + + ui := &UI{ + app: tview.NewApplication().SetRoot(frame, true), + main: frame, + msg: "", + err: "", + list: list, + node: n, + } + + ui.list.SetInputCapture( + func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyRight: + idx := ui.list.GetCurrentItem() + if len(ui.nodes) == 0 { + ui.err = errors.New("no children nodes").Error() + break + } + name := ui.nodes[idx].GetPath() + if err := ui.node.GoIn(name); err != nil { + ui.err = err.Error() + break + } + case tcell.KeyLeft: + if err := ui.node.GoOut(); err != nil { + ui.err = err.Error() + } + case tcell.KeyRune: + switch event.Rune() { + case 'q': + ui.app.Stop() + case '?': + // show help + ui.showHelp() + case 'R': + ui.app.SetRoot(ui.inputPath, true) + case 'S': + if err := ui.node.SaveToFile(); err != nil { + ui.err = err.Error() + } + case 'L': + if err := ui.node.LoadFromFile(); err != nil { + ui.err = err.Error() + } + default: + return event + } + default: + return event + } + + // END: + ui.renderList() + return nil + }) + return ui +} + +func (ui *UI) showHelp() { + helpText := []string{ + "goncdu Help", + "-------------------------", + "Use the arrow keys to navigate:", + " ↑ (up) : Move up", + " ↓ (down) : Move down", + " → (right) : Enter directory", + " ← (left) : Go up one directory", + "-------------------------", + "Other commands:", + " R : ReInput scan path", + " f : Refresh cache", + " q : Quit", + " ? : Show this help message", + "-------------------------", + "Press q, Enter, Esc key to return", + } + + // 调整字符串长度使其一致 + maxLength := 0 + for _, line := range helpText { + if len(line) > maxLength { + maxLength = len(line) + } + } + + var helpContent strings.Builder + for _, line := range helpText { + paddedLine := fmt.Sprintf("%-*s", maxLength, line) + helpContent.WriteString(paddedLine) + helpContent.WriteString("\n") + } + + // 创建一个模态窗口并添加 TextView + modal := tview.NewModal(). + SetText(helpContent.String()) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape || event.Key() == tcell.KeyEnter || + (event.Key() == tcell.KeyRune && event.Rune() == 'q') { + ui.app.SetRoot(ui.main, true) + } + return event + }) + + // 设置模态窗口为应用的根节点 + ui.app.SetRoot(modal, false) +} +func (ui *UI) drawHeaderFooter() { + curNode := ui.node.GetCurrentNode() + ui.main.Clear(). + AddText("goncdu test ~ Use the arrow keys to navigate, press ? for help", + true, tview.AlignLeft, tcell.ColorWhite). + AddText(fmt.Sprintf("--- %s", curNode.GetPath()), + true, tview.AlignLeft, tcell.ColorWhite). + AddText(fmt.Sprintf("Total Size = %s Items = %d --- Scaning time = %s", + node.FormatSize(curNode.GetSize()), curNode.GetCount()-1, ui.node.GetScanTime()), + false, tview.AlignLeft, tcell.ColorWhite). + AddText(ui.err, false, tview.AlignLeft, tcell.ColorGrey) +} + +func (ui *UI) drawList(items []string) { + ui.list.Clear() + for _, item := range items { + ui.list.AddItem(item, "", 0, nil) + } +} + +func (ui *UI) run() { + if err := ui.app.Run(); err != nil { + panic(err) + } +} + +func (ui *UI) renderList() { + icon_type := map[node.PathNodeType]string{ + node.DIR: "📁", + node.FILE: "📄", + node.UNKNOWN: "❓", + } + + nodes := ui.node.GetChildrenWithSorted() + ui.nodes = nodes + + renderLine := func(entry *node.PathNode) string { + return fmt.Sprintf("%s %10s %10s %s\n", icon_type[entry.GetType()], + node.FormatSize(entry.GetSize()), + utils.FormatProgressBar(entry.GetSize(), nodes[0].GetSize(), 10, + []string{" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"}), + entry.GetPath()) + } + + str_list := make([]string, len(nodes)) + for idx, child := range nodes { + ret := renderLine(child) + str_list[idx] = ret + } + ui.drawList(str_list) + ui.drawHeaderFooter() +} + +func (ui *UI) scanPath() { + err := ui.node.Scan() + if err != nil { + ui.err = err.Error() + } + ui.renderList() +} + +func ShowMain(entry *node.NodeManager) { + ui := NewUI(entry) + + // need input path + inputPath := tview.NewInputField() + ui.inputPath = inputPath + inputPath.SetLabel("Input Path: "). + SetFieldWidth(4096). + SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + path := inputPath.GetText() + valid := ui.node.SetRootPath(path) + if valid { + ui.app.SetRoot(ui.main, true) + ui.scanPath() + } else { + inputPath.SetLabel("Invalid Path") + } + } + }) + + if entry.VaildRoot() { + ui.app.SetRoot(ui.main, true) + ui.scanPath() + } else { + ui.app.SetRoot(inputPath, true) + } + ui.run() +} diff --git a/tui/utils.go b/tui/utils.go new file mode 100644 index 0000000..a8fdbe9 --- /dev/null +++ b/tui/utils.go @@ -0,0 +1,56 @@ +package tui + +import ( + "fmt" + "math" +) + +// TruncateStr 截断字符串以适应给定宽度 +func TruncateStr(inStr string, maxWidth int) string { + if len(inStr) <= maxWidth { + return inStr + } + headLen := (maxWidth - 3) / 2 + tailLen := maxWidth - headLen - 3 + return fmt.Sprintf("%s...%s", inStr[:headLen], inStr[len(inStr)-tailLen:]) +} + +// FormatProgressBar generates a progress bar string. +func FormatProgressBar(size, maxSize int64, width int, blockChars []string) string { + if blockChars == nil || len(blockChars) < 2 { + blockChars = []string{" ", "#"} + } + + if maxSize == 0 { + return "[" + repeatString(blockChars[0], width) + "]" + } + + ratio := float64(size) / float64(maxSize) + filledLength := int(ratio * float64(width)) + remainingLength := width - filledLength + + // Calculate the fractional part + fractionalPart := math.Mod(ratio*float64(width), 1.0) + + // Create the progress bar string + progressBar := repeatString(blockChars[len(blockChars)-1], filledLength) + + if fractionalPart > 0 && filledLength < width { + // Calculate the index for the fractional part + fractionalIndex := int(fractionalPart*float64(len(blockChars)-2)) + 1 + progressBar += blockChars[fractionalIndex] + remainingLength-- + } + + progressBar += repeatString(blockChars[0], remainingLength) + return "[" + progressBar + "]" +} + +// repeatString repeats a string n times. +func repeatString(s string, n int) string { + result := "" + for i := 0; i < n; i++ { + result += s + } + return result +}