307 lines
7.9 KiB
Go
307 lines
7.9 KiB
Go
package app
|
|
|
|
import "github.com/hjbdev/patterm/internal/scratchpad"
|
|
|
|
// navEntry is one row in the unified sidebar navigation list. Exactly
|
|
// one of childID or pad is set. childID points at a Child by ID; pad
|
|
// names a scratchpad entry. Empty zero-value means "no target".
|
|
type navEntry struct {
|
|
childID string
|
|
pad string
|
|
}
|
|
|
|
func (n navEntry) empty() bool { return n.childID == "" && n.pad == "" }
|
|
func (n navEntry) isPad() bool { return n.pad != "" }
|
|
func (n navEntry) isChild() bool { return n.childID != "" }
|
|
|
|
// visibleAgentTree returns the running entries under the active agent
|
|
// tab (root agent + its sub-agents). With the new Processes pane,
|
|
// command processes live in their own section and never show up here —
|
|
// the agent tree is for KindAgent (and KindTerminal sub-entries) only.
|
|
func visibleAgentTree(children []*Child, activeAgentID string) []*Child {
|
|
if activeAgentID == "" {
|
|
return nil
|
|
}
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.Status() != StatusRunning {
|
|
continue
|
|
}
|
|
if c.Kind == KindCommand && c.ParentID == "" {
|
|
continue
|
|
}
|
|
if c.ID == activeAgentID || c.ParentID == activeAgentID {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// visibleSessionTree is retained for the test suite and any pre-Processes
|
|
// callers — it returns the active agent's tree given the focused id,
|
|
// resolving the active agent root from focus the same way the previous
|
|
// implementation did.
|
|
func visibleSessionTree(children []*Child, focusID string) []*Child {
|
|
rootID := activeRootID(children, focusID)
|
|
if rootID == "" {
|
|
return nil
|
|
}
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.Status() != StatusRunning {
|
|
continue
|
|
}
|
|
if c.ID == rootID || c.ParentID == rootID {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// activeRootID resolves the agent root the user is "inside" right now.
|
|
// If focus is on a sub-agent, it walks up. If focus is on a top-level
|
|
// process (KindCommand), it falls through to the first running agent
|
|
// root so the agent tree section keeps showing something coherent.
|
|
func activeRootID(children []*Child, focusID string) string {
|
|
if focusID != "" {
|
|
for _, c := range children {
|
|
if c.ID != focusID {
|
|
continue
|
|
}
|
|
if c.Kind == KindCommand && c.ParentID == "" {
|
|
break
|
|
}
|
|
if c.ParentID == "" {
|
|
return c.ID
|
|
}
|
|
if parent := findChildInSnapshot(children, c.ParentID); parent != nil {
|
|
return parent.ID
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
return firstRunningAgentID(children)
|
|
}
|
|
|
|
func firstRunningAgentID(children []*Child) string {
|
|
for _, c := range children {
|
|
if c.Kind != KindAgent {
|
|
continue
|
|
}
|
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
|
return c.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// processList returns every top-level command/terminal entry in spawn
|
|
// order, regardless of running state. The Processes sidebar section
|
|
// keeps showing exited entries so the user can see what just died (and
|
|
// because Session retains KindCommand entries for restart).
|
|
func processList(children []*Child) []*Child {
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.ParentID != "" {
|
|
continue
|
|
}
|
|
if c.Kind == KindCommand || c.Kind == KindTerminal {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findChildInSnapshot(children []*Child, id string) *Child {
|
|
for _, c := range children {
|
|
if c.ID == id {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstRunningTopLevel(children []*Child) *Child {
|
|
for _, c := range children {
|
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runningTopLevels lists every running top-level agent session in the
|
|
// order they appear in the snapshot. Tabs only show agents — command
|
|
// processes live in the Processes sidebar section — so Ctrl+A/D
|
|
// navigation cycles through agent tabs exclusively.
|
|
func runningTopLevels(children []*Child) []*Child {
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.Kind != KindAgent {
|
|
continue
|
|
}
|
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nextTabID returns the id of the top-level session `step` positions
|
|
// away from the current focus in the runningTopLevels list, wrapping
|
|
// at both ends. Returns "" when there's nothing to switch to.
|
|
func nextTabID(children []*Child, focusID string, step int) string {
|
|
roots := runningTopLevels(children)
|
|
if len(roots) == 0 {
|
|
return ""
|
|
}
|
|
rootID := activeRootID(children, focusID)
|
|
idx := -1
|
|
for i, r := range roots {
|
|
if r.ID == rootID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
idx = (idx + step) % len(roots)
|
|
if idx < 0 {
|
|
idx += len(roots)
|
|
}
|
|
if roots[idx].ID == focusID {
|
|
return ""
|
|
}
|
|
return roots[idx].ID
|
|
}
|
|
|
|
// currentTabFlat returns the focused tab's processes (root first, then
|
|
// its running children) in display order. Used to step focus with
|
|
// Ctrl+W/S.
|
|
func currentTabFlat(children []*Child, focusID string) []*Child {
|
|
rootID := activeRootID(children, focusID)
|
|
if rootID == "" {
|
|
return nil
|
|
}
|
|
out := make([]*Child, 0, 4)
|
|
for _, c := range children {
|
|
if c.ID == rootID && c.Status() == StatusRunning {
|
|
out = append(out, c)
|
|
break
|
|
}
|
|
}
|
|
for _, c := range children {
|
|
if c.ParentID == rootID && c.Status() == StatusRunning {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sidebarNavList combines the Processes section and the active Agent
|
|
// Tree into one flat list — top-to-bottom matching what the user sees
|
|
// in the sidebar. Ctrl+W/S walks this list so the user can step out of
|
|
// the agent tree, into the Processes section, and back.
|
|
func sidebarNavList(children []*Child, activeAgentID string) []*Child {
|
|
out := make([]*Child, 0, 8)
|
|
for _, c := range processList(children) {
|
|
out = append(out, c)
|
|
}
|
|
for _, c := range visibleAgentTree(children, activeAgentID) {
|
|
out = append(out, c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sidebarNav returns the combined Processes + Agent Tree + Scratchpads
|
|
// navigation list. Scratchpads always appear after children so the
|
|
// existing "step past the tree" expectation still holds.
|
|
func sidebarNav(children []*Child, activeAgentID string, pads []scratchpad.Entry) []navEntry {
|
|
flat := sidebarNavList(children, activeAgentID)
|
|
out := make([]navEntry, 0, len(flat)+len(pads))
|
|
for _, c := range flat {
|
|
out = append(out, navEntry{childID: c.ID})
|
|
}
|
|
for _, p := range pads {
|
|
out = append(out, navEntry{pad: p.Name})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nextNavEntry returns the entry `step` positions away from the
|
|
// current focus in the unified nav list. Either focusChildID or
|
|
// focusPad will be set (or both empty for "nothing focused yet").
|
|
// Empty when there's nothing else to land on.
|
|
func nextNavEntry(children []*Child, focusChildID, focusPad, activeAgentID string, pads []scratchpad.Entry, step int) navEntry {
|
|
flat := sidebarNav(children, activeAgentID, pads)
|
|
if len(flat) == 0 {
|
|
return navEntry{}
|
|
}
|
|
matches := func(e navEntry) bool {
|
|
if focusPad != "" && e.pad != "" {
|
|
return e.pad == focusPad
|
|
}
|
|
if focusChildID != "" && e.childID != "" {
|
|
return e.childID == focusChildID
|
|
}
|
|
return false
|
|
}
|
|
if len(flat) == 1 {
|
|
if matches(flat[0]) {
|
|
return navEntry{}
|
|
}
|
|
return flat[0]
|
|
}
|
|
idx := -1
|
|
for i, e := range flat {
|
|
if matches(e) {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
idx = (idx + step) % len(flat)
|
|
if idx < 0 {
|
|
idx += len(flat)
|
|
}
|
|
if matches(flat[idx]) {
|
|
return navEntry{}
|
|
}
|
|
return flat[idx]
|
|
}
|
|
|
|
// nextChildID is retained for tests; it ignores scratchpads.
|
|
func nextChildID(children []*Child, focusID, activeAgentID string, step int) string {
|
|
flat := sidebarNavList(children, activeAgentID)
|
|
if len(flat) == 0 {
|
|
return ""
|
|
}
|
|
if len(flat) == 1 {
|
|
if flat[0].ID == focusID {
|
|
return ""
|
|
}
|
|
return flat[0].ID
|
|
}
|
|
idx := -1
|
|
for i, c := range flat {
|
|
if c.ID == focusID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
idx = (idx + step) % len(flat)
|
|
if idx < 0 {
|
|
idx += len(flat)
|
|
}
|
|
if flat[idx].ID == focusID {
|
|
return ""
|
|
}
|
|
return flat[idx].ID
|
|
}
|