mirror of
https://github.com/charmbracelet/crush.git
synced 2026-05-30 18:47:33 +00:00
254 lines
6.7 KiB
Go
254 lines
6.7 KiB
Go
package model
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/charmbracelet/crush/internal/diff"
|
|
"github.com/charmbracelet/crush/internal/fsext"
|
|
"github.com/charmbracelet/crush/internal/history"
|
|
"github.com/charmbracelet/crush/internal/session"
|
|
"github.com/charmbracelet/crush/internal/ui/common"
|
|
"github.com/charmbracelet/crush/internal/ui/styles"
|
|
"github.com/charmbracelet/crush/internal/ui/util"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
// loadSessionMsg is a message indicating that a session and its files have
|
|
// been loaded.
|
|
type loadSessionMsg struct {
|
|
session *session.Session
|
|
files []SessionFile
|
|
readFiles []string
|
|
}
|
|
|
|
// lspFilePaths returns deduplicated file paths from both modified and read
|
|
// files for starting LSP servers.
|
|
func (msg loadSessionMsg) lspFilePaths() []string {
|
|
seen := make(map[string]struct{}, len(msg.files)+len(msg.readFiles))
|
|
paths := make([]string, 0, len(msg.files)+len(msg.readFiles))
|
|
for _, f := range msg.files {
|
|
p := f.LatestVersion.Path
|
|
if _, ok := seen[p]; ok {
|
|
continue
|
|
}
|
|
seen[p] = struct{}{}
|
|
paths = append(paths, p)
|
|
}
|
|
for _, p := range msg.readFiles {
|
|
if _, ok := seen[p]; ok {
|
|
continue
|
|
}
|
|
seen[p] = struct{}{}
|
|
paths = append(paths, p)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// SessionFile tracks the first and latest versions of a file in a session,
|
|
// along with the total additions and deletions.
|
|
type SessionFile struct {
|
|
FirstVersion history.File
|
|
LatestVersion history.File
|
|
Additions int
|
|
Deletions int
|
|
}
|
|
|
|
// loadSession loads the session along with its associated files and computes
|
|
// the diff statistics (additions and deletions) for each file in the session.
|
|
// It returns a tea.Cmd that, when executed, fetches the session data and
|
|
// returns a sessionFilesLoadedMsg containing the processed session files.
|
|
func (m *UI) loadSession(sessionID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
|
|
if err != nil {
|
|
return util.ReportError(err)
|
|
}
|
|
|
|
sessionFiles, err := m.loadSessionFiles(sessionID)
|
|
if err != nil {
|
|
return util.ReportError(err)
|
|
}
|
|
|
|
readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
|
|
if err != nil {
|
|
slog.Error("Failed to load read files for session", "error", err)
|
|
}
|
|
|
|
return loadSessionMsg{
|
|
session: &session,
|
|
files: sessionFiles,
|
|
readFiles: readFiles,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
|
|
files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
filesByPath := make(map[string][]history.File)
|
|
for _, f := range files {
|
|
filesByPath[f.Path] = append(filesByPath[f.Path], f)
|
|
}
|
|
sessionFiles := make([]SessionFile, 0, len(filesByPath))
|
|
for _, versions := range filesByPath {
|
|
if len(versions) == 0 {
|
|
continue
|
|
}
|
|
|
|
first := versions[0]
|
|
last := versions[0]
|
|
for _, v := range versions {
|
|
if v.Version < first.Version {
|
|
first = v
|
|
}
|
|
if v.Version > last.Version {
|
|
last = v
|
|
}
|
|
}
|
|
|
|
_, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
|
|
|
|
sessionFiles = append(sessionFiles, SessionFile{
|
|
FirstVersion: first,
|
|
LatestVersion: last,
|
|
Additions: additions,
|
|
Deletions: deletions,
|
|
})
|
|
}
|
|
|
|
slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
|
|
if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
|
|
return -1
|
|
}
|
|
if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
return sessionFiles, nil
|
|
}
|
|
|
|
// handleFileEvent processes file change events and updates the session file
|
|
// list with new or updated file information.
|
|
func (m *UI) handleFileEvent(file history.File) tea.Cmd {
|
|
if m.session == nil || file.SessionID != m.session.ID {
|
|
return nil
|
|
}
|
|
|
|
return func() tea.Msg {
|
|
sessionFiles, err := m.loadSessionFiles(m.session.ID)
|
|
// could not load session files
|
|
if err != nil {
|
|
return util.NewErrorMsg(err)
|
|
}
|
|
|
|
return sessionFilesUpdatesMsg{
|
|
sessionFiles: sessionFiles,
|
|
}
|
|
}
|
|
}
|
|
|
|
// filesInfo renders the modified files section for the sidebar, showing files
|
|
// with their addition/deletion counts.
|
|
func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
|
|
t := m.com.Styles
|
|
|
|
title := t.Files.SectionTitle.Render("Modified Files")
|
|
if isSection {
|
|
title = common.Section(t, "Modified Files", width)
|
|
}
|
|
list := t.Files.EmptyMessage.Render("None")
|
|
var filesWithChanges []SessionFile
|
|
for _, f := range m.sessionFiles {
|
|
if f.Additions == 0 && f.Deletions == 0 {
|
|
continue
|
|
}
|
|
filesWithChanges = append(filesWithChanges, f)
|
|
}
|
|
if len(filesWithChanges) > 0 {
|
|
list = fileList(t, cwd, filesWithChanges, width, maxItems)
|
|
}
|
|
|
|
return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
|
|
}
|
|
|
|
// fileList renders a list of files with their diff statistics, truncating to
|
|
// maxItems and showing a "...and N more" message if needed.
|
|
func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, width, maxItems int) string {
|
|
if maxItems <= 0 {
|
|
return ""
|
|
}
|
|
var renderedFiles []string
|
|
filesShown := 0
|
|
|
|
for _, f := range filesWithChanges {
|
|
// Skip files with no changes
|
|
if filesShown >= maxItems {
|
|
break
|
|
}
|
|
|
|
// Build stats string with colors
|
|
var statusParts []string
|
|
if f.Additions > 0 {
|
|
statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
|
|
}
|
|
if f.Deletions > 0 {
|
|
statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
|
|
}
|
|
extraContent := strings.Join(statusParts, " ")
|
|
|
|
// Format file path
|
|
filePath := f.FirstVersion.Path
|
|
if rel, err := filepath.Rel(cwd, filePath); err == nil {
|
|
filePath = rel
|
|
}
|
|
filePath = fsext.DirTrim(filePath, 2)
|
|
suffix := ""
|
|
if extraContent != "" {
|
|
suffix = " " + extraContent
|
|
}
|
|
maxPathWidth := max(width-lipgloss.Width(suffix), 0)
|
|
filePath = ansi.Truncate(filePath, maxPathWidth, "…")
|
|
|
|
line := t.Files.Path.Render(filePath)
|
|
if extraContent != "" {
|
|
line = fmt.Sprintf("%s %s", line, extraContent)
|
|
}
|
|
|
|
renderedFiles = append(renderedFiles, line)
|
|
filesShown++
|
|
}
|
|
|
|
if len(filesWithChanges) > maxItems {
|
|
remaining := len(filesWithChanges) - maxItems
|
|
renderedFiles = append(renderedFiles, t.Files.TruncationHint.Render(fmt.Sprintf("…and %d more", remaining)))
|
|
}
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
|
|
}
|
|
|
|
// startLSPs starts LSP servers for the given file paths.
|
|
func (m *UI) startLSPs(paths []string) tea.Cmd {
|
|
if len(paths) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return func() tea.Msg {
|
|
ctx := context.Background()
|
|
for _, path := range paths {
|
|
m.com.Workspace.LSPStart(ctx, path)
|
|
}
|
|
return nil
|
|
}
|
|
}
|