Files
code-utils/app.go

580 lines
17 KiB
Go
Raw Normal View History

2026-08-11 19:07:05 +08:00
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"view/platform"
"view/service"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type App struct {
ctx context.Context
store *Store
bootstrapFile string
bootstrap BootstrapStatus
runtimeReady bool
mu sync.Mutex
tasks map[string]context.CancelFunc
}
func NewApp() *App { return &App{tasks: map[string]context.CancelFunc{}} }
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.runtimeReady = true
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)
runtime.LogError(ctx, e.Error())
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 runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{Title: "Initialize Code Count database", DefaultDirectory: filepath.Dir(defaultPath), DefaultFilename: filepath.Base(defaultPath), Filters: []runtime.FileFilter{{DisplayName: "SQLite Database", Pattern: "*.db"}}})
}
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 runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择代码目录", "Select code directory")})
}
// 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 runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择 WSL 代码目录", "Select WSL code directory"), DefaultDirectory: root})
}
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 runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{Title: "Select database location", DefaultFilename: "code-count.db", Filters: []runtime.FileFilter{{DisplayName: "SQLite Database", Pattern: "*.db"}}})
}
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 {
if a.runtimeReady {
runtime.LogError(a.ctx, "SaveProject: "+e.Error())
}
return Project{}, e
}
p, e := a.store.SaveProject(id, in)
if e == nil {
a.store.Log("info", "项目", "项目保存成功", p.Path)
if a.runtimeReady {
runtime.LogInfo(a.ctx, "Project saved: "+p.Path)
}
} else {
a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error())
if a.runtimeReady {
runtime.LogError(a.ctx, "Project save failed: "+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)
}
if a.runtimeReady {
runtime.LogError(a.ctx, 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))
}
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) {
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) {
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) {
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) 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
}
return a.store.SaveSettings(x)
}
func (a *App) ClearData(mode string, projectID int64) error {
if e := a.ready(); e != nil {
return e
}
return a.store.ClearData(mode, projectID)
}
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() {
defer func() {
if recovered := recover(); recovered != nil {
detail := fmt.Sprintf("%v", recovered)
a.store.Log("error", "代码分析", "后台分析异常", p.Name+" | "+detail)
runtime.EventsEmit(a.ctx, "analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}})
}
a.mu.Lock()
delete(a.tasks, taskID)
a.mu.Unlock()
}()
emit := func(stage string, progress int, messageKey string) {
runtime.EventsEmit(a.ctx, "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")
var g GitStats
g, err = (GitAnalyzer{}).Analyze(ctx, p.Path)
if err == nil {
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"
}
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")
}()
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) {
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() {
for _, p := range ps {
tid, e := a.StartAnalysis(p.ID, "all")
if e != nil {
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):
}
}
}
}()
return ids, nil
}