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