fix: fix the file history table and implement realtime file updates

Kujtim Hoxha created

Change summary

cmd/root.go                                       |   1 
cspell.json                                       |   2 
internal/db/db.go                                 |  10 -
internal/db/files.sql.go                          |  45 +-----
internal/db/migrations/20250424200609_initial.sql |   2 
internal/db/models.go                             |   2 
internal/db/querier.go                            |   1 
internal/db/sql/files.sql                         |  21 --
internal/history/file.go                          |  56 +-------
internal/llm/tools/shell/comparison_test.go       |   1 
internal/tui/components/chat/sidebar/sidebar.go   | 109 ++++++++++++----
todos.md                                          |   4 
12 files changed, 110 insertions(+), 144 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -256,6 +256,7 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
 	setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
 	setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
 	setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
+	setupSubscriber(ctx, &wg, "history", app.History.Subscribe, ch)
 
 	cleanupFunc := func() {
 		logging.Info("Cancelling all subscriptions")

cspell.json 🔗

@@ -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"}
+{"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 🔗

@@ -78,9 +78,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
 		return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
 	}
-	if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil {
-		return nil, fmt.Errorf("error preparing query UpdateFile: %w", err)
-	}
 	if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
 		return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
 	}
@@ -182,11 +179,6 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
 		}
 	}
-	if q.updateFileStmt != nil {
-		if cerr := q.updateFileStmt.Close(); cerr != nil {
-			err = fmt.Errorf("error closing updateFileStmt: %w", cerr)
-		}
-	}
 	if q.updateMessageStmt != nil {
 		if cerr := q.updateMessageStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -254,7 +246,6 @@ type Queries struct {
 	listMessagesBySessionStmt   *sql.Stmt
 	listNewFilesStmt            *sql.Stmt
 	listSessionsStmt            *sql.Stmt
-	updateFileStmt              *sql.Stmt
 	updateMessageStmt           *sql.Stmt
 	updateSessionStmt           *sql.Stmt
 }
@@ -281,7 +272,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		listMessagesBySessionStmt:   q.listMessagesBySessionStmt,
 		listNewFilesStmt:            q.listNewFilesStmt,
 		listSessionsStmt:            q.listSessionsStmt,
-		updateFileStmt:              q.updateFileStmt,
 		updateMessageStmt:           q.updateMessageStmt,
 		updateSessionStmt:           q.updateSessionStmt,
 	}

internal/db/files.sql.go 🔗

@@ -29,7 +29,7 @@ type CreateFileParams struct {
 	SessionID string `json:"session_id"`
 	Path      string `json:"path"`
 	Content   string `json:"content"`
-	Version   string `json:"version"`
+	Version   int64  `json:"version"`
 }
 
 func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) {
@@ -98,7 +98,7 @@ const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
 SELECT id, session_id, path, content, version, created_at, updated_at
 FROM files
 WHERE path = ? AND session_id = ?
-ORDER BY created_at DESC
+ORDER BY version DESC, created_at DESC
 LIMIT 1
 `
 
@@ -126,7 +126,7 @@ const listFilesByPath = `-- name: ListFilesByPath :many
 SELECT id, session_id, path, content, version, created_at, updated_at
 FROM files
 WHERE path = ?
-ORDER BY created_at DESC
+ORDER BY version DESC, created_at DESC
 `
 
 func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, error) {
@@ -164,7 +164,7 @@ const listFilesBySession = `-- name: ListFilesBySession :many
 SELECT id, session_id, path, content, version, created_at, updated_at
 FROM files
 WHERE session_id = ?
-ORDER BY created_at ASC
+ORDER BY version ASC, created_at ASC
 `
 
 func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) {
@@ -202,10 +202,10 @@ const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
 SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
 FROM files f
 INNER JOIN (
-    SELECT path, MAX(created_at) as max_created_at
+    SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
     FROM files
     GROUP BY path
-) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
+) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
 WHERE f.session_id = ?
 ORDER BY f.path
 `
@@ -245,7 +245,7 @@ const listNewFiles = `-- name: ListNewFiles :many
 SELECT id, session_id, path, content, version, created_at, updated_at
 FROM files
 WHERE is_new = 1
-ORDER BY created_at DESC
+ORDER BY version DESC, created_at DESC
 `
 
 func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
@@ -278,34 +278,3 @@ func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
 	}
 	return items, nil
 }
