diff --git a/cmd/root.go b/cmd/root.go index a29991e566e851181c332837f07200f1730fc249..9356ac271eb154989ccd690a21cd22d73d78b797 100644 --- a/cmd/root.go +++ b/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") diff --git a/cspell.json b/cspell.json index afdb1e5275851972ef8d0cf2c8503fe9f2f26323..266001569d7d8dfb6713c634286f406ae04b03b1 100644 --- a/cspell.json +++ b/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"} \ No newline at end of file +{"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"]} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index 5badad3a280eb9e11ae0b6a9d068f8f9efb937b6..62ebe0134c683f2a3f69d26ea3f826c9bbf02d14 100644 --- a/internal/db/db.go +++ b/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, } diff --git a/internal/db/files.sql.go b/internal/db/files.sql.go index 28abaa55d736b6eeefb721b69f4bcc7fceb4af37..a52516d20edb189e476ad41bbc7486b2ea8cc18b 100644 --- a/internal/db/files.sql.go +++ b/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 -} diff --git a/internal/db/migrations/20250424200609_initial.sql b/internal/db/migrations/20250424200609_initial.sql index 02caecf0c72a08a6fac44b1b96cb9150f1e07d46..094bf91e990baf76d751e57791d9e32429874b9e 100644 --- a/internal/db/migrations/20250424200609_initial.sql +++ b/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, diff --git a/internal/db/models.go b/internal/db/models.go index 07549024a230dc357a7f57d69c42440336065a9a..ec19f99b213e041331b5d6a14dee3648bc14c1de 100644 --- a/internal/db/models.go +++ b/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"` } diff --git a/internal/db/querier.go b/internal/db/querier.go index 257012526e54fd08065df410e207bee2a126b9c0..472137273387d85a83a27260037321adccc9230f 100644 --- a/internal/db/querier.go +++ b/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) } diff --git a/internal/db/sql/files.sql b/internal/db/sql/files.sql index aba2a61111088ef7362753dc7b43c79769428473..132a2821f0f9d971c994edcdd84023cb7c7ee1d2 100644 --- a/internal/db/sql/files.sql +++ b/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; diff --git a/internal/history/file.go b/internal/history/file.go index cf1b92bd436f93e49757dfe1ee6b8cddeef891d3..d8fe6088626be28262f06485c07c95693ddfd219 100644 --- a/internal/history/file.go +++ b/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 { diff --git a/internal/llm/tools/shell/comparison_test.go b/internal/llm/tools/shell/comparison_test.go index 7cfe28034bb5d53148cfef0f4815dd685dd597e4..550714f9f5b3c48d4bdc5ab364badef9d7fb274b 100644 --- a/internal/llm/tools/shell/comparison_test.go +++ b/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") diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 8adc223d5bbc1edb49fee551d894ffb9716510ed..7f408c440d6db6ab7d5444e86243516c4dad1d94 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/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 diff --git a/todos.md b/todos.md index 2a9cb74fcdf93302d0eb823c9692cf8b2c22c7ca..65ece5a7880530a1622f1b64aeaef85f3d6702bf 100644 --- a/todos.md +++ b/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