Files
code-utils/app.go
2026-08-14 17:54:06 +08:00

803 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"view/platform"
"view/service"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
type App struct {
ctx context.Context
store *Store
bootstrapFile string
bootstrap BootstrapStatus
mu sync.Mutex
tasks map[string]context.CancelFunc
taskResults map[string]string
aiStreams map[int64]context.CancelFunc
shell *shellState
notifier *notifications.NotificationService
syncing atomic.Bool
syncOnline atomic.Bool
syncMu sync.Mutex
syncLastErr string
// 全局文件存储配置的实时读取缓存(见 filestorage.go currentFileStorage
fsMu sync.Mutex
fsCfg FileStorageConfig
fsCfgAt time.Time
}
func NewApp() *App {
return &App{tasks: map[string]context.CancelFunc{}, taskResults: map[string]string{}, aiStreams: map[int64]context.CancelFunc{}}
}
// emit 向前端广播事件;纯测试环境(无 Wails 应用实例)下静默忽略。
func (a *App) emit(name string, data any) {
if app := application.Get(); app != nil {
app.Event.Emit(name, data)
}
}
// ServiceStartup 由 Wails v3 在应用启动时调用,执行数据库引导流程。
func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
a.startup(ctx)
a.RefreshShell()
go a.runAutoUpdateLoop()
go a.runReminderLoop()
go a.runSyncLoop()
go a.runTeamDigestLoop()
return nil
}
// ServiceShutdown 由 Wails v3 在应用退出时调用。
func (a *App) ServiceShutdown() error {
a.shutdown(context.Background())
return nil
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
defaultPath, e := defaultDBPath()
if e != nil {
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, ErrorCode: "DB_DEFAULT_PATH_FAILED", ErrorDetail: e.Error()}
return
}
a.bootstrapFile, e = bootstrapPath()
if e != nil {
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, ErrorCode: "BOOTSTRAP_PATH_FAILED", ErrorDetail: e.Error()}
return
}
c, e := readBootstrap(a.bootstrapFile)
if e != nil {
if os.IsNotExist(e) {
if _, se := os.Stat(defaultPath); se == nil {
c = BootstrapConfig{DatabasePath: defaultPath, Initialized: true}
_ = writeBootstrap(a.bootstrapFile, c)
} else {
a.bootstrap = BootstrapStatus{State: BootstrapSetup, DefaultPath: defaultPath, DatabasePath: defaultPath}
return
}
} else {
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: defaultPath, ErrorCode: "BOOTSTRAP_INVALID", ErrorDetail: e.Error()}
return
}
}
if _, e = os.Stat(c.DatabasePath); e != nil {
code := "DB_FILE_UNREADABLE"
if os.IsNotExist(e) {
code = "DB_FILE_MISSING"
}
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: c.DatabasePath, ErrorCode: code, ErrorDetail: e.Error()}
return
}
s, e := OpenStore(c.DatabasePath)
if e != nil {
a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e)
return
}
a.store = s
a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: defaultPath, DatabasePath: c.DatabasePath}
a.store.Log("info", "系统", "应用程序启动", "")
a.store.Log("info", "数据库", "桌面运行时已连接", s.path)
}
func (a *App) GetBootstrapStatus() BootstrapStatus { return a.bootstrap }
func (a *App) SelectInitialDatabaseFile(defaultPath string) (string, error) {
if strings.TrimSpace(defaultPath) == "" {
defaultPath = a.bootstrap.DefaultPath
}
return application.Get().Dialog.SaveFile().
SetMessage("Initialize 年糕崽崽 PMS database").
SetDirectory(filepath.Dir(defaultPath)).
SetFilename(filepath.Base(defaultPath)).
AddFilter("SQLite Database", "*.db").
PromptForSingleSelection()
}
func (a *App) InitializeDatabase(path string) (BootstrapStatus, error) {
a.mu.Lock()
defer a.mu.Unlock()
s, e := createValidatedStore(path)
if e != nil {
a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, e)
return a.bootstrap, e
}
if a.store != nil {
_ = a.store.db.Close()
}
a.store = s
if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: s.path, Initialized: true}); e != nil {
_ = s.db.Close()
a.store = nil
a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, coded("BOOTSTRAP_WRITE_FAILED", e))
return a.bootstrap, e
}
a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: s.path}
a.store.Log("info", "数据库", "数据库初始化成功", s.path)
return a.bootstrap, nil
}
func (a *App) RetryDatabase() (BootstrapStatus, error) {
return a.InitializeDatabase(a.bootstrap.DatabasePath)
}
func (a *App) shutdown(context.Context) {
if a.store != nil && a.store.db != nil {
_ = a.store.db.Close()
}
}
func (a *App) ready() error {
if a.store == nil {
return errors.New("DATABASE_NOT_READY")
}
return nil
}
func (a *App) SelectDirectory() (string, error) {
if e := a.ready(); e != nil {
return "", e
}
return application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择代码目录", "Select code directory")).
CanChooseDirectories(true).
CanChooseFiles(false).
CanCreateDirectories(true).
PromptForSingleSelection()
}
// ListWSLDistros 返回本机可用的 WSL 发行版,供前端创建 UNC 项目路径。
func (a *App) ListWSLDistros() ([]string, error) { return platform.ListWSLDistros(a.ctx) }
// SelectWSLDirectory 从指定发行版根目录打开原生目录选择器。
func (a *App) SelectWSLDirectory(distro string) (string, error) {
if strings.TrimSpace(distro) == "" {
return "", errors.New("WSL_DISTRO_REQUIRED")
}
root := "\\\\wsl.localhost\\" + distro + "\\"
return application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择 WSL 代码目录", "Select WSL code directory")).
SetDirectory(root).
CanChooseDirectories(true).
CanChooseFiles(false).
PromptForSingleSelection()
}
func (a *App) localized(zh, en string) string {
if a.store != nil {
if s, e := a.store.Settings(); e == nil && s.Locale == "en" {
return en
}
}
return zh
}
func (a *App) SelectDatabaseFile() (string, error) {
return application.Get().Dialog.SaveFile().
SetMessage("Select database location").
SetFilename("code-count.db").
AddFilter("SQLite Database", "*.db").
PromptForSingleSelection()
}
func (a *App) ListProjects() ([]Project, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListProjects(0)
}
func (a *App) ListProjectsByGroup(groupID int64) ([]Project, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListProjects(groupID)
}
func (a *App) ListProjectGroups() ([]ProjectGroup, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ListProjectGroups()
}
func (a *App) SaveProjectGroup(id int64, name string) (ProjectGroup, error) {
if e := a.ready(); e != nil {
return ProjectGroup{}, e
}
g, e := a.store.SaveProjectGroup(id, name)
if e == nil {
a.store.Log("info", "project", "Project group saved", g.Name)
}
return g, e
}
func (a *App) DeleteProjectGroup(id int64) error {
if e := a.ready(); e != nil {
return e
}
e := a.store.DeleteProjectGroup(id)
if e == nil {
a.store.Log("info", "project", "Project group deleted", fmt.Sprint(id))
}
return e
}
func (a *App) GetProject(id int64) (Project, error) {
if e := a.ready(); e != nil {
return Project{}, e
}
return a.store.GetProject(id)
}
func (a *App) SaveProject(id int64, in ProjectInput) (Project, error) {
if e := a.ready(); e != nil {
return Project{}, e
}
p, e := a.store.SaveProject(id, in)
if e == nil {
a.store.Log("info", "项目", "项目保存成功", p.Path)
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
} else {
a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error())
}
return p, e
}
func (a *App) ReportClientError(category, message, detail string) {
if a.store != nil {
a.store.Log("error", category, message, detail)
}
}
func (a *App) DeleteProject(id int64) error {
if e := a.ready(); e != nil {
return e
}
e := a.store.DeleteProject(id)
if e == nil {
a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id))
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
}
return e
}
func (a *App) GetDashboard() (Dashboard, error) {
if e := a.ready(); e != nil {
return Dashboard{}, e
}
return a.store.Dashboard(0)
}
func (a *App) GetDashboardByGroup(groupID int64) (Dashboard, error) {
if e := a.ready(); e != nil {
return Dashboard{}, e
}
return a.store.Dashboard(groupID)
}
func (a *App) GetStructure(id int64) (StructureStats, error) {
if e := a.ready(); e != nil {
return StructureStats{}, e
}
return a.store.Structure(id)
}
func (a *App) GetProjectInsights(id int64) (ProjectInsights, error) {
if e := a.ready(); e != nil {
return ProjectInsights{}, e
}
x, e := a.store.Insights(id)
if e == nil {
return x, nil
}
if errors.Is(e, sql.ErrNoRows) {
return a.RefreshProjectInsights(id)
}
return ProjectInsights{}, e
}
func (a *App) RefreshProjectInsights(id int64) (ProjectInsights, error) {
if e := a.ready(); e != nil {
return ProjectInsights{}, e
}
p, e := a.store.GetProject(id)
if e != nil {
return ProjectInsights{}, e
}
structure, e := a.store.Structure(id)
if e != nil {
return ProjectInsights{}, e
}
git, _ := a.store.GitStats(id)
x, e := (service.InsightService{}).Analyze(a.ctx, p, structure, git)
if e != nil {
a.store.Log("error", "项目检查", "深度检查失败", p.Name+" | "+e.Error())
return x, e
}
if e = a.store.ReplaceInsights(id, x); e != nil {
a.store.Log("error", "项目检查", "保存检查结果失败", p.Name+" | "+e.Error())
return x, e
}
a.store.Log("info", "项目检查", "深度检查完成", fmt.Sprintf("%s | %d 分 | %d 个问题", p.Name, x.HealthScore, len(x.Issues)))
return x, nil
}
func (a *App) GetGitStats(id int64) (GitStats, error) {
if e := a.ready(); e != nil {
return GitStats{}, e
}
return a.store.GitStats(id)
}
// GetGitDiagnostics 分步检查 Git/WSL 状态,用于解释 Git 统计为空或失败的原因。
func (a *App) GetGitDiagnostics(id int64) (GitDiagnostics, error) {
if e := a.ready(); e != nil {
return GitDiagnostics{}, e
}
p, e := a.store.GetProject(id)
if e != nil {
return GitDiagnostics{}, e
}
d := (GitAnalyzer{}).Diagnostics(a.ctx, p.Path, "")
if d.Available {
a.store.Log("info", "Git分析", "Git 诊断完成", p.Name+" | "+d.WorkspaceBranch)
} else {
a.store.Log("warning", "Git分析", "Git 诊断失败", p.Name+" | "+d.ErrorCode+" | "+d.Detail)
}
return d, nil
}
// GetGitStatsForRef 只改变统计视图,不执行 checkout。
func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) {
if e := a.ready(); e != nil {
return GitStats{}, e
}
p, e := a.store.GetProject(id)
if e != nil {
return GitStats{}, e
}
a.store.Log("info", "Git分析", "查询分支统计", p.Name+" | "+ref)
g, e := (GitAnalyzer{}).AnalyzeRef(a.ctx, p.Path, ref)
if e != nil {
a.store.Log("error", "Git分析", "查询分支统计失败", e.Error())
}
return g, e
}
// GetCommitDetails 按需查询提交涉及的文件,避免 Git 首页一次加载所有明细。
func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) {
if e := a.ready(); e != nil {
return GitCommitDetail{}, e
}
p, e := a.store.GetProject(id)
if e != nil {
return GitCommitDetail{}, e
}
return (GitAnalyzer{}).CommitDetail(a.ctx, p.Path, hash)
}
// CheckoutBranch 安全切换工作区分支;服务层会拒绝任何脏工作区。
func (a *App) CheckoutBranch(id int64, ref string) (CheckoutResult, error) {
if e := a.ready(); e != nil {
return CheckoutResult{}, e
}
p, e := a.store.GetProject(id)
if e != nil {
return CheckoutResult{}, e
}
result, e := (GitAnalyzer{}).CheckoutBranch(a.ctx, p.Path, ref)
if e != nil {
a.store.Log("error", "Git分析", "切换分支失败", p.Name+" | "+ref+" | "+e.Error())
return result, e
}
a.store.Log("info", "Git分析", "切换分支成功", p.Name+" | "+result.Branch)
return result, nil
}
func (a *App) GetRules() ([]ExclusionRule, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.Rules()
}
func (a *App) AddRule(pattern, category string) (ExclusionRule, error) {
if e := a.ready(); e != nil {
return ExclusionRule{}, e
}
return a.store.AddRule(pattern, category)
}
func (a *App) DeleteRule(id int64) error {
if e := a.ready(); e != nil {
return e
}
return a.store.DeleteRule(id)
}
func (a *App) GetLogs(level string) ([]LogEntry, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.Logs(level)
}
func (a *App) SearchLogs(query, level, category string, offset, limit int64) (LogPage, error) {
if e := a.ready(); e != nil {
return LogPage{}, e
}
return a.store.SearchLogs(query, level, category, offset, limit)
}
func (a *App) GetLogCategories() ([]string, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.LogCategories()
}
// previewMaxBytes 是文件预览读取上限1MB超出部分截断。
const previewMaxBytes = 1 << 20
// ReadProjectFile 读取项目内的相对路径文件用于预览:限制大小、检测二进制、阻止路径穿越。
func (a *App) ReadProjectFile(projectID int64, relPath string) (FilePreview, error) {
if e := a.ready(); e != nil {
return FilePreview{}, e
}
p, e := a.store.GetProject(projectID)
if e != nil {
return FilePreview{}, e
}
rel := strings.TrimSpace(relPath)
if rel == "" {
return FilePreview{}, errors.New("FILE_PATH_REQUIRED")
}
desc, e := platform.ResolvePath(p.Path)
if e != nil {
return FilePreview{}, e
}
root := desc.WindowsPath
full := filepath.Join(root, filepath.FromSlash(rel))
if back, e := filepath.Rel(root, full); e != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) {
return FilePreview{}, errors.New("FILE_PATH_INVALID")
}
st, e := os.Stat(full)
if e != nil {
if os.IsNotExist(e) {
return FilePreview{}, coded("FILE_NOT_FOUND", e)
}
return FilePreview{}, coded("FILE_READ_FAILED", e)
}
if st.IsDir() {
return FilePreview{}, errors.New("FILE_IS_DIRECTORY")
}
out := FilePreview{Path: filepath.ToSlash(rel), Name: st.Name(), Extension: strings.ToLower(filepath.Ext(st.Name())), Size: st.Size()}
f, e := os.Open(full)
if e != nil {
return out, coded("FILE_READ_FAILED", e)
}
defer f.Close()
buf := make([]byte, min(st.Size(), previewMaxBytes))
n, e := io.ReadFull(f, buf)
if e != nil && !errors.Is(e, io.ErrUnexpectedEOF) && !errors.Is(e, io.EOF) {
return out, coded("FILE_READ_FAILED", e)
}
buf = buf[:n]
out.Truncated = st.Size() > previewMaxBytes
probe := buf
if len(probe) > 8192 {
probe = probe[:8192]
}
if bytes.IndexByte(probe, 0) >= 0 {
out.Binary = true
return out, nil
}
out.Content = string(buf)
if out.Content != "" {
out.Lines = strings.Count(out.Content, "\n") + 1
}
return out, nil
}
func (a *App) ClearLogs() error {
if e := a.ready(); e != nil {
return e
}
return a.store.ClearLogs()
}
func (a *App) GetSettings() (AppSettings, error) {
if e := a.ready(); e != nil {
return AppSettings{}, e
}
return a.store.Settings()
}
func (a *App) SaveSettings(x AppSettings) error {
if e := a.ready(); e != nil {
return e
}
if e := a.store.SaveSettings(x); e != nil {
return e
}
a.RefreshShell()
return nil
}
func (a *App) ClearData(mode string, projectID int64) error {
if e := a.ready(); e != nil {
return e
}
e := a.store.ClearData(mode, projectID)
if e == nil {
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
}
return e
}
// MigrateDatabase 仅把本机 SQLite 文件搬到新路径并切换连接。
// 不做任何 schema DDL云端 MySQL 建表/升级只允许通过仓库根目录 init.sql或 tools/applyinit执行。
func (a *App) MigrateDatabase(target string) error {
if e := a.ready(); e != nil {
return e
}
if target == "" {
return errors.New("PATH_REQUIRED")
}
target = filepath.Clean(target)
if target == a.store.path {
return nil
}
if e := os.MkdirAll(filepath.Dir(target), 0755); e != nil {
return e
}
tmp := target + ".tmp"
_ = os.Remove(tmp)
_, _ = a.store.db.Exec(`PRAGMA wal_checkpoint(FULL)`)
src, e := os.Open(a.store.path)
if e != nil {
return e
}
defer src.Close()
dst, e := os.Create(tmp)
if e != nil {
return e
}
if _, e = dst.ReadFrom(src); e != nil {
dst.Close()
os.Remove(tmp)
return e
}
_ = dst.Sync()
dst.Close()
check, e := OpenStore(tmp)
if e != nil {
os.Remove(tmp)
return e
}
var result string
e = check.db.QueryRow(`PRAGMA integrity_check`).Scan(&result)
check.db.Close()
if e != nil || result != "ok" {
os.Remove(tmp)
return errors.New("DATABASE_INTEGRITY_FAILED")
}
if e = os.Rename(tmp, target); e != nil {
return e
}
next, e := OpenStore(target)
if e != nil {
return e
}
old := a.store
a.store = next
_ = old.db.Close()
if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: target, Initialized: true}); e != nil {
return coded("BOOTSTRAP_WRITE_FAILED", e)
}
a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: target}
a.store.Log("info", "数据库", "数据库迁移成功", target)
return nil
}
func (a *App) StartAnalysis(projectID int64, kind string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
p, e := a.store.GetProject(projectID)
if e != nil {
return "", e
}
taskID := fmt.Sprintf("%d-%s", projectID, kind)
a.mu.Lock()
if _, ok := a.tasks[taskID]; ok {
a.mu.Unlock()
return "", errors.New("TASK_ALREADY_RUNNING")
}
ctx, cancel := context.WithCancel(a.ctx)
a.tasks[taskID] = cancel
a.mu.Unlock()
go func() {
finalStage := "completed"
defer func() {
if recovered := recover(); recovered != nil {
detail := fmt.Sprintf("%v", recovered)
finalStage = "error"
a.store.Log("error", "代码分析", "后台分析异常", p.Name+" | "+detail)
a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}})
}
a.mu.Lock()
if a.taskResults != nil {
a.taskResults[taskID] = finalStage
}
delete(a.tasks, taskID)
a.mu.Unlock()
}()
emit := func(stage string, progress int, messageKey string) {
a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}})
}
emit("start", 2, "task.start")
a.store.Log("info", "代码分析", "开始分析项目", p.Name)
var err error
if kind == "all" || kind == "code" {
rules, _ := a.store.Rules()
var ls []LanguageStat
var fs []FileEntry
ls, fs, err = (Scanner{}).Analyze(ctx, p.Path, rules, func(n int, s string) { emit(s, n, s) })
if err == nil {
err = a.store.ReplaceScan(projectID, ls, fs)
}
}
if err == nil && (kind == "all" || kind == "git") {
emit("git", 80, "task.git")
scopeAll := false
if st, se := a.store.Settings(); se == nil && st.GitScope == "all" {
scopeAll = true
}
var g GitStats
var incremental bool
g, incremental, err = (GitAnalyzer{}).AnalyzeWithOptions(ctx, p.Path, service.GitAnalyzeOptions{
AllBranches: scopeAll,
SinceHash: a.store.LatestCommitHash(projectID),
})
if err == nil {
if incremental {
err = a.store.MergeGit(projectID, g)
a.store.Log("info", "Git分析", "增量分析完成", fmt.Sprintf("%s | 新增 %d 个提交", p.Name, len(g.Commits)))
} else {
err = a.store.ReplaceGit(projectID, g)
}
} else if kind == "all" && (err.Error() == "NOT_GIT_REPOSITORY" || err.Error() == "GIT_NOT_INSTALLED") {
a.store.Log("warning", "Git分析", "已跳过 Git 分析", err.Error())
err = nil
}
}
if err != nil {
level, stage := "error", "error"
if errors.Is(err, context.Canceled) {
level, stage = "warning", "cancelled"
}
finalStage = stage
a.store.Log(level, "代码分析", "项目分析失败", p.Name+" | "+err.Error())
emit(stage, 100, "task.failed")
return
}
a.store.Log("info", "代码分析", "项目分析完成", p.Name)
emit("completed", 100, "task.completed")
// 数据就绪后异步生成各模块的 AI 分析(未配置 Key 时静默跳过)。
// 代码数据变化会使洞察过期,先重算再交给 AI 解读。
switch kind {
case "code":
go func() {
a.RefreshProjectInsights(projectID)
a.generateAISummaries(projectID, "project", "structure", "insights")
}()
case "git":
go a.generateAISummaries(projectID, "git")
default:
go func() {
a.RefreshProjectInsights(projectID)
a.generateAISummaries(projectID, "project", "git", "structure", "insights")
}()
}
}()
return taskID, nil
}
func (a *App) CancelAnalysis(taskID string) error {
a.mu.Lock()
defer a.mu.Unlock()
if c, ok := a.tasks[taskID]; ok {
c()
return nil
}
return errors.New("TASK_NOT_FOUND")
}
func (a *App) StartBatchAnalysis() ([]string, error) {
return a.StartBatchAnalysisByGroup(0)
}
func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
if e := a.ready(); e != nil {
return nil, e
}
ps, e := a.store.ListProjects(groupID)
if e != nil {
return nil, e
}
ids := make([]string, 0, len(ps))
for _, p := range ps {
ids = append(ids, fmt.Sprintf("%d-all", p.ID))
}
go func() {
summary := BatchSummary{Total: len(ps), Failures: []string{}}
for _, p := range ps {
tid, e := a.StartAnalysis(p.ID, "all")
if e != nil {
summary.Failed++
summary.Failures = append(summary.Failures, p.Name+": "+e.Error())
continue
}
for {
a.mu.Lock()
_, running := a.tasks[tid]
a.mu.Unlock()
if !running {
break
}
select {
case <-a.ctx.Done():
return
case <-time.After(100 * time.Millisecond):
}
}
a.mu.Lock()
result := a.taskResults[tid]
delete(a.taskResults, tid)
a.mu.Unlock()
switch result {
case "cancelled":
summary.Cancelled++
case "error":
summary.Failed++
summary.Failures = append(summary.Failures, p.Name)
default:
summary.Completed++
}
}
a.store.Log("info", "代码分析", "批量统计完成",
fmt.Sprintf("共 %d 个 | 成功 %d | 失败 %d | 取消 %d | %s", summary.Total, summary.Completed, summary.Failed, summary.Cancelled, strings.Join(summary.Failures, "")))
a.emit("batch:done", summary)
a.notifyBatchDone(summary)
}()
return ids, nil
}
// notifyBatchDone 将批量统计结果写入消息中心,失败时附系统通知。
func (a *App) notifyBatchDone(s BatchSummary) {
locale := "zh-CN"
if st, e := a.store.Settings(); e == nil {
locale = st.Locale
}
title := "批量统计完成"
body := fmt.Sprintf("共 %d 个项目:成功 %d失败 %d取消 %d", s.Total, s.Completed, s.Failed, s.Cancelled)
if locale == "en" {
title = "Batch analysis finished"
body = fmt.Sprintf("%d projects: %d succeeded, %d failed, %d cancelled", s.Total, s.Completed, s.Failed, s.Cancelled)
}
if len(s.Failures) > 0 {
sep := ";失败:"
if locale == "en" {
sep = "; failed: "
}
body += sep + strings.Join(s.Failures, ", ")
}
a.pushMessage("analysis", title, body, "", 0, s.Failed > 0)
}