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