初始化仓库
This commit is contained in:
@@ -0,0 +1,698 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// geminiSession manages multi-turn conversations with the Gemini CLI.
|
||||
// Each Send() launches a new `gemini -p - --output-format stream-json` process
|
||||
// with --resume for conversation continuity. The prompt is passed via stdin
|
||||
// (using -p - flag) to preserve newlines in multi-line messages.
|
||||
type geminiSession struct {
|
||||
cmd string
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
timeout time.Duration
|
||||
extraEnv []string
|
||||
events chan core.Event
|
||||
chatID atomic.Value // stores string — Gemini session ID
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
alive atomic.Bool
|
||||
|
||||
pendingMsgs []string // buffered assistant messages awaiting classification
|
||||
}
|
||||
|
||||
func newGeminiSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*geminiSession, error) {
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
gs := &geminiSession{
|
||||
cmd: cmd,
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
timeout: timeout,
|
||||
extraEnv: extraEnv,
|
||||
events: make(chan core.Event, 64),
|
||||
ctx: sessionCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
gs.alive.Store(true)
|
||||
|
||||
if resumeID != "" && resumeID != core.ContinueSession {
|
||||
gs.chatID.Store(resumeID)
|
||||
}
|
||||
|
||||
return gs, nil
|
||||
}
|
||||
|
||||
func (gs *geminiSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) (err error) {
|
||||
if !gs.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
|
||||
// Save images and files into the workspace so Gemini CLI tools can access them.
|
||||
attachDir := filepath.Join(gs.workDir, ".cc-connect", "attachments")
|
||||
if (len(images) > 0 || len(files) > 0) && os.MkdirAll(attachDir, 0o755) != nil {
|
||||
attachDir = os.TempDir()
|
||||
}
|
||||
|
||||
var imageRefs []string
|
||||
for i, img := range images {
|
||||
ext := ".png"
|
||||
switch img.MimeType {
|
||||
case "image/jpeg":
|
||||
ext = ".jpg"
|
||||
case "image/gif":
|
||||
ext = ".gif"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
}
|
||||
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
|
||||
fpath := filepath.Join(attachDir, fname)
|
||||
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
|
||||
slog.Warn("geminiSession: failed to save image", "error", err)
|
||||
continue
|
||||
}
|
||||
imageRefs = append(imageRefs, fpath)
|
||||
}
|
||||
|
||||
var fileRefs []string
|
||||
for i, f := range files {
|
||||
fname := filepath.Base(f.FileName)
|
||||
if fname == "" || fname == "." || fname == ".." {
|
||||
fname = fmt.Sprintf("file_%d_%d", time.Now().UnixMilli(), i)
|
||||
}
|
||||
fpath := filepath.Join(attachDir, fname)
|
||||
if err := os.WriteFile(fpath, f.Data, 0o644); err != nil {
|
||||
slog.Warn("geminiSession: failed to save file", "error", err)
|
||||
continue
|
||||
}
|
||||
fileRefs = append(fileRefs, fpath)
|
||||
}
|
||||
|
||||
chatID := gs.CurrentSessionID()
|
||||
isResume := chatID != ""
|
||||
|
||||
args := []string{
|
||||
"--output-format", "stream-json",
|
||||
}
|
||||
|
||||
switch gs.mode {
|
||||
case "yolo":
|
||||
args = append(args, "-y")
|
||||
case "auto_edit":
|
||||
args = append(args, "--approval-mode", "auto_edit")
|
||||
case "plan":
|
||||
args = append(args, "--approval-mode", "plan")
|
||||
}
|
||||
|
||||
if isResume {
|
||||
args = append(args, "--resume", chatID)
|
||||
}
|
||||
if gs.model != "" {
|
||||
args = append(args, "-m", gs.model)
|
||||
}
|
||||
|
||||
// Build prompt with explicit file path references so Gemini can find them.
|
||||
fullPrompt := prompt
|
||||
if len(imageRefs) > 0 {
|
||||
if fullPrompt == "" {
|
||||
fullPrompt = "Please analyze the attached image(s)."
|
||||
}
|
||||
fullPrompt += "\n\n[Attached images saved at: " + strings.Join(imageRefs, ", ") + "]"
|
||||
}
|
||||
if len(fileRefs) > 0 {
|
||||
if fullPrompt == "" {
|
||||
fullPrompt = "Please analyze the attached file(s)."
|
||||
}
|
||||
fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]"
|
||||
}
|
||||
|
||||
// Pass prompt via stdin instead of -p flag to preserve newlines.
|
||||
// The -p flag can truncate at newline characters in some Gemini CLI versions.
|
||||
args = append(args, "-p", "-")
|
||||
|
||||
// Add timeout for each turn to prevent hanging processes
|
||||
var cancel context.CancelFunc
|
||||
var ctx context.Context
|
||||
if gs.timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(gs.ctx, gs.timeout)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(gs.ctx)
|
||||
}
|
||||
|
||||
// ensure cancel is called on early return errors
|
||||
started := false
|
||||
defer func() {
|
||||
if !started {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Debug("geminiSession: launching", "resume", isResume, "args", core.RedactArgs(args))
|
||||
cmd := exec.CommandContext(ctx, gs.cmd, args...)
|
||||
// Set a short WaitDelay to ensure I/O goroutines don't block for long after the context is done
|
||||
cmd.WaitDelay = 1 * time.Second
|
||||
cmd.Dir = gs.workDir
|
||||
env := os.Environ()
|
||||
if len(gs.extraEnv) > 0 {
|
||||
env = core.MergeEnv(env, gs.extraEnv)
|
||||
}
|
||||
cmd.Env = env
|
||||
cmd.Stdin = strings.NewReader(fullPrompt)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("geminiSession: stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("geminiSession: start: %w", err)
|
||||
}
|
||||
|
||||
started = true
|
||||
gs.wg.Add(1)
|
||||
go func() {
|
||||
defer cancel()
|
||||
gs.readLoop(ctx, cmd, stdout, &stderrBuf, append(imageRefs, fileRefs...))
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gs *geminiSession) readLoop(ctx context.Context, cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer, tempImages []string) {
|
||||
defer gs.wg.Done()
|
||||
defer func() {
|
||||
// Clean up temp image files
|
||||
for _, f := range tempImages {
|
||||
os.Remove(f)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
stderrMsg := strings.TrimSpace(stderrBuf.String())
|
||||
if stderrMsg != "" {
|
||||
slog.Error("geminiSession: process failed", "error", err, "stderr", stderrMsg)
|
||||
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Unblock scanner if context is canceled
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
stdout.Close()
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("geminiSession: raw", "line", truncate(line, 500))
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
||||
slog.Debug("geminiSession: non-JSON line", "line", line)
|
||||
continue
|
||||
}
|
||||
|
||||
gs.handleEvent(raw)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
slog.Error("geminiSession: scanner error", "error", err)
|
||||
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini CLI stream-json event types:
|
||||
//
|
||||
// init — session_id, model
|
||||
// message — role (user/assistant), content, delta
|
||||
// tool_use — tool_name, tool_id, parameters
|
||||
// tool_result — tool_id, status, output, error
|
||||
// error — severity, message
|
||||
// result — status, stats (final event)
|
||||
func (gs *geminiSession) handleEvent(raw map[string]any) {
|
||||
eventType, _ := raw["type"].(string)
|
||||
|
||||
switch eventType {
|
||||
case "init":
|
||||
gs.handleInit(raw)
|
||||
case "message":
|
||||
gs.handleMessage(raw)
|
||||
case "tool_use":
|
||||
gs.handleToolUse(raw)
|
||||
case "tool_result":
|
||||
gs.handleToolResult(raw)
|
||||
case "error":
|
||||
gs.handleError(raw)
|
||||
case "result":
|
||||
gs.handleResult(raw)
|
||||
default:
|
||||
slog.Debug("geminiSession: unhandled event", "type", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *geminiSession) handleInit(raw map[string]any) {
|
||||
sid, _ := raw["session_id"].(string)
|
||||
model, _ := raw["model"].(string)
|
||||
|
||||
if sid != "" {
|
||||
gs.chatID.Store(sid)
|
||||
slog.Debug("geminiSession: session init", "session_id", sid, "model", model)
|
||||
|
||||
evt := core.Event{Type: core.EventText, SessionID: sid, Content: "", ToolName: model}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *geminiSession) handleMessage(raw map[string]any) {
|
||||
role, _ := raw["role"].(string)
|
||||
content, _ := raw["content"].(string)
|
||||
|
||||
if role == "user" || content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Delta messages are incremental streaming fragments — emit immediately
|
||||
// as EventText so engine's stream preview can update in real time.
|
||||
// Non-delta messages (complete text) are buffered for later classification
|
||||
// (thinking vs final text) based on what event follows.
|
||||
delta, _ := raw["delta"].(bool)
|
||||
if delta {
|
||||
evt := core.Event{Type: core.EventText, Content: content}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
gs.pendingMsgs = append(gs.pendingMsgs, content)
|
||||
}
|
||||
|
||||
func (gs *geminiSession) handleToolUse(raw map[string]any) {
|
||||
gs.flushPendingAsThinking()
|
||||
|
||||
toolName, _ := raw["tool_name"].(string)
|
||||
toolID, _ := raw["tool_id"].(string)
|
||||
params, _ := raw["parameters"].(map[string]any)
|
||||
|
||||
input := formatToolParams(toolName, params)
|
||||
|
||||
slog.Debug("geminiSession: tool_use", "tool", toolName, "id", toolID)
|
||||
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *geminiSession) handleToolResult(raw map[string]any) {
|
||||
toolID, _ := raw["tool_id"].(string)
|
||||
status, _ := raw["status"].(string)
|
||||
output, _ := raw["output"].(string)
|
||||
|
||||
slog.Debug("geminiSession: tool_result", "tool_id", toolID, "status", status)
|
||||
|
||||
if status == "error" {
|
||||
errObj, _ := raw["error"].(map[string]any)
|
||||
if errObj != nil {
|
||||
errMsg, _ := errObj["message"].(string)
|
||||
if errMsg != "" {
|
||||
output = "Error: " + errMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
evt := core.Event{Type: core.EventToolResult, ToolName: toolID, Content: truncate(output, 500)}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *geminiSession) handleError(raw map[string]any) {
|
||||
severity, _ := raw["severity"].(string)
|
||||
message, _ := raw["message"].(string)
|
||||
|
||||
if message != "" {
|
||||
slog.Warn("geminiSession: error event", "severity", severity, "message", message)
|
||||
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("[%s] %s", severity, message)}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *geminiSession) handleResult(raw map[string]any) {
|
||||
gs.flushPendingAsText()
|
||||
|
||||
status, _ := raw["status"].(string)
|
||||
|
||||
var errMsg string
|
||||
if status == "error" {
|
||||
errObj, _ := raw["error"].(map[string]any)
|
||||
if errObj != nil {
|
||||
errMsg, _ = errObj["message"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
sid := gs.CurrentSessionID()
|
||||
|
||||
if errMsg != "" {
|
||||
evt := core.Event{Type: core.EventResult, Content: errMsg, SessionID: sid, Done: true, Error: fmt.Errorf("%s", errMsg)}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
} else {
|
||||
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *geminiSession) flushPendingAsThinking() {
|
||||
if len(gs.pendingMsgs) == 0 {
|
||||
return
|
||||
}
|
||||
text := strings.Join(gs.pendingMsgs, "")
|
||||
gs.pendingMsgs = gs.pendingMsgs[:0]
|
||||
if text != "" {
|
||||
evt := core.Event{Type: core.EventThinking, Content: text}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *geminiSession) flushPendingAsText() {
|
||||
if len(gs.pendingMsgs) == 0 {
|
||||
return
|
||||
}
|
||||
text := strings.Join(gs.pendingMsgs, "")
|
||||
gs.pendingMsgs = gs.pendingMsgs[:0]
|
||||
if text != "" {
|
||||
evt := core.Event{Type: core.EventText, Content: text}
|
||||
select {
|
||||
case gs.events <- evt:
|
||||
case <-gs.ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RespondPermission is a no-op — Gemini CLI permissions are handled via -y / --approval-mode flags.
|
||||
func (gs *geminiSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gs *geminiSession) Events() <-chan core.Event {
|
||||
return gs.events
|
||||
}
|
||||
|
||||
func (gs *geminiSession) CurrentSessionID() string {
|
||||
v, _ := gs.chatID.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (gs *geminiSession) Alive() bool {
|
||||
return gs.alive.Load()
|
||||
}
|
||||
|
||||
func (gs *geminiSession) Close() error {
|
||||
gs.alive.Store(false)
|
||||
gs.cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
gs.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
close(gs.events)
|
||||
case <-time.After(8 * time.Second):
|
||||
slog.Warn("geminiSession: close timed out, abandoning wg.Wait")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatToolParams extracts a human-readable summary from tool parameters.
|
||||
func formatToolParams(toolName string, params map[string]any) string {
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch toolName {
|
||||
case "shell", "run_shell_command", "Bash":
|
||||
if cmd, ok := params["command"].(string); ok {
|
||||
return cmd
|
||||
}
|
||||
case "write_file", "WriteFile":
|
||||
fp, _ := params["file_path"].(string)
|
||||
if fp == "" {
|
||||
fp, _ = params["path"].(string)
|
||||
}
|
||||
if fp != "" {
|
||||
if content, ok := params["content"].(string); ok && content != "" {
|
||||
return "`" + fp + "`\n```\n" + content + "\n```"
|
||||
}
|
||||
return fp
|
||||
}
|
||||
case "replace", "ReplaceInFile":
|
||||
fp, _ := params["file_path"].(string)
|
||||
if fp == "" {
|
||||
fp, _ = params["path"].(string)
|
||||
}
|
||||
if fp != "" {
|
||||
old, _ := params["old_string"].(string)
|
||||
new_, _ := params["new_string"].(string)
|
||||
if old == "" {
|
||||
old, _ = params["old_str"].(string)
|
||||
new_, _ = params["new_str"].(string)
|
||||
}
|
||||
if old != "" || new_ != "" {
|
||||
diff := computeLineDiff(old, new_)
|
||||
return "`" + fp + "`\n```diff\n" + diff + "\n```"
|
||||
}
|
||||
return fp
|
||||
}
|
||||
case "read_file", "ReadFile":
|
||||
if p, ok := params["file_path"].(string); ok {
|
||||
return p
|
||||
}
|
||||
if p, ok := params["path"].(string); ok {
|
||||
return p
|
||||
}
|
||||
case "list_directory", "ListDirectory":
|
||||
if p, ok := params["dir_path"].(string); ok {
|
||||
return p
|
||||
}
|
||||
if p, ok := params["path"].(string); ok {
|
||||
return p
|
||||
}
|
||||
if p, ok := params["directory"].(string); ok {
|
||||
return p
|
||||
}
|
||||
case "web_fetch", "WebFetch":
|
||||
if p, ok := params["prompt"].(string); ok {
|
||||
return p
|
||||
}
|
||||
if u, ok := params["url"].(string); ok {
|
||||
return u
|
||||
}
|
||||
case "google_web_search", "GoogleWebSearch":
|
||||
if q, ok := params["query"].(string); ok {
|
||||
return q
|
||||
}
|
||||
case "activate_skill":
|
||||
if n, ok := params["name"].(string); ok {
|
||||
return n
|
||||
}
|
||||
case "search_code", "SearchCode", "Glob", "glob", "Grep", "grep_search":
|
||||
if q, ok := params["query"].(string); ok {
|
||||
return q
|
||||
}
|
||||
if p, ok := params["pattern"].(string); ok {
|
||||
return p
|
||||
}
|
||||
case "save_memory":
|
||||
if f, ok := params["fact"].(string); ok {
|
||||
return f
|
||||
}
|
||||
case "ask_user":
|
||||
if qs, ok := params["questions"].([]any); ok && len(qs) > 0 {
|
||||
if q0, ok := qs[0].(map[string]any); ok {
|
||||
if question, ok := q0["question"].(string); ok {
|
||||
return question
|
||||
}
|
||||
}
|
||||
}
|
||||
case "enter_plan_mode":
|
||||
if r, ok := params["reason"].(string); ok {
|
||||
return r
|
||||
}
|
||||
case "exit_plan_mode":
|
||||
if p, ok := params["plan_path"].(string); ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: format as key: value pairs for readability
|
||||
var parts []string
|
||||
for k, v := range params {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
parts = append(parts, k+": "+val)
|
||||
default:
|
||||
j, _ := json.Marshal(val)
|
||||
parts = append(parts, k+": "+string(j))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// computeLineDiff computes a minimal unified-style diff between old and new text.
|
||||
// It finds common prefix/suffix lines and shows only the changed lines with
|
||||
// up to 1 line of surrounding context. Unchanged context lines are prefixed
|
||||
// with " ", removed lines with "- ", and added lines with "+ ".
|
||||
func computeLineDiff(old, new_ string) string {
|
||||
oldLines := strings.Split(old, "\n")
|
||||
newLines := strings.Split(new_, "\n")
|
||||
|
||||
// Find common prefix lines
|
||||
prefixLen := 0
|
||||
minLen := len(oldLines)
|
||||
if len(newLines) < minLen {
|
||||
minLen = len(newLines)
|
||||
}
|
||||
for prefixLen < minLen && oldLines[prefixLen] == newLines[prefixLen] {
|
||||
prefixLen++
|
||||
}
|
||||
|
||||
// Find common suffix lines (not overlapping with prefix)
|
||||
suffixLen := 0
|
||||
for suffixLen < len(oldLines)-prefixLen && suffixLen < len(newLines)-prefixLen {
|
||||
oi := len(oldLines) - 1 - suffixLen
|
||||
ni := len(newLines) - 1 - suffixLen
|
||||
if oldLines[oi] != newLines[ni] {
|
||||
break
|
||||
}
|
||||
suffixLen++
|
||||
}
|
||||
|
||||
// No actual changes
|
||||
if prefixLen+suffixLen >= len(oldLines) && prefixLen+suffixLen >= len(newLines) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If everything differs (no common lines), show full old/new
|
||||
if prefixLen == 0 && suffixLen == 0 {
|
||||
var sb strings.Builder
|
||||
for _, l := range oldLines {
|
||||
sb.WriteString("- " + l + "\n")
|
||||
}
|
||||
for _, l := range newLines {
|
||||
sb.WriteString("+ " + l + "\n")
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
const contextN = 1
|
||||
|
||||
// Context: tail of common prefix
|
||||
ctxStart := prefixLen - contextN
|
||||
if ctxStart < 0 {
|
||||
ctxStart = 0
|
||||
}
|
||||
if ctxStart > 0 {
|
||||
sb.WriteString(" ...\n")
|
||||
}
|
||||
for i := ctxStart; i < prefixLen; i++ {
|
||||
sb.WriteString(" " + oldLines[i] + "\n")
|
||||
}
|
||||
|
||||
// Removed lines
|
||||
for i := prefixLen; i < len(oldLines)-suffixLen; i++ {
|
||||
sb.WriteString("- " + oldLines[i] + "\n")
|
||||
}
|
||||
|
||||
// Added lines
|
||||
for i := prefixLen; i < len(newLines)-suffixLen; i++ {
|
||||
sb.WriteString("+ " + newLines[i] + "\n")
|
||||
}
|
||||
|
||||
// Context: head of common suffix
|
||||
suffStart := len(oldLines) - suffixLen
|
||||
suffEnd := suffStart + contextN
|
||||
if suffEnd > len(oldLines) {
|
||||
suffEnd = len(oldLines)
|
||||
}
|
||||
for i := suffStart; i < suffEnd; i++ {
|
||||
sb.WriteString(" " + oldLines[i] + "\n")
|
||||
}
|
||||
if suffEnd < len(oldLines) {
|
||||
sb.WriteString(" ...")
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func truncate(s string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:maxRunes]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user