Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a279c3e5de | ||
|
|
60a7e26eac | ||
|
|
5376dc561e | ||
|
|
b7880fffab |
No files matched your search
+6
-4
@@ -126,8 +126,8 @@ The first screen the user sees. Split-pane layout. Host list on the left, detail
|
||||
- The header line above the boxes says `limen` (left) and `DETAILS` (right), styled with the accent color.
|
||||
- Status dots: filled green circle (`●`) for online, hollow circle (`○`) for unreachable, dash (`—`) for "no sessions yet" indication when applicable.
|
||||
- Session count column right-aligned.
|
||||
- localhost is always the first entry. Its display name is `localhost (<short-hostname>)` where short-hostname comes from `hostname -s`. This makes it immediately clear which physical machine the user is sitting at.
|
||||
- Hosts are listed in the order declared in the config file. No automatic reordering. (Sorting by "most recently attached" is a future feature, called out as such.)
|
||||
- localhost's display name is `localhost (<short-hostname>)` where short-hostname comes from `hostname -s`. This makes it immediately clear which physical machine the user is sitting at.
|
||||
- Hosts are listed A-Z by display name, case-insensitive. The implicit localhost entry participates in this ordering.
|
||||
- A footer line shows the keyboard cheat sheet, dimmed.
|
||||
|
||||
**Details pane content (for the highlighted host):**
|
||||
@@ -149,6 +149,8 @@ The first screen the user sees. Split-pane layout. Host list on the left, detail
|
||||
| `End` / `G` | Jump to last entry |
|
||||
| `Enter` | Accept; advance to stage 2 with this host as target |
|
||||
| `/` | Open search/filter mode (filter the list as you type) |
|
||||
| Text input | Start search/filter mode with the typed character |
|
||||
| `Tab` | Complete the filter to the highlighted host name |
|
||||
| `Esc` | If in search mode, cancel filter. Otherwise, escape semantics (see §5.5) |
|
||||
| `?` | Toggle help overlay |
|
||||
| `Ctrl+C` / `q` | Quit `limen` entirely without exec'ing anything. Exit code 130 / 0 respectively |
|
||||
@@ -177,7 +179,7 @@ Same split-pane layout as stage 1, applied to sessions on the chosen host. The u
|
||||
**Layout requirements:**
|
||||
|
||||
- The left-pane header shows the host name (e.g. `prod`), not the word "limen", so the user knows where they are.
|
||||
- `+ New session` is always the first entry, followed by a horizontal separator (`─` rule), followed by existing sessions in tmux's default ordering (most recently created first, matching `tmux ls`).
|
||||
- `+ New session` is always the first entry, followed by a horizontal separator (`─` rule), followed by existing sessions sorted A-Z by name, case-insensitive.
|
||||
- `●` marks the currently-attached session if any (i.e., the one a tmux client is currently inside). On localhost, there can be one. On remotes accessed via `ssh ... tmux ls`, attached-state comes from `#{session_attached}`.
|
||||
- `★` next to the window count marks the currently-attached session (redundant with the dot for users who scan the right column).
|
||||
|
||||
@@ -212,7 +214,7 @@ Same as stage 1, with the addition that when the highlighted entry is `+ New ses
|
||||
|
||||
### 5.4 Search / filter mode
|
||||
|
||||
In either stage, `/` opens an inline filter. The list narrows to entries matching the typed substring (case-insensitive, fuzzy match optional but not required for v1 — simple substring is fine). Esc cancels filter. Enter selects the top match.
|
||||
In either picker stage, `/` opens an inline filter. Typing a letter while not already filtering also opens the inline filter and seeds it with that character, so the user can type directly toward the target host or session without first pressing `/`. The list narrows to entries whose visible host or session name starts with the typed prefix, case-insensitive. Tab completes the filter to the highlighted host or session name. Arrow keys continue to navigate the filtered matches. Esc cancels filter. Enter selects the highlighted match.
|
||||
|
||||
While filter is active, the cheat-sheet footer changes to indicate `esc cancel filter`.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
pname = "limen";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
vendorHash = null;
|
||||
vendorHash = "sha256-zYQ6hMFBFDPNlViB7EmnxbXozardyYeHiUSiG4uS/9Y=";
|
||||
|
||||
ldflags = [
|
||||
"-s"
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
module github.com/KofTwentyTwo/limen
|
||||
|
||||
go 1.22
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.2 // 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/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.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/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/text v0.28.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
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.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
||||
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
||||
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/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b h1:Cw2ewBSl8ZRJFPlBHDiSpRNCATyzc/7yffziFeT0ymQ=
|
||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
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/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/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-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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
osexec "os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -113,7 +114,13 @@ func Handoff(plan Plan, opts Options) int {
|
||||
st.LastAttached[plan.HostName] = deps.Now().UTC()
|
||||
_ = deps.SaveState(deps.StatePath, st)
|
||||
|
||||
if err := deps.Exec(plan.Argv[0], plan.Argv, deps.Environ()); err != nil {
|
||||
execPath, err := osexec.LookPath(plan.Argv[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
|
||||
return 127
|
||||
}
|
||||
|
||||
if err := deps.Exec(execPath, plan.Argv, deps.Environ()); err != nil {
|
||||
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
|
||||
return 127
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package exec
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
osexec "os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -144,8 +145,12 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
|
||||
var execArgv []string
|
||||
var execEnv []string
|
||||
var stderr bytes.Buffer
|
||||
wantPath, err := osexec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skipf("sh is unavailable: %v", err)
|
||||
}
|
||||
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod", "tmux", "new"}}, Options{
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"sh", "-c", "true"}}, Options{
|
||||
StatePath: "/tmp/state.json",
|
||||
Now: func() time.Time { return now },
|
||||
Environ: func() []string { return []string{"TERM=xterm-256color"} },
|
||||
@@ -181,7 +186,7 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
|
||||
if _, ok := saved.LastAttached["localhost"]; !ok {
|
||||
t.Fatal("existing localhost timestamp was not preserved")
|
||||
}
|
||||
if execPath != "ssh" || !reflect.DeepEqual(execArgv, []string{"ssh", "-t", "prod", "tmux", "new"}) {
|
||||
if execPath != wantPath || !reflect.DeepEqual(execArgv, []string{"sh", "-c", "true"}) {
|
||||
t.Fatalf("exec saw %q %#v", execPath, execArgv)
|
||||
}
|
||||
if !reflect.DeepEqual(execEnv, []string{"TERM=xterm-256color"}) {
|
||||
@@ -192,10 +197,44 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffResolvesExecutableBeforeExec(t *testing.T) {
|
||||
wantPath, err := osexec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skipf("sh is unavailable: %v", err)
|
||||
}
|
||||
|
||||
var execPath string
|
||||
var execArgv []string
|
||||
|
||||
code := Handoff(Plan{HostName: "localhost", Argv: []string{"sh", "-c", "true"}}, Options{
|
||||
LoadState: func(path string) state.State {
|
||||
return state.State{}
|
||||
},
|
||||
SaveState: func(path string, st state.State) error {
|
||||
return nil
|
||||
},
|
||||
Exec: func(path string, argv []string, env []string) error {
|
||||
execPath = path
|
||||
execArgv = append([]string(nil), argv...)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("Handoff exit code = %d, want 0", code)
|
||||
}
|
||||
if execPath != wantPath {
|
||||
t.Fatalf("exec path = %q, want %q", execPath, wantPath)
|
||||
}
|
||||
if !reflect.DeepEqual(execArgv, []string{"sh", "-c", "true"}) {
|
||||
t.Fatalf("exec argv = %#v", execArgv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffTreatsStateSaveFailureAsAdvisory(t *testing.T) {
|
||||
var calledExec bool
|
||||
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh"}}, Options{
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"sh"}}, Options{
|
||||
LoadState: func(path string) state.State {
|
||||
return state.State{}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
// Package ui contains limen's Bubble Tea models.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/format"
|
||||
"github.com/KofTwentyTwo/limen/internal/probe"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
"github.com/KofTwentyTwo/limen/internal/ui/theme"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWidth = 100
|
||||
defaultHeight = 30
|
||||
|
||||
tinyTerminalMessage = "terminal too small for limen UI; falling back to bare tmux new"
|
||||
)
|
||||
|
||||
type stage int
|
||||
|
||||
const (
|
||||
stageHosts stage = iota
|
||||
stageSessions
|
||||
stageNewSession
|
||||
)
|
||||
|
||||
// App is the data needed to start the TUI.
|
||||
type App struct {
|
||||
Config config.Config
|
||||
State state.State
|
||||
ProbeResults <-chan probe.ProbeResult
|
||||
Now func() time.Time
|
||||
Hostname string
|
||||
}
|
||||
|
||||
// Result is the selected command plan or quit outcome from the TUI.
|
||||
type Result struct {
|
||||
Plan limenexec.Plan
|
||||
HasPlan bool
|
||||
ExitCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
// Run starts the Bubble Tea program and returns the final user decision.
|
||||
func Run(app App) (Result, error) {
|
||||
program := tea.NewProgram(NewModel(app), tea.WithAltScreen())
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
return Result{ExitCode: 1}, err
|
||||
}
|
||||
|
||||
model, ok := finalModel.(Model)
|
||||
if !ok {
|
||||
return Result{ExitCode: 1}, fmt.Errorf("unexpected final model %T", finalModel)
|
||||
}
|
||||
return model.Result(), nil
|
||||
}
|
||||
|
||||
// Model is the root Bubble Tea model for both picker stages.
|
||||
type Model struct {
|
||||
stage stage
|
||||
width int
|
||||
height int
|
||||
hosts []hostEntry
|
||||
hostCursor int
|
||||
sessionCursor int
|
||||
filter string
|
||||
filtering bool
|
||||
help bool
|
||||
newName string
|
||||
inputError string
|
||||
probeResults <-chan probe.ProbeResult
|
||||
now func() time.Time
|
||||
result Result
|
||||
}
|
||||
|
||||
type hostEntry struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Connection string
|
||||
Description string
|
||||
Remote *config.Host
|
||||
Probing bool
|
||||
Reachable bool
|
||||
Sessions []probe.SessionInfo
|
||||
LastAttached time.Time
|
||||
}
|
||||
|
||||
type sessionRow struct {
|
||||
New bool
|
||||
Session probe.SessionInfo
|
||||
}
|
||||
|
||||
type probeMsg probe.ProbeResult
|
||||
type probesDoneMsg struct{}
|
||||
|
||||
// NewModel constructs the initial root model.
|
||||
func NewModel(app App) Model {
|
||||
now := app.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
hostname := app.Hostname
|
||||
if hostname == "" {
|
||||
if local, err := os.Hostname(); err == nil {
|
||||
hostname = local
|
||||
}
|
||||
}
|
||||
|
||||
hosts := make([]hostEntry, 0, len(app.Config.Hosts)+1)
|
||||
hosts = append(hosts, hostEntry{
|
||||
Name: probe.LocalhostName,
|
||||
DisplayName: "localhost (" + shortHostname(hostname) + ")",
|
||||
Connection: "localhost",
|
||||
Probing: true,
|
||||
LastAttached: app.State.LastAttached[probe.LocalhostName],
|
||||
})
|
||||
for _, host := range app.Config.Hosts {
|
||||
hostCopy := host
|
||||
hosts = append(hosts, hostEntry{
|
||||
Name: host.Name,
|
||||
DisplayName: host.Name,
|
||||
Connection: connectionLine(host),
|
||||
Description: host.Description,
|
||||
Remote: &hostCopy,
|
||||
Probing: true,
|
||||
LastAttached: app.State.LastAttached[host.Name],
|
||||
})
|
||||
}
|
||||
sort.SliceStable(hosts, func(i int, j int) bool {
|
||||
return labelLess(hosts[i].DisplayName, hosts[j].DisplayName)
|
||||
})
|
||||
|
||||
probeResults := app.ProbeResults
|
||||
if probeResults == nil {
|
||||
probeResults = probe.Start(contextBackground(), app.Config.Hosts, probe.Options{})
|
||||
}
|
||||
|
||||
return Model{
|
||||
width: defaultWidth,
|
||||
height: defaultHeight,
|
||||
hosts: hosts,
|
||||
probeResults: probeResults,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func contextBackground() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// Init starts listening for probe results.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return waitForProbe(m.probeResults)
|
||||
}
|
||||
|
||||
// Update applies keyboard, resize, and probe messages.
|
||||
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
|
||||
if msg.Width < 50 || msg.Height < 15 {
|
||||
m.acceptPlan(mustPlan(limenexec.LocalNew("")))
|
||||
m.result.Message = tinyTerminalMessage
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case probeMsg:
|
||||
m.applyProbeResult(probe.ProbeResult(msg))
|
||||
m.clampCursor()
|
||||
return m, waitForProbe(m.probeResults)
|
||||
case probesDoneMsg:
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
return m.updateKey(msg)
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// View renders the active picker stage.
|
||||
func (m Model) View() string {
|
||||
if m.width < 50 || m.height < 15 {
|
||||
return tinyTerminalMessage
|
||||
}
|
||||
|
||||
var body string
|
||||
if m.width < 80 || m.height < 24 {
|
||||
body = m.singleColumnView()
|
||||
} else {
|
||||
body = m.splitView()
|
||||
}
|
||||
if m.help {
|
||||
body = body + "\n\n" + m.helpView()
|
||||
}
|
||||
return body + "\n\n" + m.footer()
|
||||
}
|
||||
|
||||
// Result returns the final user decision.
|
||||
func (m Model) Result() Result {
|
||||
return m.result
|
||||
}
|
||||
|
||||
func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.help {
|
||||
switch msg.String() {
|
||||
case "?", "esc":
|
||||
m.help = false
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if m.filtering {
|
||||
return m.updateFilterKey(msg)
|
||||
}
|
||||
|
||||
if msg.Type == tea.KeyRunes && m.stage != stageNewSession && startsTypeahead(msg.String()) {
|
||||
m.filtering = true
|
||||
m.filter = string(msg.Runes)
|
||||
m.clampCursor()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
m.result.ExitCode = 130
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
m.result.ExitCode = 0
|
||||
return m, tea.Quit
|
||||
case "?":
|
||||
m.help = !m.help
|
||||
return m, nil
|
||||
case "/":
|
||||
if m.stage != stageNewSession {
|
||||
m.filtering = true
|
||||
m.filter = ""
|
||||
}
|
||||
return m, nil
|
||||
case "tab":
|
||||
if m.stage != stageNewSession {
|
||||
m.filtering = true
|
||||
m.completeFilter()
|
||||
}
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
m.moveCursor(-1)
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
m.moveCursor(1)
|
||||
return m, nil
|
||||
case "home", "g":
|
||||
m.setCursor(0)
|
||||
return m, nil
|
||||
case "end", "G":
|
||||
m.setCursor(m.entryCount() - 1)
|
||||
return m, nil
|
||||
case "esc":
|
||||
return m.escape()
|
||||
case "enter":
|
||||
return m.enter()
|
||||
case "backspace":
|
||||
if m.stage == stageNewSession && len(m.newName) > 0 {
|
||||
m.newName = m.newName[:len(m.newName)-1]
|
||||
m.inputError = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if msg.Type == tea.KeyRunes && m.stage == stageNewSession {
|
||||
m.newName += string(msg.Runes)
|
||||
m.inputError = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.filtering = false
|
||||
m.filter = ""
|
||||
m.clampCursor()
|
||||
return m, nil
|
||||
case "enter":
|
||||
m.filtering = false
|
||||
return m.enter()
|
||||
case "tab":
|
||||
m.completeFilter()
|
||||
return m, nil
|
||||
case "up":
|
||||
m.moveCursor(-1)
|
||||
return m, nil
|
||||
case "down":
|
||||
m.moveCursor(1)
|
||||
return m, nil
|
||||
case "home":
|
||||
m.setCursor(0)
|
||||
return m, nil
|
||||
case "end":
|
||||
m.setCursor(m.entryCount() - 1)
|
||||
return m, nil
|
||||
case "backspace":
|
||||
if len(m.filter) > 0 {
|
||||
m.filter = m.filter[:len(m.filter)-1]
|
||||
m.clampCursor()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if msg.Type == tea.KeyRunes {
|
||||
m.filter += string(msg.Runes)
|
||||
m.clampCursor()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) enter() (tea.Model, tea.Cmd) {
|
||||
switch m.stage {
|
||||
case stageHosts:
|
||||
indexes := m.filteredHostIndexes()
|
||||
if len(indexes) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.hostCursor = indexes[m.hostCursor]
|
||||
m.stage = stageSessions
|
||||
m.sessionCursor = 0
|
||||
m.filter = ""
|
||||
m.filtering = false
|
||||
return m, nil
|
||||
case stageSessions:
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
row := rows[m.sessionCursor]
|
||||
if row.New {
|
||||
m.stage = stageNewSession
|
||||
m.newName = ""
|
||||
m.inputError = ""
|
||||
m.filter = ""
|
||||
m.filtering = false
|
||||
return m, nil
|
||||
}
|
||||
host := m.selectedHost()
|
||||
session := row.Session
|
||||
if host.Remote == nil {
|
||||
m.acceptPlan(limenexec.LocalAttach(session.Name))
|
||||
} else {
|
||||
m.acceptPlan(limenexec.RemoteAttach(*host.Remote, session.Name))
|
||||
}
|
||||
return m, tea.Quit
|
||||
case stageNewSession:
|
||||
host := m.selectedHost()
|
||||
var plan limenexec.Plan
|
||||
var err error
|
||||
if host.Remote == nil {
|
||||
plan, err = limenexec.LocalNew(m.newName)
|
||||
} else {
|
||||
plan, err = limenexec.RemoteNew(*host.Remote, m.newName)
|
||||
}
|
||||
if err != nil {
|
||||
m.inputError = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.acceptPlan(plan)
|
||||
return m, tea.Quit
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) escape() (tea.Model, tea.Cmd) {
|
||||
if m.stage == stageHosts {
|
||||
m.acceptPlan(mustPlan(limenexec.LocalNew("")))
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
host := m.selectedHost()
|
||||
var plan limenexec.Plan
|
||||
if host.Remote == nil {
|
||||
plan = mustPlan(limenexec.LocalNew(""))
|
||||
} else {
|
||||
plan = mustPlan(limenexec.RemoteNew(*host.Remote, ""))
|
||||
}
|
||||
m.acceptPlan(plan)
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
func (m *Model) acceptPlan(plan limenexec.Plan) {
|
||||
m.result = Result{Plan: plan, HasPlan: true, ExitCode: 0}
|
||||
}
|
||||
|
||||
func (m *Model) applyProbeResult(result probe.ProbeResult) {
|
||||
for i := range m.hosts {
|
||||
if m.hosts[i].Name != result.HostName {
|
||||
continue
|
||||
}
|
||||
m.hosts[i].Probing = false
|
||||
m.hosts[i].Reachable = result.Reachable
|
||||
m.hosts[i].Sessions = result.Sessions
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func waitForProbe(results <-chan probe.ProbeResult) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
result, ok := <-results
|
||||
if !ok {
|
||||
return probesDoneMsg{}
|
||||
}
|
||||
return probeMsg(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) splitView() string {
|
||||
leftWidth := maxInt(30, int(float64(m.width)*0.40))
|
||||
rightWidth := maxInt(30, m.width-leftWidth-4)
|
||||
height := maxInt(10, m.height-6)
|
||||
|
||||
left := theme.Pane.Width(leftWidth).Height(height).Render(m.listView())
|
||||
right := theme.Pane.Width(rightWidth).Height(height).Render(m.detailView())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
||||
}
|
||||
|
||||
func (m Model) singleColumnView() string {
|
||||
return theme.Pane.Width(maxInt(30, m.width-4)).Render(m.listView())
|
||||
}
|
||||
|
||||
func (m Model) listView() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render(m.leftTitle()))
|
||||
builder.WriteString("\n\n")
|
||||
|
||||
switch m.stage {
|
||||
case stageHosts:
|
||||
m.writeHostRows(&builder)
|
||||
case stageSessions, stageNewSession:
|
||||
m.writeSessionRows(&builder)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) leftTitle() string {
|
||||
if m.stage == stageHosts {
|
||||
return "limen"
|
||||
}
|
||||
return m.selectedHost().DisplayName
|
||||
}
|
||||
|
||||
func (m Model) writeHostRows(builder *strings.Builder) {
|
||||
indexes := m.filteredHostIndexes()
|
||||
if len(indexes) == 0 {
|
||||
builder.WriteString(theme.Dimmed.Render("no matching hosts"))
|
||||
return
|
||||
}
|
||||
for row, index := range indexes {
|
||||
host := m.hosts[index]
|
||||
cursor := " "
|
||||
if row == m.hostCursor {
|
||||
cursor = ">"
|
||||
}
|
||||
line := fmt.Sprintf("%s %s %-24s %s", cursor, statusDot(host), host.DisplayName, sessionSummary(host))
|
||||
if row == m.hostCursor {
|
||||
line = theme.Selected.Render(line)
|
||||
}
|
||||
builder.WriteString(line)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) writeSessionRows(builder *strings.Builder) {
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
builder.WriteString(theme.Dimmed.Render("no matching sessions"))
|
||||
return
|
||||
}
|
||||
|
||||
for row, entry := range rows {
|
||||
cursor := " "
|
||||
if row == m.sessionCursor {
|
||||
cursor = ">"
|
||||
}
|
||||
line := cursor + " " + sessionRowText(entry)
|
||||
if row == m.sessionCursor {
|
||||
line = theme.Selected.Render(line)
|
||||
}
|
||||
builder.WriteString(line)
|
||||
builder.WriteString("\n")
|
||||
if entry.New && len(rows) > 1 {
|
||||
builder.WriteString(theme.Dimmed.Render("──────────────────────────────"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) detailView() string {
|
||||
if m.stage == stageNewSession {
|
||||
return m.newSessionView()
|
||||
}
|
||||
if m.stage == stageSessions {
|
||||
return m.sessionDetailView()
|
||||
}
|
||||
|
||||
host := m.selectedHost()
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render("DETAILS"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(host.DisplayName)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(theme.Dimmed.Render("──────────────────────────────────"))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(host.Connection)
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString("Status: ")
|
||||
builder.WriteString(statusText(host))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("Sessions: ")
|
||||
builder.WriteString(sessionDetail(host))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("Last attached: ")
|
||||
builder.WriteString(format.RelativeTime(host.LastAttached, m.now()))
|
||||
if host.Description != "" {
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(host.Description)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) sessionDetailView() string {
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
return theme.Title.Render("DETAILS") + "\n\n" + theme.Dimmed.Render("no matching sessions")
|
||||
}
|
||||
if rows[m.sessionCursor].New {
|
||||
return m.newSessionView()
|
||||
}
|
||||
|
||||
session := rows[m.sessionCursor].Session
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render("DETAILS"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(session.Name)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(theme.Dimmed.Render("──────────────────────────────────"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString("Windows: ")
|
||||
builder.WriteString(fmt.Sprintf("%d", session.Windows))
|
||||
builder.WriteString("\n")
|
||||
if !session.Created.IsZero() {
|
||||
builder.WriteString("Created: ")
|
||||
builder.WriteString(session.Created.Format("Mon Jan 2 15:04"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("Attached: ")
|
||||
if session.Attached {
|
||||
builder.WriteString("★ currently attached")
|
||||
} else {
|
||||
builder.WriteString("no")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) newSessionView() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render("+ New session"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString("Name for the new session:\n\n")
|
||||
builder.WriteString("> ")
|
||||
builder.WriteString(m.newName)
|
||||
builder.WriteString("\n\n")
|
||||
if m.inputError != "" {
|
||||
builder.WriteString(m.inputError)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString(theme.Dimmed.Render("Enter to create. Esc creates an unnamed session."))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) helpView() string {
|
||||
escapeHelp := "esc skip"
|
||||
if m.stage != stageHosts {
|
||||
escapeHelp = "esc new unnamed"
|
||||
}
|
||||
return theme.Pane.Render("↑/k up ↓/j down enter connect / search " + escapeHelp + " q quit ctrl+c quit")
|
||||
}
|
||||
|
||||
func (m Model) footer() string {
|
||||
if m.filtering {
|
||||
return theme.Dimmed.Render("/ " + m.filter + " · enter select · esc cancel filter")
|
||||
}
|
||||
if m.stage == stageNewSession {
|
||||
return theme.Dimmed.Render("enter create · esc new unnamed · q quit")
|
||||
}
|
||||
if m.stage == stageSessions {
|
||||
return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc new unnamed")
|
||||
}
|
||||
return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc skip")
|
||||
}
|
||||
|
||||
func (m Model) selectedHost() hostEntry {
|
||||
if len(m.hosts) == 0 {
|
||||
return hostEntry{Name: probe.LocalhostName, DisplayName: "localhost", Connection: "localhost"}
|
||||
}
|
||||
if m.hostCursor < 0 || m.hostCursor >= len(m.hosts) {
|
||||
return m.hosts[0]
|
||||
}
|
||||
return m.hosts[m.hostCursor]
|
||||
}
|
||||
|
||||
func (m Model) sessionEntries() []probe.SessionInfo {
|
||||
sessions := append([]probe.SessionInfo(nil), m.selectedHost().Sessions...)
|
||||
sort.SliceStable(sessions, func(i int, j int) bool {
|
||||
return labelLess(sessions[i].Name, sessions[j].Name)
|
||||
})
|
||||
return sessions
|
||||
}
|
||||
|
||||
func (m *Model) moveCursor(delta int) {
|
||||
m.setCursor(m.currentCursor() + delta)
|
||||
}
|
||||
|
||||
func (m *Model) setCursor(value int) {
|
||||
count := m.entryCount()
|
||||
if count == 0 {
|
||||
m.hostCursor = 0
|
||||
m.sessionCursor = 0
|
||||
return
|
||||
}
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
if value >= count {
|
||||
value = count - 1
|
||||
}
|
||||
switch m.stage {
|
||||
case stageHosts:
|
||||
m.hostCursor = value
|
||||
case stageSessions, stageNewSession:
|
||||
m.sessionCursor = value
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) clampCursor() {
|
||||
m.setCursor(m.currentCursor())
|
||||
}
|
||||
|
||||
func (m *Model) completeFilter() {
|
||||
completion, ok := m.currentCompletion()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.filter = completion
|
||||
m.clampCursor()
|
||||
}
|
||||
|
||||
func (m Model) currentCompletion() (string, bool) {
|
||||
if m.stage == stageHosts {
|
||||
indexes := m.filteredHostIndexes()
|
||||
if len(indexes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return m.hosts[indexes[m.hostCursor]].Name, true
|
||||
}
|
||||
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
return "", false
|
||||
}
|
||||
row := rows[m.sessionCursor]
|
||||
if row.New {
|
||||
return "new session", true
|
||||
}
|
||||
return row.Session.Name, true
|
||||
}
|
||||
|
||||
func (m Model) currentCursor() int {
|
||||
if m.stage == stageHosts {
|
||||
return m.hostCursor
|
||||
}
|
||||
return m.sessionCursor
|
||||
}
|
||||
|
||||
func (m Model) entryCount() int {
|
||||
if m.stage == stageHosts {
|
||||
return len(m.filteredHostIndexes())
|
||||
}
|
||||
return len(m.filteredSessionRows())
|
||||
}
|
||||
|
||||
func (m Model) filteredHostIndexes() []int {
|
||||
indexes := make([]int, 0, len(m.hosts))
|
||||
filter := strings.ToLower(m.filter)
|
||||
for i, host := range m.hosts {
|
||||
if filter == "" || startsWithFold(host.DisplayName, filter) {
|
||||
indexes = append(indexes, i)
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
func (m Model) filteredSessionRows() []sessionRow {
|
||||
filter := strings.ToLower(m.filter)
|
||||
rows := make([]sessionRow, 0, len(m.sessionEntries())+1)
|
||||
|
||||
if filter == "" || startsWithFold("new session", filter) {
|
||||
rows = append(rows, sessionRow{New: true})
|
||||
}
|
||||
for _, session := range m.sessionEntries() {
|
||||
if filter == "" || startsWithFold(session.Name, filter) {
|
||||
rows = append(rows, sessionRow{Session: session})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func sessionRowText(row sessionRow) string {
|
||||
if row.New {
|
||||
return "+ New session"
|
||||
}
|
||||
|
||||
marker := " "
|
||||
if row.Session.Attached {
|
||||
marker = "●"
|
||||
}
|
||||
star := ""
|
||||
if row.Session.Attached {
|
||||
star = " ★"
|
||||
}
|
||||
return fmt.Sprintf("%s %-18s %d windows%s", marker, row.Session.Name, row.Session.Windows, star)
|
||||
}
|
||||
|
||||
func statusDot(host hostEntry) string {
|
||||
switch {
|
||||
case host.Probing:
|
||||
return theme.ProbingStyle.Render("…")
|
||||
case host.Reachable:
|
||||
return theme.OnlineStyle.Render("●")
|
||||
default:
|
||||
return theme.UnreachableStyle.Render("○")
|
||||
}
|
||||
}
|
||||
|
||||
func statusText(host hostEntry) string {
|
||||
switch {
|
||||
case host.Probing:
|
||||
return theme.ProbingStyle.Render("… probing")
|
||||
case host.Reachable:
|
||||
return theme.OnlineStyle.Render("● online")
|
||||
default:
|
||||
return theme.UnreachableStyle.Render("○ unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func sessionSummary(host hostEntry) string {
|
||||
if host.Probing {
|
||||
return "probing"
|
||||
}
|
||||
if !host.Reachable {
|
||||
return "unreach."
|
||||
}
|
||||
if len(host.Sessions) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%d sess.", len(host.Sessions))
|
||||
}
|
||||
|
||||
func sessionDetail(host hostEntry) string {
|
||||
if host.Probing {
|
||||
return "probing"
|
||||
}
|
||||
if !host.Reachable {
|
||||
return "n/a"
|
||||
}
|
||||
if len(host.Sessions) == 0 {
|
||||
return "—"
|
||||
}
|
||||
names := make([]string, 0, len(host.Sessions))
|
||||
for _, session := range host.Sessions {
|
||||
names = append(names, session.Name)
|
||||
}
|
||||
return fmt.Sprintf("%d (%s)", len(host.Sessions), strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
func connectionLine(host config.Host) string {
|
||||
target := host.Hostname
|
||||
if host.User != "" {
|
||||
target = host.User + "@" + target
|
||||
}
|
||||
if host.Port != 0 && host.Port != 22 {
|
||||
target = fmt.Sprintf("%s:%d", target, host.Port)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func shortHostname(hostname string) string {
|
||||
if hostname == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.SplitN(hostname, ".", 2)[0]
|
||||
}
|
||||
|
||||
func mustPlan(plan limenexec.Plan, err error) limenexec.Plan {
|
||||
if err != nil {
|
||||
return limenexec.Plan{}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func labelLess(left string, right string) bool {
|
||||
leftFolded := strings.ToLower(left)
|
||||
rightFolded := strings.ToLower(right)
|
||||
if leftFolded == rightFolded {
|
||||
return left < right
|
||||
}
|
||||
return leftFolded < rightFolded
|
||||
}
|
||||
|
||||
func startsTypeahead(key string) bool {
|
||||
return key != "/" && key != "?" && key != "q"
|
||||
}
|
||||
|
||||
func startsWithFold(value string, foldedPrefix string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(value), foldedPrefix)
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/exp/teatest"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/probe"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
)
|
||||
|
||||
func TestTeatestHostSelectionAdvancesToSessionPicker(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(100, 30))
|
||||
defer func() {
|
||||
_ = tm.Quit()
|
||||
}()
|
||||
|
||||
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
|
||||
return bytes.Contains(bts, []byte("localhost (renova)"))
|
||||
}, teatest.WithDuration(2*time.Second))
|
||||
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
|
||||
return bytes.Contains(bts, []byte("+ New session"))
|
||||
}, teatest.WithDuration(2*time.Second))
|
||||
}
|
||||
|
||||
func TestHostsAreSortedByDisplayName(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "zeta", Hostname: "zeta.example.com"},
|
||||
{Name: "alpha", Hostname: "alpha.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
view := model.View()
|
||||
assertOrder(t, view, "alpha", "localhost (renova)", "zeta")
|
||||
}
|
||||
|
||||
func TestSelectExistingRemoteSessionProducesAttachPlan(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = updateModel(t, model, probeMsg(probe.ProbeResult{
|
||||
HostName: "prod",
|
||||
Reachable: true,
|
||||
Sessions: []probe.SessionInfo{{Name: "api", Windows: 3, Attached: true}},
|
||||
}))
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "api"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsAreSortedByName(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "zeta", Windows: 1},
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
view := model.View()
|
||||
assertOrder(t, view, "+ New session", "api", "ops", "zeta")
|
||||
}
|
||||
|
||||
func TestTypingAndTabCompleteHostSelection(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "prod", Hostname: "prod.example.com"},
|
||||
{Name: "builder", Hostname: "builder.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
for _, r := range "pr" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyTab})
|
||||
|
||||
if !model.filtering || model.filter != "prod" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want completed prod filter", model.filtering, model.filter)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if model.stage != stageSessions {
|
||||
t.Fatalf("stage = %v, want sessions", model.stage)
|
||||
}
|
||||
if got := model.selectedHost().Name; got != "prod" {
|
||||
t.Fatalf("selected host = %q, want prod", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypingBoundNavigationLetterStartsHostFilter(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "gamma", Hostname: "gamma.example.com"},
|
||||
{Name: "prod", Hostname: "prod.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
|
||||
|
||||
if !model.filtering || model.filter != "g" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want g filter", model.filtering, model.filter)
|
||||
}
|
||||
if view := model.View(); strings.Contains(view, "prod") {
|
||||
t.Fatalf("View() = %q, want prod filtered out", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFilterOnlyMatchesNamePrefixes(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "prod", Hostname: "prod.example.com"},
|
||||
{Name: "sandbox", Hostname: "box.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
for _, r := range "box" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
view := model.View()
|
||||
if strings.Contains(view, "sandbox") || strings.Contains(view, "box.example.com") {
|
||||
t.Fatalf("View() = %q, want non-prefix host and connection filtered out", view)
|
||||
}
|
||||
if !strings.Contains(view, "no matching hosts") {
|
||||
t.Fatalf("View() = %q, want no matching hosts", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFilterSelectsMatchingSession(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
for _, r := range "ops" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "ops"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFilterOnlyMatchesNamePrefixes(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "pi" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
view := model.View()
|
||||
if strings.Contains(view, "api") {
|
||||
t.Fatalf("View() = %q, want substring session filtered out", view)
|
||||
}
|
||||
if !strings.Contains(view, "no matching sessions") {
|
||||
t.Fatalf("View() = %q, want no matching sessions", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypingAndTabCompleteSessionSelection(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "op" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyTab})
|
||||
|
||||
if !model.filtering || model.filter != "ops" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want completed ops filter", model.filtering, model.filter)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "ops"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionArrowKeysNavigateWhileFiltering(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "app", Windows: 1},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "a" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
if model.sessionCursor != 1 {
|
||||
t.Fatalf("sessionCursor = %d, want 1 after arrow down in filter mode", model.sessionCursor)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyUp})
|
||||
if model.sessionCursor != 0 {
|
||||
t.Fatalf("sessionCursor = %d, want 0 after arrow up in filter mode", model.sessionCursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeFromHostPickerProducesLocalNewPlan(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
want := limenexec.Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeFromRemoteSessionPickerProducesRemoteNewPlan(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "new"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSelectsMatchingHost(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
for _, r := range "prod" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
if model.stage != stageSessions {
|
||||
t.Fatalf("stage = %v, want sessions", model.stage)
|
||||
}
|
||||
if got := model.selectedHost().Name; got != "prod" {
|
||||
t.Fatalf("selected host = %q, want prod", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterCanBeCanceledAfterNoMatches(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
for _, r := range "missing" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
if view := model.View(); !strings.Contains(view, "no matching hosts") {
|
||||
t.Fatalf("View() = %q, want no-match message", view)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if model.filtering || model.filter != "" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want canceled", model.filtering, model.filter)
|
||||
}
|
||||
if view := model.View(); !strings.Contains(view, "prod") {
|
||||
t.Fatalf("View() = %q, want unfiltered host list", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionPromptRejectsInvalidName(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "bad:name" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
if model.inputError != "name cannot contain : or ." {
|
||||
t.Fatalf("inputError = %q", model.inputError)
|
||||
}
|
||||
if model.result.HasPlan {
|
||||
t.Fatalf("result = %#v, want no plan", model.result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionPromptCreatesTrimmedRemoteSession(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range " api " {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "new", "-s", "api"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPickerRendersSessionDetails(t *testing.T) {
|
||||
created := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC)
|
||||
model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3, Attached: true, Created: created})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
view := model.View()
|
||||
for _, want := range []string{"api", "Windows: 3", "Created: Tue May 26 13:45", "currently attached"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("View() missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeUpdatesClampSessionCursor(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnd})
|
||||
model = updateModel(t, model, probeMsg(probe.ProbeResult{
|
||||
HostName: "prod",
|
||||
Reachable: true,
|
||||
Sessions: []probe.SessionInfo{{Name: "api", Windows: 3}},
|
||||
}))
|
||||
|
||||
if model.sessionCursor != 1 {
|
||||
t.Fatalf("sessionCursor = %d, want clamped to 1", model.sessionCursor)
|
||||
}
|
||||
if view := model.View(); !strings.Contains(view, "api") {
|
||||
t.Fatalf("View() = %q, want remaining session", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetailsRenderProbeStateAndStateTimestamp(t *testing.T) {
|
||||
model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
view := model.View()
|
||||
for _, want := range []string{"deploy@prod.example.com", "online", "1 (api)", "1 hour ago", "Production application servers."} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("View() missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableHostDetailsShowNoSessionInventory(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = updateModel(t, model, probeMsg(probe.ProbeResult{HostName: "prod", Reachable: false}))
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
view := model.View()
|
||||
for _, want := range []string{"unreachable", "Sessions: n/a"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("View() missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleColumnViewOmitsDetailsPane(t *testing.T) {
|
||||
model := updateModel(t, NewModel(testApp()), tea.WindowSizeMsg{Width: 70, Height: 20})
|
||||
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "limen") {
|
||||
t.Fatalf("View() = %q, want list pane", view)
|
||||
}
|
||||
if strings.Contains(view, "DETAILS") {
|
||||
t.Fatalf("View() = %q, want single-column layout without details pane", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpOverlayTogglesAndEscapeDismisses(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
||||
if !model.help {
|
||||
t.Fatal("help = false, want true")
|
||||
}
|
||||
if view := model.View(); !strings.Contains(view, "ctrl+c quit") {
|
||||
t.Fatalf("View() = %q, want help overlay", view)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if model.help {
|
||||
t.Fatal("help = true, want false")
|
||||
}
|
||||
if model.result.HasPlan {
|
||||
t.Fatalf("result = %#v, want no plan after dismissing help", model.result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitKeysReturnExitCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key tea.KeyMsg
|
||||
want int
|
||||
}{
|
||||
{name: "q", key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, want: 0},
|
||||
{name: "ctrl-c", key: tea.KeyMsg{Type: tea.KeyCtrlC}, want: 130},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
model := press(t, NewModel(testApp()), tt.key)
|
||||
if model.result.ExitCode != tt.want {
|
||||
t.Fatalf("exit code = %d, want %d", model.result.ExitCode, tt.want)
|
||||
}
|
||||
if model.result.HasPlan {
|
||||
t.Fatalf("result = %#v, want no plan", model.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTinyTerminalFallsBackToLocalNew(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = updateModel(t, model, tea.WindowSizeMsg{Width: 40, Height: 10})
|
||||
|
||||
want := limenexec.Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
if model.result.Message != tinyTerminalMessage {
|
||||
t.Fatalf("message = %q, want %q", model.result.Message, tinyTerminalMessage)
|
||||
}
|
||||
if model.View() != tinyTerminalMessage {
|
||||
t.Fatalf("View() = %q, want tiny terminal message", model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForProbeReturnsResultAndDoneMessages(t *testing.T) {
|
||||
probes := make(chan probe.ProbeResult, 1)
|
||||
probes <- probe.ProbeResult{HostName: "prod", Reachable: true}
|
||||
close(probes)
|
||||
|
||||
got, ok := waitForProbe(probes)().(probeMsg)
|
||||
if !ok {
|
||||
t.Fatalf("first probe message type = %T, want probeMsg", got)
|
||||
}
|
||||
if got.HostName != "prod" || !got.Reachable {
|
||||
t.Fatalf("first probe message = %#v", got)
|
||||
}
|
||||
if got := waitForProbe(probes)(); got != (probesDoneMsg{}) {
|
||||
t.Fatalf("second probe message = %#v, want done", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testApp() App {
|
||||
return App{
|
||||
Config: config.Config{Hosts: []config.Host{{
|
||||
Name: "prod",
|
||||
Hostname: "prod.example.com",
|
||||
User: "deploy",
|
||||
Description: "Production application servers.",
|
||||
}}},
|
||||
State: state.State{LastAttached: map[string]time.Time{
|
||||
"prod": time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC),
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Now: func() time.Time { return time.Date(2026, 5, 26, 14, 45, 0, 0, time.UTC) },
|
||||
Hostname: "renova.local",
|
||||
}
|
||||
}
|
||||
|
||||
func closedProbeResults() <-chan probe.ProbeResult {
|
||||
probes := make(chan probe.ProbeResult)
|
||||
close(probes)
|
||||
return probes
|
||||
}
|
||||
|
||||
func modelWithProdSessions(t *testing.T, sessions ...probe.SessionInfo) Model {
|
||||
t.Helper()
|
||||
return updateModel(t, NewModel(testApp()), probeMsg(probe.ProbeResult{
|
||||
HostName: "prod",
|
||||
Reachable: true,
|
||||
Sessions: sessions,
|
||||
}))
|
||||
}
|
||||
|
||||
func assertOrder(t *testing.T, text string, values ...string) {
|
||||
t.Helper()
|
||||
|
||||
last := -1
|
||||
for _, value := range values {
|
||||
index := strings.Index(text, value)
|
||||
if index == -1 {
|
||||
t.Fatalf("text missing %q:\n%s", value, text)
|
||||
}
|
||||
if index < last {
|
||||
t.Fatalf("%q appeared before prior value in:\n%s", value, text)
|
||||
}
|
||||
last = index
|
||||
}
|
||||
}
|
||||
|
||||
func press(t *testing.T, model Model, key tea.KeyMsg) Model {
|
||||
t.Helper()
|
||||
return updateModel(t, model, key)
|
||||
}
|
||||
|
||||
func updateModel(t *testing.T, model Model, msg tea.Msg) Model {
|
||||
t.Helper()
|
||||
updated, _ := model.Update(msg)
|
||||
next, ok := updated.(Model)
|
||||
if !ok {
|
||||
t.Fatalf("updated model type = %T, want ui.Model", updated)
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package theme defines the fixed v1 terminal styling for limen.
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
Accent = lipgloss.Color("#5fd7ff")
|
||||
Online = lipgloss.Color("#5fdf87")
|
||||
Unreachable = lipgloss.Color("#6c6c6c")
|
||||
Probing = lipgloss.Color("#87afdf")
|
||||
Dim = lipgloss.Color("#8a8a8a")
|
||||
|
||||
Title = lipgloss.NewStyle().
|
||||
Foreground(Accent).
|
||||
Bold(true)
|
||||
|
||||
Pane = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(Accent).
|
||||
Padding(0, 1)
|
||||
|
||||
Selected = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#0f1720")).
|
||||
Background(Accent)
|
||||
|
||||
Dimmed = lipgloss.NewStyle().
|
||||
Foreground(Dim)
|
||||
|
||||
OnlineStyle = lipgloss.NewStyle().
|
||||
Foreground(Online)
|
||||
|
||||
UnreachableStyle = lipgloss.NewStyle().
|
||||
Foreground(Unreachable)
|
||||
|
||||
ProbingStyle = lipgloss.NewStyle().
|
||||
Foreground(Probing)
|
||||
)
|
||||
@@ -1,34 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
"github.com/KofTwentyTwo/limen/internal/ui"
|
||||
)
|
||||
|
||||
type output struct {
|
||||
Config config.Config `json:"config"`
|
||||
State state.State `json:"state"`
|
||||
var version = "dev"
|
||||
|
||||
type appDeps struct {
|
||||
runUI func(ui.App) (ui.Result, error)
|
||||
handoff func(limenexec.Plan, limenexec.Options) int
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
os.Exit(runWithDeps(os.Args[1:], os.Stdout, os.Stderr, appDeps{
|
||||
runUI: ui.Run,
|
||||
handoff: limenexec.Handoff,
|
||||
}))
|
||||
}
|
||||
|
||||
func run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
return runWithDeps(args, stdout, stderr, appDeps{
|
||||
runUI: ui.Run,
|
||||
handoff: limenexec.Handoff,
|
||||
})
|
||||
}
|
||||
|
||||
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
flags := flag.NewFlagSet("limen", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
|
||||
configPath := flags.String("config", config.DefaultPath(), "path to hosts.json")
|
||||
statePath := flags.String("state", state.DefaultPath(), "path to state.json")
|
||||
showVersion := flags.Bool("version", false, "print version")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if *showVersion {
|
||||
fmt.Fprintln(stdout, version)
|
||||
return 0
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
fmt.Fprintf(stderr, "limen: unexpected argument %q\n", flags.Arg(0))
|
||||
return 2
|
||||
@@ -41,12 +63,21 @@ func run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
st := state.Load(*statePath)
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(output{Config: cfg, State: st}); err != nil {
|
||||
fmt.Fprintf(stderr, "limen: cannot write output: %v\n", err)
|
||||
|
||||
result, err := deps.runUI(ui.App{Config: cfg, State: st})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "limen: ui failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if !result.HasPlan {
|
||||
return result.ExitCode
|
||||
}
|
||||
if result.Message != "" {
|
||||
fmt.Fprintln(stderr, result.Message)
|
||||
}
|
||||
|
||||
return 0
|
||||
return deps.handoff(result.Plan, limenexec.Options{
|
||||
StatePath: *statePath,
|
||||
Stderr: stderr,
|
||||
})
|
||||
}
|
||||
+120
-75
@@ -2,15 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/ui"
|
||||
)
|
||||
|
||||
func TestRunPrintsConfigAndStateAsJSON(t *testing.T) {
|
||||
func TestRunLoadsConfigStateAndHandsOffSelectedPlan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "hosts.json")
|
||||
statePath := filepath.Join(dir, "state.json")
|
||||
@@ -22,88 +25,101 @@ func TestRunPrintsConfigAndStateAsJSON(t *testing.T) {
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(statePath, []byte(`{
|
||||
"lastAttached": {
|
||||
"prod": "2026-05-26T13:45:00Z"
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(statePath, []byte(`{"lastAttached":{"prod":"2026-05-26T13:45:00Z"}}`), 0o600); err != nil {
|
||||
t.Fatalf("write state fixture: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr)
|
||||
wantPlan := limenexec.Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod.example.com", "tmux", "new"}}
|
||||
var sawHosts int
|
||||
var sawStateEntries int
|
||||
var handedPlan limenexec.Plan
|
||||
var handedOptions limenexec.Options
|
||||
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", statePath}, &bytes.Buffer{}, &bytes.Buffer{}, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
sawHosts = len(app.Config.Hosts)
|
||||
sawStateEntries = len(app.State.LastAttached)
|
||||
return ui.Result{Plan: wantPlan, HasPlan: true}, nil
|
||||
},
|
||||
handoff: func(plan limenexec.Plan, opts limenexec.Options) int {
|
||||
handedPlan = plan
|
||||
handedOptions = opts
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String())
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
if sawHosts != 1 {
|
||||
t.Fatalf("UI saw %d hosts, want 1", sawHosts)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Config struct {
|
||||
Hosts []struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
User string `json:"user,omitempty"`
|
||||
} `json:"hosts"`
|
||||
} `json:"config"`
|
||||
State struct {
|
||||
LastAttached map[string]time.Time `json:"lastAttached"`
|
||||
} `json:"state"`
|
||||
if sawStateEntries != 1 {
|
||||
t.Fatalf("UI saw %d state entries, want 1", sawStateEntries)
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
if !reflect.DeepEqual(handedPlan, wantPlan) {
|
||||
t.Fatalf("handoff plan = %#v, want %#v", handedPlan, wantPlan)
|
||||
}
|
||||
|
||||
if len(got.Config.Hosts) != 1 {
|
||||
t.Fatalf("len(hosts) = %d, want 1", len(got.Config.Hosts))
|
||||
}
|
||||
if got.Config.Hosts[0].Name != "prod" || got.Config.Hosts[0].Hostname != "prod.example.com" || got.Config.Hosts[0].User != "deploy" {
|
||||
t.Fatalf("host = %#v", got.Config.Hosts[0])
|
||||
}
|
||||
|
||||
wantTime := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC)
|
||||
if gotTime := got.State.LastAttached["prod"]; !gotTime.Equal(wantTime) {
|
||||
t.Fatalf("state timestamp = %s, want %s", gotTime, wantTime)
|
||||
if handedOptions.StatePath != statePath {
|
||||
t.Fatalf("handoff state path = %q, want %q", handedOptions.StatePath, statePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWarnsForMissingConfigAndPrintsEmptyHosts(t *testing.T) {
|
||||
func TestRunPrintsResultMessageBeforeHandoff(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"hosts":[]}`), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &bytes.Buffer{}, &stderr, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
return ui.Result{
|
||||
Plan: limenexec.Plan{HostName: "localhost", Argv: []string{"tmux", "new"}},
|
||||
HasPlan: true,
|
||||
Message: "terminal too small for limen UI; falling back to bare tmux new",
|
||||
}, nil
|
||||
},
|
||||
handoff: func(plan limenexec.Plan, opts limenexec.Options) int {
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
want := "terminal too small for limen UI; falling back to bare tmux new\n"
|
||||
if stderr.String() != want {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWarnsForMissingConfigAndStillStartsUI(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
statePath := filepath.Join(t.TempDir(), "state.json")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr)
|
||||
var sawHosts int
|
||||
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", statePath}, &bytes.Buffer{}, &stderr, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
sawHosts = len(app.Config.Hosts)
|
||||
return ui.Result{ExitCode: 0}, nil
|
||||
},
|
||||
handoff: func(plan limenexec.Plan, opts limenexec.Options) int {
|
||||
t.Fatal("handoff should not be called")
|
||||
return 1
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String())
|
||||
}
|
||||
|
||||
wantWarning := "limen: no hosts.json found at " + configPath + "; only localhost will be available\n"
|
||||
if stderr.String() != wantWarning {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), wantWarning)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Config struct {
|
||||
Hosts []struct{} `json:"hosts"`
|
||||
} `json:"config"`
|
||||
State struct {
|
||||
LastAttached map[string]time.Time `json:"lastAttached"`
|
||||
} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Config.Hosts) != 0 {
|
||||
t.Fatalf("len(hosts) = %d, want 0", len(got.Config.Hosts))
|
||||
}
|
||||
if len(got.State.LastAttached) != 0 {
|
||||
t.Fatalf("len(lastAttached) = %d, want 0", len(got.State.LastAttached))
|
||||
if sawHosts != 0 {
|
||||
t.Fatalf("UI saw %d configured hosts, want 0", sawHosts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +131,7 @@ func TestRunReturnsFailureForConfigErrors(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &stdout, &stderr)
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &stdout, &stderr, appDeps{})
|
||||
|
||||
if code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
@@ -164,29 +180,58 @@ func TestRunReturnsUsageFailureForUnexpectedArguments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsFailureWhenJSONOutputCannotBeWritten(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
statePath := filepath.Join(t.TempDir(), "state.json")
|
||||
func TestRunReturnsVersion(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run([]string{"--version"}, &stdout, &stderr)
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
if stdout.String() != version+"\n" {
|
||||
t.Fatalf("stdout = %q, want version", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run([]string{"--help"}, &stdout, &stderr)
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "Usage of limen") {
|
||||
t.Fatalf("stderr = %q, want usage", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsUIFailure(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"hosts":[]}`), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", statePath}, failingWriter{}, &stderr)
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &bytes.Buffer{}, &stderr, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
return ui.Result{}, errors.New("terminal unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
if code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
|
||||
want := "limen: cannot write output: write failed\n"
|
||||
want := "limen: ui failed: terminal unavailable\n"
|
||||
if stderr.String() != want {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
Reference in new issue
Block a user