From baaa0a62c0cd093751364670f8fe305f38594ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Fri, 28 Aug 2026 14:43:05 -0300 Subject: [PATCH] Add a work-in-progress Git History view to the source-control panel. Display bounded first-parent commit history with asynchronous loading, independent pagination, and lazily expandable merge commits. Propagate inherited exclusions through nested merges to avoid repeating already represented history. Add custom compact history rows, loading/error/retry states, keyboard navigation, repository and history-generation guards, and lifecycle invalidation after history-changing operations. Add a commit details tab with full commit metadata and message, changed-line totals, GitHub links when available, and a multi-file diff viewer with global collapse and unified/split controls. Extend UIDiffView with multi-file view controls and register the required Codicons. The Git History interface and visual design remain a work in progress. Fix caret positioning inside shaped ligature clusters. Add explicit closest-glyph and interpolated ligature caret-positioning modes while keeping interpolation as the default for general text geometry. Use the original closest-glyph behavior for caret and IME positioning in UICodeEditor, UITextEdit, UITextInput, and UIConsole, preventing cursor positions from being synthesized inside ligature glyphs. Add regression coverage for both positioning modes. Optimize SyntaxTokenizer by returning tokenizer results in a SmallVector, avoiding heap allocations in common cases. --- bin/assets/i18n/de.xml | 11 + bin/assets/i18n/en.xml | 11 + bin/assets/i18n/fr.xml | 11 + bin/assets/i18n/zh.xml | 11 + include/eepp/graphics/text.hpp | 23 +- include/eepp/ui/abstract/uiabstractview.hpp | 2 +- include/eepp/ui/doc/syntaxhighlighter.hpp | 10 +- include/eepp/ui/doc/syntaxtokenizer.hpp | 6 +- include/eepp/ui/tools/uidiffview.hpp | 10 + include/eepp/ui/uicodeeditor.hpp | 10 +- src/eepp/graphics/text.cpp | 34 +- src/eepp/ui/abstract/uiabstracttableview.cpp | 4 +- src/eepp/ui/abstract/uiabstractview.cpp | 14 + src/eepp/ui/doc/syntaxhighlighter.cpp | 12 +- src/eepp/ui/doc/syntaxtokenizer.cpp | 14 +- src/eepp/ui/iconmanager.cpp | 4 + src/eepp/ui/tools/uidiffview.cpp | 27 + src/eepp/ui/uicodeeditor.cpp | 14 +- src/eepp/ui/uiconsole.cpp | 11 +- src/eepp/ui/uitextedit.cpp | 3 +- src/eepp/ui/uitextinput.cpp | 4 +- src/eepp/ui/uitreeview.cpp | 4 + src/tests/unit_tests/fontrendering_tests.cpp | 5 + src/tests/unit_tests/gitconflict_tests.cpp | 205 +++++++ .../unit_tests/modeloperations_tests.cpp | 51 ++ src/tests/unit_tests/uidiffview_tests.cpp | 13 + src/tools/ecode/plugins/git/git.cpp | 285 ++++++++++ src/tools/ecode/plugins/git/git.hpp | 48 ++ .../ecode/plugins/git/githistorymodel.cpp | 294 ++++++++++ .../ecode/plugins/git/githistorymodel.hpp | 104 ++++ .../ecode/plugins/git/githistorytreeview.cpp | 61 +++ .../ecode/plugins/git/githistorytreeview.hpp | 40 ++ src/tools/ecode/plugins/git/gitplugin.cpp | 512 +++++++++++++++++- src/tools/ecode/plugins/git/gitplugin.hpp | 50 +- 34 files changed, 1839 insertions(+), 79 deletions(-) create mode 100644 src/tools/ecode/plugins/git/githistorymodel.cpp create mode 100644 src/tools/ecode/plugins/git/githistorymodel.hpp create mode 100644 src/tools/ecode/plugins/git/githistorytreeview.cpp create mode 100644 src/tools/ecode/plugins/git/githistorytreeview.hpp diff --git a/bin/assets/i18n/de.xml b/bin/assets/i18n/de.xml index 86d2b75b4..b7ffded0e 100644 --- a/bin/assets/i18n/de.xml +++ b/bin/assets/i18n/de.xml @@ -1254,4 +1254,15 @@ Für sichtbare Änderung ecode neu starten. Choose the initial layout used when opening text diffs. Side by Side Unified + Verlauf + Betreff + Autor + Datum + Hash + Wird geladen... + Aktualisieren + Ältere Commits laden + Noch keine Commits + Git-Verlauf konnte nicht geladen werden + Wiederholen diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index af76c40e6..14f519b2b 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -1239,4 +1239,15 @@ Restart ecode to see the changes. Choose the initial layout used when opening text diffs. Side by Side Unified + History + Subject + Author + Date + Hash + Loading... + Refresh + Load older commits + No commits yet + Could not load Git history + Retry diff --git a/bin/assets/i18n/fr.xml b/bin/assets/i18n/fr.xml index cbd12d570..ba9c2c73d 100644 --- a/bin/assets/i18n/fr.xml +++ b/bin/assets/i18n/fr.xml @@ -1238,4 +1238,15 @@ Redémarrer ecode pour voir les changements. Choose the initial layout used when opening text diffs. Side by Side Unified + Historique + Sujet + Auteur + Date + Hash + Chargement... + Actualiser + Charger les commits précédents + Aucun commit pour le moment + Impossible de charger l’historique Git + Réessayer diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index 93af77ac1..c0aacb124 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -1043,4 +1043,15 @@ file in the directory tree. Choose the initial layout used when opening text diffs. Side by Side Unified + 历史 + 主题 + 作者 + 日期 + 哈希 + 正在加载... + 刷新 + 加载更早的提交 + 尚无提交 + 无法加载 Git 历史记录 + 重试 diff --git a/include/eepp/graphics/text.hpp b/include/eepp/graphics/text.hpp index dfbb9c102..4d3505b59 100644 --- a/include/eepp/graphics/text.hpp +++ b/include/eepp/graphics/text.hpp @@ -41,6 +41,8 @@ class EE_API Text { Shadow = 1 << 4 ///< Draw a shadow below the text }; + enum class LigatureCaretMode : Uint8 { ClosestGlyph, Interpolate }; + static inline bool canSkipShaping( Uint32 textDrawHints ) { return Text::TextShaperOptimizations && !( textDrawHints & TextHints::OpenTypeFeatures ) && ( textDrawHints & ( TextHints::AllLatin1 | TextHints::AllAscii ) ) != 0; @@ -134,7 +136,8 @@ class EE_API Text { std::size_t index, Font* font, const Uint32& fontSize, const String& string, const Uint32& style, const Uint32& tabWidth = 4, const Float& outlineThickness = 0.f, std::optional tabOffset = {}, bool allowNewLine = true, Uint32 textHints = 0, - TextDirection direction = TextDirection::Unspecified, const Vector2f& initialOffset = {} ); + TextDirection direction = TextDirection::Unspecified, const Vector2f& initialOffset = {}, + LigatureCaretMode ligatureCaretMode = LigatureCaretMode::Interpolate ); static std::size_t findLastCharPosWithinLength( Font* font, const Uint32& fontSize, const String& string, @@ -239,7 +242,9 @@ class EE_API Text { Float getOutlineThickness() const; - Vector2f findCharacterPos( std::size_t index ) const; + Vector2f + findCharacterPos( std::size_t index, + LigatureCaretMode ligatureCaretMode = LigatureCaretMode::Interpolate ) const; /** @return The current text local bounds. */ Rectf getLocalBounds(); @@ -497,13 +502,13 @@ class EE_API Text { /** Ensures visual line info is up to date. */ void ensureVisualLinesUpdate(); - static Vector2f findCharacterPos( std::size_t index, Font* font, const Uint32& fontSize, - const String& string, const Uint32& style, - const Uint32& tabWidth, const Float& outlineThickness, - std::optional tabOffset, bool allowNewLine, - Uint32 textHints, TextDirection direction, - LineWrapMode lineWrapMode, Float maxWrapWidth, - const Vector2f& initialOffset = {} ); + static Vector2f + findCharacterPos( std::size_t index, Font* font, const Uint32& fontSize, const String& string, + const Uint32& style, const Uint32& tabWidth, const Float& outlineThickness, + std::optional tabOffset, bool allowNewLine, Uint32 textHints, + TextDirection direction, LineWrapMode lineWrapMode, Float maxWrapWidth, + const Vector2f& initialOffset = {}, + LigatureCaretMode ligatureCaretMode = LigatureCaretMode::Interpolate ); static Int32 findCharacterFromPos( const Vector2i& pos, bool returnNearest, Font* font, const Uint32& fontSize, const String& string, diff --git a/include/eepp/ui/abstract/uiabstractview.hpp b/include/eepp/ui/abstract/uiabstractview.hpp index 4cf81e5aa..6849a2714 100644 --- a/include/eepp/ui/abstract/uiabstractview.hpp +++ b/include/eepp/ui/abstract/uiabstractview.hpp @@ -137,7 +137,7 @@ class EE_API UIAbstractView : public UIScrollableWidget { virtual void onModelSelectionChange(); - virtual void onModelIndexDeleted( const void* ) {} + virtual void onModelIndexDeleted( const void* internalData ); void modelUpdate( unsigned flags ); diff --git a/include/eepp/ui/doc/syntaxhighlighter.hpp b/include/eepp/ui/doc/syntaxhighlighter.hpp index 73806fbe9..84bdae026 100644 --- a/include/eepp/ui/doc/syntaxhighlighter.hpp +++ b/include/eepp/ui/doc/syntaxhighlighter.hpp @@ -10,13 +10,13 @@ namespace EE { namespace UI { namespace Doc { struct EE_API TokenizedLine { SyntaxState initState; String::HashType hash{ 0 }; - std::vector tokens; + SmallVector tokens; SyntaxState state; Uint64 signature{ 0 }; void updateSignature(); - static Uint64 calcSignature( const std::vector& tokens ); + static Uint64 calcSignature( const SmallVector& tokens ); }; class EE_API SyntaxHighlighter { @@ -31,10 +31,10 @@ class EE_API SyntaxHighlighter { void invalidate( Int64 lineIndex ); - const std::vector& getLine( const size_t& index, - bool mustTokenize = true ); + const SmallVector& getLine( const size_t& index, + bool mustTokenize = true ); - void copyLineToBuffer( const size_t& index, std::vector& buffer, + void copyLineToBuffer( const size_t& index, SmallVector& buffer, bool mustTokenize = true ); Int64 getFirstInvalidLine() const; diff --git a/include/eepp/ui/doc/syntaxtokenizer.hpp b/include/eepp/ui/doc/syntaxtokenizer.hpp index 2f12b3bf9..2271ca276 100644 --- a/include/eepp/ui/doc/syntaxtokenizer.hpp +++ b/include/eepp/ui/doc/syntaxtokenizer.hpp @@ -83,16 +83,16 @@ struct SyntaxState { class EE_API SyntaxTokenizer { public: - static std::pair, SyntaxState> + static std::pair, SyntaxState> tokenize( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state, const size_t& startIndex = 0, bool skipSubSyntaxSeparator = false ); - static std::pair, SyntaxState> + static std::pair, SyntaxState> tokenizePosition( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state, const size_t& startIndex = 0, bool skipSubSyntaxSeparator = false ); - static std::pair, SyntaxState> + static std::pair, SyntaxState> tokenizeComplete( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state, const size_t& startIndex = 0, bool skipSubSyntaxSeparator = false ); diff --git a/include/eepp/ui/tools/uidiffview.hpp b/include/eepp/ui/tools/uidiffview.hpp index 4c4491d71..ef50cfb76 100644 --- a/include/eepp/ui/tools/uidiffview.hpp +++ b/include/eepp/ui/tools/uidiffview.hpp @@ -36,6 +36,12 @@ class EE_API UIDiffView : public UIWidget, public WidgetCommandExecuter { static bool isMultiFileDiff( const std::string& diff ); + static std::vector multiFileDiffViews( UIScrollView* multiDiff ); + + static void setMultiFileViewMode( UIScrollView* multiDiff, ViewMode mode ); + + static void setMultiFileCollapsed( UIScrollView* multiDiff, bool collapsed ); + virtual ~UIDiffView(); virtual Uint32 getType() const override; @@ -84,6 +90,9 @@ class EE_API UIDiffView : public UIWidget, public WidgetCommandExecuter { void setHeadersVisible( bool visible ); + void setCollapsed( bool collapsed ); + bool isCollapsed() const { return mCollapsed; } + bool areHeadersVisible() const { return mHeadersVisible; } const String& getFileName() const { return mFileName; } @@ -115,6 +124,7 @@ class EE_API UIDiffView : public UIWidget, public WidgetCommandExecuter { bool mHeadersVisible{ false }; bool mIsImageDiff{ false }; bool mAutoDeleteOldTempImage{ false }; + bool mCollapsed{ false }; std::shared_ptr mSyntaxDef; String mFileName; std::string mImageDiffOldPath; diff --git a/include/eepp/ui/uicodeeditor.hpp b/include/eepp/ui/uicodeeditor.hpp index e1c8bdba6..d7a2f5405 100644 --- a/include/eepp/ui/uicodeeditor.hpp +++ b/include/eepp/ui/uicodeeditor.hpp @@ -559,10 +559,10 @@ class EE_API UICodeEditor : public UIWidget, public TextDocument::Client { void unregisterPlugin( UICodeEditorPlugin* plugin ); - virtual Vector2d getTextPositionOffset( const TextPosition& pos, - std::optional lineHeight = {}, - bool allowVisualLineEnd = false, - bool visualizeNewLine = false ) const; + virtual Vector2d getTextPositionOffset( + const TextPosition& pos, std::optional lineHeight = {}, + bool allowVisualLineEnd = false, bool visualizeNewLine = false, + Text::LigatureCaretMode ligatureCaretMode = Text::LigatureCaretMode::Interpolate ) const; Vector2d getTextPositionOffsetSanitized( TextPosition pos, std::optional lineHeight = {} ) const; @@ -1042,7 +1042,7 @@ class EE_API UICodeEditor : public UIWidget, public TextDocument::Client { String::HashType mTagFoldRange{ 0 }; Uint32 mTabIndentCharacter{ 187 /*'»'*/ }; CharacterAlignment mTabIndentAlignment{ CharacterAlignment::Center }; - std::vector mTokens; + SmallVector mTokens; TextDirection mTextDirection{ TextDirection::LeftToRight }; UICodeEditor( const bool& autoRegisterBaseCommands = true, diff --git a/src/eepp/graphics/text.cpp b/src/eepp/graphics/text.cpp index 16091a548..550afc2ee 100644 --- a/src/eepp/graphics/text.cpp +++ b/src/eepp/graphics/text.cpp @@ -863,7 +863,7 @@ Float Text::getOutlineThickness() const { return mFontStyleConfig.OutlineThickness; } -Vector2f Text::findCharacterPos( std::size_t index ) const { +Vector2f Text::findCharacterPos( std::size_t index, LigatureCaretMode ligatureCaretMode ) const { // Make sure that we have a valid font if ( !mFontStyleConfig.Font || mString.empty() ) return Vector2f(); @@ -873,19 +873,20 @@ Vector2f Text::findCharacterPos( std::size_t index ) const { index = mString.size(); if ( mLineWrapMode == LineWrapMode::NoWrap || mMaxWrapWidth <= 0 ) { - return Text::findCharacterPos( index, mFontStyleConfig.Font, mFontStyleConfig.CharacterSize, - mString, mFontStyleConfig.Style, mTabWidth, - mFontStyleConfig.OutlineThickness, {}, true, mTextHints, - TextDirection::Unspecified, mInitialOffset ); + return Text::findCharacterPos( + index, mFontStyleConfig.Font, mFontStyleConfig.CharacterSize, mString, + mFontStyleConfig.Style, mTabWidth, mFontStyleConfig.OutlineThickness, {}, true, + mTextHints, TextDirection::Unspecified, mInitialOffset, ligatureCaretMode ); } #ifdef EE_TEXT_SHAPER_ENABLED if ( TextShaperEnabled && mFontStyleConfig.Font->getType() == FontType::TTF && !canSkipShaping( mTextHints ) ) { - return Text::findCharacterPos( - index, mFontStyleConfig.Font, mFontStyleConfig.CharacterSize, mString, - mFontStyleConfig.Style, mTabWidth, mFontStyleConfig.OutlineThickness, {}, true, - mTextHints, TextDirection::Unspecified, mLineWrapMode, mMaxWrapWidth, mInitialOffset ); + return Text::findCharacterPos( index, mFontStyleConfig.Font, mFontStyleConfig.CharacterSize, + mString, mFontStyleConfig.Style, mTabWidth, + mFontStyleConfig.OutlineThickness, {}, true, mTextHints, + TextDirection::Unspecified, mLineWrapMode, mMaxWrapWidth, + mInitialOffset, ligatureCaretMode ); } #endif @@ -933,7 +934,8 @@ Vector2f Text::findCharacterPos( std::size_t index ) const { Vector2f pos = Text::findCharacterPos( index - startIdx, mFontStyleConfig.Font, mFontStyleConfig.CharacterSize, strWrapper, mFontStyleConfig.Style, mTabWidth, mFontStyleConfig.OutlineThickness, {}, true, mTextHints, - TextDirection::Unspecified, lineIndex == 0 ? mInitialOffset : Vector2f::Zero ); + TextDirection::Unspecified, lineIndex == 0 ? mInitialOffset : Vector2f::Zero, + ligatureCaretMode ); return Vector2f( pos.x + centerDiffX, y ); } @@ -1184,10 +1186,11 @@ Vector2f Text::findCharacterPos( std::size_t index, Font* font, const Uint32& fo const String& string, const Uint32& style, const Uint32& tabWidth, const Float& outlineThickness, std::optional tabOffset, bool allowNewLine, Uint32 textDrawHints, TextDirection direction, - const Vector2f& initialOffset ) { + const Vector2f& initialOffset, + LigatureCaretMode ligatureCaretMode ) { return findCharacterPos( index, font, fontSize, string, style, tabWidth, outlineThickness, tabOffset, allowNewLine, textDrawHints, direction, - LineWrapMode::NoWrap, 0.f, initialOffset ); + LineWrapMode::NoWrap, 0.f, initialOffset, ligatureCaretMode ); } Vector2f Text::findCharacterPos( std::size_t index, Font* font, const Uint32& fontSize, @@ -1195,7 +1198,8 @@ Vector2f Text::findCharacterPos( std::size_t index, Font* font, const Uint32& fo const Float& outlineThickness, std::optional tabOffset, bool allowNewLine, Uint32 textDrawHints, TextDirection direction, LineWrapMode lineWrapMode, Float maxWrapWidth, - const Vector2f& initialOffset ) { + const Vector2f& initialOffset, + LigatureCaretMode ligatureCaretMode ) { // Make sure that we have a valid font if ( !font ) return Vector2f(); @@ -1268,8 +1272,8 @@ Vector2f Text::findCharacterPos( std::size_t index, Font* font, const Uint32& fo i = j; } } - if ( caretCluster && !hasExactCluster && index < clusterEnd && - string.isGraphemeBoundary( index ) ) { + if ( ligatureCaretMode == LigatureCaretMode::Interpolate && caretCluster && + !hasExactCluster && index < clusterEnd && string.isGraphemeBoundary( index ) ) { std::size_t boundaryCount = 0; std::size_t boundaryIndex = 0; for ( std::size_t boundary = clusterStart + 1; boundary <= clusterEnd; ++boundary ) { diff --git a/src/eepp/ui/abstract/uiabstracttableview.cpp b/src/eepp/ui/abstract/uiabstracttableview.cpp index af1d137d1..2fdebf841 100644 --- a/src/eepp/ui/abstract/uiabstracttableview.cpp +++ b/src/eepp/ui/abstract/uiabstracttableview.cpp @@ -627,7 +627,9 @@ void UIAbstractTableView::updateColumnsWidth() { if ( mAutoExpandOnSingleColumn || mAutoColumnsWidth ) { int col = 0; if ( visibleColumnCount() == 1 && ( col = visibleColumn() ) != -1 ) { - Float width = eemax( getContentSpaceWidth(), getMaxColumnContentWidth( col, true ) ); + Float width = mFitAllColumnsToWidget ? getContentSpaceWidth() + : eemax( getContentSpaceWidth(), + getMaxColumnContentWidth( col, true ) ); bool shouldVScrollBeVisible = shouldVerticalScrollBeVisible(); const bool verticalScrollConsumesWidth = mScrollViewType == ScrollViewType::Outside || mVScroll->getAlpha() != 0.f; diff --git a/src/eepp/ui/abstract/uiabstractview.cpp b/src/eepp/ui/abstract/uiabstractview.cpp index cceef642e..2aeef88ca 100644 --- a/src/eepp/ui/abstract/uiabstractview.cpp +++ b/src/eepp/ui/abstract/uiabstractview.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -143,6 +144,19 @@ void UIAbstractView::modelUpdate( unsigned flags ) { } } +void UIAbstractView::onModelIndexDeleted( const void* internalData ) { + auto indexes = getSelection().indexes(); + auto firstRemoved = + std::remove_if( indexes.begin(), indexes.end(), [internalData]( const ModelIndex& index ) { + return index.internalData() == internalData; + } ); + if ( firstRemoved == indexes.end() ) + return; + indexes.erase( firstRemoved, indexes.end() ); + // Model-driven deletion must not invoke user selection callbacks re-entrantly. + getSelection().set( indexes, false ); +} + void UIAbstractView::onModelUpdate( unsigned flags ) { mPendingUpdateFlags.fetch_or( flags ); if ( !Engine::instance()->isMainThread() ) { diff --git a/src/eepp/ui/doc/syntaxhighlighter.cpp b/src/eepp/ui/doc/syntaxhighlighter.cpp index 97a7f06d2..caf710414 100644 --- a/src/eepp/ui/doc/syntaxhighlighter.cpp +++ b/src/eepp/ui/doc/syntaxhighlighter.cpp @@ -5,7 +5,7 @@ namespace EE { namespace UI { namespace Doc { -Uint64 TokenizedLine::calcSignature( const std::vector& tokens ) { +Uint64 TokenizedLine::calcSignature( const SmallVector& tokens ) { if ( !tokens.empty() ) { return String::hash( reinterpret_cast( tokens.data() ), sizeof( SyntaxTokenPosition ) * tokens.size() ); @@ -149,9 +149,9 @@ void SyntaxHighlighter::tokenizeAsync( std::shared_ptr pool, } ); } -const std::vector& SyntaxHighlighter::getLine( const size_t& index, - bool mustTokenize ) { - static std::vector noHighlightVector = { +const SmallVector& SyntaxHighlighter::getLine( const size_t& index, + bool mustTokenize ) { + static SmallVector noHighlightVector = { { SyntaxStyleTypes::Normal, 0, 0 } }; if ( mDoc->getSyntaxDefinition().getPatterns().empty() ) { noHighlightVector[0].len = mDoc->getLineLength( index ); @@ -192,9 +192,9 @@ const std::vector& SyntaxHighlighter::getLine( const size_t } void SyntaxHighlighter::copyLineToBuffer( const size_t& index, - std::vector& buffer, + SmallVector& buffer, bool mustTokenize ) { - static std::vector noHighlightVector = { + static SmallVector noHighlightVector = { { SyntaxStyleTypes::Normal, 0, 0 } }; if ( mDoc->getSyntaxDefinition().getPatterns().empty() ) { noHighlightVector[0].len = mDoc->getLineLength( index ); diff --git a/src/eepp/ui/doc/syntaxtokenizer.cpp b/src/eepp/ui/doc/syntaxtokenizer.cpp index c20ab4e39..3701cd467 100644 --- a/src/eepp/ui/doc/syntaxtokenizer.cpp +++ b/src/eepp/ui/doc/syntaxtokenizer.cpp @@ -51,7 +51,7 @@ static int isInMultiByteCodePoint( const char* text, const size_t& textSize, con } template -static void pushToken( std::vector& tokens, const SyntaxStyleType& type, +static void pushToken( SmallVector& tokens, const SyntaxStyleType& type, const std::string_view& text ) { if ( text.empty() ) return; @@ -275,7 +275,7 @@ static inline void popStack( SyntaxStateRestored& curState, SyntaxState& retStat template static inline void pushTokensToOpenCloseSubsyntax( int i, std::string_view textv, const SyntaxPattern* subsyntaxInfo, - const NonEscapedMatch& rangeSubsyntax, std::vector& tokens, + const NonEscapedMatch& rangeSubsyntax, SmallVector& tokens, std::vector& priorityMap, bool isClose = false ) { const auto& types = isClose && !subsyntaxInfo->endTypes.empty() ? subsyntaxInfo->endTypes : subsyntaxInfo->types; @@ -388,10 +388,10 @@ pushTokensToOpenCloseSubsyntax( int i, std::string_view textv, const SyntaxPatte } template -static inline std::pair, SyntaxState> +static inline std::pair, SyntaxState> _tokenize( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state, const size_t& startIndex, bool skipSubSyntaxSeparator ) { - std::vector tokens; + SmallVector tokens; if ( syntax.getPatterns().empty() ) { pushToken( tokens, SyntaxStyleTypes::Normal, text ); @@ -987,14 +987,14 @@ _tokenize( const SyntaxDefinition& syntax, const std::string& text, const Syntax return std::make_pair( std::move( tokens ), retState ); } -std::pair, SyntaxState> +std::pair, SyntaxState> SyntaxTokenizer::tokenize( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state, const size_t& startIndex, bool skipSubSyntaxSeparator ) { return _tokenize( syntax, text, state, startIndex, skipSubSyntaxSeparator ); } -std::pair, SyntaxState> +std::pair, SyntaxState> SyntaxTokenizer::tokenizePosition( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state, const size_t& startIndex, bool skipSubSyntaxSeparator ) { @@ -1002,7 +1002,7 @@ SyntaxTokenizer::tokenizePosition( const SyntaxDefinition& syntax, const std::st skipSubSyntaxSeparator ); } -std::pair, SyntaxState> +std::pair, SyntaxState> SyntaxTokenizer::tokenizeComplete( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state, const size_t& startIndex, bool skipSubSyntaxSeparator ) { diff --git a/src/eepp/ui/iconmanager.cpp b/src/eepp/ui/iconmanager.cpp index b8cddbe75..10653ca9f 100644 --- a/src/eepp/ui/iconmanager.cpp +++ b/src/eepp/ui/iconmanager.cpp @@ -248,6 +248,10 @@ ResourcePtr IconManager::init( const std::string& iconThemeName, { "symbol-key", 0xea93 }, { "symbol-null", 0xea8f }, { "collapse-all", 0xeac5 }, + { "split-horizontal", 0xeb56 }, + { "layout", 0xebeb }, + { "github", 0xea84 }, + { "unfold", 0xeb73 }, { "chevron-down", 0xeab4 }, { "chevron-right", 0xeab6 }, { "lightbulb-autofix", 0xeb13 }, diff --git a/src/eepp/ui/tools/uidiffview.cpp b/src/eepp/ui/tools/uidiffview.cpp index f6b9c22ef..7a19f2014 100644 --- a/src/eepp/ui/tools/uidiffview.cpp +++ b/src/eepp/ui/tools/uidiffview.cpp @@ -91,12 +91,29 @@ UIScrollView* UIDiffView::NewMultiFileDiffViewer( const std::string& patchText, diffView->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::WrapContent ); diffView->setParent( vbox ); diffView->setHeadersVisible( true ); + diffView->setViewModeToggleVisible( false ); + diffView->setCompleteViewToggleVisible( false ); diffView->loadFromPatch( diff, "", "", repoPath ); } return scrollView; } +std::vector UIDiffView::multiFileDiffViews( UIScrollView* multiDiff ) { + return multiDiff ? multiDiff->findAllByType( UI_TYPE_DIFF_VIEW ) + : std::vector{}; +} + +void UIDiffView::setMultiFileViewMode( UIScrollView* multiDiff, ViewMode mode ) { + for ( auto* diff : multiFileDiffViews( multiDiff ) ) + diff->setViewMode( mode ); +} + +void UIDiffView::setMultiFileCollapsed( UIScrollView* multiDiff, bool collapsed ) { + for ( auto* diff : multiFileDiffViews( multiDiff ) ) + diff->setCollapsed( collapsed ); +} + class UIDiffEditorPlugin : public UICodeEditorPlugin { public: UIDiffEditorPlugin( UIDiffView* view ) : mView( view ) {} @@ -692,6 +709,8 @@ void UIDiffView::updateEditorsText() { mViewLines.clear(); for ( size_t i = 0; i < mLines.size(); ++i ) { + if ( mCollapsed ) + continue; bool showLine = mShowCompleteView; if ( !showLine ) { @@ -1320,6 +1339,14 @@ void UIDiffView::setSyntaxColorScheme( const SyntaxColorScheme& colorScheme ) { mRightEditor->setColorScheme( colorScheme ); } +void UIDiffView::setCollapsed( bool collapsed ) { + if ( mCollapsed == collapsed ) + return; + mCollapsed = collapsed; + updateEditorsText(); + onSizeChange(); +} + void UIDiffView::setHeadersVisible( bool visible ) { if ( visible == mHeadersVisible ) return; diff --git a/src/eepp/ui/uicodeeditor.cpp b/src/eepp/ui/uicodeeditor.cpp index 4ad3fec41..528729c5d 100644 --- a/src/eepp/ui/uicodeeditor.cpp +++ b/src/eepp/ui/uicodeeditor.cpp @@ -418,7 +418,8 @@ void UICodeEditor::draw() { } if ( hasFocus() && getUISceneNode()->getWindow()->getIME().isEditing() ) { - auto offset = getTextPositionOffset( cursor, lineHeight ); + auto offset = getTextPositionOffset( cursor, lineHeight, false, false, + Text::LigatureCaretMode::ClosestGlyph ); Vector2f cursorPos( startScroll.x + offset.x, startScroll.y + offset.y ); FontStyleConfig config( mFontStyleConfig ); config.FontColor = mFontStyleConfig.getFontSelectedColor(); @@ -2056,7 +2057,8 @@ void UICodeEditor::drawCursor( const Vector2f& startScroll, const Float& lineHei const TextPosition& cursor ) { if ( mCursorVisible && !mLocked && isTextSelectionEnabled() && !mDocView.isFolded( cursor.line(), true ) ) { - auto offset = getTextPositionOffset( cursor, lineHeight ); + auto offset = getTextPositionOffset( cursor, lineHeight, false, false, + Text::LigatureCaretMode::ClosestGlyph ); Vector2f cursorPos( startScroll.x + offset.x, startScroll.y + offset.y + getLineOffset() ); Primitives primitives; primitives.setColor( Color( mCaretColor ).blendAlpha( mAlpha ) ); @@ -2613,8 +2615,8 @@ bool UICodeEditor::setScrollY( const Float& val, bool emitEvent ) { Vector2d UICodeEditor::getTextPositionOffset( const TextPosition& position, std::optional lineHeight, - bool allowVisualLineEnd, - bool visualizeNewLine ) const { + bool allowVisualLineEnd, bool visualizeNewLine, + Text::LigatureCaretMode ligatureCaretMode ) const { double lh = lineHeight ? *lineHeight : getLineHeight(); if ( mDocView.isWrappedLine( position.line() ) ) { auto info = mDocView.getVisibleLineRange( position, allowVisualLineEnd ); @@ -2635,7 +2637,7 @@ Vector2d UICodeEditor::getTextPositionOffset( const TextPosition& position, partialLine, mFontStyleConfig.Style, mTabWidth, mFontStyleConfig.OutlineThickness, mTabStops ? 0 : std::optional(), false, mDoc->line( position.line() ).getTextHints() | getWidgetTextDrawHints(), - mTextDirection ) + mTextDirection, {}, ligatureCaretMode ) .x; if ( visualizeNewLine && allowVisualLineEnd && position.column() == (Int64)mDoc->line( position.line() ).getText().size() - 1 ) @@ -2670,7 +2672,7 @@ Vector2d UICodeEditor::getTextPositionOffset( const TextPosition& position, mDoc->line( position.line() ).getText(), mFontStyleConfig.Style, mTabWidth, mFontStyleConfig.OutlineThickness, mTabStops ? 0 : std::optional(), false, mDoc->line( position.line() ).getTextHints() | getWidgetTextDrawHints(), - mTextDirection ) + mTextDirection, {}, ligatureCaretMode ) .x; if ( visualizeNewLine && isLastChar ) x += getGlyphWidth(); diff --git a/src/eepp/ui/uiconsole.cpp b/src/eepp/ui/uiconsole.cpp index f506551a8..162b4b8d0 100644 --- a/src/eepp/ui/uiconsole.cpp +++ b/src/eepp/ui/uiconsole.cpp @@ -524,11 +524,13 @@ void UIConsole::draw() { Primitives p; p.setColor( Color( mFontStyleConfig.FontSelectionBackColor ).blendAlpha( (Uint8)mAlpha ) ); - const auto characterPos = [this]( const String& string, std::size_t index ) { + const auto characterPos = [this]( const String& string, std::size_t index, + Text::LigatureCaretMode ligatureCaretMode = + Text::LigatureCaretMode::Interpolate ) { return Text::findCharacterPos( index, mFontStyleConfig.Font, mFontStyleConfig.CharacterSize, string, mFontStyleConfig.Style, 4, - mFontStyleConfig.OutlineThickness, {}, false, - getTextHints() ) + mFontStyleConfig.OutlineThickness, {}, false, getTextHints(), + TextDirection::Unspecified, {}, ligatureCaretMode ) .x; }; auto to = eemax( mCon.min - mCon.modif, 0 ); @@ -618,7 +620,8 @@ void UIConsole::draw() { if ( mCursorVisible ) { Float cursorPos = - editCharWidth + characterPos( inputLine, mDoc.getSelection().start().column() ); + editCharWidth + characterPos( inputLine, mDoc.getSelection().start().column(), + Text::LigatureCaretMode::ClosestGlyph ); Rectf r( { mScreenPos.x + mPaddingPx.Left + cursorPos, curY }, { cursorPos, lineHeight } ); updateIMELocation( r ); if ( hasFocus() && getUISceneNode()->getWindow()->getIME().isEditing() ) { diff --git a/src/eepp/ui/uitextedit.cpp b/src/eepp/ui/uitextedit.cpp index 7099e5b65..bfc4af68e 100644 --- a/src/eepp/ui/uitextedit.cpp +++ b/src/eepp/ui/uitextedit.cpp @@ -111,7 +111,8 @@ bool UITextEdit::applyProperty( const StyleSheetProperty& attribute ) { void UITextEdit::drawCursor( const Vector2f& startScroll, const Float& lineHeight, const TextPosition& cursor ) { if ( mCursorVisible && !mLocked && isTextSelectionEnabled() ) { - auto offset = getTextPositionOffset( cursor ); + auto offset = getTextPositionOffset( cursor, {}, false, false, + Text::LigatureCaretMode::ClosestGlyph ); Vector2f cursorPos( startScroll.x + offset.x, startScroll.y + offset.y ); Primitives primitives; primitives.setColor( Color( mFontStyleConfig.FontColor ).blendAlpha( mAlpha ) ); diff --git a/src/eepp/ui/uitextinput.cpp b/src/eepp/ui/uitextinput.cpp index d7cddf2d2..56afdadcb 100644 --- a/src/eepp/ui/uitextinput.cpp +++ b/src/eepp/ui/uitextinput.cpp @@ -230,7 +230,9 @@ void UITextInput::alignFix() { UITextView::alignFix(); if ( mAllowEditing /* && Font::getHorizontalAlign( getFlags() ) == UI_HALIGN_LEFT */ ) { - Float tW = getVisibleTextCache().findCharacterPos( selCurInit() ).x; + Float tW = getVisibleTextCache() + .findCharacterPos( selCurInit(), Text::LigatureCaretMode::ClosestGlyph ) + .x; mCurPos.x = tW; mCurPos.y = 0; diff --git a/src/eepp/ui/uitreeview.cpp b/src/eepp/ui/uitreeview.cpp index a022ddae9..730ab9890 100644 --- a/src/eepp/ui/uitreeview.cpp +++ b/src/eepp/ui/uitreeview.cpp @@ -46,6 +46,7 @@ UITreeView::MetadataForIndex& UITreeView::getIndexMetadata( const ModelIndex& in } void UITreeView::onModelIndexDeleted( const void* internalData ) { + UIAbstractTableView::onModelIndexDeleted( internalData ); mViewMetadata.erase( const_cast( internalData ) ); } @@ -728,6 +729,7 @@ Uint32 UITreeView::onKeyDown( const KeyEvent& event ) { if ( !metadata.open ) { metadata.open = true; createOrUpdateColumns( false ); + onOpenTreeModelIndex( curIndex, true ); return 0; } getSelection().set( getModel()->index( 0, getModel()->treeColumn(), curIndex ) ); @@ -740,6 +742,7 @@ Uint32 UITreeView::onKeyDown( const KeyEvent& event ) { if ( metadata.open ) { metadata.open = false; createOrUpdateColumns( false ); + onOpenTreeModelIndex( curIndex, false ); return 0; } } @@ -756,6 +759,7 @@ Uint32 UITreeView::onKeyDown( const KeyEvent& event ) { auto& metadata = getIndexMetadata( curIndex ); metadata.open = !metadata.open; createOrUpdateColumns( false ); + onOpenTreeModelIndex( curIndex, metadata.open ); } else { onOpenModelIndex( curIndex, &event ); } diff --git a/src/tests/unit_tests/fontrendering_tests.cpp b/src/tests/unit_tests/fontrendering_tests.cpp index e4d4e8424..b675f420a 100644 --- a/src/tests/unit_tests/fontrendering_tests.cpp +++ b/src/tests/unit_tests/fontrendering_tests.cpp @@ -461,8 +461,13 @@ UTEST( FontRendering, latinOpenTypeFeaturesAreExplicitAndCachedByTextHints ) { 4, 0, {}, false, ligatureHints ); const Vector2f afterLigature = Text::findCharacterPos( 2, font.get(), 24, text, Text::Regular, 4, 0, {}, false, ligatureHints ); + const Vector2f closestGlyph = Text::findCharacterPos( + 1, font.get(), 24, text, Text::Regular, 4, 0, {}, false, ligatureHints, + TextDirection::Unspecified, {}, Text::LigatureCaretMode::ClosestGlyph ); EXPECT_LT( beforeLigature.x, insideLigature.x ); EXPECT_LT( insideLigature.x, afterLigature.x ); + EXPECT_LE( beforeLigature.x, closestGlyph.x ); + EXPECT_LE( closestGlyph.x, afterLigature.x ); EXPECT_EQ( 1, Text::findCharacterFromPos( insideLigature.asInt(), true, font.get(), 24, text, Text::Regular, 4, 0, {}, ligatureHints ) ); } diff --git a/src/tests/unit_tests/gitconflict_tests.cpp b/src/tests/unit_tests/gitconflict_tests.cpp index a27e40595..97daf0121 100644 --- a/src/tests/unit_tests/gitconflict_tests.cpp +++ b/src/tests/unit_tests/gitconflict_tests.cpp @@ -102,3 +102,208 @@ UTEST( GitConflict, KeepsMergeOperationAfterAllConflictsAreStaged ) { EXPECT_FALSE( state.hasConflicts() ); EXPECT_EQ( Git::GitOperation::Merge, state.operation ); } + +UTEST( GitHistory, PaginatesFirstParentWithoutDuplicates ) { + 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 ) { + return git.git( args, temp.path.string(), output ); + }; + ASSERT_EQ( EXIT_SUCCESS, run( { "init", "-b", "main" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.name", "History Tester" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.email", "history@example.invalid" } ) ); + for ( int i = 0; i < 5; ++i ) + ASSERT_EQ( EXIT_SUCCESS, + run( { "commit", "--allow-empty", "-m", "commit " + std::to_string( i ) } ) ); + Git::HistoryQuery query; + query.limit = 2; + auto first = git.history( query, temp.path.string() ); + ASSERT_TRUE( first.success() ); + ASSERT_EQ( 2u, first.commits.size() ); + ASSERT_TRUE( first.hasMore ); + query.continuation = first.commits.back().parents.front(); + auto second = git.history( query, temp.path.string() ); + ASSERT_TRUE( second.success() ); + ASSERT_EQ( 2u, second.commits.size() ); + EXPECT_FALSE( first.commits.back().hash == second.commits.front().hash ); + query.continuation = second.commits.back().parents.front(); + auto third = git.history( query, temp.path.string() ); + ASSERT_TRUE( third.success() ); + ASSERT_EQ( 1u, third.commits.size() ); + EXPECT_FALSE( third.hasMore ); +} + +UTEST( GitHistory, PropagatedExclusionsHideAlreadyRepresentedMainline ) { + 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", "History Tester" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.email", "history@example.invalid" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "--allow-empty", "-m", "base" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "-c", "feature" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "--allow-empty", "-m", "feature one" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "main" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "--allow-empty", "-m", "main one" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "feature" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "merge", "--no-ff", "main", "-m", "merge main" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "--allow-empty", "-m", "feature two" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "main" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "merge", "--no-ff", "feature", "-m", "merge feature" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "branch", "-D", "feature" } ) ); + + Git::HistoryQuery rootQuery; + auto root = git.history( rootQuery, temp.path.string() ); + ASSERT_TRUE( root.success() ); + ASSERT_TRUE( root.commits.front().isMerge() ); + Git::HistoryQuery featureQuery; + featureQuery.revision = root.commits.front().parents[1]; + featureQuery.exclusions.emplace_back( root.commits.front().parents[0] ); + auto feature = git.history( featureQuery, temp.path.string() ); + ASSERT_TRUE( feature.success() ); + ASSERT_EQ( 3u, feature.commits.size() ); + ASSERT_TRUE( feature.commits[1].isMerge() ); + Git::HistoryQuery nestedQuery; + nestedQuery.revision = feature.commits[1].parents[1]; + nestedQuery.exclusions = featureQuery.exclusions; + nestedQuery.exclusions.emplace_back( feature.commits[1].parents[0] ); + auto nested = git.history( nestedQuery, temp.path.string() ); + ASSERT_TRUE( nested.success() ); + EXPECT_TRUE( nested.commits.empty() ); +} + +UTEST( GitHistory, HandlesEmptyRepositoryUnicodeAndHardLimit ) { + 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" } ) ); + Git::HistoryQuery query; + auto empty = git.history( query, temp.path.string() ); + ASSERT_TRUE( empty.success() ); + EXPECT_TRUE( empty.commits.empty() ); + query.limit = 1001; + EXPECT_TRUE( git.history( query, temp.path.string() ).fail() ); + + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.name", "Tést 🚀" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.email", "unicode@example.invalid" } ) ); + const std::string subject = "unicode 🚀 | quote \" and tab\tend"; + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "--allow-empty", "-m", subject } ) ); + query.limit = 200; + auto page = git.history( query, temp.path.string() ); + ASSERT_TRUE( page.success() ); + ASSERT_EQ( 1u, page.commits.size() ); + EXPECT_STREQ( subject.c_str(), page.commits.front().subject.c_str() ); + EXPECT_STREQ( "Tést 🚀", page.commits.front().authorName.c_str() ); +} + +UTEST( GitHistory, PaginatesMergedFirstParentLevelIndependently ) { + 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", "History Tester" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.email", "history@example.invalid" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "--allow-empty", "-m", "base" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "-c", "feature" } ) ); + for ( int i = 0; i < 5; ++i ) + ASSERT_EQ( EXIT_SUCCESS, + run( { "commit", "--allow-empty", "-m", "feature " + std::to_string( i ) } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "switch", "main" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "merge", "--no-ff", "feature", "-m", "merge feature" } ) ); + + Git::HistoryQuery rootQuery; + auto root = git.history( rootQuery, temp.path.string() ); + ASSERT_TRUE( root.success() ); + ASSERT_TRUE( root.commits.front().isMerge() ); + Git::HistoryQuery childQuery; + childQuery.revision = root.commits.front().parents[1]; + childQuery.exclusions.emplace_back( root.commits.front().parents[0] ); + childQuery.limit = 2; + auto first = git.history( childQuery, temp.path.string() ); + ASSERT_TRUE( first.success() ); + ASSERT_EQ( 2u, first.commits.size() ); + ASSERT_TRUE( first.hasMore ); + childQuery.continuation = first.commits.back().parents.front(); + auto second = git.history( childQuery, temp.path.string() ); + ASSERT_TRUE( second.success() ); + ASSERT_EQ( 2u, second.commits.size() ); + ASSERT_TRUE( second.hasMore ); + childQuery.continuation = second.commits.back().parents.front(); + auto third = git.history( childQuery, temp.path.string() ); + ASSERT_TRUE( third.success() ); + ASSERT_EQ( 1u, third.commits.size() ); + EXPECT_FALSE( third.hasMore ); +} + +UTEST( GitHistory, ListsChangedFilesAndLoadsFirstParentDiff ) { + 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", "History Tester" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "config", "user.email", "history@example.invalid" } ) ); + ASSERT_TRUE( FileSystem::fileWrite( ( temp.path / "old name.txt" ).string(), + "same line one\nbefore\nsame line three\n" ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "add", "old name.txt" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "commit", "-m", "base" } ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "mv", "old name.txt", "new name.txt" } ) ); + ASSERT_TRUE( FileSystem::fileWrite( ( temp.path / "new name.txt" ).string(), + "same line one\nafter\nsame line three\n" ) ); + ASSERT_EQ( EXIT_SUCCESS, run( { "add", "new name.txt" } ) ); + ASSERT_EQ( EXIT_SUCCESS, + run( { "commit", "-m", "rename and modify", "-m", "Detailed body line." } ) ); + ASSERT_EQ( EXIT_SUCCESS, + run( { "remote", "add", "origin", "git@github.com:SpartanJ/eepp.git" } ) ); + + auto history = git.history( {}, temp.path.string() ); + ASSERT_TRUE( history.success() ); + ASSERT_FALSE( history.commits.empty() ); + auto files = git.commitFiles( history.commits.front(), temp.path.string() ); + ASSERT_TRUE( files.success() ); + ASSERT_EQ( 1u, files.files.size() ); + EXPECT_STREQ( "new name.txt", files.files.front().path.c_str() ); + EXPECT_STREQ( "old name.txt", files.files.front().oldPath.c_str() ); + EXPECT_EQ( 1, files.files.front().inserts ); + EXPECT_EQ( 1, files.files.front().deletes ); + EXPECT_FALSE( files.files.front().isBinary ); + EXPECT_NE( std::string::npos, files.message.find( "Detailed body line." ) ); + const std::string expectedCommitURL = + "https://github.com/SpartanJ/eepp/commit/" + history.commits.front().hash; + EXPECT_STREQ( expectedCommitURL.c_str(), files.commitURL.c_str() ); + auto diff = git.commitDiff( history.commits.front(), files.files.front(), temp.path.string() ); + ASSERT_TRUE( diff.success() ); + EXPECT_NE( std::string::npos, diff.result.find( "-before" ) ); + EXPECT_NE( std::string::npos, diff.result.find( "+after" ) ); +} diff --git a/src/tests/unit_tests/modeloperations_tests.cpp b/src/tests/unit_tests/modeloperations_tests.cpp index f5a20ceb8..3ac05c6b1 100644 --- a/src/tests/unit_tests/modeloperations_tests.cpp +++ b/src/tests/unit_tests/modeloperations_tests.cpp @@ -71,6 +71,13 @@ class MoveTestModel : public Model { return {}; } + void deleteRootAt( int row ) { + if ( beginDeleteRows( {}, row, row ) ) { + root.children.erase( root.children.begin() + row ); + endDeleteRows(); + } + } + void moveA1ToB() { beginMoveRows( indexFor( a ), 1, 1, indexFor( b ), 1 ); a1->parent = b; @@ -215,6 +222,50 @@ UTEST( ModelInsert, persistentIndexesPreserveInternalIds ) { ASSERT_EQ( index.internalId(), 30 ); } +UTEST( ModelDelete, selectedTreeIndexIsRemovedBeforeNodeFree ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "Model selection deletion test", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + initTreeViewTestScene(); + + auto model = std::make_shared(); + UITreeView* treeView = UITreeView::New(); + treeView->setModel( model ); + treeView->getSelection().set( model->indexFor( model->b ) ); + ASSERT_EQ( 1, treeView->getSelection().size() ); + int selectionCallbacks = 0; + treeView->setOnSelectionChange( [&selectionCallbacks] { ++selectionCallbacks; } ); + + model->deleteRootAt( 1 ); + // This is the same non-index-invalidating refresh used after History pagination. + model->invalidate( Model::DontInvalidateIndexes ); + + EXPECT_TRUE( treeView->getSelection().isEmpty() ); + EXPECT_EQ( 0, selectionCallbacks ); +} + +UTEST( TableView, fitSingleColumnUsesViewportWidth ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "Single-column fit test", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + initTreeViewTestScene(); + + auto model = std::make_shared(); + model->a->name.assign( 1024, 'x' ); + UITreeView* treeView = UITreeView::New(); + treeView->setPixelsSize( 220, 300 ); + treeView->setModel( model ); + treeView->setAutoExpandOnSingleColumn( true ); + treeView->setFitAllColumnsToWidget( true ); + treeView->setHorizontalScrollMode( ScrollBarMode::AlwaysOff ); + treeView->recalculateColumnsWidth(); + + EXPECT_TRUE( treeView->getColumnWidth( 0 ) <= treeView->getContentSpaceWidth() ); + EXPECT_EQ( ScrollBarMode::AlwaysOff, treeView->getHorizontalScrollMode() ); +} + UTEST( ModelDelete, persistentIndexesExpireOrShiftWithoutIdentityCollisions ) { PersistentRowsModel model( false ); PersistentModelIndex deleted( model.index( 1 ) ); diff --git a/src/tests/unit_tests/uidiffview_tests.cpp b/src/tests/unit_tests/uidiffview_tests.cpp index afd52527a..006f095c4 100644 --- a/src/tests/unit_tests/uidiffview_tests.cpp +++ b/src/tests/unit_tests/uidiffview_tests.cpp @@ -127,6 +127,19 @@ diff --git a/second.txt b/second.txt for ( const auto* diffView : diffViews ) EXPECT_EQ( UIDiffView::ViewMode::SideBySide, diffView->getViewMode() ); + UIDiffView::setMultiFileViewMode( viewer, UIDiffView::ViewMode::Unified ); + UIDiffView::setMultiFileCollapsed( viewer, true ); + for ( const auto* diffView : diffViews ) { + EXPECT_EQ( UIDiffView::ViewMode::Unified, diffView->getViewMode() ); + EXPECT_TRUE( diffView->isCollapsed() ); + EXPECT_TRUE( diffView->getViewLines().empty() ); + } + UIDiffView::setMultiFileCollapsed( viewer, false ); + for ( const auto* diffView : diffViews ) { + EXPECT_FALSE( diffView->isCollapsed() ); + EXPECT_FALSE( diffView->getViewLines().empty() ); + } + eeDelete( viewer ); } diff --git a/src/tools/ecode/plugins/git/git.cpp b/src/tools/ecode/plugins/git/git.cpp index ba0e44990..07be4aa6b 100644 --- a/src/tools/ecode/plugins/git/git.cpp +++ b/src/tools/ecode/plugins/git/git.cpp @@ -9,6 +9,7 @@ #include #include +#include #include using namespace EE; @@ -18,6 +19,290 @@ using namespace std::literals; namespace ecode { +static bool parseHistoryTimestamp( std::string_view value, int64_t& timestamp ) { + const char* end = value.data() + value.size(); + auto result = std::from_chars( value.data(), end, timestamp ); + return result.ec == std::errc{} && result.ptr == end; +} + +Git::HistoryPage Git::history( const HistoryQuery& query, const std::string& projectDir ) const { + HistoryPage page; + if ( query.limit == 0 || query.limit > 1000 || + ( query.revision.empty() && query.continuation.empty() ) ) { + page.returnCode = EXIT_FAILURE; + page.result = "Invalid Git history query"; + return page; + } + std::vector args{ "log", + "--first-parent", + String::format( "--max-count=%zu", query.limit + 1 ), + "-z", + "--format=%H%x00%h%x00%P%x00%an%x00%ae%x00%at%x00%ct%x00%s", + query.continuation.empty() ? query.revision + : query.continuation }; + if ( !query.exclusions.empty() ) { + args.emplace_back( "--not" ); + args.insert( args.end(), query.exclusions.begin(), query.exclusions.end() ); + } + page.returnCode = git( args, projectDir, page.result ); + if ( page.fail() ) { + std::string head; + if ( query.revision == "HEAD" && query.continuation.empty() && + git( { "rev-parse", "--verify", "HEAD" }, projectDir, head ) != EXIT_SUCCESS ) { + page.returnCode = EXIT_SUCCESS; + page.result.clear(); + } + return page; + } + constexpr size_t FieldCount = 8; + size_t offset = 0; + while ( offset < page.result.size() ) { + std::string_view fields[FieldCount]; + for ( size_t field = 0; field < FieldCount; ++field ) { + const size_t end = page.result.find( '\0', offset ); + if ( end == std::string::npos ) { + page.returnCode = EXIT_FAILURE; + page.commits.clear(); + page.result = "Invalid NUL-framed git log output"; + return page; + } + fields[field] = std::string_view( page.result ).substr( offset, end - offset ); + offset = end + 1; + } + if ( offset < page.result.size() && page.result[offset] == '\0' ) + ++offset; + Commit commit; + commit.hash = fields[0]; + commit.shortHash = fields[1]; + size_t parentOffset = 0; + while ( parentOffset < fields[2].size() ) { + const size_t separator = fields[2].find( ' ', parentOffset ); + commit.parents.emplace_back( fields[2].substr( + parentOffset, separator == std::string_view::npos ? fields[2].size() - parentOffset + : separator - parentOffset ) ); + if ( separator == std::string_view::npos ) + break; + parentOffset = separator + 1; + } + commit.authorName = fields[3]; + commit.authorEmail = fields[4]; + commit.subject = fields[7]; + if ( !parseHistoryTimestamp( fields[5], commit.authorTime ) || + !parseHistoryTimestamp( fields[6], commit.commitTime ) ) { + page.returnCode = EXIT_FAILURE; + page.commits.clear(); + page.result = "Invalid timestamp in git log output"; + return page; + } + page.commits.emplace_back( std::move( commit ) ); + } + page.result.clear(); + page.hasMore = page.commits.size() > query.limit; + if ( page.hasMore ) + page.commits.resize( query.limit ); + return page; +} + +Git::CommitFiles Git::commitFiles( const Commit& commit, const std::string& projectDir ) const { + CommitFiles result; + if ( commit.hash.empty() ) { + result.returnCode = EXIT_FAILURE; + result.result = "Invalid commit"; + return result; + } + std::vector args; + if ( commit.parents.empty() ) { + args = { "diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-z", "-M", + commit.hash, "--" }; + } else { + args = { "diff", "--name-status", "-z", "-M", commit.parents[0], commit.hash, "--" }; + } + result.returnCode = git( args, projectDir, result.result ); + if ( result.fail() ) + return result; + + size_t offset = 0; + auto nextField = [&result, &offset]( std::string_view& field ) { + if ( offset >= result.result.size() ) + return false; + const size_t end = result.result.find( '\0', offset ); + if ( end == std::string::npos ) + return false; + field = std::string_view( result.result ).substr( offset, end - offset ); + offset = end + 1; + return true; + }; + while ( offset < result.result.size() ) { + std::string_view status; + std::string_view path; + if ( !nextField( status ) || !nextField( path ) || status.empty() ) { + result.returnCode = EXIT_FAILURE; + result.files.clear(); + result.result = "Invalid NUL-framed git diff output"; + return result; + } + CommitFile file; + file.status = status; + if ( status[0] == 'R' || status[0] == 'C' ) { + std::string_view newPath; + if ( !nextField( newPath ) ) { + result.returnCode = EXIT_FAILURE; + result.files.clear(); + result.result = "Invalid renamed path in git diff output"; + return result; + } + file.oldPath = path; + file.path = newPath; + } else { + file.path = path; + } + result.files.emplace_back( std::move( file ) ); + } + std::string numstat; + if ( commit.parents.empty() ) { + args = { "diff-tree", "--root", "--no-commit-id", "--numstat", "-r", + "-z", "-M", commit.hash, "--" }; + } else { + args = { "diff", "--numstat", "-z", "-M", commit.parents[0], commit.hash, "--" }; + } + result.returnCode = git( args, projectDir, numstat ); + if ( result.fail() ) { + result.result = std::move( numstat ); + result.files.clear(); + return result; + } + offset = 0; + while ( offset < numstat.size() ) { + const size_t end = numstat.find( '\0', offset ); + if ( end == std::string::npos ) { + result.returnCode = EXIT_FAILURE; + result.result = "Invalid NUL-framed git numstat output"; + result.files.clear(); + return result; + } + const std::string_view record( numstat.data() + offset, end - offset ); + offset = end + 1; + const size_t firstTab = record.find( '\t' ); + const size_t secondTab = + firstTab == std::string_view::npos ? firstTab : record.find( '\t', firstTab + 1 ); + if ( firstTab == std::string_view::npos || secondTab == std::string_view::npos ) { + result.returnCode = EXIT_FAILURE; + result.result = "Invalid git numstat record"; + result.files.clear(); + return result; + } + std::string_view path = record.substr( secondTab + 1 ); + std::string_view oldPath; + if ( path.empty() ) { + const size_t oldEnd = numstat.find( '\0', offset ); + if ( oldEnd == std::string::npos ) { + result.returnCode = EXIT_FAILURE; + result.result = "Invalid renamed path in git numstat output"; + result.files.clear(); + return result; + } + oldPath = std::string_view( numstat ).substr( offset, oldEnd - offset ); + offset = oldEnd + 1; + const size_t newEnd = numstat.find( '\0', offset ); + if ( newEnd == std::string::npos ) { + result.returnCode = EXIT_FAILURE; + result.result = "Invalid renamed path in git numstat output"; + result.files.clear(); + return result; + } + path = std::string_view( numstat ).substr( offset, newEnd - offset ); + offset = newEnd + 1; + } + auto file = std::find_if( result.files.begin(), result.files.end(), + [path, oldPath]( const CommitFile& candidate ) { + return candidate.path == path && + ( oldPath.empty() || candidate.oldPath == oldPath ); + } ); + if ( file == result.files.end() ) + continue; + const std::string_view inserted = record.substr( 0, firstTab ); + const std::string_view deleted = record.substr( firstTab + 1, secondTab - firstTab - 1 ); + file->isBinary = inserted == "-" || deleted == "-"; + auto parseCount = []( std::string_view value, int& count ) { + const char* end = value.data() + value.size(); + auto parsed = std::from_chars( value.data(), end, count ); + return parsed.ec == std::errc{} && parsed.ptr == end; + }; + if ( !file->isBinary && + ( !parseCount( inserted, file->inserts ) || !parseCount( deleted, file->deletes ) ) ) { + result.returnCode = EXIT_FAILURE; + result.result = "Invalid line count in git numstat output"; + result.files.clear(); + return result; + } + } + std::string message; + result.returnCode = git( { "show", "-s", "--format=%B", commit.hash }, projectDir, message ); + if ( result.fail() ) { + result.result = std::move( message ); + result.files.clear(); + return result; + } + while ( !message.empty() && ( message.back() == '\n' || message.back() == '\r' ) ) + message.pop_back(); + result.message = std::move( message ); + std::string patch; + if ( commit.parents.empty() ) { + args = { "show", "--format=", "--no-ext-diff", "--no-color", "-M", commit.hash }; + } else { + args = { "diff", "--no-ext-diff", "--no-color", "-M", commit.parents[0], commit.hash }; + } + result.returnCode = git( args, projectDir, patch ); + if ( result.fail() ) { + result.result = std::move( patch ); + result.files.clear(); + return result; + } + result.patch = std::move( patch ); + std::string remote; + if ( git( { "remote", "get-url", "origin" }, projectDir, remote ) == EXIT_SUCCESS ) { + String::trimInPlace( remote, " \t\r\n" ); + const size_t host = remote.find( "github.com" ); + if ( host != std::string::npos ) { + size_t pathStart = host + std::string_view( "github.com" ).size(); + while ( pathStart < remote.size() && + ( remote[pathStart] == '/' || remote[pathStart] == ':' ) ) + ++pathStart; + std::string path = remote.substr( pathStart ); + while ( !path.empty() && path.back() == '/' ) + path.pop_back(); + if ( String::endsWith( path, ".git" ) ) + path.resize( path.size() - 4 ); + if ( !path.empty() ) + result.commitURL = "https://github.com/" + path + "/commit/" + commit.hash; + } + } + result.result.clear(); + return result; +} + +Git::Result Git::commitDiff( const Commit& commit, const CommitFile& file, + const std::string& projectDir ) const { + Result result; + if ( commit.hash.empty() || file.path.empty() ) { + result.returnCode = EXIT_FAILURE; + result.result = "Invalid commit diff query"; + return result; + } + std::vector args; + if ( commit.parents.empty() ) { + args = { "show", "--format=", "--no-ext-diff", "--no-color", "-M", commit.hash, "--" }; + } else { + args = { "diff", "--no-ext-diff", "--no-color", "-M", commit.parents[0], commit.hash, + "--" }; + } + if ( !file.oldPath.empty() ) + args.emplace_back( file.oldPath ); + args.emplace_back( file.path ); + result.returnCode = git( args, projectDir, result.result ); + return result; +} + static constexpr auto sNotCommittedYetHash = "0000000000000000000000000000000000000000"; static constexpr std::string_view sAsciiWhitespace = " \t\r\n"; diff --git a/src/tools/ecode/plugins/git/git.hpp b/src/tools/ecode/plugins/git/git.hpp index b995d18ac..a67d723c7 100644 --- a/src/tools/ecode/plugins/git/git.hpp +++ b/src/tools/ecode/plugins/git/git.hpp @@ -201,6 +201,47 @@ class Git { bool fail() const { return !success(); } }; + struct Commit { + std::string hash; + std::string shortHash; + std::vector parents; + std::string subject; + std::string authorName; + std::string authorEmail; + int64_t authorTime{ 0 }; + int64_t commitTime{ 0 }; + + bool isMerge() const { return parents.size() > 1; } + }; + + struct HistoryQuery { + std::string revision{ "HEAD" }; + std::vector exclusions; + std::string continuation; + size_t limit{ 200 }; + }; + + struct HistoryPage : public Result { + std::vector commits; + bool hasMore{ false }; + }; + + struct CommitFile { + std::string status; + int inserts{ 0 }; + int deletes{ 0 }; + bool isBinary{ false }; + std::string path; + std::string oldPath; + }; + + struct CommitFiles : public Result { + std::vector files; + std::string message; + std::string patch; + std::string commitURL; + }; + struct CheckoutResult : public Result { std::string branch; }; @@ -422,6 +463,13 @@ class Git { Result stashDrop( const std::string& stashId, const std::string& projectDir = "" ); + HistoryPage history( const HistoryQuery& query, const std::string& projectDir = "" ) const; + + CommitFiles commitFiles( const Commit& commit, const std::string& projectDir = "" ) const; + + Result commitDiff( const Commit& commit, const CommitFile& file, + const std::string& projectDir = "" ) const; + protected: std::string mGitPath; std::string mProjectPath; diff --git a/src/tools/ecode/plugins/git/githistorymodel.cpp b/src/tools/ecode/plugins/git/githistorymodel.cpp new file mode 100644 index 000000000..fe833a499 --- /dev/null +++ b/src/tools/ecode/plugins/git/githistorymodel.cpp @@ -0,0 +1,294 @@ +#include "githistorymodel.hpp" +#include "gitplugin.hpp" +#include +#include +#include + +namespace ecode { + +size_t GitHistoryModel::rowCount( const ModelIndex& index ) const { + if ( !index.isValid() ) + return mRoots.size(); + const Node* item = node( index ); + return item ? item->children.size() : 0; +} + +std::string GitHistoryModel::columnName( const size_t& column ) const { + switch ( column ) { + case Subject: + return mPlugin->i18n( "git_history_subject", "Subject" ); + case Author: + return mPlugin->i18n( "git_history_author", "Author" ); + case Date: + return mPlugin->i18n( "git_history_date", "Date" ); + case Hash: + return mPlugin->i18n( "git_history_hash", "Hash" ); + } + return {}; +} + +GitHistoryModel::Node* GitHistoryModel::node( const ModelIndex& index ) const { + return index.isValid() ? static_cast( index.internalData() ) : nullptr; +} + +const GitHistoryModel::Nodes& GitHistoryModel::siblings( const Node* parent ) const { + return parent ? parent->children : mRoots; +} + +GitHistoryModel::Nodes& GitHistoryModel::siblings( Node* parent ) { + return parent ? parent->children : mRoots; +} + +ModelIndex GitHistoryModel::indexForNode( const Node* item, int column ) const { + if ( !item ) + return {}; + const auto& items = siblings( item->parent ); + for ( size_t row = 0; row < items.size(); ++row ) + if ( items[row].get() == item ) + return createIndex( row, column, item ); + return {}; +} + +ModelIndex GitHistoryModel::parentIndex( const ModelIndex& index ) const { + Node* item = node( index ); + return item ? indexForNode( item->parent ) : ModelIndex{}; +} + +ModelIndex GitHistoryModel::index( int row, int column, const ModelIndex& parent ) const { + if ( row < 0 || column < 0 ) + return {}; + const auto& items = siblings( node( parent ) ); + if ( static_cast( row ) >= items.size() ) + return {}; + return createIndex( row, column, items[row].get() ); +} + +Variant GitHistoryModel::data( const ModelIndex& index, ModelRole role ) const { + const Node* item = node( index ); + if ( !item ) + return {}; + if ( role == ModelRole::Class ) { + if ( item->type == NodeType::LoadMore || item->type == NodeType::Error ) + return Variant( "git_history_action" ); + if ( item->type != NodeType::Commit ) + return Variant( "git_history_secondary" ); + if ( item->commit.isMerge() ) + return Variant( "git_history_merge" ); + return Variant( "" ); + } + if ( role == ModelRole::Tooltip ) { + if ( item->type == NodeType::Error ) + return Variant( &item->error ); + if ( item->type != NodeType::Commit ) + return Variant( &item->message ); + return Variant( &item->tooltip ); + } + if ( role != ModelRole::Display ) + return {}; + if ( item->type != NodeType::Commit ) + return index.column() == Subject ? Variant( &item->message ) : Variant( "" ); + switch ( index.column() ) { + case Subject: + return Variant( &item->subject ); + case Author: + return Variant( &item->author ); + case Date: + return Variant( &item->date ); + case Hash: + return Variant( &item->hash ); + } + return {}; +} + +std::unique_ptr +GitHistoryModel::commitNode( Git::Commit commit, Node* parent, + const Git::HistoryQuery& query ) const { + auto item = std::make_unique(); + item->parent = parent; + item->commit = std::move( commit ); + item->subject = String::fromUtf8( item->commit.subject ); + item->author = String::fromUtf8( item->commit.authorName ); + item->date = Sys::epochToString( item->commit.commitTime ); + item->hash = String::fromUtf8( item->commit.shortHash ); + item->tooltip = String::format( "%s\n%s <%s>\n%s", item->commit.hash, item->commit.authorName, + item->commit.authorEmail, item->commit.subject ); + item->message = item->date + String{ " · " } + item->author + String{ " · " } + item->hash; + if ( item->commit.parents.size() == 2 ) { + item->query = query; + auto loading = std::make_unique(); + loading->type = NodeType::Loading; + loading->parent = item.get(); + loading->message = mPlugin->i18n( "git_history_loading", "Loading..." ); + item->children.emplace_back( std::move( loading ) ); + } + return item; +} + +void GitHistoryModel::fillPage( Nodes& nodes, Node* parent, Git::HistoryPage page, + const Git::HistoryQuery& query ) { + for ( auto& commit : page.commits ) + nodes.emplace_back( commitNode( std::move( commit ), parent, query ) ); + if ( page.hasMore && !nodes.empty() && !nodes.back()->commit.parents.empty() ) { + auto more = std::make_unique(); + more->type = NodeType::LoadMore; + more->parent = parent; + more->message = mPlugin->i18n( "git_history_load_older", "Load older commits" ); + more->query = query; + more->query.continuation = nodes.back()->commit.parents[0]; + nodes.emplace_back( std::move( more ) ); + } +} + +void GitHistoryModel::replaceChildren( Node* parent, Nodes children ) { + auto& current = parent->children; + const ModelIndex parentIndex = indexForNode( parent ); + if ( !current.empty() && beginDeleteRows( parentIndex, 0, current.size() - 1 ) ) { + current.clear(); + endDeleteRows(); + } + if ( !children.empty() ) { + beginInsertRows( parentIndex, 0, children.size() - 1 ); + current = std::move( children ); + endInsertRows(); + } + // Views are not Model::Client instances, so structural notifications alone do not rebuild a + // UITreeView's flattened rows and scrollable content height. + invalidate( Model::DontInvalidateIndexes ); +} + +void GitHistoryModel::setRootLoading() { + mRoots.clear(); + auto item = std::make_unique(); + item->type = NodeType::Loading; + item->message = mPlugin->i18n( "git_history_loading", "Loading..." ); + mRoots.emplace_back( std::move( item ) ); + invalidate(); +} + +void GitHistoryModel::setRootPage( Git::HistoryPage page, const Git::HistoryQuery& query ) { + mRoots.clear(); + fillPage( mRoots, nullptr, std::move( page ), query ); + if ( mRoots.empty() ) { + auto item = std::make_unique(); + item->type = NodeType::Empty; + item->message = mPlugin->i18n( "git_history_empty", "No commits yet" ); + mRoots.emplace_back( std::move( item ) ); + } + invalidate(); +} + +void GitHistoryModel::setRootError( std::string error ) { + mRoots.clear(); + auto item = std::make_unique(); + item->type = NodeType::Error; + item->message = mPlugin->i18n( "git_history_error", "Could not load Git history" ) + + String{ " · " } + mPlugin->i18n( "git_history_retry", "Retry" ); + item->error = error.empty() ? item->message : String::fromUtf8( error ); + mRoots.emplace_back( std::move( item ) ); + invalidate(); +} + +void GitHistoryModel::setChildrenLoading( Node* item ) { + if ( !item ) + return; + item->childrenLoading = true; + if ( item->children.size() == 1 && ( item->children.front()->type == NodeType::Error || + item->children.front()->type == NodeType::Loading ) ) { + item->children.front()->type = NodeType::Loading; + item->children.front()->message = mPlugin->i18n( "git_history_loading", "Loading..." ); + item->children.front()->error.clear(); + invalidate( Model::DontInvalidateIndexes ); + } +} + +void GitHistoryModel::setChildrenPage( Node* item, Git::HistoryPage page, + const Git::HistoryQuery& query ) { + if ( !item ) + return; + Nodes children; + fillPage( children, item, std::move( page ), query ); + item->childrenLoading = false; + item->childrenLoaded = true; + replaceChildren( item, std::move( children ) ); +} + +void GitHistoryModel::setChildrenError( Node* item, std::string error, + const Git::HistoryQuery& query ) { + if ( !item ) + return; + Nodes children; + auto child = std::make_unique(); + child->type = NodeType::Error; + child->parent = item; + child->query = query; + child->message = mPlugin->i18n( "git_history_error", "Could not load Git history" ) + + String{ " · " } + mPlugin->i18n( "git_history_retry", "Retry" ); + child->error = error.empty() ? child->message : String::fromUtf8( error ); + children.emplace_back( std::move( child ) ); + item->childrenLoading = false; + replaceChildren( item, std::move( children ) ); +} + +void GitHistoryModel::setPageLoading( Node* item ) { + if ( !item || item->childrenLoading ) + return; + item->childrenLoading = true; + item->type = NodeType::Loading; + item->message = mPlugin->i18n( "git_history_loading", "Loading..." ); + item->error.clear(); + invalidate( Model::DontInvalidateIndexes ); +} + +void GitHistoryModel::setPageError( Node* item, std::string error ) { + if ( !item ) + return; + item->type = NodeType::Error; + item->childrenLoading = false; + item->retryAppend = true; + item->message = mPlugin->i18n( "git_history_error", "Could not load Git history" ) + + String{ " · " } + mPlugin->i18n( "git_history_retry", "Retry" ); + item->error = error.empty() ? item->message : String::fromUtf8( error ); + invalidate( Model::DontInvalidateIndexes ); +} + +void GitHistoryModel::appendPage( Node* loadMore, Git::HistoryPage page ) { + if ( !loadMore || ( loadMore->type != NodeType::LoadMore && !loadMore->retryAppend ) ) + return; + Node* parent = loadMore->parent; + auto& items = siblings( parent ); + auto pos = std::find_if( items.begin(), items.end(), + [loadMore]( const auto& item ) { return item.get() == loadMore; } ); + if ( pos == items.end() ) + return; + Git::HistoryQuery query = loadMore->query; + Nodes additions; + fillPage( additions, parent, std::move( page ), query ); + const size_t row = std::distance( items.begin(), pos ); + const ModelIndex parentIndex = indexForNode( parent ); + if ( !beginDeleteRows( parentIndex, row, row ) ) + return; + items.erase( pos ); + endDeleteRows(); + if ( additions.empty() ) { + invalidate( Model::DontInvalidateIndexes ); + return; + } + beginInsertRows( parentIndex, row, row + additions.size() - 1 ); + items.insert( items.begin() + row, std::make_move_iterator( additions.begin() ), + std::make_move_iterator( additions.end() ) ); + endInsertRows(); + invalidate( Model::DontInvalidateIndexes ); +} + +Git::HistoryQuery GitHistoryModel::mergeQuery( const Node* item, size_t limit ) const { + Git::HistoryQuery query; + query.limit = limit; + if ( !item || item->commit.parents.size() < 2 ) + return query; + query.revision = item->commit.parents[1]; + query.exclusions = item->query.exclusions; + query.exclusions.emplace_back( item->commit.parents[0] ); + return query; +} + +} // namespace ecode diff --git a/src/tools/ecode/plugins/git/githistorymodel.hpp b/src/tools/ecode/plugins/git/githistorymodel.hpp new file mode 100644 index 000000000..5f01bc28b --- /dev/null +++ b/src/tools/ecode/plugins/git/githistorymodel.hpp @@ -0,0 +1,104 @@ +#ifndef ECODE_GITHISTORYMODEL_HPP +#define ECODE_GITHISTORYMODEL_HPP + +#include "git.hpp" +#include +#include + +using namespace EE; +using namespace EE::UI::Models; + +namespace ecode { +class GitPlugin; + +class GitHistoryModel : public Model { + public: + enum class NodeType : uint8_t { Commit, LoadMore, Loading, Error, Empty }; + enum Column { Subject, Author, Date, Hash }; + struct Node { + Git::Commit commit; + Git::HistoryQuery query; + Node* parent{ nullptr }; + std::vector> children; + String subject; + String author; + String date; + String hash; + String tooltip; + String message; + String error; + NodeType type{ NodeType::Commit }; + bool childrenLoaded{ false }; + bool childrenLoading{ false }; + bool retryAppend{ false }; + }; + + static std::shared_ptr asModel( GitPlugin* plugin ) { + return std::make_shared( plugin ); + } + + explicit GitHistoryModel( GitPlugin* plugin ) : mPlugin( plugin ) {} + + size_t treeColumn() const { return Subject; } + + size_t rowCount( const ModelIndex& index = {} ) const; + + size_t columnCount( const ModelIndex& = {} ) const { return 1; } + + std::string columnName( const size_t& column ) const; + + ModelIndex parentIndex( const ModelIndex& index ) const; + + ModelIndex index( int row, int column, const ModelIndex& parent = {} ) const; + + Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const; + + bool classModelRoleEnabled() { return true; } + + bool tooltipModelRoleEnabled() { return true; } + + Node* node( const ModelIndex& index ) const; + + ModelIndex indexForNode( const Node* node, int column = 0 ) const; + + void setRootLoading(); + + void setRootPage( Git::HistoryPage page, const Git::HistoryQuery& query ); + + void setRootError( std::string error ); + + void setChildrenLoading( Node* node ); + + void setChildrenPage( Node* node, Git::HistoryPage page, const Git::HistoryQuery& query ); + + void setChildrenError( Node* node, std::string error, const Git::HistoryQuery& query ); + + void setPageLoading( Node* node ); + + void setPageError( Node* node, std::string error ); + + void appendPage( Node* loadMore, Git::HistoryPage page ); + + Git::HistoryQuery mergeQuery( const Node* node, size_t limit ) const; + + private: + using Nodes = std::vector>; + Nodes mRoots; + GitPlugin* mPlugin{ nullptr }; + + Nodes& siblings( Node* parent ); + + const Nodes& siblings( const Node* parent ) const; + + std::unique_ptr commitNode( Git::Commit commit, Node* parent, + const Git::HistoryQuery& query ) const; + + void fillPage( Nodes& nodes, Node* parent, Git::HistoryPage page, + const Git::HistoryQuery& query ); + + void replaceChildren( Node* parent, Nodes children ); +}; + +} // namespace ecode + +#endif diff --git a/src/tools/ecode/plugins/git/githistorytreeview.cpp b/src/tools/ecode/plugins/git/githistorytreeview.cpp new file mode 100644 index 000000000..7cff1d23f --- /dev/null +++ b/src/tools/ecode/plugins/git/githistorytreeview.cpp @@ -0,0 +1,61 @@ +#include "githistorytreeview.hpp" +#include "githistorymodel.hpp" +#include + +namespace ecode { + +GitHistoryTreeViewCell::GitHistoryTreeViewCell() : + UITreeViewCell(), + mHintColor( Color::fromString( + mUISceneNode->getRoot()->getUIStyle()->getVariable( "--font-hint" ).getValue() ) ) { + mTextBox->setTextOverflow( "ellipsis" ); +} + +Sizef GitHistoryTreeViewCell::updateLayout() { + Sizef size = UITreeViewCell::updateLayout(); + if ( mTextBox ) + mTextBox->setPixelsPosition( + Vector2f{ mTextBox->getPixelsPosition().x, PixelDensity::dpToPx( 2 ) }.trunc() ); + return size; +} + +void GitHistoryTreeViewCell::updateCell( Model* model ) { + auto* historyModel = static_cast( model ); + const auto* item = historyModel->node( getCurIndex() ); + if ( !item || item->type != GitHistoryModel::NodeType::Commit ) { + mMetadataText.setString( "" ); + mTextBox->setTextAlign( UI_HALIGN_LEFT | UI_VALIGN_CENTER ); + return; + } + + mTextBox->setTextAlign( UI_HALIGN_LEFT | UI_VALIGN_TOP ); + mMetadataText.setFont( mTextBox->getFont() ); + mMetadataText.setFontSize( + eemax( 8, mTextBox->getFontSize() > 2 ? mTextBox->getFontSize() - 2 : 8 ) ); + mMetadataText.setString( item->message ); + mMetadataText.setFillColor( mHintColor ); + mMetadataText.setTextHints( mTextBox->getTextHints() ); +} + +void GitHistoryTreeViewCell::draw() { + UITreeViewCell::draw(); + + if ( mMetadataText.getString().empty() ) + return; + + const bool selected = getParent() && ( getParent()->asType()->getStyleState() & + UIState::StateFlagSelected ); + + mMetadataText.setFillColor( selected ? mTextBox->getFontColor() : mHintColor ); + mMetadataText.draw( std::floor( mScreenPos.x + mTextBox->getPixelsPosition().x ), + std::floor( mScreenPos.y + PixelDensity::dpToPx( 19 ) ) ); +} + +UIWidget* GitHistoryTreeView::createCell( UIWidget* rowWidget, const ModelIndex& index ) { + UITableCell* widget = index.column() == static_cast( getModel()->treeColumn() ) + ? GitHistoryTreeViewCell::New() + : UITableCell::New( mTag + "::cell" ); + return setupCell( widget, rowWidget, index ); +} + +} // namespace ecode diff --git a/src/tools/ecode/plugins/git/githistorytreeview.hpp b/src/tools/ecode/plugins/git/githistorytreeview.hpp new file mode 100644 index 000000000..a891220fd --- /dev/null +++ b/src/tools/ecode/plugins/git/githistorytreeview.hpp @@ -0,0 +1,40 @@ +#ifndef ECODE_GITHISTORYTREEVIEW_HPP +#define ECODE_GITHISTORYTREEVIEW_HPP + +#include +#include + +using namespace EE::Graphics; +using namespace EE::UI; + +namespace ecode { + +class GitHistoryTreeViewCell : public UITreeViewCell { + public: + static GitHistoryTreeViewCell* New() { return eeNew( GitHistoryTreeViewCell, () ); } + + void draw() override; + void updateCell( Model* model ) override; + + protected: + GitHistoryTreeViewCell(); + + Sizef updateLayout() override; + + Text mMetadataText; + Color mHintColor; +}; + +class GitHistoryTreeView : public UITreeView { + public: + static GitHistoryTreeView* New() { return eeNew( GitHistoryTreeView, () ); } + + UIWidget* createCell( UIWidget* rowWidget, const ModelIndex& index ) override; + + protected: + GitHistoryTreeView() = default; +}; + +} // namespace ecode + +#endif diff --git a/src/tools/ecode/plugins/git/gitplugin.cpp b/src/tools/ecode/plugins/git/gitplugin.cpp index 1f97c4054..653ee871c 100644 --- a/src/tools/ecode/plugins/git/gitplugin.cpp +++ b/src/tools/ecode/plugins/git/gitplugin.cpp @@ -1,6 +1,8 @@ #include "gitplugin.hpp" #include "../../settingspage.hpp" #include "gitbranchmodel.hpp" +#include "githistorymodel.hpp" +#include "githistorytreeview.hpp" #include "gitstatusmodel.hpp" #include #include @@ -24,6 +26,8 @@ #include #include #include +#include +#include #include using namespace EE::UI; @@ -174,6 +178,9 @@ GitPlugin::~GitPlugin() { while ( mRunningUpdateBranches ) Sys::sleep( Milliseconds( 1.f ) ); + while ( mRunningHistoryRequests ) + Sys::sleep( Milliseconds( 1.f ) ); + while ( *mRunningAsyncTasks ) Sys::sleep( Milliseconds( 1.f ) ); } @@ -909,7 +916,11 @@ void GitPlugin::checkout( Git::Branch branch ) { } else { showMessage( LSPMessageType::Warning, result.result ); } - getUISceneNode()->runOnMainThread( [this] { mLoader->setVisible( false ); } ); + getUISceneNode()->runOnMainThread( [this, success = result.success()] { + if ( success ) + invalidateHistory(); + mLoader->setVisible( false ); + } ); } ); }; @@ -1135,6 +1146,7 @@ void GitPlugin::commit( const std::string& repoPath, bool mergeCommit ) { if ( res.success() ) { lifetime.run( [mergeCommit, repoPath]( GitPlugin* plugin ) { plugin->mLastCommitMsg.clear(); + plugin->invalidateHistory(); if ( mergeCommit ) { ++plugin->mConflictGeneration; plugin->mConflictSessions.erase( repoPath ); @@ -1199,7 +1211,7 @@ void GitPlugin::fastForwardMerge( Git::Branch branch ) { return mGit->updateRef( branch.name, remoteBranch[0].lastCommit, repoSelected() ); }, - false, true ); + false, true, false, false, false, true ); } // Branch operations @@ -2232,6 +2244,395 @@ std::unordered_map GitPlugin::updateReposBranches() { return prevBranch; } +void GitPlugin::invalidateHistory() { + ++mHistoryGeneration; + mHistoryLoaded = false; + mHistoryRepo.clear(); + if ( mPanelSwicher && mPanelSwicher->getListBox()->getItemSelectedIndex() == 2 ) + reloadHistory(); +} + +void GitPlugin::updateHistoryHeader() { + if ( !mHistoryRefText ) + return; + std::string branch; + { + const std::string repo = repoSelected(); + Lock l( mGitBranchMutex ); + auto it = mGitBranches.find( repo ); + if ( it != mGitBranches.end() ) + branch = it->second; + } + mHistoryRefText->setText( branch.empty() ? "HEAD" : branch ); +} + +void GitPlugin::ensureHistoryLoaded() { + const std::string repo = repoSelected(); + if ( !mHistoryLoaded || mHistoryRepo != repo ) + reloadHistory(); +} + +void GitPlugin::reloadHistory() { + if ( mShuttingDown || !mGit || !mGitFound || !mHistoryTree ) + return; + updateHistoryHeader(); + const std::string repo = repoSelected(); + const Uint64 generation = ++mHistoryGeneration; + mHistoryRepo = repo; + mHistoryLoaded = false; + if ( !mHistoryModel ) { + mHistoryModel = GitHistoryModel::asModel( this ); + mHistoryTree->setModel( mHistoryModel ); + mHistoryTree->setColumnsVisible( { GitHistoryModel::Subject } ); + } + mHistoryTree->clearViewMetadata(); + mHistoryModel->setRootLoading(); + Git::HistoryQuery query; + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + ++mRunningHistoryRequests; + mThreadPool->run( + [git = std::move( git ), lifetime, repo, generation, query]() mutable { + auto page = git->history( query, repo ); + lifetime.run( + [repo, generation, query, page = std::move( page )]( GitPlugin* plugin ) mutable { + if ( plugin->mShuttingDown || generation != plugin->mHistoryGeneration || + repo != plugin->repoSelected() ) + return; + plugin->mHistoryLoaded = true; + if ( page.success() ) + plugin->mHistoryModel->setRootPage( std::move( page ), query ); + else + plugin->mHistoryModel->setRootError( std::move( page.result ) ); + } ); + }, + [this]( auto ) { --mRunningHistoryRequests; } ); +} + +void GitPlugin::loadHistoryPage( GitHistoryModel::Node* node, Git::HistoryQuery query, + bool append ) { + if ( !node || mShuttingDown || node->childrenLoading ) + return; + if ( append && node->type != GitHistoryModel::NodeType::LoadMore && !node->retryAppend ) + return; + if ( !append && ( node->type != GitHistoryModel::NodeType::Commit || node->childrenLoaded ) ) + return; + const std::string repo = repoSelected(); + const Uint64 generation = mHistoryGeneration; + if ( append ) + mHistoryModel->setPageLoading( node ); + else + mHistoryModel->setChildrenLoading( node ); + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + ++mRunningHistoryRequests; + mThreadPool->run( + [git = std::move( git ), lifetime, repo, generation, node, query = std::move( query ), + append]() mutable { + auto page = git->history( query, repo ); + lifetime.run( [repo, generation, node, query = std::move( query ), append, + page = std::move( page )]( GitPlugin* plugin ) mutable { + if ( plugin->mShuttingDown || generation != plugin->mHistoryGeneration || + repo != plugin->repoSelected() ) + return; + if ( page.fail() ) { + if ( append ) + plugin->mHistoryModel->setPageError( node, std::move( page.result ) ); + else + plugin->mHistoryModel->setChildrenError( node, std::move( page.result ), + query ); + return; + } + if ( append ) + plugin->mHistoryModel->appendPage( node, std::move( page ) ); + else + plugin->mHistoryModel->setChildrenPage( node, std::move( page ), query ); + plugin->mHistoryTree->recalculateColumnsWidth(); + } ); + }, + [this]( auto ) { --mRunningHistoryRequests; } ); +} + +void GitPlugin::activateHistoryIndex( const ModelIndex& index, bool expand ) { + if ( !mHistoryModel ) + return; + auto* node = mHistoryModel->node( index ); + if ( !node ) + return; + if ( node->type == GitHistoryModel::NodeType::LoadMore ) { + if ( !expand && !node->childrenLoading ) + loadHistoryPage( node, node->query, true ); + return; + } + if ( node->type == GitHistoryModel::NodeType::Error && !expand ) { + if ( node->retryAppend && !node->childrenLoading ) + loadHistoryPage( node, node->query, true ); + else if ( node->parent && !node->parent->childrenLoading ) + loadHistoryPage( node->parent, node->query, false ); + else + reloadHistory(); + return; + } + if ( expand && node->type == GitHistoryModel::NodeType::Commit && + node->commit.parents.size() == 2 && !node->childrenLoaded && !node->childrenLoading ) { + loadHistoryPage( node, mHistoryModel->mergeQuery( node, 200 ), false ); + } +} + +void GitPlugin::openCommitDetails( const Git::Commit& commit ) { + if ( commit.hash.empty() ) + return; + const std::string repo = repoSelected(); + const Uint64 generation = ++mCommitDetailsGeneration; + mCommitDetailsCommit = commit; + mCommitDetailsRepo = repo; + + const bool newView = + !mCommitDetailsView || !mManager->getSplitter()->ownedWidgetExists( mCommitDetailsView ); + if ( newView ) { + mCommitDetailsView = getUISceneNode()->loadLayoutFromString( R"xml( + + + + + + + + + + + + + + + + + + + + + + )xml" ); + mCommitDetailsSubject = mCommitDetailsView->find( "git_commit_subject" ); + mCommitDetailsMetadata = mCommitDetailsView->find( "git_commit_metadata" ); + mCommitDetailsParents = mCommitDetailsView->find( "git_commit_parents" ); + mCommitDetailsMessage = mCommitDetailsView->find( "git_commit_message" ); + mCommitDetailsStatus = mCommitDetailsView->find( "git_commit_files_status" ); + mCommitDetailsMessageToggle = + mCommitDetailsView->find( "git_commit_message_toggle" ); + mCommitDetailsFilesToggle = + mCommitDetailsView->find( "git_commit_files_toggle" ); + mCommitDetailsModeToggle = + mCommitDetailsView->find( "git_commit_mode_toggle" ); + mCommitDetailsGitHub = mCommitDetailsView->find( "git_commit_github" ); + mCommitDetailsDiffContainer = mCommitDetailsView->find( "git_commit_diff" ); + mCommitDetailsMessageToggle->onClick( [this]( const Event* ) { + mCommitDetailsMessageExpanded = !mCommitDetailsMessageExpanded; + mCommitDetailsMessage->setVisible( mCommitDetailsMessageExpanded ); + mCommitDetailsMessageToggle->setText( + mCommitDetailsMessageExpanded + ? i18n( "git_collapse_commit_description", "Collapse Commit Description" ) + : i18n( "git_expand_commit_description", "Expand Commit Description" ) ); + } ); + mCommitDetailsFilesToggle->onClick( [this]( const Event* ) { + mCommitDetailsFilesCollapsed = !mCommitDetailsFilesCollapsed; + UIDiffView::setMultiFileCollapsed( mCommitDetailsDiff, mCommitDetailsFilesCollapsed ); + mCommitDetailsFilesToggle->setTooltipText( + mCommitDetailsFilesCollapsed + ? i18n( "git_expand_all_files", "Expand All Files" ) + : i18n( "git_collapse_all_files", "Collapse All Files" ) ); + if ( auto* icon = + findIcon( mCommitDetailsFilesCollapsed ? "expand-all" : "collapse-all" ) ) + mCommitDetailsFilesToggle->setIcon( + icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + } ); + mCommitDetailsModeToggle->onClick( [this]( const Event* ) { + mCommitDetailsViewMode = mCommitDetailsViewMode == UIDiffView::ViewMode::Unified + ? UIDiffView::ViewMode::SideBySide + : UIDiffView::ViewMode::Unified; + UIDiffView::setMultiFileViewMode( mCommitDetailsDiff, mCommitDetailsViewMode ); + mCommitDetailsModeToggle->setText( mCommitDetailsViewMode == + UIDiffView::ViewMode::Unified + ? i18n( "git_split_diff", "Split" ) + : i18n( "git_unified_diff", "Unified" ) ); + if ( auto* icon = findIcon( mCommitDetailsViewMode == UIDiffView::ViewMode::Unified + ? "split-horizontal" + : "layout" ) ) + mCommitDetailsModeToggle->setIcon( + icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + } ); + mCommitDetailsGitHub->onClick( [this]( const Event* ) { + if ( !mCommitDetailsURL.empty() ) + Engine::instance()->openURI( mCommitDetailsURL ); + } ); + mCommitDetailsView->find( "git_commit_sha" ) + ->onClick( [this]( const Event* ) { + getUISceneNode()->getWindow()->getClipboard()->setText( mCommitDetailsCommit.hash ); + } ); + auto* view = mCommitDetailsView; + mCommitDetailsCloseConnection = + view->connect( Event::OnClose, [this, view]( const Event* ) { + if ( mCommitDetailsView != view ) + return; + ++mCommitDetailsGeneration; + mCommitDetailsView = nullptr; + mCommitDetailsSubject = nullptr; + mCommitDetailsMetadata = nullptr; + mCommitDetailsParents = nullptr; + mCommitDetailsMessage = nullptr; + mCommitDetailsStatus = nullptr; + mCommitDetailsMessageToggle = nullptr; + mCommitDetailsFilesToggle = nullptr; + mCommitDetailsModeToggle = nullptr; + mCommitDetailsGitHub = nullptr; + mCommitDetailsDiffContainer = nullptr; + mCommitDetailsDiff = nullptr; + mCommitDetailsMessageBody.clear(); + mCommitDetailsURL.clear(); + mCommitDetailsRepo.clear(); + } ); + } + + mCommitDetailsSubject->setText( String::fromUtf8( commit.subject ) ); + String metadata = String::fromUtf8( commit.authorName ) + "\n" + + Sys::epochToString( commit.commitTime ) + " - " + + String::fromUtf8( commit.authorEmail ); + mCommitDetailsMetadata->setText( metadata ); + String parents; + for ( size_t i = 0; i < commit.parents.size(); ++i ) { + if ( i ) + parents += ", "; + parents += String::fromUtf8( commit.parents[i] ); + } + mCommitDetailsMessageBody.clear(); + mCommitDetailsMessageExpanded = false; + mCommitDetailsMessage->setText( "" ); + mCommitDetailsMessage->setVisible( false ); + mCommitDetailsMessageToggle->setVisible( false ); + mCommitDetailsParents->setVisible( !parents.empty() ); + mCommitDetailsParents->setText( i18n( "git_parents", "Parents" ) + ": " + parents ); + mCommitDetailsStatus->setText( + i18n( "git_loading_changed_files", "Loading changed files..." ) ); + mCommitDetailsFilesCollapsed = false; + mCommitDetailsFilesToggle->setTooltipText( + i18n( "git_collapse_all_files", "Collapse All Files" ) ); + if ( auto* icon = findIcon( "collapse-all" ) ) + mCommitDetailsFilesToggle->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + mCommitDetailsURL.clear(); + mCommitDetailsGitHub->setVisible( false ); + mCommitDetailsDiff = nullptr; + mCommitDetailsDiffContainer->closeAllChildren(); + auto* shaButton = mCommitDetailsView->find( "git_commit_sha" ); + shaButton->setText( String::fromUtf8( commit.shortHash ) ); + shaButton->setTooltipText( String::fromUtf8( commit.hash ) ); + + const std::string tabName = commit.shortHash + " " + commit.subject; + if ( newView ) { + mManager->getSplitter()->createWidget( mCommitDetailsView, tabName, true ); + } else { + auto tabs = mManager->getSplitter()->getTabFromOwnedWidgetId( mCommitDetailsView->getId() ); + if ( !tabs.empty() ) { + tabs.front().first->setText( tabName ); + tabs.front().second->setTabSelected( tabs.front().first ); + } + } + if ( generation == mCommitDetailsGeneration ) + loadCommitFiles(); +} + +void GitPlugin::loadCommitFiles() { + const Uint64 generation = mCommitDetailsGeneration; + const std::string repo = mCommitDetailsRepo; + const Git::Commit commit = mCommitDetailsCommit; + auto git = mGit; + const auto lifetime = mLifetime.weakHandle(); + runAsyncTask( [git = std::move( git ), lifetime, generation, repo, commit] { + auto result = git->commitFiles( commit, repo ); + lifetime.run( [generation, repo, commit, + result = std::move( result )]( GitPlugin* plugin ) mutable { + if ( plugin->mShuttingDown || generation != plugin->mCommitDetailsGeneration || + repo != plugin->mCommitDetailsRepo || repo != plugin->repoSelected() || + commit.hash != plugin->mCommitDetailsCommit.hash || !plugin->mCommitDetailsView || + !plugin->mManager->getSplitter()->ownedWidgetExists( plugin->mCommitDetailsView ) ) + return; + if ( result.fail() ) { + plugin->mCommitDetailsStatus->setText( + plugin->i18n( "git_changed_files_error", "Could not load changed files" ) + + ( result.result.empty() ? "" : ": " + result.result ) ); + return; + } + std::string body = std::move( result.message ); + if ( body.compare( 0, commit.subject.size(), commit.subject ) == 0 ) { + body.erase( 0, commit.subject.size() ); + while ( !body.empty() && ( body.front() == '\n' || body.front() == '\r' ) ) + body.erase( body.begin() ); + } + plugin->mCommitDetailsMessageBody = std::move( body ); + plugin->mCommitDetailsMessage->setText( + String::fromUtf8( plugin->mCommitDetailsMessageBody ) ); + plugin->mCommitDetailsMessage->setVisible( false ); + plugin->mCommitDetailsMessageExpanded = false; + plugin->mCommitDetailsMessageToggle->setVisible( + !plugin->mCommitDetailsMessageBody.empty() ); + plugin->mCommitDetailsMessageToggle->setText( + plugin->i18n( "git_expand_commit_description", "Expand Commit Description" ) ); + + int totalInserts = 0; + int totalDeletes = 0; + for ( const auto& file : result.files ) { + totalInserts += file.inserts; + totalDeletes += file.deletes; + } + if ( result.files.empty() ) { + plugin->mCommitDetailsStatus->setText( + plugin->i18n( "git_no_changed_files", "No changed files" ) ); + } else { + plugin->mCommitDetailsStatus->setText( String::format( + plugin->i18n( "git_changed_files_summary", "Changed files (%zu) +%d -%d" ) + .toUtf8(), + result.files.size(), totalInserts, totalDeletes ) ); + } + + plugin->mCommitDetailsURL = std::move( result.commitURL ); + plugin->mCommitDetailsGitHub->setVisible( !plugin->mCommitDetailsURL.empty() ); + plugin->mCommitDetailsDiffContainer->closeAllChildren(); + plugin->mCommitDetailsDiff = nullptr; + if ( !result.patch.empty() ) { + plugin->mCommitDetailsDiff = UIDiffView::NewMultiFileDiffViewer( + result.patch, repo, plugin->mCommitDetailsViewMode ); + plugin->mCommitDetailsDiff->setLayoutSizePolicy( SizePolicy::MatchParent, + SizePolicy::MatchParent ); + plugin->mCommitDetailsDiff->setParent( plugin->mCommitDetailsDiffContainer ); + for ( auto* diff : UIDiffView::multiFileDiffViews( plugin->mCommitDetailsDiff ) ) { + if ( const auto* scheme = plugin->getPluginContext()->getCurrentColorScheme() ) + diff->setSyntaxColorScheme( *scheme ); + } + } + const bool hasDiff = plugin->mCommitDetailsDiff != nullptr; + plugin->mCommitDetailsFilesToggle->setVisible( hasDiff ); + plugin->mCommitDetailsModeToggle->setVisible( hasDiff ); + } ); + } ); +} + void GitPlugin::updateBranches( bool force ) { if ( !mGit || !mGitFound || ( mRunningUpdateBranches && !force ) ) return; @@ -2254,14 +2655,29 @@ void GitPlugin::updateBranches( bool force ) { auto hash = GitBranchModel::hashBranches( branches ); auto model = GitBranchModel::asModel( std::move( branches ), hash, this ); + bool branchChanged; + { + Lock l( mGitBranchMutex ); + branchChanged = prevBranch != mGitBranches; + } if ( mBranchesTree && mBranchesTree->getModel() && static_cast( mBranchesTree->getModel() )->getHash() == hash ) { - if ( prevBranch != mGitBranches ) - mBranchesTree->getModel()->invalidate( Model::DontInvalidateIndexes ); + if ( branchChanged ) { + getUISceneNode()->runOnMainThread( [this] { + if ( mBranchesTree && mBranchesTree->getModel() ) + mBranchesTree->getModel()->invalidate( Model::DontInvalidateIndexes ); + updateHistoryHeader(); + invalidateHistory(); + } ); + } return; } - getUISceneNode()->runOnMainThread( [this, model] { updateBranchesUI( model ); } ); + getUISceneNode()->runOnMainThread( [this, model, branchChanged] { + updateBranchesUI( model ); + if ( branchChanged ) + invalidateHistory(); + } ); }, [this]( auto ) { mRunningUpdateBranches--; } ); } @@ -2290,6 +2706,7 @@ void GitPlugin::updateRepos() { void GitPlugin::updateBranchesUI( std::shared_ptr model ) { buildSidePanelTab(); + updateHistoryHeader(); if ( !model ) { mBranchesTree->setModel( model ); @@ -2340,10 +2757,13 @@ void GitPlugin::buildSidePanelTab() { return; if ( mSidePanel == nullptr ) getUISceneNode()->bind( "panel", mSidePanel ); + if ( !UIWidgetCreator::isWidgetRegistered( "GitHistoryTreeView" ) ) + UIWidgetCreator::registerWidget( "GitHistoryTreeView", GitHistoryTreeView::New ); static constexpr auto STYLE = R"html(