feature/go-rewrite : base Go avec walk GUI
- Rewrite complet en Go : exe unique sans _internal/, sans extraction temp - GUI Windows-native via github.com/lxn/walk (TableView, TextEdit, PushButton) - Meme fonctionnalites : check repos, pull, checkout, auto-update, logs - build.bat : go build -ldflags "-H windowsgui -s -w" -> 9.6 Mo, zero dependance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
531
gui.go
Normal file
531
gui.go
Normal file
@@ -0,0 +1,531 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user