#include "gitplugin.hpp" #include "../../appconfig.hpp" #include "../../settingspage.hpp" #include "gitbranchmodel.hpp" #include "githistorymodel.hpp" #include "githistorytreeview.hpp" #include "gitstatusmodel.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace EE::UI; using namespace EE::UI::Doc; using namespace std::literals; using json = nlohmann::json; namespace ecode { GitHistoryRefModel::GitHistoryRefModel( std::shared_ptr source, String headLabel ) : mSource( std::move( source ) ), mHeadLabel( std::move( headLabel ) ) { if ( !mSource ) return; for ( size_t groupRow = 0; groupRow < mSource->rowCount( {} ); ++groupRow ) { const ModelIndex group = mSource->index( groupRow, GitBranchModel::Name, {} ); for ( size_t row = 0; row < mSource->rowCount( group ); ++row ) { const ModelIndex index = mSource->index( row, GitBranchModel::HistoryDisplay, group ); if ( mSource->branchRef( index ).type != Git::RefType::Stash ) mSourceIndexes.emplace_back( index ); } } } ModelIndex GitHistoryRefModel::index( int row, int column, const ModelIndex& parent ) const { if ( parent.isValid() || row < 0 || static_cast( row ) >= rowCount() || column != 0 ) return {}; return createIndex( row, column ); } Variant GitHistoryRefModel::data( const ModelIndex& index, ModelRole role ) const { if ( role != ModelRole::Display || !index.isValid() || index.row() < 0 || static_cast( index.row() ) >= rowCount() ) return {}; if ( index.row() == 0 ) return Variant( &mHeadLabel ); return mSource->data( mSourceIndexes[index.row() - 1], role ); } std::string GitHistoryRefModel::revision( size_t index ) const { if ( index == 0 || index > mSourceIndexes.size() ) return "HEAD"; const auto& branch = mSource->branchRef( mSourceIndexes[index - 1] ); switch ( branch.type ) { case Git::RefType::Head: return "refs/heads/" + branch.name; case Git::RefType::Remote: return "refs/remotes/" + branch.name; case Git::RefType::Tag: return "refs/tags/" + branch.name; default: return "HEAD"; } } bool GitHistoryRefModel::isRevision( size_t index, std::string_view revision ) const { if ( index == 0 || index > mSourceIndexes.size() ) return revision == "HEAD"; const auto& branch = mSource->branchRef( mSourceIndexes[index - 1] ); std::string_view prefix; switch ( branch.type ) { case Git::RefType::Head: prefix = "refs/heads/"; break; case Git::RefType::Remote: prefix = "refs/remotes/"; break; case Git::RefType::Tag: prefix = "refs/tags/"; break; default: return false; } return revision.size() == prefix.size() + branch.name.size() && revision.starts_with( prefix ) && revision.substr( prefix.size() ) == branch.name; } void GitPlugin::registerSettings( SettingsPage& page ) { page.addGroup( i18n( "general", "General" ) ); page.addText( "ui-refresh-frequency", "/config/ui_refresh_frequency", i18n( "git_ui_refresh_frequency", "Refresh Frequency" ), i18n( "git_ui_refresh_frequency_desc", "How often Git information in the interface is refreshed." ), mRefreshFreq.toString(), []( const std::string& text ) { Time value; return SettingsPage::parseNonNegativeSettingsTime( text, value ); } ); page.addBool( "statusbar-display-branch", "/config/statusbar_display_branch", i18n( "git_statusbar_display_branch", "Show Branch in Status Bar" ), i18n( "git_statusbar_display_branch_desc", "Display the current Git branch in the status bar." ), true ); page.addBool( "filetree-highlight-changes", "/config/filetree_highlight_changes", i18n( "git_filetree_highlight_changes", "Highlight File Tree Changes" ), i18n( "git_filetree_highlight_changes_desc", "Highlight files with Git changes in the file tree." ), true ); page.addText( "filetree-highlight-style-color", "/config/filetree_highlight_style_color", i18n( "git_filetree_highlight_style_color", "File Tree Highlight Color" ), i18n( "git_filetree_highlight_style_color_desc", "CSS color used to highlight changed files." ), "var(--font-highlight)" ); page.addBool( "statusbar-display-modifications", "/config/statusbar_display_modifications", i18n( "git_statusbar_display_modifications", "Show Modifications in Status Bar" ), i18n( "git_statusbar_display_modifications_desc", "Display repository modification counts in the status bar." ), true ); page.addBool( "status-recurse-submodules", "/config/status_recurse_submodules", i18n( "git_status_recurse_submodules", "Recurse into Submodules" ), i18n( "git_status_recurse_submodules_desc", "Include submodule changes when collecting repository status." ), true ); page.addBool( "silent", "/config/silent", i18n( "git_silent", "Silent Git Logs" ), i18n( "git_silent_desc", "Hide non-critical Git plugin log messages." ), true ); } static constexpr auto DEFAULT_HIGHLIGHT_COLOR = "var(--font-highlight)"sv; static constexpr auto GIT_STATUS_UPDATE_TAG = String::hash( "git::status-update" ); static std::string writeGitBlobTempFile( const std::string& contents, const std::string& sourceFilePath ) { if ( contents.empty() ) return ""; std::string ext( FileSystem::fileExtension( sourceFilePath ) ); std::string path( Sys::getTempPath() + ".ecode-git-diff-" + String::randString( 16 ) ); if ( !ext.empty() ) path += "." + ext; return FileSystem::fileWrite( path, contents ) ? path : ""; } std::string GitPlugin::statusTypeToString( Git::GitStatusType type ) { switch ( type ) { case Git::GitStatusType::Untracked: return i18n( "git_untracked", "Untracked" ); case Git::GitStatusType::Unmerged: return i18n( "git_conflicts", "Conflicts" ); case Git::GitStatusType::Changed: return i18n( "git_changed", "Changed" ); case Git::GitStatusType::Staged: return i18n( "git_staged", "Staged" ); case Git::GitStatusType::Ignored: return i18n( "git_ignored", "Ignored" ); } return ""; } std::vector GitPlugin::repos() { Lock l( mReposMutex ); std::vector ret; for ( const auto& repo : mRepos ) ret.push_back( repo.first ); return ret; } Plugin* GitPlugin::New( PluginManager* pluginManager ) { return eeNew( GitPlugin, ( pluginManager, false ) ); } Plugin* GitPlugin::NewSync( PluginManager* pluginManager ) { return eeNew( GitPlugin, ( pluginManager, true ) ); } GitPlugin::GitPlugin( PluginManager* pluginManager, bool sync ) : PluginBase( pluginManager ), mLifetime( this, getUISceneNode() ), mHighlightStyleColor( DEFAULT_HIGHLIGHT_COLOR ) { if ( sync ) { load( pluginManager ); } else { mThreadPool->run( [this, pluginManager] { load( pluginManager ); } ); } } GitPlugin::~GitPlugin() { mLifetime.invalidate(); waitUntilLoaded(); mShuttingDown = true; mConflictViewCloseConnection.disconnect(); mConflictView = nullptr; mConflictSessions.clear(); if ( mStatusButton ) mStatusButton->close(); if ( mSidePanel && mTab ) mSidePanel->removeTab( mTab ); endModelStyler(); if ( getUISceneNode() ) getUISceneNode()->removeActionsByTag( GIT_STATUS_UPDATE_TAG ); if ( mStatusBar && mRepositionCbId ) mStatusBar->removeEventListener( mRepositionCbId ); { Lock l( mGitBranchMutex ); } { Lock l( mGitStatusMutex ); } { Lock l( mRepoMutex ); } { Lock l( mReposMutex ); } // TODO: Add a signal for these waits while ( mRunningUpdateStatus ) Sys::sleep( Milliseconds( 1.f ) ); while ( mRunningUpdateBranches ) Sys::sleep( Milliseconds( 1.f ) ); while ( mRunningHistoryRequests ) Sys::sleep( Milliseconds( 1.f ) ); while ( *mRunningAsyncTasks ) Sys::sleep( Milliseconds( 1.f ) ); } void GitPlugin::runAsyncTask( std::function task ) { auto runningTasks = mRunningAsyncTasks; ++*runningTasks; mThreadPool->run( std::move( task ), [runningTasks = std::move( runningTasks )]( auto ) { --*runningTasks; } ); } void GitPlugin::load( PluginManager* pluginManager ) { Clock clock; AtomicBoolScopedOp loading( mLoading, true ); pluginManager->subscribeMessages( this, [this]( const auto& notification ) -> PluginRequestHandle { return processMessage( notification ); } ); std::string path = pluginManager->getPluginsPath() + "git.json"; if ( FileSystem::fileExists( path ) || FileSystem::fileWrite( path, "{\n \"config\":{},\n \"keybindings\":{}\n}\n" ) ) { mConfigPath = path; } std::string data; if ( !FileSystem::fileGet( path, data ) ) return; mConfigHash = String::hash( data ); json j; try { j = json::parse( data, nullptr, true, true ); } catch ( const json::exception& e ) { Log::error( "GitPlugin::load - Error parsing config from path %s, error: %s, config " "file content:\n%s", path.c_str(), e.what(), data.c_str() ); // Recreate it j = json::parse( "{\n \"config\":{},\n \"keybindings\":{},\n}\n", nullptr, true, true ); } bool updateConfigFile = false; if ( j.contains( "config" ) ) { auto& config = j["config"]; if ( config.contains( "ui_refresh_frequency" ) ) mRefreshFreq = Time::fromString( config.value( "ui_refresh_frequency", "5s" ) ); else { config["ui_refresh_frequency"] = mRefreshFreq.toString(); updateConfigFile = true; } if ( config.contains( "statusbar_display_branch" ) ) mStatusBarDisplayBranch = config.value( "statusbar_display_branch", true ); else { config["statusbar_display_branch"] = mStatusBarDisplayBranch; updateConfigFile = true; } if ( config.contains( "filetree_highlight_changes" ) ) mFileTreeHighlightChanges = config.value( "filetree_highlight_changes", true ); else { config["filetree_highlight_changes"] = mFileTreeHighlightChanges; updateConfigFile = true; } if ( config.contains( "filetree_highlight_style_color" ) ) { mHighlightStyleColor = config.value( "filetree_highlight_style_color", DEFAULT_HIGHLIGHT_COLOR ); } else { config["filetree_highlight_style_color"] = mHighlightStyleColor; updateConfigFile = true; } if ( config.contains( "statusbar_display_modifications" ) ) mStatusBarDisplayModifications = config.value( "statusbar_display_modifications", true ); else { config["statusbar_display_modifications"] = mStatusBarDisplayModifications; updateConfigFile = true; } if ( config.contains( "status_recurse_submodules" ) ) mStatusRecurseSubmodules = config.value( "status_recurse_submodules", true ); else { config["status_recurse_submodules"] = mStatusRecurseSubmodules; updateConfigFile = true; } if ( config.contains( "silent" ) ) mSilent = config.value( "silent", true ); else { config["silent"] = mSilent; updateConfigFile = true; } } if ( mKeyBindings.empty() ) { mKeyBindings["git-blame"] = "alt+shift+b"; } if ( j.contains( "keybindings" ) ) { auto& kb = j["keybindings"]; auto list = { "git-blame" }; for ( const auto& key : list ) { if ( kb.contains( key ) ) { if ( !kb[key].empty() ) mKeyBindings[key] = kb[key]; } else { kb[key] = mKeyBindings[key]; updateConfigFile = true; } } } if ( updateConfigFile ) { std::string newData = j.dump( 2 ); if ( newData != data ) { FileSystem::fileWrite( path, newData ); mConfigHash = String::hash( newData ); } } mGit = std::make_shared( pluginManager->getWorkspaceFolder() ); mGit->setSilent( mSilent ); mGitFound = !mGit->getGitPath().empty(); mProjectPath = mRepoSelected = mGit->getProjectPath(); if ( getUISceneNode() ) { mLifetime.setDispatcher( getUISceneNode() ); initModelStyler(); updateStatus(); updateBranches(); } subscribeFileSystemListener(); mReady = true; fireReadyCbs(); setReady( clock.getElapsedTime() ); } void GitPlugin::initModelStyler() { if ( !mFileTreeHighlightChanges ) return; auto projectView = getUISceneNode()->getRoot()->find( "project_view" ); if ( !projectView || !projectView->getModel() ) return; if ( mModelChangedId == 0 ) { mModelChangedId = projectView->on( Event::OnModelChanged, [this]( auto ) { initModelStyler(); } ); } mModelStylerId = projectView->getModel()->subscribeModelStyler( [this]( const ModelIndex& index, const void* data ) -> Variant { static const char* STYLE_MODIFIED = "git_highlight_style"; static const char* STYLE_NONE = "git_highlight_style_clear"; auto model = static_cast( index.model() ); auto node = static_cast( data ); Lock l( mGitStatusFileCacheMutex ); std::string_view nodePath = model->getNodeRelativePath( node ); auto found = std::find_if( mGitStatusFilesCache.begin(), mGitStatusFilesCache.end(), [&nodePath]( const std::string& key ) { return std::string_view{ key } == nodePath; } ); if ( found != mGitStatusFilesCache.end() ) return Variant( STYLE_MODIFIED ); return Variant( STYLE_NONE ); } ); } void GitPlugin::endModelStyler() { if ( !mFileTreeHighlightChanges || mModelStylerId == 0 || !SceneManager::existsSingleton() || SceneManager::instance()->isShuttingDown() ) return; auto projectView = getUISceneNode()->getRoot()->find( "project_view" ); if ( !projectView ) return; if ( mModelChangedId ) { projectView->removeEventListener( mModelChangedId ); mModelChangedId = 0; } if ( projectView->getModel() ) { projectView->getModel()->unsubscribeModelStyler( mModelStylerId ); mModelStylerId = 0; } } void GitPlugin::updateUINow( bool force ) { if ( !mGit || !getUISceneNode() ) return; if ( !mProjectPath.empty() ) mLifetime.weakHandle().run( []( GitPlugin* plugin ) { plugin->buildSidePanelTab(); } ); updateStatus( force ); updateBranches(); } void GitPlugin::updateUI() { if ( !mGit || !getUISceneNode() ) return; const auto lifetime = mLifetime.weakHandle(); getUISceneNode()->debounce( [lifetime] { lifetime.run( []( GitPlugin* plugin ) { plugin->updateUINow(); } ); }, mRefreshFreq, GIT_STATUS_UPDATE_TAG ); } void GitPlugin::updateStatusBarSync() { buildSidePanelTab(); mGitContentView->setVisible( !mGit->getGitFolder().empty() ) ->setEnabled( !mGit->getGitFolder().empty() ); mGitNoContentView->setVisible( !mGitContentView->isVisible() ) ->setEnabled( !mGitContentView->isEnabled() ); if ( !mGit->getGitFolder().empty() ) { { Lock l( mGitStatusMutex ); mStatusTree->setModel( GitStatusModel::asModel( mGitStatus.files, this ) ); } mStatusTree->expandAll(); } else { return; } if ( mConflictStateBar && mConflictStateText ) { auto* conflictSession = activeConflictSession(); const bool hasState = conflictSession && ( !conflictSession->files.empty() || conflictSession->operation != Git::GitOperation::None ); mConflictStateBar->setVisible( hasState ); if ( hasState ) { String operation; switch ( conflictSession->operation ) { case Git::GitOperation::Merge: operation = i18n( "git_operation_merge", "Merging" ); break; case Git::GitOperation::Rebase: operation = i18n( "git_operation_rebase", "Rebasing" ); break; case Git::GitOperation::CherryPick: operation = i18n( "git_operation_cherry_pick", "Cherry-picking" ); break; case Git::GitOperation::Revert: operation = i18n( "git_operation_revert", "Reverting" ); break; case Git::GitOperation::StashApply: operation = i18n( "git_operation_stash_apply", "Applying stash" ); break; case Git::GitOperation::None: operation = i18n( "git_conflicts", "Conflicts" ); break; } mConflictStateText->setText( conflictSession->files.empty() ? String::format( ( conflictSession->operation == Git::GitOperation::Merge ? i18n( "git_operation_ready_commit", "%s · Ready to commit" ) : i18n( "git_operation_ready", "%s · Ready to continue" ) ) .toUtf8(), operation.toUtf8().c_str() ) : String::format( ( conflictSession->files.size() == 1 ? i18n( "git_operation_conflict", "%s · %d conflict" ) : i18n( "git_operation_conflicts", "%s · %d conflicts" ) ) .toUtf8(), operation.toUtf8().c_str(), static_cast( conflictSession->files.size() ) ) ); const bool canContinue = conflictSession->files.empty() && conflictSession->operation != Git::GitOperation::None && conflictSession->operation != Git::GitOperation::StashApply; auto* continueButton = mTabContents->find( "git_conflict_continue" )->asType(); continueButton->setText( conflictSession->operation == Git::GitOperation::Merge ? i18n( "git_commit_merge", "Commit" ) : i18n( "git_continue_operation", "Continue" ) ); if ( auto* icon = getUISceneNode()->findIcon( conflictSession->operation == Git::GitOperation::Merge ? "git-commit" : "debug-continue" ) ) continueButton->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); continueButton->setEnabled( canContinue ); mTabContents->find( "git_conflict_abort" ) ->setEnabled( conflictSession->operation != Git::GitOperation::None && conflictSession->operation != Git::GitOperation::StashApply ); } } if ( !mStatusBarDisplayBranch ) return; if ( !mStatusBar ) getUISceneNode()->bind( "status_bar", mStatusBar ); if ( !mStatusBar ) return; if ( !mStatusButton ) { mStatusButton = UIPushButton::New(); mStatusButton->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::MatchParent ); mStatusButton->setParent( mStatusBar ); mStatusButton->setId( "git_status" ); mStatusButton->setClass( "status_but" ); mStatusButton->setIcon( iconDrawable( "source-control", 10 ) ); mStatusButton->reloadStyle( true, true ); mStatusButton->getTextView()->setUsingCustomStyling( true ); mStatusButton->on( Event::MouseClick, [this]( const Event* event ) { if ( nullptr == mTab ) return; mTab->setTabSelected(); if ( event->asMouseEvent()->getFlags() & EE_BUTTON_RMASK ) mPanelSwicher->getListBox()->setSelected( 0 ); else if ( mGitStatus.totalInserts || mGitStatus.totalDeletions ) mPanelSwicher->getListBox()->setSelected( 1 ); } ); const auto reposition = [this] { if ( mStatusBar == nullptr ) return; if ( mStatusButton->getNextNode() == nullptr || mStatusButton->getNextNode()->getId() != "doc_info" ) { auto docInfo = mStatusBar->find( "doc_info" ); if ( docInfo != nullptr && docInfo->getParent() == mStatusButton->getParent() ) { mStatusButton->toPosition( docInfo->getNodeIndex() ); } } }; reposition(); mRepositionCbId = mStatusBar->on( Event::OnChildCountChanged, [reposition]( auto ) { reposition(); } ); } mStatusButton->setVisible( !mGit->getGitFolder().empty() ); if ( mGit->getGitFolder().empty() ) return; std::string text; { Lock l( mGitStatusMutex ); text = mStatusBarDisplayModifications && ( mGitStatus.totalInserts || mGitStatus.totalDeletions ) ? String::format( "%s (+%d / -%d)", gitBranch().c_str(), mGitStatus.totalInserts, mGitStatus.totalDeletions ) : gitBranch(); } mStatusButton->setText( text ); if ( !mStatusBarDisplayModifications ) return; if ( !mStatusCustomTokenizer.has_value() ) { std::vector patterns; auto fontColor( getVarColor( "--font" ) ); auto insertedColor( getVarColor( "--theme-success" ) ); auto deletedColor( getVarColor( "--theme-error" ) ); patterns.emplace_back( SyntaxPattern( { ".*%((%+%d+)%s/%s(%-%d+)%)" }, { "normal", "keyword", "type" } ) ); SyntaxDefinition syntaxDef( "custom_build", {}, std::move( patterns ) ); SyntaxColorScheme scheme( "status_bar_git", { { "normal"_sst, { fontColor } }, { "keyword"_sst, { insertedColor } }, { "type"_sst, { deletedColor } } }, {} ); mStatusCustomTokenizer = { std::move( syntaxDef ), std::move( scheme ) }; } SyntaxTokenizer::tokenizeText( mStatusCustomTokenizer->def, mStatusCustomTokenizer->scheme, mStatusButton->getTextView()->getTextCache() ); 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; if ( mRunningUpdateStatus ) { if ( force ) mPendingForcedStatusUpdate = true; return; } if ( !mGit || mGit->getGitFolder().empty() ) { mLifetime.weakHandle().run( []( GitPlugin* plugin ) { plugin->updateStatusBarSync(); } ); return; } mRunningUpdateStatus++; UnorderedSet sessionRepos; for ( const auto& [repo, session] : mConflictSessions ) if ( session ) sessionRepos.insert( repo ); const std::string selectedRepo = repoSelected(); const auto lifetime = mLifetime.weakHandle(); mThreadPool->run( [this, lifetime, force, selectedRepo, sessionRepos = std::move( sessionRepos )] { if ( !mGit || mGit->getGitFolder().empty() ) { lifetime.run( []( GitPlugin* plugin ) { plugin->updateStatusBarSync(); } ); return; } auto prevBranch = updateReposBranches(); Git::Status prevGitStatus; { Lock l( mGitStatusMutex ); prevGitStatus = mGitStatus; } Git::Status newGitStatus = mGit->status( mStatusRecurseSubmodules ); UnorderedSet conflictRepos; for ( const auto& repo : newGitStatus.files ) { for ( const auto& file : repo.second ) if ( file.report.type == Git::GitStatusType::Unmerged ) conflictRepos.insert( mGit->repoPath( file.file ) ); } if ( !selectedRepo.empty() ) conflictRepos.insert( selectedRepo ); conflictRepos.insert( sessionRepos.begin(), sessionRepos.end() ); UnorderedMap conflictStates; for ( const auto& repo : conflictRepos ) conflictStates.emplace( repo, mGit->conflictState( repo, false ) ); for ( auto state = conflictStates.begin(); state != conflictStates.end(); ) { if ( !state->second.hasConflicts() && state->second.operation == Git::GitOperation::None && sessionRepos.find( state->first ) == sessionRepos.end() ) state = conflictStates.erase( state ); else ++state; } UnorderedSet cache; for ( const auto& status : newGitStatus.files ) { for ( const auto& file : status.second ) { std::string p( FileSystem::fileRemoveFileName( file.file ) ); std::string lp; while ( p != lp ) { cache.insert( p ); lp = p; p = FileSystem::removeLastFolderFromPath( p ); } cache.insert( file.file ); } } { Lock l( mGitStatusFileCacheMutex ); mGitStatusFilesCache = std::move( cache ); } { Lock l( mGitStatusMutex ); mGitStatus = std::move( newGitStatus ); if ( !force && conflictStates.empty() && mGitBranches == prevBranch && mGitStatus == prevGitStatus ) return; } lifetime.run( [conflictStates = std::move( conflictStates )]( GitPlugin* plugin ) mutable { const bool selectStatusPanel = plugin->updateConflictSessions( conflictStates ); plugin->updateStatusBarSync(); if ( plugin->mHistoryLoaded && plugin->mHistoryModel ) { Git::Status status; { Lock l( plugin->mGitStatusMutex ); status = plugin->mGitStatus; } const std::string repo = plugin->repoSelected(); plugin->mHistoryModel->setWorkingTreeStatus( status, plugin->mGit->repoName( repo, true, repo ) ); } if ( selectStatusPanel && plugin->mPanelSwicher ) plugin->mPanelSwicher->getListBox()->setSelected( 1 ); } ); }, [this, lifetime]( auto ) { --mRunningUpdateStatus; if ( mPendingForcedStatusUpdate.exchange( false ) ) lifetime.run( []( GitPlugin* plugin ) { plugin->updateStatus( true ); } ); } ); } PluginRequestHandle GitPlugin::processMessage( const PluginMessage& msg ) { switch ( msg.type ) { case PluginMessageType::WorkspaceFolderChanged: { if ( mGit ) { { Lock l( mGitStatusMutex ); mGitStatus = {}; } if ( getUISceneNode() ) { mLifetime.weakHandle().run( []( GitPlugin* plugin ) { plugin->mConflictViewCloseConnection.disconnect(); plugin->mConflictView = nullptr; plugin->mConflictSessions.clear(); plugin->mActiveConflictRepo.clear(); ++plugin->mConflictGeneration; if ( plugin->mManager && plugin->mManager->getSplitter() ) plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( "git_conflict_resolver" ); if ( plugin->mPanelSwicher ) plugin->mPanelSwicher->getListBox()->setSelected( 0 ); if ( plugin->mStackWidget && !plugin->mStackMap.empty() ) plugin->mStackWidget->setActiveWidget( plugin->mStackMap[0] ); } ); } mGit->setProjectPath( msg.asJSON()["folder"] ); { Lock l( mGitBranchMutex ); mGitBranches.clear(); } { Lock l( mRepoMutex ); mProjectPath = mRepoSelected = mGit->getProjectPath(); } { Lock l( mReposMutex ); mRepos.clear(); } if ( getUISceneNode() && mSidePanel ) { mLifetime.weakHandle().run( []( GitPlugin* plugin ) { if ( plugin->mProjectPath.empty() ) { plugin->hideSidePanel(); } } ); } updateUINow( true ); mInitialized = true; if ( mModelStylerId == 0 ) initModelStyler(); } break; } case ecode::PluginMessageType::UIReady: { mLifetime.setDispatcher( getUISceneNode() ); if ( !mInitialized ) updateUINow(); if ( mModelStylerId == 0 ) initModelStyler(); break; } case ecode::PluginMessageType::UIThemeReloaded: { mStatusCustomTokenizer.reset(); mCommitStatusCustomTokenizer.reset(); styleCommitFilesStatus( mCommitDetails.status ); styleCommitFilesStatus( mDetachedHistory.details.status ); updateUINow( true ); break; } default: break; } return PluginRequestHandle::empty(); } void GitPlugin::onFileSystemEvent( const FileEvent& ev, const FileInfo& file ) { PluginBase::onFileSystemEvent( ev, file ); if ( mShuttingDown || isLoading() ) return; if ( file.isDirectory() ) return; auto inGitFolder = file.getFilepath().find( "/.git/" ) != std::string::npos; #if EE_PLATFORM == EE_PLATFORM_WIN inGitFolder |= file.getFilepath().find( "\\.git\\" ) != std::string::npos; #endif if ( inGitFolder && file.getExtension() == "lock" ) return; updateUI(); } FileSystemListenerOptions GitPlugin::getFileSystemListenerOptions() const { auto options = Plugin::getFileSystemListenerOptions(); const auto& workspace = getManager()->getWorkspaceFolder(); if ( !workspace.empty() ) { FileSystemListenerFilter filter; filter.path = workspace; options.filters.emplace_back( std::move( filter ) ); } return options; } void GitPlugin::displayTooltip( UICodeEditor* editor, const Git::Blame& blame, const Vector2f& position ) { // HACK: Gets the old font style to restore it when the tooltip is hidden UITooltip* tooltip = editor->createTooltip(); if ( tooltip == nullptr ) return; String str( blame.error.empty() ? String::format( "%s: %s (%s)\n%s: %s (%s)\n%s: %s\n\n%s", i18n( "commit", "Commit" ).toUtf8().c_str(), blame.commitHash.c_str(), blame.commitShortHash.c_str(), i18n( "author", "Author" ).toUtf8().c_str(), blame.author.c_str(), blame.authorEmail.c_str(), i18n( "date", "Date" ).toUtf8().c_str(), blame.date.c_str(), blame.commitMessage.c_str() ) : blame.error ); Text::hardWrapText( str, PixelDensity::dpToPx( 400 ), tooltip->getFontStyleConfig(), editor->getTabWidth() ); editor->setTooltipText( str ); mTooltipInfoShowing = true; mOldBackgroundColor = tooltip->getBackgroundColor(); if ( Color::Transparent == mOldBackgroundColor ) { tooltip->reloadStyle( true, true, true, true ); mOldBackgroundColor = tooltip->getBackgroundColor(); } mOldTextStyle = tooltip->getFontStyle(); mOldTextAlign = tooltip->getHorizontalAlign(); mOldDontAutoHideOnMouseMove = tooltip->dontAutoHideOnMouseMove(); mOldUsingCustomStyling = tooltip->getUsingCustomStyling(); tooltip->setHorizontalAlign( UI_HALIGN_LEFT ); tooltip->setPixelsPosition( tooltip->getTooltipPosition( position ) ); tooltip->setDontAutoHideOnMouseMove( true ); tooltip->setUsingCustomStyling( true ); tooltip->setData( String::hash( "git" ) ); tooltip->setBackgroundColor( editor->getColorScheme().getEditorColor( "background"_sst ) ); tooltip->getUIStyle()->setStyleSheetProperty( StyleSheetProperty( "background-color", editor->getColorScheme().getEditorColor( "background"_sst ).toHexString(), true, StyleSheetSelectorRule::SpecificityImportant ) ); if ( !mTooltipCustomSyntaxDef.has_value() ) { static std::vector patterns; patterns.emplace_back( SyntaxPattern( { "([%w:]+)%s(%x+)%s%((%x+)%)" }, { "normal", "keyword", "number", "number" } ) ); patterns.emplace_back( SyntaxPattern( { "([%w:]+)%s(.*)%(([%w%+%.-]+@[%w%.-]+%.%w+)%)" }, { "normal", "keyword", "function", "link" } ) ); patterns.emplace_back( SyntaxPattern( { "([%w:]+)%s(%d%d%d%d%-%d%d%-%d%d[%s%d%-+:]+)" }, { "normal", "keyword", "warning" } ) ); SyntaxDefinition syntaxDef( "custom_build", {}, std::move( patterns ) ); mTooltipCustomSyntaxDef = std::move( syntaxDef ); } SyntaxTokenizer::tokenizeText( *mTooltipCustomSyntaxDef, editor->getColorScheme(), tooltip->getTextCache() ); tooltip->notifyTextChangedFromTextCache(); if ( editor->hasFocus() && !tooltip->isVisible() ) tooltip->show(); } void GitPlugin::hideTooltip( UICodeEditor* editor ) { mTooltipInfoShowing = false; UITooltip* tooltip = nullptr; if ( editor && ( tooltip = editor->getTooltip() ) && tooltip->isVisible() && tooltip->getData() == String::hash( "git" ) ) { editor->setTooltipText( "" ); tooltip->hide(); // Restore old tooltip state tooltip->setData( 0 ); tooltip->setFontStyle( mOldTextStyle ); tooltip->setHorizontalAlign( mOldTextAlign ); tooltip->setUsingCustomStyling( mOldUsingCustomStyling ); tooltip->setDontAutoHideOnMouseMove( mOldDontAutoHideOnMouseMove ); tooltip->setBackgroundColor( mOldBackgroundColor ); } } bool GitPlugin::onMouseLeave( UICodeEditor* editor, const Vector2i&, const Uint32& ) { hideTooltip( editor ); return false; } std::string GitPlugin::gitBranch() { std::string repoSel = repoSelected(); Lock l( mGitBranchMutex ); return mGitBranches[repoSel]; } void GitPlugin::onRegisterListeners( UICodeEditor* editor, std::vector& listeners ) { listeners.push_back( editor->on( Event::OnCursorPosChange, [this, editor]( const Event* ) { if ( mTooltipInfoShowing ) hideTooltip( editor ); } ) ); } Color GitPlugin::getVarColor( const std::string& var ) { return Color::fromString( getUISceneNode()->getRoot()->getUIStyle()->getVariable( var ).getValue() ); } void GitPlugin::blame( UICodeEditor* editor ) { if ( !mGitFound ) { editor->setTooltipText( i18n( "git_not_found", "Git binary not found.\nPlease check that git is accessible via PATH" ) ); return; } mThreadPool->run( [this, editor]() { auto blame = mGit->blame( editor->getDocument().getFilePath(), editor->getDocument().getSelection().start().line() + 1 ); editor->runOnMainThread( [this, editor, blame] { displayTooltip( editor, blame, editor->getScreenPosition( editor->getDocument().getSelection().start() ) .getPosition() ); } ); } ); } // Branch operations void GitPlugin::checkout( Git::Branch branch ) { if ( !mGit ) return; const auto lifetime = mLifetime.weakHandle(); const auto git = mGit; const std::string repo = repoSelected(); const auto checkOutFn = [this, branch, lifetime, git, repo]( bool createLocal ) { mLoader->setVisible( true ); mThreadPool->run( [branch, createLocal, lifetime, git, repo] { auto result = createLocal ? git->checkoutAndCreateLocalBranch( branch.name, "", repo ) : git->checkout( branch.name, repo ); lifetime.run( [branch, createLocal, repo, result = std::move( result )]( GitPlugin* plugin ) { if ( result.success() ) { { Lock l( plugin->mGitBranchMutex ); plugin->mGitBranches[repo] = branch.name; } if ( plugin->mBranchesTree->getModel() ) { if ( createLocal ) plugin->updateBranches(); else plugin->mBranchesTree->getModel()->invalidate( Model::DontInvalidateIndexes ); } plugin->invalidateHistory(); } else { plugin->showMessage( LSPMessageType::Warning, result.result ); } plugin->mLoader->setVisible( false ); } ); } ); }; if ( branch.type == Git::RefType::Remote ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::YES_NO, i18n( "git_create_local_branch", "Create local branch?" ) ); msgBox->on( Event::OnConfirm, [checkOutFn]( const Event* ) { checkOutFn( true ); } ); msgBox->on( Event::OnDiscard, [checkOutFn]( const Event* ) { checkOutFn( false ); } ); msgBox->setTitle( i18n( "git_checkout", "Check Out" ) ); msgBox->center(); msgBox->showWhenReady(); return; } checkOutFn( false ); } void GitPlugin::branchRename( Git::Branch branch ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::INPUT, String::format( i18n( "git_rename_branch_ask", "Enter the new name for the branch: '%s'" ).toUtf8(), branch.name ) ); msgBox->on( Event::OnConfirm, [this, branch, msgBox]( const Event* ) { std::string newName( msgBox->getTextInput()->getText().toUtf8() ); if ( newName.empty() || branch.name == newName ) return; msgBox->closeWindow(); runAsync( [this, branch, newName]() { return mGit->renameBranch( branch.name, newName, repoSelected() ); }, false, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_rename_branch", "Rename Branch" ) ); msgBox->center(); msgBox->getTextInput()->setText( branch.name ); msgBox->showWhenReady(); } void GitPlugin::branchDelete( Git::Branch branch ) { if ( branch.type == Git::RefType::Tag ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, String::format( i18n( "git_confirm_tag_delete", "Delete tag '%s'?" ).toUtf8(), branch.name ) ); msgBox->on( Event::OnConfirm, [this, branch]( auto ) { runAsync( [this, branch] { return mGit->deleteTag( branch.name, repoSelected() ); }, false, true ); } ); msgBox->setTitle( i18n( "git_delete", "Delete" ) ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } )->center(); msgBox->showWhenReady(); return; } auto remoteParts = []( const std::string& remoteBranch ) { const size_t separator = remoteBranch.find( '/' ); return std::pair{ separator == std::string::npos ? std::string{} : remoteBranch.substr( 0, separator ), separator == std::string::npos ? remoteBranch : remoteBranch.substr( separator + 1 ) }; }; if ( branch.type == Git::RefType::Remote ) { auto [remote, name] = remoteParts( branch.name ); UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, String::format( i18n( "git_confirm_remote_branch_delete", "Delete remote branch '%s' from '%s'?" ) .toUtf8(), name, remote ) ); msgBox->on( Event::OnConfirm, [this, remote = std::move( remote ), name = std::move( name )]( auto ) { runAsync( [this, remote, name] { return mGit->deleteRemoteBranch( remote, name, repoSelected() ); }, true, true ); } ); msgBox->setTitle( i18n( "git_delete", "Delete" ) ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } )->center(); msgBox->showWhenReady(); return; } UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, String::format( i18n( "git_confirm_branch_delete", "Are you sure you want to delete the branch \"%s\"?" ) .toUtf8(), branch.name ) ); UICheckBox* deleteRemote = nullptr; if ( !branch.remote.empty() ) { deleteRemote = UICheckBox::New(); deleteRemote ->setText( i18n( "git_delete_tracking_remote", "Also delete the tracked remote branch" ) ) ->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ) ->setLayoutMarginTop( 8 ) ->setParent( msgBox->getTextBox()->getParent() ); deleteRemote->toPosition( 1 ); } msgBox->on( Event::OnConfirm, [this, branch, deleteRemote, remoteParts]( auto ) { const bool removeRemote = deleteRemote && deleteRemote->isChecked(); runAsync( [this, branch, removeRemote, remoteParts]() { auto result = mGit->deleteBranch( branch.name, repoSelected() ); if ( result.fail() || !removeRemote ) return result; auto [remote, name] = remoteParts( branch.remote ); return mGit->deleteRemoteBranch( remote, name, repoSelected() ); }, false, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_confirm", "Confirm" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::branchMerge( Git::Branch branch ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, String::format( i18n( "git_confirm_branch_merge", "Are you sure you want to merge from branch \"%s\"?" ) .toUtf8(), branch.name ) ); msgBox->on( Event::OnConfirm, [this, branch]( auto ) { const std::string repoPath = repoSelected(); runMergeLikeAsync( [branch, repoPath]( Git& git ) { return git.mergeBranch( branch.name, false, repoPath ); }, repoPath ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_confirm", "Confirm" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::pull( const std::string& repoPath ) { runMergeLikeAsync( [repoPath]( Git& git ) { return git.pull( repoPath ); }, repoPath ); } void GitPlugin::push( const std::string& repoPath ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, i18n( "git_confirm_push_changes", "Are you sure you want to push the local changes to the remote server?" ) ); msgBox->on( Event::OnConfirm, [this, repoPath]( auto ) { runAsync( [this, repoPath]() { std::optional branch = getBranchFromRepoPath( repoPath ); bool pushNewBranch = branch && !branch->name.empty() && branch->remote.empty(); if ( pushNewBranch ) return mGit->pushNewBranch( branch->name, repoPath ); return mGit->push( repoPath ); }, true, true, true, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_confirm", "Confirm" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::fetch( const std::string& repoPath ) { runAsync( [this, repoPath]() { return mGit->fetch( repoPath ); }, true, true, true ); } void GitPlugin::branchCreate() { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::INPUT, i18n( "git_create_branch_ask", "Create new branch at current branch (HEAD).\nEnter the name for the branch:" ) ); msgBox->on( Event::OnConfirm, [this, msgBox]( const Event* ) { std::string newName( msgBox->getTextInput()->getText().toUtf8() ); if ( newName.empty() ) return; msgBox->closeWindow(); runAsync( [this, newName]() { return mGit->createBranch( newName, true, repoSelected() ); }, false, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_add_branch", "Add Branch" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::commit( const std::string& repoPath, bool mergeCommit ) { if ( !mergeCommit && !mGitStatus.hasStagedChanges( mGit->repoName( repoPath, true ) ) ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK, i18n( "git_nothing_to_commit", "Nothing to Commit" ) ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_nothing_to_commit", "Nothing to Commit" ) ); msgBox->center(); msgBox->showWhenReady(); return; } UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::TEXT_EDIT, i18n( "git_commit_message", "Commit Message:" ) ); UITextEdit* txtEdit = msgBox->getTextEdit(); txtEdit->setLineWrapType( LineWrapType::Viewport ); txtEdit->setLineWrapMode( LineWrapMode::Word ); txtEdit->setText( mLastCommitMsg ); UICheckBox* chkAmend = UICheckBox::New(); chkAmend->setLayoutMargin( Rectf( 0, 8, 0, 0 ) ) ->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ) ->setLayoutGravity( UI_HALIGN_LEFT | UI_VALIGN_CENTER ) ->setClipType( ClipType::None ) ->setParent( msgBox->getLayoutCont()->getFirstChild() ) ->setId( "git-amend" ); chkAmend->setText( i18n( "git_amend", "Amend last commit" ) ); chkAmend->setEnabled( !mergeCommit ); chkAmend->toPosition( 2 ); chkAmend->setTooltipText( getUISceneNode()->getKeyBindings().getShortcutString( { KEY_A, KeyMod::getDefaultModifier() }, true ) ); UICheckBox* chkBypassHook = UICheckBox::New(); chkBypassHook->setLayoutMargin( Rectf( 0, 8, 0, 0 ) ) ->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ) ->setLayoutGravity( UI_HALIGN_LEFT | UI_VALIGN_CENTER ) ->setClipType( ClipType::None ) ->setParent( msgBox->getLayoutCont()->getFirstChild() ) ->setId( "git-bypass-hook" ); chkBypassHook->setText( i18n( "git_bypass_hook", "Bypass commit hook" ) ); chkBypassHook->toPosition( 3 ); chkBypassHook->setTooltipText( getUISceneNode()->getKeyBindings().getShortcutString( { KEY_B, KeyMod::getDefaultModifier() }, true ) ); UICheckBox* chkPush = UICheckBox::New(); chkPush->setLayoutMargin( Rectf( 0, 8, 0, 0 ) ) ->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ) ->setLayoutGravity( UI_HALIGN_LEFT | UI_VALIGN_CENTER ) ->setClipType( ClipType::None ) ->setParent( msgBox->getLayoutCont()->getFirstChild() ) ->setId( "git-push-commit" ); chkPush->setText( i18n( "git_push_commit", "Push commit" ) ); chkPush->toPosition( 4 ); chkPush->setTooltipText( getUISceneNode()->getKeyBindings().getShortcutString( { KEY_P, KeyMod::getDefaultModifier() }, true ) ); if ( !mergeCommit ) { txtEdit->getDocument().setCommand( "commit-amend", [chkAmend] { chkAmend->setChecked( !chkAmend->isChecked() ); } ); txtEdit->getKeyBindings().addKeybind( { KEY_L, KeyMod::getDefaultModifier() }, "commit-amend" ); } txtEdit->getDocument().setCommand( "commit-push", [chkPush] { chkPush->setChecked( !chkPush->isChecked() ); } ); txtEdit->getKeyBindings().addKeybind( { KEY_P, KeyMod::getDefaultModifier() }, "commit-push" ); txtEdit->getDocument().setCommand( "commit-bypass-hook", [chkBypassHook] { chkBypassHook->setChecked( !chkBypassHook->isChecked() ); } ); txtEdit->getKeyBindings().addKeybind( { KEY_B, KeyMod::getDefaultModifier() }, "commit-bypass-hook" ); msgBox->on( Event::OnConfirm, [this, msgBox, chkAmend, chkBypassHook, chkPush, repoPath, mergeCommit]( const Event* ) { std::string msg( msgBox->getTextEdit()->getText().toUtf8() ); if ( msg.empty() ) return; bool amend = chkAmend->isChecked(); bool bypassHook = chkBypassHook->isChecked(); bool pushCommit = chkPush->isChecked(); std::optional branch = getBranchFromRepoPath( repoPath ); auto git = mGit; const auto lifetime = mLifetime.weakHandle(); msgBox->closeWindow(); runAsync( [git = std::move( git ), lifetime, branch = std::move( branch ), msg, amend, bypassHook, pushCommit, repoPath, mergeCommit]() { bool pushNewBranch = branch && !branch->name.empty() && branch->remote.empty(); auto res = git->commit( msg, amend, bypassHook, repoPath, mergeCommit ); if ( res.success() ) { lifetime.run( [mergeCommit, repoPath]( GitPlugin* plugin ) { plugin->mLastCommitMsg.clear(); plugin->invalidateHistory(); if ( mergeCommit ) { ++plugin->mConflictGeneration; plugin->mConflictSessions.erase( repoPath ); if ( plugin->mActiveConflictRepo == repoPath ) { plugin->mActiveConflictRepo.clear(); plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( "git_conflict_resolver" ); } } } ); if ( pushCommit ) { if ( pushNewBranch ) return git->pushNewBranch( branch->name, repoPath ); return git->push( repoPath ); } } else { lifetime.run( [msg]( GitPlugin* plugin ) { plugin->mLastCommitMsg = msg; } ); } return res; }, true, true, true, true, true ); } ); msgBox->on( Event::OnDiscard, [this, msgBox]( const Event* ) { mLastCommitMsg = msgBox->getTextEdit()->getText(); } ); msgBox->on( Event::OnVisibleChange, [msgBox, txtEdit]( const Event* ) { if ( !msgBox->isVisible() ) return; msgBox->getLayoutCont()->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); msgBox->getLayoutCont()->getFirstChild()->asType()->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); txtEdit->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ); txtEdit->setLayoutWeight( 1 ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_commit", "Commit" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::fastForwardMerge( Git::Branch branch ) { runAsync( [this, branch]() { if ( branch.name == gitBranch() ) { auto res = mGit->fastForwardMerge( repoSelected() ); if ( res.success() ) return res; return mGit->mergeBranch( "", true, repoSelected() ); } auto remoteBranch = mGit->getAllBranchesAndTags( Git::RefType::Remote, "refs/remotes/" + branch.remote, repoSelected() ); if ( remoteBranch.empty() ) return Git::Result{ "", -1 }; return mGit->updateRef( branch.name, remoteBranch[0].lastCommit, repoSelected() ); }, false, true, false, false, false, true ); } // Branch operations // File operations static bool isPath( const std::string& file ) { bool ret = !file.empty() && file[0] == '/'; #if EE_PLATFORM == EE_PLATFORM_WIN if ( !ret ) ret = LuaPattern::hasMatches( file, "%w:[\\/][\\/]" ); #endif return ret; } std::string GitPlugin::fixFilePath( const std::string& file ) { std::string path; if ( !isPath( file ) ) { path = ( mProjectPath + file ); } return path; } std::vector GitPlugin::fixFilePaths( const std::vector& files ) { std::vector paths; paths.reserve( files.size() ); for ( const auto& file : files ) { if ( !isPath( file ) ) { paths.push_back( mProjectPath + file ); } else { paths.push_back( file ); } } return paths; } std::optional GitPlugin::getBranchFromRepoPath( const std::string& repoPath ) { Git::Branch branch; std::string branchName; if ( repoPath.empty() ) return {}; { Lock l( mGitBranchMutex ); branchName = mGitBranches[repoPath]; } if ( branchName.empty() ) return {}; if ( repoPath != repoSelected() || !mBranchesTree->getModel() ) { auto branches = mGit->getAllBranchesAndTags( Git::RefType::Head, "refs/heads/" + branchName, repoPath ); if ( !branches.empty() ) return branches.front(); } else { auto modelShared = mBranchesTree->getModelShared(); auto model = static_cast( modelShared.get() ); return model->branch( branchName ); } return {}; } void GitPlugin::stage( const std::vector& files ) { runFileOperation( files, FileOperation::Stage ); } void GitPlugin::unstage( const std::vector& files ) { runFileOperation( files, FileOperation::Unstage ); } void GitPlugin::runFileOperation( std::vector files, FileOperation operation ) { if ( files.empty() ) return; runAsync( [this, files = std::move( files ), operation]() { std::map> filesByRepo; for ( const auto& file : files ) filesByRepo[mGit->repoPath( file )].emplace_back( file ); Git::Result result; for ( auto& [repoPath, repoFiles] : filesByRepo ) { auto paths = fixFilePaths( repoFiles ); switch ( operation ) { case FileOperation::Stage: result = mGit->add( paths, repoPath ); break; case FileOperation::Unstage: result = mGit->reset( paths, repoPath ); break; case FileOperation::Discard: result = mGit->restore( paths, repoPath ); break; } if ( result.fail() ) return result; } return result; }, true, operation == FileOperation::Discard ); } void GitPlugin::discard( const std::vector& files ) { if ( files.empty() ) return; UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, i18n( "git_confirm_discard_changes", "Are you sure you want to discard all file changes?" ) .toUtf8() ); msgBox->on( Event::OnConfirm, [this, files]( auto ) { runFileOperation( files, FileOperation::Discard ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_confirm", "Confirm" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::discard( const std::string& file ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, String::format( i18n( "git_confirm_discard_changes", "Are you sure you want to discard the changes in file: \"%s\"?" ) .toUtf8(), file ) ); msgBox->on( Event::OnConfirm, [this, file]( auto ) { runAsync( [this, file]() { return mGit->restore( fixFilePath( file ), mGit->repoPath( file ) ); }, true, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_confirm", "Confirm" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::openFile( const std::string& file ) { mLifetime.weakHandle().run( [file]( GitPlugin* plugin ) { plugin->mManager->getLoadFileFn()( plugin->mGit->getProjectPath() + file, []( auto, auto ) {} ); } ); } GitPlugin::GitConflictSession* GitPlugin::conflictSession( const std::string& repoPath ) { auto session = mConflictSessions.find( repoPath ); return session != mConflictSessions.end() ? session->second.get() : nullptr; } GitPlugin::GitConflictSession* GitPlugin::activeConflictSession() { if ( auto* session = conflictSession( mActiveConflictRepo ) ) return session; if ( mConflictSessions.empty() ) return nullptr; mActiveConflictRepo = mConflictSessions.begin()->first; return mConflictSessions.begin()->second.get(); } bool GitPlugin::updateConflictSessions( UnorderedMap& conflictStates ) { bool selectStatusPanel = false; for ( auto& [repo, conflictState] : conflictStates ) { if ( !conflictState.error.empty() ) continue; if ( !conflictState.hasConflicts() && conflictState.operation == Git::GitOperation::None ) { const bool wasActive = mActiveConflictRepo == repo; ++mConflictGeneration; mConflictSessions.erase( repo ); if ( wasActive ) { mActiveConflictRepo.clear(); mConflictViewCloseConnection.disconnect(); mConflictView = nullptr; mManager->getSplitter()->removeTabWithOwnedWidgetId( "git_conflict_resolver" ); } continue; } std::vector files; files.reserve( conflictState.files.size() ); for ( auto& conflict : conflictState.files ) files.emplace_back( std::move( conflict.path ) ); auto& session = mConflictSessions[repo]; const bool stateChanged = session && ( session->files != files || session->operation != conflictState.operation ); if ( !session ) { session = std::make_unique(); selectStatusPanel = true; } else if ( session->files.empty() && !files.empty() ) { selectStatusPanel = true; } session->repoPath = repo; session->files = std::move( files ); session->operation = conflictState.operation; if ( session->generation == 0 || stateChanged ) session->generation = ++mConflictGeneration; if ( session->currentFile >= session->files.size() ) session->currentFile = 0; if ( mActiveConflictRepo.empty() ) mActiveConflictRepo = repo; } if ( mActiveConflictRepo.empty() && !mConflictSessions.empty() ) mActiveConflictRepo = mConflictSessions.begin()->first; return selectStatusPanel; } void GitPlugin::loadConflictResolverView( std::shared_ptr resultDocument, Git::ConflictFile conflict, std::string repository, std::vector files, size_t currentFile, Git::GitOperation operation, Uint64 generation ) { auto* session = conflictSession( repository ); if ( !resultDocument ) return; if ( mShuttingDown || mActiveConflictRepo != repository || !session || generation != session->generation ) return; auto toVersion = []( const std::optional& stage, String label ) { MergeVersion version; version.label = std::move( label ); if ( stage ) { version.present = true; version.text = String::fromUtf8( stage->contents ); version.objectId = stage->objectId; version.mode = stage->mode; } return version; }; String stage2Label; String stage3Label; switch ( operation ) { case Git::GitOperation::Merge: stage2Label = i18n( "git_merge_current_branch", "Current branch (ours)" ); stage3Label = i18n( "git_merge_incoming_branch", "Incoming branch (theirs)" ); break; case Git::GitOperation::Rebase: stage2Label = i18n( "git_merge_upstream", "Upstream" ); stage3Label = i18n( "git_merge_replayed_commit", "Replayed commit (your change)" ); break; case Git::GitOperation::None: case Git::GitOperation::CherryPick: case Git::GitOperation::Revert: case Git::GitOperation::StashApply: stage2Label = i18n( "git_merge_ours_stage", "Ours (stage 2)" ); stage3Label = i18n( "git_merge_theirs_stage", "Theirs (stage 3)" ); break; } MergeInput input; input.path = conflict.path; input.base = toVersion( conflict.base, i18n( "git_merge_base", "Base" ) ); input.stage2 = toVersion( conflict.stage2, std::move( stage2Label ) ); input.stage3 = toVersion( conflict.stage3, std::move( stage3Label ) ); input.resultDocument = resultDocument; input.resultLabel = i18n( "git_merge_result", "Result" ); input.missingVersionLabel = i18n( "git_merge_not_present", "Not present" ); auto* mergeView = mConflictView && mManager->getSplitter()->ownedWidgetExists( mConflictView ) ? mConflictView : UIMergeView::New(); mergeView->load( std::move( input ) ); mergeView->setRecreateConflictCallback( [this] { recreateConflict(); } ); const String continueText = operation == Git::GitOperation::Merge ? i18n( "git_commit_merge", "Commit" ) : i18n( "git_continue_operation", "Continue" ); const bool newView = mergeView != mConflictView; session->files = std::move( files ); session->currentFile = currentFile; session->generation = generation; session->operation = operation; mConflictView = mergeView; if ( newView ) { const Uint32 defaultModifier = KeyMod::getDefaultModifier(); const Uint32 abortModifier = defaultModifier | KeyMod::getDefaultSecondaryModifier(); mergeView->setId( "git_conflict_resolver" ); mergeView->addToolbarAction( "git-conflict-save-stage", i18n( "git_save_and_stage", "Save & Stage" ), { KEY_S, defaultModifier }, "git-branch-staged-changes", [this] { saveAndStageConflict(); } ); mergeView->addToolbarAction( "git-conflict-previous-file", i18n( "git_previous_conflict_file", "Previous File" ), { KEY_PAGEUP, KeyMod::getDefaultSecondaryModifier() }, "arrow-circle-left", [this] { openAdjacentConflict( false ); } ); mergeView->addToolbarAction( "git-conflict-next-file", i18n( "git_next_conflict_file", "Next File" ), { KEY_PAGEDOWN, KeyMod::getDefaultSecondaryModifier() }, "arrow-circle-right", [this] { openAdjacentConflict( true ); } ); mergeView->addToolbarAction( "git-conflict-continue", continueText, { KEY_RETURN, defaultModifier }, operation == Git::GitOperation::Merge ? "git-commit" : "debug-continue", [this] { continueConflictOperation(); } ); mergeView->addToolbarAction( "git-conflict-abort", i18n( "git_abort_operation", "Abort" ), { KEY_A, abortModifier }, "discard", [this] { abortConflictOperation(); } ); mConflictViewCloseConnection = mergeView->connect( Event::OnClose, [this, mergeView]( const Event* ) { if ( mConflictView == mergeView ) { mConflictView = nullptr; if ( auto* session = activeConflictSession() ) session->generation = ++mConflictGeneration; } } ); mManager->getSplitter()->createWidget( mergeView, String::format( i18n( "git_merge_tab", "Merge: %s" ).toUtf8(), resultDocument->getFilename() ), true ); } else { if ( auto* continueButton = mergeView->find( "git-conflict-continue" ) ) { continueButton->setText( continueText ); if ( auto* icon = getUISceneNode()->findIcon( operation == Git::GitOperation::Merge ? "git-commit" : "debug-continue" ) ) continueButton->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); } auto tabs = mManager->getSplitter()->getTabFromOwnedWidgetId( mergeView->getId() ); if ( !tabs.empty() ) { tabs.front().first->setText( String::format( i18n( "git_merge_tab", "Merge: %s" ).toUtf8(), resultDocument->getFilename() ) ); tabs.front().second->setTabSelected( tabs.front().first ); } } } void GitPlugin::openConflictResolver( const std::string& file ) { const std::string repository = mGit->repoPath( file ); std::string absolutePath = mGit->getProjectPath() + file; std::string relativePath = absolutePath; FileSystem::filePathRemoveBasePath( repository, relativePath ); const Uint64 generation = ++mConflictGeneration; auto& session = mConflictSessions[repository]; if ( !session ) session = std::make_unique(); session->repoPath = repository; session->generation = generation; mActiveConflictRepo = repository; auto git = mGit; const auto lifetime = mLifetime.weakHandle(); const std::string stateErrorMessage = i18n( "git_conflict_state_failed", "Unable to read Git's conflict state." ).toUtf8(); runAsyncTask( [git = std::move( git ), lifetime, repository, relativePath = std::move( relativePath ), absolutePath = std::move( absolutePath ), generation, stateErrorMessage] { auto state = git->conflictState( repository ); if ( !state.error.empty() ) { lifetime.run( [stateErrorMessage]( GitPlugin* plugin ) { plugin->showMessage( LSPMessageType::Error, stateErrorMessage ); } ); return; } auto found = std::find_if( state.files.begin(), state.files.end(), [&relativePath]( const auto& conflict ) { return conflict.path == relativePath; } ); if ( found == state.files.end() ) { lifetime.run( []( GitPlugin* plugin ) { plugin->updateStatus( true ); } ); return; } const size_t currentFile = std::distance( state.files.begin(), found ); std::vector files; files.reserve( state.files.size() ); for ( const auto& conflictFile : state.files ) files.emplace_back( conflictFile.path ); Git::ConflictFile conflict = std::move( state.files[currentFile] ); lifetime.run( [lifetime, conflict = std::move( conflict ), absolutePath = std::move( absolutePath ), repository, files = std::move( files ), currentFile, operation = state.operation, generation]( GitPlugin* plugin ) mutable { const auto* session = plugin->conflictSession( repository ); if ( plugin->mShuttingDown || plugin->mActiveConflictRepo != repository || !session || generation != session->generation ) return; if ( !conflict.workingTreeExists ) { plugin->showMessage( LSPMessageType::Warning, plugin ->i18n( "git_conflict_missing_result", "This conflict requires choosing whether to keep or delete the file." ) .toUtf8() ); return; } auto openView = [plugin, conflict = std::move( conflict ), repository, files = std::move( files ), currentFile, operation, generation]( std::shared_ptr document ) mutable { plugin->loadConflictResolverView( std::move( document ), std::move( conflict ), std::move( repository ), std::move( files ), currentFile, operation, generation ); }; if ( auto* tab = plugin->mManager->getSplitter()->isDocumentOpen( absolutePath, false, true ) ) { openView( tab->getOwnedWidget()->asType()->getDocumentRef() ); return; } auto document = std::make_shared(); document->loadAsyncFromFile( absolutePath, plugin->mThreadPool, [lifetime, document, openView = std::move( openView )]( TextDocument*, bool success ) mutable { lifetime.run( [document, openView = std::move( openView ), success]( GitPlugin* ) mutable { if ( success ) openView( std::move( document ) ); } ); } ); } ); } ); } void GitPlugin::recreateConflict() { auto* session = activeConflictSession(); if ( !session || !mConflictView || !mManager->getSplitter()->ownedWidgetExists( mConflictView ) ) return; const MergeInput& input = mConflictView->getInput(); Git::ConflictFile conflict; conflict.path = input.path; const auto toStage = []( const MergeVersion& version, Uint8 stage ) -> std::optional { if ( !version.present ) return std::nullopt; return Git::ConflictStage{ version.objectId, {}, version.mode, stage }; }; conflict.base = toStage( input.base, 1 ); conflict.stage2 = toStage( input.stage2, 2 ); conflict.stage3 = toStage( input.stage3, 3 ); const std::string repo = session->repoPath; const std::string path = conflict.path; const Uint64 generation = session->generation; auto* view = mConflictView; auto git = mGit; const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [git = std::move( git ), lifetime, repo, path, conflict = std::move( conflict ), generation, view]() mutable { auto result = git->restoreConflictStages( conflict, repo ); auto state = git->conflictState( repo, false ); lifetime.run( [result = std::move( result ), state = std::move( state ), repo, path, generation, view]( GitPlugin* plugin ) mutable { auto* session = plugin->conflictSession( repo ); if ( plugin->mShuttingDown || plugin->mActiveConflictRepo != repo || !session || generation != session->generation || plugin->mConflictView != view || !plugin->mManager->getSplitter()->ownedWidgetExists( view ) ) return; auto restored = std::find_if( state.files.begin(), state.files.end(), [&path]( const Git::ConflictFile& file ) { return file.path == path; } ); if ( result.fail() || restored == state.files.end() ) { plugin->showMessage( LSPMessageType::Error, result.result.empty() ? plugin ->i18n( "git_conflict_recreate_failed", "Unable to restore the conflict in Git's index." ) .toUtf8() : result.result ); return; } session->files.clear(); session->files.reserve( state.files.size() ); for ( const auto& file : state.files ) session->files.emplace_back( file.path ); session->currentFile = static_cast( std::distance( state.files.begin(), restored ) ); session->operation = state.operation; view->recreateConflict(); if ( !view->getResultEditor()->getDocument().save() ) { plugin->showMessage( LSPMessageType::Warning, plugin ->i18n( "git_conflict_recreate_save_failed", "The conflict was restored in Git's index, but " "its marker text could not " "be saved to disk." ) .toUtf8() ); } plugin->updateStatus( true ); } ); } ); } void GitPlugin::saveAndStageConflict() { auto* session = activeConflictSession(); if ( !session || !mConflictView || session->currentFile >= session->files.size() ) return; auto& document = mConflictView->getResultEditor()->getDocument(); if ( !document.save() ) { showMessage( LSPMessageType::Error, i18n( "git_conflict_save_failed", "Unable to save the conflict result." ).toUtf8() ); return; } if ( mConflictView->hasUnresolvedMarkerBlocks() ) showMessage( LSPMessageType::Warning, i18n( "git_conflict_markers_remain", "The saved result still contains recognizable conflict markers." ) .toUtf8() ); const std::string repo = session->repoPath; const std::string path = session->files[session->currentFile]; const Uint64 generation = session->generation; auto git = mGit; const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [git = std::move( git ), lifetime, repo, path, generation] { auto result = git->resolveConflict( path, false, repo ); auto state = git->conflictState( repo ); lifetime.run( [result = std::move( result ), state = std::move( state ), repo, path, generation]( GitPlugin* plugin ) mutable { auto* session = plugin->conflictSession( repo ); if ( plugin->mShuttingDown || !session || generation != session->generation ) return; if ( !state.error.empty() ) { plugin->showMessage( LSPMessageType::Error, plugin ->i18n( "git_conflict_state_failed", "Unable to read Git's conflict state." ) .toUtf8() ); return; } const bool unresolved = std::any_of( state.files.begin(), state.files.end(), [&path]( const auto& conflict ) { return conflict.path == path; } ); if ( result.fail() || unresolved ) { plugin->showMessage( LSPMessageType::Error, result.result.empty() ? plugin ->i18n( "git_conflict_still_unmerged", "Git still reports this path as unmerged." ) .toUtf8() : result.result ); return; } session->files.clear(); session->files.reserve( state.files.size() ); for ( const auto& conflict : state.files ) session->files.emplace_back( conflict.path ); session->currentFile = 0; session->operation = state.operation; session->generation = ++plugin->mConflictGeneration; if ( !state.files.empty() ) { std::string next = session->repoPath + state.files.front().path; FileSystem::filePathRemoveBasePath( plugin->mGit->getProjectPath(), next ); plugin->openConflictResolver( next ); } plugin->updateStatus( true ); } ); } ); } void GitPlugin::openAdjacentConflict( bool next ) { auto* session = activeConflictSession(); if ( !session || session->files.empty() ) return; const size_t count = session->files.size(); const size_t index = next ? ( session->currentFile + 1 ) % count : ( session->currentFile + count - 1 ) % count; std::string file = session->repoPath + session->files[index]; FileSystem::filePathRemoveBasePath( mGit->getProjectPath(), file ); openConflictResolver( file ); } void GitPlugin::acceptConflictSide( const std::string& file, bool stage2 ) { const std::string repository = mGit->repoPath( file ); std::string path = mGit->getProjectPath() + file; FileSystem::filePathRemoveBasePath( repository, path ); const std::string noLongerUnmerged = i18n( "git_conflict_no_longer_unmerged", "Git no longer reports this path as unmerged." ) .toUtf8(); const std::string stateErrorMessage = i18n( "git_conflict_state_failed", "Unable to read Git's conflict state." ).toUtf8(); mLoader->setVisible( true ); auto git = mGit; const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [git = std::move( git ), lifetime, repository, path, stage2, noLongerUnmerged, stateErrorMessage] { auto state = git->conflictState( repository ); auto found = std::find_if( state.files.begin(), state.files.end(), [&path]( const auto& conflict ) { return conflict.path == path; } ); Git::Result result; if ( !state.error.empty() ) { result.returnCode = EXIT_FAILURE; result.result = stateErrorMessage; } else if ( found == state.files.end() ) { result.returnCode = EXIT_FAILURE; result.result = noLongerUnmerged; } else { const bool present = stage2 ? found->stage2.has_value() : found->stage3.has_value(); result = git->acceptConflictStage( path, stage2, present, repository ); } lifetime.run( [result = std::move( result )]( GitPlugin* plugin ) mutable { plugin->mLoader->setVisible( false ); if ( plugin->mShuttingDown ) return; if ( result.fail() ) plugin->showMessage( LSPMessageType::Warning, result.result ); plugin->updateStatus( true ); } ); } ); } void GitPlugin::continueConflictOperation() { auto* session = activeConflictSession(); if ( !session ) return; const std::string repo = session->repoPath; const auto operation = session->operation; const Uint64 generation = session->generation; const std::string unresolvedMessage = i18n( "git_resolve_before_continue", "Resolve and stage all conflicts before continuing." ) .toUtf8(); const std::string stateErrorMessage = i18n( "git_conflict_state_failed", "Unable to read Git's conflict state." ).toUtf8(); if ( operation == Git::GitOperation::Merge ) { mLoader->setVisible( true ); auto git = mGit; const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [git = std::move( git ), lifetime, repo, generation, unresolvedMessage, stateErrorMessage] { auto state = git->conflictState( repo, false ); Git::Result message; if ( !state.error.empty() ) { message.returnCode = EXIT_FAILURE; message.result = stateErrorMessage; } else if ( state.hasConflicts() ) { message.returnCode = EXIT_FAILURE; message.result = unresolvedMessage; } else if ( state.operation != Git::GitOperation::Merge ) { message.returnCode = EXIT_FAILURE; } else { message = git->preparedMergeMessage( repo ); } lifetime.run( [message = std::move( message ), repo, generation]( GitPlugin* plugin ) mutable { plugin->mLoader->setVisible( false ); auto* session = plugin->conflictSession( repo ); if ( plugin->mShuttingDown || !session || generation != session->generation ) return; if ( message.fail() ) { plugin->showMessage( LSPMessageType::Warning, message.result.empty() ? plugin ->i18n( "git_no_continuable_operation", "No continuable Git operation is active." ) .toUtf8() : message.result ); return; } plugin->mLastCommitMsg = String::fromUtf8( message.result ); plugin->commit( repo, true ); } ); } ); return; } mLoader->setVisible( true ); auto git = mGit; const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [git = std::move( git ), lifetime, repo, operation, generation, unresolvedMessage, stateErrorMessage] { auto state = git->conflictState( repo ); Git::Result result; if ( !state.error.empty() ) { result.returnCode = EXIT_FAILURE; result.result = stateErrorMessage; } else if ( state.hasConflicts() ) { result.returnCode = EXIT_FAILURE; result.result = unresolvedMessage; } else { result = git->continueOperation( operation, repo ); } lifetime.run( [result = std::move( result ), repo, generation]( GitPlugin* plugin ) mutable { plugin->mLoader->setVisible( false ); auto* session = plugin->conflictSession( repo ); if ( plugin->mShuttingDown || !session || generation != session->generation ) return; if ( result.fail() ) { plugin->showMessage( LSPMessageType::Warning, result.result.empty() ? plugin ->i18n( "git_no_continuable_operation", "No continuable Git operation is active." ) .toUtf8() : result.result ); return; } ++plugin->mConflictGeneration; plugin->mConflictSessions.erase( repo ); if ( plugin->mActiveConflictRepo == repo ) { plugin->mActiveConflictRepo.clear(); plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( "git_conflict_resolver" ); } plugin->updateBranches(); plugin->updateStatus( true ); } ); } ); } void GitPlugin::abortConflictOperation() { if ( !activeConflictSession() ) return; auto* message = UIMessageBox::New( UIMessageBox::OK_CANCEL, i18n( "git_confirm_abort_operation", "Abort the current Git operation?" ) ); message->setTitle( i18n( "git_confirm", "Confirm" ) ); message->on( Event::OnConfirm, [this, message]( const Event* ) { message->closeWindow(); auto* session = activeConflictSession(); if ( !session ) return; const std::string repo = session->repoPath; const auto operation = session->operation; const Uint64 generation = session->generation; mLoader->setVisible( true ); auto git = mGit; const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [git = std::move( git ), lifetime, repo, operation, generation] { auto result = git->abortOperation( operation, repo ); lifetime.run( [result = std::move( result ), repo, generation]( GitPlugin* plugin ) mutable { plugin->mLoader->setVisible( false ); auto* session = plugin->conflictSession( repo ); if ( plugin->mShuttingDown || !session || generation != session->generation ) return; if ( result.fail() ) { plugin->showMessage( LSPMessageType::Warning, result.result.empty() ? plugin ->i18n( "git_no_abortable_operation", "No abortable Git operation is active." ) .toUtf8() : result.result ); return; } ++plugin->mConflictGeneration; plugin->mConflictSessions.erase( repo ); if ( plugin->mActiveConflictRepo == repo ) { plugin->mActiveConflictRepo.clear(); plugin->mManager->getSplitter()->removeTabWithOwnedWidgetId( "git_conflict_resolver" ); } plugin->updateBranches(); plugin->updateStatus( true ); } ); } ); } ); message->center(); message->showWhenReady(); } void GitPlugin::diff( const Git::DiffMode mode, const std::string& repoPath ) { const auto lifetime = mLifetime.weakHandle(); mThreadPool->run( [this, mode, repoPath, lifetime] { auto res = mGit->diff( mode, repoPath ); if ( res.fail() ) return; lifetime.run( [mode, res = std::move( res ), repoPath]( GitPlugin* plugin ) { std::string modeName; switch ( mode ) { case Git::DiffHead: { modeName = "HEAD"; break; } case Git::DiffStaged: modeName = "staged"; break; } plugin->getPluginContext()->loadDiffFromMemory( res.result, UIDiffView::isMultiFileDiff( res.result ) ? modeName : "", "", repoPath ); } ); } ); } void GitPlugin::diff( const std::string& file, Git::GitStatusType status ) { const auto lifetime = mLifetime.weakHandle(); mThreadPool->run( [this, file, status, lifetime] { auto filePath = fixFilePath( file ); if ( status == Git::GitStatusType::Untracked ) { lifetime.run( [filePath = std::move( filePath )]( GitPlugin* plugin ) { plugin->getPluginContext()->loadDiffFromPaths( "", filePath ); } ); return; } auto res = mGit->diff( filePath, status == Git::GitStatusType::Staged, mGit->repoPath( file ) ); if ( res.fail() ) return; auto result = std::move( res.result ); std::string oldImagePath; std::string newImagePath( filePath ); if ( EE::Graphics::Image::isImageExtension( filePath ) ) { std::string repoPath( mGit->repoPath( file ) ); auto oldBlob = mGit->showFile( filePath, status == Git::GitStatusType::Staged ? "HEAD" : ":", repoPath ); if ( oldBlob.success() ) oldImagePath = writeGitBlobTempFile( oldBlob.result, filePath ); if ( status == Git::GitStatusType::Staged ) { auto newBlob = mGit->showFile( filePath, ":", repoPath ); if ( newBlob.success() ) newImagePath = writeGitBlobTempFile( newBlob.result, filePath ); } } lifetime.run( [result = std::move( result ), filePath = std::move( filePath ), oldImagePath = std::move( oldImagePath ), newImagePath = std::move( newImagePath )]( GitPlugin* plugin ) { plugin->getPluginContext()->loadDiffFromMemory( result, newImagePath.empty() ? filePath : newImagePath, oldImagePath ); } ); } ); } void GitPlugin::diff( std::vector files ) { if ( files.empty() ) return; const auto lifetime = mLifetime.weakHandle(); mThreadPool->run( [this, files = std::move( files ), lifetime] { std::string patch; std::string repoPath; for ( const auto& file : files ) { auto filePath = fixFilePath( file.file ); auto fileRepoPath = mGit->repoPath( file.file ); if ( repoPath.empty() ) repoPath = fileRepoPath; auto result = file.report.type == Git::GitStatusType::Untracked ? mGit->diffUntracked( filePath, fileRepoPath ) : mGit->diff( filePath, file.report.type == Git::GitStatusType::Staged, fileRepoPath ); if ( result.fail() ) return; patch += result.result; if ( !patch.empty() && patch.back() != '\n' ) patch += '\n'; } lifetime.run( [patch = std::move( patch ), repoPath = std::move( repoPath )]( GitPlugin* plugin ) { plugin->getPluginContext()->loadDiffFromMemory( patch, "selected files", "", repoPath ); } ); } ); } // File operations // Stash operations void GitPlugin::stashPush( const std::vector& files, const std::string& repoPath ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::INPUT, i18n( "git_stash_push", "Stash Local Changes\nName your stash (optional):" ) ); UIRadioButton* rKeepIndex = UIRadioButton::New(); rKeepIndex->setLayoutMargin( Rectf( 0, 8, 0, 0 ) ) ->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ) ->setLayoutGravity( UI_HALIGN_LEFT | UI_VALIGN_CENTER ) ->setClipType( ClipType::None ) ->setParent( msgBox->getLayoutCont()->getFirstChild() ) ->setId( "git-stash-keep-index" ); rKeepIndex->setText( i18n( "git_stash_keep_index", "Keep Index" ) ); rKeepIndex->toPosition( 2 ); UIRadioButton* rKeepWorkingTree = UIRadioButton::New(); rKeepWorkingTree->setLayoutMargin( Rectf( 0, 8, 0, 0 ) ) ->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ) ->setLayoutGravity( UI_HALIGN_LEFT | UI_VALIGN_CENTER ) ->setClipType( ClipType::None ) ->setParent( msgBox->getLayoutCont()->getFirstChild() ) ->setId( "git-stash-keep-working-tree" ); rKeepWorkingTree->setText( i18n( "git_stash_keep_working_tree", "Keep Working Tree" ) ); rKeepWorkingTree->toPosition( 3 ); msgBox->on( Event::OnConfirm, [this, msgBox, rKeepIndex, rKeepWorkingTree, files, repoPath]( const Event* ) { bool keepIndex = rKeepIndex->isActive(); bool keepWorkingTree = rKeepWorkingTree->isActive(); std::string message = msgBox->getTextInput()->getText().toUtf8(); String::trimInPlace( message ); String::trimInPlace( message, '\n' ); msgBox->closeWindow(); runAsync( [this, files, keepIndex, keepWorkingTree, repoPath, message]() { auto res = mGit->stashPush( fixFilePaths( files ), message, keepIndex, repoPath ); if ( res.success() && keepWorkingTree ) mGit->stashApply( "stash@{0}", true, repoPath ); return res; }, true, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_stash_save", "Save Stash" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::stashApply( const Git::Branch& branch ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, i18n( "git_confirm_apply_stash", "Apply a previously saved stash?" ).toUtf8() ); UICheckBox* chkIndex = UICheckBox::New(); chkIndex->setLayoutMargin( Rectf( 0, 8, 0, 0 ) ) ->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ) ->setLayoutGravity( UI_HALIGN_LEFT | UI_VALIGN_CENTER ) ->setClipType( ClipType::None ) ->setParent( msgBox->getLayoutCont()->getFirstChild() ) ->setId( "git-restore-index" ); chkIndex->setText( i18n( "git_restore_index", "Restore Index" ) ); chkIndex->toPosition( 2 ); chkIndex->setChecked( true ); msgBox->on( Event::OnConfirm, [this, branch, chkIndex]( auto ) { runAsync( [this, branch, chkIndex]() { return mGit->stashApply( branch.remote, chkIndex->isChecked(), repoSelected() ); }, true, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_apply_stash_title", "Apply Stash" ) ); msgBox->center(); msgBox->showWhenReady(); } void GitPlugin::stashDrop( const Git::Branch& branch ) { UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, i18n( "git_confirm_drop_stash", "Do you want to drop the selected stash?" ).toUtf8() ); msgBox->on( Event::OnConfirm, [this, branch]( auto ) { runAsync( [this, branch]() { return mGit->stashDrop( branch.remote, repoSelected() ); }, true, true ); } ); msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); msgBox->setTitle( i18n( "git_drop_stash_title", "Drop Stash" ) ); msgBox->center(); msgBox->showWhenReady(); } // Stash operations void GitPlugin::onRegister( UICodeEditor* editor ) { PluginBase::onRegister( editor ); for ( auto& kb : mKeyBindings ) { if ( !kb.second.empty() ) editor->getKeyBindings().addKeybindString( kb.second, kb.first ); } if ( !editor->hasDocument() ) return; auto& doc = editor->getDocument(); doc.setCommand( "git-blame", [this]( TextDocument::Client* client ) { blame( static_cast( client ) ); } ); doc.setCommand( "show-source-control-tab", [this]() { if ( mTab ) mTab->setTabSelected(); } ); doc.setCommand( "git-pull", [this] { pull( projectPath() ); } ); 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 ) { PluginBase::onUnregister( editor ); } bool GitPlugin::onCreateContextMenu( UICodeEditor*, UIPopUpMenu* menu, const Vector2i& /*position*/, const Uint32& /*flags*/ ) { if ( !mGitFound ) return false; menu->addSeparator(); auto* subMenu = UIPopUpMenu::New(); subMenu->addClass( "gitplugin_menu" ); auto addFn = [this, subMenu]( const std::string& txtKey, const std::string& txtVal, const std::string& icon = "" ) { subMenu ->add( i18n( txtKey, txtVal ), !icon.empty() ? findIcon( icon )->createDrawable( PixelDensity::dpToPxI( 12 ) ) : nullptr, KeyBindings::keybindFormat( mKeyBindings[txtKey] ) ) ->setId( txtKey ); }; addFn( "git-blame", "Git Blame" ); menu->addSubMenu( i18n( "git", "Git" ), mManager->getUISceneNode() ->findIcon( "source-control" ) ->createDrawable( PixelDensity::dpToPxI( 12 ) ), subMenu ); return false; } bool GitPlugin::onKeyDown( UICodeEditor* editor, const KeyEvent& event ) { if ( event.getSanitizedMod() == 0 && event.getKeyCode() == KEY_ESCAPE && editor->getTooltip() && editor->getTooltip()->isVisible() ) { hideTooltip( editor ); } return false; } std::unordered_map GitPlugin::updateReposBranches() { mGit->getSubModules(); bool reposEmpty = false; { Lock l( mReposMutex ); reposEmpty = mRepos.empty(); } if ( reposEmpty ) updateRepos(); Lock l( mGitBranchMutex ); decltype( mGitBranches ) prevBranch; if ( mGitBranches.empty() || mLastBranchesUpdate.getElapsedTime() > Seconds( 1 ) ) { prevBranch = mGitBranches; mGitBranches = mGit->branches( repos() ); mLastBranchesUpdate.restart(); } else { Lock l( mGitBranchMutex ); prevBranch = mGitBranches; } return prevBranch; } void GitPlugin::invalidateHistory() { ++mHistoryGeneration; mHistoryLoaded = false; mHistoryRepo.clear(); if ( mPanelSwicher && mPanelSwicher->getListBox()->getItemSelectedIndex() == 2 ) reloadHistory(); } void GitPlugin::updateHistoryHeader() { if ( !mHistoryRefDropDown || !mHistoryRefModel ) return; auto rowCount = mHistoryRefModel->rowCount(); for ( size_t i = 0; i < rowCount; ++i ) { if ( mHistoryRefModel->isRevision( i, mHistoryRevision ) ) { mUpdatingHistoryRefs = true; mHistoryRefDropDown->getListView()->getSelection().set( mHistoryRefModel->index( i, 0 ) ); mUpdatingHistoryRefs = false; return; } } } void GitPlugin::updateHistoryRefs( const std::shared_ptr& model ) { if ( !mHistoryRefDropDown ) return; std::string currentBranch; { const std::string repo = repoSelected(); Lock l( mGitBranchMutex ); auto it = mGitBranches.find( repo ); if ( it != mGitBranches.end() ) currentBranch = it->second; } String headLabel{ "HEAD" }; if ( !currentBranch.empty() ) headLabel = String::fromUtf8( String::format( "HEAD · %s", currentBranch ) ); auto historyRefModel = std::make_shared( model, std::move( headLabel ) ); size_t selected = 0; bool found = false; for ( size_t i = 0; i < historyRefModel->rowCount(); ++i ) { if ( historyRefModel->isRevision( i, mHistoryRevision ) ) { selected = i; found = true; break; } } const bool revisionChanged = !found && mHistoryRevision != "HEAD"; if ( !found ) mHistoryRevision = "HEAD"; mUpdatingHistoryRefs = true; mHistoryRefModel = std::move( historyRefModel ); mHistoryRefDropDown->setModel( mHistoryRefModel ); mHistoryRefDropDown->getListView()->getSelection().set( mHistoryRefModel->index( selected, 0 ) ); mUpdatingHistoryRefs = false; updateDetachedHistoryTitle(); if ( revisionChanged ) invalidateHistory(); } 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(); if ( mDetachedHistory.tree ) mDetachedHistory.tree->clearViewMetadata(); mHistoryModel->setRootLoading(); Git::HistoryQuery query; query.revision = mHistoryRevision; 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 ); auto status = git->status( false, repo ); auto repoName = git->repoName( repo, true, repo ); lifetime.run( [repo, generation, query, page = std::move( page ), status = std::move( status ), repoName = std::move( repoName )]( 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, status, repoName ); else plugin->mHistoryModel->setRootError( std::move( page.result ) ); plugin->focusDetachedHistory(); } ); }, [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(); if ( plugin->mDetachedHistory.tree ) plugin->mDetachedHistory.tree->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::openHistoryMenu( const ModelIndex& index, bool showHistoryAction ) { if ( !mHistoryModel ) return; const auto* node = mHistoryModel->node( index ); if ( !node ) return; if ( node->type == GitHistoryModel::NodeType::WorkingTree ) { if ( !canStartGitOperation() ) return; UIPopUpMenu* menu = UIPopUpMenu::New(); if ( node->hasStagedChanges ) { menuAdd( menu, "git-commit", i18n( "git_commit_ellipsis", "Commit..." ), "git-commit" ); } if ( node->hasWorkingTreeChanges ) menuAdd( menu, "git-stage", i18n( "git_stage", "Stage" ), "diff-added" ); const std::string repo = repoSelected(); menu->on( Event::OnItemClicked, [this, repo]( const Event* event ) { const std::string id = event->getNode()->asType()->getId(); if ( id == "git-commit" ) { commit( repo ); } else if ( id == "git-stage" ) { std::vector files; { Lock l( mGitStatusMutex ); const auto found = mGitStatus.files.find( mGit->repoName( repo, true, repo ) ); if ( found != mGitStatus.files.end() ) { for ( const auto& file : found->second ) { if ( file.report.type != Git::GitStatusType::Staged ) files.emplace_back( file.file ); } } } stage( files ); } } ); menu->showOverMouseCursor(); return; } if ( node->type != GitHistoryModel::NodeType::Commit ) return; UIPopUpMenu* menu = UIPopUpMenu::New(); if ( showHistoryAction ) { menuAdd( menu, "git-show-history", i18n( "git_show_in_history", "Show in Git History" ), "history" ); menu->addSeparator(); } if ( canStartGitOperation() ) { menuAdd( menu, "git-checkout", i18n( "git_checkout_ellipsis", "Check Out..." ), "git-fetch" ); menuAdd( menu, "git-merge-commit", i18n( "git_merge_ellipsis", "Merge..." ), "git-merge" ); menuAdd( menu, "git-fast-forward-commit", i18n( "git_fast_forward_merge", "Fast Forward Merge" ), "git-merge" ); menuAdd( menu, "git-cherry-pick", i18n( "git_cherry_pick_ellipsis", "Cherry-Pick..." ), "git-cherry-pick" ); menuAdd( menu, "git-revert-commit", i18n( "git_revert_ellipsis", "Revert..." ), "discard" ); menu->addSeparator(); } menuAdd( menu, "git-create-branch", i18n( "git_create_branch_ellipsis", "Add Branch..." ), "repo-forked" ); menuAdd( menu, "git-add-tag", i18n( "git_add_tag_ellipsis", "Add Tag..." ), "tag" ); menu->addSeparator(); menuAdd( menu, "git-copy-message", i18n( "git_copy_message", "Copy Message" ), "copy" ); menuAdd( menu, "git-copy-id", i18n( "git_copy_id", "Copy ID" ), "copy" ); const Git::Commit commit = node->commit; menu->on( Event::OnItemClicked, [this, commit]( const Event* event ) { const std::string id = event->getNode()->asType()->getId(); if ( id == "git-show-history" ) showGitHistory( &commit ); else if ( id == "git-copy-id" ) getUISceneNode()->getWindow()->getClipboard()->setText( commit.hash ); else if ( id == "git-copy-message" ) { getUISceneNode()->getWindow()->getClipboard()->setText( commit.subject + ( commit.message.empty() ? "" : "\n\n" + commit.message ) ); } else if ( id == "git-create-branch" ) createBranchAtCommit( commit ); else if ( id == "git-add-tag" ) addTag( commit ); else if ( id == "git-checkout" ) runAsync( [this, commit] { return mGit->checkout( commit.hash, repoSelected() ); }, true, true, false, false, false, true ); else if ( id == "git-merge-commit" ) { std::string repo = repoSelected(); runMergeLikeAsync( [commit, repo = std::move( repo )]( Git& git ) { return git.mergeBranch( commit.hash, false, repo ); }, repoSelected() ); } else if ( id == "git-fast-forward-commit" ) { std::string repo = repoSelected(); runMergeLikeAsync( [commit, repo = std::move( repo )]( Git& git ) { return git.mergeBranch( commit.hash, true, repo ); }, repoSelected() ); } else if ( id == "git-cherry-pick" ) { std::string repo = repoSelected(); runMergeLikeAsync( [commit, repo = std::move( repo )]( Git& git ) { return git.cherryPick( commit.hash, repo ); }, repoSelected() ); } else if ( id == "git-revert-commit" ) revertCommit( commit ); } ); menu->showOverMouseCursor(); } bool GitPlugin::canStartGitOperation() { return mGit && mGit->operation( repoSelected() ) == Git::GitOperation::None; } void GitPlugin::createBranchAtCommit( const Git::Commit& commit ) { UIMessageBox* box = UIMessageBox::New( UIMessageBox::INPUT, String::format( i18n( "git_create_branch_at_commit", "Create a branch at commit %s." ).toUtf8(), commit.shortHash ) ); box->on( Event::OnConfirm, [this, box, commit]( const Event* ) { const std::string name = box->getTextInput()->getText().toUtf8(); if ( name.empty() ) return; box->closeWindow(); runAsync( [this, name, commit] { return mGit->createBranchAt( name, commit.hash, repoSelected() ); }, false, true ); } ); box->setTitle( i18n( "git_add_branch", "Add Branch" ) ); box->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } )->center(); box->showWhenReady(); } void GitPlugin::addTag( const Git::Commit& commit ) { UIMessageBox* box = UIMessageBox::New( UIMessageBox::TEXT_EDIT, String::format( i18n( "git_add_tag_to_commit", "Add tag to commit %s" ).toUtf8(), commit.shortHash ) ); auto* name = UITextInput::New(); name->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::WrapContent ) ->setLayoutMargin( Rectf( 0, 4, 0, 4 ) ) ->setParent( box->getTextEdit()->getParent() ); name->toPosition( 1 ); box->getTextEdit()->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::Fixed ); box->getButtonOK()->setText( i18n( "git_add_tag", "Add Tag" ) ); box->on( Event::OnConfirm, [this, box, name, commit]( const Event* ) { const std::string tag = name->getText().toUtf8(); if ( tag.empty() ) return; const std::string message = box->getTextEdit()->getText().toUtf8(); box->closeWindow(); runAsync( [this, tag, message, commit] { return mGit->createTag( tag, commit.hash, message, repoSelected() ); }, false, true ); } ); box->setTitle( i18n( "git_add_tag", "Add Tag" ) ); box->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } )->center(); box->showWhenReady(); name->setFocus(); } void GitPlugin::revertCommit( const Git::Commit& commit ) { UIMessageBox* box = UIMessageBox::New( UIMessageBox::YES_NO, i18n( "git_confirm_revert_commit", "Do you want to revert the selected commit?\nThis will " "create a commit that undoes its changes." ) ); box->getButtonOK()->setText( i18n( "git_revert_and_commit", "Revert & Commit" ) ); box->on( Event::OnConfirm, [this, commit]( const Event* ) { std::string repo = repoSelected(); runMergeLikeAsync( [commit, repo = std::move( repo )]( Git& git ) { return git.revert( commit.hash, true, repo ); }, repoSelected() ); } ); box->setTitle( i18n( "git_revert", "Revert" ) ); box->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } )->center(); box->showWhenReady(); } 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(), false ); } ); mDetachedHistory.tree->setOnSelection( [this]( const ModelIndex& index ) { if ( const auto* node = mHistoryModel ? mHistoryModel->node( index ) : nullptr; node ) { if ( node->type == GitHistoryModel::NodeType::WorkingTree ) openWorkingTreeDetails( true ); else if ( 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 ) { if ( node->type == GitHistoryModel::NodeType::WorkingTree ) openWorkingTreeDetails( true ); else if ( 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; ensureDetachedCommitDetailsHost(); mDetachedHistory.details.openCommitDetails( *this, commit, true ); } void GitPlugin::ensureDetachedCommitDetailsHost() { 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%" ) ); } } void GitPlugin::openCommitDetails( const Git::Commit& commit ) { mCommitDetails.openCommitDetails( *this, commit, false ); } void GitPlugin::openWorkingTreeDetails( bool detached ) { Git::Commit commit; commit.subject = i18n( "git_working_tree_index", "Working Tree / Index" ); if ( detached ) ensureDetachedCommitDetailsHost(); ( detached ? mDetachedHistory.details : mCommitDetails ) .openCommitDetails( *this, commit, detached, true ); } void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const Git::Commit& commit, bool detached, bool isWorkingTree ) { if ( commit.hash.empty() && !isWorkingTree ) return; const std::string selectedRepo = plugin.repoSelected(); const Uint64 requestGeneration = ++generation; this->commit = commit; workingTree = isWorkingTree; repo = selectedRepo; const bool createView = !view; if ( createView ) { view = plugin.getUISceneNode()->loadLayoutFromString( R"xml( )xml" ); 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" ) ); } ); 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 = owner->findIcon( state->filesCollapsed ? "expand-all" : "collapse-all" ) ) state->filesToggle->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 ) ) ); } ); gitHub->onClick( [state]( const Event* ) { if ( !state->url.empty() ) Engine::instance()->openURI( state->url ); } ); 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(); } ); } } subject->setText( String::fromUtf8( commit.subject ) ); author->setText( isWorkingTree ? plugin.i18n( "git_uncommitted_changes", "Uncommitted changes" ) : String::fromUtf8( commit.authorName ) ); dateEmail->setText( isWorkingTree ? String{} : 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 ); view->find( "git_commit_sha" )->setVisible( !isWorkingTree ); view->find( "git_commit_show_history" )->setVisible( !isWorkingTree ); 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 = isWorkingTree ? commit.subject : commit.shortHash + " " + commit.subject; 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 ( requestGeneration == generation ) loadCommitFiles( plugin, detached ); } 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; const bool workingTree = this->workingTree; auto git = plugin.mGit; const auto lifetime = plugin.mLifetime.weakHandle(); plugin.runAsyncTask( [git = std::move( git ), lifetime, generation, repo, commit, detached, workingTree] { auto result = workingTree ? git->workingTreeFiles( repo ) : git->commitFiles( commit, repo ); lifetime.run( [generation, repo, commit, detached, workingTree, result = std::move( result )]( GitPlugin* plugin ) mutable { 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 || workingTree != details.workingTree || !details.view || ( detached ? !plugin->mDetachedHistory.view : !plugin->mManager->getSplitter()->ownedWidgetExists( details.view ) ) ) return; if ( result.fail() ) { 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 }; details.subject->setText( String::fromUtf8( subject.empty() ? commit.subject : std::string{ subject } ) ); std::string body; if ( subjectEnd != std::string::npos ) { size_t bodyStart = subjectEnd; while ( bodyStart < message.size() && ( message[bodyStart] == '\n' || message[bodyStart] == '\r' ) ) ++bodyStart; body = message.substr( bodyStart ); } 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; int totalDeletes = 0; for ( const auto& file : result.files ) { totalInserts += file.inserts; totalDeletes += file.deletes; } if ( result.files.empty() ) { details.status->setText( plugin->i18n( "git_no_changed_files", "No changed files" ) ); } else { 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 ); details.url = std::move( result.commitURL ); details.gitHub->setVisible( !details.url.empty() ); details.diffContainer->closeAllChildren(); details.diff = nullptr; if ( !result.patch.empty() ) { 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 ) ) { diff->setInteractiveFileHeader( true ); if ( const auto* scheme = plugin->getPluginContext()->getCurrentColorScheme() ) diff->setSyntaxColorScheme( *scheme ); } } const bool hasDiff = details.diff != nullptr; details.filesToggle->setVisible( hasDiff ); details.modeToggle->setVisible( hasDiff ); } ); } ); } void GitPlugin::updateBranches( bool force ) { if ( !mGit || !mGitFound || ( mRunningUpdateBranches && !force ) ) return; if ( !mGit || mGit->getGitFolder().empty() ) { mLifetime.weakHandle().run( []( GitPlugin* plugin ) { plugin->updateBranchesUI( nullptr ); } ); return; } const std::string requestedRepo = repoSelected(); const auto lifetime = mLifetime.weakHandle(); mRunningUpdateBranches++; mThreadPool->run( [this, requestedRepo, lifetime] { if ( !mGit || mGit->getGitFolder().empty() ) { lifetime.run( [requestedRepo]( GitPlugin* plugin ) { if ( requestedRepo == plugin->repoSelected() ) plugin->updateBranchesUI( nullptr ); } ); return; } auto prevBranch = updateReposBranches(); auto branches = mGit->getAllBranchesAndTags( Git::RefType::All, {}, requestedRepo ); auto hash = GitBranchModel::hashBranches( branches ); auto model = GitBranchModel::asModel( std::move( branches ), hash, this ); bool branchChanged; { Lock l( mGitBranchMutex ); branchChanged = prevBranch != mGitBranches; } lifetime.run( [model, hash, branchChanged, requestedRepo]( GitPlugin* plugin ) { if ( requestedRepo != plugin->repoSelected() ) return; if ( plugin->mBranchesTree && plugin->mBranchesTree->getModel() && static_cast( plugin->mBranchesTree->getModel() )->getHash() == hash ) { if ( plugin->mBranchesTree->getModel() ) { if ( branchChanged ) plugin->mBranchesTree->getModel()->invalidate( Model::DontInvalidateIndexes ); plugin->updateHistoryRefs( std::static_pointer_cast( plugin->mBranchesTree->getModelShared() ) ); } if ( branchChanged ) plugin->invalidateHistory(); } else { plugin->updateBranchesUI( model ); if ( branchChanged ) plugin->invalidateHistory(); } } ); }, [this]( auto ) { mRunningUpdateBranches--; } ); } void GitPlugin::updateRepos() { if ( !mGit ) return; auto subModules = mGit->getSubModules(); std::sort( subModules.begin(), subModules.end() ); std::vector> repos; repos.clear(); repos.emplace_back( mProjectPath, FileSystem::fileNameFromPath( mProjectPath ) ); for ( auto& subModule : subModules ) { std::string subModulePath = mProjectPath + subModule; repos.emplace_back( std::move( subModulePath ), FileSystem::fileNameFromPath( subModule ) ); } Lock l( mReposMutex ); if ( repos == mRepos ) return; mRepos = std::move( repos ); } void GitPlugin::updateBranchesUI( std::shared_ptr model ) { buildSidePanelTab(); if ( !model ) { mBranchesTree->setModel( model ); } else { mBranchesTree->setModel( model ); mBranchesTree->setColumnsVisible( { GitBranchModel::Name } ); mBranchesTree->expandAll(); } updateHistoryRefs( model ); updateRepos(); std::vector items; decltype( mRepos ) repos; { Lock l( mReposMutex ); repos = mRepos; } for ( const auto& repo : repos ) items.push_back( repo.second ); if ( repos.empty() || ( repos.size() == 1 && repos.begin()->second == "" ) ) { if ( !mRepoDropDown->getListBox()->isEmpty() ) mRepoDropDown->getListBox()->clear(); return; } if ( mRepoDropDown->getListBox()->getItemsText() != items ) { mRepoDropDown->getListBox()->clear(); mRepoDropDown->getListBox()->addListBoxItems( items ); mRepoDropDown->getListBox()->setSelected( repoName( repoSelected() ) ); } } void GitPlugin::buildSidePanelTab() { if ( mTabContents && !mTab ) { if ( mProjectPath.empty() ) return; UIIcon* icon = findIcon( "source-control" ); mTab = mSidePanel->add( i18n( "source_control", "Source Control" ), mTabContents, icon ? icon->createDrawable( PixelDensity::dpToPx( 12 ) ) : nullptr ); mTab->setId( "source_control_tab" ); mTab->setTextAsFallback( true ); return; } if ( mTab ) return; if ( mSidePanel == nullptr ) getUISceneNode()->bind( "panel", mSidePanel ); if ( !UIWidgetCreator::isWidgetRegistered( "GitHistoryTreeView" ) ) UIWidgetCreator::registerWidget( "GitHistoryTreeView", GitHistoryTreeView::New ); static constexpr auto STYLE = R"html( )html"; UIIcon* icon = findIcon( "source-control" ); std::string color = !mHighlightStyleColor.empty() && Color::isColorString( mHighlightStyleColor ) ? mHighlightStyleColor : std::string{ DEFAULT_HIGHLIGHT_COLOR }; mTabContents = getUISceneNode()->loadLayoutFromString( 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 ); mTab->setId( "source_control_tab" ); mTab->setTextAsFallback( true ); mTabContents->bind( "git_panel_switcher", mPanelSwicher ); 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", mHistoryRefDropDown ); mTabContents->bind( "git_content", mGitContentView ); mTabContents->bind( "git_no_content", mGitNoContentView ); mTabContents->bind( "git_conflict_state", mConflictStateBar ); mTabContents->bind( "git_conflict_state_text", mConflictStateText ); mTabContents->bind( "git_panel_loader", mLoader ); mTabContents->bind( "git_repo", mRepoDropDown ); 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(); } ); mTabContents->find( "git_history_open" )->onClick( [this]( auto ) { showGitHistory(); } ); mHistoryRefDropDown->getListView()->setColumnsVisible( { 0 } ); mHistoryRefDropDown->getListView()->setAutoExpandOnSingleColumn( true ); mHistoryRefDropDown->on( Event::OnItemSelected, [this]( const Event* ) { if ( mUpdatingHistoryRefs ) return; const ModelIndex selected = mHistoryRefDropDown->getListView()->getSelection().first(); if ( !selected.isValid() || !mHistoryRefModel ) return; const std::string revision = mHistoryRefModel->revision( selected.row() ); if ( revision == mHistoryRevision ) return; mHistoryRevision = revision; updateDetachedHistoryTitle(); invalidateHistory(); } ); updateHistoryRefs( mBranchesTree && mBranchesTree->getModel() ? std::static_pointer_cast( mBranchesTree->getModelShared() ) : nullptr ); mTabContents->find( "git_conflict_continue" )->onClick( [this]( auto ) { continueConflictOperation(); } ); mTabContents->find( "git_conflict_abort" )->onClick( [this]( auto ) { abortConflictOperation(); } ); mBranchesTree->setAutoExpandOnSingleColumn( true ); mBranchesTree->setHeadersVisible( false ); mBranchesTree->setExpandersAsIcons( true ); mBranchesTree->setIndentWidth( PixelDensity::dpToPx( 16 ) ); mBranchesTree->setScrollViewType( ScrollViewType::Overlay ); mBranchesTree->on( Event::OnModelEvent, [this]( const Event* event ) { const ModelEvent* modelEvent = static_cast( event ); if ( !modelEvent->getModelIndex().hasParent() ) return; const Git::Branch* branch = static_cast( modelEvent->getModelIndex().internalData() ); switch ( modelEvent->getModelEventType() ) { case ModelEventType::Open: { if ( branch->type != Git::RefType::Stash ) checkout( *branch ); else stashApply( *branch ); break; } case ModelEventType::OpenMenu: { openBranchMenu( *branch ); break; } default: break; } } ); mBranchesTree->on( Event::KeyDown, [this]( const Event* event ) { const KeyEvent* keyEvent = event->asKeyEvent(); ModelIndex modelIndex = mBranchesTree->getSelection().first(); if ( !modelIndex.isValid() || modelIndex.internalId() == -1 || !mBranchesTree->getModel() ) return; Git::Branch branch = static_cast( mBranchesTree->getModel() )->branch( modelIndex ); switch ( keyEvent->getKeyCode() ) { case KEY_F7: branchCreate(); break; case KEY_F2: branchRename( branch ); break; case KEY_DELETE: if ( branch.type == Git::RefType::Stash ) stashDrop( branch ); else if ( branch.type == Git::RefType::Head ) branchDelete( branch ); break; default: break; } } ); auto listBox = mPanelSwicher->getListBox(); listBox->addListBoxItems( { i18n( "branches", "Branches" ), i18n( "status", "Status" ), i18n( "git_history", "History" ) } ); mStackMap.resize( 3 ); mStackMap[0] = mTabContents->find( "git_branches" ); mStackMap[1] = mTabContents->find( "git_status" ); mStackMap[2] = mTabContents->find( "git_history" ); mPanelSwicher->on( Event::OnItemSelected, [this, listBox]( const Event* ) { mStackWidget->setActiveWidget( mStackMap[listBox->getItemSelectedIndex()] ); if ( listBox->getItemSelectedIndex() == 2 ) { if ( mBranchesTree && mBranchesTree->getModel() ) updateHistoryRefs( std::static_pointer_cast( mBranchesTree->getModelShared() ) ); 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( event ); if ( modelEvent->getModelEventType() == ModelEventType::OpenTree ) activateHistoryIndex( modelEvent->getModelIndex(), true ); else if ( modelEvent->getModelEventType() == ModelEventType::Open ) { activateHistoryIndex( modelEvent->getModelIndex(), false ); if ( const auto* node = mHistoryModel->node( modelEvent->getModelIndex() ); node ) { if ( node->type == GitHistoryModel::NodeType::WorkingTree ) openWorkingTreeDetails( false ); else if ( node->type == GitHistoryModel::NodeType::Commit ) openCommitDetails( node->commit ); } } else if ( modelEvent->getModelEventType() == ModelEventType::OpenMenu ) openHistoryMenu( modelEvent->getModelIndex(), true ); } ); mHistoryTree->setOnSelection( [this]( const ModelIndex& index ) { if ( !mHistoryModel ) return; const auto* node = mHistoryModel->node( index ); if ( node && node->type == GitHistoryModel::NodeType::WorkingTree ) openWorkingTreeDetails( false ); else if ( node && node->type == GitHistoryModel::NodeType::Commit ) openCommitDetails( node->commit ); } ); mStatusTree->setAutoColumnsWidth( true ); mStatusTree->setHeadersVisible( false ); mStatusTree->setExpandersAsIcons( true ); mStatusTree->setScrollViewType( ScrollViewType::Overlay ); mStatusTree->setIndentWidth( PixelDensity::dpToPx( 4 ) ); mStatusTree->setSelectionKind( UIAbstractView::SelectionKind::Multiple ); mStatusTree->on( Event::OnRowCreated, [this]( const Event* event ) { UITableRow* row = event->asRowCreatedEvent()->getRow(); row->on( Event::MouseUp, [this, row]( const Event* event ) { if ( event->asMouseEvent()->getFlags() & EE_BUTTON_RMASK ) mStatusTree->onOpenMenuModelIndex( row->getCurIndex(), event ); } ); } ); mStatusTree->on( Event::OnModelEvent, [this]( const Event* event ) { const ModelEvent* modelEvent = static_cast( event ); auto modelShared = mStatusTree->getModelShared(); if ( !modelShared ) return; auto model = static_cast( modelShared.get() ); if ( modelEvent->getModelIndex().internalId() == GitStatusModel::GitFile ) { const Git::DiffFile* file = model->file( modelEvent->getModelIndex() ); if ( file == nullptr ) return; switch ( modelEvent->getModelEventType() ) { case ModelEventType::OpenMenu: { std::vector files; files.reserve( mStatusTree->getSelection().size() ); mStatusTree->getSelection().forEachIndex( [model, &files]( const ModelIndex& index ) { if ( const auto* selectedFile = model->file( index ) ) files.emplace_back( *selectedFile ); } ); if ( files.empty() ) files.emplace_back( *file ); openFileStatusMenu( std::move( files ) ); break; } case ModelEventType::Open: { if ( file->report.type == Git::GitStatusType::Unmerged ) openConflictResolver( file->file ); else diff( file->file, file->report.type ); break; } default: break; } } else if ( modelEvent->getModelIndex().internalId() == GitStatusModel::Status ) { switch ( modelEvent->getModelEventType() ) { case ModelEventType::OpenMenu: { const auto* status = model->statusType( modelEvent->getModelIndex() ); auto type = status->type; if ( type == Git::GitStatusType::Staged || type == Git::GitStatusType::Untracked || type == Git::GitStatusType::Changed ) { std::string repoPath; if ( !status->files.empty() ) repoPath = mGit->repoPath( status->files.front().file ); UIPopUpMenu* menu = UIPopUpMenu::New(); menu->setId( "git_status_type_menu" ); if ( type == Git::GitStatusType::Staged ) { menuAdd( menu, "git-commit", i18n( "git_commit", "Commit" ), "git-commit" ); menuAdd( menu, "git-diff-staged", i18n( "git_diff_staged", "Diff Staged" ), "diff-multiple" ); menuAdd( menu, "git-unstage-all", i18n( "git_unstage_all", "Unstage All" ), "diff-removed" ); } if ( type == Git::GitStatusType::Untracked || type == Git::GitStatusType::Changed ) menuAdd( menu, "git-stage-all", i18n( "git_stage_all", "Stage All" ), "diff-added" ); if ( type == Git::GitStatusType::Changed ) { menu->addSeparator(); menuAdd( menu, "git-discard-all", i18n( "git_discard_all", "Discard All" ) ); } menu->on( Event::OnItemClicked, [this, modelShared, repoPath, type]( const Event* event ) { if ( !mGit || !modelShared ) return; auto model = static_cast( modelShared.get() ); UIMenuItem* item = event->getNode()->asType(); std::string id( item->getId() ); if ( id == "git-commit" ) { commit( repoPath ); } else if ( id == "git-stage-all" ) { stage( model->getFiles( repoFullName( repoPath ), static_cast( type ) ) ); } else if ( id == "git-unstage-all" ) { unstage( model->getFiles( repoFullName( repoPath ), (Uint32)Git::GitStatusType::Staged ) ); } else if ( id == "git-discard-all" ) { discard( model->getFiles( repoFullName( repoPath ), (Uint32)Git::GitStatusType::Changed ) ); } else if ( id == "git-diff-staged" ) { diff( Git::DiffMode::DiffStaged, repoPath ); } } ); menu->showOverMouseCursor(); } break; } default: break; } } else if ( modelEvent->getModelIndex().internalId() == GitStatusModel::Repo ) { switch ( modelEvent->getModelEventType() ) { case ModelEventType::OpenMenu: { const auto* repo = model->repo( modelEvent->getModelIndex() ); if ( repo == nullptr ) return; std::string repoName = repo->repo; std::string repoPath = this->repoPath( repo->repo ); if ( repoPath.empty() && !repo->type.empty() && !repo->type.front().files.empty() ) repoPath = mGit->repoPath( repo->type.front().files.front().file ); if ( repoPath.empty() ) return; UIPopUpMenu* menu = UIPopUpMenu::New(); menu->setId( "git_repo_type_menu" ); if ( repo->hasStatusType( Git::GitStatusType::Staged ) ) { menuAdd( menu, "git-commit", i18n( "git_commit", "Commit" ), "git-commit" ); } if ( repo->hasStatusType( Git::GitStatusType::Untracked ) ) { menuAdd( menu, "git-stage-all", i18n( "git_stage_all", "Stage All" ), "diff-added" ); } menuAdd( menu, "git-fetch", i18n( "git_fetch", "Fetch" ), "repo-fetch" ); menuAdd( menu, "git-pull", i18n( "git_pull", "Pull" ), "repo-pull" ); menuAdd( menu, "git-push", i18n( "git_push", "Push" ), "repo-push" ); menuAdd( menu, "git-stash", i18n( "git_stash_all", "Stash All" ), "git-stash" ); menuAdd( menu, "git-diff-head", i18n( "git_diff_head", "Diff HEAD" ), "diff-multiple" ); menu->on( Event::OnItemClicked, [this, model, repoName, repoPath]( const Event* event ) { if ( !mGit ) return; UIMenuItem* item = event->getNode()->asType(); std::string id( item->getId() ); if ( id == "git-stash" ) { stashPush( model->getFiles( repoName ), repoPath ); } else if ( id == "git-fetch" ) { fetch( repoPath ); } else if ( id == "git-pull" ) { pull( repoPath ); } else if ( id == "git-push" ) { push( repoPath ); } else if ( id == "git-commit" ) { commit( repoPath ); } else if ( id == "git-stage-all" ) { stage( model->getFiles( repoName, (Uint32)Git::GitStatusType::Untracked | (Uint32)Git::GitStatusType::Changed ) ); } else if ( id == "git-diff-head" ) { diff( Git::DiffMode::DiffHead, repoPath ); } } ); menu->showOverMouseCursor(); break; } default: break; } } } ); mRepoDropDown->on( Event::OnItemSelected, [this]( const Event* ) { const auto& txt = mRepoDropDown->getListBox()->getItemSelectedText(); for ( const auto& repo : mRepos ) { if ( txt == repo.second ) { { Lock l( mRepoMutex ); mRepoSelected = repo.first; } mHistoryRevision = "HEAD"; updateHistoryRefs( nullptr ); invalidateHistory(); updateBranches( true ); updateStatus( true ); break; } } } ); } void GitPlugin::hideSidePanel() { if ( mSidePanel && mTab ) { mSidePanel->removeTab( mTab, false ); mTab = nullptr; } } void GitPlugin::openBranchMenu( const Git::Branch& branch ) { UIPopUpMenu* menu = UIPopUpMenu::New(); menu->setId( "git_branch_menu" ); if ( branch.type != Git::RefType::Stash ) { menuAdd( menu, "git-fetch", i18n( "git_fetch", "Fetch" ), "repo-fetch" ); if ( gitBranch() != branch.name ) { menuAdd( menu, "git-checkout", i18n( "git_checkout_ellipsis", "Check Out..." ), "git-fetch" ); } if ( branch.type == Git::RefType::Head ) { menuAdd( menu, "git-branch-rename", i18n( "git_rename", "Rename" ), "", { KEY_F2 } ); menuAdd( menu, "git-pull", i18n( "git_pull", "Pull" ), "repo-pull" ); if ( branch.ahead ) menuAdd( menu, "git-push", i18n( "git_push", "Push" ), "repo-push" ); if ( branch.behind ) menuAdd( menu, "git-fast-forward-merge", i18n( "git_fast_forward_merge", "Fast Forward Merge" ) ); } if ( branch.type == Git::RefType::Head || branch.type == Git::RefType::Remote || branch.type == Git::RefType::Tag ) { menu->addSeparator(); menuAdd( menu, "git-branch-delete", branch.type == Git::RefType::Tag ? i18n( "git_delete_tag", "Delete Tag" ) : i18n( "git_delete_branch", "Delete" ), "remove" ); } if ( branch.type != Git::RefType::Tag ) menuAdd( menu, "git-merge-branch", i18n( "git_merge_branch", "Merge Branch" ), "git-merge" ); menuAdd( menu, "git-create-branch", i18n( "git_create_branch", "Create Branch" ), "repo-forked", { KEY_F7 } ); } else { menuAdd( menu, "git-stash-apply", i18n( "git_apply_stash", "Apply Stash" ), "git-stash-apply" ); menuAdd( menu, "git-stash-drop", i18n( "git_drop_stash", "Drop Stash" ), "git-stash-pop" ); } menu->on( Event::OnItemClicked, [this, branch]( const Event* event ) { if ( !mGit ) return; UIMenuItem* item = event->getNode()->asType(); std::string id( item->getId() ); if ( id == "git-checkout" ) { checkout( branch ); } else if ( id == "git-pull" ) { pull( repoSelected() ); } else if ( id == "git-push" ) { push( repoSelected() ); } else if ( id == "git-branch-delete" ) { branchDelete( branch ); } else if ( id == "git-branch-rename" ) { branchRename( branch ); } else if ( id == "git-fetch" ) { fetch( repoSelected() ); } else if ( id == "git-fast-forward-merge" ) { fastForwardMerge( branch ); } else if ( id == "git-create-branch" ) { branchCreate(); } else if ( id == "git-stash-apply" ) { stashApply( branch ); } else if ( id == "git-stash-drop" ) { stashDrop( branch ); } else if ( id == "git-merge-branch" ) { branchMerge( branch ); } } ); menu->showOverMouseCursor(); } void GitPlugin::openFileStatusMenu( std::vector files ) { if ( files.empty() ) return; UIPopUpMenu* menu = UIPopUpMenu::New(); menu->setId( "git_file_status_menu" ); const bool multiple = files.size() > 1; bool hasStaged = false; bool hasUnstaged = false; bool hasUnmerged = false; for ( const auto& file : files ) { hasStaged |= file.report.type == Git::GitStatusType::Staged; hasUnstaged |= file.report.type != Git::GitStatusType::Staged; hasUnmerged |= file.report.type == Git::GitStatusType::Unmerged; } if ( hasUnmerged && !multiple ) { menuAdd( menu, "git-resolve-conflict", i18n( "git_resolve_conflict", "Resolve Conflict" ), "diff-modified" ); menuAdd( menu, "git-accept-ours", i18n( "git_accept_ours", "Accept Ours" ) ); menuAdd( menu, "git-accept-theirs", i18n( "git_accept_theirs", "Accept Theirs" ) ); menu->on( Event::OnItemClicked, [this, file = files.front()]( const Event* event ) { const std::string id = event->getNode()->asType()->getId(); if ( id == "git-resolve-conflict" ) openConflictResolver( file.file ); else if ( id == "git-accept-ours" ) acceptConflictSide( file.file, true ); else if ( id == "git-accept-theirs" ) acceptConflictSide( file.file, false ); } ); menu->showOverMouseCursor(); return; } menuAdd( menu, "git-open-file", multiple ? i18n( "git_open_files", "Open Files" ) : i18n( "git_open_file", "Open File" ), "file" ); menuAdd( menu, "git-diff", multiple ? i18n( "git_open_diffs", "Open Diffs" ) : i18n( "git_open_diff", "Open Diff" ), multiple ? "diff-multiple" : "diff-single" ); if ( hasUnstaged ) menuAdd( menu, "git-stage", i18n( "git_stage", "Stage" ), "diff-added" ); if ( hasStaged ) menuAdd( menu, "git-unstage", i18n( "git_unstage", "Unstage" ), "diff-removed" ); menu->addSeparator(); if ( hasUnstaged ) menuAdd( menu, "git-discard", i18n( "git_discard", "Discard" ) ); menu->on( Event::OnItemClicked, [this, files = std::move( files )]( const Event* event ) mutable { if ( !mGit ) return; UIMenuItem* item = event->getNode()->asType(); std::string id( item->getId() ); std::vector paths; paths.reserve( files.size() ); if ( id == "git-stage" ) { for ( const auto& file : files ) if ( file.report.type != Git::GitStatusType::Staged ) paths.emplace_back( file.file ); stage( paths ); } else if ( id == "git-unstage" ) { for ( const auto& file : files ) if ( file.report.type == Git::GitStatusType::Staged ) paths.emplace_back( file.file ); unstage( paths ); } else if ( id == "git-discard" ) { for ( const auto& file : files ) if ( file.report.type != Git::GitStatusType::Staged ) paths.emplace_back( file.file ); if ( paths.size() == 1 ) discard( paths.front() ); else discard( paths ); } else if ( id == "git-open-file" ) { for ( const auto& file : files ) openFile( file.file ); } else if ( id == "git-diff" ) { if ( files.size() == 1 ) diff( files.front().file, files.front().report.type ); else diff( std::move( files ) ); } } ); menu->showOverMouseCursor(); } void GitPlugin::runAsync( std::function fn, bool _updateStatus, bool _updateBranches, bool displaySuccessMsg, bool updateBranchesOnError, bool updateStatusOnError, bool historyChanged ) { if ( !mGit ) return; mLoader->setVisible( true ); const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [lifetime, fn, _updateStatus, _updateBranches, displaySuccessMsg, updateBranchesOnError, updateStatusOnError, historyChanged] { auto res = fn(); lifetime.run( [res = std::move( res ), _updateStatus, _updateBranches, displaySuccessMsg, 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 ) plugin->updateBranches(); if ( _updateStatus && updateStatusOnError ) plugin->updateStatus( true ); return; } if ( _updateBranches ) plugin->updateBranches(); if ( _updateStatus ) plugin->updateStatus( true ); } ); } ); } void GitPlugin::runMergeLikeAsync( std::function fn, const std::string& repoPath ) { if ( !mGit ) return; mLoader->setVisible( true ); auto git = mGit; const auto lifetime = mLifetime.weakHandle(); runAsyncTask( [git = std::move( git ), lifetime, fn = std::move( fn ), repoPath] { auto result = fn( *git ); Git::ConflictState conflicts; if ( result.fail() ) conflicts = git->conflictState( repoPath ); 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() ) plugin->showMessage( LSPMessageType::Warning, result.result ); } ); } ); } void GitPlugin::menuAdd( UIMenu* menu, const std::string& cmd, const std::string& text, const std::string& icon, const KeyBindings::Shortcut& forcedKeybinding ) { menu->add( text, iconDrawable( icon, 12 ), forcedKeybinding.empty() ? KeyBindings::keybindFormat( mKeyBindings[cmd] ) : getUISceneNode()->getKeyBindings().getShortcutString( forcedKeybinding, true ) ) ->setId( cmd ); } std::string GitPlugin::repoSelected() { Lock l( mRepoMutex ); return mRepoSelected; } std::string GitPlugin::projectPath() { Lock l( mRepoMutex ); return mProjectPath; } std::string GitPlugin::repoName( const std::string& repoPath ) { Lock l( mRepoMutex ); for ( const auto& repo : mRepos ) if ( repo.first == repoPath ) return repo.second; return ""; } std::string GitPlugin::repoFullName( const std::string& repoPath ) { Lock l( mRepoMutex ); for ( const auto& repo : mRepos ) { if ( repo.first == repoPath ) { if ( repoPath != mProjectPath ) { auto fullName( repo.first ); FileSystem::filePathRemoveBasePath( mProjectPath, fullName ); return fullName; } return repo.second; } } return ""; } std::string GitPlugin::repoPath( const std::string& repoName ) { Lock l( mRepoMutex ); for ( const auto& repo : mRepos ) if ( repo.second == repoName ) return repo.first; return ""; } } // namespace ecode