[vcs-log] commit message prefix navigation in VCS Log table (IJPL-89240)

GitOrigin-RevId: 3b0196284ac76797ee92b7f050643e01393850a5
This commit is contained in:
Dmitry Zhuravlev
2024-05-28 14:50:15 +02:00
committed by intellij-monorepo-bot
parent 6100fb3643
commit d641d05793
23 changed files with 794 additions and 92 deletions

View File

@@ -817,6 +817,10 @@ vcs.log.filter.text.on.the.fly=false
vcs.log.filter.text.on.the.fly.description=If enabled, applies text filter to the Log while typing
vcs.log.filter.text.highlight.matches=false
vcs.log.filter.text.highlight.matches.description=Highlight text filter matches in the Log table
vcs.log.render.commit.links=true
vcs.log.render.commit.links.description=Render commit links (e.g., fixup!, squash!) in the Log table.
vcs.log.render.commit.links.process.chunk=50
vcs.log.render.commit.links.process.chunk.description=The number of commits from the Log table to resolve links.
vcs.log.max.changes.shown=50000
vcs.log.max.changes.shown.description=Limit for showing commits in the changes view.
vcs.log.max.branches.shown=100

View File

@@ -13,6 +13,8 @@
*f:com.intellij.codeInsight.hints.VcsCodeVisionLanguageContext$Companion
- sf:EXTENSION:java.lang.String
- f:getProvidersExtensionPoint():com.intellij.lang.LanguageExtension
*:com.intellij.openapi.vcs.LinkDescriptor
- a:getRange():com.intellij.openapi.util.TextRange
*:com.intellij.openapi.vcs.changes.CommitExecutorWithRichDescription
- com.intellij.openapi.vcs.changes.CommitExecutor
- a:getText(com.intellij.vcs.commit.CommitWorkflowHandlerState):java.lang.String

View File

