feat: safely control Shell Target

This commit is contained in:
proxyctl coordinator
2026-07-17 02:59:41 +08:00
parent 4e3f780eba
commit 12e857932b
15 changed files with 1552 additions and 16 deletions
+70 -2
View File
@@ -8,6 +8,7 @@ import (
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
type page int
@@ -32,6 +33,11 @@ type Model struct {
form formState
confirm bool
status string
applying bool
}
type targetOperationDone struct {
result app.Result
}
func NewModel(application app.Module) Model {
@@ -41,6 +47,16 @@ func NewModel(application app.Module) Model {
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if completed, ok := message.(targetOperationDone); ok {
m.applying = false
m.snapshot = completed.result.Snapshot
if completed.result.Err != nil {
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
} else {
m.status = completed.result.Notice
}
return m, nil
}
key, ok := message.(tea.KeyMsg)
if !ok {
return m, nil
@@ -58,10 +74,29 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
targets := m.availableTargets()
switch key.String() {
case "p":
m.page = profilesPage
m.status = ""
case "up", "k":
if !m.applying && m.selected > 0 {
m.selected--
}
case "down", "j":
if !m.applying && m.selected+1 < len(targets) {
m.selected++
}
case " ":
if !m.applying && m.selected >= 0 && m.selected < len(targets) {
selected := targets[m.selected]
desired := selected.State != target.Enabled
m.applying = true
m.status = ""
return m, func() tea.Msg {
return targetOperationDone{result: m.application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired})}
}
}
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
@@ -208,6 +243,16 @@ func (m Model) View() string {
}
}
func (m Model) availableTargets() []app.TargetSnapshot {
var result []app.TargetSnapshot
for _, item := range m.snapshot.Targets {
if item.Available {
result = append(result, item)
}
}
return result
}
func (m Model) mainView() string {
var b strings.Builder
b.WriteString("proxyctl — Target panel\n\n")
@@ -217,8 +262,31 @@ func (m Model) mainView() string {
} else {
fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile)
}
b.WriteString("\nNo Targets are available in this build.\n")
b.WriteString("\np: manage Proxy Profiles q/Esc: quit\n")
targets := m.availableTargets()
if len(targets) == 0 {
b.WriteString("\nNo Targets are available in this build.\n")
} else {
b.WriteString("\nTargets\n")
for i, item := range targets {
cursor := " "
if i == m.selected {
cursor = "> "
}
state := string(item.State)
if m.applying && i == m.selected {
state = "Applying…"
}
fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name)
}
if m.selected >= 0 && m.selected < len(targets) {
selected := targets[m.selected]
fmt.Fprintf(&b, "\n%s\n%s\n", selected.Description.Summary, selected.Evidence)
}
}
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n")
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
}
+64
View File
@@ -0,0 +1,64 @@
package tui
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/target"
)
type targetModule struct {
snapshot app.Snapshot
result app.Result
intents []app.Intent
}
func (m *targetModule) Snapshot() app.Snapshot { return m.snapshot }
func (m *targetModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent)
m.snapshot = m.result.Snapshot
return m.result
}
func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing.T) {
module := &targetModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.External, Evidence: "existing proxy"},
{ID: "apt", Description: target.Description{Name: "APT"}, Available: false},
}}}
module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"}
model := NewModel(module)
view := model.View()
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
t.Fatalf("Target panel:\n%s", view)
}
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command == nil || !strings.Contains(model.View(), "Applying") || len(module.intents) != 0 {
t.Fatalf("operation did not expose applying state before effect:\n%s", model.View())
}
message := command()
updated, _ = model.Update(message)
model = updated.(Model)
if len(module.intents) != 1 || module.intents[0].Kind != app.SetTargetDesired || !module.intents[0].Enabled || !strings.Contains(model.View(), "Enabled") {
t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View())
}
}
func TestTargetFailurePreservesTruthfulStateAndRendersRedactedError(t *testing.T) {
initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("permission denied for alice:REDACTED@proxy")}}
model := NewModel(module)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
updated, _ = model.Update(command())
model = updated.(Model)
view := model.View()
if !strings.Contains(view, "Enabled") || !strings.Contains(view, "\x1b[31mError:") || !strings.Contains(view, "REDACTED") {
t.Fatalf("failed operation rendering:\n%s", view)
}
}