From 36d73546f9ab2a75f9f3a8398f432ab7356eec4c Mon Sep 17 00:00:00 2001 From: Ronny Coste Date: Mon, 9 Feb 2026 19:57:36 -0500 Subject: [PATCH 1/2] feat: implement initial Go application with CLI, web UI, and core functionalities. --- .gitignore | 1 + go-source/README.md | 29 + go-source/cmd/difflearn/main.go | 9 + go-source/go.mod | 34 + go-source/go.sum | 61 + go-source/internal/api/server.go | 258 ++++ go-source/internal/cli/root.go | 360 ++++++ go-source/internal/cli/tui.go | 208 +++ go-source/internal/config/config.go | 199 +++ go-source/internal/git/extractor.go | 221 ++++ go-source/internal/git/formatter.go | 181 +++ go-source/internal/git/parser.go | 152 +++ go-source/internal/git/types.go | 57 + go-source/internal/llm/client.go | 251 ++++ go-source/internal/llm/prompts.go | 67 + go-source/internal/mcp/server.go | 159 +++ go-source/internal/update/update.go | 82 ++ go-source/web/app.js | 1099 ++++++++++++++++ go-source/web/index.html | 181 +++ go-source/web/styles.css | 1808 +++++++++++++++++++++++++++ 20 files changed, 5417 insertions(+) create mode 100644 go-source/README.md create mode 100644 go-source/cmd/difflearn/main.go create mode 100644 go-source/go.mod create mode 100644 go-source/go.sum create mode 100644 go-source/internal/api/server.go create mode 100644 go-source/internal/cli/root.go create mode 100644 go-source/internal/cli/tui.go create mode 100644 go-source/internal/config/config.go create mode 100644 go-source/internal/git/extractor.go create mode 100644 go-source/internal/git/formatter.go create mode 100644 go-source/internal/git/parser.go create mode 100644 go-source/internal/git/types.go create mode 100644 go-source/internal/llm/client.go create mode 100644 go-source/internal/llm/prompts.go create mode 100644 go-source/internal/mcp/server.go create mode 100644 go-source/internal/update/update.go create mode 100644 go-source/web/app.js create mode 100644 go-source/web/index.html create mode 100644 go-source/web/styles.css diff --git a/.gitignore b/.gitignore index 3221d00..19e69e3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ npm-debug.log* # Test coverage coverage/ +go-source/difflearn-go diff --git a/go-source/README.md b/go-source/README.md new file mode 100644 index 0000000..d2ad391 --- /dev/null +++ b/go-source/README.md @@ -0,0 +1,29 @@ +# DiffLearn Go Port + +This folder contains a Go port of DiffLearn, designed to run alongside the original Bun/TypeScript source. + +## Run + +```bash +cd go-source +go mod tidy +go run ./cmd/difflearn +``` + +## Commands + +- `difflearn` (interactive dashboard) +- `difflearn local [--staged]` +- `difflearn commit [--compare ]` +- `difflearn branch ` +- `difflearn explain [--staged]` +- `difflearn review [--staged]` +- `difflearn summary [--staged]` +- `difflearn export --format markdown|json|terminal [--staged]` +- `difflearn history [-n 10]` +- `difflearn web [-p 3000]` +- `difflearn config` +- `difflearn serve-mcp` +- `difflearn update` + +The Go port reuses the same `~/.difflearn` config file format and compatible environment variables. diff --git a/go-source/cmd/difflearn/main.go b/go-source/cmd/difflearn/main.go new file mode 100644 index 0000000..bf28070 --- /dev/null +++ b/go-source/cmd/difflearn/main.go @@ -0,0 +1,9 @@ +package main + +import "difflearn-go/internal/cli" + +func main() { + if err := cli.Execute(); err != nil { + cli.PrintErrAndExit(err) + } +} diff --git a/go-source/go.mod b/go-source/go.mod new file mode 100644 index 0000000..fe5cea0 --- /dev/null +++ b/go-source/go.mod @@ -0,0 +1,34 @@ +module difflearn-go + +go 1.22 + +require ( + github.com/charmbracelet/bubbletea v0.26.6 + github.com/charmbracelet/lipgloss v0.13.0 + github.com/fatih/color v1.17.0 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/charmbracelet/x/input v0.1.0 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go-source/go.sum b/go-source/go.sum new file mode 100644 index 0000000..6a92a67 --- /dev/null +++ b/go-source/go.sum @@ -0,0 +1,61 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s= +github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= +github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= +github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-source/internal/api/server.go b/go-source/internal/api/server.go new file mode 100644 index 0000000..61d06f1 --- /dev/null +++ b/go-source/internal/api/server.go @@ -0,0 +1,258 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "difflearn-go/internal/config" + "difflearn-go/internal/git" + "difflearn-go/internal/llm" +) + +func StartAPIServer(port int, repoPath string) error { + if port == 0 { + port = 3000 + } + if repoPath == "" { + repoPath = "." + } + g := git.NewGitExtractor(repoPath) + formatter := git.NewDiffFormatter() + + webDir, err := findWebDir(repoPath) + if err != nil { + return err + } + + mux := http.NewServeMux() + withCORS := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + h(w, r) + } + } + + mux.HandleFunc("/styles.css", withCORS(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, filepath.Join(webDir, "styles.css")) + })) + mux.HandleFunc("/app.js", withCORS(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, filepath.Join(webDir, "app.js")) + })) + + mux.HandleFunc("/", withCORS(func(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("Accept") + if strings.Contains(accept, "text/html") { + http.ServeFile(w, r, filepath.Join(webDir, "index.html")) + return + } + cfg := config.LoadConfig() + writeJSON(w, 200, map[string]any{ + "name": "difflearn", + "version": "0.3.0", + "status": "running", + "llmAvailable": config.IsLLMAvailable(cfg), + "llmProvider": cfg.Provider, + "cwd": g.RepoPath(), + }) + })) + + mux.HandleFunc("/diff/local", withCORS(func(w http.ResponseWriter, r *http.Request) { + staged := r.URL.Query().Get("staged") == "true" + format := r.URL.Query().Get("format") + if format == "" { + format = "json" + } + diffs, err := g.GetLocalDiff(git.DiffOptions{Staged: staged}) + if err != nil { + writeJSON(w, 500, map[string]any{"success": false, "error": err.Error()}) + return + } + switch format { + case "markdown": + w.Write([]byte(formatter.ToMarkdown(diffs))) + case "raw": + raw, err := g.GetRawDiff(map[bool]string{true: "staged", false: "local"}[staged], nil) + if err != nil { + writeJSON(w, 500, map[string]any{"success": false, "error": err.Error()}) + return + } + w.Write([]byte(raw)) + default: + var parsed any + _ = json.Unmarshal([]byte(formatter.ToJSON(diffs)), &parsed) + writeJSON(w, 200, map[string]any{"success": true, "data": parsed}) + } + })) + + mux.HandleFunc("/diff/commit/", withCORS(func(w http.ResponseWriter, r *http.Request) { + sha := strings.TrimPrefix(r.URL.Path, "/diff/commit/") + sha2 := r.URL.Query().Get("compare") + diffs, err := g.GetCommitDiff(sha, sha2) + if err != nil { + writeJSON(w, 500, map[string]any{"success": false, "error": err.Error()}) + return + } + var parsed any + _ = json.Unmarshal([]byte(formatter.ToJSON(diffs)), &parsed) + writeJSON(w, 200, map[string]any{"success": true, "data": parsed}) + })) + + mux.HandleFunc("/diff/branch/", withCORS(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/diff/branch/"), "/") + if len(parts) < 2 { + writeJSON(w, 400, map[string]any{"success": false, "error": "branch1 and branch2 required"}) + return + } + diffs, err := g.GetBranchDiff(parts[0], parts[1]) + if err != nil { + writeJSON(w, 500, map[string]any{"success": false, "error": err.Error()}) + return + } + var parsed any + _ = json.Unmarshal([]byte(formatter.ToJSON(diffs)), &parsed) + writeJSON(w, 200, map[string]any{"success": true, "data": parsed}) + })) + + mux.HandleFunc("/history", withCORS(func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit == 0 { + limit = 10 + } + commits, err := g.GetCommitHistory(limit) + if err != nil { + writeJSON(w, 500, map[string]any{"success": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"success": true, "data": commits}) + })) + + aiHandler := func(kind string) http.HandlerFunc { + return withCORS(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Question string `json:"question"` + Staged bool `json:"staged"` + Commit string `json:"commit"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + + diffs, err := getDiffForRequest(g, body.Commit, body.Staged) + if err != nil { + writeJSON(w, 500, map[string]any{"success": false, "error": err.Error()}) + return + } + if len(diffs) == 0 { + field := map[string]string{"explain": "explanation", "review": "review", "ask": "answer", "summary": "summary"}[kind] + writeJSON(w, 200, map[string]any{"success": true, "data": map[string]any{field: "No changes."}}) + return + } + + cfg := config.LoadConfig() + if !config.IsLLMAvailable(cfg) { + prompt := "" + switch kind { + case "explain": + prompt = llm.CreateExplainPrompt(formatter, diffs) + case "review": + prompt = llm.CreateReviewPrompt(formatter, diffs) + case "ask": + if body.Question == "" { + writeJSON(w, 400, map[string]any{"success": false, "error": "Question is required"}) + return + } + prompt = llm.CreateQuestionPrompt(formatter, diffs, body.Question) + case "summary": + writeJSON(w, 200, map[string]any{"success": true, "data": map[string]any{"summary": formatter.ToSummary(diffs), "llmAvailable": false}}) + return + } + writeJSON(w, 200, map[string]any{"success": true, "data": map[string]any{"llmAvailable": false, "prompt": prompt, "message": "No LLM API key configured. Use the prompt with your own LLM."}}) + return + } + + client := llm.NewClient(cfg) + prompt := "" + respField := "" + switch kind { + case "explain": + prompt = llm.CreateExplainPrompt(formatter, diffs) + respField = "explanation" + case "review": + prompt = llm.CreateReviewPrompt(formatter, diffs) + respField = "review" + case "ask": + if body.Question == "" { + writeJSON(w, 400, map[string]any{"success": false, "error": "Question is required"}) + return + } + prompt = llm.CreateQuestionPrompt(formatter, diffs, body.Question) + respField = "answer" + case "summary": + prompt = llm.CreateSummaryPrompt(formatter, diffs) + respField = "summary" + } + resp, err := client.Chat([]llm.ChatMessage{{Role: "system", Content: llm.SystemPrompt}, {Role: "user", Content: prompt}}) + if err != nil { + writeJSON(w, 500, map[string]any{"success": false, "error": err.Error()}) + return + } + data := map[string]any{respField: resp.Content, "usage": resp.Usage} + if kind == "summary" { + data["basicSummary"] = formatter.ToSummary(diffs) + } + writeJSON(w, 200, map[string]any{"success": true, "data": data}) + }) + } + + mux.HandleFunc("/explain", aiHandler("explain")) + mux.HandleFunc("/review", aiHandler("review")) + mux.HandleFunc("/ask", aiHandler("ask")) + mux.HandleFunc("/summary", aiHandler("summary")) + + addr := fmt.Sprintf(":%d", port) + fmt.Printf("\nšŸ” DiffLearn Web UI running at http://localhost:%d\n", port) + fmt.Printf(" API available at http://localhost:%d/diff/local\n\n", port) + return http.ListenAndServe(addr, mux) +} + +func findWebDir(repoPath string) (string, error) { + candidates := []string{ + filepath.Join(repoPath, "go-source", "web"), + filepath.Join(repoPath, "web"), + filepath.Join(repoPath, "src", "web"), + } + for _, c := range candidates { + if _, err := os.Stat(filepath.Join(c, "index.html")); err == nil { + return c, nil + } + } + return "", fmt.Errorf("could not find web directory") +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func getDiffForRequest(g *git.GitExtractor, commit string, staged bool) ([]git.ParsedDiff, error) { + if commit != "" { + if strings.Contains(commit, "..") { + parts := strings.SplitN(commit, "..", 2) + if len(parts) == 2 { + return g.GetCommitDiff(parts[0], parts[1]) + } + } + return g.GetCommitDiff(commit, "") + } + return g.GetLocalDiff(git.DiffOptions{Staged: staged}) +} diff --git a/go-source/internal/cli/root.go b/go-source/internal/cli/root.go new file mode 100644 index 0000000..82c5228 --- /dev/null +++ b/go-source/internal/cli/root.go @@ -0,0 +1,360 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "difflearn-go/internal/api" + "difflearn-go/internal/config" + "difflearn-go/internal/git" + "difflearn-go/internal/llm" + "difflearn-go/internal/mcp" + "difflearn-go/internal/update" +) + +func NewRootCmd() *cobra.Command { + var repoPath string + root := &cobra.Command{ + Use: "difflearn", + Short: "Interactive git diff learning tool with LLM-powered explanations", + Version: "0.3.0-go", + RunE: func(cmd *cobra.Command, args []string) error { + return RunDashboard(repoPath) + }, + } + root.PersistentFlags().StringVar(&repoPath, "repo", ".", "Repository path") + + root.AddCommand(localCmd(&repoPath)) + root.AddCommand(commitCmd(&repoPath)) + root.AddCommand(branchCmd(&repoPath)) + root.AddCommand(explainCmd(&repoPath)) + root.AddCommand(reviewCmd(&repoPath)) + root.AddCommand(summaryCmd(&repoPath)) + root.AddCommand(exportCmd(&repoPath)) + root.AddCommand(historyCmd(&repoPath)) + root.AddCommand(webCmd(&repoPath)) + root.AddCommand(configCmd()) + root.AddCommand(mcpCmd(&repoPath)) + root.AddCommand(updateCmd()) + + return root +} + +func Execute() error { return NewRootCmd().Execute() } + +func localCmd(repoPath *string) *cobra.Command { + var staged bool + var noInteractive bool + cmd := &cobra.Command{ + Use: "local", + Short: "View local uncommitted changes interactively", + RunE: func(cmd *cobra.Command, args []string) error { + if !noInteractive { + return RunDashboard(*repoPath) + } + g := git.NewGitExtractor(*repoPath) + formatter := git.NewDiffFormatter() + diffs, err := g.GetLocalDiff(git.DiffOptions{Staged: staged}) + if err != nil { + return err + } + fmt.Println(formatter.ToTerminal(diffs, git.FormatterOptions{})) + return nil + }, + } + cmd.Flags().BoolVarP(&staged, "staged", "s", false, "View only staged changes") + cmd.Flags().BoolVar(&noInteractive, "no-interactive", false, "Print diff without interactive mode") + return cmd +} + +func commitCmd(repoPath *string) *cobra.Command { + var compare string + var noInteractive bool + cmd := &cobra.Command{ + Use: "commit ", + Short: "View changes in a specific commit", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !noInteractive { + return RunCommitView(*repoPath, args[0], compare) + } + g := git.NewGitExtractor(*repoPath) + diffs, err := g.GetCommitDiff(args[0], compare) + if err != nil { + return err + } + fmt.Println(git.NewDiffFormatter().ToTerminal(diffs, git.FormatterOptions{})) + return nil + }, + } + cmd.Flags().StringVarP(&compare, "compare", "c", "", "Compare with another commit") + cmd.Flags().BoolVar(&noInteractive, "no-interactive", false, "Print diff without interactive mode") + return cmd +} + +func branchCmd(repoPath *string) *cobra.Command { + var noInteractive bool + cmd := &cobra.Command{ + Use: "branch ", + Short: "Compare two branches", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if !noInteractive { + return RunBranchView(*repoPath, args[0], args[1]) + } + g := git.NewGitExtractor(*repoPath) + diffs, err := g.GetBranchDiff(args[0], args[1]) + if err != nil { + return err + } + fmt.Println(git.NewDiffFormatter().ToTerminal(diffs, git.FormatterOptions{})) + return nil + }, + } + cmd.Flags().BoolVar(&noInteractive, "no-interactive", false, "Print diff without interactive mode") + return cmd +} + +func explainCmd(repoPath *string) *cobra.Command { + var staged bool + cmd := &cobra.Command{ + Use: "explain", + Short: "Get an AI explanation of local changes", + RunE: func(cmd *cobra.Command, args []string) error { + return runLLMCommand(*repoPath, staged, "explain") + }, + } + cmd.Flags().BoolVarP(&staged, "staged", "s", false, "Explain only staged changes") + return cmd +} + +func reviewCmd(repoPath *string) *cobra.Command { + var staged bool + cmd := &cobra.Command{ + Use: "review", + Short: "Get an AI code review of local changes", + RunE: func(cmd *cobra.Command, args []string) error { + return runLLMCommand(*repoPath, staged, "review") + }, + } + cmd.Flags().BoolVarP(&staged, "staged", "s", false, "Review only staged changes") + return cmd +} + +func summaryCmd(repoPath *string) *cobra.Command { + var staged bool + cmd := &cobra.Command{ + Use: "summary", + Short: "Get a quick summary of changes", + RunE: func(cmd *cobra.Command, args []string) error { + return runLLMCommand(*repoPath, staged, "summary") + }, + } + cmd.Flags().BoolVarP(&staged, "staged", "s", false, "Summarize only staged changes") + return cmd +} + +func exportCmd(repoPath *string) *cobra.Command { + var staged bool + var format string + cmd := &cobra.Command{ + Use: "export", + Short: "Export diff in various formats", + RunE: func(cmd *cobra.Command, args []string) error { + g := git.NewGitExtractor(*repoPath) + formatter := git.NewDiffFormatter() + diffs, err := g.GetLocalDiff(git.DiffOptions{Staged: staged}) + if err != nil { + return err + } + switch format { + case "json": + fmt.Println(formatter.ToJSON(diffs)) + case "terminal": + fmt.Println(formatter.ToTerminal(diffs, git.FormatterOptions{})) + default: + fmt.Println(formatter.ToMarkdown(diffs)) + } + return nil + }, + } + cmd.Flags().StringVarP(&format, "format", "f", "markdown", "Output format: json, markdown, terminal") + cmd.Flags().BoolVarP(&staged, "staged", "s", false, "Export only staged changes") + return cmd +} + +func historyCmd(repoPath *string) *cobra.Command { + var number int + cmd := &cobra.Command{ + Use: "history", + Short: "List recent commits", + RunE: func(cmd *cobra.Command, args []string) error { + g := git.NewGitExtractor(*repoPath) + commits, err := g.GetCommitHistory(number) + if err != nil { + return err + } + for _, c := range commits { + t, _ := time.Parse(time.RFC3339, c.Date) + fmt.Printf("%s %s %s (%s)\n", color.YellowString(short(c.Hash, 7)), color.HiBlackString(t.Format("2006-01-02")), c.Message, color.HiBlackString(c.Author)) + } + return nil + }, + } + cmd.Flags().IntVarP(&number, "number", "n", 10, "Number of commits to show") + return cmd +} + +func webCmd(repoPath *string) *cobra.Command { + var port int + cmd := &cobra.Command{ + Use: "web", + Short: "Launch the web UI in your browser", + RunE: func(cmd *cobra.Command, args []string) error { + go func() { _ = openBrowser(fmt.Sprintf("http://localhost:%d", port)) }() + return api.StartAPIServer(port, *repoPath) + }, + } + cmd.Flags().IntVarP(&port, "port", "p", 3000, "Port for web server") + return cmd +} + +func configCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Show LLM configuration status", + Run: func(cmd *cobra.Command, args []string) { + cfg := config.LoadConfig() + fmt.Printf("Provider: %s\n", cfg.Provider) + fmt.Printf("Model: %s\n", cfg.Model) + fmt.Printf("LLM Available: %t\n", config.IsLLMAvailable(cfg)) + if cfg.BaseURL != "" { + fmt.Printf("Base URL: %s\n", cfg.BaseURL) + } + }, + } + return cmd +} + +func mcpCmd(repoPath *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "serve-mcp", + Short: "Run MCP server over stdio", + RunE: func(cmd *cobra.Command, args []string) error { + return mcp.Serve(*repoPath) + }, + } + return cmd +} + +func updateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Check for updates", + RunE: func(cmd *cobra.Command, args []string) error { + info, err := update.CheckForUpdates() + if err != nil { + return err + } + if info == nil || !info.UpdateAvailable { + fmt.Println("āœ… You're on the latest version") + return nil + } + fmt.Printf("šŸ†• Update available: v%s -> v%s\n", info.CurrentVersion, info.LatestVersion) + fmt.Printf("Run: %s\n", update.GetUpdateCommand()) + fmt.Printf("Release: %s\n", info.ReleaseURL) + return nil + }, + } + return cmd +} + +func runLLMCommand(repoPath string, staged bool, kind string) error { + cfg := config.LoadConfig() + g := git.NewGitExtractor(repoPath) + formatter := git.NewDiffFormatter() + diffs, err := g.GetLocalDiff(git.DiffOptions{Staged: staged}) + if err != nil { + return err + } + if len(diffs) == 0 { + fmt.Println(color.YellowString("No changes found.")) + return nil + } + if !config.IsLLMAvailable(cfg) { + fmt.Println(color.YellowString("No LLM API key configured.")) + switch kind { + case "explain": + fmt.Println(llm.CreateExplainPrompt(formatter, diffs)) + case "review": + fmt.Println(llm.CreateReviewPrompt(formatter, diffs)) + case "summary": + fmt.Println(formatter.ToSummary(diffs)) + } + return nil + } + client := llm.NewClient(cfg) + prompt := "" + label := "" + switch kind { + case "explain": + prompt = llm.CreateExplainPrompt(formatter, diffs) + label = "Explanation" + case "review": + prompt = llm.CreateReviewPrompt(formatter, diffs) + label = "Code Review" + case "summary": + prompt = llm.CreateSummaryPrompt(formatter, diffs) + label = "Summary" + } + fmt.Printf("%s\n\n", color.GreenString("šŸ“ "+label+":")) + chunks, errs := client.StreamChat([]llm.ChatMessage{{Role: "system", Content: llm.SystemPrompt}, {Role: "user", Content: prompt}}) + for c := range chunks { + fmt.Print(c) + } + if err := <-errs; err != nil { + return err + } + fmt.Println() + return nil +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +func short(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +func atoiOrDefault(s string, d int) int { + v, err := strconv.Atoi(s) + if err != nil { + return d + } + return v +} + +func PrintErrAndExit(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/go-source/internal/cli/tui.go b/go-source/internal/cli/tui.go new file mode 100644 index 0000000..57033e1 --- /dev/null +++ b/go-source/internal/cli/tui.go @@ -0,0 +1,208 @@ +package cli + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "difflearn-go/internal/git" +) + +type section string + +const ( + secLocal section = "local" + secStaged section = "staged" + secHistory section = "history" +) + +type dashboardModel struct { + repoPath string + section section + localDiffs []git.ParsedDiff + stagedDiffs []git.ParsedDiff + commits []git.CommitInfo + historyIndex int + status string + loading bool + selectedDiffs []git.ParsedDiff +} + +type loadedMsg struct { + local []git.ParsedDiff + staged []git.ParsedDiff + commits []git.CommitInfo + err error +} + +type commitDiffMsg struct { + diffs []git.ParsedDiff + err error +} + +func RunDashboard(repoPath string) error { + m := dashboardModel{repoPath: repoPath, section: secLocal, loading: true, status: "Loading..."} + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() + return err +} + +func RunCommitView(repoPath, c1, c2 string) error { + g := git.NewGitExtractor(repoPath) + diffs, err := g.GetCommitDiff(c1, c2) + if err != nil { + return err + } + fmt.Println(git.NewDiffFormatter().ToTerminal(diffs, git.FormatterOptions{})) + return nil +} + +func RunBranchView(repoPath, b1, b2 string) error { + g := git.NewGitExtractor(repoPath) + diffs, err := g.GetBranchDiff(b1, b2) + if err != nil { + return err + } + fmt.Println(git.NewDiffFormatter().ToTerminal(diffs, git.FormatterOptions{})) + return nil +} + +func (m dashboardModel) Init() tea.Cmd { + return m.loadAllCmd() +} + +func (m dashboardModel) loadAllCmd() tea.Cmd { + return func() tea.Msg { + g := git.NewGitExtractor(m.repoPath) + if !g.IsRepo() { + return loadedMsg{err: fmt.Errorf("not a git repository")} + } + local, err := g.GetLocalDiff(git.DiffOptions{}) + if err != nil { + return loadedMsg{err: err} + } + staged, err := g.GetLocalDiff(git.DiffOptions{Staged: true}) + if err != nil { + return loadedMsg{err: err} + } + commits, err := g.GetCommitHistory(50) + if err != nil { + return loadedMsg{err: err} + } + return loadedMsg{local: local, staged: staged, commits: commits} + } +} + +func (m dashboardModel) loadCommitDiffCmd(hash string) tea.Cmd { + return func() tea.Msg { + g := git.NewGitExtractor(m.repoPath) + diffs, err := g.GetCommitDiff(hash, "") + return commitDiffMsg{diffs: diffs, err: err} + } +} + +func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "tab": + if m.section == secLocal { + m.section = secStaged + m.selectedDiffs = m.stagedDiffs + m.status = "Staged changes" + } else if m.section == secStaged { + m.section = secHistory + m.selectedDiffs = nil + m.status = "History view" + } else { + m.section = secLocal + m.selectedDiffs = m.localDiffs + m.status = "Local changes" + } + case "r": + m.loading = true + m.status = "Refreshing..." + return m, m.loadAllCmd() + case "up", "k", "w": + if m.section == secHistory && m.historyIndex > 0 { + m.historyIndex-- + } + case "down", "j", "s": + if m.section == secHistory && m.historyIndex < len(m.commits)-1 { + m.historyIndex++ + } + case "enter": + if m.section == secHistory && len(m.commits) > 0 { + m.loading = true + m.status = "Loading commit diff..." + return m, m.loadCommitDiffCmd(m.commits[m.historyIndex].Hash) + } + } + case loadedMsg: + m.loading = false + if msg.err != nil { + m.status = "Error: " + msg.err.Error() + return m, nil + } + m.localDiffs = msg.local + m.stagedDiffs = msg.staged + m.commits = msg.commits + m.selectedDiffs = msg.local + m.status = "Loaded" + case commitDiffMsg: + m.loading = false + if msg.err != nil { + m.status = "Error: " + msg.err.Error() + return m, nil + } + m.selectedDiffs = msg.diffs + m.section = secHistory + m.status = "Showing selected commit diff" + } + return m, nil +} + +func (m dashboardModel) View() string { + header := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("13")).Render("šŸ” DiffLearn") + tabs := []string{"Local", "Staged", "History"} + active := map[section]int{secLocal: 0, secStaged: 1, secHistory: 2}[m.section] + for i := range tabs { + if i == active { + tabs[i] = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true).Render(tabs[i]) + } + } + line := strings.Join(tabs, " | ") + status := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(m.status + " • q quit • Tab switch • Enter select • r refresh") + + if m.loading { + return fmt.Sprintf("%s\n%s\n\nLoading...\n\n%s", header, line, status) + } + + body := "" + if m.section == secHistory { + if len(m.commits) == 0 { + body = "No commits found" + } else { + rows := make([]string, 0, len(m.commits)) + for i, c := range m.commits { + prefix := " " + if i == m.historyIndex { + prefix = "> " + } + rows = append(rows, fmt.Sprintf("%s%s %s (%s)", prefix, short(c.Hash, 7), c.Message, c.Author)) + } + body = strings.Join(rows, "\n") + } + } else { + if len(m.selectedDiffs) == 0 { + body = "No changes found" + } else { + body = git.NewDiffFormatter().ToTerminal(m.selectedDiffs, git.FormatterOptions{}) + } + } + return fmt.Sprintf("%s\n%s\n\n%s\n\n%s", header, line, body, status) +} diff --git a/go-source/internal/config/config.go b/go-source/internal/config/config.go new file mode 100644 index 0000000..790e3e0 --- /dev/null +++ b/go-source/internal/config/config.go @@ -0,0 +1,199 @@ +package config + +import ( + "bufio" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type LLMProvider string + +const ( + ProviderOpenAI LLMProvider = "openai" + ProviderAnthropic LLMProvider = "anthropic" + ProviderGoogle LLMProvider = "google" + ProviderOllama LLMProvider = "ollama" + ProviderLMStudio LLMProvider = "lmstudio" + ProviderGeminiCLI LLMProvider = "gemini-cli" + ProviderClaude LLMProvider = "claude-code" + ProviderCodex LLMProvider = "codex" + ProviderCursor LLMProvider = "cursor-cli" +) + +type Config struct { + Provider LLMProvider + Model string + APIKey string + BaseURL string + Temperature float64 + MaxTokens int + UseCLI bool +} + +type providerDefaults struct { + model string + envKey string + cli bool + command string + baseURL string + noAPIKey bool + authCmd []string + authHint []string + authCheck []string +} + +var providerDefaultsMap = map[LLMProvider]providerDefaults{ + ProviderOpenAI: {model: "gpt-4o", envKey: "OPENAI_API_KEY"}, + ProviderAnthropic: {model: "claude-sonnet-4-20250514", envKey: "ANTHROPIC_API_KEY"}, + ProviderGoogle: {model: "gemini-2.0-flash", envKey: "GOOGLE_AI_API_KEY"}, + ProviderOllama: {model: "llama3.2", noAPIKey: true, baseURL: "http://localhost:11434/v1"}, + ProviderLMStudio: {model: "local-model", noAPIKey: true, baseURL: "http://localhost:1234/v1"}, + ProviderGeminiCLI: {model: "gemini", cli: true, command: "gemini", authCmd: []string{"gemini"}}, + ProviderClaude: {model: "claude", cli: true, command: "claude", authCmd: []string{"claude"}}, + ProviderCodex: {model: "codex", cli: true, command: "codex", authCmd: []string{"codex", "login"}, authCheck: []string{"codex", "login", "status"}}, + ProviderCursor: {model: "cursor", cli: true, command: "agent", authCmd: []string{"agent", "login"}, authCheck: []string{"agent", "status"}}, +} + +func loadConfigFromFile() map[string]string { + home, err := os.UserHomeDir() + if err != nil { + return map[string]string{} + } + p := filepath.Join(home, ".difflearn") + f, err := os.Open(p) + if err != nil { + return map[string]string{} + } + defer f.Close() + + out := map[string]string{} + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + out[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + return out +} + +func LoadConfig() Config { + fileCfg := loadConfigFromFile() + for k, v := range fileCfg { + if os.Getenv(k) == "" { + _ = os.Setenv(k, v) + } + } + + provider := LLMProvider(os.Getenv("DIFFLEARN_LLM_PROVIDER")) + if provider == "" { + provider = DetectProvider() + } + if provider == "" { + provider = ProviderOpenAI + } + + d, ok := providerDefaultsMap[provider] + if !ok { + provider = ProviderOpenAI + d = providerDefaultsMap[provider] + } + + needsAPIKey := !d.cli && !d.noAPIKey + apiKey := "local" + if needsAPIKey { + apiKey = os.Getenv(d.envKey) + } + + temp, _ := strconv.ParseFloat(defaultStr(os.Getenv("DIFFLEARN_TEMPERATURE"), "0.3"), 64) + maxTokens, _ := strconv.Atoi(defaultStr(os.Getenv("DIFFLEARN_MAX_TOKENS"), "4096")) + baseURL := os.Getenv("DIFFLEARN_BASE_URL") + if baseURL == "" { + baseURL = d.baseURL + } + + return Config{ + Provider: provider, + Model: defaultStr(os.Getenv("DIFFLEARN_MODEL"), d.model), + APIKey: apiKey, + BaseURL: baseURL, + Temperature: temp, + MaxTokens: maxTokens, + UseCLI: d.cli, + } +} + +func IsLLMAvailable(c Config) bool { + if c.UseCLI || c.Provider == ProviderOllama || c.Provider == ProviderLMStudio { + return true + } + return strings.TrimSpace(c.APIKey) != "" +} + +func DetectProvider() LLMProvider { + if os.Getenv("OPENAI_API_KEY") != "" { + return ProviderOpenAI + } + if os.Getenv("ANTHROPIC_API_KEY") != "" { + return ProviderAnthropic + } + if os.Getenv("GOOGLE_AI_API_KEY") != "" { + return ProviderGoogle + } + return "" +} + +func DetectCLIProvider() LLMProvider { + if IsCLIAvailable("gemini") { + return ProviderGeminiCLI + } + if IsCLIAvailable("claude") { + return ProviderClaude + } + if IsCLIAvailable("codex") { + return ProviderCodex + } + if IsCursorAgentAvailable() { + return ProviderCursor + } + return "" +} + +func IsCLIAvailable(command string) bool { + _, err := exec.LookPath(command) + return err == nil +} + +func IsCursorAgentAvailable() bool { + if !IsCLIAvailable("agent") { + return false + } + cmd := exec.Command("agent", "--version") + b, err := cmd.CombinedOutput() + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(b)), "cursor") +} + +func GetCLIAuthCommand(provider LLMProvider) []string { + return providerDefaultsMap[provider].authCmd +} + +func GetCLIAuthHint(provider LLMProvider) []string { + return providerDefaultsMap[provider].authHint +} + +func defaultStr(v, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return v +} diff --git a/go-source/internal/git/extractor.go b/go-source/internal/git/extractor.go new file mode 100644 index 0000000..48c6f06 --- /dev/null +++ b/go-source/internal/git/extractor.go @@ -0,0 +1,221 @@ +package git + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +type DiffOptions struct { + Staged bool + Context int +} + +type GitExtractor struct { + repoPath string + parser *DiffParser +} + +func NewGitExtractor(repoPath string) *GitExtractor { + if repoPath == "" { + repoPath = "." + } + return &GitExtractor{repoPath: repoPath, parser: NewDiffParser()} +} + +func (g *GitExtractor) runGit(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = g.repoPath + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("git %s failed: %s", strings.Join(args, " "), msg) + } + return out.String(), nil +} + +func (g *GitExtractor) GetLocalDiff(options DiffOptions) ([]ParsedDiff, error) { + ctx := options.Context + if ctx == 0 { + ctx = 3 + } + args := []string{"diff", fmt.Sprintf("-U%d", ctx)} + if options.Staged { + args = []string{"diff", "--cached", fmt.Sprintf("-U%d", ctx)} + } + raw, err := g.runGit(args...) + if err != nil { + return nil, err + } + return g.parser.Parse(raw), nil +} + +func (g *GitExtractor) GetAllLocalChanges() (staged, unstaged []ParsedDiff, err error) { + staged, err = g.GetLocalDiff(DiffOptions{Staged: true}) + if err != nil { + return nil, nil, err + } + unstaged, err = g.GetLocalDiff(DiffOptions{Staged: false}) + return staged, unstaged, err +} + +func (g *GitExtractor) GetCommitDiff(commit1 string, commit2 string) ([]ParsedDiff, error) { + rangeArg := commit1 + "^.." + commit1 + if commit2 != "" { + rangeArg = commit1 + ".." + commit2 + } + raw, err := g.runGit("diff", rangeArg) + if err != nil { + return nil, err + } + return g.parser.Parse(raw), nil +} + +func (g *GitExtractor) GetBranchDiff(branch1, branch2 string) ([]ParsedDiff, error) { + raw, err := g.runGit("diff", branch1+"..."+branch2) + if err != nil { + return nil, err + } + return g.parser.Parse(raw), nil +} + +func (g *GitExtractor) GetFileDiff(filePath, commit string) ([]ParsedDiff, error) { + if commit != "" { + raw, err := g.runGit("diff", commit+"^.."+commit, "--", filePath) + if err != nil { + return nil, err + } + return g.parser.Parse(raw), nil + } + raw, err := g.runGit("diff", "--", filePath) + if err != nil { + return nil, err + } + return g.parser.Parse(raw), nil +} + +func (g *GitExtractor) GetCommitHistory(limit int) ([]CommitInfo, error) { + if limit <= 0 { + limit = 20 + } + format := `%H%x1f%aI%x1f%s%x1f%an` + out, err := g.runGit("log", fmt.Sprintf("--max-count=%d", limit), "--name-only", "--pretty=format:"+format) + if err != nil { + return nil, err + } + + commits := make([]CommitInfo, 0) + blocks := strings.Split(out, "\n\n") + for _, b := range blocks { + lines := strings.Split(strings.TrimSpace(b), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + continue + } + parts := strings.Split(lines[0], "\x1f") + if len(parts) < 4 { + continue + } + files := make([]string, 0) + for _, f := range lines[1:] { + f = strings.TrimSpace(f) + if f != "" { + files = append(files, f) + } + } + commits = append(commits, CommitInfo{ + Hash: parts[0], + Date: parts[1], + Message: parts[2], + Author: parts[3], + Files: files, + }) + } + return commits, nil +} + +func (g *GitExtractor) GetBranches() ([]BranchInfo, error) { + out, err := g.runGit("branch", "-vv", "--no-abbrev") + if err != nil { + return nil, err + } + branches := make([]BranchInfo, 0) + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + current := strings.HasPrefix(line, "*") + if current { + line = strings.TrimSpace(strings.TrimPrefix(line, "*")) + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + branches = append(branches, BranchInfo{Name: parts[0], Current: current, Commit: parts[1]}) + } + return branches, nil +} + +func (g *GitExtractor) GetCurrentBranch() (string, error) { + out, err := g.runGit("rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +func (g *GitExtractor) IsRepo() bool { + cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") + cmd.Dir = g.repoPath + return cmd.Run() == nil +} + +func (g *GitExtractor) GetRawDiff(kind string, options map[string]string) (string, error) { + switch kind { + case "local": + return g.runGit("diff") + case "staged": + return g.runGit("diff", "--cached") + case "commit": + c1 := options["commit1"] + if c1 == "" { + return "", fmt.Errorf("commit1 is required") + } + r := c1 + "^.." + c1 + if c2 := options["commit2"]; c2 != "" { + r = c1 + ".." + c2 + } + return g.runGit("diff", r) + case "branch": + b1, b2 := options["branch1"], options["branch2"] + if b1 == "" || b2 == "" { + return "", fmt.Errorf("branch1 and branch2 are required") + } + return g.runGit("diff", b1+"..."+b2) + default: + return "", fmt.Errorf("unknown diff type: %s", kind) + } +} + +func (g *GitExtractor) RepoPath() string { + abs, err := filepath.Abs(g.repoPath) + if err != nil { + return g.repoPath + } + return abs +} + +func MarshalJSON(v any) string { + b, _ := json.MarshalIndent(v, "", " ") + return string(b) +} diff --git a/go-source/internal/git/formatter.go b/go-source/internal/git/formatter.go new file mode 100644 index 0000000..9c99509 --- /dev/null +++ b/go-source/internal/git/formatter.go @@ -0,0 +1,181 @@ +package git + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/fatih/color" +) + +type FormatterOptions struct { + ShowLineNumbers bool + ShowStats bool +} + +type DiffFormatter struct{} + +func NewDiffFormatter() *DiffFormatter { return &DiffFormatter{} } + +func (f *DiffFormatter) ToTerminal(diffs []ParsedDiff, options FormatterOptions) string { + showLineNumbers := true + showStats := true + if options.ShowLineNumbers == false { + showLineNumbers = false + } + if options.ShowStats == false { + showStats = false + } + + out := make([]string, 0) + for _, diff := range diffs { + out = append(out, color.New(color.Bold).Sprint(strings.Repeat("─", 60))) + out = append(out, f.formatFileHeader(diff)) + if showStats { + out = append(out, fmt.Sprintf(" %s %s", color.GreenString("+%d", diff.Additions), color.RedString("-%d", diff.Deletions))) + } + out = append(out, "") + for _, h := range diff.Hunks { + out = append(out, color.CyanString(h.Header)) + for _, line := range h.Lines { + out = append(out, f.formatLine(line, showLineNumbers)) + } + out = append(out, "") + } + } + return strings.Join(out, "\n") +} + +func (f *DiffFormatter) formatFileHeader(diff ParsedDiff) string { + switch { + case diff.IsNew: + return color.New(color.FgGreen, color.Bold).Sprintf("+ New: %s", diff.NewFile) + case diff.IsDeleted: + return color.New(color.FgRed, color.Bold).Sprintf("- Deleted: %s", diff.OldFile) + case diff.IsRenamed: + return color.New(color.FgYellow, color.Bold).Sprintf("→ Renamed: %s → %s", diff.OldFile, diff.NewFile) + default: + return color.New(color.FgBlue, color.Bold).Sprintf("Modified: %s", diff.NewFile) + } +} + +func (f *DiffFormatter) formatLine(line ParsedLine, showLineNumbers bool) string { + lineNum := "" + if showLineNumbers { + oldNum := " " + newNum := " " + if line.OldLineNumber != nil { + oldNum = fmt.Sprintf("%4d", *line.OldLineNumber) + } + if line.NewLineNumber != nil { + newNum = fmt.Sprintf("%4d", *line.NewLineNumber) + } + lineNum = color.HiBlackString("%s %s │ ", oldNum, newNum) + } + prefix := " " + if line.Type == LineAdd { + prefix = "+" + } + if line.Type == LineDelete { + prefix = "-" + } + content := prefix + line.Content + switch line.Type { + case LineAdd: + return lineNum + color.GreenString(content) + case LineDelete: + return lineNum + color.RedString(content) + default: + return lineNum + color.HiBlackString(content) + } +} + +func (f *DiffFormatter) ToMarkdown(diffs []ParsedDiff) string { + out := make([]string, 0) + out = append(out, "# Git Diff Summary", "") + adds, dels := 0, 0 + for _, d := range diffs { + adds += d.Additions + dels += d.Deletions + } + out = append(out, fmt.Sprintf("**Files changed:** %d", len(diffs))) + out = append(out, fmt.Sprintf("**Additions:** +%d | **Deletions:** -%d", adds, dels), "") + + for _, d := range diffs { + status := "" + if d.IsNew { + status = "(new)" + } else if d.IsDeleted { + status = "(deleted)" + } else if d.IsRenamed { + status = "(renamed)" + } + out = append(out, fmt.Sprintf("## %s %s", d.NewFile, status)) + if d.Additions > 0 || d.Deletions > 0 { + out = append(out, fmt.Sprintf("*+%d -%d*", d.Additions, d.Deletions), "") + } + out = append(out, "```diff") + for _, h := range d.Hunks { + out = append(out, h.Header) + for _, line := range h.Lines { + prefix := " " + if line.Type == LineAdd { + prefix = "+" + } + if line.Type == LineDelete { + prefix = "-" + } + out = append(out, prefix+line.Content) + } + } + out = append(out, "```", "") + } + return strings.Join(out, "\n") +} + +func (f *DiffFormatter) ToJSON(diffs []ParsedDiff) string { + payload := map[string]any{ + "summary": map[string]any{ + "files": len(diffs), + "additions": sumAdds(diffs), + "deletions": sumDels(diffs), + }, + "files": diffs, + } + b, _ := json.MarshalIndent(payload, "", " ") + return string(b) +} + +func (f *DiffFormatter) ToSummary(diffs []ParsedDiff) string { + files := len(diffs) + adds, dels := sumAdds(diffs), sumDels(diffs) + list := make([]string, 0, len(diffs)) + for _, d := range diffs { + status := "M " + if d.IsNew { + status = "+ " + } else if d.IsDeleted { + status = "- " + } else if d.IsRenamed { + status = "→ " + } + list = append(list, status+d.NewFile) + } + return fmt.Sprintf("%d file(s) changed, +%d -%d\n\n%s", files, adds, dels, strings.Join(list, "\n")) +} + +func sumAdds(diffs []ParsedDiff) int { + t := 0 + for _, d := range diffs { + t += d.Additions + } + return t +} + +func sumDels(diffs []ParsedDiff) int { + t := 0 + for _, d := range diffs { + t += d.Deletions + } + return t +} diff --git a/go-source/internal/git/parser.go b/go-source/internal/git/parser.go new file mode 100644 index 0000000..ffa1ef9 --- /dev/null +++ b/go-source/internal/git/parser.go @@ -0,0 +1,152 @@ +package git + +import ( + "regexp" + "strconv" + "strings" +) + +type DiffParser struct{} + +func NewDiffParser() *DiffParser { return &DiffParser{} } + +func (p *DiffParser) Parse(rawDiff string) []ParsedDiff { + if strings.TrimSpace(rawDiff) == "" { + return []ParsedDiff{} + } + + parts := p.splitByFile(rawDiff) + diffs := make([]ParsedDiff, 0, len(parts)) + for _, part := range parts { + if parsed, ok := p.parseFileDiff(part); ok { + diffs = append(diffs, parsed) + } + } + return diffs +} + +func (p *DiffParser) splitByFile(rawDiff string) []string { + r := regexp.MustCompile(`(?m)^diff --git `) + idxs := r.FindAllStringIndex(rawDiff, -1) + if len(idxs) == 0 { + return nil + } + out := make([]string, 0, len(idxs)) + for i := range idxs { + start := idxs[i][0] + end := len(rawDiff) + if i+1 < len(idxs) { + end = idxs[i+1][0] + } + out = append(out, rawDiff[start:end]) + } + return out +} + +func (p *DiffParser) parseFileDiff(fileDiff string) (ParsedDiff, bool) { + lines := strings.Split(fileDiff, "\n") + if len(lines) == 0 { + return ParsedDiff{}, false + } + + headerRe := regexp.MustCompile(`^diff --git a/(.+?) b/(.+)$`) + hm := headerRe.FindStringSubmatch(lines[0]) + if len(hm) != 3 { + return ParsedDiff{}, false + } + + oldFile, newFile := hm[1], hm[2] + isBinary := strings.Contains(fileDiff, "Binary files") + isNew := strings.Contains(fileDiff, "new file mode") + isDeleted := strings.Contains(fileDiff, "deleted file mode") + isRenamed := strings.Contains(fileDiff, "rename from") || oldFile != newFile + + hunks := make([]ParsedHunk, 0) + var current *ParsedHunk + oldLineNum, newLineNum := 0, 0 + hunkRe := regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`) + + for _, line := range lines { + if m := hunkRe.FindStringSubmatch(line); len(m) > 0 { + if current != nil { + hunks = append(hunks, *current) + } + oldStart, _ := strconv.Atoi(m[1]) + oldLines := 1 + if m[2] != "" { + oldLines, _ = strconv.Atoi(m[2]) + } + newStart, _ := strconv.Atoi(m[3]) + newLines := 1 + if m[4] != "" { + newLines, _ = strconv.Atoi(m[4]) + } + oldLineNum, newLineNum = oldStart, newStart + current = &ParsedHunk{ + OldStart: oldStart, + OldLines: oldLines, + NewStart: newStart, + NewLines: newLines, + Header: line, + Lines: make([]ParsedLine, 0), + } + continue + } + + if current == nil { + continue + } + + switch { + case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"): + n := newLineNum + current.Lines = append(current.Lines, ParsedLine{Type: LineAdd, Content: strings.TrimPrefix(line, "+"), NewLineNumber: &n}) + newLineNum++ + case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"): + n := oldLineNum + current.Lines = append(current.Lines, ParsedLine{Type: LineDelete, Content: strings.TrimPrefix(line, "-"), OldLineNumber: &n}) + oldLineNum++ + case strings.HasPrefix(line, " "): + o, n := oldLineNum, newLineNum + current.Lines = append(current.Lines, ParsedLine{Type: LineContext, Content: strings.TrimPrefix(line, " "), OldLineNumber: &o, NewLineNumber: &n}) + oldLineNum++ + newLineNum++ + } + } + if current != nil { + hunks = append(hunks, *current) + } + + adds, dels := 0, 0 + for _, h := range hunks { + for _, l := range h.Lines { + if l.Type == LineAdd { + adds++ + } + if l.Type == LineDelete { + dels++ + } + } + } + + return ParsedDiff{ + OldFile: oldFile, + NewFile: newFile, + Hunks: hunks, + IsBinary: isBinary, + IsNew: isNew, + IsDeleted: isDeleted, + IsRenamed: isRenamed, + Additions: adds, + Deletions: dels, + }, true +} + +func (p *DiffParser) GetStats(diffs []ParsedDiff) DiffStats { + stats := DiffStats{Files: len(diffs)} + for _, d := range diffs { + stats.Additions += d.Additions + stats.Deletions += d.Deletions + } + return stats +} diff --git a/go-source/internal/git/types.go b/go-source/internal/git/types.go new file mode 100644 index 0000000..0e23f39 --- /dev/null +++ b/go-source/internal/git/types.go @@ -0,0 +1,57 @@ +package git + +type ParsedLineType string + +const ( + LineAdd ParsedLineType = "add" + LineDelete ParsedLineType = "delete" + LineContext ParsedLineType = "context" +) + +type ParsedLine struct { + Type ParsedLineType `json:"type"` + Content string `json:"content"` + OldLineNumber *int `json:"oldLineNumber,omitempty"` + NewLineNumber *int `json:"newLineNumber,omitempty"` +} + +type ParsedHunk struct { + OldStart int `json:"oldStart"` + OldLines int `json:"oldLines"` + NewStart int `json:"newStart"` + NewLines int `json:"newLines"` + Header string `json:"header"` + Lines []ParsedLine `json:"lines"` +} + +type ParsedDiff struct { + OldFile string `json:"oldFile"` + NewFile string `json:"newFile"` + Hunks []ParsedHunk `json:"hunks"` + IsBinary bool `json:"isBinary"` + IsNew bool `json:"isNew"` + IsDeleted bool `json:"isDeleted"` + IsRenamed bool `json:"isRenamed"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +type DiffStats struct { + Files int `json:"files"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +type CommitInfo struct { + Hash string `json:"hash"` + Date string `json:"date"` + Message string `json:"message"` + Author string `json:"author"` + Files []string `json:"files"` +} + +type BranchInfo struct { + Name string `json:"name"` + Current bool `json:"current"` + Commit string `json:"commit"` +} diff --git a/go-source/internal/llm/client.go b/go-source/internal/llm/client.go new file mode 100644 index 0000000..8f6a58a --- /dev/null +++ b/go-source/internal/llm/client.go @@ -0,0 +1,251 @@ +package llm + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + "difflearn-go/internal/config" +) + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type LLMResponse struct { + Content string `json:"content"` + Usage map[string]any `json:"usage,omitempty"` +} + +type Client struct { + cfg config.Config + httpClient *http.Client +} + +func NewClient(cfg config.Config) *Client { + return &Client{cfg: cfg, httpClient: &http.Client{Timeout: 120 * time.Second}} +} + +func (c *Client) Chat(messages []ChatMessage) (LLMResponse, error) { + if c.cfg.UseCLI { + return c.chatCLI(messages) + } + switch c.cfg.Provider { + case config.ProviderOpenAI, config.ProviderOllama, config.ProviderLMStudio: + return c.chatOpenAICompat(messages) + case config.ProviderAnthropic: + return c.chatAnthropic(messages) + case config.ProviderGoogle: + return c.chatGoogle(messages) + default: + return LLMResponse{}, fmt.Errorf("unknown provider: %s", c.cfg.Provider) + } +} + +func (c *Client) StreamChat(messages []ChatMessage) (<-chan string, <-chan error) { + chunks := make(chan string) + errs := make(chan error, 1) + go func() { + defer close(chunks) + defer close(errs) + resp, err := c.Chat(messages) + if err != nil { + errs <- err + return + } + for _, tok := range strings.Fields(resp.Content) { + chunks <- tok + " " + } + }() + return chunks, errs +} + +func (c *Client) chatCLI(messages []ChatMessage) (LLMResponse, error) { + system := "" + var sb strings.Builder + for _, m := range messages { + if m.Role == "system" { + system = m.Content + continue + } + role := "User" + if m.Role == "assistant" { + role = "Assistant" + } + sb.WriteString(role + ": " + m.Content + "\n\n") + } + prompt := sb.String() + if system != "" { + prompt = system + "\n\n" + prompt + } + + switch c.cfg.Provider { + case config.ProviderGeminiCLI: + out, err := runCLIWithStdin("gemini", []string{}, prompt) + return LLMResponse{Content: out}, err + case config.ProviderClaude: + out, err := runCLIWithStdin("claude", []string{"-p", prompt}, "") + return LLMResponse{Content: out}, err + case config.ProviderCursor: + out, err := runCLIWithStdin("agent", []string{"-p", prompt, "--output-format", "text"}, "") + if err != nil && strings.Contains(strings.ToLower(err.Error()), "output-format") { + out, err = runCLIWithStdin("agent", []string{"-p", prompt}, "") + } + return LLMResponse{Content: out}, err + case config.ProviderCodex: + out, err := runCLIWithStdin("codex", []string{"exec", "-"}, prompt) + return LLMResponse{Content: out}, err + default: + return LLMResponse{}, fmt.Errorf("unsupported CLI provider: %s", c.cfg.Provider) + } +} + +func runCLIWithStdin(command string, args []string, input string) (string, error) { + cmd := exec.Command(command, args...) + if input != "" { + cmd.Stdin = strings.NewReader(input) + } + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%s failed: %s", command, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func (c *Client) chatOpenAICompat(messages []ChatMessage) (LLMResponse, error) { + url := "https://api.openai.com/v1/chat/completions" + if c.cfg.Provider == config.ProviderOllama || c.cfg.Provider == config.ProviderLMStudio { + url = strings.TrimRight(c.cfg.BaseURL, "/") + "/chat/completions" + } + + payload := map[string]any{ + "model": c.cfg.Model, + "messages": messages, + "temperature": c.cfg.Temperature, + "max_tokens": c.cfg.MaxTokens, + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if c.cfg.Provider == config.ProviderOpenAI { + req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return LLMResponse{}, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return LLMResponse{}, errors.New(string(respBody)) + } + var parsed struct { + Choices []struct { + Message ChatMessage `json:"message"` + } `json:"choices"` + Usage map[string]any `json:"usage"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return LLMResponse{}, err + } + if len(parsed.Choices) == 0 { + return LLMResponse{}, fmt.Errorf("empty response") + } + return LLMResponse{Content: parsed.Choices[0].Message.Content, Usage: parsed.Usage}, nil +} + +func (c *Client) chatAnthropic(messages []ChatMessage) (LLMResponse, error) { + url := "https://api.anthropic.com/v1/messages" + system := "" + msgs := make([]map[string]string, 0) + for _, m := range messages { + if m.Role == "system" { + system = m.Content + continue + } + role := m.Role + if role == "system" { + role = "user" + } + msgs = append(msgs, map[string]string{"role": role, "content": m.Content}) + } + payload := map[string]any{"model": c.cfg.Model, "system": system, "max_tokens": c.cfg.MaxTokens, "messages": msgs} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.cfg.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := c.httpClient.Do(req) + if err != nil { + return LLMResponse{}, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return LLMResponse{}, errors.New(string(respBody)) + } + var parsed struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + Usage map[string]any `json:"usage"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return LLMResponse{}, err + } + if len(parsed.Content) == 0 { + return LLMResponse{}, fmt.Errorf("empty response") + } + return LLMResponse{Content: parsed.Content[0].Text, Usage: parsed.Usage}, nil +} + +func (c *Client) chatGoogle(messages []ChatMessage) (LLMResponse, error) { + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", c.cfg.Model, c.cfg.APIKey) + parts := make([]map[string]any, 0) + for _, m := range messages { + if m.Role == "system" { + parts = append(parts, map[string]any{"text": "System: " + m.Content}) + continue + } + parts = append(parts, map[string]any{"text": strings.Title(m.Role) + ": " + m.Content}) + } + payload := map[string]any{"contents": []map[string]any{{"parts": parts}}} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return LLMResponse{}, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return LLMResponse{}, errors.New(string(respBody)) + } + var parsed struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return LLMResponse{}, err + } + if len(parsed.Candidates) == 0 || len(parsed.Candidates[0].Content.Parts) == 0 { + return LLMResponse{}, fmt.Errorf("empty response") + } + return LLMResponse{Content: parsed.Candidates[0].Content.Parts[0].Text}, nil +} diff --git a/go-source/internal/llm/prompts.go b/go-source/internal/llm/prompts.go new file mode 100644 index 0000000..85694e9 --- /dev/null +++ b/go-source/internal/llm/prompts.go @@ -0,0 +1,67 @@ +package llm + +import ( + "fmt" + + "difflearn-go/internal/git" +) + +var SystemPrompt = `You are DiffLearn, an expert code reviewer and teacher. Your role is to help developers understand git diffs and code changes. + +When analyzing diffs: +- Explain what changed in clear, accessible language +- Highlight potential issues, bugs, or improvements +- Note any patterns or best practices (or violations) +- Be concise but thorough + +Format: +- In diffs, lines starting with '+' are additions (shown in green) +- Lines starting with '-' are deletions (shown in red) +- Lines starting with ' ' are context (unchanged) + +Output Format: +- Use Markdown for formatting +- Use **bold** for emphasis and important terms +- Use ` + "`code blocks`" + ` for code snippets +- Use lists for readability + +Keep responses focused and actionable.` + +func CreateExplainPrompt(formatter *git.DiffFormatter, diffs []git.ParsedDiff) string { + diffMarkdown := formatter.ToMarkdown(diffs) + return fmt.Sprintf("Please explain the following code changes. Describe what was changed, why it might have been changed, and any implications:\n\n%s\n\nProvide a clear, structured explanation that would help someone understand these changes quickly.", diffMarkdown) +} + +func CreateReviewPrompt(formatter *git.DiffFormatter, diffs []git.ParsedDiff) string { + diffMarkdown := formatter.ToMarkdown(diffs) + return fmt.Sprintf("Please review the following code changes. Look for:\n- Potential bugs or errors\n- Security concerns\n- Performance issues\n- Code style and best practices\n- Suggestions for improvement\n\n%s\n\nProvide constructive feedback organized by severity (critical, important, minor).", diffMarkdown) +} + +func CreateSummaryPrompt(formatter *git.DiffFormatter, diffs []git.ParsedDiff) string { + diffMarkdown := formatter.ToMarkdown(diffs) + return fmt.Sprintf("Please provide a brief summary of these changes in 2-3 sentences. Focus on the main purpose and impact:\n\n%s", diffMarkdown) +} + +func CreateQuestionPrompt(formatter *git.DiffFormatter, diffs []git.ParsedDiff, question string) string { + diffMarkdown := formatter.ToMarkdown(diffs) + return fmt.Sprintf("Given the following code changes:\n\n%s\n\nUser question: %s\n\nPlease answer the question based on the diff context provided.", diffMarkdown, question) +} + +func CreateLineQuestionPrompt(diff git.ParsedDiff, hunkIndex int, question string) string { + if hunkIndex < 0 || hunkIndex >= len(diff.Hunks) { + return CreateQuestionPrompt(git.NewDiffFormatter(), []git.ParsedDiff{diff}, question) + } + h := diff.Hunks[hunkIndex] + lines := "" + for _, l := range h.Lines { + prefix := " " + if l.Type == git.LineAdd { + prefix = "+" + } + if l.Type == git.LineDelete { + prefix = "-" + } + lines += prefix + l.Content + "\n" + } + return fmt.Sprintf("In file `%s`, looking at this specific change:\n\n```diff\n%s\n%s```\n\nUser question: %s\n\nPlease answer focusing on this specific change.", diff.NewFile, h.Header, lines, question) +} diff --git a/go-source/internal/mcp/server.go b/go-source/internal/mcp/server.go new file mode 100644 index 0000000..5bbbbe3 --- /dev/null +++ b/go-source/internal/mcp/server.go @@ -0,0 +1,159 @@ +package mcp + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + + "difflearn-go/internal/config" + "difflearn-go/internal/git" + "difflearn-go/internal/llm" +) + +type rpcReq struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type rpcResp struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result any `json:"result,omitempty"` + Error any `json:"error,omitempty"` +} + +func Serve(repoPath string) error { + g := git.NewGitExtractor(repoPath) + formatter := git.NewDiffFormatter() + s := bufio.NewScanner(os.Stdin) + for s.Scan() { + line := s.Bytes() + var req rpcReq + if err := json.Unmarshal(line, &req); err != nil { + continue + } + resp := rpcResp{JSONRPC: "2.0", ID: req.ID} + switch req.Method { + case "tools/list": + resp.Result = map[string]any{"tools": []map[string]any{{"name": "get_local_diff", "description": "Get uncommitted changes"}, {"name": "get_commit_diff", "description": "Get diff for commit"}, {"name": "get_branch_diff", "description": "Get diff between branches"}, {"name": "get_commit_history", "description": "Get recent commits"}, {"name": "explain_diff", "description": "AI explanation"}, {"name": "review_diff", "description": "AI review"}, {"name": "ask_about_diff", "description": "Ask question"}}} + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + } + _ = json.Unmarshal(req.Params, &p) + result, err := callTool(g, formatter, p.Name, p.Arguments) + if err != nil { + resp.Error = map[string]any{"code": -32000, "message": err.Error()} + } else { + resp.Result = result + } + default: + resp.Error = map[string]any{"code": -32601, "message": "method not found"} + } + b, _ := json.Marshal(resp) + fmt.Println(string(b)) + } + return s.Err() +} + +func callTool(g *git.GitExtractor, formatter *git.DiffFormatter, name string, args map[string]interface{}) (map[string]any, error) { + toText := func(s string) map[string]any { return map[string]any{"content": []map[string]string{{"type": "text", "text": s}}} } + + sBool := func(key string) bool { + v, ok := args[key] + if !ok { + return false + } + b, _ := v.(bool) + return b + } + sStr := func(key string) string { + v, ok := args[key] + if !ok { + return "" + } + s, _ := v.(string) + return s + } + sNum := func(key string, d int) int { + v, ok := args[key] + if !ok { + return d + } + f, ok := v.(float64) + if !ok { + return d + } + return int(f) + } + + switch name { + case "get_local_diff": + diffs, err := g.GetLocalDiff(git.DiffOptions{Staged: sBool("staged")}) + if err != nil { + return nil, err + } + format := sStr("format") + if format == "json" { + return toText(formatter.ToJSON(diffs)), nil + } + if format == "raw" { + raw, err := g.GetRawDiff(map[bool]string{true: "staged", false: "local"}[sBool("staged")], nil) + if err != nil { + return nil, err + } + return toText(raw), nil + } + return toText(formatter.ToMarkdown(diffs)), nil + case "get_commit_diff": + diffs, err := g.GetCommitDiff(sStr("commit1"), sStr("commit2")) + if err != nil { + return nil, err + } + return toText(formatter.ToMarkdown(diffs)), nil + case "get_branch_diff": + diffs, err := g.GetBranchDiff(sStr("branch1"), sStr("branch2")) + if err != nil { + return nil, err + } + return toText(formatter.ToMarkdown(diffs)), nil + case "get_commit_history": + commits, err := g.GetCommitHistory(sNum("limit", 10)) + if err != nil { + return nil, err + } + b, _ := json.MarshalIndent(commits, "", " ") + return toText(string(b)), nil + case "explain_diff", "review_diff", "ask_about_diff": + cfg := config.LoadConfig() + diffs, err := g.GetLocalDiff(git.DiffOptions{Staged: sBool("staged")}) + if err != nil { + return nil, err + } + if !config.IsLLMAvailable(cfg) { + return toText("No LLM configured."), nil + } + client := llm.NewClient(cfg) + prompt := "" + if name == "explain_diff" { + prompt = llm.CreateExplainPrompt(formatter, diffs) + } + if name == "review_diff" { + prompt = llm.CreateReviewPrompt(formatter, diffs) + } + if name == "ask_about_diff" { + prompt = llm.CreateQuestionPrompt(formatter, diffs, sStr("question")) + } + resp, err := client.Chat([]llm.ChatMessage{{Role: "system", Content: llm.SystemPrompt}, {Role: "user", Content: prompt}}) + if err != nil { + return nil, err + } + return toText(resp.Content), nil + default: + return nil, fmt.Errorf("unknown tool: %s", name) + } +} diff --git a/go-source/internal/update/update.go b/go-source/internal/update/update.go new file mode 100644 index 0000000..0a031ef --- /dev/null +++ b/go-source/internal/update/update.go @@ -0,0 +1,82 @@ +package update + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime/debug" + "strings" +) + +const githubRepo = "lertsoft/DiffLearn" + +func GetCurrentVersion() string { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + return "0.3.0" +} + +type UpdateInfo struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` + ReleaseURL string `json:"releaseUrl"` + PublishedAt string `json:"publishedAt,omitempty"` +} + +func CheckForUpdates() (*UpdateInfo, error) { + req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/repos/"+githubRepo+"/releases/latest", nil) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "DiffLearn-Go") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("github status: %d", resp.StatusCode) + } + var p struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + return nil, err + } + latest := strings.TrimPrefix(p.TagName, "v") + current := GetCurrentVersion() + return &UpdateInfo{CurrentVersion: current, LatestVersion: latest, UpdateAvailable: compareVersions(latest, current) > 0, ReleaseURL: p.HTMLURL, PublishedAt: p.PublishedAt}, nil +} + +func compareVersions(v1, v2 string) int { + var a1, b1, c1 int + var a2, b2, c2 int + fmt.Sscanf(v1, "%d.%d.%d", &a1, &b1, &c1) + fmt.Sscanf(v2, "%d.%d.%d", &a2, &b2, &c2) + if a1 != a2 { + if a1 > a2 { return 1 } + return -1 + } + if b1 != b2 { + if b1 > b2 { return 1 } + return -1 + } + if c1 != c2 { + if c1 > c2 { return 1 } + return -1 + } + return 0 +} + +func GetUpdateCommand() string { + exe := os.Args[0] + if strings.HasSuffix(exe, ".go") { + wd, _ := os.Getwd() + return fmt.Sprintf("cd %s && git pull", filepath.Clean(wd)) + } + return fmt.Sprintf("curl -fsSL https://raw.githubusercontent.com/%s/master/install.sh | bash", githubRepo) +} diff --git a/go-source/web/app.js b/go-source/web/app.js new file mode 100644 index 0000000..72ee39c --- /dev/null +++ b/go-source/web/app.js @@ -0,0 +1,1099 @@ +/** + * DiffLearn Web UI - Client-side JavaScript + */ + +// API endpoint +const API_URL = ''; + +// State +// State +let currentView = 'local'; +let currentDiff = null; +let currentCommit = null; +let commits = []; +let pendingContext = null; +let selectedForCompare = []; // Array of commit hashes selected for comparison (max 2) + +// DOM Elements +const elements = { + llmStatus: document.getElementById('llmStatus'), + refreshBtn: document.getElementById('refreshBtn'), + commitList: document.getElementById('commitList'), + diffHeader: document.getElementById('diffHeader'), + diffStats: document.getElementById('diffStats'), + diffContent: document.getElementById('diffContent'), + quickActions: document.getElementById('quickActions'), + explainBtn: document.getElementById('explainBtn'), + reviewBtn: document.getElementById('reviewBtn'), + summaryBtn: document.getElementById('summaryBtn'), + exportBtn: document.getElementById('exportBtn'), + chatPanel: document.getElementById('chatPanel'), + chatMessages: document.getElementById('chatMessages'), + chatForm: document.getElementById('chatForm'), + chatInput: document.getElementById('chatInput'), + sendBtn: document.getElementById('sendBtn'), + clearChatBtn: document.getElementById('clearChatBtn'), + viewBtns: document.querySelectorAll('.view-btn'), +}; + +// ============================================ +// API Functions +// ============================================ + +async function fetchJSON(url, options = {}) { + try { + const response = await fetch(API_URL + url, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + return await response.json(); + } catch (error) { + console.error('API Error:', error); + return { success: false, error: error.message }; + } +} + +async function checkLLMStatus() { + const result = await fetchJSON('/'); + const statusDot = elements.llmStatus.querySelector('.status-dot'); + const statusText = elements.llmStatus.querySelector('.status-text'); + + if (result.llmAvailable) { + statusDot.classList.add('ready'); + statusText.textContent = `LLM Ready (${result.llmProvider})`; + } else if (result.status === 'running') { + statusDot.classList.remove('ready', 'error'); + statusText.textContent = 'No LLM Configured'; + } else { + statusDot.classList.add('error'); + statusText.textContent = 'API Error'; + } + + if (result.cwd) { + const cwdEl = document.getElementById('cwdDisplay'); + if (cwdEl) { + cwdEl.textContent = `You are seeing the Diffs for this directory: ${result.cwd}`; + } + } +} + +async function fetchLocalDiff(staged = false) { + const url = staged ? '/diff/local?staged=true' : '/diff/local'; + return await fetchJSON(url); +} + +async function fetchCommitDiff(sha) { + return await fetchJSON(`/diff/commit/${sha}`); +} + +async function fetchHistory(limit = 20) { + return await fetchJSON(`/history?limit=${limit}`); +} + +async function askQuestion(question, staged = false, commit = null) { + return await fetchJSON('/ask', { + method: 'POST', + body: JSON.stringify({ question, staged, commit }), + }); +} + +async function explainDiff(staged = false, commit = null) { + return await fetchJSON('/explain', { + method: 'POST', + body: JSON.stringify({ staged, commit }), + }); +} + +async function reviewDiff(staged = false, commit = null) { + return await fetchJSON('/review', { + method: 'POST', + body: JSON.stringify({ staged, commit }), + }); +} + +async function summarizeDiff(staged = false, commit = null) { + return await fetchJSON('/summary', { + method: 'POST', + body: JSON.stringify({ staged, commit }), + }); +} + +// ============================================ +// Rendering Functions +// ============================================ + +function renderCommitList() { + if (currentView === 'history') { + renderHistoryList(); + } else { + renderLocalChangesItem(); + } +} + +async function renderLocalChangesItem() { + const staged = currentView === 'staged'; + const label = staged ? 'Staged Changes' : 'Local Changes'; + + elements.commitList.innerHTML = ` +
+
šŸ“ Working Directory
+
${label}
+
+ Click to view +
+
+ `; + + // Auto-load local changes + await loadLocalDiff(staged); +} + +async function renderHistoryList() { + elements.commitList.innerHTML = '
Loading commits...
'; + + const result = await fetchHistory(30); + + if (!result.success || !result.data || result.data.length === 0) { + elements.commitList.innerHTML = ` +
+
šŸ“­
+

