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 pubsub.Event[session.Session]:
55 if msg.Type == pubsub.UpdatedEvent {
56 if m.session.ID == msg.Payload.ID {
57 m.session = msg.Payload
58 }
59 }
60 case pubsub.Event[history.File]:
61 if msg.Payload.SessionID == m.session.ID {
62 // When a file changes, reload all modified files
63 // This ensures we have the complete and accurate list
64 ctx := context.Background()
65 m.loadModifiedFiles(ctx)
66 }
67 }
68 return m, nil
69}
70
71func (m *sidebarCmp) View() string {
72 return styles.BaseStyle.
73 Width(m.width).
74 Height(m.height - 1).
75 Render(
76 lipgloss.JoinVertical(
77 lipgloss.Top,
78 header(m.width),
79 " ",
80 m.sessionSection(),
81 " ",
82 m.modifiedFiles(),
83 " ",
84 lspsConfigured(m.width),
85 ),
86 )
87}
88
89func (m *sidebarCmp) sessionSection() string {
90 sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
91 sessionValue := styles.BaseStyle.
92 Foreground(styles.Forground).
93 Width(m.width - lipgloss.Width(sessionKey)).
94 Render(fmt.Sprintf(": %s", m.session.Title))
95 return lipgloss.JoinHorizontal(
96 lipgloss.Left,
97 sessionKey,
98 sessionValue,
99 )
100}
101
102func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
103 stats := ""
104 if additions > 0 && removals > 0 {
105 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals))
106 } else if additions > 0 {
107 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
108 } else if removals > 0 {
109 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
110 }
111 filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
112
113 return styles.BaseStyle.
114 Width(m.width).
115 Render(
116 lipgloss.JoinHorizontal(
117 lipgloss.Left,
118 filePathStr,
119 stats,
120 ),
121 )
122}
123
124func (m *sidebarCmp) modifiedFiles() string {
125 modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
126
127 // If no modified files, show a placeholder message
128 if m.modFiles == nil || len(m.modFiles) == 0 {
129 message := "No modified files"
130 remainingWidth := m.width - lipgloss.Width(modifiedFiles)
131 if remainingWidth > 0 {
132 message += strings.Repeat(" ", remainingWidth)
133 }
134 return styles.BaseStyle.
135 Width(m.width).
136 Render(
137 lipgloss.JoinVertical(
138 lipgloss.Top,
139 modifiedFiles,
140 styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
141 ),
142 )
143 }
144
145 // Sort file paths alphabetically for consistent ordering
146 var paths []string
147 for path := range m.modFiles {
148 paths = append(paths, path)
149 }
150 sort.Strings(paths)
151
152 // Create views for each file in sorted order
153 var fileViews []string
154 for _, path := range paths {
155 stats := m.modFiles[path]
156 fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
157 }
158
159 return styles.BaseStyle.
160 Width(m.width).
161 Render(
162 lipgloss.JoinVertical(
163 lipgloss.Top,
164 modifiedFiles,
165 lipgloss.JoinVertical(
166 lipgloss.Left,
167 fileViews...,
168 ),
169 ),
170 )
171}
172
173func (m *sidebarCmp) SetSize(width, height int) {
174 m.width = width
175 m.height = height
176}
177
178func (m *sidebarCmp) GetSize() (int, int) {
179 return m.width, m.height
180}
181
182func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
183 return &sidebarCmp{
184 session: session,
185 history: history,
186 }
187}
188
189func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
190 if m.history == nil || m.session.ID == "" {
191 return
192 }
193
194 // Get all latest files for this session
195 latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
196 if err != nil {
197 return
198 }
199
200 // Get all files for this session (to find initial versions)
201 allFiles, err := m.history.ListBySession(ctx, m.session.ID)
202 if err != nil {
203 return
204 }
205
206 // Process each latest file
207 for _, file := range latestFiles {
208 // Skip if this is the initial version (no changes to show)
209 if file.Version == history.InitialVersion {
210 continue
211 }
212
213 // Find the initial version for this specific file
214 var initialVersion history.File
215 for _, v := range allFiles {
216 if v.Path == file.Path && v.Version == history.InitialVersion {
217 initialVersion = v
218 break
219 }
220 }
221
222 // Skip if we can't find the initial version
223 if initialVersion.ID == "" {
224 continue
225 }
226
227 // Calculate diff between initial and latest version
228 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
229
230 // Only add to modified files if there are changes
231 if additions > 0 || removals > 0 {
232 // Remove working directory prefix from file path
233 displayPath := file.Path
234 workingDir := config.WorkingDirectory()
235 displayPath = strings.TrimPrefix(displayPath, workingDir)
236 displayPath = strings.TrimPrefix(displayPath, "/")
237
238 m.modFiles[displayPath] = struct {
239 additions int
240 removals int
241 }{
242 additions: additions,
243 removals: removals,
244 }
245 }
246 }
247}
248
249func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
250 // Skip if not the latest version
251 if file.Version == history.InitialVersion {
252 return
253 }
254
255 // Get all versions of this file
256 fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
257 if err != nil {
258 return
259 }
260
261 // Find the initial version
262 var initialVersion history.File
263 for _, v := range fileVersions {
264 if v.Path == file.Path && v.Version == history.InitialVersion {
265 initialVersion = v
266 break
267 }
268 }
269
270 // Skip if we can't find the initial version
271 if initialVersion.ID == "" {
272 return
273 }
274
275 // Calculate diff between initial and latest version
276 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
277
278 // Only add to modified files if there are changes
279 if additions > 0 || removals > 0 {
280 // Remove working directory prefix from file path
281 displayPath := file.Path
282 workingDir := config.WorkingDirectory()
283 displayPath = strings.TrimPrefix(displayPath, workingDir)
284 displayPath = strings.TrimPrefix(displayPath, "/")
285
286 m.modFiles[displayPath] = struct {
287 additions int
288 removals int
289 }{
290 additions: additions,
291 removals: removals,
292 }
293 }
294}