@@ -281,6 +281,7 @@ c:com.intellij.openapi.vcs.IssueNavigationConfiguration
- s:processTextWithLinks(java.lang.String,java.util.List,java.util.function.Consumer,java.util.function.BiConsumer):V
- setLinks(java.util.List):V
c:com.intellij.openapi.vcs.IssueNavigationConfiguration$LinkMatch
- com.intellij.openapi.vcs.LinkDescriptor
- java.lang.Comparable
- <init>(com.intellij.openapi.util.TextRange,java.lang.String):V
- compareTo(java.lang.Object):I

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.vcs;
@@ -56,7 +56,7 @@ public class IssueNavigationConfiguration extends SimpleModificationTracker
XmlSerializerUtil.copyBean(state, this);
}
public static class LinkMatch implements Comparable {
public static class LinkMatch implements LinkDescriptor, Comparable {
private final TextRange myRange;
private final String myTargetUrl;
@@ -65,6 +65,8 @@ public class IssueNavigationConfiguration extends SimpleModificationTracker
myTargetUrl = targetUrl;
}
@NotNull
@Override
public TextRange getRange() {
return myRange;
}

View File

@@ -0,0 +1,16 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.vcs
import com.intellij.openapi.util.TextRange
import org.jetbrains.annotations.ApiStatus
/**
* Represent a link in some text.
*
* [range] - E.g., link text range.
* E.g., a substring range of corresponding text with a link.
*/
@ApiStatus.Experimental
interface LinkDescriptor {
val range: TextRange
}

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.vcs.changes.issueLinks;
import com.intellij.openapi.project.Project;
@@ -7,6 +7,7 @@ import com.intellij.openapi.vcs.IssueNavigationConfiguration;
import com.intellij.ui.SimpleColoredComponent;
import com.intellij.ui.SimpleTextAttributes;
import com.intellij.util.ui.JBUI;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
@@ -53,7 +54,8 @@ public class IssueLinkRenderer {
myColoredComponent.append(piece, baseStyle, new SimpleColoredComponent.BrowserLauncherTag(targetUrl));
}
private static SimpleTextAttributes getLinkAttributes(@NotNull SimpleTextAttributes baseStyle) {
@ApiStatus.Internal
public static SimpleTextAttributes getLinkAttributes(@NotNull SimpleTextAttributes baseStyle) {
Color color = baseStyle.getFgColor();
int alpha = color != null ? color.getAlpha() : 255;
Color linkColor = JBUI.CurrentTheme.Link.Foreground.ENABLED;

View File

@@ -145,6 +145,19 @@
- a:getValue(com.intellij.vcs.log.ui.table.GraphTableModel,I):java.lang.Object
- a:isDynamic():Z
- isResizable():Z
*:com.intellij.vcs.log.ui.table.links.CommitLinksProvider
- *sf:Companion:com.intellij.vcs.log.ui.table.links.CommitLinksProvider$Companion
- a:getLinks(com.intellij.vcs.log.CommitId):java.util.List
- s:getServiceOrNull(com.intellij.openapi.project.Project):com.intellij.vcs.log.ui.table.links.CommitLinksProvider
- a:resolveLinks(java.lang.String,com.intellij.vcs.log.data.VcsLogData,com.intellij.vcs.log.visible.VisiblePack,I,I):V
*f:com.intellij.vcs.log.ui.table.links.CommitLinksProvider$Companion
- f:getServiceOrNull(com.intellij.openapi.project.Project):com.intellij.vcs.log.ui.table.links.CommitLinksProvider
*:com.intellij.vcs.log.ui.table.links.CommitLinksResolveListener
- java.util.EventListener
- *sf:Companion:com.intellij.vcs.log.ui.table.links.CommitLinksResolveListener$Companion
- sf:TOPIC:com.intellij.util.messages.Topic
- a:onLinksResolved(java.lang.String):V
*f:com.intellij.vcs.log.ui.table.links.CommitLinksResolveListener$Companion
f:com.intellij.vcs.log.visible.VcsLogFilterUtilKt
- *sf:filter(com.intellij.vcs.log.data.VcsLogData,com.intellij.vcs.log.VcsLogFilterCollection,com.intellij.vcs.log.visible.CommitCountStage):it.unimi.dsi.fastutil.ints.IntSet
- *bs:filter$default(com.intellij.vcs.log.data.VcsLogData,com.intellij.vcs.log.VcsLogFilterCollection,com.intellij.vcs.log.visible.CommitCountStage,I,java.lang.Object):it.unimi.dsi.fastutil.ints.IntSet

View File

@@ -1205,7 +1205,7 @@ c:com.intellij.vcs.log.ui.table.VcsLogGraphTable
- com.intellij.openapi.actionSystem.UiCompatibleDataProvider
- com.intellij.ui.table.JBTable
- com.intellij.vcs.log.ui.table.VcsLogCommitList
- <init>(java.lang.String,com.intellij.vcs.log.data.VcsLogData,com.intellij.vcs.log.impl.VcsLogUiProperties,com.intellij.vcs.log.ui.VcsLogColorManager,java.lang.Runnable,com.intellij.openapi.Disposable):V
- <init>(com.intellij.vcs.log.ui.VcsLogUiEx,com.intellij.vcs.log.data.VcsLogData,com.intellij.vcs.log.impl.VcsLogUiProperties,com.intellij.vcs.log.ui.VcsLogColorManager,java.lang.Runnable,com.intellij.openapi.Disposable):V
- addHighlighter(com.intellij.vcs.log.VcsLogHighlighter):V
- p:appendActionToEmptyText(java.lang.String,java.lang.Runnable):V
- applyHighlighters(java.awt.Component,I,I,Z,Z):com.intellij.ui.SimpleTextAttributes

View File

@@ -44,5 +44,6 @@
<orderEntry type="module" module-name="intellij.platform.backend.observation" />
<orderEntry type="module" module-name="intellij.platform.util.coroutines" />
<orderEntry type="module" module-name="intellij.platform.util.io.storages" />
<orderEntry type="library" scope="TEST" name="mockito" level="project" />
</component>
</module>

View File

@@ -96,7 +96,7 @@ class FileHistoryPanel extends JPanel implements UiDataProvider, Disposable {
myFileHistoryModel = fileHistoryModel;
myProperties = logUi.getProperties();
myGraphTable = new VcsLogGraphTable(logUi.getId(), logData, logUi.getProperties(), colorManager,
myGraphTable = new VcsLogGraphTable(logUi, logData, logUi.getProperties(), colorManager,
() -> logUi.requestMore(EmptyRunnable.INSTANCE), disposable) {
@Override
protected void updateEmptyText() {

View File

@@ -40,10 +40,7 @@ import com.intellij.vcs.log.impl.CommonUiProperties;
import com.intellij.vcs.log.impl.MainVcsLogUiProperties;
import com.intellij.vcs.log.impl.VcsLogNavigationUtil;
import com.intellij.vcs.log.impl.VcsLogUiProperties;
import com.intellij.vcs.log.ui.AbstractVcsLogUi;
import com.intellij.vcs.log.ui.VcsLogActionIds;
import com.intellij.vcs.log.ui.VcsLogColorManager;
import com.intellij.vcs.log.ui.VcsLogInternalDataKeys;
import com.intellij.vcs.log.ui.*;
import com.intellij.vcs.log.ui.details.CommitDetailsListPanel;
import com.intellij.vcs.log.ui.details.commit.CommitDetailsPanel;
import com.intellij.vcs.log.ui.filter.VcsLogFilterUiEx;
@@ -109,7 +106,7 @@ public class MainFrame extends JPanel implements UiDataProvider, Disposable {
myFilterUi = filterUi;
myGraphTable = new MyVcsLogGraphTable(logUi.getId(), logData, logUi.getProperties(), colorManager,
myGraphTable = new MyVcsLogGraphTable(logUi, logData, logUi.getProperties(), colorManager,
() -> logUi.getRefresher().onRefresh(), () -> logUi.requestMore(EmptyRunnable.INSTANCE),
disposable);
String vcsDisplayName = VcsLogUtil.getVcsDisplayName(logData.getProject(), logData.getLogProviders().values());
@@ -405,11 +402,11 @@ public class MainFrame extends JPanel implements UiDataProvider, Disposable {
private class MyVcsLogGraphTable extends VcsLogGraphTable {
private final @NotNull Runnable myRefresh;
MyVcsLogGraphTable(@NotNull String logId, @NotNull VcsLogData logData,
MyVcsLogGraphTable(@NotNull VcsLogUiEx logUi, @NotNull VcsLogData logData,
@NotNull VcsLogUiProperties uiProperties, @NotNull VcsLogColorManager colorManager,
@NotNull Runnable refresh, @NotNull Runnable requestMore,
@NotNull Disposable disposable) {
super(logId, logData, uiProperties, colorManager, requestMore, disposable);
super(logUi, logData, uiProperties, colorManager, requestMore, disposable);
myRefresh = refresh;
IndexSpeedSearch speedSearch = new IndexSpeedSearch(myLogData.getProject(), myLogData.getIndex(), myLogData.getStorage(), this) {
@Override

View File

@@ -1,12 +1,14 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.vcs.log.ui.render;
import com.intellij.openapi.util.NlsSafe;
import com.intellij.vcs.log.CommitId;
import com.intellij.vcs.log.VcsRef;
import com.intellij.vcs.log.graph.PrintElement;
import com.intellij.vcs.log.ui.VcsBookmarkRef;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
@@ -18,14 +20,21 @@ public class GraphCommitCell {
private final @NotNull Collection<VcsBookmarkRef> myBookmarksToThisCommit;
private final @NotNull Collection<? extends PrintElement> myPrintElements;
public GraphCommitCell(@NotNull String text,
private final @Nullable CommitId myCommitId;
private final boolean myIsLoading;
public GraphCommitCell(@Nullable CommitId commitId,
@NotNull String text,
@NotNull Collection<VcsRef> refsToThisCommit,
@NotNull Collection<VcsBookmarkRef> bookmarksToThisCommit,
@NotNull Collection<? extends PrintElement> printElements) {
@NotNull Collection<? extends PrintElement> printElements,
boolean isLoading) {
myCommitId = commitId;
myText = text;
myRefsToThisCommit = refsToThisCommit;
myBookmarksToThisCommit = bookmarksToThisCommit;
myPrintElements = printElements;
myIsLoading = isLoading;
}
public @NotNull @NlsSafe String getText() {
@@ -44,6 +53,14 @@ public class GraphCommitCell {
return myPrintElements;
}
public @Nullable CommitId getCommitId() {
return myCommitId;
}
public boolean isLoading() {
return myIsLoading;
}
@Override
public String toString() {
return myText;

View File

@@ -11,6 +11,7 @@ import com.intellij.ui.scale.JBUIScale;
import com.intellij.ui.speedSearch.SpeedSearchUtil;
import com.intellij.util.ui.JBUI;
import com.intellij.util.ui.StartupUiUtil;
import com.intellij.vcs.log.CommitId;
import com.intellij.vcs.log.VcsLogFilterCollection;
import com.intellij.vcs.log.VcsLogTextFilter;
import com.intellij.vcs.log.VcsRef;
@@ -26,6 +27,7 @@ import com.intellij.vcs.log.ui.table.VcsLogCellRenderer;
import com.intellij.vcs.log.ui.table.VcsLogGraphTable;
import com.intellij.vcs.log.ui.table.column.Commit;
import com.intellij.vcs.log.ui.table.column.VcsLogColumnManager;
import com.intellij.vcs.log.ui.table.links.VcsLinksRenderer;
import com.intellij.vcs.log.visible.filters.VcsLogTextFilterWithMatches;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
@@ -158,6 +160,7 @@ public class GraphCommitCellRenderer extends TypeSafeTableCellRenderer<GraphComm
private final @NotNull VcsLogGraphTable myGraphTable;
private final @NotNull GraphCellPainter myPainter;
private final @NotNull IssueLinkRenderer myIssueLinkRenderer;
private final @NotNull VcsLinksRenderer myVcsLinksRenderer;
private final @NotNull VcsLogLabelPainter myReferencePainter;
private @NotNull Collection<? extends PrintElement> myPrintElements = Collections.emptyList();
@@ -174,6 +177,7 @@ public class GraphCommitCellRenderer extends TypeSafeTableCellRenderer<GraphComm
myGraphTable = table;
myReferencePainter = new VcsLogLabelPainter(data, table, iconCache);
myVcsLinksRenderer = new VcsLinksRenderer(data.getProject(), this);
myIssueLinkRenderer = new IssueLinkRenderer(data.getProject(), this);
setCellState(new VcsLogTableCellState());
@@ -229,6 +233,7 @@ public class GraphCommitCellRenderer extends TypeSafeTableCellRenderer<GraphComm
}
append(""); // appendTextPadding wont work without this
boolean renderLinks = !cell.isLoading();
if (myReferencePainter.isLeftAligned()) {
myReferencePainter.customizePainter(refs, bookmarks, getBackground(), labelForeground, isSelected,
getAvailableWidth(column, myGraphWidth));
@@ -236,18 +241,32 @@ public class GraphCommitCellRenderer extends TypeSafeTableCellRenderer<GraphComm
int referencesWidth = myReferencePainter.getSize().width;
if (referencesWidth > 0) referencesWidth += LabelPainter.RIGHT_PADDING.get();
appendTextPadding(myGraphWidth + referencesWidth);
appendText(cell, style, isSelected);
appendText(cell, style, isSelected, renderLinks);
}
else {
appendTextPadding(myGraphWidth);
appendText(cell, style, isSelected);
appendText(cell, style, isSelected, renderLinks);
myReferencePainter.customizePainter(refs, bookmarks, getBackground(), labelForeground, isSelected,
getAvailableWidth(column, myGraphWidth));
}
}
private void appendText(@NotNull GraphCommitCell cell, @NotNull SimpleTextAttributes style, boolean isSelected) {
myIssueLinkRenderer.appendTextWithLinks(StringUtil.replace(cell.getText(), "\t", " ").trim(), style);
private void appendText(@NotNull GraphCommitCell cell, @NotNull SimpleTextAttributes style, boolean isSelected, boolean renderLinks) {
String cellText = StringUtil.replace(cell.getText(), "\t", " ").trim();
CommitId commitId = cell.getCommitId();
if (renderLinks) {
if (VcsLinksRenderer.isEnabled()) {
myVcsLinksRenderer.appendTextWithLinks(cellText, style, commitId);
}
else {
myIssueLinkRenderer.appendTextWithLinks(cellText, style);
}
}
else {
append(cellText, style);
}
SpeedSearchUtil.applySpeedSearchHighlighting(myGraphTable, this, false, isSelected);
if (Registry.is("vcs.log.filter.text.highlight.matches")) {
VcsLogTextFilter textFilter = myGraphTable.getModel().getVisiblePack().getFilters().get(VcsLogFilterCollection.TEXT_FILTER);

View File

@@ -16,6 +16,7 @@ import com.intellij.vcs.log.ui.table.column.VcsLogColumn;
import com.intellij.vcs.log.ui.table.column.VcsLogColumnManager;
import com.intellij.vcs.log.util.VcsLogUtil;
import com.intellij.vcs.log.visible.VisiblePack;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -156,6 +157,12 @@ public final class GraphTableModel extends AbstractTableModel implements VcsLogC
public @Nullable CommitId getCommitId(int row) {
VcsCommitMetadata metadata = getCommitMetadata(row);
if (metadata instanceof LoadingDetails) return null;
return getCommitId(metadata);
}
@ApiStatus.Internal
public @Nullable CommitId getCommitId(@NotNull VcsCommitMetadata metadata) {
if (metadata instanceof LoadingDetails) return null;
return new CommitId(metadata.getId(), metadata.getRoot());
}

View File

@@ -38,13 +38,18 @@ import com.intellij.vcs.log.graph.RowType;
import com.intellij.vcs.log.graph.VisibleGraph;
import com.intellij.vcs.log.graph.actions.GraphAnswer;
import com.intellij.vcs.log.impl.CommonUiProperties;
import com.intellij.vcs.log.impl.VcsLogNavigationUtil;
import com.intellij.vcs.log.impl.VcsLogUiProperties;
import com.intellij.vcs.log.statistics.VcsLogUsageTriggerCollector;
import com.intellij.vcs.log.ui.VcsLogColorManager;
import com.intellij.vcs.log.ui.VcsLogInternalDataKeys;
import com.intellij.vcs.log.ui.VcsLogUiEx;
import com.intellij.vcs.log.ui.render.GraphCommitCellRenderer;
import com.intellij.vcs.log.ui.render.SimpleColoredComponentLinkMouseListener;
import com.intellij.vcs.log.ui.table.column.*;
import com.intellij.vcs.log.ui.table.links.CommitLinksProvider;
import com.intellij.vcs.log.ui.table.links.NavigateToCommit;
import com.intellij.vcs.log.ui.table.links.VcsLinksRenderer;
import com.intellij.vcs.log.util.VcsLogUiUtil;
import com.intellij.vcs.log.util.VcsLogUtil;
import com.intellij.vcs.log.visible.VisiblePack;
@@ -55,6 +60,7 @@ import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
@@ -95,7 +101,7 @@ public class VcsLogGraphTable extends TableWithProgress
private static final Color SELECTION_FOREGROUND_INACTIVE = JBColor.namedColor("VersionControl.Log.Commit.selectionInactiveForeground",
NamedColorUtil.getListSelectionForeground(false));
private final @NotNull VcsLogData myLogData;
private final @NotNull String myId;
private final @NotNull VcsLogUiEx myLogUi;
private final @NotNull VcsLogUiProperties myProperties;
private final @NotNull VcsLogColorManager myColorManager;
@@ -113,14 +119,14 @@ public class VcsLogGraphTable extends TableWithProgress
private boolean myDisposed = false;
public VcsLogGraphTable(@NotNull String logId, @NotNull VcsLogData logData,
public VcsLogGraphTable(@NotNull VcsLogUiEx logUi, @NotNull VcsLogData logData,
@NotNull VcsLogUiProperties uiProperties, @NotNull VcsLogColorManager colorManager,
@NotNull Runnable requestMore, @NotNull Disposable disposable) {
super(new GraphTableModel(logData, requestMore, uiProperties));
Disposer.register(disposable, this);
myLogData = logData;
myId = logId;
myLogUi = logUi;
myProperties = uiProperties;
myColorManager = colorManager;
@@ -161,6 +167,31 @@ public class VcsLogGraphTable extends TableWithProgress
putClientProperty(BookmarksManager.ALLOWED, true);
ScrollingUtil.installActions(this, false);
registerResolveLinks();
}
private void registerResolveLinks() {
GraphTableModel model = getModel();
model.addTableModelListener(new TableModelListener() {
@Override
public void tableChanged(TableModelEvent e) {
resolveLinks();
}
});
}
private void resolveLinks() {
if (!VcsLinksRenderer.isEnabled()) return;
VisiblePack visiblePack = getModel().getVisiblePack();
VisibleGraph<Integer> visibleGraph = visiblePack.getVisibleGraph();
if (visibleGraph.getVisibleCommitCount() == 0) return;
CommitLinksProvider linksProvider = CommitLinksProvider.getServiceOrNull(getLogData().getProject());
if (linksProvider != null) {
Couple<Integer> visibleRows = ScrollingUtil.getVisibleRows(this);
linksProvider.resolveLinks(getId(), myLogData, visiblePack, visibleRows.first, visibleRows.second);
}
}
@Override
@@ -170,7 +201,7 @@ public class VcsLogGraphTable extends TableWithProgress
}
public @NotNull @NonNls String getId() {
return myId;
return myLogUi.getId();
}
@Override
@@ -687,6 +718,9 @@ public class VcsLogGraphTable extends TableWithProgress
model.fireTableChanged(evt);
}
}
resolveLinks();
mySelectionSnapshot = null;
});
}
@@ -904,10 +938,18 @@ public class VcsLogGraphTable extends TableWithProgress
getExpandableItemsHandler().setEnabled(true);
}
private static class MyLinkMouseListener extends SimpleColoredComponentLinkMouseListener {
private class MyLinkMouseListener extends SimpleColoredComponentLinkMouseListener {
@Override
public @Nullable Object getTagAt(@NotNull MouseEvent e) {
return ObjectUtils.tryCast(super.getTagAt(e), SimpleColoredComponent.BrowserLauncherTag.class);
public boolean onClick(@NotNull MouseEvent e, int clickCount) {
Object tag = getTagAt(e);
if ((tag instanceof Runnable)) return super.onClick(e, clickCount);
if (tag instanceof NavigateToCommit navigateToCommitTag) {
VcsLogNavigationUtil.jumpToHash(VcsLogGraphTable.this.myLogUi, navigateToCommitTag.getTarget(), false, true);
return true;
}
return false;
}
}
}
@@ -941,7 +983,7 @@ public class VcsLogGraphTable extends TableWithProgress
@Override
public void progressChanged(@NotNull Collection<? extends VcsLogProgress.ProgressKey> keys) {
if (VcsLogUiUtil.isProgressVisible(keys, myId)) {
if (VcsLogUiUtil.isProgressVisible(keys, myLogUi.getId())) {
getEmptyText().setText(VcsLogBundle.message("vcs.log.loading.status"));
}
else {

View File

@@ -24,6 +24,7 @@ import com.intellij.vcs.log.ui.frame.CommitPresentationUtil
import com.intellij.vcs.log.ui.render.GraphCommitCell
import com.intellij.vcs.log.ui.render.GraphCommitCellRenderer
import com.intellij.vcs.log.ui.table.*
import com.intellij.vcs.log.ui.table.links.CommitLinksResolveListener
import com.intellij.vcs.log.util.VcsLogUtil
import com.intellij.vcs.log.visible.VisiblePack
import com.intellij.vcsUtil.VcsUtil
@@ -82,11 +83,14 @@ internal object Commit : VcsLogDefaultColumn<GraphCommitCell>("Default.Subject",
else model.getRowInfo(row).printElements
val metadata = model.getCommitMetadata(row, true)
val commitId = model.getCommitId(metadata)
return GraphCommitCell(
commitId,
getValue(model, metadata),
model.getRefsAtRow(row),
if (metadata !is LoadingDetails) getBookmarkRefs(model.logData.project, metadata.id, metadata.root) else emptyList(),
printElements
printElements,
metadata is LoadingDetails
)
}
@@ -124,10 +128,17 @@ internal object Commit : VcsLogDefaultColumn<GraphCommitCell>("Default.Subject",
}
})
table.logData.project.messageBus.connect(table).subscribe(CommitLinksResolveListener.TOPIC, CommitLinksResolveListener { logId->
if (logId == table.id) {
table.repaint()
}
})
return commitCellRenderer
}
override fun getStubValue(model: GraphTableModel): GraphCommitCell = GraphCommitCell("", emptyList(), emptyList(), emptyList())
override fun getStubValue(model: GraphTableModel): GraphCommitCell {
return GraphCommitCell(null, "", emptyList(), emptyList(), emptyList(), true)
}
}

View File

@@ -0,0 +1,44 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.vcs.log.ui.table.links
import com.intellij.openapi.components.serviceOrNull
import com.intellij.openapi.project.Project
import com.intellij.openapi.vcs.LinkDescriptor
import com.intellij.util.messages.Topic
import com.intellij.vcs.log.CommitId
import com.intellij.vcs.log.data.VcsLogData
import com.intellij.vcs.log.visible.VisiblePack
import org.jetbrains.annotations.ApiStatus
import java.util.*
@ApiStatus.Experimental
interface CommitLinksProvider {
/**
* Return cached [LinkDescriptor] for the given [CommitId].
*/
fun getLinks(commitId: CommitId): List<LinkDescriptor>
/**
* Asynchronously search in the given commits for links and cache it.
*
* E.g., in the Git VCS it could be "fixup!", "squash!" and "amend!" prefixes in the commit message subject.
*/
fun resolveLinks(logId: String, logData: VcsLogData, visiblePack: VisiblePack,
startRow: Int, endRow: Int)
companion object {
@JvmStatic
fun getServiceOrNull(project: Project) = project.serviceOrNull<CommitLinksProvider>()
}
}
@ApiStatus.Experimental
fun interface CommitLinksResolveListener : EventListener {
companion object {
@JvmField
@Topic.ProjectLevel
val TOPIC = Topic(CommitLinksResolveListener::class.java, Topic.BroadcastDirection.NONE)
}
fun onLinksResolved(logId: String)
}

View File

@@ -0,0 +1,85 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.vcs.log.ui.table.links
import com.intellij.openapi.components.serviceOrNull
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.vcs.IssueNavigationConfiguration
import com.intellij.openapi.vcs.IssueNavigationConfiguration.LinkMatch
import com.intellij.openapi.vcs.LinkDescriptor
import com.intellij.openapi.vcs.changes.issueLinks.IssueLinkRenderer
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleColoredComponent.BrowserLauncherTag
import com.intellij.ui.SimpleTextAttributes
import com.intellij.util.text.TextRangeUtil
import com.intellij.vcs.log.CommitId
import org.jetbrains.annotations.ApiStatus
internal class VcsLinksRenderer(project: Project,
private val coloredComponent: SimpleColoredComponent,
private val commitPrefixLinkRenderer: CommitLinksProvider?) {
constructor(project: Project, coloredComponent: SimpleColoredComponent) :
this(project, coloredComponent, project.serviceOrNull<CommitLinksProvider>())
private val issueNavigationConfiguration = IssueNavigationConfiguration.getInstance(project);
@Suppress("HardCodedStringLiteral")
fun appendTextWithLinks(textWithLinks: @NlsSafe String, baseStyle: SimpleTextAttributes, commitId: CommitId?) {
val issuesLinks = issueNavigationConfiguration.findIssueLinks(textWithLinks)
val linksToCommits =
if (commitId != null && commitPrefixLinkRenderer != null) {
commitPrefixLinkRenderer.getLinks(commitId)
}
else {
emptyList()
}
if (issuesLinks.isEmpty() && linksToCommits.isEmpty()) {
coloredComponent.append(textWithLinks, baseStyle)
return
}
val allMatched =
(issuesLinks.filterNot { TextRangeUtil.intersectsOneOf(it.range, linksToCommits.map(LinkDescriptor::range)) } + linksToCommits)
.sortedWith { l1, l2 -> TextRangeUtil.RANGE_COMPARATOR.compare(l1.range, l2.range) }
val linkStyle = IssueLinkRenderer.getLinkAttributes(baseStyle)
var processedOffset = 0
for (link in allMatched) {
val textRange = link.range
if (textRange.startOffset > processedOffset) {
coloredComponent.append(textWithLinks.substring(processedOffset, textRange.startOffset), baseStyle)
}
coloredComponent.append(textRange.substring(textWithLinks), linkStyle, link.asTag())
processedOffset = textRange.endOffset
}
if (processedOffset < textWithLinks.length) {
coloredComponent.append(textWithLinks.substring(processedOffset), baseStyle)
}
}
private fun LinkDescriptor?.asTag(): Any? {
return when (this) {
is NavigateToCommit -> this
is LinkMatch -> BrowserLauncherTag(targetUrl)
else -> null
}
}
companion object {
@JvmStatic
fun isEnabled() = Registry.`is`("vcs.log.render.commit.links", false)
}
}
@ApiStatus.Internal
class NavigateToCommit(override val range: TextRange, val target: String) : LinkDescriptor

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.vcs.log.data;
import com.intellij.openapi.diagnostic.Logger;
@@ -6,6 +6,7 @@ import com.intellij.openapi.progress.EmptyProgressIndicator;
import com.intellij.openapi.progress.ProgressManager;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.progress.impl.ProgressManagerImpl;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.ExceptionUtil;
@@ -31,11 +32,10 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
private static final int RECENT_COMMITS_COUNT = 2;
private TestVcsLogProvider myLogProvider;
private VcsLogData myLogData;
private Map<VirtualFile, VcsLogProvider> myLogProviders;
private LogRefresherTestHelper myRefresherTestHelper;
private DataWaiter myDataWaiter;
private VcsLogRefresher myLoader;
private final List<Future<?>> myStartedTasks = Collections.synchronizedList(new ArrayList<>());
private List<String> myCommits;
@@ -44,24 +44,24 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
super.setUp();
myLogProvider = new TestVcsLogProvider();
myLogProviders = Collections.singletonMap(getProjectRoot(), myLogProvider);
Map<VirtualFile, VcsLogProvider> logProviders = Collections.singletonMap(getProjectRoot(), myLogProvider);
myCommits = Arrays.asList("3|-a2|-a1", "2|-a1|-a", "1|-a|-");
myLogProvider.appendHistory(log(myCommits));
myLogProvider.addRef(createBranchRef("master", "a2"));
myLogData =
new VcsLogData(myProject, logProviders, new LoggingErrorHandler(LOG), VcsLogSharedSettings.isIndexSwitchedOn(getProject()),
myProject);
myRefresherTestHelper = new LogRefresherTestHelper(myLogData, RECENT_COMMITS_COUNT);
myDataWaiter = new DataWaiter();
myLoader = createLoader(myDataWaiter);
myDataWaiter = myRefresherTestHelper.myDataWaiter;
myLoader = myRefresherTestHelper.myLoader;
}
@Override
public void tearDown() {
try {
assertNoMoreResultsArrive();
myDataWaiter.tearDown();
if (myDataWaiter.failed()) {
fail("Only one refresh should have happened, an error happened instead: " + myDataWaiter.getExceptionText());
}
myRefresherTestHelper.tearDown();
}
catch (Throwable e) {
addSuppressedException(e);
@@ -84,7 +84,7 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
myLogProvider.unblockFullLog();
assertNotNull(result);
assertDataPack(log(myCommits.subList(0, RECENT_COMMITS_COUNT)), result.getPermanentGraph().getAllCommits());
waitForBackgroundTasksToComplete();
myRefresherTestHelper.waitForBackgroundTasksToComplete();
myDataWaiter.get();
}
@@ -102,7 +102,7 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
myLogProvider.blockFullLog();
myLoader.initialize();
myDataWaiter.get();
assertTimeout("Refresh waiter should have failed on the timeout");
myRefresherTestHelper.assertTimeout("Refresh waiter should have failed on the timeout");
myLogProvider.unblockFullLog();
DataPack result = myDataWaiter.get();
@@ -110,7 +110,7 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
}
public void test_refresh_captures_new_commits() throws InterruptedException, ExecutionException, TimeoutException {
initAndWaitForFirstRefresh();
myRefresherTestHelper.initAndWaitForFirstRefresh();
String newCommit = "4|-a3|-a2";
myLogProvider.appendHistory(log(newCommit));
@@ -124,7 +124,7 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
}
public void test_single_refresh_causes_single_data_read() throws InterruptedException, ExecutionException, TimeoutException {
initAndWaitForFirstRefresh();
myRefresherTestHelper.initAndWaitForFirstRefresh();
myLogProvider.resetReadFirstBlockCounter();
myLoader.refresh(Collections.singletonList(getProjectRoot()), false);
@@ -133,7 +133,7 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
}
public void test_reinitialize_makes_refresh_cancelled() throws InterruptedException, ExecutionException, TimeoutException {
initAndWaitForFirstRefresh();
myRefresherTestHelper.initAndWaitForFirstRefresh();
// initiate the refresh and make it hang
myLogProvider.blockRefresh();
@@ -146,68 +146,96 @@ public class VcsLogRefresherTest extends VcsPlatformTest {
// we want to make sure only one data pack is reported
myLogProvider.unblockRefresh();
myDataWaiter.get();
assertNoMoreResultsArrive();
}
private void assertNoMoreResultsArrive() throws InterruptedException, ExecutionException, TimeoutException {
waitForBackgroundTasksToComplete();
assertTrue(myDataWaiter.myQueue.isEmpty());
}
private void waitForBackgroundTasksToComplete() throws InterruptedException, ExecutionException, TimeoutException {
for (Future<?> task : new ArrayList<>(myStartedTasks)) {
task.get(1, TimeUnit.SECONDS);
}
myRefresherTestHelper.assertNoMoreResultsArrive();
}
public void test_two_immediately_consecutive_refreshes_causes_only_one_data_pack_update()
throws InterruptedException, ExecutionException, TimeoutException {
initAndWaitForFirstRefresh();
myRefresherTestHelper.initAndWaitForFirstRefresh();
myLogProvider.blockRefresh();
myLoader.refresh(Collections.singletonList(getProjectRoot()), false); // this refresh hangs in VcsLogProvider.readFirstBlock()
myLoader.refresh(Collections.singletonList(getProjectRoot()), false); // this refresh is queued
myLogProvider.unblockRefresh(); // this will make the first one complete, and then perform the second as well
myDataWaiter.get();
assertTimeout("Second refresh shouldn't cause the data pack update"); // it may also fail in beforehand in set().
myRefresherTestHelper.assertTimeout("Second refresh shouldn't cause the data pack update"); // it may also fail in beforehand in set().
}
private void initAndWaitForFirstRefresh() throws InterruptedException, ExecutionException, TimeoutException {
// wait for the first block and the whole log to complete
myLoader.initialize();
public static class LogRefresherTestHelper {
DataPack firstDataPack = myDataWaiter.get();
assertFalse(firstDataPack.isFull());
private final @NotNull Project myProject;
DataPack fullDataPack = myDataWaiter.get();
assertTrue(fullDataPack.isFull());
assertNoMoreResultsArrive();
}
private final @NotNull DataWaiter myDataWaiter;
private final @NotNull VcsLogRefresher myLoader;
private void assertTimeout(@NotNull String message) throws InterruptedException {
assertNull(message, myDataWaiter.myQueue.poll(500, TimeUnit.MILLISECONDS));
}
private final List<Future<?>> myStartedTasks = Collections.synchronizedList(new ArrayList<>());
private VcsLogRefresherImpl createLoader(Consumer<? super DataPack> dataPackConsumer) {
myLogData = new VcsLogData(myProject, myLogProviders, new LoggingErrorHandler(LOG), VcsLogSharedSettings.isIndexSwitchedOn(getProject()),
myProject);
VcsLogRefresherImpl refresher =
new VcsLogRefresherImpl(myProject, myLogData.getStorage(), myLogProviders, myLogData.getUserRegistry(),
myLogData.getModifiableIndex(),
new VcsLogProgress(myLogData),
myLogData.getTopCommitsCache(), dataPackConsumer, RECENT_COMMITS_COUNT
) {
@Override
protected SingleTaskController.SingleTask startNewBackgroundTask(final @NotNull Task.Backgroundable refreshTask) {
LOG.debug("Starting a background task...");
Future<?> future = ((ProgressManagerImpl)ProgressManager.getInstance()).runProcessWithProgressAsynchronously(refreshTask);
myStartedTasks.add(future);
LOG.debug(myStartedTasks.size() + " started tasks");
return new SingleTaskController.SingleTaskImpl(future, new EmptyProgressIndicator());
}
};
Disposer.register(myLogData, refresher);
return refresher;
public LogRefresherTestHelper(@NotNull VcsLogData logData, int recentCommitsCount) {
myProject = logData.getProject();
myDataWaiter = new DataWaiter();
myLoader = createLoader(logData, recentCommitsCount, myDataWaiter);
}
public void initAndWaitForFirstRefresh() throws InterruptedException, ExecutionException, TimeoutException {
// wait for the first block and the whole log to complete
myLoader.initialize();
DataPack firstDataPack = myDataWaiter.get();
assertFalse(firstDataPack.isFull());
DataPack fullDataPack = myDataWaiter.get();
assertTrue(fullDataPack.isFull());
assertNoMoreResultsArrive();
}
public void tearDown() throws ExecutionException, InterruptedException, TimeoutException {
assertNoMoreResultsArrive();
myDataWaiter.tearDown();
if (myDataWaiter.failed()) {
fail("Only one refresh should have happened, an error happened instead: " + myDataWaiter.getExceptionText());
}
}
public @NotNull DataPack getDataPack() {
return myLoader.getCurrentDataPack();
}
private void assertTimeout(@NotNull String message) throws InterruptedException {
assertNull(message, myDataWaiter.myQueue.poll(500, TimeUnit.MILLISECONDS));
}
private void assertNoMoreResultsArrive() throws InterruptedException, ExecutionException, TimeoutException {
waitForBackgroundTasksToComplete();
assertTrue(myDataWaiter.myQueue.isEmpty());
}
private void waitForBackgroundTasksToComplete() throws InterruptedException, ExecutionException, TimeoutException {
for (Future<?> task : new ArrayList<>(myStartedTasks)) {
task.get(1, TimeUnit.SECONDS);
}
}
private VcsLogRefresherImpl createLoader(@NotNull VcsLogData logData, int recentCommitsCount,
@NotNull Consumer<? super DataPack> dataPackConsumer) {
VcsLogRefresherImpl refresher =
new VcsLogRefresherImpl(myProject, logData.getStorage(), logData.getLogProviders(), logData.getUserRegistry(),
logData.getModifiableIndex(),
new VcsLogProgress(logData),
logData.getTopCommitsCache(), dataPackConsumer, recentCommitsCount
) {
@Override
protected SingleTaskController.SingleTask startNewBackgroundTask(final @NotNull Task.Backgroundable refreshTask) {
LOG.debug("Starting a background task...");
Future<?> future = ((ProgressManagerImpl)ProgressManager.getInstance()).runProcessWithProgressAsynchronously(refreshTask);
myStartedTasks.add(future);
LOG.debug(myStartedTasks.size() + " started tasks");
return new SingleTaskController.SingleTaskImpl(future, new EmptyProgressIndicator());
}
};
Disposer.register(logData, refresher);
return refresher;
}
}
private void assertDataPack(@NotNull List<TimedVcsCommit> expectedLog, @NotNull List<? extends GraphCommit<Integer>> actualLog) {

View File

@@ -0,0 +1,76 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.vcs.log.ui.table.links
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vcs.IssueNavigationConfiguration
import com.intellij.openapi.vcs.IssueNavigationLink
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes.REGULAR_ATTRIBUTES
import com.intellij.vcs.log.CommitId
import com.intellij.vcs.log.Hash
import com.intellij.vcs.test.VcsPlatformTest
import org.mockito.Mockito
class VcsLinksRendererTest : VcsPlatformTest() {
private lateinit var linksRenderer: VcsLinksRenderer
fun `test single prefix match`() {
val text = "fixup! some text"
val linksProvider = Mockito.mock(CommitLinksProvider::class.java)
val emptyCommitId = CommitId(EMPTY_HASH, projectRoot)
Mockito.`when`(linksProvider.getLinks(emptyCommitId))
.thenReturn(listOf("fixup!".toLink()))
val textComponent = SimpleColoredComponent()
linksRenderer = VcsLinksRenderer(project, textComponent, linksProvider)
linksRenderer.appendTextWithLinks(text, REGULAR_ATTRIBUTES, emptyCommitId)
assertTrue(textComponent.fragmentCount == 2)
assertEquals(text, textComponent.toString())
}
fun `test multiple prefix match`() {
val text = "fixup! fixup! squash! some text"
val emptyCommitId = CommitId(EMPTY_HASH, projectRoot)
val linksProvider = Mockito.mock(CommitLinksProvider::class.java)
Mockito.`when`(linksProvider.getLinks(emptyCommitId))
.thenReturn(listOf("fixup!".toLink(), "fixup!".toLink(7), "squash!".toLink(14)))
val textComponent = SimpleColoredComponent()
linksRenderer = VcsLinksRenderer(project, textComponent, linksProvider)
linksRenderer.appendTextWithLinks(text, REGULAR_ATTRIBUTES, emptyCommitId)
assertTrue(textComponent.fragmentCount == 6)
assertEquals(text, textComponent.toString())
}
fun `test multiple prefix with issue links match`() {
val text = "fixup! fixup! squash! some IDEA-1234 text IDEA-4567"
val linksProvider = Mockito.mock(CommitLinksProvider::class.java)
val emptyCommitId = CommitId(EMPTY_HASH, projectRoot)
IssueNavigationConfiguration.getInstance(project).links = listOf(
IssueNavigationLink("\\b[A-Z]+\\-\\d+\\b", "http://example.com/$0")
)
Mockito.`when`(linksProvider.getLinks(emptyCommitId))
.thenReturn(listOf("fixup!".toLink(), "fixup!".toLink(7), "squash!".toLink(14)))
val textComponent = SimpleColoredComponent()
linksRenderer = VcsLinksRenderer(project, textComponent, linksProvider)
linksRenderer.appendTextWithLinks(text, REGULAR_ATTRIBUTES, emptyCommitId)
assertTrue(textComponent.fragmentCount == 9)
assertEquals(text, textComponent.toString())
}
companion object {
private val EMPTY_HASH = object : Hash {
override fun asString(): String = ""
override fun toShortString(): String = ""
}
private fun String.toLink(offset: Int = 0) = NavigateToCommit(TextRange.from(offset, length), "")
}
}

View File

@@ -847,6 +847,8 @@
<history.activityPresentationProvider implementation="git4idea.GitActivityPresentationProvider"/>
<searchScopesProvider implementation="git4idea.search.GitSearchScopeProvider"/>
<projectService serviceInterface="com.intellij.vcs.log.ui.table.links.CommitLinksProvider"
serviceImplementation="git4idea.log.GitCommitLinkProvider"/>
</extensions>
<extensions defaultExtensionNs="Git4Idea">

View File

@@ -0,0 +1,205 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.log
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.vcs.LinkDescriptor
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.util.ui.update.MergingUpdateQueue
import com.intellij.util.ui.update.Update
import com.intellij.vcs.log.CommitId
import com.intellij.vcs.log.VcsCommitMetadata
import com.intellij.vcs.log.data.VcsLogData
import com.intellij.vcs.log.graph.impl.facade.VisibleGraphImpl
import com.intellij.vcs.log.graph.utils.DfsWalk
import com.intellij.vcs.log.graph.utils.LinearGraphUtils
import com.intellij.vcs.log.graph.utils.isAncestor
import com.intellij.vcs.log.ui.table.links.CommitLinksProvider
import com.intellij.vcs.log.ui.table.links.CommitLinksResolveListener
import com.intellij.vcs.log.ui.table.links.NavigateToCommit
import com.intellij.vcs.log.visible.VisiblePack
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
internal class GitCommitLinkProvider(private val project: Project) : CommitLinksProvider {
override fun getLinks(commitId: CommitId): List<LinkDescriptor> {
return project.service<GitLinkToCommitResolver>().getLinks(commitId)
}
override fun resolveLinks(logId: String, logData: VcsLogData, visiblePack: VisiblePack,
startRow: Int, endRow: Int) {
project.service<GitLinkToCommitResolver>().submitResolveLinks(logId, logData, visiblePack,
startRow, endRow)
}
}
@Service(Service.Level.PROJECT)
internal class GitLinkToCommitResolver(private val project: Project) {
companion object {
private const val PREFIX_DELIMITER_LENGTH = 1
private val prefixes = listOf("fixup!", "squash!", "amend!")
private val regex = "^(${prefixes.joinToString("|")}) (.*)$".toRegex()
private const val CACHE_MAX_SIZE = 1_000L
}
private val prefixesCache: Cache<CommitId, List<PrefixTarget>> =
Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.build()
private val resolveQueue = MergingUpdateQueue("resolve links queue", 100, true, null, project, null, false)
private val updateQueue = MergingUpdateQueue("after resolve links ui update queue", 100, true, null, project, null, true)
internal fun getLinks(commitId: CommitId): List<LinkDescriptor> {
return getCachedOrEmpty(commitId).map { NavigateToCommit(it.range, it.targetHash) }
}
internal fun submitResolveLinks(
logId: String,
logData: VcsLogData,
visiblePack: VisiblePack,
startRow: Int,
endRow: Int
) {
if (visiblePack.visibleGraph !is VisibleGraphImpl) return
val startFrom = max(0, startRow)
val end = max(0, endRow)
val rowRange = startFrom..end
val processingCount = min(abs(Registry.intValue("vcs.log.render.commit.links.process.chunk")), rowRange.count())
if (processingCount < 2) return
val visibleGraph = visiblePack.visibleGraph
resolveQueue.queue(Update.create(logId + startFrom) {
for (i in rowRange) {
val commitId = visibleGraph.getRowInfo(i).commit
val commit = logData.commitMetadataCache.getCachedData(commitId) ?: continue
resolveLinks(logData, visiblePack, commit.getCommitId(), commit.subject, processingCount)
}
updateQueue.queue(Update.create(logId) {
project.messageBus.syncPublisher(CommitLinksResolveListener.TOPIC).onLinksResolved(logId)
})
})
}
@RequiresBackgroundThread
internal fun resolveLinks(
logData: VcsLogData,
visiblePack: VisiblePack,
commitId: CommitId, commitMessage: String,
processingCount: Int
) {
if (visiblePack.visibleGraph !is VisibleGraphImpl) return
val cachedPrefixes = getCachedOrEmpty(commitId)
if (cachedPrefixes.isNotEmpty()) {
return
}
var match = regex.matchEntire(commitMessage) ?: return
var prefix = match.groups[1]?.value.orEmpty()
var rest = match.groups[2]?.value.orEmpty()
if (prefix.isBlank() && rest.isBlank()) return
var prefixOffset = 0
val existingPrefixes = cachedPrefixes.toMutableList()
while (prefix.isNotBlank() && rest.isNotBlank()) {
if (prefix.isNotBlank()) {
val prefixRange = TextRange.from(prefixOffset, prefix.length)
val targetHash = resolveHash(logData, visiblePack, commitId, rest, processingCount)
if (targetHash != null) {
existingPrefixes.add(PrefixTarget(prefixRange, targetHash))
}
prefixOffset += prefix.length + PREFIX_DELIMITER_LENGTH
}
match = regex.matchEntire(rest) ?: break
prefix = match.groups[1]?.value.orEmpty()
rest = match.groups[2]?.value.orEmpty()
}
if (existingPrefixes.isNotEmpty()) {
prefixesCache.put(commitId, existingPrefixes)
}
}
private fun resolveHash(
logData: VcsLogData,
visiblePack: VisiblePack,
commitId: CommitId, commitMessage: String,
processingCount: Int
): String? {
val sourceCommitId = logData.getCommitIndex(commitId.hash, commitId.root)
val visibleGraph = visiblePack.visibleGraph as VisibleGraphImpl
val sourceCommitNodeIndex = visibleGraph.getVisibleRowIndex(sourceCommitId) ?: return null
val liteLinearGraph = LinearGraphUtils.asLiteLinearGraph(visibleGraph.linearGraph)
var foundData: VcsCommitMetadata? = null
iterateCommits(logData, visiblePack, sourceCommitNodeIndex, processingCount) { currentData ->
val currentNodeId = visibleGraph.getVisibleRowIndex(currentData.getCommitIndex(logData))
if (currentNodeId != null && currentData.subject == commitMessage
&& liteLinearGraph.isAncestor(currentNodeId, sourceCommitNodeIndex)
) {
foundData = currentData
}
foundData != null
}
return foundData?.id?.toString()
}
private fun iterateCommits(
logData: VcsLogData, visiblePack: VisiblePack,
startFromCommitIndex: Int,
commitsCount: Int,
consumer: (VcsCommitMetadata) -> Boolean
) {
val visibleGraph = visiblePack.visibleGraph as VisibleGraphImpl
val linearGraph = visibleGraph.linearGraph
val walkDepth = min(commitsCount, visibleGraph.visibleCommitCount)
var visitedNode = 0
DfsWalk(listOf(startFromCommitIndex), linearGraph).walk(true) { currentNodeId ->
val currentCommitIndex = visibleGraph.getRowInfo(currentNodeId).commit
val currentCommitData = logData.commitMetadataCache.getCachedData(currentCommitIndex)
var consumed = false
visitedNode++
if (visitedNode > walkDepth) return@walk false
if (currentCommitData != null) {
if (currentCommitData.parents.size > 1) return@walk false
consumed = consumer(currentCommitData)
}
!consumed
}
}
private fun getCachedOrEmpty(commitId: CommitId): List<PrefixTarget> {
return prefixesCache.getIfPresent(commitId) ?: emptyList()
}
private fun VcsCommitMetadata.getCommitIndex(logData: VcsLogData): Int = logData.getCommitIndex(id, root)
private fun VcsCommitMetadata.getCommitId(): CommitId = CommitId(id, root)
private data class PrefixTarget(val range: TextRange, val targetHash: String)
}

View File

@@ -0,0 +1,128 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.log
import com.intellij.openapi.components.service
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.vcs.LinkDescriptor
import com.intellij.vcs.log.CommitId
import com.intellij.vcs.log.data.VcsLogData
import com.intellij.vcs.log.data.VcsLogRefresherTest.LogRefresherTestHelper
import com.intellij.vcs.log.graph.PermanentGraph
import com.intellij.vcs.log.impl.HashImpl
import com.intellij.vcs.log.impl.VcsProjectLog
import com.intellij.vcs.log.ui.table.links.NavigateToCommit
import com.intellij.vcs.log.visible.VisiblePack
import com.intellij.vcs.log.visible.filters.VcsLogFilterObject
import git4idea.test.GitSingleRepoTest
import git4idea.test.commit
class GitLinkToCommitResolverTest : GitSingleRepoTest() {
private lateinit var logData: VcsLogData
private lateinit var logRefresherHelper: LogRefresherTestHelper
private lateinit var visiblePack: VisiblePack
override fun setUp() {
super.setUp()
if (VcsProjectLog.ensureLogCreated(project)) {
val projectLog = VcsProjectLog.getInstance(project)
logData = projectLog.dataManager!!
logRefresherHelper = LogRefresherTestHelper(logData, VcsLogData.getRecentCommitsCount())
}
}
override fun tearDown() {
try {
logRefresherHelper.tearDown()
}
catch (e: Throwable) {
addSuppressedException(e)
}
finally {
super.tearDown()
}
}
fun `test resolve single fixup`() {
val fixupCommitMsg = "fixup! [subsystem] add file 1"
val commitMsg = "[subsystem] add file 1"
file("1.txt").create("File 1 content").add()
val commitHash = repo.commit(commitMsg)
file("2.txt").create("File 2 content").add()
val fixupCommitHash = HashImpl.build(repo.commit(fixupCommitMsg))
refreshVisibleGraph()
val resolver = project.service<GitLinkToCommitResolver>()
resolver.resolveLinks(CommitId(fixupCommitHash, repo.root), fixupCommitMsg)
val links = resolver.getLinks(CommitId(fixupCommitHash, repo.root))
assertTrue(links.size == 1)
assertEquals(links[0].target, commitHash)
assertEquals(links[0].range, TextRange.from(0, "fixup!".length))
assertEquals(links[0].range.substring(fixupCommitMsg), "fixup!")
}
fun `test resolve multiple prefixes`() {
val squashCommitMsg = "fixup! squash! add file 1"
val fixup2CommitMsg = "fixup! fixup! add file 1"
val fixup1CommitMsg = "fixup! add file 1"
val commitMsg = "add file 1"
file("1.txt").create("File 1 content").add()
val commitHash = repo.commit(commitMsg)
file("2.txt").create("File 2 content").add()
val fixup1CommitHash = HashImpl.build(repo.commit(fixup1CommitMsg))
file("3.txt").create("File 3 content").add()
val fixup2CommitHash = HashImpl.build(repo.commit(fixup2CommitMsg))
file("4.txt").create("File 4 content").add()
val squashCommitHash = HashImpl.build(repo.commit(squashCommitMsg))
refreshVisibleGraph()
val resolver = project.service<GitLinkToCommitResolver>()
resolver.resolveLinks(CommitId(fixup1CommitHash, repo.root), fixup1CommitMsg)
resolver.resolveLinks(CommitId(fixup2CommitHash, repo.root), fixup2CommitMsg)
resolver.resolveLinks(CommitId(squashCommitHash, repo.root), squashCommitMsg)
var links = resolver.getLinks(CommitId(fixup1CommitHash, repo.root))
assertTrue(links.size == 1)
assertEquals(links[0].target, commitHash)
assertEquals(links[0].range, TextRange.from(0, "fixup!".length))
assertEquals(links[0].range.substring(fixup1CommitMsg), "fixup!")
links = resolver.getLinks(CommitId(fixup2CommitHash, repo.root))
assertTrue(links.size == 2)
assertEquals(links[0].target, fixup1CommitHash.toString())
assertEquals(links[0].range, TextRange.from(0, "fixup!".length))
assertEquals(links[0].range.substring(fixup2CommitMsg), "fixup!")
assertEquals(links[1].target, commitHash)
assertEquals(links[1].range, TextRange.from(7, "fixup!".length))
assertEquals(links[1].range.substring(fixup2CommitMsg), "fixup!")
links = resolver.getLinks(CommitId(squashCommitHash, repo.root))
assertTrue(links.size == 1)
assertEquals(links[0].target, commitHash)
assertEquals(links[0].range, TextRange.from(7, "squash!".length))
assertEquals(links[0].range.substring(squashCommitMsg), "squash!")
}
private fun GitLinkToCommitResolver.resolveLinks(commitId: CommitId, commitMessage: @NlsSafe String) {
resolveLinks(logData, visiblePack, commitId, commitMessage, Registry.intValue("vcs.log.render.commit.links.process.chunk"))
}
private fun refreshVisibleGraph() {
logRefresherHelper.initAndWaitForFirstRefresh()
val dataPack = logRefresherHelper.dataPack
val visibleGraph = dataPack.permanentGraph.createVisibleGraph(PermanentGraph.Options.Default, null, null)
visiblePack = VisiblePack(dataPack, visibleGraph, false, VcsLogFilterObject.collection())
}
private val LinkDescriptor.target get() = (this as NavigateToCommit).target
}