1package model
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "path/filepath"
8 "slices"
9 "strings"
10
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/session"
17 "github.com/charmbracelet/crush/internal/ui/common"
18 "github.com/charmbracelet/crush/internal/ui/styles"
19 "github.com/charmbracelet/crush/internal/uiutil"
20 "github.com/charmbracelet/x/ansi"
21)
22
23// loadSessionMsg is a message indicating that a session and its files have
24// been loaded.
25type loadSessionMsg struct {
26 session *session.Session
27 files []SessionFile
28}
29
30// SessionFile tracks the first and latest versions of a file in a session,
31// along with the total additions and deletions.
32type SessionFile struct {
33 FirstVersion history.File
34 LatestVersion history.File
35 Additions int
36 Deletions int
37}
38
39// loadSession loads the session along with its associated files and computes
40// the diff statistics (additions and deletions) for each file in the session.
41// It returns a tea.Cmd that, when executed, fetches the session data and
42// returns a sessionFilesLoadedMsg containing the processed session files.
43func (m *UI) loadSession(sessionID string) tea.Cmd {
44 return func() tea.Msg {
45 session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
46 if err != nil {
47 // TODO: better error handling
48 return uiutil.ReportError(err)()
49 }
50
51 if recoverErr := m.com.App.AgentCoordinator.RecoverSession(context.Background(), session.ID); recoverErr != nil {
52 slog.Error("Failed to recover session", "session_id", session.ID, "error", recoverErr)
53 }
54
55 files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
56 if err != nil {
57 // TODO: better error handling
58 return uiutil.ReportError(err)()
59 }
60
61 filesByPath := make(map[string][]history.File)
62 for _, f := range files {
63 filesByPath[f.Path] = append(filesByPath[f.Path], f)
64 }
65
66 sessionFiles := make([]SessionFile, 0, len(filesByPath))
67 for _, versions := range filesByPath {
68 if len(versions) == 0 {
69 continue
70 }
71
72 first := versions[0]
73 last := versions[0]
74 for _, v := range versions {
75 if v.Version < first.Version {
76 first = v
77 }
78 if v.Version > last.Version {
79 last = v
80 }
81 }
82
83 _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
84
85 sessionFiles = append(sessionFiles, SessionFile{
86 FirstVersion: first,
87 LatestVersion: last,
88 Additions: additions,
89 Deletions: deletions,
90 })
91 }
92
93 slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
94 if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
95 return -1
96 }
97 if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
98 return 1
99 }
100 return 0
101 })
102
103 return loadSessionMsg{
104 session: &session,
105 files: sessionFiles,
106 }
107 }
108}
109
110// handleFileEvent processes file change events and updates the session file
111// list with new or updated file information.
112func (m *UI) handleFileEvent(file history.File) tea.Cmd {
113 if m.session == nil || file.SessionID != m.session.ID {
114 return nil
115 }
116
117 return func() tea.Msg {
118 existingIdx := -1
119 for i, sf := range m.sessionFiles {
120 if sf.FirstVersion.Path == file.Path {
121 existingIdx = i
122 break
123 }
124 }
125
126 if existingIdx == -1 {
127 newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1)
128 newFiles = append(newFiles, SessionFile{
129 FirstVersion: file,
130 LatestVersion: file,
131 Additions: 0,
132 Deletions: 0,
133 })
134 newFiles = append(newFiles, m.sessionFiles...)
135
136 return loadSessionMsg{
137 session: m.session,
138 files: newFiles,
139 }
140 }
141
142 updated := m.sessionFiles[existingIdx]
143
144 if file.Version < updated.FirstVersion.Version {
145 updated.FirstVersion = file
146 }
147
148 if file.Version > updated.LatestVersion.Version {
149 updated.LatestVersion = file
150 }
151
152 _, additions, deletions := diff.GenerateDiff(
153 updated.FirstVersion.Content,
154 updated.LatestVersion.Content,
155 updated.FirstVersion.Path,
156 )
157 updated.Additions = additions
158 updated.Deletions = deletions
159
160 newFiles := make([]SessionFile, 0, len(m.sessionFiles))
161 newFiles = append(newFiles, updated)
162 for i, sf := range m.sessionFiles {
163 if i != existingIdx {
164 newFiles = append(newFiles, sf)
165 }
166 }
167
168 return loadSessionMsg{
169 session: m.session,
170 files: newFiles,
171 }
172 }
173}
174
175// filesInfo renders the modified files section for the sidebar, showing files
176// with their addition/deletion counts.
177func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
178 t := m.com.Styles
179
180 title := t.Subtle.Render("Modified Files")
181 if isSection {
182 title = common.Section(t, "Modified Files", width)
183 }
184 list := t.Subtle.Render("None")
185
186 if len(m.sessionFiles) > 0 {
187 list = fileList(t, cwd, m.sessionFiles, width, maxItems)
188 }
189
190 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
191}
192
193// fileList renders a list of files with their diff statistics, truncating to
194// maxItems and showing a "...and N more" message if needed.
195func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
196 if maxItems <= 0 {
197 return ""
198 }
199 var renderedFiles []string
200 filesShown := 0
201
202 var filesWithChanges []SessionFile
203 for _, f := range files {
204 if f.Additions == 0 && f.Deletions == 0 {
205 continue
206 }
207 filesWithChanges = append(filesWithChanges, f)
208 }
209
210 for _, f := range filesWithChanges {
211 // Skip files with no changes
212 if filesShown >= maxItems {
213 break
214 }
215
216 // Build stats string with colors
217 var statusParts []string
218 if f.Additions > 0 {
219 statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
220 }
221 if f.Deletions > 0 {
222 statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
223 }
224 extraContent := strings.Join(statusParts, " ")
225
226 // Format file path
227 filePath := f.FirstVersion.Path
228 if rel, err := filepath.Rel(cwd, filePath); err == nil {
229 filePath = rel
230 }
231 filePath = fsext.DirTrim(filePath, 2)
232 filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "β¦")
233
234 line := t.Files.Path.Render(filePath)
235 if extraContent != "" {
236 line = fmt.Sprintf("%s %s", line, extraContent)
237 }
238
239 renderedFiles = append(renderedFiles, line)
240 filesShown++
241 }
242
243 if len(filesWithChanges) > maxItems {
244 remaining := len(filesWithChanges) - maxItems
245 renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("β¦and %d more", remaining)))
246 }
247
248 return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
249}