diff --git a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java index deadaf19ee..d83506b943 100644 --- a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java +++ b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java @@ -34,7 +34,7 @@ public class FileBrowser implements AppInstance private FileBrowserController controller; - FileBrowser(final AppDescriptor app, final File directory) + FileBrowser(final AppDescriptor app, final File file) { this.app = app; @@ -58,8 +58,14 @@ public class FileBrowser implements AppInstance final DockItem tab = new DockItem(this, content); DockPane.getActiveDockPane().addTab(tab); - if (controller != null && directory != null) - controller.setRoot(directory); + if (controller != null && file != null){ + if(file.isDirectory()){ + controller.setRoot(file); + } + else{ + controller.setRootAndHighlight(file); + } + } tab.addClosedNotification(controller::shutdown); } diff --git a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java index 7577f277ac..bbf0d1fa73 100644 --- a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java +++ b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java @@ -3,6 +3,8 @@ import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.Alert; @@ -69,22 +71,19 @@ public class FileBrowserController { private final Menu openWith = new Menu(Messages.OpenWith, ImageCache.getImageView(PhoebusApplication.class, "/icons/fldr_obj.png")); private final ContextMenu contextMenu = new ContextMenu(); - public FileBrowserController() - { + private ExpandedCountChangeListener expandedCountChangeListener; + + public FileBrowserController() { monitor = new DirectoryMonitor(this::handleFilesystemChanges); } - private void handleFilesystemChanges(final File file, final DirectoryMonitor.Change change) - { + private void handleFilesystemChanges(final File file, final DirectoryMonitor.Change change) { // The notification might address a file that the file browser itself just added/renamed/removed, // and the file browser is already in the process of updating itself. // Wait a little to allow that to happen - try - { + try { Thread.sleep(1000); - } - catch (InterruptedException ex) - { + } catch (InterruptedException ex) { return; } @@ -97,11 +96,9 @@ else if (change == DirectoryMonitor.Change.REMOVED) assertTreeDoesntContain(treeView.getRoot(), file.toPath()); } - private void assertTreeContains(final TreeItem item, final Path file) - { + private void assertTreeContains(final TreeItem item, final Path file) { final Path dir = item.getValue().file.toPath(); - if (! file.startsWith(dir)) - { + if (!file.startsWith(dir)) { logger.log(Level.WARNING, "Cannot check for " + file + " within " + dir); return; } @@ -115,23 +112,20 @@ private void assertTreeContains(final TreeItem item, final Path file) logger.log(Level.FINE, () -> "Looking for " + sub + " in " + dir); for (TreeItem child : item.getChildren()) - if (sub.equals(child.getValue().file)) - { - logger.log(Level.FINE,"Found it!"); + if (sub.equals(child.getValue().file)) { + logger.log(Level.FINE, "Found it!"); if (sub.isDirectory()) assertTreeContains(child, file); return; } logger.log(Level.FINE, () -> "Forcing refresh of " + dir + " to show " + sub); - Platform.runLater(() -> ((FileTreeItem)item).forceRefresh()); + Platform.runLater(() -> ((FileTreeItem) item).forceRefresh()); } - private void refreshTreeItem(final TreeItem item, final Path file) - { + private void refreshTreeItem(final TreeItem item, final Path file) { final Path dir = item.getValue().file.toPath(); - if (dir.equals(file)) - { + if (dir.equals(file)) { logger.log(Level.FINE, () -> "Forcing refresh of " + item); Platform.runLater(() -> { @@ -143,8 +137,7 @@ private void refreshTreeItem(final TreeItem item, final Path file) return; } - if (! file.startsWith(dir)) - { + if (!file.startsWith(dir)) { logger.log(Level.WARNING, "Cannot refresh " + file + " within " + dir); return; } @@ -159,12 +152,10 @@ private void refreshTreeItem(final TreeItem item, final Path file) } - private void assertTreeDoesntContain(final TreeItem item, final Path file) - { + private void assertTreeDoesntContain(final TreeItem item, final Path file) { final Path dir = item.getValue().file.toPath(); logger.log(Level.FINE, () -> "Does " + dir + " still contain " + file + "?"); - if (! file.startsWith(dir)) - { + if (!file.startsWith(dir)) { logger.log(Level.FINE, "Can't!"); return; } @@ -172,16 +163,14 @@ private void assertTreeDoesntContain(final TreeItem item, final Path f final int dir_len = dir.getNameCount(); final File sub = new File(item.getValue().file, file.getName(dir_len).toString()); for (TreeItem child : item.getChildren()) - if (sub.equals(child.getValue().file)) - { + if (sub.equals(child.getValue().file)) { // Found file or sub path to it.. if (sub.isDirectory()) assertTreeDoesntContain(child, file); - else - { // Found the file still listed as a child of 'item', + else { // Found the file still listed as a child of 'item', // so refresh 'item' logger.log(Level.FINE, () -> "Forcing refresh of " + dir + " to hide " + sub); - Platform.runLater(() -> ((FileTreeItem)item).forceRefresh()); + Platform.runLater(() -> ((FileTreeItem) item).forceRefresh()); } return; } @@ -189,15 +178,15 @@ private void assertTreeDoesntContain(final TreeItem item, final Path f logger.log(Level.FINE, "Not found, all good"); } - /** Try to open resource, show error dialog on failure - * @param file Resource to open - * @param stage Stage to use to prompt for specific app. - * Otherwise null to use default app. + /** + * Try to open resource, show error dialog on failure + * + * @param file Resource to open + * @param stage Stage to use to prompt for specific app. + * Otherwise null to use default app. */ - private void openResource(final File file, final Stage stage) - { - if (! ApplicationLauncherService.openFile(file, stage != null, stage)) - { + private void openResource(final File file, final Stage stage) { + if (!ApplicationLauncherService.openFile(file, stage != null, stage)) { final Alert alert = new Alert(AlertType.ERROR); alert.setHeaderText(Messages.OpenAlert1 + file + Messages.OpenAlert2); DialogHelper.positionDialog(alert, treeView, -300, -200); @@ -205,17 +194,18 @@ private void openResource(final File file, final Stage stage) } } - /** Try to open all the currently selected resources */ - private void openSelectedResources() - { + /** + * Try to open all the currently selected resources + */ + private void openSelectedResources() { treeView.selectionModelProperty() .getValue() .getSelectedItems() .forEach(item -> - { - if (item.isLeaf()) - openResource(item.getValue().file, null); - }); + { + if (item.isLeaf()) + openResource(item.getValue().file, null); + }); } @FXML @@ -253,17 +243,15 @@ public void initialize() { // Available with, less space used for the TableMenuButton '+' on the right // so that the up/down column sort markers remain visible double available = treeView.getWidth() - 10; - if (name_col.isVisible()) - { + if (name_col.isVisible()) { // Only name visible? Use the space! if (!size_col.isVisible() && !time_col.isVisible()) name_col.setPrefWidth(available); else available -= name_col.getWidth(); } - if (size_col.isVisible()) - { - if (! time_col.isVisible()) + if (size_col.isVisible()) { + if (!time_col.isVisible()) size_col.setPrefWidth(available); else available -= size_col.getWidth(); @@ -288,98 +276,86 @@ public void initialize() { treeView.setOnKeyPressed(this::handleKeys); } - TreeTableView getView() - { + TreeTableView getView() { return treeView; } - private void handleKeys(final KeyEvent event) - { - switch(event.getCode()) - { - case ENTER: // Open - { - openSelectedResources(); - event.consume(); - break; - } - case F2: // Rename file - { - final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); - if (items.size() == 1) + private void handleKeys(final KeyEvent event) { + switch (event.getCode()) { + case ENTER: // Open { - final TreeItem item = items.get(0); - if (item.isLeaf()) - new RenameAction(treeView, item).fire(); + openSelectedResources(); + event.consume(); + break; } - event.consume(); - break; - } - case DELETE: // Delete - { - final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); - if (items.size() > 0) - new DeleteAction(treeView, items).fire(); - event.consume(); - break; - } - case C: // Copy - { - if (event.isShortcutDown()) + case F2: // Rename file { final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); - new CopyPath(items).fire(); + if (items.size() == 1) { + final TreeItem item = items.get(0); + if (item.isLeaf()) + new RenameAction(treeView, item).fire(); + } event.consume(); + break; } - break; - } - case V: // Paste - { - if (event.isShortcutDown()) + case DELETE: // Delete { - TreeItem item = treeView.selectionModelProperty().getValue().getSelectedItem(); - if (item == null) - item = treeView.getRoot(); - else if (item.isLeaf()) - item = item.getParent(); - new PasteFiles(treeView, item).fire(); + final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); + if (items.size() > 0) + new DeleteAction(treeView, items).fire(); event.consume(); + break; } - break; - } - case ESCAPE: // De-select - treeView.selectionModelProperty().get().clearSelection(); - break; - default: - if ((event.getCode().compareTo(KeyCode.A) >= 0 && event.getCode().compareTo(KeyCode.Z) <= 0) || - (event.getCode().compareTo(KeyCode.DIGIT0) >= 0 && event.getCode().compareTo(KeyCode.DIGIT9) <= 0)) + case C: // Copy { - // Move selection to first/next file that starts with that character - final String ch = event.getCode().getChar().toLowerCase(); - - final TreeItem selected = treeView.selectionModelProperty().getValue().getSelectedItem(); - final ObservableList> siblings; - int index; - if (selected != null) - { // Start after the selected item - siblings = selected.getParent().getChildren(); - index = siblings.indexOf(selected); + if (event.isShortcutDown()) { + final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); + new CopyPath(items).fire(); + event.consume(); } - else if (!treeView.getRoot().getChildren().isEmpty()) - { // Start at the root - siblings = treeView.getRoot().getChildren(); - index = -1; + break; + } + case V: // Paste + { + if (event.isShortcutDown()) { + TreeItem item = treeView.selectionModelProperty().getValue().getSelectedItem(); + if (item == null) + item = treeView.getRoot(); + else if (item.isLeaf()) + item = item.getParent(); + new PasteFiles(treeView, item).fire(); + event.consume(); } - else - break; - for (++index; index < siblings.size(); ++index) - if (siblings.get(index).getValue().file.getName().toLowerCase().startsWith(ch)) - { - treeView.selectionModelProperty().get().clearSelection(); - treeView.selectionModelProperty().get().select(siblings.get(index)); - break; - } + break; } + case ESCAPE: // De-select + treeView.selectionModelProperty().get().clearSelection(); + break; + default: + if ((event.getCode().compareTo(KeyCode.A) >= 0 && event.getCode().compareTo(KeyCode.Z) <= 0) || + (event.getCode().compareTo(KeyCode.DIGIT0) >= 0 && event.getCode().compareTo(KeyCode.DIGIT9) <= 0)) { + // Move selection to first/next file that starts with that character + final String ch = event.getCode().getChar().toLowerCase(); + + final TreeItem selected = treeView.selectionModelProperty().getValue().getSelectedItem(); + final ObservableList> siblings; + int index; + if (selected != null) { // Start after the selected item + siblings = selected.getParent().getChildren(); + index = siblings.indexOf(selected); + } else if (!treeView.getRoot().getChildren().isEmpty()) { // Start at the root + siblings = treeView.getRoot().getChildren(); + index = -1; + } else + break; + for (++index; index < siblings.size(); ++index) + if (siblings.get(index).getValue().file.getName().toLowerCase().startsWith(ch)) { + treeView.selectionModelProperty().get().clearSelection(); + treeView.selectionModelProperty().get().select(siblings.get(index)); + break; + } + } } } @@ -389,18 +365,15 @@ public void createContextMenu(ContextMenuEvent e) { contextMenu.getItems().clear(); - if (selectedItems.isEmpty()) - { + if (selectedItems.isEmpty()) { // Create directory at root contextMenu.getItems().addAll(new CreateDirectoryAction(treeView, treeView.getRoot())); // Paste files at root if (Clipboard.getSystemClipboard().hasFiles()) contextMenu.getItems().addAll(new PasteFiles(treeView, treeView.getRoot())); - } - else - { + } else { // allMatch() would return true for empty, so only check if there are items - if (selectedItems.stream().allMatch(item -> item.isLeaf())){ + if (selectedItems.stream().allMatch(item -> item.isLeaf())) { contextMenu.getItems().add(open); } @@ -408,7 +381,7 @@ public void createContextMenu(ContextMenuEvent e) { configureOpenWithMenuItem(selectedItems); if (selectedItems.size() == 1) { - if(file.isDirectory()){ + if (file.isDirectory()) { contextMenu.getItems().add(new SetBaseDirectory(file, this::setRoot)); contextMenu.getItems().add(new SeparatorMenuItem()); @@ -434,25 +407,21 @@ public void createContextMenu(ContextMenuEvent e) { contextMenu.getItems().add(new CopyPath(selectedItems)); contextMenu.getItems().add(new SeparatorMenuItem()); } - if (selectedItems.size() >= 1) - { + if (selectedItems.size() >= 1) { final TreeItem item = selectedItems.get(0); final boolean is_file = item.isLeaf(); - if (selectedItems.size() == 1) - { - if (is_file) - { + if (selectedItems.size() == 1) { + if (is_file) { // Create directory within the _parent_ contextMenu.getItems().addAll(new CreateDirectoryAction(treeView, item.getParent())); // Plain file can be duplicated, directory can't contextMenu.getItems().add(new DuplicateAction(treeView, item)); - } - else + } else // Within a directory, a new directory can be created contextMenu.getItems().addAll(new CreateDirectoryAction(treeView, item)); - contextMenu.getItems().addAll(new RenameAction(treeView, selectedItems.get(0))); + contextMenu.getItems().addAll(new RenameAction(treeView, selectedItems.get(0))); if (Clipboard.getSystemClipboard().hasFiles()) contextMenu.getItems().addAll(new PasteFiles(treeView, selectedItems.get(0))); @@ -467,15 +436,14 @@ public void createContextMenu(ContextMenuEvent e) { contextMenu.getItems().add(new RefreshAction(treeView, item)); } - if (selectedItems.size() == 1){ - contextMenu.getItems().addAll(new PropertiesAction(treeView, selectedItems.get(0))); + if (selectedItems.size() == 1) { + contextMenu.getItems().addAll(new PropertiesAction(treeView, selectedItems.get(0))); } contextMenu.show(treeView.getScene().getWindow(), e.getScreenX(), e.getScreenY()); } @FXML - public void handleMouseClickEvents(final MouseEvent event) - { + public void handleMouseClickEvents(final MouseEvent event) { if (event.getClickCount() == 2) openSelectedResources(); } @@ -486,17 +454,34 @@ public void setNewRoot() { setRoot(p.toFile()); } - /** @param directory Desired root directory */ - public void setRoot(final File directory) - { + /** + * @param directory Desired root directory + */ + public void setRoot(final File directory) { monitor.setRoot(directory); path.setText(directory.toString()); treeView.setRoot(new FileTreeItem(monitor, directory)); } - /** @return Root directory */ - public File getRoot() - { + /** + * Set a new root directory and highlight the file provided (unless it is a directory). + * + * @param file A {@link File} object representing a file (or directory). + */ + public void setRootAndHighlight(final File file) { + if (file.isDirectory()) { + setRoot(file); + } else { + this.expandedCountChangeListener = new ExpandedCountChangeListener(file); + treeView.expandedItemCountProperty().addListener(expandedCountChangeListener); + setRoot(file.getParentFile()); + } + } + + /** + * @return Root directory + */ + public File getRoot() { return treeView.getRoot().getValue().file; } @@ -518,9 +503,10 @@ public void browseNewRoot() { setRoot(newRootFile); } - /** Call when no longer needed */ - public void shutdown() - { + /** + * Call when no longer needed + */ + public void shutdown() { monitor.shutdown(); } @@ -532,10 +518,11 @@ public void shutdown() *
  • If all selected items are of same type, the Open With menu item will be added and the * the sub-menu items will open all items. This also covers the case when only one item is selected.
  • * + * * @param selectedItems List of items selected by user in the tree table view. */ - private void configureOpenWithMenuItem(List> selectedItems){ - if(!areSelectedFilesOfSameType(selectedItems)) { + private void configureOpenWithMenuItem(List> selectedItems) { + if (!areSelectedFilesOfSameType(selectedItems)) { openWith.getItems().clear(); return; } @@ -544,17 +531,15 @@ private void configureOpenWithMenuItem(List> selectedItems){ final File file = selectedItems.get(0).getValue().file; final URI resource = ResourceParser.getURI(file); final List applications = ApplicationService.getApplications(resource); - if (applications.size() > 0) - { + if (applications.size() > 0) { openWith.getItems().clear(); - for (AppResourceDescriptor app : applications) - { + for (AppResourceDescriptor app : applications) { final MenuItem open_app = new MenuItem(app.getDisplayName()); final URL icon_url = app.getIconURL(); if (icon_url != null) open_app.setGraphic(new ImageView(icon_url.toExternalForm())); open_app.setOnAction(event -> { - for(TreeItem item : selectedItems){ + for (TreeItem item : selectedItems) { URI u = ResourceParser.getURI(item.getValue().file); app.create(u); } @@ -568,20 +553,48 @@ private void configureOpenWithMenuItem(List> selectedItems){ /** * Examines the file selection to determine whether all files are of the same type. A type is * defined by the file extension (case-insensitive substring after last dot). + * * @param selectedItems Items selected by user in the tree table view * @return true if all selected files have same (case-insensitive) extension. */ - private boolean areSelectedFilesOfSameType(List> selectedItems){ + private boolean areSelectedFilesOfSameType(List> selectedItems) { File file = selectedItems.get(0).getValue().file; String firstExtension = file.getPath().substring(file.getPath().lastIndexOf(".") + 1).toLowerCase(); - for(int i = 1; i < selectedItems.size(); i++){ + for (int i = 1; i < selectedItems.size(); i++) { file = selectedItems.get(i).getValue().file; String nextExtension = file.getPath().substring(file.getPath().lastIndexOf(".") + 1).toLowerCase(); - if(!firstExtension.equals(nextExtension)){ + if (!firstExtension.equals(nextExtension)) { return false; } } return true; } + + private class ExpandedCountChangeListener implements ChangeListener { + + /** + * A {@link File} object representing a file (i.e. not a directory) in case client calls + * {@link #setRootAndHighlight(File)} using a file. If the {@link #setRootAndHighlight(File)} call + * specifies a directory, this is set to null. + */ + private File fileToHighlight; + + public ExpandedCountChangeListener(File fileToHighlight){ + this.fileToHighlight = fileToHighlight; + } + @Override + public void changed(ObservableValue observable, Object oldValue, Object newValue) { + TreeItem root = treeView.getRoot(); + List children = root.getChildren(); + for (TreeItem child : children) { + if (((FileInfo) child.getValue()).file.equals(fileToHighlight)) { + treeView.getSelectionModel().select(child); + treeView.scrollTo(treeView.getSelectionModel().getSelectedIndex()); + treeView.expandedItemCountProperty().removeListener(expandedCountChangeListener); + return; + } + } + } + } } diff --git a/core/ui/src/main/java/org/phoebus/ui/application/Messages.java b/core/ui/src/main/java/org/phoebus/ui/application/Messages.java index e9a8cdb981..85e440852f 100644 --- a/core/ui/src/main/java/org/phoebus/ui/application/Messages.java +++ b/core/ui/src/main/java/org/phoebus/ui/application/Messages.java @@ -20,6 +20,7 @@ public class Messages public static String AppRevision; public static String AppVersionHeader; public static String CloseAllTabs; + public static String CopyResourcePath; public static String DeleteLayouts; public static String DeleteLayoutsConfirmFmt; public static String DeleteLayoutsInfo; @@ -130,6 +131,7 @@ public class Messages public static String ScreenshotErrHdr; public static String ScreenshotErrMsg; public static String SelectTab; + public static String ShowInFileBrowserApp; public static String ShowStatusbar; public static String ShowToolbar; public static String Time12h; diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java index e44a4ee891..8c3629cfdf 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java @@ -137,6 +137,23 @@ public class DockItem extends Tab /** Called after tab was closed */ private List closed_callback = null; + private MenuItem info; + private MenuItem detach; + + private MenuItem split_horiz; + + private MenuItem split_vert; + + private MenuItem save_window; + + private MenuItem close; + + private MenuItem close_other; + + private MenuItem close_all; + + private ContextMenu menu; + /** Create dock item for instance of an application * @param applicationInstance {@link AppInstance} * @param content Content for this application instance @@ -224,19 +241,19 @@ public DockPane getDockPane() private void createContextMenu() { - final MenuItem info = new MenuItem(Messages.DockInfo, new ImageView(info_icon)); + info = new MenuItem(Messages.DockInfo, new ImageView(info_icon)); info.setOnAction(event -> showInfo()); - final MenuItem detach = new MenuItem(Messages.DockDetach, new ImageView(detach_icon)); + detach = new MenuItem(Messages.DockDetach, new ImageView(detach_icon)); detach.setOnAction(event -> detach()); - final MenuItem split_horiz = new MenuItem(Messages.DockSplitH, new ImageView(split_horiz_icon)); + split_horiz = new MenuItem(Messages.DockSplitH, new ImageView(split_horiz_icon)); split_horiz.setOnAction(event -> split(true)); - final MenuItem split_vert = new MenuItem(Messages.DockSplitV, new ImageView(split_vert_icon)); + split_vert = new MenuItem(Messages.DockSplitV, new ImageView(split_vert_icon)); split_vert.setOnAction(event -> split(false)); - final MenuItem save_window = new MenuItem(Messages.SaveLayoutOfContainingWindowAs, new ImageView(save_window_layout_icon)); + save_window = new MenuItem(Messages.SaveLayoutOfContainingWindowAs, new ImageView(save_window_layout_icon)); save_window.setOnAction(event -> { DockPane activeDockPane = getActiveDockPane(); List stagesContainingActiveDockPane = DockStage.getDockStages().stream().filter(stage -> getDockPanes(stage).contains(activeDockPane)).collect(Collectors.toList()); @@ -251,12 +268,12 @@ else if (stagesContainingActiveDockPane.size() == 0) { } }); - final MenuItem close = new MenuItem(Messages.DockClose, new ImageView(DockPane.close_icon)); + close = new MenuItem(Messages.DockClose, new ImageView(DockPane.close_icon)); ArrayList arrayList = new ArrayList(); arrayList.add(this); close.setOnAction(event -> close(arrayList)); - final MenuItem close_other = new MenuItem(Messages.DockCloseOthers, new ImageView(close_many_icon)); + close_other = new MenuItem(Messages.DockCloseOthers, new ImageView(close_many_icon)); close_other.setOnAction(event -> { // Close all other tabs in non-fixed panes of this window @@ -270,7 +287,7 @@ else if (stagesContainingActiveDockPane.size() == 0) { close(tabs); }); - final MenuItem close_all = new MenuItem(Messages.DockCloseAll, new ImageView(close_many_icon)); + close_all = new MenuItem(Messages.DockCloseAll, new ImageView(close_many_icon)); close_all.setOnAction(event -> { // Close all tabs in non-fixed panes of this window @@ -284,41 +301,44 @@ else if (stagesContainingActiveDockPane.size() == 0) { close(tabs); }); - final ContextMenu menu = new ContextMenu(info); - + menu = new ContextMenu(info); menu.setOnShowing(event -> { - menu.getItems().setAll(info); - - final boolean may_lock = AuthorizationService.hasAuthorization("lock_ui"); - final DockPane pane = getDockPane(); - if (pane.isFixed()) - { - if (may_lock) - menu.getItems().addAll(new NamePaneMenuItem(pane), new UnlockMenuItem(pane)); - } - else - { - menu.getItems().addAll(new SeparatorMenuItem(), - detach, - split_horiz, - split_vert); - - if (may_lock) - menu.getItems().addAll(new NamePaneMenuItem(pane), new LockMenuItem(pane)); - - menu.getItems().addAll(new SeparatorMenuItem(), - close, - close_other, - new SeparatorMenuItem(), - close_all); - } - menu.getItems().addAll(new SeparatorMenuItem(), save_window); + configureContextMenu(menu); }); name_tab.setContextMenu(menu); } + protected void configureContextMenu(ContextMenu menu){ + menu.getItems().setAll(info); + + final boolean may_lock = AuthorizationService.hasAuthorization("lock_ui"); + final DockPane pane = getDockPane(); + if (pane.isFixed()) + { + if (may_lock) + menu.getItems().addAll(new NamePaneMenuItem(pane), new UnlockMenuItem(pane)); + } + else + { + menu.getItems().addAll(new SeparatorMenuItem(), + detach, + split_horiz, + split_vert); + + if (may_lock) + menu.getItems().addAll(new NamePaneMenuItem(pane), new LockMenuItem(pane)); + + menu.getItems().addAll(new SeparatorMenuItem(), + close, + close_other, + new SeparatorMenuItem(), + close_all); + } + menu.getItems().addAll(new SeparatorMenuItem(), save_window); + } + /** @param tabs Tabs to prepare and then close */ private void close(final ArrayList tabs) { diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java index fba63d64e6..9c91ec9c0a 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java @@ -7,7 +7,34 @@ *******************************************************************************/ package org.phoebus.ui.docking; -import static org.phoebus.ui.application.PhoebusApplication.logger; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.Tab; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.layout.Region; +import javafx.stage.FileChooser.ExtensionFilter; +import javafx.stage.Window; +import org.apache.commons.io.FilenameUtils; +import org.phoebus.framework.jobs.JobManager; +import org.phoebus.framework.jobs.JobMonitor; +import org.phoebus.framework.jobs.JobRunnable; +import org.phoebus.framework.spi.AppInstance; +import org.phoebus.framework.util.ResourceParser; +import org.phoebus.framework.workbench.ApplicationService; +import org.phoebus.ui.application.Messages; +import org.phoebus.ui.dialog.DialogHelper; +import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; +import org.phoebus.ui.dialog.SaveAsDialog; +import org.phoebus.ui.javafx.ImageCache; import java.io.File; import java.net.URI; @@ -23,54 +50,36 @@ import java.util.logging.Level; import java.util.stream.Collectors; -import javafx.scene.layout.Region; -import javafx.stage.Window; -import org.apache.commons.io.FilenameUtils; -import org.phoebus.framework.jobs.JobManager; -import org.phoebus.framework.jobs.JobMonitor; -import org.phoebus.framework.jobs.JobRunnable; -import org.phoebus.framework.spi.AppInstance; -import org.phoebus.framework.util.ResourceParser; -import org.phoebus.ui.application.Messages; -import org.phoebus.ui.dialog.DialogHelper; -import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; -import org.phoebus.ui.dialog.SaveAsDialog; - -import javafx.application.Platform; -import javafx.scene.Node; -import javafx.scene.control.Alert; -import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.ButtonType; -import javafx.scene.control.Tab; -import javafx.scene.control.Tooltip; -import javafx.stage.FileChooser.ExtensionFilter; +import static org.phoebus.ui.application.PhoebusApplication.logger; -/** Item for a {@link DockPane} that has an 'input' file or URI. +/** + * Item for a {@link DockPane} that has an 'input' file or URI. * - *

    While technically a {@link Tab}, - * only the methods declared in here and - * in {@link DockItem} should be called - * to assert compatibility with future updates. + *

    While technically a {@link Tab}, + * only the methods declared in here and + * in {@link DockItem} should be called + * to assert compatibility with future updates. * - *

    Tracks the current 'input' and the 'dirty' state. - * When the item becomes 'dirty', 'Save' or 'Save As' - * are supported via the provided list of file extensions - * and a 'save_handler'. - * User will be asked to save a dirty tab when the tab is closed. - * Saving can also be initiated from the 'File' menu. - * If the 'input' is null, 'Save' automatically - * invokes 'Save As' to prompt for a file name. + *

    Tracks the current 'input' and the 'dirty' state. + * When the item becomes 'dirty', 'Save' or 'Save As' + * are supported via the provided list of file extensions + * and a 'save_handler'. + * User will be asked to save a dirty tab when the tab is closed. + * Saving can also be initiated from the 'File' menu. + * If the 'input' is null, 'Save' automatically + * invokes 'Save As' to prompt for a file name. * - * @author Kay Kasemir + * @author Kay Kasemir */ @SuppressWarnings("nls") -public class DockItemWithInput extends DockItem -{ +public class DockItemWithInput extends DockItem { private static final String DIRTY = "* "; private AtomicBoolean is_dirty = new AtomicBoolean(false); - /** The one item that should always be included in 'file_extensions' */ + /** + * The one item that should always be included in 'file_extensions' + */ public static final ExtensionFilter ALL_FILES = new ExtensionFilter(Messages.DockAll, "*.*"); private final ExtensionFilter[] file_extensions; @@ -79,37 +88,80 @@ public class DockItemWithInput extends DockItem private volatile URI input; - /** Create dock item + private final static Image copyToClipboardIcon = ImageCache.getImage(DockItem.class, "/icons/copy.png"); + + private final static Image fileBrowserIcon = ImageCache.getImage(DockItem.class, "/icons/filebrowser.png"); + + /** + * Create dock item * - *

    The 'save_handler' will be called to save the content. - * It will be called in a background job, because writing files - * might be slow. + *

    The 'save_handler' will be called to save the content. + * It will be called in a background job, because writing files + * might be slow. * - *

    When 'save_handler' is called, the 'input' will be set to a file-based URI. - * On success, or if for some reason there is nothing to save, - * the 'save_handler' returns. - * On error, the 'save_handler' throws an exception. + *

    When 'save_handler' is called, the 'input' will be set to a file-based URI. + * On success, or if for some reason there is nothing to save, + * the 'save_handler' returns. + * On error, the 'save_handler' throws an exception. * - * @param application {@link AppInstance} - * @param content Initial content - * @param input URI for the input. May be null - * @param file_extensions File extensions for "Save As". May be null if never calling setDirty(true) - * @param save_handler Will be called to 'save' the content. May be null if never calling setDirty(true) + * @param application {@link AppInstance} + * @param content Initial content + * @param input URI for the input. May be null + * @param file_extensions File extensions for "Save As". May be null if never calling setDirty(true) + * @param save_handler Will be called to 'save' the content. May be null if never calling setDirty(true) */ public DockItemWithInput(final AppInstance application, final Node content, final URI input, final ExtensionFilter[] file_extensions, - final JobRunnable save_handler) - { + final JobRunnable save_handler) { super(application, content); - this.file_extensions = file_extensions; + this.file_extensions = file_extensions; this.save_handler = save_handler; setInput(input); + name_tab.getContextMenu().setOnShowing(e -> { + this.configureContextMenu(name_tab.getContextMenu()); + }); + } + + /** + * Configures additional and optional items in the tab header context menu if the resource field is non-null: + * + *

  • Copy the resource to clipboard
  • + *
  • For file resources a sub-menu with items:
  • + *
      + *
    • Open and highlight file in new File Browser instance
    • + *
    • Open file's parent directory in native file browser
    • + *
    + * + * + * @param menu The {@link ContextMenu} to update. + */ + protected void configureContextMenu(ContextMenu menu) { + super.configureContextMenu(menu); + if (input == null) { + return; + } + boolean isFileResource = input.getScheme().toLowerCase().startsWith("file"); + final MenuItem copyResourceToClipboard = new MenuItem(Messages.CopyResourcePath, new ImageView(copyToClipboardIcon)); + copyResourceToClipboard.setOnAction(e -> { + final ClipboardContent content = new ClipboardContent(); + content.putString(isFileResource ? input.getPath() : input.toString()); + Clipboard.getSystemClipboard().setContent(content); + }); + + if (isFileResource) { + final MenuItem showInFileBrowser = new MenuItem(Messages.ShowInFileBrowserApp, new ImageView(fileBrowserIcon)); + showInFileBrowser.setOnAction(e -> { + ApplicationService.createInstance("file_browser", new File(input.getPath()).toURI()); + }); + name_tab.getContextMenu().getItems().add(1, showInFileBrowser); + } + + name_tab.getContextMenu().getItems().add(2, copyResourceToClipboard); } // Override to include 'dirty' tab @Override - public void setLabel(final String label) - { + public void setLabel(final String label) { name = label; if (isDirty()) name_tab.setText(DIRTY + label); @@ -119,15 +171,13 @@ public void setLabel(final String label) // Add 'input' @Override - protected void fillInformation(final StringBuilder info) - { + protected void fillInformation(final StringBuilder info) { super.fillInformation(info); info.append("\n"); info.append(Messages.DockInput).append(getInput()); } - private static String extract_name(String path) - { + private static String extract_name(String path) { if (path == null) return null; @@ -136,7 +186,7 @@ private static String extract_name(String path) if (sep < 0) sep = path.lastIndexOf('\\'); if (sep >= 0) - path = path.substring(sep+1); + path = path.substring(sep + 1); // Remove extension sep = path.lastIndexOf('.'); @@ -145,19 +195,19 @@ private static String extract_name(String path) return path.substring(0, sep); } - /** Set input + /** + * Set input * - *

    Registers the input to be persisted and restored. - * The tab tooltip indicates complete input, - * while tab label will be set to basic name (sans path and extension). - * For custom name, call setLabel after updating input - * in Platform.runLater() + *

    Registers the input to be persisted and restored. + * The tab tooltip indicates complete input, + * while tab label will be set to basic name (sans path and extension). + * For custom name, call setLabel after updating input + * in Platform.runLater() * - * @param input Input - * @see DockItemWithInput#setLabel(String) + * @param input Input + * @see DockItemWithInput#setLabel(String) */ - public void setInput(final URI input) - { + public void setInput(final URI input) { this.input = input; final String name = input == null ? null : extract_name(input.getPath()); @@ -166,8 +216,7 @@ public void setInput(final URI input) { if (input == null) name_tab.setTooltip(new Tooltip(Messages.DockNotSaved)); - else - { + else { String decodedInputURI = URLDecoder.decode(input.toString(), StandardCharsets.UTF_8); name_tab.setTooltip(new Tooltip(decodedInputURI)); setLabel(name); @@ -175,43 +224,48 @@ public void setInput(final URI input) }); } - /** @return Input, which may be null (OK to call from any thread) */ - public URI getInput() - { + /** + * @return Input, which may be null (OK to call from any thread) + */ + public URI getInput() { return input; } - /** @return Current 'dirty' state */ - public boolean isDirty() - { + /** + * @return Current 'dirty' state + */ + public boolean isDirty() { return is_dirty.get(); } - /** Update 'dirty' state. + /** + * Update 'dirty' state. * - *

    May be called from any thread - * @param dirty Updated 'dirty' state + *

    May be called from any thread + * + * @param dirty Updated 'dirty' state */ - public void setDirty(final boolean dirty) - { + public void setDirty(final boolean dirty) { if (is_dirty.getAndSet(dirty) == dirty) return; // Dirty state changed. Update label on UI thread Platform.runLater(() -> setLabel(name)); } - /** @return Is "Save As" supported, i.e. have file extensions and a save handler? */ - public boolean isSaveAsSupported() - { - return file_extensions != null && save_handler != null; + /** + * @return Is "Save As" supported, i.e. have file extensions and a save handler? + */ + public boolean isSaveAsSupported() { + return file_extensions != null && save_handler != null; } - /** Called when user tries to close the tab - * @return Should the tab close? Otherwise it stays open. + /** + * Called when user tries to close the tab + * + * @return Should the tab close? Otherwise it stays open. */ - public Future okToClose() - { - if (! isDirty()) + public Future okToClose() { + if (!isDirty()) return CompletableFuture.completedFuture(true); final FutureTask promptToSave = new FutureTask(() -> { @@ -229,7 +283,7 @@ public Future okToClose() Platform.runLater(promptToSave); try { - ButtonType result = (ButtonType)promptToSave.get(); + ButtonType result = (ButtonType) promptToSave.get(); // Cancel the close request if (result == ButtonType.CANCEL) return CompletableFuture.completedFuture(false); @@ -252,40 +306,38 @@ public Future okToClose() return done; } - /** Save the content of the item to its current 'input' + /** + * Save the content of the item to its current 'input' * - *

    Called by the framework when user invokes the 'Save*' - * menu items or when a 'dirty' tab is closed. + *

    Called by the framework when user invokes the 'Save*' + * menu items or when a 'dirty' tab is closed. * - *

    Will never be called when the item remains clean, - * i.e. never called {@link DockItemWithInput#setDirty(boolean)}. + *

    Will never be called when the item remains clean, + * i.e. never called {@link DockItemWithInput#setDirty(boolean)}. * - * @param monitor {@link JobMonitor} for reporting progress - * @return true on success + * @param monitor {@link JobMonitor} for reporting progress + * @return true on success */ - public final boolean save(final JobMonitor monitor, Window parentWindow) - { + public final boolean save(final JobMonitor monitor, Window parentWindow) { // 'final' because any save customization should be possible // inside the save_handler - monitor.beginTask(MessageFormat.format(Messages.Saving , input)); + monitor.beginTask(MessageFormat.format(Messages.Saving, input)); - try - { // If there is no file (input is null or for example http:), + try { // If there is no file (input is null or for example http:), // call save_as to prompt for file File file = ResourceParser.getFile(getInput()); if (file == null) return save_as(monitor, parentWindow); - if (file.exists() && !file.canWrite()) - { // Warn on UI thread that file is read-only + if (file.exists() && !file.canWrite()) { // Warn on UI thread that file is read-only final CompletableFuture response = new CompletableFuture<>(); Platform.runLater(() -> { final Alert prompt = new Alert(AlertType.CONFIRMATION); prompt.setTitle(Messages.SavingAlertTitle); prompt.setResizable(true); - prompt.setHeaderText(MessageFormat.format(Messages.SavingAlert , file.toString())); + prompt.setHeaderText(MessageFormat.format(Messages.SavingAlert, file.toString())); DialogHelper.positionDialog(prompt, getTabPane(), -200, -200); response.complete(prompt.showAndWait().orElse(ButtonType.CANCEL)); @@ -300,13 +352,11 @@ public final boolean save(final JobMonitor monitor, Window parentWindow) if (save_handler == null) throw new Exception("No save_handler provided for 'dirty' " + toString()); save_handler.run(monitor); - } - catch (Exception ex) - { + } catch (Exception ex) { logger.log(Level.WARNING, "Save error", ex); Platform.runLater(() -> - ExceptionDetailsErrorDialog.openError(Messages.SavingHdr, - Messages.SavingErr + getLabel(), ex)); + ExceptionDetailsErrorDialog.openError(Messages.SavingHdr, + Messages.SavingErr + getLabel(), ex)); return false; } @@ -315,43 +365,42 @@ public final boolean save(final JobMonitor monitor, Window parentWindow) return true; } - /** @param file_extensions {@link ExtensionFilter}s - * @return List of valid file extensions, ignoring "*.*" + /** + * @param file_extensions {@link ExtensionFilter}s + * @return List of valid file extensions, ignoring "*.*" */ - private static List getValidExtensions(final ExtensionFilter[] file_extensions) - { + private static List getValidExtensions(final ExtensionFilter[] file_extensions) { final List valid = new ArrayList<>(); for (ExtensionFilter filter : file_extensions) for (String ext : filter.getExtensions()) - if (! ext.equals("*.*")) - { + if (!ext.equals("*.*")) { final int sep = ext.lastIndexOf('.'); if (sep > 0) - valid.add(ext.substring(sep+1)); + valid.add(ext.substring(sep + 1)); } return valid; } - /** @param file File - * @param valid List of valid file extensions - * @return true if file has one of the valid extensions + /** + * @param file File + * @param valid List of valid file extensions + * @return true if file has one of the valid extensions */ - private static boolean checkFileExtension(final File file, final List valid) - { + private static boolean checkFileExtension(final File file, final List valid) { final String path = file.getPath(); final int sep = path.lastIndexOf('.'); if (sep < 0) return false; - final String ext = path.substring(sep+1); + final String ext = path.substring(sep + 1); return valid.contains(ext); } - /** @param file File - * @param valid List of valid file extensions - * @return File updated to the first valid file extension + /** + * @param file File + * @param valid List of valid file extensions + * @return File updated to the first valid file extension */ - private static File setFileExtension(final File file, final List valid) - { + private static File setFileExtension(final File file, final List valid) { String path = file.getPath(); // Remove existing extension final int sep = path.lastIndexOf('.'); @@ -363,27 +412,26 @@ private static File setFileExtension(final File file, final List valid) return new File(path); } - /** Prompt for new file, then save the content of the item that file. + /** + * Prompt for new file, then save the content of the item that file. * - *

    Called by the framework when user invokes the 'Save As' - * menu item. + *

    Called by the framework when user invokes the 'Save As' + * menu item. * - *

    Will never be called when the item does not report - * {@link #isSaveAsSupported()}. + *

    Will never be called when the item does not report + * {@link #isSaveAsSupported()}. * - * @param monitor {@link JobMonitor} for reporting progress - * @return true on success + * @param monitor {@link JobMonitor} for reporting progress + * @return true on success */ - public final boolean save_as(final JobMonitor monitor, Window parentWindow) - { + public final boolean save_as(final JobMonitor monitor, Window parentWindow) { // 'final' because any save customization should be possible // inside the save_handler - try - { + try { // Prompt for file final File initial = ResourceParser.getFile(getInput()); final File file = new SaveAsDialog().promptForFile(parentWindow, - Messages.SaveAs, initial, file_extensions); + Messages.SaveAs, initial, file_extensions); if (file == null) return false; @@ -392,16 +440,15 @@ public final boolean save_as(final JobMonitor monitor, Window parentWindow) final CompletableFuture actual_file = new CompletableFuture<>(); if (checkFileExtension(file, valid)) actual_file.complete(file); - else - { + else { // Suggest name with valid extension final File suggestion = setFileExtension(file, valid); // Prompt on UI thread final String prompt = MessageFormat.format(Messages.SaveAsPrompt, - file, - valid.stream().collect(Collectors.joining(", ")), - suggestion); + file, + valid.stream().collect(Collectors.joining(", ")), + suggestion); Runnable confirmFileExtension = () -> { @@ -424,8 +471,7 @@ else if (response == ButtonType.NO) if (Platform.isFxApplicationThread()) { confirmFileExtension.run(); - } - else { + } else { Platform.runLater(confirmFileExtension); } @@ -441,8 +487,7 @@ else if (response == ButtonType.NO) setInput(ResourceParser.getURI(actual_file.get())); // Save in that file return save(monitor, getTabPane().getScene().getWindow()); - } - else { + } else { CompletableFuture waitForDialogToClose = new CompletableFuture<>(); Platform.runLater(() -> { String filename = FilenameUtils.getName(newInput.getPath()); @@ -458,7 +503,7 @@ else if (response == ButtonType.NO) dialog.getDialogPane().setPrefSize(width, height); dialog.getDialogPane().setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); dialog.setResizable(false); - DialogHelper.positionDialog(dialog, getTabPane(), -width/2, -height/2); + DialogHelper.positionDialog(dialog, getTabPane(), -width / 2, -height / 2); dialog.showAndWait(); waitForDialogToClose.complete(true); }); @@ -466,23 +511,20 @@ else if (response == ButtonType.NO) waitForDialogToClose.get(); save_as(monitor, getTabPane().getScene().getWindow()); } - } - catch (Exception ex) - { + } catch (Exception ex) { logger.log(Level.WARNING, "Save-As error", ex); Platform.runLater(() -> - ExceptionDetailsErrorDialog.openError(Messages.SaveAsErrHdr, - Messages.SaveAsErrMsg + getLabel(), ex)); + ExceptionDetailsErrorDialog.openError(Messages.SaveAsErrHdr, + Messages.SaveAsErrMsg + getLabel(), ex)); } return false; } /** * {@inheritDoc} - * */ + */ @Override - final protected void handleClosed() - { + final protected void handleClosed() { // Do the same as in the parent class, DockItem.handleClosed... super.handleClosed(); @@ -492,8 +534,7 @@ final protected void handleClosed() } @Override - public String toString() - { + public String toString() { return "DockItemWithInput(\"" + getLabel() + "\", " + getInput() + ")"; } } \ No newline at end of file diff --git a/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties b/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties index 2c9a3c4882..66d2cb0609 100644 --- a/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties +++ b/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties @@ -6,6 +6,7 @@ AppVersion=${version} AppRevision=${revision} AppVersionHeader=CS Studio Version CloseAllTabs=Close All Tabs +CopyResourcePath=Copy resource path to clipboard DeleteLayouts=Delete Layouts... DeleteLayoutsConfirmFmt=Delete {0} selected layouts? DeleteLayoutsInfo=Select layouts to delete @@ -116,6 +117,7 @@ SavingHdr=Save error ScreenshotErrHdr=Screenshot error ScreenshotErrMsg=Cannot write screenshot SelectTab=Select Tab +ShowInFileBrowserApp=Show in File Browser app ShowStatusbar=Show Status bar ShowToolbar=Show Toolbar Time12h=12 h