session.go

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