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}