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.
67//
68// The returned batch also reports the new current-session selection to
69// the workspace so the server can update its per-client presence map.
70// That report is fire-and-forget: errors are logged at debug and the
71// UI never blocks on the call.
72func (m *UI) loadSession(sessionID string) tea.Cmd {
73 load := func() tea.Msg {
74 session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
75 if err != nil {
76 return util.ReportError(err)
77 }
78
79 sessionFiles, err := m.loadSessionFiles(sessionID)
80 if err != nil {
81 return util.ReportError(err)
82 }
83
84 readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
85 if err != nil {
86 slog.Error("Failed to load read files for session", "error", err)
87 }
88
89 return loadSessionMsg{
90 session: &session,
91 files: sessionFiles,
92 readFiles: readFiles,
93 }
94 }
95 return tea.Batch(load, m.reportCurrentSession(sessionID))
96}
97
98// reportCurrentSession returns a fire-and-forget tea.Cmd that
99// informs the workspace which session this client is currently
100// viewing. Errors are logged at debug only; the call is a hint
101// for server-side presence tracking, not correctness-critical
102// state.
103func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
104 return func() tea.Msg {
105 if err := m.com.Workspace.SetCurrentSession(context.Background(), sessionID); err != nil {
106 slog.Debug("Failed to report current session", "session_id", sessionID, "error", err)
107 }
108 return nil
109 }
110}
111
112func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
113 files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
114 if err != nil {
115 return nil, err
116 }
117
118 filesByPath := make(map[string][]history.File)
119 for _, f := range files {
120 filesByPath[f.Path] = append(filesByPath[f.Path], f)
121 }
122 sessionFiles := make([]SessionFile, 0, len(filesByPath))
123 for _, versions := range filesByPath {
124 if len(versions) == 0 {
125 continue
126 }
127
128 first := versions[0]
129 last := versions[0]
130 for _, v := range versions {
131 if v.Version < first.Version {
132 first = v
133 }
134 if v.Version > last.Version {
135 last = v
136 }
137 }
138
139 _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
140
141 sessionFiles = append(sessionFiles, SessionFile{
142 FirstVersion: first,
143 LatestVersion: last,
144 Additions: additions,
145 Deletions: deletions,
146 })
147 }
148
149 slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
150 if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
151 return -1
152 }
153 if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
154 return 1
155 }
156 return 0
157 })
158 return sessionFiles, nil
159}
160
161// handleFileEvent processes file change events and updates the session file
162// list with new or updated file information.
163func (m *UI) handleFileEvent(file history.File) tea.Cmd {
164 if m.session == nil || file.SessionID != m.session.ID {
165 return nil
166 }
167
168 return func() tea.Msg {
169 sessionFiles, err := m.loadSessionFiles(m.session.ID)
170 // could not load session files
171 if err != nil {
172 return util.NewErrorMsg(err)
173 }
174
175 return sessionFilesUpdatesMsg{
176 sessionFiles: sessionFiles,
177 }
178 }
179}
180
181// filesInfo renders the modified files section for the sidebar, showing files
182// with their addition/deletion counts.
183func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
184 t := m.com.Styles
185
186 title := t.Files.SectionTitle.Render("Modified Files")
187 if isSection {
188 title = common.Section(t, "Modified Files", width)
189 }
190 list := t.Files.EmptyMessage.Render("None")
191 var filesWithChanges []SessionFile
192 for _, f := range m.sessionFiles {
193 if f.Additions == 0 && f.Deletions == 0 {
194 continue
195 }
196 filesWithChanges = append(filesWithChanges, f)
197 }
198 if len(filesWithChanges) > 0 {
199 list = fileList(t, cwd, filesWithChanges, width, maxItems)
200 }
201
202 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
203}
204
205// fileList renders a list of files with their diff statistics, truncating to
206// maxItems and showing a "...and N more" message if needed.
207func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, width, maxItems int) string {
208 if maxItems <= 0 {
209 return ""
210 }
211 var renderedFiles []string
212 filesShown := 0
213
214 for _, f := range filesWithChanges {
215 // Skip files with no changes
216 if filesShown >= maxItems {
217 break
218 }
219
220 // Build stats string with colors
221 var statusParts []string
222 if f.Additions > 0 {
223 statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
224 }
225 if f.Deletions > 0 {
226 statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
227 }
228 extraContent := strings.Join(statusParts, " ")
229
230 // Format file path
231 filePath := f.FirstVersion.Path
232 if rel, err := filepath.Rel(cwd, filePath); err == nil {
233 filePath = rel
234 }
235 filePath = fsext.DirTrim(filePath, 2)
236 suffix := ""
237 if extraContent != "" {
238 suffix = " " + extraContent
239 }
240 maxPathWidth := max(width-lipgloss.Width(suffix), 0)
241 filePath = ansi.Truncate(filePath, maxPathWidth, "β¦")
242
243 line := t.Files.Path.Render(filePath)
244 if extraContent != "" {
245 line = fmt.Sprintf("%s %s", line, extraContent)
246 }
247
248 renderedFiles = append(renderedFiles, line)
249 filesShown++
250 }
251
252 if len(filesWithChanges) > maxItems {
253 remaining := len(filesWithChanges) - maxItems
254 renderedFiles = append(renderedFiles, t.Files.TruncationHint.Render(fmt.Sprintf("β¦and %d more", remaining)))
255 }
256
257 return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
258}
259
260// startLSPs starts LSP servers for the given file paths.
261func (m *UI) startLSPs(paths []string) tea.Cmd {
262 if len(paths) == 0 {
263 return nil
264 }
265
266 return func() tea.Msg {
267 ctx := context.Background()
268 for _, path := range paths {
269 m.com.Workspace.LSPStart(ctx, path)
270 }
271 return nil
272 }
273}