No commits found

+
+ `; + return; + } + + commits = result.data; + + // Build the compare bar if commits are selected + let compareBarHtml = ''; + if (selectedForCompare.length > 0) { + const selectedCommits = selectedForCompare.map(hash => { + const c = commits.find(commit => commit.hash === hash); + return c ? { hash: c.hash, message: c.message.split('\n')[0].slice(0, 30) } : { hash, message: hash.slice(0, 7) }; + }); + + compareBarHtml = ` +
+
+ šŸ”€ Compare: + ${selectedCommits.map((c, i) => ` + + ${c.hash.slice(0, 7)} + + + ${i === 0 && selectedCommits.length === 2 ? 'vs' : ''} + `).join('')} +
+
+ ${selectedForCompare.length === 2 ? '' : 'Select another commit'} + +
+
+ `; + } + + elements.commitList.innerHTML = compareBarHtml + commits.map((commit, index) => { + const isSelected = selectedForCompare.includes(commit.hash); + const canSelect = selectedForCompare.length < 2 || isSelected; + + return ` +
+ +
+
${commit.hash.slice(0, 7)}
+
${escapeHtml(commit.message.split('\n')[0])}
+
+ ${formatDate(commit.date)} + ${escapeHtml(commit.author)} +
+
+
+ `; + }).join(''); + + // Add compare bar event listeners + const compareGoBtn = document.getElementById('compareGoBtn'); + const compareClearBtn = document.getElementById('compareClearBtn'); + + if (compareGoBtn) { + compareGoBtn.addEventListener('click', (e) => { + e.stopPropagation(); + loadComparisonDiff(); + }); + } + + if (compareClearBtn) { + compareClearBtn.addEventListener('click', (e) => { + e.stopPropagation(); + clearCompareSelection(); + }); + } + + // Add remove button listeners + document.querySelectorAll('.compare-remove').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleCompareCommit(btn.dataset.hash); + }); + }); + + // Auto-load first commit if no compare selection + if (commits.length > 0 && selectedForCompare.length === 0) { + await loadCommitDiff(commits[0].hash); + } +} + +async function loadLocalDiff(staged = false) { + elements.diffContent.innerHTML = '
Loading diff...
'; + + const result = await fetchLocalDiff(staged); + + if (!result.success) { + elements.diffContent.innerHTML = ` +
+
āŒ
+

