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 看過畫面再調。
This commit is contained in:
@@ -1009,6 +1009,65 @@ func main() {
|
||||
}, win)
|
||||
}
|
||||
|
||||
// 🔴 t192(leo 08-04,issue #18):主視窗——「daemon 設定項越來越多,
|
||||
// **不可能統統塞在下拉選單**」「參考 Google Drive 設定畫面:點擊 daemon
|
||||
// 就開一個視窗,佔螢幕約 1/2」「資料夾清單要**可捲動**」。
|
||||
// 托盤選單保留(快速動作),但**主入口改成這個視窗**。
|
||||
// 所有動作都**複用既有 handler**,不在視窗那邊重寫一份邏輯。
|
||||
mw := newMainWindow(a)
|
||||
mw.sup = sup
|
||||
mw.cfg = cfg
|
||||
mw.reload = func() *directConfig { return cfg }
|
||||
mw.onAddAccount = func() { showConnectWizard() }
|
||||
mw.onAddFolder = func(accIdx int) {
|
||||
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
addAccountWatchFolder(cfg, accIdx, uri.Path())
|
||||
if serr := saveConfig(cfg); serr != nil {
|
||||
dialog.ShowError(serr, mw.win)
|
||||
return
|
||||
}
|
||||
restartWatch()
|
||||
rebuildTray()
|
||||
mw.refresh()
|
||||
}, mw.win)
|
||||
}
|
||||
mw.onDelFolder = func(accIdx int, folder string) {
|
||||
removeAccountWatchFolder(cfg, accIdx, folder)
|
||||
if serr := saveConfig(cfg); serr != nil {
|
||||
dialog.ShowError(serr, mw.win)
|
||||
return
|
||||
}
|
||||
restartWatch()
|
||||
rebuildTray()
|
||||
mw.refresh()
|
||||
}
|
||||
mw.onAISettings = func() { showAISettings() }
|
||||
mw.onSyncNow = func() {
|
||||
_ = os.MkdirAll(appDir(), 0o755)
|
||||
_ = os.WriteFile(syncNowSignalPath(), []byte{}, 0o644)
|
||||
mw.refresh()
|
||||
}
|
||||
mw.onCheckUpdate = func() {
|
||||
go func() {
|
||||
st := checkForUpdate()
|
||||
switch {
|
||||
case st.Err != "":
|
||||
dialog.ShowInformation("檢查更新", "暫時連不上更新伺服器:\n"+st.Err, mw.win)
|
||||
case st.Available:
|
||||
go prepareUpdateInBackground()
|
||||
dialog.ShowInformation("有新版本",
|
||||
"發現新版 "+st.Latest+",正在背景下載。\n下載完成後會出現「重新啟動完成更新」。", mw.win)
|
||||
default:
|
||||
dialog.ShowInformation("檢查更新", "你已經是最新版本("+versionLabel()+")。", mw.win)
|
||||
}
|
||||
}()
|
||||
}
|
||||
mw.build()
|
||||
mw.startAutoRefresh()
|
||||
|
||||
// t98:立刻同步——寫訊號檔觸發 collector 立即跑一輪,無需等下一個定時週期。
|
||||
// syncNowItem 宣告在 rebuildTray 外,確保 click handler 的 goroutine 能還原標籤後重建選單。
|
||||
var syncNowItem *fyne.MenuItem
|
||||
@@ -1078,6 +1137,14 @@ func main() {
|
||||
|
||||
var items []*fyne.MenuItem
|
||||
|
||||
// 🔴 t192:主視窗入口放**選單第一項**——leo 點破過同一個病兩次:
|
||||
// 「能力做出來了,入口沒出現在用戶會看的地方」
|
||||
// (docs 建好卻沒連結/MCP 端點活著卻零處提及)。做完就要看得見。
|
||||
items = append(items,
|
||||
fyne.NewMenuItem("開啟 Arcrun…", func() { mw.showAndRefresh() }),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
)
|
||||
|
||||
// t104:per-account 分組——每個帳號獨立一段(帳號名稱標題 + 資料夾 + 新增按鈕)。
|
||||
if len(cfg.Accounts) > 0 {
|
||||
for ai, acc := range cfg.Accounts {
|
||||
@@ -1259,6 +1326,11 @@ func buildStatusLabel(s supervisor.Status, sync traySyncStatus, extractor string
|
||||
base = fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04"))
|
||||
}
|
||||
return base + syncStatusLabel(sync, hasExtractor)
|
||||
// 🔴 t191(leo 08-04,issue #17):正在跑就要**看得出來在跑**。
|
||||
// leo:「如果它顯示『萃取中』、『同步中』⋯⋯而不是看起來好像就做完了,
|
||||
// 這時使用者一看沒做完啊,就覺得是 bug」「這是不讓用戶誤解的方法」。
|
||||
case supervisor.StateSyncing:
|
||||
return "同步中… 正在讀檔並整理成知識卡"
|
||||
case supervisor.StateStarting:
|
||||
return "啟動中…"
|
||||
case supervisor.StateError:
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// mainwindow.go — Arcrun 主視窗(t192,issue #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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"arcrun-rag/collector/supervisor"
|
||||
)
|
||||
|
||||
// t192/#17:正在跑就要**看得出來在跑**。
|
||||
// leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了,
|
||||
// 這時使用者一看沒做完啊,就覺得是 bug」。
|
||||
func TestMainWindowStatusShowsSyncing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
state supervisor.State
|
||||
want string
|
||||
}{
|
||||
{"同步中要講在做什麼", supervisor.StateSyncing, "同步中"},
|
||||
{"看守中", supervisor.StateWatching, "看守中"},
|
||||
{"啟動中", supervisor.StateStarting, "啟動中"},
|
||||
{"出錯要說會自動重試", supervisor.StateError, "重試"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := mainWindowStatusText(supervisor.Status{State: c.state})
|
||||
if !strings.Contains(got, c.want) {
|
||||
t.Errorf("state=%s → %q,應含 %q", c.state, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 同步中的文案必須說明「正在做什麼」,不能只寫兩個字讓人乾等。
|
||||
func TestSyncingTextExplainsWhatItIsDoing(t *testing.T) {
|
||||
got := mainWindowStatusText(supervisor.Status{State: supervisor.StateSyncing})
|
||||
if !strings.Contains(got, "讀檔") && !strings.Contains(got, "知識卡") {
|
||||
t.Errorf("同步中應說明正在做什麼,got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 副標:有錯誤時**優先顯示錯誤**(不能被「上次同步 12:34」蓋掉)。
|
||||
func TestSubTextPrioritisesError(t *testing.T) {
|
||||
got := mainWindowSubText(
|
||||
supervisor.Status{LastRoundAt: time.Now()},
|
||||
traySyncStatus{ExtractorOK: false, ExtractorError: "還沒連上知識庫"},
|
||||
)
|
||||
if !strings.Contains(got, "還沒連上知識庫") {
|
||||
t.Errorf("有錯誤時應優先顯示,got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 副標:正常時要看得到「上次同步時間」與「已整理幾份」。
|
||||
func TestSubTextShowsProgress(t *testing.T) {
|
||||
got := mainWindowSubText(
|
||||
supervisor.Status{LastRoundAt: time.Date(2026, 8, 4, 21, 5, 0, 0, time.Local)},
|
||||
traySyncStatus{ExtractorOK: true, ExtractedOK: 3, ExtractFailed: 1},
|
||||
)
|
||||
for _, want := range []string{"21:05", "已整理 3", "1 份失敗"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("副標應含 %q,got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 沒同步過時不能顯示空白(空白=用戶不知道是壞了還是還沒開始)。
|
||||
func TestSubTextNeverEmpty(t *testing.T) {
|
||||
got := mainWindowSubText(supervisor.Status{}, traySyncStatus{ExtractorOK: true})
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Error("副標不可空白——用戶會分不清是壞了還是還沒開始")
|
||||
}
|
||||
}
|
||||
|
||||
// t192/#18:資料夾清單攤平——帳號一列標題,其下每個資料夾各一列。
|
||||
// 用扁平清單才能吃 widget.List 的虛擬捲動(leo:「幾十個,根本塞不下」)。
|
||||
func TestRebuildRowsFlattensAccountsAndFolders(t *testing.T) {
|
||||
m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{
|
||||
{InstanceName: "geek6688", CypherURL: "https://a.workers.dev", WatchFolders: []string{"/p/one", "/p/two"}},
|
||||
{InstanceName: "youlin", CypherURL: "https://b.workers.dev", WatchFolders: []string{"/p/three"}},
|
||||
}}}
|
||||
m.rebuildRows()
|
||||
|
||||
if len(m.rows) != 5 { // 2 標題 + 3 資料夾
|
||||
t.Fatalf("應攤平成 5 列,got %d:%+v", len(m.rows), m.rows)
|
||||
}
|
||||
if !m.rows[0].isHeader || m.rows[0].title != "geek6688" {
|
||||
t.Errorf("第 0 列應是帳號標題 geek6688,got %+v", m.rows[0])
|
||||
}
|
||||
if m.rows[1].isHeader || m.rows[1].title != "/p/one" {
|
||||
t.Errorf("第 1 列應是資料夾 /p/one,got %+v", m.rows[1])
|
||||
}
|
||||
// 刪除要作用在正確帳號上(t101 踩過:刪錯帳號的資料夾)
|
||||
if m.rows[4].accIdx != 1 {
|
||||
t.Errorf("第 4 列(youlin 的資料夾)accIdx 應為 1,got %d", m.rows[4].accIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// 幾十個資料夾也要能攤平(不是為了效能,是確保沒有隱藏上限)。
|
||||
func TestRebuildRowsHandlesManyFolders(t *testing.T) {
|
||||
folders := make([]string, 60)
|
||||
for i := range folders {
|
||||
folders[i] = "/gitea/project-" + string(rune('a'+i%26))
|
||||
}
|
||||
m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{
|
||||
{InstanceName: "leo", CypherURL: "https://x.workers.dev", WatchFolders: folders},
|
||||
}}}
|
||||
m.rebuildRows()
|
||||
if len(m.rows) != 61 {
|
||||
t.Errorf("60 個資料夾+1 標題應為 61 列,got %d", len(m.rows))
|
||||
}
|
||||
}
|
||||
|
||||
// 沒有任何帳號時清單是空的(走 onboarding,不是顯示壞掉的空面板)。
|
||||
func TestRebuildRowsEmptyWhenNoAccount(t *testing.T) {
|
||||
m := &mainWindow{cfg: &directConfig{}}
|
||||
m.rebuildRows()
|
||||
if len(m.rows) != 0 {
|
||||
t.Errorf("無帳號時應為空清單,got %d 列", len(m.rows))
|
||||
}
|
||||
}
|
||||
@@ -896,12 +896,27 @@ func runDirect(args []string) int {
|
||||
// (留著空轉的回寫邏輯會讓未來的人以為 claude 路還活著=錯誤的環境信號。)
|
||||
|
||||
runOne := func() int {
|
||||
// 🔴 t191(leo 08-04,issue #17):「按『立刻同步』後**很快就回到「看守中」**,
|
||||
// 看起來好像就做完了,這時使用者一看沒做完啊,就覺得是 bug」。
|
||||
//
|
||||
// 真兇:舊版**只在一輪跑完後**才印 JSON ⇒ 托盤無從得知「正在跑」,
|
||||
// 整段工作時間對用戶是隱形的,只看得到前後都是「看守中」。
|
||||
// ⇒ 開工前先印一筆 `phase:"start"`,托盤收到就顯示「同步中…」,
|
||||
// 收到 `phase:"done"` 再切回「看守中」。
|
||||
// 形狀相容:兩筆都有 `at`,舊版托盤只會多算一次 round,不會壞掉。
|
||||
startOut, _ := json.MarshalIndent(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
}{time.Now().Format(time.RFC3339), "start"}, "", " ")
|
||||
fmt.Println(string(startOut))
|
||||
|
||||
results, exit, _ := RunDirectOnce(cfg, *dryRun)
|
||||
out, _ := json.MarshalIndent(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
Folders []string `json:"folders"`
|
||||
Results []DirectResult `json:"results"`
|
||||
}{time.Now().Format(time.RFC3339), cfg.Folders(), results}, "", " ")
|
||||
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results}, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return exit
|
||||
}
|
||||
|
||||
@@ -30,7 +30,12 @@ const (
|
||||
StateStopped State = "stopped" // 未啟動或已停止
|
||||
StateStarting State = "starting" // 行程剛拉起、尚無第一輪
|
||||
StateWatching State = "watching" // 正常看守中(已有掃描輪)
|
||||
StateError State = "error" // 行程非預期退出、等待重起
|
||||
// 🔴 t191(leo 08-04,issue #17):「按『立刻同步』後很快就回到『看守中』,
|
||||
// 看起來好像就做完了⋯⋯使用者一看沒做完啊,就覺得是 bug」。
|
||||
// collector 開工前會印 phase:"start"、跑完印 phase:"done"
|
||||
// ⇒ 這中間就是本狀態,讓托盤顯示「同步中…」,工作時間不再對用戶隱形。
|
||||
StateSyncing State = "syncing" // 正在掃描/萃取(收到 start、還沒收到 done)
|
||||
StateError State = "error" // 行程非預期退出、等待重起
|
||||
)
|
||||
|
||||
// Status 是托盤要顯示的即時狀態(值型別、複製安全)。
|
||||
@@ -46,6 +51,8 @@ type Status struct {
|
||||
// round 對應 collector direct 每輪印到 stdout 的 JSON(見 direct.go runOne)。
|
||||
type round struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"` // t191:"start"(開工)/"done"(跑完);舊版沒有此欄=空
|
||||
|
||||
Folder string `json:"folder"`
|
||||
Results []json.RawMessage `json:"results"`
|
||||
}
|
||||
@@ -298,6 +305,11 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
break
|
||||
}
|
||||
at := parseAt(r.At)
|
||||
if r.Phase == "start" {
|
||||
// t191:開工 → 顯示「同步中…」。不累加 Rounds(那是「完成幾輪」)。
|
||||
s.setState(func(st *Status) { st.State = StateSyncing })
|
||||
continue
|
||||
}
|
||||
s.setState(func(st *Status) {
|
||||
st.State = StateWatching
|
||||
st.Rounds++
|
||||
|
||||
Reference in New Issue
Block a user