sidebar.go 8.7 KB

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