session.go 6.5 KB

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