sidebar.go 9.1 KB

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