Error loading diff: ${result.error}

+
+ `; + return; + } + + currentDiff = result.data; + currentCommit = null; + + const label = staged ? 'Staged Changes' : 'Local Changes'; + renderDiff(result.data, label); +} + +async function loadCommitDiff(sha) { + elements.diffContent.innerHTML = '
Loading diff...
'; + + // Update active state + document.querySelectorAll('.commit-item').forEach(el => { + el.classList.remove('active'); + el.setAttribute('aria-selected', 'false'); + }); + const activeItem = document.querySelector(`[data-sha="${sha}"]`); + if (activeItem) { + activeItem.classList.add('active'); + activeItem.setAttribute('aria-selected', 'true'); + // Ensure visible + activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + const result = await fetchCommitDiff(sha); + + if (!result.success) { + elements.diffContent.innerHTML = ` +
+
āŒ
+

Error loading diff: ${result.error}

+
+ `; + return; + } + + currentDiff = result.data; + currentCommit = sha; + + const commit = commits.find(c => c.hash === sha); + const title = commit ? `${sha.slice(0, 7)}: ${commit.message.split('\n')[0]}` : sha.slice(0, 7); + renderDiff(result.data, title); +} + +function renderDiff(data, title) { + const { summary, files } = data; + + // Update header + elements.diffHeader.querySelector('h2').textContent = title; + elements.diffStats.innerHTML = ` + +${summary.additions} + -${summary.deletions} + ${summary.files} file(s) + `; + + // No changes + if (!files || files.length === 0) { + elements.diffContent.innerHTML = ` +
+
✨
+

