package main import ( "fmt" "os" "os/exec" "path/filepath" "sync" "sync/atomic" "time" "github.com/lxn/walk" . "github.com/lxn/walk/declarative" ) // ── Modèle TableView ────────────────────────────────────────────────────────── type RepoItem struct { result RepoResult } func (it *RepoItem) statusText() string { r := it.result if r.Pending { return "Vérification..." } if r.Error != "" { return r.Error } if r.NeedsClone { return "À cloner" } if r.UpToDate { return "À jour" } msg := "" if r.NewCommits > 0 { msg = fmt.Sprintf("%d commit(s)", r.NewCommits) } if r.LocalChanges > 0 { if msg != "" { msg += ", " } msg += fmt.Sprintf("%d modif. locale(s)", r.LocalChanges) } if msg == "" { return "À jour" } return msg } func (it *RepoItem) textColor() walk.Color { r := it.result if r.Pending { return walk.RGB(120, 120, 120) } if r.Error != "" { return walk.RGB(200, 50, 50) } if r.NeedsClone { return walk.RGB(180, 120, 0) } if r.UpToDate { return walk.RGB(0, 150, 0) } return walk.RGB(180, 120, 0) } type RepoModel struct { walk.TableModelBase mu sync.RWMutex items []*RepoItem } func newRepoModel(cfgs []RepoConfig) *RepoModel { m := &RepoModel{} m.items = make([]*RepoItem, len(cfgs)) for i, c := range cfgs { m.items[i] = &RepoItem{result: RepoResult{Name: c.Name, Pending: true}} } return m } func (m *RepoModel) RowCount() int { m.mu.RLock() defer m.mu.RUnlock() return len(m.items) } func (m *RepoModel) Value(row, col int) interface{} { m.mu.RLock() defer m.mu.RUnlock() if row >= len(m.items) { return "" } it := m.items[row] if col == 0 { return it.result.Name } return it.statusText() } func (m *RepoModel) StyleCell(style *walk.CellStyle) { if style.Col() != 1 { return } m.mu.RLock() defer m.mu.RUnlock() if style.Row() >= len(m.items) { return } style.TextColor = m.items[style.Row()].textColor() } func (m *RepoModel) setResult(row int, res RepoResult) { m.mu.Lock() if row < len(m.items) { m.items[row].result = res } m.mu.Unlock() m.PublishRowChanged(row) } func (m *RepoModel) reset(cfgs []RepoConfig) { m.mu.Lock() m.items = make([]*RepoItem, len(cfgs)) for i, c := range cfgs { m.items[i] = &RepoItem{result: RepoResult{Name: c.Name, Pending: true}} } m.mu.Unlock() m.PublishRowsReset() } func (m *RepoModel) getResult(row int) (RepoResult, bool) { m.mu.RLock() defer m.mu.RUnlock() if row < 0 || row >= len(m.items) { return RepoResult{}, false } return m.items[row].result, true } func (m *RepoModel) hasUpdates() bool { m.mu.RLock() defer m.mu.RUnlock() for _, it := range m.items { r := it.result if !r.Pending && r.Error == "" && !r.Offline && (r.NewCommits > 0 || r.LocalChanges > 0 || r.NeedsClone) { return true } } return false } // ── Application ─────────────────────────────────────────────────────────────── type App struct { mw *walk.MainWindow statusLabel *walk.Label tv *walk.TableView model *RepoModel logEdit *walk.TextEdit btnRefresh *walk.PushButton btnUpdateAll *walk.PushButton btnAction *walk.PushButton reposConfig []RepoConfig suConfig SelfUpdateConfig checking atomic.Bool } func runApp() error { app := &App{} var err error app.reposConfig, app.suConfig, err = loadConfig() if err != nil { logWarn("Config: " + err.Error()) } app.model = newRepoModel(app.reposConfig) return app.buildAndRun() } func (a *App) buildAndRun() error { if err := (MainWindow{ AssignTo: &a.mw, Title: "Git Update Checker v" + VERSION, MinSize: Size{Width: 650, Height: 400}, Size: Size{Width: 820, Height: 600}, Layout: VBox{Margins: Margins{Left: 10, Top: 10, Right: 10, Bottom: 10}}, Children: []Widget{ // En-tête Composite{ Layout: HBox{MarginsZero: true}, Children: []Widget{ Label{ Text: "Git Update Checker v" + VERSION, Font: Font{Bold: true, PointSize: 12}, }, HSpacer{}, Label{AssignTo: &a.statusLabel, Text: "Démarrage..."}, }, }, // Zone principale : table + log VSplitter{ Children: []Widget{ TableView{ AssignTo: &a.tv, AlternatingRowBG: true, ColumnsOrderable: false, Columns: []TableViewColumn{ {Title: "Dépôt", Width: 180}, {Title: "Statut", Width: 350}, }, Model: a.model, OnCurrentIndexChanged: a.onSelectionChanged, OnItemActivated: a.doAction, }, Composite{ Layout: VBox{MarginsZero: true}, Children: []Widget{ Composite{ Layout: HBox{MarginsZero: true}, Children: []Widget{ Label{Text: "Journal", Font: Font{Bold: true}}, HSpacer{}, PushButton{ Text: "Effacer", MaxSize: Size{Width: 65}, OnClicked: func() { a.logEdit.SetText("") }, }, }, }, TextEdit{ AssignTo: &a.logEdit, ReadOnly: true, VScroll: true, Font: Font{Family: "Consolas", PointSize: 9}, }, }, }, }, }, // Boutons Composite{ Layout: HBox{MarginsZero: true}, Children: []Widget{ PushButton{ AssignTo: &a.btnRefresh, Text: "Rafraîchir", OnClicked: a.startCheck, }, PushButton{ AssignTo: &a.btnUpdateAll, Text: "Tout mettre à jour", Enabled: false, OnClicked: a.updateAll, }, PushButton{ AssignTo: &a.btnAction, Text: "Mettre à jour", Enabled: false, OnClicked: a.doAction, }, HSpacer{}, PushButton{Text: "config.ini", OnClicked: a.openConfig}, PushButton{Text: "Logs", OnClicked: a.openLogs}, }, }, }, }.Create()); err != nil { return err } // Icône fenêtre if icoPath := filepath.Join(exeDir(), "icon.ico"); fileExists(icoPath) { if icon, err := walk.NewIconFromFile(icoPath); err == nil { a.mw.SetIcon(icon) } } // Lancer la vérification au démarrage go func() { time.Sleep(150 * time.Millisecond) a.mw.Synchronize(a.checkSelfUpdateThenRepos) }() a.mw.Run() return nil } // ── Vérifications ───────────────────────────────────────────────────────────── func (a *App) checkSelfUpdateThenRepos() { a.setStatus("Vérification auto-update...") go func() { needs, info, err := checkSelfUpdate(a.suConfig) a.mw.Synchronize(func() { if err != nil { logWarn("Auto-update: " + err.Error()) } if needs { ans := walk.MsgBox(a.mw, "Mise à jour disponible", info+"\n\nTélécharger maintenant ?", walk.MsgBoxYesNo|walk.MsgBoxIconQuestion, ) if ans == walk.DlgCmdYes { a.doSelfUpdate() return } } a.startCheck() }) }() } func (a *App) startCheck() { if !a.checking.CompareAndSwap(false, true) { return } a.btnRefresh.SetEnabled(false) a.btnUpdateAll.SetEnabled(false) a.btnAction.SetEnabled(false) a.model.reset(a.reposConfig) a.setStatus(fmt.Sprintf("Vérification 0/%d...", len(a.reposConfig))) logInfo("Vérification des dépôts...") done := atomic.Int32{} total := int32(len(a.reposConfig)) for i, cfg := range a.reposConfig { i, cfg := i, cfg go func() { res := checkRepo(cfg) a.mw.Synchronize(func() { a.model.setResult(i, res) a.appendLog(logLineForResult(res)) logInfo(fmt.Sprintf("[%s] %s", res.Name, logLineForResult(res))) n := done.Add(1) if int32(n) == total { a.onCheckDone() } else { a.setStatus(fmt.Sprintf("Vérification %d/%d...", n, total)) } }) }() } if total == 0 { a.onCheckDone() } } func (a *App) onCheckDone() { a.checking.Store(false) a.btnRefresh.SetEnabled(true) a.btnUpdateAll.SetEnabled(a.model.hasUpdates()) a.setStatus(fmt.Sprintf("Dernière vérification : %s", time.Now().Format("15:04:05"))) } func logLineForResult(r RepoResult) string { if r.Error != "" { return r.Error } if r.NeedsClone { return "À cloner" } if r.UpToDate { return "À jour" } msg := "" if r.NewCommits > 0 { msg += fmt.Sprintf("%d commit(s) disponible(s)", r.NewCommits) } if r.LocalChanges > 0 { if msg != "" { msg += ", " } msg += fmt.Sprintf("%d modif. locale(s)", r.LocalChanges) } return msg } // ── Actions dépôt ───────────────────────────────────────────────────────────── func (a *App) onSelectionChanged() { idx := a.tv.CurrentIndex() res, ok := a.model.getResult(idx) if !ok || res.Pending || res.Offline || res.Error != "" { a.btnAction.SetEnabled(false) return } if res.NeedsClone { a.btnAction.SetText("Cloner") a.btnAction.SetEnabled(true) } else if res.NewCommits > 0 || res.LocalChanges > 0 { a.btnAction.SetText("Mettre à jour") a.btnAction.SetEnabled(true) } else { a.btnAction.SetEnabled(false) } } func (a *App) doAction() { idx := a.tv.CurrentIndex() res, ok := a.model.getResult(idx) if !ok { return } cfg := a.reposConfig[idx] a.btnAction.SetEnabled(false) a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name)) go func() { var err error if res.NeedsClone { err = doClone(cfg) } else { if res.LocalChanges > 0 { err = doCheckout(res) } if err == nil && res.NewCommits > 0 { err = doPull(res) } } a.mw.Synchronize(func() { if err != nil { walk.MsgBox(a.mw, "Erreur", res.Name+"\n\n"+err.Error(), walk.MsgBoxIconError) logError(fmt.Sprintf("[%s] %v", res.Name, err)) } else { a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name)) logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name)) } a.startCheck() }) }() } func (a *App) updateAll() { a.btnUpdateAll.SetEnabled(false) a.btnRefresh.SetEnabled(false) pending := atomic.Int32{} for i, cfg := range a.reposConfig { res, ok := a.model.getResult(i) if !ok || res.Pending || res.UpToDate || res.Offline || res.Error != "" { continue } pending.Add(1) cfg, res := cfg, res go func() { var err error if res.NeedsClone { err = doClone(cfg) } else { if res.LocalChanges > 0 { err = doCheckout(res) } if err == nil && res.NewCommits > 0 { err = doPull(res) } } a.mw.Synchronize(func() { if err != nil { logError(fmt.Sprintf("[%s] %v", res.Name, err)) a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err)) } else { logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name)) a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name)) } if pending.Add(-1) == 0 { a.startCheck() } }) }() } if pending.Load() == 0 { a.startCheck() } } // ── Auto-update programme ───────────────────────────────────────────────────── func (a *App) doSelfUpdate() { a.setStatus("Téléchargement de la mise à jour...") go func() { err := doSelfUpdate(a.suConfig) a.mw.Synchronize(func() { if err != nil { walk.MsgBox(a.mw, "Erreur", "Mise à jour échouée :\n"+err.Error(), walk.MsgBoxIconError) logError("Auto-update: " + err.Error()) a.startCheck() return } logInfo("Auto-update: mise à jour appliquée, redémarrage...") walk.MsgBox(a.mw, "Mise à jour", "Mise à jour installée.\nLe programme va redémarrer.", walk.MsgBoxIconInformation) exePath, _ := os.Executable() relaunchAfterUpdate(exePath) a.mw.Close() }) }() } // ── Utilitaires GUI ─────────────────────────────────────────────────────────── func (a *App) setStatus(text string) { a.statusLabel.SetText(text) } func (a *App) appendLog(line string) { ts := time.Now().Format("15:04:05") current := a.logEdit.Text() if current != "" { current += "\r\n" } a.logEdit.SetText(current + "[" + ts + "] " + line) // Scroller en bas a.logEdit.SendMessage(0x0115 /*WM_VSCROLL*/, 7 /*SB_BOTTOM*/, 0) } func (a *App) openConfig() { p := filepath.Join(exeDir(), "config.ini") exec.Command("notepad", p).Start() } func (a *App) openLogs() { p := filepath.Join(exeDir(), "log") exec.Command("explorer", p).Start() }