diff --git a/internal/tui/api/api.go b/internal/tui/api/api.go index 394a485..b1cef0a 100644 --- a/internal/tui/api/api.go +++ b/internal/tui/api/api.go @@ -162,10 +162,22 @@ func LoadData() tea.Cmd { return msg.Error{Err: errT} } + // Load all domains and env vars for all pods + var podDomains []model.PodDomain + var podEnvVars []model.PodEnvVar + for _, p := range pods { + domains, _ := fetchPodDomains(p.ID) + podDomains = append(podDomains, domains...) + vars, _ := fetchPodEnvVars(p.ID) + podEnvVars = append(podEnvVars, vars...) + } + return msg.DataLoaded{ - Projects: projects, - Pods: pods, - GitTokens: gitTokens, + Projects: projects, + Pods: pods, + GitTokens: gitTokens, + PodDomains: podDomains, + PodEnvVars: podEnvVars, } } } @@ -199,7 +211,12 @@ func CreateProject(title string) tea.Cmd { } defer resp.Body.Close() - return msg.ProjectCreated{} + var created model.Project + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return msg.Error{Err: err} + } + + return msg.ProjectCreated{Project: created} } } @@ -211,7 +228,12 @@ func UpdateProject(project *model.Project) tea.Cmd { } defer resp.Body.Close() - return msg.ProjectUpdated{} + var updated model.Project + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + return msg.Error{Err: err} + } + + return msg.ProjectUpdated{Project: updated} } } @@ -223,7 +245,7 @@ func DeleteProject(id string) tea.Cmd { } defer resp.Body.Close() - return msg.ProjectDeleted{} + return msg.ProjectDeleted{ProjectID: id} } } @@ -252,7 +274,12 @@ func CreatePod(pod *model.Pod) tea.Cmd { } defer resp.Body.Close() - return msg.PodCreated{} + var created model.Pod + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return msg.Error{Err: err} + } + + return msg.PodCreated{Pod: created} } } @@ -264,11 +291,16 @@ func UpdatePod(pod *model.Pod) tea.Cmd { } defer resp.Body.Close() - return msg.PodUpdated{} + var updated model.Pod + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + return msg.Error{Err: err} + } + + return msg.PodUpdated{Pod: updated} } } -func DeletePod(id string) tea.Cmd { +func DeletePod(id, projectID string) tea.Cmd { return func() tea.Msg { resp, err := del("/pods/" + id) if err != nil { @@ -276,7 +308,7 @@ func DeletePod(id string) tea.Cmd { } defer resp.Body.Close() - return msg.PodDeleted{} + return msg.PodDeleted{PodID: id, ProjectID: projectID} } } @@ -373,7 +405,12 @@ func CreateGitToken(name, provider, token string) tea.Cmd { } defer resp.Body.Close() - return msg.GitTokenCreated{} + var created model.GitToken + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return msg.Error{Err: err} + } + + return msg.GitTokenCreated{Token: created} } } @@ -385,28 +422,25 @@ func DeleteGitToken(id string) tea.Cmd { } defer resp.Body.Close() - return msg.GitTokenDeleted{} + return msg.GitTokenDeleted{TokenID: id} } } // --- Pod Domains --- -func FetchPodDomains(podID string) tea.Cmd { - return func() tea.Msg { - resp, err := get("/pods/" + podID + "/domains") - if err != nil { - return msg.Error{Err: err} - } - defer resp.Body.Close() - - var domains []model.PodDomain - err = json.NewDecoder(resp.Body).Decode(&domains) - if err != nil { - return msg.Error{Err: err} - } +func fetchPodDomains(podID string) ([]model.PodDomain, error) { + resp, err := get("/pods/" + podID + "/domains") + if err != nil { + return nil, err + } + defer resp.Body.Close() - return msg.PodDomainsLoaded{Domains: domains} + var domains []model.PodDomain + err = json.NewDecoder(resp.Body).Decode(&domains) + if err != nil { + return nil, err } + return domains, nil } func CreatePodDomain(podID, domain string, port int, sslEnabled bool) tea.Cmd { @@ -427,7 +461,12 @@ func CreatePodDomain(podID, domain string, port int, sslEnabled bool) tea.Cmd { } defer resp.Body.Close() - return msg.PodDomainCreated{} + var created model.PodDomain + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return msg.Error{Err: err} + } + + return msg.PodDomainCreated{Domain: created} } } @@ -439,7 +478,7 @@ func DeletePodDomain(podID, domainID string) tea.Cmd { } defer resp.Body.Close() - return msg.PodDomainDeleted{} + return msg.PodDomainDeleted{DomainID: domainID, PodID: podID} } } @@ -457,7 +496,12 @@ func UpdatePodDomain(podID, domainID, domain string, port int, sslEnabled bool) } defer resp.Body.Close() - return msg.PodDomainUpdated{} + var updated model.PodDomain + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + return msg.Error{Err: err} + } + + return msg.PodDomainUpdated{Domain: updated} } } @@ -477,28 +521,30 @@ func GenerateAutoDomain(podID string, port int, sslEnabled bool) tea.Cmd { } defer resp.Body.Close() - return msg.PodDomainCreated{} + var created model.PodDomain + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return msg.Error{Err: err} + } + + return msg.PodDomainCreated{Domain: created} } } // --- Pod Env Vars --- -func FetchPodEnvVars(podID string) tea.Cmd { - return func() tea.Msg { - resp, err := get("/pods/" + podID + "/vars") - if err != nil { - return msg.Error{Err: err} - } - defer resp.Body.Close() - - var envVars []model.PodEnvVar - err = json.NewDecoder(resp.Body).Decode(&envVars) - if err != nil { - return msg.Error{Err: err} - } +func fetchPodEnvVars(podID string) ([]model.PodEnvVar, error) { + resp, err := get("/pods/" + podID + "/vars") + if err != nil { + return nil, err + } + defer resp.Body.Close() - return msg.PodEnvVarsLoaded{EnvVars: envVars} + var envVars []model.PodEnvVar + err = json.NewDecoder(resp.Body).Decode(&envVars) + if err != nil { + return nil, err } + return envVars, nil } func UpdatePodEnvVars(podID string, vars []model.PodEnvVar) tea.Cmd { @@ -513,7 +559,12 @@ func UpdatePodEnvVars(podID string, vars []model.PodEnvVar) tea.Cmd { } defer resp.Body.Close() - return msg.PodEnvVarsUpdated{} + var updated []model.PodEnvVar + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + return msg.Error{Err: err} + } + + return msg.PodEnvVarsUpdated{PodID: podID, EnvVars: updated} } } diff --git a/internal/tui/msg/msg.go b/internal/tui/msg/msg.go index 3e84632..28cf02c 100644 --- a/internal/tui/msg/msg.go +++ b/internal/tui/msg/msg.go @@ -11,11 +11,14 @@ type ChangePage struct { PageFactory func(s Store) tea.Model } + // Store interface for page factories type Store interface { Projects() []model.Project Pods() []model.Pod GitTokens() []model.GitToken + PodDomains(podID string) []model.PodDomain + PodEnvVars(podID string) []model.PodEnvVar } // --- Connection --- @@ -43,25 +46,31 @@ type AuthSuccess struct{} // --- Data Loaded --- type DataLoaded struct { - Projects []model.Project - Pods []model.Pod - GitTokens []model.GitToken + Projects []model.Project + Pods []model.Pod + GitTokens []model.GitToken + PodDomains []model.PodDomain + PodEnvVars []model.PodEnvVar } type ProjectsLoaded struct{ Projects []model.Project } type PodsLoaded struct{ Pods []model.Pod } -// --- CRUD Success (trigger reload) --- +// --- CRUD Success (optimistic updates) --- -type ProjectCreated struct{} -type ProjectUpdated struct{} -type ProjectDeleted struct{} -type PodCreated struct{} -type PodUpdated struct{} -type PodDeleted struct{} +type ProjectCreated struct{ Project model.Project } +type ProjectUpdated struct{ Project model.Project } +type ProjectDeleted struct{ ProjectID string } +type PodCreated struct{ Pod model.Pod } +type PodUpdated struct{ Pod model.Pod } +type PodDeleted struct { + PodID string + ProjectID string +} // --- Pod Deploy --- +type PodLoaded struct{ Pod model.Pod } type PodDeployed struct{} type PodStopped struct{} type PodRestarted struct{} @@ -69,20 +78,26 @@ type PodLogsLoaded struct{ Logs []string } // --- Git Tokens --- -type GitTokenCreated struct{} -type GitTokenDeleted struct{} +type GitTokenCreated struct{ Token model.GitToken } +type GitTokenDeleted struct{ TokenID string } // --- Pod Domains --- type PodDomainsLoaded struct{ Domains []model.PodDomain } -type PodDomainCreated struct{} -type PodDomainUpdated struct{} -type PodDomainDeleted struct{} +type PodDomainCreated struct{ Domain model.PodDomain } +type PodDomainUpdated struct{ Domain model.PodDomain } +type PodDomainDeleted struct { + DomainID string + PodID string +} // --- Pod Env Vars --- type PodEnvVarsLoaded struct{ EnvVars []model.PodEnvVar } -type PodEnvVarsUpdated struct{} +type PodEnvVarsUpdated struct { + PodID string + EnvVars []model.PodEnvVar +} // --- Server Settings --- diff --git a/internal/tui/ui/page/app.go b/internal/tui/ui/page/app.go index df2eac3..8b9f846 100644 --- a/internal/tui/ui/page/app.go +++ b/internal/tui/ui/page/app.go @@ -2,6 +2,7 @@ package page import ( "fmt" + "slices" "strings" "time" @@ -34,6 +35,8 @@ type app struct { projects []model.Project pods []model.Pod gitTokens []model.GitToken + podDomains []model.PodDomain + podEnvVars []model.PodEnvVar width int height int heartbeatStarted bool @@ -63,6 +66,26 @@ func (m *app) GitTokens() []model.GitToken { return m.gitTokens } +func (m *app) PodDomains(podID string) []model.PodDomain { + var result []model.PodDomain + for _, d := range m.podDomains { + if d.PodID == podID { + result = append(result, d) + } + } + return result +} + +func (m *app) PodEnvVars(podID string) []model.PodEnvVar { + var result []model.PodEnvVar + for _, v := range m.podEnvVars { + if v.PodID == podID { + result = append(result, v) + } + } + return result +} + func (m *app) clearStatusAfter(d time.Duration) tea.Cmd { return tea.Tick(d, func(t time.Time) tea.Msg { return msg.ClearStatus{} @@ -242,6 +265,8 @@ func (m app) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { m.projects = tmsg.Projects m.pods = tmsg.Pods m.gitTokens = tmsg.GitTokens + m.podDomains = tmsg.PodDomains + m.podEnvVars = tmsg.PodEnvVars // Forward to current page so it can update its list var cmd tea.Cmd @@ -302,17 +327,192 @@ func (m app) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { m.statusText = "" return m, m.spinner.Tick - case msg.ProjectCreated, msg.ProjectUpdated, msg.ProjectDeleted, - msg.PodCreated, msg.PodUpdated, msg.PodDeleted, - msg.PodDeployed, msg.PodStopped, msg.PodRestarted, - msg.GitTokenCreated, msg.GitTokenDeleted, - msg.PodDomainCreated, msg.PodDomainUpdated, msg.PodDomainDeleted, - msg.PodEnvVarsUpdated: + // --- Project CRUD --- + case msg.ProjectCreated: + m.projects = append(m.projects, tmsg.Project) + m.isLoading = false + projectID := tmsg.Project.ID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Project created", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewProjectDetail(s, projectID) }, + } + }, + ) + + case msg.ProjectUpdated: + for i, p := range m.projects { + if p.ID == tmsg.Project.ID { + m.projects[i] = tmsg.Project + break + } + } + m.isLoading = false + projectID := tmsg.Project.ID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Project updated", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewProjectDetail(s, projectID) }, + } + }, + ) + + case msg.ProjectDeleted: + m.projects = slices.DeleteFunc(m.projects, func(p model.Project) bool { return p.ID == tmsg.ProjectID }) + m.isLoading = false + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Project deleted", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewDashboard(s) }, + } + }, + ) + + // --- Pod CRUD --- + case msg.PodCreated: + m.pods = append(m.pods, tmsg.Pod) + m.isLoading = false + podID := tmsg.Pod.ID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Pod created", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewPodDetail(s, podID) }, + } + }, + ) + + case msg.PodUpdated: + for i, p := range m.pods { + if p.ID == tmsg.Pod.ID { + m.pods[i] = tmsg.Pod + break + } + } + m.isLoading = false + podID := tmsg.Pod.ID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Pod updated", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewPodDetail(s, podID) }, + } + }, + ) + + case msg.PodDeleted: + m.pods = slices.DeleteFunc(m.pods, func(p model.Pod) bool { return p.ID == tmsg.PodID }) + m.isLoading = false + projectID := tmsg.ProjectID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Pod deleted", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewProjectDetail(s, projectID) }, + } + }, + ) + + // --- Pod Deploy/Stop/Restart (no store update, just clear loading) --- + case msg.PodDeployed, msg.PodStopped, msg.PodRestarted: m.isLoading = false var cmd tea.Cmd m.currentPage, cmd = m.currentPage.Update(tmsg) return m, cmd + // --- Git Token CRUD --- + case msg.GitTokenCreated: + m.gitTokens = append(m.gitTokens, tmsg.Token) + m.isLoading = false + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Token created", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewGitTokens(s.GitTokens()) }, + } + }, + ) + + case msg.GitTokenDeleted: + m.gitTokens = slices.DeleteFunc(m.gitTokens, func(t model.GitToken) bool { return t.ID == tmsg.TokenID }) + m.isLoading = false + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Token deleted", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewGitTokens(s.GitTokens()) }, + } + }, + ) + + // --- Pod Domains (no store update, just navigate back to PodDetail) --- + case msg.PodDomainCreated: + m.podDomains = append(m.podDomains, tmsg.Domain) + m.isLoading = false + podID := tmsg.Domain.PodID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Domain created", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewPodDetail(s, podID) }, + } + }, + ) + + case msg.PodDomainUpdated: + for i, d := range m.podDomains { + if d.ID == tmsg.Domain.ID { + m.podDomains[i] = tmsg.Domain + break + } + } + m.isLoading = false + podID := tmsg.Domain.PodID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Domain updated", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewPodDetail(s, podID) }, + } + }, + ) + + case msg.PodDomainDeleted: + m.podDomains = slices.DeleteFunc(m.podDomains, func(d model.PodDomain) bool { + return d.ID == tmsg.DomainID + }) + m.isLoading = false + podID := tmsg.PodID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Domain deleted", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewPodDetail(s, podID) }, + } + }, + ) + + // --- Pod Env Vars --- + case msg.PodEnvVarsUpdated: + // Remove old env vars for this pod, add new ones + m.podEnvVars = slices.DeleteFunc(m.podEnvVars, func(v model.PodEnvVar) bool { + return v.PodID == tmsg.PodID + }) + m.podEnvVars = append(m.podEnvVars, tmsg.EnvVars...) + m.isLoading = false + podID := tmsg.PodID + return m, tea.Batch( + func() tea.Msg { return msg.ShowStatus{Text: "Saved. Restart to apply.", Type: msg.StatusSuccess} }, + func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { return NewPodDetail(s, podID) }, + } + }, + ) + case msg.ThemeSwitcherClose: m.themeSwitcher = nil return m, nil diff --git a/internal/tui/ui/page/git_tokens.go b/internal/tui/ui/page/git_tokens.go index 616a7c5..0256006 100644 --- a/internal/tui/ui/page/git_tokens.go +++ b/internal/tui/ui/page/git_tokens.go @@ -8,7 +8,6 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" "github.com/deeploy-sh/deeploy/internal/shared/model" - "github.com/deeploy-sh/deeploy/internal/tui/api" "github.com/deeploy-sh/deeploy/internal/tui/msg" "github.com/deeploy-sh/deeploy/internal/tui/ui/styles" ) @@ -46,9 +45,6 @@ func (m gitTokens) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { m.tokens = tmsg.GitTokens return m, nil - case msg.GitTokenDeleted: - return m, api.LoadData() - case tea.KeyPressMsg: switch { case key.Matches(tmsg, m.keyBack): diff --git a/internal/tui/ui/page/git_tokens_delete.go b/internal/tui/ui/page/git_tokens_delete.go index 1f8a965..2d2414d 100644 --- a/internal/tui/ui/page/git_tokens_delete.go +++ b/internal/tui/ui/page/git_tokens_delete.go @@ -48,16 +48,6 @@ func (p gitTokenDelete) Init() tea.Cmd { func (p gitTokenDelete) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.GitTokenDeleted: - return p, tea.Batch( - api.LoadData(), - func() tea.Msg { - return msg.ChangePage{ - PageFactory: func(s msg.Store) tea.Model { return NewGitTokens(s.GitTokens()) }, - } - }, - ) - case tea.KeyPressMsg: switch tmsg.Code { case tea.KeyEscape: diff --git a/internal/tui/ui/page/git_tokens_form.go b/internal/tui/ui/page/git_tokens_form.go index 404ef6d..495c88b 100644 --- a/internal/tui/ui/page/git_tokens_form.go +++ b/internal/tui/ui/page/git_tokens_form.go @@ -64,16 +64,6 @@ func (m gitTokenForm) Init() tea.Cmd { func (m gitTokenForm) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.GitTokenCreated: - return m, tea.Batch( - api.LoadData(), - func() tea.Msg { - return msg.ChangePage{ - PageFactory: func(s msg.Store) tea.Model { return NewGitTokens(s.GitTokens()) }, - } - }, - ) - case tea.KeyPressMsg: return m.handleKeyPress(tmsg) diff --git a/internal/tui/ui/page/pods_delete.go b/internal/tui/ui/page/pod_delete.go similarity index 88% rename from internal/tui/ui/page/pods_delete.go rename to internal/tui/ui/page/pod_delete.go index 89c8f36..8debcc4 100644 --- a/internal/tui/ui/page/pods_delete.go +++ b/internal/tui/ui/page/pod_delete.go @@ -56,14 +56,6 @@ func (p podDelete) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch tmsg := tmsg.(type) { - case msg.PodDeleted: - projectID := p.pod.ProjectID - return p, tea.Batch( - api.LoadData(), - func() tea.Msg { return msg.ShowStatus{Text: "Pod deleted", Type: msg.StatusSuccess} }, - func() tea.Msg { return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewProjectDetail(s, projectID) }} }, - ) - case tea.KeyPressMsg: switch tmsg.Code { case tea.KeyEscape: @@ -78,7 +70,7 @@ func (p podDelete) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { } return p, tea.Batch( func() tea.Msg { return msg.StartLoading{Text: "Deleting pod"} }, - api.DeletePod(p.pod.ID), + api.DeletePod(p.pod.ID, p.pod.ProjectID), ) } case tea.WindowSizeMsg: diff --git a/internal/tui/ui/page/pod_detail.go b/internal/tui/ui/page/pod_detail.go index 3a6f617..76911c7 100644 --- a/internal/tui/ui/page/pod_detail.go +++ b/internal/tui/ui/page/pod_detail.go @@ -37,55 +37,48 @@ func (m podDetail) HelpKeys() []key.Binding { } func NewPodDetail(s msg.Store, podID string) podDetail { - var pod model.Pod + // Get pod from store + var pod *model.Pod + var project *model.Project for _, p := range s.Pods() { if p.ID == podID { - pod = p + pod = &p break } } - - var project model.Project - for _, pr := range s.Projects() { - if pr.ID == pod.ProjectID { - project = pr - break + if pod != nil { + for _, pr := range s.Projects() { + if pr.ID == pod.ProjectID { + project = &pr + break + } } } return podDetail{ - store: s, - pod: &pod, - project: &project, - keyDeploy: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "deploy")), - keyStop: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stop")), - keyRestart: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "restart")), - keyLogs: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "logs")), - keyEdit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), - keyDomains: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "domains")), - keyVars: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "env vars")), - keyToken: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "token")), - keyBack: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + store: s, + pod: pod, + project: project, + domains: s.PodDomains(podID), + envVarCount: len(s.PodEnvVars(podID)), + keyDeploy: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "deploy")), + keyStop: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stop")), + keyRestart: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "restart")), + keyLogs: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "logs")), + keyEdit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + keyDomains: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "domains")), + keyVars: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "env vars")), + keyToken: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "token")), + keyBack: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), } } func (m podDetail) Init() tea.Cmd { - return tea.Batch( - api.FetchPodDomains(m.pod.ID), - api.FetchPodEnvVars(m.pod.ID), - ) + return nil } func (m podDetail) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.PodDomainsLoaded: - m.domains = tmsg.Domains - return m, nil - - case msg.PodEnvVarsLoaded: - m.envVarCount = len(tmsg.EnvVars) - return m, nil - case msg.PodDeployed: return m, api.LoadData() @@ -124,6 +117,11 @@ func (m podDetail) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { } func (m podDetail) handleKeyPress(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + // Still loading - ignore keys + if m.pod.Title == "" { + return m, nil + } + switch { case key.Matches(tmsg, m.keyBack): projectID := m.project.ID @@ -188,7 +186,7 @@ func (m podDetail) handleKeyPress(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return msg.ChangePage{ PageFactory: func(s msg.Store) tea.Model { - return NewPodDomains(pod, project) + return NewPodDomains(s, pod, project) }, } } @@ -199,7 +197,7 @@ func (m podDetail) handleKeyPress(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return msg.ChangePage{ PageFactory: func(s msg.Store) tea.Model { - return NewPodVars(pod, project) + return NewPodVars(s, pod, project) }, } } @@ -220,6 +218,11 @@ func (m podDetail) handleKeyPress(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } func (m podDetail) View() tea.View { + // Still loading + if m.pod.Title == "" { + return tea.NewView("") + } + var b strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.ColorPrimary()) @@ -328,5 +331,9 @@ func (m podDetail) renderStatus() string { } func (m podDetail) Breadcrumbs() []string { - return []string{"Projects", m.project.Title, "Pods", m.pod.Title} + projectTitle := "" + if m.project != nil { + projectTitle = m.project.Title + } + return []string{"Projects", projectTitle, "Pods", m.pod.Title} } diff --git a/internal/tui/ui/page/pod_domains.go b/internal/tui/ui/page/pod_domains.go index b0bfcec..4fe36ad 100644 --- a/internal/tui/ui/page/pod_domains.go +++ b/internal/tui/ui/page/pod_domains.go @@ -2,28 +2,18 @@ package page import ( "fmt" - "strconv" "strings" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" "github.com/deeploy-sh/deeploy/internal/shared/model" - "github.com/deeploy-sh/deeploy/internal/tui/api" "github.com/deeploy-sh/deeploy/internal/tui/msg" "github.com/deeploy-sh/deeploy/internal/tui/ui/components" "github.com/deeploy-sh/deeploy/internal/tui/ui/styles" "github.com/deeploy-sh/deeploy/internal/tui/utils" ) -type podDomainsMode int - -const ( - modeDomainList podDomainsMode = iota - modeDomainAdd -) - // domainItem wraps PodDomain to implement ScrollItem interface type domainItem struct { domain model.PodDomain @@ -42,100 +32,54 @@ func (d domainItem) Suffix() string { var podDomainsCard = styles.CardProps{Width: styles.CardWidthMD, Padding: []int{1, 2}, Accent: true} type podDomains struct { - pod *model.Pod - project *model.Project - domains []model.PodDomain - list components.ScrollList - mode podDomainsMode - domainInput textinput.Model - portInput textinput.Model - isAuto bool - focusedInput int - keyAdd key.Binding - keyAuto key.Binding - keyEdit key.Binding - keyDelete key.Binding - keyOpen key.Binding - keyBack key.Binding - keySave key.Binding - keyTab key.Binding - width int - height int - // Note: SSL toggle removed - SSL is now always enabled automatically - // via Let's Encrypt in production (see docker.go RunContainer) + pod *model.Pod + project *model.Project + domains components.ScrollList + keyAdd key.Binding + keyAuto key.Binding + keyEdit key.Binding + keyDelete key.Binding + keyOpen key.Binding + keyBack key.Binding + width int + height int } func (m podDomains) HelpKeys() []key.Binding { - if m.mode == modeDomainAdd { - return []key.Binding{m.keySave, m.keyTab, m.keyBack} - } return []key.Binding{m.keyAdd, m.keyAuto, m.keyEdit, m.keyDelete, m.keyOpen, m.keyBack} } -func NewPodDomains(pod *model.Pod, project *model.Project) podDomains { - domainInput := components.NewTextInput(40) - domainInput.Placeholder = "app.example.com" - domainInput.CharLimit = 100 - - portInput := components.NewTextInput(10) - portInput.Placeholder = "8080" - portInput.CharLimit = 5 +func NewPodDomains(s msg.Store, pod *model.Pod, project *model.Project) podDomains { + rawDomains := s.PodDomains(pod.ID) + items := make([]components.ScrollItem, len(rawDomains)) + for i, d := range rawDomains { + items[i] = domainItem{domain: d} + } return podDomains{ - pod: pod, - project: project, - list: components.NewScrollList(nil, components.ScrollListConfig{Width: podDomainsCard.InnerWidth(), Height: 8}), - domainInput: domainInput, - portInput: portInput, - keyAdd: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new custom")), - keyAuto: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "generate auto")), - keyEdit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), - keyDelete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), - keyOpen: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open")), - keyBack: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), - keySave: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")), - keyTab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")), + pod: pod, + project: project, + domains: components.NewScrollList(items, components.ScrollListConfig{Width: podDomainsCard.InnerWidth(), Height: 8}), + keyAdd: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new custom")), + keyAuto: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "generate auto")), + keyEdit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + keyDelete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + keyOpen: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open")), + keyBack: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), } } func (m podDomains) Init() tea.Cmd { - return tea.Batch(api.FetchPodDomains(m.pod.ID), textinput.Blink) + return nil } func (m podDomains) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.PodDomainsLoaded: - m.domains = tmsg.Domains - items := make([]components.ScrollItem, len(tmsg.Domains)) - for i, d := range tmsg.Domains { - items[i] = domainItem{domain: d} - } - m.list.SetItems(items) - return m, nil - - case msg.PodDomainCreated, msg.PodDomainUpdated: - m.mode = modeDomainList - m.domainInput.SetValue("") - m.portInput.SetValue("") - m.isAuto = false - m.domains = nil // trigger loading state - return m, tea.Batch( - api.FetchPodDomains(m.pod.ID), - func() tea.Msg { - return msg.ShowStatus{Text: "Saved. Restart or deploy to apply.", Type: msg.StatusSuccess} - }, - ) - case tea.KeyPressMsg: - if m.mode == modeDomainAdd { - return m.handleAddMode(tmsg) - } - return m.handleListMode(tmsg) + return m.handleKeyPress(tmsg) case tea.MouseWheelMsg: - if m.mode == modeDomainList { - m.list, _ = m.list.Update(tmsg) - } + m.domains, _ = m.domains.Update(tmsg) case tea.WindowSizeMsg: m.width = tmsg.Width @@ -145,7 +89,7 @@ func (m podDomains) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m podDomains) handleListMode(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { +func (m podDomains) handleKeyPress(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(tmsg, m.keyBack): podID := m.pod.ID @@ -158,38 +102,43 @@ func (m podDomains) handleListMode(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } case key.Matches(tmsg, m.keyAdd): - m.mode = modeDomainAdd - m.isAuto = false - m.focusedInput = 0 - m.domainInput.Focus() - m.portInput.SetValue("8080") - return m, textinput.Blink + pod := m.pod + project := m.project + return m, func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { + return NewPodDomainsForm(pod, project, nil, false) + }, + } + } case key.Matches(tmsg, m.keyAuto): - m.mode = modeDomainAdd - m.isAuto = true - m.focusedInput = 1 - m.domainInput.Blur() - m.portInput.Focus() - m.portInput.SetValue("8080") - return m, textinput.Blink + pod := m.pod + project := m.project + return m, func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { + return NewPodDomainsForm(pod, project, nil, true) + }, + } + } case key.Matches(tmsg, m.keyEdit): - if item := m.list.SelectedItem(); item != nil { + if item := m.domains.SelectedItem(); item != nil { domain := item.(domainItem).domain pod := m.pod project := m.project return m, func() tea.Msg { return msg.ChangePage{ PageFactory: func(s msg.Store) tea.Model { - return NewPodDomainsEdit(domain, pod, project) + return NewPodDomainsForm(pod, project, &domain, false) }, } } } case key.Matches(tmsg, m.keyDelete): - if item := m.list.SelectedItem(); item != nil { + if item := m.domains.SelectedItem(); item != nil { domain := item.(domainItem).domain pod := m.pod project := m.project @@ -203,78 +152,16 @@ func (m podDomains) handleListMode(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } case key.Matches(tmsg, m.keyOpen): - if item := m.list.SelectedItem(); item != nil { + if item := m.domains.SelectedItem(); item != nil { return m, utils.OpenBrowserCmd(item.(domainItem).domain.URL) } } // Let ScrollList handle navigation (up/down/j/k/mouse) - m.list, _ = m.list.Update(tmsg) + m.domains, _ = m.domains.Update(tmsg) return m, nil } -func (m podDomains) handleAddMode(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - switch { - case key.Matches(tmsg, m.keyBack): - m.mode = modeDomainList - m.domainInput.SetValue("") - m.portInput.SetValue("") - m.isAuto = false - return m, nil - - case key.Matches(tmsg, m.keyTab): - if m.isAuto { - // Only port for auto domains (SSL is automatic) - // Keep focus on port field - return m, nil - } - // For custom domains: toggle between domain and port - m.focusedInput = (m.focusedInput + 1) % 2 - m.domainInput.Blur() - m.portInput.Blur() - switch m.focusedInput { - case 0: - m.domainInput.Focus() - case 1: - m.portInput.Focus() - } - return m, nil - - case key.Matches(tmsg, m.keySave): - port := 8080 - pVal, err := strconv.Atoi(m.portInput.Value()) - if err == nil && pVal > 0 { - port = pVal - } - - // SSL is always enabled - it's automatic in production via Let's Encrypt - if m.isAuto { - return m, tea.Batch( - func() tea.Msg { return msg.StartLoading{Text: "Generating domain"} }, - api.GenerateAutoDomain(m.pod.ID, port, true), - ) - } - - domain := strings.TrimSpace(m.domainInput.Value()) - if domain != "" { - return m, tea.Batch( - func() tea.Msg { return msg.StartLoading{Text: "Creating domain"} }, - api.CreatePodDomain(m.pod.ID, domain, port, true), - ) - } - return m, nil - } - - var cmd tea.Cmd - switch m.focusedInput { - case 0: - m.domainInput, cmd = m.domainInput.Update(tmsg) - case 1: - m.portInput, cmd = m.portInput.Update(tmsg) - } - return m, cmd -} - func (m podDomains) View() tea.View { var b strings.Builder @@ -284,73 +171,20 @@ func (m podDomains) View() tea.View { b.WriteString(styles.MutedStyle().Render("Configure domains for " + m.pod.Title)) b.WriteString("\n\n") - if m.mode == modeDomainAdd { - b.WriteString(m.renderAddMode()) - } else { - b.WriteString(m.renderListMode()) - } - - return tea.NewView(m.centeredCard(b.String())) -} - -func (m podDomains) renderListMode() string { - if len(m.domains) == 0 { - var b strings.Builder + if len(m.domains.Items()) == 0 { b.WriteString(styles.MutedStyle().Render("No domains configured.")) b.WriteString("\n\n") b.WriteString(styles.MutedStyle().Render("Press 'g' to generate an auto domain, or 'n' to add a custom one.")) b.WriteString("\n") b.WriteString(styles.MutedStyle().Render("A domain is required before you can deploy.")) - return b.String() - } - return m.list.View() -} - -func (m podDomains) renderAddMode() string { - var b strings.Builder - - if m.isAuto { - b.WriteString("Generate Auto Domain\n\n") - b.WriteString(styles.MutedStyle().Render("Domain will be auto-generated based on pod name")) - b.WriteString("\n\n") } else { - b.WriteString("Add Custom Domain\n\n") - } - - labelStyle := lipgloss.NewStyle().Width(12) - activeLabel := lipgloss.NewStyle().Width(12).Foreground(styles.ColorPrimary()) - - // Domain field (only for custom domains) - if !m.isAuto { - if m.focusedInput == 0 { - b.WriteString(activeLabel.Render("Domain:")) - } else { - b.WriteString(labelStyle.Render("Domain:")) - } - b.WriteString(m.domainInput.View()) - b.WriteString("\n\n") + b.WriteString(m.domains.View()) } - // Port field - if m.focusedInput == 1 { - b.WriteString(activeLabel.Render("Port:")) - } else { - b.WriteString(labelStyle.Render("Port:")) - } - b.WriteString(m.portInput.View()) - b.WriteString("\n\n") - - // SSL info (no toggle - SSL is automatic in production) - b.WriteString(labelStyle.Render("SSL:")) - b.WriteString(styles.MutedStyle().Render("Automatic (Let's Encrypt)")) - b.WriteString("\n") - - return b.String() -} + card := styles.Card(podDomainsCard).Render(b.String()) + centered := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, card) -func (m podDomains) centeredCard(content string) string { - card := styles.Card(podDomainsCard).Render(content) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, card) + return tea.NewView(centered) } func (m podDomains) Breadcrumbs() []string { diff --git a/internal/tui/ui/page/pod_domains_delete.go b/internal/tui/ui/page/pod_domains_delete.go index 141f9fc..2a3c223 100644 --- a/internal/tui/ui/page/pod_domains_delete.go +++ b/internal/tui/ui/page/pod_domains_delete.go @@ -52,14 +52,6 @@ func (p podDomainsDelete) Init() tea.Cmd { func (p podDomainsDelete) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.PodDomainDeleted: - pod := p.pod - project := p.project - return p, tea.Batch( - func() tea.Msg { return msg.ShowStatus{Text: "Deleted. Restart or deploy to apply.", Type: msg.StatusSuccess} }, - func() tea.Msg { return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewPodDomains(pod, project) }} }, - ) - case tea.KeyPressMsg: switch tmsg.Code { case tea.KeyEscape: @@ -67,7 +59,7 @@ func (p podDomainsDelete) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { project := p.project return p, func() tea.Msg { return msg.ChangePage{ - PageFactory: func(s msg.Store) tea.Model { return NewPodDomains(pod, project) }, + PageFactory: func(s msg.Store) tea.Model { return NewPodDomains(s, pod, project) }, } } case tea.KeyEnter: diff --git a/internal/tui/ui/page/pod_domains_edit.go b/internal/tui/ui/page/pod_domains_edit.go deleted file mode 100644 index e9355ca..0000000 --- a/internal/tui/ui/page/pod_domains_edit.go +++ /dev/null @@ -1,193 +0,0 @@ -package page - -import ( - "fmt" - "strconv" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - lipgloss "charm.land/lipgloss/v2" - "github.com/deeploy-sh/deeploy/internal/shared/model" - "github.com/deeploy-sh/deeploy/internal/tui/api" - "github.com/deeploy-sh/deeploy/internal/tui/msg" - "github.com/deeploy-sh/deeploy/internal/tui/ui/components" - "github.com/deeploy-sh/deeploy/internal/tui/ui/styles" -) - -type podDomainsEdit struct { - domain model.PodDomain - pod *model.Pod - project *model.Project - domainInput textinput.Model - portInput textinput.Model - focusedInput int - keySave key.Binding - keyTab key.Binding - keyCancel key.Binding - width int - height int - // Note: SSL toggle removed - SSL is automatic in production via Let's Encrypt -} - -func (p podDomainsEdit) HelpKeys() []key.Binding { - return []key.Binding{p.keySave, p.keyTab, p.keyCancel} -} - -func NewPodDomainsEdit(domain model.PodDomain, pod *model.Pod, project *model.Project) podDomainsEdit { - domainInput := components.NewTextInput(40) - domainInput.Placeholder = "app.example.com" - domainInput.CharLimit = 100 - domainInput.SetValue(domain.Domain) - domainInput.Focus() - - portInput := components.NewTextInput(10) - portInput.Placeholder = "8080" - portInput.CharLimit = 5 - portInput.SetValue(strconv.Itoa(domain.Port)) - - return podDomainsEdit{ - domain: domain, - pod: pod, - project: project, - domainInput: domainInput, - portInput: portInput, - keySave: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")), - keyTab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")), - keyCancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), - // Note: SSL toggle removed - SSL is automatic in production - } -} - -func (p podDomainsEdit) Init() tea.Cmd { - return textinput.Blink -} - -func (p podDomainsEdit) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { - switch tmsg := tmsg.(type) { - case msg.PodDomainUpdated: - pod := p.pod - project := p.project - return p, tea.Batch( - func() tea.Msg { return msg.ShowStatus{Text: "Saved. Restart or deploy to apply.", Type: msg.StatusSuccess} }, - func() tea.Msg { return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewPodDomains(pod, project) }} }, - ) - - case tea.KeyPressMsg: - switch { - case key.Matches(tmsg, p.keyCancel): - pod := p.pod - project := p.project - return p, func() tea.Msg { - return msg.ChangePage{ - PageFactory: func(s msg.Store) tea.Model { return NewPodDomains(pod, project) }, - } - } - - case key.Matches(tmsg, p.keyTab): - // Toggle between domain and port (no SSL toggle anymore) - p.focusedInput = (p.focusedInput + 1) % 2 - p.domainInput.Blur() - p.portInput.Blur() - switch p.focusedInput { - case 0: - p.domainInput.Focus() - case 1: - p.portInput.Focus() - } - return p, nil - - case key.Matches(tmsg, p.keySave): - domain := strings.TrimSpace(p.domainInput.Value()) - if domain == "" { - return p, nil - } - - port := 8080 - pVal, err := strconv.Atoi(p.portInput.Value()) - if err == nil && pVal > 0 { - port = pVal - } - - // SSL is always enabled - it's automatic in production via Let's Encrypt - return p, tea.Batch( - func() tea.Msg { return msg.StartLoading{Text: "Updating domain"} }, - api.UpdatePodDomain(p.pod.ID, p.domain.ID, domain, port, true), - ) - } - - case tea.WindowSizeMsg: - p.width = tmsg.Width - p.height = tmsg.Height - return p, nil - } - - var cmd tea.Cmd - switch p.focusedInput { - case 0: - p.domainInput, cmd = p.domainInput.Update(tmsg) - case 1: - p.portInput, cmd = p.portInput.Update(tmsg) - } - return p, cmd -} - -func (p podDomainsEdit) View() tea.View { - var b strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.ColorPrimary()) - b.WriteString(titleStyle.Render("Edit Domain")) - b.WriteString("\n\n") - - // Type badge (read-only) - typeLabel := "custom" - if p.domain.Type == "auto" { - typeLabel = "auto" - } - b.WriteString(styles.MutedStyle().Render(fmt.Sprintf("Type: %s", typeLabel))) - b.WriteString("\n\n") - - labelStyle := lipgloss.NewStyle().Width(12) - activeLabel := lipgloss.NewStyle().Width(12).Foreground(styles.ColorPrimary()) - - // Domain field - if p.focusedInput == 0 { - b.WriteString(activeLabel.Render("Domain:")) - } else { - b.WriteString(labelStyle.Render("Domain:")) - } - b.WriteString(p.domainInput.View()) - b.WriteString("\n\n") - - // Port field - if p.focusedInput == 1 { - b.WriteString(activeLabel.Render("Port:")) - } else { - b.WriteString(labelStyle.Render("Port:")) - } - b.WriteString(p.portInput.View()) - b.WriteString("\n\n") - - // SSL info (no toggle - SSL is automatic in production) - b.WriteString(labelStyle.Render("SSL:")) - b.WriteString(styles.MutedStyle().Render("Automatic (Let's Encrypt)")) - b.WriteString("\n") - - return tea.NewView(p.centeredCard(b.String())) -} - -func (p podDomainsEdit) centeredCard(content string) string { - card := styles.Card(styles.CardProps{ - Width: styles.CardWidthMD, - Padding: []int{1, 2}, - Accent: true, - }).Render(content) - - return lipgloss.Place(p.width, p.height, - lipgloss.Center, lipgloss.Center, card) -} - -func (p podDomainsEdit) Breadcrumbs() []string { - return []string{"Projects", p.project.Title, "Pods", p.pod.Title, "Domains", "Edit"} -} diff --git a/internal/tui/ui/page/pod_domains_form.go b/internal/tui/ui/page/pod_domains_form.go new file mode 100644 index 0000000..1bb73f2 --- /dev/null +++ b/internal/tui/ui/page/pod_domains_form.go @@ -0,0 +1,284 @@ +package page + +import ( + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" + "github.com/deeploy-sh/deeploy/internal/shared/model" + "github.com/deeploy-sh/deeploy/internal/tui/api" + "github.com/deeploy-sh/deeploy/internal/tui/msg" + "github.com/deeploy-sh/deeploy/internal/tui/ui/components" + "github.com/deeploy-sh/deeploy/internal/tui/ui/styles" +) + +type podDomainsForm struct { + domain *model.PodDomain // nil = create, otherwise edit + pod *model.Pod + project *model.Project + isAuto bool // only relevant for create + domainInput textinput.Model + portInput textinput.Model + focusedField int + keySave key.Binding + keyBack key.Binding + keyTab key.Binding + keyShiftTab key.Binding + width int + height int +} + +const ( + fieldDomain = iota + fieldPort +) + +func (m podDomainsForm) HelpKeys() []key.Binding { + return []key.Binding{m.keySave, m.keyTab, m.keyBack} +} + +func NewPodDomainsForm(pod *model.Pod, project *model.Project, domain *model.PodDomain, isAuto bool) podDomainsForm { + card := styles.CardProps{Width: styles.CardWidthMD, Padding: []int{1, 2}, Accent: true} + inputWidth := card.InnerWidth() + + domainInput := components.NewTextInput(inputWidth) + domainInput.Placeholder = "app.example.com" + domainInput.CharLimit = 100 + + portInput := components.NewTextInput(inputWidth) + portInput.Placeholder = "8080" + portInput.CharLimit = 5 + portInput.SetValue("8080") + + // Set values if editing + if domain != nil { + domainInput.SetValue(domain.Domain) + portInput.SetValue(strconv.Itoa(domain.Port)) + } + + // Set initial focus + if isAuto { + // Auto domain: focus on port (domain is auto-generated) + portInput.Focus() + } else { + domainInput.Focus() + } + + focusedField := fieldDomain + if isAuto { + focusedField = fieldPort + } + + return podDomainsForm{ + domain: domain, + pod: pod, + project: project, + isAuto: isAuto, + domainInput: domainInput, + portInput: portInput, + focusedField: focusedField, + keySave: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")), + keyBack: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + keyTab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next")), + keyShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev")), + } +} + +func (m podDomainsForm) Init() tea.Cmd { + return textinput.Blink +} + +func (m podDomainsForm) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { + switch tmsg := tmsg.(type) { + case tea.KeyPressMsg: + return m.handleKeyPress(tmsg) + + case tea.WindowSizeMsg: + m.width = tmsg.Width + m.height = tmsg.Height + return m, nil + } + + // Update focused input for blink messages + var cmd tea.Cmd + switch m.focusedField { + case fieldDomain: + m.domainInput, cmd = m.domainInput.Update(tmsg) + case fieldPort: + m.portInput, cmd = m.portInput.Update(tmsg) + } + return m, cmd +} + +func (m *podDomainsForm) handleKeyPress(tmsg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(tmsg, m.keyBack): + pod := m.pod + project := m.project + return m, func() tea.Msg { + return msg.ChangePage{ + PageFactory: func(s msg.Store) tea.Model { + return NewPodDomains(s, pod, project) + }, + } + } + + case key.Matches(tmsg, m.keySave): + return m.save() + + case key.Matches(tmsg, m.keyTab): + if m.isAuto && m.domain == nil { + // Auto create: only port field, no tab + return m, nil + } + m.focusedField = (m.focusedField + 1) % 2 + return m, m.updateFocus() + + case key.Matches(tmsg, m.keyShiftTab): + if m.isAuto && m.domain == nil { + // Auto create: only port field, no tab + return m, nil + } + m.focusedField = (m.focusedField + 1) % 2 + return m, m.updateFocus() + } + + // Update focused input + var cmd tea.Cmd + switch m.focusedField { + case fieldDomain: + m.domainInput, cmd = m.domainInput.Update(tmsg) + case fieldPort: + m.portInput, cmd = m.portInput.Update(tmsg) + } + return m, cmd +} + +func (m *podDomainsForm) blurAll() { + m.domainInput.Blur() + m.portInput.Blur() +} + +func (m *podDomainsForm) updateFocus() tea.Cmd { + m.blurAll() + switch m.focusedField { + case fieldDomain: + return m.domainInput.Focus() + case fieldPort: + return m.portInput.Focus() + } + return nil +} + +func (m *podDomainsForm) save() (tea.Model, tea.Cmd) { + port := 8080 + pVal, err := strconv.Atoi(m.portInput.Value()) + if err == nil && pVal > 0 { + port = pVal + } + + // Auto domain create + if m.domain == nil && m.isAuto { + return m, tea.Batch( + func() tea.Msg { return msg.StartLoading{Text: "Generating domain"} }, + api.GenerateAutoDomain(m.pod.ID, port, true), + ) + } + + // Custom domain - validate + domain := strings.TrimSpace(m.domainInput.Value()) + if domain == "" { + return m, nil + } + + // Create + if m.domain == nil { + return m, tea.Batch( + func() tea.Msg { return msg.StartLoading{Text: "Creating domain"} }, + api.CreatePodDomain(m.pod.ID, domain, port, true), + ) + } + + // Update + return m, tea.Batch( + func() tea.Msg { return msg.StartLoading{Text: "Updating domain"} }, + api.UpdatePodDomain(m.pod.ID, m.domain.ID, domain, port, true), + ) +} + +func (m podDomainsForm) View() tea.View { + var b strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.ColorPrimary()) + + // Title based on mode + if m.domain == nil { + if m.isAuto { + b.WriteString(titleStyle.Render("Generate Auto Domain")) + b.WriteString("\n") + b.WriteString(styles.MutedStyle().Render("Domain will be auto-generated based on pod name")) + } else { + b.WriteString(titleStyle.Render("New Domain")) + } + } else { + b.WriteString(titleStyle.Render("Edit Domain")) + b.WriteString("\n") + typeLabel := "custom" + if m.domain.Type == "auto" { + typeLabel = "auto" + } + b.WriteString(styles.MutedStyle().Render("Type: " + typeLabel)) + } + b.WriteString("\n\n") + + labelStyle := lipgloss.NewStyle().Foreground(styles.ColorMuted()) + activeLabel := lipgloss.NewStyle().Foreground(styles.ColorPrimary()) + + // Domain field (not for auto create) + if !(m.domain == nil && m.isAuto) { + if m.focusedField == fieldDomain { + b.WriteString(activeLabel.Render("Domain")) + } else { + b.WriteString(labelStyle.Render("Domain")) + } + b.WriteString("\n") + b.WriteString(m.domainInput.View()) + b.WriteString("\n\n") + } + + // Port field + if m.focusedField == fieldPort { + b.WriteString(activeLabel.Render("Port")) + } else { + b.WriteString(labelStyle.Render("Port")) + } + b.WriteString("\n") + b.WriteString(m.portInput.View()) + b.WriteString("\n\n") + + // SSL info + b.WriteString(labelStyle.Render("SSL")) + b.WriteString("\n") + b.WriteString(styles.MutedStyle().Render("Automatic (Let's Encrypt)")) + + card := styles.Card(styles.CardProps{ + Width: styles.CardWidthMD, + Padding: []int{1, 2}, + Accent: true, + }).Render(b.String()) + + centered := lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, card) + + return tea.NewView(centered) +} + +func (m podDomainsForm) Breadcrumbs() []string { + if m.domain == nil { + return []string{"Projects", m.project.Title, "Pods", m.pod.Title, "Domains", "New"} + } + return []string{"Projects", m.project.Title, "Pods", m.pod.Title, "Domains", "Edit"} +} diff --git a/internal/tui/ui/page/pods_form.go b/internal/tui/ui/page/pod_form.go similarity index 92% rename from internal/tui/ui/page/pods_form.go rename to internal/tui/ui/page/pod_form.go index 18936a5..797f9f3 100644 --- a/internal/tui/ui/page/pods_form.go +++ b/internal/tui/ui/page/pod_form.go @@ -97,28 +97,6 @@ func (m podForm) Init() tea.Cmd { func (m podForm) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.PodCreated: - projectID := m.projectID - return m, tea.Batch( - api.LoadData(), - func() tea.Msg { return msg.ShowStatus{Text: "Pod created", Type: msg.StatusSuccess} }, - func() tea.Msg { - return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewProjectDetail(s, projectID) }} - }, - ) - - case msg.PodUpdated: - podID := m.pod.ID - return m, tea.Batch( - api.LoadData(), - func() tea.Msg { return msg.ShowStatus{Text: "Pod saved", Type: msg.StatusSuccess} }, - func() tea.Msg { - return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { - return NewPodDetail(s, podID) - }} - }, - ) - case tea.KeyPressMsg: return m.handleKeyPress(tmsg) diff --git a/internal/tui/ui/page/pod_token.go b/internal/tui/ui/page/pod_token.go index 1732473..557e436 100644 --- a/internal/tui/ui/page/pod_token.go +++ b/internal/tui/ui/page/pod_token.go @@ -56,18 +56,6 @@ func (m podToken) Init() tea.Cmd { func (m podToken) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.PodUpdated: - podID := m.pod.ID - return m, tea.Batch( - api.LoadData(), - func() tea.Msg { return msg.ShowStatus{Text: "Token updated", Type: msg.StatusSuccess} }, - func() tea.Msg { - return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { - return NewPodDetail(s, podID) - }} - }, - ) - case tea.KeyPressMsg: return m.handleKeyPress(tmsg) diff --git a/internal/tui/ui/page/pod_vars.go b/internal/tui/ui/page/pod_vars.go index 2b7868b..7f91158 100644 --- a/internal/tui/ui/page/pod_vars.go +++ b/internal/tui/ui/page/pod_vars.go @@ -28,7 +28,10 @@ func (m podVars) HelpKeys() []key.Binding { return []key.Binding{m.keySave, m.keyBack} } -func NewPodVars(pod *model.Pod, project *model.Project) podVars { +func NewPodVars(s msg.Store, pod *model.Pod, project *model.Project) podVars { + // Get env vars from store + envVars := s.PodEnvVars(pod.ID) + ta := textarea.New() ta.Placeholder = "DATABASE_URL=postgres://..." ta.Prompt = "" @@ -36,33 +39,26 @@ func NewPodVars(pod *model.Pod, project *model.Project) podVars { ta.SetHeight(10) ta.Focus() - return podVars{ + m := podVars{ pod: pod, project: project, textarea: ta, + envVars: envVars, keySave: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")), keyBack: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), } + ta.SetValue(m.envVarsToText()) + m.textarea = ta + + return m } func (m podVars) Init() tea.Cmd { - return tea.Batch(api.FetchPodEnvVars(m.pod.ID), textarea.Blink) + return textarea.Blink } func (m podVars) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { switch tmsg := tmsg.(type) { - case msg.PodEnvVarsLoaded: - m.envVars = tmsg.EnvVars - m.textarea.SetValue(m.envVarsToText()) - return m, nil - - case msg.PodEnvVarsUpdated: - podID := m.pod.ID - return m, tea.Batch( - func() tea.Msg { return msg.ShowStatus{Text: "Saved. Restart or deploy to apply.", Type: msg.StatusSuccess} }, - func() tea.Msg { return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewPodDetail(s, podID) }} }, - ) - case tea.KeyPressMsg: if key.Matches(tmsg, m.keyBack) { podID := m.pod.ID diff --git a/internal/tui/ui/page/projects_delete.go b/internal/tui/ui/page/project_delete.go similarity index 91% rename from internal/tui/ui/page/projects_delete.go rename to internal/tui/ui/page/project_delete.go index e0536a5..0e76b49 100644 --- a/internal/tui/ui/page/projects_delete.go +++ b/internal/tui/ui/page/project_delete.go @@ -64,13 +64,6 @@ func (p projectDelete) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch tmsg := tmsg.(type) { - case msg.ProjectDeleted: - return p, tea.Batch( - api.LoadData(), - func() tea.Msg { return msg.ShowStatus{Text: "Project deleted", Type: msg.StatusSuccess} }, - func() tea.Msg { return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewDashboard(s) }} }, - ) - case tea.KeyPressMsg: switch tmsg.Code { case tea.KeyEscape: diff --git a/internal/tui/ui/page/projects_form.go b/internal/tui/ui/page/project_form.go similarity index 84% rename from internal/tui/ui/page/projects_form.go rename to internal/tui/ui/page/project_form.go index 1c7bd50..c70eea1 100644 --- a/internal/tui/ui/page/projects_form.go +++ b/internal/tui/ui/page/project_form.go @@ -53,21 +53,6 @@ func (p projectForm) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch tmsg := tmsg.(type) { - case msg.ProjectCreated: - return p, tea.Batch( - api.LoadData(), - func() tea.Msg { return msg.ShowStatus{Text: "Project created", Type: msg.StatusSuccess} }, - func() tea.Msg { return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewDashboard(s) }} }, - ) - - case msg.ProjectUpdated: - projectID := p.project.ID - return p, tea.Batch( - api.LoadData(), - func() tea.Msg { return msg.ShowStatus{Text: "Project saved", Type: msg.StatusSuccess} }, - func() tea.Msg { return msg.ChangePage{PageFactory: func(s msg.Store) tea.Model { return NewProjectDetail(s, projectID) }} }, - ) - case tea.KeyPressMsg: switch tmsg.Code { case tea.KeyEscape: