package main import ( "bytes" _ "embed" "fmt" "image/png" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/lxn/walk" . "github.com/lxn/walk/declarative" ) //go:embed icon.png var iconPNG []byte // ── Modèle TableView ────────────────────────────────────────────────────────── type RepoItem struct { result RepoResult progress float64 // 0.0 à 1.0 progressText string // ex: "Réception 45% (1.2 Go/2.5 Go)" } 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" } var parts []string if r.HasUpdate { parts = append(parts, "MAJ disponible") } if r.LocalChanges > 0 { parts = append(parts, fmt.Sprintf("%d modif. locale(s)", r.LocalChanges)) } if r.UntrackedFiles > 0 { parts = append(parts, fmt.Sprintf("%d fichier(s) en trop", r.UntrackedFiles)) } if len(parts) == 0 { return "À jour" } return strings.Join(parts, ", ") } // progressBarText génère une barre de progression visuelle en Unicode. // Ex: "████████░░░░ 67% Réception objets" func progressBarText(pct float64, width int, label string) string { if width <= 0 { width = 20 } filled := int(pct * float64(width)) if filled > width { filled = width } bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) pctInt := int(pct * 100) if pctInt > 100 { pctInt = 100 } if label != "" { return fmt.Sprintf("%s %3d%% %s", bar, pctInt, label) } return fmt.Sprintf("%s %3d%%", bar, pctInt) } 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] switch col { case 0: return it.result.Name case 1: return it.statusText() case 2: return it.progressText } return "" } func (m *RepoModel) StyleCell(style *walk.CellStyle) { m.mu.RLock() defer m.mu.RUnlock() row := style.Row() if row >= len(m.items) { return } col := style.Col() if col == 1 { style.TextColor = m.items[row].textColor() } if col == 2 { it := m.items[row] if it.progress > 0 && it.progress < 1.0 { style.TextColor = walk.RGB(0, 100, 180) } else if it.progress >= 1.0 { style.TextColor = walk.RGB(0, 150, 0) } } } func (m *RepoModel) setResult(row int, res RepoResult) { m.mu.Lock() if row < len(m.items) { m.items[row].result = res m.items[row].progress = 0 m.items[row].progressText = "" } m.mu.Unlock() m.PublishRowChanged(row) } func (m *RepoModel) setProgress(row int, pct float64, text string) { m.mu.Lock() if row < len(m.items) { m.items[row].progress = pct m.items[row].progressText = text } 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.HasUpdate || r.LocalChanges > 0 || r.UntrackedFiles > 0 || r.NeedsClone) { return true } } return false } // ── Application ─────────────────────────────────────────────────────────────── type App struct { mw *walk.MainWindow iconView *walk.ImageView 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: 750, Height: 400}, Size: Size{Width: 950, 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{ ImageView{ AssignTo: &a.iconView, MinSize: Size{Width: 24, Height: 24}, MaxSize: Size{Width: 24, Height: 24}, Mode: ImageViewModeIdeal, }, 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: 150}, {Title: "Statut", Width: 200}, {Title: "Progression", 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 (depuis fichier .ico externe) if icoPath := filepath.Join(exeDir(), "icon.ico"); fileExists(icoPath) { if icon, err := walk.NewIconFromFile(icoPath); err == nil { a.mw.SetIcon(icon) } } // Icône dans l'en-tête (depuis PNG embarqué dans l'exe) if img, err := png.Decode(bytes.NewReader(iconPNG)); err == nil { if bmp, err := walk.NewBitmapFromImageForDPI(img, 96); err == nil { a.iconView.SetImage(bmp) } } // 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" } var parts []string if r.HasUpdate { parts = append(parts, "MAJ disponible") } if r.LocalChanges > 0 { parts = append(parts, fmt.Sprintf("%d modif. locale(s)", r.LocalChanges)) } if r.UntrackedFiles > 0 { parts = append(parts, fmt.Sprintf("%d fichier(s) en trop", r.UntrackedFiles)) } return strings.Join(parts, ", ") } // recheckOne re-vérifie un seul dépôt sans toucher aux autres. func (a *App) recheckOne(idx int) { if idx < 0 || idx >= len(a.reposConfig) { return } cfg := a.reposConfig[idx] a.model.setResult(idx, RepoResult{Name: cfg.Name, Pending: true}) go func() { res := checkRepo(cfg) a.mw.Synchronize(func() { a.model.setResult(idx, res) a.btnUpdateAll.SetEnabled(a.model.hasUpdates()) a.onSelectionChanged() }) }() } // proposeClean affiche un popup listant les fichiers non suivis et propose de les supprimer. func (a *App) proposeClean(idx int, res RepoResult) { // Construire la liste des fichiers (max 30 affichés) list := "" for i, f := range res.UntrackedList { if i >= 30 { list += fmt.Sprintf("\n... et %d autre(s)", len(res.UntrackedList)-30) break } list += "\n - " + f } msg := fmt.Sprintf("[%s] %d fichier(s) non suivi(s) détecté(s) :%s\n\nSupprimer ces fichiers ?", res.Name, res.UntrackedFiles, list) ans := walk.MsgBox(a.mw, "Fichiers en trop", msg, walk.MsgBoxYesNo|walk.MsgBoxIconQuestion) if ans == walk.DlgCmdYes { a.appendLog(fmt.Sprintf("[%s] Nettoyage de %d fichier(s)...", res.Name, res.UntrackedFiles)) go func() { err := doClean(res) a.mw.Synchronize(func() { if err != nil { a.appendLog(fmt.Sprintf("[%s] Erreur nettoyage: %v", res.Name, err)) logError(fmt.Sprintf("[%s] clean: %v", res.Name, err)) } else { a.appendLog(fmt.Sprintf("[%s] %d fichier(s) supprimé(s)", res.Name, res.UntrackedFiles)) logInfo(fmt.Sprintf("[%s] %d fichier(s) supprimé(s)", res.Name, res.UntrackedFiles)) } a.recheckOne(idx) }) }() } else { a.recheckOne(idx) } } // ── Progression ─────────────────────────────────────────────────────────────── // makeProgressCB crée un callback de progression pour la ligne row du tableau. // Le callback est appelé depuis un goroutine git et synchronise l'UI via mw.Synchronize. func (a *App) makeProgressCB(row int) ProgressCallback { // Limiter les mises à jour UI (max ~10/s) pour ne pas surcharger var lastUpdate time.Time return func(info ProgressInfo) { now := time.Now() if now.Sub(lastUpdate) < 100*time.Millisecond && info.Percent < 1.0 { return } lastUpdate = now label := info.Phase if info.Speed != "" { label += " " + info.Speed } text := progressBarText(info.Percent, 20, label) a.mw.Synchronize(func() { a.model.setProgress(row, info.Percent, text) }) } } // ── 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.HasUpdate || res.LocalChanges > 0 || res.UntrackedFiles > 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] // Si uniquement des fichiers en trop, proposer directement le nettoyage if res.UntrackedFiles > 0 && !res.HasUpdate && res.LocalChanges == 0 && !res.NeedsClone { a.proposeClean(idx, res) return } a.btnAction.SetEnabled(false) a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name)) cb := a.makeProgressCB(idx) go func() { var err error if res.NeedsClone { err = doCloneWithProgress(cfg, cb) } else { if res.LocalChanges > 0 { err = doCheckout(res) } if err == nil && res.HasUpdate { err = doPullWithProgress(res, cb) } } a.mw.Synchronize(func() { if err != nil { a.model.setProgress(idx, 0, "Erreur") a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err)) logError(fmt.Sprintf("[%s] %v", res.Name, err)) } else { a.model.setProgress(idx, 1.0, progressBarText(1.0, 20, "Terminé")) a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name)) logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name)) } // Si des fichiers en trop après la mise à jour, proposer le nettoyage if res.UntrackedFiles > 0 { a.proposeClean(idx, res) return } // Re-vérifier uniquement ce dépôt, pas tous a.recheckOne(idx) }) }() } 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) i, cfg, res := i, cfg, res cb := a.makeProgressCB(i) go func() { var err error if res.NeedsClone { err = doCloneWithProgress(cfg, cb) } else { if res.LocalChanges > 0 { err = doCheckout(res) } if err == nil && res.HasUpdate { err = doPullWithProgress(res, cb) } if err == nil && res.UntrackedFiles > 0 { err = doClean(res) } } a.mw.Synchronize(func() { if err != nil { a.model.setProgress(i, 0, "Erreur") logError(fmt.Sprintf("[%s] %v", res.Name, err)) a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err)) } else { a.model.setProgress(i, 1.0, progressBarText(1.0, 20, "Terminé")) 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() }