feat: add file changes

Kujtim Hoxha created

need to still handle realtime file changes

Change summary

internal/fileutil/fileutil.go                   |  24 +++
internal/tui/components/chat/sidebar/sidebar.go | 133 ++++++++++++++++++
internal/tui/components/core/helpers.go         |  13 +
internal/tui/page/chat/chat.go                  |   2 
todos.md                                        |   1 
5 files changed, 165 insertions(+), 8 deletions(-)

Detailed changes

internal/fileutil/fileutil.go 🔗

@@ -211,3 +211,27 @@ func PrettyPath(path string) string {
 	}
 	return path
 }
+
+func DirTrim(pwd string, lim int) string {
+	var (
+		out string
+		sep = string(filepath.Separator)
+	)
+	dirs := strings.Split(pwd, sep)
+	if lim > len(dirs)-1 || lim <= 0 {
+		return pwd
+	}
+	for i := len(dirs) - 1; i > 0; i-- {
+		out = sep + out
+		if i == len(dirs)-1 {
+			out = dirs[i]
+		} else if i >= len(dirs)-lim {
+			out = string(dirs[i][0]) + out
+		} else {
+			out = "..." + out
+			break
+		}
+	}
+	out = filepath.Join("~", out)
+	return out
+}

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -1,12 +1,16 @@
 package sidebar
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"strings"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fileutil"
+	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/llm/models"
 	"github.com/charmbracelet/crush/internal/logging"
 	"github.com/charmbracelet/crush/internal/lsp"
@@ -21,12 +25,22 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
 )
 
 const (
 	logoBreakpoint = 65
 )
 
+type SessionFile struct {
+	FilePath  string
+	Additions int
+	Deletions int
+}
+type SessionFilesMsg struct {
+	Files []SessionFile
+}
+
 type Sidebar interface {
 	util.Model
 	layout.Sizeable
@@ -38,11 +52,14 @@ type sidebarCmp struct {
 	logo          string
 	cwd           string
 	lspClients    map[string]*lsp.Client
+	history       history.Service
+	files         []SessionFile
 }
 
-func NewSidebarCmp(lspClients map[string]*lsp.Client) Sidebar {
+func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client) Sidebar {
 	return &sidebarCmp{
 		lspClients: lspClients,
+		history:    history,
 	}
 }
 
@@ -58,6 +75,12 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if msg.ID != m.session.ID {
 			m.session = msg
 		}
+		return m, m.loadSessionFiles
+	case SessionFilesMsg:
+		m.files = msg.Files
+		logging.Info("Loaded session files", "count", len(m.files))
+		return m, nil
+
 	case chat.SessionClearedMsg:
 		m.session = session.Session{}
 	case pubsub.Event[session.Session]:
@@ -85,6 +108,8 @@ func (m *sidebarCmp) View() tea.View {
 		"",
 		m.currentModelBlock(),
 		"",
+		m.filesBlock(),
+		"",
 		m.lspBlock(),
 		"",
 		m.mcpBlock(),
@@ -95,6 +120,58 @@ func (m *sidebarCmp) View() tea.View {
 	)
 }
 
+func (m *sidebarCmp) loadSessionFiles() tea.Msg {
+	files, err := m.history.ListBySession(context.Background(), m.session.ID)
+	if err != nil {
+		return util.InfoMsg{
+			Type: util.InfoTypeError,
+			Msg:  err.Error(),
+		}
+	}
+
+	type fileHistory struct {
+		initialVersion history.File
+		latestVersion  history.File
+	}
+
+	fileMap := make(map[string]fileHistory)
+
+	for _, file := range files {
+		if existing, ok := fileMap[file.Path]; ok {
+			// Update the latest version
+			if existing.latestVersion.CreatedAt < file.CreatedAt {
+				existing.latestVersion = file
+			}
+			if file.Version == history.InitialVersion {
+				existing.initialVersion = file
+			}
+			fileMap[file.Path] = existing
+		} else {
+			// Add the initial version
+			fileMap[file.Path] = fileHistory{
+				initialVersion: file,
+				latestVersion:  file,
+			}
+		}
+	}
+
+	sessionFiles := make([]SessionFile, 0, len(fileMap))
+	for path, fh := range fileMap {
+		if fh.initialVersion.Version == history.InitialVersion {
+			_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
+			sessionFiles = append(sessionFiles, SessionFile{
+				FilePath:  path,
+				Additions: additions,
+				Deletions: deletions,
+			})
+		}
+	}
+
+	return SessionFilesMsg{
+		Files: sessionFiles,
+	}
+}
+
 func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
 	if width < logoBreakpoint && m.width >= logoBreakpoint {
 		m.logo = m.logoBlock(true)
@@ -122,6 +199,59 @@ func (m *sidebarCmp) logoBlock(compact bool) string {
 	})
 }
 
