| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- package chat
- import (
- "context"
- "fmt"
- "sort"
- "strings"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/config"
- "github.com/sst/opencode/internal/diff"
- "github.com/sst/opencode/internal/history"
- "github.com/sst/opencode/internal/pubsub"
- "github.com/sst/opencode/internal/tui/state"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- )
- type sidebarCmp struct {
- app *app.App
- width, height int
- modFiles map[string]struct {
- additions int
- removals int
- }
- }
- func (m *sidebarCmp) Init() tea.Cmd {
- if m.app.History != nil {
- ctx := context.Background()
- // Subscribe to file events
- filesCh := m.app.History.Subscribe(ctx)
- // Initialize the modified files map
- m.modFiles = make(map[string]struct {
- additions int
- removals int
- })
- // Load initial files and calculate diffs
- m.loadModifiedFiles(ctx)
- // Return a command that will send file events to the Update method
- return func() tea.Msg {
- return <-filesCh
- }
- }
- return nil
- }
- func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case state.SessionSelectedMsg:
- ctx := context.Background()
- m.loadModifiedFiles(ctx)
- case pubsub.Event[history.File]:
- if msg.Payload.SessionID == m.app.CurrentSession.ID {
- // Process the individual file change instead of reloading all files
- ctx := context.Background()
- m.processFileChanges(ctx, msg.Payload)
- }
- }
- return m, nil
- }
- func (m *sidebarCmp) View() string {
- baseStyle := styles.BaseStyle()
- return baseStyle.
- Width(m.width).
- PaddingLeft(4).
- PaddingRight(1).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(m.width),
- " ",
- m.sessionSection(),
- " ",
- lspsConfigured(m.width),
- " ",
- m.modifiedFiles(),
- ),
- )
- }
- func (m *sidebarCmp) sessionSection() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
- sessionKey := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Render("Session")
- sessionValue := baseStyle.
- Foreground(t.Text()).
- Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title))
- return sessionKey + sessionValue
- }
- func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
- stats := ""
- if additions > 0 && removals > 0 {
- additionsStr := baseStyle.
- Foreground(t.Success()).
- PaddingLeft(1).
- Render(fmt.Sprintf("+%d", additions))
- removalsStr := baseStyle.
- Foreground(t.Error()).
- PaddingLeft(1).
- Render(fmt.Sprintf("-%d", removals))
- content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
- stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
- } else if additions > 0 {
- additionsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Success()).
- Render(fmt.Sprintf("+%d", additions)))
- stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
- } else if removals > 0 {
- removalsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Error()).
- Render(fmt.Sprintf("-%d", removals)))
- stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
- }
- filePathStr := baseStyle.Render(filePath)
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- filePathStr,
- stats,
- ),
- )
- }
- func (m *sidebarCmp) modifiedFiles() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
- modifiedFiles := baseStyle.
- Width(m.width).
- Foreground(t.Primary()).
- Bold(true).
- Render("Modified Files:")
- // If no modified files, show a placeholder message
- if m.modFiles == nil || len(m.modFiles) == 0 {
- message := "No modified files"
- remainingWidth := m.width - lipgloss.Width(message)
- if remainingWidth > 0 {
- message += strings.Repeat(" ", remainingWidth)
- }
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- baseStyle.Foreground(t.TextMuted()).Render(message),
- ),
- )
- }
- // Sort file paths alphabetically for consistent ordering
- var paths []string
- for path := range m.modFiles {
- paths = append(paths, path)
- }
- sort.Strings(paths)
- // Create views for each file in sorted order
- var fileViews []string
- for _, path := range paths {
- stats := m.modFiles[path]
- fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
- }
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- lipgloss.JoinVertical(
- lipgloss.Left,
- fileViews...,
- ),
- ),
- )
- }
- func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
- m.width = width
- m.height = height
- return nil
- }
- func (m *sidebarCmp) GetSize() (int, int) {
- return m.width, m.height
- }
- func NewSidebarCmp(app *app.App) tea.Model {
- return &sidebarCmp{
- app: app,
- }
- }
- func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
- if m.app.CurrentSession.ID == "" {
- return
- }
- // Get all latest files for this session
- latestFiles, err := m.app.History.ListLatestSessionFiles(ctx, m.app.CurrentSession.ID)
- if err != nil {
- return
- }
- // Get all files for this session (to find initial versions)
- allFiles, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
- if err != nil {
- return
- }
- // Clear the existing map to rebuild it
- m.modFiles = make(map[string]struct {
- additions int
- removals int
- })
- // Process each latest file
- for _, file := range latestFiles {
- // Skip if this is the initial version (no changes to show)
- if file.Version == history.InitialVersion {
- continue
- }
- // Find the initial version for this specific file
- var initialVersion history.File
- for _, v := range allFiles {
- if v.Path == file.Path && v.Version == history.InitialVersion {
- initialVersion = v
- break
- }
- }
- // Skip if we can't find the initial version
- if initialVersion.ID == "" {
- continue
- }
- if initialVersion.Content == file.Content {
- continue
- }
- // Calculate diff between initial and latest version
- _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
- // Only add to modified files if there are changes
- if additions > 0 || removals > 0 {
- // Remove working directory prefix from file path
- displayPath := file.Path
- workingDir := config.WorkingDirectory()
- displayPath = strings.TrimPrefix(displayPath, workingDir)
- displayPath = strings.TrimPrefix(displayPath, "/")
- m.modFiles[displayPath] = struct {
- additions int
- removals int
- }{
- additions: additions,
- removals: removals,
- }
- }
- }
- }
- func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
- // Skip if this is the initial version (no changes to show)
- if file.Version == history.InitialVersion {
- return
- }
- // Find the initial version for this file
- initialVersion, err := m.findInitialVersion(ctx, file.Path)
- if err != nil || initialVersion.ID == "" {
- return
- }
- // Skip if content hasn't changed
- if initialVersion.Content == file.Content {
- // If this file was previously modified but now matches the initial version,
- // remove it from the modified files list
- displayPath := getDisplayPath(file.Path)
- delete(m.modFiles, displayPath)
- return
- }
- // Calculate diff between initial and latest version
- _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
- // Only add to modified files if there are changes
- if additions > 0 || removals > 0 {
- displayPath := getDisplayPath(file.Path)
- m.modFiles[displayPath] = struct {
- additions int
- removals int
- }{
- additions: additions,
- removals: removals,
- }
- } else {
- // If no changes, remove from modified files
- displayPath := getDisplayPath(file.Path)
- delete(m.modFiles, displayPath)
- }
- }
- // Helper function to find the initial version of a file
- func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
- // Get all versions of this file for the session
- fileVersions, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
- if err != nil {
- return history.File{}, err
- }
- // Find the initial version
- for _, v := range fileVersions {
- if v.Path == path && v.Version == history.InitialVersion {
- return v, nil
- }
- }
- return history.File{}, fmt.Errorf("initial version not found")
- }
- // Helper function to get the display path for a file
- func getDisplayPath(path string) string {
- workingDir := config.WorkingDirectory()
- displayPath := strings.TrimPrefix(path, workingDir)
- return strings.TrimPrefix(displayPath, "/")
- }
|