sidebar.go

  1package chat
  2
  3import (
  4	"context"
  5	"fmt"
  6	"strings"
  7
  8	tea "github.com/charmbracelet/bubbletea"
  9	"github.com/charmbracelet/lipgloss"
 10	"github.com/kujtimiihoxha/opencode/internal/config"
 11	"github.com/kujtimiihoxha/opencode/internal/diff"
 12	"github.com/kujtimiihoxha/opencode/internal/history"
 13	"github.com/kujtimiihoxha/opencode/internal/pubsub"
 14	"github.com/kujtimiihoxha/opencode/internal/session"
 15	"github.com/kujtimiihoxha/opencode/internal/tui/styles"
 16)
 17
 18type sidebarCmp struct {
 19	width, height int
 20	session       session.Session
 21	history       history.Service
 22	modFiles      map[string]struct {
 23		additions int
 24		removals  int
 25	}
 26}
 27
 28func (m *sidebarCmp) Init() tea.Cmd {
 29	if m.history != nil {
 30		ctx := context.Background()
 31		// Subscribe to file events
 32		filesCh := m.history.Subscribe(ctx)
 33
 34		// Initialize the modified files map
 35		m.modFiles = make(map[string]struct {
 36			additions int
 37			removals  int
 38		})
 39
 40		// Load initial files and calculate diffs
 41		m.loadModifiedFiles(ctx)
 42
 43		// Return a command that will send file events to the Update method
 44		return func() tea.Msg {
 45			return <-filesCh
 46		}
 47	}
 48	return nil
 49}
 50
 51func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 52	switch msg := msg.(type) {
 53	case pubsub.Event[session.Session]:
 54		if msg.Type == pubsub.UpdatedEvent {
 55			if m.session.ID == msg.Payload.ID {
 56				m.session = msg.Payload
 57			}
 58		}
 59	case pubsub.Event[history.File]:
 60		if msg.Payload.SessionID == m.session.ID {
 61			// When a file changes, reload all modified files
 62			// This ensures we have the complete and accurate list
 63			ctx := context.Background()
 64			m.loadModifiedFiles(ctx)
 65		}
 66	}
 67	return m, nil
 68}
 69
 70func (m *sidebarCmp) View() string {
 71	return styles.BaseStyle.
 72		Width(m.width).
 73		Height(m.height - 1).
 74		Render(
 75			lipgloss.JoinVertical(
 76				lipgloss.Top,
 77				header(m.width),
 78				" ",
 79				m.sessionSection(),
 80				" ",
 81				m.modifiedFiles(),
 82				" ",
 83				lspsConfigured(m.width),
 84			),
 85		)
 86}
 87
 88func (m *sidebarCmp) sessionSection() string {
 89	sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
 90	sessionValue := styles.BaseStyle.
 91		Foreground(styles.Forground).
 92		Width(m.width - lipgloss.Width(sessionKey)).
 93		Render(fmt.Sprintf(": %s", m.session.Title))
 94	return lipgloss.JoinHorizontal(
 95		lipgloss.Left,
 96		sessionKey,
 97		sessionValue,
 98	)
 99}
100
101func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
102	stats := ""
103	if additions > 0 && removals > 0 {
104		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and  %d removals", additions, removals))
105	} else if additions > 0 {
106		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
107	} else if removals > 0 {
108		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
109	}
110	filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
111
112	return styles.BaseStyle.
113		Width(m.width).
114		Render(
115			lipgloss.JoinHorizontal(
116				lipgloss.Left,
117				filePathStr,
118				stats,
119			),
120		)
121}
122
123func (m *sidebarCmp) modifiedFiles() string {
124	modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
125
126	// If no modified files, show a placeholder message
127	if m.modFiles == nil || len(m.modFiles) == 0 {
128		message := "No modified files"
129		remainingWidth := m.width - lipgloss.Width(modifiedFiles)
130		if remainingWidth > 0 {
131			message += strings.Repeat(" ", remainingWidth)
132		}
133		return styles.BaseStyle.
134			Width(m.width).
135			Render(
136				lipgloss.JoinVertical(
137					lipgloss.Top,
138					modifiedFiles,
139					styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
140				),
141			)
142	}
143
144	var fileViews []string
145	for path, stats := range m.modFiles {
146		fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
147	}
148
149	return styles.BaseStyle.
150		Width(m.width).
151		Render(
152			lipgloss.JoinVertical(
153				lipgloss.Top,
154				modifiedFiles,
155				lipgloss.JoinVertical(
156					lipgloss.Left,
157					fileViews...,
158				),
159			),
160		)
161}
162
163func (m *sidebarCmp) SetSize(width, height int) {
164	m.width = width
165	m.height = height
166}
167
168func (m *sidebarCmp) GetSize() (int, int) {
169	return m.width, m.height
170}
171
172func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
173	return &sidebarCmp{
174		session: session,
175		history: history,
176	}
177}
178
179func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
180	if m.history == nil || m.session.ID == "" {
181		return
182	}
183
184	// Get all latest files for this session
185	latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
186	if err != nil {
187		return
188	}
189
190	// Get all files for this session (to find initial versions)
191	allFiles, err := m.history.ListBySession(ctx, m.session.ID)
192	if err != nil {
193		return
194	}
195
196	// Process each latest file
197	for _, file := range latestFiles {
198		// Skip if this is the initial version (no changes to show)
199		if file.Version == history.InitialVersion {
200			continue
201		}
202
203		// Find the initial version for this specific file
204		var initialVersion history.File
205		for _, v := range allFiles {
206			if v.Path == file.Path && v.Version == history.InitialVersion {
207				initialVersion = v
208				break
209			}
210		}
211
212		// Skip if we can't find the initial version
213		if initialVersion.ID == "" {
214			continue
215		}
216
217		// Calculate diff between initial and latest version
218		_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
219
220		// Only add to modified files if there are changes
221		if additions > 0 || removals > 0 {
222			// Remove working directory prefix from file path
223			displayPath := file.Path
224			workingDir := config.WorkingDirectory()
225			displayPath = strings.TrimPrefix(displayPath, workingDir)
226			displayPath = strings.TrimPrefix(displayPath, "/")
227
228			m.modFiles[displayPath] = struct {
229				additions int
230				removals  int
231			}{
232				additions: additions,
233				removals:  removals,
234			}
235		}
236	}
237}
238
239func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
240	// Skip if not the latest version
241	if file.Version == history.InitialVersion {
242		return
243	}
244
245	// Get all versions of this file
246	fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
247	if err != nil {
248		return
249	}
250
251	// Find the initial version
252	var initialVersion history.File
253	for _, v := range fileVersions {
254		if v.Path == file.Path && v.Version == history.InitialVersion {
255			initialVersion = v
256			break
257		}
258	}
259
260	// Skip if we can't find the initial version
261	if initialVersion.ID == "" {
262		return
263	}
264
265	// Calculate diff between initial and latest version
266	_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
267
268	// Only add to modified files if there are changes
269	if additions > 0 || removals > 0 {
270		// Remove working directory prefix from file path
271		displayPath := file.Path
272		workingDir := config.WorkingDirectory()
273		displayPath = strings.TrimPrefix(displayPath, workingDir)
274		displayPath = strings.TrimPrefix(displayPath, "/")
275
276		m.modFiles[displayPath] = struct {
277			additions int
278			removals  int
279		}{
280			additions: additions,
281			removals:  removals,
282		}
283	}
284}