Add shared controls for multi-file diff views

- Introduce UIMultiDiffView as a first-class container for multi-file diffs, with a compact toolbar for expanding or collapsing all files, switching between unified and split views, and displaying aggregate file and line-change statistics.
	- Calculate addition and removal totals during diff preparation so asynchronously prepared diffs do not need to rescan their contents on the UI thread. Cache the child diff views and expose the multi-diff state through the new component.
	- Use UIMultiDiffView for regular diff tabs and Git commit details, removing the duplicated commit-specific toolbar, styling logic, tokenizer state, and view-mode handling. Preserve the original Git commit toolbar appearance, spacing, icons, tooltips, and colored changed-files summary.
	- Keep the existing UIDiffView multi-file factory available for compatibility and add regression coverage for aggregate statistics, toolbar visibility, unified/split propagation, collapse state, prepared diffs, and large patches.
	- Add localized changed-files summaries to all ecode translation catalogs.
	- Fix the secondary-window UI test to use the appropriate std::string assertion helper.
This commit is contained in:
Martín Lucas Golini
2026-09-10 23:15:46 -03:00
parent 095fe7919e
commit 3effffd16f
11 changed files with 318 additions and 137 deletions
+11 -22
View File
@@ -2663,21 +2663,16 @@ void App::loadDiffFromMemory( const std::string& content, const std::string& ori
if ( !icon )
icon = getUISceneNode()->findIcon( "file" );
auto scrollView = UIDiffView::NewMultiFileDiffViewer(
content, repoPath, mConfig.editor.diffViewMode, interactiveFileHeaders );
auto [tab, iv] = getSplitter()->createWidget( scrollView, diffViewTitle );
auto multiDiff = UIMultiDiffView::New( content, repoPath, mConfig.editor.diffViewMode,
interactiveFileHeaders );
auto [tab, iv] = getSplitter()->createWidget( multiDiff, diffViewTitle );
if ( icon )
tab->setIcon( icon->createDrawable( getMenuIconSize() ) );
tab->setText( diffViewTitle );
auto diffView = scrollView->getFirstChild()->asType<UILinearLayout>()->getFirstChild();
while ( diffView ) {
if ( diffView->isType( UI_TYPE_DIFF_VIEW ) ) {
configureDiffView( diffView->asType<UIDiffView>() );
diffView->asType<UIDiffView>()->setSyntaxColorScheme( *getCurrentColorScheme() );
}
diffView = diffView->getNextNode();
for ( auto* diffView : multiDiff->getDiffViews() ) {
configureDiffView( diffView );
diffView->setSyntaxColorScheme( *getCurrentColorScheme() );
}
return;
}
@@ -2719,21 +2714,15 @@ void App::loadDiffFromPath( const std::string& path ) {
if ( !icon )
icon = getUISceneNode()->findIcon( "file" );
auto scrollView =
UIDiffView::NewMultiFileDiffViewer( content, "", mConfig.editor.diffViewMode );
auto [tab, iv] = getSplitter()->createWidget( scrollView, diffViewTitle );
auto multiDiff = UIMultiDiffView::New( content, "", mConfig.editor.diffViewMode );
auto [tab, iv] = getSplitter()->createWidget( multiDiff, diffViewTitle );
if ( icon )
tab->setIcon( icon->createDrawable( getMenuIconSize() ) );
tab->setText( diffViewTitle );
auto diffView = scrollView->getFirstChild()->asType<UILinearLayout>()->getFirstChild();
while ( diffView ) {
if ( diffView->isType( UI_TYPE_DIFF_VIEW ) ) {
configureDiffView( diffView->asType<UIDiffView>() );
diffView->asType<UIDiffView>()->setSyntaxColorScheme( *getCurrentColorScheme() );
}
diffView = diffView->getNextNode();
for ( auto* diffView : multiDiff->getDiffViews() ) {
configureDiffView( diffView );
diffView->setSyntaxColorScheme( *getCurrentColorScheme() );
}
return;
}
+17 -92
View File
@@ -646,28 +646,6 @@ void GitPlugin::updateStatusBarSync() {
mStatusButton->invalidateDraw();
}
void GitPlugin::styleCommitFilesStatus( UITextView* status ) {
if ( !status )
return;
status->setUsingCustomStyling( true );
if ( !mCommitStatusCustomTokenizer.has_value() ) {
std::vector<SyntaxPattern> 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;
@@ -843,9 +821,6 @@ PluginRequestHandle GitPlugin::processMessage( const PluginMessage& msg ) {
}
case ecode::PluginMessageType::UIThemeReloaded: {
mStatusCustomTokenizer.reset();
mCommitStatusCustomTokenizer.reset();
styleCommitFilesStatus( mCommitDetails.status );
styleCommitFilesStatus( mDetachedHistory.details.status );
updateUINow( true );
break;
}
@@ -3060,19 +3035,8 @@ void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const
<TextView id="git_commit_message" lw="mp" lh="wc" word-wrap="true"
focusable="false" visible="false" />
</vbox>
<hbox lw="mp" lh="wc" padding-left="8dp" padding-right="8dp"
padding-top="4dp" padding-bottom="4dp">
<PushButton id="git_commit_files_toggle"
tooltip="@string(git_collapse_all_files, Collapse All Files)"
icon="icon(collapse-all, 12dp)" class="git_commit_btn" />
<PushButton id="git_commit_mode_toggle"
text="@string(git_split_diff, Split)"
tooltip="@string(git_switch_to_split_diff, Switch to split diff view)"
icon="icon(split-horizontal, 12dp)" text-as-fallback="true"
margin-left="4dp" class="git_commit_btn" />
<TextView id="git_commit_files_status" lw="0" lw8="1" lh="wc"
margin-left="8dp" layout_gravity="center_vertical" focusable="false" />
</hbox>
<TextView id="git_commit_files_status" lw="mp" lh="wc" padding="8dp"
focusable="false" />
<vbox id="git_commit_diff" lw="mp" lh="0" lw8="1" />
</vbox>
)xml" );
@@ -3086,8 +3050,6 @@ void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const
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* ) {
@@ -3099,34 +3061,6 @@ void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const
"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 );
@@ -3158,11 +3092,7 @@ void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const
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 ) ) );
status->setVisible( true );
url.clear();
gitHub->setVisible( false );
view->find( "git_commit_sha" )->setVisible( !isWorkingTree );
@@ -3233,7 +3163,6 @@ void GitPlugin::CommitDetailsState::loadCommitFiles( GitPlugin& plugin, bool det
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 );
@@ -3258,42 +3187,28 @@ void GitPlugin::CommitDetailsState::loadCommitFiles( GitPlugin& plugin, bool det
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 ) );
details.status->setVisible( false );
}
plugin->styleCommitFilesStatus( details.status );
details.url = std::move( result.commitURL );
details.gitHub->setVisible( !details.url.empty() );
details.diffContainer->closeAllChildren();
details.diff = nullptr;
if ( preparedDiff ) {
details.diff = UIDiffView::NewMultiFileDiffViewer( std::move( preparedDiff ), repo,
details.viewMode );
details.diff = UIMultiDiffView::New( std::move( preparedDiff ), repo,
UIDiffView::ViewMode::Unified, true );
details.diff->setLayoutSizePolicy( SizePolicy::MatchParent,
SizePolicy::MatchParent );
details.diff->setParent( details.diffContainer );
for ( auto* diff : UIDiffView::multiFileDiffViews( details.diff ) ) {
diff->setInteractiveFileHeader( true );
for ( auto* diff : details.diff->getDiffViews() ) {
if ( const auto* scheme = plugin->getPluginContext()->getCurrentColorScheme() )
diff->setSyntaxColorScheme( *scheme );
}
}
const bool hasDiff = details.diff != nullptr;
details.filesToggle->setVisible( hasDiff );
details.modeToggle->setVisible( hasDiff );
} );
} );
}
@@ -3496,6 +3411,16 @@ void GitPlugin::buildSidePanelTab() {
#git_commit_details .git_commit_btn:hover {
border-color: var(--primary);
}
multidiffview .git_commit_btn {
lw: 20dp;
lh: 20dp;
padding: 0;
background-color: var(--list-back);
border-color: transparent;
}
multidiffview .git_commit_btn:hover {
border-color: var(--primary);
}
#git_commit_details #git_commit_author {
font-size: 11dp;
text-stroke-width: 1dp;
+1 -12
View File
@@ -178,11 +178,9 @@ class GitPlugin : public PluginBase {
UITextView* message{ nullptr };
UITextView* status{ nullptr };
UIPushButton* messageToggle{ nullptr };
UIPushButton* filesToggle{ nullptr };
UIPushButton* modeToggle{ nullptr };
UIPushButton* gitHub{ nullptr };
UIWidget* diffContainer{ nullptr };
UIScrollView* diff{ nullptr };
Tools::UIMultiDiffView* diff{ nullptr };
std::string messageBody;
std::string url;
Git::Commit commit;
@@ -190,9 +188,7 @@ class GitPlugin : public PluginBase {
std::atomic<Uint64> generation{ 0 };
std::shared_ptr<std::atomic_bool> diffPreparationCancelled;
EventConnection closeConnection;
Tools::UIDiffView::ViewMode viewMode{ Tools::UIDiffView::ViewMode::Unified };
bool messageExpanded{ false };
bool filesCollapsed{ false };
bool workingTree{ false };
void openCommitDetails( GitPlugin& plugin, const Git::Commit& commit, bool detached,
@@ -216,8 +212,6 @@ class GitPlugin : public PluginBase {
message = nullptr;
status = nullptr;
messageToggle = nullptr;
filesToggle = nullptr;
modeToggle = nullptr;
gitHub = nullptr;
diffContainer = nullptr;
diff = nullptr;
@@ -225,9 +219,7 @@ class GitPlugin : public PluginBase {
url.clear();
commit = {};
repo.clear();
viewMode = Tools::UIDiffView::ViewMode::Unified;
messageExpanded = false;
filesCollapsed = false;
workingTree = false;
}
};
@@ -278,7 +270,6 @@ class GitPlugin : public PluginBase {
SyntaxColorScheme scheme;
};
std::optional<CustomTokenizer> mStatusCustomTokenizer;
std::optional<CustomTokenizer> mCommitStatusCustomTokenizer;
std::optional<SyntaxDefinition> mTooltipCustomSyntaxDef;
Uint32 mModelChangedId{ 0 };
Uint32 mModelStylerId{ 0 };
@@ -365,8 +356,6 @@ class GitPlugin : public PluginBase {
void updateStatusBarSync();
void styleCommitFilesStatus( UITextView* status );
void updateUI();
void updateUINow( bool force = false );