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