session.go

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