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

This commit is contained in:
Kujtim Hoxha
2025-06-17 14:03:12 +02:00
parent d7246a9e7c
commit ac5fb91e20
12 changed files with 110 additions and 144 deletions

View File

@@ -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")

View File

@@ -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"]}

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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

View File

@@ -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