Merge branch 'feature/JAL-3169cancelOverwrite' into
[jalview.git] / src / jalview / io / JalviewFileChooser.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  *
5  * This file is part of Jalview.
6  *
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *
12  * Jalview is distributed in the hope that it will be useful, but
13  * WITHOUT ANY WARRANTY; without even the implied warranty
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR
15  * PURPOSE.  See the GNU General Public License for more details.
16  *
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 //////////////////////////////////////////////////////////////////
22 package jalview.io;
23
24 import jalview.bin.Cache;
25 import jalview.bin.Jalview;
26 import jalview.gui.JvOptionPane;
27 import jalview.util.MessageManager;
28 import jalview.util.Platform;
29 import jalview.util.dialogrunner.DialogRunner;
30 import jalview.util.dialogrunner.DialogRunnerI;
31 import jalview.util.dialogrunner.RunResponse;
32
33 import java.awt.Component;
34 import java.awt.Dimension;
35 import java.awt.EventQueue;
36 import java.awt.HeadlessException;
37 import java.awt.event.MouseAdapter;
38 import java.awt.event.MouseEvent;
39 import java.beans.PropertyChangeEvent;
40 import java.beans.PropertyChangeListener;
41 import java.io.File;
42 import java.util.ArrayList;
43 import java.util.List;
44 import java.util.StringTokenizer;
45 import java.util.Vector;
46
47 import javax.swing.DefaultListCellRenderer;
48 import javax.swing.JFileChooser;
49 import javax.swing.JList;
50 import javax.swing.JPanel;
51 import javax.swing.JScrollPane;
52 import javax.swing.SpringLayout;
53 import javax.swing.SwingUtilities;
54 import javax.swing.border.TitledBorder;
55 import javax.swing.plaf.basic.BasicFileChooserUI;
56
57 /**
58  * Enhanced file chooser dialog box.
59  *
60  * NOTE: bug on Windows systems when filechooser opened on directory to view
61  * files with colons in title.
62  *
63  * @author AMW
64  *
65  */
66 public class JalviewFileChooser extends JFileChooser implements DialogRunnerI,
67     PropertyChangeListener
68 {
69   private static final long serialVersionUID = 1L;
70
71   private DialogRunnerI runner = new DialogRunner();
72   
73   File selectedFile = null;
74
75   /**
76    * On user selecting a file to save to, this response is run to check if the
77    * file already exists, and if so show a dialog to prompt for confirmation of
78    * overwrite.
79    */
80   RunResponse overwriteCheck = new RunResponse(JalviewFileChooser.APPROVE_OPTION)
81   {
82     @Override
83     public void run()
84         {
85             selectedFile = getSelectedFile();
86         
87             if (selectedFile == null)
88             {
89               // Workaround for Java 9,10 on OSX - no selected file, but there is a
90               // filename typed in
91               // TODO is this needed in Java 8 or 11?
92               try
93               {
94                 String filename = ((BasicFileChooserUI) getUI()).getFileName();
95                 if (filename != null && filename.length() > 0)
96                 {
97                   selectedFile = new File(getCurrentDirectory(), filename);
98                 }
99               } catch (Throwable x)
100               {
101                 System.err.println(
102                         "Unexpected exception when trying to get filename.");
103                 x.printStackTrace();
104               }
105             }
106             if (selectedFile == null)
107             {
108               setReturnValue(JalviewFileChooser.CANCEL_OPTION);
109               return;
110             }
111             // JBP Note - this code was executed regardless of 'SAVE' being pressed
112             // need to see if there were side effects
113             if (getFileFilter() instanceof JalviewFileFilter)
114             {
115               JalviewFileFilter jvf = (JalviewFileFilter) getFileFilter();
116         
117               if (!jvf.accept(getSelectedFile()))
118               {
119                 String withExtension = getSelectedFile() + "."
120                         + jvf.getAcceptableExtension();
121                 setSelectedFile(new File(withExtension));
122               }
123             }
124             // All good, so we continue to save
125             setReturnValue(JalviewFileChooser.APPROVE_OPTION);
126         
127             // TODO: ENSURE THAT FILES SAVED WITH A ':' IN THE NAME ARE REFUSED AND THE
128             // USER PROMPTED FOR A NEW FILENAME
129             if (!Jalview.isJS())
130             {
131               if (getSelectedFile().exists())
132               {
133                 // JAL-3048 - may not need to raise this for browser saves
134                 // yes/no cancel
135                 int confirm = JvOptionPane.showConfirmDialog(JalviewFileChooser.this,
136                         MessageManager.getString("label.overwrite_existing_file"),
137                         MessageManager.getString("label.file_already_exists"),
138                  JvOptionPane.YES_NO_OPTION);
139         
140              if (confirm != JvOptionPane.YES_OPTION)
141              {
142                    setReturnValue(JalviewFileChooser.CANCEL_OPTION);
143                  }
144                }
145              }
146         };
147   };
148
149   /**
150    * Factory method to return a file chooser that offers readable alignment file
151    * formats
152    * 
153    * @param directory
154    * @param selected
155    * @return
156    */
157   public static JalviewFileChooser forRead(String directory,
158           String selected)
159   {
160     List<String> extensions = new ArrayList<>();
161     List<String> descs = new ArrayList<>();
162     for (FileFormatI format : FileFormats.getInstance().getFormats())
163     {
164       if (format.isReadable())
165       {
166         extensions.add(format.getExtensions());
167         descs.add(format.getName());
168       }
169     }
170     return new JalviewFileChooser(directory,
171             extensions.toArray(new String[extensions.size()]),
172             descs.toArray(new String[descs.size()]), selected, true);
173   }
174
175   /**
176    * Factory method to return a file chooser that offers writable alignment file
177    * formats
178    * 
179    * @param directory
180    * @param selected
181    * @return
182    */
183   public static JalviewFileChooser forWrite(String directory,
184           String selected)
185   {
186     // TODO in Java 8, forRead and forWrite can be a single method
187     // with a lambda expression parameter for isReadable/isWritable
188     List<String> extensions = new ArrayList<>();
189     List<String> descs = new ArrayList<>();
190     for (FileFormatI format : FileFormats.getInstance().getFormats())
191     {
192       if (format.isWritable())
193       {
194         extensions.add(format.getExtensions());
195         descs.add(format.getName());
196       }
197     }
198     return new JalviewFileChooser(directory,
199             extensions.toArray(new String[extensions.size()]),
200             descs.toArray(new String[descs.size()]), selected, false);
201   }
202
203   public JalviewFileChooser(String dir)
204   {
205     super(safePath(dir));
206     setAccessory(new RecentlyOpened());
207   }
208
209   public JalviewFileChooser(String dir, String[] suffix, String[] desc,
210           String selected)
211   {
212     this(dir, suffix, desc, selected, true);
213   }
214
215   /**
216    * Constructor for a single choice of file extension and description
217    * 
218    * @param extension
219    * @param desc
220    */
221   public JalviewFileChooser(String extension, String desc)
222   {
223     this(Cache.getProperty("LAST_DIRECTORY"), new String[] { extension },
224             new String[]
225             { desc }, desc, true);
226   }
227
228   JalviewFileChooser(String dir, String[] extensions, String[] descs,
229           String selected, boolean acceptAny)
230   {
231     super(safePath(dir));
232     if (extensions.length == descs.length)
233     {
234       List<String[]> formats = new ArrayList<>();
235       for (int i = 0; i < extensions.length; i++)
236       {
237         formats.add(new String[] { extensions[i], descs[i] });
238       }
239       init(formats, selected, acceptAny);
240     }
241     else
242     {
243       System.err.println("JalviewFileChooser arguments mismatch: "
244               + extensions + ", " + descs);
245     }
246   }
247
248   private static File safePath(String dir)
249   {
250     if (dir == null)
251     {
252       return null;
253     }
254
255     File f = new File(dir);
256     if (f.getName().indexOf(':') > -1)
257     {
258       return null;
259     }
260     return f;
261   }
262
263   /**
264    * Overridden for JalviewJS compatibility: only one thread in Javascript, 
265    * so we can't wait for user choice in another thread and then perform the 
266    * desired action
267    */
268   @Override
269   public int showOpenDialog(Component parent)
270   {
271    // runner.resetResponses();
272     int value = super.showOpenDialog(this);
273     if (!Jalview.isJS())
274     {
275       runner.handleResponse(value);
276     }
277     return value;
278   }
279
280   /**
281    * 
282    * @param formats
283    *          a list of {extensions, description} for each file format
284    * @param selected
285    * @param acceptAny
286    *          if true, 'any format' option is included
287    */
288   void init(List<String[]> formats, String selected, boolean acceptAny)
289   {
290
291     JalviewFileFilter chosen = null;
292
293     // SelectAllFilter needs to be set first before adding further
294     // file filters to fix bug on Mac OSX
295     setAcceptAllFileFilterUsed(acceptAny);
296
297     for (String[] format : formats)
298     {
299       JalviewFileFilter jvf = new JalviewFileFilter(format[0], format[1]);
300       addChoosableFileFilter(jvf);
301       if ((selected != null) && selected.equalsIgnoreCase(format[1]))
302       {
303         chosen = jvf;
304       }
305     }
306
307     if (chosen != null)
308     {
309       setFileFilter(chosen);
310     }
311
312     setAccessory(new RecentlyOpened());
313   }
314
315   @Override
316   public void setFileFilter(javax.swing.filechooser.FileFilter filter)
317   {
318     super.setFileFilter(filter);
319
320     try
321     {
322       if (getUI() instanceof BasicFileChooserUI)
323       {
324         final BasicFileChooserUI fcui = (BasicFileChooserUI) getUI();
325         final String name = fcui.getFileName().trim();
326
327         if ((name == null) || (name.length() == 0))
328         {
329           return;
330         }
331
332         EventQueue.invokeLater(new Thread()
333         {
334           @Override
335           public void run()
336           {
337             String currentName = fcui.getFileName();
338             if ((currentName == null) || (currentName.length() == 0))
339             {
340               fcui.setFileName(name);
341             }
342           }
343         });
344       }
345     } catch (Exception ex)
346     {
347       ex.printStackTrace();
348       // Some platforms do not have BasicFileChooserUI
349     }
350   }
351
352   /**
353    * Returns the selected file format, or null if none selected
354    * 
355    * @return
356    */
357   public FileFormatI getSelectedFormat()
358   {
359     if (getFileFilter() == null)
360     {
361       return null;
362     }
363
364     /*
365      * logic here depends on option description being formatted as 
366      * formatName (extension, extension...)
367      * or the 'no option selected' value
368      * All Files
369      * @see JalviewFileFilter.getDescription
370      */
371     String format = getFileFilter().getDescription();
372     int parenPos = format.indexOf("(");
373     if (parenPos > 0)
374     {
375       format = format.substring(0, parenPos).trim();
376       try
377       {
378         return FileFormats.getInstance().forName(format);
379       } catch (IllegalArgumentException e)
380       {
381         System.err.println("Unexpected format: " + format);
382       }
383     }
384     return null;
385   }
386
387   @Override
388   public File getSelectedFile()
389   {
390     File f = super.getSelectedFile();
391     return f == null ? selectedFile : f;
392   }
393
394   /**
395    * Overridden for JalviewJS compatibility: only one thread in Javascript, 
396    * so we can't wait for user choice in another thread and then perform the 
397    * desired action
398    */
399   @Override
400   public int showSaveDialog(Component parent) throws HeadlessException
401   {
402     this.setAccessory(null);
403     this.setSelectedFile(null);
404     return super.showSaveDialog(parent);
405   }
406
407   /**
408    * If doing a Save, and an existing file is chosen or entered, prompt for
409    * confirmation of overwrite. Proceed if Yes, else leave the file chooser
410    * open.
411    * 
412    * @see https://stackoverflow.com/questions/8581215/jfilechooser-and-checking-for-overwrite
413    */
414   @Override
415   public void approveSelection()
416   {
417     if (getDialogType() != SAVE_DIALOG)
418     {
419       super.approveSelection();
420       return;
421     }
422
423     selectedFile = getSelectedFile();
424
425     if (selectedFile == null)
426     {
427       // Workaround for Java 9,10 on OSX - no selected file, but there is a
428       // filename typed in
429       try
430       {
431         String filename = ((BasicFileChooserUI) getUI()).getFileName();
432         if (filename != null && filename.length() > 0)
433         {
434           selectedFile = new File(getCurrentDirectory(), filename);
435         }
436       } catch (Throwable x)
437       {
438         System.err.println(
439                 "Unexpected exception when trying to get filename.");
440         x.printStackTrace();
441       }
442       // TODO: ENSURE THAT FILES SAVED WITH A ':' IN THE NAME ARE REFUSED AND
443       // THE
444       // USER PROMPTED FOR A NEW FILENAME
445     }
446
447     if (selectedFile == null)
448     {
449       return;
450     }
451
452     if (getFileFilter() instanceof JalviewFileFilter)
453     {
454       JalviewFileFilter jvf = (JalviewFileFilter) getFileFilter();
455
456       if (!jvf.accept(selectedFile))
457       {
458         String withExtension = getSelectedFile().getName() + "."
459                 + jvf.getAcceptableExtension();
460         selectedFile = (new File(getCurrentDirectory(), withExtension));
461         setSelectedFile(selectedFile);
462       }
463     }
464
465     if (selectedFile.exists())
466     {
467       int confirm = JvOptionPane.showConfirmDialog(this,
468               MessageManager.getString("label.overwrite_existing_file"),
469               MessageManager.getString("label.file_already_exists"),
470               JvOptionPane.YES_NO_OPTION);
471       if (confirm != JvOptionPane.YES_OPTION)
472       {
473         return;
474       }
475     }
476
477     super.approveSelection();
478   }
479
480   void recentListSelectionChanged(Object selection)
481   {
482     setSelectedFile(null);
483     if (selection != null)
484     {
485       File file = new File((String) selection);
486       if (getFileFilter() instanceof JalviewFileFilter)
487       {
488         JalviewFileFilter jvf = (JalviewFileFilter) this.getFileFilter();
489
490         if (!jvf.accept(file))
491         {
492           setFileFilter(getChoosableFileFilters()[0]);
493         }
494       }
495
496       setSelectedFile(file);
497     }
498   }
499
500   /**
501    * A panel to set as the 'accessory' component to the file chooser dialog,
502    * holding a list of recently opened files (if any). These are held as a
503    * tab-separated list of file paths under key <code>RECENT_FILE</code> in
504    * <code>.jalview_properties</code>. A click in the list calls a method in
505    * JalviewFileChooser to set the chosen file as the selection.
506    */
507   class RecentlyOpened extends JPanel
508   {
509     private static final long serialVersionUID = 1L;
510
511     JList<String> list;
512
513     RecentlyOpened()
514     {
515       String historyItems = Cache.getProperty("RECENT_FILE");
516       StringTokenizer st;
517       Vector<String> recent = new Vector<>();
518
519       if (historyItems != null)
520       {
521         st = new StringTokenizer(historyItems, "\t");
522         while (st.hasMoreTokens())
523         {
524           recent.addElement(st.nextToken());
525         }
526       }
527
528       list = new JList<>(recent);
529
530       DefaultListCellRenderer dlcr = new DefaultListCellRenderer();
531       dlcr.setHorizontalAlignment(DefaultListCellRenderer.RIGHT);
532       list.setCellRenderer(dlcr);
533
534       list.addMouseListener(new MouseAdapter()
535       {
536         @Override
537         public void mousePressed(MouseEvent evt)
538         {
539           recentListSelectionChanged(list.getSelectedValue());
540         }
541       });
542
543       this.setBorder(new TitledBorder(
544               MessageManager.getString("label.recently_opened")));
545
546       final JScrollPane scroller = new JScrollPane(list);
547
548       SpringLayout layout = new SpringLayout();
549       layout.putConstraint(SpringLayout.WEST, scroller, 5,
550               SpringLayout.WEST, this);
551       layout.putConstraint(SpringLayout.NORTH, scroller, 5,
552               SpringLayout.NORTH, this);
553
554       if (Platform.isAMac())
555       {
556         scroller.setPreferredSize(new Dimension(500, 100));
557       }
558       else
559       {
560         scroller.setPreferredSize(new Dimension(130, 200));
561       }
562
563       this.add(scroller);
564
565       SwingUtilities.invokeLater(new Runnable()
566       {
567         @Override
568         public void run()
569         {
570           scroller.getHorizontalScrollBar()
571                   .setValue(scroller.getHorizontalScrollBar().getMaximum());
572         }
573       });
574     }
575   }
576
577   @Override
578   public DialogRunnerI addResponse(RunResponse action)
579   {
580     return runner.addResponse(action);
581   }
582
583   /**
584    * JalviewJS signals file selection by a property change event
585    * for property "SelectedFile".  This methods responds to that by
586    * running the response action for 'OK' in the dialog.
587    * 
588    * @param evt
589    */
590   @Override
591   public void propertyChange(PropertyChangeEvent evt)
592   {
593     // TODO other properties need runners...
594     switch (evt.getPropertyName())
595     {
596     case "SelectedFile": 
597       runner.handleResponse(APPROVE_OPTION);
598       break;
599     }
600   }
601 }