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/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}