burterm/internal/tui/targetview.go
2026-09-14 10:55:07 +03:00

341 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-<unix-время>.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()
}