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