-
-const updateFile = `-- name: UpdateFile :one
-UPDATE files
-SET
-    content = ?,
-    version = ?,
-    updated_at = strftime('%s', 'now')
-WHERE id = ?
-RETURNING id, session_id, path, content, version, created_at, updated_at
-`
-
-type UpdateFileParams struct {
-	Content string `json:"content"`
-	Version string `json:"version"`
-	ID      string `json:"id"`
-}
-
-func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) {
-	row := q.queryRow(ctx, q.updateFileStmt, updateFile, arg.Content, arg.Version, arg.ID)
-	var i File
-	err := row.Scan(
-		&i.ID,
-		&i.SessionID,
-		&i.Path,
-		&i.Content,
-		&i.Version,
-		&i.CreatedAt,
-		&i.UpdatedAt,
-	)
-	return i, err
-}

internal/db/migrations/20250424200609_initial.sql 🔗

@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS files (
     session_id TEXT NOT NULL,
     path TEXT NOT NULL,
     content TEXT NOT NULL,
-    version TEXT NOT NULL,
+    version INTEGER NOT NULL DEFAULT 0,
     created_at INTEGER NOT NULL,  -- Unix timestamp in milliseconds
     updated_at INTEGER NOT NULL,  -- Unix timestamp in milliseconds
     FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,

internal/db/models.go 🔗

@@ -13,7 +13,7 @@ type File struct {
 	SessionID string `json:"session_id"`
 	Path      string `json:"path"`
 	Content   string `json:"content"`
-	Version   string `json:"version"`
+	Version   int64  `json:"version"`
 	CreatedAt int64  `json:"created_at"`
 	UpdatedAt int64  `json:"updated_at"`
 }

internal/db/querier.go 🔗

@@ -27,7 +27,6 @@ type Querier interface {
 	ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
 	ListNewFiles(ctx context.Context) ([]File, error)
 	ListSessions(ctx context.Context) ([]Session, error)
-	UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
 	UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
 	UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
 }

internal/db/sql/files.sql 🔗

@@ -7,20 +7,20 @@ WHERE id = ? LIMIT 1;
 SELECT *
 FROM files
 WHERE path = ? AND session_id = ?
-ORDER BY created_at DESC
+ORDER BY version DESC, created_at DESC
 LIMIT 1;
 
 -- name: ListFilesBySession :many
 SELECT *
 FROM files
 WHERE session_id = ?
-ORDER BY created_at ASC;
+ORDER BY version ASC, created_at ASC;
 
 -- name: ListFilesByPath :many
 SELECT *
 FROM files
 WHERE path = ?
-ORDER BY created_at DESC;
+ORDER BY version DESC, created_at DESC;
 
 -- name: CreateFile :one
 INSERT INTO files (
@@ -36,15 +36,6 @@ INSERT INTO files (
 )
 RETURNING *;
 
--- name: UpdateFile :one
-UPDATE files
-SET
-    content = ?,
-    version = ?,
-    updated_at = strftime('%s', 'now')
-WHERE id = ?
-RETURNING *;
-
 -- name: DeleteFile :exec
 DELETE FROM files
 WHERE id = ?;
@@ -57,10 +48,10 @@ WHERE session_id = ?;
 SELECT f.*
 FROM files f
 INNER JOIN (
-    SELECT path, MAX(created_at) as max_created_at
+    SELECT path, MAX(version) as max_version, MAX(created_at) as max_created_at
     FROM files
     GROUP BY path
-) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
+) latest ON f.path = latest.path AND f.version = latest.max_version AND f.created_at = latest.max_created_at
 WHERE f.session_id = ?
 ORDER BY f.path;
 
@@ -68,4 +59,4 @@ ORDER BY f.path;
 SELECT *
 FROM files
 WHERE is_new = 1
-ORDER BY created_at DESC;
+ORDER BY version DESC, created_at DESC;

internal/history/file.go 🔗

@@ -4,9 +4,7 @@ import (
 	"context"
 	"database/sql"
 	"fmt"
-	"strconv"
 	"strings"
-	"time"
 
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/pubsub"
@@ -14,7 +12,7 @@ import (
 )
 
 const (
-	InitialVersion = "initial"
+	InitialVersion = 0
 )
 
 type File struct {
@@ -22,7 +20,7 @@ type File struct {
 	SessionID string
 	Path      string
 	Content   string
-	Version   string
+	Version   int64
 	CreatedAt int64
 	UpdatedAt int64
 }
@@ -35,7 +33,6 @@ type Service interface {
 	GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
 	ListBySession(ctx context.Context, sessionID string) ([]File, error)
 	ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
-	Update(ctx context.Context, file File) (File, error)
 	Delete(ctx context.Context, id string) error
 	DeleteSessionFiles(ctx context.Context, sessionID string) error
 }
@@ -71,30 +68,13 @@ func (s *service) CreateVersion(ctx context.Context, sessionID, path, content st
 	}
 
 	// Get the latest version
-	latestFile := files[0] // Files are ordered by created_at DESC
-	latestVersion := latestFile.Version
-
-	// Generate the next version
-	var nextVersion string
-	if latestVersion == InitialVersion {
-		nextVersion = "v1"
-	} else if strings.HasPrefix(latestVersion, "v") {
-		versionNum, err := strconv.Atoi(latestVersion[1:])
-		if err != nil {
-			// If we can't parse the version, just use a timestamp-based version
-			nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
-		} else {
-			nextVersion = fmt.Sprintf("v%d", versionNum+1)
-		}
-	} else {
-		// If the version format is unexpected, use a timestamp-based version
-		nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
-	}
+	latestFile := files[0] // Files are ordered by version DESC, created_at DESC
+	nextVersion := latestFile.Version + 1
 
 	return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
 }
 
-func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
+func (s *service) createWithVersion(ctx context.Context, sessionID, path, content string, version int64) (File, error) {
 	// Maximum number of retries for transaction conflicts
 	const maxRetries = 3
 	var file File
@@ -126,16 +106,8 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
 			// Check if this is a uniqueness constraint violation
 			if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
 				if attempt < maxRetries-1 {
-					// If we have retries left, generate a new version and try again
-					if strings.HasPrefix(version, "v") {
-						versionNum, parseErr := strconv.Atoi(version[1:])
-						if parseErr == nil {
-							version = fmt.Sprintf("v%d", versionNum+1)
-							continue
-						}
-					}
-					// If we can't parse the version, use a timestamp-based version
-					version = fmt.Sprintf("v%d", time.Now().Unix())
+					// If we have retries left, increment version and try again
+					version++
 					continue
 				}
 			}
@@ -198,20 +170,6 @@ func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string)
 	return files, nil
 }
 
