Files
arcrun-collector/cmd/arcrun-tray/mainwindow.go
T
Leo cc6534dd39 t191/t192:daemon 主視窗(Google Drive 風格)+「同步中…」進行中狀態
解 issue #18+#17(leo 指定併做)。

## #17 進行中狀態——真兇是「工作時間對用戶隱形」
leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了,
      這時使用者一看沒做完啊,就覺得是 bug」。
真兇:collector **只在一輪跑完後**才印 JSON ⇒ 托盤無從得知「正在跑」。
修:開工前先印 phase:"start"、跑完印 phase:"done";
    supervisor 新增 StateSyncing;托盤/主視窗顯示「同步中… 正在讀檔並整理成知識卡」。
形狀相容:兩筆都有 at,舊版托盤只會多算一次 round,不會壞。

實測(真的跑一輪 --once):
  第1筆 phase='start' at=21:31:26
  第2筆 phase='done'  at=21:31:27

## #18 主視窗(新檔 mainwindow.go)
leo:「daemon 設定項越來越多,不可能統統塞在下拉選單」
     「參考 Google Drive 設定畫面:點擊 daemon 就開一個視窗,佔螢幕約 1/2」
     「資料夾清單要可捲動——我每個 gitea 專案都要同步,那就是幾十個,根本塞不下」
- 920x620 視窗,關窗=隱藏(daemon 續跑)
- 上:大字狀態+副標(上次同步/已整理幾份/失敗幾份;有錯誤優先顯示)
- 中:widget.List 虛擬捲動的資料夾清單(帳號標題+其下資料夾攤平成單層)
- 下:白話動作列(加入資料夾/新增知識庫帳號/AI 設定/檢查更新)
- 每秒自動刷新 ⇒ 同步中看得到在動
- 無帳號時引導去連線(onboarding),不是丟錯誤或空面板
- **所有動作複用既有 handler**,不在視窗那邊重寫一份邏輯

托盤選單第一項加「開啟 Arcrun…」——leo 已點破兩次
「能力做出來了,入口沒出現在用戶會看的地方」(docs 沒連結/MCP 零處提及),
這次做完就讓它看得見。

測試:t192_test.go 八則(狀態文案四態/同步中要說明在做什麼/
副標優先顯示錯誤/副標不可空白/清單攤平且 accIdx 正確/60 個資料夾/無帳號空清單)全過。
collector 與 supervisor 全套綠。

⚠️ 未驗:fyne GUI 的實際外觀需真機開窗(無螢幕環境驗不到),
   CIS 視覺套用待 leo 看過畫面再調。
2026-08-04 21:32:31 +08:00

