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

285 lines
8.7 KiB
Go
Raw 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 (
"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()
}