package tui import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/r3g1tpr0cs/burterm/internal/proxy" "github.com/r3g1tpr0cs/burterm/internal/sitemap" ) // targetModel — вкладка Target/Site Map: поле ввода regex-правила scope, // список уже добавленных правил (include/exclude) с возможностью удалить // любое, и построенное из перехваченного трафика дерево путей. Дерево // пересобирается сразу при добавлении/удалении правила и при каждой новой // записи от Proxy — отдельной кнопки "обновить" не нужно. type targetModel struct { input textinput.Model scope *sitemap.Scope entries []proxy.Entry tree *sitemap.Tree treeVP viewport.Model cursor int // индекс выбранного правила в scope.Rules() focus int // 0 — поле ввода, 1 — список правил, 2 — дерево (скролл) statusMsg string errMsg string } func newTargetView() targetModel { in := textinput.New() in.Placeholder = `regex, напр. ^https://example\.com/api/.*` in.CharLimit = 512 in.Focus() return targetModel{ input: in, scope: sitemap.NewScope(), treeVP: viewport.New(0, 0), } } func (m *targetModel) SetSize(width, height int) { if width < 0 { width = 0 } if height < 0 { height = 0 } m.treeVP.Width = width // 13 фиксированных строк вокруг treeVP (построчный подсчёт по View(), // baseline без правил/ошибок: "Правило scope"-метка+поле+разделитель // (3), "Правила (0)"-метка+"(пусто...)"-строка+разделитель (3), // разделитель+"Карта сайта"-метка (2), разделитель+5 строк футера // (подсказки клавиш разбиты на две строки, отдельно навигация, // отдельно примечание про scope) (5) — итого 13. Раньше стояло -10 — // недосчитано на 2 строки, плюс сам футер был одной длинной строкой // (свыше 190 символов), которая непредсказуемо переносилась терминалом. vh := height - 13 if vh < 3 { vh = 3 } m.treeVP.Height = vh } // AddEntry добавляет перехваченный запрос и сразу пересобирает дерево. func (m *targetModel) AddEntry(e proxy.Entry) { m.entries = append(m.entries, e) m.rebuildTree() } func (m *targetModel) rebuildTree() { records := make([]sitemap.Record, 0, len(m.entries)) for _, e := range m.entries { errStr := "" if e.Err != nil { errStr = e.Err.Error() } records = append(records, sitemap.Record{ ID: e.ID, Method: e.Method, URL: e.URL, StatusCode: e.StatusCode, Err: errStr, }) } m.tree = sitemap.Build(records, m.scope) m.refreshTreeView() } func (m *targetModel) refreshTreeView() { if m.tree == nil || len(m.tree.Hosts) == 0 { m.treeVP.SetContent("Карта пуста. Перехвати трафик через Proxy или дождись совпадений со scope.") return } var b strings.Builder hosts := make([]string, 0, len(m.tree.Hosts)) for h := range m.tree.Hosts { hosts = append(hosts, h) } sort.Strings(hosts) for _, h := range hosts { b.WriteString(h + "/\n") renderSitemapNode(&b, m.tree.Hosts[h], 1) } m.treeVP.SetContent(b.String()) } // renderSitemapNode рекурсивно печатает дерево с отступами по глубине: // сначала дочерние сегменты пути в алфавитном порядке, затем эндпоинты // на самом узле. func renderSitemapNode(b *strings.Builder, n *sitemap.Node, depth int) { indent := strings.Repeat(" ", depth) names := make([]string, 0, len(n.Children)) for name := range n.Children { names = append(names, name) } sort.Strings(names) for _, name := range names { b.WriteString(indent + name + "/\n") renderSitemapNode(b, n.Children[name], depth+1) } for _, ep := range n.Endpoints { status := fmt.Sprintf("%d", ep.StatusCode) if ep.Err != "" { status = "ERR" } b.WriteString(fmt.Sprintf("%s [%s %s] %s\n", indent, ep.Method, status, ep.URL)) } } func (m targetModel) Update(msg tea.Msg) (targetModel, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "tab": m.focus = (m.focus + 1) % 3 if m.focus == 0 { m.input.Focus() } else { m.input.Blur() } return m, nil case "enter": if m.focus == 0 { m.addRule(false) return m, m.scopeChangedCmd() } case "ctrl+e": if m.focus == 0 { m.addRule(true) return m, m.scopeChangedCmd() } case "ctrl+s": m.export() return m, nil case "ctrl+p": return m, m.saveScopeCmd() case "up": if m.focus == 1 && m.cursor > 0 { m.cursor-- return m, nil } case "down": if m.focus == 1 && m.cursor < len(m.scope.Rules())-1 { m.cursor++ return m, nil } case "d": if m.focus == 1 { m.scope.RemoveAt(m.cursor) if m.cursor > 0 { m.cursor-- } m.rebuildTree() return m, m.scopeChangedCmd() } } } // Ключи, не перехваченные выше для текущего фокуса (например, "d" при // вводе паттерна, или "up"/"down" вне списка правил), уходят как // обычный ввод в активный компонент — это и есть провал сквозь свитч, // а не двойная обработка. var cmd tea.Cmd switch m.focus { case 0: m.input, cmd = m.input.Update(msg) case 2: m.treeVP, cmd = m.treeVP.Update(msg) } return m, cmd } // addRule берёт текст из поля ввода, компилирует как regex и добавляет // в scope. Регулярка компилируется синхронно (это дёшево), без ухода // в tea.Cmd — в отличие от сетевых операций в других вкладках, здесь // блокировать TUI попросту нечем. func (m *targetModel) addRule(exclude bool) { pattern := strings.TrimSpace(m.input.Value()) if pattern == "" { return } if err := m.scope.Add(pattern, exclude); err != nil { m.errMsg = err.Error() return } m.errMsg = "" m.statusMsg = "" m.input.SetValue("") m.rebuildTree() } // scopeChangedCmd уведомляет корневую модель, что правила scope // изменились — она пересоберёт независимый снапшот и передаст его // в живой proxy.Server, чтобы "скип трафика вне scope" в Proxy сразу // подхватывал актуальные правила, а не только то, что было на момент // запуска TUI. Снимок правил делаем сразу (Rules() — дешёвая копия), // а не откладываем на потом: к моменту, когда tea.Cmd выполнится, // m.scope мог уже почитаться дальше пользователем. func (m targetModel) scopeChangedCmd() tea.Cmd { rules := m.scope.Rules() return func() tea.Msg { return scopeChangedMsg{rules: rules} } } // saveScopeCmd (ctrl+p) сериализует текущие scope-правила в JSON и // отправляет корневой модели — сама targetModel не держит ссылку на // project.Store (та живёт только в Model), поэтому запись в БД делает // корневой обработчик saveScopeMsg, а не эта функция напрямую. // Сериализация здесь синхронная (это просто json.Marshal небольшого // среза, не I/O) — в tea.Cmd уходит уже готовая строка. func (m targetModel) saveScopeCmd() tea.Cmd { data, err := json.Marshal(m.scope.Rules()) return func() tea.Msg { if err != nil { return projectErrMsg{fmt.Errorf("сериализация scope-правил: %w", err)} } return saveScopeMsg{json: string(data)} } } // export пишет текущее дерево в ~/.burterm/sitemap-.json. func (m *targetModel) export() { if m.tree == nil || len(m.tree.Hosts) == 0 { m.errMsg = "нечего экспортировать — карта пуста" return } path, err := defaultSitemapExportPath() if err != nil { m.errMsg = err.Error() return } if err := m.tree.ExportJSON(path); err != nil { m.errMsg = err.Error() return } m.errMsg = "" m.statusMsg = "экспортировано в " + path } func defaultSitemapExportPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err) } dir := filepath.Join(home, ".burterm") if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("не удалось создать %s: %w", dir, err) } return filepath.Join(dir, fmt.Sprintf("sitemap-%d.json", time.Now().Unix())), nil } func (m targetModel) View() string { focusMark := func(i int) string { if m.focus == i { return "▸ " } return " " } var b strings.Builder b.WriteString(focusMark(0) + "Правило scope (regex)\n") b.WriteString(m.input.View()) b.WriteString("\n\n") rules := m.scope.Rules() b.WriteString(focusMark(1) + fmt.Sprintf("Правила (%d)\n", len(rules))) if len(rules) == 0 { b.WriteString(" (пусто — весь перехваченный трафик в scope)\n") } for i, r := range rules { marker := " " if m.focus == 1 && i == m.cursor { marker = "▸ " } kind := "+" if r.Exclude { kind = "-" } b.WriteString(fmt.Sprintf("%s[%s] %s\n", marker, kind, r.Pattern)) } b.WriteString("\n") if m.errMsg != "" { b.WriteString("Ошибка: " + m.errMsg + "\n") } if m.statusMsg != "" { b.WriteString(m.statusMsg + "\n") } b.WriteString("\n" + focusMark(2) + "Карта сайта\n") b.WriteString(m.treeVP.View()) b.WriteString("\n\ntab — фокус · enter — добавить include · ctrl+e — добавить exclude · d — удалить правило\n") b.WriteString("ctrl+s — экспорт JSON · ctrl+p — сохранить scope в проект\n") b.WriteString("f1-f12, pgup — вкладки\n") b.WriteString("Правила сразу применяются к Proxy: трафик вне scope больше не попадает в историю.\n") return b.String() }