summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--go.mod7
-rw-r--r--go.sum15
-rw-r--r--internal/tui/pidpicker/model.go273
-rw-r--r--internal/tui/pidpicker/model_test.go116
-rw-r--r--internal/tui/pidpicker/proclist.go114
-rw-r--r--internal/tui/pidpicker/proclist_test.go71
6 files changed, 596 insertions, 0 deletions
diff --git a/go.mod b/go.mod
index c290610..9cd5527 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index c59e2fb..b0f37d2 100644
--- a/go.sum
+++ b/go.sum
@@ -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)
+ }
+}