From 66d22ea5bb8b490eb0bcee3b7f792c8c967b2575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Thu, 27 Aug 2026 22:18:58 -0300 Subject: [PATCH] Add integrated Git conflict resolution workflow Introduce a three-pane UIMergeView with an editable shared result document, conflict navigation and acceptance actions, live diff highlighting, synchronized scrolling, and contextual pane labels. Read conflict stages directly from Git's index, detect paused merge operations, restore conflict sessions, and support resolving, staging, recreating, continuing, committing, and aborting operations. Add repository-aware conflict state handling, safe asynchronous lifetimes, merge operation UI, configurable key modifiers, i18n strings, and focused merge-view and Git conflict tests. --- .agent/rules/project-introduction.md | 17 + bin/assets/i18n/de.xml | 42 +- bin/assets/i18n/en.xml | 42 +- bin/assets/i18n/fr.xml | 42 +- bin/assets/i18n/zh.xml | 42 +- include/eepp/scene/mainthreadlifetime.hpp | 91 ++ include/eepp/ui/doc/textdocument.hpp | 9 + include/eepp/ui/tools/uidiffview.hpp | 3 +- include/eepp/ui/tools/uimergeview.hpp | 110 +++ include/eepp/ui/uicodeeditor.hpp | 3 + include/eepp/ui/uihelper.hpp | 1 + premake4.lua | 1 + premake5.lua | 1 + src/eepp/ui/doc/textdocument.cpp | 3 + src/eepp/ui/iconmanager.cpp | 7 + src/eepp/ui/tools/uidiffview.cpp | 3 +- src/eepp/ui/tools/uimergeview.cpp | 579 ++++++++++++ src/eepp/ui/uicodeeditor.cpp | 89 +- src/eepp/ui/uiwidgetcreator.cpp | 2 + src/tests/unit_tests/gitconflict_tests.cpp | 104 +++ src/tests/unit_tests/uicodeeditor_tests.cpp | 38 + src/tests/unit_tests/uidiffview_tests.cpp | 27 + src/tests/unit_tests/uimergeview_tests.cpp | 103 +++ src/tools/ecode/ecode.cpp | 6 +- src/tools/ecode/plugins/git/git.cpp | 307 ++++++- src/tools/ecode/plugins/git/git.hpp | 57 +- src/tools/ecode/plugins/git/gitplugin.cpp | 922 +++++++++++++++++++- src/tools/ecode/plugins/git/gitplugin.hpp | 54 +- src/tools/ecode/settingspanel.cpp | 6 +- 29 files changed, 2604 insertions(+), 107 deletions(-) create mode 100644 include/eepp/scene/mainthreadlifetime.hpp create mode 100644 include/eepp/ui/tools/uimergeview.hpp create mode 100644 src/eepp/ui/tools/uimergeview.cpp create mode 100644 src/tests/unit_tests/gitconflict_tests.cpp create mode 100644 src/tests/unit_tests/uimergeview_tests.cpp diff --git a/.agent/rules/project-introduction.md b/.agent/rules/project-introduction.md index 3adf631e5..7bf928d05 100644 --- a/.agent/rules/project-introduction.md +++ b/.agent/rules/project-introduction.md @@ -38,3 +38,20 @@ Follow eepp's established namespace style: prefer the appropriate `using namespa declarations and unqualified eepp type names, such as `UISplitter`, over repeatedly spelling fully qualified names such as `EE::UI::UISplitter`. Keep explicit qualification only where it is required to resolve ambiguity or avoid importing an unusually broad namespace into an unsuitable scope. + +## Control-Statement Braces + +Use braces around the body of an `if`, `else`, `for`, `while`, or similar control statement whenever +that body occupies more than one physical source line. A statement remains visually multi-line even +when C++ treats it as a single statement, so a wrapped function call must be braced: + +```cpp +if ( condition ) { + object->function( + argument, + anotherArgument ); +} +``` + +An unbraced body is acceptable only when the complete body fits on one physical source line. Apply +this rule when writing or modifying code; do not add unrelated braces throughout untouched code. diff --git a/bin/assets/i18n/de.xml b/bin/assets/i18n/de.xml index 7bda84bf5..86d2b75b4 100644 --- a/bin/assets/i18n/de.xml +++ b/bin/assets/i18n/de.xml @@ -407,7 +407,47 @@ Stash benennen (optional): Stash speichern Stashes Tags - Unvereinigt + Konflikte + Bei diesem Konflikt muss die Datei behalten oder gelöscht werden. + Speichern und stagen + Vorherige Datei + Nächste Datei + Fortfahren + Committen + Abbrechen + Es ist kein fortsetzbarer Git-Vorgang aktiv. + Es ist kein abbrechbarer Git-Vorgang aktiv. + Der Git-Konfliktstatus konnte nicht gelesen werden. + Zusammenführen: %s + Basis + Ergebnis + Aktueller Branch (unsere Version) + Eingehender Branch (deren Version) + Upstream + Wiederholter Commit (Ihre Änderung) + Unsere Version (Stufe 2) + Deren Version (Stufe 3) + Nicht vorhanden + Das Konfliktergebnis konnte nicht gespeichert werden. + Das gespeicherte Ergebnis enthält noch erkennbare Konfliktmarkierungen. + Git meldet diesen Pfad weiterhin als nicht zusammengeführt. + Der Konflikt konnte im Git-Index nicht wiederhergestellt werden. + Der Konflikt wurde im Git-Index wiederhergestellt, aber seine Markierungen konnten nicht gespeichert werden. + Alle Konflikte müssen vor dem Fortfahren gelöst und gestaged werden. + Den aktuellen Git-Vorgang abbrechen? + Konflikt lösen + Unsere Version übernehmen + Deren Version übernehmen + Git meldet diesen Pfad nicht mehr als nicht zusammengeführt. + Zusammenführung läuft + Rebase läuft + Cherry-Pick läuft + Rücknahme läuft + Stash wird angewendet + %s · Bereit zum Fortfahren + %s · Bereit zum Committen + %s · %d Konflikte + %s · %d Konflikt Entstagen Alles entstagen Unverfolgt diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index aea2406f1..af76c40e6 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -391,7 +391,47 @@ Name your stash (optional): Save Stash Stashes Tags - Unmerged + Conflicts + This conflict requires choosing whether to keep or delete the file. + Save & Stage + Previous File + Next File + Continue + Commit + Abort + No continuable Git operation is active. + No abortable Git operation is active. + Unable to read Git's conflict state. + Merge: %s + Base + Result + Current branch (ours) + Incoming branch (theirs) + Upstream + Replayed commit (your change) + Ours (stage 2) + Theirs (stage 3) + Not present + Unable to save the conflict result. + The saved result still contains recognizable conflict markers. + Git still reports this path as unmerged. + Unable to restore the conflict in Git's index. + The conflict was restored in Git's index, but its marker text could not be saved to disk. + Resolve and stage all conflicts before continuing. + Abort the current Git operation? + Resolve Conflict + Accept Ours + Accept Theirs + Git no longer reports this path as unmerged. + Merging + Rebasing + Cherry-picking + Reverting + Applying stash + %s · Ready to continue + %s · Ready to commit + %s · %d conflicts + %s · %d conflict Unstage Unstage All Untracked diff --git a/bin/assets/i18n/fr.xml b/bin/assets/i18n/fr.xml index 20d4ddc0b..cbd12d570 100644 --- a/bin/assets/i18n/fr.xml +++ b/bin/assets/i18n/fr.xml @@ -390,7 +390,47 @@ Nommez votre stash (optionnel): Sauvegarder le stash Stashes Tags - Non fusionné + Conflits + Ce conflit nécessite de choisir de conserver ou de supprimer le fichier. + Enregistrer et indexer + Fichier précédent + Fichier suivant + Continuer + Valider + Abandonner + Aucune opération Git pouvant être poursuivie n’est active. + Aucune opération Git pouvant être abandonnée n’est active. + Impossible de lire l’état des conflits Git. + Fusion : %s + Base + Résultat + Branche actuelle (la nôtre) + Branche entrante (la leur) + Branche amont + Commit rejoué (votre modification) + Notre version (étape 2) + Leur version (étape 3) + Absent + Impossible d’enregistrer le résultat du conflit. + Le résultat enregistré contient encore des marqueurs de conflit reconnaissables. + Git signale toujours ce chemin comme non fusionné. + Impossible de restaurer le conflit dans l’index Git. + Le conflit a été restauré dans l’index Git, mais ses marqueurs n’ont pas pu être enregistrés. + Résolvez et indexez tous les conflits avant de continuer. + Abandonner l’opération Git en cours ? + Résoudre le conflit + Accepter la nôtre + Accepter la leur + Git ne signale plus ce chemin comme non fusionné. + Fusion en cours + Rebasage en cours + Picorage en cours + Annulation en cours + Application du stash + %s · Prêt à continuer + %s · Prêt à valider + %s · %d conflits + %s · %d conflit Déstocker Tout déstocker Non suivi diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index 0528df4bd..93af77ac1 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -308,7 +308,47 @@ Name your stash (optional): Save Stash Stashes 标签 - 未合并 + 冲突 + 此冲突需要选择保留或删除文件。 + 保存并暂存 + 上一个文件 + 下一个文件 + 继续 + 提交 + 中止 + 没有可继续的 Git 操作。 + 没有可中止的 Git 操作。 + 无法读取 Git 冲突状态。 + 合并:%s + 基础版本 + 结果 + 当前分支(我们的版本) + 传入分支(他们的版本) + 上游 + 重放的提交(您的更改) + 我们的版本(阶段 2) + 他们的版本(阶段 3) + 不存在 + 无法保存冲突解决结果。 + 保存的结果中仍包含可识别的冲突标记。 + Git 仍将此路径报告为未合并。 + 无法在 Git 索引中恢复冲突。 + 冲突已在 Git 索引中恢复,但无法将冲突标记保存到磁盘。 + 继续之前请解决并暂存所有冲突。 + 中止当前 Git 操作? + 解决冲突 + 接受我们的版本 + 接受他们的版本 + Git 不再将此路径报告为未合并。 + 正在合并 + 正在变基 + 正在拣选 + 正在还原 + 正在应用储藏 + %s · 可以继续 + %s · 可以提交 + %s · %d 个冲突 + %s · %d 个冲突 Unstage Untracked 全局搜索 diff --git a/include/eepp/scene/mainthreadlifetime.hpp b/include/eepp/scene/mainthreadlifetime.hpp new file mode 100644 index 000000000..6621618a0 --- /dev/null +++ b/include/eepp/scene/mainthreadlifetime.hpp @@ -0,0 +1,91 @@ +#ifndef EE_SCENE_MAINTHREADLIFETIME_HPP +#define EE_SCENE_MAINTHREADLIFETIME_HPP + +#include +#include +#include +#include + +namespace EE::Scene { + +/** + * Gates main-thread callbacks with the lifetime of a non-owning object. + * + * The dispatcher and the guarded object must belong to the main thread. The dispatcher may be the + * guarded object itself, but it must otherwise outlive it. Worker threads may safely copy a + * WeakHandle and call run(); callbacks queued before invalidation become no-ops once the object is + * destroyed. + */ +template class MainThreadLifetime { + private: + struct State { + State( T* object, Node* dispatcher ) : object( object ), dispatcher( dispatcher ) {} + std::mutex mutex; + T* object{ nullptr }; + Node* dispatcher{ nullptr }; + }; + + public: + class WeakHandle { + public: + WeakHandle() = default; + + void run( std::function callback ) const { + auto state = mState.lock(); + if ( !state ) + return; + std::lock_guard lock( state->mutex ); + if ( !state->object || !state->dispatcher ) + return; + state->dispatcher->runOnMainThread( + [state = std::weak_ptr{ state }, callback = std::move( callback )] { + auto lockedState = state.lock(); + if ( !lockedState ) + return; + T* object; + { + std::lock_guard lock( lockedState->mutex ); + object = lockedState->object; + } + if ( object ) + callback( object ); + } ); + } + + explicit operator bool() const { return !mState.expired(); } + + private: + friend class MainThreadLifetime; + explicit WeakHandle( const std::shared_ptr& state ) : mState( state ) {} + std::weak_ptr mState; + }; + + MainThreadLifetime( T* object, Node* dispatcher ) : + mState( std::make_shared( object, dispatcher ) ) {} + + ~MainThreadLifetime() { invalidate(); } + + MainThreadLifetime( const MainThreadLifetime& ) = delete; + MainThreadLifetime& operator=( const MainThreadLifetime& ) = delete; + + WeakHandle weakHandle() const { return WeakHandle{ mState }; } + + void setDispatcher( Node* dispatcher ) { + std::lock_guard lock( mState->mutex ); + if ( mState->object ) + mState->dispatcher = dispatcher; + } + + void invalidate() { + std::lock_guard lock( mState->mutex ); + mState->object = nullptr; + mState->dispatcher = nullptr; + } + + private: + std::shared_ptr mState; +}; + +} // namespace EE::Scene + +#endif diff --git a/include/eepp/ui/doc/textdocument.hpp b/include/eepp/ui/doc/textdocument.hpp index 99cedb150..dc27b276c 100644 --- a/include/eepp/ui/doc/textdocument.hpp +++ b/include/eepp/ui/doc/textdocument.hpp @@ -38,6 +38,15 @@ struct DocumentContentChange { class EE_API TextDocument { public: + class EE_API ScopedReadLock : NonCopyable { + public: + explicit ScopedReadLock( const TextDocument& document ); + + private: + Lock mLinesLock; + Lock mDocumentLock; + }; + static bool isTextDocumentCommand( std::string_view cmd ); static bool isTextDocumentCommand( String::HashType cmdHash ); diff --git a/include/eepp/ui/tools/uidiffview.hpp b/include/eepp/ui/tools/uidiffview.hpp index 1ab43b394..4c4491d71 100644 --- a/include/eepp/ui/tools/uidiffview.hpp +++ b/include/eepp/ui/tools/uidiffview.hpp @@ -29,7 +29,8 @@ class EE_API UIDiffView : public UIWidget, public WidgetCommandExecuter { static UIDiffView* New(); static UIScrollView* NewMultiFileDiffViewer( const std::string& patchText, - const std::string& repoPath = "" ); + const std::string& repoPath = "", + ViewMode viewMode = ViewMode::Unified ); static std::vector splitDiff( const std::string& multiFileDiff ); diff --git a/include/eepp/ui/tools/uimergeview.hpp b/include/eepp/ui/tools/uimergeview.hpp new file mode 100644 index 000000000..379d5a8ed --- /dev/null +++ b/include/eepp/ui/tools/uimergeview.hpp @@ -0,0 +1,110 @@ +#ifndef EE_UI_TOOLS_UIMERGEVIEW_HPP +#define EE_UI_TOOLS_UIMERGEVIEW_HPP + +#include +#include +#include +#include +#include + +namespace EE::UI::Tools { + +class UIMergeEditorPlugin; + +struct EE_API MergeVersion { + String text; + String label; + std::string objectId; + Uint32 mode{ 0 }; + bool present{ false }; +}; + +struct EE_API MergeInput { + MergeVersion base; + MergeVersion stage2; + MergeVersion stage3; + std::shared_ptr resultDocument; + std::string path; + String resultLabel; + String missingVersionLabel; +}; + +class EE_API UIMergeView : public UILinearLayout, public WidgetCommandExecuter { + public: + enum class Order { Stage2ThenStage3, Stage3ThenStage2 }; + + struct ConflictBlock { + TextRange range; + String stage2; + String stage3; + }; + + static UIMergeView* New(); + static std::vector parseConflictBlocks( const String& text ); + + UIMergeView(); + virtual ~UIMergeView(); + virtual Uint32 getType() const override; + virtual bool isType( const Uint32& type ) const override; + + void load( MergeInput input ); + UICodeEditor* getLeftEditor() const { return mLeftEditor; } + UICodeEditor* getResultEditor() const { return mResultEditor; } + UICodeEditor* getRightEditor() const { return mRightEditor; } + const MergeInput& getInput() const { return mInput; } + + bool hasUnresolvedMarkerBlocks() const; + void recreateConflict(); + void setRecreateConflictCallback( CommandCallback callback ) { + mRecreateConflictCallback = std::move( callback ); + } + void goToNextConflict(); + void goToPreviousConflict(); + void acceptStage2( size_t block ); + void acceptStage3( size_t block ); + void acceptBoth( size_t block, Order order = Order::Stage2ThenStage3 ); + + void setSyntaxColorScheme( const SyntaxColorScheme& colorScheme ); + void setToolbarVisible( bool visible ); + bool isToolbarVisible() const { return mToolbarVisible; } + UIPushButton* addToolbarAction( const std::string& command, const String& text, + const KeyBindings::Shortcut& shortcut, const std::string& icon, + CommandCallback callback ); + + protected: + MergeInput mInput; + UICodeEditor* mLeftEditor{ nullptr }; + UICodeEditor* mResultEditor{ nullptr }; + UICodeEditor* mRightEditor{ nullptr }; + UIStackLayout* mToolbar{ nullptr }; + UILinearLayout* mEditorsLayout{ nullptr }; + size_t mCurrentBlock{ 0 }; + bool mSyncingScroll{ false }; + bool mApplyingBlock{ false }; + bool mToolbarVisible{ true }; + + virtual Uint32 onKeyDown( const KeyEvent& event ) override; + void replaceBlock( size_t block, const String& replacement ); + void refreshBlocks(); + std::vector mBlocks; + String mOriginalResultText; + CommandCallback mRecreateConflictCallback; + std::unique_ptr mLeftPlugin; + std::unique_ptr mResultPlugin; + std::unique_ptr mRightPlugin; + + void refreshHighlights(); + void syncScroll( UICodeEditor* source ); + void createToolbar(); + void goToConflict( size_t block ); + size_t getCurrentConflictBlock(); + void acceptCurrentStage2(); + void acceptCurrentStage3(); + void acceptCurrentBoth(); + bool executeMergeKeyBinding( const KeyEvent& event ); + friend class UIMergeEditorPlugin; +}; + +} // namespace EE::UI::Tools + +#endif diff --git a/include/eepp/ui/uicodeeditor.hpp b/include/eepp/ui/uicodeeditor.hpp index 240637a1c..e1c8bdba6 100644 --- a/include/eepp/ui/uicodeeditor.hpp +++ b/include/eepp/ui/uicodeeditor.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include using namespace EE::Graphics; +using namespace EE::Scene; using namespace EE::UI::Doc; namespace EE { namespace Graphics { @@ -901,6 +903,7 @@ class EE_API UICodeEditor : public UIWidget, public TextDocument::Client { Font* mFont; UIFontStyleConfig mFontStyleConfig; std::shared_ptr mDoc; + MainThreadLifetime mAsyncLifetime; bool mDirtyEditor{ false }; bool mDirtyScroll{ false }; bool mCursorVisible{ false }; diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index e967685fb..3595cc2b0 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -128,6 +128,7 @@ enum UINodeType { UI_TYPE_HTML_TABLE_CELL, UI_TYPE_DROPDOWNMODELLIST, UI_TYPE_DIFF_VIEW, + UI_TYPE_MERGE_VIEW, UI_TYPE_BR, UI_TYPE_HTML_HTML, UI_TYPE_HTML_HEAD, diff --git a/premake4.lua b/premake4.lua index 0101e8c2f..05b45b1ae 100644 --- a/premake4.lua +++ b/premake4.lua @@ -1977,6 +1977,7 @@ solution "eepp" end files { "src/tests/unit_tests/*.cpp", "src/tools/ecode/jsonhelper.cpp", + "src/tools/ecode/plugins/git/git.cpp", "src/tools/ecode/plugins/autocomplete/snippetparser.cpp", "src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" } eepp_module_backward_add( false ) diff --git a/premake5.lua b/premake5.lua index c8c7dae81..ebca58aa2 100644 --- a/premake5.lua +++ b/premake5.lua @@ -2004,6 +2004,7 @@ workspace "eepp" language "C++" files { "src/tests/unit_tests/*.cpp", "src/tools/ecode/jsonhelper.cpp", + "src/tools/ecode/plugins/git/git.cpp", "src/tools/ecode/plugins/autocomplete/snippetparser.cpp", "src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" } filter { "system:not windows", "system:not haiku" } diff --git a/src/eepp/ui/doc/textdocument.cpp b/src/eepp/ui/doc/textdocument.cpp index 48684abe0..5179a8bd7 100644 --- a/src/eepp/ui/doc/textdocument.cpp +++ b/src/eepp/ui/doc/textdocument.cpp @@ -31,6 +31,9 @@ static constexpr char DEFAULT_NON_WORD_CHARS[] = " \t\n/\\()\"':,.;<>~!@#$%^&*|+ static UnorderedSet TEXT_DOCUMENT_COMMANDS = {}; +TextDocument::ScopedReadLock::ScopedReadLock( const TextDocument& document ) : + mLinesLock( document.mLinesMutex ), mDocumentLock( *document.mDocumentMutex ) {} + bool TextDocument::fileMightBeBinary( const std::string& file ) { static constexpr size_t MAX_READ = 4096; static constexpr std::array NULL_SEQUENCE = { 0, 0, 0, 0 }; diff --git a/src/eepp/ui/iconmanager.cpp b/src/eepp/ui/iconmanager.cpp index aa908f093..b8cddbe75 100644 --- a/src/eepp/ui/iconmanager.cpp +++ b/src/eepp/ui/iconmanager.cpp @@ -268,6 +268,7 @@ ResourcePtr IconManager::init( const std::string& iconThemeName, { "git-stash-apply", 0xec27 }, { "git-stash-pop", 0xec28 }, { "git-merge", 0xeafe }, + { "git-branch-staged-changes", 0xec6d }, { "diff-single", 0xec22 }, { "remove", 0xeb3b }, { "tag", 0xea66 }, @@ -305,6 +306,12 @@ ResourcePtr IconManager::init( const std::string& iconThemeName, { "link", 0xeb15 }, { "agent", 0xec67 }, { "diff", 0xeae1 }, + { "arrow-both", 0xea99 }, + { "arrow-left", 0xea9b }, + { "arrow-right", 0xea9c }, + { "arrow-circle-left", 0xebfd }, + { "arrow-circle-right", 0xebfe }, + { "discard", 0xeae2 }, } ) { iconTheme->add( UIGlyphIcon::New( icon.first, codIconFont, icon.second ) ); diff --git a/src/eepp/ui/tools/uidiffview.cpp b/src/eepp/ui/tools/uidiffview.cpp index ddb88ca1d..f6b9c22ef 100644 --- a/src/eepp/ui/tools/uidiffview.cpp +++ b/src/eepp/ui/tools/uidiffview.cpp @@ -77,7 +77,7 @@ static Sprite* setImageViewerImage( UIImageViewer* viewer, Image* image ) { } UIScrollView* UIDiffView::NewMultiFileDiffViewer( const std::string& patchText, - const std::string& repoPath ) { + const std::string& repoPath, ViewMode viewMode ) { auto scrollView = UIScrollView::New(); auto vbox = UILinearLayout::NewVertical(); vbox->setParent( scrollView ); @@ -87,6 +87,7 @@ UIScrollView* UIDiffView::NewMultiFileDiffViewer( const std::string& patchText, for ( const auto& diff : diffs ) { auto* diffView = UIDiffView::New(); + diffView->setViewMode( viewMode ); diffView->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::WrapContent ); diffView->setParent( vbox ); diffView->setHeadersVisible( true ); diff --git a/src/eepp/ui/tools/uimergeview.cpp b/src/eepp/ui/tools/uimergeview.cpp new file mode 100644 index 000000000..1fef37bc0 --- /dev/null +++ b/src/eepp/ui/tools/uimergeview.cpp @@ -0,0 +1,579 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace EE::UI::Tools { + +class UIMergeEditorPlugin : public UICodeEditorPlugin { + public: + explicit UIMergeEditorPlugin( UIMergeView* view ) : mView( view ) {} + enum class LineType : Uint8 { Common, Added, Removed, Conflict }; + + std::string getId() override { return "MergeEditorPlugin"; } + std::string getTitle() override { return "MergeEditorPlugin"; } + std::string getDescription() override { return "Highlights merge changes and conflicts."; } + bool isReady() const override { return true; } + void onRegister( UICodeEditor* editor ) override { + auto* themeManager = editor->getUISceneNode()->getUIThemeManager(); + Font* font = themeManager->getDefaultFont(); + mHeaderHeight = + font ? std::ceil( themeManager->getDefaultFontSize() ) + PixelDensity::dpToPxI( 8 ) + : PixelDensity::dpToPxI( 24 ); + editor->registerTopSpace( this, mHeaderHeight, 0 ); + } + void onUnregister( UICodeEditor* editor ) override { editor->unregisterTopSpace( this ); } + bool onKeyDown( UICodeEditor*, const KeyEvent& event ) override { + return mView->executeMergeKeyBinding( event ); + } + + void setLines( std::vector lines ) { mLines = std::move( lines ); } + void setHeader( String label, bool present, const String& missingVersionLabel ) { + mHeader = std::move( label ); + if ( !present && !missingVersionLabel.empty() ) + mHeader += " · " + missingVersionLabel; + } + + void drawTop( UICodeEditor* editor, const Vector2f& screenStart, const Sizef& size, + const Float& ) override { + Primitives primitives; + primitives.setColor( + editor->getColorScheme().getEditorColor( SyntaxStyleTypes::Background ) ); + primitives.drawRectangle( Rectf( screenStart, size ) ); + primitives.setColor( + editor->getColorScheme().getEditorColor( SyntaxStyleTypes::LineBreakColumn ) ); + primitives.drawRectangle( + { { screenStart.x, screenStart.y + size.y - PixelDensity::dpToPx( 1 ) }, + { size.x, PixelDensity::dpToPx( 1 ) } } ); + + auto* themeManager = editor->getUISceneNode()->getUIThemeManager(); + Font* font = themeManager->getDefaultFont(); + if ( !font || mHeader.empty() ) + return; + const Float fontSize = themeManager->getDefaultFontSize(); + const Float offsetY = eefloor( ( size.y - font->getLineSpacing( fontSize ) ) * 0.5f ); + Text::draw( mHeader, { screenStart.x + PixelDensity::dpToPx( 8 ), screenStart.y + offsetY }, + font, fontSize, + editor->getColorScheme().getEditorColor( SyntaxStyleTypes::LineNumber2 ) ); + } + + void drawBeforeLineText( UICodeEditor* editor, const Int64& index, Vector2f position, + const Float&, const Float& lineHeight ) override { + if ( index < 0 || index >= static_cast( mLines.size() ) || + mLines[index] == LineType::Common ) + return; + Color color; + switch ( mLines[index] ) { + case LineType::Added: + color = Color( 0, 150, 32, 40 ); + break; + case LineType::Removed: + color = Color( 180, 0, 32, 40 ); + break; + case LineType::Conflict: + color = Color( 180, 0, 32, 64 ); + break; + case LineType::Common: + return; + } + Primitives primitives; + primitives.setColor( color ); + primitives.drawRectangle( { { editor->getScreenPos().x, position.y }, + { editor->getPixelsSize().x, lineHeight } } ); + } + + private: + UIMergeView* mView; + std::vector mLines; + String mHeader; + Float mHeaderHeight{ 0 }; +}; + +UIMergeView* UIMergeView::New() { + return eeNew( UIMergeView, () ); +} + +UIMergeView::UIMergeView() : + UILinearLayout( "mergeview", UIOrientation::Vertical ), + WidgetCommandExecuter( KeyBindings{ getUISceneNode()->getWindow()->getInput() } ) { + setFlags( UI_AUTO_SIZE ); + createToolbar(); + mEditorsLayout = UILinearLayout::NewHorizontal(); + mEditorsLayout->setParent( this ); + mEditorsLayout->setId( "merge_editors" ); + mEditorsLayout->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ); + mEditorsLayout->setLayoutWeight( 1 ); + for ( auto** editor : { &mLeftEditor, &mResultEditor, &mRightEditor } ) { + *editor = UICodeEditor::New(); + ( *editor )->setParent( mEditorsLayout ); + ( *editor )->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::MatchParent ); + ( *editor )->setLayoutWeight( 1 ); + ( *editor )->setShowFoldingRegion( false ); + } + mLeftEditor->setLocked( true ); + mRightEditor->setLocked( true ); + // The right editor owns the single visible vertical scrollbar for the three panes. + mLeftEditor->setVerticalScrollBarEnabled( false ); + mResultEditor->setVerticalScrollBarEnabled( false ); + mLeftPlugin = std::make_unique( this ); + mResultPlugin = std::make_unique( this ); + mRightPlugin = std::make_unique( this ); + mLeftEditor->registerPlugin( mLeftPlugin.get() ); + mResultEditor->registerPlugin( mResultPlugin.get() ); + mRightEditor->registerPlugin( mRightPlugin.get() ); + mResultEditor->on( Event::OnTextChanged, [this]( const Event* ) { + if ( mApplyingBlock ) + return; + debounce( + [this] { + refreshBlocks(); + refreshHighlights(); + }, + Milliseconds( 100 ), String::hash( "merge-view-refresh" ) ); + } ); + + for ( auto* source : { mLeftEditor, mResultEditor, mRightEditor } ) { + source->on( Event::OnScrollChange, + [this, source]( const Event* ) { syncScroll( source ); } ); + source->getVScrollBar()->on( Event::OnValueChange, + [this, source]( const Event* ) { syncScroll( source ); } ); + } +} + +void UIMergeView::syncScroll( UICodeEditor* source ) { + if ( mSyncingScroll ) + return; + mSyncingScroll = true; + const Float sourceMaxY = source->getMaxScroll().y; + const Float scrollRatio = sourceMaxY > 0 ? source->getScroll().y / sourceMaxY : 0; + for ( auto* target : { mLeftEditor, mResultEditor, mRightEditor } ) { + if ( target != source ) { + target->setScrollY( scrollRatio * target->getMaxScroll().y, false ); + target->setScrollX( source->getScroll().x, false ); + } + } + mSyncingScroll = false; +} + +void UIMergeView::createToolbar() { + static constexpr auto TOOLBAR_LAYOUT = R"xml( + + + + + + + + + + )xml"; + getUISceneNode()->loadLayoutFromString( TOOLBAR_LAYOUT, this, + String::hash( "uimergeview_toolbar" ) ); + mToolbar = find( "merge_toolbar" ); + + const auto bindAction = [this]( const char* buttonId, const char* command, + const KeyBindings::Shortcut& shortcut, + CommandCallback callback ) { + setCommand( command, std::move( callback ) ); + if ( !shortcut.empty() ) + getKeyBindings().addKeybind( shortcut, command ); + auto* button = find( buttonId ); + const auto keybind = getKeyBindings().getCommandKeybindString( command ); + if ( !keybind.empty() ) + button->setTooltipText( button->getText() + " (" + keybind + ")" ); + button->onClick( + [this, command = std::string{ command }]( const Event* ) { execute( command ); } ); + }; + bindAction( "merge_previous_conflict", "merge-previous-conflict", { KEY_F7 }, + [this] { goToPreviousConflict(); } ); + bindAction( "merge_accept_left", "merge-accept-left", + { KEY_1, KeyMod::getDefaultSecondaryModifier() }, + [this] { acceptCurrentStage2(); } ); + bindAction( "merge_accept_both", "merge-accept-both", + { KEY_2, KeyMod::getDefaultSecondaryModifier() }, [this] { acceptCurrentBoth(); } ); + bindAction( "merge_accept_right", "merge-accept-right", + { KEY_3, KeyMod::getDefaultSecondaryModifier() }, + [this] { acceptCurrentStage3(); } ); + bindAction( "merge_recreate_conflict", "merge-recreate-conflict", {}, [this] { + if ( mRecreateConflictCallback ) + mRecreateConflictCallback(); + else + recreateConflict(); + } ); + bindAction( "merge_next_conflict", "merge-next-conflict", { KEY_F8 }, + [this] { goToNextConflict(); } ); +} + +UIPushButton* UIMergeView::addToolbarAction( const std::string& command, const String& text, + const KeyBindings::Shortcut& shortcut, + const std::string& icon, CommandCallback callback ) { + setCommand( command, std::move( callback ) ); + if ( !shortcut.empty() ) + getKeyBindings().addKeybind( shortcut, command ); + auto* button = UIPushButton::New(); + button->setParent( mToolbar ); + button->setId( command ); + button->setText( text ); + if ( auto* toolbarIcon = getUISceneNode()->findIcon( icon ) ) + button->setIcon( toolbarIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + button->setClass( "mergeview_toolbar_button" ); + button->setLayoutMarginRight( PixelDensity::dpToPx( 4 ) ); + const auto keybind = getKeyBindings().getCommandKeybindString( command ); + button->setTooltipText( keybind.empty() ? text : text + " (" + keybind + ")" ); + button->onClick( [this, command]( const Event* ) { execute( command ); } ); + return button; +} + +UIMergeView::~UIMergeView() { + if ( mLeftEditor && mLeftPlugin ) + mLeftEditor->unregisterPlugin( mLeftPlugin.get() ); + if ( mResultEditor && mResultPlugin ) + mResultEditor->unregisterPlugin( mResultPlugin.get() ); + if ( mRightEditor && mRightPlugin ) + mRightEditor->unregisterPlugin( mRightPlugin.get() ); +} + +Uint32 UIMergeView::getType() const { + return UI_TYPE_MERGE_VIEW; +} + +bool UIMergeView::isType( const Uint32& type ) const { + return type == getType() || UILinearLayout::isType( type ); +} + +void UIMergeView::load( MergeInput input ) { + mInput = std::move( input ); + mLeftPlugin->setHeader( mInput.stage2.label, mInput.stage2.present, + mInput.missingVersionLabel ); + mResultPlugin->setHeader( mInput.resultLabel, true, mInput.missingVersionLabel ); + mRightPlugin->setHeader( mInput.stage3.label, mInput.stage3.present, + mInput.missingVersionLabel ); + if ( mInput.resultDocument ) + mResultEditor->setDocument( mInput.resultDocument ); + const auto& syntaxDefinition = mResultEditor->getDocument().getSyntaxDefinition(); + const auto makeSideDocument = [&syntaxDefinition]( const MergeVersion& version ) { + auto document = std::make_shared(); + if ( version.present ) + document->textInput( version.text ); + document->setSyntaxDefinition( syntaxDefinition ); + document->setSelection( { 0, 0 } ); + return document; + }; + mLeftEditor->setDocument( makeSideDocument( mInput.stage2 ) ); + mRightEditor->setDocument( makeSideDocument( mInput.stage3 ) ); + mOriginalResultText = mResultEditor->getDocument().getText(); + refreshBlocks(); + refreshHighlights(); +} + +void UIMergeView::refreshHighlights() { + using LineType = UIMergeEditorPlugin::LineType; + const auto& leftDocument = mLeftEditor->getDocument(); + const auto& resultDocument = mResultEditor->getDocument(); + const auto& rightDocument = mRightEditor->getDocument(); + Doc::TextDocument::ScopedReadLock leftLock( leftDocument ); + Doc::TextDocument::ScopedReadLock resultLock( resultDocument ); + Doc::TextDocument::ScopedReadLock rightLock( rightDocument ); + const auto lineViews = []( const Doc::TextDocument& document ) { + const size_t lineCount = document.linesCount(); + std::vector lines; + lines.reserve( lineCount ); + for ( size_t line = 0; line < lineCount; ++line ) + lines.emplace_back( document.line( line ).getTextViewWithoutNewLine() ); + return lines; + }; + const auto leftLines = lineViews( leftDocument ); + const auto resultLines = lineViews( resultDocument ); + const auto rightLines = lineViews( rightDocument ); + std::vector leftTypes( leftLines.size(), LineType::Common ); + std::vector resultTypes( resultLines.size(), LineType::Common ); + std::vector rightTypes( rightLines.size(), LineType::Common ); + const auto mergeType = []( LineType& current, LineType type ) { + if ( current == LineType::Common ) + current = type; + else if ( current != type ) + current = LineType::Conflict; + }; + const auto computeDiff = [&mergeType]( const std::vector& oldLines, + const std::vector& newLines, + std::vector& oldTypes, + std::vector& newTypes ) { + dtl::Diff diff( oldLines, newLines ); + diff.compose(); + size_t oldLine = 0; + size_t newLine = 0; + for ( const auto& entry : diff.getSes().getSequence() ) { + switch ( entry.second.type ) { + case dtl::SES_COMMON: + ++oldLine; + ++newLine; + break; + case dtl::SES_DELETE: + if ( oldLine < oldTypes.size() ) + mergeType( oldTypes[oldLine], LineType::Removed ); + ++oldLine; + break; + case dtl::SES_ADD: + if ( newLine < newTypes.size() ) + mergeType( newTypes[newLine], LineType::Added ); + ++newLine; + break; + } + } + }; + computeDiff( leftLines, resultLines, leftTypes, resultTypes ); + computeDiff( resultLines, rightLines, resultTypes, rightTypes ); + + for ( const auto& block : mBlocks ) { + const Int64 start = std::max( 0, block.range.start().line() ); + const Int64 end = std::min( resultTypes.size(), block.range.end().line() ); + for ( Int64 line = start; line < end; ++line ) + resultTypes[line] = LineType::Conflict; + } + mLeftPlugin->setLines( std::move( leftTypes ) ); + mResultPlugin->setLines( std::move( resultTypes ) ); + mRightPlugin->setLines( std::move( rightTypes ) ); + for ( auto* editor : { mLeftEditor, mResultEditor, mRightEditor } ) + editor->invalidateDraw(); +} + +std::vector UIMergeView::parseConflictBlocks( const String& text ) { + std::vector blocks; + const auto markerWidth = []( std::string_view line, char marker ) { + size_t width = 0; + while ( width < line.size() && line[width] == marker ) + ++width; + return width >= 7 ? width : size_t{ 0 }; + }; + enum class ParseState { Outside, Stage2, Base, Stage3 }; + ParseState state = ParseState::Outside; + size_t lineNumber = 0; + size_t start = 0; + size_t width = 0; + std::string stage2; + std::string stage3; + bool firstStage2Line = true; + bool firstStage3Line = true; + const auto appendLine = []( std::string& destination, bool& firstLine, std::string_view line ) { + if ( !firstLine ) + destination += '\n'; + destination.append( line.data(), line.size() ); + firstLine = false; + }; + const auto processLine = [&]( std::string_view line ) { + switch ( state ) { + case ParseState::Outside: + width = markerWidth( line, '<' ); + if ( width != 0 ) { + start = lineNumber; + stage2.clear(); + stage3.clear(); + firstStage2Line = true; + firstStage3Line = true; + state = ParseState::Stage2; + } + break; + case ParseState::Stage2: + if ( markerWidth( line, '|' ) == width ) + state = ParseState::Base; + else if ( markerWidth( line, '=' ) == width ) + state = ParseState::Stage3; + else + appendLine( stage2, firstStage2Line, line ); + break; + case ParseState::Base: + if ( markerWidth( line, '=' ) == width ) + state = ParseState::Stage3; + break; + case ParseState::Stage3: + if ( markerWidth( line, '>' ) == width ) { + ConflictBlock block; + block.range = { { static_cast( start ), 0 }, + { static_cast( lineNumber + 1 ), 0 } }; + block.stage2 = String::fromUtf8( stage2 ); + block.stage3 = String::fromUtf8( stage3 ); + blocks.emplace_back( std::move( block ) ); + state = ParseState::Outside; + } else { + appendLine( stage3, firstStage3Line, line ); + } + break; + } + }; + const std::string utf8Text = text.toUtf8(); + bool processedCurrentLine = false; + String::splitCb( + [&]( std::string_view token ) { + if ( token == "\n" ) { + if ( !processedCurrentLine ) + processLine( {} ); + processedCurrentLine = false; + ++lineNumber; + } else { + processLine( token ); + processedCurrentLine = true; + } + return true; + }, + utf8Text, "", "\n", "" ); + return blocks; +} + +void UIMergeView::refreshBlocks() { + mBlocks = mResultEditor->hasDocument() + ? parseConflictBlocks( mResultEditor->getDocument().getText() ) + : std::vector{}; + if ( mCurrentBlock >= mBlocks.size() ) + mCurrentBlock = mBlocks.empty() ? 0 : mBlocks.size() - 1; +} + +bool UIMergeView::hasUnresolvedMarkerBlocks() const { + return !mBlocks.empty(); +} + +void UIMergeView::recreateConflict() { + if ( !mResultEditor->hasDocument() || + mResultEditor->getDocument().getText() == mOriginalResultText ) + return; + auto& document = mResultEditor->getDocument(); + document.selectAll(); + mApplyingBlock = true; + document.textInput( mOriginalResultText ); + mApplyingBlock = false; + refreshBlocks(); + refreshHighlights(); +} + +void UIMergeView::goToNextConflict() { + refreshBlocks(); + if ( mBlocks.empty() ) + return; + const Int64 line = mResultEditor->getDocument().getSelection().start().line(); + auto next = std::find_if( mBlocks.begin(), mBlocks.end(), [line]( const auto& block ) { + return block.range.start().line() > line; + } ); + goToConflict( next == mBlocks.end() ? 0 : std::distance( mBlocks.begin(), next ) ); +} + +void UIMergeView::goToPreviousConflict() { + refreshBlocks(); + if ( mBlocks.empty() ) + return; + const Int64 line = mResultEditor->getDocument().getSelection().start().line(); + auto previous = std::find_if( mBlocks.rbegin(), mBlocks.rend(), [line]( const auto& block ) { + return block.range.start().line() < line; + } ); + goToConflict( previous == mBlocks.rend() ? mBlocks.size() - 1 + : std::distance( previous, mBlocks.rend() ) - 1 ); +} + +void UIMergeView::goToConflict( size_t block ) { + if ( block >= mBlocks.size() ) + return; + mCurrentBlock = block; + const TextPosition position = mBlocks[block].range.start(); + mResultEditor->getDocument().setSelection( position ); + mResultEditor->goToLine( position ); + mResultEditor->setFocus(); +} + +size_t UIMergeView::getCurrentConflictBlock() { + const TextPosition cursor = mResultEditor->getDocument().getSelection().start(); + auto block = std::find_if( mBlocks.begin(), mBlocks.end(), [&cursor]( const auto& conflict ) { + return conflict.range.contains( cursor ); + } ); + if ( block != mBlocks.end() ) + mCurrentBlock = std::distance( mBlocks.begin(), block ); + return std::min( mCurrentBlock, mBlocks.size() - 1 ); +} + +void UIMergeView::acceptCurrentStage2() { + refreshBlocks(); + if ( !mBlocks.empty() ) + acceptStage2( getCurrentConflictBlock() ); +} + +void UIMergeView::acceptCurrentStage3() { + refreshBlocks(); + if ( !mBlocks.empty() ) + acceptStage3( getCurrentConflictBlock() ); +} + +void UIMergeView::acceptCurrentBoth() { + refreshBlocks(); + if ( !mBlocks.empty() ) + acceptBoth( getCurrentConflictBlock() ); +} + +void UIMergeView::replaceBlock( size_t block, const String& replacement ) { + if ( block >= mBlocks.size() ) + return; + auto& document = mResultEditor->getDocument(); + const TextRange range = mBlocks[block].range; + String text( replacement ); + if ( !text.empty() && text.back() != '\n' && + range.end().line() < static_cast( document.linesCount() ) ) + text += '\n'; + document.setSelection( range ); + mApplyingBlock = true; + document.textInput( text ); + mApplyingBlock = false; + refreshBlocks(); + refreshHighlights(); +} + +void UIMergeView::acceptStage2( size_t block ) { + refreshBlocks(); + if ( block < mBlocks.size() ) + replaceBlock( block, String( mBlocks[block].stage2 ) ); +} + +void UIMergeView::acceptStage3( size_t block ) { + refreshBlocks(); + if ( block < mBlocks.size() ) + replaceBlock( block, String( mBlocks[block].stage3 ) ); +} + +void UIMergeView::acceptBoth( size_t block, Order order ) { + refreshBlocks(); + if ( block >= mBlocks.size() ) + return; + const auto& first = + order == Order::Stage2ThenStage3 ? mBlocks[block].stage2 : mBlocks[block].stage3; + const auto& second = + order == Order::Stage2ThenStage3 ? mBlocks[block].stage3 : mBlocks[block].stage2; + replaceBlock( block, first + ( first.empty() || second.empty() ? "" : "\n" ) + second ); +} + +void UIMergeView::setSyntaxColorScheme( const SyntaxColorScheme& colorScheme ) { + for ( auto* editor : { mLeftEditor, mResultEditor, mRightEditor } ) + editor->setColorScheme( colorScheme ); +} + +void UIMergeView::setToolbarVisible( bool visible ) { + if ( mToolbarVisible == visible ) + return; + mToolbarVisible = visible; + mToolbar->setVisible( visible ); +} + +Uint32 UIMergeView::onKeyDown( const KeyEvent& event ) { + const Uint32 handled = executeMergeKeyBinding( event ); + return handled ? handled : UIWidget::onKeyDown( event ); +} + +bool UIMergeView::executeMergeKeyBinding( const KeyEvent& event ) { + return WidgetCommandExecuter::onKeyDown( event ) != 0; +} + +} // namespace EE::UI::Tools diff --git a/src/eepp/ui/uicodeeditor.cpp b/src/eepp/ui/uicodeeditor.cpp index 5dc8171bb..4ad3fec41 100644 --- a/src/eepp/ui/uicodeeditor.cpp +++ b/src/eepp/ui/uicodeeditor.cpp @@ -143,6 +143,7 @@ UICodeEditor::UICodeEditor( const std::string& elementTag, const bool& autoRegis UIWidget( elementTag ), mFont( getUISceneNode()->getResourceScope()->findFont( "monospace" ).get() ), mDoc( std::make_shared() ), + mAsyncLifetime( this, this ), mDocView( mDoc, mFontStyleConfig, { .textHints = TextHints::NoKerning, .tabStops = mTabStops } ), mBlinkTime( Seconds( 0.5f ) ), @@ -201,6 +202,8 @@ UICodeEditor::UICodeEditor( const bool& autoRegisterBaseCommands, UICodeEditor( "codeeditor", autoRegisterBaseCommands, autoRegisterBaseKeybindings ) {} UICodeEditor::~UICodeEditor() { + mAsyncLifetime.invalidate(); + if ( getUISceneNode()->hasThreadPool() ) { Uint64 tag = reinterpret_cast( this ); getUISceneNode()->getThreadPool()->removeWithTag( tag ); @@ -535,32 +538,33 @@ bool UICodeEditor::loadAsyncFromFile( bool wasLocked = isLocked(); if ( !wasLocked ) setLocked( true ); + const auto lifetime = mAsyncLifetime.weakHandle(); + auto document = mDoc; bool ret = mDoc->loadAsyncFromFile( - path, pool, [this, onLoaded, wasLocked]( TextDocument*, bool success ) { - if ( !success ) { - runOnMainThread( [this, onLoaded, wasLocked, success] { - if ( !wasLocked ) - setLocked( false ); + path, pool, [lifetime, document, onLoaded, wasLocked]( TextDocument*, bool success ) { + lifetime.run( [lifetime, onLoaded, wasLocked, document, + success]( UICodeEditor* editor ) { + if ( editor->mDoc != document ) { if ( onLoaded ) - onLoaded( mDoc, success ); - } ); - return; - } - if ( mMinimapEnabled && getUISceneNode()->hasThreadPool() ) { - mDoc->getHighlighter()->tokenizeAsync( getUISceneNode()->getThreadPool(), [this] { - runOnMainThread( [this] { invalidateDraw(); } ); - } ); - } - - if ( mDocView.isWrapEnabled() ) - mDocView.setPendingReconstruction( true ); - - runOnMainThread( [this, onLoaded, wasLocked, success] { + onLoaded( document, success ); + return; + } if ( !wasLocked ) - setLocked( false ); - onDocumentLoaded(); + editor->setLocked( false ); + if ( success ) { + if ( editor->mMinimapEnabled && editor->getUISceneNode()->hasThreadPool() ) { + editor->mDoc->getHighlighter()->tokenizeAsync( + editor->getUISceneNode()->getThreadPool(), [lifetime] { + lifetime.run( + []( UICodeEditor* editor ) { editor->invalidateDraw(); } ); + } ); + } + if ( editor->mDocView.isWrapEnabled() ) + editor->mDocView.setPendingReconstruction( true ); + editor->onDocumentLoaded(); + } if ( onLoaded ) - onLoaded( mDoc, success ); + onLoaded( editor->mDoc, success ); } ); } ); if ( !ret && !wasLocked ) @@ -583,27 +587,36 @@ bool UICodeEditor::loadAsyncFromURL( bool wasLocked = isLocked(); if ( !wasLocked ) setLocked( true ); + const auto lifetime = mAsyncLifetime.weakHandle(); + auto document = mDoc; bool ret = mDoc->loadAsyncFromURL( url, headers, - [this, onLoaded, wasLocked]( TextDocument*, bool success ) { - if ( mMinimapEnabled && getUISceneNode()->hasThreadPool() ) - mDoc->getHighlighter()->tokenizeAsync( getUISceneNode()->getThreadPool(), [this] { - runOnMainThread( [this] { invalidateDraw(); } ); + [lifetime, document, onLoaded, wasLocked]( TextDocument*, bool success ) { + lifetime.run( + [lifetime, document, success, onLoaded, wasLocked]( UICodeEditor* editor ) { + if ( editor->mDoc != document ) { + if ( onLoaded ) + onLoaded( document, success ); + return; + } + if ( editor->mMinimapEnabled && editor->getUISceneNode()->hasThreadPool() ) + editor->mDoc->getHighlighter()->tokenizeAsync( + editor->getUISceneNode()->getThreadPool(), [lifetime] { + lifetime.run( + []( UICodeEditor* editor ) { editor->invalidateDraw(); } ); + } ); + if ( !wasLocked ) + editor->setLocked( false ); + editor->onDocumentLoaded(); + if ( onLoaded ) + onLoaded( editor->mDoc, success ); } ); - - runOnMainThread( [this, success, onLoaded, wasLocked] { - if ( !wasLocked ) - setLocked( false ); - onDocumentLoaded(); - if ( onLoaded ) - onLoaded( mDoc, success ); - } ); }, - [this]( const Http&, const Http::Request&, const Http::Response&, - const Http::Request::Status& status, size_t /*totalBytes*/, - size_t /*currentBytes*/ ) { + [lifetime]( const Http&, const Http::Request&, const Http::Response&, + const Http::Request::Status& status, size_t /*totalBytes*/, + size_t /*currentBytes*/ ) { if ( status == Http::Request::ContentReceived ) { - runOnMainThread( [this] { invalidateDraw(); } ); + lifetime.run( []( UICodeEditor* editor ) { editor->invalidateDraw(); } ); } return true; } ); diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index a3741c340..accaa1bb3 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -247,6 +248,7 @@ void UIWidgetCreator::createBaseWidgetList() { registeredWidget["richtext"] = UIRichText::New; registeredWidget["textspan"] = UITextSpan::New; registeredWidget["markdownview"] = UIMarkdownView::New; + registeredWidget["mergeview"] = Tools::UIMergeView::New; // Aliases registeredWidget["hbox"] = UILinearLayout::NewHorizontal; diff --git a/src/tests/unit_tests/gitconflict_tests.cpp b/src/tests/unit_tests/gitconflict_tests.cpp new file mode 100644 index 000000000..a27e40595 --- /dev/null +++ b/src/tests/unit_tests/gitconflict_tests.cpp @@ -0,0 +1,104 @@ +#include "utest.h" + +#include "../../tools/ecode/plugins/git/git.hpp" +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace ecode; + +namespace { + +struct GitTempDirectory { + GitTempDirectory() { + path = std::filesystem::temp_directory_path() / + ( "eepp-git-conflict-" + + std::to_string( std::chrono::steady_clock::now().time_since_epoch().count() ) ); + std::filesystem::create_directories( path ); + } + ~GitTempDirectory() { FileSystem::dirRemoveAll( path.string() ); } + std::filesystem::path path; +}; + +} // namespace + +UTEST( GitConflict, ParsesNulDelimitedStageRecordsAndUnusualPaths ) { + if ( Sys::which( "git" ).empty() ) + UTEST_SKIP( "Git is not installed" ); + + const std::string oid( 40, 'a' ); + const std::string path = "space tab\tquote\" unicode-ñ newline\n.txt"; + std::string records = "100644 " + oid + " 1\t" + path; + records += '\0'; + records += "100644 " + oid + " 2\t" + path; + records += '\0'; + + auto state = Git::parseUnmergedIndex( records ); + EXPECT_TRUE( state.error.empty() ); + ASSERT_EQ( 1u, state.files.size() ); + EXPECT_STREQ( path.c_str(), state.files.front().path.c_str() ); + EXPECT_TRUE( state.files.front().base.has_value() ); + EXPECT_TRUE( state.files.front().stage2.has_value() ); + EXPECT_FALSE( state.files.front().stage3.has_value() ); +} + +UTEST( GitConflict, RejectsMalformedUnmergedIndexRecords ) { + if ( Sys::which( "git" ).empty() ) + UTEST_SKIP( "Git is not installed" ); + + auto state = Git::parseUnmergedIndex( "not an index record\0" ); + EXPECT_FALSE( state.error.empty() ); + EXPECT_TRUE( state.files.empty() ); +} + +UTEST( GitConflict, DetectsRebaseDirectory ) { + const std::string gitPath = Sys::which( "git" ); + if ( gitPath.empty() ) + UTEST_SKIP( "Git is not installed" ); + + GitTempDirectory temp; + Git git( temp.path.string(), gitPath ); + std::string output; + ASSERT_EQ( EXIT_SUCCESS, + git.git( std::vector{ "init" }, temp.path.string(), output ) ); + ASSERT_TRUE( FileSystem::makeDir( ( temp.path / ".git/rebase-merge" ).string(), true ) ); + EXPECT_EQ( Git::GitOperation::Rebase, git.operation( temp.path.string() ) ); +} + +UTEST( GitConflict, KeepsMergeOperationAfterAllConflictsAreStaged ) { + const std::string gitPath = Sys::which( "git" ); + if ( gitPath.empty() ) + UTEST_SKIP( "Git is not installed" ); + + GitTempDirectory temp; + Git git( temp.path.string(), gitPath ); + std::string output; + auto run = [&]( std::vector args ) { + output.clear(); + return git.git( args, temp.path.string(), output ); + }; + ASSERT_EQ( EXIT_SUCCESS, run( { "init", "-b", "main" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.name", "eepp tests" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.email", "eepp-tests@example.invalid" } ) ); + const std::string file = ( temp.path / "conflict.txt" ).string(); + ASSERT_TRUE( FileSystem::fileWrite( file, "base\n" ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "add", "conflict.txt" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "-m", "base" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "-c", "incoming" } ) ); + ASSERT_TRUE( FileSystem::fileWrite( file, "incoming\n" ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "-am", "incoming" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "main" } ) ); + ASSERT_TRUE( FileSystem::fileWrite( file, "current\n" ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "-am", "current" } ) ); + EXPECT_NE( EXIT_SUCCESS, run( { "merge", "incoming" } ) ); + ASSERT_TRUE( FileSystem::fileWrite( file, "resolved\n" ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "add", "conflict.txt" } ) ); + + auto state = git.conflictState( temp.path.string(), false ); + EXPECT_TRUE( state.error.empty() ); + EXPECT_FALSE( state.hasConflicts() ); + EXPECT_EQ( Git::GitOperation::Merge, state.operation ); +} diff --git a/src/tests/unit_tests/uicodeeditor_tests.cpp b/src/tests/unit_tests/uicodeeditor_tests.cpp index 30527ba39..7b9e20286 100644 --- a/src/tests/unit_tests/uicodeeditor_tests.cpp +++ b/src/tests/unit_tests/uicodeeditor_tests.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "../../tools/ecode/keybindingshelper.cpp" @@ -15,6 +16,43 @@ using namespace EE::UI::Doc; using namespace EE::Scene; using namespace EE::System; +UTEST( MainThreadLifetime, InvalidatedCallbacksDoNotRun ) { + UIApplication app( WindowSettings{ 320, 240, "eepp - main thread lifetime test" } ); + int owner = 42; + MainThreadLifetime lifetime( &owner, app.getUI() ); + auto weak = lifetime.weakHandle(); + bool called = false; + weak.run( [&called]( int* ) { called = true; } ); + lifetime.invalidate(); + SceneManager::instance()->update(); + EXPECT_FALSE( called ); +} + +UTEST( MainThreadLifetime, LiveCallbacksReceiveOwner ) { + UIApplication app( WindowSettings{ 320, 240, "eepp - main thread lifetime test" } ); + int owner = 42; + MainThreadLifetime lifetime( &owner, app.getUI() ); + int value = 0; + lifetime.weakHandle().run( [&value]( int* object ) { value = *object; } ); + SceneManager::instance()->update(); + EXPECT_EQ( 42, value ); +} + +UTEST( MainThreadLifetime, DispatcherCanBeAttachedAfterConstruction ) { + UIApplication app( WindowSettings{ 320, 240, "eepp - deferred dispatcher test" } ); + int owner = 42; + MainThreadLifetime lifetime( &owner, nullptr ); + bool called = false; + lifetime.weakHandle().run( [&called]( int* ) { called = true; } ); + SceneManager::instance()->update(); + EXPECT_FALSE( called ); + + lifetime.setDispatcher( app.getUI() ); + lifetime.weakHandle().run( [&called]( int* ) { called = true; } ); + SceneManager::instance()->update(); + EXPECT_TRUE( called ); +} + static const std::string userCode = R"objcpp(#import "common.h" #import #import diff --git a/src/tests/unit_tests/uidiffview_tests.cpp b/src/tests/unit_tests/uidiffview_tests.cpp index 06da314cd..afd52527a 100644 --- a/src/tests/unit_tests/uidiffview_tests.cpp +++ b/src/tests/unit_tests/uidiffview_tests.cpp @@ -6,6 +6,7 @@ #include #include #include +#include using namespace EE; using namespace EE::UI; @@ -103,6 +104,32 @@ UTEST( UIDiffView, LoadFromPatchAndVerifyCleanText ) { eeDelete( diffView ); } +UTEST( UIDiffView, MultiFileViewerUsesRequestedViewMode ) { + UIApplication app( WindowSettings{ 800, 600, "eepp - unit tests" } ); + std::string patchText = R"patch(diff --git a/first.txt b/first.txt +--- a/first.txt ++++ b/first.txt +@@ -1 +1 @@ +-old ++new +diff --git a/second.txt b/second.txt +--- a/second.txt ++++ b/second.txt +@@ -1 +1 @@ +-before ++after +)patch"; + + auto* viewer = + UIDiffView::NewMultiFileDiffViewer( patchText, "", UIDiffView::ViewMode::SideBySide ); + auto diffViews = viewer->findAllByType( UI_TYPE_DIFF_VIEW ); + ASSERT_EQ( size_t{ 2 }, diffViews.size() ); + for ( const auto* diffView : diffViews ) + EXPECT_EQ( UIDiffView::ViewMode::SideBySide, diffView->getViewMode() ); + + eeDelete( viewer ); +} + UTEST( UIDiffView, LoadFromFileImageDiffUsesImageViewers ) { UIApplication app( WindowSettings{ 800, 600, "eepp - unit tests" } ); diff --git a/src/tests/unit_tests/uimergeview_tests.cpp b/src/tests/unit_tests/uimergeview_tests.cpp new file mode 100644 index 000000000..5d658570e --- /dev/null +++ b/src/tests/unit_tests/uimergeview_tests.cpp @@ -0,0 +1,103 @@ +#include "utest.h" +#include +#include +#include + +using namespace EE; +using namespace EE::UI; +using namespace EE::UI::Tools; + +UTEST( UIMergeView, ParsesNormalConflictMarkers ) { + auto blocks = UIMergeView::parseConflictBlocks( + "before\n<<<<<<< ours\nleft one\nleft two\n=======\nright\n>>>>>>> theirs\nafter\n" ); + ASSERT_EQ( 1u, blocks.size() ); + EXPECT_TRUE( blocks[0].stage2 == "left one\nleft two" ); + EXPECT_TRUE( blocks[0].stage3 == "right" ); + EXPECT_EQ( 1, blocks[0].range.start().line() ); + EXPECT_EQ( 7, blocks[0].range.end().line() ); +} + +UTEST( UIMergeView, ParsesDiff3WithoutTreatingBaseAsOurs ) { + auto blocks = UIMergeView::parseConflictBlocks( + "<<<<<<< ours\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> theirs\n" ); + ASSERT_EQ( 1u, blocks.size() ); + EXPECT_TRUE( blocks[0].stage2 == "left" ); + EXPECT_TRUE( blocks[0].stage3 == "right" ); +} + +UTEST( UIMergeView, PreservesEmptyLinesAndLineRanges ) { + auto blocks = UIMergeView::parseConflictBlocks( + "before\n<<<<<<< ours\n\nleft\n\n=======\n\nright\n\n>>>>>>> theirs\nafter" ); + ASSERT_EQ( 1u, blocks.size() ); + EXPECT_TRUE( blocks[0].stage2 == "\nleft\n" ); + EXPECT_TRUE( blocks[0].stage3 == "\nright\n" ); + EXPECT_EQ( 1, blocks[0].range.start().line() ); + EXPECT_EQ( 10, blocks[0].range.end().line() ); +} + +UTEST( UIMergeView, SupportsConfiguredMarkerWidthsAndIgnoresMalformedBlocks ) { + auto blocks = UIMergeView::parseConflictBlocks( + "<<<<<<<<<< ours\nleft\n==========\nright\n>>>>>>>>>> theirs\n<<<<<<< incomplete\n" ); + ASSERT_EQ( 1u, blocks.size() ); + EXPECT_TRUE( blocks[0].stage2 == "left" ); + EXPECT_TRUE( blocks[0].stage3 == "right" ); +} + +UTEST( UIMergeView, UsesSharedResultDocumentAndAcceptIsUndoable ) { + UIApplication app( WindowSettings{ 800, 600, "eepp - unit tests" } ); + auto result = std::make_shared(); + result->setSyntaxDefinition( + Doc::SyntaxDefinitionManager::instance()->getByLanguageName( "JavaScript" ) ); + const String conflicted = "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n"; + result->textInput( conflicted ); + result->resetUndoRedo(); + MergeInput input; + input.resultDocument = result; + input.stage2.present = true; + input.stage2.text = "left"; + input.stage2.label = "Ours"; + input.stage3.present = true; + input.stage3.text = "right"; + input.stage3.label = "Theirs"; + auto* view = UIMergeView::New(); + view->load( std::move( input ) ); + auto* toolbar = view->find( "merge_toolbar" ); + auto* editorsLayout = view->find( "merge_editors" ); + ASSERT_TRUE( toolbar != nullptr ); + ASSERT_TRUE( editorsLayout != nullptr ); + EXPECT_TRUE( view->getFirstChild() == toolbar ); + EXPECT_TRUE( toolbar->getNextNode() == editorsLayout ); + EXPECT_TRUE( view->getLeftEditor()->getParent() == editorsLayout ); + EXPECT_TRUE( view->getResultEditor()->getParent() == editorsLayout ); + EXPECT_TRUE( view->getRightEditor()->getParent() == editorsLayout ); + EXPECT_TRUE( view->getResultEditor()->getDocumentRef() == result ); + EXPECT_EQ( result->getSyntaxDefinition().getLanguageIndex(), + view->getLeftEditor()->getDocument().getSyntaxDefinition().getLanguageIndex() ); + EXPECT_EQ( result->getSyntaxDefinition().getLanguageIndex(), + view->getRightEditor()->getDocument().getSyntaxDefinition().getLanguageIndex() ); + EXPECT_FALSE( view->getLeftEditor()->getVerticalScrollBarEnabled() ); + EXPECT_FALSE( view->getResultEditor()->getVerticalScrollBarEnabled() ); + EXPECT_TRUE( view->getRightEditor()->getVerticalScrollBarEnabled() ); + EXPECT_TRUE( view->isToolbarVisible() ); + EXPECT_TRUE( view->hasCommand( "merge-accept-left" ) ); + view->setToolbarVisible( false ); + EXPECT_FALSE( view->isToolbarVisible() ); + view->setToolbarVisible( true ); + EXPECT_TRUE( view->execute( "merge-accept-left" ) ); + EXPECT_TRUE( result->getText() == "left\n" ); + result->undo(); + EXPECT_TRUE( result->getText() == conflicted ); + EXPECT_TRUE( view->execute( "merge-accept-left" ) ); + view->recreateConflict(); + EXPECT_TRUE( result->getText() == conflicted ); + const String twoConflicts = + "before\n<<<<<<< ours\nleft one\n=======\nright one\n>>>>>>> theirs\nmiddle\n<<<<<<< " + "ours\nleft two\n=======\nright two\n>>>>>>> theirs\nafter\n"; + result->selectAll(); + result->textInput( twoConflicts ); + result->setSelection( { 10, 0 } ); + EXPECT_TRUE( view->execute( "merge-accept-right" ) ); + EXPECT_TRUE( result->getText() == "before\n<<<<<<< ours\nleft one\n=======\nright one\n>>>>>>> " + "theirs\nmiddle\nright two\nafter\n" ); + eeDelete( view ); +} diff --git a/src/tools/ecode/ecode.cpp b/src/tools/ecode/ecode.cpp index c71512d8a..ed7f21bd4 100644 --- a/src/tools/ecode/ecode.cpp +++ b/src/tools/ecode/ecode.cpp @@ -2629,7 +2629,8 @@ void App::loadDiffFromMemory( const std::string& content, const std::string& ori if ( !icon ) icon = getUISceneNode()->findIcon( "file" ); - auto scrollView = UIDiffView::NewMultiFileDiffViewer( content, repoPath ); + auto scrollView = + UIDiffView::NewMultiFileDiffViewer( content, repoPath, mConfig.editor.diffViewMode ); auto [tab, iv] = getSplitter()->createWidget( scrollView, diffViewTitle ); if ( icon ) tab->setIcon( icon->createDrawable( getMenuIconSize() ) ); @@ -2684,7 +2685,8 @@ void App::loadDiffFromPath( const std::string& path ) { if ( !icon ) icon = getUISceneNode()->findIcon( "file" ); - auto scrollView = UIDiffView::NewMultiFileDiffViewer( content ); + auto scrollView = + UIDiffView::NewMultiFileDiffViewer( content, "", mConfig.editor.diffViewMode ); auto [tab, iv] = getSplitter()->createWidget( scrollView, diffViewTitle ); if ( icon ) tab->setIcon( icon->createDrawable( getMenuIconSize() ) ); diff --git a/src/tools/ecode/plugins/git/git.cpp b/src/tools/ecode/plugins/git/git.cpp index 619693371..ba0e44990 100644 --- a/src/tools/ecode/plugins/git/git.cpp +++ b/src/tools/ecode/plugins/git/git.cpp @@ -1,4 +1,5 @@ #include "git.hpp" +#include #include #include #include @@ -8,6 +9,7 @@ #include #include +#include using namespace EE; using namespace EE::System; @@ -17,6 +19,7 @@ using namespace std::literals; namespace ecode { static constexpr auto sNotCommittedYetHash = "0000000000000000000000000000000000000000"; +static constexpr std::string_view sAsciiWhitespace = " \t\r\n"; Git::Blame::Blame( const std::string& error ) : error( error ), line( 0 ) {} @@ -39,6 +42,16 @@ Git::Git( const std::string& projectDir, const std::string& gitPath ) : mGitPath } int Git::git( const std::string& args, const std::string& projectDir, std::string& buf ) const { + return git( Process::parseArgs( args ), projectDir, buf ); +} + +int Git::git( const std::vector& args, const std::string& projectDir, + std::string& buf ) const { + return git( args, projectDir, buf, {} ); +} + +int Git::git( const std::vector& args, const std::string& projectDir, std::string& buf, + std::string_view input ) const { Clock clock; buf.clear(); Process p; @@ -49,17 +62,293 @@ int Git::git( const std::string& args, const std::string& projectDir, std::strin projectDir.empty() ? mProjectPath : projectDir ) ) { return EXIT_FAILURE; } - p.readAllStdOut( buf ); int retCode = 0; - p.join( &retCode ); + if ( !input.empty() ) { + size_t written = 0; + while ( written < input.size() ) { + const size_t count = p.write( input.substr( written ) ); + if ( count == 0 ) + break; + written += count; + } + p.join( &retCode ); + p.readAllStdOut( buf ); + } else { + p.readAllStdOut( buf ); + p.join( &retCode ); + } if ( !mSilent || retCode != EXIT_SUCCESS ) { + const std::string joinedArgs = String::join( args ); Log::instance()->writef( retCode != EXIT_SUCCESS ? LogLevel::Info : LogLevel::Debug, "GitPlugin cmd in %s (%d): %s %s", - clock.getElapsedTime().toString(), retCode, mGitPath, args ); + clock.getElapsedTime().toString(), retCode, mGitPath, joinedArgs ); } return retCode; } +Git::ConflictState Git::parseUnmergedIndex( const std::string& output ) { + ConflictState state; + UnorderedMap fileIndices; + size_t recordStart = 0; + while ( recordStart < output.size() ) { + const size_t recordEnd = output.find( '\0', recordStart ); + const size_t end = recordEnd == std::string::npos ? output.size() : recordEnd; + const std::string_view record( output.data() + recordStart, end - recordStart ); + const size_t space = record.find( ' ' ); + const size_t secondSpace = + space == std::string_view::npos ? space : record.find( ' ', space + 1 ); + const size_t tab = secondSpace == std::string_view::npos + ? secondSpace + : record.find( '\t', secondSpace + 1 ); + if ( space == std::string_view::npos || secondSpace == std::string_view::npos || + tab == std::string_view::npos || tab <= secondSpace + 1 ) { + state.error = "Invalid git ls-files --unmerged output"; + return state; + } + + ConflictStage conflictStage; + try { + conflictStage.mode = static_cast( + std::stoul( std::string( record.substr( 0, space ) ), nullptr, 8 ) ); + conflictStage.stage = static_cast( std::stoul( + std::string( record.substr( secondSpace + 1, tab - secondSpace - 1 ) ) ) ); + } catch ( const std::exception& ) { + state.error = "Invalid mode or stage in git ls-files --unmerged output"; + return state; + } + conflictStage.objectId = std::string( record.substr( space + 1, secondSpace - space - 1 ) ); + std::string path( record.substr( tab + 1 ) ); + auto [it, inserted] = fileIndices.emplace( path, state.files.size() ); + if ( inserted ) + state.files.emplace_back( ConflictFile{ std::move( path ) } ); + auto& file = state.files[it->second]; + switch ( conflictStage.stage ) { + case 1: + file.base = std::move( conflictStage ); + break; + case 2: + file.stage2 = std::move( conflictStage ); + break; + case 3: + file.stage3 = std::move( conflictStage ); + break; + default: + state.error = "Invalid index stage in git ls-files --unmerged output"; + return state; + } + recordStart = end + 1; + } + return state; +} + +Git::GitOperation Git::operation( const std::string& projectDir ) const { + const auto hasRef = [this, &projectDir]( const char* ref ) { + std::string output; + return git( { "rev-parse", "-q", "--verify", ref }, projectDir, output ) == EXIT_SUCCESS; + }; + if ( hasRef( "MERGE_HEAD" ) ) + return GitOperation::Merge; + if ( hasRef( "CHERRY_PICK_HEAD" ) ) + return GitOperation::CherryPick; + if ( hasRef( "REVERT_HEAD" ) ) + return GitOperation::Revert; + + const auto hasGitPath = [this, &projectDir]( const char* name ) { + std::string output; + if ( git( { "rev-parse", "--git-path", name }, projectDir, output ) != EXIT_SUCCESS ) + return false; + String::trimInPlace( output, sAsciiWhitespace ); + const bool absolute = + !output.empty() && ( output.front() == '/' || output.front() == '\\' || + ( output.size() > 1 && output[1] == ':' ) ); + if ( !absolute ) { + const std::string& repo = projectDir.empty() ? mProjectPath : projectDir; + output = repo + ( !repo.empty() && repo.back() == '/' ? "" : "/" ) + output; + } + return FileSystem::fileExists( output ) || FileSystem::isDirectory( output ); + }; + if ( hasGitPath( "rebase-merge" ) || hasGitPath( "rebase-apply" ) ) + return GitOperation::Rebase; + if ( hasGitPath( "MERGE_AUTOSTASH" ) ) + return GitOperation::StashApply; + return GitOperation::None; +} + +Git::ConflictState Git::conflictState( const std::string& projectDir, bool loadContents ) const { + std::string output; + const int ret = git( { "ls-files", "--unmerged", "--stage", "-z" }, projectDir, output ); + ConflictState state = parseUnmergedIndex( output ); + state.operation = operation( projectDir ); + if ( ret != EXIT_SUCCESS ) { + state.error = std::move( output ); + state.files.clear(); + return state; + } + const std::string& repo = projectDir.empty() ? mProjectPath : projectDir; + for ( auto& file : state.files ) { + file.workingTreeExists = FileSystem::fileExists( + repo + ( !repo.empty() && repo.back() == '/' ? "" : "/" ) + file.path ); + if ( !loadContents ) + continue; + for ( auto* stage : { &file.base, &file.stage2, &file.stage3 } ) { + if ( !stage->has_value() ) + continue; + std::string contents; + if ( git( { "cat-file", "blob", ( *stage )->objectId }, projectDir, contents ) != + EXIT_SUCCESS ) { + state.error = std::move( contents ); + return state; + } + ( *stage )->contents = std::move( contents ); + file.binary = file.binary || ( *stage )->contents.find( '\0' ) != std::string::npos; + } + } + return state; +} + +Git::Result Git::resolveConflict( const std::string& path, bool remove, + const std::string& projectDir ) const { + Result result; + result.returnCode = git( remove ? std::vector{ "rm", "--", path } + : std::vector{ "add", "--", path }, + projectDir, result.result ); + return result; +} + +Git::Result Git::acceptConflictStage( const std::string& path, bool stage2, bool present, + const std::string& projectDir ) const { + if ( !present ) + return resolveConflict( path, true, projectDir ); + Result result; + result.returnCode = git( { "checkout", stage2 ? "--ours" : "--theirs", "--", path }, projectDir, + result.result ); + if ( result.success() ) + return resolveConflict( path, false, projectDir ); + return result; +} + +Git::Result Git::restoreConflictStages( const ConflictFile& conflict, + const std::string& projectDir ) const { + Result result; + const ConflictStage* firstStage = conflict.base ? &*conflict.base + : conflict.stage2 ? &*conflict.stage2 + : conflict.stage3 ? &*conflict.stage3 + : nullptr; + if ( !firstStage ) { + result.returnCode = EXIT_FAILURE; + return result; + } + + std::string indexInfo; + indexInfo.reserve( conflict.path.size() * 4 + 512 ); + indexInfo += "0 "; + indexInfo.append( firstStage->objectId.size(), '0' ); + indexInfo += '\t'; + indexInfo += conflict.path; + indexInfo += '\0'; + for ( const auto* stage : { &conflict.base, &conflict.stage2, &conflict.stage3 } ) { + if ( !stage->has_value() ) + continue; + char header[128]; + const int length = std::snprintf( header, sizeof( header ), "%06o %s %u\t", + ( *stage )->mode, ( *stage )->objectId.c_str(), + static_cast( ( *stage )->stage ) ); + if ( length <= 0 || static_cast( length ) >= sizeof( header ) ) { + result.returnCode = EXIT_FAILURE; + return result; + } + indexInfo.append( header, static_cast( length ) ); + indexInfo += conflict.path; + indexInfo += '\0'; + } + result.returnCode = + git( { "update-index", "-z", "--index-info" }, projectDir, result.result, indexInfo ); + return result; +} + +Git::Result Git::preparedMergeMessage( const std::string& projectDir ) const { + Result result; + std::string path; + result.returnCode = git( { "rev-parse", "--path-format=absolute", "--git-path", "MERGE_MSG" }, + projectDir, path ); + String::trimInPlace( path, sAsciiWhitespace ); + if ( result.success() && FileSystem::fileGet( path, result.result ) ) + return result; + + std::string gitDir; + result.returnCode = + git( std::vector{ "rev-parse", "--absolute-git-dir" }, projectDir, gitDir ); + String::trimInPlace( gitDir, sAsciiWhitespace ); + if ( result.success() ) { + FileSystem::dirAddSlashAtEnd( gitDir ); + path = gitDir + "MERGE_MSG"; + if ( FileSystem::fileGet( path, result.result ) ) + return result; + } + + std::string mergeName; + result.returnCode = git( std::vector{ "name-rev", "--name-only", "--no-undefined", + "--refs=refs/heads/*", + "--refs=refs/remotes/*", "MERGE_HEAD" }, + projectDir, mergeName ); + String::trimInPlace( mergeName, sAsciiWhitespace ); + if ( result.fail() || mergeName.empty() ) { + result.returnCode = git( std::vector{ "rev-parse", "--short", "MERGE_HEAD" }, + projectDir, mergeName ); + String::trimInPlace( mergeName, sAsciiWhitespace ); + } + if ( result.success() && !mergeName.empty() ) { + result.result = "Merge '" + mergeName + "'"; + return result; + } + + // MERGE_HEAD was already verified when the operation state was detected. Keep the commit + // workflow usable even when the tool that initiated the merge did not create MERGE_MSG. + result.returnCode = EXIT_SUCCESS; + result.result = "Merge"; + return result; +} + +static std::vector operationArgs( Git::GitOperation operation, bool abort ) { + const char* action = abort ? "--abort" : "--continue"; + switch ( operation ) { + case Git::GitOperation::Merge: + return { "-c", "core.editor=true", "merge", action }; + case Git::GitOperation::Rebase: + return { "-c", "core.editor=true", "rebase", action }; + case Git::GitOperation::CherryPick: + return { "-c", "core.editor=true", "cherry-pick", action }; + case Git::GitOperation::Revert: + return { "-c", "core.editor=true", "revert", action }; + case Git::GitOperation::None: + case Git::GitOperation::StashApply: + return {}; + } + return {}; +} + +Git::Result Git::continueOperation( GitOperation operation, const std::string& projectDir ) const { + Result result; + auto args = operationArgs( operation, false ); + if ( args.empty() ) { + result.returnCode = EXIT_FAILURE; + return result; + } + result.returnCode = git( args, projectDir, result.result ); + return result; +} + +Git::Result Git::abortOperation( GitOperation operation, const std::string& projectDir ) const { + Result result; + auto args = operationArgs( operation, true ); + if ( args.empty() ) { + result.returnCode = EXIT_FAILURE; + return result; + } + result.returnCode = git( args, projectDir, result.result ); + return result; +} + void Git::gitSubmodules( const std::string& args, const std::string& projectDir, std::string& buf ) { git( String::format( "submodule foreach \"git %s\"", args ), projectDir, buf ); @@ -68,7 +357,7 @@ void Git::gitSubmodules( const std::string& args, const std::string& projectDir, bool Git::isGitRepo( const std::string& projectDir ) { std::string buf; git( "rev-parse --is-inside-work-tree", projectDir, buf ); - String::trimInPlace( buf ); + String::trimInPlace( buf, sAsciiWhitespace ); return "true" == buf; } @@ -308,7 +597,7 @@ Git::Result Git::mergeBranch( const std::string& branch, bool fastForward, } Git::Result Git::commit( const std::string& commitMsg, bool amend, bool byPassCommitHook, - const std::string& projectDir ) { + const std::string& projectDir, bool cleanupComments ) { auto tmpPath = Sys::getTempPath() + ".ecode-git-commit-" + String::randString( 16 ); if ( !FileSystem::fileWrite( tmpPath, commitMsg ) ) { Git::Result res; @@ -324,9 +613,9 @@ Git::Result Git::commit( const std::string& commitMsg, bool amend, bool byPassCo if ( byPassCommitHook ) opts += " --no-verify"; - int retCode = git( - String::format( "commit %s --cleanup=whitespace --allow-empty --file=%s", opts, tmpPath ), - projectDir, buf ); + int retCode = git( String::format( "commit %s --cleanup=%s --allow-empty --file=%s", opts, + cleanupComments ? "strip" : "whitespace", tmpPath ), + projectDir, buf ); FileSystem::fileRemove( tmpPath ); Git::Result res; res.returnCode = retCode; @@ -358,7 +647,7 @@ Git::CountResult Git::branchHistoryPosition( const std::string& localBranch, Git::CountResult res; res.returnCode = retCode; if ( res.success() ) { - String::trimInPlace( buf ); + String::trimInPlace( buf, sAsciiWhitespace ); auto results = String::split( buf, '\t' ); if ( results.size() == 2 ) { Int64 behind = 0; diff --git a/src/tools/ecode/plugins/git/git.hpp b/src/tools/ecode/plugins/git/git.hpp index 7a102f9e6..b995d18ac 100644 --- a/src/tools/ecode/plugins/git/git.hpp +++ b/src/tools/ecode/plugins/git/git.hpp @@ -3,7 +3,9 @@ #include #include +#include #include +#include #include #include @@ -18,6 +20,31 @@ namespace ecode { class Git { public: + enum class GitOperation : uint8_t { None, Merge, Rebase, CherryPick, Revert, StashApply }; + + struct ConflictStage { + std::string objectId; + std::string contents; + uint32_t mode{ 0 }; + uint8_t stage{ 0 }; + }; + + struct ConflictFile { + std::string path; + std::optional base; + std::optional stage2; + std::optional stage3; + bool workingTreeExists{ false }; + bool binary{ false }; + }; + + struct ConflictState { + std::vector files; + GitOperation operation{ GitOperation::None }; + std::string error; + + bool hasConflicts() const { return !files.empty(); } + }; struct Blame { Blame( const std::string& error ); @@ -243,6 +270,34 @@ class Git { int git( const std::string& args, const std::string& projectDir, std::string& buf ) const; + int git( const std::vector& args, const std::string& projectDir, + std::string& buf ) const; + + int git( const std::vector& args, const std::string& projectDir, std::string& buf, + std::string_view input ) const; + + static ConflictState parseUnmergedIndex( const std::string& output ); + + ConflictState conflictState( const std::string& projectDir = "", + bool loadContents = true ) const; + + GitOperation operation( const std::string& projectDir = "" ) const; + + Result resolveConflict( const std::string& path, bool remove, + const std::string& projectDir = "" ) const; + + Result acceptConflictStage( const std::string& path, bool stage2, bool present, + const std::string& projectDir = "" ) const; + + Result restoreConflictStages( const ConflictFile& conflict, + const std::string& projectDir = "" ) const; + + Result preparedMergeMessage( const std::string& projectDir = "" ) const; + + Result continueOperation( GitOperation operation, const std::string& projectDir = "" ) const; + + Result abortOperation( GitOperation operation, const std::string& projectDir = "" ) const; + void gitSubmodules( const std::string& args, const std::string& projectDir, std::string& buf ); bool isGitRepo( const std::string& projectDir ); @@ -286,7 +341,7 @@ class Git { const std::string& projectDir = "" ); Result commit( const std::string& commitMsg, bool amend, bool byPassCommitHook, - const std::string& projectDir = "" ); + const std::string& projectDir = "", bool cleanupComments = false ); Result fetch( const std::string& projectDir = "" ); diff --git a/src/tools/ecode/plugins/git/gitplugin.cpp b/src/tools/ecode/plugins/git/gitplugin.cpp index cd24fcaae..1f97c4054 100644 --- a/src/tools/ecode/plugins/git/gitplugin.cpp +++ b/src/tools/ecode/plugins/git/gitplugin.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -94,7 +95,7 @@ std::string GitPlugin::statusTypeToString( Git::GitStatusType type ) { case Git::GitStatusType::Untracked: return i18n( "git_untracked", "Untracked" ); case Git::GitStatusType::Unmerged: - return i18n( "git_unmerged", "Unmerged" ); + return i18n( "git_conflicts", "Conflicts" ); case Git::GitStatusType::Changed: return i18n( "git_changed", "Changed" ); case Git::GitStatusType::Staged: @@ -122,7 +123,9 @@ Plugin* GitPlugin::NewSync( PluginManager* pluginManager ) { } GitPlugin::GitPlugin( PluginManager* pluginManager, bool sync ) : - PluginBase( pluginManager ), mHighlightStyleColor( DEFAULT_HIGHLIGHT_COLOR ) { + PluginBase( pluginManager ), + mLifetime( this, getUISceneNode() ), + mHighlightStyleColor( DEFAULT_HIGHLIGHT_COLOR ) { if ( sync ) { load( pluginManager ); } else { @@ -131,8 +134,12 @@ GitPlugin::GitPlugin( PluginManager* pluginManager, bool sync ) : } GitPlugin::~GitPlugin() { + mLifetime.invalidate(); waitUntilLoaded(); mShuttingDown = true; + mConflictViewCloseConnection.disconnect(); + mConflictView = nullptr; + mConflictSessions.clear(); if ( mStatusButton ) mStatusButton->close(); @@ -166,6 +173,16 @@ GitPlugin::~GitPlugin() { while ( mRunningUpdateBranches ) Sys::sleep( Milliseconds( 1.f ) ); + + while ( *mRunningAsyncTasks ) + Sys::sleep( Milliseconds( 1.f ) ); +} + +void GitPlugin::runAsyncTask( std::function task ) { + auto runningTasks = mRunningAsyncTasks; + ++*runningTasks; + mThreadPool->run( std::move( task ), + [runningTasks = std::move( runningTasks )]( auto ) { --*runningTasks; } ); } void GitPlugin::load( PluginManager* pluginManager ) { @@ -280,12 +297,13 @@ void GitPlugin::load( PluginManager* pluginManager ) { } } - mGit = std::make_unique( pluginManager->getWorkspaceFolder() ); + mGit = std::make_shared( pluginManager->getWorkspaceFolder() ); mGit->setSilent( mSilent ); mGitFound = !mGit->getGitPath().empty(); mProjectPath = mRepoSelected = mGit->getProjectPath(); if ( getUISceneNode() ) { + mLifetime.setDispatcher( getUISceneNode() ); initModelStyler(); updateStatus(); updateBranches(); @@ -351,6 +369,7 @@ void GitPlugin::endModelStyler() { void GitPlugin::updateUINow( bool force ) { if ( !mGit || !getUISceneNode() ) return; + mLifetime.setDispatcher( getUISceneNode() ); if ( !mProjectPath.empty() ) getUISceneNode()->runOnMainThread( [this] { buildSidePanelTab(); } ); @@ -384,6 +403,68 @@ void GitPlugin::updateStatusBarSync() { return; } + if ( mConflictStateBar && mConflictStateText ) { + auto* conflictSession = activeConflictSession(); + const bool hasState = + conflictSession && ( !conflictSession->files.empty() || + conflictSession->operation != Git::GitOperation::None ); + mConflictStateBar->setVisible( hasState ); + if ( hasState ) { + String operation; + switch ( conflictSession->operation ) { + case Git::GitOperation::Merge: + operation = i18n( "git_operation_merge", "Merging" ); + break; + case Git::GitOperation::Rebase: + operation = i18n( "git_operation_rebase", "Rebasing" ); + break; + case Git::GitOperation::CherryPick: + operation = i18n( "git_operation_cherry_pick", "Cherry-picking" ); + break; + case Git::GitOperation::Revert: + operation = i18n( "git_operation_revert", "Reverting" ); + break; + case Git::GitOperation::StashApply: + operation = i18n( "git_operation_stash_apply", "Applying stash" ); + break; + case Git::GitOperation::None: + operation = i18n( "git_conflicts", "Conflicts" ); + break; + } + mConflictStateText->setText( + conflictSession->files.empty() + ? String::format( + ( conflictSession->operation == Git::GitOperation::Merge + ? i18n( "git_operation_ready_commit", "%s · Ready to commit" ) + : i18n( "git_operation_ready", "%s · Ready to continue" ) ) + .toUtf8(), + operation.toUtf8().c_str() ) + : String::format( + ( conflictSession->files.size() == 1 + ? i18n( "git_operation_conflict", "%s · %d conflict" ) + : i18n( "git_operation_conflicts", "%s · %d conflicts" ) ) + .toUtf8(), + operation.toUtf8().c_str(), + static_cast( conflictSession->files.size() ) ) ); + const bool canContinue = conflictSession->files.empty() && + conflictSession->operation != Git::GitOperation::None && + conflictSession->operation != Git::GitOperation::StashApply; + auto* continueButton = + mTabContents->find( "git_conflict_continue" )->asType(); + continueButton->setText( conflictSession->operation == Git::GitOperation::Merge + ? i18n( "git_commit_merge", "Commit" ) + : i18n( "git_continue_operation", "Continue" ) ); + if ( auto* icon = getUISceneNode()->findIcon( + conflictSession->operation == Git::GitOperation::Merge ? "git-commit" + : "debug-continue" ) ) + continueButton->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + continueButton->setEnabled( canContinue ); + mTabContents->find( "git_conflict_abort" ) + ->setEnabled( conflictSession->operation != Git::GitOperation::None && + conflictSession->operation != Git::GitOperation::StashApply ); + } + } + if ( !mStatusBarDisplayBranch ) return; @@ -472,19 +553,30 @@ void GitPlugin::updateStatusBarSync() { } void GitPlugin::updateStatus( bool force ) { - if ( !mGit || !mGitFound || mRunningUpdateStatus ) + if ( !mGit || !mGitFound ) return; + if ( mRunningUpdateStatus ) { + if ( force ) + mPendingForcedStatusUpdate = true; + return; + } if ( !mGit || mGit->getGitFolder().empty() ) { - getUISceneNode()->runOnMainThread( [this] { updateStatusBarSync(); } ); + mLifetime.weakHandle().run( []( GitPlugin* plugin ) { plugin->updateStatusBarSync(); } ); return; } mRunningUpdateStatus++; + UnorderedSet sessionRepos; + for ( const auto& [repo, session] : mConflictSessions ) + if ( session ) + sessionRepos.insert( repo ); + const std::string selectedRepo = repoSelected(); + const auto lifetime = mLifetime.weakHandle(); mThreadPool->run( - [this, force] { + [this, lifetime, force, selectedRepo, sessionRepos = std::move( sessionRepos )] { if ( !mGit || mGit->getGitFolder().empty() ) { - getUISceneNode()->runOnMainThread( [this] { updateStatusBarSync(); } ); + lifetime.run( []( GitPlugin* plugin ) { plugin->updateStatusBarSync(); } ); return; } @@ -495,6 +587,26 @@ void GitPlugin::updateStatus( bool force ) { prevGitStatus = mGitStatus; } Git::Status newGitStatus = mGit->status( mStatusRecurseSubmodules ); + UnorderedSet conflictRepos; + for ( const auto& repo : newGitStatus.files ) { + for ( const auto& file : repo.second ) + if ( file.report.type == Git::GitStatusType::Unmerged ) + conflictRepos.insert( mGit->repoPath( file.file ) ); + } + if ( !selectedRepo.empty() ) + conflictRepos.insert( selectedRepo ); + conflictRepos.insert( sessionRepos.begin(), sessionRepos.end() ); + UnorderedMap conflictStates; + for ( const auto& repo : conflictRepos ) + conflictStates.emplace( repo, mGit->conflictState( repo, false ) ); + for ( auto state = conflictStates.begin(); state != conflictStates.end(); ) { + if ( !state->second.hasConflicts() && + state->second.operation == Git::GitOperation::None && + sessionRepos.find( state->first ) == sessionRepos.end() ) + state = conflictStates.erase( state ); + else + ++state; + } UnorderedSet cache; for ( const auto& status : newGitStatus.files ) { @@ -518,19 +630,52 @@ void GitPlugin::updateStatus( bool force ) { { Lock l( mGitStatusMutex ); mGitStatus = std::move( newGitStatus ); - if ( !force && mGitBranches == prevBranch && mGitStatus == prevGitStatus ) + if ( !force && conflictStates.empty() && mGitBranches == prevBranch && + mGitStatus == prevGitStatus ) return; } - getUISceneNode()->runOnMainThread( [this] { updateStatusBarSync(); } ); + lifetime.run( + [conflictStates = std::move( conflictStates )]( GitPlugin* plugin ) mutable { + const bool selectStatusPanel = plugin->updateConflictSessions( conflictStates ); + plugin->updateStatusBarSync(); + if ( selectStatusPanel && plugin->mPanelSwicher ) + plugin->mPanelSwicher->getListBox()->setSelected( 1 ); + } ); }, - [this]( auto ) { mRunningUpdateStatus--; } ); + [this, lifetime]( auto ) { + --mRunningUpdateStatus; + if ( mPendingForcedStatusUpdate.exchange( false ) ) + lifetime.run( []( GitPlugin* plugin ) { plugin->updateStatus( true ); } ); + } ); } PluginRequestHandle GitPlugin::processMessage( const PluginMessage& msg ) { + if ( getUISceneNode() ) + mLifetime.setDispatcher( getUISceneNode() ); switch ( msg.type ) { case PluginMessageType::WorkspaceFolderChanged: { if ( mGit ) { + { + Lock l( mGitStatusMutex ); + mGitStatus = {}; + } + if ( getUISceneNode() ) { + mLifetime.weakHandle().run( []( GitPlugin* plugin ) { + plugin->mConflictViewCloseConnection.disconnect(); + plugin->mConflictView = nullptr; + plugin->mConflictSessions.clear(); + plugin->mActiveConflictRepo.clear(); + ++plugin->mConflictGeneration; + if ( plugin->mManager && plugin->mManager->getSplitter() ) + plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( + "git_conflict_resolver" ); + if ( plugin->mPanelSwicher ) + plugin->mPanelSwicher->getListBox()->setSelected( 0 ); + if ( plugin->mStackWidget && !plugin->mStackMap.empty() ) + plugin->mStackWidget->setActiveWidget( plugin->mStackMap[0] ); + } ); + } mGit->setProjectPath( msg.asJSON()["folder"] ); { @@ -833,9 +978,12 @@ void GitPlugin::branchMerge( Git::Branch branch ) { branch.name ) ); msgBox->on( Event::OnConfirm, [this, branch]( auto ) { - runAsync( - [this, branch]() { return mGit->mergeBranch( branch.name, false, repoSelected() ); }, - true, true, true, true, true ); + const std::string repoPath = repoSelected(); + runMergeLikeAsync( + [branch, repoPath]( Git& git ) { + return git.mergeBranch( branch.name, false, repoPath ); + }, + repoPath ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_confirm", "Confirm" ) ); @@ -844,7 +992,7 @@ void GitPlugin::branchMerge( Git::Branch branch ) { } void GitPlugin::pull( const std::string& repoPath ) { - runAsync( [this, repoPath]() { return mGit->pull( repoPath ); }, true, true, true ); + runMergeLikeAsync( [repoPath]( Git& git ) { return git.pull( repoPath ); }, repoPath ); } void GitPlugin::push( const std::string& repoPath ) { @@ -893,8 +1041,8 @@ void GitPlugin::branchCreate() { msgBox->showWhenReady(); } -void GitPlugin::commit( const std::string& repoPath ) { - if ( !mGitStatus.hasStagedChanges( mGit->repoName( repoPath, true ) ) ) { +void GitPlugin::commit( const std::string& repoPath, bool mergeCommit ) { + if ( !mergeCommit && !mGitStatus.hasStagedChanges( mGit->repoName( repoPath, true ) ) ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK, i18n( "git_nothing_to_commit", "Nothing to Commit" ) ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); @@ -920,6 +1068,7 @@ void GitPlugin::commit( const std::string& repoPath ) { ->setParent( msgBox->getLayoutCont()->getFirstChild() ) ->setId( "git-amend" ); chkAmend->setText( i18n( "git_amend", "Amend last commit" ) ); + chkAmend->setEnabled( !mergeCommit ); chkAmend->toPosition( 2 ); chkAmend->setTooltipText( getUISceneNode()->getKeyBindings().getShortcutString( { KEY_A, KeyMod::getDefaultModifier() }, true ) ); @@ -948,9 +1097,12 @@ void GitPlugin::commit( const std::string& repoPath ) { chkPush->setTooltipText( getUISceneNode()->getKeyBindings().getShortcutString( { KEY_P, KeyMod::getDefaultModifier() }, true ) ); - txtEdit->getDocument().setCommand( - "commit-amend", [chkAmend] { chkAmend->setChecked( !chkAmend->isChecked() ); } ); - txtEdit->getKeyBindings().addKeybind( { KEY_L, KeyMod::getDefaultModifier() }, "commit-amend" ); + if ( !mergeCommit ) { + txtEdit->getDocument().setCommand( + "commit-amend", [chkAmend] { chkAmend->setChecked( !chkAmend->isChecked() ); } ); + txtEdit->getKeyBindings().addKeybind( { KEY_L, KeyMod::getDefaultModifier() }, + "commit-amend" ); + } txtEdit->getDocument().setCommand( "commit-push", [chkPush] { chkPush->setChecked( !chkPush->isChecked() ); } ); @@ -962,30 +1114,45 @@ void GitPlugin::commit( const std::string& repoPath ) { txtEdit->getKeyBindings().addKeybind( { KEY_B, KeyMod::getDefaultModifier() }, "commit-bypass-hook" ); - msgBox->on( Event::OnConfirm, [this, msgBox, chkAmend, chkBypassHook, chkPush, - repoPath]( const Event* ) { + msgBox->on( Event::OnConfirm, [this, msgBox, chkAmend, chkBypassHook, chkPush, repoPath, + mergeCommit]( const Event* ) { std::string msg( msgBox->getTextEdit()->getText().toUtf8() ); if ( msg.empty() ) return; bool amend = chkAmend->isChecked(); bool bypassHook = chkBypassHook->isChecked(); bool pushCommit = chkPush->isChecked(); + std::optional branch = getBranchFromRepoPath( repoPath ); + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); msgBox->closeWindow(); runAsync( - [this, msg, amend, bypassHook, pushCommit, repoPath]() { - std::optional branch = getBranchFromRepoPath( repoPath ); + [git = std::move( git ), lifetime, branch = std::move( branch ), msg, amend, bypassHook, + pushCommit, repoPath, mergeCommit]() { bool pushNewBranch = branch && !branch->name.empty() && branch->remote.empty(); - auto res = mGit->commit( msg, amend, bypassHook, repoPath ); + auto res = git->commit( msg, amend, bypassHook, repoPath, mergeCommit ); if ( res.success() ) { - mLastCommitMsg.clear(); + lifetime.run( [mergeCommit, repoPath]( GitPlugin* plugin ) { + plugin->mLastCommitMsg.clear(); + if ( mergeCommit ) { + ++plugin->mConflictGeneration; + plugin->mConflictSessions.erase( repoPath ); + if ( plugin->mActiveConflictRepo == repoPath ) { + plugin->mActiveConflictRepo.clear(); + plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( + "git_conflict_resolver" ); + } + } + } ); if ( pushCommit ) { if ( pushNewBranch ) - return mGit->pushNewBranch( branch->name, repoPath ); - return mGit->push( repoPath ); + return git->pushNewBranch( branch->name, repoPath ); + return git->push( repoPath ); } - } else - mLastCommitMsg = msg; + } else { + lifetime.run( [msg]( GitPlugin* plugin ) { plugin->mLastCommitMsg = msg; } ); + } return res; }, true, true, true, true, true ); @@ -1178,6 +1345,604 @@ void GitPlugin::openFile( const std::string& file ) { } ); } +GitPlugin::GitConflictSession* GitPlugin::conflictSession( const std::string& repoPath ) { + auto session = mConflictSessions.find( repoPath ); + return session != mConflictSessions.end() ? session->second.get() : nullptr; +} + +GitPlugin::GitConflictSession* GitPlugin::activeConflictSession() { + if ( auto* session = conflictSession( mActiveConflictRepo ) ) + return session; + if ( mConflictSessions.empty() ) + return nullptr; + mActiveConflictRepo = mConflictSessions.begin()->first; + return mConflictSessions.begin()->second.get(); +} + +bool GitPlugin::updateConflictSessions( + UnorderedMap& conflictStates ) { + bool selectStatusPanel = false; + for ( auto& [repo, conflictState] : conflictStates ) { + if ( !conflictState.error.empty() ) + continue; + if ( !conflictState.hasConflicts() && conflictState.operation == Git::GitOperation::None ) { + const bool wasActive = mActiveConflictRepo == repo; + ++mConflictGeneration; + mConflictSessions.erase( repo ); + if ( wasActive ) { + mActiveConflictRepo.clear(); + mConflictViewCloseConnection.disconnect(); + mConflictView = nullptr; + mManager->getSplitter()->removeTabWithOwnedWidgetId( "git_conflict_resolver" ); + } + continue; + } + std::vector files; + files.reserve( conflictState.files.size() ); + for ( auto& conflict : conflictState.files ) + files.emplace_back( std::move( conflict.path ) ); + auto& session = mConflictSessions[repo]; + const bool stateChanged = + session && ( session->files != files || session->operation != conflictState.operation ); + if ( !session ) { + session = std::make_unique(); + selectStatusPanel = true; + } else if ( session->files.empty() && !files.empty() ) { + selectStatusPanel = true; + } + session->repoPath = repo; + session->files = std::move( files ); + session->operation = conflictState.operation; + if ( session->generation == 0 || stateChanged ) + session->generation = ++mConflictGeneration; + if ( session->currentFile >= session->files.size() ) + session->currentFile = 0; + if ( mActiveConflictRepo.empty() ) + mActiveConflictRepo = repo; + } + if ( mActiveConflictRepo.empty() && !mConflictSessions.empty() ) + mActiveConflictRepo = mConflictSessions.begin()->first; + return selectStatusPanel; +} + +void GitPlugin::loadConflictResolverView( std::shared_ptr resultDocument, + Git::ConflictFile conflict, std::string repository, + std::vector files, size_t currentFile, + Git::GitOperation operation, Uint64 generation ) { + auto* session = conflictSession( repository ); + if ( !resultDocument ) + return; + if ( mShuttingDown || mActiveConflictRepo != repository || !session || + generation != session->generation ) + return; + auto toVersion = []( const std::optional& stage, String label ) { + MergeVersion version; + version.label = std::move( label ); + if ( stage ) { + version.present = true; + version.text = String::fromUtf8( stage->contents ); + version.objectId = stage->objectId; + version.mode = stage->mode; + } + return version; + }; + String stage2Label; + String stage3Label; + switch ( operation ) { + case Git::GitOperation::Merge: + stage2Label = i18n( "git_merge_current_branch", "Current branch (ours)" ); + stage3Label = i18n( "git_merge_incoming_branch", "Incoming branch (theirs)" ); + break; + case Git::GitOperation::Rebase: + stage2Label = i18n( "git_merge_upstream", "Upstream" ); + stage3Label = i18n( "git_merge_replayed_commit", "Replayed commit (your change)" ); + break; + case Git::GitOperation::None: + case Git::GitOperation::CherryPick: + case Git::GitOperation::Revert: + case Git::GitOperation::StashApply: + stage2Label = i18n( "git_merge_ours_stage", "Ours (stage 2)" ); + stage3Label = i18n( "git_merge_theirs_stage", "Theirs (stage 3)" ); + break; + } + MergeInput input; + input.path = conflict.path; + input.base = toVersion( conflict.base, i18n( "git_merge_base", "Base" ) ); + input.stage2 = toVersion( conflict.stage2, std::move( stage2Label ) ); + input.stage3 = toVersion( conflict.stage3, std::move( stage3Label ) ); + input.resultDocument = resultDocument; + input.resultLabel = i18n( "git_merge_result", "Result" ); + input.missingVersionLabel = i18n( "git_merge_not_present", "Not present" ); + auto* mergeView = mConflictView && mManager->getSplitter()->ownedWidgetExists( mConflictView ) + ? mConflictView + : UIMergeView::New(); + mergeView->load( std::move( input ) ); + mergeView->setRecreateConflictCallback( [this] { recreateConflict(); } ); + const String continueText = operation == Git::GitOperation::Merge + ? i18n( "git_commit_merge", "Commit" ) + : i18n( "git_continue_operation", "Continue" ); + const bool newView = mergeView != mConflictView; + session->files = std::move( files ); + session->currentFile = currentFile; + session->generation = generation; + session->operation = operation; + mConflictView = mergeView; + if ( newView ) { + const Uint32 defaultModifier = KeyMod::getDefaultModifier(); + const Uint32 abortModifier = defaultModifier | KeyMod::getDefaultSecondaryModifier(); + mergeView->setId( "git_conflict_resolver" ); + mergeView->addToolbarAction( "git-conflict-save-stage", + i18n( "git_save_and_stage", "Save & Stage" ), + { KEY_S, defaultModifier }, "git-branch-staged-changes", + [this] { saveAndStageConflict(); } ); + mergeView->addToolbarAction( + "git-conflict-previous-file", i18n( "git_previous_conflict_file", "Previous File" ), + { KEY_PAGEUP, KeyMod::getDefaultSecondaryModifier() }, "arrow-circle-left", + [this] { openAdjacentConflict( false ); } ); + mergeView->addToolbarAction( + "git-conflict-next-file", i18n( "git_next_conflict_file", "Next File" ), + { KEY_PAGEDOWN, KeyMod::getDefaultSecondaryModifier() }, "arrow-circle-right", + [this] { openAdjacentConflict( true ); } ); + mergeView->addToolbarAction( + "git-conflict-continue", continueText, { KEY_RETURN, defaultModifier }, + operation == Git::GitOperation::Merge ? "git-commit" : "debug-continue", + [this] { continueConflictOperation(); } ); + mergeView->addToolbarAction( "git-conflict-abort", i18n( "git_abort_operation", "Abort" ), + { KEY_A, abortModifier }, "discard", + [this] { abortConflictOperation(); } ); + mConflictViewCloseConnection = + mergeView->connect( Event::OnClose, [this, mergeView]( const Event* ) { + if ( mConflictView == mergeView ) { + mConflictView = nullptr; + if ( auto* session = activeConflictSession() ) + session->generation = ++mConflictGeneration; + } + } ); + mManager->getSplitter()->createWidget( + mergeView, + String::format( i18n( "git_merge_tab", "Merge: %s" ).toUtf8(), + resultDocument->getFilename() ), + true ); + } else { + if ( auto* continueButton = mergeView->find( "git-conflict-continue" ) ) { + continueButton->setText( continueText ); + if ( auto* icon = getUISceneNode()->findIcon( + operation == Git::GitOperation::Merge ? "git-commit" : "debug-continue" ) ) + continueButton->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + } + auto tabs = mManager->getSplitter()->getTabFromOwnedWidgetId( mergeView->getId() ); + if ( !tabs.empty() ) { + tabs.front().first->setText( String::format( + i18n( "git_merge_tab", "Merge: %s" ).toUtf8(), resultDocument->getFilename() ) ); + tabs.front().second->setTabSelected( tabs.front().first ); + } + } +} + +void GitPlugin::openConflictResolver( const std::string& file ) { + const std::string repository = mGit->repoPath( file ); + std::string absolutePath = mGit->getProjectPath() + file; + std::string relativePath = absolutePath; + FileSystem::filePathRemoveBasePath( repository, relativePath ); + const Uint64 generation = ++mConflictGeneration; + auto& session = mConflictSessions[repository]; + if ( !session ) + session = std::make_unique(); + session->repoPath = repository; + session->generation = generation; + mActiveConflictRepo = repository; + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + const std::string stateErrorMessage = + i18n( "git_conflict_state_failed", "Unable to read Git's conflict state." ).toUtf8(); + runAsyncTask( [git = std::move( git ), lifetime, repository, + relativePath = std::move( relativePath ), + absolutePath = std::move( absolutePath ), generation, stateErrorMessage] { + auto state = git->conflictState( repository ); + if ( !state.error.empty() ) { + lifetime.run( [stateErrorMessage]( GitPlugin* plugin ) { + plugin->showMessage( LSPMessageType::Error, stateErrorMessage ); + } ); + return; + } + auto found = std::find_if( + state.files.begin(), state.files.end(), + [&relativePath]( const auto& conflict ) { return conflict.path == relativePath; } ); + if ( found == state.files.end() ) { + lifetime.run( []( GitPlugin* plugin ) { plugin->updateStatus( true ); } ); + return; + } + const size_t currentFile = std::distance( state.files.begin(), found ); + std::vector files; + files.reserve( state.files.size() ); + for ( const auto& conflictFile : state.files ) + files.emplace_back( conflictFile.path ); + Git::ConflictFile conflict = std::move( state.files[currentFile] ); + lifetime.run( [lifetime, conflict = std::move( conflict ), + absolutePath = std::move( absolutePath ), repository, + files = std::move( files ), currentFile, operation = state.operation, + generation]( GitPlugin* plugin ) mutable { + const auto* session = plugin->conflictSession( repository ); + if ( plugin->mShuttingDown || plugin->mActiveConflictRepo != repository || !session || + generation != session->generation ) + return; + if ( !conflict.workingTreeExists ) { + plugin->showMessage( + LSPMessageType::Warning, + plugin + ->i18n( + "git_conflict_missing_result", + "This conflict requires choosing whether to keep or delete the file." ) + .toUtf8() ); + return; + } + auto openView = [plugin, conflict = std::move( conflict ), repository, + files = std::move( files ), currentFile, operation, + generation]( std::shared_ptr document ) mutable { + plugin->loadConflictResolverView( std::move( document ), std::move( conflict ), + std::move( repository ), std::move( files ), + currentFile, operation, generation ); + }; + if ( auto* tab = plugin->mManager->getSplitter()->isDocumentOpen( absolutePath, false, + true ) ) { + openView( tab->getOwnedWidget()->asType()->getDocumentRef() ); + return; + } + auto document = std::make_shared(); + document->loadAsyncFromFile( + absolutePath, plugin->mThreadPool, + [lifetime, document, openView = std::move( openView )]( TextDocument*, + bool success ) mutable { + lifetime.run( [document, openView = std::move( openView ), + success]( GitPlugin* ) mutable { + if ( success ) + openView( std::move( document ) ); + } ); + } ); + } ); + } ); +} + +void GitPlugin::recreateConflict() { + auto* session = activeConflictSession(); + if ( !session || !mConflictView || + !mManager->getSplitter()->ownedWidgetExists( mConflictView ) ) + return; + const MergeInput& input = mConflictView->getInput(); + Git::ConflictFile conflict; + conflict.path = input.path; + const auto toStage = []( const MergeVersion& version, + Uint8 stage ) -> std::optional { + if ( !version.present ) + return std::nullopt; + return Git::ConflictStage{ version.objectId, {}, version.mode, stage }; + }; + conflict.base = toStage( input.base, 1 ); + conflict.stage2 = toStage( input.stage2, 2 ); + conflict.stage3 = toStage( input.stage3, 3 ); + const std::string repo = session->repoPath; + const std::string path = conflict.path; + const Uint64 generation = session->generation; + auto* view = mConflictView; + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, repo, path, conflict = std::move( conflict ), + generation, view]() mutable { + auto result = git->restoreConflictStages( conflict, repo ); + auto state = git->conflictState( repo, false ); + lifetime.run( [result = std::move( result ), state = std::move( state ), repo, path, + generation, view]( GitPlugin* plugin ) mutable { + auto* session = plugin->conflictSession( repo ); + if ( plugin->mShuttingDown || plugin->mActiveConflictRepo != repo || !session || + generation != session->generation || plugin->mConflictView != view || + !plugin->mManager->getSplitter()->ownedWidgetExists( view ) ) + return; + auto restored = std::find_if( + state.files.begin(), state.files.end(), + [&path]( const Git::ConflictFile& file ) { return file.path == path; } ); + if ( result.fail() || restored == state.files.end() ) { + plugin->showMessage( + LSPMessageType::Error, + result.result.empty() + ? plugin + ->i18n( "git_conflict_recreate_failed", + "Unable to restore the conflict in Git's index." ) + .toUtf8() + : result.result ); + return; + } + session->files.clear(); + session->files.reserve( state.files.size() ); + for ( const auto& file : state.files ) + session->files.emplace_back( file.path ); + session->currentFile = + static_cast( std::distance( state.files.begin(), restored ) ); + session->operation = state.operation; + view->recreateConflict(); + if ( !view->getResultEditor()->getDocument().save() ) { + plugin->showMessage( LSPMessageType::Warning, + plugin + ->i18n( "git_conflict_recreate_save_failed", + "The conflict was restored in Git's index, but " + "its marker text could not " + "be saved to disk." ) + .toUtf8() ); + } + plugin->updateStatus( true ); + } ); + } ); +} + +void GitPlugin::saveAndStageConflict() { + auto* session = activeConflictSession(); + if ( !session || !mConflictView || session->currentFile >= session->files.size() ) + return; + auto& document = mConflictView->getResultEditor()->getDocument(); + if ( !document.save() ) { + showMessage( + LSPMessageType::Error, + i18n( "git_conflict_save_failed", "Unable to save the conflict result." ).toUtf8() ); + return; + } + if ( mConflictView->hasUnresolvedMarkerBlocks() ) + showMessage( LSPMessageType::Warning, + i18n( "git_conflict_markers_remain", + "The saved result still contains recognizable conflict markers." ) + .toUtf8() ); + const std::string repo = session->repoPath; + const std::string path = session->files[session->currentFile]; + const Uint64 generation = session->generation; + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, repo, path, generation] { + auto result = git->resolveConflict( path, false, repo ); + auto state = git->conflictState( repo ); + lifetime.run( [result = std::move( result ), state = std::move( state ), repo, path, + generation]( GitPlugin* plugin ) mutable { + auto* session = plugin->conflictSession( repo ); + if ( plugin->mShuttingDown || !session || generation != session->generation ) + return; + if ( !state.error.empty() ) { + plugin->showMessage( LSPMessageType::Error, + plugin + ->i18n( "git_conflict_state_failed", + "Unable to read Git's conflict state." ) + .toUtf8() ); + return; + } + const bool unresolved = + std::any_of( state.files.begin(), state.files.end(), + [&path]( const auto& conflict ) { return conflict.path == path; } ); + if ( result.fail() || unresolved ) { + plugin->showMessage( LSPMessageType::Error, + result.result.empty() + ? plugin + ->i18n( "git_conflict_still_unmerged", + "Git still reports this path as unmerged." ) + .toUtf8() + : result.result ); + return; + } + session->files.clear(); + session->files.reserve( state.files.size() ); + for ( const auto& conflict : state.files ) + session->files.emplace_back( conflict.path ); + session->currentFile = 0; + session->operation = state.operation; + session->generation = ++plugin->mConflictGeneration; + if ( !state.files.empty() ) { + std::string next = session->repoPath + state.files.front().path; + FileSystem::filePathRemoveBasePath( plugin->mGit->getProjectPath(), next ); + plugin->openConflictResolver( next ); + } + plugin->updateStatus( true ); + } ); + } ); +} + +void GitPlugin::openAdjacentConflict( bool next ) { + auto* session = activeConflictSession(); + if ( !session || session->files.empty() ) + return; + const size_t count = session->files.size(); + const size_t index = + next ? ( session->currentFile + 1 ) % count : ( session->currentFile + count - 1 ) % count; + std::string file = session->repoPath + session->files[index]; + FileSystem::filePathRemoveBasePath( mGit->getProjectPath(), file ); + openConflictResolver( file ); +} + +void GitPlugin::acceptConflictSide( const std::string& file, bool stage2 ) { + const std::string repository = mGit->repoPath( file ); + std::string path = mGit->getProjectPath() + file; + FileSystem::filePathRemoveBasePath( repository, path ); + const std::string noLongerUnmerged = + i18n( "git_conflict_no_longer_unmerged", "Git no longer reports this path as unmerged." ) + .toUtf8(); + const std::string stateErrorMessage = + i18n( "git_conflict_state_failed", "Unable to read Git's conflict state." ).toUtf8(); + mLoader->setVisible( true ); + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, repository, path, stage2, noLongerUnmerged, + stateErrorMessage] { + auto state = git->conflictState( repository ); + auto found = + std::find_if( state.files.begin(), state.files.end(), + [&path]( const auto& conflict ) { return conflict.path == path; } ); + Git::Result result; + if ( !state.error.empty() ) { + result.returnCode = EXIT_FAILURE; + result.result = stateErrorMessage; + } else if ( found == state.files.end() ) { + result.returnCode = EXIT_FAILURE; + result.result = noLongerUnmerged; + } else { + const bool present = stage2 ? found->stage2.has_value() : found->stage3.has_value(); + result = git->acceptConflictStage( path, stage2, present, repository ); + } + lifetime.run( [result = std::move( result )]( GitPlugin* plugin ) mutable { + plugin->mLoader->setVisible( false ); + if ( plugin->mShuttingDown ) + return; + if ( result.fail() ) + plugin->showMessage( LSPMessageType::Warning, result.result ); + plugin->updateStatus( true ); + } ); + } ); +} + +void GitPlugin::continueConflictOperation() { + auto* session = activeConflictSession(); + if ( !session ) + return; + const std::string repo = session->repoPath; + const auto operation = session->operation; + const Uint64 generation = session->generation; + const std::string unresolvedMessage = + i18n( "git_resolve_before_continue", "Resolve and stage all conflicts before continuing." ) + .toUtf8(); + const std::string stateErrorMessage = + i18n( "git_conflict_state_failed", "Unable to read Git's conflict state." ).toUtf8(); + if ( operation == Git::GitOperation::Merge ) { + mLoader->setVisible( true ); + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, repo, generation, unresolvedMessage, + stateErrorMessage] { + auto state = git->conflictState( repo, false ); + Git::Result message; + if ( !state.error.empty() ) { + message.returnCode = EXIT_FAILURE; + message.result = stateErrorMessage; + } else if ( state.hasConflicts() ) { + message.returnCode = EXIT_FAILURE; + message.result = unresolvedMessage; + } else if ( state.operation != Git::GitOperation::Merge ) { + message.returnCode = EXIT_FAILURE; + } else { + message = git->preparedMergeMessage( repo ); + } + lifetime.run( + [message = std::move( message ), repo, generation]( GitPlugin* plugin ) mutable { + plugin->mLoader->setVisible( false ); + auto* session = plugin->conflictSession( repo ); + if ( plugin->mShuttingDown || !session || generation != session->generation ) + return; + if ( message.fail() ) { + plugin->showMessage( + LSPMessageType::Warning, + message.result.empty() + ? plugin + ->i18n( "git_no_continuable_operation", + "No continuable Git operation is active." ) + .toUtf8() + : message.result ); + return; + } + plugin->mLastCommitMsg = String::fromUtf8( message.result ); + plugin->commit( repo, true ); + } ); + } ); + return; + } + mLoader->setVisible( true ); + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, repo, operation, generation, unresolvedMessage, + stateErrorMessage] { + auto state = git->conflictState( repo ); + Git::Result result; + if ( !state.error.empty() ) { + result.returnCode = EXIT_FAILURE; + result.result = stateErrorMessage; + } else if ( state.hasConflicts() ) { + result.returnCode = EXIT_FAILURE; + result.result = unresolvedMessage; + } else { + result = git->continueOperation( operation, repo ); + } + lifetime.run( [result = std::move( result ), repo, + generation]( GitPlugin* plugin ) mutable { + plugin->mLoader->setVisible( false ); + auto* session = plugin->conflictSession( repo ); + if ( plugin->mShuttingDown || !session || generation != session->generation ) + return; + if ( result.fail() ) { + plugin->showMessage( LSPMessageType::Warning, + result.result.empty() + ? plugin + ->i18n( "git_no_continuable_operation", + "No continuable Git operation is active." ) + .toUtf8() + : result.result ); + return; + } + ++plugin->mConflictGeneration; + plugin->mConflictSessions.erase( repo ); + if ( plugin->mActiveConflictRepo == repo ) { + plugin->mActiveConflictRepo.clear(); + plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( + "git_conflict_resolver" ); + } + plugin->updateBranches(); + plugin->updateStatus( true ); + } ); + } ); +} + +void GitPlugin::abortConflictOperation() { + if ( !activeConflictSession() ) + return; + auto* message = + UIMessageBox::New( UIMessageBox::OK_CANCEL, i18n( "git_confirm_abort_operation", + "Abort the current Git operation?" ) ); + message->setTitle( i18n( "git_confirm", "Confirm" ) ); + message->on( Event::OnConfirm, [this, message]( const Event* ) { + message->closeWindow(); + auto* session = activeConflictSession(); + if ( !session ) + return; + const std::string repo = session->repoPath; + const auto operation = session->operation; + const Uint64 generation = session->generation; + mLoader->setVisible( true ); + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, repo, operation, generation] { + auto result = git->abortOperation( operation, repo ); + lifetime.run( [result = std::move( result ), repo, + generation]( GitPlugin* plugin ) mutable { + plugin->mLoader->setVisible( false ); + auto* session = plugin->conflictSession( repo ); + if ( plugin->mShuttingDown || !session || generation != session->generation ) + return; + if ( result.fail() ) { + plugin->showMessage( LSPMessageType::Warning, + result.result.empty() + ? plugin + ->i18n( "git_no_abortable_operation", + "No abortable Git operation is active." ) + .toUtf8() + : result.result ); + return; + } + ++plugin->mConflictGeneration; + plugin->mConflictSessions.erase( repo ); + if ( plugin->mActiveConflictRepo == repo ) { + plugin->mActiveConflictRepo.clear(); + plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( + "git_conflict_resolver" ); + } + plugin->updateBranches(); + plugin->updateStatus( true ); + } ); + } ); + } ); + message->center(); + message->showWhenReady(); +} + void GitPlugin::diff( const Git::DiffMode mode, const std::string& repoPath ) { mThreadPool->run( [this, mode, repoPath] { auto res = mGit->diff( mode, repoPath ); @@ -1610,6 +2375,13 @@ void GitPlugin::buildSidePanelTab() { + + + + + + + @@ -1649,12 +2421,20 @@ void GitPlugin::buildSidePanelTab() { mTabContents->bind( "git_status_tree", mStatusTree ); mTabContents->bind( "git_content", mGitContentView ); mTabContents->bind( "git_no_content", mGitNoContentView ); + mTabContents->bind( "git_conflict_state", mConflictStateBar ); + mTabContents->bind( "git_conflict_state_text", mConflictStateText ); mTabContents->bind( "git_panel_loader", mLoader ); mTabContents->bind( "git_repo", mRepoDropDown ); mTabContents->find( "branch_pull" )->onClick( [this]( auto ) { pull( repoSelected() ); } ); mTabContents->find( "branch_push" )->onClick( [this]( auto ) { push( repoSelected() ); } ); mTabContents->find( "branch_add" )->onClick( [this]( auto ) { branchCreate(); } ); + mTabContents->find( "git_conflict_continue" )->onClick( [this]( auto ) { + continueConflictOperation(); + } ); + mTabContents->find( "git_conflict_abort" )->onClick( [this]( auto ) { + abortConflictOperation(); + } ); mBranchesTree->setAutoExpandOnSingleColumn( true ); mBranchesTree->setHeadersVisible( false ); @@ -1717,11 +2497,12 @@ void GitPlugin::buildSidePanelTab() { mStackMap.resize( 2 ); mStackMap[0] = mTabContents->find( "git_branches" ); mStackMap[1] = mTabContents->find( "git_status" ); - listBox->setSelected( 0 ); mPanelSwicher->on( Event::OnItemSelected, [this, listBox]( const Event* ) { mStackWidget->setActiveWidget( mStackMap[listBox->getItemSelectedIndex()] ); } ); + listBox->setSelected( 0 ); + mStackWidget->setActiveWidget( mStackMap[0] ); mStatusTree->setAutoColumnsWidth( true ); mStatusTree->setHeadersVisible( false ); @@ -1763,7 +2544,10 @@ void GitPlugin::buildSidePanelTab() { break; } case ModelEventType::Open: { - diff( file->file, file->report.type ); + if ( file->report.type == Git::GitStatusType::Unmerged ) + openConflictResolver( file->file ); + else + diff( file->file, file->report.type ); break; } default: @@ -1915,6 +2699,7 @@ void GitPlugin::buildSidePanelTab() { mRepoSelected = repo.first; } updateBranches( true ); + updateStatus( true ); break; } } @@ -2005,9 +2790,28 @@ void GitPlugin::openFileStatusMenu( std::vector files ) { const bool multiple = files.size() > 1; bool hasStaged = false; bool hasUnstaged = false; + bool hasUnmerged = false; for ( const auto& file : files ) { hasStaged |= file.report.type == Git::GitStatusType::Staged; hasUnstaged |= file.report.type != Git::GitStatusType::Staged; + hasUnmerged |= file.report.type == Git::GitStatusType::Unmerged; + } + if ( hasUnmerged && !multiple ) { + menuAdd( menu, "git-resolve-conflict", i18n( "git_resolve_conflict", "Resolve Conflict" ), + "diff-modified" ); + menuAdd( menu, "git-accept-ours", i18n( "git_accept_ours", "Accept Ours" ) ); + menuAdd( menu, "git-accept-theirs", i18n( "git_accept_theirs", "Accept Theirs" ) ); + menu->on( Event::OnItemClicked, [this, file = files.front()]( const Event* event ) { + const std::string id = event->getNode()->asType()->getId(); + if ( id == "git-resolve-conflict" ) + openConflictResolver( file.file ); + else if ( id == "git-accept-ours" ) + acceptConflictSide( file.file, true ); + else if ( id == "git-accept-theirs" ) + acceptConflictSide( file.file, false ); + } ); + menu->showOverMouseCursor(); + return; } menuAdd( menu, "git-open-file", @@ -2075,23 +2879,49 @@ void GitPlugin::runAsync( std::function fn, bool _updateStatus, b if ( !mGit ) return; mLoader->setVisible( true ); - mThreadPool->run( [this, fn, _updateStatus, _updateBranches, displaySuccessMsg, - updateBranchesOnError, updateStatusOnError] { + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [lifetime, fn, _updateStatus, _updateBranches, displaySuccessMsg, + updateBranchesOnError, updateStatusOnError] { auto res = fn(); - mLoader->runOnMainThread( [this] { mLoader->setVisible( false ); } ); - if ( res.fail() || displaySuccessMsg ) { - showMessage( LSPMessageType::Warning, res.result ); - if ( _updateBranches && updateBranchesOnError ) - updateBranches(); - if ( _updateStatus && updateStatusOnError ) - updateStatus( true ); - return; - } - if ( _updateBranches ) - updateBranches(); + lifetime.run( [res = std::move( res ), _updateStatus, _updateBranches, displaySuccessMsg, + updateBranchesOnError, updateStatusOnError]( GitPlugin* plugin ) mutable { + plugin->mLoader->setVisible( false ); + if ( res.fail() || displaySuccessMsg ) { + plugin->showMessage( LSPMessageType::Warning, res.result ); + if ( _updateBranches && updateBranchesOnError ) + plugin->updateBranches(); + if ( _updateStatus && updateStatusOnError ) + plugin->updateStatus( true ); + return; + } + if ( _updateBranches ) + plugin->updateBranches(); + if ( _updateStatus ) + plugin->updateStatus( true ); + } ); + } ); +} - if ( _updateStatus ) - updateStatus( true ); +void GitPlugin::runMergeLikeAsync( std::function fn, + const std::string& repoPath ) { + if ( !mGit ) + return; + mLoader->setVisible( true ); + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, fn = std::move( fn ), repoPath] { + auto result = fn( *git ); + Git::ConflictState conflicts; + if ( result.fail() ) + conflicts = git->conflictState( repoPath ); + lifetime.run( [result = std::move( result ), + conflicts = std::move( conflicts )]( GitPlugin* plugin ) mutable { + plugin->mLoader->setVisible( false ); + plugin->updateBranches(); + plugin->updateStatus( true ); + if ( result.fail() && !conflicts.hasConflicts() ) + plugin->showMessage( LSPMessageType::Warning, result.result ); + } ); } ); } diff --git a/src/tools/ecode/plugins/git/gitplugin.hpp b/src/tools/ecode/plugins/git/gitplugin.hpp index 9dac990c7..845986862 100644 --- a/src/tools/ecode/plugins/git/gitplugin.hpp +++ b/src/tools/ecode/plugins/git/gitplugin.hpp @@ -4,12 +4,16 @@ #include "../plugin.hpp" #include "../pluginmanager.hpp" #include "git.hpp" +#include +#include #include +#include #include #include using namespace EE::UI::Models; using namespace EE::UI; +using namespace EE::Scene; namespace EE::UI { class UITreeView; @@ -17,6 +21,7 @@ class UIDropDownList; class UIStackWidget; class UIListBoxItem; class UIMenu; +class UITextView; } // namespace EE::UI namespace ecode { @@ -84,7 +89,9 @@ class GitPlugin : public PluginBase { bool isSilent() const { return mSilent; } protected: - std::unique_ptr mGit; + MainThreadLifetime mLifetime; + + std::shared_ptr mGit; std::unordered_map mGitBranches; Git::Status mGitStatus; std::vector> mRepos; @@ -121,9 +128,13 @@ class GitPlugin : public PluginBase { std::vector mStackMap; UIWidget* mGitContentView{ nullptr }; UIWidget* mGitNoContentView{ nullptr }; + UIWidget* mConflictStateBar{ nullptr }; + UITextView* mConflictStateText{ nullptr }; UILoader* mLoader{ nullptr }; std::atomic mRunningUpdateBranches{ 0 }; std::atomic mRunningUpdateStatus{ 0 }; + std::atomic mPendingForcedStatusUpdate{ false }; + std::shared_ptr> mRunningAsyncTasks{ std::make_shared>( 0 ) }; Clock mLastBranchesUpdate; Mutex mGitBranchMutex; Mutex mGitStatusMutex; @@ -131,6 +142,18 @@ class GitPlugin : public PluginBase { Mutex mRepoMutex; Mutex mReposMutex; String mLastCommitMsg; + struct GitConflictSession { + std::string repoPath; + std::vector files; + size_t currentFile{ 0 }; + Uint64 generation{ 0 }; + Git::GitOperation operation{ Git::GitOperation::None }; + }; + UnorderedMap> mConflictSessions; + std::string mActiveConflictRepo; + Tools::UIMergeView* mConflictView{ nullptr }; + EE::Scene::EventConnection mConflictViewCloseConnection; + Uint64 mConflictGeneration{ 0 }; Uint32 mRepositionCbId{ 0 }; struct CustomTokenizer { @@ -176,7 +199,7 @@ class GitPlugin : public PluginBase { void branchCreate(); - void commit( const std::string& repoPath ); + void commit( const std::string& repoPath, bool mergeCommit = false ); void stage( const std::vector& files ); @@ -198,6 +221,27 @@ class GitPlugin : public PluginBase { void openFile( const std::string& file ); + void openConflictResolver( const std::string& file ); + + void loadConflictResolverView( std::shared_ptr resultDocument, + Git::ConflictFile conflict, std::string repository, + std::vector files, size_t currentFile, + Git::GitOperation operation, Uint64 generation ); + + void recreateConflict(); + + void saveAndStageConflict(); + + void openAdjacentConflict( bool next ); + + void continueConflictOperation(); + + void abortConflictOperation(); + + void acceptConflictSide( const std::string& file, bool stage2 ); + + void runAsyncTask( std::function task ); + void updateStatus( bool force = false ); void updateStatusBarSync(); @@ -225,6 +269,12 @@ class GitPlugin : public PluginBase { void runAsync( std::function fn, bool updateStatus, bool updateBranches, bool displaySuccessMsg = false, bool updateBranchesOnError = false, bool updateStatusOnError = false ); + void runMergeLikeAsync( std::function fn, const std::string& repoPath ); + + GitConflictSession* conflictSession( const std::string& repoPath ); + + GitConflictSession* activeConflictSession(); + bool updateConflictSessions( UnorderedMap& conflictStates ); void menuAdd( UIMenu* menu, const std::string& cmd, const std::string& text, const std::string& icon = "", diff --git a/src/tools/ecode/settingspanel.cpp b/src/tools/ecode/settingspanel.cpp index e376a4339..0caf9eb42 100644 --- a/src/tools/ecode/settingspanel.cpp +++ b/src/tools/ecode/settingspanel.cpp @@ -918,9 +918,9 @@ void SettingsPanel::addUserSettings( PanelState& panel ) { auto mode = selected == 0 ? UIDiffView::ViewMode::Unified : UIDiffView::ViewMode::SideBySide; mApp->getConfig().editor.diffViewMode = mode; - mApp->getSplitter()->forEachWidgetType( UI_TYPE_DIFF_VIEW, [mode]( UIWidget* widget ) { - widget->asType()->setViewMode( mode ); - } ); + for ( auto* diffView : + mApp->getUISceneNode()->findAllByType( UI_TYPE_DIFF_VIEW ) ) + diffView->setViewMode( mode ); } ); auto addEditorBool = [this, &panel]( std::string id, const char* nameKey, const char* name, const char* descriptionKey, const char* description,