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,