diff options
| author | adamdottv <[email protected]> | 2025-06-03 12:44:46 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-06-03 12:45:28 -0500 |
| commit | b00326a75a7449f43be6790dfcb08fc970c044cd (patch) | |
| tree | f1dc42a0f4783becf58b625ac968f5608761c613 /packages/tui/internal | |
| parent | 4cf0aebb2e74d1148090fb8f0b8c3c6c22931e15 (diff) | |
| download | opencode-b00326a75a7449f43be6790dfcb08fc970c044cd.tar.gz opencode-b00326a75a7449f43be6790dfcb08fc970c044cd.zip | |
wip: refactoring tui
Diffstat (limited to 'packages/tui/internal')
| -rw-r--r-- | packages/tui/internal/diff/diff_test.go | 103 | ||||
| -rw-r--r-- | packages/tui/internal/diff/patch.go | 740 | ||||
| -rw-r--r-- | packages/tui/internal/pubsub/broker_test.go | 144 | ||||
| -rw-r--r-- | packages/tui/internal/tui/app/app.go | 20 | ||||
| -rw-r--r-- | packages/tui/internal/tui/components/chat/message.go | 20 | ||||
| -rw-r--r-- | packages/tui/internal/tui/components/diff/diff.go (renamed from packages/tui/internal/diff/diff.go) | 51 |
6 files changed, 12 insertions, 1066 deletions
diff --git a/packages/tui/internal/diff/diff_test.go b/packages/tui/internal/diff/diff_test.go deleted file mode 100644 index 4c014e45c..000000000 --- a/packages/tui/internal/diff/diff_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package diff - -import ( - "fmt" - "testing" - - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" -) - -// TestApplyHighlighting tests the applyHighlighting function with various ANSI sequences -func TestApplyHighlighting(t *testing.T) { - t.Parallel() - - // Mock theme colors for testing - mockHighlightBg := lipgloss.AdaptiveColor{ - Dark: "#FF0000", // Red background for highlighting - Light: "#FF0000", - } - - // Test cases - tests := []struct { - name string - content string - segments []Segment - segmentType LineType - expectContains string - }{ - { - name: "Simple text with no ANSI", - content: "This is a test", - segments: []Segment{{Start: 0, End: 4, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - { - name: "Text with existing ANSI foreground", - content: "This \x1b[32mis\x1b[0m a test", // "is" in green - segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - { - name: "Text with existing ANSI background", - content: "This \x1b[42mis\x1b[0m a test", // "is" with green background - segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - { - name: "Text with complex ANSI styling", - content: "This \x1b[1;32;45mis\x1b[0m a test", // "is" bold green on magenta - segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - } - - for _, tc := range tests { - tc := tc // Capture range variable for parallel testing - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg) - - // Verify the result contains the expected sequence - assert.Contains(t, result, tc.expectContains, - "Result should contain full reset sequence") - - // Print the result for manual inspection if needed - if t.Failed() { - fmt.Printf("Original: %q\nResult: %q\n", tc.content, result) - } - }) - } -} - -// TestApplyHighlightingWithMultipleSegments tests highlighting multiple segments -func TestApplyHighlightingWithMultipleSegments(t *testing.T) { - t.Parallel() - - // Mock theme colors for testing - mockHighlightBg := lipgloss.AdaptiveColor{ - Dark: "#FF0000", // Red background for highlighting - Light: "#FF0000", - } - - content := "This is a test with multiple segments to highlight" - segments := []Segment{ - {Start: 0, End: 4, Type: LineAdded}, // "This" - {Start: 8, End: 9, Type: LineAdded}, // "a" - {Start: 15, End: 23, Type: LineAdded}, // "multiple" - } - - result := applyHighlighting(content, segments, LineAdded, mockHighlightBg) - - // Verify the result contains the full reset sequence - assert.Contains(t, result, "\x1b[0m", - "Result should contain full reset sequence") -}
\ No newline at end of file diff --git a/packages/tui/internal/diff/patch.go b/packages/tui/internal/diff/patch.go deleted file mode 100644 index 49242f7ef..000000000 --- a/packages/tui/internal/diff/patch.go +++ /dev/null @@ -1,740 +0,0 @@ -package diff - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" -) - -type ActionType string - -const ( - ActionAdd ActionType = "add" - ActionDelete ActionType = "delete" - ActionUpdate ActionType = "update" -) - -type FileChange struct { - Type ActionType - OldContent *string - NewContent *string - MovePath *string -} - -type Commit struct { - Changes map[string]FileChange -} - -type Chunk struct { - OrigIndex int // line index of the first line in the original file - DelLines []string // lines to delete - InsLines []string // lines to insert -} - -type PatchAction struct { - Type ActionType - NewFile *string - Chunks []Chunk - MovePath *string -} - -type Patch struct { - Actions map[string]PatchAction -} - -type DiffError struct { - message string -} - -func (e DiffError) Error() string { - return e.message -} - -// Helper functions for error handling -func NewDiffError(message string) DiffError { - return DiffError{message: message} -} - -func fileError(action, reason, path string) DiffError { - return NewDiffError(fmt.Sprintf("%s File Error: %s: %s", action, reason, path)) -} - -func contextError(index int, context string, isEOF bool) DiffError { - prefix := "Invalid Context" - if isEOF { - prefix = "Invalid EOF Context" - } - return NewDiffError(fmt.Sprintf("%s %d:\n%s", prefix, index, context)) -} - -type Parser struct { - currentFiles map[string]string - lines []string - index int - patch Patch - fuzz int -} - -func NewParser(currentFiles map[string]string, lines []string) *Parser { - return &Parser{ - currentFiles: currentFiles, - lines: lines, - index: 0, - patch: Patch{Actions: make(map[string]PatchAction, len(currentFiles))}, - fuzz: 0, - } -} - -func (p *Parser) isDone(prefixes []string) bool { - if p.index >= len(p.lines) { - return true - } - for _, prefix := range prefixes { - if strings.HasPrefix(p.lines[p.index], prefix) { - return true - } - } - return false -} - -func (p *Parser) startsWith(prefix any) bool { - var prefixes []string - switch v := prefix.(type) { - case string: - prefixes = []string{v} - case []string: - prefixes = v - } - - for _, pfx := range prefixes { - if strings.HasPrefix(p.lines[p.index], pfx) { - return true - } - } - return false -} - -func (p *Parser) readStr(prefix string, returnEverything bool) string { - if p.index >= len(p.lines) { - return "" // Changed from panic to return empty string for safer operation - } - if strings.HasPrefix(p.lines[p.index], prefix) { - var text string - if returnEverything { - text = p.lines[p.index] - } else { - text = p.lines[p.index][len(prefix):] - } - p.index++ - return text - } - return "" -} - -func (p *Parser) Parse() error { - endPatchPrefixes := []string{"*** End Patch"} - - for !p.isDone(endPatchPrefixes) { - path := p.readStr("*** Update File: ", false) - if path != "" { - if _, exists := p.patch.Actions[path]; exists { - return fileError("Update", "Duplicate Path", path) - } - moveTo := p.readStr("*** Move to: ", false) - if _, exists := p.currentFiles[path]; !exists { - return fileError("Update", "Missing File", path) - } - text := p.currentFiles[path] - action, err := p.parseUpdateFile(text) - if err != nil { - return err - } - if moveTo != "" { - action.MovePath = &moveTo - } - p.patch.Actions[path] = action - continue - } - - path = p.readStr("*** Delete File: ", false) - if path != "" { - if _, exists := p.patch.Actions[path]; exists { - return fileError("Delete", "Duplicate Path", path) - } - if _, exists := p.currentFiles[path]; !exists { - return fileError("Delete", "Missing File", path) - } - p.patch.Actions[path] = PatchAction{Type: ActionDelete, Chunks: []Chunk{}} - continue - } - - path = p.readStr("*** Add File: ", false) - if path != "" { - if _, exists := p.patch.Actions[path]; exists { - return fileError("Add", "Duplicate Path", path) - } - if _, exists := p.currentFiles[path]; exists { - return fileError("Add", "File already exists", path) - } - action, err := p.parseAddFile() - if err != nil { - return err - } - p.patch.Actions[path] = action - continue - } - - return NewDiffError(fmt.Sprintf("Unknown Line: %s", p.lines[p.index])) - } - - if !p.startsWith("*** End Patch") { - return NewDiffError("Missing End Patch") - } - p.index++ - - return nil -} - -func (p *Parser) parseUpdateFile(text string) (PatchAction, error) { - action := PatchAction{Type: ActionUpdate, Chunks: []Chunk{}} - fileLines := strings.Split(text, "\n") - index := 0 - - endPrefixes := []string{ - "*** End Patch", - "*** Update File:", - "*** Delete File:", - "*** Add File:", - "*** End of File", - } - - for !p.isDone(endPrefixes) { - defStr := p.readStr("@@ ", false) - sectionStr := "" - if defStr == "" && p.index < len(p.lines) && p.lines[p.index] == "@@" { - sectionStr = p.lines[p.index] - p.index++ - } - if defStr == "" && sectionStr == "" && index != 0 { - return action, NewDiffError(fmt.Sprintf("Invalid Line:\n%s", p.lines[p.index])) - } - if strings.TrimSpace(defStr) != "" { - found := false - for i := range fileLines[:index] { - if fileLines[i] == defStr { - found = true - break - } - } - - if !found { - for i := index; i < len(fileLines); i++ { - if fileLines[i] == defStr { - index = i + 1 - found = true - break - } - } - } - - if !found { - for i := range fileLines[:index] { - if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) { - found = true - break - } - } - } - - if !found { - for i := index; i < len(fileLines); i++ { - if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) { - index = i + 1 - p.fuzz++ - found = true - break - } - } - } - } - - nextChunkContext, chunks, endPatchIndex, eof := peekNextSection(p.lines, p.index) - newIndex, fuzz := findContext(fileLines, nextChunkContext, index, eof) - if newIndex == -1 { - ctxText := strings.Join(nextChunkContext, "\n") - return action, contextError(index, ctxText, eof) - } - p.fuzz += fuzz - - for _, ch := range chunks { - ch.OrigIndex += newIndex - action.Chunks = append(action.Chunks, ch) - } - index = newIndex + len(nextChunkContext) - p.index = endPatchIndex - } - return action, nil -} - -func (p *Parser) parseAddFile() (PatchAction, error) { - lines := make([]string, 0, 16) // Preallocate space for better performance - endPrefixes := []string{ - "*** End Patch", - "*** Update File:", - "*** Delete File:", - "*** Add File:", - } - - for !p.isDone(endPrefixes) { - s := p.readStr("", true) - if !strings.HasPrefix(s, "+") { - return PatchAction{}, NewDiffError(fmt.Sprintf("Invalid Add File Line: %s", s)) - } - lines = append(lines, s[1:]) - } - - newFile := strings.Join(lines, "\n") - return PatchAction{ - Type: ActionAdd, - NewFile: &newFile, - Chunks: []Chunk{}, - }, nil -} - -// Refactored to use a matcher function for each comparison type -func findContextCore(lines []string, context []string, start int) (int, int) { - if len(context) == 0 { - return start, 0 - } - - // Try exact match - if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool { - return a == b - }); idx >= 0 { - return idx, fuzz - } - - // Try trimming right whitespace - if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool { - return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t") - }); idx >= 0 { - return idx, fuzz - } - - // Try trimming all whitespace - if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool { - return strings.TrimSpace(a) == strings.TrimSpace(b) - }); idx >= 0 { - return idx, fuzz - } - - return -1, 0 -} - -// Helper function to DRY up the match logic -func tryFindMatch(lines []string, context []string, start int, - compareFunc func(string, string) bool, -) (int, int) { - for i := start; i < len(lines); i++ { - if i+len(context) <= len(lines) { - match := true - for j := range context { - if !compareFunc(lines[i+j], context[j]) { - match = false - break - } - } - if match { - // Return fuzz level: 0 for exact, 1 for trimRight, 100 for trimSpace - var fuzz int - if compareFunc("a ", "a") && !compareFunc("a", "b") { - fuzz = 1 - } else if compareFunc("a ", "a") { - fuzz = 100 - } - return i, fuzz - } - } - } - return -1, 0 -} - -func findContext(lines []string, context []string, start int, eof bool) (int, int) { - if eof { - newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context)) - if newIndex != -1 { - return newIndex, fuzz - } - newIndex, fuzz = findContextCore(lines, context, start) - return newIndex, fuzz + 10000 - } - return findContextCore(lines, context, start) -} - -func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool) { - index := initialIndex - old := make([]string, 0, 32) // Preallocate for better performance - delLines := make([]string, 0, 8) - insLines := make([]string, 0, 8) - chunks := make([]Chunk, 0, 4) - mode := "keep" - - // End conditions for the section - endSectionConditions := func(s string) bool { - return strings.HasPrefix(s, "@@") || - strings.HasPrefix(s, "*** End Patch") || - strings.HasPrefix(s, "*** Update File:") || - strings.HasPrefix(s, "*** Delete File:") || - strings.HasPrefix(s, "*** Add File:") || - strings.HasPrefix(s, "*** End of File") || - s == "***" || - strings.HasPrefix(s, "***") - } - - for index < len(lines) { - s := lines[index] - if endSectionConditions(s) { - break - } - index++ - lastMode := mode - line := s - - if len(line) > 0 { - switch line[0] { - case '+': - mode = "add" - case '-': - mode = "delete" - case ' ': - mode = "keep" - default: - mode = "keep" - line = " " + line - } - } else { - mode = "keep" - line = " " - } - - line = line[1:] - if mode == "keep" && lastMode != mode { - if len(insLines) > 0 || len(delLines) > 0 { - chunks = append(chunks, Chunk{ - OrigIndex: len(old) - len(delLines), - DelLines: delLines, - InsLines: insLines, - }) - } - delLines = make([]string, 0, 8) - insLines = make([]string, 0, 8) - } - switch mode { - case "delete": - delLines = append(delLines, line) - old = append(old, line) - case "add": - insLines = append(insLines, line) - default: - old = append(old, line) - } - } - - if len(insLines) > 0 || len(delLines) > 0 { - chunks = append(chunks, Chunk{ - OrigIndex: len(old) - len(delLines), - DelLines: delLines, - InsLines: insLines, - }) - } - - if index < len(lines) && lines[index] == "*** End of File" { - index++ - return old, chunks, index, true - } - return old, chunks, index, false -} - -func TextToPatch(text string, orig map[string]string) (Patch, int, error) { - text = strings.TrimSpace(text) - lines := strings.Split(text, "\n") - if len(lines) < 2 || !strings.HasPrefix(lines[0], "*** Begin Patch") || lines[len(lines)-1] != "*** End Patch" { - return Patch{}, 0, NewDiffError("Invalid patch text") - } - parser := NewParser(orig, lines) - parser.index = 1 - if err := parser.Parse(); err != nil { - return Patch{}, 0, err - } - return parser.patch, parser.fuzz, nil -} - -func IdentifyFilesNeeded(text string) []string { - text = strings.TrimSpace(text) - lines := strings.Split(text, "\n") - result := make(map[string]bool) - - for _, line := range lines { - if strings.HasPrefix(line, "*** Update File: ") { - result[line[len("*** Update File: "):]] = true - } - if strings.HasPrefix(line, "*** Delete File: ") { - result[line[len("*** Delete File: "):]] = true - } - } - - files := make([]string, 0, len(result)) - for file := range result { - files = append(files, file) - } - return files -} - -func IdentifyFilesAdded(text string) []string { - text = strings.TrimSpace(text) - lines := strings.Split(text, "\n") - result := make(map[string]bool) - - for _, line := range lines { - if strings.HasPrefix(line, "*** Add File: ") { - result[line[len("*** Add File: "):]] = true - } - } - - files := make([]string, 0, len(result)) - for file := range result { - files = append(files, file) - } - return files -} - -func getUpdatedFile(text string, action PatchAction, path string) (string, error) { - if action.Type != ActionUpdate { - return "", errors.New("expected UPDATE action") - } - origLines := strings.Split(text, "\n") - destLines := make([]string, 0, len(origLines)) // Preallocate with capacity - origIndex := 0 - - for _, chunk := range action.Chunks { - if chunk.OrigIndex > len(origLines) { - return "", NewDiffError(fmt.Sprintf("%s: chunk.orig_index %d > len(lines) %d", path, chunk.OrigIndex, len(origLines))) - } - if origIndex > chunk.OrigIndex { - return "", NewDiffError(fmt.Sprintf("%s: orig_index %d > chunk.orig_index %d", path, origIndex, chunk.OrigIndex)) - } - destLines = append(destLines, origLines[origIndex:chunk.OrigIndex]...) - delta := chunk.OrigIndex - origIndex - origIndex += delta - - if len(chunk.InsLines) > 0 { - destLines = append(destLines, chunk.InsLines...) - } - origIndex += len(chunk.DelLines) - } - - destLines = append(destLines, origLines[origIndex:]...) - return strings.Join(destLines, "\n"), nil -} - -func PatchToCommit(patch Patch, orig map[string]string) (Commit, error) { - commit := Commit{Changes: make(map[string]FileChange, len(patch.Actions))} - for pathKey, action := range patch.Actions { - switch action.Type { - case ActionDelete: - oldContent := orig[pathKey] - commit.Changes[pathKey] = FileChange{ - Type: ActionDelete, - OldContent: &oldContent, - } - case ActionAdd: - commit.Changes[pathKey] = FileChange{ - Type: ActionAdd, - NewContent: action.NewFile, - } - case ActionUpdate: - newContent, err := getUpdatedFile(orig[pathKey], action, pathKey) - if err != nil { - return Commit{}, err - } - oldContent := orig[pathKey] - fileChange := FileChange{ - Type: ActionUpdate, - OldContent: &oldContent, - NewContent: &newContent, - } - if action.MovePath != nil { - fileChange.MovePath = action.MovePath - } - commit.Changes[pathKey] = fileChange - } - } - return commit, nil -} - -func AssembleChanges(orig map[string]string, updatedFiles map[string]string) Commit { - commit := Commit{Changes: make(map[string]FileChange, len(updatedFiles))} - for p, newContent := range updatedFiles { - oldContent, exists := orig[p] - if exists && oldContent == newContent { - continue - } - - if exists && newContent != "" { - commit.Changes[p] = FileChange{ - Type: ActionUpdate, - OldContent: &oldContent, - NewContent: &newContent, - } - } else if newContent != "" { - commit.Changes[p] = FileChange{ - Type: ActionAdd, - NewContent: &newContent, - } - } else if exists { - commit.Changes[p] = FileChange{ - Type: ActionDelete, - OldContent: &oldContent, - } - } else { - return commit // Changed from panic to simply return current commit - } - } - return commit -} - -func LoadFiles(paths []string, openFn func(string) (string, error)) (map[string]string, error) { - orig := make(map[string]string, len(paths)) - for _, p := range paths { - content, err := openFn(p) - if err != nil { - return nil, fileError("Open", "File not found", p) - } - orig[p] = content - } - return orig, nil -} - -func ApplyCommit(commit Commit, writeFn func(string, string) error, removeFn func(string) error) error { - for p, change := range commit.Changes { - switch change.Type { - case ActionDelete: - if err := removeFn(p); err != nil { - return err - } - case ActionAdd: - if change.NewContent == nil { - return NewDiffError(fmt.Sprintf("Add action for %s has nil new_content", p)) - } - if err := writeFn(p, *change.NewContent); err != nil { - return err - } - case ActionUpdate: - if change.NewContent == nil { - return NewDiffError(fmt.Sprintf("Update action for %s has nil new_content", p)) - } - if change.MovePath != nil { - if err := writeFn(*change.MovePath, *change.NewContent); err != nil { - return err - } - if err := removeFn(p); err != nil { - return err - } - } else { - if err := writeFn(p, *change.NewContent); err != nil { - return err - } - } - } - } - return nil -} - -func ProcessPatch(text string, openFn func(string) (string, error), writeFn func(string, string) error, removeFn func(string) error) (string, error) { - if !strings.HasPrefix(text, "*** Begin Patch") { - return "", NewDiffError("Patch must start with *** Begin Patch") - } - paths := IdentifyFilesNeeded(text) - orig, err := LoadFiles(paths, openFn) - if err != nil { - return "", err - } - - patch, fuzz, err := TextToPatch(text, orig) - if err != nil { - return "", err - } - - if fuzz > 0 { - return "", NewDiffError(fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz)) - } - - commit, err := PatchToCommit(patch, orig) - if err != nil { - return "", err - } - - if err := ApplyCommit(commit, writeFn, removeFn); err != nil { - return "", err - } - - return "Patch applied successfully", nil -} - -func OpenFile(p string) (string, error) { - data, err := os.ReadFile(p) - if err != nil { - return "", err - } - return string(data), nil -} - -func WriteFile(p string, content string) error { - if filepath.IsAbs(p) { - return NewDiffError("We do not support absolute paths.") - } - - dir := filepath.Dir(p) - if dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - } - - return os.WriteFile(p, []byte(content), 0o644) -} - -func RemoveFile(p string) error { - return os.Remove(p) -} - -func ValidatePatch(patchText string, files map[string]string) (bool, string, error) { - if !strings.HasPrefix(patchText, "*** Begin Patch") { - return false, "Patch must start with *** Begin Patch", nil - } - - neededFiles := IdentifyFilesNeeded(patchText) - for _, filePath := range neededFiles { - if _, exists := files[filePath]; !exists { - return false, fmt.Sprintf("File not found: %s", filePath), nil - } - } - - patch, fuzz, err := TextToPatch(patchText, files) - if err != nil { - return false, err.Error(), nil - } - - if fuzz > 0 { - return false, fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz), nil - } - - _, err = PatchToCommit(patch, files) - if err != nil { - return false, err.Error(), nil - } - - return true, "Patch is valid", nil -} diff --git a/packages/tui/internal/pubsub/broker_test.go b/packages/tui/internal/pubsub/broker_test.go deleted file mode 100644 index b4caa98f3..000000000 --- a/packages/tui/internal/pubsub/broker_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package pubsub - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestBrokerSubscribe(t *testing.T) { - t.Parallel() - - t.Run("with cancellable context", func(t *testing.T) { - t.Parallel() - broker := NewBroker[string]() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ch := broker.Subscribe(ctx) - assert.NotNil(t, ch) - assert.Equal(t, 1, broker.GetSubscriberCount()) - - // Cancel the context should remove the subscription - cancel() - time.Sleep(10 * time.Millisecond) // Give time for goroutine to process - assert.Equal(t, 0, broker.GetSubscriberCount()) - }) - - t.Run("with background context", func(t *testing.T) { - t.Parallel() - broker := NewBroker[string]() - - // Using context.Background() should not leak goroutines - ch := broker.Subscribe(context.Background()) - assert.NotNil(t, ch) - assert.Equal(t, 1, broker.GetSubscriberCount()) - - // Shutdown should clean up all subscriptions - broker.Shutdown() - assert.Equal(t, 0, broker.GetSubscriberCount()) - }) -} - -func TestBrokerPublish(t *testing.T) { - t.Parallel() - broker := NewBroker[string]() - ctx := t.Context() - - ch := broker.Subscribe(ctx) - - // Publish a message - broker.Publish(EventTypeCreated, "test message") - - // Verify message is received - select { - case event := <-ch: - assert.Equal(t, EventTypeCreated, event.Type) - assert.Equal(t, "test message", event.Payload) - case <-time.After(100 * time.Millisecond): - t.Fatal("timeout waiting for message") - } -} - -func TestBrokerShutdown(t *testing.T) { - t.Parallel() - broker := NewBroker[string]() - - // Create multiple subscribers - ch1 := broker.Subscribe(context.Background()) - ch2 := broker.Subscribe(context.Background()) - - assert.Equal(t, 2, broker.GetSubscriberCount()) - - // Shutdown should close all channels and clean up - broker.Shutdown() - - // Verify channels are closed - _, ok1 := <-ch1 - _, ok2 := <-ch2 - assert.False(t, ok1, "channel 1 should be closed") - assert.False(t, ok2, "channel 2 should be closed") - - // Verify subscriber count is reset - assert.Equal(t, 0, broker.GetSubscriberCount()) -} - -func TestBrokerConcurrency(t *testing.T) { - t.Parallel() - broker := NewBroker[int]() - - // Create a large number of subscribers - const numSubscribers = 100 - var wg sync.WaitGroup - wg.Add(numSubscribers) - - // Create a channel to collect received events - receivedEvents := make(chan int, numSubscribers) - - for i := range numSubscribers { - go func(id int) { - defer wg.Done() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ch := broker.Subscribe(ctx) - - // Receive one message then cancel - select { - case event := <-ch: - receivedEvents <- event.Payload - case <-time.After(1 * time.Second): - t.Errorf("timeout waiting for message %d", id) - } - cancel() - }(i) - } - - // Give subscribers time to set up - time.Sleep(10 * time.Millisecond) - - // Publish messages to all subscribers - for i := range numSubscribers { - broker.Publish(EventTypeCreated, i) - } - - // Wait for all subscribers to finish - wg.Wait() - close(receivedEvents) - - // Give time for cleanup goroutines to run - time.Sleep(10 * time.Millisecond) - - // Verify all subscribers are cleaned up - assert.Equal(t, 0, broker.GetSubscriberCount()) - - // Verify we received the expected number of events - count := 0 - for range receivedEvents { - count++ - } - assert.Equal(t, numSubscribers, count) -} diff --git a/packages/tui/internal/tui/app/app.go b/packages/tui/internal/tui/app/app.go index 2e89971f8..83c64517e 100644 --- a/packages/tui/internal/tui/app/app.go +++ b/packages/tui/internal/tui/app/app.go @@ -25,7 +25,6 @@ type App struct { Root string `json:"root"` } Client *client.ClientWithResponses - Events *client.Client Provider *client.ProviderInfo Model *client.ProviderModel Session *client.SessionInfo @@ -39,39 +38,22 @@ type App struct { completionDialogOpen bool } -func New(ctx context.Context) (*App, error) { - // Initialize status service (still needed for UI notifications) +func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, error) { err := status.InitService() if err != nil { slog.Error("Failed to initialize status service", "error", err) return nil, err } - // Initialize file utilities fileutil.Init() - // Create HTTP client - url := "http://localhost:16713" - httpClient, err := client.NewClientWithResponses(url) - if err != nil { - slog.Error("Failed to create client", "error", err) - return nil, err - } - eventClient, err := client.NewClient(url) - if err != nil { - slog.Error("Failed to create event client", "error", err) - return nil, err - } - paths, _ := httpClient.PostPathGetWithResponse(context.Background()) - // Create service bridges agentBridge := NewAgentServiceBridge(httpClient) app := &App{ Paths: paths.JSON200, Client: httpClient, - Events: eventClient, Session: &client.SessionInfo{}, Messages: []client.MessageInfo{}, PrimaryAgentOLD: agentBridge, diff --git a/packages/tui/internal/tui/components/chat/message.go b/packages/tui/internal/tui/components/chat/message.go index ff3fd17e0..c343ec128 100644 --- a/packages/tui/internal/tui/components/chat/message.go +++ b/packages/tui/internal/tui/components/chat/message.go @@ -9,7 +9,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/sst/opencode/internal/config" - "github.com/sst/opencode/internal/diff" + "github.com/sst/opencode/internal/tui/components/diff" "github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/theme" "github.com/sst/opencode/pkg/client" @@ -176,18 +176,20 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result body = *result } - var markdown string if toolCall.ToolName == "opencode_edit" { filename := toolMap["filePath"].(string) title = styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, filename)) - oldString := toolMap["oldString"].(string) - newString := toolMap["newString"].(string) - patch, _, _ := diff.GenerateDiff(oldString, newString, filename) - formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width)) - markdown = strings.TrimSpace(formattedDiff) + // oldString := toolMap["oldString"].(string) + // newString := toolMap["newString"].(string) + if finished { + patch := metadata["diff"].(string) + formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width)) + body = strings.TrimSpace(formattedDiff) + } + return style.Render(lipgloss.JoinVertical(lipgloss.Left, title, - markdown, + body, )) } else if toolCall.ToolName == "opencode_view" { filename := toolMap["filePath"].(string) @@ -214,7 +216,7 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result // Default rendering if finished { body = styles.Padded().Render(truncateHeight(strings.TrimSpace(body), 10)) - markdown = toMarkdown(body, width) + body = toMarkdown(body, width) } content := style.Render(lipgloss.JoinVertical(lipgloss.Left, title, diff --git a/packages/tui/internal/diff/diff.go b/packages/tui/internal/tui/components/diff/diff.go index 350db664a..5d8b98150 100644 --- a/packages/tui/internal/diff/diff.go +++ b/packages/tui/internal/tui/components/diff/diff.go @@ -12,11 +12,9 @@ import ( "github.com/alecthomas/chroma/v2/formatters" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" - "github.com/aymanbagabas/go-udiff" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/sergi/go-diff/diffmatchpatch" - "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/tui/theme" ) @@ -70,27 +68,6 @@ type linePair struct { } // ------------------------------------------------------------------------- -// Parse Configuration -// ------------------------------------------------------------------------- - -// ParseConfig configures the behavior of diff parsing -type ParseConfig struct { - ContextSize int // Number of context lines to include -} - -// ParseOption modifies a ParseConfig -type ParseOption func(*ParseConfig) - -// WithContextSize sets the number of context lines to include -func WithContextSize(size int) ParseOption { - return func(p *ParseConfig) { - if size >= 0 { - p.ContextSize = size - } - } -} - -// ------------------------------------------------------------------------- // Side-by-Side Configuration // ------------------------------------------------------------------------- @@ -839,31 +816,3 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) { return sb.String(), nil } - -// GenerateDiff creates a unified diff from two file contents -func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) { - // remove the cwd prefix and ensure consistent path format - // this prevents issues with absolute paths in different environments - cwd := config.WorkingDirectory() - fileName = strings.TrimPrefix(fileName, cwd) - fileName = strings.TrimPrefix(fileName, "/") - - edits := udiff.Strings(beforeContent, afterContent) - unified, _ := udiff.ToUnified("a/"+fileName, "b/"+fileName, beforeContent, edits, 8) - - var ( - additions = 0 - removals = 0 - ) - - lines := strings.SplitSeq(unified, "\n") - for line := range lines { - if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { - additions++ - } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { - removals++ - } - } - - return unified, additions, removals -} |
