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(