diff --git a/src/plugins/git/gitclient.cpp b/src/plugins/git/gitclient.cpp index 83197da61ff..9651d73a1d1 100644 --- a/src/plugins/git/gitclient.cpp +++ b/src/plugins/git/gitclient.cpp @@ -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 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 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"); diff --git a/src/plugins/git/gitclient.h b/src/plugins/git/gitclient.h index 6ff835d0b99..ff93e801a11 100644 --- a/src/plugins/git/gitclient.h +++ b/src/plugins/git/gitclient.h @@ -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, diff --git a/src/plugins/git/gitconstants.h b/src/plugins/git/gitconstants.h index 5b2dabcc1fe..2b97881caf9 100644 --- a/src/plugins/git/gitconstants.h +++ b/src/plugins/git/gitconstants.h @@ -36,5 +36,7 @@ const int MAX_OBSOLETE_COMMITS_TO_DISPLAY = 5; const char EXPAND_BRANCHES[] = "Branches: "; const char DEFAULT_COMMENT_CHAR = '#'; +const char TEXT_MARK_CATEGORY_BLAME[] = "Git.Mark.Blame"; + } // namespace Constants } // namespace Git diff --git a/src/plugins/git/gitplugin.cpp b/src/plugins/git/gitplugin.cpp index 1af164f247c..3d19b55f330 100644 --- a/src/plugins/git/gitplugin.cpp +++ b/src/plugins/git/gitplugin.cpp @@ -36,7 +36,9 @@ #include +#include #include +#include #include #include @@ -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{copyToClipboardAction, showAction}; + }); + } + + QString toolTipText(const CommitInfo &info) const + { + const QString result = QString( + "" + " " + " " + " " + " " + " " + "
commit%1
Author:%2 <%3>
Date:%4
%5
") + .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(widget)) + return; // Skip in VCS editors like log or blame + + auto cursorPosConn = std::make_shared(); + *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 +// author-time 1613752276 +// author-tz +0100 +// committer John Doe +// committer-mail +// 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(); + connect(EditorManager::instance(), &EditorManager::currentEditorChanged, + this, [editorChangedConn] { + disconnect(*editorChangedConn); + delete m_blameMark; + m_blameMark = nullptr; + }); + + auto cursorPosConn = std::make_shared(); + *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), diff --git a/src/plugins/git/gitsettings.cpp b/src/plugins/git/gitsettings.cpp index 4e2432941ad..e5799931c1a 100644 --- a/src/plugins/git/gitsettings.cpp +++ b/src/plugins/git/gitsettings.cpp @@ -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); }); diff --git a/src/plugins/git/gitsettings.h b/src/plugins/git/gitsettings.h index 0bf4e68fa50..0eb5a2b9773 100644 --- a/src/plugins/git/gitsettings.h +++ b/src/plugins/git/gitsettings.h @@ -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; };