on main help sign in
feat: add file changes

need to still handle realtime file changes

commite8f07b0ce55bfd57eb93fadc098d9e9c002be150 authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 16 Jun 2025, 17:50 (1 year ago) parent65e7346
5 files changed+165−8
internal/fileutil/fileutil.go+24 internal/tui/components/chat/sidebar/sidebar.go+131−2 internal/tui/components/core/helpers.go+8−5 internal/tui/page/chat/chat.go+1−1 todos.md+1
internal/fileutil/fileutil.go+24
@@ -211,3 +211,27 @@ func PrettyPath(path string) string {
211211 }
212212 return path
213213}
214+
215+func DirTrim(pwd string, lim int) string {
216+ var (
217+ out string
218+ sep = string(filepath.Separator)
219+ )
220+ dirs := strings.Split(pwd, sep)
221+ if lim > len(dirs)-1 || lim <= 0 {
222+ return pwd
223+ }
224+ for i := len(dirs) - 1; i > 0; i-- {
225+ out = sep + out
226+ if i == len(dirs)-1 {
227+ out = dirs[i]
228+ } else if i >= len(dirs)-lim {
229+ out = string(dirs[i][0]) + out
230+ } else {
231+ out = "..." + out
232+ break
233+ }
234+ }
235+ out = filepath.Join("~", out)
236+ return out
237+}
internal/tui/components/chat/sidebar/sidebar.go+131−2
@@ -1,12 +1,16 @@
11package sidebar
22
33import (
4+ "context"
45 "fmt"
56 "os"
67 "strings"
78
89 tea "github.com/charmbracelet/bubbletea/v2"
910 "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/diff"
12+ "github.com/charmbracelet/crush/internal/fileutil"
13+ "github.com/charmbracelet/crush/internal/history"
1014 "github.com/charmbracelet/crush/internal/llm/models"
1115 "github.com/charmbracelet/crush/internal/logging"
1216 "github.com/charmbracelet/crush/internal/lsp"
@@ -21,12 +25,22 @@ import (
2125 "github.com/charmbracelet/crush/internal/tui/util"
2226 "github.com/charmbracelet/crush/internal/version"
2327 "github.com/charmbracelet/lipgloss/v2"
28+ "github.com/charmbracelet/x/ansi"
2429)
2530
2631const (
2732 logoBreakpoint = 65
2833)
2934
35+type SessionFile struct {
36+ FilePath string
37+ Additions int
38+ Deletions int
39+}
40+type SessionFilesMsg struct {
41+ Files []SessionFile
42+}
43+
3044type Sidebar interface {
3145 util.Model
3246 layout.Sizeable
@@ -38,11 +52,14 @@ type sidebarCmp struct {
3852 logo string
3953 cwd string
4054 lspClients map[string]*lsp.Client
55+ history history.Service
56+ files []SessionFile
4157}
4258
43−func NewSidebarCmp(lspClients map[string]*lsp.Client) Sidebar {
59+func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client) Sidebar {
4460 return &sidebarCmp{
4561 lspClients: lspClients,
62+ history: history,
4663 }
4764}
4865
@@ -58,6 +75,12 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5875 if msg.ID != m.session.ID {
5976 m.session = msg
6077 }
78+ return m, m.loadSessionFiles
79+ case SessionFilesMsg:
80+ m.files = msg.Files
81+ logging.Info("Loaded session files", "count", len(m.files))
82+ return m, nil
83+
6184 case chat.SessionClearedMsg:
6285 m.session = session.Session{}
6386 case pubsub.Event[session.Session]:
@@ -85,6 +108,8 @@ func (m *sidebarCmp) View() tea.View {
85108 "",
86109 m.currentModelBlock(),
87110 "",
111+ m.filesBlock(),
112+ "",
88113 m.lspBlock(),
89114 "",
90115 m.mcpBlock(),
@@ -95,6 +120,58 @@ func (m *sidebarCmp) View() tea.View {
95120 )
96121}
97122
123+func (m *sidebarCmp) loadSessionFiles() tea.Msg {
124+ files, err := m.history.ListBySession(context.Background(), m.session.ID)
125+ if err != nil {
126+ return util.InfoMsg{
127+ Type: util.InfoTypeError,
128+ Msg: err.Error(),
129+ }
130+ }
131+
132+ type fileHistory struct {
133+ initialVersion history.File
134+ latestVersion history.File
135+ }
136+
137+ fileMap := make(map[string]fileHistory)
138+
139+ for _, file := range files {
140+ if existing, ok := fileMap[file.Path]; ok {
141+ // Update the latest version
142+ if existing.latestVersion.CreatedAt < file.CreatedAt {
143+ existing.latestVersion = file
144+ }
145+ if file.Version == history.InitialVersion {
146+ existing.initialVersion = file
147+ }
148+ fileMap[file.Path] = existing
149+ } else {
150+ // Add the initial version
151+ fileMap[file.Path] = fileHistory{
152+ initialVersion: file,
153+ latestVersion: file,
154+ }
155+ }
156+ }
157+
158+ sessionFiles := make([]SessionFile, 0, len(fileMap))
159+ for path, fh := range fileMap {
160+ if fh.initialVersion.Version == history.InitialVersion {
161+ _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
162+ sessionFiles = append(sessionFiles, SessionFile{
163+ FilePath: path,
164+ Additions: additions,
165+ Deletions: deletions,
166+ })
167+ }
168+ }
169+
170+ return SessionFilesMsg{
171+ Files: sessionFiles,
172+ }
173+}
174+
98175func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
99176 if width < logoBreakpoint && m.width >= logoBreakpoint {
100177 m.logo = m.logoBlock(true)
@@ -122,6 +199,59 @@ func (m *sidebarCmp) logoBlock(compact bool) string {
122199 })
123200}
124201
202+func (m *sidebarCmp) filesBlock() string {
203+ maxWidth := min(m.width, 58)
204+ t := styles.CurrentTheme()
205+
206+ section := t.S().Muted.Render(
207+ core.Section("Files", maxWidth),
208+ )
209+
210+ if len(m.files) == 0 {
211+ return lipgloss.JoinVertical(
212+ lipgloss.Left,
213+ section,
214+ "",
215+ t.S().Base.Foreground(t.Border).Render("None"),
216+ )
217+ }
218+
219+ fileList := []string{section, ""}
220+
221+ for _, file := range m.files {
222+ // Extract just the filename from the path
223+
224+ // Create status indicators for additions/deletions
225+ var statusParts []string
226+ if file.Additions > 0 {
227+ statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
228+ }
229+ if file.Deletions > 0 {
230+ statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
231+ }
232+
233+ extraContent := strings.Join(statusParts, " ")
234+ filePath := fileutil.DirTrim(fileutil.PrettyPath(file.FilePath), 2)
235+ filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
236+ fileList = append(fileList,
237+ core.Status(
238+ core.StatusOpts{
239+ IconColor: t.FgMuted,
240+ NoIcon: true,
241+ Title: filePath,
242+ ExtraContent: extraContent,
243+ },
244+ m.width,
245+ ),
246+ )
247+ }
248+
249+ return lipgloss.JoinVertical(
250+ lipgloss.Left,
251+ fileList...,
252+ )
253+}
254+
125255func (m *sidebarCmp) lspBlock() string {
126256 maxWidth := min(m.width, 58)
127257 t := styles.CurrentTheme()
@@ -177,7 +307,6 @@ func (m *sidebarCmp) lspBlock() string {
177307 errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
178308 }
179309
180− logging.Info("LSP Errors", "errors", errs)
181310 lspList = append(lspList,
182311 core.Status(
183312 core.StatusOpts{
internal/tui/components/core/helpers.go+8−5
@@ -38,6 +38,7 @@ func Title(title string, width int) string {
3838type StatusOpts struct {
3939 Icon string
4040 IconColor color.Color
41+ NoIcon bool // If true, no icon will be displayed
4142 Title string
4243 TitleColor color.Color
4344 Description string
@@ -51,6 +52,8 @@ func Status(ops StatusOpts, width int) string {
5152 iconColor := t.Success
5253 if ops.Icon != "" {
5354 icon = ops.Icon
55+ } else if ops.NoIcon {
56+ icon = ""
5457 }
5558 if ops.IconColor != nil {
5659 iconColor = ops.IconColor
@@ -65,7 +68,6 @@ func Status(ops StatusOpts, width int) string {
6568 if ops.DescriptionColor != nil {
6669 descriptionColor = ops.DescriptionColor
6770 }
68− icon = t.S().Base.Foreground(iconColor).Render(icon)
6971 title = t.S().Base.Foreground(titleColor).Render(title)
7072 if description != "" {
7173 extraContent := len(ops.ExtraContent)
@@ -75,11 +77,12 @@ func Status(ops StatusOpts, width int) string {
7577 description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContent, "…")
7678 }
7779 description = t.S().Base.Foreground(descriptionColor).Render(description)
78− content := []string{
79− icon,
80− title,
81− description,
80+
81+ content := []string{}
82+ if icon != "" {
83+ content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
8284 }
85+ content = append(content, title, description)
8386 if ops.ExtraContent != "" {
8487 content = append(content, ops.ExtraContent)
8588 }
internal/tui/page/chat/chat.go+1−1
@@ -209,7 +209,7 @@ func (p *chatPage) Bindings() []key.Binding {
209209
210210func NewChatPage(app *app.App) ChatPage {
211211 sidebarContainer := layout.NewContainer(
212− sidebar.NewSidebarCmp(app.LSPClients),
212+ sidebar.NewSidebarCmp(app.History, app.LSPClients),
213213 layout.WithPadding(1, 1, 1, 1),
214214 )
215215 editorContainer := layout.NewContainer(
todos.md+1
@@ -27,6 +27,7 @@
2727- [ ] Update interactive mode to use the spinner
2828- [ ] Revisit the core list component
2929 - [ ] This component has become super complex we might need to fix this.
30+- [ ] Handle correct LSP and MCP status icon
3031- [ ] Investigate ways to make the spinner less CPU intensive
3132- [ ] General cleanup and documentation
3233- [ ] Update the readme