feat: properly handle file history

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/agent/tools/delete.go                  |  50 +++++--
internal/tui/components/chat/sidebar/sidebar.go | 118 +++++++++++++-----
2 files changed, 118 insertions(+), 50 deletions(-)

Detailed changes

internal/agent/tools/delete.go 🔗

@@ -90,12 +90,14 @@ func NewDeleteTool(lspClients *csync.Map[string, *lsp.Client], permissions permi
 				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 			}
 
+			// Save file content to history BEFORE deleting.
+			saveDeletedFileHistory(ctx, files, sessionID, filePath, isDir)
+
 			if err := os.RemoveAll(filePath); err != nil {
 				return fantasy.ToolResponse{}, fmt.Errorf("error deleting path: %w", err)
 			}
 
 			lspCloseAndDeleteFiles(ctx, lspClients, filePath, isDir)
-			deleteFileHistory(ctx, files, sessionID, filePath, isDir)
 			return fantasy.NewTextResponse(fmt.Sprintf("Successfully deleted: %s", filePath)), nil
 		})
 }
@@ -136,21 +138,43 @@ func lspCloseAndDeleteFiles(ctx context.Context, lsps *csync.Map[string, *lsp.Cl
 	}
 }
 
-func deleteFileHistory(ctx context.Context, files history.Service, sessionID, filePath string, isDir bool) {
-	sessionFiles, err := files.ListLatestSessionFiles(ctx, sessionID)
-	if err != nil {
+func saveDeletedFileHistory(ctx context.Context, files history.Service, sessionID, filePath string, isDir bool) {
+	if isDir {
+		// For directories, walk through and save all files.
+		_ = filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error {
+			if err != nil || info.IsDir() {
+				return nil
+			}
+			saveFileBeforeDeletion(ctx, files, sessionID, path)
+			return nil
+		})
+	} else {
+		// For single file.
+		saveFileBeforeDeletion(ctx, files, sessionID, filePath)
+	}
+}
+
+func saveFileBeforeDeletion(ctx context.Context, files history.Service, sessionID, filePath string) {
+	// Check if file already exists in history.
+	existing, err := files.GetByPathAndSession(ctx, filePath, sessionID)
+	if err == nil && existing.Path != "" {
+		// File exists in history, create empty version to show deletion.
+		_, _ = files.CreateVersion(ctx, sessionID, filePath, "")
 		return
 	}
 
-	for _, file := range sessionFiles {
-		if !shouldDeletePath(file.Path, filePath, isDir) {
-			continue
-		}
+	// File not in history, read content and create initial + empty version.
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return
+	}
 
-		fileEntry, err := files.GetByPathAndSession(ctx, file.Path, sessionID)
-		if err != nil {
-			continue
-		}
-		_ = files.Delete(ctx, fileEntry.ID)
+	// Create initial version with current content.
+	_, err = files.Create(ctx, sessionID, filePath, string(content))
+	if err != nil {
+		return
 	}
+
+	// Create empty version to show deletion.
+	_, _ = files.CreateVersion(ctx, sessionID, filePath, "")
 }

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

@@ -180,49 +180,93 @@ func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) te
 		file := event.Payload
 
 		if event.Type == pubsub.DeletedEvent {
-			m.files.Del(file.Path)
-			return nil
-		}
-		found := false
-		for existing := range m.files.Seq() {
-			if existing.FilePath != file.Path {
-				continue
-			}
-			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
-				continue
-			}
-			before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
-			after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
-			path := existing.History.initialVersion.Path
-			cwd := config.Get().WorkingDir()
-			path = strings.TrimPrefix(path, cwd)
-			_, additions, deletions := diff.GenerateDiff(before, after, path)
-			existing.Additions = additions
-			existing.Deletions = deletions
-			m.files.Set(file.Path, existing)
-			found = true
-			break
+			return m.handleFileDeleted(file)
 		}
-		if found {
+
+		existing, found := m.files.Get(file.Path)
+		if !found {
+			m.files.Set(file.Path, SessionFile{
+				History: FileHistory{
+					initialVersion: file,
+					latestVersion:  file,
+				},
+				FilePath: file.Path,
+			})
 			return nil
 		}
-		sf := SessionFile{
-			History: FileHistory{
-				initialVersion: file,
-				latestVersion:  file,
-			},
-			FilePath:  file.Path,
-			Additions: 0,
-			Deletions: 0,
+
+		if !m.shouldUpdateFileVersion(existing, file) {
+			return nil
 		}
-		m.files.Set(file.Path, sf)
+
+		m.updateFileVersion(&existing, file)
+		m.recalculateFileDiff(&existing)
+		m.files.Set(file.Path, existing)
+		return nil
+	}
+}
+
+func (m *sidebarCmp) handleFileDeleted(file history.File) tea.Msg {
+	existing, found := m.files.Get(file.Path)
+	if !found {
 		return nil
 	}
+
+	if existing.History.initialVersion.Content == "" {
+		m.files.Del(file.Path)
+		return nil
+	}
+
+	existing.History.latestVersion = history.File{
+		ID:        file.ID,
+		SessionID: file.SessionID,
+		Path:      file.Path,
+		Content:   "",
+		Version:   file.Version,
+		CreatedAt: file.CreatedAt,
+		UpdatedAt: file.UpdatedAt,
+	}
+
+	m.recalculateFileDiff(&existing)
+	m.files.Set(file.Path, existing)
+	return nil
+}
+
+func (m *sidebarCmp) shouldUpdateFileVersion(existing SessionFile, file history.File) bool {
+	if existing.FilePath != file.Path {
+		return false
+	}
+
+	if existing.History.latestVersion.Version < file.Version {
+		return true
+	}
+
+	if file.Version == 0 {
+		return true
+	}
+
+	return false
+}
+
+func (m *sidebarCmp) updateFileVersion(existing *SessionFile, file history.File) {
+	if existing.History.latestVersion.Version < file.Version {
+		existing.History.latestVersion = file
+	} else if file.Version == 0 {
+		existing.History.initialVersion = file
+	}
+}
+
+func (m *sidebarCmp) recalculateFileDiff(existing *SessionFile) {
+	before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
+	after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
+
+	path := existing.History.initialVersion.Path
+	cwd := config.Get().WorkingDir()
+	path = strings.TrimPrefix(path, cwd)
+
+	_, additions, deletions := diff.GenerateDiff(before, after, path)
+	existing.Additions = additions
+	existing.Deletions = deletions
 }
 
 func (m *sidebarCmp) loadSessionFiles() tea.Msg {