diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-23 23:39:22 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-23 23:39:22 +0200 |
| commit | 570b7b5d9283b9e443e7da25661e9f2098cc2305 (patch) | |
| tree | 22386c756c6d9a588e97af103574f711c98f5bdc | |
| parent | e68102bc79ac26f93959c3d5676bcdca86c94623 (diff) | |
tui: add pid picker model and /proc scanner
| -rw-r--r-- | go.mod | 7 | ||||
| -rw-r--r-- | go.sum | 15 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model.go | 273 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model_test.go | 116 | ||||
| -rw-r--r-- | internal/tui/pidpicker/proclist.go | 114 | ||||
| -rw-r--r-- | internal/tui/pidpicker/proclist_test.go | 71 |
6 files changed, 596 insertions, 0 deletions
@@ -6,11 +6,13 @@ require ( github.com/DataDog/zstd v1.5.7 github.com/aquasecurity/libbpfgo v0.6.0-libbpf-1.3.0.20240111220235-90dbffffbdab github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/magefile/mage v1.15.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect @@ -19,11 +21,16 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect ) @@ -2,10 +2,14 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/aquasecurity/libbpfgo v0.6.0-libbpf-1.3.0.20240111220235-90dbffffbdab h1:w74AraWsnj+AgEOk2uERlLtECCWutMtuwCGCCWzpBBs= github.com/aquasecurity/libbpfgo v0.6.0-libbpf-1.3.0.20240111220235-90dbffffbdab/go.mod h1:0rEApF1YBHGuZ4C8OYI9q5oDBVpgqtRqYATePl9mCDk= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -24,14 +28,22 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -44,8 +56,11 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tui/pidpicker/model.go b/internal/tui/pidpicker/model.go new file mode 100644 index 0000000..4e63429 --- /dev/null +++ b/internal/tui/pidpicker/model.go @@ -0,0 +1,273 @@ +package pidpicker + +import ( + "fmt" + "ior/internal/tui" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const allPIDsLabel = "All PIDs" + +type processesLoadedMsg struct { + processes []ProcessInfo + err error +} + +// Model is the Bubble Tea model for the PID picker screen. +type Model struct { + input textinput.Model + processes []ProcessInfo + filtered []ProcessInfo + selectedIndex int + width int + height int + keys tui.KeyMap + lastErr error +} + +// New creates a PID picker model with default shared key bindings. +func New() Model { + return NewWithKeys(tui.Keys) +} + +// NewWithKeys creates a PID picker model with the provided key bindings. +func NewWithKeys(keys tui.KeyMap) Model { + input := textinput.New() + input.Prompt = "Filter: " + input.Placeholder = "pid, comm, or cmdline" + input.Focus() + input.CharLimit = 0 + input.Width = 40 + + return Model{ + input: input, + keys: keys, + filtered: []ProcessInfo{}, + } +} + +// Init starts the initial process scan. +func (m Model) Init() tea.Cmd { + return scanProcessesCmd +} + +// Update handles key presses and async process-scan responses. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.input.Width = clamp(msg.Width-16, 10, 100) + return m, nil + case processesLoadedMsg: + m.processes = msg.processes + m.lastErr = msg.err + m.applyFilter() + return m, nil + case tea.KeyMsg: + return m.updateKey(msg) + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.applyFilter() + return m, cmd +} + +func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Esc): + return m, tea.Quit + case msg.Type == tea.KeyCtrlR: + return m, scanProcessesCmd + case key.Matches(msg, m.keys.Enter): + return m, m.emitSelection() + case msg.Type == tea.KeyUp: + if m.selectedIndex > 0 { + m.selectedIndex-- + } + m.input.Blur() + return m, nil + case msg.Type == tea.KeyDown: + maxIndex := len(m.filtered) + if m.selectedIndex < maxIndex { + m.selectedIndex++ + } + m.input.Blur() + return m, nil + } + + if msg.Type == tea.KeyRunes && !m.input.Focused() { + if key.Matches(msg, m.keys.Refresh) { + return m, scanProcessesCmd + } + m.input.Focus() + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.applyFilter() + return m, cmd +} + +func (m Model) emitSelection() tea.Cmd { + if m.selectedIndex <= 0 { + return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} } + } + + idx := m.selectedIndex - 1 + if idx < 0 || idx >= len(m.filtered) { + return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} } + } + + pid := m.filtered[idx].Pid + return func() tea.Msg { return tui.PidSelectedMsg{Pid: pid} } +} + +func (m *Model) applyFilter() { + query := strings.TrimSpace(strings.ToLower(m.input.Value())) + if query == "" { + m.filtered = cloneProcesses(m.processes) + if m.selectedIndex > len(m.filtered) { + m.selectedIndex = len(m.filtered) + } + return + } + + filtered := make([]ProcessInfo, 0, len(m.processes)) + for _, process := range m.processes { + if matchesQuery(process, query) { + filtered = append(filtered, process) + } + } + + m.filtered = filtered + if m.selectedIndex > len(m.filtered) { + m.selectedIndex = len(m.filtered) + } +} + +func matchesQuery(process ProcessInfo, query string) bool { + pidStr := fmt.Sprintf("%d", process.Pid) + if strings.Contains(strings.ToLower(pidStr), query) { + return true + } + if strings.Contains(strings.ToLower(process.Comm), query) { + return true + } + return strings.Contains(strings.ToLower(process.Cmdline), query) +} + +func cloneProcesses(in []ProcessInfo) []ProcessInfo { + if len(in) == 0 { + return []ProcessInfo{} + } + out := make([]ProcessInfo, len(in)) + copy(out, in) + return out +} + +// View renders the PID picker with filter input, list, and help bar. +func (m Model) View() string { + var b strings.Builder + b.WriteString(tui.HeaderStyle.Render("Select PID")) + b.WriteString("\n") + b.WriteString(m.input.View()) + b.WriteString("\n\n") + + rows := m.renderRows() + b.WriteString(rows) + + if m.lastErr != nil { + b.WriteString("\n") + b.WriteString(tui.ErrorStyle.Render("scan error: " + m.lastErr.Error())) + } + + b.WriteString("\n") + b.WriteString(tui.HelpBarStyle.Render(renderHelp(m.keys.PickerShortHelp()))) + return tui.ScreenStyle.Render(b.String()) +} + +func (m Model) renderRows() string { + lines := make([]string, 0, len(m.filtered)+1) + lines = append(lines, m.renderRow(0, allPIDsLabel)) + for i, process := range m.filtered { + label := formatProcess(process) + lines = append(lines, m.renderRow(i+1, label)) + } + + maxRows := m.visibleRows() + if maxRows > 0 && len(lines) > maxRows { + start := m.selectedIndex - (maxRows / 2) + if start < 0 { + start = 0 + } + limit := len(lines) - maxRows + if start > limit { + start = limit + } + lines = lines[start : start+maxRows] + } + return strings.Join(lines, "\n") +} + +func (m Model) renderRow(index int, label string) string { + prefix := " " + style := lipgloss.NewStyle() + if index == m.selectedIndex { + prefix = "> " + style = tui.HighlightStyle + } + return style.Render(prefix + label) +} + +func (m Model) visibleRows() int { + if m.height <= 0 { + return 0 + } + const reservedLines = 6 + rows := m.height - reservedLines + if rows < 1 { + return 1 + } + return rows +} + +func renderHelp(bindings []key.Binding) string { + parts := make([]string, 0, len(bindings)) + for _, binding := range bindings { + help := binding.Help() + parts = append(parts, fmt.Sprintf("%s %s", help.Key, help.Desc)) + } + return strings.Join(parts, " • ") +} + +func scanProcessesCmd() tea.Msg { + processes, err := ScanProcesses() + return processesLoadedMsg{ + processes: processes, + err: err, + } +} + +func clamp(v, min, max int) int { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +func formatProcess(process ProcessInfo) string { + if process.Cmdline == "" { + return fmt.Sprintf("%d %s", process.Pid, process.Comm) + } + return fmt.Sprintf("%d %s %s", process.Pid, process.Comm, process.Cmdline) +} diff --git a/internal/tui/pidpicker/model_test.go b/internal/tui/pidpicker/model_test.go new file mode 100644 index 0000000..c8e59af --- /dev/null +++ b/internal/tui/pidpicker/model_test.go @@ -0,0 +1,116 @@ +package pidpicker + +import ( + "ior/internal/tui" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestApplyFilterByPIDCommAndCmdline(t *testing.T) { + m := NewWithKeys(tui.DefaultKeyMap()) + m.processes = []ProcessInfo{ + {Pid: 100, Comm: "bash", Cmdline: "bash -l"}, + {Pid: 200, Comm: "sshd", Cmdline: "/usr/sbin/sshd -D"}, + } + + m.input.SetValue("200") + m.applyFilter() + if len(m.filtered) != 1 || m.filtered[0].Pid != 200 { + t.Fatalf("expected pid filter to keep only 200, got %+v", m.filtered) + } + + m.input.SetValue("BASH") + m.applyFilter() + if len(m.filtered) != 1 || m.filtered[0].Pid != 100 { + t.Fatalf("expected comm filter to keep only 100, got %+v", m.filtered) + } + + m.input.SetValue("/usr/sbin") + m.applyFilter() + if len(m.filtered) != 1 || m.filtered[0].Pid != 200 { + t.Fatalf("expected cmdline filter to keep only 200, got %+v", m.filtered) + } +} + +func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) { + m := NewWithKeys(tui.DefaultKeyMap()) + m.processes = []ProcessInfo{{Pid: 7, Comm: "vim"}, {Pid: 9, Comm: "top"}} + m.applyFilter() + + modelAny, cmdAny := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _ = modelAny + msgAny := cmdAny() + pidAny, ok := msgAny.(tui.PidSelectedMsg) + if !ok { + t.Fatalf("expected PidSelectedMsg for all-pids selection, got %T", msgAny) + } + if pidAny.Pid != 0 { + t.Fatalf("expected all-pids to emit pid 0, got %d", pidAny.Pid) + } + + m.selectedIndex = 2 + modelOne, cmdOne := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _ = modelOne + msgOne := cmdOne() + pidOne, ok := msgOne.(tui.PidSelectedMsg) + if !ok { + t.Fatalf("expected PidSelectedMsg for concrete selection, got %T", msgOne) + } + if pidOne.Pid != 9 { + t.Fatalf("expected selected pid 9, got %d", pidOne.Pid) + } +} + +func TestEscQuitsAndRefreshTriggersScan(t *testing.T) { + m := NewWithKeys(tui.DefaultKeyMap()) + + _, escCmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if escCmd == nil { + t.Fatalf("expected esc to return quit cmd") + } + if msg := escCmd(); msg != (tea.QuitMsg{}) { + t.Fatalf("expected quit msg from esc, got %T", msg) + } + + _, refreshCmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlR}) + if refreshCmd == nil { + t.Fatalf("expected refresh cmd") + } + if _, ok := refreshCmd().(processesLoadedMsg); !ok { + t.Fatalf("expected refresh to emit processesLoadedMsg") + } +} + +func TestRuneRDoesNotTriggerRefreshWhileFilterFocused(t *testing.T) { + m := NewWithKeys(tui.DefaultKeyMap()) + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + if cmd == nil { + t.Fatalf("expected textinput update cmd") + } + + updated := next.(Model) + if got := updated.input.Value(); got != "r" { + t.Fatalf("expected filter input to contain typed r, got %q", got) + } +} + +func TestRenderRowsKeepsSelectionVisible(t *testing.T) { + m := NewWithKeys(tui.DefaultKeyMap()) + m.height = 8 // visible rows == 2 + m.processes = []ProcessInfo{ + {Pid: 1, Comm: "p1"}, + {Pid: 2, Comm: "p2"}, + {Pid: 3, Comm: "p3"}, + {Pid: 4, Comm: "p4"}, + } + m.applyFilter() + m.selectedIndex = 4 + + rows := m.renderRows() + if !strings.Contains(rows, "> 4 p4") { + t.Fatalf("expected selected row to remain visible, got:\n%s", rows) + } +} diff --git a/internal/tui/pidpicker/proclist.go b/internal/tui/pidpicker/proclist.go new file mode 100644 index 0000000..63306e9 --- /dev/null +++ b/internal/tui/pidpicker/proclist.go @@ -0,0 +1,114 @@ +package pidpicker + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// ProcessInfo is the metadata shown in the PID picker list. +type ProcessInfo struct { + Pid int + Comm string + Cmdline string +} + +// ScanProcesses returns process metadata from /proc. +func ScanProcesses() ([]ProcessInfo, error) { + return scanProcessesFrom("/proc") +} + +func scanProcessesFrom(procRoot string) ([]ProcessInfo, error) { + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil, fmt.Errorf("read proc root %q: %w", procRoot, err) + } + + processes := make([]ProcessInfo, 0, len(entries)) + for _, entry := range entries { + process, ok := readProcessInfo(procRoot, entry) + if !ok { + continue + } + processes = append(processes, process) + } + + sort.Slice(processes, func(i, j int) bool { + return processes[i].Pid < processes[j].Pid + }) + return processes, nil +} + +func readProcessInfo(procRoot string, entry fs.DirEntry) (ProcessInfo, bool) { + if !entry.IsDir() { + return ProcessInfo{}, false + } + + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + return ProcessInfo{}, false + } + + statPath := filepath.Join(procRoot, entry.Name(), "stat") + statData, err := os.ReadFile(statPath) + if err != nil { + return ProcessInfo{}, false + } + + comm, err := parseCommFromStat(string(statData)) + if err != nil { + return ProcessInfo{}, false + } + + cmdlinePath := filepath.Join(procRoot, entry.Name(), "cmdline") + cmdlineData, err := os.ReadFile(cmdlinePath) + if err != nil { + cmdlineData = nil + } + + return ProcessInfo{ + Pid: pid, + Comm: comm, + Cmdline: normalizeCmdline(cmdlineData), + }, true +} + +func parseCommFromStat(statLine string) (string, error) { + open := strings.IndexByte(statLine, '(') + close := strings.LastIndexByte(statLine, ')') + if open < 0 || close < 0 || close <= open+1 { + return "", fmt.Errorf("invalid stat line") + } + + comm := statLine[open+1 : close] + if strings.TrimSpace(comm) == "" { + return "", fmt.Errorf("empty comm in stat line") + } + return comm, nil +} + +func normalizeCmdline(raw []byte) string { + if len(raw) == 0 { + return "" + } + + trimmed := bytes.TrimRight(raw, "\x00") + if len(trimmed) == 0 { + return "" + } + + parts := bytes.Split(trimmed, []byte{0}) + out := make([]string, 0, len(parts)) + for _, part := range parts { + if len(part) == 0 { + continue + } + out = append(out, string(part)) + } + return strings.Join(out, " ") +} diff --git a/internal/tui/pidpicker/proclist_test.go b/internal/tui/pidpicker/proclist_test.go new file mode 100644 index 0000000..060b3b0 --- /dev/null +++ b/internal/tui/pidpicker/proclist_test.go @@ -0,0 +1,71 @@ +package pidpicker + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScanProcessesFrom(t *testing.T) { + root := t.TempDir() + + mkProc(t, root, "12", "12 (bash) S 1 1 1 0", "bash\x00-l\x00") + mkProc(t, root, "99", "99 (sshd) S 1 1 1 0", "") + mkProc(t, root, "bad", "bad data", "ignored") + + processes, err := scanProcessesFrom(root) + if err != nil { + t.Fatalf("scanProcessesFrom returned error: %v", err) + } + + if len(processes) != 2 { + t.Fatalf("expected 2 valid processes, got %d", len(processes)) + } + + if processes[0].Pid != 12 || processes[0].Comm != "bash" || processes[0].Cmdline != "bash -l" { + t.Fatalf("unexpected first process: %+v", processes[0]) + } + if processes[1].Pid != 99 || processes[1].Comm != "sshd" || processes[1].Cmdline != "" { + t.Fatalf("unexpected second process: %+v", processes[1]) + } +} + +func TestParseCommFromStatInvalid(t *testing.T) { + cases := []string{ + "", + "100 no-parens", + "100 () S 1 1 1 0", + "100 (unterminated S 1 1 1 0", + } + + for _, tc := range cases { + if _, err := parseCommFromStat(tc); err == nil { + t.Fatalf("expected parseCommFromStat to fail for %q", tc) + } + } +} + +func TestNormalizeCmdline(t *testing.T) { + got := normalizeCmdline([]byte("python\x00main.py\x00")) + if got != "python main.py" { + t.Fatalf("unexpected normalized cmdline: %q", got) + } + + if normalizeCmdline(nil) != "" { + t.Fatalf("expected empty cmdline for nil bytes") + } +} + +func mkProc(t *testing.T, root, pid, stat, cmdline string) { + t.Helper() + dir := filepath.Join(root, pid) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644); err != nil { + t.Fatalf("write stat: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644); err != nil { + t.Fatalf("write cmdline: %v", err) + } +} |
