KiCad PCB EDA Suite
Loading...
Searching...
No Matches
panel_git_history.cpp
Go to the documentation of this file.
1/*
2 * This program source code file is part of KiCad, a free EDA CAD application.
3 *
4 * Copyright The KiCad Developers, see AUTHORS.txt for contributors.
5 *
6 * This program is free software: you can redistribute it and/or modify it
7 * under the terms of the GNU General Public License as published by the
8 * Free Software Foundation, either version 3 of the License, or (at your
9 * option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful, but
12 * WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 * General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program. If not, see <http://www.gnu.org/licenses/>.
18 */
19
21
22#include <algorithm>
23#include <memory>
24#include <mutex>
25#include <string>
26#include <vector>
27
28#include <wx/arrstr.h>
29#include <wx/dcgraph.h>
30#include <wx/dcmemory.h>
31#include <wx/image.h>
32#include <wx/menu.h>
33#include <wx/mstream.h>
34#include <wx/richmsgdlg.h>
35
36#include <picosha2.h>
37
38#include <confirm.h>
45#include <eda_base_frame.h>
49#include <kiplatform/ui.h>
50#include <kiway.h>
51#include <pgm_base.h>
52#include <project.h>
54
55
56namespace
57{
58
59wxColour avatarColour( const wxString& aName )
60{
61 static const wxColour palette[] = {
62 wxColour( 0x3b, 0x7d, 0xd8 ), wxColour( 0x8a, 0x5c, 0xd0 ), wxColour( 0x2f, 0x9e, 0x52 ),
63 wxColour( 0xc0, 0x85, 0x1a ), wxColour( 0xcf, 0x3b, 0x3b ), wxColour( 0x0e, 0x9a, 0xa0 ),
64 };
65
66 unsigned hash = 0;
67
68 for( wxUniChar c : aName )
69 hash = hash * 31 + static_cast<unsigned>( c.GetValue() );
70
71 return palette[hash % ( sizeof( palette ) / sizeof( palette[0] ) )];
72}
73
74
75wxString initialsOf( const wxString& aName )
76{
77 wxString result;
78
79 for( const wxString& part : wxSplit( aName, ' ' ) )
80 {
81 if( !part.IsEmpty() )
82 {
83 result += wxString( part[0] ).Upper();
84
85 if( result.length() >= 2 )
86 break;
87 }
88 }
89
90 return result.IsEmpty() ? wxString( "?" ) : result;
91}
92
93
94// Draw the avatar circle of aDiameter centered in an aSlot square bitmap. The slot is taller
95// than the circle so the row has room and the circle never clips against the row edge.
96wxBitmap makeAvatarBitmap( wxWindow* aCtx, const wxString& aName, int aSlot, int aDiameter )
97{
98 wxBitmap bmp( aSlot, aSlot, 32 );
99 bmp.UseAlpha();
100
101 wxMemoryDC mdc( bmp );
102 wxGCDC dc( mdc );
103
104 dc.SetBackground( *wxTRANSPARENT_BRUSH );
105 dc.Clear();
106
107 int offset = ( aSlot - aDiameter ) / 2;
108
109 dc.SetPen( *wxTRANSPARENT_PEN );
110 dc.SetBrush( wxBrush( avatarColour( aName ) ) );
111 dc.DrawEllipse( offset, offset, aDiameter, aDiameter );
112
113 wxFont font = aCtx->GetFont();
114 font.SetWeight( wxFONTWEIGHT_BOLD );
115 font.SetPixelSize( wxSize( 0, aDiameter / 2 ) );
116 dc.SetFont( font );
117 dc.SetTextForeground( *wxWHITE );
118
119 wxString ini = initialsOf( aName );
120 wxSize te = dc.GetTextExtent( ini );
121 dc.DrawText( ini, ( aSlot - te.x ) / 2, ( aSlot - te.y ) / 2 );
122
123 mdc.SelectObject( wxNullBitmap );
124 return bmp;
125}
126
127
128// Fetch a contributor's Gravatar over HTTPS using the SHA-256 of their email. Returns the raw
129// image bytes, or empty on any failure or when the contributor has no Gravatar. Runs on a
130// worker thread, so it does no wx GUI work.
131std::string fetchGravatarBytes( const wxString& aEmail, int aPixels, const std::atomic<bool>& aCancel )
132{
133 wxString normalized = aEmail;
134 normalized.Trim( true ).Trim( false );
135 normalized.MakeLower();
136
137 std::string hash = picosha2::hash256_hex_string( std::string( normalized.utf8_string() ) );
138
139 KICAD_CURL_EASY curl;
140 curl.SetURL( "https://www.gravatar.com/avatar/" + hash + "?s=" + std::to_string( aPixels ) + "&d=404" );
141 curl.SetUserAgent( "KiCad" );
142 curl.SetFollowRedirects( true );
143
144 // A photo is decoration, so it must not sit on an unresponsive host for the default 30
145 // second connect timeout.
146 curl.SetConnectTimeout( 5 );
147 curl.SetTimeout( 10 );
148
149 // Closing the page must not wait out those timeouts, so the fetch gives up when asked.
151 [&aCancel]( size_t, size_t, size_t, size_t ) -> int
152 {
153 return aCancel ? 1 : 0;
154 },
155 0 );
156
157 if( curl.Perform() != 0 || curl.GetResponseStatusCode() != 200 )
158 return {};
159
160 return curl.GetBuffer();
161}
162
163
164// Scale a fetched photo to aDiameter and clip it to a circle so it matches the generated
165// initials avatars.
166wxBitmap makeCircularBitmap( const wxImage& aImage, int aDiameter )
167{
168 wxImage scaled = aImage.Scale( aDiameter, aDiameter, wxIMAGE_QUALITY_HIGH );
169
170 if( !scaled.HasAlpha() )
171 scaled.InitAlpha();
172
173 // Clear the alpha outside the inscribed circle so the photo shows as a disc, matching the
174 // generated initials avatars.
175 const double centre = ( aDiameter - 1 ) / 2.0;
176 const double radius = aDiameter / 2.0;
177
178 for( int y = 0; y < aDiameter; ++y )
179 {
180 for( int x = 0; x < aDiameter; ++x )
181 {
182 double dx = x - centre;
183 double dy = y - centre;
184
185 if( dx * dx + dy * dy > radius * radius )
186 scaled.SetAlpha( x, y, 0 );
187 }
188 }
189
190 return wxBitmap( scaled );
191}
192
193
194// The native icon renderer caps the icon size, so the avatar and summary share one cell here
195// to let the avatar show at full size.
196class HISTORY_AVATAR_RENDERER : public wxDataViewCustomRenderer
197{
198public:
199 HISTORY_AVATAR_RENDERER( int aRowHeight, int aGap ) :
200 wxDataViewCustomRenderer( wxT( "wxDataViewIconText" ), wxDATAVIEW_CELL_INERT ),
201 m_rowHeight( aRowHeight ),
202 m_gap( aGap )
203 {
204 }
205
206 bool SetValue( const wxVariant& aValue ) override
207 {
208 m_value << aValue;
209 return true;
210 }
211
212 bool GetValue( wxVariant& aValue ) const override
213 {
214 aValue << m_value;
215 return true;
216 }
217
218 wxSize GetSize() const override { return wxSize( GetOwner()->GetWidth(), m_rowHeight ); }
219
220 bool Render( wxRect aRect, wxDC* aDc, int aState ) override
221 {
222 RenderBackground( aDc, aRect );
223
224 wxBitmap bmp = m_value.GetBitmapBundle().GetBitmap( wxDefaultSize );
225 int textX = aRect.x;
226
227 if( bmp.IsOk() )
228 {
229 int y = aRect.y + ( aRect.height - bmp.GetHeight() ) / 2;
230 aDc->DrawBitmap( bmp, aRect.x, y, true );
231 textX += bmp.GetWidth() + m_gap;
232 }
233
234 wxRect textRect( textX, aRect.y, aRect.GetRight() - textX, aRect.height );
235 RenderText( m_value.GetText(), 0, textRect, aDc, aState );
236 return true;
237 }
238
239private:
240 wxDataViewIconText m_value;
241 int m_rowHeight;
242 int m_gap;
243};
244
245
246wxString joinNames( const std::vector<wxString>& aNames )
247{
248 wxString joined;
249
250 for( const wxString& name : aNames )
251 joined += ( joined.IsEmpty() ? wxString() : wxString( wxS( ", " ) ) ) + name;
252
253 return joined;
254}
255
256
257wxString fileStatusLabel( KIGIT::FILE_CHANGE_STATUS aStatus )
258{
259 switch( aStatus )
260 {
261 case KIGIT::FILE_CHANGE_STATUS::ADDED: return _( "added" );
262 case KIGIT::FILE_CHANGE_STATUS::REMOVED: return _( "removed" );
263 case KIGIT::FILE_CHANGE_STATUS::RENAMED: return _( "renamed" );
264 case KIGIT::FILE_CHANGE_STATUS::COPIED: return _( "copied" );
265 case KIGIT::FILE_CHANGE_STATUS::TYPECHANGE: return _( "type changed" );
266 default: return _( "modified" );
267 }
268}
269
270} // namespace
271
272
274 PANEL_GIT_HISTORY_BASE( aParent ),
275 m_dialog( aDialog ),
276 m_frame( aFrame ),
277 m_historyRefsColumn( nullptr ),
278 m_fileDiff( nullptr ),
280 [this]()
281 {
283 },
284 300 )
285{
286 m_historySearch->SetDescriptiveText( _( "Search history" ) );
287 m_historyPathRow->ShowItems( false );
288
289 // A custom renderer draws the avatar at full size and fixes the row height, since the
290 // native icon renderer caps the icon size.
291 int rowHeight = m_historyList->FromDIP( 28 );
292 int gap = m_historyList->FromDIP( 6 );
293
294 // The row height must be set explicitly. A renderer's reported size does not grow the row.
295 m_historyList->SetRowHeight( rowHeight );
296
297 m_historyList->AppendColumn(
298 new wxDataViewColumn( _( "Description" ), new HISTORY_AVATAR_RENDERER( rowHeight, gap ), 0,
299 m_historyList->FromDIP( 320 ), wxALIGN_LEFT, wxDATAVIEW_COL_RESIZABLE ),
300 wxT( "wxDataViewIconText" ) );
301 m_historyList->AppendTextColumn( _( "Author" ), wxDATAVIEW_CELL_INERT, m_historyList->FromDIP( 120 ) );
302 m_historyList->AppendTextColumn( _( "Date" ), wxDATAVIEW_CELL_INERT, m_historyList->FromDIP( 90 ) );
303 m_historyList->AppendTextColumn( _( "Commit" ), wxDATAVIEW_CELL_INERT, m_historyList->FromDIP( 90 ) );
304 m_historyRefsColumn =
305 m_historyList->AppendTextColumn( _( "Refs" ), wxDATAVIEW_CELL_INERT, m_historyList->FromDIP( 120 ) );
306
307 m_commitFiles->AppendColumn( _( "File" ), wxLIST_FORMAT_LEFT, FromDIP( 220 ) );
308 m_commitFiles->AppendColumn( _( "Status" ), wxLIST_FORMAT_LEFT, FromDIP( 90 ) );
309 m_commitFiles->AppendColumn( wxS( "+" ), wxLIST_FORMAT_RIGHT, FromDIP( 50 ) );
310 m_commitFiles->AppendColumn( wxS( "-" ), wxLIST_FORMAT_RIGHT, FromDIP( 50 ) );
311
312 // Use the diff colors for the line counts.
314 m_commitAdded->SetForegroundColour( theme.added.ToColour() );
315 m_commitRemoved->SetForegroundColour( theme.removed.ToColour() );
316
317 // The detail pane appears once a commit is selected, and the diff area once a file is picked.
318 m_historySashPos = FromDIP( 460 );
319 m_historySplitter->Unsplit( m_commitDetail );
320 m_detailSashPos = FromDIP( 140 );
321 m_fileDiff = new GIT_FILE_DIFF_AREA( m_detailSplitter, m_frame );
322 m_fileDiff->Hide();
323
324 m_historyScrollTimer.SetOwner( this );
325 Bind(
326 wxEVT_TIMER,
327 [this]( wxTimerEvent& )
328 {
329 onHistoryScrollTick();
330 },
331 m_historyScrollTimer.GetId() );
332
333 m_showAllButton->Bind( wxEVT_BUTTON,
334 [this]( wxCommandEvent& )
335 {
336 ShowHistoryForFile( wxEmptyString );
337 } );
338
339 // Walking the whole history for a search text that matches nothing takes a moment in a big
340 // repository, so wait for the typing to pause.
341 m_historySearch->Bind( wxEVT_TEXT,
342 [this]( wxCommandEvent& )
343 {
344 // A completed older search must not replace the list while the
345 // debounce timer is waiting for the new text.
346 m_historyQuery.Cancel();
347 m_detailQuery.Cancel();
348 m_historyScrollTimer.Stop();
349 hideFileDiff();
350 m_historySearchDebounce.Restart();
351 } );
352
353 m_historyBranch->Bind( wxEVT_CHOICE,
354 [this]( wxCommandEvent& )
355 {
356 int sel = m_historyBranch->GetSelection();
357 m_historyBranchName = sel > 0 ? m_historyBranch->GetString( sel ) : wxString();
358 reloadHistory();
359 } );
360
361 m_historyShowRefs->Bind( wxEVT_CHECKBOX,
362 [this]( wxCommandEvent& )
363 {
364 m_historyRefsColumn->SetHidden( !m_historyShowRefs->IsChecked() );
365 } );
366
367 m_historyList->Bind( wxEVT_DATAVIEW_SELECTION_CHANGED,
368 [this]( wxDataViewEvent& )
369 {
370 if( m_fillingHistory )
371 return;
372
373 int row = m_historyList->GetSelectedRow();
374 wxString hash = row == wxNOT_FOUND ? wxString() : m_historyList->GetTextValue( row, 3 );
375 showCommitDetail( hash );
376 } );
377
378 // Double-clicking a commit shows what it changed against its parent.
379 m_historyList->SetToolTip( _( "Double click a commit to see what it changed. Right click for more." ) );
380 m_historyList->Bind( wxEVT_DATAVIEW_ITEM_ACTIVATED,
381 [this]( wxDataViewEvent& aEvent )
382 {
383 int row = m_historyList->ItemToRow( aEvent.GetItem() );
384
385 if( row == wxNOT_FOUND )
386 return;
387
388 wxString hash = m_historyList->GetTextValue( row, 3 );
389
390 if( !hash.IsEmpty() )
391 ShowGitCommitDiff( m_frame, hash, this );
392 } );
393
394 // The Changes page checks whether the selected commit can still be amended.
395 m_historyList->Bind( wxEVT_DATAVIEW_ITEM_CONTEXT_MENU,
396 [this]( wxDataViewEvent& aEvent )
397 {
398 int row = m_historyList->ItemToRow( aEvent.GetItem() );
399
400 if( row == wxNOT_FOUND )
401 return;
402
403 wxString hash = m_historyList->GetTextValue( row, 3 );
404
405 if( hash.IsEmpty() )
406 return;
407
408 wxMenu menu;
409
410 // Amending rewrites the commit HEAD points at. With every branch
411 // listed the top row can be another branch's, so the row has to be
412 // HEAD's own commit and not merely the newest one shown.
413 if( row < static_cast<int>( m_historyAtHead.size() ) && m_historyAtHead[row] )
414 menu.Append( 1, _( "Amend Last Commit..." ) );
415
416 menu.Append( 2, _( "Restore This Version" ) );
417
418 menu.Bind(
419 wxEVT_MENU,
420 [this, hash]( wxCommandEvent& )
421 {
422 m_dialog->ShowAmend( hash );
423 },
424 1 );
425 menu.Bind(
426 wxEVT_MENU,
427 [this, hash]( wxCommandEvent& )
428 {
429 onRestoreVersion( hash );
430 },
431 2 );
432 m_historyList->PopupMenu( &menu );
433 } );
434
435 // Picking a changed file shows what the commit did to it under the list.
436 m_commitFiles->Bind( wxEVT_LIST_ITEM_SELECTED,
437 [this]( wxListEvent& aEvent )
438 {
439 if( !m_fillingHistory )
440 showFileDiff( m_commitFiles->GetItemText( aEvent.GetIndex(), 0 ) );
441 } );
442
443 m_commitFiles->Bind( wxEVT_LIST_ITEM_RIGHT_CLICK,
444 [this]( wxListEvent& aEvent )
445 {
446 // Read before the menu, which runs an event loop of its own in
447 // which a refresh can rebuild the list and retire the row.
448 wxString path = m_commitFiles->GetItemText( aEvent.GetIndex(), 0 );
449
450 if( path.IsEmpty() )
451 return;
452
453 wxMenu menu;
454 menu.Append( 1, _( "Show History for This File" ) );
455
456 if( GetPopupMenuSelectionFromUser( menu ) == 1 )
457 ShowHistoryForFile( path );
458 } );
459
460 // Double-clicking opens the full comparison window for it.
461 m_commitFiles->SetToolTip( _( "Double click a file to see what the commit changed in it." ) );
462 m_commitFiles->Bind( wxEVT_LIST_ITEM_ACTIVATED,
463 [this]( wxListEvent& aEvent )
464 {
465 ShowGitCommitDiff( m_frame, m_detailHash, this,
466 m_commitFiles->GetItemText( aEvent.GetIndex(), 0 ) );
467 } );
468}
469
470
472{
475 m_historyQuery.Cancel();
476 m_detailQuery.Cancel();
477 m_actionQuery.Cancel();
478 m_avatarCancel = true;
479
480 for( std::thread& worker : m_avatarWorkers )
481 worker.join();
482}
483
484
485void PANEL_GIT_HISTORY::ShowHistoryForFile( const wxString& aPath )
486{
487 m_historyPath = aPath;
488 m_historyPathLabel->SetLabel( aPath );
489 m_historyPathRow->ShowItems( !aPath.IsEmpty() );
490 Layout();
492}
493
494
503
504
511
512
513void PANEL_GIT_HISTORY::SetBranches( const std::vector<KIGIT_BRANCH_INFO>& aBranches )
514{
515 m_historyBranch->Clear();
516 m_historyBranch->Append( _( "All Branches" ) );
517
518 for( const KIGIT_BRANCH_INFO& branch : aBranches )
519 m_historyBranch->Append( branch.name );
520
521 if( m_historyBranchName.IsEmpty() || !m_historyBranch->SetStringSelection( m_historyBranchName ) )
522 {
523 m_historyBranch->SetSelection( 0 );
524
525 // The branch the list was narrowed to is gone, so the list is empty. Show everything.
526 if( !m_historyBranchName.IsEmpty() )
527 {
528 m_historyBranchName.clear();
529 Populate();
530 }
531 }
532
533 m_historyBranch->InvalidateBestSize();
534 m_historyBranch->GetParent()->Layout();
535}
536
537
538void PANEL_GIT_HISTORY::showCommitDetail( const wxString& aHash )
539{
540 m_detailQuery.Cancel();
541
542 if( m_detailHash != aHash )
544
545 m_detailHash = aHash;
546
547 if( aHash.IsEmpty() )
548 {
550 return;
551 }
552
553 if( !m_dialog->SessionStillCurrent() )
554 return;
555
556 auto detail = std::make_shared<KIGIT_COMMIT_DETAIL>();
557
558 m_detailQuery.Run(
559 m_frame->Prj().GetGitCommon(),
560 [detail, hash = aHash]( KIGIT_COMMON& aCommon )
561 {
562 *detail = aCommon.GetCommitDetail( hash );
563 },
564 [this, detail]()
565 {
566 if( m_dialog->SessionStillCurrent() )
567 fillCommitDetail( *detail );
568 } );
569}
570
571
573{
574 if( !aDetail.found )
575 {
577 return;
578 }
579
580 wxString title = aDetail.shortHash;
581
582 for( const wxString& ref : aDetail.refs )
583 title += wxS( " [" ) + ref + wxS( "]" );
584
585 m_commitTitle->SetLabel( title );
586 m_commitAuthor->SetLabel( wxString::Format( wxS( "%s <%s>, %s" ), aDetail.author, aDetail.email,
587 KIGIT::RelativeDate( aDetail.when ) ) );
588
589 // The same size the list uses, so a photo fetched for a row serves the pane too.
590 bool showAvatars = Pgm().GetCommonSettings()->m_Git.showContributorAvatars;
591 int diameter = m_historyList->FromDIP( 20 );
592 auto cached = m_avatarCache.find( aDetail.email );
593
594 m_detailEmail = aDetail.email;
595
596 if( showAvatars && cached != m_avatarCache.end() && cached->second.IsOk() )
597 m_commitAvatar->SetBitmap( cached->second );
598 else
599 m_commitAvatar->SetBitmap( makeAvatarBitmap( m_commitDetail, aDetail.author, diameter, diameter ) );
600
601 if( showAvatars )
602 requestAvatar( aDetail.email, diameter );
603
604 m_commitMessage->ChangeValue( aDetail.message );
605
606 wxString parents = aDetail.parents.empty() ? _( "No parent" )
607 : wxString::Format( _( "Parent: %s" ), joinNames( aDetail.parents ) );
608
609 m_detailParent = aDetail.parents.empty() ? wxString() : aDetail.parents.front();
610 hideFileDiff();
611
612 int fileCount = static_cast<int>( aDetail.files.size() );
613 wxString files = fileCount == 1 ? _( "1 changed file" ) : wxString::Format( _( "%d changed files" ), fileCount );
614
615 m_commitStats->SetLabel( parents );
616 m_commitFileCount->SetLabel( files );
617 m_commitAdded->SetLabel( wxString::Format( wxS( "+%d" ), aDetail.added ) );
618 m_commitRemoved->SetLabel( wxString::Format( wxS( "-%d" ), aDetail.removed ) );
619
620 m_commitFiles->DeleteAllItems();
621
622 for( const KIGIT_COMMIT_FILE& file : aDetail.files )
623 {
624 long idx = m_commitFiles->InsertItem( m_commitFiles->GetItemCount(), file.path );
625
626 wxString status = fileStatusLabel( file.status );
627
628 if( !file.oldPath.IsEmpty() )
629 status = wxString::Format( _( "%s from %s" ), status, file.oldPath );
630
631 m_commitFiles->SetItem( idx, 1, status );
632
633 if( file.binary )
634 {
635 m_commitFiles->SetItem( idx, 2, _( "binary" ) );
636 }
637 else
638 {
639 m_commitFiles->SetItem( idx, 2, wxString::Format( wxS( "%d" ), file.added ) );
640 m_commitFiles->SetItem( idx, 3, wxString::Format( wxS( "%d" ), file.removed ) );
641 }
642 }
643
644 // Select a path filter's file without letting selection events start a duplicate diff.
645 if( !m_historyPath.IsEmpty() )
646 {
647 long row = m_commitFiles->FindItem( -1, m_historyPath );
648
649 if( row != wxNOT_FOUND )
650 {
651 m_fillingHistory = true;
652 m_commitFiles->SetItemState( row, wxLIST_STATE_SELECTED, wxLIST_STATE_SELECTED );
653 m_fillingHistory = false;
654
656 }
657 }
658
659 if( !m_historySplitter->IsSplit() )
661
662 m_commitDetail->Layout();
663}
664
665
667{
669 hideFileDiff();
670 m_detailEmail.clear();
671 m_commitTitle->SetLabel( wxEmptyString );
672 m_commitAuthor->SetLabel( wxEmptyString );
673 m_commitMessage->ChangeValue( wxEmptyString );
674 m_commitStats->SetLabel( wxEmptyString );
675 m_commitFileCount->SetLabel( wxEmptyString );
676 m_commitAdded->SetLabel( wxEmptyString );
677 m_commitRemoved->SetLabel( wxEmptyString );
678 m_commitFiles->DeleteAllItems();
679}
680
681
687
688
689void PANEL_GIT_HISTORY::showFileDiff( const wxString& aPath )
690{
691 if( aPath.IsEmpty() || m_detailHash.IsEmpty() )
692 {
693 hideFileDiff();
694 return;
695 }
696
697 wxString beforeLabel =
698 m_detailParent.IsEmpty() ? _( "BASE (nothing)" ) : wxString::Format( _( "BASE (%s)" ), m_detailParent );
699 wxString afterLabel = wxString::Format( _( "WORKING (%s)" ), m_detailHash );
700
701 m_fileDiff->ShowFile(
702 m_frame->Prj().GetGitCommon(),
703 [hash = m_detailHash, path = aPath]( KIGIT_COMMON& aCommon, GIT_DIFF_WORKSPACE& aWorkspace,
704 GIT_FILE_SIDES& aSides )
705 {
706 return MaterializeCommitFile( aCommon, aWorkspace, hash, path, aSides );
707 },
708 beforeLabel, afterLabel );
709
710 if( !m_detailSplitter->IsSplit() )
712}
713
714
716{
717 m_detailQuery.Cancel();
718 hideFileDiff();
720
721 if( !m_dialog->SessionStillCurrent() )
722 return;
723
724 auto commits = std::make_shared<std::vector<KIGIT_COMMIT_INFO>>();
725 size_t limit = m_historyLimit;
726
727 m_historyQuery.Run(
728 m_frame->Prj().GetGitCommon(),
729 [commits, limit, filter = historyFilter()]( KIGIT_COMMON& aCommon )
730 {
731 *commits = aCommon.GetRecentCommits( limit, filter );
732 },
733 [this, commits, limit]()
734 {
735 if( m_dialog->SessionStillCurrent() )
736 fillHistory( *commits, limit );
737 } );
738}
739
740
741void PANEL_GIT_HISTORY::fillHistory( const std::vector<KIGIT_COMMIT_INFO>& aCommits, size_t aLimit )
742{
743 // Emptying the list drops its selection, which clears the shown hash, so keep it aside.
744 wxString shown = m_detailHash;
745
746 m_fillingHistory = true;
747 m_historyList->DeleteAllItems();
748 m_historyEmails.clear();
749 m_historyAtHead.clear();
750
751 appendHistoryRows( aCommits, 0 );
752
753 m_historyComplete = aCommits.size() < aLimit;
754
755 // Put the selection back on the commit the detail pane showed, if it is still listed, or
756 // start on the newest commit so the pane is never empty while there is something to show.
757 wxString selected;
758
759 for( int row = 0; row < m_historyList->GetItemCount() && !shown.IsEmpty(); ++row )
760 {
761 if( m_historyList->GetTextValue( row, 3 ) == shown )
762 {
763 m_historyList->SelectRow( row );
764 selected = shown;
765 break;
766 }
767 }
768
769 if( selected.IsEmpty() && m_historyList->GetItemCount() > 0 )
770 {
771 m_historyList->SelectRow( 0 );
772 selected = m_historyList->GetTextValue( 0, 3 );
773 }
774
775 m_fillingHistory = false;
776
777 showCommitDetail( selected );
778
779 // Scrolling near the end grows the window. Poll for it, because native wxDataViewCtrl
780 // ports deliver no portable scroll events.
783 else if( !m_historyScrollTimer.IsRunning() )
784 m_historyScrollTimer.Start( 250 );
785}
786
787
788void PANEL_GIT_HISTORY::appendHistoryRows( const std::vector<KIGIT_COMMIT_INFO>& aCommits, size_t aFrom )
789{
790 bool showAvatars = Pgm().GetCommonSettings()->m_Git.showContributorAvatars;
791
792 // The custom renderer centers this avatar in the row, so the bitmap is just the circle.
793 int diameter = m_historyList->FromDIP( 20 );
794
795 for( size_t ii = aFrom; ii < aCommits.size(); ++ii )
796 {
797 const KIGIT_COMMIT_INFO& c = aCommits[ii];
798
799 wxBitmap avatar;
800 auto cached = m_avatarCache.find( c.email );
801
802 if( showAvatars && cached != m_avatarCache.end() && cached->second.IsOk() )
803 avatar = cached->second;
804 else
805 avatar = makeAvatarBitmap( m_historyList, c.author, diameter, diameter );
806
807 wxVariant iconText;
808 iconText << wxDataViewIconText( c.summary, wxBitmapBundle::FromBitmap( avatar ) );
809
810 wxVector<wxVariant> row;
811 row.push_back( iconText );
812 row.push_back( wxVariant( c.author ) );
813 row.push_back( wxVariant( KIGIT::RelativeDate( c.when ) ) );
814 row.push_back( wxVariant( c.hash ) );
815 row.push_back( wxVariant( joinNames( c.refs ) ) );
816 m_historyList->AppendItem( row );
817
818 m_historyEmails.push_back( c.email );
819 m_historyAtHead.push_back( std::find( c.refs.begin(), c.refs.end(), wxS( "HEAD" ) ) != c.refs.end() );
820
821 if( showAvatars )
822 requestAvatar( c.email, diameter );
823 }
824}
825
826
828{
829 if( m_historyComplete || m_historyQuery.IsBusy() || !m_historyList || !m_historyList->IsShownOnScreen()
830 || !m_dialog->SessionStillCurrent() )
831 return;
832
833 int itemCount = m_historyList->GetItemCount();
834
835 if( itemCount == 0 )
836 return;
837
838 // Which row sits at the bottom of the view right now? HitTest works the same way whether
839 // the user scrolled with the wheel, the scrollbar or the keyboard.
840 wxDataViewItem item;
841 wxDataViewColumn* column = nullptr;
842 wxSize client = m_historyList->GetClientSize();
843
844 m_historyList->HitTest( wxPoint( 8, client.y - 8 ), item, column );
845
846 int row = item.IsOk() ? m_historyList->ItemToRow( item ) : wxNOT_FOUND;
847
848 // Past the last row (short list or overscroll) also counts as "at the end".
849 if( row != wxNOT_FOUND && row < itemCount - 5 )
850 return;
851
852 m_historyLimit += 50;
853
854 auto commits = std::make_shared<std::vector<KIGIT_COMMIT_INFO>>();
855 size_t limit = m_historyLimit;
856
857 m_historyQuery.Run(
858 m_frame->Prj().GetGitCommon(),
859 [commits, limit, filter = historyFilter()]( KIGIT_COMMON& aCommon )
860 {
861 *commits = aCommon.GetRecentCommits( limit, filter );
862 },
863 [this, commits, limit, itemCount]()
864 {
865 if( !m_dialog->SessionStillCurrent() )
866 return;
867
868 // Append only the new rows, so the scroll position stays where the user is reading.
869 appendHistoryRows( *commits, itemCount );
870 m_historyComplete = commits->size() < limit;
871
874 } );
875}
876
877
878void PANEL_GIT_HISTORY::requestAvatar( const wxString& aEmail, int aDiameter )
879{
880 if( aEmail.IsEmpty() || m_avatarCache.count( aEmail ) || m_avatarRequested.count( aEmail ) )
881 return;
882
883 // Each fetch is its own network round trip, so cap how many a history page can start. The
884 // rest keep their generated initials avatar.
885 const size_t maxAvatarFetches = 24;
886
887 if( m_avatarRequested.size() >= maxAvatarFetches )
888 return;
889
890 m_avatarRequested.insert( aEmail );
891
892 wxString email = aEmail;
893 int fetchPx = aDiameter * 2;
894
895 // Never the shared compute pool. A network read parked there stalls DRC, zone filling
896 // and library loading.
897 m_avatarWorkers.emplace_back(
898 [this, email, aDiameter, fetchPx]()
899 {
900 std::string bytes = fetchGravatarBytes( email, fetchPx, m_avatarCancel );
901
902 CallAfter(
903 [this, email, aDiameter, bytes]()
904 {
905 applyAvatar( email, aDiameter, bytes );
906 } );
907 } );
908}
909
910
911void PANEL_GIT_HISTORY::applyAvatar( const wxString& aEmail, int aDiameter, const std::string& aBytes )
912{
913 if( aBytes.empty() )
914 return;
915
916 wxMemoryInputStream stream( aBytes.data(), aBytes.size() );
917 wxImage image;
918
919 if( !image.LoadFile( stream, wxBITMAP_TYPE_ANY ) )
920 return;
921
922 wxBitmap circle = makeCircularBitmap( image, aDiameter );
923 m_avatarCache[aEmail] = circle;
924
925 if( aEmail == m_detailEmail )
926 m_commitAvatar->SetBitmap( circle );
927
928 for( unsigned row = 0; row < m_historyEmails.size(); ++row )
929 {
930 if( m_historyEmails[row] != aEmail )
931 continue;
932
933 wxVariant value;
934 m_historyList->GetValue( value, row, 0 );
935
936 wxDataViewIconText iconText;
937 iconText << value;
938
939 wxDataViewIconText updated( iconText.GetText(), wxBitmapBundle::FromBitmap( circle ) );
940 wxVariant updatedValue;
941 updatedValue << updated;
942 m_historyList->SetValue( updatedValue, row, 0 );
943 }
944}
945
946
947void PANEL_GIT_HISTORY::onRestoreVersion( const wxString& aCommitHash )
948{
949 if( !m_dialog->SessionStillCurrent() )
950 return;
951
952 std::shared_ptr<KIGIT_COMMON> common = m_frame->Prj().GetGitCommon();
953
954 struct RESTORE_INFO
955 {
956 wxString lfsMessage;
957 KIGIT::GIT_IDENTITY identity;
958 bool detached = false;
959 bool loaded = false;
960 };
961
962 auto info = std::make_shared<RESTORE_INFO>();
963
964 m_actionQuery.Run(
965 common,
966 [info]( KIGIT_COMMON& aCommon )
967 {
968 info->detached = aCommon.IsHeadDetached();
969 info->lfsMessage = KIGIT::PROJECT_GIT_UTILS::LfsRefusal( &aCommon );
970 info->identity = KIGIT::ReadGitIdentity( aCommon );
971 info->loaded = true;
972 },
973 [this, common, info, aCommitHash]()
974 {
975 if( !info->loaded || m_frame->Prj().GetGitCommon() != common || !m_dialog->SessionStillCurrent() )
976 {
977 return;
978 }
979
980 // Refuse an unknown project folder or LFS files before asking to restore.
981 if( !info->lfsMessage.IsEmpty() )
982 {
983 DisplayErrorMessage( this, info->lfsMessage );
984 return;
985 }
986
987 // A restore commit on a detached HEAD is lost on the next branch switch.
988 if( info->detached )
989 {
990 DisplayInfoMessage( this, _( "This project is showing an old snapshot rather than a branch. "
991 "Double click a branch on the Branches page first, then restore." ) );
992 return;
993 }
994
995 restoreVersion( aCommitHash, info->identity );
996 } );
997}
998
999
1000void PANEL_GIT_HISTORY::restoreVersion( const wxString& aCommitHash, const KIGIT::GIT_IDENTITY& aIdentity )
1001{
1002 std::shared_ptr<KIGIT_COMMON> common = m_frame->Prj().GetGitCommon();
1003
1004 // Check unsaved edits before creating the restore commit and again after the prompts.
1005 auto editorsReady = [this]()
1006 {
1007 if( DocumentEditorsDirty( m_frame->Kiway() ) )
1008 {
1009 DisplayInfoMessage( this, _( "There are unsaved changes in the open editors. Commit them "
1010 "first, so the restore stays a separate step that can be "
1011 "undone." ) );
1012 return false;
1013 }
1014
1015 if( !LibraryEditorsWithUnsavedChanges( m_frame->Kiway() ).empty() )
1016 {
1017 DisplayInfoMessage( this, _( "A library editor has unsaved changes. Save or discard them first, so "
1018 "the restore does not overwrite what it is editing." ) );
1019 return false;
1020 }
1021
1022 return true;
1023 };
1024
1025 if( !editorsReady() )
1026 return;
1027
1028 wxRichMessageDialog confirm( this,
1029 wxString::Format( _( "Restore this project's files as they were at version %s?\n\n"
1030 "They are replaced with that version, recorded as a new "
1031 "commit, so nothing in the history is lost. Files outside "
1032 "the project folder, including any shared with other "
1033 "projects, keep their current state." ),
1034 aCommitHash ),
1035 _( "Restore Version" ), wxYES_NO | wxICON_QUESTION | wxCENTER );
1036 confirm.SetYesNoLabels( _( "&Restore" ), _( "Cancel" ) );
1037
1038 if( confirm.ShowModal() != wxID_YES )
1039 return;
1040
1041 // The restore commit needs an identity to be recorded with.
1042 if( !KIGIT::EnsureGitIdentity( this, m_frame, common, aIdentity ) )
1043 return;
1044
1045 if( m_frame->Prj().GetGitCommon() != common || !m_dialog->SessionStillCurrent() )
1046 return;
1047
1048 if( !editorsReady() )
1049 return;
1050
1051 // The restore rewrites files the editors have open, so snapshot them for a scoped reload.
1052 EDITOR_SNAPSHOT docsBefore = CaptureOpenEditorDocs( m_frame->Kiway() );
1053
1055 wxString error;
1056
1057 {
1058 std::unique_lock<std::mutex> lock = KIGIT::TryLockAction( *common, this );
1059
1060 if( !lock.owns_lock() )
1061 return;
1062
1063 result = common->RestoreVersion( aCommitHash, error );
1064 }
1065
1066 switch( result )
1067 {
1069 ReloadOpenEditors( m_frame->Kiway(), docsBefore );
1070 common->NotifyRefreshListeners();
1071 break;
1072
1074 DisplayInfoMessage( this, _( "The project already matches that version." ) );
1075 break;
1076
1078 {
1079 // The restore checks the whole repository, while the checklist shows this project only.
1080 auto outside = std::make_shared<std::vector<wxString>>();
1081
1082 m_actionQuery.Run(
1083 common,
1084 [outside, path = m_frame->Prj().GetProjectPath()]( KIGIT_COMMON& aCommon )
1085 {
1086 *outside = ChangedTrackedFilesOutsideProject( aCommon, path );
1087 },
1088 [this, common, outside]()
1089 {
1090 if( m_frame->Prj().GetGitCommon() != common || !m_dialog->SessionStillCurrent() )
1091 return;
1092
1093 if( !outside->empty() )
1094 {
1095 wxString listed;
1096
1097 for( const wxString& path : *outside )
1098 listed += wxS( "\n " ) + path;
1099
1100 DisplayInfoMessage(
1101 this,
1102 wxString::Format(
1103 _( "There are uncommitted changes elsewhere in this repository:%s\n\n"
1104 "Commit or discard them from that project first, or set them aside with "
1105 "Stash Changes." ),
1106 listed ) );
1107 }
1108 else
1109 {
1110 DisplayInfoMessage( this, _( "There are uncommitted changes. Commit them first, so the "
1111 "restore stays a separate step that can be undone." ) );
1112 }
1113 } );
1114
1115 break;
1116 }
1117
1118 case RestoreResult::Error: DisplayErrorMessage( this, _( "Could not restore that version." ), error ); break;
1119 }
1120}
const char * name
static std::optional< VECTOR2D > normalized(const VECTOR2D &aVec)
The base frame for deriving all KiCad main window classes.
The advanced Git window: a non-modal dialog with a top bar naming the repository and branch and a sid...
A private scratch directory for extracting committed file versions to diff, removed when this object ...
Shows what changed in one file: the old and new drawing side by side for a schematic or board,...
int Perform()
Equivalent to curl_easy_perform.
bool SetUserAgent(const std::string &aAgent)
Set the request user agent.
const std::string & GetBuffer()
Return a reference to the received data buffer.
bool SetTransferCallback(const TRANSFER_CALLBACK &aCallback, size_t aInterval)
bool SetURL(const std::string &aURL)
Set the request URL.
bool SetFollowRedirects(bool aFollow)
Enable the following of HTTP(s) and other redirects, by default curl does not follow redirects.
bool SetConnectTimeout(long aTimeoutSecs)
Set the connection timeout in seconds.
bool SetTimeout(long aTimeoutSecs)
Set the total operation timeout in seconds.
wxColour ToColour() const
Definition color4d.cpp:221
static wxString LfsRefusal(KIGIT_COMMON *aCommon)
Return a refusal message for LFS files or an unknown project folder, otherwise empty.
The project's git session.
std::vector< KIGIT_COMMIT_INFO > GetRecentCommits(size_t aMax=50, const KIGIT_HISTORY_FILTER &aFilter={}) const
bool IsHeadDetached() const
True when HEAD points at a commit rather than a branch, as after checking out a tag.
wxSplitterWindow * m_historySplitter
wxDataViewListCtrl * m_historyList
PANEL_GIT_HISTORY_BASE(wxWindow *parent, wxWindowID id=wxID_ANY, const wxPoint &pos=wxDefaultPosition, const wxSize &size=wxSize(-1,-1), long style=wxTAB_TRAVERSAL, const wxString &name=wxEmptyString)
wxSplitterWindow * m_detailSplitter
WX_DEBOUNCED_ACTION m_historySearchDebounce
Waits for the user to pause typing in the search box before walking the history again.
PANEL_GIT_HISTORY(wxWindow *aParent, GIT_ADVANCED_DIALOG *aDialog, EDA_BASE_FRAME *aFrame)
void onHistoryScrollTick()
Grow the history window when the user has scrolled near its end.
bool m_fillingHistory
True while the list is being refilled, when its selection events are its own.
wxString m_detailEmail
Its author's email, so a web avatar that arrives later lands on the pane too.
wxDataViewColumn * m_historyRefsColumn
void Populate()
Load the repository's recent commits in the background, up to the current window.
wxTimer m_historyScrollTimer
Polls whether the user scrolled near the end of the history, which is the one portable way to notice ...
void requestAvatar(const wxString &aEmail, int aDiameter)
Start a background fetch of the contributor's web avatar for aEmail, if opted in and not already cach...
std::atomic< bool > m_avatarCancel
Set when the page goes, so a fetch still waiting on the network gives up rather than holding the clos...
size_t m_historyLimit
How many commits the page currently shows at most. Grows as the user scrolls.
bool m_historyComplete
True once a load returned fewer commits than asked for, meaning everything is shown.
KIGIT_QUERY m_historyQuery
Listing and selection have separate requests so choosing a commit does not cancel paging.
int m_historySashPos
Where the user left the sash, so the pane comes back at the same width.
std::set< wxString > m_avatarRequested
Emails whose avatar fetch was already started, so each is fetched at most once.
KIGIT_QUERY m_actionQuery
Read action preconditions without delaying history selection or scrolling.
void showFileDiff(const wxString &aPath)
Show what the selected commit did to aPath under the file list, or hide that area.
std::vector< wxString > m_historyEmails
Author email for each history row, so a fetched avatar can find its rows.
void SetBranches(const std::vector< KIGIT_BRANCH_INFO > &aBranches)
Offer every branch in the branch filter, keeping the current choice.
std::vector< std::thread > m_avatarWorkers
The avatar fetches this page started, joined before it goes.
void showCommitDetail(const wxString &aHash)
Fill the detail pane with aHash, or clear it when the hash is empty or gone.
void ShowHistoryForFile(const wxString &aPath)
Narrow the list to the commits that changed aPath, a file or folder relative to the repository root.
GIT_ADVANCED_DIALOG * m_dialog
void onRestoreVersion(const wxString &aCommitHash)
Restore the project as it was at the given commit, recorded as a new commit on top of the current hea...
void applyAvatar(const wxString &aEmail, int aDiameter, const std::string &aBytes)
On the UI thread, decode the fetched avatar bytes and place the photo on every history row for aEmail...
GIT_FILE_DIFF_AREA * m_fileDiff
std::vector< bool > m_historyAtHead
Whether each row's commit is the one HEAD points at.
EDA_BASE_FRAME * m_frame
void restoreVersion(const wxString &aCommitHash, const KIGIT::GIT_IDENTITY &aIdentity)
void fillCommitDetail(const KIGIT_COMMIT_DETAIL &aDetail)
wxString m_detailParent
The first parent of the shown commit, which the diff area's before side names.
void fillHistory(const std::vector< KIGIT_COMMIT_INFO > &aCommits, size_t aLimit)
Apply a loaded history window, keeping the currently selected commit when possible.
void appendHistoryRows(const std::vector< KIGIT_COMMIT_INFO > &aCommits, size_t aFrom)
Append rows for aCommits starting at aFrom, without clearing, so growing the history window keeps the...
std::map< wxString, wxBitmap > m_avatarCache
Fetched web avatars, keyed by author email.
void reloadHistory()
Restart the history at the first window after the search or branch filter changed.
KIGIT_HISTORY_FILTER historyFilter() const
The search text and branch the page is narrowed to.
wxString m_historyPath
The file or folder the history is narrowed to, or empty for all of it.
wxString m_historyBranchName
The branch the list is narrowed to, or empty for every branch.
int m_detailSashPos
Where the user left the sash between the file list and the diff area.
wxString m_detailHash
The commit the detail pane shows, so a refresh can put the selection back on it.
virtual COMMON_SETTINGS * GetCommonSettings() const
Definition pgm_base.cpp:550
void DisplayInfoMessage(wxWindow *aParent, const wxString &aMessage, const wxString &aExtraInfo)
Display an informational message box with aMessage.
Definition confirm.cpp:245
void DisplayErrorMessage(wxWindow *aParent, const wxString &aText, const wxString &aExtraInfo)
Display an error message with aMessage.
Definition confirm.cpp:217
This file is part of the common library.
#define _(s)
Base window classes and related definitions.
void ShowGitCommitDiff(EDA_BASE_FRAME *aFrame, const wxString &aCommitHash, wxWindow *aParentWindow, const wxString &aRelativePath)
Open the diff for what a commit changed, comparing it against its parent.
EDITOR_SNAPSHOT CaptureOpenEditorDocs(KIWAY &aKiway)
Record the on-disk identity of the open documents, before an operation that may rewrite them.
void ReloadOpenEditors(KIWAY &aKiway, const EDITOR_SNAPSHOT &aBefore)
Tell the open board and schematic editors to reload from disk.
std::vector< wxString > LibraryEditorsWithUnsavedChanges(KIWAY &aKiway)
Names of the library editors holding unsaved changes.
bool DocumentEditorsDirty(KIWAY &aKiway)
Unsaved schematic or board edits can be saved for a commit, even with no changes on disk.
RestoreResult
@ AlreadyCurrent
The chosen version's content matches the current head.
@ Restored
A new commit carrying the old version's content was created.
@ Error
The restore failed. See the error string.
@ Dirty
Uncommitted changes exist, and restoring would overwrite them.
GIT_IDENTITY ReadGitIdentity(KIGIT_COMMON &aCommon)
Read on a worker while holding the session's action lock.
bool EnsureGitIdentity(wxWindow *aParent, EDA_BASE_FRAME *aFrame, const std::shared_ptr< KIGIT_COMMON > &aCommon, const GIT_IDENTITY &aIdentity)
Open the same editor when the worker snapshot has no usable Git identity.
void UnsplitKeepingSash(wxSplitterWindow *aSplitter, wxWindow *aPane, int &aSashPos)
Close a splitter's second pane, keeping the sash where the user left it for next time.
std::unique_lock< std::mutex > TryLockAction(KIGIT_COMMON &aCommon, wxWindow *aParent)
Try the action lock and show a busy message if it is unavailable.
wxString RelativeDate(const wxDateTime &aWhen)
"just now", "3 hours ago", or the ISO date once it is more than a month old.
bool IsDarkTheme()
Determine if the desktop interface is currently using a dark theme or a light theme.
Definition wxgtk/ui.cpp:54
PGM_BASE & Pgm()
The global program "get" accessor.
see class PGM_BASE
What the open editors and the project settings looked like on disk before an operation that may rewri...
The two sides of one file, written out for a comparison.
Diff colors with defaults for light backgrounds.
Definition diff_scene.h:275
static DIFF_COLOR_THEME ForText(bool aDarkTheme)
Resolve change colors for UI text. Pass the desktop theme captured on the UI thread.
Repository values and whether Git can use them to record a commit.
std::vector< wxString > parents
Short hashes.
std::vector< KIGIT_COMMIT_FILE > files
std::vector< wxString > refs
As in KIGIT_COMMIT_INFO.
wxString message
Full message, body included.
wxString oldPath
For a rename or copy, where the file came from.
KIGIT::FILE_CHANGE_STATUS status
wxString author
Author name.
wxString email
Author email, used to look up a web avatar.
wxString summary
First line of the commit message.
std::vector< wxString > refs
HEAD, branches and tags at this commit, in that order.
wxString hash
Short commit hash.
wxDateTime when
Commit time.
std::string path
int radius
SHAPE_CIRCLE circle(c.m_circle_center, c.m_circle_radius)
wxString result
Test unit parsing edge cases and error handling.