No changes

+
+ `; + elements.quickActions.style.display = 'none'; + return; + } + + // Render files + elements.diffContent.innerHTML = files.map(file => renderFileDiff(file)).join(''); + elements.quickActions.style.display = 'flex'; + + // Add click handlers for hunk headers + document.querySelectorAll('.hunk-header').forEach(header => { + header.addEventListener('click', (e) => { + const btn = e.target.closest('.ask-btn'); + if (btn) { + const hunkIndex = header.dataset.hunk; + const fileName = header.dataset.file; + askAboutHunk(fileName, hunkIndex); + } + }); + }); +} + +function renderFileDiff(file) { + const status = file.isNew ? 'new' : file.isDeleted ? 'deleted' : file.isRenamed ? 'renamed' : 'modified'; + const statusLabel = file.isNew ? 'NEW' : file.isDeleted ? 'DEL' : file.isRenamed ? 'REN' : 'MOD'; + + return ` +
+
+
+ ${statusLabel} + ${escapeHtml(file.newFile || file.oldFile)} +
+
+ +${file.additions} + -${file.deletions} +
+
+ ${file.hunks.map((hunk, idx) => renderHunk(hunk, idx, file.newFile)).join('')} +
+ `; +} + +function renderHunk(hunk, index, fileName) { + return ` +
+
+ ${escapeHtml(hunk.header)} + +
+ ${hunk.lines.map(line => renderDiffLine(line)).join('')} +
+ `; +} + +function renderDiffLine(line) { + const type = line.type; + // Map type to CSS class (CSS uses 'del' not 'delete') + const cssClass = type === 'delete' ? 'del' : type; + const prefix = type === 'add' ? '+' : type === 'delete' ? '-' : ' '; + const lineNum = type === 'delete' ? (line.oldLineNumber || '') : (line.newLineNumber || ''); + + return ` +
+ ${lineNum} + ${prefix}${escapeHtml(line.content)} +
+ `; +} + +// ============================================ +// Chat Functions +// ============================================ + +function getContextLabel() { + if (currentView === 'history' && currentCommit) { + return `Commit ${currentCommit.slice(0, 7)}`; + } + return currentView === 'staged' ? 'Staged Changes' : 'Local Changes'; +} + +function addMessage(role, content, meta = null) { + const icon = role === 'user' ? 'šŸ‘¤' : 'šŸ¤–'; + const label = role === 'user' ? 'You' : 'DiffLearn'; + + // Remove welcome message + const welcome = elements.chatMessages.querySelector('.chat-welcome'); + if (welcome) welcome.remove(); + + const metaHtml = meta ? `
${escapeHtml(meta)}
` : ''; + + const messageEl = document.createElement('div'); + messageEl.className = `message ${role}`; + let renderedContent; + if (role === 'assistant' && typeof marked !== 'undefined') { + renderedContent = marked.parse(content); + } else { + renderedContent = escapeHtml(content); + } + + messageEl.innerHTML = ` +
+ ${icon} + ${label} +
+ ${metaHtml} +
${renderedContent}
+ `; + + elements.chatMessages.appendChild(messageEl); + elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; + + return messageEl; +} + +function addLoadingMessage() { + const messageEl = document.createElement('div'); + messageEl.className = 'message assistant loading-message'; + messageEl.innerHTML = ` +
+ šŸ¤– + DiffLearn +
+
+ + + +
+ `; + + elements.chatMessages.appendChild(messageEl); + elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; + + return messageEl; +} + +function removeLoadingMessage() { + const loading = elements.chatMessages.querySelector('.loading-message'); + if (loading) loading.remove(); +} + +async function handleChat(question, contextOverride = null) { + if (!question.trim()) return; + + // Check for slash commands first + if (question.trim().startsWith('/')) { + const handled = handleSlashCommand(question); + if (handled) { + elements.chatInput.value = ''; + return; + } + } + + let context = contextOverride; + if (!context) { + context = pendingContext || getContextLabel(); + // Clear pending context after use + pendingContext = null; + } + + addMessage('user', question, context); + elements.chatInput.value = ''; + elements.sendBtn.disabled = true; + + const loadingEl = addLoadingMessage(); + + try { + const staged = currentView === 'staged'; + const commit = currentView === 'history' ? currentCommit : null; + const result = await askQuestion(question, staged, commit); + + removeLoadingMessage(); + + if (result.success && result.data) { + const answer = result.data.answer || result.data.prompt || 'No response'; + addMessage('assistant', answer, context); + } else { + addMessage('assistant', `Error: ${result.error || 'Unknown error'}`, context); + } + } catch (error) { + removeLoadingMessage(); + addMessage('assistant', `Error: ${error.message}`); + } + + elements.sendBtn.disabled = false; + elements.chatInput.focus(); +} + +async function askAboutHunk(fileName, hunkIndex) { + const question = `Please explain the changes in file "${fileName}", specifically the hunk at position ${hunkIndex}. What does this change do?`; + + // Pre-fill input instead of sending immediately + elements.chatInput.value = question; + pendingContext = `File: ${fileName}`; + + // Open chat panel + elements.chatPanel.classList.add('open'); + elements.chatInput.focus(); +} + +function clearChat() { + elements.chatMessages.innerHTML = ` +
+

