package app import ( "context" "errors" "fmt" "net/url" "path/filepath" "strings" "time" "github.com/eugenioenko/ttt/internal/asynctask" "github.com/eugenioenko/ttt/internal/core/diff" "github.com/eugenioenko/ttt/internal/i18n" "github.com/eugenioenko/ttt/internal/icons" "github.com/eugenioenko/ttt/internal/scm" "github.com/eugenioenko/ttt/internal/ui" "github.com/eugenioenko/ttt/internal/view" "github.com/eugenioenko/ttt/internal/widgets" "github.com/gdamore/tcell/v3" ) // Commit history and detail reads run off the event loop and apply their widget // state after returning through it. // eventPoster is the one thing the panel needs from the screen: a way to hand a // finished read back to the event loop. Narrow so a test can stand in for it. type eventPoster interface { PostEvent(tcell.Event) error } const ( commitLogInitialLimit = 10 commitLogPageSize = 50 historyLoadOlderID = "history:load-older" commitHistoryTimeout = 15 * time.Second commitFilesTimeout = 15 * time.Second commitDetailTimeout = 30 * time.Second diffOpenTimeout = 15 * time.Second ) type commitFilesRequest struct { ID uint64 Cancel context.CancelFunc } type commitDetailRequest struct { Incarnation uint64 Context context.Context Cancel context.CancelFunc } // CommitLogResult carries a finished read of one repository's recent commits. type CommitLogResult struct { Gen int Dir string Branch string Anchor string Offset int Append bool HasMore bool Entries []scm.HistoryEntry Err error Unavailable bool Canceled bool } // CommitFilesResult carries the file list of a single commit back to the node // that asked for it. type CommitFilesResult struct { Request uint64 Dir string Ref string Short string NodeID string Files []scm.Change Err error Canceled bool } // CommitDetailResult carries the complete message and every per-file diff for // one immutable commit. Incarnation distinguishes repeated openings of the same // repository and ref so a closed request can never target its replacement. type CommitDetailResult struct { Incarnation uint64 Dir string Ref string Short string Message string AuthoredAt time.Time AuthoredAtUnavailable bool Files []ui.CommitDetailFile Err string Canceled bool } func readChangesGroups(provider scm.Provider, dirs []string) []changesGroup { entries := scanRepositoryStatus(context.Background(), provider, dirs) groups := make([]changesGroup, 0, len(entries)) for _, entry := range entries { groups = append(groups, entry.Group) } return groups } func readCommitLog(ctx context.Context, source repositorySource, gen int) *CommitLogResult { result := &CommitLogResult{Gen: gen, Dir: source.root} repo, err := source.resolve(ctx) if err != nil { result.Unavailable = errors.Is(err, scm.ErrNotRepository) if !result.Unavailable { result.Err = err } result.Canceled = errors.Is(err, context.Canceled) return result } reader, ok := repo.(scm.HistoryReader) if !ok { result.Unavailable = true return result } page, err := reader.ReadHistory(ctx, scm.HistoryRequest{Limit: commitLogInitialLimit}) if ctx.Err() != nil { err = ctx.Err() } if err != nil { result.Err = err result.Canceled = errors.Is(err, context.Canceled) return result } identity, err := repo.Identity(ctx) if ctx.Err() != nil { err = ctx.Err() } if err != nil { result.Err = err result.Canceled = errors.Is(err, context.Canceled) return result } result.Branch, result.Anchor, result.Entries, result.HasMore = identity.Label, page.Anchor, page.Entries, page.HasMore return result } func readCommitLogPage(ctx context.Context, source repositorySource, anchor string, offset, gen int) *CommitLogResult { result := &CommitLogResult{Gen: gen, Dir: source.root, Anchor: anchor, Offset: offset, Append: true} repo, err := source.resolve(ctx) if err == nil { reader, ok := repo.(scm.HistoryReader) if !ok { err = errors.ErrUnsupported } else { var page scm.HistoryPage page, err = reader.ReadHistory(ctx, scm.HistoryRequest{Anchor: anchor, Offset: offset, Limit: commitLogPageSize}) if err == nil && page.Anchor != anchor { err = fmt.Errorf("history snapshot changed during pagination") } if err == nil { result.Entries, result.HasMore = page.Entries, page.HasMore } } } if ctx.Err() != nil { err = ctx.Err() } result.Err, result.Canceled = err, errors.Is(err, context.Canceled) return result } // ApplyCommitLog rebuilds the commit log from a finished read. func (cp *ChangesPanel) ApplyCommitLog(r *CommitLogResult) { if r == nil { return } // Both checks, not one: the counter catches a superseded read, and the // directory catches a counter that agrees for some reason it should not. if r.Gen != cp.logGen || r.Dir != cp.lastLogDir { return } if r.Append { cp.applyCommitLogPage(r) return } cp.cancelLogRead() if r.Canceled { return } if r.Unavailable { cp.logDir = "" cp.resetHistoryPaging() cp.logCommits = make(map[string]commitFileRef) cp.logFiles = make(map[string]commitFileRef) cp.CommitLog.SetItems(nil) if cp.OnHistoryResult != nil { cp.OnHistoryResult(nil) } return } if r.Err != nil { cp.logPagePending = false cp.lastLogDir = "" if cp.logDir != r.Dir { cp.CommitLog.SetItems([]*widgets.TreeNode{{ID: "history:error", Label: i18n.Text("changes.couldNotReadHistory"), Muted: true}}) } if cp.OnError != nil { cp.OnError(i18n.Format("changes.couldNotRefreshCommitHistory", i18n.Args{"error": r.Err.Error()})) } if cp.OnHistoryResult != nil { cp.OnHistoryResult(r.Err) } return } name := filepath.Base(r.Dir) if r.Branch != "" { cp.Input.Config.Placeholder = i18n.Format("changes.commitToBranch", i18n.Args{"name": name, "branch": r.Branch}) } else { cp.Input.Config.Placeholder = i18n.Format("changes.commitToRepository", i18n.Args{"name": name}) } // Record what was open before the rebuild, so a Refresh triggered by // staging a file does not collapse the commit the reader was in the middle // of โ€” and so does switching to another root and back. cp.saveCommitLogState() cp.logDir = r.Dir cp.logAnchor = r.Anchor cp.logOffset = len(r.Entries) cp.logHasMore = r.HasMore && len(r.Entries) > 0 cp.logPagePending = false selected := cp.logSelected[r.Dir] cp.logCommits = make(map[string]commitFileRef) cp.logFiles = make(map[string]commitFileRef) branchLabel := name if r.Branch != "" { branchLabel = fmt.Sprintf("%s ยท %s", name, r.Branch) } nodes := make([]*widgets.TreeNode, 0, len(r.Entries)+2) nodes = append(nodes, &widgets.TreeNode{ ID: "branch", Label: branchLabel, Icon: icons.Get(cp.icons, icons.Branch), Muted: true, }) for _, e := range r.Entries { nodes = append(nodes, cp.commitLogNode(r.Dir, e)) } if cp.logHasMore { nodes = append(nodes, historyLoadOlderNode(false, false)) } if len(r.Entries) == 0 { nodes = append(nodes, &widgets.TreeNode{ID: "history:empty", Label: i18n.Text("changes.noCommits"), Muted: true}) } cp.CommitLog.SetItems(nodes) if cp.OnHistoryResult != nil { cp.OnHistoryResult(nil) } // Restore by identity, never by the index SetItems happened to preserve. // Committing prepends rows, and rewriting history can remove them entirely; // in either case the old index now names something the reader did not pick. cp.pendingLogSelection = "" if selected == "" { cp.CommitLog.SetSelectedIndex(0) return } if cp.selectLogNode(selected) { return } // A commit file can be absent temporarily while its surviving parent is // expanded and the file list is in flight. Rest on that parent and restore // the child when it arrives. Every other missing identity is permanently // gone from this log, so the inert branch header is the safe resting place. if parentID, ref, isCommitFile := commitFileParent(selected); isCommitFile { key := r.Dir + "\x00" + ref if _, pending := cp.commitFilesPending[key]; cp.commitNode(parentID) != nil && pending { cp.selectLogNode(parentID) cp.pendingLogSelection = selected return } } delete(cp.logSelected, r.Dir) cp.CommitLog.SetSelectedIndex(0) } func (cp *ChangesPanel) commitLogNode(dir string, entry scm.HistoryEntry) *widgets.TreeNode { id := historyCommitNodeID(entry.Ref) cp.logCommits[id] = commitFileRef{Dir: dir, Ref: entry.Ref, Short: entry.Label} node := &widgets.TreeNode{ID: id, Label: entry.Message, Icon: icons.Get(cp.icons, icons.Commit), Badge: entry.Label, Expandable: true} if cp.logExpanded[commitLogStateKey(dir, id)] { node.Expanded = true node.Children = cp.commitChildren(dir, entry.Ref, entry.Label, id) } return node } func historyLoadOlderNode(loading, retry bool) *widgets.TreeNode { label := i18n.Text("changes.loadOlderCommits") if loading { label = i18n.Text("changes.loadingOlderCommits") } else if retry { label = i18n.Text("changes.retryLoadingOlderCommits") } return &widgets.TreeNode{ID: historyLoadOlderID, Label: label, Icon: "โ†“", Muted: true} } func (cp *ChangesPanel) applyCommitLogPage(r *CommitLogResult) { if r.Gen != cp.logGen || r.Dir != cp.logDir || r.Anchor != cp.logAnchor || r.Offset != cp.logOffset || !cp.logPagePending { return } cp.cancelLogRead() cp.logPagePending = false if r.Canceled { return } if r.Err != nil { cp.replaceHistoryLoadNode(false, true) if cp.OnError != nil { cp.OnError(i18n.Format("changes.couldNotLoadOlderCommits", i18n.Args{"error": r.Err.Error()})) } return } selectedID := "" if selected := cp.CommitLog.Selected(); selected != nil { selectedID = selected.ID } nodes := cp.CommitLog.Config.Items if len(nodes) > 0 && nodes[len(nodes)-1].ID == historyLoadOlderID { nodes = nodes[:len(nodes)-1] } firstAddedID := "" for _, entry := range r.Entries { id := historyCommitNodeID(entry.Ref) if _, exists := cp.logCommits[id]; exists { continue } if firstAddedID == "" { firstAddedID = id } nodes = append(nodes, cp.commitLogNode(r.Dir, entry)) } cp.logOffset = r.Offset + len(r.Entries) cp.logHasMore = r.HasMore && len(r.Entries) > 0 if cp.logHasMore { nodes = append(nodes, historyLoadOlderNode(false, false)) } cp.CommitLog.SetItems(nodes) if selectedID != historyLoadOlderID { cp.selectLogNode(selectedID) } else if firstAddedID != "" { cp.selectLogNode(firstAddedID) } else if cp.logHasMore { cp.selectLogNode(historyLoadOlderID) } } func (cp *ChangesPanel) replaceHistoryLoadNode(loading, retry bool) { nodes := cp.CommitLog.Config.Items if len(nodes) == 0 || nodes[len(nodes)-1].ID != historyLoadOlderID { return } nodes[len(nodes)-1] = historyLoadOlderNode(loading, retry) cp.CommitLog.SetItems(nodes) } // commitFileParent extracts only the stable parent identity from a commit-file // node ID. Revision identities are escaped independently of literal paths. func commitFileParent(id string) (parentID, ref string, ok bool) { rest, ok := strings.CutPrefix(id, "cfile:commit:") if !ok { return "", "", false } ref, _, ok = strings.Cut(rest, ":") if !ok || ref == "" { return "", "", false } decoded, err := url.QueryUnescape(ref) if err != nil { return "", "", false } return "commit:" + ref, decoded, true } // selectLogNode selects a node by ID, reporting whether it was there to select. func (cp *ChangesPanel) selectLogNode(id string) bool { return revealTreeSelection(cp.CommitLog, id) } func readCommitFiles(ctx context.Context, request uint64, source repositorySource, ref, short, nodeID string) *CommitFilesResult { reader, err := source.revisionReader(ctx) var files []scm.Change if err == nil { files, err = reader.RevisionChanges(ctx, ref) } if ctx.Err() != nil { err = ctx.Err() } return &CommitFilesResult{ Request: request, Dir: source.root, Ref: ref, Short: short, NodeID: nodeID, Files: files, Err: err, Canceled: errors.Is(err, context.Canceled), } } // recordCommitFiles caches a finished read and clears its in-flight mark. // // A failure is deliberately not cached. A commit's contents are immutable, // which is what makes the cache safe โ€” but a failure is not a fact about the // commit, it is a fact about the moment, and index.lock or a mid-rebase repo // clears on its own. Caching one would leave that commit permanently empty. func (cp *ChangesPanel) recordCommitFiles(r *CommitFilesResult) bool { key := r.Dir + "\x00" + r.Ref if r.Request != 0 { request, ok := cp.commitFilesPending[key] if !ok || request.ID != r.Request { return false } request.Cancel() delete(cp.commitFilesPending, key) } if r.Err == nil { cp.cacheCommitFiles(key, r.Files) } return true } func (cp *ChangesPanel) childrenFor(r *CommitFilesResult) []*widgets.TreeNode { if r.Err != nil { return []*widgets.TreeNode{errorNode(r.NodeID)} } return cp.commitFileNodes(r.Dir, r.Ref, r.Short, r.NodeID, r.Files) } // fetchCommitFiles reads one commit's file list in the background. A commit // already being read is skipped rather than queued, so holding down the expand // key cannot start a run of identical git processes. func (cp *ChangesPanel) fetchCommitFiles(dir, ref, short, nodeID string) { key := dir + "\x00" + ref if _, pending := cp.commitFilesPending[key]; pending { return } cp.commitFilesNext++ requestID := cp.commitFilesNext ctx, cancel := context.WithTimeout(context.Background(), commitFilesTimeout) cp.commitFilesPending[key] = commitFilesRequest{ID: requestID, Cancel: cancel} source := repositorySource{root: dir, repository: cp.repository(dir), provider: cp.provider} screen := cp.Screen go func() { result := readCommitFiles(ctx, requestID, source, ref, short, nodeID) cancel() screen.PostEvent(tcell.NewEventInterrupt(result)) }() } // ApplyCommitFiles installs a commit's file list under the node that asked for // it. The node is looked up by ID rather than held as a pointer: the log may // have been rebuilt, or moved to another repository, while git was running. func (cp *ChangesPanel) ApplyCommitFiles(r *CommitFilesResult) { if r == nil || r.Canceled || !cp.recordCommitFiles(r) { return } if r.Dir != cp.logDir { return } node := cp.commitNode(r.NodeID) if node == nil { return } node.Children = cp.childrenFor(r) // Same slice, but the widget has to re-flatten now that it has children. cp.CommitLog.SetItems(cp.CommitLog.Config.Items) if cp.pendingLogSelection != "" { parentID, _, isCommitFile := commitFileParent(cp.pendingLogSelection) if !isCommitFile || parentID != r.NodeID { return } if cp.selectLogNode(cp.pendingLogSelection) { cp.pendingLogSelection = "" return } // This parent's read completed without the selected child, so that // identity can no longer arrive. Keep the cursor on the surviving parent // and stop carrying the dead selection into later rebuilds. cp.pendingLogSelection = "" cp.selectLogNode(r.NodeID) cp.logSelected[r.Dir] = r.NodeID } } func (cp *ChangesPanel) commitNode(id string) *widgets.TreeNode { for _, node := range cp.CommitLog.Config.Items { if node.ID == id { return node } } return nil } func commitDetailTabID(dir, ref string) string { return "commit:" + dir + "\x00" + ref } func readCommitDetail(ctx context.Context, incarnation uint64, source repositorySource, ref, short string) *CommitDetailResult { result := &CommitDetailResult{Incarnation: incarnation, Dir: source.root, Ref: ref, Short: short} reader, err := source.revisionReader(ctx) if err != nil { result.Err = i18n.Format("changes.readCommitFailed", i18n.Args{"short": short}) result.Canceled = errors.Is(err, context.Canceled) return result } revision, err := reader.ReadRevision(ctx, ref) if ctx.Err() != nil { err = ctx.Err() } if err != nil { if errors.Is(err, context.Canceled) { result.Canceled = true return result } result.Err = i18n.Format("changes.readCommitFailed", i18n.Args{"short": short}) return result } result.Message = revision.Message result.AuthoredAt = revision.AuthoredAt result.AuthoredAtUnavailable = revision.AuthoredAtUnavailable files, err := reader.RevisionChanges(ctx, ref) if ctx.Err() != nil { err = ctx.Err() } if err != nil { if errors.Is(err, context.Canceled) { result.Canceled = true return result } result.Err = i18n.Format("changes.couldNotReadFilesForCommit", i18n.Args{"short": short}) return result } result.Files = make([]ui.CommitDetailFile, 0, len(files)) for _, file := range files { detail := ui.CommitDetailFile{Status: ui.ChangeBadge(file), Path: file.Path, OldPath: file.OldPath, Group: string(file.Group)} diffText, err := reader.ReadRevisionPatch(ctx, ref, file) if ctx.Err() != nil { err = ctx.Err() } if err != nil { if errors.Is(err, context.Canceled) { result.Canceled = true return result } detail.Error = i18n.Format("changes.couldNotReadDiffFor", i18n.Args{"path": file.Path}) } else { detail.Diff = diff.Parse(diffText) } result.Files = append(result.Files, detail) } return result } // OpenCommitDetail puts a loading tab in front of the reader immediately, then // performs every Git read off the event loop. Reopening an immutable commit // reuses its full-hash tab and never starts a duplicate read. func (a *App) OpenCommitDetail(dir, ref, short string) { a.cancelPendingDiff() tabID := commitDetailTabID(dir, ref) for _, group := range a.editorGroups() { if group.CommitDetailWidgetByTab(tabID) != nil { a.activateEditorGroup(group) group.SwitchToTabByPath(tabID) a.FocusEditorIfEnabled() return } } source := a.Repository.repositorySource(dir) request := a.beginCommitDetailRequest(tabID) detail := ui.NewCommitDetailWidget(dir, ref, short, a.EditorGroup.SyntaxHighlight) detail.Incarnation = request.Incarnation detail.OnClose = func() { a.cancelCommitDetailRequest(tabID, request.Incarnation) } a.wireCommitDetailContext(tabID, detail, request) a.EditorGroup.ApplyDiffDefaults(detail) a.EditorGroup.OpenPluginTab(tabID, i18n.Format("changes.commit", i18n.Args{"short": short}), detail) a.FocusEditorIfEnabled() read := func() *CommitDetailResult { ctx, cancel := context.WithTimeout(request.Context, commitDetailTimeout) defer cancel() return readCommitDetail(ctx, request.Incarnation, source, ref, short) } if a.Screen == nil { a.ApplyCommitDetail(read()) return } screen := a.Screen go func() { screen.PostEvent(tcell.NewEventInterrupt(read())) }() } func (a *App) beginCommitDetailRequest(tabID string) commitDetailRequest { a.commitDetailMu.Lock() defer a.commitDetailMu.Unlock() if a.commitDetailRequests == nil { a.commitDetailRequests = make(map[string]commitDetailRequest) } if previous, ok := a.commitDetailRequests[tabID]; ok { previous.Cancel() } a.commitDetailNext++ ctx, cancel := context.WithCancel(context.Background()) request := commitDetailRequest{Incarnation: a.commitDetailNext, Context: ctx, Cancel: cancel} a.commitDetailRequests[tabID] = request return request } func (a *App) cancelCommitDetailRequest(tabID string, incarnation uint64) { a.commitDetailMu.Lock() defer a.commitDetailMu.Unlock() request, ok := a.commitDetailRequests[tabID] if !ok || request.Incarnation != incarnation { return } delete(a.commitDetailRequests, tabID) request.Cancel() } func (a *App) ShutdownGitReads() { a.cancelPendingDiff() a.commitDetailMu.Lock() requests := a.commitDetailRequests a.commitDetailRequests = nil a.commitDetailMu.Unlock() for _, request := range requests { request.Cancel() } if a.Changes != nil { a.Changes.Shutdown() } } func (a *App) commitDetailForResult(tabID, dir, ref string, incarnation uint64) *ui.CommitDetailWidget { for _, group := range a.editorGroups() { detail := group.CommitDetailWidgetByTab(tabID) if detail != nil && detail.Dir == dir && detail.Ref == ref && detail.Incarnation == incarnation { return detail } } return nil } // ApplyCommitDetail fills only the still-open incarnation whose repository and // full hash match the result. Closing the tab while Git runs supersedes it. func (a *App) ApplyCommitDetail(result *CommitDetailResult) { if result == nil || result.Canceled { return } detail := a.commitDetailForResult(commitDetailTabID(result.Dir, result.Ref), result.Dir, result.Ref, result.Incarnation) if detail == nil { return } if !result.AuthoredAt.IsZero() { detail.Metadata = i18n.Format("changes.authored", i18n.Args{"date": result.AuthoredAt.Format("Jan 2, 2006 at 3:04:05 PM -0700")}) } else if result.AuthoredAtUnavailable { detail.Metadata = i18n.Text("changes.authoredDateUnavailable") } detail.SetDetail(result.Message, result.Files, result.Err) } type diffOpenResult struct { Canceled bool Conflict string Warn string TabName string Title string Path string Diff diff.FileDiff OldLines []string NewLines []string Extended bool } func (a *App) cancelPendingDiff() { if a.diffOpenTask != nil { a.diffOpenTask.Stop() } if a.Status != nil { a.setDiffOpenSegment("") } } func (a *App) startDiffOpen(read func(context.Context) *diffOpenResult) { a.cancelPendingDiff() if a.diffOpenTask == nil { a.diffOpenTask = NewTask[*diffOpenResult](a, asynctask.TaskOptions{}) } origin := a.EditorGroup.ActiveFilePath() a.setDiffOpenSegment(i18n.Text("changes.openingDiff")) a.diffOpenTask.Schedule(func(ctx context.Context) (*diffOpenResult, error) { ctx, cancel := context.WithTimeout(ctx, diffOpenTimeout) defer cancel() return read(ctx), nil }, func(result *diffOpenResult, err error) { a.setDiffOpenSegment("") if err != nil { a.StatusWarn(i18n.Format("status.couldNotOpenDiff", i18n.Args{"error": err.Error()})) return } a.applyDiffOpen(origin, result) }) } func (a *App) setDiffOpenSegment(text string) { a.Status.SetSegment(view.StatusSegment{ID: "diffopen", Side: "left", Priority: 160, Text: text}) } func (a *App) applyDiffOpen(origin string, result *diffOpenResult) { if result == nil { return } if result.Canceled || a.EditorGroup.ActiveFilePath() != origin { return } if result.Warn != "" { a.StatusWarn(result.Warn) return } if result.Conflict != "" { a.openConflictedFile(result.Path, result.Conflict) return } a.EditorGroup.OpenDiffTab(result.TabName, result.Title, result.Path, result.Diff, result.OldLines, result.NewLines, result.Extended) a.FocusEditorIfEnabled() } func historyCommitNodeID(ref string) string { return "commit:" + url.QueryEscape(ref) }