diff --git a/.gitignore b/.gitignore index ee50554..db033e8 100644 --- a/.gitignore +++ b/.gitignore @@ -140,9 +140,8 @@ cython_debug/ *.swp *.swo -linutil # Go -bleach +/bleach *.exe *.test *.out diff --git a/cmd/bleach/main.go b/cmd/bleach/main.go index 172bf5f..411b905 100644 --- a/cmd/bleach/main.go +++ b/cmd/bleach/main.go @@ -1,42 +1,54 @@ package main import ( + "bufio" "fmt" + "io" "os" + "os/exec" + "strings" "bleach/internal/tui/dashboard" "bleach/internal/tui/menu" "bleach/internal/ops" "bleach/internal/tui/styles" - + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -type appsState int +const Version = "v1.0.1" + +type appState int const ( - stateDashboard appsState = iota - stateRunning + stateIdle appState = iota + stateAuth + stateStreaming ) -const Version = "v1.0.0" +type lineMsg string +type streamDoneMsg struct{ err error } +type authResultMsg struct{ err error } + +var activeScanner *bufio.Scanner type model struct { - width int - height int - state appsState + width, height int + state appState dashboard dashboard.Model menu menu.Model - outputLog string + logs []string + pendingOp *exec.Cmd } func initialModel() model { return model{ dashboard: dashboard.NewModel(), menu: menu.NewModel(), - outputLog: fmt.Sprintf("Ready (v%s). Select an action.", Version), + state: stateIdle, + logs: []string{fmt.Sprintf("Ready (%s). Select an action.", Version)}, } } @@ -44,123 +56,173 @@ func (m model) Init() tea.Cmd { return tea.Batch(m.dashboard.Init(), m.menu.Init()) } +func nextLine() tea.Cmd { + return func() tea.Msg { + if activeScanner != nil && activeScanner.Scan() { + return lineMsg(activeScanner.Text()) + } + return nil + } +} + +func waitCmd(cmd *exec.Cmd) tea.Cmd { + return func() tea.Msg { + err := cmd.Wait() + return streamDoneMsg{err} + } +} + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case tea.KeyMsg: - if msg.String() == "q" || msg.String() == "ctrl+c" { - return m, tea.Quit + if m.state == stateStreaming { + if msg.String() == "ctrl+c" { return m, tea.Quit } + return m, nil } - // If running, ignore keys or allow cancel? - if m.state == stateRunning { - return m, nil + switch msg.String() { + case "q", "ctrl+c": return m, tea.Quit + case "enter": + if m.menu.Selected != nil { + return m.handleMenuSelect(m.menu.Selected.Title) + } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + var cmd tea.Cmd + m.dashboard, cmd = m.dashboard.Update(msg) + cmds = append(cmds, cmd) + m.menu.Width = (m.width / 2) - 4 + m.menu, cmd = m.menu.Update(msg) + cmds = append(cmds, cmd) + + // Auth Finished + case authResultMsg: + if msg.err != nil { + m.logs = append(m.logs, "Authentication failed.") + m.state = stateIdle + m.pendingOp = nil + return m, nil + } - // Resize Dashboard - var dCmd tea.Cmd - m.dashboard, dCmd = m.dashboard.Update(msg) - cmds = append(cmds, dCmd) + m.logs = []string{"Authentication successful.", "Starting operation..."} + m.state = stateStreaming - // Resize Menu (Bottom Left) - // Logic: Menu gets half width? or full width bottom? - // Design said: Bottom Left = Menu, Bottom Right = Output. - // Let's pass half width to menu. - halfWidth := (m.width / 2) - 4 - m.menu.Width = halfWidth - m.menu, _ = m.menu.Update(msg) // just to update width + cmd := m.pendingOp + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() - case ops.OpResultMsg: - m.state = stateDashboard - if msg.Err != nil { - m.outputLog = fmt.Sprintf("Error: %v\n%s", msg.Err, msg.Output) + if err := cmd.Start(); err != nil { + m.logs = append(m.logs, fmt.Sprintf("Error: %v", err)) + m.state = stateIdle + return m, nil + } + + activeScanner = bufio.NewScanner(io.MultiReader(stdout, stderr)) + return m, tea.Batch(nextLine(), waitCmd(cmd)) + + // Stream Line + case lineMsg: + m.logs = append(m.logs, string(msg)) + maxLines := 20 + if len(m.logs) > maxLines { + m.logs = m.logs[len(m.logs)-maxLines:] + } + return m, nextLine() + + // Stream Done + case streamDoneMsg: + activeScanner = nil + m.state = stateIdle + if msg.err != nil { + m.logs = append(m.logs, fmt.Sprintf("Failed: %v", msg.err)) } else { - m.outputLog = fmt.Sprintf("Success:\n%s", msg.Output) + m.logs = append(m.logs, "Done.") } - // Clear selection - m.menu.Selected = nil + return m, nil } - - // Route messages to components - // Dashboard always updates (ticks) - var dCmd tea.Cmd - m.dashboard, dCmd = m.dashboard.Update(msg) - cmds = append(cmds, dCmd) - - // Menu updates only if not running - if m.state == stateDashboard { - newMenu, mCmd := m.menu.Update(msg) - m.menu = newMenu - cmds = append(cmds, mCmd) - - // Check selection - if m.menu.Selected != nil { - // Trigger Action - switch m.menu.Selected.Title { - case "Exit": - return m, tea.Quit - case "System Cleanup": - m.state = stateRunning - m.outputLog = "Running System Cleanup... (Password may be required in terminal if not cached)" - cmds = append(cmds, ops.RunCleanupCmd()) - case "System Updates": - m.state = stateRunning - m.outputLog = "Running System Updates..." - cmds = append(cmds, ops.RunUpdateCmd()) - case "Maintenance": - m.state = stateRunning - m.outputLog = "Running Maintenance..." - cmds = append(cmds, ops.RunMaintenanceCmd()) - case "View Logs": - m.outputLog = "Log viewing not implemented yet." - m.menu.Selected = nil - } - } + + // Update Components + if m.state == stateIdle { + var cmd tea.Cmd + m.dashboard, cmd = m.dashboard.Update(msg) + cmds = append(cmds, cmd) + m.menu, cmd = m.menu.Update(msg) + cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) } -func (m model) View() string { - if m.width == 0 { - return "Initializing..." +func (m model) handleMenuSelect(title string) (tea.Model, tea.Cmd) { + var op *exec.Cmd + switch title { + case "Exit": return m, tea.Quit + case "View Logs": + m.logs = append(m.logs, "Log viewing not implemented yet.") + return m, nil + case "Quick Clean (System)": op = ops.CmdDeepSystemClean() + case "Deep Clean (Dev + Sys)": + op = ops.CmdFullClean() + case "System Updates": op = ops.CmdUpdateSystem() + case "Maintenance": op = ops.CmdMaintenance() } + + if op != nil { + m.pendingOp = op + m.state = stateAuth + return m, tea.ExecProcess(ops.CmdCheckSudo(), func(err error) tea.Msg { + return authResultMsg{err} + }) + } + return m, nil +} + +func (m model) View() string { + if m.width == 0 { return "Loading..." } - // 1. Dashboard (Top) + m.dashboard.Width = m.width topView := m.dashboard.View() + topHeight := lipgloss.Height(topView) - // 2. Bottom Area - // Left: Menu - menuView := m.menu.View() + mainHeight := m.height - topHeight - 3 + if mainHeight < 5 { mainHeight = 5 } - // Right: Output Log - // Calculate width for right box - rWidth := m.width - m.menu.Width - 6 // margin - outputView := styles.Panel.Width(rWidth).Height(6).Render( + sidebarWidth := 28 + m.menu.Width = sidebarWidth - 4 + + // Ensure menu doesn't blow up vertical space + menuContent := m.menu.View() // just lines + + menuView := styles.Panel.Width(sidebarWidth - 2).Height(mainHeight - 2).Render( + lipgloss.JoinVertical(lipgloss.Left, + styles.Label.Render("MENU"), + menuContent, + ), + ) + + logWidth := m.width - sidebarWidth - 4 + if logWidth < 20 { logWidth = 20 } + + logContent := strings.Join(m.logs, "\n") + + statusView := styles.Panel.Width(logWidth).Height(mainHeight - 2).Render( lipgloss.JoinVertical(lipgloss.Left, - styles.Label.Render("OUTPUT"), - styles.Value.Render(truncate(m.outputLog, 200)), // simple truncate + styles.Label.Render("STATUS / LOGS"), + styles.Value.Width(logWidth-2).Render(logContent), ), ) - bottomView := lipgloss.JoinHorizontal(lipgloss.Top, menuView, outputView) + bottomView := lipgloss.JoinHorizontal(lipgloss.Top, menuView, statusView) return lipgloss.JoinVertical(lipgloss.Left, topView, bottomView) } -func truncate(s string, max int) string { - if len(s) > max { - return s[:max] + "..." - } - return s -} - func main() { - // Enable mouse? tea.WithMouseCellMotion() p := tea.NewProgram(initialModel(), tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Printf("Error: %v", err) diff --git a/install.sh b/install.sh index 4a28ef8..95da570 100755 --- a/install.sh +++ b/install.sh @@ -23,8 +23,19 @@ export PATH=$PATH:/usr/local/go/bin if ! command -v go &>/dev/null; then echo "Go not found. Installing Go 1.23.2..." + # Detect Architecture + ARCH=$(uname -m) + case $ARCH in + x86_64) GO_ARCH="amd64" ;; + aarch64) GO_ARCH="arm64" ;; + arm64) GO_ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + esac + + echo "Detected architecture: linux-$GO_ARCH" + # Download Go - curl -L "https://go.dev/dl/go1.23.2.linux-amd64.tar.gz" -o /tmp/go.tar.gz + curl -L "https://go.dev/dl/go1.23.2.linux-$GO_ARCH.tar.gz" -o /tmp/go.tar.gz # Remove old installation if exists rm -rf /usr/local/go diff --git a/internal/ops/ops.go b/internal/ops/ops.go index f6fe207..4916098 100644 --- a/internal/ops/ops.go +++ b/internal/ops/ops.go @@ -2,44 +2,125 @@ package ops import ( "os/exec" - "time" - - tea "github.com/charmbracelet/bubbletea" ) -// OpResultMsg wraps the result of an operation -type OpResultMsg struct { - Output string - Err error -} - -// RunCleanupCmd returns a tea.Cmd that executes the cleanup -func RunCleanupCmd() tea.Cmd { - return func() tea.Msg { - // Simulate work or run real command - // Real: exec.Command("sudo", "apt", "autoremove", "-y").Output() - // For safety in this demo, strict real commands: - - cmd := exec.Command("bash", "-c", "sudo apt-get autoremove -y && sudo apt-get clean") - out, err := cmd.CombinedOutput() - - return OpResultMsg{Output: string(out), Err: err} - } -} - -// RunUpdateCmd -func RunUpdateCmd() tea.Cmd { - return func() tea.Msg { - cmd := exec.Command("bash", "-c", "sudo apt-get update") - out, err := cmd.CombinedOutput() - return OpResultMsg{Output: string(out), Err: err} - } -} - -// Emulate a maintenance task -func RunMaintenanceCmd() tea.Cmd { - return func() tea.Msg { - time.Sleep(2 * time.Second) - return OpResultMsg{Output: "Maintenance routines executed successfully (Logs rotated).", Err: nil} - } +// Msg for completion +type OpDoneMsg struct{ Err error } + +// CmdCheckSudo prompts for sudo password (caches credential) +func CmdCheckSudo() *exec.Cmd { + return exec.Command("sudo", "-v") +} + +// CmdCleanupAPT returns the command structure +func CmdCleanupAPT() *exec.Cmd { + return exec.Command("bash", "-c", "sudo apt-get autoremove -y && sudo apt-get autoclean -y && sudo apt-get clean") +} + +func CmdUpdateSystem() *exec.Cmd { + return exec.Command("bash", "-c", "sudo apt-get update && sudo apt-get upgrade -y") +} + +// CmdMaintenance returns the command structure +func CmdMaintenance() *exec.Cmd { + return exec.Command("bash", "-c", "sudo fstrim -av && sudo journalctl --vacuum-time=3d") +} + +func CmdTrash() *exec.Cmd { + return exec.Command("bash", "-c", "rm -rf ~/.local/share/Trash/*") +} + +func CmdCache() *exec.Cmd { + return exec.Command("bash", "-c", "rm -rf ~/.cache/thumbnails/*") +} + +func CmdFixLocks() *exec.Cmd { + cmd := ` + sudo killall apt apt-get 2>/dev/null || true + sudo rm -f /var/lib/apt/lists/lock + sudo rm -f /var/cache/apt/archives/lock + sudo rm -f /var/lib/dpkg/lock* + sudo dpkg --configure -a + ` + return exec.Command("bash", "-c", cmd) +} + +// --- Developer & Deep Cleanup Ops --- + +func CmdCleanDocker() *exec.Cmd { + return exec.Command("bash", "-c", "docker system prune -f 2>/dev/null || true") +} + +func CmdCleanNode() *exec.Cmd { + return exec.Command("bash", "-c", "npm cache clean --force 2>/dev/null; pnpm store prune 2>/dev/null || true") +} + +// CmdCleanPython removes cache but respects git/safety +func CmdCleanPython() *exec.Cmd { + cmd := ` + rm -rf ~/.cache/pip ~/.cache/pypoetry ~/.cache/virtualenv 2>/dev/null + find "$HOME/projects" "$HOME/code" "$HOME/dev" \ + -path "*/.git" -prune -o \ + -type d -name "__pycache__" \ + -exec rm -rf {} + 2>/dev/null || true + ` + return exec.Command("bash", "-c", cmd) +} + +func CmdCleanIDEs() *exec.Cmd { + cmd := ` + rm -rf ~/.config/Code/Cache ~/.config/Code/CachedData 2>/dev/null + rm -rf ~/.cache/JetBrains 2>/dev/null + ` + return exec.Command("bash", "-c", cmd) +} + +func CmdDeepSystemClean() *exec.Cmd { + // Combines APT, Logs, Snap, Flatpak, Temp, SSD + cmd := ` + echo "Cleaning APT..." + sudo apt-get autoremove -y && sudo apt-get autoclean -y && sudo apt-get clean + + echo "Cleaning System Logs..." + sudo journalctl --vacuum-size=100M + + echo "Cleaning Snap/Flatpak..." + sudo rm -rf /var/lib/snapd/cache/* 2>/dev/null + flatpak uninstall --unused -y 2>/dev/null + + echo "Cleaning Temp & Trimming SSD..." + sudo rm -rf /tmp/* + sudo fstrim -av + ` + return exec.Command("bash", "-c", cmd) +} + +func CmdFullClean() *exec.Cmd { + cmd := ` + # 1. System + sudo apt-get autoremove -y && sudo apt-get autoclean -y + sudo journalctl --vacuum-size=100M + sudo rm -rf /var/lib/snapd/cache/* 2>/dev/null + flatpak uninstall --unused -y 2>/dev/null + sudo rm -rf /tmp/* + sudo fstrim -av + + # 2. Docker + docker system prune -f 2>/dev/null || true + + # 3. Node/JS + npm cache clean --force 2>/dev/null || true + pnpm store prune 2>/dev/null || true + rm -rf ~/node_modules 2>/dev/null + + # 4. Python + rm -rf ~/.cache/pip ~/.cache/pypoetry 2>/dev/null + + # 5. IDEs + rm -rf ~/.config/Code/Cache 2>/dev/null + rm -rf ~/.cache/JetBrains 2>/dev/null + ` + return exec.Command("bash", "-c", cmd) } + + diff --git a/internal/sys/info.go b/internal/sys/info.go index 4db1592..f02f03f 100644 --- a/internal/sys/info.go +++ b/internal/sys/info.go @@ -1,7 +1,10 @@ package sys import ( + "fmt" "os" + "os/exec" + "strings" "time" "github.com/shirou/gopsutil/v3/cpu" @@ -15,6 +18,10 @@ type SystemInfo struct { Kernel string Uptime string Shell string + Distro string + PkgCount string + Procs uint64 + Threads string CPU float64 RAM ResourceUsage Disk ResourceUsage @@ -34,6 +41,10 @@ func GetSystemInfo() SystemInfo { Kernel: h.KernelVersion, Uptime: formatUptime(h.Uptime), Shell: os.Getenv("SHELL"), + Distro: fmt.Sprintf("%s %s", h.Platform, h.PlatformVersion), + PkgCount: getPkgCount(), + Procs: h.Procs, + Threads: getThreadCount(), } // Memory @@ -53,21 +64,34 @@ func GetSystemInfo() SystemInfo { } // CPU - // We use the helper to get non-blocking or cached CPU info.CPU = GetCPUPercent() return info } +func getPkgCount() string { + // Try apt/dpkg first + out, err := exec.Command("sh", "-c", "dpkg -l | grep ^ii | wc -l").Output() + if err == nil { + return strings.TrimSpace(string(out)) + " (dpkg)" + } + // Try rpm + out, err = exec.Command("sh", "-c", "rpm -qa | wc -l").Output() + if err == nil { + return strings.TrimSpace(string(out)) + " (rpm)" + } + return "?" +} + +func getThreadCount() string { + out, err := exec.Command("sh", "-c", "ps -eLf | wc -l").Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + return "?" +} + func GetCPUPercent() float64 { - // We use a short interval. For a TUI loop, 100ms is okay-ish but might block UI slightly. - // ideally we run this in a goroutine/tea.Cmd but for now this is simple. - // Better: Use TotalPercent with 0 interval (instant) if we calculated delta ourselves, - // but gopsutil needs interval for delta. - // Let's rely on the simplified View update loop time diff or just block 100ms. - // actually, tea.Tick handles the scheduling, but this call WILL block the Update loop for 100ms. - // To fix this proper, we'd need a separate Msg for "CPUUpdate". - // For MVP, blocking 100ms every 1s is acceptable. p, _ := cpu.Percent(100*time.Millisecond, false) if len(p) > 0 { return p[0] @@ -77,6 +101,5 @@ func GetCPUPercent() float64 { func formatUptime(seconds uint64) string { d := time.Duration(seconds) * time.Second - // formatting logic... - return d.String() // simplistic + return d.Round(time.Minute).String() } diff --git a/internal/tui/dashboard/dashboard.go b/internal/tui/dashboard/dashboard.go index f6cef0b..daa7ab9 100644 --- a/internal/tui/dashboard/dashboard.go +++ b/internal/tui/dashboard/dashboard.go @@ -60,12 +60,13 @@ func (m Model) View() string { // Use styles.Panel (with borders) // Available width = m.Width - 2 (outer margin/border if any) - halfWidth := (m.Width / 2) - 4 // minus borders/padding + // Safe width reduction to prevent wrapping issues + halfWidth := (m.Width / 2) - 6 // minus borders/padding/safety leftPanel := m.renderLeftPanel(halfWidth) rightPanel := m.renderRightPanel(halfWidth) - if m.Width > 90 { + if m.Width > 105 { // Side-by-Side return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) } @@ -77,16 +78,20 @@ func (m Model) View() string { func (m Model) renderLeftPanel(width int) string { // Logo logo := styles.Logo.Render(` - ____ _each + ____ Bleach | __ )| | ___ __ _ ___| |__ | _ \| |/ _ \/ _` + "`" + ` |/ __| '_ \ | |_) | | __/ (_| | (__| | | | |____/|_|\___|\__,_|\___|_| |_|`) // Info - info := fmt.Sprintf("\n\nHost: %s\nKernel: %s\nUptime: %s\nShell: %s", + info := fmt.Sprintf("\n\nHost: %s\nDistro: %s\nKernel: %s\nPkgs: %s\nProcs: %d\nThreads:%s\nUptime: %s\nShell: %s", m.SysInfo.Hostname, + m.SysInfo.Distro, m.SysInfo.Kernel, + m.SysInfo.PkgCount, + m.SysInfo.Procs, + m.SysInfo.Threads, m.SysInfo.Uptime, m.SysInfo.Shell, ) diff --git a/internal/tui/menu/menu.go b/internal/tui/menu/menu.go index 9e191e9..54d119c 100644 --- a/internal/tui/menu/menu.go +++ b/internal/tui/menu/menu.go @@ -21,8 +21,10 @@ type Model struct { func NewModel() Model { return Model{ Items: []Item{ - {Title: "System Cleanup"}, + {Title: "Quick Clean (System)"}, + {Title: "Deep Clean (Dev + Sys)"}, {Title: "System Updates"}, + {Title: "Fix APT Locks"}, {Title: "Maintenance"}, {Title: "View Logs"}, {Title: "Exit"}, @@ -62,9 +64,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) View() string { var s string - // Title - s += styles.Label.Render(" :: ACTIONS ::") + "\n" - + // Just render items, no extra border/padding for i, item := range m.Items { cursor := " " // 2 spaces title := item.Title @@ -79,5 +79,5 @@ func (m Model) View() string { s += fmt.Sprintf("%s%s\n", cursor, title) } - return styles.Panel.Width(m.Width).Render(s) + return s }