šŸ‘‹ Ask questions about the selected diff!

+

Try: "What does this change do?" or "Is there a bug here?"

+
+ `; +} + +// ============================================ +// Quick Actions +// ============================================ + +async function handleQuickAction(action) { + const staged = currentView === 'staged'; + const commit = currentView === 'history' ? currentCommit : null; + const btn = elements[`${action}Btn`]; + const originalText = btn.innerHTML; + + const questions = { + explain: 'Please explain these changes.', + review: 'Please review these changes for potential issues.', + summary: 'Please provide a summary of these changes.' + }; + + const context = getContextLabel(); + addMessage('user', questions[action] || `Action: ${action}`, context); + + btn.disabled = true; + btn.innerHTML = 'ā³ Loading...'; + + addLoadingMessage(); + + try { + let result; + switch (action) { + case 'explain': + result = await explainDiff(staged, commit); + break; + case 'review': + result = await reviewDiff(staged, commit); + break; + case 'summary': + result = await summarizeDiff(staged, commit); + break; + } + + removeLoadingMessage(); + + if (result.success && result.data) { + const content = result.data.explanation || result.data.review || result.data.summary || result.data.prompt || 'No response'; + addMessage('assistant', content, context); + } else { + addMessage('assistant', `Error: ${result.error || 'Unknown error'}`, context); + } + } catch (error) { + removeLoadingMessage(); + addMessage('assistant', `Error: ${error.message}`); + } + + btn.disabled = false; + btn.innerHTML = originalText; +} + +// ============================================ +// Export Function +// ============================================ + +function handleExport() { + if (!currentDiff) { + addMessage('assistant', 'āš ļø No diff to export. Select local/staged changes or a commit first.'); + return; + } + + const { summary, files } = currentDiff; + + // Generate markdown export + let markdown = `# Diff Export\n\n`; + markdown += `**Summary:** ${summary.files} file(s), +${summary.additions}/-${summary.deletions}\n\n`; + + if (currentCommit) { + markdown += `**Commit:** ${currentCommit}\n\n`; + } else { + markdown += `**View:** ${currentView === 'staged' ? 'Staged Changes' : 'Local Changes'}\n\n`; + } + + markdown += `---\n\n`; + + files.forEach(file => { + const status = file.isNew ? '[NEW]' : file.isDeleted ? '[DEL]' : file.isRenamed ? '[REN]' : '[MOD]'; + markdown += `## ${status} ${file.newFile || file.oldFile}\n\n`; + markdown += `+${file.additions}/-${file.deletions}\n\n`; + + file.hunks.forEach(hunk => { + markdown += `\`\`\`diff\n${hunk.header}\n`; + hunk.lines.forEach(line => { + const prefix = line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' '; + markdown += `${prefix}${line.content}\n`; + }); + markdown += `\`\`\`\n\n`; + }); + }); + + // Create download + const blob = new Blob([markdown], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `diff-export-${Date.now()}.md`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + addMessage('assistant', 'šŸ“¤ Diff exported as markdown file!'); +} + +// ============================================ +// Slash Command Handling +// ============================================ + +const SLASH_COMMANDS = [ + { cmd: '/explain', desc: 'Get AI explanation of changes', action: 'explain' }, + { cmd: '/review', desc: 'Get AI code review', action: 'review' }, + { cmd: '/summarize', desc: 'Get AI summary of changes', action: 'summary' }, + { cmd: '/export', desc: 'Export diff as markdown', action: 'export' }, + { cmd: '/local', desc: 'Switch to local changes view', action: 'local' }, + { cmd: '/staged', desc: 'Switch to staged changes view', action: 'staged' }, + { cmd: '/history', desc: 'Switch to commit history view', action: 'history' }, + { cmd: '/clear', desc: 'Clear chat messages', action: 'clear' }, +]; + +function handleSlashCommand(input) { + const cmd = input.trim().toLowerCase(); + + // Find matching command + const match = SLASH_COMMANDS.find(c => c.cmd === cmd); + if (!match) { + // Show available commands + if (cmd === '/') { + let helpText = '**Available Commands:**\n\n'; + SLASH_COMMANDS.forEach(c => { + helpText += `\`${c.cmd}\` - ${c.desc}\n`; + }); + addMessage('assistant', helpText); + return true; + } + return false; + } + + switch (match.action) { + case 'explain': + case 'review': + case 'summary': + if (!currentDiff || !currentDiff.files || currentDiff.files.length === 0) { + addMessage('assistant', 'āš ļø No changes to analyze. Select local/staged changes or a commit with content first.'); + } else { + handleQuickAction(match.action); + } + break; + case 'export': + handleExport(); + break; + case 'local': + document.querySelector('[data-view="local"]')?.click(); + addMessage('assistant', 'šŸ“ Switched to Local Changes view'); + break; + case 'staged': + document.querySelector('[data-view="staged"]')?.click(); + addMessage('assistant', 'šŸ“¦ Switched to Staged Changes view'); + break; + case 'history': + document.querySelector('[data-view="history"]')?.click(); + addMessage('assistant', 'šŸ“œ Switched to Commit History view'); + break; + case 'clear': + clearChat(); + break; + } + + return true; +} + +// ============================================ +// Utility Functions +// ============================================ + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatDate(dateStr) { + const date = new Date(dateStr); + const now = new Date(); + const diff = now - date; + + if (diff < 60000) return 'just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`; + + return date.toLocaleDateString(); +} + +// ============================================ +// Compare Functions +// ============================================ + +function toggleCompareCommit(hash) { + const index = selectedForCompare.indexOf(hash); + if (index >= 0) { + // Remove from selection + selectedForCompare.splice(index, 1); + } else if (selectedForCompare.length < 2) { + // Add to selection + selectedForCompare.push(hash); + } + // Re-render to update UI + renderHistoryList(); +} + +function clearCompareSelection() { + selectedForCompare = []; + renderHistoryList(); +} + +async function loadComparisonDiff() { + if (selectedForCompare.length !== 2) return; + + const [sha1, sha2] = selectedForCompare; + elements.diffContent.innerHTML = '
Loading comparison...
'; + + try { + const result = await fetchJSON(`/diff/commit/${sha1}?compare=${sha2}`); + + if (!result.success) { + elements.diffContent.innerHTML = ` +
+
āŒ
+

