1package chat
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10 "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/diff"
12 "github.com/kujtimiihoxha/termai/internal/history"
13 "github.com/kujtimiihoxha/termai/internal/pubsub"
14 "github.com/kujtimiihoxha/termai/internal/session"
15 "github.com/kujtimiihoxha/termai/internal/tui/styles"
16)
17
18type sidebarCmp struct {
19 width, height int
20 session session.Session
21 history history.Service
22 modFiles map[string]struct {
23 additions int
24 removals int
25 }
26}
27
28func (m *sidebarCmp) Init() tea.Cmd {
29 if m.history != nil {
30 ctx := context.Background()
31 // Subscribe to file events
32 filesCh := m.history.Subscribe(ctx)
33
34 // Initialize the modified files map
35 m.modFiles = make(map[string]struct {
36 additions int
37 removals int
38 })
39
40 // Load initial files and calculate diffs
41 m.loadModifiedFiles(ctx)
42
43 // Return a command that will send file events to the Update method
44 return func() tea.Msg {
45 return <-filesCh
46 }
47 }
48 return nil
49}
50
51func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
52 switch msg := msg.(type) {
53 case pubsub.Event[session.Session]:
54 if msg.Type == pubsub.UpdatedEvent {
55 if m.session.ID == msg.Payload.ID {
56 m.session = msg.Payload
57 }
58 }
59 case pubsub.Event[history.File]:
60 if msg.Payload.SessionID == m.session.ID {
61 // When a file changes, reload all modified files
62 // This ensures we have the complete and accurate list
63 ctx := context.Background()
64 m.loadModifiedFiles(ctx)
65 }
66 }
67 return m, nil
68}
69
70func (m *sidebarCmp) View() string {
71 return styles.BaseStyle.
72 Width(m.width).
73 Height(m.height - 1).
74 Render(
75 lipgloss.JoinVertical(
76 lipgloss.Top,
77 header(m.width),
78 " ",
79 m.sessionSection(),
80 " ",
81 m.modifiedFiles(),
82 " ",
83 lspsConfigured(m.width),
84 ),
85 )
86}
87
88func (m *sidebarCmp) sessionSection() string {
89 sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
90 sessionValue := styles.BaseStyle.
91 Foreground(styles.Forground).
92 Width(m.width - lipgloss.Width(sessionKey)).
93 Render(fmt.Sprintf(": %s", m.session.Title))
94 return lipgloss.JoinHorizontal(
95 lipgloss.Left,
96 sessionKey,
97 sessionValue,
98 )
99}
100
101func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
102 stats := ""
103 if additions > 0 && removals > 0 {
104 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals))
105 } else if additions > 0 {
106 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
107 } else if removals > 0 {
108 stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
109 }
110 filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
111
112 return styles.BaseStyle.
113 Width(m.width).
114 Render(
115 lipgloss.JoinHorizontal(
116 lipgloss.Left,
117 filePathStr,
118 stats,
119 ),
120 )
121}
122
123func (m *sidebarCmp) modifiedFiles() string {
124 modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
125
126 // If no modified files, show a placeholder message
127 if m.modFiles == nil || len(m.modFiles) == 0 {
128 message := "No modified files"
129 remainingWidth := m.width - lipgloss.Width(modifiedFiles)
130 if remainingWidth > 0 {
131 message += strings.Repeat(" ", remainingWidth)
132 }
133 return styles.BaseStyle.
134 Width(m.width).
135 Render(
136 lipgloss.JoinVertical(
137 lipgloss.Top,
138 modifiedFiles,
139 styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
140 ),
141 )
142 }
143
144 var fileViews []string
145 for path, stats := range m.modFiles {
146 fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
147 }
148
149 return styles.BaseStyle.
150 Width(m.width).
151 Render(
152 lipgloss.JoinVertical(
153 lipgloss.Top,
154 modifiedFiles,
155 lipgloss.JoinVertical(
156 lipgloss.Left,
157 fileViews...,
158 ),
159 ),
160 )
161}
162
163func (m *sidebarCmp) SetSize(width, height int) {
164 m.width = width
165 m.height = height
166}
167
168func (m *sidebarCmp) GetSize() (int, int) {
169 return m.width, m.height
170}
171
172func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
173 return &sidebarCmp{
174 session: session,
175 history: history,
176 }
177}
178
179func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
180 if m.history == nil || m.session.ID == "" {
181 return
182 }
183
184 // Get all latest files for this session
185 latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
186 if err != nil {
187 return
188 }
189
190 // Get all files for this session (to find initial versions)
191 allFiles, err := m.history.ListBySession(ctx, m.session.ID)
192 if err != nil {
193 return
194 }
195
196 // Process each latest file
197 for _, file := range latestFiles {
198 // Skip if this is the initial version (no changes to show)
199 if file.Version == history.InitialVersion {
200 continue
201 }
202
203 // Find the initial version for this specific file
204 var initialVersion history.File
205 for _, v := range allFiles {
206 if v.Path == file.Path && v.Version == history.InitialVersion {
207 initialVersion = v
208 break
209 }
210 }
211
212 // Skip if we can't find the initial version
213 if initialVersion.ID == "" {
214 continue
215 }
216
217 // Calculate diff between initial and latest version
218 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
219
220 // Only add to modified files if there are changes
221 if additions > 0 || removals > 0 {
222 // Remove working directory prefix from file path
223 displayPath := file.Path
224 workingDir := config.WorkingDirectory()
225 displayPath = strings.TrimPrefix(displayPath, workingDir)
226 displayPath = strings.TrimPrefix(displayPath, "/")
227
228 m.modFiles[displayPath] = struct {
229 additions int
230 removals int
231 }{
232 additions: additions,
233 removals: removals,
234 }
235 }
236 }
237}
238
239func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
240 // Skip if not the latest version
241 if file.Version == history.InitialVersion {
242 return
243 }
244
245 // Get all versions of this file
246 fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
247 if err != nil {
248 return
249 }
250
251 // Find the initial version
252 var initialVersion history.File
253 for _, v := range fileVersions {
254 if v.Path == file.Path && v.Version == history.InitialVersion {
255 initialVersion = v
256 break
257 }
258 }
259
260 // Skip if we can't find the initial version
261 if initialVersion.ID == "" {
262 return
263 }
264
265 // Calculate diff between initial and latest version
266 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
267
268 // Only add to modified files if there are changes
269 if additions > 0 || removals > 0 {
270 // Remove working directory prefix from file path
271 displayPath := file.Path
272 workingDir := config.WorkingDirectory()
273 displayPath = strings.TrimPrefix(displayPath, workingDir)
274 displayPath = strings.TrimPrefix(displayPath, "/")
275
276 m.modFiles[displayPath] = struct {
277 additions int
278 removals int
279 }{
280 additions: additions,
281 removals: removals,
282 }
283 }
284}