feat: coordinate Active Profile target panel
This commit is contained in:
+147
-22
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user