Error loading comparison: ${result.error}

+
+ `; + return; + } + + currentDiff = result.data; + currentCommit = `${sha1}..${sha2}`; + + const commit1 = commits.find(c => c.hash === sha1); + const commit2 = commits.find(c => c.hash === sha2); + const title1 = commit1 ? commit1.message.split('\n')[0].slice(0, 25) : sha1.slice(0, 7); + const title2 = commit2 ? commit2.message.split('\n')[0].slice(0, 25) : sha2.slice(0, 7); + + renderDiff(result.data, `${sha1.slice(0, 7)} vs ${sha2.slice(0, 7)}`); + + // Show comparison info in chat + addMessage('assistant', `šŸ”€ **Comparing commits:**\n\n**From:** \`${sha1.slice(0, 7)}\` - ${title1}\n\n**To:** \`${sha2.slice(0, 7)}\` - ${title2}\n\n${result.data.summary.files} file(s) changed, +${result.data.summary.additions}/-${result.data.summary.deletions}`); + } catch (error) { + elements.diffContent.innerHTML = ` +
+
āŒ
+

Error: ${error.message}

+
+ `; + } +} + +// ============================================ +// Event Handlers +// ============================================ + +// View selector +elements.viewBtns.forEach(btn => { + btn.addEventListener('click', () => { + elements.viewBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentView = btn.dataset.view; + renderCommitList(); + }); +}); + +// Commit list clicks +elements.commitList.addEventListener('click', async (e) => { + // Check if clicking a compare button + const compareBtn = e.target.closest('.compare-btn'); + if (compareBtn) { + e.stopPropagation(); + const sha = compareBtn.dataset.sha; + if (sha) { + toggleCompareCommit(sha); + } + return; + } + + const item = e.target.closest('.commit-item'); + if (!item) return; + + if (item.dataset.type === 'local') { + const staged = item.dataset.staged === 'true'; + await loadLocalDiff(staged); + } else if (item.dataset.type === 'commit') { + await loadCommitDiff(item.dataset.sha); + } +}); + +// Refresh button +elements.refreshBtn.addEventListener('click', () => { + renderCommitList(); +}); + +// Chat form +elements.chatForm.addEventListener('submit', (e) => { + e.preventDefault(); + handleChat(elements.chatInput.value); +}); + +// Clear chat +elements.clearChatBtn.addEventListener('click', clearChat); + +// Quick actions +elements.explainBtn.addEventListener('click', () => handleQuickAction('explain')); +elements.reviewBtn.addEventListener('click', () => handleQuickAction('review')); +elements.summaryBtn.addEventListener('click', () => handleQuickAction('summary')); +if (elements.exportBtn) { + elements.exportBtn.addEventListener('click', handleExport); +} + +// ============================================ +// Mobile Interactions +// ============================================ + +const sidebar = document.querySelector('.sidebar'); +const mobileChatToggle = document.getElementById('mobileChatToggle'); +const closeChatBtn = document.getElementById('closeChatBtn'); + +// Toggle sidebar on mobile (tap header to expand/collapse) +if (sidebar) { + const sidebarHeader = sidebar.querySelector('.sidebar-header'); + + sidebarHeader?.addEventListener('click', (e) => { + // Don't toggle if clicking the refresh button + if (e.target.closest('#refreshBtn')) return; + + if (window.innerWidth <= 700) { + sidebar.classList.toggle('expanded'); + } + }); + + // Collapse sidebar when clicking outside on mobile + document.addEventListener('click', (e) => { + if (window.innerWidth <= 700 && + !sidebar.contains(e.target) && + sidebar.classList.contains('expanded')) { + sidebar.classList.remove('expanded'); + } + }); +} + +// Mobile chat toggle button (floating) +if (mobileChatToggle) { + mobileChatToggle.addEventListener('click', () => { + elements.chatPanel.classList.add('open'); + elements.chatInput.focus(); + }); +} + +// Header chat button +const headerChatBtn = document.getElementById('headerChatBtn'); +if (headerChatBtn) { + headerChatBtn.addEventListener('click', () => { + elements.chatPanel.classList.add('open'); + elements.chatInput.focus(); + }); +} + +// Close chat button +if (closeChatBtn) { + closeChatBtn.addEventListener('click', () => { + elements.chatPanel.classList.remove('open'); + }); +} + +// Close chat panel when clicking outside on mobile +document.addEventListener('click', (e) => { + if (window.innerWidth <= 900) { + const chatPanel = elements.chatPanel; + const toggle = mobileChatToggle; + const headerBtn = document.getElementById('headerChatBtn'); + + if (chatPanel.classList.contains('open') && + !chatPanel.contains(e.target) && + !toggle?.contains(e.target) && + !headerBtn?.contains(e.target)) { + chatPanel.classList.remove('open'); + } + } +}); + +// Handle window resize +let resizeTimeout; +window.addEventListener('resize', () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + // Reset mobile states when resizing to desktop + if (window.innerWidth > 900) { + elements.chatPanel.classList.remove('open'); + } + if (window.innerWidth > 700) { + sidebar?.classList.remove('expanded'); + } + }, 100); +}); + +// ============================================ +// Initialize +// ============================================ + +function initTheme() { + const themeToggleBtn = document.getElementById('themeToggleBtn'); + if (!themeToggleBtn) return; + + const saved = localStorage.getItem('theme'); + + // Default is dark (no attribute) + // If saved is light, switch to light + if (saved === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + themeToggleBtn.textContent = 'šŸŒ™'; + } else { + document.documentElement.removeAttribute('data-theme'); + themeToggleBtn.textContent = 'ā˜€ļø'; + } + + themeToggleBtn.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-theme'); + if (current === 'light') { + // Switch to Dark + document.documentElement.removeAttribute('data-theme'); + themeToggleBtn.textContent = 'ā˜€ļø'; + localStorage.setItem('theme', 'dark'); + } else { + // Switch to Light + document.documentElement.setAttribute('data-theme', 'light'); + themeToggleBtn.textContent = 'šŸŒ™'; + localStorage.setItem('theme', 'light'); + } + }); +} + +function initKeyboardShortcuts() { + document.addEventListener('keydown', (e) => { + const isInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA'; + if (isInput) { + if (e.key === 'Escape') { + e.target.blur(); + } + return; + } + + switch (e.key) { + case '/': + e.preventDefault(); + if (elements.chatPanel) elements.chatPanel.classList.add('open'); + if (elements.chatInput) elements.chatInput.focus(); + break; + case 'a': + case 'ArrowLeft': + cycleView(-1); + break; + case 'd': + case 'ArrowRight': + cycleView(1); + break; + case 's': + case 'ArrowDown': + moveCommitSelection(1); + break; + case 'w': + case 'ArrowUp': + moveCommitSelection(-1); + break; + case 'Enter': + selectCurrentCommit(); + break; + case 'Escape': + if (elements.chatPanel) elements.chatPanel.classList.remove('open'); + const sidebar = document.querySelector('.sidebar'); + if (window.innerWidth <= 700 && sidebar) { + sidebar.classList.remove('expanded'); + } + break; + } + }); +} + +function cycleView(direction) { + const views = Array.from(elements.viewBtns); + const currentIndex = views.findIndex(btn => btn.classList.contains('active')); + if (currentIndex === -1) return; + + let newIndex = currentIndex + direction; + if (newIndex < 0) newIndex = views.length - 1; + if (newIndex >= views.length) newIndex = 0; + + views[newIndex].click(); +} + +function moveCommitSelection(direction) { + const active = elements.commitList.querySelector('.commit-item.active'); + let target; + + if (!active) { + target = elements.commitList.querySelector('.commit-item'); + } else { + target = direction > 0 ? active.nextElementSibling : active.previousElementSibling; + } + + if (target && target.classList.contains('commit-item')) { + target.click(); + target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } +} + +function selectCurrentCommit() { + const active = elements.commitList.querySelector('.commit-item.active'); + if (active) active.click(); +} + +// Init Shortcuts Modal +function initShortcutsModal() { + const btn = document.getElementById('shortcutsBtn'); + const dialog = document.getElementById('shortcutsDialog'); + const closeBtn = document.getElementById('closeShortcutsBtn'); + + if (!btn || !dialog || !closeBtn) return; + + btn.addEventListener('click', () => { + dialog.showModal(); + }); + + closeBtn.addEventListener('click', () => { + dialog.close(); + }); + + dialog.addEventListener('click', (e) => { + if (e.target === dialog) { + dialog.close(); + } + }); +} + +async function init() { + initTheme(); + initShortcutsModal(); + initKeyboardShortcuts(); + await checkLLMStatus(); + await renderCommitList(); +} + +init(); + diff --git a/go-source/web/index.html b/go-source/web/index.html new file mode 100644 index 0000000..5ae72cb --- /dev/null +++ b/go-source/web/index.html @@ -0,0 +1,181 @@ + + + + + + + + + + DiffLearn - Git Diff Learning Tool + + + + + + + + +
+ +
+
+
+
+

+ šŸ” + DiffLearn +

+ Git Diff Learning Tool +
+
+
+
+
+
+ + Checking AI... +
+ + + +
+
+ + +
+ + + + +
+
+

Select a commit or view local changes

+
+
+ +
+
+ +

Select a commit from the sidebar or view your local changes