-func (s *service) Update(ctx context.Context, file File) (File, error) {
-	dbFile, err := s.q.UpdateFile(ctx, db.UpdateFileParams{
-		ID:      file.ID,
-		Content: file.Content,
-		Version: file.Version,
-	})
-	if err != nil {
-		return File{}, err
-	}
-	updatedFile := s.fromDBItem(dbFile)
-	s.Publish(pubsub.UpdatedEvent, updatedFile)
-	return updatedFile, nil
-}
-
 func (s *service) Delete(ctx context.Context, id string) error {
 	file, err := s.Get(ctx, id)
 	if err != nil {

internal/llm/tools/shell/comparison_test.go 🔗

@@ -31,7 +31,6 @@ func TestShellPerformanceComparison(t *testing.T) {
 	t.Logf("Quick command took: %v", duration)
 }
 
-
 // Benchmark CPU usage during polling
 func BenchmarkShellPolling(b *testing.B) {
 	tmpDir, err := os.MkdirTemp("", "shell-bench")

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

@@ -4,7 +4,9 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"sort"
 	"strings"
+	"sync"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
@@ -32,7 +34,13 @@ const (
 	logoBreakpoint = 65
 )
 
+type FileHistory struct {
+	initialVersion history.File
+	latestVersion  history.File
+}
+
 type SessionFile struct {
+	History   FileHistory
 	FilePath  string
 	Additions int
 	Deletions int
@@ -53,7 +61,8 @@ type sidebarCmp struct {
 	cwd           string
 	lspClients    map[string]*lsp.Client
 	history       history.Service
-	files         []SessionFile
+	// Using a sync map here because we might receive file history events concurrently
+	files sync.Map
 }
 
 func 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) {
 		}
 		return m, m.loadSessionFiles
 	case SessionFilesMsg:
-		m.files = msg.Files
-		logging.Info("Loaded session files", "count", len(m.files))
+		m.files = sync.Map{}
+		for _, file := range msg.Files {
+			m.files.Store(file.FilePath, file)
+		}
 		return m, nil
 
 	case chat.SessionClearedMsg:
 		m.session = session.Session{}
+	case pubsub.Event[history.File]:
+		logging.Info("sidebar", "Received file history event", "file", msg.Payload.Path, "session", msg.Payload.SessionID)
+		return m, m.handleFileHistoryEvent(msg)
 	case pubsub.Event[session.Session]:
 		if msg.Type == pubsub.UpdatedEvent {
 			if m.session.ID == msg.Payload.ID {
@@ -123,6 +137,50 @@ func (m *sidebarCmp) View() tea.View {
 	)
 }
 
+func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
+	return func() tea.Msg {
+		file := event.Payload
+		found := false
+		m.files.Range(func(key, value any) bool {
+			existing := value.(SessionFile)
+			if existing.FilePath == file.Path {
+				if existing.History.latestVersion.Version < file.Version {
+					existing.History.latestVersion = file
+				} else if file.Version == 0 {
+					existing.History.initialVersion = file
+				} else {
+					// If the version is not greater than the latest, we ignore it
+					return true
+				}
+				before := existing.History.initialVersion.Content
+				after := existing.History.latestVersion.Content
+				path := existing.History.initialVersion.Path
+				_, additions, deletions := diff.GenerateDiff(before, after, path)
+				existing.Additions = additions
+				existing.Deletions = deletions
+				m.files.Store(file.Path, existing)
+				found = true
+				return false
+			}
+			return true
+		})
+		if found {
+			return nil
+		}
+		sf := SessionFile{
+			History: FileHistory{
+				initialVersion: file,
+				latestVersion:  file,
+			},
+			FilePath:  file.Path,
+			Additions: 0,
+			Deletions: 0,
+		}
+		m.files.Store(file.Path, sf)
+		return nil
+	}
+}
+
 func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 	files, err := m.history.ListBySession(context.Background(), m.session.ID)
 	if err != nil {
@@ -132,26 +190,16 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 		}
 	}
 
-	type fileHistory struct {
-		initialVersion history.File
-		latestVersion  history.File
-	}
-
-	fileMap := make(map[string]fileHistory)
+	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
-			}
+			existing.latestVersion = file
 			fileMap[file.Path] = existing
 		} else {
 			// Add the initial version
-			fileMap[file.Path] = fileHistory{
+			fileMap[file.Path] = FileHistory{
 				initialVersion: file,
 				latestVersion:  file,
 			}
@@ -160,14 +208,13 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 
 	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,
-			})
-		}
+		_, additions, deletions := diff.GenerateDiff(fh.initialVersion.Content, fh.latestVersion.Content, fh.initialVersion.Path)
+		sessionFiles = append(sessionFiles, SessionFile{
+			History:   fh,
+			FilePath:  path,
+			Additions: additions,
+			Deletions: deletions,
+		})
 	}
 
 	return SessionFilesMsg{
@@ -210,7 +257,13 @@ func (m *sidebarCmp) filesBlock() string {
 		core.Section("Modified Files", maxWidth),
 	)
 
-	if len(m.files) == 0 {
+	files := make([]SessionFile, 0)
+	m.files.Range(func(key, value any) bool {
+		file := value.(SessionFile)
+		files = append(files, file)
+		return true // continue iterating
+	})
+	if len(files) == 0 {
 		return lipgloss.JoinVertical(
 			lipgloss.Left,
 			section,
@@ -220,8 +273,12 @@ func (m *sidebarCmp) filesBlock() string {
 	}
 
 	fileList := []string{section, ""}
+	// order files by the latest version's created time
+	sort.Slice(files, func(i, j int) bool {
+		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
+	})
 
-	for _, file := range m.files {
+	for _, file := range files {
 		// Extract just the filename from the path
 
 		// Create status indicators for additions/deletions

todos.md 🔗

@@ -5,7 +5,9 @@
   - [x] Make help dependent on the focused pane and page
 - [x] Implement current model in the sidebar
 - [x] Implement LSP errors
-- [ ] Implement changed files
+- [x] Implement changed files
+  - [x] Implement initial load
+  - [x] Implement realtime file changes
 - [ ] Events when tool error
 - [ ] Support bash commands
 - [ ] Editor attachments fixes