sidebar.go

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