mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 03:04:21 +01:00
wip: refactoring tui
This commit is contained in:
212
packages/tui/cmd/opencode/main.go
Normal file
212
packages/tui/cmd/opencode/main.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/internal/tui/app"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := "http://localhost:16713"
|
||||
httpClient, err := client.NewClientWithResponses(url)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
paths, _ := httpClient.PostPathGetWithResponse(context.Background())
|
||||
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
|
||||
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
file, err := os.Create(logfile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log file", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app, err := app.New(ctx, httpClient)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create app", "error", err)
|
||||
// return err
|
||||
}
|
||||
|
||||
// Set up the TUI
|
||||
zone.NewGlobal()
|
||||
program := tea.NewProgram(
|
||||
tui.New(app),
|
||||
tea.WithAltScreen(),
|
||||
)
|
||||
|
||||
eventClient, err := client.NewClient(url)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create event client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
evts, err := eventClient.Event(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to subscribe to events", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for item := range evts {
|
||||
program.Send(item)
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup the subscriptions, this will send services events to the TUI
|
||||
ch, cancelSubs := setupSubscriptions(app, ctx)
|
||||
|
||||
// Create a context for the TUI message handler
|
||||
tuiCtx, tuiCancel := context.WithCancel(ctx)
|
||||
var tuiWg sync.WaitGroup
|
||||
tuiWg.Add(1)
|
||||
|
||||
// Set up message handling for the TUI
|
||||
go func() {
|
||||
defer tuiWg.Done()
|
||||
// defer logging.RecoverPanic("TUI-message-handler", func() {
|
||||
// attemptTUIRecovery(program)
|
||||
// })
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tuiCtx.Done():
|
||||
slog.Info("TUI message handler shutting down")
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
slog.Info("TUI message channel closed")
|
||||
return
|
||||
}
|
||||
program.Send(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Cleanup function for when the program exits
|
||||
cleanup := func() {
|
||||
// Cancel subscriptions first
|
||||
cancelSubs()
|
||||
|
||||
// Then shutdown the app
|
||||
app.Shutdown()
|
||||
|
||||
// Then cancel TUI message handler
|
||||
tuiCancel()
|
||||
|
||||
// Wait for TUI message handler to finish
|
||||
tuiWg.Wait()
|
||||
|
||||
slog.Info("All goroutines cleaned up")
|
||||
}
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
cleanup()
|
||||
|
||||
if err != nil {
|
||||
slog.Error("TUI error", "error", err)
|
||||
// return fmt.Errorf("TUI error: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("TUI exited", "result", result)
|
||||
}
|
||||
|
||||
func setupSubscriber[T any](
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
name string,
|
||||
subscriber func(context.Context) <-chan pubsub.Event[T],
|
||||
outputCh chan<- tea.Msg,
|
||||
) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
|
||||
|
||||
subCh := subscriber(ctx)
|
||||
if subCh == nil {
|
||||
slog.Warn("subscription channel is nil", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-subCh:
|
||||
if !ok {
|
||||
slog.Info("subscription channel closed", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
var msg tea.Msg = event
|
||||
|
||||
select {
|
||||
case outputCh <- msg:
|
||||
case <-time.After(2 * time.Second):
|
||||
slog.Warn("message dropped due to slow consumer", "name", name)
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
|
||||
ch := make(chan tea.Msg, 100)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
|
||||
|
||||
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
|
||||
|
||||
cleanupFunc := func() {
|
||||
slog.Info("Cancelling all subscriptions")
|
||||
cancel() // Signal all goroutines to stop
|
||||
|
||||
waitCh := make(chan struct{})
|
||||
go func() {
|
||||
// defer logging.RecoverPanic("subscription-cleanup", nil)
|
||||
wg.Wait()
|
||||
close(waitCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
slog.Info("All subscription goroutines completed successfully")
|
||||
close(ch) // Only close after all writers are confirmed done
|
||||
case <-time.After(5 * time.Second):
|
||||
slog.Warn("Timed out waiting for some subscription goroutines to complete")
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
return ch, cleanupFunc
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/internal/tui/app"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "OpenCode",
|
||||
Short: "A terminal AI assistant for software development",
|
||||
Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
|
||||
It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
|
||||
to assist developers in writing, debugging, and understanding code directly from the terminal.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Setup logging
|
||||
// file, err := os.OpenFile("debug.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// defer file.Close()
|
||||
// logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
// slog.SetDefault(logger)
|
||||
|
||||
// Load the config
|
||||
debug, _ := cmd.Flags().GetBool("debug")
|
||||
cwd, _ := cmd.Flags().GetString("cwd")
|
||||
if cwd != "" {
|
||||
err := os.Chdir(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to change directory: %v", err)
|
||||
}
|
||||
}
|
||||
if cwd == "" {
|
||||
c, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %v", err)
|
||||
}
|
||||
cwd = c
|
||||
}
|
||||
_, err := config.Load(cwd, debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app, err := app.New(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create app", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set up the TUI
|
||||
zone.NewGlobal()
|
||||
program := tea.NewProgram(
|
||||
tui.New(app),
|
||||
tea.WithAltScreen(),
|
||||
)
|
||||
|
||||
evts, err := app.Events.Event(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to subscribe to events", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for item := range evts {
|
||||
program.Send(item)
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup the subscriptions, this will send services events to the TUI
|
||||
ch, cancelSubs := setupSubscriptions(app, ctx)
|
||||
|
||||
// Create a context for the TUI message handler
|
||||
tuiCtx, tuiCancel := context.WithCancel(ctx)
|
||||
var tuiWg sync.WaitGroup
|
||||
tuiWg.Add(1)
|
||||
|
||||
// Set up message handling for the TUI
|
||||
go func() {
|
||||
defer tuiWg.Done()
|
||||
// defer logging.RecoverPanic("TUI-message-handler", func() {
|
||||
// attemptTUIRecovery(program)
|
||||
// })
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tuiCtx.Done():
|
||||
slog.Info("TUI message handler shutting down")
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
slog.Info("TUI message channel closed")
|
||||
return
|
||||
}
|
||||
program.Send(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Cleanup function for when the program exits
|
||||
cleanup := func() {
|
||||
// Cancel subscriptions first
|
||||
cancelSubs()
|
||||
|
||||
// Then shutdown the app
|
||||
app.Shutdown()
|
||||
|
||||
// Then cancel TUI message handler
|
||||
tuiCancel()
|
||||
|
||||
// Wait for TUI message handler to finish
|
||||
tuiWg.Wait()
|
||||
|
||||
slog.Info("All goroutines cleaned up")
|
||||
}
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
cleanup()
|
||||
|
||||
if err != nil {
|
||||
slog.Error("TUI error", "error", err)
|
||||
return fmt.Errorf("TUI error: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("TUI exited", "result", result)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func setupSubscriber[T any](
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
name string,
|
||||
subscriber func(context.Context) <-chan pubsub.Event[T],
|
||||
outputCh chan<- tea.Msg,
|
||||
) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
|
||||
|
||||
subCh := subscriber(ctx)
|
||||
if subCh == nil {
|
||||
slog.Warn("subscription channel is nil", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-subCh:
|
||||
if !ok {
|
||||
slog.Info("subscription channel closed", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
var msg tea.Msg = event
|
||||
|
||||
select {
|
||||
case outputCh <- msg:
|
||||
case <-time.After(2 * time.Second):
|
||||
slog.Warn("message dropped due to slow consumer", "name", name)
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
|
||||
ch := make(chan tea.Msg, 100)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
|
||||
|
||||
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
|
||||
|
||||
cleanupFunc := func() {
|
||||
slog.Info("Cancelling all subscriptions")
|
||||
cancel() // Signal all goroutines to stop
|
||||
|
||||
waitCh := make(chan struct{})
|
||||
go func() {
|
||||
// defer logging.RecoverPanic("subscription-cleanup", nil)
|
||||
wg.Wait()
|
||||
close(waitCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
slog.Info("All subscription goroutines completed successfully")
|
||||
close(ch) // Only close after all writers are confirmed done
|
||||
case <-time.After(5 * time.Second):
|
||||
slog.Warn("Timed out waiting for some subscription goroutines to complete")
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
return ch, cleanupFunc
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().BoolP("help", "h", false, "Help")
|
||||
rootCmd.Flags().BoolP("version", "v", false, "Version")
|
||||
rootCmd.Flags().BoolP("debug", "d", false, "Debug")
|
||||
rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
|
||||
rootCmd.Flags().StringP("prompt", "p", "", "Run a single prompt in non-interactive mode")
|
||||
rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)")
|
||||
rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
|
||||
rootCmd.Flags().BoolP("verbose", "", false, "Display logs to stderr in non-interactive mode")
|
||||
rootCmd.Flags().StringSlice("allowedTools", nil, "Restrict the agent to only use the specified tools in non-interactive mode (comma-separated list)")
|
||||
rootCmd.Flags().StringSlice("excludedTools", nil, "Prevent the agent from using the specified tools in non-interactive mode (comma-separated list)")
|
||||
|
||||
// Make allowedTools and excludedTools mutually exclusive
|
||||
rootCmd.MarkFlagsMutuallyExclusive("allowedTools", "excludedTools")
|
||||
|
||||
// Make quiet and verbose mutually exclusive
|
||||
rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose")
|
||||
}
|
||||
@@ -4,7 +4,6 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.15.0
|
||||
github.com/aymanbagabas/go-udiff v0.2.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/catppuccin/go v0.3.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -69,27 +67,6 @@ type linePair struct {
|
||||
right *DiffLine
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/sst/opencode/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// OpenCodeTheme implements the Theme interface with OpenCode brand colors.
|
||||
// It provides both dark and light variants.
|
||||
type OpenCodeTheme struct {
|
||||
BaseTheme
|
||||
}
|
||||
|
||||
// NewOpenCodeTheme creates a new instance of the OpenCode theme.
|
||||
func NewOpenCodeTheme() *OpenCodeTheme {
|
||||
// OpenCode color palette
|
||||
// Dark mode colors
|
||||
darkBackground := "#212121"
|
||||
darkCurrentLine := "#252525"
|
||||
darkSelection := "#303030"
|
||||
darkForeground := "#e0e0e0"
|
||||
darkComment := "#6a6a6a"
|
||||
darkPrimary := "#fab283" // Primary orange/gold
|
||||
darkSecondary := "#5c9cf5" // Secondary blue
|
||||
darkAccent := "#9d7cd8" // Accent purple
|
||||
darkRed := "#e06c75" // Error red
|
||||
darkOrange := "#f5a742" // Warning orange
|
||||
darkGreen := "#7fd88f" // Success green
|
||||
darkCyan := "#56b6c2" // Info cyan
|
||||
darkYellow := "#e5c07b" // Emphasized text
|
||||
darkBorder := "#4b4c5c" // Border color
|
||||
|
||||
// Light mode colors
|
||||
lightBackground := "#f8f8f8"
|
||||
lightCurrentLine := "#f0f0f0"
|
||||
lightSelection := "#e5e5e6"
|
||||
lightForeground := "#2a2a2a"
|
||||
lightComment := "#8a8a8a"
|
||||
lightPrimary := "#3b7dd8" // Primary blue
|
||||
lightSecondary := "#7b5bb6" // Secondary purple
|
||||
lightAccent := "#d68c27" // Accent orange/gold
|
||||
lightRed := "#d1383d" // Error red
|
||||
lightOrange := "#d68c27" // Warning orange
|
||||
lightGreen := "#3d9a57" // Success green
|
||||
lightCyan := "#318795" // Info cyan
|
||||
lightYellow := "#b0851f" // Emphasized text
|
||||
lightBorder := "#d3d3d3" // Border color
|
||||
|
||||
theme := &OpenCodeTheme{}
|
||||
|
||||
// Base colors
|
||||
theme.PrimaryColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkPrimary,
|
||||
Light: lightPrimary,
|
||||
}
|
||||
theme.SecondaryColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkSecondary,
|
||||
Light: lightSecondary,
|
||||
}
|
||||
theme.AccentColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkAccent,
|
||||
Light: lightAccent,
|
||||
}
|
||||
|
||||
// Status colors
|
||||
theme.ErrorColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkRed,
|
||||
Light: lightRed,
|
||||
}
|
||||
theme.WarningColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkOrange,
|
||||
Light: lightOrange,
|
||||
}
|
||||
theme.SuccessColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkGreen,
|
||||
Light: lightGreen,
|
||||
}
|
||||
theme.InfoColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkCyan,
|
||||
Light: lightCyan,
|
||||
}
|
||||
|
||||
// Text colors
|
||||
theme.TextColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkForeground,
|
||||
Light: lightForeground,
|
||||
}
|
||||
theme.TextMutedColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkComment,
|
||||
Light: lightComment,
|
||||
}
|
||||
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkYellow,
|
||||
Light: lightYellow,
|
||||
}
|
||||
|
||||
// Background colors
|
||||
theme.BackgroundColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkBackground,
|
||||
Light: lightBackground,
|
||||
}
|
||||
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkCurrentLine,
|
||||
Light: lightCurrentLine,
|
||||
}
|
||||
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#121212", // Slightly darker than background
|
||||
Light: "#ffffff", // Slightly lighter than background
|
||||
}
|
||||
|
||||
// Border colors
|
||||
theme.BorderNormalColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkBorder,
|
||||
Light: lightBorder,
|
||||
}
|
||||
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkPrimary,
|
||||
Light: lightPrimary,
|
||||
}
|
||||
theme.BorderDimColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkSelection,
|
||||
Light: lightSelection,
|
||||
}
|
||||
|
||||
// Diff view colors
|
||||
theme.DiffAddedColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#478247",
|
||||
Light: "#2E7D32",
|
||||
}
|
||||
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#7C4444",
|
||||
Light: "#C62828",
|
||||
}
|
||||
theme.DiffContextColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#a0a0a0",
|
||||
Light: "#757575",
|
||||
}
|
||||
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#a0a0a0",
|
||||
Light: "#757575",
|
||||
}
|
||||
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#DAFADA",
|
||||
Light: "#A5D6A7",
|
||||
}
|
||||
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#FADADD",
|
||||
Light: "#EF9A9A",
|
||||
}
|
||||
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#303A30",
|
||||
Light: "#E8F5E9",
|
||||
}
|
||||
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#3A3030",
|
||||
Light: "#FFEBEE",
|
||||
}
|
||||
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkBackground,
|
||||
Light: lightBackground,
|
||||
}
|
||||
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#888888",
|
||||
Light: "#9E9E9E",
|
||||
}
|
||||
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#293229",
|
||||
Light: "#C8E6C9",
|
||||
}
|
||||
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
|
||||
Dark: "#332929",
|
||||
Light: "#FFCDD2",
|
||||
}
|
||||
|
||||
// Markdown colors
|
||||
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkForeground,
|
||||
Light: lightForeground,
|
||||
}
|
||||
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkSecondary,
|
||||
Light: lightSecondary,
|
||||
}
|
||||
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkPrimary,
|
||||
Light: lightPrimary,
|
||||
}
|
||||
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkCyan,
|
||||
Light: lightCyan,
|
||||
}
|
||||
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkGreen,
|
||||
Light: lightGreen,
|
||||
}
|
||||
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkYellow,
|
||||
Light: lightYellow,
|
||||
}
|
||||
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkYellow,
|
||||
Light: lightYellow,
|
||||
}
|
||||
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkAccent,
|
||||
Light: lightAccent,
|
||||
}
|
||||
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkComment,
|
||||
Light: lightComment,
|
||||
}
|
||||
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkPrimary,
|
||||
Light: lightPrimary,
|
||||
}
|
||||
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkCyan,
|
||||
Light: lightCyan,
|
||||
}
|
||||
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkPrimary,
|
||||
Light: lightPrimary,
|
||||
}
|
||||
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkCyan,
|
||||
Light: lightCyan,
|
||||
}
|
||||
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkForeground,
|
||||
Light: lightForeground,
|
||||
}
|
||||
|
||||
// Syntax highlighting colors
|
||||
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkComment,
|
||||
Light: lightComment,
|
||||
}
|
||||
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkSecondary,
|
||||
Light: lightSecondary,
|
||||
}
|
||||
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkPrimary,
|
||||
Light: lightPrimary,
|
||||
}
|
||||
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkRed,
|
||||
Light: lightRed,
|
||||
}
|
||||
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkGreen,
|
||||
Light: lightGreen,
|
||||
}
|
||||
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkAccent,
|
||||
Light: lightAccent,
|
||||
}
|
||||
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkYellow,
|
||||
Light: lightYellow,
|
||||
}
|
||||
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkCyan,
|
||||
Light: lightCyan,
|
||||
}
|
||||
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
|
||||
Dark: darkForeground,
|
||||
Light: lightForeground,
|
||||
}
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the OpenCode theme with the theme manager
|
||||
RegisterTheme("opencode", NewOpenCodeTheme())
|
||||
}
|
||||
Reference in New Issue
Block a user