Add integrated Git conflict resolution workflow

Introduce a three-pane UIMergeView with an editable shared result
document, conflict navigation and acceptance actions, live diff
highlighting, synchronized scrolling, and contextual pane labels.

Read conflict stages directly from Git's index, detect paused merge
operations, restore conflict sessions, and support resolving, staging,
recreating, continuing, committing, and aborting operations.

Add repository-aware conflict state handling, safe asynchronous
lifetimes, merge operation UI, configurable key modifiers, i18n
strings, and focused merge-view and Git conflict tests.
This commit is contained in:
Martín Lucas Golini
2026-08-27 22:18:58 -03:00
parent 7d322a035f
commit 66d22ea5bb
29 changed files with 2604 additions and 107 deletions
+91
View File
@@ -0,0 +1,91 @@
#ifndef EE_SCENE_MAINTHREADLIFETIME_HPP
#define EE_SCENE_MAINTHREADLIFETIME_HPP
#include <eepp/scene/node.hpp>
#include <functional>
#include <memory>
#include <mutex>
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 <typename T> 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<void( T* )> callback ) const {
auto state = mState.lock();
if ( !state )
return;
std::lock_guard<std::mutex> lock( state->mutex );
if ( !state->object || !state->dispatcher )
return;
state->dispatcher->runOnMainThread(
[state = std::weak_ptr<State>{ state }, callback = std::move( callback )] {
auto lockedState = state.lock();
if ( !lockedState )
return;
T* object;
{
std::lock_guard<std::mutex> lock( lockedState->mutex );
object = lockedState->object;
}
if ( object )
callback( object );
} );
}
explicit operator bool() const { return !mState.expired(); }
private:
friend class MainThreadLifetime<T>;
explicit WeakHandle( const std::shared_ptr<State>& state ) : mState( state ) {}
std::weak_ptr<State> mState;
};
MainThreadLifetime( T* object, Node* dispatcher ) :
mState( std::make_shared<State>( 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<std::mutex> lock( mState->mutex );
if ( mState->object )
mState->dispatcher = dispatcher;
}
void invalidate() {
std::lock_guard<std::mutex> lock( mState->mutex );
mState->object = nullptr;
mState->dispatcher = nullptr;
}
private:
std::shared_ptr<State> mState;
};
} // namespace EE::Scene
#endif
+9
View File
@@ -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 );
+2 -1
View File
@@ -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<std::string> splitDiff( const std::string& multiFileDiff );
+110
View File
@@ -0,0 +1,110 @@
#ifndef EE_UI_TOOLS_UIMERGEVIEW_HPP
#define EE_UI_TOOLS_UIMERGEVIEW_HPP
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <eepp/ui/uipushbutton.hpp>
#include <eepp/ui/uistacklayout.hpp>
#include <eepp/ui/widgetcommandexecuter.hpp>
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<Doc::TextDocument> 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<ConflictBlock> 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<ConflictBlock> mBlocks;
String mOriginalResultText;
CommandCallback mRecreateConflictCallback;
std::unique_ptr<UIMergeEditorPlugin> mLeftPlugin;
std::unique_ptr<UIMergeEditorPlugin> mResultPlugin;
std::unique_ptr<UIMergeEditorPlugin> 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
+3
View File
@@ -3,6 +3,7 @@
#include <eepp/core/small_vector.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/scene/mainthreadlifetime.hpp>
#include <eepp/ui/doc/documentview.hpp>
#include <eepp/ui/doc/syntaxcolorscheme.hpp>
#include <eepp/ui/doc/syntaxhighlighter.hpp>
@@ -15,6 +16,7 @@
#include <unordered_set>
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<Doc::TextDocument> mDoc;
MainThreadLifetime<UICodeEditor> mAsyncLifetime;
bool mDirtyEditor{ false };
bool mDirtyScroll{ false };
bool mCursorVisible{ false };
+1
View File
@@ -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,