sidebar.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. package chat
  2. import (
  3. "context"
  4. "fmt"
  5. "sort"
  6. "strings"
  7. tea "github.com/charmbracelet/bubbletea"
  8. "github.com/charmbracelet/lipgloss"
  9. "github.com/opencode-ai/opencode/internal/config"
  10. "github.com/opencode-ai/opencode/internal/diff"
  11. "github.com/opencode-ai/opencode/internal/history"
  12. "github.com/opencode-ai/opencode/internal/pubsub"
  13. "github.com/opencode-ai/opencode/internal/session"
  14. "github.com/opencode-ai/opencode/internal/tui/styles"
  15. )
  16. type sidebarCmp struct {
  17. width, height int
  18. session session.Session
  19. history history.Service
  20. modFiles map[string]struct {
  21. additions int
  22. removals int
  23. }
  24. }
  25. func (m *sidebarCmp) Init() tea.Cmd {
  26. if m.history != nil {
  27. ctx := context.Background()
  28. // Subscribe to file events
  29. filesCh := m.history.Subscribe(ctx)
  30. // Initialize the modified files map
  31. m.modFiles = make(map[string]struct {
  32. additions int
  33. removals int
  34. })
  35. // Load initial files and calculate diffs
  36. m.loadModifiedFiles(ctx)
  37. // Return a command that will send file events to the Update method
  38. return func() tea.Msg {
  39. return <-filesCh
  40. }
  41. }
  42. return nil
  43. }
  44. func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  45. switch msg := msg.(type) {
  46. case SessionSelectedMsg:
  47. if msg.ID != m.session.ID {
  48. m.session = msg
  49. ctx := context.Background()
  50. m.loadModifiedFiles(ctx)
  51. }
  52. case pubsub.Event[session.Session]:
  53. if msg.Type == pubsub.UpdatedEvent {
  54. if m.session.ID == msg.Payload.ID {
  55. m.session = msg.Payload
  56. }
  57. }
  58. case pubsub.Event[history.File]:
  59. if msg.Payload.SessionID == m.session.ID {
  60. // Process the individual file change instead of reloading all files
  61. ctx := context.Background()
  62. m.processFileChanges(ctx, msg.Payload)
  63. // Return a command to continue receiving events
  64. return m, func() tea.Msg {
  65. ctx := context.Background()
  66. filesCh := m.history.Subscribe(ctx)
  67. return <-filesCh
  68. }
  69. }
  70. }
  71. return m, nil
  72. }
  73. func (m *sidebarCmp) View() string {
  74. return styles.BaseStyle.
  75. Width(m.width).
  76. PaddingLeft(4).
  77. PaddingRight(2).
  78. Height(m.height - 1).
  79. Render(
  80. lipgloss.JoinVertical(
  81. lipgloss.Top,
  82. header(m.width),
  83. " ",
  84. m.sessionSection(),
  85. " ",
  86. lspsConfigured(m.width),
  87. " ",
  88. m.modifiedFiles(),
  89. ),
  90. )
  91. }
  92. func (m *sidebarCmp) sessionSection() string {
  93. sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
  94. sessionValue := styles.BaseStyle.
  95. Foreground(styles.Forground).
  96. Width(m.width - lipgloss.Width(sessionKey)).
  97. Render(fmt.Sprintf(": %s", m.session.Title))
  98. return lipgloss.JoinHorizontal(
  99. lipgloss.Left,
  100. sessionKey,
  101. sessionValue,
  102. )
  103. }
  104. func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
  105. stats := ""
  106. if additions > 0 && removals > 0 {
  107. additions := styles.BaseStyle.Foreground(styles.Green).PaddingLeft(1).Render(fmt.Sprintf("+%d", additions))
  108. removals := styles.BaseStyle.Foreground(styles.Red).PaddingLeft(1).Render(fmt.Sprintf("-%d", removals))
  109. content := lipgloss.JoinHorizontal(lipgloss.Left, additions, removals)
  110. stats = styles.BaseStyle.Width(lipgloss.Width(content)).Render(content)
  111. } else if additions > 0 {
  112. additions := fmt.Sprintf(" %s", styles.BaseStyle.PaddingLeft(1).Foreground(styles.Green).Render(fmt.Sprintf("+%d", additions)))
  113. stats = styles.BaseStyle.Width(lipgloss.Width(additions)).Render(additions)
  114. } else if removals > 0 {
  115. removals := fmt.Sprintf(" %s", styles.BaseStyle.PaddingLeft(1).Foreground(styles.Red).Render(fmt.Sprintf("-%d", removals)))
  116. stats = styles.BaseStyle.Width(lipgloss.Width(removals)).Render(removals)
  117. }
  118. filePathStr := styles.BaseStyle.Render(filePath)
  119. return styles.BaseStyle.
  120. Width(m.width).
  121. Render(
  122. lipgloss.JoinHorizontal(
  123. lipgloss.Left,
  124. filePathStr,
  125. stats,
  126. ),
  127. )
  128. }
  129. func (m *sidebarCmp) modifiedFiles() string {
  130. modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
  131. // If no modified files, show a placeholder message
  132. if m.modFiles == nil || len(m.modFiles) == 0 {
  133. message := "No modified files"
  134. remainingWidth := m.width - lipgloss.Width(message)
  135. if remainingWidth > 0 {
  136. message += strings.Repeat(" ", remainingWidth)
  137. }
  138. return styles.BaseStyle.
  139. Width(m.width).
  140. Render(
  141. lipgloss.JoinVertical(
  142. lipgloss.Top,
  143. modifiedFiles,
  144. styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
  145. ),
  146. )
  147. }
  148. // Sort file paths alphabetically for consistent ordering
  149. var paths []string
  150. for path := range m.modFiles {
  151. paths = append(paths, path)
  152. }
  153. sort.Strings(paths)
  154. // Create views for each file in sorted order
  155. var fileViews []string
  156. for _, path := range paths {
  157. stats := m.modFiles[path]
  158. fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
  159. }
  160. return styles.BaseStyle.
  161. Width(m.width).
  162. Render(
  163. lipgloss.JoinVertical(
  164. lipgloss.Top,
  165. modifiedFiles,
  166. lipgloss.JoinVertical(
  167. lipgloss.Left,
  168. fileViews...,
  169. ),
  170. ),
  171. )
  172. }
  173. func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
  174. m.width = width
  175. m.height = height
  176. return nil
  177. }
  178. func (m *sidebarCmp) GetSize() (int, int) {
  179. return m.width, m.height
  180. }
  181. func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
  182. return &sidebarCmp{
  183. session: session,
  184. history: history,
  185. }
  186. }
  187. func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
  188. if m.history == nil || m.session.ID == "" {
  189. return
  190. }
  191. // Get all latest files for this session
  192. latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
  193. if err != nil {
  194. return
  195. }
  196. // Get all files for this session (to find initial versions)
  197. allFiles, err := m.history.ListBySession(ctx, m.session.ID)
  198. if err != nil {
  199. return
  200. }
  201. // Clear the existing map to rebuild it
  202. m.modFiles = make(map[string]struct {
  203. additions int
  204. removals int
  205. })
  206. // Process each latest file
  207. for _, file := range latestFiles {
  208. // Skip if this is the initial version (no changes to show)
  209. if file.Version == history.InitialVersion {
  210. continue
  211. }
  212. // Find the initial version for this specific file
  213. var initialVersion history.File
  214. for _, v := range allFiles {
  215. if v.Path == file.Path && v.Version == history.InitialVersion {
  216. initialVersion = v
  217. break
  218. }
  219. }
  220. // Skip if we can't find the initial version
  221. if initialVersion.ID == "" {
  222. continue
  223. }
  224. if initialVersion.Content == file.Content {
  225. continue
  226. }
  227. // Calculate diff between initial and latest version
  228. _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
  229. // Only add to modified files if there are changes
  230. if additions > 0 || removals > 0 {
  231. // Remove working directory prefix from file path
  232. displayPath := file.Path
  233. workingDir := config.WorkingDirectory()
  234. displayPath = strings.TrimPrefix(displayPath, workingDir)
  235. displayPath = strings.TrimPrefix(displayPath, "/")
  236. m.modFiles[displayPath] = struct {
  237. additions int
  238. removals int
  239. }{
  240. additions: additions,
  241. removals: removals,
  242. }
  243. }
  244. }
  245. }
  246. func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
  247. // Skip if this is the initial version (no changes to show)
  248. if file.Version == history.InitialVersion {
  249. return
  250. }
  251. // Find the initial version for this file
  252. initialVersion, err := m.findInitialVersion(ctx, file.Path)
  253. if err != nil || initialVersion.ID == "" {
  254. return
  255. }
  256. // Skip if content hasn't changed
  257. if initialVersion.Content == file.Content {
  258. // If this file was previously modified but now matches the initial version,
  259. // remove it from the modified files list
  260. displayPath := getDisplayPath(file.Path)
  261. delete(m.modFiles, displayPath)
  262. return
  263. }
  264. // Calculate diff between initial and latest version
  265. _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
  266. // Only add to modified files if there are changes
  267. if additions > 0 || removals > 0 {
  268. displayPath := getDisplayPath(file.Path)
  269. m.modFiles[displayPath] = struct {
  270. additions int
  271. removals int
  272. }{
  273. additions: additions,
  274. removals: removals,
  275. }
  276. } else {
  277. // If no changes, remove from modified files
  278. displayPath := getDisplayPath(file.Path)
  279. delete(m.modFiles, displayPath)
  280. }
  281. }
  282. // Helper function to find the initial version of a file
  283. func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
  284. // Get all versions of this file for the session
  285. fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
  286. if err != nil {
  287. return history.File{}, err
  288. }
  289. // Find the initial version
  290. for _, v := range fileVersions {
  291. if v.Path == path && v.Version == history.InitialVersion {
  292. return v, nil
  293. }
  294. }
  295. return history.File{}, fmt.Errorf("initial version not found")
  296. }
  297. // Helper function to get the display path for a file
  298. func getDisplayPath(path string) string {
  299. workingDir := config.WorkingDirectory()
  300. displayPath := strings.TrimPrefix(path, workingDir)
  301. return strings.TrimPrefix(displayPath, "/")
  302. }