Files
Lanceur-Geco/gui.go
zogzog 037f211d9d v0.7.3 - Fix re-check unique apres action depot
Changements :
- Ajout barre de progression Unicode dans la colonne Progression
- Capture temps reel de la sortie git (clone/pull --progress)
- Timeouts augmentes (2h clone/pull, 5min fetch) pour gros depots 10+ Go
- Apres mise a jour d'un depot, seul ce depot est re-verifie (plus de re-fetch global)
- Config self-update : ajout branch = feature/go-rewrite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:59:28 +01:00

632 lines
16 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
)
// ── 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"
}
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
}
// 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.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: 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{
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
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
}
// 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()
})
}()
}
// ── 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.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))
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.NewCommits > 0 {
err = doPullWithProgress(res, cb)
}
}
a.mw.Synchronize(func() {
if err != nil {
a.model.setProgress(idx, 0, "Erreur")
walk.MsgBox(a.mw, "Erreur", res.Name+"\n\n"+err.Error(), walk.MsgBoxIconError)
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))
}
// 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.NewCommits > 0 {
err = doPullWithProgress(res, cb)
}
}
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()
}