feat: coordinate Active Profile target panel

This commit is contained in:
proxyctl coordinator
2026-07-17 04:05:14 +08:00
parent 34b45c807f
commit fd113fb316
11 changed files with 652 additions and 36 deletions
+147 -22
View File
@@ -17,6 +17,7 @@ const (
mainPage page = iota
profilesPage
formPage
helpPage
)
type formState struct {
@@ -26,21 +27,40 @@ type formState struct {
}
type Model struct {
application app.Module
snapshot app.Snapshot
page page
selected int
form formState
confirm bool
targetConfirm *app.Intent
status string
applying bool
application app.Module
snapshot app.Snapshot
page page
selected int
form formState
confirm bool
targetConfirm *app.Intent
profileConfirm *app.Intent
pendingActivation app.Intent
status string
applying bool
profileTransition bool
progressTarget string
}
type targetOperationDone struct {
result app.Result
}
type profileOperationProgress struct {
result app.Result
updates <-chan app.Result
}
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
return func() tea.Msg {
result, ok := <-updates
if !ok {
return profileOperationProgress{result: app.Result{Complete: true}, updates: updates}
}
return profileOperationProgress{result: result, updates: updates}
}
}
func NewModel(application app.Module) Model {
return Model{application: application, snapshot: application.Snapshot()}
}
@@ -48,6 +68,38 @@ 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 progress, ok := message.(profileOperationProgress); ok {
m.snapshot = progress.result.Snapshot
m.progressTarget = ""
if len(progress.result.Progress) > 0 {
item := progress.result.Progress[len(progress.result.Progress)-1]
if item.Applying {
m.progressTarget = item.TargetID
}
}
if progress.result.Confirmation != "" {
m.applying = false
m.profileTransition = false
m.page = profilesPage
intent := m.pendingActivation
m.profileConfirm = &intent
m.status = progress.result.Confirmation
return m, nil
}
if progress.result.Err != nil {
m.status = "\x1b[31mError: " + progress.result.Err.Error() + "\x1b[0m"
} else if progress.result.Notice != "" {
m.status = progress.result.Notice
}
if progress.result.Complete {
m.applying = false
m.profileTransition = false
m.progressTarget = ""
m.pendingActivation = app.Intent{}
return m, nil
}
return m, waitForProfileProgress(progress.updates)
}
if completed, ok := message.(targetOperationDone); ok {
m.applying = false
m.snapshot = completed.result.Snapshot
@@ -78,6 +130,11 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m.updateProfiles(key)
case formPage:
return m.updateForm(key)
case helpPage:
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
}
return m, nil
default:
return m, nil
}
@@ -103,7 +160,23 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "p":
m.page = profilesPage
m.selected = 0
m.status = ""
case "?":
m.page = helpPage
m.status = ""
case "r":
if !m.applying {
result := m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})
m.snapshot = result.Snapshot
m.status = result.Notice
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
}
if m.selected >= len(m.availableTargets()) && m.selected > 0 {
m.selected--
}
}
case "up", "k":
if !m.applying && m.selected > 0 {
m.selected--
@@ -115,7 +188,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case " ":
if !m.applying && m.selected >= 0 && m.selected < len(targets) {
selected := targets[m.selected]
desired := selected.State != target.Enabled
desired := selected.State == target.Disabled || selected.State == target.External
m.applying = true
m.status = ""
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
@@ -131,6 +204,19 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.profileConfirm != nil {
switch key.String() {
case "y":
intent := *m.profileConfirm
intent.Confirm = true
m.profileConfirm = nil
return m.beginProfileActivation(intent)
case "n", "esc", "q":
m.profileConfirm = nil
m.status = ""
}
return m, nil
}
if m.confirm {
switch key.String() {
case "y":
@@ -147,6 +233,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "q", "esc":
m.page = mainPage
m.selected = 0
m.status = ""
case "up", "k":
if m.selected > 0 {
@@ -168,7 +255,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case "a", "enter":
if selected, ok := m.selectedProfile(); ok {
m.apply(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
}
case "d":
if _, ok := m.selectedProfile(); ok {
@@ -230,9 +317,29 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
if progressive, ok := m.application.(app.ProgressiveModule); ok {
m.applying = true
m.profileTransition = true
m.pendingActivation = intent
m.page = mainPage
m.selected = 0
m.status = "Changing Active Profile…"
updates := progressive.DispatchProgress(intent)
return m, waitForProfileProgress(updates)
}
m.apply(intent)
return m, nil
}
func (m *Model) apply(intent app.Intent) {
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if result.Confirmation != "" {
m.profileConfirm = &intent
m.status = result.Confirmation
return
}
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
m.selected--
}
@@ -265,6 +372,8 @@ func (m Model) View() string {
return m.profilesView()
case formPage:
return m.formView()
case helpPage:
return m.helpView()
default:
return m.mainView()
}
@@ -291,7 +400,7 @@ func (m Model) mainView() string {
}
targets := m.availableTargets()
if len(targets) == 0 {
b.WriteString("\nNo Targets are available in this build.\n")
b.WriteString("\nNo Targets are currently Available. Press ? to review detection reasons.\n")
} else {
b.WriteString("\nTargets\n")
for i, item := range targets {
@@ -300,25 +409,20 @@ func (m Model) mainView() string {
cursor = "> "
}
state := string(item.State)
if m.applying && i == m.selected {
if (m.progressTarget != "" && item.ID == m.progressTarget) || (!m.profileTransition && m.progressTarget == "" && 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)
}
}
for _, item := range m.snapshot.Targets {
if !item.Available {
fmt.Fprintf(&b, "\nUnavailable %s: %s\n", item.Description.Name, item.AvailabilityEvidence)
fmt.Fprintf(&b, "\n%s\n%s\n%s\n", selected.Description.Summary, selected.Description.Details, selected.Evidence)
}
}
if m.targetConfirm != nil && !m.applying {
b.WriteString("\ny: confirm n/Esc: cancel\n")
} else {
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n")
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles r: refresh ?: help q/Esc: quit\n")
}
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
@@ -326,6 +430,21 @@ func (m Model) mainView() string {
return b.String()
}
func (m Model) helpView() string {
var b strings.Builder
b.WriteString("proxyctl Help — built-in Targets\n\n")
for _, item := range m.snapshot.Targets {
fmt.Fprintf(&b, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details)
if item.Available {
fmt.Fprintf(&b, "Detected: %s\n\n", item.AvailabilityEvidence)
} else {
fmt.Fprintf(&b, "Not detected: %s\n\n", item.AvailabilityEvidence)
}
}
b.WriteString("Keys: ↑/k and ↓/j navigate; Space toggles; p manages Proxy Profiles; r refreshes; ? opens help; q/Escape returns or exits.\n")
return b.String()
}
func (m Model) profilesView() string {
var b strings.Builder
b.WriteString("Proxy Profiles\n\n")
@@ -353,8 +472,14 @@ func (m Model) profilesView() string {
}
fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", "))
}
if m.confirm {
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
if m.profileConfirm != nil {
b.WriteString("\ny: confirm n/Esc: cancel\n")
} else if m.confirm {
if len(m.snapshot.Profiles) == 1 {
b.WriteString("\nDelete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted. y: confirm n/Esc: cancel\n")
} else {
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
}
} else {
b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n")
}
+96 -6
View File
@@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
@@ -32,7 +33,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
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, "Unavailable APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
t.Fatalf("Target panel:\n%s", view)
}
@@ -85,13 +86,102 @@ func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T)
}
}
func TestUnavailableShellIsOutsideMainListWithDetectionExplanation(t *testing.T) {
func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: false, AvailabilityEvidence: "Shell Target requires Bash or Zsh; neither was detected"},
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "summary", Details: "affects future Shell commands; does not affect daemons; owns a generated fragment; user permission; reload side effect"}, Available: true, State: target.Disabled, Evidence: "integration installed"},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "persistent package proxy", Details: "owns an overlay and requires system write permission"}, Available: false, AvailabilityEvidence: "Debian APT tool apt-config was not detected"},
}}}
view := NewModel(module).View()
if strings.Contains(view, "[Disabled ] Shell") || !strings.Contains(view, "Unavailable Shell") || !strings.Contains(view, "requires Bash or Zsh") {
t.Fatalf("unavailable Shell explanation:\n%s", view)
model := NewModel(module)
view := model.View()
if strings.Contains(view, "APT") || !strings.Contains(view, "affects future Shell commands") || !strings.Contains(view, "integration installed") {
t.Fatalf("main Target panel:\n%s", view)
}
model = updateKey(model, "?")
view = model.View()
if !strings.Contains(view, "Shell") || !strings.Contains(view, "APT") || !strings.Contains(view, "Not detected: Debian APT tool apt-config was not detected") || !strings.Contains(view, "owns an overlay") {
t.Fatalf("Target help:\n%s", view)
}
model = updateKey(model, "esc")
if !strings.Contains(model.View(), "Target panel") {
t.Fatalf("Escape did not return from help:\n%s", model.View())
}
}
func TestRefreshKeyDispatchesDetectionRefresh(t *testing.T) {
module := &targetModule{snapshot: app.Snapshot{}, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "apt", Description: target.Description{Name: "APT"}, Available: true, State: target.External}}}}}
model := NewModel(module)
model = updateKey(model, "r")
if len(module.intents) != 1 || module.intents[0].Kind != app.RefreshTargets || !strings.Contains(model.View(), "External") {
t.Fatalf("refresh = intents %#v\n%s", module.intents, model.View())
}
}
type progressiveTargetModule struct {
targetModule
updates chan app.Result
}
func (m *progressiveTargetModule) DispatchProgress(intent app.Intent) <-chan app.Result {
m.intents = append(m.intents, intent)
return m.updates
}
func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
initial := app.Snapshot{ActiveProfile: "old", Profiles: []profile.Profile{{Name: "new"}}, Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
updates := make(chan app.Result, 3)
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: initial.Targets}, Progress: []app.TargetProgress{{TargetID: "shell", Applying: true}}}
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Progress: []app.TargetProgress{{TargetID: "shell", State: target.Enabled}}, Notice: "Shell Target update completed"}
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Complete: true, Notice: "Activated Profile new"}
close(updates)
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command == nil {
t.Fatal("activation did not start progressive operation")
}
updated, command = model.Update(command())
model = updated.(Model)
if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") {
t.Fatalf("applying progress:\n%s", model.View())
}
updated, command = model.Update(command())
model = updated.(Model)
if command == nil || !strings.Contains(model.View(), "Enabled") || !strings.Contains(model.View(), "update completed") {
t.Fatalf("Target completion:\n%s", model.View())
}
updated, _ = model.Update(command())
model = updated.(Model)
if !strings.Contains(model.View(), "Activated Profile new") {
t.Fatalf("final transition:\n%s", model.View())
}
}
func TestTargetPanelRendersEveryTargetState(t *testing.T) {
states := []target.State{target.Enabled, target.Disabled, target.External, target.Drifted}
var targets []app.TargetSnapshot
for _, state := range states {
targets = append(targets, app.TargetSnapshot{ID: string(state), Description: target.Description{Name: string(state)}, Available: true, State: state})
}
view := NewModel(&targetModule{snapshot: app.Snapshot{Targets: targets}}).View()
for _, state := range states {
if !strings.Contains(view, string(state)) {
t.Fatalf("missing state %s:\n%s", state, view)
}
}
}
func TestDriftedTargetWithoutActiveProfileTogglesTowardDisabled(t *testing.T) {
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}}}
model := NewModel(module)
_, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
if command == nil {
t.Fatal("Drifted Target did not start disable")
}
_ = command()
if len(module.intents) != 1 || module.intents[0].Enabled {
t.Fatalf("Drifted Target intent = %#v", module.intents)
}
}