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}