sidebar.go

  1package chat
  2
  3import (
  4	"context"
  5	"fmt"
  6	"sort"
  7	"strings"
  8
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	"github.com/charmbracelet/lipgloss/v2"
 11	"github.com/opencode-ai/opencode/internal/config"
 12	"github.com/opencode-ai/opencode/internal/diff"
 13	"github.com/opencode-ai/opencode/internal/history"
 14	"github.com/opencode-ai/opencode/internal/pubsub"
 15	"github.com/opencode-ai/opencode/internal/session"
 16	"github.com/opencode-ai/opencode/internal/tui/styles"
 17	"github.com/opencode-ai/opencode/internal/tui/theme"
 18	"github.com/opencode-ai/opencode/internal/tui/util"
 19)
 20
 21type sidebarCmp struct {
 22	width, height int
 23	session       session.Session
 24	history       history.Service
 25	modFiles      map[string]struct {
 26		additions int
 27		removals  int
 28	}
 29}
 30
 31func (m *sidebarCmp) Init() tea.Cmd {
 32	if m.history != nil {
 33		ctx := context.Background()
 34		// Subscribe to file events
 35		filesCh := m.history.Subscribe(ctx)
 36
 37		// Initialize the modified files map
 38		m.modFiles = make(map[string]struct {
 39			additions int
 40			removals  int
 41		})
 42
 43		// Load initial files and calculate diffs
 44		m.loadModifiedFiles(ctx)
 45
 46		// Return a command that will send file events to the Update method
 47		return func() tea.Msg {
 48			return <-filesCh
 49		}
 50	}
 51	return nil
 52}
 53
 54func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 55	switch msg := msg.(type) {
 56	case SessionSelectedMsg:
 57		if msg.ID != m.session.ID {
 58			m.session = msg
 59			ctx := context.Background()
 60			m.loadModifiedFiles(ctx)
 61		}
 62	case pubsub.Event[session.Session]:
 63		if msg.Type == pubsub.UpdatedEvent {
 64			if m.session.ID == msg.Payload.ID {
 65				m.session = msg.Payload
 66			}
 67		}
 68	case pubsub.Event[history.File]:
 69		if msg.Payload.SessionID == m.session.ID {
 70			// Process the individual file change instead of reloading all files
 71			ctx := context.Background()
 72			m.processFileChanges(ctx, msg.Payload)
 73
 74			// Return a command to continue receiving events
 75			return m, func() tea.Msg {
 76				ctx := context.Background()
 77				filesCh := m.history.Subscribe(ctx)
 78				return <-filesCh
 79			}
 80		}
 81	}
 82	return m, nil
 83}
 84
 85func (m *sidebarCmp) View() string {
 86	baseStyle := styles.BaseStyle()
 87
 88	return baseStyle.
 89		Width(m.width).
 90		PaddingLeft(4).
 91		PaddingRight(2).
 92		Height(m.height - 1).
 93		Render(
 94			lipgloss.JoinVertical(
 95				lipgloss.Top,
 96				header(m.width),
 97				" ",
 98				m.sessionSection(),
 99				" ",
100				lspsConfigured(m.width),
101				" ",
102				m.modifiedFiles(),
103			),
104		)
105}
106
107func (m *sidebarCmp) sessionSection() string {
108	t := theme.CurrentTheme()
109	baseStyle := styles.BaseStyle()
110
111	sessionKey := baseStyle.
112		Foreground(t.Primary()).
113		Bold(true).
114		Render("Session")
115
116	sessionValue := baseStyle.
117		Foreground(t.Text()).
118		Width(m.width - lipgloss.Width(sessionKey)).
119		Render(fmt.Sprintf(": %s", m.session.Title))
120
121	return lipgloss.JoinHorizontal(
122		lipgloss.Left,
123		sessionKey,
124		sessionValue,
125	)
126}
127
128func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
129	t := theme.CurrentTheme()
130	baseStyle := styles.BaseStyle()
131
132	stats := ""
133	if additions > 0 && removals > 0 {
134		additionsStr := baseStyle.
135			Foreground(t.Success()).
136			PaddingLeft(1).
137			Render(fmt.Sprintf("+%d", additions))
138
139		removalsStr := baseStyle.
140			Foreground(t.Error()).
141			PaddingLeft(1).
142			Render(fmt.Sprintf("-%d", removals))
143
144		content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
145		stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
146	} else if additions > 0 {
147		additionsStr := fmt.Sprintf(" %s", baseStyle.
148			PaddingLeft(1).
149			Foreground(t.Success()).
150			Render(fmt.Sprintf("+%d", additions)))
151		stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
152	} else if removals > 0 {
153		removalsStr := fmt.Sprintf(" %s", baseStyle.
154			PaddingLeft(1).
155			Foreground(t.Error()).
156			Render(fmt.Sprintf("-%d", removals)))
157		stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
158	}
159
160	filePathStr := baseStyle.Render(filePath)
161
162	return baseStyle.
163		Width(m.width).
164		Render(
165			lipgloss.JoinHorizontal(
166				lipgloss.Left,
167				filePathStr,
168				stats,
169			),
170		)
171}
172
173func (m *sidebarCmp) modifiedFiles() string {
174	t := theme.CurrentTheme()
175	baseStyle := styles.BaseStyle()
176
177	modifiedFiles := baseStyle.
178		Width(m.width).
179		Foreground(t.Primary()).
180		Bold(true).
181		Render("Modified Files:")
182
183	// If no modified files, show a placeholder message
184	if m.modFiles == nil || len(m.modFiles) == 0 {
185		message := "No modified files"
186		remainingWidth := m.width - lipgloss.Width(message)
187		if remainingWidth > 0 {
188			message += strings.Repeat(" ", remainingWidth)
189		}
190		return baseStyle.
191			Width(m.width).
192			Render(
193				lipgloss.JoinVertical(
194					lipgloss.Top,
195					modifiedFiles,
196					baseStyle.Foreground(t.TextMuted()).Render(message),
197				),
198			)
199	}
200
201	// Sort file paths alphabetically for consistent ordering
202	var paths []string
203	for path := range m.modFiles {
204		paths = append(paths, path)
205	}
206	sort.Strings(paths)
207
208	// Create views for each file in sorted order
209	var fileViews []string
210	for _, path := range paths {
211		stats := m.modFiles[path]
212		fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
213	}
214
215	return baseStyle.
216		Width(m.width).
217		Render(
218			lipgloss.JoinVertical(
219				lipgloss.Top,
220				modifiedFiles,
221				lipgloss.JoinVertical(
222					lipgloss.Left,
223					fileViews...,
224				),
225			),
226		)
227}
228
229func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
230	m.width = width
231	m.height = height
232	return nil
233}
234
235func (m *sidebarCmp) GetSize() (int, int) {
236	return m.width, m.height
237}
238
239func NewSidebarCmp(session session.Session, history history.Service) util.Model {
240	return &sidebarCmp{
241		session: session,
242		history: history,
243	}
244}
245
246func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
247	if m.history == nil || m.session.ID == "" {
248		return
249	}
250
251	// Get all latest files for this session
252	latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
253	if err != nil {
254		return
255	}
256
257	// Get all files for this session (to find initial versions)
258	allFiles, err := m.history.ListBySession(ctx, m.session.ID)
259	if err != nil {
260		return
261	}
262
263	// Clear the existing map to rebuild it
264	m.modFiles = make(map[string]struct {
265		additions int
266		removals  int
267	})
268
269	// Process each latest file
270	for _, file := range latestFiles {
271		// Skip if this is the initial version (no changes to show)
272		if file.Version == history.InitialVersion {
273			continue
274		}
275
276		// Find the initial version for this specific file
277		var initialVersion history.File
278		for _, v := range allFiles {
279			if v.Path == file.Path && v.Version == history.InitialVersion {
280				initialVersion = v
281				break
282			}
283		}
284
285		// Skip if we can't find the initial version
286		if initialVersion.ID == "" {
287			continue
288		}
289		if initialVersion.Content == file.Content {
290			continue
291		}
292
293		// Calculate diff between initial and latest version
294		_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
295
296		// Only add to modified files if there are changes
297		if additions > 0 || removals > 0 {
298			// Remove working directory prefix from file path
299			displayPath := file.Path
300			workingDir := config.WorkingDirectory()
301			displayPath = strings.TrimPrefix(displayPath, workingDir)
302			displayPath = strings.TrimPrefix(displayPath, "/")
303
304			m.modFiles[displayPath] = struct {
305				additions int
306				removals  int
307			}{
308				additions: additions,
309				removals:  removals,
310			}
311		}
312	}
313}
314
315func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
316	// Skip if this is the initial version (no changes to show)
317	if file.Version == history.InitialVersion {
318		return
319	}
320
321	// Find the initial version for this file
322	initialVersion, err := m.findInitialVersion(ctx, file.Path)
323	if err != nil || initialVersion.ID == "" {
324		return
325	}
326
327	// Skip if content hasn't changed
328	if initialVersion.Content == file.Content {
329		// If this file was previously modified but now matches the initial version,
330		// remove it from the modified files list
331		displayPath := getDisplayPath(file.Path)
332		delete(m.modFiles, displayPath)
333		return
334	}
335
336	// Calculate diff between initial and latest version
337	_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
338
339	// Only add to modified files if there are changes
340	if additions > 0 || removals > 0 {
341		displayPath := getDisplayPath(file.Path)
342		m.modFiles[displayPath] = struct {
343			additions int
344			removals  int
345		}{
346			additions: additions,
347			removals:  removals,
348		}
349	} else {
350		// If no changes, remove from modified files
351		displayPath := getDisplayPath(file.Path)
352		delete(m.modFiles, displayPath)
353	}
354}
355
356// Helper function to find the initial version of a file
357func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
358	// Get all versions of this file for the session
359	fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
360	if err != nil {
361		return history.File{}, err
362	}
363
364	// Find the initial version
365	for _, v := range fileVersions {
366		if v.Path == path && v.Version == history.InitialVersion {
367			return v, nil
368		}
369	}
370
371	return history.File{}, fmt.Errorf("initial version not found")
372}
373
374// Helper function to get the display path for a file
375func getDisplayPath(path string) string {
376	workingDir := config.WorkingDirectory()
377	displayPath := strings.TrimPrefix(path, workingDir)
378	return strings.TrimPrefix(displayPath, "/")
379}