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/kujtimiihoxha/opencode/internal/config"
12 "github.com/kujtimiihoxha/opencode/internal/diff"
13 "github.com/kujtimiihoxha/opencode/internal/history"
14 "github.com/kujtimiihoxha/opencode/internal/pubsub"
15 "github.com/kujtimiihoxha/opencode/internal/session"
16 "github.com/kujtimiihoxha/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 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals))
120 } else if additions > 0 {
121 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
122 } else if removals > 0 {
123 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
124 }
125 filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
126
127 return styles.BaseStyle.
128 Width(m.width).
129 Render(
130 lipgloss.JoinHorizontal(
131 lipgloss.Left,
132 filePathStr,
133 stats,
134 ),
135 )
136}
137
138func (m *sidebarCmp) modifiedFiles() string {
139 modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
140
141 // If no modified files, show a placeholder message
142 if m.modFiles == nil || len(m.modFiles) == 0 {
143 message := "No modified files"
144 remainingWidth := m.width - lipgloss.Width(message)
145 if remainingWidth > 0 {
146 message += strings.Repeat(" ", remainingWidth)
147 }
148 return styles.BaseStyle.
149 Width(m.width).
150 Render(
151 lipgloss.JoinVertical(
152 lipgloss.Top,
153 modifiedFiles,
154 styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
155 ),
156 )
157 }
158
159 // Sort file paths alphabetically for consistent ordering
160 var paths []string
161 for path := range m.modFiles {
162 paths = append(paths, path)
163 }
164 sort.Strings(paths)
165
166 // Create views for each file in sorted order
167 var fileViews []string
168 for _, path := range paths {
169 stats := m.modFiles[path]
170 fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
171 }
172
173 return styles.BaseStyle.
174 Width(m.width).
175 Render(
176 lipgloss.JoinVertical(
177 lipgloss.Top,
178 modifiedFiles,
179 lipgloss.JoinVertical(
180 lipgloss.Left,
181 fileViews...,
182 ),
183 ),
184 )
185}
186
187func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
188 m.width = width
189 m.height = height
190 return nil
191}
192
193func (m *sidebarCmp) GetSize() (int, int) {
194 return m.width, m.height
195}
196
197func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
198 return &sidebarCmp{
199 session: session,
200 history: history,
201 }
202}
203
204func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
205 if m.history == nil || m.session.ID == "" {
206 return
207 }
208
209 // Get all latest files for this session
210 latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
211 if err != nil {
212 return
213 }
214
215 // Get all files for this session (to find initial versions)
216 allFiles, err := m.history.ListBySession(ctx, m.session.ID)
217 if err != nil {
218 return
219 }
220
221 // Clear the existing map to rebuild it
222 m.modFiles = make(map[string]struct {
223 additions int
224 removals int
225 })
226
227 // Process each latest file
228 for _, file := range latestFiles {
229 // Skip if this is the initial version (no changes to show)
230 if file.Version == history.InitialVersion {
231 continue
232 }
233
234 // Find the initial version for this specific file
235 var initialVersion history.File
236 for _, v := range allFiles {
237 if v.Path == file.Path && v.Version == history.InitialVersion {
238 initialVersion = v
239 break
240 }
241 }
242
243 // Skip if we can't find the initial version
244 if initialVersion.ID == "" {
245 continue
246 }
247 if initialVersion.Content == file.Content {
248 continue
249 }
250
251 // Calculate diff between initial and latest version
252 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
253
254 // Only add to modified files if there are changes
255 if additions > 0 || removals > 0 {
256 // Remove working directory prefix from file path
257 displayPath := file.Path
258 workingDir := config.WorkingDirectory()
259 displayPath = strings.TrimPrefix(displayPath, workingDir)
260 displayPath = strings.TrimPrefix(displayPath, "/")
261
262 m.modFiles[displayPath] = struct {
263 additions int
264 removals int
265 }{
266 additions: additions,
267 removals: removals,
268 }
269 }
270 }
271}
272
273func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
274 // Skip if this is the initial version (no changes to show)
275 if file.Version == history.InitialVersion {
276 return
277 }
278
279 // Find the initial version for this file
280 initialVersion, err := m.findInitialVersion(ctx, file.Path)
281 if err != nil || initialVersion.ID == "" {
282 return
283 }
284
285 // Skip if content hasn't changed
286 if initialVersion.Content == file.Content {
287 // If this file was previously modified but now matches the initial version,
288 // remove it from the modified files list
289 displayPath := getDisplayPath(file.Path)
290 delete(m.modFiles, displayPath)
291 return
292 }
293
294 // Calculate diff between initial and latest version
295 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
296
297 // Only add to modified files if there are changes
298 if additions > 0 || removals > 0 {
299 displayPath := getDisplayPath(file.Path)
300 m.modFiles[displayPath] = struct {
301 additions int
302 removals int
303 }{
304 additions: additions,
305 removals: removals,
306 }
307 } else {
308 // If no changes, remove from modified files
309 displayPath := getDisplayPath(file.Path)
310 delete(m.modFiles, displayPath)
311 }
312}
313
314// Helper function to find the initial version of a file
315func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
316 // Get all versions of this file for the session
317 fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
318 if err != nil {
319 return history.File{}, err
320 }
321
322 // Find the initial version
323 for _, v := range fileVersions {
324 if v.Path == path && v.Version == history.InitialVersion {
325 return v, nil
326 }
327 }
328
329 return history.File{}, fmt.Errorf("initial version not found")
330}
331
332// Helper function to get the display path for a file
333func getDisplayPath(path string) string {
334 workingDir := config.WorkingDirectory()
335 displayPath := strings.TrimPrefix(path, workingDir)
336 return strings.TrimPrefix(displayPath, "/")
337}