sidebar.go

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