feat: add user-friendly help system and new commands
New Commands: - z read - Smart file reader with auto-detection (markdown, syntax highlighting) - z sys - System overview (CPU, RAM, Disk, processes) - z find - File search with pattern/size/time filters - z search - User-friendly grep alternative - z usage - Process resource usage monitor Enhancements: - Unified help system with consistent error messages - Help on error shows usage and examples for each command - "Command not found" suggests similar commands - Directory info now detects .gitignore and shows ignored files - File handler registry for extensible file type detection - Added Italic, Dim, Underline color constants
This commit is contained in:
parent
800ab8464a
commit
5b334071e0
13 changed files with 1426 additions and 129 deletions
112
cmd/dir.go
112
cmd/dir.go
|
|
@ -7,23 +7,30 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
ignore "github.com/sabhiram/go-gitignore"
|
||||||
"github.com/zemenawi/zutils/pkg/colors"
|
"github.com/zemenawi/zutils/pkg/colors"
|
||||||
"github.com/zemenawi/zutils/pkg/formatter"
|
"github.com/zemenawi/zutils/pkg/formatter"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileItem struct {
|
type FileItem struct {
|
||||||
Path string
|
Path string
|
||||||
Size int64
|
Size int64
|
||||||
|
Ignored bool
|
||||||
|
RelPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DirStats struct {
|
type DirStats struct {
|
||||||
TotalSize int64
|
TotalSize int64
|
||||||
FileCount int
|
TotalSizeIgnoring int64
|
||||||
DirCount int
|
FileCount int
|
||||||
LargestFiles []FileItem
|
DirCount int
|
||||||
LargestDirs []FileItem
|
IgnoredFileCount int
|
||||||
LastModified time.Time
|
IgnoredSize int64
|
||||||
FileExtensions map[string]int
|
LargestFiles []FileItem
|
||||||
|
LargestDirs []FileItem
|
||||||
|
LastModified time.Time
|
||||||
|
FileExtensions map[string]int
|
||||||
|
HasGitignore bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func DirInfoCommand(args []string) error {
|
func DirInfoCommand(args []string) error {
|
||||||
|
|
@ -33,51 +40,67 @@ func DirInfoCommand(args []string) error {
|
||||||
|
|
||||||
dirPath := args[0]
|
dirPath := args[0]
|
||||||
|
|
||||||
stats, err := analyzeDirectory(dirPath)
|
showIgnored := false
|
||||||
|
if len(args) > 1 && args[1] == "--all" {
|
||||||
|
showIgnored = true
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := analyzeDirectory(dirPath, showIgnored)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error analyzing directory: %w", err)
|
return fmt.Errorf("error analyzing directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
size := formatter.FormatSize(stats.TotalSize)
|
size := formatter.FormatSize(stats.TotalSize)
|
||||||
|
ignoringSize := formatter.FormatSize(stats.TotalSizeIgnoring)
|
||||||
modTime := stats.LastModified.Format(time.RFC1123)
|
modTime := stats.LastModified.Format(time.RFC1123)
|
||||||
|
|
||||||
PrintBoxHeader("DIRECTORY INFORMATION", colors.Purple)
|
PrintBoxHeader("DIRECTORY INFORMATION", colors.Purple)
|
||||||
|
|
||||||
fmt.Printf("%s%s📁 Path:%s %s\n", colors.Blue, colors.Bold, colors.Reset, dirPath)
|
fmt.Printf("%s%s📁 Path:%s %s\n", colors.Blue, colors.Bold, colors.Reset, dirPath)
|
||||||
fmt.Printf("%s%s📏 Total Size:%s %s\n", colors.Blue, colors.Bold, colors.Reset, size)
|
fmt.Printf("%s%s📏 Total Size:%s %s\n", colors.Blue, colors.Bold, colors.Reset, size)
|
||||||
|
if stats.HasGitignore {
|
||||||
|
fmt.Printf("%s%s📏 Size (excl. ignored):%s %s\n", colors.Blue, colors.Bold, colors.Reset, ignoringSize)
|
||||||
|
fmt.Printf("%s%s🚫 Ignored:%s %d files (%s)\n", colors.Gray, colors.Bold, colors.Reset, stats.IgnoredFileCount, formatter.FormatSize(stats.IgnoredSize))
|
||||||
|
}
|
||||||
fmt.Printf("%s%s📊 Files:%s %d\n", colors.Blue, colors.Bold, colors.Reset, stats.FileCount)
|
fmt.Printf("%s%s📊 Files:%s %d\n", colors.Blue, colors.Bold, colors.Reset, stats.FileCount)
|
||||||
fmt.Printf("%s%s📂 Directories:%s %d\n", colors.Blue, colors.Bold, colors.Reset, stats.DirCount)
|
fmt.Printf("%s%s📂 Directories:%s %d\n", colors.Blue, colors.Bold, colors.Reset, stats.DirCount)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Print largest files table
|
|
||||||
if len(stats.LargestFiles) > 0 {
|
if len(stats.LargestFiles) > 0 {
|
||||||
PrintSectionHeader("LARGEST FILES")
|
PrintSectionHeader("LARGEST FILES")
|
||||||
|
|
||||||
headers := []string{"#", "File Name", "Size"}
|
headers := []string{"#", "File Name", "Size"}
|
||||||
|
if stats.HasGitignore {
|
||||||
|
headers = []string{"#", "File Name", "Size", "Status"}
|
||||||
|
}
|
||||||
data := make([][]string, 0, len(stats.LargestFiles))
|
data := make([][]string, 0, len(stats.LargestFiles))
|
||||||
|
|
||||||
for i, file := range stats.LargestFiles {
|
for i, file := range stats.LargestFiles {
|
||||||
name := filepath.Base(file.Path)
|
name := file.RelPath
|
||||||
row := []string{
|
row := []string{
|
||||||
fmt.Sprintf("%d", i+1),
|
fmt.Sprintf("%d", i+1),
|
||||||
name,
|
name,
|
||||||
formatter.FormatSize(file.Size),
|
formatter.FormatSize(file.Size),
|
||||||
}
|
}
|
||||||
|
if stats.HasGitignore {
|
||||||
|
if file.Ignored {
|
||||||
|
row = append(row, colors.Gray+"[ignored]"+colors.Reset)
|
||||||
|
} else {
|
||||||
|
row = append(row, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
data = append(data, row)
|
data = append(data, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
printSimpleTable(headers, data)
|
printSimpleTable(headers, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print largest directories table
|
|
||||||
if len(stats.LargestDirs) > 0 {
|
if len(stats.LargestDirs) > 0 {
|
||||||
PrintSectionHeader("LARGEST DIRS")
|
PrintSectionHeader("LARGEST DIRS")
|
||||||
|
|
||||||
headers := []string{"#", "Directory Name", "Size"}
|
headers := []string{"#", "Directory Name", "Size"}
|
||||||
data := make([][]string, 0, len(stats.LargestDirs))
|
data := make([][]string, 0, len(stats.LargestDirs))
|
||||||
|
|
||||||
for i, dir := range stats.LargestDirs {
|
for i, dir := range stats.LargestDirs {
|
||||||
name := filepath.Base(dir.Path)
|
name := dir.RelPath
|
||||||
row := []string{
|
row := []string{
|
||||||
fmt.Sprintf("%d", i+1),
|
fmt.Sprintf("%d", i+1),
|
||||||
name,
|
name,
|
||||||
|
|
@ -89,10 +112,8 @@ func DirInfoCommand(args []string) error {
|
||||||
printSimpleTable(headers, data)
|
printSimpleTable(headers, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print file type distribution table
|
|
||||||
if len(stats.FileExtensions) > 0 {
|
if len(stats.FileExtensions) > 0 {
|
||||||
PrintSectionHeader("FILE TYPE DISTRIBUTION")
|
PrintSectionHeader("FILE TYPE DISTRIBUTION")
|
||||||
|
|
||||||
type extCount struct {
|
type extCount struct {
|
||||||
ext string
|
ext string
|
||||||
count int
|
count int
|
||||||
|
|
@ -133,6 +154,9 @@ func DirInfoCommand(args []string) error {
|
||||||
|
|
||||||
PrintSectionHeader("DIRECTORY DETAILS")
|
PrintSectionHeader("DIRECTORY DETAILS")
|
||||||
fmt.Printf("%s%s📅 Last Modified:%s %s\n", colors.Yellow, colors.Bold, colors.Reset, modTime)
|
fmt.Printf("%s%s📅 Last Modified:%s %s\n", colors.Yellow, colors.Bold, colors.Reset, modTime)
|
||||||
|
if stats.HasGitignore {
|
||||||
|
fmt.Printf("%s%s📜 Gitignore:%s %s\n", colors.Green, colors.Bold, colors.Reset, "Detected")
|
||||||
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -143,39 +167,67 @@ func printSimpleTable(headers []string, data [][]string) {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
func analyzeDirectory(path string) (*DirStats, error) {
|
func analyzeDirectory(path string, showIgnored bool) (*DirStats, error) {
|
||||||
stats := &DirStats{
|
stats := &DirStats{
|
||||||
LargestFiles: make([]FileItem, 0),
|
LargestFiles: make([]FileItem, 0),
|
||||||
LargestDirs: make([]FileItem, 0),
|
LargestDirs: make([]FileItem, 0),
|
||||||
FileExtensions: make(map[string]int),
|
FileExtensions: make(map[string]int),
|
||||||
}
|
}
|
||||||
|
|
||||||
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
ignoreMatcher, err := ignore.CompileIgnoreFile(filepath.Join(path, ".gitignore"))
|
||||||
|
if err == nil {
|
||||||
|
stats.HasGitignore = true
|
||||||
|
_ = ignoreMatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relPath, _ := filepath.Rel(path, filePath)
|
||||||
|
|
||||||
if filePath == path {
|
if filePath == path {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ignored := false
|
||||||
|
if stats.HasGitignore {
|
||||||
|
ignored = ignoreMatcher.MatchesPath(relPath)
|
||||||
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
stats.DirCount++
|
if !ignored || showIgnored {
|
||||||
stats.LargestDirs = appendSorted(stats.LargestDirs, FileItem{
|
stats.DirCount++
|
||||||
Path: filePath,
|
stats.LargestDirs = appendSorted(stats.LargestDirs, FileItem{
|
||||||
Size: info.Size(),
|
Path: filePath,
|
||||||
}, 5)
|
Size: info.Size(),
|
||||||
|
Ignored: ignored,
|
||||||
|
RelPath: relPath,
|
||||||
|
}, 5)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
stats.FileCount++
|
stats.FileCount++
|
||||||
stats.TotalSize += info.Size()
|
stats.TotalSize += info.Size()
|
||||||
|
|
||||||
|
if ignored {
|
||||||
|
stats.IgnoredFileCount++
|
||||||
|
stats.IgnoredSize += info.Size()
|
||||||
|
} else {
|
||||||
|
stats.TotalSizeIgnoring += info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(filePath)
|
ext := filepath.Ext(filePath)
|
||||||
stats.FileExtensions[ext]++
|
stats.FileExtensions[ext]++
|
||||||
|
|
||||||
stats.LargestFiles = appendSorted(stats.LargestFiles, FileItem{
|
if !ignored || showIgnored {
|
||||||
Path: filePath,
|
stats.LargestFiles = appendSorted(stats.LargestFiles, FileItem{
|
||||||
Size: info.Size(),
|
Path: filePath,
|
||||||
}, 5)
|
Size: info.Size(),
|
||||||
|
Ignored: ignored,
|
||||||
|
RelPath: relPath,
|
||||||
|
}, 5)
|
||||||
|
}
|
||||||
|
|
||||||
if info.ModTime().After(stats.LastModified) {
|
if info.ModTime().After(stats.LastModified) {
|
||||||
stats.LastModified = info.ModTime()
|
stats.LastModified = info.ModTime()
|
||||||
|
|
@ -200,4 +252,4 @@ func appendSorted(items []FileItem, newItem FileItem, maxSize int) []FileItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
192
cmd/find.go
Normal file
192
cmd/find.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/zemenawi/zutils/pkg/colors"
|
||||||
|
"github.com/zemenawi/zutils/pkg/formatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FindOptions struct {
|
||||||
|
Pattern string
|
||||||
|
SizeMin int64
|
||||||
|
SizeMax int64
|
||||||
|
ModifiedDays int
|
||||||
|
FileType string
|
||||||
|
MaxDepth int
|
||||||
|
ShowHidden bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindResult struct {
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
Modified time.Time
|
||||||
|
IsDir bool
|
||||||
|
Permissions string
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindCommand(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("find", flag.ContinueOnError)
|
||||||
|
options := FindOptions{}
|
||||||
|
|
||||||
|
fs.StringVar(&options.Pattern, "name", "*", "File name pattern (glob)")
|
||||||
|
fs.StringVar(&options.FileType, "type", "", "File type: f (file), d (dir)")
|
||||||
|
sizeMin := fs.Int64("size-min", 0, "Minimum size in bytes")
|
||||||
|
sizeMax := fs.Int64("size-max", 0, "Maximum size in bytes")
|
||||||
|
modifiedDays := fs.Int("mtime", 0, "Modified within N days")
|
||||||
|
maxDepth := fs.Int("depth", -1, "Maximum directory depth")
|
||||||
|
showHidden := fs.Bool("hidden", false, "Show hidden files")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if err == flag.ErrHelp {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid arguments: %v\n\nUsage: z find [path] [options]\n\nOptions:\n%s\n\nExamples:\n z find . -name '*.go' Find all Go files\n z find . -mtime 7 Files modified in last 7 days\n z find . -size +1m Files larger than 1MB\n z find . -type d Directories only", err, getFlagDefaults(fs))
|
||||||
|
}
|
||||||
|
|
||||||
|
options.SizeMin = *sizeMin
|
||||||
|
options.SizeMax = *sizeMax
|
||||||
|
options.ModifiedDays = *modifiedDays
|
||||||
|
options.MaxDepth = *maxDepth
|
||||||
|
options.ShowHidden = *showHidden
|
||||||
|
|
||||||
|
path := "."
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
path = fs.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("missing search path\n\nUsage: z find [path] [options]")
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := findFiles(path, options)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error finding files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
fmt.Printf("%sNo files found matching criteria%s\n", colors.Gray, colors.Reset)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Modified.After(results[j].Modified)
|
||||||
|
})
|
||||||
|
|
||||||
|
PrintBoxHeader(fmt.Sprintf("FIND RESULTS (%d files)", len(results)), colors.Green)
|
||||||
|
|
||||||
|
headers := []string{"Path", "Size", "Modified"}
|
||||||
|
data := make([][]string, 0, len(results))
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
relPath := result.Path
|
||||||
|
if strings.Contains(relPath, "/") {
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
relPath = strings.Join(parts[len(parts)-2:], "/")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
relPath = "..." + string(filepath.Separator) + strings.Join(parts[len(parts)-2:], "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modStr := result.Modified.Format("Jan 02 15:04")
|
||||||
|
if time.Since(result.Modified) > 30*24*time.Hour {
|
||||||
|
modStr = result.Modified.Format("Jan 02 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
row := []string{
|
||||||
|
relPath,
|
||||||
|
formatter.FormatSize(result.Size),
|
||||||
|
modStr,
|
||||||
|
}
|
||||||
|
data = append(data, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter.PrintTable(headers, data, colors.Cyan)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFlagDefaults(fs *flag.FlagSet) string {
|
||||||
|
var result []string
|
||||||
|
fs.VisitAll(func(f *flag.Flag) {
|
||||||
|
result = append(result, fmt.Sprintf(" -%s %s (default: %v)", f.Name, f.Usage, f.DefValue))
|
||||||
|
})
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFiles(path string, opts FindOptions) ([]FindResult, error) {
|
||||||
|
var results []FindResult
|
||||||
|
|
||||||
|
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if filePath == path {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.ShowHidden && filepath.Base(filePath)[0] == '.' {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.MaxDepth > 0 {
|
||||||
|
relPath, _ := filepath.Rel(path, filePath)
|
||||||
|
if strings.Count(relPath, string(filepath.Separator)) > opts.MaxDepth {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.FileType == "f" && info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if opts.FileType == "d" && !info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(filePath)
|
||||||
|
matched, err := filepath.Match(opts.Pattern, baseName)
|
||||||
|
if err != nil || !matched {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.SizeMin > 0 && info.Size() < opts.SizeMin {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if opts.SizeMax > 0 && info.Size() > opts.SizeMax {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ModifiedDays > 0 {
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -opts.ModifiedDays)
|
||||||
|
if info.ModTime().Before(cutoff) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, FindResult{
|
||||||
|
Path: filePath,
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime(),
|
||||||
|
IsDir: info.IsDir(),
|
||||||
|
Permissions: info.Mode().String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
193
cmd/help.go
Normal file
193
cmd/help.go
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zemenawi/zutils/pkg/colors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommandHelp struct {
|
||||||
|
Name string
|
||||||
|
Help string
|
||||||
|
Examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var readHelp = CommandHelp{
|
||||||
|
Name: "read",
|
||||||
|
Help: "Read and display file with smart detection.\n" +
|
||||||
|
"Automatically detects file type:\n" +
|
||||||
|
" - Markdown (.md) - Renders with colored headings, lists, code blocks\n" +
|
||||||
|
" - Code files - Syntax highlighting with line numbers\n" +
|
||||||
|
" - Other files - Plain text display",
|
||||||
|
Examples: []string{
|
||||||
|
"z read README.md # Read markdown file",
|
||||||
|
"z read main.go # Read code with syntax highlighting",
|
||||||
|
"z read package.json # Read JSON file",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sysHelp = CommandHelp{
|
||||||
|
Name: "sys",
|
||||||
|
Help: "Show system overview including CPU, memory, disk usage, and top processes.\n" +
|
||||||
|
"Useful for quick system diagnostics.",
|
||||||
|
Examples: []string{
|
||||||
|
"z sys # Show full system overview",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var findHelp = CommandHelp{
|
||||||
|
Name: "find",
|
||||||
|
Help: "Find files matching criteria.\n" +
|
||||||
|
"Supports glob patterns, size filters, modification time, and depth limits.",
|
||||||
|
Examples: []string{
|
||||||
|
"z find . -name '*.go' # Find all Go files",
|
||||||
|
"z find . -mtime 7 # Files modified in last 7 days",
|
||||||
|
"z find . -size +1m # Files larger than 1MB",
|
||||||
|
"z find . -type d # Directories only",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchHelp = CommandHelp{
|
||||||
|
Name: "search",
|
||||||
|
Help: "Search for a pattern in files.\n" +
|
||||||
|
"A user-friendly alternative to grep with colored output.",
|
||||||
|
Examples: []string{
|
||||||
|
"z search func cmd/ # Search for 'func' in cmd directory",
|
||||||
|
"z search -i 'error' . # Case-insensitive search",
|
||||||
|
"z search -w 'import' *.go # Whole word match",
|
||||||
|
"z search -C 2 'TODO' . # Show 2 lines of context",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var usageHelp = CommandHelp{
|
||||||
|
Name: "usage",
|
||||||
|
Help: "Show resource usage (CPU, memory) for processes matching a name.\n" +
|
||||||
|
"Useful for monitoring specific applications or services.",
|
||||||
|
Examples: []string{
|
||||||
|
"z usage cron # Show cron process usage",
|
||||||
|
"z usage mongod # Show MongoDB usage",
|
||||||
|
"z usage chrome # Show all Chrome processes",
|
||||||
|
"z usage postgres # Show PostgreSQL usage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrintErrorAndHelp(name string, err error) {
|
||||||
|
help := getHelpForCommand(name)
|
||||||
|
|
||||||
|
fmt.Printf("\n %s✖ Error: %s%s\n\n", colors.Red, err, colors.Reset)
|
||||||
|
|
||||||
|
if help != nil {
|
||||||
|
fmt.Printf(" %sUsage:%s z %s %s\n\n", colors.Bold, colors.Reset, help.Name, getUsageHint(help.Name))
|
||||||
|
|
||||||
|
if help.Help != "" {
|
||||||
|
fmt.Printf(" %sDescription:%s\n", colors.Bold, colors.Reset)
|
||||||
|
lines := splitIntoLines(help.Help, 60)
|
||||||
|
for _, line := range lines {
|
||||||
|
fmt.Printf(" %s%s%s\n", colors.Reset, line, colors.Reset)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(help.Examples) > 0 {
|
||||||
|
fmt.Printf(" %sExamples:%s\n", colors.Bold, colors.Reset)
|
||||||
|
for _, ex := range help.Examples {
|
||||||
|
fmt.Printf(" %s%s%s\n", colors.Green, ex, colors.Reset)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHelpForCommand(name string) *CommandHelp {
|
||||||
|
switch name {
|
||||||
|
case "read":
|
||||||
|
return &readHelp
|
||||||
|
case "sys":
|
||||||
|
return &sysHelp
|
||||||
|
case "find":
|
||||||
|
return &findHelp
|
||||||
|
case "search":
|
||||||
|
return &searchHelp
|
||||||
|
case "usage":
|
||||||
|
return &usageHelp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUsageHint(name string) string {
|
||||||
|
switch name {
|
||||||
|
case "read":
|
||||||
|
return "<file>"
|
||||||
|
case "sys":
|
||||||
|
return ""
|
||||||
|
case "find":
|
||||||
|
return "[path] [options]"
|
||||||
|
case "search":
|
||||||
|
return "<pattern> [path]"
|
||||||
|
case "usage":
|
||||||
|
return "<process-name>"
|
||||||
|
case "info":
|
||||||
|
return "<file|dir|network>"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitIntoLines(text string, maxLen int) []string {
|
||||||
|
var lines []string
|
||||||
|
paragraphs := splitIntoParagraphs(text)
|
||||||
|
|
||||||
|
for _, para := range paragraphs {
|
||||||
|
if para == "" {
|
||||||
|
lines = append(lines, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
words := strings.Fields(para)
|
||||||
|
currentLine := ""
|
||||||
|
|
||||||
|
for _, word := range words {
|
||||||
|
if currentLine == "" {
|
||||||
|
currentLine = word
|
||||||
|
} else if len(currentLine)+len(word)+1 <= maxLen {
|
||||||
|
currentLine += " " + word
|
||||||
|
} else {
|
||||||
|
lines = append(lines, currentLine)
|
||||||
|
currentLine = word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentLine != "" {
|
||||||
|
lines = append(lines, currentLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitIntoParagraphs(text string) []string {
|
||||||
|
var paragraphs []string
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
|
||||||
|
current := ""
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
if current != "" {
|
||||||
|
paragraphs = append(paragraphs, current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if current != "" {
|
||||||
|
current += " "
|
||||||
|
}
|
||||||
|
current += trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current != "" {
|
||||||
|
paragraphs = append(paragraphs, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraphs
|
||||||
|
}
|
||||||
67
cmd/read.go
67
cmd/read.go
|
|
@ -1,77 +1,48 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/v2/formatters"
|
|
||||||
"github.com/alecthomas/chroma/v2/lexers"
|
|
||||||
"github.com/alecthomas/chroma/v2/styles"
|
|
||||||
"github.com/zemenawi/zutils/pkg/colors"
|
|
||||||
"github.com/zemenawi/zutils/pkg/formatter"
|
"github.com/zemenawi/zutils/pkg/formatter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ReadHelp = CommandHelp{
|
||||||
|
Name: "read",
|
||||||
|
Help: "Read and display file with smart detection.\nAutomatically detects file type:\n - Markdown (.md) - Renders with colored headings, lists, code blocks\n - Code files - Syntax highlighting with line numbers\n - Other files - Plain text display",
|
||||||
|
Examples: []string{
|
||||||
|
"z read README.md # Read markdown file",
|
||||||
|
"z read main.go # Read code with syntax highlighting",
|
||||||
|
"z read package.json # Read JSON file",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func ReadCommand(args []string) error {
|
func ReadCommand(args []string) error {
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return fmt.Errorf("please specify a file path")
|
return fmt.Errorf("missing file path\n\nUsage: z read <file>")
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
|
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error opening file: %w", err)
|
return fmt.Errorf("cannot open file '%s': %s", filePath, err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
fileInfo, err := file.Stat()
|
fileInfo, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error getting file info: %w", err)
|
return fmt.Errorf("cannot read file info '%s': %s", filePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fileInfo.IsDir() {
|
if fileInfo.IsDir() {
|
||||||
return fmt.Errorf("'%s' is a directory, not a readable file", filePath)
|
return fmt.Errorf("'%s' is a directory\n\nUse: z info dir %s", filePath, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := formatter.ReadFileContent(file)
|
handler := formatter.ReadHandlers.GetHandler(filePath)
|
||||||
|
if handler != nil {
|
||||||
lexer := lexers.Match(filepath.Base(filePath))
|
return handler(args)
|
||||||
if lexer == nil {
|
|
||||||
lexer = lexers.Fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
iterator, err := lexer.Tokenise(nil, content)
|
return formatter.ReadFileWithHighlight(file, filePath)
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("error tokenizing: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
formatter_ := formatters.TTY256
|
|
||||||
style := styles.Get("monokai")
|
|
||||||
if style == nil {
|
|
||||||
style = styles.Fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("%s%s╔══════════════════════════════════════╗%s\n", colors.Cyan, colors.Bold, colors.Reset)
|
|
||||||
fmt.Printf("%s%s║%s %-38s %s%s║%s\n", colors.Cyan, colors.Bold, colors.Reset, filePath, colors.Cyan, colors.Bold, colors.Reset)
|
|
||||||
fmt.Printf("%s%s╚══════════════════════════════════════╝%s\n", colors.Cyan, colors.Bold, colors.Reset)
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err = formatter_.Format(&buf, style, iterator)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error formatting: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(buf.String(), "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
if line != "" {
|
|
||||||
fmt.Printf("%s%4d: %s%s\n", colors.Gray, i+1, colors.Reset, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -3,7 +3,6 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/v2/formatters"
|
"github.com/alecthomas/chroma/v2/formatters"
|
||||||
|
|
@ -13,7 +12,12 @@ import (
|
||||||
"github.com/zemenawi/zutils/pkg/formatter"
|
"github.com/zemenawi/zutils/pkg/formatter"
|
||||||
)
|
)
|
||||||
|
|
||||||
func MarkdownCommand(args []string) error {
|
func init() {
|
||||||
|
formatter.ReadHandlers.Register(".md", MarkdownHandler)
|
||||||
|
formatter.ReadHandlers.Register(".markdown", MarkdownHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkdownHandler(args []string) error {
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return fmt.Errorf("please specify a markdown file path")
|
return fmt.Errorf("please specify a markdown file path")
|
||||||
}
|
}
|
||||||
|
|
@ -35,11 +39,6 @@ func MarkdownCommand(args []string) error {
|
||||||
return fmt.Errorf("'%s' is a directory, not a readable file", filePath)
|
return fmt.Errorf("'%s' is a directory, not a readable file", filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(filePath)
|
|
||||||
if ext != ".md" && ext != ".markdown" {
|
|
||||||
return fmt.Errorf("'%s' is not a markdown file", filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := formatter.ReadFileContent(file)
|
content := formatter.ReadFileContent(file)
|
||||||
|
|
||||||
fmt.Printf("%s%s╔══════════════════════════════════════╗%s\n", colors.Cyan, colors.Bold, colors.Reset)
|
fmt.Printf("%s%s╔══════════════════════════════════════╗%s\n", colors.Cyan, colors.Bold, colors.Reset)
|
||||||
|
|
@ -52,7 +51,7 @@ func MarkdownCommand(args []string) error {
|
||||||
codeBuffer := &strings.Builder{}
|
codeBuffer := &strings.Builder{}
|
||||||
codeLang := ""
|
codeLang := ""
|
||||||
|
|
||||||
for i, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.HasPrefix(line, "```") {
|
if strings.HasPrefix(line, "```") {
|
||||||
if !inCodeBlock {
|
if !inCodeBlock {
|
||||||
inCodeBlock = true
|
inCodeBlock = true
|
||||||
|
|
@ -94,7 +93,6 @@ func MarkdownCommand(args []string) error {
|
||||||
} else {
|
} else {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
_ = i
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -105,20 +103,22 @@ func renderCodeBlock(code, lang string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s%s╔═══ %s ═══╗%s\n", colors.Gray, colors.Dim, lang, colors.Reset)
|
||||||
|
|
||||||
lexer := lexers.Match(lang)
|
lexer := lexers.Match(lang)
|
||||||
if lexer == nil {
|
if lexer == nil {
|
||||||
lexer = lexers.Fallback
|
lexer = lexers.Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
iterator, _ := lexer.Tokenise(nil, code)
|
iterator, _ := lexer.Tokenise(nil, code)
|
||||||
formatter := formatters.TTY256
|
formatter_ := formatters.TTY256
|
||||||
style := styles.Get("monokai")
|
style := styles.Get("monokai")
|
||||||
if style == nil {
|
if style == nil {
|
||||||
style = styles.Fallback
|
style = styles.Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
formatter.Format(&buf, style, iterator)
|
formatter_.Format(&buf, style, iterator)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
for _, line := range strings.Split(output, "\n") {
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
|
@ -126,5 +126,6 @@ func renderCodeBlock(code, lang string) {
|
||||||
fmt.Printf("%s%s│ %s%s\n", colors.Gray, colors.Dim, colors.Reset, line)
|
fmt.Printf("%s%s│ %s%s\n", colors.Gray, colors.Dim, colors.Reset, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fmt.Printf("%s%s╚══════════════╝%s\n", colors.Gray, colors.Dim, colors.Reset)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
180
cmd/search.go
Normal file
180
cmd/search.go
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zemenawi/zutils/pkg/colors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchOptions struct {
|
||||||
|
Pattern string
|
||||||
|
Path string
|
||||||
|
CaseInsensitive bool
|
||||||
|
WholeWord bool
|
||||||
|
LineNumbers bool
|
||||||
|
ContextLines int
|
||||||
|
FilePattern string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
File string
|
||||||
|
Line int
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchCommand(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("search", flag.ContinueOnError)
|
||||||
|
options := SearchOptions{}
|
||||||
|
|
||||||
|
fs.BoolVar(&options.CaseInsensitive, "i", false, "Case insensitive search")
|
||||||
|
fs.BoolVar(&options.WholeWord, "w", false, "Match whole word")
|
||||||
|
fs.BoolVar(&options.LineNumbers, "n", true, "Show line numbers")
|
||||||
|
fs.IntVar(&options.ContextLines, "C", 0, "Show N lines of context")
|
||||||
|
fs.StringVar(&options.FilePattern, "f", "*", "Search only in files matching pattern")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if err == flag.ErrHelp {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid arguments\n\nUsage: z search [options] pattern [path]\n\nExamples:\n z search func cmd/ Search for 'func' in cmd directory\n z search -i 'error' . Case-insensitive search\n z search -w 'import' *.go Whole word match in Go files")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.NArg() < 1 {
|
||||||
|
return fmt.Errorf("missing search pattern\n\nUsage: z search [options] pattern [path]\n\nExamples:\n z search func cmd/ Search for 'func' in cmd directory\n z search -i 'error' . Case-insensitive search\n z search -w 'import' *.go Whole word match in Go files")
|
||||||
|
}
|
||||||
|
|
||||||
|
options.Pattern = fs.Arg(0)
|
||||||
|
if fs.NArg() > 1 {
|
||||||
|
options.Path = fs.Arg(1)
|
||||||
|
} else {
|
||||||
|
options.Path = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := searchFiles(options)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("search error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
fmt.Printf("%sNo matches found for '%s'%s\n", colors.Gray, options.Pattern, colors.Reset)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySearchResults(results, options)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchFiles(opts SearchOptions) ([]SearchResult, error) {
|
||||||
|
var results []SearchResult
|
||||||
|
|
||||||
|
pattern := opts.Pattern
|
||||||
|
if opts.CaseInsensitive {
|
||||||
|
pattern = strings.ToLower(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
var regex *regexp.Regexp
|
||||||
|
if opts.WholeWord {
|
||||||
|
regex = regexp.MustCompile(`\b` + regexp.QuoteMeta(pattern) + `\b`)
|
||||||
|
} else {
|
||||||
|
regex = regexp.MustCompile(regexp.QuoteMeta(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.Walk(opts.Path, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(path)
|
||||||
|
matched, err := filepath.Match(opts.FilePattern, baseName)
|
||||||
|
if err != nil || !matched {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileResults := searchFile(path, regex, opts)
|
||||||
|
results = append(results, fileResults...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchFile(path string, regex *regexp.Regexp, opts SearchOptions) []SearchResult {
|
||||||
|
var results []SearchResult
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
lineNum := 0
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNum++
|
||||||
|
line := scanner.Text()
|
||||||
|
check := line
|
||||||
|
|
||||||
|
if opts.CaseInsensitive {
|
||||||
|
check = strings.ToLower(check)
|
||||||
|
}
|
||||||
|
|
||||||
|
if regex.MatchString(check) {
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
File: path,
|
||||||
|
Line: lineNum,
|
||||||
|
Content: line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func displaySearchResults(results []SearchResult, opts SearchOptions) {
|
||||||
|
files := make(map[string][]SearchResult)
|
||||||
|
for _, r := range results {
|
||||||
|
files[r.File] = append(files[r.File], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
PrintBoxHeader(fmt.Sprintf("SEARCH RESULTS (%d matches in %d files)", len(results), len(files)), colors.Cyan)
|
||||||
|
|
||||||
|
for file, fileResults := range files {
|
||||||
|
relPath := file
|
||||||
|
if strings.Contains(relPath, "/") {
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
if len(parts) > 3 {
|
||||||
|
relPath = ".../" + strings.Join(parts[len(parts)-2:], "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%s%s%s:%s\n", colors.Bold, colors.Yellow, relPath, colors.Reset)
|
||||||
|
|
||||||
|
for _, r := range fileResults {
|
||||||
|
content := r.Content
|
||||||
|
if len(content) > 100 {
|
||||||
|
content = content[:97] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.LineNumbers {
|
||||||
|
fmt.Printf("%s%4d:%s %s\n", colors.Gray, r.Line, colors.Reset, content)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %s\n", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
370
cmd/sys.go
Normal file
370
cmd/sys.go
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/zemenawi/zutils/pkg/colors"
|
||||||
|
"github.com/zemenawi/zutils/pkg/formatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SystemStats struct {
|
||||||
|
Hostname string
|
||||||
|
OS string
|
||||||
|
Platform string
|
||||||
|
CPUModel string
|
||||||
|
CPUCount int
|
||||||
|
Uptime string
|
||||||
|
LoadAvg string
|
||||||
|
MemTotal int64
|
||||||
|
MemUsed int64
|
||||||
|
MemFree int64
|
||||||
|
DiskTotal int64
|
||||||
|
DiskUsed int64
|
||||||
|
DiskFree int64
|
||||||
|
TopProcs []ProcInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcInfo struct {
|
||||||
|
PID int
|
||||||
|
Name string
|
||||||
|
CPU float64
|
||||||
|
Mem float64
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
|
||||||
|
func SystemCommand(args []string) error {
|
||||||
|
stats, err := getSystemStats()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error getting system stats: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
PrintBoxHeader("SYSTEM OVERVIEW", colors.Cyan)
|
||||||
|
|
||||||
|
fmt.Printf("%s%s🏠 Hostname:%s %s\n", colors.Blue, colors.Bold, colors.Reset, stats.Hostname)
|
||||||
|
fmt.Printf("%s%s💻 OS:%s %s\n", colors.Blue, colors.Bold, colors.Reset, stats.OS)
|
||||||
|
fmt.Printf("%s%s🔧 Platform:%s %s\n", colors.Blue, colors.Bold, colors.Reset, stats.Platform)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
PrintSectionHeader("CPU")
|
||||||
|
fmt.Printf("%s%s🖥️ Model:%s %s\n", colors.Green, colors.Bold, colors.Reset, stats.CPUModel)
|
||||||
|
fmt.Printf("%s%s🔢 Cores:%s %d\n", colors.Green, colors.Bold, colors.Reset, stats.CPUCount)
|
||||||
|
fmt.Printf("%s%s📊 Load Avg:%s %s\n", colors.Green, colors.Bold, colors.Reset, stats.LoadAvg)
|
||||||
|
fmt.Printf("%s%s⏱️ Uptime:%s %s\n", colors.Green, colors.Bold, colors.Reset, stats.Uptime)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
PrintSectionHeader("MEMORY")
|
||||||
|
memTotal := formatter.FormatSize(stats.MemTotal)
|
||||||
|
memUsed := formatter.FormatSize(stats.MemUsed)
|
||||||
|
memFree := formatter.FormatSize(stats.MemFree)
|
||||||
|
memPct := float64(stats.MemUsed) / float64(stats.MemTotal) * 100
|
||||||
|
|
||||||
|
fmt.Printf("%s%s💾 Total:%s %s\n", colors.Purple, colors.Bold, colors.Reset, memTotal)
|
||||||
|
fmt.Printf("%s%s📈 Used:%s %s (%.1f%%)\n", colors.Purple, colors.Bold, colors.Reset, memUsed, memPct)
|
||||||
|
fmt.Printf("%s%s📉 Free:%s %s\n", colors.Purple, colors.Bold, colors.Reset, memFree)
|
||||||
|
printMemoryBar(stats.MemUsed, stats.MemTotal)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
PrintSectionHeader("DISK")
|
||||||
|
diskTotal := formatter.FormatSize(stats.DiskTotal)
|
||||||
|
diskUsed := formatter.FormatSize(stats.DiskUsed)
|
||||||
|
diskFree := formatter.FormatSize(stats.DiskFree)
|
||||||
|
diskPct := float64(stats.DiskUsed) / float64(stats.DiskTotal) * 100
|
||||||
|
|
||||||
|
fmt.Printf("%s%s💽 Total:%s %s\n", colors.Yellow, colors.Bold, colors.Reset, diskTotal)
|
||||||
|
fmt.Printf("%s%s📈 Used:%s %s (%.1f%%)\n", colors.Yellow, colors.Bold, colors.Reset, diskUsed, diskPct)
|
||||||
|
fmt.Printf("%s%s📉 Free:%s %s\n", colors.Yellow, colors.Bold, colors.Reset, diskFree)
|
||||||
|
printDiskBar(stats.DiskUsed, stats.DiskTotal)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if len(stats.TopProcs) > 0 {
|
||||||
|
PrintSectionHeader("TOP PROCESSES")
|
||||||
|
headers := []string{"PID", "Name", "CPU%", "MEM%"}
|
||||||
|
data := make([][]string, 0, len(stats.TopProcs))
|
||||||
|
|
||||||
|
for _, proc := range stats.TopProcs {
|
||||||
|
row := []string{
|
||||||
|
fmt.Sprintf("%d", proc.PID),
|
||||||
|
truncateString(proc.Name, 20),
|
||||||
|
fmt.Sprintf("%.1f", proc.CPU),
|
||||||
|
fmt.Sprintf("%.1f", proc.Mem),
|
||||||
|
}
|
||||||
|
data = append(data, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter.PrintTable(headers, data, colors.Cyan)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMemoryBar(used, total int64) {
|
||||||
|
width := 30
|
||||||
|
if total == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fillLen := int(float64(used) / float64(total) * float64(width))
|
||||||
|
|
||||||
|
bar := "["
|
||||||
|
for i := 0; i < width; i++ {
|
||||||
|
if i < fillLen {
|
||||||
|
if i < width/3 {
|
||||||
|
bar += colors.Green + "█" + colors.Reset
|
||||||
|
} else if i < 2*width/3 {
|
||||||
|
bar += colors.Yellow + "█" + colors.Reset
|
||||||
|
} else {
|
||||||
|
bar += colors.Red + "█" + colors.Reset
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bar += colors.Gray + "░" + colors.Reset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bar += "]"
|
||||||
|
|
||||||
|
fmt.Printf("%s%sMemory Bar:%s %s\n", colors.Gray, colors.Bold, colors.Reset, bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printDiskBar(used, total int64) {
|
||||||
|
width := 30
|
||||||
|
if total == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fillLen := int(float64(used) / float64(total) * float64(width))
|
||||||
|
|
||||||
|
bar := "["
|
||||||
|
for i := 0; i < width; i++ {
|
||||||
|
if i < fillLen {
|
||||||
|
if i < width/3 {
|
||||||
|
bar += colors.Green + "█" + colors.Reset
|
||||||
|
} else if i < 2*width/3 {
|
||||||
|
bar += colors.Yellow + "█" + colors.Reset
|
||||||
|
} else {
|
||||||
|
bar += colors.Red + "█" + colors.Reset
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bar += colors.Gray + "░" + colors.Reset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bar += "]"
|
||||||
|
|
||||||
|
fmt.Printf("%s%sDisk Bar:%s %s\n", colors.Gray, colors.Bold, colors.Reset, bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSystemStats() (*SystemStats, error) {
|
||||||
|
stats := &SystemStats{}
|
||||||
|
|
||||||
|
stats.Hostname, _ = os.Hostname()
|
||||||
|
stats.OS = runtime.GOOS
|
||||||
|
stats.Platform = runtime.GOARCH
|
||||||
|
|
||||||
|
stats.CPUModel = getCPUModel()
|
||||||
|
stats.CPUCount = runtime.NumCPU()
|
||||||
|
|
||||||
|
stats.Uptime = getUptime()
|
||||||
|
stats.LoadAvg = getLoadAvg()
|
||||||
|
|
||||||
|
getMemoryStats(stats)
|
||||||
|
getDiskStats(stats)
|
||||||
|
|
||||||
|
stats.TopProcs = getTopProcs(5)
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCPUModel() string {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "model name") || strings.HasPrefix(line, "Processor") || strings.HasPrefix(line, "cpu model") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUptime() string {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
if data, err := os.ReadFile("/proc/uptime"); err == nil {
|
||||||
|
parts := strings.Split(string(data), " ")
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
if secs, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64); err == nil {
|
||||||
|
duration := time.Duration(int64(secs)) * time.Second
|
||||||
|
return formatDuration(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
days := int(d.Hours() / 24)
|
||||||
|
hours := int(d.Hours()) % 24
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLoadAvg() string {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||||
|
parts := strings.Split(strings.TrimSpace(string(data)), " ")
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
return fmt.Sprintf("%.2f %.2f %.2f",
|
||||||
|
parseFloatSafe(parts[0]),
|
||||||
|
parseFloatSafe(parts[1]),
|
||||||
|
parseFloatSafe(parts[2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloatSafe(s string) float64 {
|
||||||
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMemoryStats(stats *SystemStats) {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "MemTotal") {
|
||||||
|
stats.MemTotal = parseMeminfoLine(line)
|
||||||
|
} else if strings.HasPrefix(line, "MemAvailable") || strings.HasPrefix(line, "MemFree") {
|
||||||
|
if stats.MemFree == 0 {
|
||||||
|
stats.MemFree = parseMeminfoLine(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stats.MemFree > 0 && stats.MemTotal > 0 {
|
||||||
|
stats.MemUsed = stats.MemTotal - stats.MemFree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMeminfoLine(line string) int64 {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
if val, err := strconv.ParseInt(parts[1], 10, 64); err == nil {
|
||||||
|
return val * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDiskStats(stats *SystemStats) {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
output, err := exec.Command("df", "-k", "/").Output()
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
if len(lines) >= 2 {
|
||||||
|
parts := strings.Fields(lines[1])
|
||||||
|
if len(parts) >= 4 {
|
||||||
|
stats.DiskTotal, _ = strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
stats.DiskUsed, _ = strconv.ParseInt(parts[2], 10, 64)
|
||||||
|
stats.DiskFree, _ = strconv.ParseInt(parts[3], 10, 64)
|
||||||
|
stats.DiskTotal *= 1024
|
||||||
|
stats.DiskUsed *= 1024
|
||||||
|
stats.DiskFree *= 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTopProcs(count int) []ProcInfo {
|
||||||
|
var procs []ProcInfo
|
||||||
|
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
dir, err := os.Open("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return procs
|
||||||
|
}
|
||||||
|
defer dir.Close()
|
||||||
|
|
||||||
|
entries, err := dir.Readdirnames(count * 3)
|
||||||
|
if err != nil {
|
||||||
|
return procs
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
pid, err := strconv.Atoi(entry)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := readProcStat(pid)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
procs = append(procs, stat)
|
||||||
|
if len(procs) >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return procs
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProcStat(pid int) (ProcInfo, error) {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||||
|
if err != nil {
|
||||||
|
return ProcInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(string(data), " ", 4)
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return ProcInfo{}, fmt.Errorf("invalid format")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := parts[1]
|
||||||
|
name = strings.Trim(name, "(")
|
||||||
|
name = strings.TrimRight(name, ")")
|
||||||
|
|
||||||
|
comm, _ := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||||
|
commName := strings.TrimSpace(string(comm))
|
||||||
|
|
||||||
|
var rusage syscall.Rusage
|
||||||
|
syscall.Getrusage(syscall.RUSAGE_SELF, &rusage)
|
||||||
|
|
||||||
|
return ProcInfo{
|
||||||
|
PID: pid,
|
||||||
|
Name: commName,
|
||||||
|
CPU: 0,
|
||||||
|
Mem: 0,
|
||||||
|
Command: name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
228
cmd/usage.go
Normal file
228
cmd/usage.go
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zemenawi/zutils/pkg/colors"
|
||||||
|
"github.com/zemenawi/zutils/pkg/formatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessInfo struct {
|
||||||
|
PID int
|
||||||
|
Name string
|
||||||
|
Command string
|
||||||
|
CPUPercent float64
|
||||||
|
MemBytes int64
|
||||||
|
MemPercent float64
|
||||||
|
Status string
|
||||||
|
User string
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageCommand(args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("missing process name\n\nUsage: z usage <process-name>\n\nExamples:\n z usage cron # Show cron process usage\n z usage mongod # Show MongoDB usage\n z usage chrome # Show all Chrome processes")
|
||||||
|
}
|
||||||
|
|
||||||
|
processName := args[0]
|
||||||
|
processes := findProcessesByName(processName)
|
||||||
|
|
||||||
|
if len(processes) == 0 {
|
||||||
|
fmt.Printf("\n %sNo processes found matching '%s'%s\n\n", colors.Yellow, processName, colors.Reset)
|
||||||
|
fmt.Printf(" Try a different process name, or use %sz sys%s to see all processes\n\n", colors.Gray, colors.Reset)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
PrintBoxHeader(fmt.Sprintf("USAGE: %s (%d processes)", processName, len(processes)), colors.Cyan)
|
||||||
|
|
||||||
|
totalCPU := 0.0
|
||||||
|
totalMem := int64(0)
|
||||||
|
|
||||||
|
for _, p := range processes {
|
||||||
|
totalCPU += p.CPUPercent
|
||||||
|
totalMem += p.MemBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s%s📊 Total CPU:%s %.1f%%\n", colors.Green, colors.Bold, colors.Reset, totalCPU)
|
||||||
|
fmt.Printf("%s%s💾 Total Memory:%s %s (%.1f MB)\n", colors.Purple, colors.Bold, colors.Reset, formatter.FormatSize(totalMem), float64(totalMem)/1024/1024)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
headers := []string{"PID", "Process", "CPU%", "Memory", "Status"}
|
||||||
|
data := make([][]string, 0, len(processes))
|
||||||
|
|
||||||
|
sort.Slice(processes, func(i, j int) bool {
|
||||||
|
return processes[i].CPUPercent > processes[j].CPUPercent
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, p := range processes {
|
||||||
|
cmd := p.Command
|
||||||
|
if len(cmd) > 30 {
|
||||||
|
parts := strings.Fields(cmd)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
cmd = filepath.Base(parts[0]) + " " + strings.Join(parts[1:], " ")[:25]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := p.Status
|
||||||
|
if status == "R" {
|
||||||
|
status = colors.Green + "running" + colors.Reset
|
||||||
|
} else if status == "S" || status == "I" {
|
||||||
|
status = colors.Gray + "sleeping" + colors.Reset
|
||||||
|
} else {
|
||||||
|
status = colors.Yellow + status + colors.Reset
|
||||||
|
}
|
||||||
|
|
||||||
|
row := []string{
|
||||||
|
fmt.Sprintf("%d", p.PID),
|
||||||
|
cmd,
|
||||||
|
fmt.Sprintf("%.1f", p.CPUPercent),
|
||||||
|
formatter.FormatSize(p.MemBytes),
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
data = append(data, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter.PrintTable(headers, data, colors.Cyan)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findProcessesByName(name string) []ProcessInfo {
|
||||||
|
var processes []ProcessInfo
|
||||||
|
|
||||||
|
dir, err := os.Open("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return processes
|
||||||
|
}
|
||||||
|
defer dir.Close()
|
||||||
|
|
||||||
|
entries, err := dir.Readdirnames(500)
|
||||||
|
if err != nil {
|
||||||
|
return processes
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
pid, err := strconv.Atoi(entry)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := getProcessInfo(pid, name)
|
||||||
|
if err == nil && proc != nil {
|
||||||
|
processes = append(processes, *proc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processes
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProcessInfo(pid int, targetName string) (*ProcessInfo, error) {
|
||||||
|
comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
procName := strings.TrimSpace(string(comm))
|
||||||
|
|
||||||
|
if !strings.Contains(strings.ToLower(procName), strings.ToLower(targetName)) {
|
||||||
|
if !strings.Contains(strings.ToLower(procName), strings.ToLower(targetName)) {
|
||||||
|
cmdline, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||||
|
if cmdline != nil && !strings.Contains(strings.ToLower(string(cmdline)), strings.ToLower(targetName)) {
|
||||||
|
return nil, fmt.Errorf("name mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(string(stat), " ")
|
||||||
|
if len(parts) < 24 {
|
||||||
|
return nil, fmt.Errorf("invalid stat format")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "?"
|
||||||
|
if len(parts) > 2 {
|
||||||
|
status = strings.Trim(parts[2], "()")
|
||||||
|
}
|
||||||
|
|
||||||
|
utime := parseIntSafe(parts[13])
|
||||||
|
stime := parseIntSafe(parts[14])
|
||||||
|
|
||||||
|
memBytes := int64(0)
|
||||||
|
if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)); err == nil {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "VmRSS:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
memKB := parseIntSafe(fields[1])
|
||||||
|
memBytes = memKB * 1024
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMem := int64(0)
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "MemTotal:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
totalMem = parseIntSafe(fields[1]) * 1024
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuPercent := 0.0
|
||||||
|
if totalMem > 0 {
|
||||||
|
cpuPercent = float64(utime+stime) / float64(totalMem) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdline, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||||
|
cmdStr := strings.Join(strings.Fields(string(cmdline)), " ")
|
||||||
|
|
||||||
|
return &ProcessInfo{
|
||||||
|
PID: pid,
|
||||||
|
Name: procName,
|
||||||
|
Command: cmdStr,
|
||||||
|
CPUPercent: cpuPercent,
|
||||||
|
MemBytes: memBytes,
|
||||||
|
MemPercent: float64(memBytes) / float64(totalMem) * 100,
|
||||||
|
Status: status,
|
||||||
|
User: getProcessUser(pid),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProcessUser(pid int) string {
|
||||||
|
if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)); err == nil {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "Uid:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
uid := parseIntSafe(fields[1])
|
||||||
|
return fmt.Sprintf("uid:%d", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntSafe(s string) int64 {
|
||||||
|
if i, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
|
|
@ -5,5 +5,6 @@ go 1.22.0
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
8
go.sum
8
go.sum
|
|
@ -1,6 +1,14 @@
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||||
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
96
main.go
96
main.go
|
|
@ -3,41 +3,61 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/zemenawi/zutils/cmd"
|
"github.com/zemenawi/zutils/cmd"
|
||||||
|
"github.com/zemenawi/zutils/pkg/colors"
|
||||||
"github.com/zemenawi/zutils/pkg/types"
|
"github.com/zemenawi/zutils/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
registry := types.NewCommandRegistry()
|
registry := types.NewCommandRegistry()
|
||||||
|
|
||||||
// Register all commands
|
|
||||||
registry.Register(&types.Command{
|
registry.Register(&types.Command{
|
||||||
Name: "read",
|
Name: "read",
|
||||||
Description: "Read and display file with syntax highlighting",
|
Description: "Read and display file with smart detection",
|
||||||
Handler: cmd.ReadCommand,
|
Handler: cmd.ReadCommand,
|
||||||
})
|
})
|
||||||
|
|
||||||
registry.Register(&types.Command{
|
registry.Register(&types.Command{
|
||||||
Name: "md",
|
Name: "sys",
|
||||||
Description: "Render markdown file to terminal",
|
Description: "Display system information overview",
|
||||||
Handler: cmd.MarkdownCommand,
|
Handler: cmd.SystemCommand,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registry.Register(&types.Command{
|
||||||
|
Name: "find",
|
||||||
|
Description: "Find files with pattern and filter support",
|
||||||
|
Handler: cmd.FindCommand,
|
||||||
|
})
|
||||||
|
|
||||||
|
registry.Register(&types.Command{
|
||||||
|
Name: "search",
|
||||||
|
Description: "Search for text pattern in files",
|
||||||
|
Handler: cmd.SearchCommand,
|
||||||
|
})
|
||||||
|
|
||||||
|
registry.Register(&types.Command{
|
||||||
|
Name: "usage",
|
||||||
|
Description: "Show resource usage for a process",
|
||||||
|
Handler: cmd.UsageCommand,
|
||||||
|
})
|
||||||
|
|
||||||
cmd.RegisterInfoCommands(registry)
|
cmd.RegisterInfoCommands(registry)
|
||||||
|
|
||||||
registry.Register(&types.Command{
|
registry.Register(&types.Command{
|
||||||
Name: "version",
|
Name: "version",
|
||||||
Description: "Show version information",
|
Description: "Show version information",
|
||||||
Handler: cmd.VersionCommand,
|
Handler: cmd.VersionCommand,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
args := os.Args[1:]
|
args := os.Args[1:]
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
printUsage(registry)
|
printUsage(registry)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle legacy 'info' subcommand pattern for backwards compatibility
|
|
||||||
if args[0] == "info" {
|
if args[0] == "info" {
|
||||||
infoCmd, exists := registry.Get("info")
|
infoCmd, exists := registry.Get("info")
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -46,48 +66,62 @@ func main() {
|
||||||
}
|
}
|
||||||
err := infoCmd.Handler(args[1:])
|
err := infoCmd.Handler(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error: %v\n", err)
|
cmd.PrintErrorAndHelp("info", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct command: z info <args>
|
|
||||||
commandName := args[0]
|
commandName := args[0]
|
||||||
cmdArgs := args[1:]
|
cmdArgs := args[1:]
|
||||||
|
|
||||||
if command, exists := registry.Get(commandName); exists {
|
if command, exists := registry.Get(commandName); exists {
|
||||||
err := command.Handler(cmdArgs)
|
err := command.Handler(cmdArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error: %v\n", err)
|
cmd.PrintErrorAndHelp(commandName, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no command matches, try auto-detect as info command
|
fmt.Printf("\n %sCommand not found: %s%s\n\n", colors.Red, args[0], colors.Reset)
|
||||||
err := cmd.InfoCommand(args)
|
fmt.Printf(" Did you mean one of these?\n\n")
|
||||||
if err != nil {
|
suggestions := findSimilarCommands(args[0], registry.GetAll())
|
||||||
fmt.Printf("Error: %v\n", err)
|
for _, s := range suggestions {
|
||||||
printUsage(registry)
|
fmt.Printf(" %s%s%s - %s\n", colors.Yellow, s.Name, colors.Reset, s.Description)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("\n")
|
||||||
|
printUsage(registry)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printUsage(registry *types.CommandRegistry) {
|
func printUsage(registry *types.CommandRegistry) {
|
||||||
fmt.Println("z - User-friendly terminal utilities")
|
fmt.Printf("\n %s%sz%s - User-friendly terminal utilities\n\n", colors.Bold, colors.Cyan, colors.Reset)
|
||||||
fmt.Println()
|
fmt.Printf(" %sUsage:%s z <command> [arguments]\n\n", colors.Bold, colors.Reset)
|
||||||
fmt.Println("Usage: z <command> [arguments]")
|
fmt.Printf(" %sAvailable Commands:%s\n", colors.Bold, colors.Reset)
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Commands:")
|
|
||||||
for _, command := range registry.GetAll() {
|
for _, command := range registry.GetAll() {
|
||||||
fmt.Printf(" %-12s %s\n", command.Name, command.Description)
|
fmt.Printf(" %s%-10s%s %s\n", colors.Green, command.Name, colors.Reset, command.Description)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Quick Examples:")
|
fmt.Printf("\n %sQuick Examples:%s\n", colors.Bold, colors.Reset)
|
||||||
fmt.Println(" z read go.mod # Display file with line numbers")
|
fmt.Printf(" %sz read README.md%s # Display file with smart formatting\n", colors.Gray, colors.Reset)
|
||||||
fmt.Println(" z info main.go # File information")
|
fmt.Printf(" %sz sys%s # Show system overview\n", colors.Gray, colors.Reset)
|
||||||
fmt.Println(" z info . # Directory information")
|
fmt.Printf(" %sz find . -name '*.go'%s # Find files\n", colors.Gray, colors.Reset)
|
||||||
fmt.Println(" z info network # Network information")
|
fmt.Printf(" %sz search pattern path%s # Search in files\n", colors.Gray, colors.Reset)
|
||||||
fmt.Println(" z main.go # Auto-detect file info")
|
fmt.Printf(" %sz info .%s # Directory info with gitignore support\n", colors.Gray, colors.Reset)
|
||||||
fmt.Println()
|
fmt.Printf(" %sz info main.go%s # File information\n", colors.Gray, colors.Reset)
|
||||||
|
fmt.Printf("\n Type %sz <command>%s without arguments to see help\n\n", colors.Gray, colors.Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSimilarCommands(input string, commands []*types.Command) []*types.Command {
|
||||||
|
var suggestions []*types.Command
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if strings.HasPrefix(cmd.Name, input[:1]) {
|
||||||
|
suggestions = append(suggestions, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(suggestions) > 3 {
|
||||||
|
suggestions = suggestions[:3]
|
||||||
|
}
|
||||||
|
return suggestions
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,85 @@
|
||||||
package formatter
|
package formatter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alecthomas/chroma/v2/formatters"
|
||||||
|
"github.com/alecthomas/chroma/v2/lexers"
|
||||||
|
"github.com/alecthomas/chroma/v2/styles"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type FileHandler func(args []string) error
|
||||||
|
|
||||||
|
type FileHandlerRegistry struct {
|
||||||
|
handlers map[string]FileHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
var ReadHandlers = &FileHandlerRegistry{
|
||||||
|
handlers: make(map[string]FileHandler),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FileHandlerRegistry) Register(ext string, handler FileHandler) {
|
||||||
|
r.handlers[ext] = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FileHandlerRegistry) GetHandler(filename string) FileHandler {
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
|
||||||
|
if ext == "" || ext == "." {
|
||||||
|
basename := filepath.Base(filename)
|
||||||
|
if strings.HasPrefix(basename, "Dockerfile") {
|
||||||
|
return r.handlers[".dockerfile"]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if handler, ok := r.handlers[ext]; ok {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFileWithHighlight(file *os.File, filePath string) error {
|
||||||
|
content := ReadFileContent(file)
|
||||||
|
|
||||||
|
lexer := lexers.Match(filepath.Base(filePath))
|
||||||
|
if lexer == nil {
|
||||||
|
lexer = lexers.Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
iterator, err := lexer.Tokenise(nil, content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error tokenizing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter_ := formatters.TTY256
|
||||||
|
style := styles.Get("monokai")
|
||||||
|
if style == nil {
|
||||||
|
style = styles.Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = formatter_.Format(&buf, style, iterator)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error formatting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(buf.String(), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if line != "" {
|
||||||
|
fmt.Printf("%s%4d: %s%s\n", "\033[90m", i+1, "\033[0m", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// FormatSize converts bytes to human-readable format
|
// FormatSize converts bytes to human-readable format
|
||||||
func FormatSize(bytes int64) string {
|
func FormatSize(bytes int64) string {
|
||||||
if bytes == 0 {
|
if bytes == 0 {
|
||||||
|
|
@ -29,11 +103,11 @@ func CenterText(text string, width int) string {
|
||||||
if len(text) >= width {
|
if len(text) >= width {
|
||||||
return text[:width]
|
return text[:width]
|
||||||
}
|
}
|
||||||
|
|
||||||
padding := width - len(text)
|
padding := width - len(text)
|
||||||
left := padding / 2
|
left := padding / 2
|
||||||
right := padding - left
|
right := padding - left
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s%s", repeat(" ", left), text, repeat(" ", right))
|
return fmt.Sprintf("%s%s%s", repeat(" ", left), text, repeat(" ", right))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,30 @@
|
||||||
package types
|
package types
|
||||||
|
|
||||||
// Command represents a CLI command
|
|
||||||
type Command struct {
|
type Command struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
Handler func(args []string) error
|
Handler func(args []string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommandRegistry manages available commands
|
|
||||||
type CommandRegistry struct {
|
type CommandRegistry struct {
|
||||||
commands map[string]*Command
|
commands map[string]*Command
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommandRegistry creates a new command registry
|
|
||||||
func NewCommandRegistry() *CommandRegistry {
|
func NewCommandRegistry() *CommandRegistry {
|
||||||
return &CommandRegistry{
|
return &CommandRegistry{
|
||||||
commands: make(map[string]*Command),
|
commands: make(map[string]*Command),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register adds a command to the registry
|
|
||||||
func (r *CommandRegistry) Register(cmd *Command) {
|
func (r *CommandRegistry) Register(cmd *Command) {
|
||||||
r.commands[cmd.Name] = cmd
|
r.commands[cmd.Name] = cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a command by name
|
|
||||||
func (r *CommandRegistry) Get(name string) (*Command, bool) {
|
func (r *CommandRegistry) Get(name string) (*Command, bool) {
|
||||||
cmd, exists := r.commands[name]
|
cmd, exists := r.commands[name]
|
||||||
return cmd, exists
|
return cmd, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll returns all registered commands
|
|
||||||
func (r *CommandRegistry) GetAll() []*Command {
|
func (r *CommandRegistry) GetAll() []*Command {
|
||||||
cmds := make([]*Command, 0, len(r.commands))
|
cmds := make([]*Command, 0, len(r.commands))
|
||||||
for _, cmd := range r.commands {
|
for _, cmd := range r.commands {
|
||||||
|
|
@ -39,7 +33,6 @@ func (r *CommandRegistry) GetAll() []*Command {
|
||||||
return cmds
|
return cmds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has checks if a command exists
|
|
||||||
func (r *CommandRegistry) Has(name string) bool {
|
func (r *CommandRegistry) Has(name string) bool {
|
||||||
_, exists := r.commands[name]
|
_, exists := r.commands[name]
|
||||||
return exists
|
return exists
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue