forked from qt-creator/qt-creator
Git: Add instant line annotation (blame)
Inspired by the Visual Studio Code plugin GitLens. Add an annotation to the editor line the cursor is currently in. A tooltip contains the commit data and allows to invoke git show for the commit. When the automatic annotation is turned off, it can still be forced for the current line with an action. The default shortcut for this action is: Alt+G,Alt+I Task-number: QTCREATORBUG-23299 Change-Id: I58eef9efcf531afb11470e5f5456e19f282b18d0 Reviewed-by: <github-actions-qt-creator@cristianadam.eu> Reviewed-by: Orgad Shaneh <orgads@gmail.com>
This commit is contained in:
committed by
André Hartmann
parent
ea917a0aa6
commit
cae1936da3
@@ -2729,6 +2729,22 @@ bool GitClient::readDataFromCommit(const FilePath &repoDirectory, const QString
|
||||
return true;
|
||||
}
|
||||
|
||||
Author GitClient::getAuthor(const Utils::FilePath &workingDirectory)
|
||||
{
|
||||
// The format is:
|
||||
// Joe Developer <joedev@example.com> unixtimestamp +HHMM
|
||||
const QString authorInfo = readGitVar(workingDirectory, "GIT_AUTHOR_IDENT");
|
||||
int lt = authorInfo.lastIndexOf('<');
|
||||
int gt = authorInfo.lastIndexOf('>');
|
||||
if (gt == -1 || uint(lt) > uint(gt)) {
|
||||
// shouldn't happen!
|
||||
return {};
|
||||
}
|
||||
|
||||
const Author result {authorInfo.left(lt - 1), authorInfo.mid(lt + 1, gt - lt - 1)};
|
||||
return result;
|
||||
}
|
||||
|
||||
bool GitClient::getCommitData(const FilePath &workingDirectory,
|
||||
QString *commitTemplate,
|
||||
CommitData &commitData,
|
||||
@@ -2826,19 +2842,9 @@ bool GitClient::getCommitData(const FilePath &workingDirectory,
|
||||
commitData.amendSHA1.clear();
|
||||
}
|
||||
if (!authorFromCherryPick) {
|
||||
// the format is:
|
||||
// Joe Developer <joedev@example.com> unixtimestamp +HHMM
|
||||
QString author_info = readGitVar(workingDirectory, "GIT_AUTHOR_IDENT");
|
||||
int lt = author_info.lastIndexOf('<');
|
||||
int gt = author_info.lastIndexOf('>');
|
||||
if (gt == -1 || uint(lt) > uint(gt)) {
|
||||
// shouldn't happen!
|
||||
commitData.panelData.author.clear();
|
||||
commitData.panelData.email.clear();
|
||||
} else {
|
||||
commitData.panelData.author = author_info.left(lt - 1);
|
||||
commitData.panelData.email = author_info.mid(lt + 1, gt - lt - 1);
|
||||
}
|
||||
const Author author = getAuthor(workingDirectory);
|
||||
commitData.panelData.author = author.name;
|
||||
commitData.panelData.email = author.email;
|
||||
}
|
||||
// Commit: Get the commit template
|
||||
QString templateFilename = gitDirectory.absoluteFilePath("MERGE_MSG");
|
||||
|
@@ -77,6 +77,11 @@ public:
|
||||
int behind = 0;
|
||||
};
|
||||
|
||||
struct Author {
|
||||
QString name;
|
||||
QString email;
|
||||
};
|
||||
|
||||
class GITSHARED_EXPORT GitClient : public VcsBase::VcsBaseClientImpl
|
||||
{
|
||||
public:
|
||||
@@ -338,6 +343,7 @@ public:
|
||||
Core::IEditor *openShowEditor(const Utils::FilePath &workingDirectory, const QString &ref,
|
||||
const QString &path, ShowEditor showSetting = ShowEditor::Always);
|
||||
|
||||
Author getAuthor(const Utils::FilePath &workingDirectory);
|
||||
private:
|
||||
void finishSubmoduleUpdate();
|
||||
void chunkActionsRequested(DiffEditor::DiffEditorController *controller,
|
||||
|
@@ -36,5 +36,7 @@ const int MAX_OBSOLETE_COMMITS_TO_DISPLAY = 5;
|
||||
const char EXPAND_BRANCHES[] = "Branches: <Expand>";
|
||||
const char DEFAULT_COMMENT_CHAR = '#';
|
||||
|
||||
const char TEXT_MARK_CATEGORY_BLAME[] = "Git.Mark.Blame";
|
||||
|
||||
} // namespace Constants
|
||||
} // namespace Git
|
||||
|
@@ -36,7 +36,9 @@
|
||||
|
||||
#include <aggregation/aggregate.h>
|
||||
|
||||
#include <texteditor/textdocument.h>
|
||||
#include <texteditor/texteditor.h>
|
||||
#include <texteditor/textmark.h>
|
||||
|
||||
#include <utils/algorithm.h>
|
||||
#include <utils/commandline.h>
|
||||
@@ -178,6 +180,64 @@ const VcsBaseEditorParameters rebaseEditorParameters {
|
||||
"text/vnd.qtcreator.git.rebase"
|
||||
};
|
||||
|
||||
class CommitInfo {
|
||||
public:
|
||||
QString sha1;
|
||||
QString shortAuthor;
|
||||
QString author;
|
||||
QString authorMail;
|
||||
QDateTime authorTime;
|
||||
QString summary;
|
||||
QString fileName;
|
||||
};
|
||||
|
||||
class BlameMark : public TextEditor::TextMark
|
||||
{
|
||||
public:
|
||||
BlameMark(const FilePath &fileName, int lineNumber, const CommitInfo &info)
|
||||
: TextEditor::TextMark(fileName, lineNumber, Constants::TEXT_MARK_CATEGORY_BLAME)
|
||||
{
|
||||
const QString text = info.shortAuthor + " " + info.authorTime.toString("yyyy-MM-dd");
|
||||
|
||||
setPriority(TextEditor::TextMark::LowPriority);
|
||||
setToolTip(toolTipText(info));
|
||||
setLineAnnotation(text);
|
||||
setSettingsPage(VcsBase::Constants::VCS_ID_GIT);
|
||||
setActionsProvider([info] {
|
||||
QAction *copyToClipboardAction = new QAction;
|
||||
copyToClipboardAction->setIcon(QIcon::fromTheme("edit-copy", Utils::Icons::COPY.icon()));
|
||||
copyToClipboardAction->setToolTip(TextMark::tr("Copy SHA1 to Clipboard"));
|
||||
QObject::connect(copyToClipboardAction, &QAction::triggered, [info]() {
|
||||
Utils::setClipboardAndSelection(info.sha1);
|
||||
});
|
||||
QAction *showAction = new QAction;
|
||||
showAction->setIcon(Utils::Icons::ZOOM.icon());
|
||||
showAction->setToolTip(TextMark::tr("Show Commit %1").arg(info.sha1.left(8)));
|
||||
QObject::connect(showAction, &QAction::triggered, [info]() {
|
||||
GitClient::instance()->show(info.fileName, info.sha1);
|
||||
});
|
||||
return QList<QAction *>{copyToClipboardAction, showAction};
|
||||
});
|
||||
}
|
||||
|
||||
QString toolTipText(const CommitInfo &info) const
|
||||
{
|
||||
const QString result = QString(
|
||||
"<table>"
|
||||
" <tr><td>commit</td><td>%1</td></tr>"
|
||||
" <tr><td>Author:</td><td>%2 <%3></td></tr>"
|
||||
" <tr><td>Date:</td><td>%4</td></tr>"
|
||||
" <tr></tr>"
|
||||
" <tr><td colspan='2' align='left'>%5</td></tr>"
|
||||
"</table>")
|
||||
.arg(info.sha1, info.author, info.authorMail,
|
||||
info.authorTime.toString("yyyy-MM-dd hh:mm:ss"), info.summary);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
static BlameMark *m_blameMark = nullptr;
|
||||
|
||||
// GitPlugin
|
||||
|
||||
class GitPluginPrivate final : public VcsBasePluginPrivate
|
||||
@@ -330,6 +390,9 @@ public:
|
||||
void applyPatch(const FilePath &workingDirectory, QString file = {});
|
||||
void updateVersionWarning();
|
||||
|
||||
void setupInstantBlame();
|
||||
void instantBlameOnce();
|
||||
void instantBlame();
|
||||
|
||||
void onApplySettings();;
|
||||
|
||||
@@ -364,6 +427,10 @@ public:
|
||||
FilePath m_submitRepository;
|
||||
QString m_commitMessageFileName;
|
||||
|
||||
Author m_author;
|
||||
int m_lastVisitedEditorLine = -1;
|
||||
QTimer *m_cursorPositionChangedTimer = nullptr;
|
||||
|
||||
GitSettingsPage settingPage{&m_settings};
|
||||
|
||||
GitGrep gitGrep{&m_gitClient};
|
||||
@@ -675,6 +742,11 @@ GitPluginPrivate::GitPluginPrivate()
|
||||
"Git.Blame", context, true, std::bind(&GitPluginPrivate::blameFile, this),
|
||||
QKeySequence(useMacShortcuts ? Tr::tr("Meta+G,Meta+B") : Tr::tr("Alt+G,Alt+B")));
|
||||
|
||||
createFileAction(currentFileMenu, Tr::tr("Instant Blame Current Line", "Avoid translating \"Blame\""),
|
||||
Tr::tr("Instant Blame for \"%1\"", "Avoid translating \"Blame\""),
|
||||
"Git.InstantBlame", context, true, std::bind(&GitPluginPrivate::instantBlameOnce, this),
|
||||
QKeySequence(useMacShortcuts ? Tr::tr("Meta+G,Meta+I") : Tr::tr("Alt+G,Alt+I")));
|
||||
|
||||
currentFileMenu->addSeparator(context);
|
||||
|
||||
createFileAction(currentFileMenu, Tr::tr("Stage File for Commit"), Tr::tr("Stage \"%1\" for Commit"),
|
||||
@@ -996,6 +1068,8 @@ GitPluginPrivate::GitPluginPrivate()
|
||||
m_gerritPlugin->addToLocator(m_commandLocator);
|
||||
|
||||
connect(&m_settings, &AspectContainer::applied, this, &GitPluginPrivate::onApplySettings);
|
||||
|
||||
setupInstantBlame();
|
||||
}
|
||||
|
||||
void GitPluginPrivate::diffCurrentFile()
|
||||
@@ -1354,6 +1428,164 @@ void GitPluginPrivate::updateVersionWarning()
|
||||
});
|
||||
}
|
||||
|
||||
void GitPluginPrivate::setupInstantBlame()
|
||||
{
|
||||
m_cursorPositionChangedTimer = new QTimer(this);
|
||||
m_cursorPositionChangedTimer->setSingleShot(true);
|
||||
connect(m_cursorPositionChangedTimer, &QTimer::timeout, this, &GitPluginPrivate::instantBlame);
|
||||
|
||||
auto setupBlameForEditor = [this](Core::IEditor *editor) {
|
||||
if (!editor)
|
||||
return;
|
||||
|
||||
if (!GitClient::instance()->settings().instantBlame.value()) {
|
||||
m_lastVisitedEditorLine = -1;
|
||||
delete m_blameMark;
|
||||
m_blameMark = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
const Utils::FilePath workingDirectory = GitPlugin::currentState().topLevel();
|
||||
if (workingDirectory.isEmpty())
|
||||
return;
|
||||
m_author = GitClient::instance()->getAuthor(workingDirectory);
|
||||
|
||||
const TextEditorWidget *widget = TextEditorWidget::fromEditor(editor);
|
||||
if (!widget)
|
||||
return;
|
||||
|
||||
if (qobject_cast<const VcsBaseEditorWidget *>(widget))
|
||||
return; // Skip in VCS editors like log or blame
|
||||
|
||||
auto cursorPosConn = std::make_shared<QMetaObject::Connection>();
|
||||
*cursorPosConn = connect(widget, &QPlainTextEdit::cursorPositionChanged, this,
|
||||
[this, cursorPosConn] {
|
||||
if (!GitClient::instance()->settings().instantBlame.value()) {
|
||||
disconnect(*cursorPosConn);
|
||||
return;
|
||||
}
|
||||
m_cursorPositionChangedTimer->start(500);
|
||||
});
|
||||
|
||||
m_lastVisitedEditorLine = -1;
|
||||
instantBlame();
|
||||
};
|
||||
|
||||
connect(&GitClient::instance()->settings().instantBlame,
|
||||
&BoolAspect::valueChanged, this, [setupBlameForEditor](bool enabled) {
|
||||
if (enabled) {
|
||||
setupBlameForEditor(EditorManager::currentEditor());
|
||||
} else {
|
||||
delete m_blameMark;
|
||||
m_blameMark = nullptr;
|
||||
}
|
||||
});
|
||||
|
||||
connect(EditorManager::instance(), &EditorManager::currentEditorChanged,
|
||||
this, setupBlameForEditor);
|
||||
}
|
||||
|
||||
// Porcelain format of git blame output
|
||||
// 8b649d2d61416205977aba56ef93e1e1f155005e 5 5 1
|
||||
// author John Doe
|
||||
// author-mail <john.doe@gmail.com>
|
||||
// author-time 1613752276
|
||||
// author-tz +0100
|
||||
// committer John Doe
|
||||
// committer-mail <john.doe@gmail.com>
|
||||
// committer-time 1613752312
|
||||
// committer-tz +0100
|
||||
// summary Add greeting to script
|
||||
// boundary
|
||||
// filename foo
|
||||
// echo Hello World!
|
||||
|
||||
CommitInfo parseBlameOutput(const QStringList &blame, const Utils::FilePath &filePath,
|
||||
const Git::Internal::Author &author)
|
||||
{
|
||||
CommitInfo result;
|
||||
QTC_ASSERT(blame.size() > 12, return result);
|
||||
|
||||
result.sha1 = blame.at(0).left(40);
|
||||
result.author = blame.at(1).mid(7);
|
||||
result.authorMail = blame.at(2).mid(13).chopped(1);
|
||||
if (result.author == author.name || result.authorMail == author.email)
|
||||
result.shortAuthor = Tr::tr("You");
|
||||
else
|
||||
result.shortAuthor = result.author;
|
||||
const uint timeStamp = blame.at(3).mid(12).toUInt();
|
||||
result.authorTime = QDateTime::fromSecsSinceEpoch(timeStamp);
|
||||
result.summary = blame.at(9).mid(8);
|
||||
result.fileName = filePath.toString();
|
||||
return result;
|
||||
}
|
||||
|
||||
void GitPluginPrivate::instantBlameOnce()
|
||||
{
|
||||
if (!GitClient::instance()->settings().instantBlame.value()) {
|
||||
const TextEditorWidget *widget = TextEditorWidget::currentTextEditorWidget();
|
||||
if (!widget)
|
||||
return;
|
||||
auto editorChangedConn = std::make_shared<QMetaObject::Connection>();
|
||||
connect(EditorManager::instance(), &EditorManager::currentEditorChanged,
|
||||
this, [editorChangedConn] {
|
||||
disconnect(*editorChangedConn);
|
||||
delete m_blameMark;
|
||||
m_blameMark = nullptr;
|
||||
});
|
||||
|
||||
auto cursorPosConn = std::make_shared<QMetaObject::Connection>();
|
||||
*cursorPosConn = connect(widget, &QPlainTextEdit::cursorPositionChanged,
|
||||
this, [cursorPosConn] {
|
||||
disconnect(*cursorPosConn);
|
||||
delete m_blameMark;
|
||||
m_blameMark = nullptr;
|
||||
});
|
||||
|
||||
const Utils::FilePath workingDirectory = GitPlugin::currentState().topLevel();
|
||||
if (workingDirectory.isEmpty())
|
||||
return;
|
||||
m_author = GitClient::instance()->getAuthor(workingDirectory);
|
||||
}
|
||||
|
||||
m_lastVisitedEditorLine = -1;
|
||||
instantBlame();
|
||||
}
|
||||
|
||||
void GitPluginPrivate::instantBlame()
|
||||
{
|
||||
const TextEditorWidget *widget = TextEditorWidget::currentTextEditorWidget();
|
||||
const QTextCursor cursor = widget->textCursor();
|
||||
const QTextBlock block = cursor.block();
|
||||
const int line = block.blockNumber() + 1;
|
||||
const int lines = widget->document()->lineCount();
|
||||
|
||||
if (line >= lines) {
|
||||
delete m_blameMark;
|
||||
m_blameMark = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_lastVisitedEditorLine == line)
|
||||
return;
|
||||
|
||||
m_lastVisitedEditorLine = line;
|
||||
|
||||
const Utils::FilePath filePath = widget->textDocument()->filePath();
|
||||
const QFileInfo fi(filePath.toString());
|
||||
const Utils::FilePath workingDirectory = Utils::FilePath::fromString(fi.path());
|
||||
const QString lineString = QString("%1,%1").arg(line);
|
||||
const VcsCommand *command = GitClient::instance()->vcsExec(
|
||||
workingDirectory, {"blame", "-p", "-L", lineString, "--", filePath.toString()},
|
||||
nullptr, false, RunFlags::SuppressCommandLogging | RunFlags::ProgressiveOutput);
|
||||
connect(command, &VcsCommand::done, this, [command, filePath, line, this]() {
|
||||
const QString output = command->cleanedStdOut();
|
||||
const CommitInfo info = parseBlameOutput(output.split('\n'), filePath, m_author);
|
||||
delete m_blameMark;
|
||||
m_blameMark = new BlameMark(filePath, line, info);
|
||||
});
|
||||
}
|
||||
|
||||
IEditor *GitPluginPrivate::openSubmitEditor(const QString &fileName, const CommitData &cd)
|
||||
{
|
||||
IEditor *editor = EditorManager::openEditor(FilePath::fromString(fileName),
|
||||
|
@@ -88,6 +88,13 @@ GitSettings::GitSettings()
|
||||
repositoryBrowserCmd.setDisplayName(Tr::tr("Git Repository Browser Command"));
|
||||
repositoryBrowserCmd.setLabelText(Tr::tr("Command:"));
|
||||
|
||||
registerAspect(&instantBlame);
|
||||
instantBlame.setSettingsKey("Git Instant");
|
||||
instantBlame.setDefaultValue(true);
|
||||
instantBlame.setLabelText(Tr::tr("Add instant blame annotations to editor"));
|
||||
instantBlame.setToolTip(Tr::tr("Directly annotate each line in the editor "
|
||||
"when scrolling through the document."));
|
||||
|
||||
registerAspect(&graphLog);
|
||||
graphLog.setSettingsKey("GraphLog");
|
||||
|
||||
@@ -173,6 +180,11 @@ GitSettingsPage::GitSettingsPage(GitSettings *settings)
|
||||
Row { s.repositoryBrowserCmd }
|
||||
},
|
||||
|
||||
Group {
|
||||
title(Tr::tr("Instant Blame")),
|
||||
Row { s.instantBlame }
|
||||
},
|
||||
|
||||
st
|
||||
}.attachTo(widget);
|
||||
});
|
||||
|
@@ -38,6 +38,7 @@ public:
|
||||
Utils::BoolAspect followRenames;
|
||||
Utils::IntegerAspect lastResetIndex;
|
||||
Utils::BoolAspect refLogShowDate;
|
||||
Utils::BoolAspect instantBlame;
|
||||
|
||||
Utils::FilePath gitExecutable(bool *ok = nullptr, QString *errorMessage = nullptr) const;
|
||||
};
|
||||
|
Reference in New Issue
Block a user