package ui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "ssh-access-tui/internal/model" ) func (m *Model) refreshGroupsList() { st := m.loadState() items := make([]list.Item, 0, len(st.Groups)) for _, g := range st.Groups { hosts := map[string]bool{} for _, ref := range g.Services { hosts[ref.Host] = true } desc := fmt.Sprintf("%d сервисов на %d хостах", len(g.Services), len(hosts)) if g.Description != "" { desc += " — " + g.Description } items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc}) } m.groupsList = newList(items, "Группы [n]новая [e]изменить [enter]сервисы группы [d]удалить [esc]назад", m.groupsList.Width(), m.groupsList.Height()) } func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "esc": m.screen = scrMenu m.err, m.status = "", "" return m, nil case "n": m.groupFormEditTarget = "" for i := range m.groupForm { m.groupForm[i] = textinput.New() } m.groupForm[0].Placeholder = "имя группы, например team-billing" m.groupForm[1].Placeholder = "описание (необязательно)" m.groupFormFocus = 0 m.groupForm[0].Focus() m.err = "" m.screen = scrGroupForm return m, nil case "e": sel, ok := m.groupsList.SelectedItem().(simpleItem) if !ok { return m, nil } st := m.loadState() g := st.FindGroup(sel.id) if g == nil { return m, nil } m.groupFormEditTarget = g.Name for i := range m.groupForm { m.groupForm[i] = textinput.New() } m.groupForm[0].SetValue(g.Name) m.groupForm[1].SetValue(g.Description) m.groupFormFocus = 0 m.groupForm[0].Focus() m.err = "" m.screen = scrGroupForm return m, nil case "enter": sel, ok := m.groupsList.SelectedItem().(simpleItem) if !ok { return m, nil } m.selectedGroup = sel.id st := m.loadState() g := st.FindGroup(sel.id) m.groupPickSel = map[string]bool{} if g != nil { for _, ref := range g.Services { m.groupPickSel[ref.Host+"/"+ref.Service] = true } } m.groupPickRows = nil m.groupPickServices = map[string][]string{} for _, h := range st.Hosts { m.groupPickRows = append(m.groupPickRows, pickRow{isHeader: true, host: h.Name}) var svcNames []string for _, svc := range h.Services { m.groupPickRows = append(m.groupPickRows, pickRow{host: h.Name, service: svc.Name}) svcNames = append(svcNames, svc.Name) } m.groupPickServices[h.Name] = svcNames } m.groupPickCursor = 0 m.screen = scrGroupHostsPick return m, nil case "d": sel, ok := m.groupsList.SelectedItem().(simpleItem) if !ok { return m, nil } st := m.loadState() affected := st.UsernamesInGroup(sel.id) msg := fmt.Sprintf( "Удалить группу %q?\n\n"+ "Это затронет пользователей: %d — группа будет снята с них, и они\n"+ "немедленно потеряют доступ ко всем сервисам, которые давала только эта\n"+ "группа.", sel.id, len(affected)) m = m.startConfirm(confirmDeleteGroup, sel.id, msg, scrGroups) return m, nil } } var cmd tea.Cmd m.groupsList, cmd = m.groupsList.Update(msg) return m, cmd } func (m Model) viewGroups() string { return borderStyle.Render(m.groupsList.View()) } func (m Model) updateGroupForm(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "esc": m.screen = scrGroups return m, nil case "tab", "down": m.groupForm[m.groupFormFocus].Blur() m.groupFormFocus = (m.groupFormFocus + 1) % len(m.groupForm) m.groupForm[m.groupFormFocus].Focus() return m, nil case "enter": name := m.groupForm[0].Value() desc := m.groupForm[1].Value() if name == "" { m.err = "имя группы не может быть пустым" return m, nil } if !model.ValidName(name) { m.err = validNameErr("группа", name) return m, nil } target := m.groupFormEditTarget m.err = "" if target == "" { m.mutate("создана группа "+name, func(st *model.State) error { if st.FindGroup(name) != nil { return fmt.Errorf("группа %q уже существует", name) } st.Groups = append(st.Groups, model.Group{Name: name, Description: desc}) return nil }) } else { m.mutate("изменена группа "+target, func(st *model.State) error { if name != target && st.FindGroup(name) != nil { return fmt.Errorf("группа %q уже существует", name) } if name != target { st.RenameGroup(target, name) } g := st.FindGroup(name) if g == nil { return fmt.Errorf("group %s not found", name) } g.Description = desc return nil }) } m.screen = scrGroups m.refreshGroupsList() return m, nil } } var cmd tea.Cmd m.groupForm[m.groupFormFocus], cmd = m.groupForm[m.groupFormFocus].Update(msg) return m, cmd } func (m Model) viewGroupForm() string { title := "Новая группа" if m.groupFormEditTarget != "" { title = fmt.Sprintf("Изменить группу %q", m.groupFormEditTarget) } body := fmt.Sprintf("%s\n\nИмя: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена", title, m.groupForm[0].View(), m.groupForm[1].View()) return borderStyle.Render(body) } // --- pick which catalog services belong to the selected group, shown as a // host -> services tree: a host row toggles all of its services at once, // a service row toggles just that one, so access is granted per-service // (not blanket per-host) while still being quick to pick a whole host. --- // pickRow is one line of the host/service picker tree: either a host // header (isHeader, service == "") or a service leaf under that host. type pickRow struct { isHeader bool host string service string } func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "esc": m.screen = scrGroups m.refreshGroupsList() return m, nil case "up", "k": if m.groupPickCursor > 0 { m.groupPickCursor-- } return m, nil case "down", "j": if m.groupPickCursor < len(m.groupPickRows)-1 { m.groupPickCursor++ } return m, nil case " ": if len(m.groupPickRows) == 0 { return m, nil } row := m.groupPickRows[m.groupPickCursor] if row.isHeader { allSelected := true for _, svc := range m.groupPickServices[row.host] { if !m.groupPickSel[row.host+"/"+svc] { allSelected = false break } } for _, svc := range m.groupPickServices[row.host] { m.groupPickSel[row.host+"/"+svc] = !allSelected } } else { key := row.host + "/" + row.service m.groupPickSel[key] = !m.groupPickSel[key] } return m, nil case "enter": var refs []model.ServiceRef for key, on := range m.groupPickSel { if !on { continue } host, svc, found := strings.Cut(key, "/") if !found { continue } refs = append(refs, model.ServiceRef{Host: host, Service: svc}) } groupName := m.selectedGroup m.mutate("изменён список сервисов группы "+groupName, func(st *model.State) error { g := st.FindGroup(groupName) if g == nil { return fmt.Errorf("group %s not found", groupName) } g.Services = refs return resyncGroupUsers(st, groupName) }) m.screen = scrGroups m.refreshGroupsList() return m, nil } } return m, nil } func (m Model) viewGroupHostsPick() string { body := fmt.Sprintf("Какие сервисы каталога входят в группу %q\n\n", m.selectedGroup) if len(m.groupPickRows) == 0 { body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n" } for i, row := range m.groupPickRows { cursor := " " if i == m.groupPickCursor { cursor = "> " } if row.isHeader { svcs := m.groupPickServices[row.host] selected := 0 for _, svc := range svcs { if m.groupPickSel[row.host+"/"+svc] { selected++ } } check := "[ ]" switch { case len(svcs) > 0 && selected == len(svcs): check = "[x]" case selected > 0: check = "[~]" } body += fmt.Sprintf("%s%s %s\n", cursor, check, row.host) } else { check := "[ ]" if m.groupPickSel[row.host+"/"+row.service] { check = "[x]" } body += fmt.Sprintf("%s %s %s\n", cursor, check, row.service) } } body += "\n[space] отметить (на хосте — все его сервисы) [enter] сохранить [esc] назад" return borderStyle.Render(body) }