+func (m *sidebarCmp) filesBlock() string {
+	maxWidth := min(m.width, 58)
+	t := styles.CurrentTheme()
+
+	section := t.S().Muted.Render(
+		core.Section("Files", maxWidth),
+	)
+
+	if len(m.files) == 0 {
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+	}
+
+	fileList := []string{section, ""}
+
+	for _, file := range m.files {
+		// Extract just the filename from the path
+
+		// Create status indicators for additions/deletions
+		var statusParts []string
+		if file.Additions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
+		}
+		if file.Deletions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
+		}
+
+		extraContent := strings.Join(statusParts, " ")
+		filePath := fileutil.DirTrim(fileutil.PrettyPath(file.FilePath), 2)
+		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
+		fileList = append(fileList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:    t.FgMuted,
+					NoIcon:       true,
+					Title:        filePath,
+					ExtraContent: extraContent,
+				},
+				m.width,
+			),
+		)
+	}
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		fileList...,
+	)
+}
+
 func (m *sidebarCmp) lspBlock() string {
 	maxWidth := min(m.width, 58)
 	t := styles.CurrentTheme()
@@ -177,7 +307,6 @@ func (m *sidebarCmp) lspBlock() string {
 			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
 		}
 
-		logging.Info("LSP Errors", "errors", errs)
 		lspList = append(lspList,
 			core.Status(
 				core.StatusOpts{

internal/tui/components/core/helpers.go 🔗

@@ -38,6 +38,7 @@ func Title(title string, width int) string {
 type StatusOpts struct {
 	Icon             string
 	IconColor        color.Color
+	NoIcon           bool // If true, no icon will be displayed
 	Title            string
 	TitleColor       color.Color
 	Description      string
@@ -51,6 +52,8 @@ func Status(ops StatusOpts, width int) string {
 	iconColor := t.Success
 	if ops.Icon != "" {
 		icon = ops.Icon
+	} else if ops.NoIcon {
+		icon = ""
 	}
 	if ops.IconColor != nil {
 		iconColor = ops.IconColor
@@ -65,7 +68,6 @@ func Status(ops StatusOpts, width int) string {
 	if ops.DescriptionColor != nil {
 		descriptionColor = ops.DescriptionColor
 	}
-	icon = t.S().Base.Foreground(iconColor).Render(icon)
 	title = t.S().Base.Foreground(titleColor).Render(title)
 	if description != "" {
 		extraContent := len(ops.ExtraContent)
@@ -75,11 +77,12 @@ func Status(ops StatusOpts, width int) string {
 		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContent, "…")
 	}
 	description = t.S().Base.Foreground(descriptionColor).Render(description)
-	content := []string{
-		icon,
-		title,
-		description,
+
+	content := []string{}
+	if icon != "" {
+		content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
 	}
+	content = append(content, title, description)
 	if ops.ExtraContent != "" {
 		content = append(content, ops.ExtraContent)
 	}

internal/tui/page/chat/chat.go 🔗

@@ -209,7 +209,7 @@ func (p *chatPage) Bindings() []key.Binding {
 
 func NewChatPage(app *app.App) ChatPage {
 	sidebarContainer := layout.NewContainer(
-		sidebar.NewSidebarCmp(app.LSPClients),
+		sidebar.NewSidebarCmp(app.History, app.LSPClients),
 		layout.WithPadding(1, 1, 1, 1),
 	)
 	editorContainer := layout.NewContainer(

todos.md 🔗

@@ -27,6 +27,7 @@
 - [ ] Update interactive mode to use the spinner
 - [ ] Revisit the core list component
   - [ ] This component has become super complex we might need to fix this.
+- [ ] Handle correct LSP and MCP status icon
 - [ ] Investigate ways to make the spinner less CPU intensive
 - [ ] General cleanup and documentation
 - [ ] Update the readme