Add a work-in-progress Git History view to the source-control panel.

Display bounded first-parent commit history with asynchronous loading, independent pagination, and lazily expandable merge commits. Propagate inherited exclusions through nested merges to avoid repeating already represented history.
Add custom compact history rows, loading/error/retry states, keyboard navigation, repository and history-generation guards, and lifecycle invalidation after history-changing operations.
Add a commit details tab with full commit metadata and message, changed-line totals, GitHub links when available, and a multi-file diff viewer with global collapse and unified/split controls.
Extend UIDiffView with multi-file view controls and register the required Codicons.
The Git History interface and visual design remain a work in progress.
Fix caret positioning inside shaped ligature clusters.
Add explicit closest-glyph and interpolated ligature caret-positioning modes while keeping interpolation as the default for general text geometry.
Use the original closest-glyph behavior for caret and IME positioning in UICodeEditor, UITextEdit, UITextInput, and UIConsole, preventing cursor positions from being synthesized inside ligature glyphs.
Add regression coverage for both positioning modes.
Optimize SyntaxTokenizer by returning tokenizer results in a SmallVector, avoiding heap allocations in common cases.
This commit is contained in:
Martín Lucas Golini
2026-08-28 14:43:05 -03:00
parent 66d22ea5bb
commit baaa0a62c0
34 changed files with 1839 additions and 79 deletions
+11
View File
@@ -1254,4 +1254,15 @@ Für sichtbare Änderung ecode neu starten.</string>
<string name="default_diff_view_desc">Choose the initial layout used when opening text diffs.</string>
<string name="diff_view_side_by_side">Side by Side</string>
<string name="diff_view_unified">Unified</string>
<string name="git_history">Verlauf</string>
<string name="git_history_subject">Betreff</string>
<string name="git_history_author">Autor</string>
<string name="git_history_date">Datum</string>
<string name="git_history_hash">Hash</string>
<string name="git_history_loading">Wird geladen...</string>
<string name="git_history_refresh">Aktualisieren</string>
<string name="git_history_load_older">Ältere Commits laden</string>
<string name="git_history_empty">Noch keine Commits</string>
<string name="git_history_error">Git-Verlauf konnte nicht geladen werden</string>
<string name="git_history_retry">Wiederholen</string>
</resources>
+11
View File
@@ -1239,4 +1239,15 @@ Restart ecode to see the changes.</string>
<string name="default_diff_view_desc">Choose the initial layout used when opening text diffs.</string>
<string name="diff_view_side_by_side">Side by Side</string>
<string name="diff_view_unified">Unified</string>
<string name="git_history">History</string>
<string name="git_history_subject">Subject</string>
<string name="git_history_author">Author</string>
<string name="git_history_date">Date</string>
<string name="git_history_hash">Hash</string>
<string name="git_history_loading">Loading...</string>
<string name="git_history_refresh">Refresh</string>
<string name="git_history_load_older">Load older commits</string>
<string name="git_history_empty">No commits yet</string>
<string name="git_history_error">Could not load Git history</string>
<string name="git_history_retry">Retry</string>
</resources>
+11
View File
@@ -1238,4 +1238,15 @@ Redémarrer ecode pour voir les changements.</string>
<string name="default_diff_view_desc">Choose the initial layout used when opening text diffs.</string>
<string name="diff_view_side_by_side">Side by Side</string>
<string name="diff_view_unified">Unified</string>
<string name="git_history">Historique</string>
<string name="git_history_subject">Sujet</string>
<string name="git_history_author">Auteur</string>
<string name="git_history_date">Date</string>
<string name="git_history_hash">Hash</string>
<string name="git_history_loading">Chargement...</string>
<string name="git_history_refresh">Actualiser</string>
<string name="git_history_load_older">Charger les commits précédents</string>
<string name="git_history_empty">Aucun commit pour le moment</string>
<string name="git_history_error">Impossible de charger l’historique Git</string>
<string name="git_history_retry">Réessayer</string>
</resources>
+11
View File
@@ -1043,4 +1043,15 @@ file in the directory tree.</string>
<string name="default_diff_view_desc">Choose the initial layout used when opening text diffs.</string>
<string name="diff_view_side_by_side">Side by Side</string>
<string name="diff_view_unified">Unified</string>
<string name="git_history">历史</string>
<string name="git_history_subject">主题</string>
<string name="git_history_author">作者</string>
<string name="git_history_date">日期</string>
<string name="git_history_hash">哈希</string>
<string name="git_history_loading">正在加载...</string>
<string name="git_history_refresh">刷新</string>
<string name="git_history_load_older">加载更早的提交</string>
<string name="git_history_empty">尚无提交</string>
<string name="git_history_error">无法加载 Git 历史记录</string>
<string name="git_history_retry">重试</string>
</resources>
+14 -9
View File
@@ -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<Float> 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<Float> 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<Float> 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,
+1 -1
View File
@@ -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 );
+5 -5
View File
@@ -10,13 +10,13 @@ namespace EE { namespace UI { namespace Doc {
struct EE_API TokenizedLine {
SyntaxState initState;
String::HashType hash{ 0 };
std::vector<SyntaxTokenPosition> tokens;
SmallVector<SyntaxTokenPosition, 4> tokens;
SyntaxState state;
Uint64 signature{ 0 };
void updateSignature();
static Uint64 calcSignature( const std::vector<SyntaxTokenPosition>& tokens );
static Uint64 calcSignature( const SmallVector<SyntaxTokenPosition, 4>& tokens );
};
class EE_API SyntaxHighlighter {
@@ -31,10 +31,10 @@ class EE_API SyntaxHighlighter {
void invalidate( Int64 lineIndex );
const std::vector<SyntaxTokenPosition>& getLine( const size_t& index,
bool mustTokenize = true );
const SmallVector<SyntaxTokenPosition, 4>& getLine( const size_t& index,
bool mustTokenize = true );
void copyLineToBuffer( const size_t& index, std::vector<SyntaxTokenPosition>& buffer,
void copyLineToBuffer( const size_t& index, SmallVector<SyntaxTokenPosition, 4>& buffer,
bool mustTokenize = true );
Int64 getFirstInvalidLine() const;
+3 -3
View File
@@ -83,16 +83,16 @@ struct SyntaxState {
class EE_API SyntaxTokenizer {
public:
static std::pair<std::vector<SyntaxToken>, SyntaxState>
static std::pair<SmallVector<SyntaxToken, 4>, SyntaxState>
tokenize( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state,
const size_t& startIndex = 0, bool skipSubSyntaxSeparator = false );
static std::pair<std::vector<SyntaxTokenPosition>, SyntaxState>
static std::pair<SmallVector<SyntaxTokenPosition, 4>, SyntaxState>
tokenizePosition( const SyntaxDefinition& syntax, const std::string& text,
const SyntaxState& state, const size_t& startIndex = 0,
bool skipSubSyntaxSeparator = false );
static std::pair<std::vector<SyntaxTokenComplete>, SyntaxState>
static std::pair<SmallVector<SyntaxTokenComplete, 4>, SyntaxState>
tokenizeComplete( const SyntaxDefinition& syntax, const std::string& text,
const SyntaxState& state, const size_t& startIndex = 0,
bool skipSubSyntaxSeparator = false );
+10
View File
@@ -36,6 +36,12 @@ class EE_API UIDiffView : public UIWidget, public WidgetCommandExecuter {
static bool isMultiFileDiff( const std::string& diff );
static std::vector<UIDiffView*> 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<SyntaxDefinition> mSyntaxDef;
String mFileName;
std::string mImageDiffOldPath;
+5 -5
View File
@@ -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<Float> lineHeight = {},
bool allowVisualLineEnd = false,
bool visualizeNewLine = false ) const;
virtual Vector2d getTextPositionOffset(
const TextPosition& pos, std::optional<Float> lineHeight = {},
bool allowVisualLineEnd = false, bool visualizeNewLine = false,
Text::LigatureCaretMode ligatureCaretMode = Text::LigatureCaretMode::Interpolate ) const;
Vector2d getTextPositionOffsetSanitized( TextPosition pos,
std::optional<Float> 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<SyntaxTokenPosition> mTokens;
SmallVector<SyntaxTokenPosition, 4> mTokens;
TextDirection mTextDirection{ TextDirection::LeftToRight };
UICodeEditor( const bool& autoRegisterBaseCommands = true,
+19 -15
View File
@@ -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<Float> 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<Float> 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 ) {
+3 -1
View File
@@ -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;
+14
View File
@@ -1,3 +1,4 @@
#include <algorithm>
#include <eepp/system/thread.hpp>
#include <eepp/ui/abstract/uiabstractview.hpp>
#include <eepp/ui/css/propertydefinition.hpp>
@@ -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() ) {
+6 -6
View File
@@ -5,7 +5,7 @@
namespace EE { namespace UI { namespace Doc {
Uint64 TokenizedLine::calcSignature( const std::vector<SyntaxTokenPosition>& tokens ) {
Uint64 TokenizedLine::calcSignature( const SmallVector<SyntaxTokenPosition, 4>& tokens ) {
if ( !tokens.empty() ) {
return String::hash( reinterpret_cast<const char*>( tokens.data() ),
sizeof( SyntaxTokenPosition ) * tokens.size() );
@@ -149,9 +149,9 @@ void SyntaxHighlighter::tokenizeAsync( std::shared_ptr<ThreadPool> pool,
} );
}
const std::vector<SyntaxTokenPosition>& SyntaxHighlighter::getLine( const size_t& index,
bool mustTokenize ) {
static std::vector<SyntaxTokenPosition> noHighlightVector = {
const SmallVector<SyntaxTokenPosition, 4>& SyntaxHighlighter::getLine( const size_t& index,
bool mustTokenize ) {
static SmallVector<SyntaxTokenPosition, 4> noHighlightVector = {
{ SyntaxStyleTypes::Normal, 0, 0 } };
if ( mDoc->getSyntaxDefinition().getPatterns().empty() ) {
noHighlightVector[0].len = mDoc->getLineLength( index );
@@ -192,9 +192,9 @@ const std::vector<SyntaxTokenPosition>& SyntaxHighlighter::getLine( const size_t
}
void SyntaxHighlighter::copyLineToBuffer( const size_t& index,
std::vector<SyntaxTokenPosition>& buffer,
SmallVector<SyntaxTokenPosition, 4>& buffer,
bool mustTokenize ) {
static std::vector<SyntaxTokenPosition> noHighlightVector = {
static SmallVector<SyntaxTokenPosition, 4> noHighlightVector = {
{ SyntaxStyleTypes::Normal, 0, 0 } };
if ( mDoc->getSyntaxDefinition().getPatterns().empty() ) {
noHighlightVector[0].len = mDoc->getLineLength( index );
+7 -7
View File
@@ -51,7 +51,7 @@ static int isInMultiByteCodePoint( const char* text, const size_t& textSize, con
}
template <typename T>
static void pushToken( std::vector<T>& tokens, const SyntaxStyleType& type,
static void pushToken( SmallVector<T, 4>& 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 <typename T>
static inline void
pushTokensToOpenCloseSubsyntax( int i, std::string_view textv, const SyntaxPattern* subsyntaxInfo,
const NonEscapedMatch& rangeSubsyntax, std::vector<T>& tokens,
const NonEscapedMatch& rangeSubsyntax, SmallVector<T, 4>& tokens,
std::vector<size_t>& 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 <typename T>
static inline std::pair<std::vector<T>, SyntaxState>
static inline std::pair<SmallVector<T, 4>, SyntaxState>
_tokenize( const SyntaxDefinition& syntax, const std::string& text, const SyntaxState& state,
const size_t& startIndex, bool skipSubSyntaxSeparator ) {
std::vector<T> tokens;
SmallVector<T, 4> 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<std::vector<SyntaxToken>, SyntaxState>
std::pair<SmallVector<SyntaxToken, 4>, SyntaxState>
SyntaxTokenizer::tokenize( const SyntaxDefinition& syntax, const std::string& text,
const SyntaxState& state, const size_t& startIndex,
bool skipSubSyntaxSeparator ) {
return _tokenize<SyntaxToken>( syntax, text, state, startIndex, skipSubSyntaxSeparator );
}
std::pair<std::vector<SyntaxTokenPosition>, SyntaxState>
std::pair<SmallVector<SyntaxTokenPosition, 4>, 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<std::vector<SyntaxTokenComplete>, SyntaxState>
std::pair<SmallVector<SyntaxTokenComplete, 4>, SyntaxState>
SyntaxTokenizer::tokenizeComplete( const SyntaxDefinition& syntax, const std::string& text,
const SyntaxState& state, const size_t& startIndex,
bool skipSubSyntaxSeparator ) {
+4
View File
@@ -248,6 +248,10 @@ ResourcePtr<UIIconTheme> 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 },
+27
View File
@@ -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*> UIDiffView::multiFileDiffViews( UIScrollView* multiDiff ) {
return multiDiff ? multiDiff->findAllByType<UIDiffView>( UI_TYPE_DIFF_VIEW )
: std::vector<UIDiffView*>{};
}
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;
+8 -6
View File
@@ -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<Float> 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<Float>(),
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<Float>(), false,
mDoc->line( position.line() ).getTextHints() | getWidgetTextDrawHints(),
mTextDirection )
mTextDirection, {}, ligatureCaretMode )
.x;
if ( visualizeNewLine && isLastChar )
x += getGlyphWidth();
+7 -4
View File
@@ -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() ) {
+2 -1
View File
@@ -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 ) );
+3 -1
View File
@@ -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;
+4
View File
@@ -46,6 +46,7 @@ UITreeView::MetadataForIndex& UITreeView::getIndexMetadata( const ModelIndex& in
}
void UITreeView::onModelIndexDeleted( const void* internalData ) {
UIAbstractTableView::onModelIndexDeleted( internalData );
mViewMetadata.erase( const_cast<void*>( 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 );
}
@@ -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 ) );
}
+205
View File
@@ -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<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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" ) );
}
@@ -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<MoveTestModel>();
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<MoveTestModel>();
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 ) );
+13
View File
@@ -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 );
}
+285
View File
@@ -9,6 +9,7 @@
#include <eepp/system/sys.hpp>
#include <algorithm>
#include <charconv>
#include <cstdio>
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<std::string> 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<std::string> 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<std::string> 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";
+48
View File
@@ -201,6 +201,47 @@ class Git {
bool fail() const { return !success(); }
};
struct Commit {
std::string hash;
std::string shortHash;
std::vector<std::string> 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<std::string> exclusions;
std::string continuation;
size_t limit{ 200 };
};
struct HistoryPage : public Result {
std::vector<Commit> 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<CommitFile> 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;
@@ -0,0 +1,294 @@
#include "githistorymodel.hpp"
#include "gitplugin.hpp"
#include <algorithm>
#include <eepp/system/sys.hpp>
#include <iterator>
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<Node*>( 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<size_t>( 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::Node>
GitHistoryModel::commitNode( Git::Commit commit, Node* parent,
const Git::HistoryQuery& query ) const {
auto item = std::make_unique<Node>();
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<Node>();
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<Node>();
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<Node>();
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<Node>();
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<Node>();
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<Node>();
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
@@ -0,0 +1,104 @@
#ifndef ECODE_GITHISTORYMODEL_HPP
#define ECODE_GITHISTORYMODEL_HPP
#include "git.hpp"
#include <eepp/ui/models/model.hpp>
#include <memory>
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<std::unique_ptr<Node>> 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<GitHistoryModel> asModel( GitPlugin* plugin ) {
return std::make_shared<GitHistoryModel>( 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<std::unique_ptr<Node>>;
Nodes mRoots;
GitPlugin* mPlugin{ nullptr };
Nodes& siblings( Node* parent );
const Nodes& siblings( const Node* parent ) const;
std::unique_ptr<Node> 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
@@ -0,0 +1,61 @@
#include "githistorytreeview.hpp"
#include "githistorymodel.hpp"
#include <eepp/ui/uistyle.hpp>
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<GitHistoryModel*>( 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<Uint32>( 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<UIWidget>()->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<Int64>( getModel()->treeColumn() )
? GitHistoryTreeViewCell::New()
: UITableCell::New( mTag + "::cell" );
return setupCell( widget, rowWidget, index );
}
} // namespace ecode
@@ -0,0 +1,40 @@
#ifndef ECODE_GITHISTORYTREEVIEW_HPP
#define ECODE_GITHISTORYTREEVIEW_HPP
#include <eepp/graphics/text.hpp>
#include <eepp/ui/uitreeview.hpp>
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
+499 -13
View File
@@ -1,6 +1,8 @@
#include "gitplugin.hpp"
#include "../../settingspage.hpp"
#include "gitbranchmodel.hpp"
#include "githistorymodel.hpp"
#include "githistorytreeview.hpp"
#include "gitstatusmodel.hpp"
#include <eepp/graphics/image.hpp>
#include <eepp/graphics/primitives.hpp>
@@ -24,6 +26,8 @@
#include <eepp/ui/uitextedit.hpp>
#include <eepp/ui/uitooltip.hpp>
#include <eepp/ui/uitreeview.hpp>
#include <eepp/ui/uiwidgetcreator.hpp>
#include <eepp/window/engine.hpp>
#include <nlohmann/json.hpp>
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<std::string, std::string> 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(
<vbox id="git_commit_details" lw="mp" lh="mp">
<vbox lw="mp" lh="wc" padding="12dp">
<hbox lw="mp" lh="wc">
<TextView id="git_commit_metadata" lw="0" lw8="1" lh="wc"
word-wrap="true" focusable="false" />
<PushButton id="git_commit_sha" text="Commit SHA"
text-as-fallback="true" margin-left="8dp" />
</hbox>
<hbox lw="mp" lh="wc" margin-top="8dp">
<TextView id="git_commit_subject" lw="0" lw8="1" lh="wc"
font-size="14dp" word-wrap="true" focusable="false" />
<PushButton id="git_commit_message_toggle"
text="@string(git_expand_commit_description, Expand Commit Description)"
icon="icon(unfold, 12dp)" text-as-fallback="true"
margin-left="8dp" visible="false" />
</hbox>
<TextView id="git_commit_message" lw="mp" lh="wc" margin-top="8dp"
word-wrap="true" focusable="false" visible="false" />
<TextView id="git_commit_parents" lw="mp" lh="wc" margin-top="4dp"
word-wrap="true" focusable="false" />
</vbox>
<hbox lw="mp" lh="wc" padding-left="8dp" padding-right="8dp"
padding-top="4dp" padding-bottom="4dp">
<PushButton id="git_commit_files_toggle"
tooltip="@string(git_collapse_all_files, Collapse All Files)"
icon="icon(collapse-all, 12dp)" />
<PushButton id="git_commit_mode_toggle"
text="@string(git_split_diff, Split)"
icon="icon(split-horizontal, 12dp)" text-as-fallback="true"
margin-left="4dp" />
<TextView id="git_commit_files_status" lw="0" lw8="1" lh="wc"
margin-left="8dp" layout_gravity="center_vertical" focusable="false" />
<PushButton id="git_commit_github"
text="@string(git_view_on_github, View on GitHub)"
icon="icon(github, 12dp)" text-as-fallback="true"
visible="false" />
</hbox>
<vbox id="git_commit_diff" lw="mp" lh="0" lw8="1" />
</vbox>
)xml" );
mCommitDetailsSubject = mCommitDetailsView->find<UITextView>( "git_commit_subject" );
mCommitDetailsMetadata = mCommitDetailsView->find<UITextView>( "git_commit_metadata" );
mCommitDetailsParents = mCommitDetailsView->find<UITextView>( "git_commit_parents" );
mCommitDetailsMessage = mCommitDetailsView->find<UITextView>( "git_commit_message" );
mCommitDetailsStatus = mCommitDetailsView->find<UITextView>( "git_commit_files_status" );
mCommitDetailsMessageToggle =
mCommitDetailsView->find<UIPushButton>( "git_commit_message_toggle" );
mCommitDetailsFilesToggle =
mCommitDetailsView->find<UIPushButton>( "git_commit_files_toggle" );
mCommitDetailsModeToggle =
mCommitDetailsView->find<UIPushButton>( "git_commit_mode_toggle" );
mCommitDetailsGitHub = mCommitDetailsView->find<UIPushButton>( "git_commit_github" );
mCommitDetailsDiffContainer = mCommitDetailsView->find<UIWidget>( "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<UIPushButton>( "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<UIPushButton>( "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<GitBranchModel*>( 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<GitBranchModel> 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(
<style>
#git_branches_tree ScrollBar,
#git_status_tree ScrollBar {
#git_status_tree ScrollBar,
#git_history_tree ScrollBar {
opacity: 0;
transition: opacity 0.15;
}
@@ -2352,9 +2772,30 @@ void GitPlugin::buildSidePanelTab() {
#git_branches_tree ScrollBar:focus-within,
#git_status_tree:hover ScrollBar,
#git_status_tree ScrollBar.dragging,
#git_status_tree ScrollBar:focus-within {
#git_status_tree ScrollBar:focus-within,
#git_history_tree:hover ScrollBar,
#git_history_tree ScrollBar.dragging,
#git_history_tree ScrollBar:focus-within {
opacity: 1;
}
treeview::cell.git_history_secondary {
color: var(--font-hint);
}
treeview::cell.git_history_hash {
color: var(--font-hint);
font-family: monospace;
}
treeview::cell.git_history_action {
color: %s;
}
treeview::cell.git_history_merge {
font-style: italic;
}
treeview::row:selected treeview::cell.git_history_secondary,
treeview::row:selected treeview::cell.git_history_hash,
treeview::row:selected treeview::cell.git_history_action {
color: var(--font-selected-pressed);
}
treeview::cell.git_highlight_style {
color: %s;
}
@@ -2395,6 +2836,13 @@ void GitPlugin::buildSidePanelTab() {
<vbox id="git_status" lw="mp" lh="mp">
<TreeView id="git_status_tree" lw="mp" lh="mp" />
</vbox>
<vbox id="git_history" lw="mp" lh="mp">
<hbox lw="mp" lh="wc" padding="4dp">
<TextView id="git_history_ref" text="HEAD" lw="0" lw8="1" lh="wc" layout_gravity="center_vertical" text-overflow="ellipsis" />
<PushButton id="git_history_refresh" text="@string(git_history_refresh, Refresh)" icon="icon(refresh, 12dp)" text-as-fallback="true" />
</hbox>
<GitHistoryTreeView id="git_history_tree" lw="mp" lh="0" lw8="1" />
</vbox>
</StackWidget>
</vbox>
<TextView id="git_no_content" lw="mp" lh="wc" word-wrap="true" visible="false" text='@string(git_no_git_repo, "Current folder is not a Git repository.")' margin="8dp" text-align="center" />
@@ -2408,7 +2856,7 @@ void GitPlugin::buildSidePanelTab() {
: std::string{ DEFAULT_HIGHLIGHT_COLOR };
mTabContents = getUISceneNode()->loadLayoutFromString(
String::format( STYLE, color, color ), nullptr, String::hash( "git_plugin_style" ) );
String::format( STYLE, color, color, color ), nullptr, String::hash( "git_plugin_style" ) );
mTab = mSidePanel->add( i18n( "source_control", "Source Control" ), mTabContents,
icon ? icon->createDrawable( PixelDensity::dpToPx( 12 ) ) : nullptr );
@@ -2419,6 +2867,8 @@ void GitPlugin::buildSidePanelTab() {
mTabContents->bind( "git_panel_stack", mStackWidget );
mTabContents->bind( "git_branches_tree", mBranchesTree );
mTabContents->bind( "git_status_tree", mStatusTree );
mTabContents->bind( "git_history_tree", mHistoryTree );
mTabContents->bind( "git_history_ref", mHistoryRefText );
mTabContents->bind( "git_content", mGitContentView );
mTabContents->bind( "git_no_content", mGitNoContentView );
mTabContents->bind( "git_conflict_state", mConflictStateBar );
@@ -2429,6 +2879,8 @@ void GitPlugin::buildSidePanelTab() {
mTabContents->find( "branch_pull" )->onClick( [this]( auto ) { pull( repoSelected() ); } );
mTabContents->find( "branch_push" )->onClick( [this]( auto ) { push( repoSelected() ); } );
mTabContents->find( "branch_add" )->onClick( [this]( auto ) { branchCreate(); } );
mTabContents->find( "git_history_refresh" )->onClick( [this]( auto ) { reloadHistory(); } );
updateHistoryHeader();
mTabContents->find( "git_conflict_continue" )->onClick( [this]( auto ) {
continueConflictOperation();
} );
@@ -2493,17 +2945,45 @@ void GitPlugin::buildSidePanelTab() {
} );
auto listBox = mPanelSwicher->getListBox();
listBox->addListBoxItems( { i18n( "branches", "Branches" ), i18n( "status", "Status" ) } );
mStackMap.resize( 2 );
listBox->addListBoxItems( { i18n( "branches", "Branches" ), i18n( "status", "Status" ),
i18n( "git_history", "History" ) } );
mStackMap.resize( 3 );
mStackMap[0] = mTabContents->find<UIWidget>( "git_branches" );
mStackMap[1] = mTabContents->find<UIWidget>( "git_status" );
mStackMap[2] = mTabContents->find<UIWidget>( "git_history" );
mPanelSwicher->on( Event::OnItemSelected, [this, listBox]( const Event* ) {
mStackWidget->setActiveWidget( mStackMap[listBox->getItemSelectedIndex()] );
if ( listBox->getItemSelectedIndex() == 2 )
ensureHistoryLoaded();
} );
listBox->setSelected( 0 );
mStackWidget->setActiveWidget( mStackMap[0] );
mHistoryTree->setAutoExpandOnSingleColumn( true );
mHistoryTree->setFitAllColumnsToWidget( true );
mHistoryTree->setHorizontalScrollMode( ScrollBarMode::AlwaysOff );
mHistoryTree->setAutoColumnsWidth( true );
mHistoryTree->setRowHeight( PixelDensity::dpToPx( 36 ) );
mHistoryTree->setHeadersVisible( false );
mHistoryTree->setExpandersAsIcons( true );
mHistoryTree->setScrollViewType( ScrollViewType::Overlay );
mHistoryTree->setIndentWidth( PixelDensity::dpToPx( 16 ) );
mHistoryTree->on( Event::OnModelEvent, [this]( const Event* event ) {
const auto* modelEvent = static_cast<const ModelEvent*>( event );
if ( modelEvent->getModelEventType() == ModelEventType::OpenTree )
activateHistoryIndex( modelEvent->getModelIndex(), true );
else if ( modelEvent->getModelEventType() == ModelEventType::Open )
activateHistoryIndex( modelEvent->getModelIndex(), false );
} );
mHistoryTree->setOnSelection( [this]( const ModelIndex& index ) {
if ( !mHistoryModel )
return;
const auto* node = mHistoryModel->node( index );
if ( node && node->type == GitHistoryModel::NodeType::Commit )
openCommitDetails( node->commit );
} );
mStatusTree->setAutoColumnsWidth( true );
mStatusTree->setHeadersVisible( false );
mStatusTree->setExpandersAsIcons( true );
@@ -2698,6 +3178,7 @@ void GitPlugin::buildSidePanelTab() {
Lock l( mRepoMutex );
mRepoSelected = repo.first;
}
invalidateHistory();
updateBranches( true );
updateStatus( true );
break;
@@ -2875,17 +3356,20 @@ void GitPlugin::openFileStatusMenu( std::vector<Git::DiffFile> files ) {
void GitPlugin::runAsync( std::function<Git::Result()> fn, bool _updateStatus, bool _updateBranches,
bool displaySuccessMsg, bool updateBranchesOnError,
bool updateStatusOnError ) {
bool updateStatusOnError, bool historyChanged ) {
if ( !mGit )
return;
mLoader->setVisible( true );
const auto lifetime = mLifetime.weakHandle();
runAsyncTask( [lifetime, fn, _updateStatus, _updateBranches, displaySuccessMsg,
updateBranchesOnError, updateStatusOnError] {
updateBranchesOnError, updateStatusOnError, historyChanged] {
auto res = fn();
lifetime.run( [res = std::move( res ), _updateStatus, _updateBranches, displaySuccessMsg,
updateBranchesOnError, updateStatusOnError]( GitPlugin* plugin ) mutable {
updateBranchesOnError, updateStatusOnError,
historyChanged]( GitPlugin* plugin ) mutable {
plugin->mLoader->setVisible( false );
if ( res.success() && historyChanged )
plugin->invalidateHistory();
if ( res.fail() || displaySuccessMsg ) {
plugin->showMessage( LSPMessageType::Warning, res.result );
if ( _updateBranches && updateBranchesOnError )
@@ -2917,6 +3401,8 @@ void GitPlugin::runMergeLikeAsync( std::function<Git::Result( Git& )> fn,
lifetime.run( [result = std::move( result ),
conflicts = std::move( conflicts )]( GitPlugin* plugin ) mutable {
plugin->mLoader->setVisible( false );
if ( result.success() )
plugin->invalidateHistory();
plugin->updateBranches();
plugin->updateStatus( true );
if ( result.fail() && !conflicts.hasConflicts() )
+48 -2
View File
@@ -4,9 +4,11 @@
#include "../plugin.hpp"
#include "../pluginmanager.hpp"
#include "git.hpp"
#include "githistorymodel.hpp"
#include <eepp/scene/eventconnection.hpp>
#include <eepp/scene/mainthreadlifetime.hpp>
#include <eepp/ui/models/model.hpp>
#include <eepp/ui/tools/uidiffview.hpp>
#include <eepp/ui/tools/uimergeview.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <optional>
@@ -122,6 +124,9 @@ class GitPlugin : public PluginBase {
UIPushButton* mStatusButton{ nullptr };
UITreeView* mBranchesTree{ nullptr };
UITreeView* mStatusTree{ nullptr };
UITreeView* mHistoryTree{ nullptr };
UITextView* mHistoryRefText{ nullptr };
std::shared_ptr<GitHistoryModel> mHistoryModel;
UIDropDownList* mPanelSwicher{ nullptr };
UIDropDownList* mRepoDropDown{ nullptr };
UIStackWidget* mStackWidget{ nullptr };
@@ -132,6 +137,31 @@ class GitPlugin : public PluginBase {
UITextView* mConflictStateText{ nullptr };
UILoader* mLoader{ nullptr };
std::atomic<int> mRunningUpdateBranches{ 0 };
std::atomic<int> mRunningHistoryRequests{ 0 };
std::atomic<Uint64> mHistoryGeneration{ 0 };
std::string mHistoryRepo;
bool mHistoryLoaded{ false };
UIWidget* mCommitDetailsView{ nullptr };
UITextView* mCommitDetailsSubject{ nullptr };
UITextView* mCommitDetailsMetadata{ nullptr };
UITextView* mCommitDetailsMessage{ nullptr };
UITextView* mCommitDetailsParents{ nullptr };
UITextView* mCommitDetailsStatus{ nullptr };
UIPushButton* mCommitDetailsMessageToggle{ nullptr };
UIPushButton* mCommitDetailsFilesToggle{ nullptr };
UIPushButton* mCommitDetailsModeToggle{ nullptr };
UIPushButton* mCommitDetailsGitHub{ nullptr };
UIWidget* mCommitDetailsDiffContainer{ nullptr };
UIScrollView* mCommitDetailsDiff{ nullptr };
std::string mCommitDetailsMessageBody;
std::string mCommitDetailsURL;
bool mCommitDetailsMessageExpanded{ false };
bool mCommitDetailsFilesCollapsed{ false };
Tools::UIDiffView::ViewMode mCommitDetailsViewMode{ Tools::UIDiffView::ViewMode::Unified };
Git::Commit mCommitDetailsCommit;
std::string mCommitDetailsRepo;
std::atomic<Uint64> mCommitDetailsGeneration{ 0 };
EventConnection mCommitDetailsCloseConnection;
std::atomic<int> mRunningUpdateStatus{ 0 };
std::atomic<bool> mPendingForcedStatusUpdate{ false };
std::shared_ptr<std::atomic<int>> mRunningAsyncTasks{ std::make_shared<std::atomic<int>>( 0 ) };
@@ -152,7 +182,7 @@ class GitPlugin : public PluginBase {
UnorderedMap<std::string, std::unique_ptr<GitConflictSession>> mConflictSessions;
std::string mActiveConflictRepo;
Tools::UIMergeView* mConflictView{ nullptr };
EE::Scene::EventConnection mConflictViewCloseConnection;
EventConnection mConflictViewCloseConnection;
Uint64 mConflictGeneration{ 0 };
Uint32 mRepositionCbId{ 0 };
@@ -252,6 +282,22 @@ class GitPlugin : public PluginBase {
void updateBranches( bool force = false );
void ensureHistoryLoaded();
void updateHistoryHeader();
void reloadHistory();
void invalidateHistory();
void loadHistoryPage( GitHistoryModel::Node* node, Git::HistoryQuery query, bool append );
void activateHistoryIndex( const ModelIndex& index, bool expand );
void openCommitDetails( const Git::Commit& commit );
void loadCommitFiles();
void buildSidePanelTab();
void updateBranchesUI( std::shared_ptr<GitBranchModel> );
@@ -268,7 +314,7 @@ class GitPlugin : public PluginBase {
void runAsync( std::function<Git::Result()> fn, bool updateStatus, bool updateBranches,
bool displaySuccessMsg = false, bool updateBranchesOnError = false,
bool updateStatusOnError = false );
bool updateStatusOnError = false, bool historyChanged = false );
void runMergeLikeAsync( std::function<Git::Result( Git& )> fn, const std::string& repoPath );
GitConflictSession* conflictSession( const std::string& repoPath );