diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-26 22:59:16 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-26 22:59:16 +0200 |
| commit | dc7478d7dadf544787a9718608f11312bd2ea944 (patch) | |
| tree | dc445798ab132e08d8885672fcca0a37facd25ea /internal/tui/pidpicker | |
| parent | 39a11ed5997a3829751dfbe4b666d3568d466276 (diff) | |
tui: revamp status keys and add pid/tid reselection flow
Diffstat (limited to 'internal/tui/pidpicker')
| -rw-r--r-- | internal/tui/pidpicker/model.go | 96 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model_test.go | 38 | ||||
| -rw-r--r-- | internal/tui/pidpicker/proclist.go | 129 | ||||
| -rw-r--r-- | internal/tui/pidpicker/proclist_test.go | 43 |
4 files changed, 291 insertions, 15 deletions
diff --git a/internal/tui/pidpicker/model.go b/internal/tui/pidpicker/model.go index 37af257..73f21ae 100644 --- a/internal/tui/pidpicker/model.go +++ b/internal/tui/pidpicker/model.go @@ -13,6 +13,14 @@ import ( ) const allPIDsLabel = "All PIDs" +const allTIDsLabel = "All TIDs" + +type PickerMode int + +const ( + PickerModePID PickerMode = iota + PickerModeTID +) // KeyMap defines picker-specific key bindings. type KeyMap struct { @@ -53,6 +61,8 @@ type Model struct { processes []ProcessInfo filtered []ProcessInfo selectedIndex int + mode PickerMode + targetPID int width int height int keys KeyMap @@ -61,11 +71,16 @@ type Model struct { // New creates a PID picker model with default shared key bindings. func New() Model { - return NewWithKeys(DefaultKeyMap()) + return NewPIDWithKeys(DefaultKeyMap()) } // NewWithKeys creates a PID picker model with the provided key bindings. func NewWithKeys(keys KeyMap) Model { + return NewPIDWithKeys(keys) +} + +// NewPIDWithKeys creates a PID picker model with the provided key bindings. +func NewPIDWithKeys(keys KeyMap) Model { input := textinput.New() input.Prompt = "Filter: " input.Placeholder = "pid, comm, or cmdline" @@ -74,15 +89,26 @@ func NewWithKeys(keys KeyMap) Model { input.Width = 40 return Model{ - input: input, - keys: keys, - filtered: []ProcessInfo{}, + input: input, + keys: keys, + filtered: []ProcessInfo{}, + mode: PickerModePID, + targetPID: -1, } } +// NewTIDWithKeys creates a TID picker model scoped to one PID. +func NewTIDWithKeys(targetPID int, keys KeyMap) Model { + m := NewPIDWithKeys(keys) + m.mode = PickerModeTID + m.targetPID = targetPID + m.input.Placeholder = "tid, comm, or cmdline" + return m +} + // Init starts the initial process scan. func (m Model) Init() tea.Cmd { - return scanProcessesCmd + return m.scanCmd() } // Update handles key presses and async process-scan responses. @@ -113,7 +139,7 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Esc): return m, tea.Quit case msg.Type == tea.KeyCtrlR: - return m, scanProcessesCmd + return m, m.scanCmd() case key.Matches(msg, m.keys.Enter): return m, m.emitSelection() case msg.Type == tea.KeyUp: @@ -133,7 +159,7 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyRunes && !m.input.Focused() { if key.Matches(msg, m.keys.Refresh) { - return m, scanProcessesCmd + return m, m.scanCmd() } m.input.Focus() } @@ -145,6 +171,18 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m Model) emitSelection() tea.Cmd { + if m.mode == PickerModeTID { + if m.selectedIndex <= 0 { + return func() tea.Msg { return messages.TidSelectedMsg{Pid: 0, Tid: 0} } + } + idx := m.selectedIndex - 1 + if idx < 0 || idx >= len(m.filtered) { + return func() tea.Msg { return messages.TidSelectedMsg{Pid: 0, Tid: 0} } + } + thread := m.filtered[idx] + return func() tea.Msg { return messages.TidSelectedMsg{Pid: thread.ParentPID, Tid: thread.Pid} } + } + if m.selectedIndex <= 0 { return func() tea.Msg { return messages.PidSelectedMsg{Pid: 0} } } @@ -204,7 +242,15 @@ func cloneProcesses(in []ProcessInfo) []ProcessInfo { // View renders the PID picker with filter input, list, and help bar. func (m Model) View() string { var b strings.Builder - b.WriteString(headerStyle.Render("Select PID")) + if m.mode == PickerModeTID { + if m.targetPID > 0 { + b.WriteString(headerStyle.Render(fmt.Sprintf("Select TID for PID %d", m.targetPID))) + } else { + b.WriteString(headerStyle.Render("Select TID")) + } + } else { + b.WriteString(headerStyle.Render("Select PID")) + } b.WriteString("\n") b.WriteString(m.input.View()) b.WriteString("\n\n") @@ -224,7 +270,11 @@ func (m Model) View() string { func (m Model) renderRows() string { lines := make([]string, 0, len(m.filtered)+1) - lines = append(lines, m.renderRow(0, allPIDsLabel)) + allLabel := allPIDsLabel + if m.mode == PickerModeTID { + allLabel = allTIDsLabel + } + lines = append(lines, m.renderRow(0, allLabel)) for i, process := range m.filtered { label := formatProcess(process) lines = append(lines, m.renderRow(i+1, label)) @@ -284,6 +334,28 @@ func scanProcessesCmd() tea.Msg { } } +func (m Model) scanCmd() tea.Cmd { + if m.mode == PickerModeTID { + if m.targetPID <= 0 { + return func() tea.Msg { + processes, err := ScanAllThreads() + return processesLoadedMsg{ + processes: processes, + err: err, + } + } + } + return func() tea.Msg { + processes, err := ScanThreads(m.targetPID) + return processesLoadedMsg{ + processes: processes, + err: err, + } + } + } + return scanProcessesCmd +} + func clamp(v, min, max int) int { if v < min { return min @@ -295,6 +367,12 @@ func clamp(v, min, max int) int { } func formatProcess(process ProcessInfo) string { + if process.ParentPID > 0 && process.ParentPID != process.Pid { + if process.Cmdline == "" { + return fmt.Sprintf("%d (pid:%d) %s", process.Pid, process.ParentPID, process.Comm) + } + return fmt.Sprintf("%d (pid:%d) %s %s", process.Pid, process.ParentPID, process.Comm, process.Cmdline) + } if process.Cmdline == "" { return fmt.Sprintf("%d %s", process.Pid, process.Comm) } diff --git a/internal/tui/pidpicker/model_test.go b/internal/tui/pidpicker/model_test.go index 7347eca..2d76508 100644 --- a/internal/tui/pidpicker/model_test.go +++ b/internal/tui/pidpicker/model_test.go @@ -63,6 +63,44 @@ func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) { } } +func TestEnterEmitsAllTIDsAndSelectedTIDInTIDMode(t *testing.T) { + m := NewTIDWithKeys(42, DefaultKeyMap()) + m.processes = []ProcessInfo{ + {Pid: 7001, ParentPID: 42, Comm: "main"}, + {Pid: 7002, ParentPID: 42, Comm: "worker"}, + } + m.applyFilter() + + modelAny, cmdAny := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _ = modelAny + msgAny := cmdAny() + tidAny, ok := msgAny.(messages.TidSelectedMsg) + if !ok { + t.Fatalf("expected TidSelectedMsg for all-tids selection, got %T", msgAny) + } + if tidAny.Tid != 0 { + t.Fatalf("expected all-tids to emit tid 0, got %d", tidAny.Tid) + } + if tidAny.Pid != 0 { + t.Fatalf("expected all-tids to emit pid 0, got %d", tidAny.Pid) + } + + m.selectedIndex = 2 + modelOne, cmdOne := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _ = modelOne + msgOne := cmdOne() + tidOne, ok := msgOne.(messages.TidSelectedMsg) + if !ok { + t.Fatalf("expected TidSelectedMsg for concrete selection, got %T", msgOne) + } + if tidOne.Tid != 7002 { + t.Fatalf("expected selected tid 7002, got %d", tidOne.Tid) + } + if tidOne.Pid != 42 { + t.Fatalf("expected selected pid 42, got %d", tidOne.Pid) + } +} + func TestEscQuitsAndRefreshTriggersScan(t *testing.T) { m := NewWithKeys(DefaultKeyMap()) diff --git a/internal/tui/pidpicker/proclist.go b/internal/tui/pidpicker/proclist.go index 63306e9..20e580d 100644 --- a/internal/tui/pidpicker/proclist.go +++ b/internal/tui/pidpicker/proclist.go @@ -9,13 +9,15 @@ import ( "sort" "strconv" "strings" + "sync" ) // ProcessInfo is the metadata shown in the PID picker list. type ProcessInfo struct { - Pid int - Comm string - Cmdline string + Pid int + ParentPID int + Comm string + Cmdline string } // ScanProcesses returns process metadata from /proc. @@ -23,6 +25,16 @@ func ScanProcesses() ([]ProcessInfo, error) { return scanProcessesFrom("/proc") } +// ScanThreads returns thread metadata from /proc/<pid>/task for one process. +func ScanThreads(pid int) ([]ProcessInfo, error) { + return scanThreadsFrom("/proc", pid) +} + +// ScanAllThreads returns thread metadata from /proc/*/task. +func ScanAllThreads() ([]ProcessInfo, error) { + return scanAllThreadsFrom("/proc") +} + func scanProcessesFrom(procRoot string) ([]ProcessInfo, error) { entries, err := os.ReadDir(procRoot) if err != nil { @@ -72,9 +84,10 @@ func readProcessInfo(procRoot string, entry fs.DirEntry) (ProcessInfo, bool) { } return ProcessInfo{ - Pid: pid, - Comm: comm, - Cmdline: normalizeCmdline(cmdlineData), + Pid: pid, + ParentPID: pid, + Comm: comm, + Cmdline: normalizeCmdline(cmdlineData), }, true } @@ -112,3 +125,107 @@ func normalizeCmdline(raw []byte) string { } return strings.Join(out, " ") } + +func scanThreadsFrom(procRoot string, pid int) ([]ProcessInfo, error) { + taskRoot := filepath.Join(procRoot, strconv.Itoa(pid), "task") + entries, err := os.ReadDir(taskRoot) + if err != nil { + return nil, fmt.Errorf("read task root %q: %w", taskRoot, err) + } + + cmdlineData, _ := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "cmdline")) + cmdline := normalizeCmdline(cmdlineData) + + threads := make([]ProcessInfo, 0, len(entries)) + for _, entry := range entries { + thread, ok := readThreadInfo(taskRoot, entry, cmdline) + if !ok { + continue + } + threads = append(threads, thread) + } + + sort.Slice(threads, func(i, j int) bool { + return threads[i].Pid < threads[j].Pid + }) + return threads, nil +} + +func readThreadInfo(taskRoot string, entry fs.DirEntry, cmdline string) (ProcessInfo, bool) { + if !entry.IsDir() { + return ProcessInfo{}, false + } + + tid, err := strconv.Atoi(entry.Name()) + if err != nil { + return ProcessInfo{}, false + } + + commPath := filepath.Join(taskRoot, entry.Name(), "comm") + commData, err := os.ReadFile(commPath) + if err != nil { + return ProcessInfo{}, false + } + comm := strings.TrimSpace(string(commData)) + if comm == "" { + return ProcessInfo{}, false + } + + return ProcessInfo{ + Pid: tid, + ParentPID: extractPIDFromPath(taskRoot), + Comm: comm, + Cmdline: cmdline, + }, true +} + +func scanAllThreadsFrom(procRoot string) ([]ProcessInfo, error) { + processes, err := scanProcessesFrom(procRoot) + if err != nil { + return nil, err + } + + threads := make([]ProcessInfo, 0, len(processes)*8) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, 16) + + for _, p := range processes { + pid := p.Pid + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + perProc, err := scanThreadsFrom(procRoot, pid) + if err != nil { + return + } + mu.Lock() + threads = append(threads, perProc...) + mu.Unlock() + }() + } + wg.Wait() + + sort.Slice(threads, func(i, j int) bool { + if threads[i].Pid == threads[j].Pid { + return threads[i].ParentPID < threads[j].ParentPID + } + return threads[i].Pid < threads[j].Pid + }) + return threads, nil +} + +func extractPIDFromPath(taskRoot string) int { + parts := strings.Split(filepath.Clean(taskRoot), string(os.PathSeparator)) + if len(parts) < 2 { + return -1 + } + pid, err := strconv.Atoi(parts[len(parts)-2]) + if err != nil { + return -1 + } + return pid +} diff --git a/internal/tui/pidpicker/proclist_test.go b/internal/tui/pidpicker/proclist_test.go index 060b3b0..d5f6cfd 100644 --- a/internal/tui/pidpicker/proclist_test.go +++ b/internal/tui/pidpicker/proclist_test.go @@ -25,6 +25,9 @@ func TestScanProcessesFrom(t *testing.T) { if processes[0].Pid != 12 || processes[0].Comm != "bash" || processes[0].Cmdline != "bash -l" { t.Fatalf("unexpected first process: %+v", processes[0]) } + if processes[0].ParentPID != 12 { + t.Fatalf("expected first process parent pid=12, got %d", processes[0].ParentPID) + } if processes[1].Pid != 99 || processes[1].Comm != "sshd" || processes[1].Cmdline != "" { t.Fatalf("unexpected second process: %+v", processes[1]) } @@ -56,6 +59,46 @@ func TestNormalizeCmdline(t *testing.T) { } } +func TestScanThreadsFrom(t *testing.T) { + root := t.TempDir() + procDir := filepath.Join(root, "12") + if err := os.MkdirAll(filepath.Join(procDir, "task", "12"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.MkdirAll(filepath.Join(procDir, "task", "120"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(procDir, "cmdline"), []byte("bash\x00-l\x00"), 0o644); err != nil { + t.Fatalf("write cmdline: %v", err) + } + if err := os.WriteFile(filepath.Join(procDir, "task", "12", "comm"), []byte("bash-main\n"), 0o644); err != nil { + t.Fatalf("write comm: %v", err) + } + if err := os.WriteFile(filepath.Join(procDir, "task", "120", "comm"), []byte("bash-worker\n"), 0o644); err != nil { + t.Fatalf("write comm: %v", err) + } + + threads, err := scanThreadsFrom(root, 12) + if err != nil { + t.Fatalf("scanThreadsFrom returned error: %v", err) + } + if len(threads) != 2 { + t.Fatalf("expected 2 threads, got %d", len(threads)) + } + if threads[0].Pid != 12 || threads[0].Comm != "bash-main" || threads[0].Cmdline != "bash -l" { + t.Fatalf("unexpected first thread: %+v", threads[0]) + } + if threads[0].ParentPID != 12 { + t.Fatalf("unexpected first thread parent pid: %d", threads[0].ParentPID) + } + if threads[1].Pid != 120 || threads[1].Comm != "bash-worker" || threads[1].Cmdline != "bash -l" { + t.Fatalf("unexpected second thread: %+v", threads[1]) + } + if threads[1].ParentPID != 12 { + t.Fatalf("unexpected second thread parent pid: %d", threads[1].ParentPID) + } +} + func mkProc(t *testing.T, root, pid, stat, cmdline string) { t.Helper() dir := filepath.Join(root, pid) |
