package org.syncany.gui.history; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; import org.eclipse.swt.SWT; import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.ControlEvent; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.events.MouseAdapter; import org.eclipse.swt.events.MouseEvent; import org.eclipse.swt.events.TreeAdapter; import org.eclipse.swt.events.TreeEvent; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Tree; import org.eclipse.swt.widgets.TreeColumn; import org.eclipse.swt.widgets.TreeItem; import org.ocpsoft.prettytime.PrettyTime; import org.syncany.config.GuiEventBus; import org.syncany.database.DatabaseVersion; import org.syncany.database.FileVersion; import org.syncany.database.FileVersion.FileType; import org.syncany.database.PartialFileHistory.FileHistoryId; import org.syncany.gui.history.events.ModelSelectedDateUpdatedEvent; import org.syncany.gui.history.events.ModelSelectedFilePathUpdatedEvent; import org.syncany.gui.util.I18n; import org.syncany.gui.util.SWTResourceManager; import org.syncany.operations.daemon.messages.LsFolderRequest; import org.syncany.operations.daemon.messages.LsFolderResponse; import org.syncany.operations.ls.LsOperationOptions; import com.google.common.base.Predicate; import com.google.common.collect.Iterables; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.eventbus.Subscribe; /** * The file tree composite displays a tree-based view representing a particular * {@link DatabaseVersion}, and its {@link FileVersion}s. * * <p>The tree is updated by multiple {@link LsFolderRequest}s, starting with a request * to "/", and whenever a new subfolder is opened by a user, a request to that subfolder * is made. * * <p>The tree keeps track of the expanded paths, the selected file (by path) and the * selected file (by file history identifier). Whenever then tree is reloaded, the * expanded paths are reloaded (corresponding {@link LsFolderRequest}s are sent) and * the selected file is selected. * * @author Philipp C. Heckel <philipp.heckel@gmail.com> */ public class FileTreeComposite extends Composite { private static final Logger logger = Logger.getLogger(FileTreeComposite.class.getSimpleName()); private static final String TREE_ICON_RESOURCE_FORMAT = "/" + FileTreeComposite.class.getPackage().getName().replace('.', '/') + "/%s.png"; private static final Object RETRIEVING_LIST_IDENTIFIER = new Object(); private Tree fileTree; private HistoryModel historyModel; private HistoryDialog historyDialog; private Map<Integer, LsFolderRequest> pendingLsFolderRequests; private GuiEventBus eventBus; private Map<String, TreeItem> pathTreeItemCache; private Map<FileHistoryId, TreeItem> fileHistoryIdTreeItemCache; private TreeSet<String> expandedFilePaths; public FileTreeComposite(Composite parent, int style, HistoryModel historyModel, HistoryDialog historyDialog) { super(parent, style); this.fileTree = null; this.historyModel = historyModel; this.historyDialog = historyDialog; this.pendingLsFolderRequests = Maps.newConcurrentMap(); this.eventBus = GuiEventBus.getAndRegister(this); this.pathTreeItemCache = Maps.newConcurrentMap(); this.fileHistoryIdTreeItemCache = Maps.newConcurrentMap(); this.expandedFilePaths = Sets.newTreeSet(); this.createContents(); } private void createContents() { createMainComposite(); createFileTree(); createFileTreeListeners(); createFileTreeColumns(); } private void createMainComposite() { logger.log(Level.INFO, "Tree: Creating main composite ..."); GridLayout mainCompositeGridLayout = new GridLayout(1, false); mainCompositeGridLayout.marginTop = 0; mainCompositeGridLayout.marginLeft = 0; mainCompositeGridLayout.marginRight = 0; mainCompositeGridLayout.verticalSpacing = 0; mainCompositeGridLayout.horizontalSpacing = 0; setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); setLayout(mainCompositeGridLayout); } private void createFileTree() { logger.log(Level.INFO, "Tree: Creating tree ..."); fileTree = new Tree(this, SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL | SWT.DOUBLE_BUFFERED | SWT.SINGLE | SWT.FULL_SELECTION); fileTree.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); fileTree.setEnabled(false); } private void createFileTreeListeners() { logger.log(Level.INFO, "Tree: Creating tree listeners ..."); fileTree.addMouseListener(new MouseAdapter() { @Override public void mouseUp(MouseEvent e) { clickItem(getSelectedItem()); } @Override public void mouseDoubleClick(MouseEvent e) { doubleClickItem(getSelectedItem()); } }); fileTree.addKeyListener(new KeyAdapter() { @Override public void keyReleased(KeyEvent e) { clickItem(getSelectedItem()); } }); fileTree.addTreeListener(new TreeAdapter() { public void treeExpanded(TreeEvent e) { TreeItem treeItem = (TreeItem) e.item; expandTreeItem(treeItem); } @Override public void treeCollapsed(TreeEvent e) { TreeItem treeItem = (TreeItem) e.item; collapseTreeItem(treeItem); } }); } private void createFileTreeColumns() { logger.log(Level.INFO, "Tree: Creating tree columns ..."); final TreeColumn columnFile = new TreeColumn(fileTree, SWT.LEFT); columnFile.setWidth(400); final TreeColumn columnLastModified = new TreeColumn(fileTree, SWT.LEFT); columnLastModified.setWidth(150); fileTree.addControlListener(new ControlAdapter() { @Override public void controlResized(ControlEvent e) { Rectangle area = fileTree.getClientArea(); int newFileColumnWidth = area.width - columnLastModified.getWidth() - 20; columnFile.setWidth(newFileColumnWidth); } }); } @Subscribe public void onModelSelectedDateUpdatedEvent(ModelSelectedDateUpdatedEvent event) { logger.log(Level.INFO, "Tree: Model DATE changed event received (" + event.getSelectedDate() + "); resetting tree ..."); resetAndSendRootLsRequest(); } private void resetAndSendRootLsRequest() { Display.getDefault().syncExec(new Runnable() { @Override public void run() { logger.log(Level.INFO, "Tree: Reset: Remove all tree items; and resending LsRequest ..."); sendLsRequest(""); for (String expandedPath : expandedFilePaths) { sendLsRequest(expandedPath + "/"); } } }); } private void sendLsRequest(String pathExpression) { // Date Date browseDate = (historyModel.getSelectedDate() != null) ? historyModel.getSelectedDate() : new Date(); // Create list request LsOperationOptions lsOptions = new LsOperationOptions(); lsOptions.setPathExpression(pathExpression); lsOptions.setDate(browseDate); lsOptions.setRecursive(false); lsOptions.setFetchHistories(false); lsOptions.setFileTypes(Sets.newHashSet(FileType.FILE, FileType.FOLDER, FileType.SYMLINK)); LsFolderRequest lsRequest = new LsFolderRequest(); lsRequest.setRoot(historyModel.getSelectedRoot()); lsRequest.setOptions(lsOptions); logger.log(Level.INFO, "Tree: Sending LsRequest #" + lsRequest.getId() + ", date: " + browseDate + ", root: " + historyModel.getSelectedRoot() + ", path: " + pathExpression + " ..."); // Send request pendingLsFolderRequests.put(lsRequest.getId(), lsRequest); eventBus.post(lsRequest); } private void sendLsRequestsWithChildren(String pathExpression) { logger.log(Level.INFO, "Tree: Refreshing at " + pathExpression + " ..."); // Add to expanded paths addToExpandedPathsIncludingChildPaths(pathExpression); // Find all sub-paths, a/b/c/ -> [a, a/b, a/b/c] List<String> notLoadedPaths = findUnloadedPaths(pathExpression); // If items unloaded: set 'select after load' item, and send load requests if (!notLoadedPaths.isEmpty()) { logger.log(Level.INFO, "Tree: Sending LsRequests for " + notLoadedPaths.size() + " not-yet-loaded-path(s) ..."); for (String path : notLoadedPaths) { sendLsRequest(path + "/"); } } else { selectItemByPath(pathExpression); } } private List<String> findUnloadedPaths(String pathExpression) { List<String> allPaths = getPaths(pathExpression + "/"); List<String> notLoadedPaths = new ArrayList<>(); for (String path : allPaths) { TreeItem treeItem = findItemByPath(path); boolean noTreeItem = treeItem == null; boolean treeItemWithRetrievingChild = treeItem != null && hasRetrievingChildItem(treeItem); if (noTreeItem || treeItemWithRetrievingChild) { notLoadedPaths.add(path); logger.log(Level.INFO, "- Item '" + path + "' has not been loaded."); } } return notLoadedPaths; } @Subscribe public void onLsFolderResponse(final LsFolderResponse lsResponse) { Display.getDefault().syncExec(new Runnable() { @Override public void run() { fileTree.setEnabled(true); LsFolderRequest lsRequest = pendingLsFolderRequests.remove(lsResponse.getRequestId()); if (lsRequest != null) { logger.log(Level.INFO, "Tree: Received LsResponse for request #" + lsResponse.getRequestId() + "; updating tree at path " + lsRequest.getOptions().getPathExpression() + " ..."); createTreeItems(lsRequest, lsResponse); } } }); } private void createTreeItems(LsFolderRequest lsRequest, LsFolderResponse lsResponse) { logger.log(Level.INFO, "Tree: Updating with LsResponse " + lsResponse.getResult().getFileList().size() + " versions ..."); List<FileVersion> fileVersions = lsResponse.getResult().getFileList(); // Clear entire tree if '/' request String pathExpression = lsRequest.getOptions().getPathExpression(); boolean isRootRefresh = "".equals(pathExpression); if (isRootRefresh) { fileTree.removeAll(); pathTreeItemCache.clear(); fileHistoryIdTreeItemCache.clear(); } // Find parent path (where to attach new items) TreeItem parentTreeItem = findItemByPath(pathExpression); if (parentTreeItem != null) { parentTreeItem.removeAll(); // removes 'Retrieving ...' } // Create new items createFolderItems(parentTreeItem, fileVersions); createFileItems(parentTreeItem, fileVersions); // Expand parent path if (parentTreeItem != null) { parentTreeItem.setExpanded(true); } addToExpandedPathsIncludingChildPaths(pathExpression); // Select item selectItemIfSelectedPathOrFileVersion(); } private void addToExpandedPathsIncludingChildPaths(String pathExpression) { List<String> allPaths = getPaths(pathExpression + "/"); for (String path : allPaths) { if (path.endsWith("/")) { path = path.substring(0, path.length() - 1); } if (!path.isEmpty()) { expandedFilePaths.add(path); } } logExpandedPaths(); } private void createFolderItems(TreeItem parentTreeItem, List<FileVersion> fileVersions) { for (FileVersion fileVersion : fileVersions) { if (fileVersion.getType() == FileType.FOLDER) { TreeItem treeItem = createItem(parentTreeItem); treeItem.setData(fileVersion); treeItem.setText(fileVersion.getName()); treeItem.setImage(SWTResourceManager.getImage(String.format(TREE_ICON_RESOURCE_FORMAT, "folder"))); TreeItem retrieveListTreeItem = new TreeItem(treeItem, 0); retrieveListTreeItem.setData(RETRIEVING_LIST_IDENTIFIER); retrieveListTreeItem.setText(I18n.getText("org.syncany.gui.history.HistoryDialog.retrievingList")); if (expandedFilePaths.contains(fileVersion.getPath())) { treeItem.setExpanded(true); } pathTreeItemCache.put(fileVersion.getPath(), treeItem); fileHistoryIdTreeItemCache.put(fileVersion.getFileHistoryId(), treeItem); } } } private void createFileItems(TreeItem parentTreeItem, List<FileVersion> fileVersions) { for (FileVersion fileVersion : fileVersions) { if (fileVersion.getType() != FileType.FOLDER) { TreeItem treeItem = createItem(parentTreeItem); treeItem.setData(fileVersion); treeItem.setText(new String[] { fileVersion.getName(), new PrettyTime().format(fileVersion.getLastModified())}); treeItem.setImage(SWTResourceManager.getImage(String.format(TREE_ICON_RESOURCE_FORMAT, "file"))); pathTreeItemCache.put(fileVersion.getPath(), treeItem); fileHistoryIdTreeItemCache.put(fileVersion.getFileHistoryId(), treeItem); } } } private List<String> getPaths(String pathExpression) { List<String> paths = new ArrayList<>(); int previousIndexOf = -1; while (-1 != (previousIndexOf = pathExpression.indexOf('/', previousIndexOf + 1))) { paths.add(pathExpression.substring(0, previousIndexOf)); } return paths; } private void clickItem(TreeItem treeItem) { if (treeItem != null && !isRetrievingItem(treeItem)) { logger.log(Level.INFO, "Tree: Clicked: " + treeItem); selectItem(treeItem); } } private void doubleClickItem(TreeItem treeItem) { if (treeItem != null && !isRetrievingItem(treeItem)) { FileVersion fileVersion = (FileVersion) treeItem.getData(); logger.log(Level.INFO, "Tree: Double clicking item " + fileVersion.getPath() + " ..."); if (fileVersion.getType() == FileType.FOLDER) { if (treeItem.getExpanded()) { logger.log(Level.INFO, "- Is expanded folder: Collapsing ..."); collapseTreeItem(treeItem); } else { logger.log(Level.INFO, "- Is collapsed folder: Expanding ..."); expandTreeItem(treeItem); } treeItem.setExpanded(!treeItem.getExpanded()); } else { logger.log(Level.INFO, "- Is file: Showing details ..."); historyDialog.showDetailsPanel(historyModel.getSelectedRoot(), fileVersion.getFileHistoryId()); } } } private TreeItem getSelectedItem() { TreeItem[] selectedTreeItems = fileTree.getSelection(); if (selectedTreeItems != null && selectedTreeItems.length > 0) { return selectedTreeItems[0]; } else { return null; } } private void selectItem(TreeItem treeItem) { if (!isRetrievingItem(treeItem)) { FileVersion fileVersion = (FileVersion) treeItem.getData(); logger.log(Level.INFO, "Tree: Selected item with history ID " + fileVersion.getFileHistoryId()); historyModel.setSelectedFileHistoryId(fileVersion.getFileHistoryId()); } } private void expandTreeItem(TreeItem treeItem) { FileVersion fileVersion = (FileVersion) treeItem.getData(); // Add to expanded paths addToExpandedPathsIncludingChildPaths(fileVersion.getPath()); // Send 'load' request (or not) if (hasRetrievingChildItem(treeItem)) { logger.log(Level.INFO, "Tree: Expand item; Sending LsRequest for path " + fileVersion.getPath() + " ..."); sendLsRequest(fileVersion.getPath() + "/"); } else { logger.log(Level.INFO, "Tree: Expand item; Not loading item, because no 'retrieving ..' child: " + treeItem.getText()); } } private void collapseTreeItem(TreeItem treeItem) { final FileVersion fileVersion = (FileVersion) treeItem.getData(); logger.log(Level.INFO, "Tree: Collapsing item with history ID #" + fileVersion.getFileHistoryId() + ", with text " + treeItem.getText()); // Remove from expanded paths removeFromExpandedPathsIncludingChildPaths(fileVersion.getPath()); } private void removeFromExpandedPathsIncludingChildPaths(final String path) { Iterables.removeIf(expandedFilePaths, new Predicate<String>() { @Override public boolean apply(String expandedPath) { return expandedPath.startsWith(path); } }); logExpandedPaths(); } private void selectItemIfSelectedPathOrFileVersion() { if (historyModel.getSelectedFileHistoryId() != null) { selectItemByFileHistoryId(historyModel.getSelectedFileHistoryId()); } else if (historyModel.getSelectedFilePath() != null) { selectItemByPath(historyModel.getSelectedFilePath()); } } private void selectItemByFileHistoryId(final FileHistoryId fileHistoryId) { Display.getDefault().asyncExec(new Runnable() { @Override public void run() { TreeItem treeItem = fileHistoryIdTreeItemCache.get(fileHistoryId); if (treeItem != null) { logger.log(Level.INFO, "Tree: Selecting file by file history ID #" + fileHistoryId + "; tree item " + treeItem); fileTree.setSelection(treeItem); // Note: Tree.setSelection() must be called within asyncExec, not syncExec. // It took me 2-3 hours to figure this out. Don't delete this comment! } } }); } private void selectItemByPath(final String searchPath) { Display.getDefault().asyncExec(new Runnable() { @Override public void run() { TreeItem treeItem = findItemByPath(searchPath); if (treeItem != null) { logger.log(Level.INFO, "Tree: Selecting file by path " + searchPath + "; tree item " + treeItem); fileTree.setSelection(treeItem); // Note: Tree.setSelection() must be called within asyncExec, not syncExec. // It took me 2-3 hours to figure this out. Don't delete this comment! } } }); } private TreeItem createItem(TreeItem parentItem) { if (parentItem != null) { return new TreeItem(parentItem, SWT.NONE); } else { return new TreeItem(fileTree, SWT.NONE); } } private TreeItem findItemByPath(String searchPath) { if (searchPath == null || "".equals(searchPath)) { return null; } else { if (searchPath.endsWith("/")) { searchPath = searchPath.substring(0, searchPath.length() - 1); } return pathTreeItemCache.get(searchPath); } } private boolean isRetrievingItem(TreeItem treeItem) { return RETRIEVING_LIST_IDENTIFIER.equals(treeItem.getData()); } private boolean hasRetrievingChildItem(TreeItem treeItem) { return treeItem.getItemCount() == 1 && isRetrievingItem(treeItem.getItems()[0]); } @Subscribe public void onModelSelectedFilePathUpdatedEvent(ModelSelectedFilePathUpdatedEvent event) { logger.log(Level.INFO, "Tree: Model FILE PATH changed event received; refreshing tree for " + event.getSelectedFilePath() + " ..."); sendLsRequestsWithChildren(event.getSelectedFilePath()); } @Override public void dispose() { logger.log(Level.INFO, "Tree: Disposing tree ..."); Display.getDefault().syncExec(new Runnable() { @Override public void run() { eventBus.unregister(FileTreeComposite.this); } }); } private void logExpandedPaths() { if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "Tree: Updated expanded paths; full list:"); for (String path : expandedFilePaths) { logger.log(Level.INFO, " - " + path); } } } }