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}