Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save theY4Kman/7233d15dd976a524e8f287055c0f0451 to your computer and use it in GitHub Desktop.
Save theY4Kman/7233d15dd976a524e8f287055c0f0451 to your computer and use it in GitHub Desktop.
Branch Changed Files line status tracker
package com.github.rewstapp.packauthoring.openapi.vcs.impl
import com.github.rewstapp.packauthoring.psi.search.scope.packageSet.BranchChangedFilesCustomScopesProvider
import com.github.rewstapp.packauthoring.settings.PackAuthoringSettings
import com.intellij.diff.DiffApplicationSettings
import com.intellij.diff.DiffContentFactory
import com.intellij.diff.DiffManager
import com.intellij.diff.comparison.ByWord
import com.intellij.diff.comparison.ComparisonPolicy
import com.intellij.diff.contents.DiffContent
import com.intellij.diff.requests.SimpleDiffRequest
import com.intellij.diff.util.DiffUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.LineNumberConstants
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diff.DefaultFlagsProvider
import com.intellij.openapi.diff.DefaultLineFlags
import com.intellij.openapi.diff.DiffBundle
import com.intellij.openapi.diff.LineStatusMarkerDrawUtil
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.util.BackgroundTaskUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vcs.ex.*
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.EditorTextField
import java.awt.Graphics
import java.awt.Point
import java.awt.datatransfer.StringSelection
import java.util.*
import javax.swing.JComponent
class BranchChangedFileRange(
line1: Int,
line2: Int,
vcsLine1: Int,
vcsLine2: Int,
innerRanges: List<InnerRange>?
) : Range(line1, line2, vcsLine1, vcsLine2, innerRanges)
class MergeBaseLocalLineStatusTracker(
project: Project,
document: Document,
virtualFile: VirtualFile,
) : LocalLineStatusTrackerImpl<BranchChangedFileRange>(project, document, virtualFile) {
override val renderer = MergeBaseLocalLineStatusMarkerRenderer(this)
@Suppress("UNCHECKED_CAST")
override var DocumentTracker.Block.innerRanges: List<Range.InnerRange>?
get() = data as List<Range.InnerRange>?
set(value) { data = value }
override fun setBaseRevision(vcsContent: CharSequence) {
setBaseRevisionContent(vcsContent, null)
}
override fun toRange(block: DocumentTracker.Block): BranchChangedFileRange =
BranchChangedFileRange(block.start, block.end, block.vcsStart, block.vcsEnd, block.innerRanges)
override fun isRangeModified(startLine: Int, endLine: Int): Boolean = false
override fun isLineModified(line: Int): Boolean = false
protected class MergeBaseLocalLineStatusMarkerRenderer(
tracker: LocalLineStatusTrackerImpl<*>
) : LocalLineStatusMarkerRenderer(tracker) {
override fun paint(editor: Editor, g: Graphics) {
LineStatusMarkerDrawUtil.paintDefault(
editor, g, myTracker, BranchChangeFilesFlagsProvider, 0
)
}
}
}
class BranchChangedFileLocalLineStatusTracker(
project: Project,
document: Document,
virtualFile: VirtualFile
) : LocalLineStatusTrackerImpl<Range>(project, document, virtualFile) {
private val logger = Logger.getInstance(BranchChangedFileLocalLineStatusTracker::class.java)
override val renderer = BranchChangedFileLineStatusMarkerRenderer(this)
private val settings = project.service<PackAuthoringSettings>()
private val branchChangeListScopeProvider = BranchChangedFilesCustomScopesProvider.getInstance(project)
private var isPaneActive = branchChangeListScopeProvider.isPaneActive()
private val isMergeBaseEnabled: Boolean
get() = settings.enableBranchChangedFilesLineStatuses &&
when (settings.branchChangedFilesLineStatusDisplayMode) {
BranchChangedFilesLineStatusDisplayMode.NEVER_SHOW -> false
BranchChangedFilesLineStatusDisplayMode.SHOW_WHEN_PANE_ACTIVE -> isPaneActive
BranchChangedFilesLineStatusDisplayMode.ALWAYS_SHOW -> true
}
private val isChangelistEnabled: Boolean
get() = settings.enableBranchChangedFilesLineStatuses &&
when (settings.changelistLineStatusDisplayMode) {
ChangelistLineStatusDisplayMode.NEVER_SHOW -> false
ChangelistLineStatusDisplayMode.HIDE_WHEN_PANE_ACTIVE -> !isPaneActive
ChangelistLineStatusDisplayMode.ALWAYS_SHOW -> true
}
private val mergeBaseTracker = MergeBaseLocalLineStatusTracker(project, document, virtualFile)
private val changelistTracker = ChangelistsLocalLineStatusTracker(project, document, virtualFile)
private var trackers = listOf(mergeBaseTracker, changelistTracker)
init {
listOf(mergeBaseTracker, changelistTracker)
.forEach { Disposer.register(disposable, it.disposable) }
settings.addBranchChangedFilesTrackerListener(
object : PackAuthoringSettings.BranchChangedFilesSettingsListener {
override fun changed() { reconcileTrackerVisibility() }
},
disposable,
)
branchChangeListScopeProvider.addListener(
object : BranchChangedFilesCustomScopesProvider.ViewListener {
override fun changed() { reconcileTrackerVisibility() }
},
disposable,
)
reconcileTrackerVisibility()
}
private fun setTrackerVisibility(tracker: LocalLineStatusTrackerImpl<*>, isVisible: Boolean): Boolean {
if (tracker.mode.isVisible != isVisible) {
tracker.mode = LocalLineStatusTracker.Mode(
isVisible,
tracker.mode.showErrorStripeMarkers,
tracker.mode.detectWhitespaceChangedLines
)
return true
}
return false
}
private fun reconcileTrackerVisibility() {
if (!settings.enableBranchChangedFilesLineStatuses) {
this.release()
logger.debug("Branch Changed Files line statuses disabled. Releasing tracker for $virtualFile")
return
}
isPaneActive = branchChangeListScopeProvider.isPaneActive()
trackers = listOfNotNull(
if (isMergeBaseEnabled) mergeBaseTracker else null,
if (isChangelistEnabled) changelistTracker else null,
)
if (
setTrackerVisibility(mergeBaseTracker, isMergeBaseEnabled) ||
setTrackerVisibility(changelistTracker, isChangelistEnabled)
) {
updateHighlighters()
}
}
@Suppress("UNCHECKED_CAST")
override var DocumentTracker.Block.innerRanges: List<Range.InnerRange>?
get() = data as List<Range.InnerRange>?
set(value) { data = value }
override fun setBaseRevision(vcsContent: CharSequence) {
setBaseRevisionContent(vcsContent, null)
}
fun setBaseRevision(mergeBaseContent: CharSequence, headDocument: Document?) {
if (headDocument != null) {
val headText = headDocument.text
setBaseRevision(headText)
mergeBaseTracker.setBaseRevision(mergeBaseContent)
changelistTracker.setBaseRevision(headText)
}
}
override fun findRange(range: Range): Range? =
when (range) {
is BranchChangedFileRange -> mergeBaseTracker.findRange(range)
else -> changelistTracker.findRange(range)
}
override fun getNextRange(line: Int): Range? =
trackers.mapNotNull { it.getNextRange(line) }.minByOrNull { it.line1 }
override fun getPrevRange(line: Int): Range? =
trackers.mapNotNull { it.getPrevRange(line) }.maxByOrNull { it.line2 }
override fun getRangeForLine(line: Int): Range? =
trackers.firstNotNullOfOrNull { it.getRangeForLine(line) }
override fun getRangesForLines(lines: BitSet): List<Range> =
combinedTrackerResults { it.getRangesForLines(lines) }
override fun isRangeModified(startLine: Int, endLine: Int): Boolean =
// NB(zk): highlights are hidden if this returns true, and we want to hide them ONLY if the
// line is currently changed in the working tree
isChangelistEnabled && changelistTracker.isRangeModified(startLine, endLine)
override fun isLineModified(line: Int): Boolean = isRangeModified(line, line + 1)
override fun transferLineFromVcs(line: Int, approximate: Boolean): Int =
trackers
.firstOrNull { it.getRangeForLine(line) != null }
?.transferLineFromVcs(line, approximate)
?: line
override fun transferLineToVcs(line: Int, approximate: Boolean): Int =
// NB(zk): highlights are also hidden if this returns a negative number, so we always try
// to provide a value here.
trackers
.firstNotNullOfOrNull {
when (val vcsLine = it.transferLineToVcs(line, approximate)) {
LineNumberConstants.ABSENT_LINE_NUMBER,
LineNumberConstants.FAKE_LINE_NUMBER -> null
else -> vcsLine
}
}
?: LineNumberConstants.ABSENT_LINE_NUMBER
override fun toRange(block: DocumentTracker.Block): Range =
BranchChangedFileRange(block.start, block.end, block.vcsStart, block.vcsEnd, block.innerRanges)
override fun getRanges(): List<Range> =
combinedTrackerResults { it.getRanges() }
override fun rollbackChanges(range: Range) {
when (range) {
is BranchChangedFileRange -> mergeBaseTracker.rollbackChanges(range)
else -> changelistTracker.rollbackChanges(range)
}
}
override fun rollbackChanges(lines: BitSet) {
trackers
.firstOrNull { it.getRangesForLines(lines)?.isNotEmpty() ?: false }
?.rollbackChanges(lines)
}
fun getVcsContentForRange(range: Range): CharSequence =
DiffUtil.getLinesContent(getVcsDocumentForRange(range), range.vcsLine1, range.vcsLine2)
fun getVcsDocumentForRange(range: Range): Document =
when (range) {
is BranchChangedFileRange -> mergeBaseTracker.vcsDocument
else -> changelistTracker.vcsDocument
}
private fun <T> combinedTrackerResults(func: (LocalLineStatusTrackerImpl<out Range>) -> List<T>?): List<T> =
trackers
.mapNotNull(func)
.fold(emptyList()) { acc, ranges ->
acc + ranges
}
protected class BranchChangedFileLineStatusMarkerRenderer(
private val branchChangedFileTracker: BranchChangedFileLocalLineStatusTracker
) : LocalLineStatusMarkerRenderer(branchChangedFileTracker) {
override fun paint(editor: Editor, g: Graphics) {
// no-op — the delegated trackers' renderers will handle their own painting
}
/**
* Lifted from [LocalLineStatusTrackerImpl.LocalLineStatusMarkerRenderer] and changed to
* use the [vcsDocument] from the appropriate tracker (changelist or mergeBase),
* depending on the type of [Range].
*/
override fun showHintAt(editor: Editor, range: Range, mousePosition: Point?) {
if (!myTracker.isValid()) return
val disposable = Disposer.newDisposable()
var editorComponent: JComponent? = null
if (range.hasVcsLines()) {
val vcsDocument = branchChangedFileTracker.getVcsDocumentForRange(range)
val content = DiffUtil.getLinesContent(vcsDocument, range.vcsLine1, range.vcsLine2).toString()
val textField = LineStatusMarkerPopupPanel.createTextField(editor, content)
val vcsTextRange = DiffUtil.getLinesRange(vcsDocument, range.vcsLine1, range.vcsLine2)
LineStatusMarkerPopupPanel.installBaseEditorSyntaxHighlighters(
myTracker.project, textField, vcsDocument, vcsTextRange, fileType
)
installWordDiff(editor, textField, range, disposable)
editorComponent = LineStatusMarkerPopupPanel.createEditorComponent(editor, textField)
}
val actions = createToolbarActions(editor, range, mousePosition)
val toolbar = LineStatusMarkerPopupPanel.buildToolbar(editor, actions, disposable)
val additionalInfoPanel = createAdditionalInfoPanel(editor, range, mousePosition, disposable)
LineStatusMarkerPopupPanel.showPopupAt(
editor,
toolbar,
editorComponent,
additionalInfoPanel,
mousePosition,
disposable,
null
)
}
/**
* Lifted directly from [LocalLineStatusTrackerImpl.LocalLineStatusMarkerRenderer], and
* changed to use vcsContent from the appropriate tracker (changelist or mergeBase),
* depending on the type of [Range].
*/
private fun installWordDiff(
editor: Editor, textField: EditorTextField, range: Range, disposable: Disposable
) {
if (!DiffApplicationSettings.getInstance().SHOW_LST_WORD_DIFFERENCES) return
if (!range.hasLines() || !range.hasVcsLines()) return
val vcsContent = branchChangedFileTracker.getVcsContentForRange(range)
val currentContent = LineStatusMarkerPopupActions.getCurrentContent(myTracker, range)
val wordDiff = BackgroundTaskUtil.tryComputeFast(
{ indicator: ProgressIndicator ->
ByWord.compare(vcsContent, currentContent, ComparisonPolicy.DEFAULT, indicator)
}, 200
) ?: return
LineStatusMarkerPopupPanel.installMasterEditorWordHighlighters(
editor, range.line1, range.line2, wordDiff, disposable
)
LineStatusMarkerPopupPanel.installPopupEditorWordHighlighters(textField, wordDiff)
}
/**
* Install our own toolbar actions, which calculate diffs appropriately for merge-base changes
*/
override fun createToolbarActions(
editor: Editor,
range: Range,
mousePosition: Point?
): List<AnAction> =
super.createToolbarActions(editor, range, mousePosition)
.map { action ->
when (action) {
is ShowLineStatusRangeDiffAction -> RoutedShowLineStatusRangeDiffAction(editor, range)
is CopyLineStatusRangeAction -> RoutedCopyLineStatusRangeAction(editor, range)
else -> action
}
}
private inner class RoutedShowLineStatusRangeDiffAction(
editor: Editor, range: Range
) : ShowLineStatusRangeDiffAction(editor, range) {
override fun actionPerformed(editor: Editor, range: Range) {
BranchChangedFilesMarkerPopupActions.showDiff(branchChangedFileTracker, range)
}
}
private inner class RoutedCopyLineStatusRangeAction(
editor: Editor, range: Range
) : CopyLineStatusRangeAction(editor, range) {
override fun actionPerformed(editor: Editor, range: Range) {
BranchChangedFilesMarkerPopupActions.copyVcsContent(branchChangedFileTracker, range)
}
}
}
}
/**
* Lifted from [LineStatusMarkerPopupActions], and changed to use the vcsDocument from the
* appropriate tracker (changelist or mergeBase), depending on the type of [Range].
*/
object BranchChangedFilesMarkerPopupActions {
fun showDiff(tracker: BranchChangedFileLocalLineStatusTracker, range: Range) {
val vcsDocument = tracker.getVcsDocumentForRange(range)
val project = tracker.project
val ourRange = expand(range, tracker.document, vcsDocument)
val vcsContent = createDiffContent(
project,
vcsDocument,
tracker.virtualFile,
DiffUtil.getLinesRange(vcsDocument, ourRange.vcsLine1, ourRange.vcsLine2)
)
val currentContent = createDiffContent(
project,
tracker.document,
tracker.virtualFile,
LineStatusMarkerPopupActions.getCurrentTextRange(tracker, ourRange)
)
val request = SimpleDiffRequest(
DiffBundle.message("dialog.title.diff.for.range"),
vcsContent, currentContent,
DiffBundle.message("diff.content.title.up.to.date"),
DiffBundle.message("diff.content.title.current.range")
)
DiffManager.getInstance().showDiff(project, request)
}
private fun createDiffContent(
project: Project?,
document: Document,
highlightFile: VirtualFile?,
textRange: TextRange
): DiffContent {
val content = DiffContentFactory.getInstance().create(project, document, highlightFile)
return DiffContentFactory.getInstance().createFragment(project, content, textRange)
}
private fun expand(range: Range, document: Document, uDocument: Document): Range {
val canExpandBefore = range.line1 != 0 && range.vcsLine1 != 0
val canExpandAfter =
range.line2 < DiffUtil.getLineCount(document) && range.vcsLine2 < DiffUtil.getLineCount(
uDocument
)
val offset1 = range.line1 - if (canExpandBefore) 1 else 0
val uOffset1 = range.vcsLine1 - if (canExpandBefore) 1 else 0
val offset2 = range.line2 + if (canExpandAfter) 1 else 0
val uOffset2 = range.vcsLine2 + if (canExpandAfter) 1 else 0
return Range(offset1, offset2, uOffset1, uOffset2)
}
fun copyVcsContent(tracker: BranchChangedFileLocalLineStatusTracker, range: Range) {
val content = tracker.getVcsContentForRange(range).toString() + "\n"
CopyPasteManager.getInstance().setContents(StringSelection(content))
}
}
object BranchChangeFilesFlagsProvider : DefaultFlagsProvider() {
override fun getFlags(range: Range): DefaultLineFlags =
when (range) {
// NB(zk): IGNORED will paint annotations as borders only, without filling the background,
// which is perfect for showing non-working-tree, merge-base changes
is BranchChangedFileRange -> DefaultLineFlags.IGNORED
else -> DefaultLineFlags.DEFAULT
}
override fun shouldIgnoreInnerRanges(flag: DefaultLineFlags): Boolean = false
}
package com.github.rewstapp.packauthoring.openapi.vcs.impl
import com.github.rewstapp.packauthoring.services.PackAuthoringProjectService
import com.github.rewstapp.packauthoring.settings.PackAuthoringSettings
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vcs.VcsException
import com.intellij.openapi.vcs.ex.LocalLineStatusTracker
import com.intellij.openapi.vcs.impl.LineStatusTrackerContentLoader
import com.intellij.openapi.vcs.impl.LineStatusTrackerManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.vcsUtil.VcsFileUtil
import com.intellij.vcsUtil.VcsImplUtil
import com.intellij.vcsUtil.VcsUtil
import git4idea.GitContentRevision
import git4idea.index.*
import git4idea.index.vfs.GitIndexFileSystemRefresher
import git4idea.repo.GitRepositoryManager
import git4idea.util.GitFileUtils
import vendored.com.intellij.openapi.application.runReadAction
import vendored.com.intellij.util.asSafely
import java.nio.charset.Charset
enum class BranchChangedFilesLineStatusDisplayMode(private val displayName: String) {
/** Never show line statuses for merge-base changes */
NEVER_SHOW("Never show"),
/** Show line statuses for merge-base changes only when the Branch Changed Files pane is active */
SHOW_WHEN_PANE_ACTIVE("Show when pane is active"),
/** Always show line statuses for merge-base changes */
ALWAYS_SHOW("Always show");
override fun toString(): String = displayName
}
enum class ChangelistLineStatusDisplayMode(private val displayName: String) {
/** Never show line statuses for changelist changes */
NEVER_SHOW("Never show"),
/** Hide line statuses for changelist changes only when the Branch Changed Files pane is active */
HIDE_WHEN_PANE_ACTIVE("Hide when pane is active"),
/** Always show line statuses for changelist changes */
ALWAYS_SHOW("Always show");
override fun toString(): String = displayName
}
class BranchChangedFilesLocalLineStatusTrackerProvider : LineStatusTrackerContentLoader {
private val logger = Logger.getInstance(BranchChangedFilesLocalLineStatusTrackerProvider::class.java)
private val trackedProjects = mutableSetOf<Project>()
private fun addProjectListeners(project: Project) {
val settings = project.service<PackAuthoringSettings>()
val packAuthoringService = project.service<PackAuthoringProjectService>()
settings.addBranchChangedFilesTrackerListener(
object : PackAuthoringSettings.BranchChangedFilesSettingsListener {
override fun enableBranchChangedFilesLineStatusesChanged() {
if (settings.enableBranchChangedFilesLineStatuses) {
// Release existing trackers after enabling our own, so
// next request for a tracker will use our own
val lstm = LineStatusTrackerManager.getInstance(project)
val editorFactory = EditorFactory.getInstance()
val editorTrackers =
editorFactory.allEditors
.filter { editor -> editor.project == project }
.mapNotNull { editor ->
lstm.getLineStatusTracker(editor.document)
?.asSafely<LocalLineStatusTracker<*>>()
?.let { tracker -> editor to tracker }
}
.filter { (_, tracker) -> !isMyTracker(tracker) }
logger.debug("Releasing ${editorTrackers.size} other line status trackers after enabling Branch Changed Files line status tracking ...")
editorTrackers.forEach { (editor, tracker) ->
tracker.release()
ApplicationManager.getApplication().invokeLater {
lstm.requestTrackerFor(tracker.document, editor)
}
}
logger.info("Released ${editorTrackers.size} other line status trackers after enabling Branch Changed Files line status tracking")
}
}
},
packAuthoringService,
)
}
override fun createTracker(project: Project, file: VirtualFile): LocalLineStatusTracker<*>? {
val document = FileDocumentManager.getInstance().getDocument(file) ?: return null
if (project !in trackedProjects) {
addProjectListeners(project)
trackedProjects.add(project)
}
return BranchChangedFileLocalLineStatusTracker(project, document, file)
}
private class BranchChangedContentInfo(
val currentRevision: String?,
val mergeBaseRevision: String?,
val charset: Charset,
val virtualFile: VirtualFile,
) :
LineStatusTrackerContentLoader.ContentInfo
private class BranchChangedTrackerContent(
val mergeBaseContent: CharSequence,
val headDocument: Document?,
) :
LineStatusTrackerContentLoader.TrackerContent
override fun getContentInfo(
project: Project,
file: VirtualFile
): LineStatusTrackerContentLoader.ContentInfo? {
val repository = GitRepositoryManager.getInstance(project).getRepositoryForFile(file) ?: return null
val branchChangedFilesTracker = project.service<BranchChangedFilesTracker>()
return BranchChangedContentInfo(repository.currentRevision, branchChangedFilesTracker.mergeBaseRevision, file.charset, file)
}
override fun handleLoadingError(tracker: LocalLineStatusTracker<*>) {
tracker as BranchChangedFileLocalLineStatusTracker
tracker.dropBaseRevision()
}
override fun isMyTracker(tracker: LocalLineStatusTracker<*>): Boolean =
tracker is BranchChangedFileLocalLineStatusTracker
override fun isTrackedFile(project: Project, file: VirtualFile): Boolean {
// NB(zk): this is mostly lifted from GitStageLineStatusTrackerProvider
if (!file.isInLocalFileSystem) return false
val settings = project.service<PackAuthoringSettings>()
if (!settings.enableBranchChangedFilesLineStatuses) return false
val repository = GitRepositoryManager.getInstance(project).getRepositoryForFileQuick(file)
return repository != null
}
override fun loadContent(
project: Project,
info: LineStatusTrackerContentLoader.ContentInfo
): LineStatusTrackerContentLoader.TrackerContent? {
// NB(zk): this logic is mostly lifted from GitStageLineStatusTrackerProvider
info as BranchChangedContentInfo
val branchChangedFilesTracker = project.service<BranchChangedFilesTracker>()
val file = info.virtualFile
val filePath = VcsUtil.getFilePath(file)
val status = GitStageTracker.getInstance(project).status(file) ?: return null
if (GitContentRevision.getRepositoryIfSubmodule(project, filePath) != null) return null
val repository = GitRepositoryManager.getInstance(project).getRepositoryForFile(file) ?: return null
val indexFileRefresher = GitIndexFileSystemRefresher.getInstance(project)
val indexFile = indexFileRefresher.getFile(repository.root, status.path(ContentVersion.STAGED)) ?: return null
val indexDocument = runReadAction { FileDocumentManager.getInstance().getDocument(indexFile) } ?: return null
if (!status.has(ContentVersion.LOCAL)) {
return BranchChangedTrackerContent("", null)
}
val fileRev = branchChangedFilesTracker.getEarliestMergeBaseRevisionForFile(repository, file) ?: return null
try {
val bytes = GitFileUtils.getFileContent(
project,
repository.root,
fileRev,
VcsFileUtil.relativePath(
repository.root, status.path(ContentVersion.HEAD)
),
)
val headContent = VcsImplUtil.loadTextFromBytes(project, bytes, filePath)
val correctedText = StringUtil.convertLineSeparators(headContent)
return BranchChangedTrackerContent(correctedText, indexDocument)
} catch (e: VcsException) {
logger.warn("Can't load base revision content for ${file.path} with status $status", e)
return null
}
}
override fun setLoadedContent(
tracker: LocalLineStatusTracker<*>,
content: LineStatusTrackerContentLoader.TrackerContent
) {
tracker as BranchChangedFileLocalLineStatusTracker
content as BranchChangedTrackerContent
tracker.setBaseRevision(content.mergeBaseContent, content.headDocument)
}
override fun shouldBeUpdated(
oldInfo: LineStatusTrackerContentLoader.ContentInfo?,
newInfo: LineStatusTrackerContentLoader.ContentInfo
): Boolean {
newInfo as BranchChangedContentInfo
return oldInfo == null ||
oldInfo !is BranchChangedContentInfo ||
oldInfo.currentRevision != newInfo.currentRevision ||
oldInfo.mergeBaseRevision != newInfo.mergeBaseRevision ||
oldInfo.charset != newInfo.charset
}
}
package com.github.rewstapp.packauthoring.openapi.vcs.impl
import com.github.rewstapp.packauthoring.services.PackAuthoringProjectService
import com.github.rewstapp.packauthoring.settings.PackAuthoringSettings
import com.github.rewstapp.packauthoring.settings.PackAuthoringSettings.BranchChangedFilesSettingsListener
import com.github.rewstapp.packauthoring.util.profile
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.Task
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vcs.VcsException
import com.intellij.openapi.vcs.changes.ChangeListAdapter
import com.intellij.openapi.vcs.changes.ChangeListListener
import com.intellij.openapi.vcs.changes.ChangeListManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.EventDispatcher
import git4idea.GitUtil
import git4idea.commands.Git
import git4idea.commands.GitCommand
import git4idea.commands.GitLineHandler
import git4idea.history.GitHistoryUtils
import git4idea.repo.GitRepository
import git4idea.repo.GitRepositoryChangeListener
import git4idea.repo.GitRepositoryManager
import git4idea.util.StringScanner
import org.jetbrains.annotations.NonNls
import java.util.*
class BranchChangedFilesTracker(val project: Project) {
private val logger = Logger.getInstance(BranchChangedFilesTracker::class.java)
private val eventDispatcher = EventDispatcher.create(BranchChangedFilesTrackerListener::class.java)
/** Revision where the current branch meets the main branch */
private var _mergeBaseRevision: String? = null
val mergeBaseRevision: String?
get() = _mergeBaseRevision
/** Files changed since the branch deviated from master */
private var mergeBaseChangedPaths = emptySet<String>()
/** Currently-modified files */
private var currentAffectedPaths = emptySet<String>()
/** Combined set of the above two */
private var _branchChangedPaths = emptySet<String>()
var branchChangedPaths: Set<String>
get() = _branchChangedPaths
private set(value) {
_branchChangedPaths = value
}
private val settings = project.service<PackAuthoringSettings>()
private val packAuthoringService = project.service<PackAuthoringProjectService>()
private val git = Git.getInstance()
private val gitRepoManager = GitRepositoryManager.getInstance(project)
private val changeListManager = ChangeListManager.getInstance(project)
private val progressManager = ProgressManager.getInstance()
init {
// Explicitly attach our connection to our own plugin&project-level parent disposable, so
// the connection will be disposed when our plugin unloads (a prerequisite for dynamic reloading)
val busConnection = project.messageBus.connect(packAuthoringService)
// When repo is first loaded, or is fetched/pulled, recalculate file changes since the
// merge-base ref (the point at which the current branch diverges from main branch)
busConnection.subscribe(GitRepository.GIT_REPO_CHANGE, GitRepositoryChangeListener { repo ->
onRepositoryChanged(repo)
})
// Refresh the scope view pane whenever a change is made to the stage (i.e. whenever a file
// is added/removed from the stage, or a staged file is modified)
busConnection.subscribe(ChangeListListener.TOPIC, object : ChangeListAdapter() {
override fun changeListsChanged() = onAffectedFilesChanged()
override fun changeListUpdateDone() = onAffectedFilesChanged()
})
// Recalculate merge-base changes when main branch is changed
settings.addBranchChangedFilesTrackerListener(
object : BranchChangedFilesSettingsListener {
override fun mainBranchChanged() {
gitRepoManager.repositories.filter { it.project == project }.forEach { repo ->
onRepositoryChanged(repo)
}
}
},
packAuthoringService,
)
}
fun addListener(listener: BranchChangedFilesTrackerListener, disposable: Disposable) {
eventDispatcher.addListener(listener, disposable)
}
private fun onRepositoryChanged(repo: GitRepository) {
val branch = repo.currentBranch ?: return
val branchName = branch.name
val mainBranch = getMainBranchName()
val bgTask = object : Task.Backgroundable(project, "Updating branch changed files") {
override fun run(indicator: ProgressIndicator) {
profile(logger, "Determining merge base and calculating file diffs") {
_mergeBaseRevision =
GitHistoryUtils.getMergeBase(project, repo.root, branchName, mainBranch)?.rev
?: mainBranch
mergeBaseChangedPaths = getPathsDiffBetweenRefs(git, repo, branchName, mergeBaseRevision!!)
}
onAffectedFilesChanged()
}
}
progressManager.runProcessWithProgressAsynchronously(bgTask, EmptyProgressIndicator())
}
private fun onAffectedFilesChanged() {
currentAffectedPaths = changeListManager.affectedPaths.map { it.path }.toSet()
branchChangedPaths = mergeBaseChangedPaths.union(currentAffectedPaths)
eventDispatcher.multicaster.update()
}
private fun getMainBranchName(): String = settings.branchChangedFilesMainBranch
/**
* Returns absolute paths which have changed locally comparing to the current branch, i.e. performs
* `git diff --name-only origin/master...master`
*
* Paths are absolute, Git-formatted (i.e. with forward slashes).
*/
@Throws(VcsException::class)
fun getPathsDiffBetweenRefs(
git: Git, repository: GitRepository,
beforeRef: @NonNls String, afterRef: @NonNls String,
): Set<String> {
val parameters: List<String> = mutableListOf("--name-only", "--pretty=format:")
val range = "$afterRef...$beforeRef"
val result = git.diff(repository, parameters, range)
if (!result.success()) {
logger.info("Couldn't get diff in range [$range] for repository [${repository.toLogString()}]")
return emptySet()
}
val remoteChanges = HashSet<String>()
val s = StringScanner(result.outputAsJoinedString)
while (s.hasMoreData()) {
val relative = s.line()
if (StringUtil.isEmptyOrSpaces(relative)) {
continue
}
val path = repository.root.path + "/" + GitUtil.unescapePath(relative)
remoteChanges.add(path)
}
return remoteChanges
}
/**
* Find the earliest commit since mergeBaseRevision which includes the given file
*/
fun getEarliestMergeBaseRevisionForFile(repo: GitRepository, file: VirtualFile): String? {
val baseRevision = mergeBaseRevision
?: getMainBranchName().takeIf { repo.branches.findBranchByName(it) != null }
?: return null
val h = GitLineHandler(project, repo.root, GitCommand.LOG)
h.setSilent(true)
h.addParameters("--pretty=format:%H")
h.addParameters("--diff-filter=A")
h.addParameters("--follow")
h.addParameters(baseRevision, "HEAD")
h.endOptions()
h.addRelativeFiles(listOf(file))
val result = git.runCommand(h).getOutputOrThrow()
if (result.isEmpty()) {
return baseRevision
}
// NOTE: due to --follow chasing commits across renames, the commit returned by git log
// may be earlier than the merge-base commit. We must therefore check that the
// returned commit is actually a descendant of the merge-base commit.
val earliestFollowedRev = result.trim()
if (isAncestor(repo, earliestFollowedRev, baseRevision)) {
return baseRevision
}
return earliestFollowedRev
}
fun isAncestor(repo: GitRepository, ancestorRev: String, descendantRev: String): Boolean {
val descHandler = GitLineHandler(project, repo.root, GitCommand.MERGE_BASE)
descHandler.setSilent(true)
descHandler.addParameters("--is-ancestor")
descHandler.addParameters(ancestorRev)
descHandler.addParameters(descendantRev)
return git.runCommandWithoutCollectingOutput(descHandler).success()
}
}
interface BranchChangedFilesTrackerListener : EventListener {
fun update()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment