Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 75 additions & 23 deletions cmd/cloudstic/cmd_tui_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,32 @@ import (
"fmt"
"io"
"os"
"strconv"
"strings"

"github.com/cloudstic/cli/internal/tui"
)

type tuiAction int
type tuiActionKind int

const (
tuiActionNone tuiAction = iota
tuiActionNone tuiActionKind = iota
tuiActionUp
tuiActionDown
tuiActionRun
tuiActionCheck
tuiActionCreate
tuiActionEdit
tuiActionDelete
tuiActionSelectProfile
tuiActionQuit
)

type tuiAction struct {
Kind tuiActionKind
Profile string
}

func ensureSelectedProfile(d tui.Dashboard) tui.Dashboard {
if d.SelectedProfile != "" || len(d.Profiles) == 0 {
return d
Expand Down Expand Up @@ -68,64 +76,67 @@ func moveTUISelection(d tui.Dashboard, delta int) tui.Dashboard {
return d
}

func readTUIAction(r io.ByteReader) (tuiAction, error) {
func readTUIAction(r io.ByteReader, layout tui.DashboardLayout) (tuiAction, error) {
b, err := r.ReadByte()
if err != nil {
return tuiActionNone, err
return tuiAction{}, err
}
switch b {
case 'q', 'Q':
return tuiActionQuit, nil
return tuiAction{Kind: tuiActionQuit}, nil
case 'j', 'J':
return tuiActionDown, nil
return tuiAction{Kind: tuiActionDown}, nil
case 'k', 'K':
return tuiActionUp, nil
return tuiAction{Kind: tuiActionUp}, nil
case 'b', 'B':
return tuiActionRun, nil
return tuiAction{Kind: tuiActionRun}, nil
case 'c', 'C':
return tuiActionCheck, nil
return tuiAction{Kind: tuiActionCheck}, nil
case 'n', 'N':
return tuiActionCreate, nil
return tuiAction{Kind: tuiActionCreate}, nil
case 'e', 'E':
return tuiActionEdit, nil
return tuiAction{Kind: tuiActionEdit}, nil
case 'd', 'D':
return tuiActionDelete, nil
return tuiAction{Kind: tuiActionDelete}, nil
case 0x1b:
next, err := r.ReadByte()
if err != nil {
return tuiActionNone, nil
return tuiAction{}, nil
}
if next == 'O' {
dir, err := r.ReadByte()
if err != nil {
return tuiActionNone, nil
return tuiAction{}, nil
}
switch dir {
case 'A':
return tuiActionUp, nil
return tuiAction{Kind: tuiActionUp}, nil
case 'B':
return tuiActionDown, nil
return tuiAction{Kind: tuiActionDown}, nil
default:
return tuiActionNone, nil
return tuiAction{}, nil
}
}
if next != '[' {
return tuiActionNone, nil
return tuiAction{}, nil
}
csi, err := readTUICSISequence(r)
if err != nil || len(csi) == 0 {
return tuiActionNone, nil
return tuiAction{}, nil
}
if csi[0] == '<' {
return parseTUIMouseAction(csi, layout)
}
switch csi[len(csi)-1] {
case 'A':
return tuiActionUp, nil
return tuiAction{Kind: tuiActionUp}, nil
case 'B':
return tuiActionDown, nil
return tuiAction{Kind: tuiActionDown}, nil
default:
return tuiActionNone, nil
return tuiAction{}, nil
}
default:
return tuiActionNone, nil
return tuiAction{}, nil
}
}

Expand All @@ -146,6 +157,47 @@ func readTUICSISequence(r io.ByteReader) ([]byte, error) {
}
}

func parseTUIMouseAction(csi []byte, layout tui.DashboardLayout) (tuiAction, error) {
if len(csi) < 2 {
return tuiAction{}, nil
}
final := csi[len(csi)-1]
if final != 'M' {
return tuiAction{}, nil
}
parts := strings.Split(string(csi[1:len(csi)-1]), ";")
if len(parts) != 3 {
return tuiAction{}, nil
}
button, err := strconv.Atoi(parts[0])
if err != nil || button != 0 {
return tuiAction{}, nil
}
x, err := strconv.Atoi(parts[1])
if err != nil {
return tuiAction{}, nil
}
y, err := strconv.Atoi(parts[2])
if err != nil {
return tuiAction{}, nil
}
if !pointInRect(x, y, layout.ProfileRect) {
return tuiAction{}, nil
}
profile := layout.ProfileRows[y]
if profile == "" {
return tuiAction{}, nil
}
return tuiAction{Kind: tuiActionSelectProfile, Profile: profile}, nil
}

func pointInRect(x, y int, rect tui.Rect) bool {
if rect.W <= 0 || rect.H <= 0 {
return false
}
return x >= rect.X && x < rect.X+rect.W && y >= rect.Y && y < rect.Y+rect.H
}

func runSelectedTUIAction(ctx context.Context, r *runner, profilesFile string, dashboard tui.Dashboard, log *tuiActionState) error {
profile, ok := selectedTUIProfile(dashboard)
if !ok {
Expand Down
105 changes: 70 additions & 35 deletions cmd/cloudstic/cmd_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,92 +500,109 @@ func TestRunTUI_CreateActionUsesModalAndSavesProfile(t *testing.T) {
}

func TestReadTUIAction_ParsesCSIArrowKeys(t *testing.T) {
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[A")))
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[A")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction up: %v", err)
}
if ev != tuiActionUp {
t.Fatalf("up action=%v want %v", ev, tuiActionUp)
if ev.Kind != tuiActionUp {
t.Fatalf("up action=%v want %v", ev.Kind, tuiActionUp)
}

ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[B")))
ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[B")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction down: %v", err)
}
if ev != tuiActionDown {
t.Fatalf("down action=%v want %v", ev, tuiActionDown)
if ev.Kind != tuiActionDown {
t.Fatalf("down action=%v want %v", ev.Kind, tuiActionDown)
}
}

func TestReadTUIAction_ParsesParameterizedCSIArrowKeys(t *testing.T) {
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2A")))
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2A")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction param up: %v", err)
}
if ev != tuiActionUp {
t.Fatalf("param up action=%v want %v", ev, tuiActionUp)
if ev.Kind != tuiActionUp {
t.Fatalf("param up action=%v want %v", ev.Kind, tuiActionUp)
}

ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2B")))
ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2B")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction param down: %v", err)
}
if ev != tuiActionDown {
t.Fatalf("param down action=%v want %v", ev, tuiActionDown)
if ev.Kind != tuiActionDown {
t.Fatalf("param down action=%v want %v", ev.Kind, tuiActionDown)
}
}

func TestReadTUIAction_ParsesSS3ArrowKeys(t *testing.T) {
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOA")))
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOA")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction ss3 up: %v", err)
}
if ev != tuiActionUp {
t.Fatalf("ss3 up action=%v want %v", ev, tuiActionUp)
if ev.Kind != tuiActionUp {
t.Fatalf("ss3 up action=%v want %v", ev.Kind, tuiActionUp)
}

ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOB")))
ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOB")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction ss3 down: %v", err)
}
if ev != tuiActionDown {
t.Fatalf("ss3 down action=%v want %v", ev, tuiActionDown)
if ev.Kind != tuiActionDown {
t.Fatalf("ss3 down action=%v want %v", ev.Kind, tuiActionDown)
}
}

func TestReadTUIAction_ParsesCheckShortcut(t *testing.T) {
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("c")))
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("c")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction check: %v", err)
}
if ev != tuiActionCheck {
t.Fatalf("check action=%v want %v", ev, tuiActionCheck)
if ev.Kind != tuiActionCheck {
t.Fatalf("check action=%v want %v", ev.Kind, tuiActionCheck)
}
}

func TestReadTUIAction_ParsesManagementShortcuts(t *testing.T) {
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("n")))
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("n")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction create: %v", err)
}
if ev != tuiActionCreate {
t.Fatalf("create action=%v want %v", ev, tuiActionCreate)
if ev.Kind != tuiActionCreate {
t.Fatalf("create action=%v want %v", ev.Kind, tuiActionCreate)
}

ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("e")))
ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("e")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction edit: %v", err)
}
if ev != tuiActionEdit {
t.Fatalf("edit action=%v want %v", ev, tuiActionEdit)
if ev.Kind != tuiActionEdit {
t.Fatalf("edit action=%v want %v", ev.Kind, tuiActionEdit)
}

ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("d")))
ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("d")), tui.DashboardLayout{})
if err != nil {
t.Fatalf("readTUIAction delete: %v", err)
}
if ev != tuiActionDelete {
t.Fatalf("delete action=%v want %v", ev, tuiActionDelete)
if ev.Kind != tuiActionDelete {
t.Fatalf("delete action=%v want %v", ev.Kind, tuiActionDelete)
}
}

func TestReadTUIAction_ParsesProfileClick(t *testing.T) {
layout := tui.DashboardLayout{
ProfileRows: map[int]string{8: "photos"},
ProfileRect: tui.Rect{X: 1, Y: 5, W: 20, H: 6},
}
ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[<0;5;8M")), layout)
if err != nil {
t.Fatalf("readTUIAction click: %v", err)
}
if ev.Kind != tuiActionSelectProfile {
t.Fatalf("click action=%v want %v", ev.Kind, tuiActionSelectProfile)
}
if ev.Profile != "photos" {
t.Fatalf("click profile=%q want photos", ev.Profile)
}
}

Expand Down Expand Up @@ -697,7 +714,7 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) {
},
})

