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