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