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(