279 lines
8.9 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.
// mainwindow.go — Arcrun 主視窗(t192issue #18
//
// 🔴 leo 2026-08-04 的要求(逐條):
// - 「daemon 設定項越來越多,**不可能統統塞在下拉選單**」
// - 「參考 **Google Drive 設定畫面**:點擊 daemon 就開一個視窗,佔螢幕約 1/2」
// - 「資料夾清單要**可捲動**——我自己每個 gitea 專案都要同步,那就是幾十個,根本塞不下」
// - daemon 現在**沒有 onboarding**
// - 要套 CIS 視覺
// - 併入:狀態要顯示「萃取中/同步中」(issue #17,已在 supervisor/托盤那層做)
//
// 定位(leo 原話):
//
// 「Daemon 不只用來查看 RAG,它是 Arcrun 的**木馬屠城**……
// Daemon 是 Arcrun 優於 n8n 之處。」
// ⇒ 它是產品門面,不是附屬小工具。
//
// 設計原則(照 leo 立的「用戶好用、白癡化」):
// - 一眼看到「現在在做什麼」——狀態列放最上面、大字、會動
// - 資料夾清單用 widget.List(**虛擬捲動**,幾十個也不卡)
// - 動作用白話:「加入資料夾」不是「新增 watch_folder」
// - 沒連過帳號的人看到的是 onboarding,不是一堆空白面板
package main
import (
"fmt"
"time"
"arcrun-rag/collector/supervisor"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// mainWindow 是常駐的應用視窗(不是每次開新的,避免多開)。
type mainWindow struct {
win fyne.Window
app fyne.App
statusText *widget.Label // 最上方大字狀態(「同步中…」等)
statusSub *widget.Label // 次要說明(上次同步時間、萃取計數)
folderList *widget.List // 可捲動的資料夾清單
rows []folderRow // 目前顯示的列(帳號標題 + 資料夾)
// 由 main.go 注入,避免這裡重寫一份邏輯
cfg *directConfig
sup *supervisor.Supervisor
onAddAccount func()
onAddFolder func(accIdx int)
onDelFolder func(accIdx int, folder string)
onAISettings func()
onSyncNow func()
onCheckUpdate func()
reload func() *directConfig
}
// folderRow 是清單裡的一列:帳號標題或其下的資料夾。
// 用單一扁平清單(而非巢狀樹)才能用 widget.List 的虛擬捲動——
// 幾十個資料夾時只算可見的那幾列,不會卡。
type folderRow struct {
isHeader bool
accIdx int
title string // 帳號名或資料夾路徑
sub string // 次要說明(帳號:實例網址/資料夾:所屬庫)
}
// newMainWindow 建主視窗。約佔一般筆電螢幕的一半(leo:「佔螢幕約 1/2」)。
func newMainWindow(a fyne.App) *mainWindow {
m := &mainWindow{app: a}
m.win = a.NewWindow("Arcrun")
m.win.Resize(fyne.NewSize(920, 620))
m.win.SetCloseIntercept(func() { m.win.Hide() }) // 關窗=隱藏,daemon 續跑
return m
}
// build 組出整個畫面。必須在注入完各 callback 後呼叫。
func (m *mainWindow) build() {
// ── 上:狀態列(一眼看到「現在在做什麼」)──
m.statusText = widget.NewLabel("啟動中…")
m.statusText.TextStyle = fyne.TextStyle{Bold: true}
m.statusSub = widget.NewLabel("")
syncBtn := widget.NewButtonWithIcon("立刻同步", theme.ViewRefreshIcon(), func() {
if m.onSyncNow != nil {
m.onSyncNow()
}
})
syncBtn.Importance = widget.HighImportance
header := container.NewBorder(nil, nil, nil,
container.NewHBox(syncBtn),
container.NewVBox(m.statusText, m.statusSub),
)
// ── 中:可捲動的資料夾清單(leo:「幾十個,根本塞不下」)──
m.folderList = widget.NewList(
func() int { return len(m.rows) },
func() fyne.CanvasObject {
title := widget.NewLabel("")
sub := widget.NewLabel("")
del := widget.NewButtonWithIcon("", theme.DeleteIcon(), nil)
return container.NewBorder(nil, nil, nil, del, container.NewVBox(title, sub))
},
func(i widget.ListItemID, o fyne.CanvasObject) {
if i < 0 || i >= len(m.rows) {
return
}
r := m.rows[i]
box := o.(*fyne.Container)
inner := box.Objects[0].(*fyne.Container)
title := inner.Objects[0].(*widget.Label)
sub := inner.Objects[1].(*widget.Label)
del := box.Objects[1].(*widget.Button)
title.SetText(r.title)
sub.SetText(r.sub)
if r.isHeader {
title.TextStyle = fyne.TextStyle{Bold: true}
del.Hide() // 帳號列沒有「移除資料夾」
} else {
title.TextStyle = fyne.TextStyle{}
del.Show()
row := r
del.OnTapped = func() {
dialog.ShowConfirm("移除這個資料夾?",
"「"+row.title+"」不再自動同步。\n(已經上傳的知識卡不會被刪除)",
func(ok bool) {
if ok && m.onDelFolder != nil {
m.onDelFolder(row.accIdx, row.title)
}
}, m.win)
}
}
title.Refresh()
sub.Refresh()
},
)
// ── 下:動作列(白話命名,不用系統術語)──
actions := container.NewHBox(
widget.NewButtonWithIcon("加入資料夾", theme.FolderNewIcon(), func() {
// 只有一個帳號時直接加;多帳號要先選
accIdx := m.pickAccount()
if accIdx >= 0 && m.onAddFolder != nil {
m.onAddFolder(accIdx)
}
}),
widget.NewButtonWithIcon("新增知識庫帳號", theme.ContentAddIcon(), func() {
if m.onAddAccount != nil {
m.onAddAccount()
}
}),
widget.NewButtonWithIcon("AI 設定", theme.SettingsIcon(), func() {
if m.onAISettings != nil {
m.onAISettings()
}
}),
widget.NewButtonWithIcon("檢查更新", theme.DownloadIcon(), func() {
if m.onCheckUpdate != nil {
m.onCheckUpdate()
}
}),
)
footer := container.NewBorder(nil, nil, actions,
widget.NewLabel("版本 "+versionLabel()))
m.win.SetContent(container.NewBorder(
container.NewVBox(header, widget.NewSeparator()),
container.NewVBox(widget.NewSeparator(), footer),
nil, nil,
m.folderList,
))
}
// pickAccount 決定要把資料夾加到哪個帳號。
// 沒帳號 → 引導去連線(onboarding 的一環,不是丟一個錯誤給他)。
func (m *mainWindow) pickAccount() int {
if m.cfg == nil || len(m.cfg.Accounts) == 0 {
dialog.ShowInformation("先連上你的知識庫",
"還沒有任何知識庫帳號。\n請先按「新增知識庫帳號」,把這台電腦連上你的雲端知識庫。", m.win)
return -1
}
return 0 // 單帳號直接用;多帳號的選擇 UI 待 issue #19 一起做
}
// refresh 依現況重畫(狀態列 + 清單)。可從任何 goroutine 呼叫。
func (m *mainWindow) refresh() {
if m.reload != nil {
if c := m.reload(); c != nil {
m.cfg = c
}
}
m.rebuildRows()
st := m.sup.Status()
sync := loadAppSyncStatus()
m.statusText.SetText(mainWindowStatusText(st))
m.statusSub.SetText(mainWindowSubText(st, sync))
m.folderList.Refresh()
}
// rebuildRows 把 config 攤平成清單列(帳號標題 + 其下資料夾)。
func (m *mainWindow) rebuildRows() {
rows := []folderRow{}
if m.cfg != nil {
for ai, acc := range m.cfg.Accounts {
rows = append(rows, folderRow{
isHeader: true, accIdx: ai,
title: accountDisplayName(acc),
sub: shortCypherHost(acc.CypherURL),
})
for _, f := range acc.WatchFolders {
rows = append(rows, folderRow{accIdx: ai, title: f, sub: "自動同步中"})
}
}
}
m.rows = rows
}
// mainWindowStatusText 是最上方那行大字——**一眼看出現在在做什麼**(issue #17)。
func mainWindowStatusText(s supervisor.Status) string {
switch s.State {
case supervisor.StateSyncing:
return "同步中… 正在讀檔並整理成知識卡"
case supervisor.StateWatching:
return "看守中 · 資料夾有變動就會自動整理"
case supervisor.StateStarting:
return "啟動中…"
case supervisor.StateError:
return "暫時出錯,正在自動重試"
default:
return "尚未開始"
}
}
// mainWindowSubText 是狀態底下那行小字(時間、計數、錯誤)。
func mainWindowSubText(s supervisor.Status, sync traySyncStatus) string {
if sync.ExtractorError != "" && !sync.ExtractorOK {
return "⚠ " + sync.ExtractorError
}
parts := []string{}
if !s.LastRoundAt.IsZero() {
parts = append(parts, "上次同步 "+s.LastRoundAt.Local().Format("15:04"))
}
if sync.ExtractedOK > 0 {
parts = append(parts, fmt.Sprintf("已整理 %d 份", sync.ExtractedOK))
}
if sync.ExtractFailed > 0 {
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", sync.ExtractFailed))
}
if len(parts) == 0 {
return "還沒有同步紀錄"
}
out := parts[0]
for _, p := range parts[1:] {
out += " · " + p
}
return out
}
// showAndRefresh 開窗(若已開就帶到前景)並立即更新一次。
func (m *mainWindow) showAndRefresh() {
m.refresh()
m.win.Show()
m.win.RequestFocus()
}
// startAutoRefresh 每秒更新一次——**同步中的狀態要看得到在動**(issue #17)。
// 視窗隱藏時不做事,不浪費資源。
func (m *mainWindow) startAutoRefresh() {
go func() {
for range time.Tick(time.Second) {
m.refresh()
}
}()
}