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