diff --git a/bin/assets/i18n/de.xml b/bin/assets/i18n/de.xml index 98ef84fdc..222214b09 100644 --- a/bin/assets/i18n/de.xml +++ b/bin/assets/i18n/de.xml @@ -1264,6 +1264,12 @@ Für sichtbare Änderung ecode neu starten. Ältere Commits laden Noch keine Commits Keine weiteren Commits + Im Git-Verlauf anzeigen + Git-Commit-Verlauf – %s + Commit-SHA + Commit-SHA kopieren %s + Zur geteilten Diff-Ansicht wechseln + Zur vereinheitlichten Diff-Ansicht wechseln Git-Verlauf konnte nicht geladen werden Wiederholen Lokal diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index d4207b7e3..8c203c154 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -1249,6 +1249,12 @@ Restart ecode to see the changes. Load older commits No commits yet No additional commits + Show in Git History + Git Commit History - %s + Commit SHA + Copy Commit SHA %s + Switch to split diff view + Switch to unified diff view Could not load Git history Retry Local diff --git a/bin/assets/i18n/fr.xml b/bin/assets/i18n/fr.xml index 8655312ca..55582671e 100644 --- a/bin/assets/i18n/fr.xml +++ b/bin/assets/i18n/fr.xml @@ -1248,6 +1248,12 @@ Redémarrer ecode pour voir les changements. Charger les commits précédents Aucun commit pour le moment Aucun commit supplémentaire + Afficher dans l’historique Git + Historique des commits Git – %s + SHA du commit + Copier le SHA du commit %s + Passer à la vue des différences séparée + Passer à la vue des différences unifiée Impossible de charger l’historique Git Réessayer Locale diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index 5f7812978..3e815aa44 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -1053,6 +1053,12 @@ file in the directory tree. 加载更早的提交 尚无提交 没有其他提交 + 在 Git 历史记录中显示 + Git 提交历史记录 - %s + 提交 SHA + 复制提交 SHA %s + 切换到拆分差异视图 + 切换到统一差异视图 无法加载 Git 历史记录 重试 本地 diff --git a/include/eepp/ui/uistyle.hpp b/include/eepp/ui/uistyle.hpp index d181ad6be..2476c0aa9 100644 --- a/include/eepp/ui/uistyle.hpp +++ b/include/eepp/ui/uistyle.hpp @@ -141,7 +141,7 @@ class EE_API UIStyle : public UIState { /** Lazily allocated for styles that use substitutions. The common, non-reentrant resolution * needs no container allocation; nested slots are retained for later reuse. */ std::unique_ptr mPropertyResolutionSlot; - std::vector> mNestedPropertyResolutionSlots; + SmallVector, 2> mNestedPropertyResolutionSlots; bool mChangingState; bool mForceReapplyProperties; bool mDisableAnimations; diff --git a/include/eepp/ui/uiwidget.hpp b/include/eepp/ui/uiwidget.hpp index 84db6474c..1fabedec0 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -1342,6 +1342,17 @@ class EE_API UIWidget : public UINode { */ bool hasPseudoClass( const std::string& pseudoCls ) const; + /** + * @brief Checks if this widget has a specific pseudo-class. + * + * Determines whether this widget currently has the specified pseudo-class + * (e.g., hover, focus, active). + * + * @param pseudoCls The pseudo-class to check. + * @return True if the widget has the pseudo-class, false otherwise. + */ + bool hasPseudoClass( StyleSheetSelectorRule::PseudoClasses pseudoCls ) const; + /** * @brief Checks if the tooltip is enabled for this widget. * diff --git a/src/eepp/ui/iconmanager.cpp b/src/eepp/ui/iconmanager.cpp index 10653ca9f..5f721603d 100644 --- a/src/eepp/ui/iconmanager.cpp +++ b/src/eepp/ui/iconmanager.cpp @@ -261,6 +261,8 @@ ResourcePtr IconManager::init( const std::string& iconThemeName, { "error", 0xea87 }, { "search-fuzzy", 0xec0d }, { "source-control", 0xea68 }, + { "history", 0xea82 }, + { "git-commit", 0xeafc }, { "repo", 0xea62 }, { "repo-pull", 0xeb40 }, { "repo-push", 0xeb41 }, diff --git a/src/eepp/ui/uitextview.cpp b/src/eepp/ui/uitextview.cpp index 5cf4a0eac..408f67a55 100644 --- a/src/eepp/ui/uitextview.cpp +++ b/src/eepp/ui/uitextview.cpp @@ -735,6 +735,11 @@ void UITextView::onAlignChange() { } void UITextView::onSelectionChange() { + if ( mUsingCustomStyling ) { + invalidateDraw(); + return; + } + mTextCache.invalidateColors(); if ( selCurInit() != selCurEnd() ) { diff --git a/src/eepp/ui/uitreeview.cpp b/src/eepp/ui/uitreeview.cpp index 730ab9890..bce885270 100644 --- a/src/eepp/ui/uitreeview.cpp +++ b/src/eepp/ui/uitreeview.cpp @@ -308,7 +308,7 @@ UIWidget* UITreeView::updateCell( const Vector2& posIndex, const ModelInd if ( hasChildren ) { UIIcon* icon = getIndexMetadata( index ).open ? mExpandIcon : mContractIcon; DrawablePtr drawable = - icon ? icon->createDrawable( mExpanderIconSize ) : DrawablePtr{}; + icon ? icon->getSource( mExpanderIconSize ) : DrawablePtr{}; if ( drawable == nullptr ) { image->setVisible( false ); diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index 6f08fed0e..06d362a4d 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -1333,6 +1333,10 @@ bool UIWidget::hasPseudoClass( const std::string& pseudoCls ) const { return ( mPseudoClasses & StyleSheetSelectorRule::toPseudoClass( pseudoCls ) ) != 0; } +bool UIWidget::hasPseudoClass( StyleSheetSelectorRule::PseudoClasses pseudoCls ) const { + return ( mPseudoClasses & static_cast( pseudoCls ) ) != 0; +} + bool UIWidget::isTooltipEnabled() const { return ( mFlags & UI_TOOLTIP_ENABLED ) != 0; } diff --git a/src/tools/ecode/plugins/git/githistorymodel.cpp b/src/tools/ecode/plugins/git/githistorymodel.cpp index 3a7c1dfe2..44025547a 100644 --- a/src/tools/ecode/plugins/git/githistorymodel.cpp +++ b/src/tools/ecode/plugins/git/githistorymodel.cpp @@ -2,6 +2,7 @@ #include "gitplugin.hpp" #include #include +#include #include namespace ecode { @@ -49,6 +50,19 @@ ModelIndex GitHistoryModel::indexForNode( const Node* item, int column ) const { return {}; } +ModelIndex GitHistoryModel::indexForCommit( std::string_view hash, int column ) const { + std::function find = [&]( const Nodes& nodes ) -> const Node* { + for ( const auto& item : nodes ) { + if ( item->type == NodeType::Commit && item->commit.hash == hash ) + return item.get(); + if ( const Node* found = find( item->children ) ) + return found; + } + return nullptr; + }; + return indexForNode( find( mRoots ), column ); +} + ModelIndex GitHistoryModel::parentIndex( const ModelIndex& index ) const { Node* item = node( index ); return item ? indexForNode( item->parent ) : ModelIndex{}; @@ -241,7 +255,8 @@ void GitHistoryModel::setPageLoading( Node* item ) { if ( !item || item->childrenLoading ) return; item->childrenLoading = true; - item->type = NodeType::Loading; + // Keep the semantic node type intact: appendPage() must still identify this row as the + // pagination cursor when the asynchronous request completes. item->message = mPlugin->i18n( "git_history_loading", "Loading..." ); item->error.clear(); invalidate( Model::DontInvalidateIndexes ); diff --git a/src/tools/ecode/plugins/git/githistorymodel.hpp b/src/tools/ecode/plugins/git/githistorymodel.hpp index 5f01bc28b..277d908bb 100644 --- a/src/tools/ecode/plugins/git/githistorymodel.hpp +++ b/src/tools/ecode/plugins/git/githistorymodel.hpp @@ -14,7 +14,7 @@ class GitPlugin; class GitHistoryModel : public Model { public: enum class NodeType : uint8_t { Commit, LoadMore, Loading, Error, Empty }; - enum Column { Subject, Author, Date, Hash }; + enum Column { Subject, Date, Author, Hash }; struct Node { Git::Commit commit; Git::HistoryQuery query; @@ -43,7 +43,7 @@ class GitHistoryModel : public Model { size_t rowCount( const ModelIndex& index = {} ) const; - size_t columnCount( const ModelIndex& = {} ) const { return 1; } + size_t columnCount( const ModelIndex& = {} ) const { return 4; } std::string columnName( const size_t& column ) const; @@ -61,6 +61,8 @@ class GitHistoryModel : public Model { ModelIndex indexForNode( const Node* node, int column = 0 ) const; + ModelIndex indexForCommit( std::string_view hash, int column = Subject ) const; + void setRootLoading(); void setRootPage( Git::HistoryPage page, const Git::HistoryQuery& query ); diff --git a/src/tools/ecode/plugins/git/githistorytreeview.cpp b/src/tools/ecode/plugins/git/githistorytreeview.cpp index ace54c6d5..0e48190e7 100644 --- a/src/tools/ecode/plugins/git/githistorytreeview.cpp +++ b/src/tools/ecode/plugins/git/githistorytreeview.cpp @@ -50,8 +50,8 @@ void GitHistoryTreeViewCell::draw() { if ( mMetadataText.getString().empty() ) return; - const bool selected = getParent() && ( getParent()->asType()->getStyleState() & - UIState::StateFlagSelected ); + const bool selected = getParent() && ( getParent()->asType()->hasPseudoClass( + StyleSheetSelectorRule::PseudoClasses::Selected ) ); mMetadataText.setFillColor( selected ? mTextBox->getFontColor() : mHintColor ); mMetadataText.draw( std::floor( mScreenPos.x + mTextBox->getPixelsPosition().x ), diff --git a/src/tools/ecode/plugins/git/gitplugin.cpp b/src/tools/ecode/plugins/git/gitplugin.cpp index a48fd6f4f..026a6a869 100644 --- a/src/tools/ecode/plugins/git/gitplugin.cpp +++ b/src/tools/ecode/plugins/git/gitplugin.cpp @@ -1,4 +1,5 @@ #include "gitplugin.hpp" +#include "../../appconfig.hpp" #include "../../settingspage.hpp" #include "gitbranchmodel.hpp" #include "githistorymodel.hpp" @@ -22,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -628,6 +630,28 @@ void GitPlugin::updateStatusBarSync() { mStatusButton->invalidateDraw(); } +void GitPlugin::styleCommitFilesStatus( UITextView* status ) { + if ( !status ) + return; + status->setUsingCustomStyling( true ); + if ( !mCommitStatusCustomTokenizer.has_value() ) { + std::vector patterns; + patterns.emplace_back( SyntaxPattern( { ".*%((%d+)%)%s+(%+%d+)%s+(%-%d+)" }, + { "normal", "warning", "keyword", "type" } ) ); + SyntaxDefinition syntaxDef( "git_commit_files_status", {}, std::move( patterns ) ); + SyntaxColorScheme scheme( "git_commit_files_status", + { { "normal"_sst, { getVarColor( "--font" ) } }, + { "warning"_sst, { getVarColor( "--theme-warning" ) } }, + { "keyword"_sst, { getVarColor( "--theme-success" ) } }, + { "type"_sst, { getVarColor( "--theme-error" ) } } }, + {} ); + mCommitStatusCustomTokenizer = { std::move( syntaxDef ), std::move( scheme ) }; + } + SyntaxTokenizer::tokenizeText( mCommitStatusCustomTokenizer->def, + mCommitStatusCustomTokenizer->scheme, status->getTextCache() ); + status->invalidateDraw(); +} + void GitPlugin::updateStatus( bool force ) { if ( !mGit || !mGitFound ) return; @@ -793,6 +817,9 @@ PluginRequestHandle GitPlugin::processMessage( const PluginMessage& msg ) { } case ecode::PluginMessageType::UIThemeReloaded: { mStatusCustomTokenizer.reset(); + mCommitStatusCustomTokenizer.reset(); + styleCommitFilesStatus( mCommitDetails.status ); + styleCommitFilesStatus( mDetachedHistory.details.status ); updateUINow( true ); break; } @@ -2245,6 +2272,7 @@ void GitPlugin::onRegister( UICodeEditor* editor ) { doc.setCommand( "git-push", [this] { push( projectPath() ); } ); doc.setCommand( "git-fetch", [this] { fetch( projectPath() ); } ); doc.setCommand( "git-commit", [this] { commit( projectPath() ); } ); + doc.setCommand( "git-show-history", [this] { showGitHistory(); } ); } void GitPlugin::onUnregister( UICodeEditor* editor ) { @@ -2373,6 +2401,7 @@ void GitPlugin::updateHistoryRefs( const std::shared_ptr& model mHistoryRefDropDown->getListView()->getSelection().set( mHistoryRefModel->index( selected, 0 ) ); mUpdatingHistoryRefs = false; + updateDetachedHistoryTitle(); if ( revisionChanged ) invalidateHistory(); } @@ -2397,6 +2426,8 @@ void GitPlugin::reloadHistory() { mHistoryTree->setColumnsVisible( { GitHistoryModel::Subject } ); } mHistoryTree->clearViewMetadata(); + if ( mDetachedHistory.tree ) + mDetachedHistory.tree->clearViewMetadata(); mHistoryModel->setRootLoading(); Git::HistoryQuery query; query.revision = mHistoryRevision; @@ -2416,6 +2447,7 @@ void GitPlugin::reloadHistory() { plugin->mHistoryModel->setRootPage( std::move( page ), query ); else plugin->mHistoryModel->setRootError( std::move( page.result ) ); + plugin->focusDetachedHistory(); } ); }, [this]( auto ) { --mRunningHistoryRequests; } ); @@ -2460,6 +2492,8 @@ void GitPlugin::loadHistoryPage( GitHistoryModel::Node* node, Git::HistoryQuery else plugin->mHistoryModel->setChildrenPage( node, std::move( page ), query ); plugin->mHistoryTree->recalculateColumnsWidth(); + if ( plugin->mDetachedHistory.tree ) + plugin->mDetachedHistory.tree->recalculateColumnsWidth(); } ); }, [this]( auto ) { --mRunningHistoryRequests; } ); @@ -2491,212 +2525,368 @@ void GitPlugin::activateHistoryIndex( const ModelIndex& index, bool expand ) { } } +void GitPlugin::openHistoryMenu( const ModelIndex& index ) { + if ( !mHistoryModel ) + return; + const auto* node = mHistoryModel->node( index ); + if ( !node || node->type != GitHistoryModel::NodeType::Commit ) + return; + UIPopUpMenu* menu = UIPopUpMenu::New(); + menuAdd( menu, "git-show-history", i18n( "git_show_in_history", "Show in Git History" ), + "history" ); + const Git::Commit commit = node->commit; + menu->on( Event::OnItemClicked, [this, commit]( const Event* event ) { + if ( event->getNode()->asType()->getId() == "git-show-history" ) + showGitHistory( &commit ); + } ); + menu->showOverMouseCursor(); +} + +std::string GitPlugin::detachedHistoryTitle() { + String state = String::fromUtf8( mHistoryRevision ); + if ( mHistoryRefDropDown && mHistoryRefModel ) { + const ModelIndex selected = mHistoryRefDropDown->getListView()->getSelection().first(); + if ( selected.isValid() ) { + Variant display = mHistoryRefModel->data( selected ); + if ( display.is( Variant::Type::StringPtr ) ) + state = display.asStringPtr(); + else if ( display.isValid() ) + state = display.toString(); + } + } + return String::format( i18n( "git_commit_history_title", "Git Commit History - %s" ).toUtf8(), + state.toUtf8() ); +} + +void GitPlugin::updateDetachedHistoryTitle() { + if ( !mDetachedHistory.view ) + return; + auto tabs = mManager->getSplitter()->getTabFromOwnedWidgetId( mDetachedHistory.view->getId() ); + if ( !tabs.empty() ) + tabs.front().first->setText( detachedHistoryTitle() ); +} + +void GitPlugin::showGitHistory( const Git::Commit* commit ) { + if ( !mHistoryTree ) + return; + ensureHistoryLoaded(); + if ( commit ) + mDetachedHistory.focusHash = commit->hash; + else + mDetachedHistory.focusHash.clear(); + + if ( !mDetachedHistory.view || + !mManager->getSplitter()->ownedWidgetExists( mDetachedHistory.view ) ) { + mDetachedHistory.view = getUISceneNode() + ->loadLayoutFromString( R"xml( + + + + + + )xml" ) + ->asType(); + mDetachedHistory.view->setAlwaysShowSplitter( false ); + mDetachedHistory.view->setHideSplitterOnEdge( true ); + mDetachedHistory.view->setSplitPartition( StyleSheetLength( "100%" ) ); + mDetachedHistory.tree = + mDetachedHistory.view->find( "git_history_detached_tree" ); + mDetachedHistory.tree->setModel( mHistoryModel ); + mDetachedHistory.tree->setHeadersVisible( true ); + mDetachedHistory.tree->setRowHeight( PixelDensity::dpToPx( 24 ) ); + mDetachedHistory.tree->setMainColumn( GitHistoryModel::Subject ); + mDetachedHistory.tree->setFitAllColumnsToWidget( true ); + mDetachedHistory.tree->setColumnWidthMode( + UIAbstractTableView::ColumnWidthMode::Percentage ); + mDetachedHistory.tree->setColumnsWidthPercentage( { 0.55f, 0.15f, 0.2f, 0.1f } ); + const std::string savedColumns = getPluginContext()->getConfig().iniState.getValue( + "git", "commit_history_columns", "" ); + if ( !savedColumns.empty() ) { + auto columns = nlohmann::json::parse( savedColumns, nullptr, false ); + if ( !columns.is_discarded() ) + mDetachedHistory.tree->unserializeColumnWidths( columns ); + } + mDetachedHistory.tree->setIndentWidth( PixelDensity::dpToPx( 16 ) ); + mDetachedHistory.tree->setSetupCellCb( []( UITableCell* cell ) { + cell->on( Event::OnTooltipCreated, []( const Event* event ) { + auto* tooltip = event->getNode()->asType()->getTooltip(); + tooltip->setMaxWidthEq( "60%" ); + tooltip->setWordWrap( true ); + tooltip->setHorizontalAlign( UI_HALIGN_LEFT ); + } ); + } ); + mDetachedHistory.tree->on( Event::OnModelEvent, [this]( const Event* event ) { + const auto* modelEvent = static_cast( event ); + if ( modelEvent->getModelEventType() == ModelEventType::OpenTree ) + activateHistoryIndex( modelEvent->getModelIndex(), true ); + else if ( modelEvent->getModelEventType() == ModelEventType::Open ) + activateHistoryIndex( modelEvent->getModelIndex(), false ); + else if ( modelEvent->getModelEventType() == ModelEventType::OpenMenu ) + openHistoryMenu( modelEvent->getModelIndex() ); + } ); + mDetachedHistory.tree->setOnSelection( [this]( const ModelIndex& index ) { + if ( const auto* node = mHistoryModel ? mHistoryModel->node( index ) : nullptr; + node && node->type == GitHistoryModel::NodeType::Commit ) + openDetachedCommitDetails( node->commit ); + } ); + auto* view = mDetachedHistory.view; + mDetachedHistory.closeConnection = + view->connect( Event::OnClose, [this, view]( const Event* ) { + if ( mDetachedHistory.view != view ) + return; + getPluginContext()->getConfig().iniState.setValue( + "git", "commit_history_columns", + mDetachedHistory.tree->serializeColumnWidths().dump() ); + mDetachedHistory.reset(); + } ); + auto* tab = mManager->getSplitter() + ->createWidget( mDetachedHistory.view, detachedHistoryTitle(), true ) + .first; + if ( tab ) + tab->setIcon( iconDrawable( "history", 12 ) ); + } else { + auto tabs = + mManager->getSplitter()->getTabFromOwnedWidgetId( mDetachedHistory.view->getId() ); + if ( !tabs.empty() ) + tabs.front().second->setTabSelected( tabs.front().first ); + } + + focusDetachedHistory(); +} + +void GitPlugin::focusDetachedHistory() { + if ( !mDetachedHistory.tree || !mHistoryModel || !mHistoryLoaded ) + return; + ModelIndex index; + if ( mDetachedHistory.focusHash.empty() ) + index = mHistoryModel->index( 0, GitHistoryModel::Subject ); + else + index = mHistoryModel->indexForCommit( mDetachedHistory.focusHash ); + if ( !index.isValid() ) + return; + mDetachedHistory.tree->setSelection( index, true, true ); + if ( const auto* node = mHistoryModel->node( index ); + node && node->type == GitHistoryModel::NodeType::Commit && + ( !mDetachedHistory.details.view || + mDetachedHistory.details.commit.hash != node->commit.hash ) ) + openDetachedCommitDetails( node->commit ); +} + +void GitPlugin::openDetachedCommitDetails( const Git::Commit& commit ) { + if ( commit.hash.empty() || !mDetachedHistory.view ) + return; + if ( !mDetachedHistory.detailsHost ) { + mDetachedHistory.detailsHost = UILinearLayout::NewVertical(); + mDetachedHistory.detailsHost->setId( "git_history_detached_details" ); + mDetachedHistory.detailsHost->setLayoutSizePolicy( SizePolicy::MatchParent, + SizePolicy::MatchParent ); + mDetachedHistory.detailsHost->setParent( mDetachedHistory.view ); + mDetachedHistory.view->setSplitPartition( StyleSheetLength( "55%" ) ); + } + mDetachedHistory.details.openCommitDetails( *this, commit, true ); +} + void GitPlugin::openCommitDetails( const Git::Commit& commit ) { + mCommitDetails.openCommitDetails( *this, commit, false ); +} + +void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const Git::Commit& commit, + bool detached ) { if ( commit.hash.empty() ) return; - const std::string repo = repoSelected(); - const Uint64 generation = ++mCommitDetailsGeneration; - mCommitDetailsCommit = commit; - mCommitDetailsRepo = repo; + const std::string selectedRepo = plugin.repoSelected(); + const Uint64 requestGeneration = ++generation; + this->commit = commit; + repo = selectedRepo; - const bool newView = - !mCommitDetailsView || !mManager->getSplitter()->ownedWidgetExists( mCommitDetailsView ); - if ( newView ) { - mCommitDetailsView = getUISceneNode()->loadLayoutFromString( R"xml( + const bool createView = !view; + if ( createView ) { + view = plugin.getUISceneNode()->loadLayoutFromString( R"xml( - - - - - + margin-left="8dp" visible="false" class="git_commit_btn" /> + + + + - - + + + + icon="icon(collapse-all, 12dp)" class="git_commit_btn" /> + margin-left="4dp" class="git_commit_btn" /> - )xml" ); - mCommitDetailsSubject = mCommitDetailsView->find( "git_commit_subject" ); - mCommitDetailsMetadata = mCommitDetailsView->find( "git_commit_metadata" ); - mCommitDetailsParents = mCommitDetailsView->find( "git_commit_parents" ); - mCommitDetailsMessage = mCommitDetailsView->find( "git_commit_message" ); - mCommitDetailsStatus = mCommitDetailsView->find( "git_commit_files_status" ); - mCommitDetailsMessageToggle = - mCommitDetailsView->find( "git_commit_message_toggle" ); - mCommitDetailsFilesToggle = - mCommitDetailsView->find( "git_commit_files_toggle" ); - mCommitDetailsModeToggle = - mCommitDetailsView->find( "git_commit_mode_toggle" ); - mCommitDetailsGitHub = mCommitDetailsView->find( "git_commit_github" ); - mCommitDetailsDiffContainer = mCommitDetailsView->find( "git_commit_diff" ); - mCommitDetailsMessageToggle->onClick( [this]( const Event* ) { - mCommitDetailsMessageExpanded = !mCommitDetailsMessageExpanded; - mCommitDetailsMessage->setVisible( mCommitDetailsMessageExpanded ); - mCommitDetailsMessageToggle->setText( - mCommitDetailsMessageExpanded - ? i18n( "git_collapse_commit_description", "Collapse Commit Description" ) - : i18n( "git_expand_commit_description", "Expand Commit Description" ) ); + if ( detached ) + view->setParent( plugin.mDetachedHistory.detailsHost ); + auto* state = this; + auto* owner = &plugin; + view->bind( "git_commit_subject", subject ); + view->bind( "git_commit_author", author ); + view->bind( "git_commit_date_email", dateEmail ); + view->bind( "git_commit_message", message ); + view->bind( "git_commit_files_status", status ); + view->bind( "git_commit_message_toggle", messageToggle ); + view->bind( "git_commit_files_toggle", filesToggle ); + view->bind( "git_commit_mode_toggle", modeToggle ); + view->bind( "git_commit_github", gitHub ); + view->bind( "git_commit_diff", diffContainer ); + messageToggle->onClick( [owner, state]( const Event* ) { + state->messageExpanded = !state->messageExpanded; + state->message->setVisible( state->messageExpanded ); + state->messageToggle->setText( + state->messageExpanded + ? owner->i18n( "git_collapse_commit_description", + "Collapse Commit Description" ) + : owner->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" ) ); + filesToggle->onClick( [owner, state]( const Event* ) { + state->filesCollapsed = !state->filesCollapsed; + UIDiffView::setMultiFileCollapsed( state->diff, state->filesCollapsed ); + state->filesToggle->setTooltipText( + state->filesCollapsed + ? owner->i18n( "git_expand_all_files", "Expand All Files" ) + : owner->i18n( "git_collapse_all_files", "Collapse All Files" ) ); if ( auto* icon = - findIcon( mCommitDetailsFilesCollapsed ? "expand-all" : "collapse-all" ) ) - mCommitDetailsFilesToggle->setIcon( - icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + owner->findIcon( state->filesCollapsed ? "expand-all" : "collapse-all" ) ) + state->filesToggle->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 ) ) ); + modeToggle->onClick( [owner, state]( const Event* ) { + state->viewMode = state->viewMode == UIDiffView::ViewMode::Unified + ? UIDiffView::ViewMode::SideBySide + : UIDiffView::ViewMode::Unified; + UIDiffView::setMultiFileViewMode( state->diff, state->viewMode ); + state->modeToggle->setText( state->viewMode == UIDiffView::ViewMode::Unified + ? owner->i18n( "git_split_diff", "Split" ) + : owner->i18n( "git_unified_diff", "Unified" ) ); + state->modeToggle->setTooltipText( + state->viewMode == UIDiffView::ViewMode::Unified + ? owner->i18n( "git_switch_to_split_diff", "Switch to split diff view" ) + : owner->i18n( "git_switch_to_unified_diff", "Switch to unified diff view" ) ); + if ( auto* icon = owner->findIcon( state->viewMode == UIDiffView::ViewMode::Unified + ? "split-horizontal" + : "layout" ) ) + state->modeToggle->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); } ); - mCommitDetailsGitHub->onClick( [this]( const Event* ) { - if ( !mCommitDetailsURL.empty() ) - Engine::instance()->openURI( mCommitDetailsURL ); + gitHub->onClick( [state]( const Event* ) { + if ( !state->url.empty() ) + Engine::instance()->openURI( state->url ); } ); - mCommitDetailsView->find( "git_commit_sha" ) - ->onClick( [this]( const Event* ) { - getUISceneNode()->getWindow()->getClipboard()->setText( mCommitDetailsCommit.hash ); - } ); - auto* view = mCommitDetailsView; - mCommitDetailsCloseConnection = - view->connect( Event::OnClose, [this, view]( const Event* ) { - if ( mCommitDetailsView != view ) - return; - ++mCommitDetailsGeneration; - mCommitDetailsView = nullptr; - mCommitDetailsSubject = nullptr; - mCommitDetailsMetadata = nullptr; - mCommitDetailsParents = nullptr; - mCommitDetailsMessage = nullptr; - mCommitDetailsStatus = nullptr; - mCommitDetailsMessageToggle = nullptr; - mCommitDetailsFilesToggle = nullptr; - mCommitDetailsModeToggle = nullptr; - mCommitDetailsGitHub = nullptr; - mCommitDetailsDiffContainer = nullptr; - mCommitDetailsDiff = nullptr; - mCommitDetailsMessageBody.clear(); - mCommitDetailsURL.clear(); - mCommitDetailsRepo.clear(); + view->find( "git_commit_show_history" ) + ->onClick( + [owner, state]( const Event* ) { owner->showGitHistory( &state->commit ); } ); + view->find( "git_commit_sha" )->onClick( [owner, state]( const Event* ) { + owner->getUISceneNode()->getWindow()->getClipboard()->setText( state->commit.hash ); + } ); + if ( !detached ) { + auto* view = this->view; + closeConnection = view->connect( Event::OnClose, [state, view]( const Event* ) { + if ( state->view == view ) + state->reset(); } ); + } } - mCommitDetailsSubject->setText( String::fromUtf8( commit.subject ) ); - String metadata = String::fromUtf8( commit.authorName ) + "\n" + - Sys::epochToString( commit.commitTime ) + " - " + - String::fromUtf8( commit.authorEmail ); - mCommitDetailsMetadata->setText( metadata ); - String parents; - for ( size_t i = 0; i < commit.parents.size(); ++i ) { - if ( i ) - parents += ", "; - parents += String::fromUtf8( commit.parents[i] ); - } - mCommitDetailsMessageBody.clear(); - mCommitDetailsMessageExpanded = false; - mCommitDetailsMessage->setText( "" ); - mCommitDetailsMessage->setVisible( false ); - mCommitDetailsMessageToggle->setVisible( false ); - mCommitDetailsParents->setVisible( !parents.empty() ); - mCommitDetailsParents->setText( i18n( "git_parents", "Parents" ) + ": " + parents ); - mCommitDetailsStatus->setText( - i18n( "git_loading_changed_files", "Loading changed files..." ) ); - mCommitDetailsFilesCollapsed = false; - mCommitDetailsFilesToggle->setTooltipText( - i18n( "git_collapse_all_files", "Collapse All Files" ) ); - if ( auto* icon = findIcon( "collapse-all" ) ) - mCommitDetailsFilesToggle->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); - mCommitDetailsURL.clear(); - mCommitDetailsGitHub->setVisible( false ); - mCommitDetailsDiff = nullptr; - mCommitDetailsDiffContainer->closeAllChildren(); - auto* shaButton = mCommitDetailsView->find( "git_commit_sha" ); - shaButton->setText( String::fromUtf8( commit.shortHash ) ); - shaButton->setTooltipText( String::fromUtf8( commit.hash ) ); + subject->setText( String::fromUtf8( commit.subject ) ); + author->setText( String::fromUtf8( commit.authorName ) ); + dateEmail->setText( Sys::epochToString( commit.commitTime ) + " - " + + String::fromUtf8( commit.authorEmail ) ); + messageBody.clear(); + messageExpanded = false; + message->setText( "" ); + message->setVisible( false ); + messageToggle->setVisible( false ); + status->setText( plugin.i18n( "git_loading_changed_files", "Loading changed files..." ) ); + plugin.styleCommitFilesStatus( status ); + filesCollapsed = false; + filesToggle->setTooltipText( plugin.i18n( "git_collapse_all_files", "Collapse All Files" ) ); + if ( auto* icon = plugin.findIcon( "collapse-all" ) ) + filesToggle->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + url.clear(); + gitHub->setVisible( false ); + diff = nullptr; + diffContainer->closeAllChildren(); + auto* shaButton = view->find( "git_commit_sha" ); + shaButton->setTooltipText( String::format( + plugin.i18n( "git_copy_commit_sha", "Copy Commit SHA\n%s" ).toUtf8(), 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 ( !detached && !plugin.mManager->getSplitter()->ownedWidgetExists( view ) ) { + auto tab = plugin.mManager->getSplitter()->createWidget( view, tabName, true ).first; + if ( tab ) + tab->setIcon( plugin.iconDrawable( "git-commit", 12 ) ); + } else if ( !detached ) { + auto tabs = plugin.mManager->getSplitter()->getTabFromOwnedWidgetId( view->getId() ); if ( !tabs.empty() ) { tabs.front().first->setText( tabName ); tabs.front().second->setTabSelected( tabs.front().first ); } } - if ( generation == mCommitDetailsGeneration ) - loadCommitFiles(); + if ( requestGeneration == generation ) + loadCommitFiles( plugin, detached ); } -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] { +void GitPlugin::CommitDetailsState::loadCommitFiles( GitPlugin& plugin, bool detached ) { + const Uint64 generation = this->generation; + const std::string repo = this->repo; + const Git::Commit commit = this->commit; + auto git = plugin.mGit; + const auto lifetime = plugin.mLifetime.weakHandle(); + plugin.runAsyncTask( [git = std::move( git ), lifetime, generation, repo, commit, detached] { auto result = git->commitFiles( commit, repo ); - lifetime.run( [generation, repo, commit, + lifetime.run( [generation, repo, commit, detached, 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 ) ) + auto& details = detached ? plugin->mDetachedHistory.details : plugin->mCommitDetails; + if ( plugin->mShuttingDown || generation != details.generation || + repo != details.repo || repo != plugin->repoSelected() || + commit.hash != details.commit.hash || !details.view || + ( detached + ? !plugin->mDetachedHistory.view + : !plugin->mManager->getSplitter()->ownedWidgetExists( details.view ) ) ) return; if ( result.fail() ) { - plugin->mCommitDetailsStatus->setText( + details.status->setText( plugin->i18n( "git_changed_files_error", "Could not load changed files" ) + ( result.result.empty() ? "" : ": " + result.result ) ); + plugin->styleCommitFilesStatus( details.status ); return; } std::string message = std::move( result.message ); const size_t subjectEnd = message.find_first_of( "\r\n" ); const std::string_view subject{ message.data(), subjectEnd == std::string::npos ? message.size() : subjectEnd }; - plugin->mCommitDetailsSubject->setText( + details.subject->setText( String::fromUtf8( subject.empty() ? commit.subject : std::string{ subject } ) ); std::string body; if ( subjectEnd != std::string::npos ) { @@ -2706,14 +2896,12 @@ void GitPlugin::loadCommitFiles() { ++bodyStart; body = message.substr( bodyStart ); } - 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( + details.messageBody = std::move( body ); + details.message->setText( String::fromUtf8( details.messageBody ) ); + details.message->setVisible( false ); + details.messageExpanded = false; + details.messageToggle->setVisible( !details.messageBody.empty() ); + details.messageToggle->setText( plugin->i18n( "git_expand_commit_description", "Expand Commit Description" ) ); int totalInserts = 0; @@ -2723,33 +2911,34 @@ void GitPlugin::loadCommitFiles() { totalDeletes += file.deletes; } if ( result.files.empty() ) { - plugin->mCommitDetailsStatus->setText( + details.status->setText( plugin->i18n( "git_no_changed_files", "No changed files" ) ); } else { - plugin->mCommitDetailsStatus->setText( String::format( + details.status->setText( String::format( plugin->i18n( "git_changed_files_summary", "Changed files (%zu) +%d -%d" ) .toUtf8(), result.files.size(), totalInserts, totalDeletes ) ); } + plugin->styleCommitFilesStatus( details.status ); - plugin->mCommitDetailsURL = std::move( result.commitURL ); - plugin->mCommitDetailsGitHub->setVisible( !plugin->mCommitDetailsURL.empty() ); - plugin->mCommitDetailsDiffContainer->closeAllChildren(); - plugin->mCommitDetailsDiff = nullptr; + details.url = std::move( result.commitURL ); + details.gitHub->setVisible( !details.url.empty() ); + details.diffContainer->closeAllChildren(); + details.diff = 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 ) ) { + details.diff = + UIDiffView::NewMultiFileDiffViewer( result.patch, repo, details.viewMode ); + details.diff->setLayoutSizePolicy( SizePolicy::MatchParent, + SizePolicy::MatchParent ); + details.diff->setParent( details.diffContainer ); + for ( auto* diff : UIDiffView::multiFileDiffViews( details.diff ) ) { if ( const auto* scheme = plugin->getPluginContext()->getCurrentColorScheme() ) diff->setSyntaxColorScheme( *scheme ); } } - const bool hasDiff = plugin->mCommitDetailsDiff != nullptr; - plugin->mCommitDetailsFilesToggle->setVisible( hasDiff ); - plugin->mCommitDetailsModeToggle->setVisible( hasDiff ); + const bool hasDiff = details.diff != nullptr; + details.filesToggle->setVisible( hasDiff ); + details.modeToggle->setVisible( hasDiff ); } ); } ); } @@ -2942,6 +3131,34 @@ void GitPlugin::buildSidePanelTab() { treeview::cell.git_highlight_style_clear > treeview::cell::icon { foreground-image: none, none; } + #git_commit_details .git_commit_btn { + lw: 20dp; + lh: 20dp; + padding: 0; + background-color: var(--list-back); + border-color: transparent; + } + #git_commit_details .git_commit_btn:hover { + border-color: var(--primary); + } + #git_commit_details #git_commit_author { + font-size: 11dp; + text-stroke-width: 1dp; + text-stroke-color: var(--list-back); + layout-gravity: center_vertical; + } + #git_commit_details #git_commit_date_email { + color: var(--font-hint); + } + #git_commit_details #git_commit_subject { + font-size: 13dp; + text-overflow: ellipsis; + word-wrap: true; + } + #git_commit_details #git_commit_sha { + lw: wc; + padding: 0dp 4dp; + } @@ -3022,6 +3239,7 @@ void GitPlugin::buildSidePanelTab() { if ( revision == mHistoryRevision ) return; mHistoryRevision = revision; + updateDetachedHistoryTitle(); invalidateHistory(); } ); updateHistoryRefs( @@ -3124,8 +3342,13 @@ void GitPlugin::buildSidePanelTab() { const auto* modelEvent = static_cast( event ); if ( modelEvent->getModelEventType() == ModelEventType::OpenTree ) activateHistoryIndex( modelEvent->getModelIndex(), true ); - else if ( modelEvent->getModelEventType() == ModelEventType::Open ) + else if ( modelEvent->getModelEventType() == ModelEventType::Open ) { activateHistoryIndex( modelEvent->getModelIndex(), false ); + if ( const auto* node = mHistoryModel->node( modelEvent->getModelIndex() ); + node && node->type == GitHistoryModel::NodeType::Commit ) + openCommitDetails( node->commit ); + } else if ( modelEvent->getModelEventType() == ModelEventType::OpenMenu ) + openHistoryMenu( modelEvent->getModelIndex() ); } ); mHistoryTree->setOnSelection( [this]( const ModelIndex& index ) { if ( !mHistoryModel ) diff --git a/src/tools/ecode/plugins/git/gitplugin.hpp b/src/tools/ecode/plugins/git/gitplugin.hpp index 2511fc538..45814d724 100644 --- a/src/tools/ecode/plugins/git/gitplugin.hpp +++ b/src/tools/ecode/plugins/git/gitplugin.hpp @@ -25,6 +25,7 @@ class UIStackWidget; class UIListBoxItem; class UIMenu; class UITextView; +class UISplitter; } // namespace EE::UI namespace ecode { @@ -167,27 +168,74 @@ class GitPlugin : public PluginBase { std::string mHistoryRevision{ "HEAD" }; bool mUpdatingHistoryRefs{ false }; 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 mCommitDetailsGeneration{ 0 }; - EventConnection mCommitDetailsCloseConnection; + struct CommitDetailsState { + UIWidget* view{ nullptr }; + UITextView* subject{ nullptr }; + UITextView* author{ nullptr }; + UITextView* dateEmail{ nullptr }; + UITextView* message{ nullptr }; + UITextView* status{ nullptr }; + UIPushButton* messageToggle{ nullptr }; + UIPushButton* filesToggle{ nullptr }; + UIPushButton* modeToggle{ nullptr }; + UIPushButton* gitHub{ nullptr }; + UIWidget* diffContainer{ nullptr }; + UIScrollView* diff{ nullptr }; + std::string messageBody; + std::string url; + Git::Commit commit; + std::string repo; + std::atomic generation{ 0 }; + EventConnection closeConnection; + Tools::UIDiffView::ViewMode viewMode{ Tools::UIDiffView::ViewMode::Unified }; + bool messageExpanded{ false }; + bool filesCollapsed{ false }; + + void openCommitDetails( GitPlugin& plugin, const Git::Commit& commit, bool detached ); + + void loadCommitFiles( GitPlugin& plugin, bool detached ); + + void reset() { + ++generation; + view = nullptr; + subject = nullptr; + author = nullptr; + dateEmail = nullptr; + message = nullptr; + status = nullptr; + messageToggle = nullptr; + filesToggle = nullptr; + modeToggle = nullptr; + gitHub = nullptr; + diffContainer = nullptr; + diff = nullptr; + messageBody.clear(); + url.clear(); + commit = {}; + repo.clear(); + viewMode = Tools::UIDiffView::ViewMode::Unified; + messageExpanded = false; + filesCollapsed = false; + } + }; + struct DetachedHistoryState { + UISplitter* view{ nullptr }; + UITreeView* tree{ nullptr }; + UIWidget* detailsHost{ nullptr }; + EventConnection closeConnection; + std::string focusHash; + CommitDetailsState details; + + void reset() { + view = nullptr; + tree = nullptr; + detailsHost = nullptr; + focusHash.clear(); + details.reset(); + } + }; + CommitDetailsState mCommitDetails; + DetachedHistoryState mDetachedHistory; std::atomic mRunningUpdateStatus{ 0 }; std::atomic mPendingForcedStatusUpdate{ false }; std::shared_ptr> mRunningAsyncTasks{ std::make_shared>( 0 ) }; @@ -217,6 +265,7 @@ class GitPlugin : public PluginBase { SyntaxColorScheme scheme; }; std::optional mStatusCustomTokenizer; + std::optional mCommitStatusCustomTokenizer; std::optional mTooltipCustomSyntaxDef; Uint32 mModelChangedId{ 0 }; Uint32 mModelStylerId{ 0 }; @@ -302,6 +351,8 @@ class GitPlugin : public PluginBase { void updateStatusBarSync(); + void styleCommitFilesStatus( UITextView* status ); + void updateUI(); void updateUINow( bool force = false ); @@ -324,7 +375,17 @@ class GitPlugin : public PluginBase { void openCommitDetails( const Git::Commit& commit ); - void loadCommitFiles(); + void showGitHistory( const Git::Commit* commit = nullptr ); + + void focusDetachedHistory(); + + void openHistoryMenu( const ModelIndex& index ); + + void openDetachedCommitDetails( const Git::Commit& commit ); + + std::string detachedHistoryTitle(); + + void updateDetachedHistoryTitle(); void buildSidePanelTab();