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() tea.View {
 86	baseStyle := styles.BaseStyle()
 87
 88	return tea.NewView(
 89		baseStyle.
 90			Width(m.width).
 91			PaddingLeft(4).
 92			PaddingRight(2).
 93			Height(m.height - 1).
 94			Render(
 95				lipgloss.JoinVertical(
 96					lipgloss.Top,
 97					header(),
 98					" ",
 99					m.sessionSection(),
100					" ",
101					lspsConfigured(),
102					" ",
103					m.modifiedFiles(),
104				),
105			),
106	)
107}
108
109func (m *sidebarCmp) sessionSection() string {
110	t := theme.CurrentTheme()
111	baseStyle := styles.BaseStyle()
112
113	sessionKey := baseStyle.
114		Foreground(t.Primary()).
115		Bold(true).
116		Render("Session")
117
118	sessionValue := baseStyle.
119		Foreground(t.Text()).
120		Width(m.width - lipgloss.Width(sessionKey)).
121		Render(fmt.Sprintf(": %s", m.session.Title))
122
123	return lipgloss.JoinHorizontal(
124		lipgloss.Left,
125		sessionKey,
126		sessionValue,
127	)
128}
129
130func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
131	t := theme.CurrentTheme()
132	baseStyle := styles.BaseStyle()
133
134	stats := ""
135	if additions > 0 && removals > 0 {
136		additionsStr := baseStyle.
137			Foreground(t.Success()).
138			PaddingLeft(1).
139			Render(fmt.Sprintf("+%d", additions))
140
141		removalsStr := baseStyle.
142			Foreground(t.Error()).
143			PaddingLeft(1).
144			Render(fmt.Sprintf("-%d", removals))
145
146		content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
147		stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
148	} else if additions > 0 {
149		additionsStr := fmt.Sprintf(" %s", baseStyle.
150			PaddingLeft(1).
151			Foreground(t.Success()).
152			Render(fmt.Sprintf("+%d", additions)))
153		stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
154	} else if removals > 0 {
155		removalsStr := fmt.Sprintf(" %s", baseStyle.
156			PaddingLeft(1).
157			Foreground(t.Error()).
158			Render(fmt.Sprintf("-%d", removals)))
159		stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
160	}
161
162	filePathStr := baseStyle.Render(filePath)
163
164	return baseStyle.
165		Width(m.width).
166		Render(
167			lipgloss.JoinHorizontal(
168				lipgloss.Left,
169				filePathStr,
170				stats,
171			),
172		)
173}
174
175func (m *sidebarCmp) modifiedFiles() string {
176	t := theme.CurrentTheme()
177	baseStyle := styles.BaseStyle()
178
179	modifiedFiles := baseStyle.
180		Width(m.width).
181		Foreground(t.Primary()).
182		Bold(true).
183		Render("Modified Files:")
184
185	// If no modified files, show a placeholder message
186	if len(m.modFiles) == 0 {
187		message := "No modified files"
188		remainingWidth := m.width - lipgloss.Width(message)
189		if remainingWidth > 0 {
190			message += strings.Repeat(" ", remainingWidth)
191		}
192		return baseStyle.
193			Width(m.width).
194			Render(
195				lipgloss.JoinVertical(
196					lipgloss.Top,
197					modifiedFiles,
198					baseStyle.Foreground(t.TextMuted()).Render(message),
199				),
200			)
201	}
202
203	// Sort file paths alphabetically for consistent ordering
204	var paths []string
205	for path := range m.modFiles {
206		paths = append(paths, path)
207	}
208	sort.Strings(paths)
209
210	// Create views for each file in sorted order
211	var fileViews []string
212	for _, path := range paths {
213		stats := m.modFiles[path]
214		fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
215	}
216
217	return baseStyle.
218		Width(m.width).
219		Render(
220			lipgloss.JoinVertical(
221				lipgloss.Top,
222				modifiedFiles,
223				lipgloss.JoinVertical(
224					lipgloss.Left,
225					fileViews...,
226				),
227			),
228		)
229}
230
231func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
232	m.width = width
233	m.height = height
234	return nil
235}
236
237func (m *sidebarCmp) GetSize() (int, int) {
238	return m.width, m.height
239}
240
241func NewSidebarCmp(session session.Session, history history.Service) util.Model {
242	return &sidebarCmp{
243		session: session,
244		history: history,
245	}
246}
247
248func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
249	if m.history == nil || m.session.ID == "" {
250		return
251	}
252
253	// Get all latest files for this session
254	latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
255	if err != nil {
256		return
257	}
258
259	// Get all files for this session (to find initial versions)
260	allFiles, err := m.history.ListBySession(ctx, m.session.ID)
261	if err != nil {
262		return
263	}
264
265	// Clear the existing map to rebuild it
266	m.modFiles = make(map[string]struct {
267		additions int
268		removals  int
269	})
270
271	// Process each latest file
272	for _, file := range latestFiles {
273		// Skip if this is the initial version (no changes to show)
274		if file.Version == history.InitialVersion {
275			continue
276		}
277
278		// Find the initial version for this specific file
279		var initialVersion history.File
280		for _, v := range allFiles {
281			if v.Path == file.Path && v.Version == history.InitialVersion {
282				initialVersion = v
283				break
284			}
285		}
286
287		// Skip if we can't find the initial version
288		if initialVersion.ID == "" {
289			continue
290		}
291		if initialVersion.Content == file.Content {
292			continue
293		}
294
295		// Calculate diff between initial and latest version
296		_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
297
298		// Only add to modified files if there are changes
299		if additions > 0 || removals > 0 {
300			// Remove working directory prefix from file path
301			displayPath := file.Path
302			workingDir := config.WorkingDirectory()
303			displayPath = strings.TrimPrefix(displayPath, workingDir)
304			displayPath = strings.TrimPrefix(displayPath, "/")
305
306			m.modFiles[displayPath] = struct {
307				additions int
308				removals  int
309			}{
310				additions: additions,
311				removals:  removals,
312			}
313		}
314	}
315}
316
317func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
318	// Skip if this is the initial version (no changes to show)
319	if file.Version == history.InitialVersion {
320		return
321	}
322
323	// Find the initial version for this file
324	initialVersion, err := m.findInitialVersion(ctx, file.Path)
325	if err != nil || initialVersion.ID == "" {
326		return
327	}
328
329	// Skip if content hasn't changed
330	if initialVersion.Content == file.Content {
331		// If this file was previously modified but now matches the initial version,
332		// remove it from the modified files list
333		displayPath := getDisplayPath(file.Path)
334		delete(m.modFiles, displayPath)
335		return
336	}
337
338	// Calculate diff between initial and latest version
339	_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
340
341	// Only add to modified files if there are changes
342	if additions > 0 || removals > 0 {
343		displayPath := getDisplayPath(file.Path)
344		m.modFiles[displayPath] = struct {
345			additions int
346			removals  int
347		}{
348			additions: additions,
349			removals:  removals,
350		}
351	} else {
352		// If no changes, remove from modified files
353		displayPath := getDisplayPath(file.Path)
354		delete(m.modFiles, displayPath)
355	}
356}
357
358// Helper function to find the initial version of a file
359func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
360	// Get all versions of this file for the session
361	fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
362	if err != nil {
363		return history.File{}, err
364	}
365
366	// Find the initial version
367	for _, v := range fileVersions {
368		if v.Path == path && v.Version == history.InitialVersion {
369			return v, nil
370		}
371	}
372
373	return history.File{}, fmt.Errorf("initial version not found")
374}
375
376// Helper function to get the display path for a file
377func getDisplayPath(path string) string {
378	workingDir := config.WorkingDirectory()
379	displayPath := strings.TrimPrefix(path, workingDir)
380	return strings.TrimPrefix(displayPath, "/")
381}