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/ui/util"
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 return util.ReportError(err)
47 }
48
49 sessionFiles, err := m.loadSessionFiles(sessionID)
50 if err != nil {
51 return util.ReportError(err)
52 }
53
54 return loadSessionMsg{
55 session: &session,
56 files: sessionFiles,
57 }
58 }
59}
60
61func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
62 files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
63 if err != nil {
64 return nil, err
65 }
66
67 filesByPath := make(map[string][]history.File)
68 for _, f := range files {
69 filesByPath[f.Path] = append(filesByPath[f.Path], f)
70 }
71 sessionFiles := make([]SessionFile, 0, len(filesByPath))
72 for _, versions := range filesByPath {
73 if len(versions) == 0 {
74 continue
75 }
76
77 first := versions[0]
78 last := versions[0]
79 for _, v := range versions {
80 if v.Version < first.Version {
81 first = v
82 }
83 if v.Version > last.Version {
84 last = v
85 }
86 }
87
88 _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
89
90 sessionFiles = append(sessionFiles, SessionFile{
91 FirstVersion: first,
92 LatestVersion: last,
93 Additions: additions,
94 Deletions: deletions,
95 })
96 }
97
98 slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
99 if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
100 return -1
101 }
102 if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
103 return 1
104 }
105 return 0
106 })
107 return sessionFiles, nil
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 sessionFiles, err := m.loadSessionFiles(m.session.ID)
119 // could not load session files
120 if err != nil {
121 return util.NewErrorMsg(err)
122 }
123
124 return sessionFilesUpdatesMsg{
125 sessionFiles: sessionFiles,
126 }
127 }
128}
129
130// filesInfo renders the modified files section for the sidebar, showing files
131// with their addition/deletion counts.
132func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
133 t := m.com.Styles
134
135 title := t.Subtle.Render("Modified Files")
136 if isSection {
137 title = common.Section(t, "Modified Files", width)
138 }
139 list := t.Subtle.Render("None")
140 var filesWithChanges []SessionFile
141 for _, f := range m.sessionFiles {
142 if f.Additions == 0 && f.Deletions == 0 {
143 continue
144 }
145 filesWithChanges = append(filesWithChanges, f)
146 }
147 if len(filesWithChanges) > 0 {
148 list = fileList(t, cwd, filesWithChanges, width, maxItems)
149 }
150
151 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
152}
153
154// fileList renders a list of files with their diff statistics, truncating to
155// maxItems and showing a "...and N more" message if needed.
156func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, width, maxItems int) string {
157 if maxItems <= 0 {
158 return ""
159 }
160 var renderedFiles []string
161 filesShown := 0
162
163 for _, f := range filesWithChanges {
164 // Skip files with no changes
165 if filesShown >= maxItems {
166 break
167 }
168
169 // Build stats string with colors
170 var statusParts []string
171 if f.Additions > 0 {
172 statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
173 }
174 if f.Deletions > 0 {
175 statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
176 }
177 extraContent := strings.Join(statusParts, " ")
178
179 // Format file path
180 filePath := f.FirstVersion.Path
181 if rel, err := filepath.Rel(cwd, filePath); err == nil {
182 filePath = rel
183 }
184 filePath = fsext.DirTrim(filePath, 2)
185 filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "β¦")
186
187 line := t.Files.Path.Render(filePath)
188 if extraContent != "" {
189 line = fmt.Sprintf("%s %s", line, extraContent)
190 }
191
192 renderedFiles = append(renderedFiles, line)
193 filesShown++
194 }
195
196 if len(filesWithChanges) > maxItems {
197 remaining := len(filesWithChanges) - maxItems
198 renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("β¦and %d more", remaining)))
199 }
200
201 return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
202}