on main help sign in
fix: fix the file history table and implement realtime file updates
commitac5fb91e2098504d95b5e140276a11caf3558222 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 17 Jun 2025, 14:03 (1 year ago) parentd7246a9
12 files changed+110−144
cmd/root.go+1 cspell.json+1−1 internal/db/db.go−10 internal/db/files.sql.go+7−38 internal/db/migrations/20250424200609_initial.sql+1−1 internal/db/models.go+1−1 internal/db/querier.go−1 internal/db/sql/files.sql+6−15 internal/history/file.go+7−49 internal/llm/tools/shell/comparison_test.go−1 internal/tui/components/chat/sidebar/sidebar.go+83−26 todos.md+3−1
cmd/root.go+1
@@ -256,6 +256,7 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
256256 setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
257257 setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
258258 setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
259+ setupSubscriber(ctx, &wg, "history", app.History.Subscribe, ch)
259260
260261 cleanupFunc := func() {
261262 logging.Info("Cancelling all subscriptions")
cspell.json+1−1
@@ -1 +1 @@
1−{"flagWords":[],"language":"en","words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos"],"version":"0.2"}
1+{"version":"0.2","language":"en","flagWords":[],"words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos","fsext"]}
internal/db/db.go−10
@@ -78,9 +78,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
7878 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
7979 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
8080 }
81− if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil {
82− return nil, fmt.Errorf("error preparing query UpdateFile: %w", err)
83− }
8481 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
8582 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
8683 }
@@ -182,11 +179,6 @@ func (q *Queries) Close() error {
182179 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
183180 }
184181 }
185− if q.updateFileStmt != nil {
186− if cerr := q.updateFileStmt.Close(); cerr != nil {
187− err = fmt.Errorf("error closing updateFileStmt: %w", cerr)
188− }
189− }
190182 if q.updateMessageStmt != nil {
191183 if cerr := q.updateMessageStmt.Close(); cerr != nil {
192184 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -254,7 +246,6 @@ type Queries struct {
254246 listMessagesBySessionStmt *sql.Stmt
255247 listNewFilesStmt *sql.Stmt
256248 listSessionsStmt *sql.Stmt
257− updateFileStmt *sql.Stmt
258249 updateMessageStmt *sql.Stmt
259250 updateSessionStmt *sql.Stmt
260251}
@@ -281,7 +272,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
281272 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
282273 listNewFilesStmt: q.listNewFilesStmt,
283274 listSessionsStmt: q.listSessionsStmt,
284− updateFileStmt: q.updateFileStmt,
285275 updateMessageStmt: q.updateMessageStmt,
286276 updateSessionStmt: q.updateSessionStmt,
287277 }
internal/db/files.sql.go+7−38
@@ -29,7 +29,7 @@ type CreateFileParams struct {
2929 SessionID string `json:"session_id"`
3030 Path string `json:"path"`
3131 Content string `json:"content"`
32− Version string `json:"version"`
32+ Version int64 `json:"version"`
3333}
3434
3535func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) {
@@ -98,7 +98,7 @@ const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
9898SELECT id, session_id, path, content, version, created_at, updated_at
9999FROM files
100100WHERE path = ? AND session_id = ?
101−ORDER BY created_at DESC
101+ORDER BY version DESC, created_at DESC
102102LIMIT 1
103103`
104104
@@ -126,7 +126,7 @@ const listFilesByPath = `-- name: ListFilesByPath :many
126126SELECT id, session_id, path, content, version, created_at, updated_at
127127FROM files
128128WHERE path = ?
129−ORDER BY created_at DESC
129+ORDER BY version DESC, created_at DESC
130130`
131131
132132func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, error) {
@@ -164,7 +164,7 @@ const listFilesBySession = `-- name: ListFilesBySession :many
164164SELECT id, session_id, path, content, version, created_at, updated_at
165165FROM files
166166WHERE session_id = ?
167−ORDER BY created_at ASC
167+ORDER BY version ASC, created_at ASC
168168`
169169
170170func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) {
@@ -202,10 +202,10 @@ const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
202202SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
203203FROM files f
204204INNER JOIN (
205− SELECT path, MAX(created_at) as max_created_at
205+ SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
206206 FROM files
207207 GROUP BY path
208−) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
208+) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
209209WHERE f.session_id = ?
210210ORDER BY f.path
211211`
@@ -245,7 +245,7 @@ const listNewFiles = `-- name: ListNewFiles :many
245245SELECT id, session_id, path, content, version, created_at, updated_at
246246FROM files
247247WHERE is_new = 1
248−ORDER BY created_at DESC
248+ORDER BY version DESC, created_at DESC
249249`
250250
251251func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
@@ -278,34 +278,3 @@ func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
278278 }
279279 return items, nil
280280}
281−
282−const updateFile = `-- name: UpdateFile :one
283−UPDATE files
284−SET
285− content = ?,
286− version = ?,
287− updated_at = strftime('%s', 'now')
288−WHERE id = ?
289−RETURNING id, session_id, path, content, version, created_at, updated_at
290−`
291−
292−type UpdateFileParams struct {
293− Content string `json:"content"`
294− Version string `json:"version"`
295− ID string `json:"id"`
296−}
297−
298−func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) {
299− row := q.queryRow(ctx, q.updateFileStmt, updateFile, arg.Content, arg.Version, arg.ID)
300− var i File
301− err := row.Scan(
302− &i.ID,
303− &i.SessionID,
304− &i.Path,
305− &i.Content,
306− &i.Version,
307− &i.CreatedAt,
308− &i.UpdatedAt,
309− )
310− return i, err
311−}
internal/db/migrations/20250424200609_initial.sql+1−1
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS files (
2626 session_id TEXT NOT NULL,
2727 path TEXT NOT NULL,
2828 content TEXT NOT NULL,
29− version TEXT NOT NULL,
29+ version INTEGER NOT NULL DEFAULT 0,
3030 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
3131 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
3232 FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
internal/db/models.go+1−1
@@ -13,7 +13,7 @@ type File struct {
1313 SessionID string `json:"session_id"`
1414 Path string `json:"path"`
1515 Content string `json:"content"`
16− Version string `json:"version"`
16+ Version int64 `json:"version"`
1717 CreatedAt int64 `json:"created_at"`
1818 UpdatedAt int64 `json:"updated_at"`
1919}
internal/db/querier.go−1
@@ -27,7 +27,6 @@ type Querier interface {
2727 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
2828 ListNewFiles(ctx context.Context) ([]File, error)
2929 ListSessions(ctx context.Context) ([]Session, error)
30− UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
3130 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
3231 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
3332}
internal/db/sql/files.sql+6−15
@@ -7,20 +7,20 @@ WHERE id = ? LIMIT 1;
77SELECT *
88FROM files
99WHERE path = ? AND session_id = ?
10−ORDER BY created_at DESC
10+ORDER BY version DESC, created_at DESC
1111LIMIT 1;
1212
1313-- name: ListFilesBySession :many
1414SELECT *
1515FROM files
1616WHERE session_id = ?
17−ORDER BY created_at ASC;
17+ORDER BY version ASC, created_at ASC;
1818
1919-- name: ListFilesByPath :many
2020SELECT *
2121FROM files
2222WHERE path = ?
23−ORDER BY created_at DESC;
23+ORDER BY version DESC, created_at DESC;
2424
2525-- name: CreateFile :one
2626INSERT INTO files (
@@ -36,15 +36,6 @@ INSERT INTO files (
3636)
3737RETURNING *;
3838
39−-- name: UpdateFile :one
40−UPDATE files
41−SET
42− content = ?,
43− version = ?,
44− updated_at = strftime('%s', 'now')
45−WHERE id = ?
46−RETURNING *;
47−
4839-- name: DeleteFile :exec
4940DELETE FROM files
5041WHERE id = ?;
@@ -57,10 +48,10 @@ WHERE session_id = ?;
5748SELECT f.*
5849FROM files f
5950INNER JOIN (
60− SELECT path, MAX(created_at) as max_created_at
51+ SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
6152 FROM files
6253 GROUP BY path
63−) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
54+) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
6455WHERE f.session_id = ?
6556ORDER BY f.path;
6657
@@ -68,4 +59,4 @@ ORDER BY f.path;
6859SELECT *
6960FROM files
7061WHERE is_new = 1
71−ORDER BY created_at DESC;
62+ORDER BY version DESC, created_at DESC;
internal/history/file.go+7−49
@@ -4,9 +4,7 @@ import (
44 "context"
55 "database/sql"
66 "fmt"
7− "strconv"
87 "strings"
9− "time"
108
119 "github.com/charmbracelet/crush/internal/db"
1210 "github.com/charmbracelet/crush/internal/pubsub"
@@ -14,7 +12,7 @@ import (
1412)
1513
1614const (
17− InitialVersion = "initial"
15+ InitialVersion = 0
1816)
1917
2018type File struct {
@@ -22,7 +20,7 @@ type File struct {
2220 SessionID string
2321 Path string
2422 Content string
25− Version string
23+ Version int64
2624 CreatedAt int64
2725 UpdatedAt int64
2826}
@@ -35,7 +33,6 @@ type Service interface {
3533 GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
3634 ListBySession(ctx context.Context, sessionID string) ([]File, error)
3735 ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
38− Update(ctx context.Context, file File) (File, error)
3936 Delete(ctx context.Context, id string) error
4037 DeleteSessionFiles(ctx context.Context, sessionID string) error
4138}
@@ -71,30 +68,13 @@ func (s *service) CreateVersion(ctx context.Context, sessionID, path, content st
7168 }
7269
7370 // Get the latest version
74− latestFile := files[0] // Files are ordered by created_at DESC
75− latestVersion := latestFile.Version
76−
77− // Generate the next version
78− var nextVersion string
79− if latestVersion == InitialVersion {
80− nextVersion = "v1"
81− } else if strings.HasPrefix(latestVersion, "v") {
82− versionNum, err := strconv.Atoi(latestVersion[1:])
83− if err != nil {
84− // If we can't parse the version, just use a timestamp-based version
85− nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
86− } else {
87− nextVersion = fmt.Sprintf("v%d", versionNum+1)
88− }
89− } else {
90− // If the version format is unexpected, use a timestamp-based version
91− nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
92− }
71+ latestFile := files[0] // Files are ordered by version DESC, created_at DESC
72+ nextVersion := latestFile.Version + 1
9373
9474 return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
9575}
9676
97−func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
77+func (s *service) createWithVersion(ctx context.Context, sessionID, path, content string, version int64) (File, error) {
9878 // Maximum number of retries for transaction conflicts
9979 const maxRetries = 3
10080 var file File
@@ -126,16 +106,8 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
126106 // Check if this is a uniqueness constraint violation
127107 if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
128108 if attempt < maxRetries-1 {
129− // If we have retries left, generate a new version and try again
130− if strings.HasPrefix(version, "v") {
131− versionNum, parseErr := strconv.Atoi(version[1:])
132− if parseErr == nil {
133− version = fmt.Sprintf("v%d", versionNum+1)
134− continue
135− }
136− }
137− // If we can't parse the version, use a timestamp-based version
138− version = fmt.Sprintf("v%d", time.Now().Unix())
109+ // If we have retries left, increment version and try again
110+ version++
139111 continue
140112 }
141113 }
@@ -198,20 +170,6 @@ func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string)
198170 return files, nil
199171}
200172
201−func (s *service) Update(ctx context.Context, file File) (File, error) {
202− dbFile, err := s.q.UpdateFile(ctx, db.UpdateFileParams{
203− ID: file.ID,
204− Content: file.Content,
205− Version: file.Version,
206− })
207− if err != nil {
208− return File{}, err
209− }
210− updatedFile := s.fromDBItem(dbFile)
211− s.Publish(pubsub.UpdatedEvent, updatedFile)
212− return updatedFile, nil
213−}
214−
215173func (s *service) Delete(ctx context.Context, id string) error {
216174 file, err := s.Get(ctx, id)
217175 if err != nil {
internal/llm/tools/shell/comparison_test.go−1
@@ -31,7 +31,6 @@ func TestShellPerformanceComparison(t *testing.T) {
3131 t.Logf("Quick command took: %v", duration)
3232}
3333
34−
3534// Benchmark CPU usage during polling
3635func BenchmarkShellPolling(b *testing.B) {
3736 tmpDir, err := os.MkdirTemp("", "shell-bench")
internal/tui/components/chat/sidebar/sidebar.go+83−26
@@ -4,7 +4,9 @@ import (
44 "context"
55 "fmt"
66 "os"
7+ "sort"
78 "strings"
9+ "sync"
810
911 tea "github.com/charmbracelet/bubbletea/v2"
1012 "github.com/charmbracelet/crush/internal/config"
@@ -32,7 +34,13 @@ const (
3234 logoBreakpoint = 65
3335)
3436
37+type FileHistory struct {
38+ initialVersion history.File
39+ latestVersion history.File
40+}
41+
3542type SessionFile struct {
43+ History FileHistory
3644 FilePath string
3745 Additions int
3846 Deletions int
@@ -53,7 +61,8 @@ type sidebarCmp struct {
5361 cwd string
5462 lspClients map[string]*lsp.Client
5563 history history.Service
56− files []SessionFile
64+ // Using a sync map here because we might receive file history events concurrently
65+ files sync.Map
5766}
5867
5968func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client) Sidebar {
@@ -77,12 +86,17 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7786 }
7887 return m, m.loadSessionFiles
7988 case SessionFilesMsg:
80− m.files = msg.Files
81− logging.Info("Loaded session files", "count", len(m.files))
89+ m.files = sync.Map{}
90+ for _, file := range msg.Files {
91+ m.files.Store(file.FilePath, file)
92+ }
8293 return m, nil
8394
8495 case chat.SessionClearedMsg:
8596 m.session = session.Session{}
97+ case pubsub.Event[history.File]:
98+ logging.Info("sidebar", "Received file history event", "file", msg.Payload.Path, "session", msg.Payload.SessionID)
99+ return m, m.handleFileHistoryEvent(msg)
86100 case pubsub.Event[session.Session]:
87101 if msg.Type == pubsub.UpdatedEvent {
88102 if m.session.ID == msg.Payload.ID {
@@ -123,6 +137,50 @@ func (m *sidebarCmp) View() tea.View {
123137 )
124138}
125139
140+func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
141+ return func() tea.Msg {
142+ file := event.Payload
143+ found := false
144+ m.files.Range(func(key, value any) bool {
145+ existing := value.(SessionFile)
146+ if existing.FilePath == file.Path {
147+ if existing.History.latestVersion.Version < file.Version {
148+ existing.History.latestVersion = file
149+ } else if file.Version == 0 {
150+ existing.History.initialVersion = file
151+ } else {
152+ // If the version is not greater than the latest, we ignore it
153+ return true
154+ }
155+ before := existing.History.initialVersion.Content
156+ after := existing.History.latestVersion.Content
157+ path := existing.History.initialVersion.Path
158+ _, additions, deletions := diff.GenerateDiff(before, after, path)
159+ existing.Additions = additions
160+ existing.Deletions = deletions
161+ m.files.Store(file.Path, existing)
162+ found = true
163+ return false
164+ }
165+ return true
166+ })
167+ if found {
168+ return nil
169+ }
170+ sf := SessionFile{
171+ History: FileHistory{
172+ initialVersion: file,
173+ latestVersion: file,
174+ },
175+ FilePath: file.Path,
176+ Additions: 0,
177+ Deletions: 0,
178+ }
179+ m.files.Store(file.Path, sf)
180+ return nil
181+ }
182+}
183+
126184func (m *sidebarCmp) loadSessionFiles() tea.Msg {
127185 files, err := m.history.ListBySession(context.Background(), m.session.ID)
128186 if err != nil {
@@ -132,26 +190,16 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
132190 }
133191 }
134192
135− type fileHistory struct {
136− initialVersion history.File
137− latestVersion history.File
138− }
139−
140− fileMap := make(map[string]fileHistory)
193+ fileMap := make(map[string]FileHistory)
141194
142195 for _, file := range files {
143196 if existing, ok := fileMap[file.Path]; ok {
144197 // Update the latest version
145− if existing.latestVersion.CreatedAt < file.CreatedAt {
146− existing.latestVersion = file
147− }
148− if file.Version == history.InitialVersion {
149− existing.initialVersion = file
150− }
198+ existing.latestVersion = file
151199 fileMap[file.Path] = existing
152200 } else {
153201 // Add the initial version
154− fileMap[file.Path] = fileHistory{
202+ fileMap[file.Path] = FileHistory{
155203 initialVersion: file,
156204 latestVersion: file,
157205 }
@@ -160,14 +208,13 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
160208
161209 sessionFiles := make([]SessionFile, 0, len(fileMap))
162210 for path, fh := range fileMap {
163− if fh.initialVersion.Version == history.InitialVersion {
164− _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
165− sessionFiles = append(sessionFiles, SessionFile{
166− FilePath: path,
167− Additions: additions,
168− Deletions: deletions,
169− })
170− }
211+ _, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
212+ sessionFiles = append(sessionFiles, SessionFile{
213+ History: fh,
214+ FilePath: path,
215+ Additions: additions,
216+ Deletions: deletions,
217+ })
171218 }
172219
173220 return SessionFilesMsg{
@@ -210,7 +257,13 @@ func (m *sidebarCmp) filesBlock() string {
210257 core.Section("Modified Files", maxWidth),
211258 )
212259
213− if len(m.files) == 0 {
260+ files := make([]SessionFile, 0)
261+ m.files.Range(func(key, value any) bool {
262+ file := value.(SessionFile)
263+ files = append(files, file)
264+ return true // continue iterating
265+ })
266+ if len(files) == 0 {
214267 return lipgloss.JoinVertical(
215268 lipgloss.Left,
216269 section,
@@ -220,8 +273,12 @@ func (m *sidebarCmp) filesBlock() string {
220273 }
221274
222275 fileList := []string{section, ""}
276+ // order files by the latest version's created time
277+ sort.Slice(files, func(i, j int) bool {
278+ return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
279+ })
223280
224− for _, file := range m.files {
281+ for _, file := range files {
225282 // Extract just the filename from the path
226283
227284 // Create status indicators for additions/deletions
todos.md+3−1
@@ -5,7 +5,9 @@
55 - [x] Make help dependent on the focused pane and page
66- [x] Implement current model in the sidebar
77- [x] Implement LSP errors
8−- [ ] Implement changed files
8+- [x] Implement changed files
9+ - [x] Implement initial load
10+ - [x] Implement realtime file changes
911- [ ] Events when tool error
1012- [ ] Support bash commands
1113- [ ] Editor attachments fixes