package tui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/r3g1tpr0cs/burterm/internal/project" ) // projectModel — вкладка Project: путь к файлу проекта (SQLite), // поисковая строка в мини-языке (host:/method:/status:/color:/minlen:/ // maxlen: + свободный текст для FTS), список результатов с цветовыми // метками. store хранится и здесь, и на корневой Model (m.projectStore) // — небольшое дублирование указателя ради того, чтобы вкладка сама // могла строить свои команды (поиск/цвет/удаление), не прокидывая // store через каждое сообщение. type projectModel struct { store *project.Store path string pathInput textinput.Model queryInput textinput.Model results []project.Record cursor int viewport viewport.Model errMsg string statusMsg string focus int // 0 — путь, 1 — поисковый запрос, 2 — список результатов } func newProjectView() projectModel { pathIn := textinput.New() pathIn.Placeholder = "~/.burterm/projects/target.db" pathIn.CharLimit = 512 pathIn.Focus() queryIn := textinput.New() queryIn.Placeholder = "host:example.com method:POST status:200-299 color:red текст для полнотекстового поиска" queryIn.CharLimit = 512 return projectModel{ pathInput: pathIn, queryInput: queryIn, viewport: viewport.New(0, 0), } } func (m *projectModel) SetSize(width, height int) { if width < 0 { width = 0 } if height < 0 { height = 0 } m.viewport.Width = width // 11 фиксированных строк (построчный подсчёт: метка+поле+разделитель // на "Файл проекта" (3) и на "Поиск" (3), блок "Найдено: N"+пустая // (2), разделитель+футер(две строки) (3) — итого 11). Раньше стояло // -8 — недосчитано с самого начала, безотносительно разбиения // футера. Оговорка: минимум для состояния без ошибки/statusMsg — // с ними на 1-2 строки больше. vh := height - 11 if vh < 3 { vh = 3 } m.viewport.Height = vh } // refreshList перерисовывает viewport из m.results — вызывается после // любого изменения списка (поиск, смена метки, удаление). func (m *projectModel) refreshList() { if len(m.results) == 0 { m.viewport.SetContent("Пусто. Проверь запрос или сохрани что-нибудь в проект.") return } var b strings.Builder for i, r := range m.results { marker := " " if m.focus == 2 && i == m.cursor { marker = "▸ " } colorText := colorLabelStyle(r.ColorLabel).Render(fmt.Sprintf("%-6s", colorName(r.ColorLabel))) b.WriteString(fmt.Sprintf("%s#%-5d %-6s %-4d %s %-6s %s\n", marker, r.ID, r.Method, r.Status, colorText, humanBytes(uint64(max64(r.ContentLength, 0))), r.URL)) } m.viewport.SetContent(b.String()) } func colorName(c project.ColorLabel) string { if c == "" { return "-" } return string(c) } func max64(a, b int64) int64 { if a > b { return a } return b } func (m projectModel) Update(msg tea.Msg) (projectModel, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "tab": m.focus = (m.focus + 1) % 3 m.pathInput.Blur() m.queryInput.Blur() switch m.focus { case 0: m.pathInput.Focus() case 1: m.queryInput.Focus() } m.refreshList() return m, nil case "ctrl+o": m.errMsg = "" m.statusMsg = "" return m, m.openCmd() case "enter": switch m.focus { case 1: m.errMsg = "" return m, m.searchCmd() case 2: if m.cursor >= 0 && m.cursor < len(m.results) { rec := m.results[m.cursor] return m, func() tea.Msg { return projectLoadMsg{record: rec, target: targetBuilder} } } } return m, nil case "up": if m.focus == 2 && m.cursor > 0 { m.cursor-- m.refreshList() return m, nil } case "down": if m.focus == 2 && m.cursor < len(m.results)-1 { m.cursor++ m.refreshList() return m, nil } case "s": if m.focus == 2 && m.cursor < len(m.results) { rec := m.results[m.cursor] return m, func() tea.Msg { return projectLoadMsg{record: rec, target: targetRepeater} } } case "c": if m.focus == 2 { return m, m.cycleColorCmd() } case "d": if m.focus == 2 { return m, m.deleteCmd() } } } var cmd tea.Cmd switch m.focus { case 0: m.pathInput, cmd = m.pathInput.Update(msg) case 1: m.queryInput, cmd = m.queryInput.Update(msg) case 2: m.viewport, cmd = m.viewport.Update(msg) } return m, cmd } // openCmd (ctrl+o) открывает или создаёт файл проекта по пути из pathInput. // Заодно запоминает path как последний открытый проект (project.SaveLastPath) // — при следующем запуске TUI сам его подхватит. Ошибку сохранения пути // намеренно не считаем фатальной для самого открытия: то, что не удалось // запомнить "последний проект" на будущее, не должно мешать работать // с уже успешно открытым файлом прямо сейчас. func (m projectModel) openCmd() tea.Cmd { path := strings.TrimSpace(m.pathInput.Value()) return func() tea.Msg { if path == "" { return projectErrMsg{fmt.Errorf("укажи путь к файлу проекта")} } store, err := project.Open(path) if err != nil { return projectErrMsg{err} } _ = project.SaveLastPath(path) return projectOpenedMsg{store: store, path: path} } } // searchCmd (enter в строке запроса) разбирает мини-язык через // project.ParseQuery и выполняет поиск в открытом проекте. func (m projectModel) searchCmd() tea.Cmd { if m.store == nil { return func() tea.Msg { return projectErrMsg{fmt.Errorf("сначала открой проект (ctrl+o)")} } } q := m.queryInput.Value() store := m.store return func() tea.Msg { filter := project.ParseQuery(q) results, err := store.Search(filter) if err != nil { return projectErrMsg{err} } return projectSearchMsg{results: results} } } // cycleColorCmd (клавиша "c" на выбранной записи) переключает цветовую // метку по кругу и сохраняет изменение в БД. func (m projectModel) cycleColorCmd() tea.Cmd { if m.store == nil || m.cursor < 0 || m.cursor >= len(m.results) { return nil } rec := m.results[m.cursor] store := m.store next := project.NextColor(rec.ColorLabel) return func() tea.Msg { if err := store.SetColorLabel(rec.ID, next); err != nil { return projectErrMsg{err} } return projectColorSetMsg{id: rec.ID, color: next} } } // deleteCmd (клавиша "d" на выбранной записи) удаляет её из проекта. func (m projectModel) deleteCmd() tea.Cmd { if m.store == nil || m.cursor < 0 || m.cursor >= len(m.results) { return nil } rec := m.results[m.cursor] store := m.store return func() tea.Msg { if err := store.Delete(rec.ID); err != nil { return projectErrMsg{err} } return projectDeletedMsg{id: rec.ID} } } func (m projectModel) View() string { focusMark := func(i int) string { if m.focus == i { return "▸ " } return " " } openStatus := "нет открытого проекта" if m.store != nil { openStatus = m.path } var b strings.Builder b.WriteString(focusMark(0) + fmt.Sprintf("Файл проекта (ctrl+o — открыть/создать) — сейчас: %s\n", openStatus)) b.WriteString(m.pathInput.View()) b.WriteString("\n\n") b.WriteString(focusMark(1) + "Поиск (enter — искать)\n") b.WriteString(m.queryInput.View()) b.WriteString("\n\n") if m.errMsg != "" { b.WriteString("Ошибка: " + m.errMsg + "\n") } if m.statusMsg != "" { b.WriteString(m.statusMsg + "\n") } b.WriteString(fmt.Sprintf("Найдено: %d\n\n", len(m.results))) b.WriteString(focusMark(2) + m.viewport.View()) b.WriteString("\n\ntab — фокус · enter — искать/загрузить · s — в Repeater · c — цвет · d — удалить\n") b.WriteString("f1-f12, pgup — вкладки\n") return b.String() }