From 74d9fa660c3647249ae7d30383cd14b424f798ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 12 Aug 2026 00:03:25 -0300 Subject: [PATCH] ecode: add multi-file Git status operations Enable multiple selection in the Git status tree and apply stage, unstage, discard, open, and diff actions to all applicable files. Open selected diffs in a single multi-file diff view, including portable handling for untracked files, and group mutating operations by repository. Make the commit dialog resizable, allow its text editor to grow with the window, and wrap commit messages by word. --- src/tools/ecode/plugins/git/git.cpp | 24 +++ src/tools/ecode/plugins/git/git.hpp | 2 + src/tools/ecode/plugins/git/gitplugin.cpp | 197 +++++++++++++++++----- src/tools/ecode/plugins/git/gitplugin.hpp | 8 +- 4 files changed, 185 insertions(+), 46 deletions(-) diff --git a/src/tools/ecode/plugins/git/git.cpp b/src/tools/ecode/plugins/git/git.cpp index f75176e4a..619693371 100644 --- a/src/tools/ecode/plugins/git/git.cpp +++ b/src/tools/ecode/plugins/git/git.cpp @@ -249,6 +249,30 @@ Git::Result Git::diff( const std::string& file, bool isStaged, const std::string projectDir ); } +Git::Result Git::diffUntracked( const std::string& file, const std::string& projectDir ) { + const std::string emptyFilePath = + Sys::getTempPath() + ".ecode-git-empty-" + String::randString( 16 ); + if ( !FileSystem::fileWrite( emptyFilePath, "" ) ) + return { "Could not create temporary file for untracked file diff.", EXIT_FAILURE }; + + auto result = gitSimple( + String::format( "diff --no-index -- \"%s\" \"%s\"", emptyFilePath, file ), projectDir ); + FileSystem::fileRemove( emptyFilePath ); + + // git diff --no-index returns 1 when differences were found. + if ( result.returnCode == 1 && !result.result.empty() ) { + result.returnCode = 0; + const auto oldFileHeader = result.result.find( "\n--- " ); + if ( oldFileHeader != std::string::npos ) { + const auto headerEnd = result.result.find( '\n', oldFileHeader + 1 ); + if ( headerEnd != std::string::npos ) + result.result.replace( oldFileHeader + 1, headerEnd - oldFileHeader - 1, + "--- /dev/null" ); + } + } + return result; +} + Git::Result Git::showFile( const std::string& file, const std::string& ref, const std::string& projectDir ) { std::string relativePath( file ); diff --git a/src/tools/ecode/plugins/git/git.hpp b/src/tools/ecode/plugins/git/git.hpp index 875a7e0fd..7a102f9e6 100644 --- a/src/tools/ecode/plugins/git/git.hpp +++ b/src/tools/ecode/plugins/git/git.hpp @@ -269,6 +269,8 @@ class Git { Result diff( const std::string& file, bool isStaged, const std::string& projectDir = "" ); + Result diffUntracked( const std::string& file, const std::string& projectDir = "" ); + Result showFile( const std::string& file, const std::string& ref, const std::string& projectDir = "" ); diff --git a/src/tools/ecode/plugins/git/gitplugin.cpp b/src/tools/ecode/plugins/git/gitplugin.cpp index ebab5a1d5..4e7d060d3 100644 --- a/src/tools/ecode/plugins/git/gitplugin.cpp +++ b/src/tools/ecode/plugins/git/gitplugin.cpp @@ -858,7 +858,7 @@ void GitPlugin::commit( const std::string& repoPath ) { UITextEdit* txtEdit = msgBox->getTextEdit(); txtEdit->setLineWrapType( LineWrapType::Viewport ); - txtEdit->setLineWrapMode( LineWrapMode::Letter ); + txtEdit->setLineWrapMode( LineWrapMode::Word ); txtEdit->setText( mLastCommitMsg ); UICheckBox* chkAmend = UICheckBox::New(); @@ -944,6 +944,20 @@ void GitPlugin::commit( const std::string& repoPath ) { 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(); @@ -1034,36 +1048,54 @@ std::optional GitPlugin::getBranchFromRepoPath( const std::string& } void GitPlugin::stage( const std::vector& files ) { - if ( files.empty() ) - return; - runAsync( - [this, files]() { return mGit->add( fixFilePaths( files ), mGit->repoPath( files[0] ) ); }, - true, false ); + 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]() { - return mGit->reset( fixFilePaths( files ), mGit->repoPath( files[0] ) ); + [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, false ); + 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 ) { - runAsync( - [this, files]() { - return mGit->restore( fixFilePaths( files ), mGit->repoPath( files[0] ) ); - }, - true, true ); - } ); + 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(); @@ -1160,6 +1192,35 @@ void GitPlugin::diff( const std::string& file, Git::GitStatusType status ) { } ); } +void GitPlugin::diff( std::vector files ) { + if ( files.empty() ) + return; + mThreadPool->run( [this, files = std::move( files )] { + 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'; + } + getUISceneNode()->runOnMainThread( + [this, patch = std::move( patch ), repoPath = std::move( repoPath )] { + getPluginContext()->loadDiffFromMemory( patch, "selected files", "", repoPath ); + } ); + } ); +} + // File operations // Stash operations @@ -1452,8 +1513,9 @@ void GitPlugin::buildSidePanelTab() { 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 = + mSidePanel->add( i18n( "source_control", "Source Control" ), mTabContents, + icon ? icon->createDrawable( PixelDensity::dpToPx( 12 ) ) : nullptr ); mTab->setId( "source_control_tab" ); mTab->setTextAsFallback( true ); return; @@ -1615,6 +1677,7 @@ void GitPlugin::buildSidePanelTab() { 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 ) { @@ -1636,7 +1699,16 @@ void GitPlugin::buildSidePanelTab() { switch ( modelEvent->getModelEventType() ) { case ModelEventType::OpenMenu: { - openFileStatusMenu( *file ); + 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: { @@ -1872,41 +1944,76 @@ void GitPlugin::openBranchMenu( const Git::Branch& branch ) { menu->showOverMouseCursor(); } -void GitPlugin::openFileStatusMenu( const Git::DiffFile& file ) { +void GitPlugin::openFileStatusMenu( std::vector files ) { + if ( files.empty() ) + return; + UIPopUpMenu* menu = UIPopUpMenu::New(); menu->setId( "git_file_status_menu" ); - menuAdd( menu, "git-open-file", i18n( "git_open_file", "Open File" ), "file" ); - menuAdd( menu, "git-diff", i18n( "git_open_diff", "Open Diff" ), "diff-single" ); - - if ( file.report.type != Git::GitStatusType::Staged ) { - menuAdd( menu, "git-stage", i18n( "git_stage", "Stage" ), "diff-added" ); - } else { - menuAdd( menu, "git-unstage", i18n( "git_unstage", "Unstage" ), "diff-removed" ); + const bool multiple = files.size() > 1; + bool hasStaged = false; + bool hasUnstaged = false; + for ( const auto& file : files ) { + hasStaged |= file.report.type == Git::GitStatusType::Staged; + hasUnstaged |= file.report.type != Git::GitStatusType::Staged; } + 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 ( file.report.type != Git::GitStatusType::Staged ) + if ( hasUnstaged ) menuAdd( menu, "git-discard", i18n( "git_discard", "Discard" ) ); - menu->on( Event::OnItemClicked, [this, file]( const Event* event ) { - if ( !mGit ) - return; - UIMenuItem* item = event->getNode()->asType(); - std::string id( item->getId() ); - if ( id == "git-stage" ) { - stage( { file.file } ); - } else if ( id == "git-unstage" ) { - unstage( { file.file } ); - } else if ( id == "git-discard" ) { - discard( file.file ); - } else if ( id == "git-open-file" ) { - openFile( file.file ); - } else if ( id == "git-diff" ) { - diff( file.file, file.report.type ); - } - } ); + 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(); } diff --git a/src/tools/ecode/plugins/git/gitplugin.hpp b/src/tools/ecode/plugins/git/gitplugin.hpp index f1f8818f7..80975442a 100644 --- a/src/tools/ecode/plugins/git/gitplugin.hpp +++ b/src/tools/ecode/plugins/git/gitplugin.hpp @@ -176,6 +176,10 @@ class GitPlugin : public PluginBase { void unstage( const std::vector& files ); + enum class FileOperation { Stage, Unstage, Discard }; + + void runFileOperation( std::vector files, FileOperation operation ); + void discard( const std::vector& files ); void discard( const std::string& file ); @@ -184,6 +188,8 @@ class GitPlugin : public PluginBase { void diff( const std::string& file, Git::GitStatusType status ); + void diff( std::vector files ); + void openFile( const std::string& file ); void updateStatus( bool force = false ); @@ -202,7 +208,7 @@ class GitPlugin : public PluginBase { void openBranchMenu( const Git::Branch& branch ); - void openFileStatusMenu( const Git::DiffFile& file ); + void openFileStatusMenu( std::vector files ); void stashPush( const std::vector& files, const std::string& repoPath );