sidebar.go 8.6 KB

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