diff.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. package hostbridge
  2. import (
  3. "context"
  4. "fmt"
  5. "io/ioutil"
  6. "log"
  7. "os"
  8. "path/filepath"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. proto "github.com/cline/grpc-go/host"
  13. )
  14. // diffSession represents an in-memory diff editing session
  15. type diffSession struct {
  16. originalPath string // File path from OpenDiff request
  17. originalContent []byte // Original file content (for comparison)
  18. currentContent []byte // Current modified content
  19. lines []string // Current content split into lines
  20. encoding string // File encoding (default: utf8)
  21. }
  22. // DiffService implements the proto.DiffServiceServer interface
  23. type DiffService struct {
  24. proto.UnimplementedDiffServiceServer
  25. verbose bool
  26. sessions *sync.Map // thread-safe: diffId -> *diffSession
  27. counter *int64 // atomic counter for unique IDs
  28. }
  29. // NewDiffService creates a new DiffService
  30. func NewDiffService(verbose bool) *DiffService {
  31. counter := int64(0)
  32. return &DiffService{
  33. verbose: verbose,
  34. sessions: &sync.Map{},
  35. counter: &counter,
  36. }
  37. }
  38. // generateDiffID creates a unique diff ID
  39. func (s *DiffService) generateDiffID() string {
  40. id := atomic.AddInt64(s.counter, 1)
  41. return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
  42. }
  43. // splitLines splits content into lines, preserving trailing newlines.
  44. // This matches the behavior of JavaScript's String.split("\n"):
  45. // - "hello\nworld\n" -> ["hello", "world", ""]
  46. // - "hello\nworld" -> ["hello", "world"]
  47. func splitLines(content string) []string {
  48. if content == "" {
  49. return []string{}
  50. }
  51. lines := []string{}
  52. current := ""
  53. for _, char := range content {
  54. if char == '\n' {
  55. lines = append(lines, current)
  56. current = ""
  57. } else if char != '\r' { // Skip \r characters, handle \r\n as \n
  58. current += string(char)
  59. }
  60. }
  61. // Always add the last segment - if content ends with newline, this will be
  62. // an empty string which preserves the trailing newline when joined back
  63. lines = append(lines, current)
  64. return lines
  65. }
  66. // joinLines joins lines back into content with newlines
  67. func joinLines(lines []string) string {
  68. if len(lines) == 0 {
  69. return ""
  70. }
  71. return strings.Join(lines, "\n")
  72. }
  73. // OpenDiff opens a diff view for the specified file
  74. func (s *DiffService) OpenDiff(ctx context.Context, req *proto.OpenDiffRequest) (*proto.OpenDiffResponse, error) {
  75. if s.verbose {
  76. log.Printf("OpenDiff called for path: %s", req.GetPath())
  77. }
  78. diffID := s.generateDiffID()
  79. var originalContent []byte
  80. // Check if file exists and read original content
  81. if req.GetPath() != "" {
  82. if _, err := os.Stat(req.GetPath()); err == nil {
  83. // File exists, read its content
  84. var readErr error
  85. originalContent, readErr = ioutil.ReadFile(req.GetPath())
  86. if readErr != nil {
  87. return nil, fmt.Errorf("failed to read original file: %w", readErr)
  88. }
  89. } else {
  90. // File doesn't exist, use empty content
  91. originalContent = []byte{}
  92. }
  93. }
  94. // Use provided content as the initial current content
  95. currentContent := []byte(req.GetContent())
  96. // Create the diff session
  97. session := &diffSession{
  98. originalPath: req.GetPath(),
  99. originalContent: originalContent,
  100. currentContent: currentContent,
  101. lines: splitLines(req.GetContent()),
  102. encoding: "utf8", // Default encoding
  103. }
  104. // Store the session
  105. s.sessions.Store(diffID, session)
  106. if s.verbose {
  107. log.Printf("Created diff session: %s (original: %d bytes, current: %d bytes)",
  108. diffID, len(originalContent), len(currentContent))
  109. }
  110. return &proto.OpenDiffResponse{
  111. DiffId: &diffID,
  112. }, nil
  113. }
  114. // GetDocumentText returns the current content of the diff document
  115. func (s *DiffService) GetDocumentText(ctx context.Context, req *proto.GetDocumentTextRequest) (*proto.GetDocumentTextResponse, error) {
  116. if s.verbose {
  117. log.Printf("GetDocumentText called for diff ID: %s", req.GetDiffId())
  118. }
  119. sessionInterface, exists := s.sessions.Load(req.GetDiffId())
  120. if !exists {
  121. return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
  122. }
  123. session := sessionInterface.(*diffSession)
  124. content := string(session.currentContent)
  125. return &proto.GetDocumentTextResponse{
  126. Content: &content,
  127. }, nil
  128. }
  129. // ReplaceText replaces text in the diff document using line-based operations
  130. func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextRequest) (*proto.ReplaceTextResponse, error) {
  131. if s.verbose {
  132. log.Printf("ReplaceText called for diff ID: %s, lines %d-%d",
  133. req.GetDiffId(), req.GetStartLine(), req.GetEndLine())
  134. }
  135. sessionInterface, exists := s.sessions.Load(req.GetDiffId())
  136. if !exists {
  137. return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
  138. }
  139. session := sessionInterface.(*diffSession)
  140. startLine := int(req.GetStartLine())
  141. endLine := int(req.GetEndLine())
  142. newContent := req.GetContent()
  143. // Validate line ranges
  144. if startLine < 0 {
  145. startLine = 0
  146. }
  147. if endLine < startLine {
  148. endLine = startLine
  149. }
  150. // Check if we're replacing to the end of the document
  151. replacingToEnd := endLine >= len(session.lines)
  152. // Split new content into lines
  153. newLines := splitLines(newContent)
  154. // Remove trailing empty line for proper splicing, BUT only when NOT replacing
  155. // to the end of the document. When replacing to the end, keep the trailing
  156. // empty string to preserve trailing newlines from the content.
  157. if !replacingToEnd && len(newLines) > 0 && newLines[len(newLines)-1] == "" {
  158. newLines = newLines[:len(newLines)-1]
  159. }
  160. // Ensure we have enough lines in the current content
  161. for len(session.lines) < endLine {
  162. session.lines = append(session.lines, "")
  163. }
  164. // Replace the specified line range
  165. if endLine > len(session.lines) {
  166. // Extending beyond current content - append new lines
  167. session.lines = append(session.lines[:startLine], newLines...)
  168. } else {
  169. // Replace within existing content
  170. result := make([]string, 0, len(session.lines)-endLine+startLine+len(newLines))
  171. result = append(result, session.lines[:startLine]...)
  172. result = append(result, newLines...)
  173. result = append(result, session.lines[endLine:]...)
  174. session.lines = result
  175. }
  176. // Update current content
  177. session.currentContent = []byte(joinLines(session.lines))
  178. // Store the updated session
  179. s.sessions.Store(req.GetDiffId(), session)
  180. if s.verbose {
  181. log.Printf("Updated diff session %s: %d lines, %d bytes",
  182. req.GetDiffId(), len(session.lines), len(session.currentContent))
  183. }
  184. return &proto.ReplaceTextResponse{}, nil
  185. }
  186. // ScrollDiff scrolls the diff view to a specific line (no-op for CLI)
  187. func (s *DiffService) ScrollDiff(ctx context.Context, req *proto.ScrollDiffRequest) (*proto.ScrollDiffResponse, error) {
  188. if s.verbose {
  189. log.Printf("ScrollDiff called for diff ID: %s, line: %d", req.GetDiffId(), req.GetLine())
  190. }
  191. // Verify session exists
  192. if _, exists := s.sessions.Load(req.GetDiffId()); !exists {
  193. return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
  194. }
  195. // In a CLI implementation, scrolling is a no-op
  196. // In a GUI implementation, this would scroll the view to the specified line
  197. return &proto.ScrollDiffResponse{}, nil
  198. }
  199. // TruncateDocument truncates the diff document at the specified line
  200. func (s *DiffService) TruncateDocument(ctx context.Context, req *proto.TruncateDocumentRequest) (*proto.TruncateDocumentResponse, error) {
  201. if s.verbose {
  202. log.Printf("TruncateDocument called for diff ID: %s, end line: %d", req.GetDiffId(), req.GetEndLine())
  203. }
  204. sessionInterface, exists := s.sessions.Load(req.GetDiffId())
  205. if !exists {
  206. return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
  207. }
  208. session := sessionInterface.(*diffSession)
  209. endLine := int(req.GetEndLine())
  210. // Truncate lines at the specified position
  211. if endLine >= 0 && endLine < len(session.lines) {
  212. session.lines = session.lines[:endLine]
  213. session.currentContent = []byte(joinLines(session.lines))
  214. // Store the updated session
  215. s.sessions.Store(req.GetDiffId(), session)
  216. if s.verbose {
  217. log.Printf("Truncated diff session %s to %d lines", req.GetDiffId(), len(session.lines))
  218. }
  219. }
  220. return &proto.TruncateDocumentResponse{}, nil
  221. }
  222. // SaveDocument saves the diff document to the original file
  223. func (s *DiffService) SaveDocument(ctx context.Context, req *proto.SaveDocumentRequest) (*proto.SaveDocumentResponse, error) {
  224. if s.verbose {
  225. log.Printf("SaveDocument called for diff ID: %s", req.GetDiffId())
  226. }
  227. sessionInterface, exists := s.sessions.Load(req.GetDiffId())
  228. if !exists {
  229. return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
  230. }
  231. session := sessionInterface.(*diffSession)
  232. if session.originalPath == "" {
  233. return nil, fmt.Errorf("no file path specified for diff session: %s", req.GetDiffId())
  234. }
  235. // Create parent directories if they don't exist
  236. dir := filepath.Dir(session.originalPath)
  237. if err := os.MkdirAll(dir, 0755); err != nil {
  238. return nil, fmt.Errorf("failed to create directories: %w", err)
  239. }
  240. // Write the current content to the original file
  241. if err := ioutil.WriteFile(session.originalPath, session.currentContent, 0644); err != nil {
  242. return nil, fmt.Errorf("failed to save file: %w", err)
  243. }
  244. if s.verbose {
  245. log.Printf("Saved diff session %s to file: %s (%d bytes)",
  246. req.GetDiffId(), session.originalPath, len(session.currentContent))
  247. }
  248. return &proto.SaveDocumentResponse{}, nil
  249. }
  250. // CloseAllDiffs closes all diff views and cleans up all sessions
  251. func (s *DiffService) CloseAllDiffs(ctx context.Context, req *proto.CloseAllDiffsRequest) (*proto.CloseAllDiffsResponse, error) {
  252. if s.verbose {
  253. log.Printf("CloseAllDiffs called")
  254. }
  255. var count int64
  256. s.sessions.Range(func(key, value any) bool {
  257. // Optional: attempt to close if the value supports it
  258. if c, ok := value.(interface{ Close() error }); ok {
  259. _ = c.Close() // best-effort; ignore error
  260. }
  261. s.sessions.Delete(key)
  262. atomic.AddInt64(&count, 1)
  263. return true
  264. })
  265. if s.verbose {
  266. log.Printf("Closed %d diff sessions", count)
  267. }
  268. return &proto.CloseAllDiffsResponse{}, nil
  269. }
  270. // OpenMultiFileDiff displays a diff view comparing before/after states for multiple files
  271. func (s *DiffService) OpenMultiFileDiff(ctx context.Context, req *proto.OpenMultiFileDiffRequest) (*proto.OpenMultiFileDiffResponse, error) {
  272. if s.verbose {
  273. log.Printf("OpenMultiFileDiff called with title: %s, %d files", req.GetTitle(), len(req.GetDiffs()))
  274. }
  275. // In a CLI implementation, we could display the diffs to console
  276. // For now, we'll just log the information
  277. title := req.GetTitle()
  278. if title == "" {
  279. title = "Multi-file diff"
  280. }
  281. if s.verbose {
  282. log.Printf("=== %s ===", title)
  283. for i, diff := range req.GetDiffs() {
  284. log.Printf("File %d: %s", i+1, diff.GetFilePath())
  285. log.Printf(" Left content: %d bytes", len(diff.GetLeftContent()))
  286. log.Printf(" Right content: %d bytes", len(diff.GetRightContent()))
  287. }
  288. }
  289. // In a more sophisticated CLI implementation, we could:
  290. // 1. Use a diff library to generate unified diffs
  291. // 2. Display them with colors
  292. // 3. Allow navigation between files
  293. // For now, this is a no-op that just acknowledges the request
  294. return &proto.OpenMultiFileDiffResponse{}, nil
  295. }