feat(ui): команды, snapshots, Fyne-окно и интеграция (--noui)
Команды (спец 12.2): - ui.Commands: действия UI → текстовые команды канала (start/cancel/skip/ retry/continue/approve/send), единый путь через chat.Channel. - ui.Window = chat.Channel + View; NilWindow для headless/тестов. Snapshots (спец 12.5): - ui.Store: чтение-модель, возвращает только копии (ListTasks/GetTask/ GetHistory/GetTraces); ui.DBStore поверх storage. Fyne-окно (internal/ui/desktop, build-tag cgo): - список задач слева, сплиты рабочей области и панели «Логи»/«Состояние», ввод+кнопки команд, тёмная тема, fullscreen, сохранение layout в Preferences, сворачивание при закрытии крестиком. - Колбэки View через fyne.Do (спец 12.4). Интеграция: - app.New(..., noUI); флаг --noui; UI собирается только с cgo (ui_cgo/ ui_noui фабрики), приложение headless без него. - Run: окно блокирует главную горутину; «Завершить» → cancel → graceful shutdown (спец 12.6). - go.mod: fyne.io/fyne/v2 v2.6.0 (direct).
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||
"github.com/kamelion/ratatoskr-go/internal/update"
|
||||
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||
)
|
||||
@@ -67,6 +68,11 @@ type App struct {
|
||||
// Events — доменная шина UI; LogEvents — шина логов (панель «Логи»).
|
||||
Events *events.Bus
|
||||
LogEvents *events.LogBus
|
||||
|
||||
// UI — десктопное окно (если собрано с cgo и не задан --noui).
|
||||
// nil — headless-режим (без окна).
|
||||
UI ui.Window
|
||||
Controller *ui.Controller
|
||||
}
|
||||
|
||||
// New читает конфиг и собирает все зависимости.
|
||||
@@ -74,7 +80,7 @@ type App struct {
|
||||
// version — вшитая версия бинаря (ldflag -X main.version).
|
||||
// updateToken — вшитый токен read:package для авто-обновления
|
||||
// (ldflag -X main.updateToken); имеет приоритет над update.token из конфига.
|
||||
func New(configPath, version, updateToken string) (*App, error) {
|
||||
func New(configPath, version, updateToken string, noUI bool) (*App, error) {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrConfig, err)
|
||||
@@ -165,6 +171,22 @@ func New(configPath, version, updateToken string) (*App, error) {
|
||||
|
||||
router := chat.NewRouter(a.handleIncoming)
|
||||
|
||||
// UI-канал: ещё одна реализация chat.Channel (спец 12.1).
|
||||
// Собирается только при наличии cgo и отсутствии --noui.
|
||||
if !noUI {
|
||||
win := newUIWindow(ui.NewDBStore(store))
|
||||
if win != nil {
|
||||
if err := router.Attach(win); err != nil {
|
||||
store.Close()
|
||||
return nil, fmt.Errorf("attach ui: %w", err)
|
||||
}
|
||||
a.UI = win
|
||||
// Контроллер подписывается на шины и мостит события в окно (fyne.Do).
|
||||
a.Controller = ui.New(a.Events, a.LogEvents, win)
|
||||
a.Controller.Start()
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram-канал
|
||||
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
|
||||
a.tg = tg
|
||||
@@ -246,6 +268,14 @@ func (a *App) Run(ctx context.Context) error {
|
||||
// Авто-проверка обновления (только уведомление владельца; замена — по /update)
|
||||
a.startAutoCheck(ctx)
|
||||
|
||||
// UI: окно блокирует главную горутину (спец 12.6). Кнопка «Завершить»
|
||||
// отменяет ctx → fyneApp.Quit() → Run возвращается → graceful shutdown.
|
||||
// Закрытие крестиком = сворачивание: Core продолжает работать.
|
||||
if a.UI != nil {
|
||||
a.UI.SetOnQuit(cancel)
|
||||
return a.UI.Run(ctx)
|
||||
}
|
||||
|
||||
// Ожидание сигнала или фатальной ошибки Telegram
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestNew(t *testing.T) {
|
||||
t.Setenv("RATATOSKR_DB", dbPath)
|
||||
|
||||
// Загружаем без config-файла (дефолты + env)
|
||||
a, err := New("", "dev", "")
|
||||
a, err := New("", "dev", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func TestNew_MissingToken(t *testing.T) {
|
||||
t.Setenv("TG_CHAT_ID", "12345")
|
||||
t.Setenv("RATATOSKR_DB", dbPath)
|
||||
|
||||
_, err := New("", "dev", "")
|
||||
_, err := New("", "dev", "", true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestNew_BadDB(t *testing.T) {
|
||||
t.Setenv("TG_CHAT_ID", "12345")
|
||||
t.Setenv("RATATOSKR_DB", dbPath)
|
||||
|
||||
_, err := New("", "dev", "")
|
||||
_, err := New("", "dev", "", true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid db path")
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestNew_RunCtxCancel(t *testing.T) {
|
||||
t.Setenv("TG_CHAT_ID", "12345")
|
||||
t.Setenv("RATATOSKR_DB", dbPath)
|
||||
|
||||
a, err := New("", "dev", "")
|
||||
a, err := New("", "dev", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestNew_WorktreeCreated(t *testing.T) {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
a, err := New(configPath, "dev", "")
|
||||
a, err := New(configPath, "dev", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func TestNew_UpdateWiring(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1) base_url из update-блока, а НЕ git
|
||||
a, err := New(configPath, "dev", "")
|
||||
a, err := New(configPath, "dev", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestNew_UpdateWiring(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2) вшитый updateToken перекрывает конфиг
|
||||
a2, err := New(configPath, "dev", "embedded-update-token")
|
||||
a2, err := New(configPath, "dev", "embedded-update-token", true)
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
@@ -248,4 +248,4 @@ func TestVersion_Semver(t *testing.T) {
|
||||
if !re.MatchString(Version) {
|
||||
t.Errorf("Version = %q, ожидался формат major.minor.patch", Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
internal/app/ui_cgo.go
Normal file
15
internal/app/ui_cgo.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build cgo
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||
"github.com/kamelion/ratatoskr-go/internal/ui/desktop"
|
||||
)
|
||||
|
||||
// newUIWindow создаёт десктопное окно (Fyne) на основе чтения-модели.
|
||||
// Сборка с cgo: GLFW доступен, окно реально.
|
||||
func newUIWindow(store ui.Store) ui.Window {
|
||||
w := desktop.New("ratatoskr-go", store)
|
||||
return w
|
||||
}
|
||||
11
internal/app/ui_noui.go
Normal file
11
internal/app/ui_noui.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !cgo
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||
)
|
||||
|
||||
// newUIWindow возвращает nil: без cgo (нет GLFW) окно недоступно,
|
||||
// приложение работает в headless-режиме.
|
||||
func newUIWindow(ui.Store) ui.Window { return nil }
|
||||
Reference in New Issue
Block a user