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