refactor(ui): вынести панель «Детали задачи» (TaskDetailPanel) из монолита окна
Спеца 12.11: интерфейс + Nil + TaskDetailText в internal/ui/task_detail.go, Fyne-реализация в internal/ui/desktop/task_detail_panel.go. selectTask делегирует в панель; диалог и «Состояние» — отдельные панели, не трогаем.
This commit is contained in:
54
internal/ui/desktop/task_detail_panel.go
Normal file
54
internal/ui/desktop/task_detail_panel.go
Normal file
@@ -0,0 +1,54 @@
|
||||
//go:build cgo
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||
)
|
||||
|
||||
// TaskDetailPanel — Fyne-реализация ui.TaskDetailPanel (спец 12.8, 12.11):
|
||||
// детали выбранной задачи (заголовок, статус, детали) — верх right-сплита.
|
||||
type TaskDetailPanel struct {
|
||||
titleLbl *widget.Label
|
||||
statusLbl *widget.Label
|
||||
detailLbl *widget.Label
|
||||
}
|
||||
|
||||
// NewTaskDetailPanel создаёт TaskDetailPanel.
|
||||
func NewTaskDetailPanel() *TaskDetailPanel {
|
||||
p := &TaskDetailPanel{}
|
||||
p.titleLbl = widget.NewLabel("—")
|
||||
p.titleLbl.TextStyle = fyne.TextStyle{Bold: true}
|
||||
p.statusLbl = widget.NewLabel("")
|
||||
p.statusLbl.TextStyle = fyne.TextStyle{Italic: true}
|
||||
p.detailLbl = widget.NewLabel("")
|
||||
p.detailLbl.Wrapping = fyne.TextWrapWord
|
||||
return p
|
||||
}
|
||||
|
||||
// Widget возвращает контейнер панели для встраивания в окно (верх right-сплита).
|
||||
func (p *TaskDetailPanel) Widget() fyne.CanvasObject {
|
||||
return container.NewVBox(p.titleLbl, p.statusLbl, p.detailLbl)
|
||||
}
|
||||
|
||||
// ShowTask рендерит детали из снимка задачи (спец 12.11).
|
||||
func (p *TaskDetailPanel) ShowTask(t storage.Task) {
|
||||
p.titleLbl.SetText(t.Title)
|
||||
p.statusLbl.SetText(string(t.Status))
|
||||
p.detailLbl.SetText(ui.TaskDetailText(t))
|
||||
}
|
||||
|
||||
// ShowEmpty сбрасывает к placeholder при недоступной задаче.
|
||||
func (p *TaskDetailPanel) ShowEmpty() {
|
||||
p.titleLbl.SetText("—")
|
||||
p.statusLbl.SetText("")
|
||||
p.detailLbl.SetText("")
|
||||
}
|
||||
|
||||
// compile-time проверка реализации контракта.
|
||||
var _ ui.TaskDetailPanel = (*TaskDetailPanel)(nil)
|
||||
@@ -44,9 +44,7 @@ type Window struct {
|
||||
|
||||
// виджеты
|
||||
taskList *TaskListPanel
|
||||
titleLbl *widget.Label
|
||||
statusLbl *widget.Label
|
||||
detailLbl *widget.Label
|
||||
detail *TaskDetailPanel
|
||||
convLbl *widget.Label
|
||||
stateLbl *widget.Label
|
||||
logs *LogPanel
|
||||
@@ -83,12 +81,7 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn }
|
||||
|
||||
// build собирает виджеты и раскладку.
|
||||
func (w *Window) build() {
|
||||
w.titleLbl = widget.NewLabel("—")
|
||||
w.titleLbl.TextStyle = fyne.TextStyle{Bold: true}
|
||||
w.statusLbl = widget.NewLabel("")
|
||||
w.statusLbl.TextStyle = fyne.TextStyle{Italic: true}
|
||||
w.detailLbl = widget.NewLabel("")
|
||||
w.detailLbl.Wrapping = fyne.TextWrapWord
|
||||
w.detail = NewTaskDetailPanel()
|
||||
|
||||
w.convLbl = widget.NewLabel("Выберите задачу.")
|
||||
w.convLbl.Wrapping = fyne.TextWrapWord
|
||||
@@ -111,7 +104,7 @@ func (w *Window) build() {
|
||||
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
|
||||
|
||||
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане).
|
||||
details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl)
|
||||
details := w.detail.Widget()
|
||||
convScroll := container.NewScroll(w.convLbl)
|
||||
right := container.NewVSplit(
|
||||
container.NewScroll(details),
|
||||
@@ -176,19 +169,11 @@ func (w *Window) selectTask(id int64) {
|
||||
ctx := context.Background()
|
||||
t, err := w.store.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
w.titleLbl.SetText("—")
|
||||
w.statusLbl.SetText("")
|
||||
w.detailLbl.SetText("")
|
||||
w.detail.ShowEmpty()
|
||||
w.convLbl.SetText("")
|
||||
return
|
||||
}
|
||||
w.titleLbl.SetText(t.Title)
|
||||
w.statusLbl.SetText(string(t.Status))
|
||||
detail := "Цель: " + t.Goal
|
||||
if len(t.Repos) > 0 {
|
||||
detail += "\nРепозитории: " + strings.Join(t.Repos, ", ")
|
||||
}
|
||||
w.detailLbl.SetText(detail)
|
||||
w.detail.ShowTask(t)
|
||||
|
||||
// Диалог: история + краткие трассы.
|
||||
hist, err := w.store.GetHistory(ctx, id)
|
||||
|
||||
37
internal/ui/task_detail.go
Normal file
37
internal/ui/task_detail.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
)
|
||||
|
||||
// TaskDetailPanel — панель «Детали задачи» (спец 12.11): заголовок, статус,
|
||||
// детали (цель + репозитории) выбранной задачи.
|
||||
//
|
||||
// Данные — снимки-копии из Store (спец 12.5). Обновление — на потоке Fyne;
|
||||
// вызывающий уже внутри fyne.Do. Диалог и «Состояние» — отдельные панели.
|
||||
type TaskDetailPanel interface {
|
||||
// ShowTask рендерит детали из снимка задачи.
|
||||
ShowTask(t storage.Task)
|
||||
// ShowEmpty сбрасывает к placeholder («—» / пусто) при недоступной задаче.
|
||||
ShowEmpty()
|
||||
}
|
||||
|
||||
// NilTaskDetailPanel — no-op реализация TaskDetailPanel для headless-режима и тестов.
|
||||
type NilTaskDetailPanel struct{}
|
||||
|
||||
// NewNilTaskDetailPanel создаёт NilTaskDetailPanel.
|
||||
func NewNilTaskDetailPanel() *NilTaskDetailPanel { return &NilTaskDetailPanel{} }
|
||||
|
||||
func (NilTaskDetailPanel) ShowTask(storage.Task) {}
|
||||
func (NilTaskDetailPanel) ShowEmpty() {}
|
||||
|
||||
// TaskDetailText — строка деталей задачи (pure-функция, спец 12.11).
|
||||
func TaskDetailText(t storage.Task) string {
|
||||
detail := "Цель: " + t.Goal
|
||||
if len(t.Repos) > 0 {
|
||||
detail += "\nРепозитории: " + strings.Join(t.Repos, ", ")
|
||||
}
|
||||
return detail
|
||||
}
|
||||
36
internal/ui/task_detail_test.go
Normal file
36
internal/ui/task_detail_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
)
|
||||
|
||||
// TaskDetailText — строка деталей задачи (спец 12.11), pure-функция без Fyne.
|
||||
func TestTaskDetailText(t *testing.T) {
|
||||
noRepos := storage.Task{Goal: "Понять код"}
|
||||
got := TaskDetailText(noRepos)
|
||||
if got != "Цель: Понять код" {
|
||||
t.Errorf("без repos: got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "Репозитории") {
|
||||
t.Errorf("без repos не должно быть репозиториев: %q", got)
|
||||
}
|
||||
|
||||
withRepos := storage.Task{Goal: "Рефакторинг", Repos: []string{"a", "b"}}
|
||||
got = TaskDetailText(withRepos)
|
||||
if !strings.Contains(got, "Цель: Рефакторинг") {
|
||||
t.Errorf("цель не попала: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Репозитории: a, b") {
|
||||
t.Errorf("репозитории не попали: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// NilTaskDetailPanel — no-op контракт (спец 12.8): безопасно для headless/тестов.
|
||||
func TestNilTaskDetailPanelNoop(t *testing.T) {
|
||||
p := NewNilTaskDetailPanel()
|
||||
p.ShowTask(storage.Task{ID: 1, Goal: "x"})
|
||||
p.ShowEmpty()
|
||||
}
|
||||
Reference in New Issue
Block a user