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