diff --git a/go.mod b/go.mod index 2d7d8f6..2f15c61 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,34 @@ module github.com/RandomCodeSpace/unified-agent-manager -go 1.24.0 - -toolchain go1.24.7 +go 1.25.8 require ( + github.com/alecthomas/chroma/v2 v2.24.0 + github.com/charmbracelet/bubbles v0.21.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/muesli/termenv v0.16.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + 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/dlclark/regexp2 v1.12.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // 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.16 // 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.36.0 // 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 4789639..9fbc3d9 100644 --- a/go.sum +++ b/go.sum @@ -1,43 +1,60 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk= +github.com/alecthomas/chroma/v2 v2.24.0/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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 v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= 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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +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= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +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/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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 21e3267..44e52fd 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + "github.com/RandomCodeSpace/unified-agent-manager/internal/explore" "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" @@ -59,6 +60,7 @@ func Usage() { fmt.Fprintln(os.Stderr, " uam rm ") fmt.Fprintln(os.Stderr, " uam kill-all stop the private tmux server and all sessions") fmt.Fprintln(os.Stderr, " uam notify-closed (internal: tmux session-closed hook)") + fmt.Fprintln(os.Stderr, " uam explore [dir] open the jailed file explorer (default: current dir)") } // Run executes the CLI using the default Bubble Tea TUI runner. @@ -115,6 +117,8 @@ func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI fun return execAttach(ctx, svc, id, runTUI) case "last": return runLast(ctx, svc, runTUI) + case "explore": + return runExplore(args[1:]) default: return fmt.Errorf("unknown command %q", args[0]) } @@ -177,6 +181,17 @@ func runKillAll(ctx context.Context, kill func(context.Context) error) error { return nil } +// runExplore launches the jailed file-explorer rooted at the given directory +// (default: the current directory). Used both directly and from the tmux +// e split binding. +func runExplore(args []string) error { + dir := "." + if len(args) > 0 && args[0] != "" { + dir = args[0] + } + return explore.Run(dir) +} + func runLast(ctx context.Context, svc *app.Service, runTUI func(context.Context, tea.Model) error) error { // LoadSessions returns the persisted config; the "last" session is the one // with the newest persisted LastSeenAt, not the top sorted row (whose order diff --git a/internal/explore/explore.go b/internal/explore/explore.go new file mode 100644 index 0000000..c17e777 --- /dev/null +++ b/internal/explore/explore.go @@ -0,0 +1,343 @@ +// Package explore provides a borderless, jailed file-explorer with live +// syntax-highlighted preview, intended to run as a Bubble Tea TUI inside a +// tmux split via the `uam explore [dir]` subcommand. +package explore + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// leftPaneWidth is the fixed column width of the file-list pane. +const leftPaneWidth = 32 + +// Package-level lipgloss styles (borderless, minimal). +var ( + styleAccent = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + styleDim = lipgloss.NewStyle().Faint(true) + styleCursor = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")) + styleDir = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) +) + +// dirEntry represents a single filesystem entry in the current directory. +type dirEntry struct { + name string + isDir bool +} + +// Model is the Bubble Tea model for the file explorer. +type Model struct { + root string // absolute jail root — navigation cannot escape this + cwd string // absolute current directory (always root or descendant) + entries []dirEntry + cursor int + viewport viewport.Model + width int + height int + previewName string + previewErr string + color bool +} + +// New constructs a Model jailed to dir (resolved to an absolute path). +func New(dir string) Model { + abs, err := filepath.Abs(dir) + if err != nil { + abs = dir + } + const defaultW, defaultH = 80, 24 + m := Model{ + root: abs, + cwd: abs, + color: colorEnabled(), + width: defaultW, + height: defaultH, + } + _, rightW := paneWidthsFor(defaultW) + m.viewport = viewport.New(rightW, bodyHeightFor(defaultH)-2) + m.loadEntries() + m.preview() + return m +} + +// Run launches the Bubble Tea program in alt-screen mode and blocks until exit. +func Run(dir string) error { + _, err := tea.NewProgram(New(dir), tea.WithAltScreen()).Run() + return err +} + +// Init satisfies tea.Model. No initial command is needed. +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles incoming messages and returns the updated model. +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 + _, rightW := m.paneWidths() + bodyH := m.bodyHeight() + m.viewport.Width = rightW + m.viewport.Height = bodyH - 2 + + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + m.preview() + } + + case "down", "j": + if m.cursor < len(m.entries)-1 { + m.cursor++ + m.preview() + } + + case "enter", "right", "l": + if len(m.entries) == 0 { + break + } + e := m.entries[m.cursor] + if e.isDir { + target := filepath.Join(m.cwd, e.name) + if m.within(target) { + m.cwd = target + m.loadEntries() + m.cursor = 0 + m.preview() + } + } + // file: already previewed on cursor move — no-op on enter + + case "left", "h", "backspace": + if m.cwd != m.root { + m.cwd = filepath.Dir(m.cwd) + m.loadEntries() + m.cursor = 0 + m.preview() + } + // at root: jailed, no-op + + case "pgup", "pgdown", "ctrl+u", "ctrl+d": + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + } + return m, nil +} + +// View renders the borderless 2-pane layout. +func (m Model) View() string { + leftW, rightW := m.paneWidths() + bodyH := m.bodyHeight() + + left := m.renderLeft(leftW, bodyH) + right := m.renderRight(rightW, bodyH) + + body := lipgloss.JoinHorizontal(lipgloss.Top, left, right) + + footer := styleDim.Render("↑↓ move · →/enter open · ←/bksp up · pgup/pgdn scroll · q quit") + // Pad footer to full width to avoid cursor bleed on resize. + footer = lipgloss.NewStyle().Width(m.width).Render(footer) + + return lipgloss.JoinVertical(lipgloss.Left, body, footer) +} + +// renderLeft builds the file-list pane. +func (m Model) renderLeft(w, bodyH int) string { + // Header: relative path from root. + rel, err := filepath.Rel(m.root, m.cwd) + if err != nil || rel == "." { + rel = "./" + } else { + rel = "./" + rel + } + header := styleAccent.Width(w).Render(rel) + divider := styleDim.Width(w).Render(strings.Repeat("─", w)) + + // Visible window of entries centred on cursor. + listH := max(bodyH-2, 1) // header + divider + start, end := m.visibleWindow(listH) + + var rows []string + for i := start; i < end && i < len(m.entries); i++ { + e := m.entries[i] + label := e.name + if e.isDir { + label += "/" + } + // Truncate to fit pane width minus the 2-char cursor prefix. + maxLabel := max(w-2, 1) + if r := []rune(label); len(r) > maxLabel { + label = string(r[:maxLabel-1]) + "…" + } + + var row string + if i == m.cursor { + prefix := styleCursor.Render("▸ ") + text := styleCursor.Render(label) + row = prefix + text + } else { + prefix := " " + if e.isDir { + row = prefix + styleDir.Render(label) + } else { + row = prefix + label + } + } + rows = append(rows, lipgloss.NewStyle().Width(w).Render(row)) + } + + listContent := strings.Join(rows, "\n") + return lipgloss.NewStyle().Width(w).Height(bodyH).Render( + header + "\n" + divider + "\n" + listContent, + ) +} + +// renderRight builds the preview pane. +func (m Model) renderRight(w, bodyH int) string { + name := m.previewName + if name == "" { + name = "(no file)" + } + header := styleAccent.Width(w).Render(name) + divider := styleDim.Width(w).Render(strings.Repeat("─", w)) + + var content string + if m.previewErr != "" { + content = styleDim.Render(m.previewErr) + } else { + content = m.viewport.View() + } + + return lipgloss.NewStyle().Width(w).Height(bodyH).Render( + header + "\n" + divider + "\n" + content, + ) +} + +// loadEntries reads m.cwd, populates m.entries (dirs first, then files, each +// alphabetically, including hidden entries), and clears previewErr on success. +func (m *Model) loadEntries() { + des, err := os.ReadDir(m.cwd) + if err != nil { + m.entries = nil + m.previewErr = fmt.Sprintf("cannot read dir: %v", err) + return + } + m.previewErr = "" + var dirs, files []dirEntry + for _, de := range des { + e := dirEntry{name: de.Name(), isDir: de.IsDir()} + if de.IsDir() { + dirs = append(dirs, e) + } else { + files = append(files, e) + } + } + // Both slices come from os.ReadDir which returns alphabetical order already, + // but sort explicitly to guarantee stability. + sort.Slice(dirs, func(i, j int) bool { return dirs[i].name < dirs[j].name }) + sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name }) + m.entries = append(dirs, files...) +} + +// within reports whether target is inside (or equal to) m.root after resolving +// symlinks, so a symlink that points outside the project can't escape the jail. +// On any resolution failure it denies (returns false) — fail closed. +func (m Model) within(target string) bool { + root, err := filepath.EvalSymlinks(m.root) + if err != nil { + return false + } + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + return false + } + rel, err := filepath.Rel(root, resolved) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// preview updates the viewport with the content of the currently highlighted +// entry. Directories show a placeholder; files are highlighted and line-numbered. +func (m *Model) preview() { + if len(m.entries) == 0 { + m.previewName = "" + m.previewErr = "(empty directory)" + m.viewport.SetContent("") + return + } + e := m.entries[m.cursor] + if e.isDir { + m.previewName = e.name + "/" + m.previewErr = "" + m.viewport.SetContent("(directory)") + m.viewport.GotoTop() + return + } + m.previewName = e.name + fullPath := filepath.Join(m.cwd, e.name) + content, err := readFilePreview(fullPath, m.color) + if err != nil { + m.previewErr = err.Error() + m.viewport.SetContent("") + return + } + m.previewErr = "" + m.viewport.SetContent(withLineNumbers(content)) + m.viewport.GotoTop() +} + +// paneWidthsFor returns (leftWidth, rightWidth) for the given total terminal width. +func paneWidthsFor(total int) (int, int) { + if total <= leftPaneWidth+4 { + half := total / 2 + return half, total - half + } + return leftPaneWidth, total - leftPaneWidth +} + +// bodyHeightFor returns pane body height for the given terminal height. +func bodyHeightFor(total int) int { + return max(total-1, 4) +} + +// paneWidths returns (leftWidth, rightWidth) given the current terminal width. +func (m Model) paneWidths() (int, int) { + return paneWidthsFor(m.width) +} + +// bodyHeight returns the number of rows available for pane content (total +// minus the 1-line footer). +func (m Model) bodyHeight() int { + return bodyHeightFor(m.height) +} + +// visibleWindow returns the [start, end) slice indices that keep cursor visible +// within a window of listH rows. +func (m Model) visibleWindow(listH int) (int, int) { + n := len(m.entries) + if n == 0 { + return 0, 0 + } + start := max(m.cursor-listH/2, 0) + end := start + listH + if end > n { + end = n + start = max(end-listH, 0) + } + return start, end +} diff --git a/internal/explore/explore_test.go b/internal/explore/explore_test.go new file mode 100644 index 0000000..8e75eb7 --- /dev/null +++ b/internal/explore/explore_test.go @@ -0,0 +1,347 @@ +package explore + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// buildTestTree creates: +// +// / +// file_a.txt ("content of a") +// file_b.go ("package main\n") +// subdir/ +// inner.txt ("inner content") +func buildTestTree(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "file_a.txt"), []byte("content of a\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "file_b.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + sub := filepath.Join(root, "subdir") + if err := os.MkdirAll(sub, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "inner.txt"), []byte("inner content\n"), 0o600); err != nil { + t.Fatal(err) + } + return root +} + +// sendSpecialKey sends a key string (up/down/enter/left/etc.) to the model +// and returns the updated Model. +func sendSpecialKey(t *testing.T, m Model, key string) Model { + t.Helper() + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + next, ok := updated.(Model) + if !ok { + t.Fatalf("Update did not return a Model for key %q", key) + } + return next +} + +func TestNew_CwdEqualsRoot(t *testing.T) { + root := buildTestTree(t) + m := New(root) + abs, _ := filepath.Abs(root) + if m.cwd != abs { + t.Fatalf("cwd = %q, want %q", m.cwd, abs) + } + if m.root != abs { + t.Fatalf("root = %q, want %q", m.root, abs) + } +} + +func TestNew_EntriesLoaded(t *testing.T) { + root := buildTestTree(t) + m := New(root) + if len(m.entries) == 0 { + t.Fatal("expected non-empty entries after New") + } +} + +func TestNew_DirsFirstInEntries(t *testing.T) { + root := buildTestTree(t) + m := New(root) + // "subdir" must appear before the files. + found := false + for _, e := range m.entries { + if e.name == "subdir" { + found = true + if !e.isDir { + t.Fatal("subdir entry: isDir is false") + } + break + } + } + if !found { + t.Fatal("subdir not found in entries") + } + // Confirm first entry is a dir (dirs-first invariant). + if len(m.entries) > 0 && !m.entries[0].isDir { + t.Fatalf("first entry %q should be a directory", m.entries[0].name) + } +} + +func TestMoveCursorDownUp(t *testing.T) { + root := buildTestTree(t) + m := New(root) + initial := m.cursor + + m2 := sendSpecialKey(t, m, "down") + if m2.cursor != initial+1 { + t.Fatalf("after down: cursor = %d, want %d", m2.cursor, initial+1) + } + + m3 := sendSpecialKey(t, m2, "up") + if m3.cursor != initial { + t.Fatalf("after up: cursor = %d, want %d", m3.cursor, initial) + } +} + +func TestCursorAtTopNoWrap(t *testing.T) { + root := buildTestTree(t) + m := New(root) + m.cursor = 0 + m2 := sendSpecialKey(t, m, "up") + if m2.cursor != 0 { + t.Fatalf("up at top: cursor = %d, want 0", m2.cursor) + } +} + +func TestCursorAtBottomNoWrap(t *testing.T) { + root := buildTestTree(t) + m := New(root) + m.cursor = len(m.entries) - 1 + m2 := sendSpecialKey(t, m, "down") + if m2.cursor != len(m.entries)-1 { + t.Fatalf("down at bottom: cursor = %d, want %d", m2.cursor, len(m.entries)-1) + } +} + +func TestPreviewUpdatesOnCursorMove(t *testing.T) { + root := buildTestTree(t) + m := New(root) + // Move cursor onto first file (skip the subdir at position 0). + // Locate file_a.txt. + fileIdx := -1 + for i, e := range m.entries { + if !e.isDir && strings.HasSuffix(e.name, ".txt") { + fileIdx = i + break + } + } + if fileIdx < 0 { + t.Fatal("could not locate a .txt file in root entries") + } + m.cursor = fileIdx + m.preview() + if m.previewName == "" { + t.Fatal("previewName is empty after moving to a file") + } + if m.previewErr != "" { + t.Fatalf("unexpected previewErr: %q", m.previewErr) + } + if m.viewport.View() == "" { + t.Fatal("viewport is empty after previewing a file") + } +} + +func TestEnterDescendsIntoDirectory(t *testing.T) { + root := buildTestTree(t) + m := New(root) + // Cursor should be on "subdir" (first entry, dirs-first). + if !m.entries[m.cursor].isDir { + // Manually position on the dir. + for i, e := range m.entries { + if e.isDir { + m.cursor = i + break + } + } + } + m2 := sendSpecialKey(t, m, "enter") + expectedCwd := filepath.Join(m.root, "subdir") + if m2.cwd != expectedCwd { + t.Fatalf("after enter: cwd = %q, want %q", m2.cwd, expectedCwd) + } + if m2.cursor != 0 { + t.Fatalf("after enter: cursor = %d, want 0", m2.cursor) + } +} + +func TestLeftAtRootIsNoop(t *testing.T) { + root := buildTestTree(t) + m := New(root) + if m.cwd != m.root { + t.Fatal("precondition: cwd != root") + } + m2 := sendSpecialKey(t, m, "left") + if m2.cwd != m.root { + t.Fatalf("left at root changed cwd: got %q", m2.cwd) + } +} + +func TestLeftInSubdirReturnsToRoot(t *testing.T) { + root := buildTestTree(t) + m := New(root) + // Descend into subdir. + m.cwd = filepath.Join(m.root, "subdir") + m.loadEntries() + m.cursor = 0 + m.preview() + + m2 := sendSpecialKey(t, m, "left") + if m2.cwd != m.root { + t.Fatalf("left in subdir: cwd = %q, want %q", m2.cwd, m.root) + } +} + +func TestWithin_Sub(t *testing.T) { + root := t.TempDir() + m := Model{root: root} + sub := filepath.Join(root, "sub") + if err := os.MkdirAll(sub, 0o700); err != nil { + t.Fatal(err) + } + if !m.within(sub) { + t.Fatalf("within: %q should be within %q", sub, root) + } +} + +func TestWithin_ParentEscape(t *testing.T) { + root := t.TempDir() + m := Model{root: root} + parent := filepath.Dir(root) + if m.within(parent) { + t.Fatalf("within: %q should NOT be within %q", parent, root) + } +} + +func TestWithin_Root(t *testing.T) { + root := t.TempDir() + m := Model{root: root} + if !m.within(root) { + t.Fatalf("within: root itself should be within root") + } +} + +func TestQuitReturnsCmd(t *testing.T) { + root := buildTestTree(t) + m := New(root) + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + if cmd == nil { + t.Fatal("q should return a non-nil Cmd") + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("cmd() returned %T, want tea.QuitMsg", msg) + } +} + +func TestEscReturnsQuit(t *testing.T) { + root := buildTestTree(t) + m := New(root) + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if cmd == nil { + t.Fatal("esc should return a non-nil Cmd") + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("esc cmd() returned %T, want tea.QuitMsg", msg) + } +} + +func TestWindowSizeMsgUpdatesViewport(t *testing.T) { + root := buildTestTree(t) + m := New(root) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m2, ok := updated.(Model) + if !ok { + t.Fatal("Update did not return a Model for WindowSizeMsg") + } + if m2.width != 120 || m2.height != 40 { + t.Fatalf("width/height: got %d/%d, want 120/40", m2.width, m2.height) + } +} + +func TestViewRendersWithoutPanic(t *testing.T) { + root := buildTestTree(t) + m := New(root) + m.width = 100 + m.height = 30 + view := m.View() + if view == "" { + t.Fatal("View() returned empty string") + } +} + +func TestWithin_SymlinkEscapeDenied(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + escapePath := filepath.Join(root, "escape") + if err := os.Symlink(outside, escapePath); err != nil { + t.Fatal(err) + } + m := New(root) + // Symlink points outside root — must be denied. + if m.within(escapePath) { + t.Fatalf("within: symlink escape %q should NOT be within root %q", escapePath, root) + } + // A real subdirectory inside root must still be allowed. + realSub := filepath.Join(root, "realsub") + if err := os.MkdirAll(realSub, 0o700); err != nil { + t.Fatal(err) + } + if !m.within(realSub) { + t.Fatalf("within: real subdir %q should be within root %q", realSub, root) + } +} + +func TestTruncation_MultibyteSafe(t *testing.T) { + // Verify that rune-safe truncation does not split multibyte characters. + // Construct a label of CJK runes wider than maxLabel, then simulate what + // renderLeft does and confirm the result is valid UTF-8 with the ellipsis. + cjk := strings.Repeat("日", 20) // 20 CJK runes, 3 bytes each + maxLabel := 10 + var result string + if r := []rune(cjk); len(r) > maxLabel { + result = string(r[:maxLabel-1]) + "…" + } else { + result = cjk + } + runes := []rune(result) + // Should be exactly maxLabel runes: (maxLabel-1) CJK + 1 ellipsis rune. + if len(runes) != maxLabel { + t.Fatalf("truncated rune count = %d, want %d", len(runes), maxLabel) + } + if runes[len(runes)-1] != '…' { + t.Fatalf("last rune = %q, want '…'", runes[len(runes)-1]) + } +} + +func TestPreviewFileContent(t *testing.T) { + root := buildTestTree(t) + m := New(root) + m.color = false // deterministic: no ANSI in test output + + // Position cursor on file_a.txt. + for i, e := range m.entries { + if e.name == "file_a.txt" { + m.cursor = i + break + } + } + m.preview() + content := m.viewport.View() + if !strings.Contains(content, "content of a") { + t.Fatalf("viewport content does not contain file text; got: %q", content) + } +} diff --git a/internal/explore/fileview.go b/internal/explore/fileview.go new file mode 100644 index 0000000..3ac0746 --- /dev/null +++ b/internal/explore/fileview.go @@ -0,0 +1,109 @@ +package explore + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2/quick" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// maxPreviewBytes caps the file size the viewer will load so a stray multi-MB +// file can't stall the render loop or blow memory. 256 KiB comfortably covers +// source files. +const maxPreviewBytes = 256 * 1024 + +var ( + errPreviewTooLarge = errors.New("file too large to preview") + errPreviewBinary = errors.New("binary file") +) + +// readFilePreview reads path for read-only display. It rejects oversized files +// and files that look binary (a NUL byte in the first 8 KiB), returning a +// sentinel error the caller renders as guard text instead of dumping bytes. +// On success it returns the (optionally highlighted) content. +func readFilePreview(path string, colorEnabled bool) (string, error) { + f, err := os.Open(path) // #nosec G304 -- path is confined to the user's project tree (jailed to the explore root); read-only preview + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + // LimitReader caps the read in one syscall sequence — there's no TOCTOU window + // where an agent writing this file concurrently could grow it past the cap + // between a stat and a read. The +1 byte lets us detect "over the limit". + data, err := io.ReadAll(io.LimitReader(f, maxPreviewBytes+1)) + if err != nil { + return "", err + } + if int64(len(data)) > maxPreviewBytes { + return "", errPreviewTooLarge + } + sniff := data + if len(sniff) > 8192 { + sniff = sniff[:8192] + } + if bytes.IndexByte(sniff, 0) >= 0 { + return "", errPreviewBinary + } + return highlight(string(data), path, colorEnabled), nil +} + +// highlight returns src syntax-highlighted for a 256-color terminal, keyed by +// the filename for lexer detection. When colorEnabled is false (NO_COLOR or a +// non-color terminal) it returns src unchanged so no-color terminals stay clean. +// Any chroma error falls back to the plain source — the viewer never blanks. +func highlight(src, filename string, colorEnabled bool) string { + if !colorEnabled { + return src + } + var b strings.Builder + if err := quick.Highlight(&b, src, lexerName(filename), "terminal256", "monokai"); err != nil { + return src + } + return b.String() +} + +// lexerName maps a filename to a chroma lexer hint. Passing the base name +// improves chroma's detection for common extensions. +func lexerName(filename string) string { + return filepath.Base(filename) +} + +// colorEnabled reports whether the default renderer supports colour output. +// Returns false on dumb/ASCII terminals and when NO_COLOR is set. +func colorEnabled() bool { + return lipgloss.DefaultRenderer().ColorProfile() != termenv.Ascii +} + +// withLineNumbers prefixes every line in content with a right-aligned line +// number and a dim " │ " separator. It is safe to apply after chroma +// highlighting because ANSI escape sequences are per-line. +func withLineNumbers(content string) string { + lines := strings.Split(content, "\n") + // Trim a trailing empty element produced by a trailing newline so the last + // displayed line number matches the actual line count. + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) == 0 { + return content + } + width := len(fmt.Sprintf("%d", len(lines))) + dimStyle := lipgloss.NewStyle().Faint(true) + var b strings.Builder + for i, line := range lines { + gutter := fmt.Sprintf("%*d │ ", width, i+1) + b.WriteString(dimStyle.Render(gutter)) + b.WriteString(line) + if i < len(lines)-1 { + b.WriteByte('\n') + } + } + return b.String() +} diff --git a/internal/explore/fileview_test.go b/internal/explore/fileview_test.go new file mode 100644 index 0000000..620520b --- /dev/null +++ b/internal/explore/fileview_test.go @@ -0,0 +1,109 @@ +package explore + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadFilePreview_PlainText(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "hello.txt") + if err := os.WriteFile(p, []byte("line one\nline two\n"), 0o600); err != nil { + t.Fatal(err) + } + out, err := readFilePreview(p, false) // colorEnabled=false => plain text + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !strings.Contains(out, "line one") || !strings.Contains(out, "line two") { + t.Fatalf("content missing: %q", out) + } +} + +func TestReadFilePreview_OversizeGuard(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "big.bin") + if err := os.WriteFile(p, make([]byte, maxPreviewBytes+1), 0o600); err != nil { + t.Fatal(err) + } + _, err := readFilePreview(p, false) + if !errors.Is(err, errPreviewTooLarge) { + t.Fatalf("want errPreviewTooLarge, got %v", err) + } +} + +func TestReadFilePreview_BinaryGuard(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "data.bin") + if err := os.WriteFile(p, []byte("ok\x00\x01\x02binary"), 0o600); err != nil { + t.Fatal(err) + } + _, err := readFilePreview(p, false) + if !errors.Is(err, errPreviewBinary) { + t.Fatalf("want errPreviewBinary, got %v", err) + } +} + +func TestHighlight_DisabledReturnsPlain(t *testing.T) { + src := "package main\n" + if got := highlight(src, "main.go", false); got != src { + t.Fatalf("expected plain passthrough, got %q", got) + } +} + +func TestHighlight_EnabledAddsANSI(t *testing.T) { + src := "package main\nfunc main() {}\n" + got := highlight(src, "main.go", true) + if !strings.Contains(got, "\x1b[") { + t.Fatalf("expected ANSI escapes when colorEnabled, got %q", got) + } +} + +func TestReadFilePreview_AtSizeLimitAccepted(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "exact.txt") + // Exactly maxPreviewBytes of NON-NUL content: must pass the size guard (cap is + // strictly `>`) AND the binary guard. Do NOT use make([]byte, n) here — that's + // all-NUL and would trip the binary guard. + if err := os.WriteFile(p, []byte(strings.Repeat("a", maxPreviewBytes)), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readFilePreview(p, false); err != nil { + t.Fatalf("file at exactly the limit should be accepted, got %v", err) + } +} + +func TestReadFilePreview_UnreadableFileErrors(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "noperm.txt") + if err := os.WriteFile(p, []byte("secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(p, 0o600) }) // let TempDir clean up + if _, err := readFilePreview(p, false); err == nil { + t.Fatal("expected an error reading a file with no permissions") + } +} + +func TestWithLineNumbers(t *testing.T) { + input := "hello world\nsecond line\n" + out := withLineNumbers(input) + if !strings.Contains(out, "1 │ ") { + t.Fatalf("expected line number '1 │ ' in output, got: %q", out) + } + if !strings.Contains(out, "hello world") { + t.Fatalf("expected original text 'hello world' in output, got: %q", out) + } + if !strings.Contains(out, "2 │ ") { + t.Fatalf("expected line number '2 │ ' in output, got: %q", out) + } + if !strings.Contains(out, "second line") { + t.Fatalf("expected original text 'second line' in output, got: %q", out) + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 62d6ff1..c788129 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -332,6 +332,14 @@ func (c *Client) applyServerConfig(ctx context.Context) error { if _, err := c.run(ctx, "bind-key", "-n", "C-z", "display-message", "Ctrl+Z is disabled in uam sessions; use Ctrl+b d to detach"); err != nil { return fmt.Errorf("bind C-z: %w", err) } + // Bind e to split the window and open the jailed file explorer rooted + // at the current pane's directory. Best-effort: a missing binding doesn't break + // sessions. The new pane's -c start dir becomes the explorer's jail root. + if cmd := exploreSplitCommand(); cmd != "" { + if out, err := c.run(ctx, "bind-key", "e", "split-window", "-h", "-c", "#{pane_current_path}", cmd); err != nil { + log.Warn("binding explorer key failed", "error", err, "output", strings.TrimSpace(out)) + } + } // Hook install is best-effort. If we can't resolve a safe binary path, // the rest of uam still works — only the exit-in-session signal is lost, // and the user can recover via Ctrl+X or `uam rm`. We log (not return) the @@ -391,6 +399,33 @@ func hookCommandForExe(exe string) string { return fmt.Sprintf(`run-shell "%s notify-closed '#{hook_session_name}'"`, exe) } +// exploreSplitCommand returns the shell command for the e explorer +// binding, or "" if the uam binary path isn't safe to embed. +func exploreSplitCommand() string { + exe, err := os.Executable() + if err != nil { + return "" + } + if err := execpath.ValidateAbsoluteExecutable(exe); err != nil { + return "" + } + return exploreSplitCommandForExe(exe) +} + +// exploreSplitCommandForExe builds the ` explore` shell command for the +// split binding, or "" when the path isn't safe to embed (same quoting rules as +// hookCommandForExe). Split out so the rejection branch is table-testable +// without faking os.Executable. +func exploreSplitCommandForExe(exe string) string { + if !filepath.IsAbs(exe) { + return "" + } + if strings.ContainsAny(exe, "\"'\\$`") { + return "" + } + return exe + " explore" +} + func (c *Client) HasSession(ctx context.Context, target string) bool { _, err := c.run(ctx, "has-session", "-t", target) return err == nil diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 15ebc68..2586847 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -272,6 +272,55 @@ func TestHookCommandForExe(t *testing.T) { } } +// TestExploreSplitCommandForExe is the testable seam for the e +// binding: same quoting rules as hookCommandForExe, different output shape. +func TestExploreSplitCommandForExe(t *testing.T) { + cases := []struct { + name string + exe string + wantEmpty bool + }{ + {"clean absolute path", "/usr/local/bin/uam", false}, + {"relative path rejected", "uam", true}, + {"path with dollar rejected", "/opt/u$am/uam", true}, + {"path with backtick rejected", "/opt/u`am`/uam", true}, + {"path with single-quote rejected", "/opt/u'am/uam", true}, + {"path with double-quote rejected", `/opt/u"am/uam`, true}, + {"path with backslash rejected", `/opt/u\am/uam`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := exploreSplitCommandForExe(tc.exe) + if tc.wantEmpty { + if cmd != "" { + t.Fatalf("exploreSplitCommandForExe(%q) = %q, want empty (rejected)", tc.exe, cmd) + } + return + } + if cmd == "" { + t.Fatalf("exploreSplitCommandForExe(%q) was rejected, want a command", tc.exe) + } + want := tc.exe + " explore" + if cmd != want { + t.Fatalf("exploreSplitCommandForExe(%q) = %q, want %q", tc.exe, cmd, want) + } + }) + } +} + +// TestExploreSplitCommandUsesRealBinary verifies the wrapper resolves the +// running binary and delegates to exploreSplitCommandForExe. The deterministic +// rejection-branch coverage lives in TestExploreSplitCommandForExe above. +func TestExploreSplitCommandUsesRealBinary(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Skipf("os.Executable unavailable: %v", err) + } + if got, want := exploreSplitCommand(), exploreSplitCommandForExe(exe); got != want { + t.Fatalf("exploreSplitCommand() = %q, want %q (from real binary path)", got, want) + } +} + // TestSessionClosedHookCommandUsesRealBinary verifies the wrapper resolves the // running binary and delegates to hookCommandForExe. The deterministic // rejection-branch coverage lives in TestHookCommandForExe above.