1+ package appland .actions ;
2+
3+ import appland .webviews .navie .NavieEditorProvider ;
4+ import appland .webviews .navie .NaviePromptSuggestion ;
5+ import com .intellij .icons .AllIcons ;
6+ import com .intellij .ide .util .PropertiesComponent ;
7+ import com .intellij .openapi .actionSystem .ActionUpdateThread ;
8+ import com .intellij .openapi .actionSystem .AnAction ;
9+ import com .intellij .openapi .actionSystem .AnActionEvent ;
10+ import com .intellij .openapi .progress .ProgressIndicator ;
11+ import com .intellij .openapi .progress .Task ;
12+ import com .intellij .openapi .project .DumbAware ;
13+ import com .intellij .openapi .project .Project ;
14+ import com .intellij .openapi .ui .popup .JBPopupFactory ;
15+ import com .intellij .openapi .vcs .VcsException ;
16+ import com .intellij .openapi .vfs .VirtualFile ;
17+ import git4idea .commands .Git ;
18+ import git4idea .commands .GitCommand ;
19+ import git4idea .commands .GitLineHandler ;
20+ import git4idea .repo .GitRepository ;
21+ import git4idea .repo .GitRepositoryManager ;
22+ import org .jetbrains .annotations .NotNull ;
23+ import org .jetbrains .annotations .Nullable ;
24+
25+ import javax .swing .*;
26+ import java .awt .*;
27+ import java .util .ArrayList ;
28+ import java .util .Arrays ;
29+ import java .util .Comparator ;
30+ import java .util .List ;
31+ import java .util .concurrent .atomic .AtomicBoolean ;
32+ import java .util .regex .Pattern ;
33+
34+ public class QuickReviewAction extends AnAction implements DumbAware {
35+ public static final String ACTION_ID = "appmap.quickReview" ;
36+ private static final String LAST_PICKED_REF_KEY = "appmap.quickReview.lastPickedRef" ;
37+ private static final List <String > COMMON_MAIN_BRANCHES = Arrays .asList (
38+ "main" , "master" , "develop" , "release" , "staging" , "testing" , "qa" , "prod"
39+ );
40+
41+ @ Override
42+ public @ NotNull ActionUpdateThread getActionUpdateThread () {
43+ return ActionUpdateThread .BGT ;
44+ }
45+
46+ @ Override
47+ public void actionPerformed (@ NotNull AnActionEvent e ) {
48+ var project = e .getProject ();
49+ if (project == null ) {
50+ return ;
51+ }
52+
53+ var repositoryManager = GitRepositoryManager .getInstance (project );
54+ var repositories = repositoryManager .getRepositories ();
55+ if (repositories .isEmpty ()) {
56+ return ;
57+ }
58+
59+ // For now, we'll just use the first repository
60+ var repository = repositories .get (0 );
61+
62+ new Task .Backgroundable (project , "Fetching Git Refs" , true ) {
63+ private List <GitRef > refs ;
64+ private boolean dirty = false ;
65+
66+ @ Override
67+ public void run (@ NotNull ProgressIndicator indicator ) {
68+ try {
69+ refs = getItems (project , repository );
70+ dirty = isDirty ();
71+ } catch (VcsException ex ) {
72+ throw new RuntimeException ("Failed to fetch Git refs" , ex );
73+ }
74+ }
75+
76+ private boolean isDirty () {
77+ var handler = new GitLineHandler (project , repository .getRoot (), GitCommand .STATUS );
78+ handler .setSilent (true );
79+ handler .addParameters ("--porcelain" );
80+ try {
81+ var result = Git .getInstance ().runCommand (handler );
82+ result .throwOnError ();
83+ return !result .getOutput ().isEmpty ();
84+ } catch (VcsException e ) {
85+ return false ; // If we can't check, assume not dirty
86+ }
87+ }
88+
89+ @ Override
90+ public void onSuccess () {
91+ if (refs == null || refs .isEmpty ()) {
92+ return ;
93+ }
94+
95+ var head = repository .getInfo ().getCurrentRevision ();
96+ var lastPickedRef = PropertiesComponent .getInstance ().getValue (LAST_PICKED_REF_KEY );
97+ AtomicBoolean seenLastPicked = new AtomicBoolean (false );
98+
99+ refs = refs .stream ()
100+ /* only show HEAD if the repository is dirty */
101+ .filter (gitRef -> dirty || !gitRef .commit .equals (head ))
102+ .peek (gitRef -> {
103+ if (gitRef .commit .equals (head )) gitRef .description = "review uncommitted changes" ;
104+ if (gitRef .label .equals (lastPickedRef )) {
105+ gitRef .description = "last picked ⋅ " + gitRef .description ;
106+ seenLastPicked .set (true );
107+ }
108+ })
109+ .sorted (Comparator .comparing ((GitRef gitRef ) ->
110+ // if we have seen the last picked ref, sort it to the start
111+ // otherwise show common main branches first
112+ seenLastPicked .get () ? !gitRef .label .equals (lastPickedRef )
113+ : !COMMON_MAIN_BRANCHES .contains (gitRef .label )))
114+ .toList ();
115+
116+ var popup = JBPopupFactory .getInstance ()
117+ .createPopupChooserBuilder (refs )
118+ .setRenderer (new GitRefCellRenderer ())
119+ .setTitle ("Select base for review" )
120+ .setMovable (false )
121+ .setResizable (false )
122+ .setRequestFocus (true )
123+ .setItemChosenCallback ((selectedValue ) -> {
124+ if (selectedValue != null ) {
125+ PropertiesComponent .getInstance ().setValue (LAST_PICKED_REF_KEY , selectedValue .label );
126+ NavieEditorProvider .openEditorWithPrompt (project , new NaviePromptSuggestion (
127+ "Quick Review" ,
128+ String .format ("@review /base=%s" , selectedValue .label )));
129+ }
130+ }).createPopup ();
131+ popup .showCenteredInCurrentWindow (project );
132+ }
133+ }.queue ();
134+ }
135+
136+ private ArrayList <GitRef > getRefs (Project project , GitRepository repository ) throws VcsException {
137+ var refs = new ArrayList <GitRef >();
138+ var handler = new GitCommandLineHandler (project , repository .getRoot (), "for-each-ref" );
139+ handler .addParameters (
140+ "--format=%(objectname);%(if)%(HEAD)%(then)HEAD%(else)%(refname:short)%(end);%(refname:rstrip=-2);%(objectname:short) ⋅ %(creatordate:human)" ,
141+ "--merged" , "HEAD" , "--sort=-creatordate" );
142+ var result = Git .getInstance ().runCommand (handler );
143+ result .throwOnError ();
144+
145+ refs .addAll (result .getOutput ().stream ()
146+ .map (GitRef ::ofLine )
147+ .toList ());
148+ return refs ;
149+ }
150+
151+ private List <GitRef > getItems (Project project , GitRepository repository ) throws VcsException {
152+ var refs = getRefs (project , repository );
153+ var head = repository .getInfo ().getCurrentRevision ();
154+ var nextRefIdx = refs .stream ()
155+ .filter (gitRef -> !gitRef .commit .equals (head ))
156+ .findFirst ()
157+ .map (refs ::indexOf )
158+ .orElse (-1 );
159+ if (nextRefIdx > -1 ) {
160+ // add commits up to the next ref
161+ var nextCommit = refs .get (nextRefIdx ).commit ;
162+ var handler = new GitLineHandler (project , repository .getRoot (), GitCommand .LOG );
163+ handler .addParameters ("--format=%H;%h;commit;%ch ⋅ %s" , nextCommit + "...HEAD" );
164+ var result = Git .getInstance ().runCommand (handler );
165+ result .throwOnError ();
166+ var commits = result .getOutput ().stream ()
167+ .map (GitRef ::ofLine )
168+ .filter (gitRef -> !gitRef .commit .equals (head )) // Exclude HEAD commit
169+ .toList ();
170+ if (!commits .isEmpty ()) {
171+ // Insert commits before the next ref
172+ refs .addAll (nextRefIdx , commits );
173+ }
174+ }
175+ return refs ;
176+ }
177+
178+ private static class GitRef {
179+ private static final Pattern PATTERN = Pattern .compile ("^(.*?);(.*?);(.*?);(.*)$" );
180+ String commit ;
181+ String label ;
182+ String type ;
183+ String description ;
184+
185+ GitRef (String commit , String label , String type , String description ) {
186+ this .commit = commit ;
187+ this .label = label ;
188+ this .type = type ;
189+ this .description = description ;
190+ }
191+
192+ public static GitRef ofLine (String line ) {
193+ var matcher = PATTERN .matcher (line );
194+ if (matcher .matches ()) {
195+ return new GitRef (matcher .group (1 ), matcher .group (2 ), matcher .group (3 ), matcher .group (4 ));
196+ }
197+ throw new IllegalArgumentException ("Invalid ref line: " + line );
198+ }
199+
200+ @ Override
201+ public String toString () {
202+ return label + " (" + type + ") - " + commit + " - " + description ;
203+ }
204+
205+ public Icon getIcon () {
206+ switch (label ) {
207+ case "main" :
208+ case "master" :
209+ return AllIcons .Actions .Checked ;
210+ case "develop" :
211+ return AllIcons .Actions .Edit ;
212+ case "release" :
213+ return AllIcons .Actions .Download ;
214+ case "staging" :
215+ return AllIcons .Actions .Upload ;
216+ case "testing" :
217+ return AllIcons .Actions .Refresh ;
218+ case "qa" :
219+ return AllIcons .Actions .Execute ;
220+ case "prod" :
221+ return AllIcons .Actions .Restart ;
222+ case "HEAD" :
223+ return AllIcons .Actions .Pause ;
224+ }
225+ switch (type ) {
226+ case "refs/heads" :
227+ return AllIcons .Vcs .Branch ;
228+ case "refs/remotes" :
229+ return AllIcons .Nodes .PpWeb ;
230+ case "refs/tags" :
231+ return AllIcons .Nodes .Bookmark ;
232+ default :
233+ return AllIcons .Vcs .CommitNode ;
234+ }
235+ }
236+ }
237+
238+ private static class GitRefCellRenderer extends DefaultListCellRenderer {
239+ @ Override
240+ public Component getListCellRendererComponent (JList <?> list , Object value , int index , boolean isSelected , boolean cellHasFocus ) {
241+ var component = (JLabel ) super .getListCellRendererComponent (list , value , index , isSelected , cellHasFocus );
242+ if (value instanceof GitRef ) {
243+ var ref = (GitRef ) value ;
244+ component .setText ("<html><b>" + ref .label + "</b> " +
245+ "<small>" + ref .description + "</small></html>" );
246+ component .setIcon (ref .getIcon ());
247+ }
248+ return component ;
249+ }
250+ }
251+
252+ // HACK: platform version 241 does not define GitCommand.FOR_EACH_REF
253+ // and the final GitCommand constructors are private, so we need to replace
254+ // the command in GitLineHandler with a custom one.
255+ class GitCommandLineHandler extends GitLineHandler {
256+ public GitCommandLineHandler (@ Nullable Project project , @ NotNull VirtualFile directory , @ NotNull String command ) {
257+ super (project , directory , GitCommand .LOG );
258+ var paramsList = this .myCommandLine .getParametersList ();
259+ var index = paramsList .getParameters ().indexOf (GitCommand .LOG .name ());
260+ if (index != -1 ) {
261+ paramsList .set (index , command );
262+ } else {
263+ paramsList .addAt (0 , command );
264+ }
265+ }
266+ }
267+ }
0 commit comments