+
+
+ + + +
+ + + +
+ + + +
+ + + + + + + + + + \ No newline at end of file diff --git a/go-source/web/styles.css b/go-source/web/styles.css new file mode 100644 index 0000000..16e8b0e --- /dev/null +++ b/go-source/web/styles.css @@ -0,0 +1,1808 @@ +/* ============================================ + DiffLearn Web UI - Styles + ============================================ */ + +:root { + /* Colors - Dark Theme */ + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-hover: #30363d; + + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + + --accent: #58a6ff; + --accent-hover: #79b8ff; + + --success: #3fb950; + --success-bg: rgba(63, 185, 80, 0.15); + --danger: #f85149; + --danger-bg: rgba(248, 81, 73, 0.15); + --warning: #d29922; + --warning-bg: rgba(210, 153, 34, 0.15); + + --border: #30363d; + --border-subtle: #21262d; + + /* Diff Colors */ + --diff-add-bg: rgba(63, 185, 80, 0.15); + --diff-add-text: #7ee787; + --diff-del-bg: rgba(248, 81, 73, 0.15); + --diff-del-text: #ffa198; + --diff-hunk-bg: rgba(56, 139, 253, 0.1); + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); + + /* Spacing */ + --header-height: 60px; + --sidebar-width: 280px; + --chat-width: 380px; + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +} + +:root[data-theme="light"] { + /* Colors - Light Theme */ + --bg-primary: #ffffff; + --bg-secondary: #f6f8fa; + --bg-tertiary: #eaeef2; + --bg-hover: #f3f4f6; + + --text-primary: #050505; + /* Blacker */ + --text-secondary: #404040; + /* Dark Gray */ + --text-muted: #6e7781; + + --accent: #0969da; + --accent-hover: #218bff; + + --success: #1a7f37; + --success-bg: #dafbe1; + --danger: #cf222e; + --danger-bg: #ffebe9; + --warning: #9a6700; + --warning-bg: #fff8c5; + + --border: #d0d7de; + --border-subtle: #d8dee4; + + /* Diff Colors */ + --diff-add-bg: #e6ffec; + --diff-add-text: #1a7f37; + --diff-del-bg: #ffebe9; + --diff-del-text: #cf222e; + --diff-hunk-bg: #ddf4ff; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); +} + +/* ============================================ + Reset & Base + ============================================ */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Accessibility Utilities */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Global Focus Styles */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Accessibility Utilities */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Global Focus Styles */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +html, +body { + height: 100%; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + background: var(--bg-primary); + -webkit-font-smoothing: antialiased; +} + +/* ============================================ + Layout + ============================================ */ + +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-height); + padding: 0 20px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.header-right { + display: flex; + align-items: center; + gap: 12px; +} + + + +.brand-container { + display: flex; + flex-direction: column; + justify-content: center; +} + +.brand-row { + display: flex; + align-items: center; + gap: 16px; +} + +.cwd-display { + font-size: 10px; + color: var(--text-muted); + margin-left: 2px; + font-weight: 500; + margin-top: 4px; +} + +.logo { + display: flex; + align-items: center; + gap: 8px; + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.logo-icon { + font-size: 24px; +} + +.tagline { + color: var(--text-secondary); + font-size: 13px; +} + +.llm-status { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-tertiary); + border-radius: 20px; + font-size: 12px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warning); + animation: pulse 2s infinite; +} + +.status-dot.ready { + background: var(--success); + animation: none; +} + +.status-dot.error { + background: var(--danger); + animation: none; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.main { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ============================================ + Sidebar + ============================================ */ + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header h2 { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.view-selector { + display: flex; + padding: 12px; + gap: 4px; + border-bottom: 1px solid var(--border); +} + +.view-btn { + flex: 1; + padding: 8px 4px; + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.view-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.view-btn.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.commit-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.commit-item { + padding: 12px; + margin-bottom: 4px; + background: var(--bg-tertiary); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + border: 1px solid transparent; +} + +.commit-item:hover { + background: var(--bg-hover); + border-color: var(--border); +} + +.commit-item.active { + border-color: var(--accent); + background: rgba(88, 166, 255, 0.1); +} + +.commit-hash { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); + margin-bottom: 4px; +} + +.commit-message { + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 6px; +} + +.commit-meta { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-muted); +} + +.local-changes-item { + background: linear-gradient(135deg, var(--bg-tertiary), rgba(88, 166, 255, 0.1)); +} + +/* Compare Button in Commit List */ +.commit-item { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.commit-item .commit-content { + flex: 1; + min-width: 0; +} + +.compare-btn { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: bold; + color: var(--text-secondary); + background: var(--bg-primary); + border: 2px solid var(--border); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; + margin-top: 2px; +} + +.compare-btn:hover:not(.disabled) { + border-color: var(--accent); + color: var(--accent); + transform: scale(1.1); +} + +.compare-btn.selected { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.compare-btn.disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.commit-item.compare-selected { + border-color: var(--accent); + background: rgba(88, 166, 255, 0.15); +} + +/* Compare Bar */ +.compare-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + margin-bottom: 12px; + background: linear-gradient(135deg, rgba(88, 166, 255, 0.15), rgba(136, 87, 229, 0.15)); + border: 1px solid var(--accent); + border-radius: 8px; + animation: slideDown 0.2s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.compare-info { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.compare-label { + font-weight: 600; + color: var(--text-primary); +} + +.compare-commit { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; +} + +.compare-hash { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); +} + +.compare-remove { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--text-muted); + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.compare-remove:hover { + background: var(--danger-bg); + color: var(--danger); +} + +.compare-vs { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; +} + +.compare-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.compare-hint { + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + +.compare-go-btn { + padding: 6px 16px; + font-size: 13px; + font-weight: 600; + color: white; + background: linear-gradient(135deg, var(--accent), #a855f7); + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.compare-go-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(88, 166, 255, 0.4); +} + +.compare-clear-btn { + padding: 6px 12px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.compare-clear-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.loading, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-secondary); + text-align: center; +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +/* ============================================ + Diff Panel + ============================================ */ + +.diff-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-primary); +} + +.sidebar-header, +.diff-header, +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + height: 56px; +} + +.sidebar-header h2, +.diff-header h2, +.chat-header h2 { + font-size: 14px; + font-weight: 600; + margin: 0; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.diff-stats { + display: flex; + gap: 12px; + font-size: 13px; +} + +.stat-add { + color: var(--success); +} + +.stat-del { + color: var(--danger); +} + +.diff-content { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* File Diff */ +.file-diff { + margin-bottom: 24px; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border); + overflow: hidden; +} + +.file-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} + +.file-name { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; +} + +.file-status { + padding: 2px 8px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + border-radius: 4px; +} + +.file-status.new { + background: var(--success-bg); + color: var(--success); +} + +.file-status.deleted { + background: var(--danger-bg); + color: var(--danger); +} + +.file-status.modified { + background: var(--warning-bg); + color: var(--warning); +} + +.file-status.renamed { + background: rgba(136, 87, 229, 0.15); + color: #d2a8ff; +} + +.file-stats { + display: flex; + gap: 8px; + font-size: 12px; +} + +.hunk { + border-bottom: 1px solid var(--border); +} + +.hunk:last-child { + border-bottom: none; +} + +.hunk-header { + padding: 8px 16px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); + background: var(--diff-hunk-bg); + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.hunk-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hunk-header:hover { + background: rgba(56, 139, 253, 0.2); +} + +.hunk-header .ask-btn { + /* float: right; remove float */ + display: flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-size: 11px; + background: var(--accent); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.ask-icon { + display: none; +} + +.hunk-header:hover .ask-btn { + opacity: 1; +} + +.diff-line { + display: flex; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; +} + +.line-num { + flex-shrink: 0; + width: 40px; + padding: 0 8px; + text-align: right; + color: var(--text-muted); + background: var(--bg-tertiary); + user-select: none; +} + +.line-content { + flex: 1; + padding: 0 16px; + white-space: pre-wrap; + word-break: break-all; +} + +.diff-line.add { + background: var(--diff-add-bg); +} + +.diff-line.add .line-content { + color: var(--diff-add-text); +} + +.diff-line.del { + background: var(--diff-del-bg); +} + +.diff-line.del .line-content { + color: var(--diff-del-text); +} + +.diff-line.context { + color: var(--text-secondary); +} + +/* Quick Actions */ +.quick-actions { + display: flex; + gap: 8px; + padding: 16px 20px; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.action-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + height: 44px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.action-btn:hover { + background: var(--bg-hover); + border-color: var(--accent); +} + +.action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.action-icon { + font-size: 16px; +} + +/* ============================================ + Chat Panel + ============================================ */ + +.chat-panel { + width: var(--chat-width); + background: var(--bg-secondary); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.chat-header h2 { + font-size: 14px; + font-weight: 600; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.chat-welcome { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); +} + +.chat-welcome .hint { + margin-top: 8px; + font-size: 12px; + color: var(--text-muted); +} + +.message { + margin-bottom: 16px; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-size: 12px; + font-weight: 600; +} + +.message.user .message-header { + color: var(--accent); +} + +.message.assistant .message-header { + color: var(--success); +} + +.message-meta { + display: inline-block; + font-size: 11px; + color: var(--text-muted); + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 4px; + margin-top: 4px; + margin-bottom: 2px; + border: 1px solid var(--border); +} + +.message-content { + padding: 12px 16px; + background: var(--bg-tertiary); + border-radius: 8px; + font-size: 13px; + line-height: 1.6; + overflow-wrap: break-word; + word-wrap: break-word; + /* Fallback */ +} + +/* Markdown Styles */ +.message-content pre { + background: #161b22; + padding: 12px; + border-radius: 6px; + overflow-x: auto; + margin: 10px 0; + border: 1px solid var(--border); +} + +.message-content code { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + background: rgba(110, 118, 129, 0.4); + /* Inline code bg */ + padding: 2px 4px; + border-radius: 4px; +} + +.message-content pre code { + background: transparent; + padding: 0; + color: #e6edf3; + white-space: pre; + /* Keep code formatting */ +} + +.message-content p { + margin-bottom: 10px; +} + +.message-content p:last-child { + margin-bottom: 0; +} + +.message-content ul, +.message-content ol { + margin: 10px 0; + padding-left: 20px; +} + +.message-content li { + margin-bottom: 4px; +} + +.message.user .message-content { + background: rgba(88, 166, 255, 0.1); + border: 1px solid rgba(88, 166, 255, 0.2); + white-space: pre-wrap; + /* Preserve line breaks for user text */ +} + +.message.assistant .message-content { + border: 1px solid var(--border); +} + +.message-loading { + display: flex; + gap: 4px; + padding: 16px; +} + +.message-loading span { + width: 8px; + height: 8px; + background: var(--text-muted); + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out both; +} + +.message-loading span:nth-child(1) { + animation-delay: -0.32s; +} + +.message-loading span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes bounce { + + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} + +.chat-input-form { + display: flex; + gap: 8px; + padding: 16px; + border-top: 1px solid var(--border); +} + +.chat-input { + flex: 1; + padding: 12px 16px; + font-size: 13px; + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + outline: none; + transition: border-color 0.15s; +} + +.chat-input:focus { + border-color: var(--accent); +} + +.chat-input::placeholder { + color: var(--text-muted); +} + +.send-btn { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: white; + background: var(--accent); + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; +} + +.send-btn:hover { + background: var(--accent-hover); +} + +.send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================ + Buttons + ============================================ */ + +.btn { + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn:hover { + background: var(--bg-hover); +} + +.btn-sm { + padding: 4px 8px; + font-size: 12px; +} + +/* ============================================ + Scrollbar + ============================================ */ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* ============================================ + Responsive - Tablet + ============================================ */ + +@media (max-width: 1200px) { + :root { + --chat-width: 320px; + --sidebar-width: 240px; + } +} + +@media (max-width: 1024px) { + :root { + --chat-width: 280px; + --sidebar-width: 220px; + } + + .action-btn { + padding: 8px 12px; + font-size: 12px; + } + + .action-icon { + font-size: 14px; + } +} + +/* ============================================ + Responsive - Small Tablet / Large Phone + ============================================ */ + +@media (max-width: 900px) { + :root { + --header-height: 56px; + } + + /* Hide chat panel on smaller screens by default */ + .chat-panel { + position: fixed; + right: 0; + top: var(--header-height); + bottom: 0; + width: 100%; + max-width: 400px; + transform: translateX(100%); + transition: transform 0.3s ease; + z-index: 100; + box-shadow: var(--shadow-lg); + } + + .chat-panel.open { + transform: translateX(0); + } + + /* Add chat toggle button for mobile */ + .mobile-chat-toggle { + display: flex !important; + } + + .tagline { + display: none; + } +} + +/* ============================================ + Responsive - Phone + ============================================ */ + +@media (max-width: 700px) { + :root { + --header-height: 52px; + } + + /* Header adjustments */ + .header { + padding: 0 12px; + padding-top: env(safe-area-inset-top); + height: auto; + min-height: calc(var(--header-height) + env(safe-area-inset-top)); + align-items: center; + /* keep center vertically */ + } + + .logo { + font-size: 16px; + gap: 6px; + } + + .logo-icon { + font-size: 20px; + } + + .llm-status { + padding: 4px 8px; + font-size: 10px; + } + + .llm-status .status-text { + display: none; + } + + /* Convert to single column layout */ + .main { + flex-direction: column; + } + + /* Sidebar becomes bottom nav + sheet */ + .sidebar { + position: fixed; + left: 0; + bottom: 0; + right: 0; + top: auto; + width: 100%; + height: auto; + max-height: 60vh; + border-right: none; + border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; + transform: translateY(calc(100% - 100px)); + transition: transform 0.3s ease; + z-index: 50; + } + + .sidebar.expanded { + transform: translateY(0); + } + + .sidebar-header { + padding: 12px 16px; + cursor: pointer; + } + + /* Drag handle for sidebar */ + .sidebar-header::before { + content: ''; + display: block; + width: 40px; + height: 4px; + background: var(--border); + border-radius: 2px; + margin: 0 auto 12px; + } + + .view-selector { + padding: 8px 12px; + gap: 6px; + } + + .view-btn { + padding: 10px 8px; + font-size: 12px; + } + + .commit-list { + max-height: 40vh; + padding: 8px 12px; + } + + .commit-item { + padding: 10px; + } + + /* Diff panel fills remaining space */ + .diff-panel { + padding-bottom: 100px; + /* Space for bottom nav */ + } + + .diff-header { + padding: 12px 16px; + flex-wrap: wrap; + gap: 8px; + } + + .diff-header h2 { + font-size: 13px; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .diff-stats { + gap: 8px; + font-size: 11px; + } + + .diff-content { + padding: 12px; + } + + /* File diff adjustments */ + .file-diff { + margin-bottom: 16px; + } + + .file-header { + padding: 10px 12px; + flex-wrap: wrap; + gap: 8px; + } + + .file-name { + font-size: 11px; + flex: 1; + min-width: 0; + overflow: hidden; + } + + .file-name span:last-child { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .hunk-header { + padding: 8px 12px; + font-size: 10px; + } + + .hunk-header .ask-btn { + opacity: 1; + /* Always show on mobile */ + padding: 2px 8px; + /* Smaller */ + font-size: 10px; + height: 24px; + display: flex; + align-items: center; + } + + .diff-line { + font-size: 11px; + } + + .line-num { + width: 32px; + padding: 0 4px; + font-size: 10px; + } + + .line-content { + padding: 0 8px; + } + + /* Quick actions become static at bottom of content */ + .quick-actions { + position: static; + /* Not fixed */ + margin-top: 20px; + padding: 12px; + gap: 8px; + border-radius: 8px; + box-shadow: none; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + } + + .action-btn { + flex: 1; + justify-content: center; + padding: 12px 8px; + font-size: 11px; + gap: 4px; + flex-direction: column; + text-align: center; + } + + .action-icon { + font-size: 18px; + } + + /* Chat panel goes full screen on mobile */ + .chat-panel { + max-width: 100%; + border-radius: 0; + } + + .chat-header { + padding: 12px 16px; + } + + .chat-messages { + padding: 12px; + } + + .chat-input-form { + padding: 12px; + padding-bottom: max(12px, env(safe-area-inset-bottom)); + } + + .chat-input { + padding: 10px 12px; + font-size: 16px; + /* Prevent zoom on iOS */ + } + + .send-btn { + width: 40px; + height: 40px; + } + + /* Empty state adjustments */ + .empty-state { + padding: 30px 16px; + } + + .empty-icon { + font-size: 36px; + margin-bottom: 12px; + } + + .chat-welcome { + padding: 30px 16px; + } +} + +/* ============================================ + Responsive - Very Small Phone + ============================================ */ + +@media (max-width: 400px) { + .header { + padding: 0 8px; + } + + .logo { + font-size: 14px; + } + + .logo-icon { + font-size: 18px; + } + + .view-btn { + padding: 8px 4px; + font-size: 10px; + } + + .diff-header h2 { + font-size: 12px; + } + + .action-btn { + padding: 10px 4px; + font-size: 10px; + } + + .line-num { + width: 28px; + font-size: 9px; + } + + .diff-line { + font-size: 10px; + } +} + +/* ============================================ + Mobile Chat Toggle Button + ============================================ */ + +.mobile-chat-toggle { + /* We will hide this in favor of header button on mobile layout */ + display: none !important; + display: none !important; +} + +.theme-toggle-btn { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-right: 8px; + font-size: 16px; + color: var(--text-primary); +} + +.theme-toggle-btn:hover { + background: var(--bg-hover); +} + +.header-chat-btn { + display: none; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + cursor: pointer; + margin-left: 8px; + font-size: 16px; + color: var(--text-primary); +} + +@media (max-width: 900px) { + .header-chat-btn.mobile-only { + display: flex; + } +} + +@media (max-width: 700px) { + .ask-text { + display: none; + } + + .ask-icon { + display: block; + font-size: 14px; + } + + .hunk-header .ask-btn { + opacity: 1; + /* Always show on mobile */ + padding: 4px 6px; + /* remove static height if icon only */ + } +} + +/* Chat panel close button for mobile */ +.chat-close-btn { + display: none; +} + +@media (max-width: 900px) { + .chat-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 18px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + cursor: pointer; + } +} + +/* ============================================ + Touch Optimizations + ============================================ */ + +@media (pointer: coarse) { + + /* Larger touch targets */ + .commit-item { + padding: 14px 12px; + margin-bottom: 6px; + } + + .view-btn { + padding: 12px 8px; + } + + .btn { + padding: 10px 16px; + min-height: 44px; + } + + .hunk-header { + padding: 12px 16px; + } + + .hunk-header .ask-btn { + padding: 8px 12px; + } + + /* Disable hover effects that don't work on touch */ + .commit-item:hover, + .view-btn:hover, + .action-btn:hover, + .hunk-header:hover { + background: inherit; + } + + .commit-item:active { + background: var(--bg-hover); + } + + .view-btn:active { + background: var(--bg-hover); + } + + .action-btn:active { + background: var(--bg-hover); + border-color: var(--accent); + } + + .hunk-header:active { + background: rgba(56, 139, 253, 0.2); + } +} + +/* ============================================ + Safe Area Support (iPhone notch, etc.) + ============================================ */ + +@supports (padding: max(0px)) { + .header { + padding-left: max(20px, env(safe-area-inset-left)); + padding-right: max(20px, env(safe-area-inset-right)); + } + + @media (max-width: 700px) { + .header { + padding-left: max(12px, env(safe-area-inset-left)); + padding-right: max(12px, env(safe-area-inset-right)); + padding-top: max(5px, env(safe-area-inset-top)); + /* Ensure min spacing */ + } + + .sidebar { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } + } +} + +/* ============================================ + Landscape Phone + ============================================ */ + +@media (max-width: 900px) and (orientation: landscape) and (max-height: 500px) { + :root { + --header-height: 44px; + } + + .sidebar { + max-height: 50vh; + transform: translateY(calc(100% - 60px)); + } + + .quick-actions { + padding: 6px 12px; + } + + + + .action-btn { + padding: 8px 12px; + flex-direction: row; + } +} + +/* Modal - Global */ +dialog.modal { + /* Reset UA styles */ + background: transparent; + border: none; + padding: 0; + margin: auto; + color: inherit; + overflow: visible; + max-width: 100vw; + max-height: 100vh; +} + +dialog.modal::backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +.modal-content { + background-color: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); + width: 450px; + max-width: 90vw; + padding: 24px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + padding-bottom: 16px; +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.close-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 20px; + padding: 4px; + border-radius: 4px; + line-height: 1; + transition: all 0.2s; +} + +.close-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.shortcuts-grid { + display: flex; + flex-direction: column; + gap: 24px; +} + +.shortcut-group h3 { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + font-weight: 600; + margin-bottom: 12px; + letter-spacing: 0.5px; +} + +.shortcut-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 14px; +} + +kbd { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 6px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + box-shadow: 0 2px 0 var(--border); + min-width: 24px; + text-align: center; + display: inline-block; +} + +/* ============================================ + Print Styles + ============================================ */ + +@media print { + + .sidebar, + .chat-panel, + .quick-actions, + .mobile-chat-toggle, + .llm-status { + display: none !important; + } + + .main { + display: block; + } + + .diff-panel { + padding-bottom: 0; + } + + .diff-line.add { + background: #e6ffe6 !important; + color: #006400 !important; + } + + .diff-line.del { + background: #ffe6e6 !important; + color: #8b0000 !important; + } +} \ No newline at end of file From b2c63b4f62349f1c39957ea921a73308a0574a77 Mon Sep 17 00:00:00 2001 From: Ronny Coste Date: Mon, 9 Feb 2026 20:35:22 -0500 Subject: [PATCH 2/2] feat: Embed web UI assets for standalone execution and add extensive unit tests for API, Git, Config, and LLM components. --- .gitignore | 1 + go-source/README.md | 7 +++ go-source/internal/api/server.go | 35 +++++++++---- go-source/internal/api/server_test.go | 38 ++++++++++++++ go-source/internal/config/config_test.go | 56 +++++++++++++++++++++ go-source/internal/git/formatter_test.go | 46 +++++++++++++++++ go-source/internal/git/parser_test.go | 64 ++++++++++++++++++++++++ go-source/internal/llm/prompts_test.go | 60 ++++++++++++++++++++++ go-source/web/assets.go | 7 +++ 9 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 go-source/internal/api/server_test.go create mode 100644 go-source/internal/config/config_test.go create mode 100644 go-source/internal/git/formatter_test.go create mode 100644 go-source/internal/git/parser_test.go create mode 100644 go-source/internal/llm/prompts_test.go create mode 100644 go-source/web/assets.go diff --git a/.gitignore b/.gitignore index 19e69e3..703c735 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +go-source/.gocache/ # Environment .env diff --git a/go-source/README.md b/go-source/README.md index d2ad391..b66c489 100644 --- a/go-source/README.md +++ b/go-source/README.md @@ -10,6 +10,13 @@ go mod tidy go run ./cmd/difflearn ``` +## Test + +```bash +cd go-source +go test ./... +``` + ## Commands - `difflearn` (interactive dashboard) diff --git a/go-source/internal/api/server.go b/go-source/internal/api/server.go index 61d06f1..cf51006 100644 --- a/go-source/internal/api/server.go +++ b/go-source/internal/api/server.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "io/fs" "net/http" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "difflearn-go/internal/config" "difflearn-go/internal/git" "difflearn-go/internal/llm" + webassets "difflearn-go/web" ) func StartAPIServer(port int, repoPath string) error { @@ -24,10 +26,7 @@ func StartAPIServer(port int, repoPath string) error { g := git.NewGitExtractor(repoPath) formatter := git.NewDiffFormatter() - webDir, err := findWebDir(repoPath) - if err != nil { - return err - } + webDir, hasDiskWeb := findWebDir(repoPath) mux := http.NewServeMux() withCORS := func(h http.HandlerFunc) http.HandlerFunc { @@ -44,16 +43,16 @@ func StartAPIServer(port int, repoPath string) error { } mux.HandleFunc("/styles.css", withCORS(func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, filepath.Join(webDir, "styles.css")) + serveWebAsset(w, r, hasDiskWeb, webDir, "styles.css", "text/css") })) mux.HandleFunc("/app.js", withCORS(func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, filepath.Join(webDir, "app.js")) + serveWebAsset(w, r, hasDiskWeb, webDir, "app.js", "application/javascript") })) mux.HandleFunc("/", withCORS(func(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if strings.Contains(accept, "text/html") { - http.ServeFile(w, r, filepath.Join(webDir, "index.html")) + serveWebAsset(w, r, hasDiskWeb, webDir, "index.html", "text/html") return } cfg := config.LoadConfig() @@ -224,7 +223,7 @@ func StartAPIServer(port int, repoPath string) error { return http.ListenAndServe(addr, mux) } -func findWebDir(repoPath string) (string, error) { +func findWebDir(repoPath string) (string, bool) { candidates := []string{ filepath.Join(repoPath, "go-source", "web"), filepath.Join(repoPath, "web"), @@ -232,10 +231,26 @@ func findWebDir(repoPath string) (string, error) { } for _, c := range candidates { if _, err := os.Stat(filepath.Join(c, "index.html")); err == nil { - return c, nil + return c, true } } - return "", fmt.Errorf("could not find web directory") + return "", false +} + +func serveWebAsset(w http.ResponseWriter, r *http.Request, hasDiskWeb bool, webDir, name, contentType string) { + if hasDiskWeb { + http.ServeFile(w, r, filepath.Join(webDir, name)) + return + } + + data, err := fs.ReadFile(webassets.Assets, name) + if err != nil { + http.Error(w, "web asset not found: "+name, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", contentType) + _, _ = w.Write(data) } func writeJSON(w http.ResponseWriter, status int, payload any) { diff --git a/go-source/internal/api/server_test.go b/go-source/internal/api/server_test.go new file mode 100644 index 0000000..83293b1 --- /dev/null +++ b/go-source/internal/api/server_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "io" + "net/http/httptest" + "strings" + "testing" +) + +func TestFindWebDirFromRepoRoot(t *testing.T) { + dir, ok := findWebDir("../..") + if !ok { + t.Fatalf("expected to find web dir from repo root") + } + if !strings.HasSuffix(dir, "web") { + t.Fatalf("unexpected web dir path: %s", dir) + } +} + +func TestServeEmbeddedAssetFallback(t *testing.T) { + req := httptest.NewRequest("GET", "/styles.css", nil) + w := httptest.NewRecorder() + + serveWebAsset(w, req, false, "", "styles.css", "text/css") + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/css") { + t.Fatalf("expected text/css content type, got %s", ct) + } + body, _ := io.ReadAll(resp.Body) + if len(body) == 0 { + t.Fatalf("expected non-empty asset body") + } +} diff --git a/go-source/internal/config/config_test.go b/go-source/internal/config/config_test.go new file mode 100644 index 0000000..1f89acc --- /dev/null +++ b/go-source/internal/config/config_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigFromEnv(t *testing.T) { + t.Setenv("DIFFLEARN_LLM_PROVIDER", "openai") + t.Setenv("OPENAI_API_KEY", "test-key") + t.Setenv("DIFFLEARN_MODEL", "gpt-test") + t.Setenv("DIFFLEARN_TEMPERATURE", "0.7") + t.Setenv("DIFFLEARN_MAX_TOKENS", "1024") + + cfg := LoadConfig() + if cfg.Provider != ProviderOpenAI { + t.Fatalf("expected provider openai, got %s", cfg.Provider) + } + if cfg.APIKey != "test-key" { + t.Fatalf("expected api key from env") + } + if cfg.Model != "gpt-test" { + t.Fatalf("expected model gpt-test, got %s", cfg.Model) + } + if !IsLLMAvailable(cfg) { + t.Fatalf("expected llm to be available") + } +} + +func TestLoadConfigFromDotfile(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("DIFFLEARN_LLM_PROVIDER", "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("GOOGLE_AI_API_KEY", "") + t.Setenv("DIFFLEARN_MODEL", "") + + content := "DIFFLEARN_LLM_PROVIDER=ollama\nDIFFLEARN_MODEL=llama3.2\n" + if err := os.WriteFile(filepath.Join(tmpHome, ".difflearn"), []byte(content), 0o644); err != nil { + t.Fatalf("write .difflearn: %v", err) + } + + cfg := LoadConfig() + if cfg.Provider != ProviderOllama { + t.Fatalf("expected provider ollama, got %s", cfg.Provider) + } + if cfg.Model != "llama3.2" { + t.Fatalf("expected model from file, got %s", cfg.Model) + } + if !IsLLMAvailable(cfg) { + t.Fatalf("expected ollama to be treated as available") + } +} + diff --git a/go-source/internal/git/formatter_test.go b/go-source/internal/git/formatter_test.go new file mode 100644 index 0000000..f0a279c --- /dev/null +++ b/go-source/internal/git/formatter_test.go @@ -0,0 +1,46 @@ +package git + +import ( + "strings" + "testing" +) + +func TestFormatterMarkdownAndSummary(t *testing.T) { + diffs := []ParsedDiff{ + { + OldFile: "a.txt", + NewFile: "a.txt", + Additions: 2, + Deletions: 1, + Hunks: []ParsedHunk{ + { + Header: "@@ -1,2 +1,3 @@", + Lines: []ParsedLine{ + {Type: LineContext, Content: "line"}, + {Type: LineDelete, Content: "old"}, + {Type: LineAdd, Content: "new"}, + {Type: LineAdd, Content: "new2"}, + }, + }, + }, + }, + } + + f := NewDiffFormatter() + md := f.ToMarkdown(diffs) + if !strings.Contains(md, "# Git Diff Summary") { + t.Fatalf("expected markdown header, got: %s", md) + } + if !strings.Contains(md, "## a.txt") { + t.Fatalf("expected file header in markdown") + } + if !strings.Contains(md, "+new") { + t.Fatalf("expected added line in markdown") + } + + summary := f.ToSummary(diffs) + if !strings.Contains(summary, "1 file(s) changed, +2 -1") { + t.Fatalf("unexpected summary: %s", summary) + } +} + diff --git a/go-source/internal/git/parser_test.go b/go-source/internal/git/parser_test.go new file mode 100644 index 0000000..c28a39a --- /dev/null +++ b/go-source/internal/git/parser_test.go @@ -0,0 +1,64 @@ +package git + +import "testing" + +func TestParseSingleFileDiff(t *testing.T) { + raw := `diff --git a/main.go b/main.go +index 1111111..2222222 100644 +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ + package main +-func old() {} ++func old() {} ++func added() {} + func unchanged() {}` + + p := NewDiffParser() + diffs := p.Parse(raw) + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d", len(diffs)) + } + + d := diffs[0] + if d.NewFile != "main.go" { + t.Fatalf("expected new file main.go, got %s", d.NewFile) + } + if d.Additions != 2 { + t.Fatalf("expected 2 additions, got %d", d.Additions) + } + if d.Deletions != 1 { + t.Fatalf("expected 1 deletion, got %d", d.Deletions) + } + if len(d.Hunks) != 1 { + t.Fatalf("expected 1 hunk, got %d", len(d.Hunks)) + } +} + +func TestParseRenameDiff(t *testing.T) { + raw := `diff --git a/old.txt b/new.txt +similarity index 100% +rename from old.txt +rename to new.txt` + + p := NewDiffParser() + diffs := p.Parse(raw) + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d", len(diffs)) + } + if !diffs[0].IsRenamed { + t.Fatalf("expected renamed file") + } +} + +func TestGetStats(t *testing.T) { + p := NewDiffParser() + stats := p.GetStats([]ParsedDiff{ + {Additions: 3, Deletions: 1}, + {Additions: 5, Deletions: 2}, + }) + if stats.Files != 2 || stats.Additions != 8 || stats.Deletions != 3 { + t.Fatalf("unexpected stats: %+v", stats) + } +} + diff --git a/go-source/internal/llm/prompts_test.go b/go-source/internal/llm/prompts_test.go new file mode 100644 index 0000000..1ffb178 --- /dev/null +++ b/go-source/internal/llm/prompts_test.go @@ -0,0 +1,60 @@ +package llm + +import ( + "strings" + "testing" + + "difflearn-go/internal/git" +) + +func sampleDiff() git.ParsedDiff { + return git.ParsedDiff{ + NewFile: "main.go", + Hunks: []git.ParsedHunk{ + { + Header: "@@ -1,1 +1,2 @@", + Lines: []git.ParsedLine{ + {Type: git.LineDelete, Content: "old()"}, + {Type: git.LineAdd, Content: "new()"}, + }, + }, + }, + Additions: 1, + Deletions: 1, + } +} + +func TestCreatePromptVariants(t *testing.T) { + f := git.NewDiffFormatter() + diffs := []git.ParsedDiff{sampleDiff()} + + explain := CreateExplainPrompt(f, diffs) + review := CreateReviewPrompt(f, diffs) + summary := CreateSummaryPrompt(f, diffs) + question := CreateQuestionPrompt(f, diffs, "why?") + + for name, prompt := range map[string]string{ + "explain": explain, + "review": review, + "summary": summary, + "question": question, + } { + if !strings.Contains(prompt, "main.go") { + t.Fatalf("%s prompt missing file context", name) + } + } + if !strings.Contains(review, "severity") { + t.Fatalf("review prompt missing guidance") + } +} + +func TestCreateLineQuestionPrompt(t *testing.T) { + prompt := CreateLineQuestionPrompt(sampleDiff(), 0, "is this safe?") + if !strings.Contains(prompt, "In file `main.go`") { + t.Fatalf("line question missing file name") + } + if !strings.Contains(prompt, "is this safe?") { + t.Fatalf("line question missing user question") + } +} + diff --git a/go-source/web/assets.go b/go-source/web/assets.go new file mode 100644 index 0000000..424abdd --- /dev/null +++ b/go-source/web/assets.go @@ -0,0 +1,7 @@ +package webassets + +import "embed" + +// Assets bundles the web UI files into the binary. +//go:embed index.html app.js styles.css +var Assets embed.FS