if _, err := s.handleAction(context.Background(), tuiActionRun); err != nil {
if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionRun}); err != nil {
t.Fatalf("handleAction(run): %v", err)
}
if s.dashboard.SelectedProfile != "docs" {
Expand Down Expand Up @@ -771,7 +788,7 @@ func TestTUISession_HandleActionRunRefreshFailureRestoresRawMode(t *testing.T) {
})
s.rawState = state

if _, err := s.handleAction(context.Background(), tuiActionRun); err == nil {
if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionRun}); err == nil {
t.Fatalf("expected refresh failure")
}
if madeRaw != 1 || restored != 1 {
Expand Down Expand Up @@ -817,7 +834,7 @@ func TestTUISession_HandleActionCreateRefreshesDashboard(t *testing.T) {

var out strings.Builder
s := newTUISession(&runner{out: &out, stdoutFile: os.Stdout, stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, profilesPath, tui.Dashboard{})
if _, err := s.handleAction(context.Background(), tuiActionCreate); err != nil {
if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionCreate}); err != nil {
t.Fatalf("handleAction(create): %v", err)
}
if s.dashboard.SelectedProfile != "photos" {
Expand Down Expand Up @@ -869,7 +886,7 @@ func TestTUISession_HandleActionDeleteRefreshesDashboard(t *testing.T) {
s.r.stdin = readEnd
s.r.lineIn = bufio.NewReader(readEnd)
s.profilesFile = profilesPath
if _, err := s.handleAction(context.Background(), tuiActionDelete); err != nil {
if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionDelete}); err != nil {
t.Fatalf("handleAction(delete): %v", err)
}
if s.dashboard.SelectedProfile != "photos" {
Expand All @@ -880,6 +897,24 @@ func TestTUISession_HandleActionDeleteRefreshesDashboard(t *testing.T) {
}
}

func TestTUISession_HandleActionSelectProfileRefreshesSelection(t *testing.T) {
stubTUITestHooks(t)

s := newTUISession(&runner{out: io.Discard, stdoutFile: os.Stdout, stdin: os.Stdin}, "profiles.yaml", tui.Dashboard{
SelectedProfile: "docs",
Profiles: []tui.ProfileCard{
{Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady},
{Name: "photos", Source: "local:/photos", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady},
},
})
if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionSelectProfile, Profile: "photos"}); err != nil {
t.Fatalf("handleAction(select): %v", err)
}
if s.dashboard.SelectedProfile != "photos" {
t.Fatalf("selected profile=%q want photos", s.dashboard.SelectedProfile)
}
}

func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) {
oldBuild := tuiBuildDashboard
t.Cleanup(func() { tuiBuildDashboard = oldBuild })
Expand Down Expand Up @@ -992,7 +1027,7 @@ func TestReadInput_ClosesChannelOnEOF(t *testing.T) {
_ = writeEnd.Close()

s := newTUISession(&runner{stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, "", tui.Dashboard{})
readPermitCh := make(chan struct{}, 1)
readPermitCh := make(chan tui.DashboardLayout, 1)
eventCh := make(chan tuiAction, 2)
errCh := make(chan error, 1)
close(readPermitCh)
Expand Down
Loading
Loading