diff --git a/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md index 08e2feb9ff..ee61ce0169 100644 --- a/docs/API-Reference/command/Commands.md +++ b/docs/API-Reference/command/Commands.md @@ -122,6 +122,12 @@ Closes all open files ## FILE\_CLOSE\_LIST Closes files from list +**Kind**: global variable + + +## FILE\_PIN +Pins or unpins a file in the working set + **Kind**: global variable @@ -470,6 +476,12 @@ Toggles live-preview full screen (design mode with the sidebar hidden) ## TOGGLE\_TABBAR Toggles tabbar visibility +**Kind**: global variable + + +## TOGGLE\_TABBAR\_SMOOTH\_SCROLL +Toggles whether the tabbar animates when it scrolls to the active tab + **Kind**: global variable @@ -500,6 +512,12 @@ Submenu for Open in project context menu ## OPEN\_IN\_SUBMENU\_WS Submenu for Open in working set context menu +**Kind**: global variable + + +## OPEN\_IN\_SUBMENU\_FILE\_TABS +Submenu for Open in file tab bar context menu + **Kind**: global variable diff --git a/docs/API-Reference/command/Menus.md b/docs/API-Reference/command/Menus.md index 3c4b72e83c..696e65f6d5 100644 --- a/docs/API-Reference/command/Menus.md +++ b/docs/API-Reference/command/Menus.md @@ -414,6 +414,8 @@ Brackets Context Menu Constants | PROJECT_MENU | string | "project-context-menu" | | WORKING_SET_CONTEXT_MENU | string | "workingset-context-menu" | | WORKING_SET_CONFIG_MENU | string | "workingset-configuration-menu" | +| FILE_TABS_CONTEXT_MENU | string | "tabbar-context-menu" | +| FILE_TABS_SETTINGS_MENU | string | "tabbar-settings-context-menu" | | SPLITVIEW_MENU | string | "splitview-menu" | diff --git a/docs/API-Reference/features/TaskManager.md b/docs/API-Reference/features/TaskManager.md index d07490a63a..9d13cf300d 100644 --- a/docs/API-Reference/features/TaskManager.md +++ b/docs/API-Reference/features/TaskManager.md @@ -39,6 +39,7 @@ like `showStopIcon`, `hideStopIcon`, etc. | [options.onRetryClick] | function | | Callback function triggered when the retry button is clicked. | | [options.onSelect] | function | | Callback function triggered when the task is selected from the dropdown. | | [options.progressPercent] | number | | Initial progress percentage of the task. | +| [options.hideProgressBar] | boolean | | Hide progress for tasks waiting on a user action. | | [options.noSpinnerNotification] | boolean | | If set to true, will not show the task spinners for this task. This can be used for silent background tasks where user attention is not needed. | **Example** diff --git a/docs/API-Reference/project/FileViewController.md b/docs/API-Reference/project/FileViewController.md index 2d59a49e73..c18478a2ca 100644 --- a/docs/API-Reference/project/FileViewController.md +++ b/docs/API-Reference/project/FileViewController.md @@ -66,3 +66,24 @@ Opens the specified document with its associated external editor, returns either WORKING_SET_VIEW or PROJECT_MANAGER **Kind**: global function + + +## setWorkingSetContext(file, [paneId]) +Remembers the file a working set or tab bar context menu was opened on. Pass null when the +menu closes. Fires `workingSetContextChange` with the context so the views can mark the file +while the menu is up. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| file | File | the right-clicked file, or null to forget it | +| [paneId] | string | the pane the file is shown or listed in, defaults to the active pane | + + + +## getWorkingSetContext() ⇒ Object +The file a working set or tab bar context menu is currently open for + +**Kind**: global function +**Returns**: Object - null when no such menu is up diff --git a/docs/API-Reference/project/WorkingSetView.md b/docs/API-Reference/project/WorkingSetView.md index 9b05a76404..92d5c64935 100644 --- a/docs/API-Reference/project/WorkingSetView.md +++ b/docs/API-Reference/project/WorkingSetView.md @@ -63,7 +63,14 @@ to update an existing item, all previously applied classes have been cleared. -## getContext() -Gets the filesystem object for the current context in the working set. +## getContext() ⇒ File +Gets the file a working set or tab bar context menu is open for. Right-clicking an item doesn't +select it, so this is what the menu commands act on. + +**Kind**: global function + + +## getContextPaneId() ⇒ string +Gets the id of the pane the file of `getContext()` is shown or listed in **Kind**: global function diff --git a/docs/API-Reference/utils/DragAndDrop.md b/docs/API-Reference/utils/DragAndDrop.md index 5800006e76..b1caeda9b8 100644 --- a/docs/API-Reference/utils/DragAndDrop.md +++ b/docs/API-Reference/utils/DragAndDrop.md @@ -18,7 +18,8 @@ Returns true if the drag and drop items contains valid drop objects. ## openDroppedFiles(paths) ⇒ Promise -Open dropped files +Open dropped files in order so the last file becomes active, regardless of load times. +Continue processing the remaining paths if an earlier file cannot be opened. **Kind**: global function **Returns**: Promise - Promise that is resolved if all files are opened, or rejected diff --git a/docs/API-Reference/view/MainViewManager.md b/docs/API-Reference/view/MainViewManager.md index 0d38ca3362..5a45a86f80 100644 --- a/docs/API-Reference/view/MainViewManager.md +++ b/docs/API-Reference/view/MainViewManager.md @@ -44,7 +44,8 @@ This module dispatches several events: (e, fileRemoved:File, suppressRedraw:boolean, paneId:string) - workingSetRemoveList -- When multiple files are removed from the working set (e, filesRemoved:Array."File", paneId:string) - - workingSetSort -- When a pane's view array is reordered without additions or removals. + - workingSetSort -- When a pane's view array is reordered without additions or removals. Also fired when a + file is pinned or unpinned, as pinned files are kept at the front of the working set. (e, paneId:string) - workingSetUpdate -- When changes happen due to system events such as a file being deleted. listeners should discard all working set info and rebuilt it from the pane @@ -85,6 +86,8 @@ To listen for events, do something like this: (see EventDispatcher for details o * [.addToWorkingSet(paneId, file, [index], [forceRedraw])](#module_view/MainViewManager..addToWorkingSet) * [.addListToWorkingSet(paneId, fileList)](#module_view/MainViewManager..addListToWorkingSet) * [.switchPaneFocus()](#module_view/MainViewManager..switchPaneFocus) + * [.setFilePinned(paneId, file, pinned)](#module_view/MainViewManager..setFilePinned) ⇒ boolean + * [.isPathPinned(paneId, fullPath)](#module_view/MainViewManager..isPathPinned) ⇒ boolean * [.traverseToNextViewByMRU(direction)](#module_view/MainViewManager..traverseToNextViewByMRU) ⇒ Object * [.traverseToNextViewInListOrder(direction)](#module_view/MainViewManager..traverseToNextViewInListOrder) ⇒ Object * [.beginTraversal()](#module_view/MainViewManager..beginTraversal) @@ -381,6 +384,36 @@ Adds the given file list to the end of the workingset. Switch between panes **Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) + + +### view/MainViewManager.setFilePinned(paneId, file, pinned) ⇒ boolean +Pins or unpins a file in a pane. Pinned files are kept at the front of the working set, in the order the +user arranged them, and the bulk close commands (close others, close all...) leave them alone. +Pinning a file that is only being previewed (not in the working set yet) adds it to the working set first. +Fires `workingSetSort` for the pane when the pinned state changes. + +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) +**Returns**: boolean - true if the pinned state changed + +| Param | Type | Description | +| --- | --- | --- | +| paneId | string | the id of the pane the file is in or ACTIVE_PANE | +| file | File | the file to pin or unpin | +| pinned | boolean | true to pin, false to unpin | + + + +### view/MainViewManager.isPathPinned(paneId, fullPath) ⇒ boolean +Whether the file is pinned in the given pane + +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) +**Returns**: boolean - true if the file is pinned + +| Param | Type | Description | +| --- | --- | --- | +| paneId | string | the id of the pane or ACTIVE_PANE | +| fullPath | string | the full path of the file | + ### view/MainViewManager.traverseToNextViewByMRU(direction) ⇒ Object diff --git a/docs/API-Reference/view/Pane.md b/docs/API-Reference/view/Pane.md index 2ca1cdab71..cf3b2c7d33 100644 --- a/docs/API-Reference/view/Pane.md +++ b/docs/API-Reference/view/Pane.md @@ -148,6 +148,7 @@ Hidden views can be later shown by calling `pane.showView(view)` * [.ITEM_NOT_FOUND](#module_view/Pane..Pane+ITEM_NOT_FOUND) * [.ITEM_FOUND_NO_SORT](#module_view/Pane..Pane+ITEM_FOUND_NO_SORT) * [.ITEM_FOUND_NEEDS_SORT](#module_view/Pane..Pane+ITEM_FOUND_NEEDS_SORT) + * [.getTemporaryViewFile()](#module_view/Pane..Pane+getTemporaryViewFile) ⇒ File * [.mergeFrom(other)](#module_view/Pane..Pane+mergeFrom) * [.destroy()](#module_view/Pane..Pane+destroy) * [.getViewList()](#module_view/Pane..Pane+getViewList) ⇒ Array.<File> @@ -161,6 +162,9 @@ Hidden views can be later shown by calling `pane.showView(view)` * [.makeViewMostRecent(file)](#module_view/Pane..Pane+makeViewMostRecent) * [.sortViewList(compareFn)](#module_view/Pane..Pane+sortViewList) * [.swapViewListIndexes(index1, index2)](#module_view/Pane..Pane+swapViewListIndexes) ⇒ boolean + * [.isPathPinned(fullPath)](#module_view/Pane..Pane+isPathPinned) ⇒ boolean + * [.getPinnedCount()](#module_view/Pane..Pane+getPinnedCount) ⇒ number + * [.setPathPinned(fullPath, pinned)](#module_view/Pane..Pane+setPathPinned) ⇒ boolean * [.traverseViewListByMRU(direction, [current])](#module_view/Pane..Pane+traverseViewListByMRU) ⇒ File * [.showInterstitial(show)](#module_view/Pane..Pane+showInterstitial) * [.getViewForPath(path)](#module_view/Pane..Pane+getViewForPath) ⇒ boolean @@ -198,6 +202,7 @@ Hidden views can be later shown by calling `pane.showView(view)` * [.ITEM_NOT_FOUND](#module_view/Pane..Pane+ITEM_NOT_FOUND) * [.ITEM_FOUND_NO_SORT](#module_view/Pane..Pane+ITEM_FOUND_NO_SORT) * [.ITEM_FOUND_NEEDS_SORT](#module_view/Pane..Pane+ITEM_FOUND_NEEDS_SORT) + * [.getTemporaryViewFile()](#module_view/Pane..Pane+getTemporaryViewFile) ⇒ File * [.mergeFrom(other)](#module_view/Pane..Pane+mergeFrom) * [.destroy()](#module_view/Pane..Pane+destroy) * [.getViewList()](#module_view/Pane..Pane+getViewList) ⇒ Array.<File> @@ -211,6 +216,9 @@ Hidden views can be later shown by calling `pane.showView(view)` * [.makeViewMostRecent(file)](#module_view/Pane..Pane+makeViewMostRecent) * [.sortViewList(compareFn)](#module_view/Pane..Pane+sortViewList) * [.swapViewListIndexes(index1, index2)](#module_view/Pane..Pane+swapViewListIndexes) ⇒ boolean + * [.isPathPinned(fullPath)](#module_view/Pane..Pane+isPathPinned) ⇒ boolean + * [.getPinnedCount()](#module_view/Pane..Pane+getPinnedCount) ⇒ number + * [.setPathPinned(fullPath, pinned)](#module_view/Pane..Pane+setPathPinned) ⇒ boolean * [.traverseViewListByMRU(direction, [current])](#module_view/Pane..Pane+traverseViewListByMRU) ⇒ File * [.showInterstitial(show)](#module_view/Pane..Pane+showInterstitial) * [.getViewForPath(path)](#module_view/Pane..Pane+getViewForPath) ⇒ boolean @@ -319,6 +327,14 @@ and the workingset needs to be resorted **Kind**: instance constant of [Pane](#module_view/Pane..Pane) **See**: [Pane#reorderItem](Pane#reorderItem) + + +#### pane.getTemporaryViewFile() ⇒ File +Gets the file of the current view when it isn't in the working set, a file opened from the file +tree with a single click just to have a look at it. A file that was just closed stays up until +the next one is ready but is on its way out, so it doesn't count + +**Kind**: instance method of [Pane](#module_view/Pane..Pane) #### pane.mergeFrom(other) @@ -443,7 +459,7 @@ Moves the specified file to the front of the MRU (Most Recently Used) list. #### pane.sortViewList(compareFn) -Sorts items in the pane's view list. +Sorts items in the pane's view list. Pinned files are sorted amongst themselves and stay in front. **Kind**: instance method of [Pane](#module_view/Pane..Pane) @@ -464,6 +480,38 @@ Swaps two items in the file view list (used while dragging items in the working | index1 | number | the index of the first item to swap | | index2 | number | the index of the second item to swap | + + +#### pane.isPathPinned(fullPath) ⇒ boolean +Whether the file is pinned in this pane + +**Kind**: instance method of [Pane](#module_view/Pane..Pane) + +| Param | Type | Description | +| --- | --- | --- | +| fullPath | string | the full path of the file | + + + +#### pane.getPinnedCount() ⇒ number +Returns the number of pinned files. As pinned files are always kept at the front of the view list, +this is also the index of the first unpinned file. + +**Kind**: instance method of [Pane](#module_view/Pane..Pane) + + +#### pane.setPathPinned(fullPath, pinned) ⇒ boolean +Pins or unpins a file in the view list. Pinning moves the file to the end of the pinned block and +unpinning moves it to the start of the unpinned block, so nothing else changes position. + +**Kind**: instance method of [Pane](#module_view/Pane..Pane) +**Returns**: boolean - true if the pinned state changed + +| Param | Type | Description | +| --- | --- | --- | +| fullPath | string | the full path of the file, it must be in the view list | +| pinned | boolean | true to pin, false to unpin | + #### pane.traverseViewListByMRU(direction, [current]) ⇒ File diff --git a/src/command/Commands.js b/src/command/Commands.js index 6366ef6594..314e565c2c 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -85,6 +85,9 @@ define(function (require, exports, module) { /** Closes files from list */ exports.FILE_CLOSE_LIST = "file.close_list"; // DocumentCommandHandlers.js handleFileCloseList() + /** Pins or unpins a file in the working set */ + exports.FILE_PIN = "file.pin"; // DocumentCommandHandlers.js handleFilePin() + /** Reopens last closed file */ exports.FILE_REOPEN_CLOSED = "file.reopen_closed"; // DocumentCommandHandlers.js handleReopenClosed() @@ -267,6 +270,10 @@ define(function (require, exports, module) { exports.TOGGLE_TABBAR = "view.toggleTabbar"; // extensionsIntegrated/TabBar/main.js + /** Toggles whether the tabbar animates when it scrolls to the active tab */ + exports.TOGGLE_TABBAR_SMOOTH_SCROLL = "view.toggleTabbarSmoothScroll"; + // extensionsIntegrated/TabBar/main.js + /** Zooms in the editor view */ exports.VIEW_ZOOM_IN = "view.zoomIn"; // ViewCommandHandlers.js _handleZoomIn() @@ -282,6 +289,9 @@ define(function (require, exports, module) { /** Submenu for Open in working set context menu */ exports.OPEN_IN_SUBMENU_WS = "file-open-in-submenu-ws"; + /** Submenu for Open in file tab bar context menu */ + exports.OPEN_IN_SUBMENU_FILE_TABS = "file-open-in-submenu-file-tabs"; + /** Increases editor font size */ exports.VIEW_INCREASE_FONT_SIZE = "view.increaseFontSize"; // ViewCommandHandlers.js _handleIncreaseFontSize() diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index 6361f3cb91..05c1da9345 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -32,7 +32,10 @@ define(function (require, exports, module) { Menus = require("command/Menus"), Strings = require("strings"), MainViewManager = require("view/MainViewManager"), - CommandManager = require("command/CommandManager"); + CommandManager = require("command/CommandManager"), + WorkingSetView = require("project/WorkingSetView"), + FileViewController = require("project/FileViewController"), + ProjectManager = require("project/ProjectManager"); /** * Disables menu items present in items if enabled is true. @@ -54,9 +57,10 @@ define(function (require, exports, module) { /** * Checks if file saved and present on system and * disables menu items accordingly + * @param {?FileSystemEntry} contextEntry - the entry the menu was opened on, if any */ - function _setMenuItemsVisible() { - var file = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); + function _setMenuItemsVisible(contextEntry) { + const file = contextEntry || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); if (file) { file.exists(function (err, isPresent) { if (err) { @@ -70,6 +74,23 @@ define(function (require, exports, module) { } } + /** + * The pin command toggles, so its menu label has to say what it will do to the file the menu was + * opened for: the right-clicked item of the working set or the tab bar, else the current file. + * @param {boolean} forFileTabs - true for the tab bar context menu, false for the working set one + */ + function _updatePinMenuItemName(forFileTabs) { + const paneId = WorkingSetView.getContextPaneId() || MainViewManager.ACTIVE_PANE, + file = WorkingSetView.getContext() || MainViewManager.getCurrentlyViewedFile(paneId), + pinned = file && MainViewManager.isPathPinned(paneId, file.fullPath); + + let name = pinned ? Strings.CMD_FILE_UNPIN : Strings.CMD_FILE_PIN; + if (forFileTabs) { + name = pinned ? Strings.UNPIN_TAB : Strings.PIN_TAB; + } + CommandManager.get(Commands.FILE_PIN).setName(name); + } + const isBrowser = !Phoenix.isNativeApp; const isDesktop = Phoenix.isNativeApp; const fileNewShortcut = isDesktop ? "Ctrl-N" : ""; // Ctrl-1 universal shortcut is set in keyboard.json @@ -310,34 +331,69 @@ define(function (require, exports, module) { // WorkingSet context menu - Unlike most context menus, we can't attach // listeners here because the DOM nodes for each pane's working set are // created dynamically. Each WorkingSetView attaches its own listeners. + // The editor tab bar shows the same working set, so its context menu gets exactly the same + // items. Extensions that add to one of these menus should add to the other too. var workingset_cmenu = Menus.registerContextMenu(Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU); - workingset_cmenu.addMenuItem(Commands.FILE_SAVE); - workingset_cmenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_FILE_TREE); - if(Phoenix.isNativeApp){ - let subMenu = workingset_cmenu.addSubMenu(Strings.CMD_OPEN_IN, Commands.OPEN_IN_SUBMENU_WS); - subMenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_OS); - subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_TERMINAL); - subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_INTEGRATED_TERMINAL); - if (brackets.platform === "win") { - subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_POWERSHELL); + const filetabs_cmenu = Menus.registerContextMenu(Menus.ContextMenuIds.FILE_TABS_CONTEXT_MENU); + [workingset_cmenu, filetabs_cmenu].forEach(function (cmenu) { + cmenu.addMenuItem(Commands.FILE_SAVE); + cmenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_FILE_TREE); + if(Phoenix.isNativeApp){ + const subMenuId = cmenu === workingset_cmenu + ? Commands.OPEN_IN_SUBMENU_WS + : Commands.OPEN_IN_SUBMENU_FILE_TABS; + let subMenu = cmenu.addSubMenu(Strings.CMD_OPEN_IN, subMenuId); + subMenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_OS); + subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_TERMINAL); + subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_INTEGRATED_TERMINAL); + if (brackets.platform === "win") { + subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_POWERSHELL); + } + subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_DEFAULT_APP); + } + cmenu.addMenuDivider(); + cmenu.addMenuItem(Commands.FILE_COPY); + cmenu.addMenuItem(Commands.FILE_COPY_PATH); + cmenu.addMenuItem(Commands.FILE_DUPLICATE); + cmenu.addMenuItem(Commands.FILE_DOWNLOAD, undefined, undefined, undefined, { + hideWhenCommandDisabled: true + }); + cmenu.addMenuDivider(); + cmenu.addMenuItem(Commands.FILE_RENAME); + cmenu.addMenuItem(Commands.FILE_DELETE); + cmenu.addMenuDivider(); + cmenu.addMenuItem(Commands.CMD_FIND_IN_SUBTREE); + cmenu.addMenuItem(Commands.CMD_REPLACE_IN_SUBTREE); + cmenu.addMenuDivider(); + // the CloseOthers extension adds its close commands right after FILE_CLOSE + cmenu.addMenuItem(Commands.FILE_CLOSE); + cmenu.addMenuDivider(); + cmenu.addMenuItem(Commands.FILE_PIN); + cmenu.addMenuDivider(); + cmenu.addMenuItem(Commands.FILE_REOPEN_CLOSED); + }); + workingset_cmenu.on("beforeContextMenuOpen", function () { + _updatePinMenuItemName(false); + }); + filetabs_cmenu.on("beforeContextMenuOpen", function () { + _updatePinMenuItemName(true); + }); + + // Both menus act on the right-clicked file without selecting it. That file is remembered for + // exactly as long as a menu is up. Menus get closed from all over the place (an item click, + // Escape, a click elsewhere, another menu opening) and not all of them tell the menu about it, + // so the open class on the menu itself is watched instead. Right-clicking another item closes + // the menu and reopens it in one go, which is why only the final state counts. The item + // handlers have run their command by the time the observer gets to look. + function _forgetWorkingSetContextWhenClosed() { + if (!workingset_cmenu.isOpen() && !filetabs_cmenu.isOpen()) { + FileViewController.setWorkingSetContext(null); } - subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_DEFAULT_APP); } - workingset_cmenu.addMenuDivider(); - workingset_cmenu.addMenuItem(Commands.FILE_COPY); - workingset_cmenu.addMenuItem(Commands.FILE_COPY_PATH); - workingset_cmenu.addMenuItem(Commands.FILE_DUPLICATE); - workingset_cmenu.addMenuItem(Commands.FILE_DOWNLOAD, undefined, undefined, undefined, { - hideWhenCommandDisabled: true + const menuStateObserver = new window.MutationObserver(_forgetWorkingSetContextWhenClosed); + [workingset_cmenu, filetabs_cmenu].forEach(function (cmenu) { + menuStateObserver.observe($("#" + cmenu.id)[0], { attributes: true, attributeFilter: ["class"] }); }); - workingset_cmenu.addMenuDivider(); - workingset_cmenu.addMenuItem(Commands.FILE_RENAME); - workingset_cmenu.addMenuItem(Commands.FILE_DELETE); - workingset_cmenu.addMenuDivider(); - workingset_cmenu.addMenuItem(Commands.CMD_FIND_IN_SUBTREE); - workingset_cmenu.addMenuItem(Commands.CMD_REPLACE_IN_SUBTREE); - workingset_cmenu.addMenuDivider(); - workingset_cmenu.addMenuItem(Commands.FILE_CLOSE); var splitview_menu = Menus.registerContextMenu(Menus.ContextMenuIds.SPLITVIEW_MENU); splitview_menu.addMenuItem(Commands.CMD_SPLITVIEW_NONE); @@ -484,7 +540,16 @@ define(function (require, exports, module) { }); // Check the visibility of context menu items before opening the context menu. // 'Rename', 'Show in file tree' and 'Show in explorer' items will be disabled for files that have not yet been saved to disk. - Menus.getContextMenu(Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU).on("beforeContextMenuOpen", _setMenuItemsVisible); - Menus.getContextMenu(Menus.ContextMenuIds.PROJECT_MENU).on("beforeContextMenuOpen", _setMenuItemsVisible); + // the file tree menu is opened on a tree entry, the other two on the right-clicked file. A file + // from a working set menu that this menu is replacing may still be remembered at this point + [Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU, Menus.ContextMenuIds.FILE_TABS_CONTEXT_MENU] + .forEach(function (id) { + Menus.getContextMenu(id).on("beforeContextMenuOpen", function () { + _setMenuItemsVisible(WorkingSetView.getContext()); + }); + }); + Menus.getContextMenu(Menus.ContextMenuIds.PROJECT_MENU).on("beforeContextMenuOpen", function () { + _setMenuItemsVisible(ProjectManager.getContext()); + }); }); }); diff --git a/src/command/Menus.js b/src/command/Menus.js index 5c4ed88f0a..da48439327 100644 --- a/src/command/Menus.js +++ b/src/command/Menus.js @@ -72,6 +72,8 @@ define(function (require, exports, module) { PROJECT_MENU: "project-context-menu", WORKING_SET_CONTEXT_MENU: "workingset-context-menu", WORKING_SET_CONFIG_MENU: "workingset-configuration-menu", + FILE_TABS_CONTEXT_MENU: "tabbar-context-menu", + FILE_TABS_SETTINGS_MENU: "tabbar-settings-context-menu", SPLITVIEW_MENU: "splitview-menu" }; @@ -744,7 +746,6 @@ define(function (require, exports, module) { // CommandManager.execute() to trigger beforeExecuteCommand events consistently, whether invoked via // menu click or keyboard shortcut. // because there are listeners that uses beforeExecuteCommand to listen to save commands - // (for ex: tabbar listens to save commands to add placeholder tabs to working set) // Other commands use direct execution to preserve file tree context if(commandId === Commands.FILE_SAVE || commandId === Commands.FILE_SAVE_AS || diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 0f303e98ef..fdbf7f05e4 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -1288,6 +1288,18 @@ define(function (require, exports, module) { return result.promise(); } + /** + * Saving a file that is only being looked at keeps it: it joins the working set of the pane showing it + * @param {!File} file - the file being saved + * @param {string} paneId - the pane the save is for + */ + function _keepPreviewedFile(file, paneId) { + if (MainViewManager.getCurrentlyViewedPath(paneId) === file.fullPath && + MainViewManager.findInWorkingSet(paneId, file.fullPath) === -1) { + MainViewManager.addToWorkingSet(paneId, file); + } + } + /** * Saves the given file. If no file specified, assumes the current document. * @param {?{doc: ?Document}} commandData Document to close, or null @@ -1299,11 +1311,23 @@ define(function (require, exports, module) { function handleFileSave(commandData) { const activeEditor = EditorManager.getActiveEditor(), activeDoc = activeEditor && activeEditor.document, - doc = (commandData && commandData.doc) || activeDoc; + context = FileViewController.getWorkingSetContext(), + contextDoc = context && DocumentManager.getOpenDocumentForPath(context.file.fullPath), + doc = (commandData && commandData.doc) || contextDoc || activeDoc; let settings; + + // a working set or tab bar menu was opened on a file that was never loaded, nothing to save there + if (context && !contextDoc && !(commandData && commandData.doc)) { + return $.Deferred().resolve().promise(); + } if(ChangeHelper._onBeforeSave(doc)){ return $.Deferred().reject().promise(); } + // a save asked for by the user on the file they are looking at, not one of the saves a close or + // a save all hands over as commandData + if (doc && !(commandData && commandData.doc)) { + _keepPreviewedFile(doc.file, context ? context.paneId : MainViewManager.ACTIVE_PANE); + } if (doc && !doc.isSaving) { if (doc.isUntitled()) { @@ -1414,6 +1438,12 @@ define(function (require, exports, module) { * after all operations completed if any ONE of them failed. */ function handleFileSaveAll() { + MainViewManager.getPaneIdList().forEach(function (paneId) { + const file = MainViewManager.getCurrentlyViewedFile(paneId); + if (file) { + _keepPreviewedFile(file, paneId); + } + }); return saveAll(); } @@ -1480,6 +1510,13 @@ define(function (require, exports, module) { _spawnedRequest = commandData.spawnedRequest || false; } + // from a working set or tab bar context menu the file to close is the right-clicked one + const context = FileViewController.getWorkingSetContext(); + if (!file && context) { + file = context.file; + paneId = (commandData && commandData.paneId) || context.paneId; + } + // utility function for handleFileClose: closes document & removes from workingset function doClose(file) { if (!promptOnly) { @@ -1924,13 +1961,28 @@ define(function (require, exports, module) { function handleFileRename() { // Prefer selected sidebar item (which could be a folder) var entry = ProjectManager.getContext(); + if (entry) { + ProjectManager.renameItemInline(entry); + return; + } + + // Else the file a working set or tab bar menu was opened on, else the current file + // (not selected in ProjectManager if not visible in tree or workingset) + const context = FileViewController.getWorkingSetContext(); + entry = (context && context.file) || MainViewManager.getCurrentlyViewedFile(); if (!entry) { - // Else use current file (not selected in ProjectManager if not visible in tree or workingset) - entry = MainViewManager.getCurrentlyViewedFile(); + return; } - if (entry) { + if (!ProjectManager.isWithinProject(entry.fullPath)) { ProjectManager.renameItemInline(entry); + return; } + + // the rename happens inline in the file tree, so the tree has to be on screen with the file in + // view first. This is what the working set and the tab bar context menus end up in. + revealInFileTree(entry).always(function () { + ProjectManager.renameItemInline(entry); + }); } @@ -2006,8 +2058,10 @@ define(function (require, exports, module) { * Reveal a file in the file tree the way the "Show in File Tree" command does: show the sidebar * if it is hidden, switch it to the Files tab, select the file and scroll its row into view. * @param {File} file - The file to reveal. Silently no-ops outside the project. + * @return {$.Promise} resolved once the file is selected in the tree, rejected if it never showed up */ function revealInFileTree(file) { + const result = new $.Deferred(); if (!SidebarView.isVisible()) { CommandManager.execute(Commands.VIEW_HIDE_SIDEBAR); } @@ -2015,6 +2069,7 @@ define(function (require, exports, module) { ProjectManager.showInTree(file).always(function () { if (ProjectManager.isInFileTree(file.fullPath) || !ProjectManager.isWithinProject(file.fullPath)) { _scrollFileTreeSelectionIntoView(); + result.resolve(); return; } // A file created moments ago outside the FileSystem API is not in the tree yet, so the @@ -2025,14 +2080,20 @@ define(function (require, exports, module) { let retries = 0; function selectWhenInTree() { if (ProjectManager.isInFileTree(file.fullPath)) { - ProjectManager.showInTree(file).always(_scrollFileTreeSelectionIntoView); + ProjectManager.showInTree(file).always(function () { + _scrollFileTreeSelectionIntoView(); + result.resolve(); + }); } else if (++retries < MAX_RETRIES) { setTimeout(selectWhenInTree, RETRY_MS); + } else { + result.reject(); } } ProjectManager.refreshFileTree(); setTimeout(selectWhenInTree, RETRY_MS); }); + return result.promise(); } /** @@ -2049,9 +2110,10 @@ define(function (require, exports, module) { } function handleShowInTree() { - let activeFile = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); - if(activeFile){ - revealInFileTree(activeFile); + const context = FileViewController.getWorkingSetContext(); + const file = (context && context.file) || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); + if (file) { + revealInFileTree(file); } } @@ -2114,6 +2176,29 @@ define(function (require, exports, module) { }); } + /** + * Pins or unpins a file in a pane. Without `pinned` in the command data the current state is toggled. + * @param {{file: File=, paneId: string=, pinned: boolean=}=} commandData + * file - the file to pin or unpin, defaults to the file shown in the pane + * paneId - the pane the file lives in, defaults to the active pane + * pinned - true to pin, false to unpin, leave out to toggle + */ + function handleFilePin(commandData) { + const data = commandData || {}, + context = FileViewController.getWorkingSetContext(), + paneId = data.paneId || (context && context.paneId) || MainViewManager.ACTIVE_PANE, + file = data.file || (context && context.file) || MainViewManager.getCurrentlyViewedFile(paneId); + + if (!file) { + return; + } + + const pinned = (typeof data.pinned === "boolean") + ? data.pinned + : !MainViewManager.isPathPinned(paneId, file.fullPath); + MainViewManager.setFilePinned(paneId, file, pinned); + } + /** Show the selected sidebar (tree or workingset) item in Finder/Explorer */ function handleShowInOS() { var entry = ProjectManager.getSelectedItem(); @@ -2510,6 +2595,7 @@ define(function (require, exports, module) { CommandManager.register(Strings.CMD_FILE_SAVE_AS, Commands.FILE_SAVE_AS, handleFileSaveAs, _designModeOpts); CommandManager.register(Strings.CMD_FILE_RENAME, Commands.FILE_RENAME, handleFileRename); CommandManager.register(Strings.CMD_FILE_DELETE, Commands.FILE_DELETE, handleFileDelete); + CommandManager.register(Strings.CMD_FILE_PIN, Commands.FILE_PIN, handleFilePin, _designModeOpts); // Close Commands CommandManager.register(Strings.CMD_FILE_CLOSE, Commands.FILE_CLOSE, handleFileClose, _designModeOpts); diff --git a/src/editor/EditorManager.js b/src/editor/EditorManager.js index 36e613070b..f5bf5d1de3 100644 --- a/src/editor/EditorManager.js +++ b/src/editor/EditorManager.js @@ -167,9 +167,24 @@ define(function (require, exports, module) { * @param {!jQuery.Event} e - event * @param {?File} file - current file (can be null) */ - function _handleCurrentFileChange(e, file) { + function _handleCurrentFileChange(e, file, paneId) { var doc = file && DocumentManager.getOpenDocumentForPath(file.fullPath); - _notifyActiveEditorChanged(doc && doc._masterEditor); + if (!doc) { + _notifyActiveEditorChanged(null); + return; + } + + // The same document can be open in both panes. The editor that became active is the one the + // pane is showing, not necessarily the document's master editor: that is whichever of its + // editors was focused last and may sit in the other pane. Activating that pane instead would + // dispatch this event again for the other pane's file and bounce back and forth without end. + const paneEditor = paneId && MainViewManager.getCurrentlyViewedEditor(paneId); + if (paneEditor && paneEditor.document === doc) { + doc._toggleMasterEditor(paneEditor); + _notifyActiveEditorChanged(paneEditor); + } else { + _notifyActiveEditorChanged(doc._masterEditor); + } } /** diff --git a/src/extensions/default/CloseOthers/main.js b/src/extensions/default/CloseOthers/main.js index f09490f4b8..be8f0e853d 100644 --- a/src/extensions/default/CloseOthers/main.js +++ b/src/extensions/default/CloseOthers/main.js @@ -22,22 +22,43 @@ define(function (require, exports, module) { - var Menus = brackets.getModule("command/Menus"), + const Menus = brackets.getModule("command/Menus"), CommandManager = brackets.getModule("command/CommandManager"), Commands = brackets.getModule("command/Commands"), MainViewManager = brackets.getModule("view/MainViewManager"), + WorkingSetView = brackets.getModule("project/WorkingSetView"), Strings = brackets.getModule("strings"), PreferencesManager = brackets.getModule("preferences/PreferencesManager"), - workingSetListCmenu = Menus.getContextMenu(Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU); + workingSetListCmenu = Menus.getContextMenu(Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU), + fileTabsCmenu = Menus.getContextMenu(Menus.ContextMenuIds.FILE_TABS_CONTEXT_MENU); // Constants - var closeOthers = "file.close_others", + const closeOthers = "file.close_others", closeAbove = "file.close_above", - closeBelow = "file.close_below"; + closeBelow = "file.close_below", + closeAll = "file.close_all_in_pane"; + + // the order in which the items appear in the menus, right after "Close" + const MENU_ORDER = [closeAbove, closeOthers, closeBelow, closeAll]; + + // the working set is a vertical list and the tab bar a horizontal one, so the same commands get + // different names depending on which of the two menus is open + const workingSetNames = { + [closeAbove]: Strings.CMD_FILE_CLOSE_ABOVE, + [closeOthers]: Strings.CMD_FILE_CLOSE_OTHERS, + [closeBelow]: Strings.CMD_FILE_CLOSE_BELOW, + [closeAll]: Strings.CMD_FILE_CLOSE_ALL + }; + const fileTabNames = { + [closeAbove]: Strings.CLOSE_TABS_TO_THE_LEFT, + [closeOthers]: Strings.CLOSE_OTHER_TABS, + [closeBelow]: Strings.CLOSE_TABS_TO_THE_RIGHT, + [closeAll]: Strings.CLOSE_ALL_TABS + }; // Global vars and preferences - var prefs = PreferencesManager.getExtensionPrefs("closeOthers"), - menuEntriesShown = {}; + const prefs = PreferencesManager.getExtensionPrefs("closeOthers"); + let menuEntriesShown = {}; prefs.definePreference("below", "boolean", true, { description: Strings.DESCRIPTION_CLOSE_OTHERS_BELOW @@ -51,130 +72,162 @@ define(function (require, exports, module) { /** - * Handle the different Close Other commands - * @param {string} mode + * The files of the pane's working set that the command would close for the given target file. + * Pinned files are never closed by these commands. A file that is only previewed (not in the working + * set) counts as sitting after the last working set file, which is where the tab bar shows it, and + * "Close All" closes the preview as well. + * @param {string} paneId - the pane to look in, or ACTIVE_PANE + * @param {string} mode - one of the close command ids + * @param {string} targetPath - full path of the file the command is relative to + * @return {Array.} */ - function handleClose(mode) { - var targetIndex = MainViewManager.findInWorkingSet(MainViewManager.ACTIVE_PANE, MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE)), - workingSetList = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE), - start = (mode === closeBelow) ? (targetIndex + 1) : 0, - end = (mode === closeAbove) ? (targetIndex) : (workingSetList.length), - files = [], - i; - - for (i = start; i < end; i++) { - if ((mode === closeOthers && i !== targetIndex) || (mode !== closeOthers)) { + function getFilesToClose(paneId, mode, targetPath) { + const workingSetList = MainViewManager.getWorkingSet(paneId), + files = []; + + let targetIndex = MainViewManager.findInWorkingSet(paneId, targetPath); + const isPreview = targetIndex === -1; + if (isPreview) { + targetIndex = workingSetList.length; + } + + const start = (mode === closeBelow) ? (targetIndex + 1) : 0, + end = (mode === closeAbove) ? targetIndex : workingSetList.length; + + for (let i = start; i < end; i++) { + if (mode === closeOthers && i === targetIndex) { + continue; + } + if (!MainViewManager.isPathPinned(paneId, workingSetList[i].fullPath)) { files.push(workingSetList[i]); } } - CommandManager.execute(Commands.FILE_CLOSE_LIST, {fileList: files}); + if (mode === closeAll && isPreview) { + const previewFile = MainViewManager.getCurrentlyViewedFile(paneId); + if (previewFile && previewFile.fullPath === targetPath) { + files.push(previewFile); + } + } + + return files; } /** - * Enable/Disable the menu items depending on which document is selected in the working set + * The file the close commands are relative to and the pane it is in: the item a working set or + * tab bar menu was opened on, else the current file of the active pane + * @return {{file: ?File, paneId: string}} */ - function contextMenuOpenHandler() { - var file = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); + function getTarget() { + const paneId = WorkingSetView.getContextPaneId() || MainViewManager.ACTIVE_PANE, + file = WorkingSetView.getContext() || MainViewManager.getCurrentlyViewedFile(paneId); + return { file: file, paneId: paneId }; + } - if (file) { - var targetIndex = MainViewManager.findInWorkingSet(MainViewManager.ACTIVE_PANE, file.fullPath), - workingSetListSize = MainViewManager.getWorkingSetSize(MainViewManager.ACTIVE_PANE); + /** + * Handle the different Close commands + * @param {string} mode + */ + function handleClose(mode) { + const target = getTarget(); + if (!target.file) { + return; + } - if (targetIndex === workingSetListSize - 1) { // hide "Close Others Below" if the last file in Working Files is selected - CommandManager.get(closeBelow).setEnabled(false); - } else { - CommandManager.get(closeBelow).setEnabled(true); - } + const files = getFilesToClose(target.paneId, mode, target.file.fullPath); + if (files.length) { + return CommandManager.execute(Commands.FILE_CLOSE_LIST, {fileList: files}); + } + } - if (workingSetListSize === 1) { // hide "Close Others" if there is only one file in Working Files - CommandManager.get(closeOthers).setEnabled(false); - } else { - CommandManager.get(closeOthers).setEnabled(true); - } + /** + * Names the menu items for the menu that is opening and disables the ones that have nothing to close + * @param {boolean} forFileTabs - true when the tab bar menu is opening, false for the working set menu + */ + function contextMenuOpenHandler(forFileTabs) { + const target = getTarget(), + file = target.file, + paneId = target.paneId, + names = forFileTabs ? fileTabNames : workingSetNames; + + MENU_ORDER.forEach(function (commandId) { + const command = CommandManager.get(commandId); + command.setName(names[commandId]); + command.setEnabled(Boolean(file) && getFilesToClose(paneId, commandId, file.fullPath).length > 0); + }); + } - if (targetIndex === 0) { // hide "Close Others Above" if the first file in Working Files is selected - CommandManager.get(closeAbove).setEnabled(false); - } else { - CommandManager.get(closeAbove).setEnabled(true); - } + /** + * Puts the commands back in service once both menus are closed. Disabling them only greys out the items + * of the menu that is opening; a keyboard shortcut or any other caller must still be able to run them + * afterwards. Menus get closed from many places without telling the menu, so the open class is watched. + */ + function enableCommandsWhenMenusClosed() { + if (workingSetListCmenu.isOpen() || fileTabsCmenu.isOpen()) { + return; } + MENU_ORDER.forEach(function (commandId) { + CommandManager.get(commandId).setEnabled(true); + }); } /** - * Returns the preferences used to add/remove the menu items - * @return {{closeBelow: boolean, closeOthers: boolean, closeAbove: boolean}} + * Returns which menu items should be shown, as per the preferences. "Close All" is always shown. + * @return {Object.} */ function getPreferences() { // It's senseless to look prefs up for the current file, instead look them up for // the current project (or globally) return { - closeBelow: prefs.get("below", PreferencesManager.CURRENT_PROJECT), - closeOthers: prefs.get("others", PreferencesManager.CURRENT_PROJECT), - closeAbove: prefs.get("above", PreferencesManager.CURRENT_PROJECT) + [closeBelow]: prefs.get("below", PreferencesManager.CURRENT_PROJECT), + [closeOthers]: prefs.get("others", PreferencesManager.CURRENT_PROJECT), + [closeAbove]: prefs.get("above", PreferencesManager.CURRENT_PROJECT), + [closeAll]: true }; } /** - * When the preferences changed, add/remove the required menu items + * Adds the enabled menu items to both context menus in a fixed order right after "Close" and + * removes the ones that were switched off in the preferences */ - function prefChangeHandler() { - var prefs = getPreferences(); - - if (prefs.closeBelow !== menuEntriesShown.closeBelow) { - if (prefs.closeBelow) { - workingSetListCmenu.addMenuItem(closeBelow, "", Menus.AFTER, Commands.FILE_CLOSE); - } else { - workingSetListCmenu.removeMenuItem(closeBelow); - } - } - - if (prefs.closeOthers !== menuEntriesShown.closeOthers) { - if (prefs.closeOthers) { - workingSetListCmenu.addMenuItem(closeOthers, "", Menus.AFTER, Commands.FILE_CLOSE); - } else { - workingSetListCmenu.removeMenuItem(closeOthers); - } - } - - if (prefs.closeAbove !== menuEntriesShown.closeAbove) { - if (prefs.closeAbove) { - workingSetListCmenu.addMenuItem(closeAbove, "", Menus.AFTER, Commands.FILE_CLOSE); - } else { - workingSetListCmenu.removeMenuItem(closeAbove); - } - } - - menuEntriesShown = prefs; + function syncMenuItems() { + const show = getPreferences(); + + [workingSetListCmenu, fileTabsCmenu].forEach(function (cmenu) { + let previous = Commands.FILE_CLOSE; + MENU_ORDER.forEach(function (commandId) { + if (menuEntriesShown[commandId]) { + cmenu.removeMenuItem(commandId); + } + if (show[commandId]) { + cmenu.addMenuItem(commandId, "", Menus.AFTER, previous); + previous = commandId; + } + }); + }); + + menuEntriesShown = show; } /** * Register the Commands and add the Menu Items, if required */ function initializeCommands() { - var prefs = getPreferences(); - CommandManager.register(Strings.CMD_FILE_CLOSE_BELOW, closeBelow, function () { - handleClose(closeBelow); + return handleClose(closeBelow); }, { supportsDesignMode: true }); CommandManager.register(Strings.CMD_FILE_CLOSE_OTHERS, closeOthers, function () { - handleClose(closeOthers); + return handleClose(closeOthers); }, { supportsDesignMode: true }); CommandManager.register(Strings.CMD_FILE_CLOSE_ABOVE, closeAbove, function () { - handleClose(closeAbove); + return handleClose(closeAbove); + }, { supportsDesignMode: true }); + CommandManager.register(Strings.CMD_FILE_CLOSE_ALL, closeAll, function () { + return handleClose(closeAll); }, { supportsDesignMode: true }); - if (prefs.closeBelow) { - workingSetListCmenu.addMenuItem(closeBelow, "", Menus.AFTER, Commands.FILE_CLOSE); - } - if (prefs.closeOthers) { - workingSetListCmenu.addMenuItem(closeOthers, "", Menus.AFTER, Commands.FILE_CLOSE); - } - if (prefs.closeAbove) { - workingSetListCmenu.addMenuItem(closeAbove, "", Menus.AFTER, Commands.FILE_CLOSE); - } - menuEntriesShown = prefs; + syncMenuItems(); } @@ -182,7 +235,17 @@ define(function (require, exports, module) { initializeCommands(); // Add a context menu open handler - workingSetListCmenu.on("beforeContextMenuOpen", contextMenuOpenHandler); + workingSetListCmenu.on("beforeContextMenuOpen", function () { + contextMenuOpenHandler(false); + }); + fileTabsCmenu.on("beforeContextMenuOpen", function () { + contextMenuOpenHandler(true); + }); + + const menuStateObserver = new window.MutationObserver(enableCommandsWhenMenusClosed); + [workingSetListCmenu, fileTabsCmenu].forEach(function (cmenu) { + menuStateObserver.observe($("#" + cmenu.id)[0], { attributes: true, attributeFilter: ["class"] }); + }); - prefs.on("change", prefChangeHandler); + prefs.on("change", syncMenuItems); }); diff --git a/src/extensions/default/CloseOthers/unittests.js b/src/extensions/default/CloseOthers/unittests.js index cd095d7528..36f4d4ffeb 100644 --- a/src/extensions/default/CloseOthers/unittests.js +++ b/src/extensions/default/CloseOthers/unittests.js @@ -151,5 +151,45 @@ define(function (require, exports, module) { expect(MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE).length).toEqual(2); }); + + it("Close all in pane", async function () { + const ws = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + DocumentManager.getDocumentForPath(ws[2].fullPath).done(function (doc) { + MainViewManager._edit(MainViewManager.ACTIVE_PANE, doc); + }); + + await awaitsForDone(CommandManager.execute("file.close_all_in_pane"), "file.close_all_in_pane"); + + expect(MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE).length).toEqual(0); + }); + + it("Close others keeps pinned files open", async function () { + const ws = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + MainViewManager.setFilePinned(MainViewManager.ACTIVE_PANE, ws[0], true); + + docSelectIndex = 2; + cmdToRun = "file.close_others"; + + await runCloseOthers(); + + const remaining = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + expect(remaining.length).toEqual(2); + expect(remaining[0].fullPath).toEqual(ws[0].fullPath); + expect(MainViewManager.isPathPinned(MainViewManager.ACTIVE_PANE, ws[0].fullPath)).toBe(true); + }); + + it("Close all in pane keeps pinned files open", async function () { + const ws = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + MainViewManager.setFilePinned(MainViewManager.ACTIVE_PANE, ws[3], true); + DocumentManager.getDocumentForPath(ws[1].fullPath).done(function (doc) { + MainViewManager._edit(MainViewManager.ACTIVE_PANE, doc); + }); + + await awaitsForDone(CommandManager.execute("file.close_all_in_pane"), "file.close_all_in_pane"); + + const remaining = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + expect(remaining.length).toEqual(1); + expect(remaining[0].fullPath).toEqual(ws[3].fullPath); + }); }); }); diff --git a/src/extensionsIntegrated/TabBar/drag-drop.js b/src/extensionsIntegrated/TabBar/drag-drop.js index 53bcb50062..632328483d 100644 --- a/src/extensionsIntegrated/TabBar/drag-drop.js +++ b/src/extensionsIntegrated/TabBar/drag-drop.js @@ -24,523 +24,340 @@ define(function (require, exports, module) { const MainViewManager = require("view/MainViewManager"); const CommandManager = require("command/CommandManager"); const Commands = require("command/Commands"); + const FileSystem = require("filesystem/FileSystem"); /** * These variables track the drag and drop state of tabs * draggedTab: The tab that is currently being dragged - * dragIndicator: Visual indicator showing where the tab will be dropped - * dragSourcePane: To track which pane the dragged tab originated from + * sourcePaneId: To track which pane the dragged tab originated from + * targetTab: the tab the drop indicator is currently shown next to + * $indicator: Visual indicator showing where the tab will be dropped */ let draggedTab = null; - let dragIndicator = null; - let dragSourcePane = null; + let sourcePaneId = null; + let targetTab = null; + let $indicator = null; /** - * this function is responsible to make sure that all the drag state is properly cleaned up - * it is needed to make sure that the tab bar doesn't get unresponsive - * because of handlers not being attached properly + * the pane a tab bar element belongs to + * @param {HTMLElement} element + * @return {?string} the pane id */ - function cleanupDragState() { - $(".tab").removeClass("dragging drag-target"); - $(".empty-pane-drop-target").removeClass("empty-pane-drop-target"); - - // this is to make sure that the drag indicator is hidden and remove any inline styles - if (dragIndicator) { - dragIndicator.hide().css({ - top: "", - left: "", - height: "" - }); - } - - // Reset all drag state variables - draggedTab = null; - dragSourcePane = null; - - // remove any temporary elements - $("#tab-drag-helper").remove(); + function _paneIdOf(element) { + const container = element.closest(".tab-bar-container"); + return container ? container.getAttribute("data-pane-id") : null; } /** * create the drag indicator if it doesn't exist */ - function ensureDragIndicator() { - if (!dragIndicator || !dragIndicator.length) { - dragIndicator = $('
'); - $("body").append(dragIndicator); + function _getIndicator() { + if (!$indicator) { + $indicator = $('
').appendTo("body"); } + return $indicator; } /** - * this function is to update the drag indicator position - * @param {Element} targetTab - The target tab element - * @param {boolean} insertBefore - Whether to insert before the target tab + * highlights the tab the dragged tab would be dropped next to and puts the indicator line there + * @param {HTMLElement} tab - The target tab element + * @param {boolean} before - Whether the dragged tab would be inserted before the target tab */ - function updateDragIndicator(targetTab, insertBefore) { - if (!targetTab) { - dragIndicator.hide(); - return; + function _markDropTarget(tab, before) { + if (targetTab && targetTab !== tab) { + targetTab.classList.remove("drag-target"); } + targetTab = tab; + tab.classList.add("drag-target"); + + const rect = tab.getBoundingClientRect(); + _getIndicator().css({ + left: (before ? rect.left : rect.right) + "px", + top: rect.top + "px", + height: rect.height + "px" + }).show(); + } - const rect = targetTab.getBoundingClientRect(); - const x = insertBefore ? rect.left : rect.right; - - dragIndicator - .css({ - position: "fixed", - left: x + "px", - top: rect.top + "px", - height: rect.height + "px", - width: "2px", - zIndex: 10001 - }) - .show(); + function _clearDropTarget() { + if (targetTab) { + targetTab.classList.remove("drag-target"); + targetTab = null; + } + if ($indicator) { + $indicator.hide(); + } + } + + /** + * this function is responsible to make sure that all the drag state is properly cleaned up + */ + function _cleanup() { + if (draggedTab) { + draggedTab.classList.remove("dragging"); + } + $(".pane-content.pane-drop-target, .pane-content.empty-pane-drop-target") + .removeClass("pane-drop-target empty-pane-drop-target"); + _clearDropTarget(); + draggedTab = null; + sourcePaneId = null; } /** * get the drop position relative to a target tab - * @param {Element} targetTab - The target tab element - * @param {number} mouseX - The mouse X coordinate + * @param {HTMLElement} tab - The target tab element + * @param {number} clientX - The mouse X coordinate * @returns {boolean} True if should insert before, false if after */ - function getDropPosition(targetTab, mouseX) { - const rect = targetTab.getBoundingClientRect(); - const midpoint = rect.left + rect.width / 2; - return mouseX < midpoint; + function _dropsBefore(tab, clientX) { + const rect = tab.getBoundingClientRect(); + return clientX < rect.left + rect.width / 2; } /** - * find the closest tab to mouse position - * @param {jQuery} container - The tab container element - * @param {number} mouseX - The mouse X coordinate - * @returns {Element|null} The closest tab element or null + * find the closest tab to the mouse position, for drops on the empty part of the bar + * @param {HTMLElement} bar - The tab bar element + * @param {number} clientX - The mouse X coordinate + * @returns {?HTMLElement} The closest tab element or null */ - function findClosestTab(container, mouseX) { - const tabs = container.find(".tab").get(); - let closestTab = null; + function _closestTab(bar, clientX) { + let closest = null; let closestDistance = Infinity; - for (let tab of tabs) { + for (const tab of bar.children) { if (tab === draggedTab) { continue; } - const rect = tab.getBoundingClientRect(); - const tabCenter = rect.left + rect.width / 2; - const distance = Math.abs(mouseX - tabCenter); - + const distance = Math.abs(clientX - (rect.left + rect.width / 2)); if (distance < closestDistance) { closestDistance = distance; - closestTab = tab; + closest = tab; } } - return closestTab; - } - - /** - * this function handles the drag start - * @param {Event} event - the event instance - */ - function handleDragStart(event) { - draggedTab = this; - dragSourcePane = $(this).closest("#phoenix-tab-bar-2").length > 0 ? "second-pane" : "first-pane"; - - // Set up drag data - event.originalEvent.dataTransfer.effectAllowed = "move"; - event.originalEvent.dataTransfer.setData("application/x-phoenix-tab", "tab-drag"); - - // Add visual styling - $(this).addClass("dragging"); - - ensureDragIndicator(); - - // Small delay to let the dragging class take effect - setTimeout(() => { - dragIndicator.hide(); - }, 10); + return closest; } /** - * this function handles the drag over - * @param {Event} event - the event instance + * where in the pane's working set the dragged file should land when dropped on the given tab + * a placeholder tab has no working set entry and sits at the end, so dropping on it means the end + * + * @param {string} paneId - the pane of the target tab + * @param {HTMLElement} tab - the target tab + * @param {boolean} before - whether to insert before the target tab + * @return {number} */ - function handleDragOver(event) { - if (!draggedTab) { - return; - } - - event.preventDefault(); - event.originalEvent.dataTransfer.dropEffect = "move"; - - const targetTab = event.currentTarget; - if (targetTab === draggedTab) { - return; + function _dropIndex(paneId, tab, before) { + const index = MainViewManager.findInWorkingSet(paneId, tab.getAttribute("data-path")); + if (index === -1) { + return MainViewManager.getWorkingSetSize(paneId); } - - const insertBefore = getDropPosition(targetTab, event.originalEvent.clientX); - updateDragIndicator(targetTab, insertBefore); - - // Clear any existing drag-target classes - $(".tab").removeClass("drag-target"); - $(targetTab).addClass("drag-target"); - } - - /** - * handles the container drag over (for empty space drops) - * @param {Event} event - */ - function handleContainerDragOver(event) { - if (!draggedTab) { - return; - } - - event.preventDefault(); - - const container = $(this); - const mouseX = event.originalEvent.clientX; - const closestTab = findClosestTab(container, mouseX); - - if (closestTab) { - const insertBefore = getDropPosition(closestTab, mouseX); - updateDragIndicator(closestTab, insertBefore); - - // Clear existing classes and add to closest - $(".tab").removeClass("drag-target"); - $(closestTab).addClass("drag-target"); - } else { - dragIndicator.hide(); - } - } - - /** - * this handles the drop - * @param {Event} event - */ - function handleDrop(event) { - event.preventDefault(); - event.stopPropagation(); - - if (!draggedTab || this === draggedTab) { - cleanupDragState(); - return; - } - - const targetTab = this; - const targetPaneId = $(targetTab).closest("#phoenix-tab-bar-2").length > 0 ? "second-pane" : "first-pane"; - const insertBefore = getDropPosition(targetTab, event.originalEvent.clientX); - - performTabMove(targetTab, targetPaneId, insertBefore); - cleanupDragState(); + return before ? index : index + 1; } /** - * this function handles the drop on container (empty space) - * @param {Event} event + * this function is responsible to move the tab within the pane + * moving in front of a pinned tab pins the tab and moving behind an unpinned one unpins it, + * MainViewManager takes care of that + * + * @param {string} paneId - The pane ID + * @param {string} draggedPath - The path of the dragged file + * @param {number} toIndex - the working set index to move to, before the move */ - function handleContainerDrop(event) { - event.preventDefault(); + function _moveWithinPane(paneId, draggedPath, toIndex) { + const fromIndex = MainViewManager.findInWorkingSet(paneId, draggedPath); - if (!draggedTab) { - cleanupDragState(); + // a dragged placeholder tab becomes a working set file right where it was dropped + if (fromIndex === -1) { + MainViewManager.addToWorkingSet(paneId, FileSystem.getFileForPath(draggedPath), toIndex); return; } - const container = $(this); - const mouseX = event.originalEvent.clientX; - const closestTab = findClosestTab(container, mouseX); - - if (closestTab) { - const targetPaneId = container.attr("id") === "phoenix-tab-bar-2" ? "second-pane" : "first-pane"; - const insertBefore = getDropPosition(closestTab, mouseX); - performTabMove(closestTab, targetPaneId, insertBefore); - } - - cleanupDragState(); + _reorder(paneId, fromIndex, toIndex); } /** - * this function performs the actual tab move - * it can be of two types: tab move in same pane, tab move between different panes - * @param {Element} targetTab - The target tab element - * @param {string} targetPaneId - The target pane ID - * @param {boolean} insertBefore - Whether to insert before the target - */ - function performTabMove(targetTab, targetPaneId, insertBefore) { - const draggedPath = $(draggedTab).attr("data-path"); - const targetPath = $(targetTab).attr("data-path"); - - if (dragSourcePane === targetPaneId) { - // same pane move - moveWorkingSetItem(targetPaneId, draggedPath, targetPath, insertBefore); - } else { - // different pane move - moveTabBetweenPanes(dragSourcePane, targetPaneId, draggedPath, targetPath, insertBefore); - } - } - - /** - * this function is responsible to move the tab within the pane + * moves a working set item to the drop index, which still counts the item at its old place + * * @param {string} paneId - The pane ID - * @param {string} draggedPath - The path of the dragged file - * @param {string} targetPath - The path of the target file - * @param {boolean} beforeTarget - Whether to insert before the target + * @param {number} fromIndex - the working set index of the item + * @param {number} toIndex - the working set index to move to, before the move */ - function moveWorkingSetItem(paneId, draggedPath, targetPath, beforeTarget) { - const workingSet = MainViewManager.getWorkingSet(paneId); - let draggedIndex = -1; - let targetIndex = -1; - - // Find the indices of both the dragged item and the target item - for (let i = 0; i < workingSet.length; i++) { - if (workingSet[i].fullPath === draggedPath) { - draggedIndex = i; - } - if (workingSet[i].fullPath === targetPath) { - targetIndex = i; - } + function _reorder(paneId, fromIndex, toIndex) { + if (fromIndex < toIndex) { + toIndex--; } - - if (draggedIndex !== -1 && targetIndex !== -1) { - let newPosition = beforeTarget ? targetIndex : targetIndex + 1; - if (draggedIndex < newPosition) { - newPosition--; - } - MainViewManager._moveWorkingSetItem(paneId, draggedIndex, newPosition); + if (toIndex !== fromIndex) { + MainViewManager._moveWorkingSetItem(paneId, fromIndex, toIndex); } } /** * this function is responsible to move the tab in between different panes - * @param {string} sourcePaneId - The source pane ID + * the view moves along with the file, so an unsaved file doesn't get a save prompt + * * @param {string} targetPaneId - The target pane ID * @param {string} draggedPath - The path of the dragged file - * @param {string} targetPath - The path of the target file - * @param {boolean} beforeTarget - Whether to insert before the target + * @param {number} toIndex - the working set index in the target pane */ - function moveTabBetweenPanes(sourcePaneId, targetPaneId, draggedPath, targetPath, beforeTarget) { - try { - const sourceWorkingSet = MainViewManager.getWorkingSet(sourcePaneId); - const targetWorkingSet = MainViewManager.getWorkingSet(targetPaneId); - - let draggedFile = null; - let targetIndex = -1; - - // Find the dragged file and its index in the source pane - for (let i = 0; i < sourceWorkingSet.length; i++) { - if (sourceWorkingSet[i].fullPath === draggedPath) { - draggedFile = sourceWorkingSet[i]; - break; - } - } - - // Find the target index in the target pane - for (let i = 0; i < targetWorkingSet.length; i++) { - if (targetWorkingSet[i].fullPath === targetPath) { - targetIndex = i; - break; - } - } - - if (draggedFile) { - // Close in source pane - CommandManager.execute(Commands.FILE_CLOSE, { file: draggedFile, paneId: sourcePaneId }); - - // calculate where to insert it in the target pane - const insertIndex = - targetIndex !== -1 ? (beforeTarget ? targetIndex : targetIndex + 1) : targetWorkingSet.length; - - // Add to target pane - MainViewManager.addToWorkingSet(targetPaneId, draggedFile, insertIndex); - CommandManager.execute(Commands.FILE_OPEN, { fullPath: draggedPath, paneId: targetPaneId }); + function _moveToPane(targetPaneId, draggedPath, toIndex) { + const fromPaneId = sourcePaneId; + const file = FileSystem.getFileForPath(draggedPath); + const open = () => CommandManager.execute(Commands.FILE_OPEN, { fullPath: draggedPath, paneId: targetPaneId }); + const inSourceWorkingSet = MainViewManager.findInWorkingSet(fromPaneId, draggedPath) !== -1; + const targetIndex = MainViewManager.findInWorkingSet(targetPaneId, draggedPath); + const targetHasIt = targetIndex !== -1 || MainViewManager._getPane(targetPaneId).getViewForPath(draggedPath); + + // there is no view to carry over when the dragged tab is only a preview or when the target pane + // already has the file. The file becomes a working set entry of the target pane where it was + // dropped and leaves the source pane, so it never ends up showing in both panes at once + if (!inSourceWorkingSet || targetHasIt) { + if (targetIndex === -1) { + MainViewManager.addToWorkingSet(targetPaneId, file, toIndex); + } else { + _reorder(targetPaneId, targetIndex, toIndex); } - } catch (error) { - console.error("Error during cross-pane tab move:", error); + open().always(() => CommandManager.execute(Commands.FILE_CLOSE, { file: file, paneId: fromPaneId })); + return; } - } - /** - * this function handles the drag end event - * @param {Event} event - the event instance - */ - function handleDragEnd(event) { - // Small delay to ensure other events finish - setTimeout(() => { - cleanupDragState(); - }, 50); + MainViewManager._moveView(fromPaneId, targetPaneId, file, toIndex).always(open); } /** - * this function handles drag leave - * @param {Event} event - the event instance + * this handles the drop on a tab + * @param {HTMLElement} tab - the tab that was dropped on + * @param {boolean} before - Whether to insert before the target tab */ - function handleDragLeave(event) { - // Only remove styling if truly leaving the element - const relatedTarget = event.originalEvent.relatedTarget; - if (!$(this).is(relatedTarget) && !$(this).has(relatedTarget).length) { - $(this).removeClass("drag-target"); + function _dropOnTab(tab, before) { + const targetPaneId = _paneIdOf(tab); + const draggedPath = draggedTab.getAttribute("data-path"); + const toIndex = _dropIndex(targetPaneId, tab, before); + + if (targetPaneId === sourcePaneId) { + _moveWithinPane(targetPaneId, draggedPath, toIndex); + } else { + _moveToPane(targetPaneId, draggedPath, toIndex); } } /** - * Setup drag handlers for a tab bar - * @param {string} tabBarSelector - The jQuery selector for the tab bar + * Initialize drag and drop functionality. All handlers are delegated from the document, so they + * are set up once and keep working as tabs come and go */ - function setupTabBarDragHandlers(tabBarSelector) { - const $tabBar = $(tabBarSelector); + function init() { + const TAB = ".phoenix-tab-bar .tab"; - // Remove existing handlers to prevent duplicates - $tabBar.off("dragstart dragover dragenter dragleave drop dragend"); + $(document) + .on("dragstart", TAB, function (event) { + draggedTab = this; + sourcePaneId = _paneIdOf(this); - // add the handlers - $tabBar.on("dragstart", ".tab", handleDragStart); - $tabBar.on("dragover", ".tab", handleDragOver); - $tabBar.on("dragleave", ".tab", handleDragLeave); - $tabBar.on("drop", ".tab", handleDrop); - $tabBar.on("dragend", ".tab", handleDragEnd); + const dataTransfer = event.originalEvent.dataTransfer; + dataTransfer.effectAllowed = "move"; + dataTransfer.setData("application/x-phoenix-tab", this.getAttribute("data-path")); - // container level handlers (for empty space) - $tabBar.on("dragover", handleContainerDragOver); - $tabBar.on("drop", handleContainerDrop); + this.classList.add("dragging"); + }) + .on("dragover", TAB, function (event) { + if (!draggedTab) { + return; + } + event.preventDefault(); + event.originalEvent.dataTransfer.dropEffect = "move"; - // Make tabs draggable - $tabBar.find(".tab").attr("draggable", true); - } + if (this === draggedTab) { + _clearDropTarget(); + return; + } + _markDropTarget(this, _dropsBefore(this, event.originalEvent.clientX)); + }) + .on("dragover", ".phoenix-tab-bar", function (event) { + // drags over a tab are handled above, this is for the empty space after the last tab + if (!draggedTab || $(event.target).closest(".tab").length) { + return; + } + event.preventDefault(); - /** - * setup the pane drop targets - */ - function setupPaneDropTargets() { - const panes = ["#first-pane .pane-content", "#second-pane .pane-content"]; - - panes.forEach((paneSelector, index) => { - const paneId = index === 0 ? "first-pane" : "second-pane"; - const $pane = $(paneSelector); - - $pane.off("dragover dragenter dragleave drop"); - - $pane.on("dragover dragenter", function (event) { - if (draggedTab && dragSourcePane !== paneId) { - // Only allow cross-pane drops - event.preventDefault(); - - // Check if this pane is empty for visual styling - const tabBarId = paneId === "first-pane" ? "#phoenix-tab-bar" : "#phoenix-tab-bar-2"; - const $tabBar = $(tabBarId); - const isEmptyPane = - !$tabBar.length || $tabBar.is(":hidden") || $tabBar.children(".tab").length === 0; - - if (isEmptyPane) { - $(this).addClass("empty-pane-drop-target"); - } else { - // Add a different class for non-empty panes - $(this).addClass("pane-drop-target"); - } + const tab = _closestTab(this, event.originalEvent.clientX); + if (tab) { + _markDropTarget(tab, _dropsBefore(tab, event.originalEvent.clientX)); + } else { + _clearDropTarget(); } - }); + }) + .on("dragleave", TAB, function (event) { + // Only remove styling if truly leaving the element + const related = event.originalEvent && event.originalEvent.relatedTarget; + if (this === targetTab && !this.contains(related)) { + _clearDropTarget(); + } + }) + .on("drop", TAB, function (event) { + if (!draggedTab) { + return; + } + event.preventDefault(); + event.stopPropagation(); - $pane.on("dragleave", function (event) { - $(this).removeClass("empty-pane-drop-target pane-drop-target"); - }); + if (this !== draggedTab) { + _dropOnTab(this, _dropsBefore(this, event.originalEvent.clientX)); + } + _cleanup(); + }) + .on("drop", ".phoenix-tab-bar", function (event) { + if (!draggedTab) { + return; + } + event.preventDefault(); - $pane.on("drop", function (event) { - if (draggedTab && dragSourcePane !== paneId) { - event.preventDefault(); - $(this).removeClass("empty-pane-drop-target pane-drop-target"); - - const draggedPath = $(draggedTab).attr("data-path"); - const sourceWorkingSet = MainViewManager.getWorkingSet(dragSourcePane); - const targetWorkingSet = MainViewManager.getWorkingSet(paneId); - let draggedFile = null; - - // Find dragged file - for (let file of sourceWorkingSet) { - if (file.fullPath === draggedPath) { - draggedFile = file; - break; - } - } - - if (draggedFile) { - // Close in source pane - CommandManager.execute(Commands.FILE_CLOSE, { file: draggedFile, paneId: dragSourcePane }); - - // Check if target pane is empty or has files - const tabBarId = paneId === "first-pane" ? "#phoenix-tab-bar" : "#phoenix-tab-bar-2"; - const $tabBar = $(tabBarId); - const isEmptyPane = - !$tabBar.length || $tabBar.is(":hidden") || $tabBar.children(".tab").length === 0; - - if (isEmptyPane) { - // Empty pane: just add the file - MainViewManager.addToWorkingSet(paneId, draggedFile); - } else { - // Non-empty pane: add after the currently active file - const currentActiveFile = MainViewManager.getCurrentlyViewedFile(paneId); - if (currentActiveFile) { - // Find index of current active file and insert after it - let targetIndex = -1; - for (let i = 0; i < targetWorkingSet.length; i++) { - if (targetWorkingSet[i].fullPath === currentActiveFile.fullPath) { - targetIndex = i; - break; - } - } - MainViewManager.addToWorkingSet(paneId, draggedFile, targetIndex + 1); - } else { - // Fallback: add to end - MainViewManager.addToWorkingSet(paneId, draggedFile); - } - } - - // Open file in target pane - CommandManager.execute(Commands.FILE_OPEN, { fullPath: draggedPath, paneId: paneId }); - } - - cleanupDragState(); + const tab = $(event.target).closest(".tab")[0] || _closestTab(this, event.originalEvent.clientX); + if (tab && tab !== draggedTab) { + _dropOnTab(tab, _dropsBefore(tab, event.originalEvent.clientX)); } - }); - }); - } + _cleanup(); + }) + .on("dragend", TAB, _cleanup); + + // a tab dropped on the other pane's editor area goes to the end of that pane's tabs, + // that's also the only way to drop into a pane that has no tabs yet + $(document) + .on("dragover dragenter", ".view-pane .pane-content", function (event) { + const paneId = this.closest(".view-pane").id; + if (!draggedTab || paneId === sourcePaneId) { + return; + } + event.preventDefault(); - /** - * clean up the handlers - */ - function setupGlobalCleanup() { - // Clean up on document level events - $(document).on("dragend mouseup", function () { - if (draggedTab) { - setTimeout(() => cleanupDragState(), 100); - } - }); + const hasTabs = MainViewManager.getWorkingSetSize(paneId) > 0; + this.classList.toggle("empty-pane-drop-target", !hasTabs); + this.classList.toggle("pane-drop-target", hasTabs); + }) + .on("dragleave", ".view-pane .pane-content", function (event) { + const related = event.originalEvent && event.originalEvent.relatedTarget; + if (!this.contains(related)) { + this.classList.remove("empty-pane-drop-target", "pane-drop-target"); + } + }) + .on("drop", ".view-pane .pane-content", function (event) { + const paneId = this.closest(".view-pane").id; + if (!draggedTab || paneId === sourcePaneId) { + return; + } + event.preventDefault(); + + _moveToPane(paneId, draggedTab.getAttribute("data-path"), MainViewManager.getWorkingSetSize(paneId)); + _cleanup(); + }); - // Clean up on escape key + // escape cancels the drag $(document).on("keydown", function (event) { if (event.key === "Escape" && draggedTab) { - cleanupDragState(); + _cleanup(); } }); } - /** - * Initialize drag and drop functionality - * @param {string} firstPaneSelector - The selector for the first pane tab bar - * @param {string} secondPaneSelector - The selector for the second pane tab bar - */ - function init(firstPaneSelector, secondPaneSelector) { - // setup drag handlers for both tab bars - if (firstPaneSelector) { - setupTabBarDragHandlers(firstPaneSelector); - } - if (secondPaneSelector) { - setupTabBarDragHandlers(secondPaneSelector); - } - - setupPaneDropTargets(); - setupGlobalCleanup(); - ensureDragIndicator(); - } - module.exports = { init }; diff --git a/src/extensionsIntegrated/TabBar/global.js b/src/extensionsIntegrated/TabBar/global.js deleted file mode 100644 index 0b0a93fbb6..0000000000 --- a/src/extensionsIntegrated/TabBar/global.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License - * for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. - * - */ - -/* - * This file houses the global working set array - */ -define(function (require, exports, module) { - /** - * This array's represents the current working set - * It holds all the working set items that are to be displayed in the tab bar - * Properties of each object: - * path: {String} full path of the file - * name: {String} name of the file - * isFile: {Boolean} whether the file is a file or a directory - * isDirty: {Boolean} whether the file is dirty - * isPinned: {Boolean} whether the file is pinned - * displayName: {String} name to display in the tab (may include directory info for duplicate files) - */ - let firstPaneWorkingSet = []; - let secondPaneWorkingSet = []; - - module.exports = { - firstPaneWorkingSet, - secondPaneWorkingSet - }; -}); diff --git a/src/extensionsIntegrated/TabBar/helper.js b/src/extensionsIntegrated/TabBar/helper.js deleted file mode 100644 index a60d8f76d3..0000000000 --- a/src/extensionsIntegrated/TabBar/helper.js +++ /dev/null @@ -1,166 +0,0 @@ -/* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License - * for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. - * - */ - -/* This file contains helper functions for the tab bar */ -define(function (require, exports, module) { - - const WorkspaceManager = require("view/WorkspaceManager"); - const DocumentManager = require("document/DocumentManager"); - const ViewUtils = require("utils/ViewUtils"); - const WorkingSetView = require("project/WorkingSetView"); - const FileUtils = require("file/FileUtils"); - - - /** - * Shows the tab bar, when it is hidden. - * Its only shown when tab bar is enabled and there is atleast one working file - * - * @param {$.Element} $tabBar - The tab bar element - * @param {$.Element} $overflowButton - The overflow button element - */ - function _showTabBar($tabBar, $overflowButton) { - if ($tabBar) { - $tabBar.show(); - if($overflowButton) { - $overflowButton.show(); - } - // when we add/remove something from the editor, the editor shifts up/down which leads to blank space - // so we need to recompute the layout to make sure things are in the right place - WorkspaceManager.recomputeLayout(true); - } - } - - /** - * Hides the tab bar. - * Its hidden when tab bar feature is disabled or there are no working files - * both the tab bar and the overflow button are to be hidden to hide the tab bar container - * - * @param {$.Element} $tabBar - The tab bar element - * @param {$.Element} $overflowButton - The overflow button element - */ - function _hideTabBar($tabBar, $overflowButton) { - if ($tabBar) { - $tabBar.hide(); - if($overflowButton) { - $overflowButton.hide(); - } - WorkspaceManager.recomputeLayout(true); - } - } - - - /** - * Entry is a single object that comes from MainViewManager's getWorkingSet - * We extract the required data from the entry - * - * @param {Object} entry - A single file entry from MainViewManager.getWorkingSet() - * @returns {Object} - the required data - */ - function _getRequiredDataFromEntry(entry) { - return { - path: entry.fullPath, - name: entry.name, - isFile: entry.isFile, - isDirty: entry.isDirty, - isPinned: entry.isPinned, - displayName: entry.name // Initialize displayName with name, it will be updated if duplicates are found - }; - } - - /** - * checks whether a file is dirty or not - * - * @param {File} file - the file to check - * @return {boolean} true if the file is dirty, false otherwise - */ - function _isFileModified(file) { - const doc = DocumentManager.getOpenDocumentForPath(file.fullPath); - return doc && doc.isDirty; - } - - - /** - * Returns a jQuery object containing the file icon for a given file - * - * @param {Object} fileData - The file data object - * @returns {jQuery} jQuery object containing the file icon - */ - function _getFileIcon(fileData) { - const $link = $("").html( - ViewUtils.getFileEntryDisplay({ name: fileData.name }) - ); - WorkingSetView.useIconProviders({ - fullPath: fileData.path, - name: fileData.name, - isFile: true - }, $link); - return $link.children().first(); - } - - - /** - * Checks for duplicate file names in the working set and updates displayName accordingly - * if duplicate file names are found, we update the displayName to include the directory - * - * @param {Array} workingSet - The working set to check for duplicates - */ - function _handleDuplicateFileNames(workingSet) { - // Create a map to track filename occurrences - const fileNameCount = {}; - - // First, count occurrences of each filename - workingSet.forEach(entry => { - if (!fileNameCount[entry.name]) { - fileNameCount[entry.name] = 1; - } else { - fileNameCount[entry.name]++; - } - }); - - // Then, update the displayName for files with duplicate names - workingSet.forEach(entry => { - if (fileNameCount[entry.name] > 1) { - // Get the parent directory name - const path = entry.path; - const parentDir = FileUtils.getDirectoryPath(path); - - // Get just the directory name, not the full path - const dirName = parentDir.split("/"); - // Get the parent directory name (second-to-last part of the path) - const parentDirName = dirName[dirName.length - 2] || ""; - - // Set the display name to include the parent directory - entry.displayName = parentDirName + "/" + entry.name; - } else { - entry.displayName = entry.name; - } - }); - } - - - module.exports = { - _showTabBar, - _hideTabBar, - _getRequiredDataFromEntry, - _isFileModified, - _getFileIcon, - _handleDuplicateFileNames - }; -}); diff --git a/src/extensionsIntegrated/TabBar/html/tabbar-pane.html b/src/extensionsIntegrated/TabBar/html/tabbar-pane.html deleted file mode 100644 index 2f9a0e0a74..0000000000 --- a/src/extensionsIntegrated/TabBar/html/tabbar-pane.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
- -
- -
- -
-
diff --git a/src/extensionsIntegrated/TabBar/html/tabbar-second-pane.html b/src/extensionsIntegrated/TabBar/html/tabbar-second-pane.html deleted file mode 100644 index 795598e47e..0000000000 --- a/src/extensionsIntegrated/TabBar/html/tabbar-second-pane.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
- -
- -
- -
-
diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index db1476e066..7407e09fb6 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -25,34 +25,39 @@ define(function (require, exports, module) { const MainViewManager = require("view/MainViewManager"); const FileSystem = require("filesystem/FileSystem"); const PreferencesManager = require("preferences/PreferencesManager"); - const FileUtils = require("file/FileUtils"); const CommandManager = require("command/CommandManager"); const Commands = require("command/Commands"); const KeyBindingManager = require("command/KeyBindingManager"); const DocumentManager = require("document/DocumentManager"); const EditorManager = require("editor/EditorManager"); const WorkspaceManager = require("view/WorkspaceManager"); + const WorkingSetView = require("project/WorkingSetView"); + const FileViewController = require("project/FileViewController"); + const ViewUtils = require("utils/ViewUtils"); const Menus = require("command/Menus"); const Strings = require("strings"); - const Global = require("./global"); - const Helper = require("./helper"); const Preference = require("./preference"); - const MoreOptions = require("./more-options"); const Overflow = require("./overflow"); const DragDrop = require("./drag-drop"); - const TabBarHTML = require("text!./html/tabbar-pane.html"); - const TabBarHTML2 = require("text!./html/tabbar-second-pane.html"); /** - * This holds the tab bar element - * For tab bar structure, refer to `./html/tabbar-pane.html` and `./html/tabbar-second-pane.html` - * $tabBar is for the first pane and $tabBar2 is for the second pane - * - * @type {$.Element} + * One TabBarView per open pane, keyed by the pane id ('first-pane' or 'second-pane') + * @type {Object} */ - let $tabBar = null; - let $tabBar2 = null; + const _views = {}; + + // panes with a pending update. updates come in bursts (opening a file adds it to the working set, + // changes the current file and maybe the active pane) so they are collected and done once per tick + const _pendingPanes = new Set(); + let _updateTimer = null; + + // the git status of a file shows up as a class on its tab and in the tooltip + const GIT_STATUS = { + ignored: { className: "git-ignored", label: Strings.FILE_IGNORED }, + untracked: { className: "git-new", label: Strings.FILE_UNTRACKED }, + modified: { className: "git-modified", label: Strings.FILE_MODIFIED } + }; /** * this function checks if the TabBar is currently enabled or not (no. of tabs is 0 means tab bar is disabled) @@ -63,756 +68,768 @@ define(function (require, exports, module) { } /** - * This function is responsible to take all the files from the working set and gets the working sets ready - * This is placed here instead of helper.js because it modifies the working sets + * checks whether a file is dirty or not + * @param {string} path - the full path of the file + * @return {boolean} */ - function getAllFilesFromWorkingSet() { - if (!isTabBarActive()) { - return; - } + function _isDirty(path) { + const doc = DocumentManager.getOpenDocumentForPath(path); + return Boolean(doc && doc.isDirty); + } - Global.firstPaneWorkingSet = []; - Global.secondPaneWorkingSet = []; + /** + * the git status of the file, if the git extension is around and knows about it + * ignored is checked first as it takes precedence over the other two + * @param {string} path - the full path of the file + * @return {?{className: string, label: string}} + */ + function _getGitStatus(path) { + const git = window.phoenixGitEvents && window.phoenixGitEvents.TabBarIntegration; + if (!git) { + return null; + } + if (git.isIgnored && git.isIgnored(path)) { + return GIT_STATUS.ignored; + } + if (git.isUntracked(path)) { + return GIT_STATUS.untracked; + } + if (git.isModified(path)) { + return GIT_STATUS.modified; + } + return null; + } - // this gives the list of panes. When both panes are open, it will be ['first-pane', 'second-pane'] - const paneList = MainViewManager.getPaneIdList(); + /** + * the working set files that get a tab, honouring the numberOfTabs preference (-1 means all of them) + * the active file always gets a tab even when it is past the limit, it then takes the spot of the last one + * + * @param {Array} workingSet - the pane's working set + * @param {?string} currentPath - the full path of the file shown in the pane + * @return {Array<{file: File, placeholder: boolean}>} + */ + function _getVisibleEntries(workingSet, currentPath) { + const limit = Preference.tabBarNumberOfTabs; + let files = workingSet; + + if (limit > 0 && workingSet.length > limit) { + const activeIndex = workingSet.findIndex((file) => file.fullPath === currentPath); + files = activeIndex >= limit + ? workingSet.slice(0, limit - 1).concat(workingSet[activeIndex]) + : workingSet.slice(0, limit); + } - // to make sure atleast one pane is open - if (paneList && paneList.length > 0) { - // this gives the working set of the first pane - const currFirstPaneWorkingSet = MainViewManager.getWorkingSet(paneList[0]); + return files.map((file) => ({ file: file, placeholder: false })); + } - for (let i = 0; i < currFirstPaneWorkingSet.length; i++) { - // MainViewManager.getWorkingSet gives the working set of the first pane, - // but it has lot of details we don't need. Hence we use Helper._getRequiredDataFromEntry - Global.firstPaneWorkingSet.push(Helper._getRequiredDataFromEntry(currFirstPaneWorkingSet[i])); + /** + * when two tabs in the same pane have the same file name, both get the directory name in front + * of the file name, same as what the working set does + * + * @param {Array<{file: File}>} entries - the tabs of the pane + * @return {Map} full path -> directory label, only for the duplicates + */ + function _getDuplicateDirLabels(entries) { + const filesByName = new Map(); + entries.forEach(({ file }) => { + const list = filesByName.get(file.name); + if (list) { + list.push(file); + } else { + filesByName.set(file.name, [file]); } - // if there are duplicate file names, we update the displayName to include the directory - Helper._handleDuplicateFileNames(Global.firstPaneWorkingSet); - - // check if second pane is open - if (paneList.length > 1) { - const currSecondPaneWorkingSet = MainViewManager.getWorkingSet(paneList[1]); + }); - for (let i = 0; i < currSecondPaneWorkingSet.length; i++) { - Global.secondPaneWorkingSet.push(Helper._getRequiredDataFromEntry(currSecondPaneWorkingSet[i])); - } - Helper._handleDuplicateFileNames(Global.secondPaneWorkingSet); + const labels = new Map(); + filesByName.forEach((files) => { + if (files.length < 2) { + return; } - - // Update dirty status for files in the first pane working set - Global.firstPaneWorkingSet.forEach(function (entry) { - const doc = DocumentManager.getOpenDocumentForPath(entry.path); - if (doc) { - entry.isDirty = doc.isDirty; - } + ViewUtils.getDirNamesForDuplicateFiles(files).forEach((dirName, i) => { + const parts = dirName.split("/"); + labels.set(files[i].fullPath, parts.length > 3 + ? `${parts[0]}/…/${parts[parts.length - 1]}` + : dirName); }); + }); - // Update dirty status for files in the second pane working set - Global.secondPaneWorkingSet.forEach(function (entry) { - const doc = DocumentManager.getOpenDocumentForPath(entry.path); - if (doc) { - entry.isDirty = doc.isDirty; - } - }); - } + return labels; } /** * Responsible for creating the tab element - * Note: this creates a tab (for a single file) not the tab bar + * Note: this creates a tab (for a single file) not the tab bar. The file icon is added here as well, + * everything else that can change (name, active, dirty...) is filled in by _updateTab * - * @param {Object} entry - the working set entry - * @param {String} paneId - the pane id 'first-pane' or 'second-pane' - * @returns {$.Element} the tab element + * @param {File} file - the file the tab is for + * @returns {HTMLElement} the tab element */ - function createTab(entry, paneId) { - if (!$tabBar || !paneId) { - return; - } + function _createTab(file) { + const tab = document.createElement("div"); + tab.className = "tab"; + tab.setAttribute("data-path", file.fullPath); + tab.setAttribute("draggable", "true"); + tab.innerHTML = '
' + + '
'; + + WorkingSetView.useIconProviders({ fullPath: file.fullPath, name: file.name, isFile: true }, $(tab.firstChild)); + return tab; + } - // get the current active file for this specific pane - const activeFileInPane = MainViewManager.getCurrentlyViewedFile(paneId); - const activePathInPane = activeFileInPane ? activeFileInPane.fullPath : null; - - // Check if this file is active in its pane - const isActive = entry.path === activePathInPane; - - // Current active pane (used to determine whether to add the blue underline) - const currentActivePane = MainViewManager.getActivePaneId(); - const isPaneActive = paneId === currentActivePane; - - const isDirty = Helper._isFileModified(FileSystem.getFileForPath(entry.path)); - const isPlaceholder = entry.isPlaceholder === true; - - let gitStatus = ""; // this will be shown in the tooltip when a tab is hovered - let gitStatusClass = ""; // for styling - - if (window.phoenixGitEvents && window.phoenixGitEvents.TabBarIntegration) { - const TabBarIntegration = window.phoenixGitEvents.TabBarIntegration; - - // find the Git status - // Check ignored FIRST (takes precedence over other statuses) - // if ignored we add the git-ignored class - // if untracked we add the git-new class - // if modified we add the git-modified class - if (TabBarIntegration.isIgnored(entry.path)) { - gitStatus = "Ignored"; - gitStatusClass = "git-ignored"; - } else if (TabBarIntegration.isUntracked(entry.path)) { - gitStatus = "Untracked"; - gitStatusClass = "git-new"; - } else if (TabBarIntegration.isModified(entry.path)) { - gitStatus = "Modified"; - gitStatusClass = "git-modified"; + /** + * brings a tab in line with the file it represents. Only the things that actually changed touch the DOM + * + * @param {HTMLElement} tab - the tab element + * @param {{file: File, placeholder: boolean}} entry + * @param {{active: boolean, pinned: boolean, dirLabel: ?string}} state + */ + function _updateTab(tab, entry, state) { + const file = entry.file; + const classes = tab.classList; + + classes.toggle("active", state.active); + classes.toggle("placeholder", entry.placeholder); + classes.toggle("pinned", state.pinned); + classes.toggle("dirty", _isDirty(file.fullPath)); + _applyGitStatus(tab, file.fullPath); + + // the label is remembered on the element so the name is only re-rendered when it changes + const label = state.dirLabel ? `${state.dirLabel}/${file.name}` : file.name; + if (tab.dataset.label !== label) { + tab.dataset.label = label; + const $name = $(tab.children[1]); + if (state.dirLabel) { + $name.html(`${_.escape(state.dirLabel)}/${_.escape(file.name)}`); + } else { + $name.text(file.name); } } - // create tab with all the appropriate classes - const $tab = $( - `
-
-
-
-
` - ); - - // Add the file icon - const $icon = Helper._getFileIcon(entry); - $tab.find(".tab-icon").append($icon); - - // Check if we have a directory part in the displayName - const $tabName = $tab.find(".tab-name"); - if (entry.displayName && entry.displayName !== entry.name) { - // Split the displayName into directory and filename parts - const parts = entry.displayName.split("/"); - const dirName = parts[0]; - const fileName = parts[1]; - - // create the HTML for different styling for directory and filename - $tabName.html(`${dirName}/${fileName}`); - } else { - // Just the filename - $tabName.text(entry.name); + // a pinned tab can't be closed from the tab itself, the close button makes way for the pin + const control = tab.children[2]; + if (control.classList.contains("tab-pin") !== state.pinned) { + control.className = state.pinned ? "tab-pin" : "tab-close"; + control.innerHTML = state.pinned + ? '' + : ''; + control.removeAttribute("title"); } - - // only add the underline class if this is both active AND in the active pane - if (isActive && !isPaneActive) { - // if it's active but in a non-active pane, we add a special class - // to style differently in CSS to indicate that it's active but not in the active pane - $tab.addClass("active-in-inactive-pane"); - } - - // if this is a placeholder tab in inactive pane, we need to use the brown styling - // instead of the blue one for active tabs - if (isPlaceholder && isActive && !isPaneActive) { - $tab.removeClass("active"); - $tab.addClass("active-in-inactive-pane"); - } - - return $tab; } /** - * Creates the tab bar and adds it to the DOM + * sets the git status class of the tab and the tooltip (file path with the git status in brackets) + * + * @param {HTMLElement} tab - the tab element + * @param {string} path - the full path of the file */ - function createTabBar() { - if (!isTabBarActive()) { - cleanupTabBar(); - return; - } - - // clean up any existing tab bars first and start fresh - cleanupTabBar(); - - const $paneHeader = $(".pane-header"); - if ($paneHeader.length === 1) { - $tabBar = $(TabBarHTML); - // since we need to add the tab bar before the editor which has .not-editor class - $(".pane-header").after($tabBar); - $("#overflow-button").attr("title", Strings.TABBAR_SHOW_HIDDEN_TABS); - WorkspaceManager.recomputeLayout(true); - updateTabs(); - } else if ($paneHeader.length === 2) { - $tabBar = $(TabBarHTML); - $tabBar2 = $(TabBarHTML2); - - // eq(0) is for the first pane and eq(1) is for the second pane - // TODO: Fix bug where the tab bar gets hidden inside the editor in horizontal split - $paneHeader.eq(0).after($tabBar); - $paneHeader.eq(1).after($tabBar2); - $("#overflow-button").attr("title", Strings.TABBAR_SHOW_HIDDEN_TABS); - $("#overflow-button-2").attr("title", Strings.TABBAR_SHOW_HIDDEN_TABS); - WorkspaceManager.recomputeLayout(true); - updateTabs(); + function _applyGitStatus(tab, path) { + const status = _getGitStatus(path); + const className = status ? status.className : ""; + + if (tab.dataset.git !== className) { + tab.dataset.git = className; + tab.classList.remove("git-ignored", "git-new", "git-modified"); + if (className) { + tab.classList.add(className); + } + tab.title = Phoenix.app.getDisplayPath(path) + (status ? ` (${status.label})` : ""); } } /** - * This function updates the tabs in the tab bar - * It is called when the working set changes. So instead of creating a new tab bar, we just update the existing one + * The tab bar of a single pane. It keeps a map from file path to tab element, so a change + * only touches the tabs that changed. The bar is never thrown away and rebuilt. + * + * @param {string} paneId - 'first-pane' or 'second-pane' */ - function updateTabs() { - if (!isTabBarActive()) { - return; - } + function TabBarView(paneId) { + this.paneId = paneId; + this.tabs = new Map(); + this.activePath = null; + this.visible = false; + + // the element ids are what the rest of the app knows the tab bars by + const suffix = paneId === MainViewManager.FIRST_PANE ? "" : "-2"; + this.$container = $( + `
+
+ +
` + ).hide(); + this.bar = this.$container.children(".phoenix-tab-bar")[0]; + this.$overflowButton = this.$container.children(".overflow-button") + .attr("title", Strings.TABBAR_SHOW_HIDDEN_TABS); + + // the tab bar sits between the pane header and the editor + $(`#${paneId} .pane-header`).after(this.$container); + + // a resize (sidebar, live preview panel, window...) only changes whether all the tabs still fit. + // The check waits for the next frame because showing or hiding the overflow button changes the + // width of the bar itself, and doing that inside the observer callback makes the browser report + // an undelivered resize loop + this.overflowCheckPending = false; + this.resizeObserver = new ResizeObserver(() => { + if (this.overflowCheckPending) { + return; + } + this.overflowCheckPending = true; + window.requestAnimationFrame(() => { + this.overflowCheckPending = false; + this.updateOverflowButton(); + }); + }); + this.resizeObserver.observe(this.bar); - // Get all files from the working set. refer to `global.js` - getAllFilesFromWorkingSet(); + // MainViewManager only announces current file changes for the active pane, so the pane is asked + // directly as well. Otherwise a file that opens in the other pane (like the next file after its + // current tab is closed) would sit there without an active tab + this.pane = MainViewManager._getPane(paneId); + this.pane.on("currentViewChange.tabbar", () => _scheduleUpdate(paneId)); + } - // just to make sure that the number of tabs is not set to 0 - if (Preference.tabBarNumberOfTabs === 0) { - cleanupTabBar(); - return; + /** + * removes the tab bar from the pane + */ + TabBarView.prototype.destroy = function () { + this.pane.off(".tabbar"); + this.resizeObserver.disconnect(); + this.$container.remove(); + this.tabs.clear(); + if (this.visible) { + WorkspaceManager.recomputeLayout(); } + }; - // Check for active files not in working set in any pane - const activePane = MainViewManager.getActivePaneId(); - const firstPaneFile = MainViewManager.getCurrentlyViewedFile("first-pane"); - const secondPaneFile = MainViewManager.getCurrentlyViewedFile("second-pane"); + /** + * This function updates the tabs in the tab bar so that they match the pane's working set + * existing tabs are reused and moved around, only new files get a new tab and only files + * that left the working set lose theirs + */ + TabBarView.prototype.update = function () { + const paneId = this.paneId; + const workingSet = MainViewManager.getWorkingSet(paneId); + const currentPath = MainViewManager.getCurrentlyViewedPath(paneId); + const entries = _getVisibleEntries(workingSet, currentPath); // when a file is opened from the filetree and is not present in the working set, then it is a placeholder - let firstPanePlaceholder = null; - let secondPanePlaceholder = null; - - // Check if active file in first pane is not in the working set - if (firstPaneFile && !Global.firstPaneWorkingSet.some((entry) => entry.path === firstPaneFile.fullPath)) { - firstPanePlaceholder = { - path: firstPaneFile.fullPath, - name: firstPaneFile.name, - isPlaceholder: true, - displayName: firstPaneFile.name // for now we initialize with name, will check for duplicates later - }; + // and gets a tab at the end + const placeholderFile = this.pane.getTemporaryViewFile(); + if (placeholderFile) { + entries.push({ file: placeholderFile, placeholder: true }); } - // Check if active file in second pane is not in the working set - if (secondPaneFile && !Global.secondPaneWorkingSet.some((entry) => entry.path === secondPaneFile.fullPath)) { - secondPanePlaceholder = { - path: secondPaneFile.fullPath, - name: secondPaneFile.name, - isPlaceholder: true, - displayName: secondPaneFile.name - }; + // if no files are present in the pane and no placeholder, we hide the tab bar for that pane + // the layout is only recomputed when this actually changes as the editor height depends on it + const show = entries.length > 0; + if (show !== this.visible) { + this.visible = show; + this.$container.toggle(show); + WorkspaceManager.recomputeLayout(); } - // check for duplicate file names between placeholder tabs and working set entries - if (firstPanePlaceholder) { - // if any working set file has the same name as the placeholder - const hasDuplicate = Global.firstPaneWorkingSet.some((entry) => entry.name === firstPanePlaceholder.name); - - if (hasDuplicate) { - // extract directory name from path - const path = firstPanePlaceholder.path; - const parentDir = FileUtils.getDirectoryPath(path); - const dirParts = parentDir.split("/"); - const parentDirName = dirParts[dirParts.length - 2] || ""; + const keep = new Set(entries.map((entry) => entry.file.fullPath)); + this.tabs.forEach((tab, path) => { + if (!keep.has(path)) { + tab.remove(); + this.tabs.delete(path); + } + }); - // Update displayName with directory - firstPanePlaceholder.displayName = parentDirName + "/" + firstPanePlaceholder.name; + const dirLabels = _getDuplicateDirLabels(entries); + const bar = this.bar; + entries.forEach((entry, index) => { + const path = entry.file.fullPath; + let tab = this.tabs.get(path); + if (!tab) { + tab = _createTab(entry.file); + this.tabs.set(path, tab); } - } - if (secondPanePlaceholder) { - const hasDuplicate = Global.secondPaneWorkingSet.some((entry) => entry.name === secondPanePlaceholder.name); + _updateTab(tab, entry, { + active: path === currentPath, + pinned: !entry.placeholder && MainViewManager.isPathPinned(paneId, path), + dirLabel: dirLabels.get(path) + }); - if (hasDuplicate) { - const path = secondPanePlaceholder.path; - const parentDir = FileUtils.getDirectoryPath(path); - const dirParts = parentDir.split("/"); - const parentDirName = dirParts[dirParts.length - 2] || ""; + // the tabs before this one are already in order, so this one just has to land at its index + if (bar.children[index] !== tab) { + bar.insertBefore(tab, bar.children[index] || null); + } + }); - secondPanePlaceholder.displayName = parentDirName + "/" + secondPanePlaceholder.name; + // the active tab in the pane that doesn't have the focus is styled differently + this.$container.toggleClass("inactive-pane", MainViewManager.getActivePaneId() !== paneId); + this.updateOverflowButton(); + + // we scroll only in the active pane + // this is because, lets say we have a same file in both the panes + // then when the file is opened in one of the pane and is towards the end of the tab bar, + // then we need to show the scrolling animation only on that pane and not on both the panes + if (currentPath !== this.activePath) { + this.activePath = currentPath; + if (MainViewManager.getActivePaneId() === paneId) { + this.scrollActiveTabIntoView(); } } + }; - // Create tab bar if there's a placeholder or a file in the working set - if ( - (Global.firstPaneWorkingSet.length > 0 || firstPanePlaceholder) && - (!$("#phoenix-tab-bar").length || $("#phoenix-tab-bar").is(":hidden")) - ) { - createTabBar(); - } + /** + * the overflow button is shown when the tabs don't fit in the bar + * the button itself takes room from the bar once it is showing, so the tabs are measured against + * the full width. Otherwise a bar that would fit fine without the button would keep it forever + */ + TabBarView.prototype.updateOverflowButton = function () { + const bar = this.bar; + const buttonWidth = this.$overflowButton.hasClass("hidden") ? 0 : this.$overflowButton[0].offsetWidth; + // the couple of pixels of slack match what the overflow menu treats as hidden, so the button + // never shows up with an empty menu behind it + this.$overflowButton.toggleClass("hidden", bar.scrollWidth <= bar.clientWidth + buttonWidth + 2); + }; - if ( - (Global.secondPaneWorkingSet.length > 0 || secondPanePlaceholder) && - (!$("#phoenix-tab-bar-2").length || $("#phoenix-tab-bar-2").is(":hidden")) - ) { - createTabBar(); + /** + * Scrolls the tab bar to the active tab, if it isn't fully visible already + */ + TabBarView.prototype.scrollActiveTabIntoView = function () { + const tab = this.activePath && this.tabs.get(this.activePath); + if (!tab || !this.visible) { + return; } - const $firstTabBar = $("#phoenix-tab-bar"); - // Update first pane's tabs - if ($firstTabBar.length) { - $firstTabBar.empty(); - if (Global.firstPaneWorkingSet.length > 0 || firstPanePlaceholder) { - // get the count of tabs that we want to display in the tab bar (from preference settings) - // from preference settings or working set whichever smaller - let tabsCountP1 = Math.min(Global.firstPaneWorkingSet.length, Preference.tabBarNumberOfTabs); - - // the value is generally '-1', but we check for less than 0 so that it can handle edge cases gracefully - // if the value is negative then we display all tabs - if (Preference.tabBarNumberOfTabs < 0) { - tabsCountP1 = Global.firstPaneWorkingSet.length; - } + const bar = this.bar; + const barRect = bar.getBoundingClientRect(); + const tabRect = tab.getBoundingClientRect(); + let scrollLeft = bar.scrollLeft; - let displayedEntries = []; - - // check if active file is in the working set but would be excluded by tab count - if (firstPaneFile && Preference.tabBarNumberOfTabs > 0) { - const activeFileIndex = Global.firstPaneWorkingSet.findIndex( - (entry) => entry.path === firstPaneFile.fullPath - ); - - if (activeFileIndex >= 0 && activeFileIndex >= Preference.tabBarNumberOfTabs) { - // active file is in working set but would be excluded by tab count - // Show active file and one less from the top N files - displayedEntries = [ - ...Global.firstPaneWorkingSet.slice(0, Preference.tabBarNumberOfTabs - 1), - Global.firstPaneWorkingSet[activeFileIndex] - ]; - } else { - // Active file is either not in working set or already included in top N files - displayedEntries = Global.firstPaneWorkingSet.slice(0, tabsCountP1); - } - } else { - displayedEntries = Global.firstPaneWorkingSet.slice(0, tabsCountP1); - } - - // Add working set tabs - displayedEntries.forEach(function (entry) { - $firstTabBar.append(createTab(entry, "first-pane")); - }); - - // Add placeholder tab if needed - if (firstPanePlaceholder) { - $firstTabBar.append(createTab(firstPanePlaceholder, "first-pane")); - } - } + if (tabRect.left < barRect.left) { + scrollLeft += tabRect.left - barRect.left - 10; + } else if (tabRect.right > barRect.right) { + scrollLeft += tabRect.right - barRect.right + 10; + } else { + return; } - const $secondTabBar = $("#phoenix-tab-bar-2"); - // Update second pane's tabs - if ($secondTabBar.length) { - $secondTabBar.empty(); - if (Global.secondPaneWorkingSet.length > 0 || secondPanePlaceholder) { - let tabsCountP2 = Math.min(Global.secondPaneWorkingSet.length, Preference.tabBarNumberOfTabs); - if (Preference.tabBarNumberOfTabs < 0) { - tabsCountP2 = Global.secondPaneWorkingSet.length; - } + bar.scrollTo({ left: scrollLeft, behavior: Preference.tabBarSmoothScroll ? "smooth" : "auto" }); + }; - let displayedEntries2 = []; - - if (secondPaneFile && Preference.tabBarNumberOfTabs > 0) { - const activeFileIndex = Global.secondPaneWorkingSet.findIndex( - (entry) => entry.path === secondPaneFile.fullPath - ); - - if (activeFileIndex >= 0 && activeFileIndex >= Preference.tabBarNumberOfTabs) { - displayedEntries2 = [ - ...Global.secondPaneWorkingSet.slice(0, Preference.tabBarNumberOfTabs - 1), - Global.secondPaneWorkingSet[activeFileIndex] - ]; - } else { - displayedEntries2 = Global.secondPaneWorkingSet.slice(0, tabsCountP2); - } - } else { - displayedEntries2 = Global.secondPaneWorkingSet.slice(0, tabsCountP2); - } + /** + * toggles the dirty dot on the tab of the file + * + * @param {string} path - the full path of the file + * @param {boolean} isDirty + */ + TabBarView.prototype.setDirty = function (path, isDirty) { + const tab = this.tabs.get(path); + if (tab) { + tab.classList.toggle("dirty", isDirty); + } + }; - // Add working set tabs - displayedEntries2.forEach(function (entry) { - $secondTabBar.append(createTab(entry, "second-pane")); - }); + /** + * re-reads the git status of every tab + */ + TabBarView.prototype.refreshGitStatus = function () { + this.tabs.forEach((tab, path) => _applyGitStatus(tab, path)); + }; - // Add placeholder tab if needed - if (secondPanePlaceholder) { - $secondTabBar.append(createTab(secondPanePlaceholder, "second-pane")); - } - } + /** + * marks the tab a context menu is open for, so it is clear what the menu acts on while the + * active tab stays where it was + * @param {?string} path - the full path of the file, or null to clear the mark + */ + TabBarView.prototype.setContextPath = function (path) { + const previous = this.bar.querySelector(".tab.context"); + if (previous) { + previous.classList.remove("context"); } - - // if no files are present in a pane and no placeholder, we want to hide the tab bar for that pane - if (Global.firstPaneWorkingSet.length === 0 && !firstPanePlaceholder && $("#phoenix-tab-bar")) { - Helper._hideTabBar($("#phoenix-tab-bar"), $("#overflow-button")); + const tab = path && this.tabs.get(path); + if (tab) { + tab.classList.add("context"); } + }; - if (Global.secondPaneWorkingSet.length === 0 && !secondPanePlaceholder && $("#phoenix-tab-bar-2")) { - Helper._hideTabBar($("#phoenix-tab-bar-2"), $("#overflow-button-2")); - } + /** + * draws the file icons again with the icon providers registered by now + */ + TabBarView.prototype.refreshIcons = function () { + this.tabs.forEach((tab, path) => { + const file = FileSystem.getFileForPath(path); + const $icon = $(tab.firstChild).empty(); + WorkingSetView.useIconProviders({ fullPath: path, name: file.name, isFile: true }, $icon); + }); + }; - // Now that tabs are updated, scroll to the active tab if necessary. - if ($firstTabBar.length) { - Overflow.toggleOverflowVisibility("first-pane"); - - // we scroll only in the active pane - // this is because, lets say we have a same file in both the panes - // then when the file is opened in one of the pane and is towards the end of the tab bar, - // then we need to show the scrolling animation only on that pane and not on both the panes - if (activePane === "first-pane") { - setTimeout(function () { - Overflow.scrollToActiveTab($firstTabBar); - }, 0); - } - } + function _forEachView(callback) { + Object.keys(_views).forEach((paneId) => callback(_views[paneId])); + } - if ($secondTabBar.length) { - Overflow.toggleOverflowVisibility("second-pane"); - if (activePane === "second-pane") { - setTimeout(function () { - Overflow.scrollToActiveTab($secondTabBar); - }, 0); - } + /** + * marks the pane's tab bar for an update in the next tick + * @param {string} paneId + */ + function _scheduleUpdate(paneId) { + if (!paneId || !_views[paneId]) { + return; + } + _pendingPanes.add(paneId); + if (_updateTimer === null) { + _updateTimer = window.setTimeout(_flushUpdates, 0); } + } - // handle drag and drop - DragDrop.init($("#phoenix-tab-bar"), $("#phoenix-tab-bar-2")); + function _flushUpdates() { + _updateTimer = null; + _pendingPanes.forEach((paneId) => { + if (_views[paneId]) { + _views[paneId].update(); + } + }); + _pendingPanes.clear(); } /** - * Removes existing tab bar and cleans up + * Creates the tab bar for the pane and adds it to the DOM + * @param {string} paneId */ - function cleanupTabBar() { - if ($tabBar) { - $tabBar.remove(); - $tabBar = null; + function _createView(paneId) { + if (_views[paneId] || !$(`#${paneId} .pane-header`).length) { + return; } - if ($tabBar2) { - $tabBar2.remove(); - $tabBar2 = null; + _views[paneId] = new TabBarView(paneId); + _scheduleUpdate(paneId); + } + + function _destroyView(paneId) { + if (_views[paneId]) { + _views[paneId].destroy(); + delete _views[paneId]; } - // Also check for any orphaned tab bars that might exist - $(".tab-bar-container").remove(); - WorkspaceManager.recomputeLayout(true); + _pendingPanes.delete(paneId); } /** - * handle click events on the tabs to open the file + * the pane a tab or an overflow button belongs to + * @param {HTMLElement} element - an element inside a tab bar container + * @return {string} the pane id */ - function handleTabClick() { - // delegate event handling for both tab bars - $(document).on("click", ".phoenix-tab-bar .tab", function (event) { - // Get the file path from the data-path attribute of the parent tab - const filePath = $(this).attr("data-path"); - if (!filePath) { return; } - - // determine the pane inside which the tab belongs - const isSecondPane = $(this).closest("#phoenix-tab-bar-2").length > 0; - const paneId = isSecondPane ? "second-pane" : "first-pane"; - - // get the file object - const fileObj = FileSystem.getFileForPath(filePath); - - // check if the clicked element is the close button - if ($(event.target).hasClass("fa-times") || $(event.target).closest(".tab-close").length) { - event.preventDefault(); - event.stopPropagation(); + function _paneIdOf(element) { + return element.closest(".tab-bar-container").getAttribute("data-pane-id"); + } - CommandManager.execute(Commands.FILE_CLOSE, { file: fileObj, paneId: paneId }); // close the file + function _focusEditor() { + // the mousedown that got us here moves the focus to the tab once the handlers are done, so the + // editor is focused right after that but still before the frame is painted. A timer would leave + // a painted frame without a cursor in between, which shows as a flash in the editor + window.requestAnimationFrame(function () { + const editor = EditorManager.getActiveEditor(); + if (editor) { + editor.focus(); } }); + } - // add listener for tab close button to show the tooltip along with the keybinding - $(document).on("mouseenter", ".phoenix-tab-bar .tab-close", function () { + /** + * shows the file of the tab in its pane. the file is opened on mousedown rather than click so + * switching files feels instant + * + * @param {string} path - the full path of the file + * @param {string} paneId - the pane the tab is in + */ + function _openTab(path, paneId) { + const activePaneId = MainViewManager.getActivePaneId(); - // there can be more than 1 keybinding for 'Close' as one is default and other one is user-set - // we need to get the user created keybinding... - const closeBindings = KeyBindingManager.getKeyBindings(Commands.FILE_CLOSE); - const closeShortcut = closeBindings.length > 0 - ? KeyBindingManager.formatKeyDescriptor(closeBindings[closeBindings.length - 1].displayKey) - : ""; + // clicked tab is already active, just focus the editor + if (paneId === activePaneId && MainViewManager.getCurrentlyViewedPath(activePaneId) === path) { + _focusEditor(); + return; + } - const closeTabTooltip = closeShortcut - ? `${Strings.CLOSE_TAB_TOOLTIP} (${closeShortcut})` - : Strings.CLOSE_TAB_TOOLTIP; + CommandManager.execute(Commands.FILE_OPEN, { fullPath: path, paneId: paneId }).always(_focusEditor); + } - $(this).attr("title", closeTabTooltip); - }); + /** + * tooltip text with the command's keyboard shortcut appended, if it has one + * there can be more than 1 keybinding for a command as one is default and other one is user-set + * we take the last one as that is the user created keybinding + * + * @param {string} text - the tooltip text + * @param {string} commandId + * @return {string} + */ + function _withShortcut(text, commandId) { + const bindings = KeyBindingManager.getKeyBindings(commandId); + const binding = bindings[bindings.length - 1]; + const shortcut = binding ? KeyBindingManager.formatKeyDescriptor(binding.displayKey || binding.key) : ""; + return shortcut ? `${text} (${shortcut})` : text; + } - // open tab on mousedown event - $(document).on("mousedown", ".phoenix-tab-bar .tab", function (event) { - if ($(event.target).hasClass("fa-times") || $(event.target).closest(".tab-close").length) { + /** + * handle click events on the tabs to open/close/pin the file + * all the handlers are delegated from the document so they don't have to be rebound when tabs come and go + */ + function _registerTabHandlers() { + const TAB = ".phoenix-tab-bar .tab"; + + $(document).on("mousedown", TAB, function (event) { + // right clicks are for the context menu and the close/pin icons have their own click handler + const rightClick = event.which === 3 || event.button === 2 || + (event.ctrlKey && brackets.platform === "mac"); + if (rightClick || $(event.target).closest(".tab-close, .tab-pin").length) { return; } - // Get the file path from the data-path attribute of the parent tab - const filePath = $(this).attr("data-path"); - if (!filePath) { return; } - - // determine the pane inside which the tab belongs - const isSecondPane = $(this).closest("#phoenix-tab-bar-2").length > 0; - const paneId = isSecondPane ? "second-pane" : "first-pane"; - - // get the file object - const fileObj = FileSystem.getFileForPath(filePath); - const currentActivePane = MainViewManager.getActivePaneId(); - const isPaneActive = paneId === currentActivePane; - const currentFile = MainViewManager.getCurrentlyViewedFile(currentActivePane); + const path = this.getAttribute("data-path"); + const paneId = _paneIdOf(this); - // if the clicked tab is a placeholder tab, we add it to the working set - if ($(this).hasClass("placeholder")) { - MainViewManager.addToWorkingSet(paneId, fileObj); + // middle click closes the tab, except for pinned ones so they don't get closed by accident + if (event.which === 2) { + event.preventDefault(); + if (!MainViewManager.isPathPinned(paneId, path)) { + CommandManager.execute(Commands.FILE_CLOSE, { + file: FileSystem.getFileForPath(path), + paneId: paneId + }); + } + return; } - // clicked tab is already active, focus the editor - if (isPaneActive && currentFile && currentFile.fullPath === filePath) { - setTimeout(function () { - const editor = EditorManager.getActiveEditor(); - if (editor) { - editor.focus(); - } - }, 0); - return; + // if the clicked tab is a placeholder tab, we add it to the working set + if (this.classList.contains("placeholder")) { + MainViewManager.addToWorkingSet(paneId, FileSystem.getFileForPath(path)); } - CommandManager.execute(Commands.FILE_OPEN, { fullPath: filePath, paneId: paneId }) - .always(function () { - setTimeout(function () { - const editor = EditorManager.getActiveEditor(); - if (editor) { - editor.focus(); - } - }, 0); - }); + _openTab(path, paneId); }); - // Add the contextmenu (right-click) handler - $(document).on("contextmenu", ".phoenix-tab-bar .tab", function (event) { + $(document).on("click", ".phoenix-tab-bar .tab-close", function (event) { event.preventDefault(); event.stopPropagation(); - - // get the file path from the data-path attribute - const filePath = $(this).attr("data-path"); - - // determine which pane the tab belongs to - const isSecondPane = $(this).closest("#phoenix-tab-bar-2").length > 0; - const paneId = isSecondPane ? "second-pane" : "first-pane"; - - // show the context menu at mouse position - MoreOptions.showMoreOptionsContextMenu(paneId, filePath, event.pageX, event.pageY); + const tab = this.parentNode; + CommandManager.execute(Commands.FILE_CLOSE, { + file: FileSystem.getFileForPath(tab.getAttribute("data-path")), + paneId: _paneIdOf(tab) + }); }); - } - // debounce is used to prevent rapid consecutive calls to updateTabs, - // which was causing integration tests to fail in Firefox. Without it, - // the event fires too frequently when switching editors, leading to unexpected behavior - const debounceUpdateTabs = _.debounce(updateTabs, 2); - - /** - * This function is responsible to add the placeholder tab to the working set (if user press save on it) - * @param {Event} event - * @param {String} commandId - the command id, to make sure we check it do the operation only on file save - */ - function onFileSave(event, commandId) { - if (!isTabBarActive()) { - return; - } - - if (commandId === Commands.FILE_SAVE || commandId === Commands.FILE_SAVE_ALL) { - const activePane = MainViewManager.getActivePaneId(); - const currentFile = MainViewManager.getCurrentlyViewedFile(activePane); - - if (currentFile) { - const filePath = currentFile.fullPath; + $(document).on("click", ".phoenix-tab-bar .tab-pin", function (event) { + event.preventDefault(); + event.stopPropagation(); + const tab = this.parentNode; + CommandManager.execute(Commands.FILE_PIN, { + file: FileSystem.getFileForPath(tab.getAttribute("data-path")), + paneId: _paneIdOf(tab), + pinned: false + }); + }); - // check if this file is currently shown as a placeholder in any pane - const isFirstPanePlaceholder = - MainViewManager.getCurrentlyViewedFile("first-pane") && - MainViewManager.getCurrentlyViewedFile("first-pane").fullPath === filePath && - !Global.firstPaneWorkingSet.some((entry) => entry.path === filePath); + // the tooltips carry the keyboard shortcut, which the user can change, so they are built on hover + $(document).on("mouseenter", ".phoenix-tab-bar .tab-close", function () { + this.title = _withShortcut(Strings.CLOSE_TAB_TOOLTIP, Commands.FILE_CLOSE); + }); + $(document).on("mouseenter", ".phoenix-tab-bar .tab-pin", function () { + this.title = _withShortcut(Strings.UNPIN_TAB, Commands.FILE_PIN); + }); - const isSecondPanePlaceholder = - MainViewManager.getCurrentlyViewedFile("second-pane") && - MainViewManager.getCurrentlyViewedFile("second-pane").fullPath === filePath && - !Global.secondPaneWorkingSet.some((entry) => entry.path === filePath); + // Add the contextmenu (right-click) handler + // the menu acts on the right-clicked tab, the active tab stays where it is + $(document).on("contextmenu", TAB, function (event) { + event.preventDefault(); + event.stopPropagation(); - // if it's a placeholder tab, we add it to the working set - if (isFirstPanePlaceholder) { - const fileObj = FileSystem.getFileForPath(filePath); - MainViewManager.addToWorkingSet("first-pane", fileObj); - } + const path = this.getAttribute("data-path"); + FileViewController.setWorkingSetContext(FileSystem.getFileForPath(path), _paneIdOf(this)); + Menus.getContextMenu(Menus.ContextMenuIds.FILE_TABS_CONTEXT_MENU) + .open({ pageX: event.pageX, pageY: event.pageY }); + }); - if (isSecondPanePlaceholder) { - const fileObj = FileSystem.getFileForPath(filePath); - MainViewManager.addToWorkingSet("second-pane", fileObj); - } + // a click on the empty part of the bar, not on a tab, opens the tab bar settings. A press that + // started on a tab and ended on the bar is reported as a click on the bar too, that one doesn't count + let pressedEmptyBar = false; + $(document).on("mousedown", ".phoenix-tab-bar", function (event) { + pressedEmptyBar = event.target === this; + }); + $(document).on("click contextmenu", ".phoenix-tab-bar", function (event) { + if (event.target !== this || (event.type === "click" && !pressedEmptyBar)) { + return; } - } + event.preventDefault(); + event.stopPropagation(); + Menus.getContextMenu(Menus.ContextMenuIds.FILE_TABS_SETTINGS_MENU) + .open({ pageX: event.pageX, pageY: event.pageY }); + }); + + // the mouse wheel scrolls the tabs sideways. A trackpad already gives a sideways delta, + // that one is taken as is and the up/down delta is multiplied to get a decent scroll amount + $(document).on("wheel", ".phoenix-tab-bar", function (event) { + const wheel = event.originalEvent; + this.scrollLeft += (wheel.deltaX || 0) + wheel.deltaY * 2.5; + }); } /** * Registers the event handlers */ function _registerHandlers() { - // For pane layout changes, recreate the entire tab bar container - MainViewManager.on("paneCreate paneDestroy paneLayoutChange", createTabBar); + MainViewManager.on("paneCreate", function (event, paneId) { + if (isTabBarActive()) { + _createView(paneId); + } + }); + MainViewManager.on("paneDestroy", function (event, paneId) { + _destroyView(paneId); + }); - // For active pane changes, update only the tabs - MainViewManager.on("activePaneChange", debounceUpdateTabs); + MainViewManager.on("workingSetAdd", function (event, file, index, paneId) { + _scheduleUpdate(paneId); + }); + MainViewManager.on("workingSetAddList", function (event, files, paneId) { + _scheduleUpdate(paneId); + }); + MainViewManager.on("workingSetRemove", function (event, file, suppressRedraw, paneId) { + // suppressRedraw means the file is about to be put back under another name (save as), + // the add that follows takes care of the redraw + if (!suppressRedraw) { + _scheduleUpdate(paneId); + } + }); + MainViewManager.on("workingSetRemoveList", function (event, files, paneId) { + _scheduleUpdate(paneId); + }); + MainViewManager.on("workingSetSort workingSetUpdate", function (event, paneId) { + _scheduleUpdate(paneId); + }); + MainViewManager.on("workingSetMove", function (event, file, sourcePaneId, destinationPaneId) { + _scheduleUpdate(sourcePaneId); + _scheduleUpdate(destinationPaneId); + }); - // For editor changes, update only the tabs. - MainViewManager.on(MainViewManager.EVENT_CURRENT_FILE_CHANGE, debounceUpdateTabs); + MainViewManager.on(MainViewManager.EVENT_CURRENT_FILE_CHANGE, + function (event, newFile, newPaneId, oldFile, oldPaneId) { + _scheduleUpdate(newPaneId); + _scheduleUpdate(oldPaneId); + }); + MainViewManager.on("activePaneChange", function (event, newPaneId, oldPaneId) { + _scheduleUpdate(newPaneId); + _scheduleUpdate(oldPaneId); + }); + + DocumentManager.on("dirtyFlagChange", function (event, doc) { + const path = doc.file.fullPath; + _forEachView((view) => view.setDirty(path, doc.isDirty)); + }); // to listen for the Git status changes // make sure that the git extension is available if (window.phoenixGitEvents && window.phoenixGitEvents.EventEmitter) { - window.phoenixGitEvents.EventEmitter.on("GIT_FILE_STATUS_CHANGED", debounceUpdateTabs); + window.phoenixGitEvents.EventEmitter.on("GIT_FILE_STATUS_CHANGED", function () { + _forEachView((view) => view.refreshGitStatus()); + }); } - // For working set changes, update only the tabs. - const events = [ - "workingSetAdd", - "workingSetRemove", - "workingSetSort", - "workingSetMove", - "workingSetAddList", - "workingSetRemoveList", - "workingSetUpdate", - "_workingSetDisableAutoSort" - ]; - MainViewManager.off(events.join(" "), updateTabs); - MainViewManager.on(events.join(" "), updateTabs); - - // When the sidebar UI changes, update the tabs to ensure the overflow menu is correct - $("#sidebar").off("panelCollapsed panelExpanded panelResizeEnd", updateTabs); - $("#sidebar").on("panelCollapsed panelExpanded panelResizeEnd", updateTabs); - - // also update the tabs when the main plugin panel resizes - // main-plugin-panel[0] = live preview panel - new ResizeObserver(updateTabs).observe($("#main-plugin-panel")[0]); - - // listen for file save commands, needed to add placeholder tab to the working set - CommandManager.off("beforeExecuteCommand", onFileSave); - CommandManager.on("beforeExecuteCommand", onFileSave); - - // File dirty flag change handling - DocumentManager.on("dirtyFlagChange", function (event, doc) { - if (!isTabBarActive()) { - return; - } - - const filePath = doc.file.fullPath; - - // Update UI - if ($tabBar) { - const $tab = $tabBar.find(`.tab[data-path="${filePath}"]`); - $tab.toggleClass("dirty", doc.isDirty); - - // Update the working set data - // First pane - for (let i = 0; i < Global.firstPaneWorkingSet.length; i++) { - if (Global.firstPaneWorkingSet[i].path === filePath) { - Global.firstPaneWorkingSet[i].isDirty = doc.isDirty; - break; - } - } - } + FileViewController.on("workingSetContextChange", function (event, context) { + _forEachView((view) => view.setContextPath( + context && context.paneId === view.paneId ? context.file.fullPath : null)); + }); - // Also update the $tab2 if it exists - if ($tabBar2) { - const $tab2 = $tabBar2.find(`.tab[data-path="${filePath}"]`); - $tab2.toggleClass("dirty", doc.isDirty); - - // Second pane - for (let i = 0; i < Global.secondPaneWorkingSet.length; i++) { - if (Global.secondPaneWorkingSet[i].path === filePath) { - Global.secondPaneWorkingSet[i].isDirty = doc.isDirty; - break; - } - } - } + // an icon provider that comes in later, like one from an extension that loads after the tabs + // were made, gets to draw its icons on the existing tabs as well + WorkingSetView.on("providersChange", function () { + _forEachView((view) => view.refreshIcons()); }); } /** * This is called when the tab bar preference is changed either, * from the preferences file or the menu bar - * It takes care of creating or cleaning up the tab bar + * It takes care of creating or cleaning up the tab bars */ - function preferenceChanged() { + function _preferenceChanged() { const prefs = PreferencesManager.get(Preference.PREFERENCES_TAB_BAR); Preference.tabBarEnabled = prefs.showTabBar; Preference.tabBarNumberOfTabs = prefs.numberOfTabs; + Preference.tabBarSmoothScroll = Boolean(prefs.smoothScroll); - // Update menu checkmark + // Update menu checkmarks CommandManager.get(Commands.TOGGLE_TABBAR).setChecked(prefs.showTabBar); + CommandManager.get(Commands.TOGGLE_TABBAR_SMOOTH_SCROLL).setChecked(Preference.tabBarSmoothScroll); + NUMBER_OF_TABS_CHOICES.forEach((count) => { + CommandManager.get(_numberOfTabsCommandId(count)).setChecked(prefs.numberOfTabs === count); + }); if (isTabBarActive()) { - createTabBar(); + MainViewManager.getPaneIdList().forEach(_createView); + Object.keys(_views).forEach(_scheduleUpdate); } else { - cleanupTabBar(); + Object.keys(_views).forEach(_destroyView); } } + // the choices offered in the settings menu for how many tabs to show, -1 means all of them + const NUMBER_OF_TABS_CHOICES = [-1, 1, 3, 5, 10]; + const HIDE_TABBAR_COMMAND = "tabbar.hide"; + + function _numberOfTabsCommandId(count) { + return count === -1 ? "tabbar.numberOfTabs.all" : "tabbar.numberOfTabs." + count; + } + + /** + * changes one key of the tab bar preference + * @param {string} key - the key to change + * @param {*} value - the new value + */ + function _setPreference(key, value) { + const currentPref = PreferencesManager.get(Preference.PREFERENCES_TAB_BAR); + PreferencesManager.set(Preference.PREFERENCES_TAB_BAR, { + ...currentPref, + [key]: value + }); + } + /** * Registers the commands, - * for toggling the tab bar from the menu bar + * for toggling the tab bar from the menu bar and the tab bar settings */ function _registerCommands() { - CommandManager.register(Strings.CMD_TOGGLE_TABBAR, Commands.TOGGLE_TABBAR, () => { - const currentPref = PreferencesManager.get(Preference.PREFERENCES_TAB_BAR); - PreferencesManager.set(Preference.PREFERENCES_TAB_BAR, { - ...currentPref, - showTabBar: !currentPref.showTabBar - }); + CommandManager.register(Strings.CMD_TOGGLE_TABBAR, Commands.TOGGLE_TABBAR, + () => _setPreference("showTabBar", !Preference.tabBarEnabled)); + CommandManager.register(Strings.CMD_TOGGLE_TABBAR_SMOOTH_SCROLL, Commands.TOGGLE_TABBAR_SMOOTH_SCROLL, + () => _setPreference("smoothScroll", !Preference.tabBarSmoothScroll)); + CommandManager.register(Strings.CMD_HIDE_TABBAR, HIDE_TABBAR_COMMAND, + () => _setPreference("showTabBar", false)); + NUMBER_OF_TABS_CHOICES.forEach((count) => { + CommandManager.register(count === -1 ? Strings.TABBAR_ALL_TABS : String(count), + _numberOfTabsCommandId(count), () => _setPreference("numberOfTabs", count)); }); } /** - * this function sets up mouse wheel scrolling functionality for the tab bars - * when the mouse wheel is scrolled up, the tab bar will scroll to the left - * when its scrolled down, the tab bar will scroll to the right + * the settings menu that opens from the empty part of the tab bar */ - function setupTabBarScrolling() { - // common handler for both the tab bars - function handleMouseWheel(e) { - // get the tab bar element that is being scrolled - const $scrolledTabBar = $(this); - - // A negative deltaY means scrolling up so we need to scroll to the left, - // positive means scrolling down so we need to scroll to the right - // here we calculate the scroll amount (pixels) - // and multiply by 2.5 for increasing the scroll amount - const scrollAmount = e.originalEvent.deltaY * 2.5; - - // calculate the new scroll position - const newScrollLeft = $scrolledTabBar.scrollLeft() + scrollAmount; - - // apply the new scroll position - $scrolledTabBar.scrollLeft(newScrollLeft); - } - - // attach the wheel event handler to both tab bars - $(document).on("wheel", "#phoenix-tab-bar, #phoenix-tab-bar-2", handleMouseWheel); + function _registerSettingsMenu() { + const menu = Menus.registerContextMenu(Menus.ContextMenuIds.FILE_TABS_SETTINGS_MENU); + menu.addMenuItem(Commands.TOGGLE_TABBAR_SMOOTH_SCROLL); + const countMenu = menu.addSubMenu(Strings.TABBAR_NUMBER_OF_TABS, "tabbar-number-of-tabs-submenu"); + NUMBER_OF_TABS_CHOICES.forEach((count) => countMenu.addMenuItem(_numberOfTabsCommandId(count))); + menu.addMenuDivider(); + menu.addMenuItem(HIDE_TABBAR_COMMAND); } AppInit.appReady(function () { _registerCommands(); + _registerSettingsMenu(); // add the toggle tab bar command to the view menu const viewMenu = Menus.getMenu(Menus.AppMenuBar.VIEW_MENU); viewMenu.addMenuItem(Commands.TOGGLE_TABBAR, "", Menus.AFTER, Commands.VIEW_HIDE_SIDEBAR); - PreferencesManager.on("change", Preference.PREFERENCES_TAB_BAR, preferenceChanged); - // calling preference changed here itself to check if the tab bar is enabled, - // because if it is enabled, preferenceChange automatically calls createTabBar - preferenceChanged(); - - // this should be called at the last as everything should be setup before registering handlers _registerHandlers(); - - // handle when a single tab gets clicked - handleTabClick(); - - MoreOptions.init(); + _registerTabHandlers(); Overflow.init(); - DragDrop.init($("#phoenix-tab-bar"), $("#phoenix-tab-bar-2")); + DragDrop.init(); - // setup the mouse wheel scrolling - setupTabBarScrolling(); + PreferencesManager.on("change", Preference.PREFERENCES_TAB_BAR, _preferenceChanged); + // calling preference changed here itself to check if the tab bar is enabled, + // because if it is enabled, it creates the tab bars for the open panes + _preferenceChanged(); }); }); diff --git a/src/extensionsIntegrated/TabBar/more-options.js b/src/extensionsIntegrated/TabBar/more-options.js deleted file mode 100644 index b6df56139a..0000000000 --- a/src/extensionsIntegrated/TabBar/more-options.js +++ /dev/null @@ -1,165 +0,0 @@ -/* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License - * for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. - * - */ - -/* - * This file manages the more options context menu. - * The more options context menu is shown when a tab is right-clicked - */ -define(function (require, exports, module) { - const CommandManager = require("command/CommandManager"); - const Commands = require("command/Commands"); - const FileSystem = require("filesystem/FileSystem"); - const Menus = require("command/Menus"); - const Strings = require("strings"); - - const Global = require("./global"); - - // these are Tab bar specific commands for the context menu - // not added in the Commands.js as Tab bar is not a core module but an extension - // read init function - const TABBAR_CLOSE_TABS_LEFT = "tabbar.closeTabsLeft"; - const TABBAR_CLOSE_TABS_RIGHT = "tabbar.closeTabsRight"; - const TABBAR_CLOSE_SAVED_TABS = "tabbar.closeSavedTabs"; - const TABBAR_CLOSE_ALL = "tabbar.closeAllTabs"; - - // stores the context of the right-clicked tab (which file, which pane) - // this is set inside the showMoreOptionsContextMenu. read that func for more details - let _currentTabContext = { filePath: null, paneId: null }; - - // gets the working set (list of open files) for the given pane - function _getWorkingSet(paneId) { - return paneId === "first-pane" ? Global.firstPaneWorkingSet : Global.secondPaneWorkingSet; - } - - // closes files from right to left to avoid index shifts during iteration - function _closeFiles(files, paneId) { - for (let i = files.length - 1; i >= 0; i--) { - const fileObj = FileSystem.getFileForPath(files[i].path); - CommandManager.execute(Commands.FILE_CLOSE, { file: fileObj, paneId: paneId }); - } - } - - // executes a command with the right-clicked tab's file as the target - function _executeWithFileContext(commandId, options = {}) { - if (_currentTabContext.filePath) { - // we need to get the file object from the file path, as the commandManager expects the file object - const fileObj = FileSystem.getFileForPath(_currentTabContext.filePath); - CommandManager.execute(commandId, { file: fileObj, ...options }); - } - } - - // **Close All Tabs** - // closes all tabs in the pane where the tab was right-clicked - function handleCloseAllTabs() { - const workingSet = _getWorkingSet(_currentTabContext.paneId); - if (workingSet && workingSet.length !== 0) { - // close everything in the pane - _closeFiles(workingSet, _currentTabContext.paneId); - } - } - - // **Close Saved Tabs** - // closes all saved tabs (not dirty) in the pane - function handleCloseSavedTabs() { - const workingSet = _getWorkingSet(_currentTabContext.paneId); - if (workingSet && workingSet.length !== 0) { - // filter out dirty tabs, only close the saved ones - const savedTabs = workingSet.filter(entry => !entry.isDirty); - _closeFiles(savedTabs, _currentTabContext.paneId); - } - } - - // **Close Tabs to the Left** - // closes all tabs to the left of the right-clicked tab - function handleCloseTabsToTheLeft() { - const workingSet = _getWorkingSet(_currentTabContext.paneId); - if (!workingSet) { return; } - - // find where the right-clicked tab is in the list - const currentIndex = workingSet.findIndex(entry => entry.path === _currentTabContext.filePath); - if (currentIndex > 0) { - // slice from start to currentIndex (not including currentIndex) - _closeFiles(workingSet.slice(0, currentIndex), _currentTabContext.paneId); - } - } - - // **Close Tabs to the Right** - // closes all tabs to the right of the right-clicked tab - function handleCloseTabsToTheRight() { - const workingSet = _getWorkingSet(_currentTabContext.paneId); - if (!workingSet) { return; } - - // find where the right-clicked tab is in the list - const currentIndex = workingSet.findIndex(entry => entry.path === _currentTabContext.filePath); - if (currentIndex !== -1 && currentIndex < workingSet.length - 1) { - // slice from currentIndex+1 to end - _closeFiles(workingSet.slice(currentIndex + 1), _currentTabContext.paneId); - } - } - - /** - * this function is called from Tabbar/main.js when a tab is right clicked - * it is responsible to show the context menu and also set the currentTabContext - * @param {String} paneId - the id of the pane ["first-pane", "second-pane"] - * @param {String} filePath - the path of the file that was right-clicked - * @param {Number} x - the x coordinate for positioning the menu - * @param {Number} y - the y coordinate for positioning the menu - */ - function showMoreOptionsContextMenu(paneId, filePath, x, y) { - _currentTabContext.filePath = filePath; - _currentTabContext.paneId = paneId; - - const contextMenu = Menus.getContextMenu("tabbar-context-menu"); - const event = $.Event("contextmenu", { pageX: x, pageY: y }); - contextMenu.open(event); - } - - - /** - * this is the main function, it gets called only once on load from TabBar/main.js - * this registers the context menu and add the menu items inside it - */ - function init() { - // these are the tab bar specific commands - CommandManager.register(Strings.CLOSE_TABS_TO_THE_LEFT, TABBAR_CLOSE_TABS_LEFT, handleCloseTabsToTheLeft); - CommandManager.register(Strings.CLOSE_TABS_TO_THE_RIGHT, TABBAR_CLOSE_TABS_RIGHT, handleCloseTabsToTheRight); - CommandManager.register(Strings.CLOSE_SAVED_TABS, TABBAR_CLOSE_SAVED_TABS, handleCloseSavedTabs); - CommandManager.register(Strings.CLOSE_ALL_TABS, TABBAR_CLOSE_ALL, handleCloseAllTabs); - - // these commands already exist for working files, just reusing them - const menu = Menus.registerContextMenu("tabbar-context-menu"); - menu.addMenuItem(Commands.FILE_CLOSE); - menu.addMenuItem(TABBAR_CLOSE_TABS_LEFT); - menu.addMenuItem(TABBAR_CLOSE_TABS_RIGHT); - menu.addMenuItem(TABBAR_CLOSE_SAVED_TABS); - menu.addMenuItem(TABBAR_CLOSE_ALL); - menu.addMenuDivider(); - menu.addMenuItem(Commands.FILE_RENAME); - menu.addMenuItem(Commands.FILE_DELETE); - menu.addMenuItem(Commands.NAVIGATE_SHOW_IN_FILE_TREE); - menu.addMenuDivider(); - menu.addMenuItem(Commands.FILE_REOPEN_CLOSED); - } - - module.exports = { - showMoreOptionsContextMenu, - init - }; -}); diff --git a/src/extensionsIntegrated/TabBar/overflow.js b/src/extensionsIntegrated/TabBar/overflow.js index 77f1e04c76..d821c4680f 100644 --- a/src/extensionsIntegrated/TabBar/overflow.js +++ b/src/extensionsIntegrated/TabBar/overflow.js @@ -27,355 +27,155 @@ */ /* eslint-disable no-invalid-this */ define(function (require, exports, module) { - + const _ = require("thirdparty/lodash"); const DropdownButton = require("widgets/DropdownButton"); - const MainViewManager = require("view/MainViewManager"); const CommandManager = require("command/CommandManager"); const Commands = require("command/Commands"); - const EditorManager = require("editor/EditorManager"); const FileSystem = require("filesystem/FileSystem"); - // holds the dropdown instance - let $dropdown = null; + // the open dropdown, there is only ever one + let _dropdown = null; /** * This function determines which tabs are hidden in the tab bar due to overflow - * and returns them as an array of tab data objects + * and returns them as an array of tab data objects. It reads the DOM, so it is only + * ever called when the menu is actually opened * - * @param {String} paneId - The ID of the pane ("first-pane" or "second-pane") + * @param {HTMLElement} bar - the pane's tab bar element * @returns {Array} - Array of hidden tab data objects */ - function _getListOfHiddenTabs(paneId) { - // get the appropriate tab bar based on pane ID - const $currentTabBar = paneId === "first-pane" - ? $("#phoenix-tab-bar") - : $("#phoenix-tab-bar-2"); - - // access the DOM element to get its bounding rectangle - const tabBarRect = $currentTabBar[0].getBoundingClientRect(); - - // an array of hidden tabs objects which will store properties like - // path, name, isActive, isDirty and $icon + function _getHiddenTabs(bar) { + const barRect = bar.getBoundingClientRect(); const hiddenTabs = []; - // check each tab to determine if it's visible - $currentTabBar.find('.tab').each(function () { - const tabRect = this.getBoundingClientRect(); + for (const tab of bar.children) { + const tabRect = tab.getBoundingClientRect(); // A tab is considered hidden if it is not completely visible // 2 is added here, because when we scroll till the very end of the tab bar even though, // the last tab was shown in the overflow menu even though it was completely visible // this bug was coming because a part of the last tab got hidden inside the dropdown icon - const isVisible = tabRect.left >= tabBarRect.left && - tabRect.right <= (tabBarRect.right + 2); - - if (!isVisible) { - // extract and store information about the hidden tab - const $tab = $(this); - const tabData = { - path: $tab.data('path'), - name: $tab.find('.tab-name').text(), - isActive: $tab.hasClass('active'), - isDirty: $tab.hasClass('dirty'), - isPlaceholder: $tab.hasClass('placeholder'), - $icon: $tab.find('.tab-icon').clone() - }; - - hiddenTabs.push(tabData); + if (tabRect.left < barRect.left || tabRect.right > barRect.right + 2) { + hiddenTabs.push({ + path: tab.getAttribute("data-path"), + name: tab.querySelector(".tab-name").textContent, + isDirty: tab.classList.contains("dirty"), + isPinned: tab.classList.contains("pinned"), + isPlaceholder: tab.classList.contains("placeholder"), + iconHtml: tab.querySelector(".tab-icon").innerHTML + }); } - }); + } return hiddenTabs; } - /** - * Toggles the visibility of the overflow button based on whether - * there are any hidden tabs - * Hidden tabs are tabs that are currently not visible in the tab bar - * - * @param {String} paneId - The ID of the pane ("first-pane" or "second-pane") + * renders a single item of the dropdown + * @param {Object} item - a hidden tab data object + * @return {{html: string, enabled: boolean}} */ - function toggleOverflowVisibility(paneId) { - const hiddenTabs = _getListOfHiddenTabs(paneId); - - if (paneId === "first-pane") { - // for the html element, refer to ./html/tabbar-pane.html - const $overflowButton = $("#overflow-button"); - - if (hiddenTabs.length > 0) { - $overflowButton.removeClass("hidden"); - } else { - $overflowButton.addClass("hidden"); - } - } else { - // for the html element, refer to ./html/tabbar-second-pane.html - const $overflowButton = $("#overflow-button-2"); - - if (hiddenTabs.length > 0) { - $overflowButton.removeClass("hidden"); - } else { - $overflowButton.addClass("hidden"); - } - } + function _renderItem(item) { + const path = _.escape(item.path); + const dirtyHtml = item.isDirty + ? '•' + : ''; // adding an empty span for better alignment + const controlHtml = item.isPinned + ? ` + ` + : ` + `; + + return { + html: ``, + enabled: true + }; } - /** * This function is called when the overflow button is clicked * This will show the overflow context menu * * @param {String} paneId - the id of the pane ["first-pane", "second-pane"] - * @param {Number} x - x coordinate for positioning the menu - * @param {Number} y - y coordinate for positioning the menu + * @param {HTMLElement} bar - the pane's tab bar element + * @param {jQuery} $button - the overflow button that was clicked */ - function showOverflowMenu(paneId, x, y) { - const hiddenTabs = _getListOfHiddenTabs(paneId); - - // Create a map to track tabs that are being closed - // Using paths as keys for quick lookup - const closingTabPaths = {}; - - // create the dropdown - $dropdown = new DropdownButton.DropdownButton("", hiddenTabs, function (item, index) { - const iconHtml = item.$icon[0].outerHTML; // the file icon - const dirtyHtml = item.isDirty - ? '•' - : ''; // adding an empty span for better alignment - - const closeIconHtml = - ` - - `; - - // add placeholder class to style it differently - const placeholderClass = item.isPlaceholder ? ' placeholder-name' : ''; - - // return html for this item - return { - html: - ``, - enabled: true - }; - }); + function _showOverflowMenu(paneId, bar, $button) { + const hiddenTabs = _getHiddenTabs(bar); + if (!hiddenTabs.length) { + return; + } + const dropdown = new DropdownButton.DropdownButton("", hiddenTabs, _renderItem); // add custom class to separate overflow dropdown from regular ones - $dropdown.dropdownExtraClasses = "dropdown-overflow-menu"; - - // appending to document body. we'll position this with absolute positioning - $("body").append($dropdown.$button); - - // position the dropdown where the user clicked - $dropdown.$button.css({ - position: "absolute", - left: x + "px", - top: y + "px", - zIndex: 1000 + dropdown.dropdownExtraClasses = "dropdown-overflow-menu"; + // the real overflow button stands in for the one DropdownButton creates, that way the list opens + // right under it and clicking it again isn't taken as a click outside the list + dropdown.$button = $button; + _dropdown = dropdown; + + dropdown.on("select", function (event, item) { + CommandManager.execute(Commands.FILE_OPEN, { fullPath: item.path, paneId: paneId }); }); - - - // custom handler for close button clicks - must be set up BEFORE showing dropdown - // using one because we only want to run this handler once - $(document).one("mousedown", ".tab-close-icon-overflow", function (e) { - // store the path of the tab being closed - const tabPath = $(this).data("tab-path"); - closingTabPaths[tabPath] = true; - - // we don't stop propagation here - let the event bubble up - // because we want the tab click handler to run as we are not closing the tab here - // Why all this is done instead of simply closing the tab? - // There was no way to stop the tab click handler from running. - // Tried propagating the event, and all other stuff but that didn't work. - // So what used to happen was that the tab used to get closed but then appeared again - - // But we do prevent the default action - e.preventDefault(); + dropdown.on(DropdownButton.EVENT_DROPDOWN_CLOSED, function () { + if (_dropdown === dropdown) { + _dropdown = null; + } }); - $dropdown.showDropdown(); - - // handle the option selection - $dropdown.on("select", function (e, item, index) { - // check if this tab was marked for closing - if (closingTabPaths[item.path]) { - // this tab is being closed, so handle the close operation - const file = FileSystem.getFileForPath(item.path); - - if (file) { - // use setTimeout to ensure this happens after all event handlers - setTimeout(function () { - CommandManager.execute(Commands.FILE_CLOSE, { file: file, paneId: paneId }); - // clean up - delete closingTabPaths[item.path]; - }, 0); - } - - return false; - } - // regular tab selection - open the file - const filePath = item.path; - if (filePath) { - // Set the active pane and open the file - MainViewManager.setActivePaneId(paneId); - CommandManager.execute(Commands.FILE_OPEN, { fullPath: filePath }); + dropdown.showDropdown(); - // get the tab bar element based on paneId and scroll to the active tab - // we use setTimeout to ensure that the DOM has updated after the file open command - setTimeout(function () { - const $tabBarElement = paneId === "first-pane" ? - $("#phoenix-tab-bar") : $("#phoenix-tab-bar-2"); - scrollToActiveTab($tabBarElement); - }, 100); - } + // these icons sit inside the list items. Being the innermost element they get the click first + // and keep it from reaching the item, which would otherwise open the file + dropdown.$dropdown.find(".tab-close-icon-overflow").on("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + const file = FileSystem.getFileForPath(this.getAttribute("data-tab-path")); + dropdown.closeDropdown(); + CommandManager.execute(Commands.FILE_CLOSE, { file: file, paneId: paneId }); }); - - // a button was getting displayed on the screen wherever a click was made. not sure why - // but this fixes it - $dropdown.$button.css({ - display: "none" + dropdown.$dropdown.find(".tab-pin-icon-overflow").on("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + const file = FileSystem.getFileForPath(this.getAttribute("data-tab-path")); + dropdown.closeDropdown(); + CommandManager.execute(Commands.FILE_PIN, { file: file, paneId: paneId, pinned: false }); }); } - - /** - * Scrolls the tab bar to the active tab. - * When scrolling the tab bar, we calculate the distance we need to scroll and based on that distance, - * we set the duration. - * This ensures that the scrolling speed is consistent no matter the distance or number of tabs present in tab bar. - * - * @param {JQuery} $tabBarElement - The tab bar element, - * this is either $('#phoenix-tab-bar') or $('phoenix-tab-bar-2') - */ - function scrollToActiveTab($tabBarElement) { - if (!$tabBarElement || !$tabBarElement.length) { - return; - } - - let activePath; - - // get the active file - const activeEditor = EditorManager.getActiveEditor(); - if (activeEditor && activeEditor.document && activeEditor.document.file) { - activePath = activeEditor.document.file.fullPath; - } else { - // If there is no active editor, we need to check if its an image file - const currentFile = MainViewManager.getCurrentlyViewedFile(); - if (currentFile) { - activePath = currentFile.fullPath; - } else { - // if not an image file, not a text file, we don't need to scroll - return; - } - } - - // get the active tab. the active tab is the tab that is currently open - const $activeTab = $tabBarElement.find(`.tab[data-path="${activePath}"]`); - - if ($activeTab.length) { - // get the tab bar container's dimensions - const tabBar = $tabBarElement[0]; - const tabBarRect = tabBar.getBoundingClientRect(); - const tabBarVisibleWidth = tabBarRect.width; - - // get the active tab's dimensions - const tabRect = $activeTab[0].getBoundingClientRect(); - - // calculate the tab's position relative to the tab bar container - const tabLeftRelative = tabRect.left - tabBarRect.left; - const tabRightRelative = tabRect.right - tabBarRect.left; - - // get current scroll position - const currentScroll = tabBar.scrollLeft; - let targetScroll = currentScroll; - let scrollDistance = 0; - - // calculate needed scroll adjustment - if (tabLeftRelative < 0) { - targetScroll = currentScroll + tabLeftRelative - 10; - } else if (tabRightRelative > tabBarVisibleWidth) { - const scrollAdjustment = tabRightRelative - tabBarVisibleWidth + 10; - targetScroll = currentScroll + scrollAdjustment; - } - - // calculate the scroll distance in pixels - scrollDistance = Math.abs(targetScroll - currentScroll); - - // calculate duration based on distance (0.15ms per pixel + 100ms base) - // min 100ms, max 400ms - let duration = Math.min(Math.max(scrollDistance * 0.15, 100), 400); - - // only animate if we need to move more than 5 pixels - // otherwise, we can just jump - if (scrollDistance > 5) { - $tabBarElement.stop(true).animate( - { scrollLeft: targetScroll }, - duration, - 'linear' - ); - } else { - tabBar.scrollLeft = targetScroll; - } - } - } - - /** - * to close the overflow button's dropdown - */ - function _closeDropdown() { - if ($dropdown) { - if ($dropdown.$button) { - $dropdown.$button.remove(); - } - $dropdown = null; - } - } - - /** - * this function gets called when the overflow button gets clicked - * it shows/closes the dropdown as required - * @param {Event} e - the event instance - * @param {String} paneId - the pane id "first-pane" or "second-pane" - */ - function _handleOverflowButtonClick(e, paneId) { - e.stopPropagation(); - $dropdown ? _closeDropdown() : showOverflowMenu(paneId, e.pageX, e.pageY); - } - /** * initialize the handling of the overflow buttons * this also registers the event handlers */ function init() { - // when clicked anywhere on the page we want to close the dropdown - // except the overflow-buttons - $("html").on("click", function (e) { - if ($(e.target).closest("#overflow-button, #overflow-button-2").length) { return; } - _closeDropdown(); - }); - - // handle when the overflow button is clicked for the first pane - $(document).on("click", "#overflow-button", function (e) { - _handleOverflowButtonClick(e, "first-pane"); + // the overflow button opens the dropdown and closes it when it is open already + $(document).on("click", ".tab-bar-container .overflow-button", function (event) { + event.stopPropagation(); + if (_dropdown) { + _dropdown.closeDropdown(); + return; + } + const container = this.closest(".tab-bar-container"); + _showOverflowMenu(container.getAttribute("data-pane-id"), + container.querySelector(".phoenix-tab-bar"), $(this)); }); - // for second pane - $(document).on("click", "#overflow-button-2", function (e) { - _handleOverflowButtonClick(e, "second-pane"); + // DropdownButton closes itself on a mousedown outside the list, this covers plain clicks too + $(document).on("click", function (event) { + if (_dropdown && !$(event.target).closest(".dropdown-overflow-menu, .overflow-button").length) { + _dropdown.closeDropdown(); + } }); } module.exports = { - init, - toggleOverflowVisibility, - scrollToActiveTab + init }; }); diff --git a/src/extensionsIntegrated/TabBar/preference.js b/src/extensionsIntegrated/TabBar/preference.js index f62a51fd70..4900d98b12 100644 --- a/src/extensionsIntegrated/TabBar/preference.js +++ b/src/extensionsIntegrated/TabBar/preference.js @@ -30,12 +30,13 @@ define(function (require, exports, module) { const PREFERENCES_TAB_BAR = "tabBar.options"; // Preference name let tabBarEnabled = true; // preference to check if the tab bar is enabled let tabBarNumberOfTabs = -1; // preference to check the number of tabs, -1 means all tabs + let tabBarSmoothScroll = false; // whether the tab bar animates when it scrolls to the active tab PreferencesManager.definePreference( PREFERENCES_TAB_BAR, "object", - { showTabBar: true, numberOfTabs: -1 }, + { showTabBar: true, numberOfTabs: -1, smoothScroll: false }, { description: Strings.DESCRIPTION_TABBAR, keys: { @@ -48,6 +49,11 @@ define(function (require, exports, module) { type: "number", description: Strings.DESCRIPTION_NUMBER_OF_TABS, initial: -1 + }, + smoothScroll: { + type: "boolean", + description: Strings.DESCRIPTION_TABBAR_SMOOTH_SCROLL, + initial: false } } }); @@ -56,6 +62,7 @@ define(function (require, exports, module) { module.exports = { PREFERENCES_TAB_BAR, tabBarEnabled, - tabBarNumberOfTabs + tabBarNumberOfTabs, + tabBarSmoothScroll }; }); diff --git a/src/htmlContent/working-set.html b/src/htmlContent/working-set.html index 7d18a73e11..dd706016bc 100644 --- a/src/htmlContent/working-set.html +++ b/src/htmlContent/working-set.html @@ -5,4 +5,4 @@
-
\ No newline at end of file +
\ No newline at end of file diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 455651f51e..dbe8149e3d 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1046,7 +1046,7 @@ define({ "GEAR_MENU_TOOLTIP": "Configure Working Files", "CMD_TOGGLE_SHOW_WORKING_SET": "Show Working Files", - "CMD_TOGGLE_SHOW_FILE_TABS": "Show File Tab Bar", + "CMD_TOGGLE_SHOW_FILE_TABS": "Show Tab Bar", "SPLITVIEW_INFO_TITLE": "Already Open", "SPLITVIEW_MULTIPANE_WARNING": "The file is already open in another pane. {APP_NAME} will soon support opening the same file in more than one pane. Until then, the file will be shown in the pane it's already open in.

(You'll only see this message once.)", @@ -1151,9 +1151,11 @@ define({ // Tab bar Strings "CLOSE_TABS_TO_THE_RIGHT": "Close Tabs to the Right", "CLOSE_TABS_TO_THE_LEFT": "Close Tabs to the Left", + "CLOSE_OTHER_TABS": "Close Other Tabs", "CLOSE_ALL_TABS": "Close All Tabs", - "CLOSE_SAVED_TABS": "Close Saved Tabs", "CLOSE_TAB_TOOLTIP": "Close Tab", + "PIN_TAB": "Pin Tab", + "UNPIN_TAB": "Unpin Tab", // CodeInspection: errors/warnings "ERRORS_NO_FILE": "No File Open", @@ -1208,6 +1210,8 @@ define({ "CMD_FILE_CLOSE_OTHERS": "Close Others", "CMD_FILE_CLOSE_ABOVE": "Close Others Above", "CMD_FILE_CLOSE_BELOW": "Close Others Below", + "CMD_FILE_PIN": "Pin", + "CMD_FILE_UNPIN": "Unpin", "CMD_FILE_SAVE": "Save File", "CMD_FILE_SAVE_ALL": "Save All", "CMD_FILE_SAVE_AS": "Save As\u2026", @@ -1288,7 +1292,11 @@ define({ "CMD_TOGGLE_LP_FULL_SCREEN": "Toggle Live Preview Full Screen", "LIVE_PREVIEW_FULL_SCREEN": "Expand Live Preview to Full Screen", "LIVE_PREVIEW_EXIT_FULL_SCREEN": "Exit Full Screen", - "CMD_TOGGLE_TABBAR": "File Tab Bar", + "CMD_TOGGLE_TABBAR": "Toggle Tab Bar", + "CMD_TOGGLE_TABBAR_SMOOTH_SCROLL": "Smooth Tab Scrolling", + "CMD_HIDE_TABBAR": "Hide Tab Bar", + "TABBAR_NUMBER_OF_TABS": "Number of Tabs", + "TABBAR_ALL_TABS": "All", "CMD_TOGGLE_PANELS": "Toggle Panels", "CMD_TOGGLE_PURE_CODE": "No Distractions", "CMD_TOGGLE_FULLSCREEN": "Fullscreen", @@ -2146,6 +2154,7 @@ define({ "DESCRIPTION_TABBAR": "Set the tab bar settings.", "DESCRIPTION_SHOW_TABBAR": "true to show the tab bar, else false.", "DESCRIPTION_NUMBER_OF_TABS": "The number of tabs to show in the tab bar. Set to -1 to show all tabs", + "DESCRIPTION_TABBAR_SMOOTH_SCROLL": "true to animate the tab bar when it scrolls to the active tab, else false.", "TABBAR_SHOW_HIDDEN_TABS": "Show hidden tabs", // Git extension diff --git a/src/project/FileViewController.js b/src/project/FileViewController.js index 1e63bc64ec..b39f3bda6c 100644 --- a/src/project/FileViewController.js +++ b/src/project/FileViewController.js @@ -62,6 +62,15 @@ define(function (require, exports, module) { */ var _curDocChangedDueToMe = false; + /** + * The file a context menu of the working set or the file tab bar was opened on, with the pane it + * belongs to. A right click there doesn't select the file, so the file commands in those menus + * look here first and only then at the current file. Cleared when the menu goes away. + * @type {?{file: File, paneId: string}} + * @private + */ + let _workingSetContext = null; + /** * view managing working set. * @type {string} @@ -286,7 +295,32 @@ define(function (require, exports, module) { EventDispatcher.makeEventDispatcher(exports); // Deprecated + /** + * Remembers the file a working set or tab bar context menu was opened on. Pass null when the + * menu closes. Fires `workingSetContextChange` with the context so the views can mark the file + * while the menu is up. + * @param {?File} file - the right-clicked file, or null to forget it + * @param {string=} paneId - the pane the file is shown or listed in, defaults to the active pane + */ + function setWorkingSetContext(file, paneId) { + if (!file && !_workingSetContext) { + return; + } + _workingSetContext = file ? { file: file, paneId: paneId || MainViewManager.getActivePaneId() } : null; + exports.trigger("workingSetContextChange", _workingSetContext); + } + + /** + * The file a working set or tab bar context menu is currently open for + * @return {?{file: File, paneId: string}} null when no such menu is up + */ + function getWorkingSetContext() { + return _workingSetContext; + } + exports.addToWorkingSetAndSelect = addToWorkingSetAndSelect; + exports.setWorkingSetContext = setWorkingSetContext; + exports.getWorkingSetContext = getWorkingSetContext; // Define public API exports.getFileSelectionFocus = getFileSelectionFocus; diff --git a/src/project/ProjectManager.js b/src/project/ProjectManager.js index 093ac700ff..b83164ad8a 100644 --- a/src/project/ProjectManager.js +++ b/src/project/ProjectManager.js @@ -557,8 +557,13 @@ define(function (require, exports, module) { * @return {?(File|Directory)} */ function getSelectedItem() { - // Prefer file tree context, then file tree selection, else use working set + // Prefer file tree context, then the item a working set or tab bar menu is open for, + // then file tree selection, else use working set var selectedEntry = getFileTreeContext(); + if (!selectedEntry) { + const workingSetContext = FileViewController.getWorkingSetContext(); + selectedEntry = workingSetContext && workingSetContext.file; + } if (!selectedEntry) { selectedEntry = model.getSelected(); } @@ -1797,12 +1802,23 @@ define(function (require, exports, module) { setProjectBusy(false, Strings.RENAMING); }); - function _duplicateFileCMD() { + /** + * The path the file commands of the context menus work on: the right-clicked item in the file + * tree, else the item a working set or tab bar menu is open for, else the file in the active pane + * @private + * @return {?string} + */ + function _getCommandTargetPath() { let context = getContext(); - let fullPath = context && context.fullPath; - if(!fullPath){ - fullPath = MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE); + if (!context) { + const workingSetContext = FileViewController.getWorkingSetContext(); + context = workingSetContext && workingSetContext.file; } + return (context && context.fullPath) || MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE); + } + + function _duplicateFileCMD() { + let fullPath = _getCommandTargetPath(); if(fullPath && isWithinProject(fullPath)){ let name = _getProjectDisplayNameOrPath(fullPath); let message = StringUtils.format(Strings.DUPLICATING, name); @@ -1856,11 +1872,7 @@ define(function (require, exports, module) { } function _downloadCommand(entryToDownload) { - let context = entryToDownload || getContext(); - let fullPath = context && context.fullPath; - if(!fullPath){ - fullPath = MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE); - } + let fullPath = entryToDownload ? entryToDownload.fullPath : _getCommandTargetPath(); if(fullPath){ FileSystem.resolve(fullPath, function (err, fileOrFolder) { if(err) { @@ -1928,11 +1940,7 @@ define(function (require, exports, module) { } function _copyProjectRelativePath() { - let context = getContext(); - let fullPath = context && context.fullPath; - if(!fullPath){ - fullPath = MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE); - } + let fullPath = _getCommandTargetPath(); if(fullPath){ let pathToCopy = makeProjectRelativeIfPossible(fullPath); Phoenix.app.copyToClipboard(pathToCopy); @@ -1952,11 +1960,7 @@ define(function (require, exports, module) { } function _copyFileCMD() { - let context = getContext(); - let fullPath = context && context.fullPath; - if(!fullPath){ - fullPath = MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE); - } + let fullPath = _getCommandTargetPath(); if(fullPath){ _registerPathWithClipboard(fullPath, OPERATION_COPY); } diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index cb8da7106c..aae2ba885b 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -45,7 +45,8 @@ define(function (require, exports, module) { paneListTemplate = require("text!htmlContent/working-set.html"), Strings = require("strings"), _ = require("thirdparty/lodash"), - Mustache = require("thirdparty/mustache/mustache"); + Mustache = require("thirdparty/mustache/mustache"), + EventDispatcher = require("utils/EventDispatcher"); /** * Open view dictionary @@ -62,13 +63,6 @@ define(function (require, exports, module) { */ var _iconProviders = []; - /** - * The file/folder object of the current context - * @type {FileSystemEntry} - * @private - */ - var _contextEntry; - /** * Class Providers * @see {@link #addClassProvider} @@ -90,7 +84,8 @@ define(function (require, exports, module) { * @enum {number} */ var LEFT_BUTTON = 1, - MIDDLE_BUTTON = 2; + MIDDLE_BUTTON = 2, + RIGHT_BUTTON = 3; /** * Each list item in the working set stores a references to the related document in the list item's data. @@ -299,6 +294,7 @@ define(function (require, exports, module) { lastPageY = startPageY, lastHit = { where: NOMANSLAND }, tryClosing = $(e.target).hasClass("can-close"), + tryUnpinning = $(e.target).closest(".file-status-icon").hasClass("pinned"), currentFile = MainViewManager.getCurrentlyViewedFile(), activePaneId = MainViewManager.getActivePaneId(), activeView = _views[activePaneId], @@ -763,14 +759,12 @@ define(function (require, exports, module) { // re-enable stuff we turned off _suppressSortRedrawForAllViews(false); _suppressScrollShadowsOnAllViews(false); - } - // we don't need to refresh if the item - // was dragged but not enough to not change - // its order in the working set - if (!noRefresh) { - // rebuild the view - refresh(true); + // a plain click changes nothing in the list, rebuilding it would only lose the hover + // state of the row under the mouse. A drag that didn't change the order skips it too + if (!noRefresh) { + refresh(true); + } } // focus the editor MainViewManager.focusActivePane(); @@ -782,8 +776,18 @@ define(function (require, exports, module) { if (sourceView.paneId === currentView.paneId && startingIndex === $el.index()) { // if the item was dragged but not moved then don't open or close if (!dragged) { - // Click on close icon, or middle click anywhere - close the item without selecting it first - if (tryClosing || e.which === MIDDLE_BUTTON) { + // pinned items show a pin instead of the close icon, clicking it unpins the file. + // a middle click on a pinned item does nothing so pinned files don't close by accident + const isPinned = MainViewManager.isPathPinned(sourceView.paneId, sourceFile.fullPath); + if (tryUnpinning) { + CommandManager + .execute(Commands.FILE_PIN, {file: sourceFile, + paneId: sourceView.paneId, pinned: false}) + .always(function () { + postDropCleanup(); + }); + } else if (tryClosing || (e.which === MIDDLE_BUTTON && !isPinned)) { + // Click on close icon, or middle click anywhere - close the item without selecting it first CommandManager .execute(Commands.FILE_CLOSE, {file: sourceFile, paneId: sourceView.paneId}) @@ -862,9 +866,14 @@ define(function (require, exports, module) { // close all menus, and disable sorting Menus.closeAll(); - // Dragging only happens with the left mouse button - // or (on the Mac) when the ctrl key isn't pressed - if (e.which !== LEFT_BUTTON || (e.ctrlKey && brackets.platform === "mac")) { + // a right click is for the context menu, it neither selects nor opens the item + if (e.which === RIGHT_BUTTON || (e.ctrlKey && brackets.platform === "mac")) { + preDropCleanup(); + return; + } + + // Dragging only happens with the left mouse button, a middle click closes the item + if (e.which !== LEFT_BUTTON) { drop(); return; } @@ -873,7 +882,7 @@ define(function (require, exports, module) { // editor tab bar do, so switching files feels fast instead of waiting // for mouse up. A drag can still follow - the item just becomes the // current file as soon as it is pressed. - if (!tryClosing) { + if (!tryClosing && !tryUnpinning) { openedOnMouseDown = true; FileViewController.setFileViewFocus(FileViewController.WORKING_SET_VIEW); CommandManager.execute(Commands.FILE_OPEN, {fullPath: sourceFile.fullPath, @@ -904,11 +913,46 @@ define(function (require, exports, module) { this.init(); } + /** + * The file the pane is showing without it being in the working set (opened with a single click in + * the file tree). It gets a placeholder item at the end of the list, the same way the tab bar + * shows a placeholder tab for it. + * @private + * @return {?File} + */ + WorkingSetView.prototype._getPlaceholderFile = function () { + return this._pane.getTemporaryViewFile(); + }; + + /** + * Keeps the placeholder item in step with the file the pane shows + * @private + */ + WorkingSetView.prototype._syncPlaceholder = function () { + const $placeholder = this.$openFilesList.children("li.placeholder"), + file = this._getPlaceholderFile(), + shownPath = $placeholder.length ? $placeholder.data(_FILE_KEY).fullPath : null; + + if ((file ? file.fullPath : null) === shownPath) { + return; + } + + $placeholder.remove(); + if (file) { + this._createNewListItem(file, true); + } + this._updateVisibility(); + this._updateItemClasses(); + this._adjustForScrollbars(); + this._updateListSelection(); + }; + /** * Hides or shows the WorkingSetView * @private */ WorkingSetView.prototype._updateVisibility = function () { + // a placeholder alone keeps a hidden working set hidden, showing it would push the file tree under the mouse var fileList = MainViewManager.getWorkingSet(this.paneId); if (MainViewManager.getPaneCount() === 1 && (!fileList || fileList.length === 0)) { this.$openFilesContainer.hide(); @@ -976,10 +1020,6 @@ define(function (require, exports, module) { * @private */ WorkingSetView.prototype._scrollSelectedFileIntoView = function () { - if (!_hasSelectionFocus()) { - return; - } - var file = MainViewManager.getCurrentlyViewedFile(this.paneId); var $selectedFile = this._findListItemFromFile(file); @@ -1134,6 +1174,15 @@ define(function (require, exports, module) { externalProjectFiles = [], fileList = MainViewManager.getWorkingSet(MainViewManager.ALL_PANES); + // the placeholder items take part as well, their files are shown but not in the working sets + MainViewManager.getPaneIdList().forEach(function (paneId) { + const file = MainViewManager.getCurrentlyViewedFile(paneId); + const isPlaceholder = file && MainViewManager.findInWorkingSet(paneId, file.fullPath) === -1; + if (isPlaceholder && fileList.indexOf(file) === -1) { + fileList.push(file); + } + }); + // We need to always clear current directories as files could be removed from working tree. this.$openFilesContainer.find("ul > li > a > span.directory").remove(); @@ -1183,15 +1232,21 @@ define(function (require, exports, module) { }; /** - * Updates the appearance of the list element based on the parameters provided + * Updates the appearance of the list element based on the parameters provided. + * The status icon slot shows one thing at a time: the close icon while hovering an unpinned item, + * the pin icon for a pinned item (hovering or not dirty), otherwise the dirty dot if the file is dirty. * @private * @param {!HTMLLIElement} listElement * @param {bool} isDirty - * @param {bool} canClose + * @param {bool} canClose - true while the mouse is over the item + * @param {bool} isPinned */ - WorkingSetView.prototype._updateFileStatusIcon = function (listElement, isDirty, canClose) { + WorkingSetView.prototype._updateFileStatusIcon = function (listElement, isDirty, canClose, isPinned) { var $fileStatusIcon = listElement.find(".file-status-icon"), - showIcon = isDirty || canClose; + showPin = isPinned && (canClose || !isDirty), + showClose = canClose && !isPinned, + showDirty = isDirty && !showPin && !showClose, + showIcon = showPin || showClose || showDirty; // remove icon if its not needed if (!showIcon && $fileStatusIcon.length !== 0) { @@ -1207,8 +1262,16 @@ define(function (require, exports, module) { // Set icon's class if ($fileStatusIcon) { - ViewUtils.toggleClass($fileStatusIcon, "dirty", isDirty); - ViewUtils.toggleClass($fileStatusIcon, "can-close", canClose); + ViewUtils.toggleClass($fileStatusIcon, "dirty", showDirty); + ViewUtils.toggleClass($fileStatusIcon, "can-close", showClose); + ViewUtils.toggleClass($fileStatusIcon, "pinned", showPin); + if (showPin) { + if ($fileStatusIcon.children(".pinned-file-icon").length === 0) { + $fileStatusIcon.html("").attr("title", Strings.CMD_FILE_UNPIN); + } + } else if ($fileStatusIcon.children().length !== 0) { + $fileStatusIcon.empty().removeAttr("title"); + } } }; @@ -1235,9 +1298,10 @@ define(function (require, exports, module) { * Builds the UI for a new list item and inserts in into the end of the list * @private * @param {File} file + * @param {boolean=} isPlaceholder - true for the file the pane shows without it being in the working set * @return {HTMLLIElement} newListItem */ - WorkingSetView.prototype._createNewListItem = function (file) { + WorkingSetView.prototype._createNewListItem = function (file, isPlaceholder) { var self = this, selectedFile = MainViewManager.getCurrentlyViewedFile(this.paneId), data = {fullPath: file.fullPath, @@ -1270,20 +1334,51 @@ define(function (require, exports, module) { }); // Update the listItem's apperance - this._updateFileStatusIcon($newItem, _isOpenAndDirty(file), false); + this._updateFileStatusIcon($newItem, _isOpenAndDirty(file), false, this._isPinned(file)); _updateListItemSelection($newItem, selectedFile); - _makeDraggable($newItem); + if (isPlaceholder) { + // a placeholder can't be dragged around as it has no place in the working set yet. + // Clicking it keeps the file, like clicking the placeholder tab does + $newItem.addClass("placeholder"); + $newItem.on("mousedown", function (e) { + e.preventDefault(); + e.stopPropagation(); + Menus.closeAll(); + if (e.which === MIDDLE_BUTTON || (e.which === LEFT_BUTTON && $(e.target).hasClass("can-close"))) { + CommandManager.execute(Commands.FILE_CLOSE, {file: file, paneId: self.paneId}); + } else if (e.which === LEFT_BUTTON && !(e.ctrlKey && brackets.platform === "mac")) { + FileViewController.openFileAndAddToWorkingSet(file.fullPath, self.paneId); + } + }); + } else { + _makeDraggable($newItem); + } + + // the hover state is remembered on the item as the status icon alone can't tell anymore, + // a pinned item shows its pin whether the mouse is over it or not $newItem.hover( function () { - self._updateFileStatusIcon($(this), _isOpenAndDirty(file), true); + $(this).data("hovered", true); + self._updateFileStatusIcon($(this), _isOpenAndDirty(file), true, self._isPinned(file)); }, function () { - self._updateFileStatusIcon($(this), _isOpenAndDirty(file), false); + $(this).data("hovered", false); + self._updateFileStatusIcon($(this), _isOpenAndDirty(file), false, self._isPinned(file)); } ); }; + /** + * Whether the file is pinned in this view's pane + * @private + * @param {!File} file + * @return {boolean} + */ + WorkingSetView.prototype._isPinned = function (file) { + return MainViewManager.isPathPinned(this.paneId, file.fullPath); + }; + /** * Deletes all the list items in the view and rebuilds them from the working set model * @private @@ -1298,6 +1393,11 @@ define(function (require, exports, module) { self._createNewListItem(file); }); + const placeholderFile = this._getPlaceholderFile(); + if (placeholderFile) { + this._createNewListItem(placeholderFile, true); + } + if (forceRedraw) { self._redraw(); } @@ -1392,9 +1492,10 @@ define(function (require, exports, module) { // without having to move the mouse, if there is a next file. var $nextListItem = $listItem.next(); if ($nextListItem && $nextListItem.length > 0) { - var canClose = ($listItem.find(".can-close").length === 1); - var isDirty = _isOpenAndDirty($nextListItem.data(_FILE_KEY)); - this._updateFileStatusIcon($nextListItem, isDirty, canClose); + const canClose = ($listItem.data("hovered") === true); + const nextFile = $nextListItem.data(_FILE_KEY); + const isDirty = _isOpenAndDirty(nextFile); + this._updateFileStatusIcon($nextListItem, isDirty, canClose, this._isPinned(nextFile)); } $listItem.remove(); } @@ -1455,8 +1556,8 @@ define(function (require, exports, module) { WorkingSetView.prototype._handleDirtyFlagChanged = function (e, doc) { var listItem = this._findListItemFromFile(doc.file); if (listItem) { - var canClose = $(listItem).find(".can-close").length === 1; - this._updateFileStatusIcon(listItem, doc.isDirty, canClose); + const canClose = $(listItem).data("hovered") === true; + this._updateFileStatusIcon(listItem, doc.isDirty, canClose, this._isPinned(doc.file)); } }; @@ -1474,12 +1575,50 @@ define(function (require, exports, module) { } }; + /** + * Marks the item a context menu is open for, so it is clear what the menu acts on while the + * selection stays where it was + * @private + * @param {jQuery.Event} e - event object + * @param {?{file: File, paneId: string}} context - the file the menu is open for + */ + WorkingSetView.prototype._handleWorkingSetContextChange = function (e, context) { + this.$openFilesList.children("li.context").removeClass("context"); + if (context && context.paneId === this.paneId) { + const $listItem = this._findListItemFromFile(context.file); + if ($listItem) { + $listItem.addClass("context"); + } + } + }; + + /** + * workingSetMove event handler, a file moved from one pane's working set to the other's + * @private + * @param {jQuery.Event} e - event object + * @param {!File} file - the file that moved + * @param {!string} sourcePaneId - the pane the file left + * @param {!string} destinationPaneId - the pane the file went to + */ + WorkingSetView.prototype._handleWorkingSetMove = function (e, file, sourcePaneId, destinationPaneId) { + // a drag within the working set itself refreshes all the views once the drop is done + if (this.suppressSortRedraw) { + return; + } + if (this.paneId === sourcePaneId || this.paneId === destinationPaneId) { + this._rebuildViewList(true); + } else { + this._checkForDuplicateAndNonProjectFiles(); + } + }; + /** * Initializes the WorkingSetView object * @private */ WorkingSetView.prototype.init = function () { + this._pane = MainViewManager._getPane(this.paneId); this.$openFilesContainer = this.$el.find(".open-files-container"); this.$workingSetListViewHeader = this.$el.find(".working-set-header"); @@ -1494,10 +1633,18 @@ define(function (require, exports, module) { MainViewManager.on(this._makeEventName("activePaneChange"), _.bind(this._handleActivePaneChange, this)); MainViewManager.on(this._makeEventName("paneLayoutChange"), _.bind(this._handlePaneLayoutChange, this)); MainViewManager.on(this._makeEventName("workingSetUpdate"), _.bind(this._handleWorkingSetUpdate, this)); + MainViewManager.on(this._makeEventName("workingSetMove"), _.bind(this._handleWorkingSetMove, this)); + + // the placeholder item follows the file the pane shows. MainViewManager only announces the + // active pane's file changes, so the pane itself is asked for the rest + MainViewManager.on(this._makeEventName("currentFileChange"), _.bind(this._syncPlaceholder, this)); + this._pane.on(this._makeEventName("currentViewChange"), _.bind(this._syncPlaceholder, this)); DocumentManager.on(this._makeEventName("dirtyFlagChange"), _.bind(this._handleDirtyFlagChanged, this)); FileViewController.on(this._makeEventName("documentSelectionFocusChange") + " " + this._makeEventName("fileViewFocusChange"), _.bind(this._updateListSelection, this)); + FileViewController.on(this._makeEventName("workingSetContextChange"), + _.bind(this._handleWorkingSetContextChange, this)); // Show scroller shadows when open-files-container scrolls ViewUtils.addScrollerShadow(this.$openFilesContainer[0], null, true); @@ -1506,8 +1653,11 @@ define(function (require, exports, module) { // Disable horizontal scrolling until WebKit bug #99379 is fixed this.$openFilesContainer.css("overflow-x", "hidden"); + const self = this; this.$openFilesContainer.on("contextmenu.workingSetView", function (e) { - _contextEntry = $(e.target).closest("li").data(_FILE_KEY); + // the menu acts on the right-clicked item, the selection stays where it is + const file = $(e.target).closest("li").data(_FILE_KEY); + FileViewController.setWorkingSetContext(file || null, self.paneId); Menus.getContextMenu(Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU).open(e); }); @@ -1523,6 +1673,7 @@ define(function (require, exports, module) { this.$openFilesContainer.off(".workingSetView"); this.$el.remove(); MainViewManager.off(this._makeEventName("")); + this._pane.off(this._makeEventName("")); DocumentManager.off(this._makeEventName("")); FileViewController.off(this._makeEventName("")); }; @@ -1563,6 +1714,7 @@ define(function (require, exports, module) { // build all views so the provider has a chance to add icons // to all items that have already been created refresh(true); + exports.trigger("providersChange"); } /** @@ -1586,6 +1738,7 @@ define(function (require, exports, module) { // build all views so the provider has a chance to style // all items that have already been created refresh(true); + exports.trigger("providersChange"); } AppInit.htmlReady(function () { @@ -1644,13 +1797,28 @@ define(function (require, exports, module) { } /** - * Gets the filesystem object for the current context in the working set. + * Gets the file a working set or tab bar context menu is open for. Right-clicking an item doesn't + * select it, so this is what the menu commands act on. + * @return {?File} */ function getContext() { - return _contextEntry; + const context = FileViewController.getWorkingSetContext(); + return context ? context.file : undefined; + } + + /** + * Gets the id of the pane the file of `getContext()` is shown or listed in + * @return {?string} + */ + function getContextPaneId() { + const context = FileViewController.getWorkingSetContext(); + return context ? context.paneId : undefined; } // Public API + // the tab bar draws its icons with the same providers, so it is told when new ones arrive + EventDispatcher.makeEventDispatcher(exports); + exports.createWorkingSetViewForPane = createWorkingSetViewForPane; exports.refresh = refresh; exports.refreshSelection = refreshSelection; @@ -1658,6 +1826,7 @@ define(function (require, exports, module) { exports.addClassProvider = addClassProvider; exports.syncSelectionIndicator = syncSelectionIndicator; exports.getContext = getContext; + exports.getContextPaneId = getContextPaneId; // API to be used only by default extensions exports.useIconProviders = useIconProviders; diff --git a/src/styles/Extn-TabBar.less b/src/styles/Extn-TabBar.less index 59655a39fd..6e751f0ca2 100644 --- a/src/styles/Extn-TabBar.less +++ b/src/styles/Extn-TabBar.less @@ -1,16 +1,14 @@ .tab-bar-container { display: flex; align-items: center; - background-color: #f5f5f5; - border-bottom: none; position: relative; overflow: hidden; z-index: 2; -} + background-color: #f5f5f5; -.dark .tab-bar-container { - background-color: #1E1E1E; - border-bottom: none; + .dark & { + background-color: #1E1E1E; + } } .phoenix-tab-bar { @@ -20,28 +18,22 @@ overflow-x: auto; overflow-y: hidden; white-space: nowrap; - transition: height 0.3s ease; scroll-behavior: auto; - background-color: #f5f5f5; + + &::-webkit-scrollbar { + display: none; + } .bd-icon { color: #666; + .dark & { color: unset; } } } -.dark .phoenix-tab-bar { - background-color: #1E1E1E; -} - -.phoenix-tab-bar::-webkit-scrollbar { - display: none; -} - -.overflow-button, -.overflow-button-2 { +.overflow-button { display: flex; align-items: center; height: 2.1rem; @@ -50,41 +42,40 @@ position: relative; z-index: 2; margin-left: auto; -} -.overflow-button.hidden, -.overflow-button-2.hidden { - display: none; -} + i { + pointer-events: none; + } -.overflow-button:hover, -.overflow-button-2:hover { - background-color: #e0e0e0; -} + &.hidden { + display: none; + } -.dark .overflow-button:hover, -.dark .overflow-button-2:hover { - background-color: #333; -} + &:hover { + background-color: #e0e0e0; -.overflow-button::before, -.overflow-button-2::before { - content: ""; - position: absolute; - top: 0; - left: -1rem; - width: 1rem; - height: 100%; - pointer-events: none; - background: linear-gradient(to right, rgba(245, 245, 245, 0), #f5f5f5); -} + .dark & { + background-color: #333; + } + } + + &::before { + content: ""; + position: absolute; + top: 0; + left: -1rem; + width: 1rem; + height: 100%; + pointer-events: none; + background: linear-gradient(to right, rgba(245, 245, 245, 0), #f5f5f5); -.dark .overflow-button::before, -.dark .overflow-button-2::before { - background: linear-gradient(to right, rgba(30, 30, 30, 0), #1E1E1E); + .dark & { + background: linear-gradient(to right, rgba(30, 30, 30, 0), #1E1E1E); + } + } } -.tab { +.phoenix-tab-bar .tab { display: inline-flex; align-items: center; padding: 0 0.5rem 0 0.6rem; @@ -96,221 +87,244 @@ flex: 0 0 auto; min-width: fit-content; user-select: none; - transition: transform 60ms ease, opacity 60ms ease; -} + transition: background-color 120ms ease-out, opacity 60ms ease; -.dark .tab { - background-color: #292929; - border-right: 1px solid rgba(255, 255, 255, 0.05); -} + .dark & { + background-color: #292929; + border-right: 1px solid rgba(255, 255, 255, 0.05); + } -.tab, -.tab-close, -.tab-icon, -.tab-name { - transition: all 120ms ease-out; -} + &::before { + content: "•"; + color: transparent; + font-size: 1.5rem; + position: absolute; + left: 0.4rem; + top: 0.28rem; + } -.tab-icon { - display: flex; - align-items: center; - margin-bottom: -2px; - margin-left: 0.7rem; -} + &.dirty::before { + color: #888; -.tab-name { - color: #555; - display: inline-flex; - align-items: center; - font-size: 0.875rem; - letter-spacing: 0.4px; - word-spacing: 0.75px; - white-space: nowrap; -} + .dark & { + color: #8D8D8E; + } + } -.dark .tab-name { - color: #aaa; -} + &:hover { + background-color: #e0e0e0; -.tab.active .tab-name { - color: #333; -} + .dark & { + background-color: #3b3a3a; + } + } -.dark .tab.active .tab-name { - color: #dedede; -} + &.active { + background-color: #fff; -.tab .tab-dirname { - font-size: 0.7rem; - opacity: 0.7; - font-weight: normal; - position: relative; - top: 0.05rem; -} + .dark & { + background-color: #1D1F21; + } -.tab.active { - background-color: #fff; -} + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 0.12rem; + background-color: #0078D7; + + .dark & { + background-color: #75BEFF; + } + } + } -.dark .tab.active { - background-color: #1D1F21; -} + &.placeholder::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 0.12rem; + background-color: #b4b2b2; -.tab:hover { - background-color: #e0e0e0; - cursor: pointer; -} + .dark & { + background-color: #666; + } + } -.dark .tab:hover { - background-color: #3b3a3a; -} + &.placeholder.active::after { + background-color: #0078D7; -.tab.active::after { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 0.12rem; - background-color: #0078D7; -} + .dark & { + background-color: #75BEFF; + } + } -.dark .tab.active::after { - background-color: #75BEFF; -} + &.dragging { + opacity: 0.7; + transform: scale(0.95); + cursor: grabbing; + z-index: 10000; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); -.tab.active-in-inactive-pane { - background-color: #fafafa; -} + .dark & { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } + } -.dark .tab.active-in-inactive-pane { - background-color: #252729; -} + &.drag-target, + &.context { + background-color: #e5e5e5; -.tab.active-in-inactive-pane::after { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 0.12rem; - background-color: #b4b2b2; + .dark & { + background-color: #383838; + } + } } -.dark .tab.active-in-inactive-pane::after { - background-color: #666; -} +.phoenix-tab-bar { + .tab-icon { + display: flex; + align-items: center; + margin-bottom: -2px; + margin-left: 0.7rem; + } -.tab::before { - content: "•"; - color: transparent; - font-size: 1.5rem; - position: absolute; - left: 0.4rem; - top: 0.28rem; -} + .tab-name { + color: #555; + display: inline-flex; + align-items: center; + font-size: 0.875rem; + letter-spacing: 0.4px; + word-spacing: 0.75px; + white-space: nowrap; -.tab.dirty::before { - color: #888; -} + .dark & { + color: #aaa; + } + } -.dark .tab.dirty::before { - color: #8D8D8E; -} + .tab.active .tab-name { + color: #333; -.tab.dirty .tab-icon { - margin-left: 0.7rem; -} + .dark & { + color: #dedede; + } + } -.tab-close { - font-size: 0.75rem; - padding: 0.1rem 0.4rem; - margin-left: 0.25rem; - color: #666; - transition: all 0.2s ease; - border-radius: 0.2rem; - visibility: hidden; - opacity: 0; - pointer-events: none; -} + .tab-dirname { + font-size: 0.7rem; + opacity: 0.7; + font-weight: normal; + position: relative; + top: 0.05rem; + } -.dark .tab-close { - color: #CCC; -} + .tab.placeholder .tab-name { + font-style: italic; + color: #999; + } -.tab:hover .tab-close, -.tab.active .tab-close { - visibility: visible; - opacity: 1; - pointer-events: auto; -} + .tab.placeholder.active .tab-name { + color: #777; -.tab-close:hover { - cursor: pointer; - background-color: rgba(0, 0, 0, 0.1); -} + .dark & { + color: #bbb; + } + } -.dark .tab-close:hover { - background-color: rgba(255, 255, 255, 0.1); -} + .tab-close, + .tab-pin { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.3rem; + height: 1.2rem; + margin-left: 0.25rem; + color: #666; + border-radius: 0.2rem; + transition: background-color 0.2s ease, opacity 0.2s ease; -.tab.placeholder .tab-name { - font-style: italic; - color: #999; -} + .dark & { + color: #CCC; + } -.dark .tab.placeholder .tab-name { - color: #999; -} + &:hover { + cursor: pointer; + background-color: rgba(0, 0, 0, 0.1); -.tab.placeholder.active .tab-name { - color: #777; -} + .dark & { + background-color: rgba(255, 255, 255, 0.1); + } + } + } -.dark .tab.placeholder.active .tab-name { - color: #bbb; -} + .tab-close { + font-size: 0.75rem; + visibility: hidden; + opacity: 0; + pointer-events: none; + } -.tab.placeholder::after { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 0.12rem; - background-color: #b4b2b2; -} + .tab:hover .tab-close, + .tab.active .tab-close { + visibility: visible; + opacity: 1; + pointer-events: auto; + } -.dark .tab.placeholder::after { - background-color: #666; -} + .tab-pin { + font-size: 0.8rem; + } -.tab.placeholder.active::after { - background-color: #0078D7; -} + .tab.git-modified > .tab-icon::before, + .tab.git-new > .tab-icon::before { + content: "|"; + position: absolute; + margin-left: -4px; + margin-bottom: 5px; + } -.dark .tab.placeholder.active::after { - background-color: #75BEFF; -} + .tab.git-modified > .tab-icon::before { + color: #f1ae1c; -.tab.dragging { - opacity: 0.7; - transform: scale(0.95); - cursor: grabbing !important; - z-index: 10000; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); -} + .dark & { + color: #E3B551; + } + } -.dark .tab.dragging { - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); -} + .tab.git-new > .tab-icon::before { + color: #69a01f; + + .dark & { + color: #91CC41; + } + } -.tab.drag-target { - background-color: #e5e5e5; + .tab.git-ignored .tab-name, + .tab.git-ignored .tab-dirname { + color: #868888 !important; + font-style: italic; + } } -.dark .tab.drag-target { - background-color: #383838; +.tab-bar-container.inactive-pane .phoenix-tab-bar .tab.active { + background-color: #fafafa; + + .dark & { + background-color: #252729; + } + + &::after { + background-color: #b4b2b2; + + .dark & { + background-color: #666; + } + } } .tab-drag-indicator { @@ -322,11 +336,11 @@ box-shadow: 0 0 3px rgba(0, 120, 215, 0.4); display: none; animation: pulse 2s infinite; -} -.dark .tab-drag-indicator { - background-color: rgba(117, 190, 255, 0.7); - box-shadow: 0 0 3px rgba(117, 190, 255, 0.4); + .dark & { + background-color: rgba(117, 190, 255, 0.7); + box-shadow: 0 0 3px rgba(117, 190, 255, 0.4); + } } @keyframes pulse { @@ -343,25 +357,36 @@ } } +.empty-pane-drop-target { + border: 1px solid #6db6ff !important; +} + .dropdown-tab-item { display: flex; align-items: center; cursor: pointer; justify-content: space-between; + .bd-icon { color: #666; - .dark &, .selected & { + + .dark &, + .selected & { color: unset; } } -} -.dropdown-tab-item:hover { - background-color: #e8f0fa; -} + &:hover { + background-color: #e8f0fa; + + .dark & { + background-color: #2A3B50; + } + } -.dark .dropdown-tab-item:hover { - background-color: #2A3B50; + &.placeholder-item .tab-name-container { + font-style: italic; + } } .tab-info-container { @@ -388,93 +413,42 @@ width: 0.2rem; display: inline-flex; margin: 0.125rem 0.6rem 0.4rem -0.2rem; -} -.dark .tab-dirty-icon-overflow { - color: #8D8D8E; -} + .dark & { + color: #8D8D8E; + } -.tab-dirty-icon-overflow.empty { - visibility: hidden; + &.empty { + visibility: hidden; + } } -.tab-close-icon-overflow { +.tab-close-icon-overflow, +.tab-pin-icon-overflow { font-size: 0.75rem; font-weight: 300; padding: 0.3rem 0.6rem; margin-left: 0.5rem; margin-top: -0.1rem; color: #666; - transition: all 0.2s ease; + transition: color 0.2s ease; border-radius: 0.25rem; -} - -.dark .tab-close-icon-overflow { - color: #CCC; -} - -.tab-close-icon-overflow .fa-solid.fa-times:hover { - color: #333; -} - -.dark .tab-close-icon-overflow .fa-solid.fa-times:hover { - color: #FFF; -} -.empty-pane-drop-target { - border: 1px solid #6db6ff !important; -} - -.dropdown-tab-item.placeholder-item .tab-name-container, -.tab-name-container.placeholder-name { - font-style: italic; -} - -/* this is for invisible extended drag zone for tabs */ -#tab-drag-extended-zone { - background-color: transparent; - pointer-events: all; -} - -/* ------ for the git status markers -------------- */ -.tab.git-modified > .tab-icon:before { - content: "|"; - color: #f1ae1c; - position: absolute; - margin-left: -4px; - margin-bottom: 5px; -} - -.dark .tab.git-modified > .tab-icon:before { - color: #E3B551; -} - -.tab.git-new > .tab-icon:before { - content: "|"; - color: #69a01f; - position: absolute; - margin-left: -4px; - margin-bottom: 5px; -} - -.dark .tab.git-new > .tab-icon:before { - color: #91CC41; -} - -.tab.git-ignored .tab-name { - color: #868888 !important; - font-style: italic; -} + .dark & { + color: #CCC; + } -.tab.git-ignored .tab-dirname { - color: #868888 !important; - font-style: italic; -} + &:hover { + color: #333; -.dark .tab.git-ignored .tab-name { - color: #868888 !important; + .dark & { + color: #FFF; + } + } } -.dark .tab.git-ignored .tab-dirname { - color: #868888 !important; +.tab-pin-icon-overflow { + font-size: 0.85rem; + display: inline-flex; + align-items: center; } diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 9c2f444a00..380ed99452 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1036,15 +1036,18 @@ a, img { .pane-header { opacity: 0.7; // quieten inactive pane header box-sizing: border-box; + display: flex; + align-items: center; border-bottom: 1px solid rgba(0, 0, 0, 0.05); padding: 5px 10px; background-color: @background; white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; &-text { - display: inline; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .dark & { @@ -1053,20 +1056,13 @@ a, img { } &-flipview-btn { - position: relative; display: none; - top: 0; - padding-top: 2px; - padding-right: 4px; - padding-left: 4px; - margin-left: 0; - margin-bottom: 0; + flex: 0 0 auto; + padding: 0 4px; .sprite-icon(0, 0, 13px, 13px, "images/flip-view-icons.svg"); background-origin: content-box; -webkit-transform: translateZ(0); // forces GPU mode for better filter rendering on retina transform: translateZ(0); // future proofing - z-index: 1; - vertical-align: middle; &:hover { background-image: url("images/flip-view-icons-hover.svg") @@ -1081,7 +1077,7 @@ a, img { } &.flipview-icon-right { - background-position: center -18px; + background-position: center -17px; } &.flipview-icon-bottom { @@ -1089,20 +1085,23 @@ a, img { } &.flipview-icon-left { - background-position: center -54px; + background-position: center -53px; } } &-close-btn { position: relative; display: none; + flex: 0 0 auto; + align-items: center; + justify-content: center; height: 16px; width: 16px; - float: right; - margin-top: -2px; + margin-left: auto; &:before { color: rgba(0, 0, 0, 0.5); + line-height: 16px; } &:hover:before { @@ -1122,11 +1121,11 @@ a, img { &:hover, &.always-show-header-buttons { > .pane-header-flipview-btn:not(.flipview-icon-none) { - display: inline-block; + display: block; } > .pane-header-close-btn { - display: inline; + display: flex; } } } @@ -1384,10 +1383,6 @@ a, img { #working-set-list-container { background: @bc-working-set-bg; display: block; - - > div:last-child ul { - padding-bottom: 21px; // Adds working set bottom padding to the last UL. - } } #working-set-list-container.working-set-hidden { @@ -1395,24 +1390,13 @@ a, img { } .working-set-header { - position: relative; - height: 19px; - padding: 10px 0 9px 12px; + display: flex; + align-items: center; + height: 30px; + padding: 0 0 0 12px; background: @bc-working-set-bg; color: @project-panel-text-2; - text-shadow: none; overflow: hidden; - - > span { - background: @bc-working-set-bg; - position: relative; - z-index: 9; - } - - > div { - background-color: @bc-working-set-bg; - opacity: 1; - } } #mainNavBar { @@ -1569,12 +1553,9 @@ a, img { pointer-events: none !important; } -.working-set-header-title{ - font-size: 15px; -} - // Keep project context typography consistent between Files and Layers. #project-title, +.working-set-header-title, .layers-tab-container .layers-files-project-name { font-family: @sansFontFamily; font-size: 15px; @@ -1586,11 +1567,10 @@ a, img { #project-files-header { display: flex; align-items: center; - border-top: none; - padding: 6px 0 6px 12px; + height: 30px; + padding: 0 0 0 12px; font-size: 15px; color: @project-panel-text-2; - background-color: @bc-project-files-bg; overflow: hidden; gap: .5em; @@ -1617,6 +1597,11 @@ a, img { ul { list-style-type: none; margin: 0 0 2px; // 2px bottom margin required to stop scrollbar from appearing when the cursor is hovering over the last opened file. + padding-bottom: 23px; + } + + ul:has(> li.placeholder) { + padding-bottom: 0; } li { @@ -1645,7 +1630,7 @@ a, img { display: block; height: 16px; line-height: 15px; - margin-left: 18px; + margin-left: 22px; padding: 4px 0 3px 0; cursor: default; @@ -1688,6 +1673,14 @@ a, img { z-index: @z-index-brackets-selection-extension; /* scroller-shadow appears above the extension */ } +.open-files-container li.placeholder a { + font-style: italic; +} + +.open-files-container li.context { + background: rgba(255, 255, 255, 0.06); +} + .filetree-context-extension { background: rgba(255, 255, 255, 0.06); box-sizing: border-box; @@ -1725,19 +1718,22 @@ a, img { background-size: 100%; background-repeat: no-repeat; height: 8px; - position: fixed; + position: sticky; z-index: @z-index-brackets-scroller-shadow; pointer-events: none; // serves as a "click through", so clicks go to the file tree behind (see #12684) + opacity: 0; &.top { + top: 0; + margin-bottom: -8px; #gradient > .vertical(rgba(0,0,0,0.18), rgba(0,0,0,0)); - background-position: 0 -8px; background-color: transparent; /* override background-color: @endColor from #gradient.vertical */ } &.bottom { + bottom: 0; + margin-top: -8px; #gradient > .vertical(rgba(0,0,0,0), rgba(0,0,0,0.12)); - background-position: 0 8px; background-color: transparent; /* override background-color: @endColor from #gradient.vertical */ } } @@ -1849,6 +1845,38 @@ a, img { &.can-close:active:before { color: rgba(255, 255, 255, 0.3); } + + &.pinned { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 23px; + + > i { + font-size: 12px; + color: rgba(255, 255, 255, 0.75); + } + + &:hover > i { + color: rgba(255, 255, 255, 0.95); + } + + &:active > i { + color: rgba(255, 255, 255, 0.4); + } + } +} + +.pinned-file-icon { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: middle; + background-color: currentColor; + -webkit-mask: url("images/pinned-icon.svg") center / contain no-repeat; + mask: url("images/pinned-icon.svg") center / contain no-repeat; } /* Styles for inline editors */ diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index f293063952..a1521b35c8 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -132,6 +132,7 @@ a:focus { position: absolute; z-index: @z-index-brackets-drag-ghost; background-color: transparent; + pointer-events: none; text-shadow: 0 1px 2px @bc-shadow-large; @@ -148,6 +149,10 @@ a:focus { background-color: @bc-sidebar-selection; } +#working-set-list-container.dragging .file-status-icon.can-close:before { + visibility: hidden; +} + /* Toolbar-related styles */ /* Simple toolbar layout (project panel, find in files, etc.) @@ -610,6 +615,7 @@ a:focus { .keyboard-icon { visibility: hidden; + margin-left: 15px; } .menu-shortcut { diff --git a/src/styles/images/pinned-icon.svg b/src/styles/images/pinned-icon.svg new file mode 100644 index 0000000000..07798e001c --- /dev/null +++ b/src/styles/images/pinned-icon.svg @@ -0,0 +1 @@ + diff --git a/src/utils/ViewUtils.js b/src/utils/ViewUtils.js index 7e5f4ca589..088ceefd30 100644 --- a/src/utils/ViewUtils.js +++ b/src/utils/ViewUtils.js @@ -28,7 +28,8 @@ define(function (require, exports, module) { ProjectManager = require("project/ProjectManager"), LanguageManager = require("language/LanguageManager"); - var SCROLL_SHADOW_HEIGHT = 5; + // keep in step with the .scroller-shadow height in brackets.less + const SCROLL_SHADOW_HEIGHT = 8; /** * @private @@ -36,61 +37,41 @@ define(function (require, exports, module) { var _resizeHandlers = []; /** - * Positions shadow background elements to indicate vertical scrolling. + * Fades the shadow elements in and out to indicate vertical scrolling. * * @private - * @param {!DOMElement} $displayElement the DOMElement that displays the shadow * @param {!Object} $scrollElement the object that is scrolled * @param {!DOMElement} $shadowTop div .scroller-shadow.top * @param {!DOMElement} $shadowBottom div .scroller-shadow.bottom - * @param {boolean} isPositionFixed When using absolute position, top remains at 0. */ - function _updateScrollerShadow($displayElement, $scrollElement, $shadowTop, $shadowBottom, isPositionFixed) { - var offsetTop = 0, - scrollElement = $scrollElement.get(0), - scrollTop = scrollElement.scrollTop, - topShadowOffset = Math.min(scrollTop - SCROLL_SHADOW_HEIGHT, 0), - displayElementWidth = $displayElement.width(); + function _updateScrollerShadow($scrollElement, $shadowTop, $shadowBottom) { + const scrollElement = $scrollElement.get(0), + scrollTop = scrollElement.scrollTop; if ($shadowTop) { - $shadowTop.css("background-position", "0px " + topShadowOffset + "px"); - - if (isPositionFixed) { - offsetTop = $displayElement.offset().top; - $shadowTop.css("top", offsetTop); - } - - if (isPositionFixed) { - $shadowTop.css("width", displayElementWidth); - } + $shadowTop.css("opacity", Math.min(scrollTop / SCROLL_SHADOW_HEIGHT, 1)); } if ($shadowBottom) { - var clientHeight = scrollElement.clientHeight, - outerHeight = $displayElement.outerHeight(), - scrollHeight = scrollElement.scrollHeight, - bottomShadowOffset = SCROLL_SHADOW_HEIGHT; // outside of shadow div viewport - - if (scrollHeight > clientHeight) { - bottomShadowOffset -= Math.min(SCROLL_SHADOW_HEIGHT, (scrollHeight - (scrollTop + clientHeight))); - } - - $shadowBottom.css("background-position", "0px " + bottomShadowOffset + "px"); - $shadowBottom.css("top", offsetTop + outerHeight - SCROLL_SHADOW_HEIGHT); - $shadowBottom.css("width", displayElementWidth); + const remaining = scrollElement.scrollHeight - (scrollTop + scrollElement.clientHeight); + $shadowBottom.css("opacity", Math.min(Math.max(remaining, 0) / SCROLL_SHADOW_HEIGHT, 1)); } } - function getOrCreateShadow($displayElement, position, isPositionFixed) { + function getOrCreateShadow($displayElement, position, isSticky) { var $findShadow = $displayElement.find(".scroller-shadow." + position); if ($findShadow.length === 0) { $findShadow = $(window.document.createElement("div")).addClass("scroller-shadow " + position); - $displayElement.append($findShadow); + // a sticky top shadow has to come first in the flow to stick to the top of the scroller + if (isSticky && position === "top") { + $displayElement.prepend($findShadow); + } else { + $displayElement.append($findShadow); + } } - if (!isPositionFixed) { - // position is fixed by default + if (!isSticky) { $findShadow.css("position", "absolute"); $findShadow.css(position, "0"); } @@ -107,23 +88,23 @@ define(function (require, exports, module) { * @param {?boolean} showBottom optionally show the bottom shadow */ function addScrollerShadow(displayElement, scrollElement, showBottom) { - // use fixed positioning when the display and scroll elements are the same - var isPositionFixed = false; + // the shadows stick inside the scroller itself when the display and scroll elements are the same + let isSticky = false; if (!scrollElement) { scrollElement = displayElement; - isPositionFixed = true; + isSticky = true; } // update shadows when the scrolling element is scrolled var $displayElement = $(displayElement), $scrollElement = $(scrollElement); - var $shadowTop = getOrCreateShadow($displayElement, "top", isPositionFixed); - var $shadowBottom = (showBottom) ? getOrCreateShadow($displayElement, "bottom", isPositionFixed) : null; + const $shadowTop = getOrCreateShadow($displayElement, "top", isSticky); + const $shadowBottom = (showBottom) ? getOrCreateShadow($displayElement, "bottom", isSticky) : null; var doUpdate = function () { - _updateScrollerShadow($displayElement, $scrollElement, $shadowTop, $shadowBottom, isPositionFixed); + _updateScrollerShadow($scrollElement, $shadowTop, $shadowBottom); }; // remove any previously installed listeners on this node diff --git a/src/view/MainViewManager.js b/src/view/MainViewManager.js index b6eca09f39..f48888f212 100644 --- a/src/view/MainViewManager.js +++ b/src/view/MainViewManager.js @@ -62,7 +62,8 @@ * (e, fileRemoved:File, suppressRedraw:boolean, paneId:string) * - workingSetRemoveList -- When multiple files are removed from the working set * (e, filesRemoved:Array."File", paneId:string) - * - workingSetSort -- When a pane's view array is reordered without additions or removals. + * - workingSetSort -- When a pane's view array is reordered without additions or removals. Also fired when a + * file is pinned or unpinned, as pinned files are kept at the front of the working set. * (e, paneId:string) * - workingSetUpdate -- When changes happen due to system events such as a file being deleted. * listeners should discard all working set info and rebuilt it from the pane @@ -491,8 +492,10 @@ define(function (require, exports, module) { pane = _getPaneFromElement($container); if (pane) { - // Editor is a full editor - if (pane.id !== _activePaneId) { + // Editor is a full editor. Only the editor a pane is actually showing may activate the + // pane: a document that is open in both panes has an editor in each, and the one the + // other pane keeps hidden must not pull the activation over there. + if (pane.id !== _activePaneId && pane.getCurrentlyViewedEditor() === current) { // we just need to set the active pane in this case // it will dispatch the currentFileChange message as well // as dispatching other events when the active pane changes @@ -1002,6 +1005,46 @@ define(function (require, exports, module) { exports.trigger("_workingSetDisableAutoSort", pane.id); } + /** + * Pins or unpins a file in a pane. Pinned files are kept at the front of the working set, in the order the + * user arranged them, and the bulk close commands (close others, close all...) leave them alone. + * Pinning a file that is only being previewed (not in the working set yet) adds it to the working set first. + * Fires `workingSetSort` for the pane when the pinned state changes. + * @param {!string} paneId - the id of the pane the file is in or ACTIVE_PANE + * @param {!File} file - the file to pin or unpin + * @param {boolean} pinned - true to pin, false to unpin + * @return {boolean} true if the pinned state changed + */ + function setFilePinned(paneId, file, pinned) { + const pane = _getPane(paneId); + if (!pane || !file) { + return false; + } + + if (pinned && pane.findInViewList(file.fullPath) === -1) { + addToWorkingSet(pane.id, file); + } + + if (!pane.setPathPinned(file.fullPath, pinned)) { + return false; + } + + exports.trigger("workingSetSort", pane.id); + _scheduleViewStateSave(); + return true; + } + + /** + * Whether the file is pinned in the given pane + * @param {!string} paneId - the id of the pane or ACTIVE_PANE + * @param {!string} fullPath - the full path of the file + * @return {boolean} true if the file is pinned + */ + function isPathPinned(paneId, fullPath) { + const pane = _getPane(paneId); + return Boolean(pane && pane.isPathPinned(fullPath)); + } + /** * Get the next or previous file in the MRU list. * @param {!number} direction - Must be 1 or -1 to traverse forward or backward @@ -1808,6 +1851,8 @@ define(function (require, exports, module) { exports.addListToWorkingSet = addListToWorkingSet; exports.getWorkingSetSize = getWorkingSetSize; exports.getWorkingSet = getWorkingSet; + exports.setFilePinned = setFilePinned; + exports.isPathPinned = isPathPinned; // Pane state exports.cacheScrollState = cacheScrollState; diff --git a/src/view/Pane.js b/src/view/Pane.js index ea507d6a29..8629a426bc 100644 --- a/src/view/Pane.js +++ b/src/view/Pane.js @@ -485,6 +485,14 @@ define(function (require, exports, module) { */ Pane.prototype._viewListAddedOrder = []; + /** + * Full paths of the pinned files. Pinned files always sit at the front of the view list in the order + * the user arranged them, and the bulk close commands leave them alone. + * @private + * @type {Set} + */ + Pane.prototype._pinnedPaths = null; + /** * Dictionary mapping fullpath to view * @type {Object.} @@ -499,6 +507,13 @@ define(function (require, exports, module) { */ Pane.prototype._currentView = null; + /** + * Path of the closed file whose view stays up until the next file is ready to show + * @type {?string} + * @private + */ + Pane.prototype._removedCurrentPath = null; + /** * The last thing that received a focus event * @type {?DomElement} @@ -514,6 +529,7 @@ define(function (require, exports, module) { this._viewList = []; this._viewListMRUOrder = []; this._viewListAddedOrder = []; + this._pinnedPaths = new Set(); this._views = {}; this._currentView = null; this.showInterstitial(true); @@ -558,6 +574,20 @@ define(function (require, exports, module) { } }; + /** + * Gets the file of the current view when it isn't in the working set, a file opened from the file + * tree with a single click just to have a look at it. A file that was just closed stays up until + * the next one is ready but is on its way out, so it doesn't count + * @return {?File} + */ + Pane.prototype.getTemporaryViewFile = function () { + const file = this._currentView && this._currentView.getFile(); + if (!file || file.fullPath === this._removedCurrentPath || this.findInViewList(file.fullPath) !== -1) { + return null; + } + return file; + }; + /** * moves a view from one pane to another * @param {!File} file - the File to move @@ -616,6 +646,11 @@ define(function (require, exports, module) { self._viewListMRUOrder.splice(self.findInViewListMRUOrder(file.fullPath), 1); self._viewListAddedOrder.splice(self.findInViewListAddedOrder(file.fullPath), 1); + // a pinned file stays pinned when it moves to the other pane + if (self._pinnedPaths.delete(file.fullPath)) { + destinationPane._pinnedPaths.add(file.fullPath); + } + // insert the view into the working set destinationPane._addToViewList(file, _makeIndexRequestObject(true, destinationIndex)); @@ -662,6 +697,8 @@ define(function (require, exports, module) { this._viewList = _.union(this._viewList, other._viewList); this._viewListMRUOrder = _.union(this._viewListMRUOrder, other._viewListMRUOrder); this._viewListAddedOrder = _.union(this._viewListAddedOrder, other._viewListAddedOrder); + other._pinnedPaths.forEach((fullPath) => this._pinnedPaths.add(fullPath)); + this._settlePinnedOrder(); var self = this, viewsToDestroy = []; @@ -805,12 +842,11 @@ define(function (require, exports, module) { if (curIndex !== -1) { // File is in view list, but not at the specifically requested index - only need to reorder - if (force || (indexRequested && curIndex !== index)) { - var entry = this._viewList.splice(curIndex, 1)[0]; - this._viewList.splice(index, 0, entry); + if (indexRequested && curIndex !== index) { + this.moveWorkingSetItem(curIndex, index); return this.ITEM_FOUND_NEEDS_SORT; } - return this.ITEM_FOUND_NO_SORT; + return force ? this.ITEM_FOUND_NEEDS_SORT : this.ITEM_FOUND_NO_SORT; } return this.ITEM_NOT_FOUND; @@ -828,20 +864,37 @@ define(function (require, exports, module) { }; /** - * Adds the given file to the end of the workingset, if it is not already in the list + * Adds the given file to the workingset, if it is not already in the list * @private * @param {!File} file * @param {Object=} inPlace record with inPlace add data (index, indexRequested). Used internally + * @return {number} the index the file was actually added at */ Pane.prototype._addToViewList = function (file, inPlace) { + const pinnedCount = this.getPinnedCount(); + let index = this._viewList.length; + if (inPlace && inPlace.indexRequested) { - // If specified, insert into the workingset at this 0-based index - this._viewList.splice(inPlace.index, 0, file); - } else { - // If no index is specified, just add the file to the end of the workingset. - this._viewList.push(file); + // no index means the start of the list, which for us is the first spot after the pinned files. + // a requested index inside the pinned block means the file was dropped amongst the pinned + // files, so it becomes pinned as well + if (inPlace.index === undefined || inPlace.index === null) { + index = pinnedCount; + } else { + index = Math.max(0, Math.min(inPlace.index, this._viewList.length)); + } + if (index < pinnedCount) { + this._pinnedPaths.add(file.fullPath); + } + } + + // a file that is already pinned (moved in from the other pane) never lands behind an unpinned one + if (this._pinnedPaths.has(file.fullPath)) { + index = Math.min(index, pinnedCount); } + this._viewList.splice(index, 0, file); + // Add to MRU order: either first or last, depending on whether it's already the current doc or not var currentPath = this.getCurrentlyViewedPath(); if (currentPath && currentPath === file.fullPath) { @@ -852,6 +905,8 @@ define(function (require, exports, module) { // Add first to Added order this._viewListAddedOrder.unshift(file); + + return index; }; @@ -864,13 +919,7 @@ define(function (require, exports, module) { */ Pane.prototype.addToViewList = function (file, index) { var indexRequested = (index !== undefined && index !== null && index >= 0 && index < this._viewList.length); - this._addToViewList(file, _makeIndexRequestObject(indexRequested, index)); - - if (!indexRequested) { - index = this._viewList.length - 1; - } - - return index; + return this._addToViewList(file, _makeIndexRequestObject(indexRequested, index)); }; @@ -901,6 +950,7 @@ define(function (require, exports, module) { * @param {?View} oldView - the view being replaced */ Pane.prototype._notifyCurrentViewChange = function (newView, oldView) { + this._removedCurrentPath = null; this.updateHeaderText(); this.trigger("currentViewChange", newView, oldView); @@ -949,6 +999,7 @@ define(function (require, exports, module) { this._viewList.splice(index, 1); this._viewListMRUOrder.splice(this.findInViewListMRUOrder(file.fullPath), 1); this._viewListAddedOrder.splice(this.findInViewListAddedOrder(file.fullPath), 1); + this._pinnedPaths.delete(file.fullPath); } // Destroy the view @@ -957,6 +1008,10 @@ define(function (require, exports, module) { if (view) { if (!preventViewChange) { this._doDestroyView(view); + } else if (view === this._currentView) { + // the view stays up until the next file is ready, but it is on its way out so it + // must not be taken for a file that is just being looked at + this._removedCurrentPath = file.fullPath; } } @@ -976,22 +1031,46 @@ define(function (require, exports, module) { }; /** - * Sorts items in the pane's view list. + * Sorts items in the pane's view list. Pinned files are sorted amongst themselves and stay in front. * @param {function(string, string, string): number} compareFn - The function used to compare items in the view list. */ Pane.prototype.sortViewList = function (compareFn) { - this._viewList.sort(_.partial(compareFn, this.id)); + const self = this; + this._viewList.sort(function (file1, file2) { + const pinned1 = self._pinnedPaths.has(file1.fullPath), + pinned2 = self._pinnedPaths.has(file2.fullPath); + if (pinned1 !== pinned2) { + return pinned1 ? -1 : 1; + } + return compareFn(self.id, file1, file2); + }); }; /** * Moves a working set item from one index to another, shifting the items after * it in the working set up and reinserting it at the desired location. + * Landing in front of a pinned file pins the moved file and landing behind an unpinned + * file unpins it, so the pinned files always stay together at the front. * @param {!number} fromIndex - The index of the item to move. * @param {!number} toIndex - The index to move the item to. * @private */ Pane.prototype.moveWorkingSetItem = function (fromIndex, toIndex) { - this._viewList.splice(toIndex, 0, this._viewList.splice(fromIndex, 1)[0]); + const file = this._viewList.splice(fromIndex, 1)[0]; + if (!file) { + return; + } + + // with the file taken out, this is where the pinned files end + const pinnedCount = this.getPinnedCount(); + toIndex = Math.max(0, Math.min(toIndex, this._viewList.length)); + this._viewList.splice(toIndex, 0, file); + + if (toIndex < pinnedCount) { + this._pinnedPaths.add(file.fullPath); + } else if (toIndex > pinnedCount) { + this._pinnedPaths.delete(file.fullPath); + } }; /** @@ -1004,9 +1083,69 @@ define(function (require, exports, module) { var temp = this._viewList[index1]; this._viewList[index1] = this._viewList[index2]; this._viewList[index2] = temp; + this._settlePinnedOrder(); + return true; + }; + + /** + * Whether the file is pinned in this pane + * @param {!string} fullPath - the full path of the file + * @return {boolean} + */ + Pane.prototype.isPathPinned = function (fullPath) { + return this._pinnedPaths.has(fullPath); + }; + + /** + * Returns the number of pinned files. As pinned files are always kept at the front of the view list, + * this is also the index of the first unpinned file. + * @return {number} + */ + Pane.prototype.getPinnedCount = function () { + let count = 0; + while (count < this._viewList.length && this._pinnedPaths.has(this._viewList[count].fullPath)) { + count++; + } + return count; + }; + + /** + * Pins or unpins a file in the view list. Pinning moves the file to the end of the pinned block and + * unpinning moves it to the start of the unpinned block, so nothing else changes position. + * @param {!string} fullPath - the full path of the file, it must be in the view list + * @param {boolean} pinned - true to pin, false to unpin + * @return {boolean} true if the pinned state changed + */ + Pane.prototype.setPathPinned = function (fullPath, pinned) { + const index = this.findInViewList(fullPath); + if (index === -1 || this._pinnedPaths.has(fullPath) === pinned) { + return false; + } + + const file = this._viewList.splice(index, 1)[0]; + if (pinned) { + this._pinnedPaths.add(fullPath); + } else { + this._pinnedPaths.delete(fullPath); + } + this._viewList.splice(this.getPinnedCount(), 0, file); return true; }; + /** + * Moves the pinned files back to the front of the view list without changing the order within + * the pinned or the unpinned files + * @private + */ + Pane.prototype._settlePinnedOrder = function () { + const pinned = [], + unpinned = []; + this._viewList.forEach((file) => { + (this._pinnedPaths.has(file.fullPath) ? pinned : unpinned).push(file); + }); + this._viewList = pinned.concat(unpinned); + }; + /** * Traverses the list and returns the File object of the next item in the MRU order * @param {!number} direction - Must be 1 or -1 to traverse forward or backward @@ -1088,7 +1227,9 @@ define(function (require, exports, module) { // the File object's name has changed by the time we've gotten the event. // So, we need to look for the file by its new name to determine if // if we need to dispatch the event which may look funny - var dispatchEvent = (this.findInViewList(newname) >= 0); + // a file that is only being looked at has a tab and a working set row as well, so its rename + // is announced too + const dispatchEvent = (this.findInViewList(newname) >= 0) || this.getCurrentlyViewedPath() === newname; // rename the view if (this._views.hasOwnProperty(oldname)) { @@ -1098,6 +1239,16 @@ define(function (require, exports, module) { delete this._views[oldname]; } + // pinned paths are stored as strings so they have to follow the rename. For a folder rename + // oldname is the folder path and every pinned file inside it moves along + const isFolder = oldname.endsWith("/"); + Array.from(this._pinnedPaths).forEach((pinnedPath) => { + if (pinnedPath === oldname || (isFolder && pinnedPath.startsWith(oldname))) { + this._pinnedPaths.delete(pinnedPath); + this._pinnedPaths.add(newname + pinnedPath.slice(oldname.length)); + } + }); + this.updateHeaderText(); // dispatch the change event @@ -1531,10 +1682,20 @@ define(function (require, exports, module) { if (entry.viewState) { viewStates[entry.file] = entry.viewState; } + if (entry.pinned) { + self._pinnedPaths.add(entry.file); + } }); this.addListToViewList(filesToAdd); + // a saved pinned file may have been refused by addListToViewList (open in the other pane already) + Array.from(this._pinnedPaths).forEach(function (fullPath) { + if (self.findInViewList(fullPath) === -1) { + self._pinnedPaths.delete(fullPath); + } + }); + ViewStateManager.addViewStates(viewStates); activeFile = activeFile || getInitialViewFilePath(); @@ -1552,6 +1713,7 @@ define(function (require, exports, module) { */ Pane.prototype.saveState = function () { var result = [], + self = this, currentlyViewedPath = this.getCurrentlyViewedPath(); // Save the current view state first @@ -1568,11 +1730,15 @@ define(function (require, exports, module) { this._viewList.forEach(function (file) { // Do not persist untitled document paths if (!(file instanceof InMemoryFile)) { - result.push({ + const entry = { file: file.fullPath, active: (file.fullPath === currentlyViewedPath), viewState: ViewStateManager.getViewState(file) - }); + }; + if (self._pinnedPaths.has(file.fullPath)) { + entry.pinned = true; + } + result.push(entry); } }); diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index 64aaa72cee..31f671a297 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -18,7 +18,7 @@ * */ -/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, awaits, jsPromise */ +/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, jsPromise, spyOn */ define(function (require, exports, module) { const SpecRunnerUtils = require("spec/SpecRunnerUtils"); @@ -77,6 +77,25 @@ define(function (require, exports, module) { } } + /** + * Helper function to add files to a working set and open them, like the file tree double click does + * @param {string[]} filePaths - Array of file paths to add + * @param {string} [paneId] - Optional pane ID to add the files to + * @returns {Promise} + */ + async function addToWorkingSet(filePaths, paneId) { + for (const filePath of filePaths) { + const options = { fullPath: filePath }; + if (paneId) { + options.paneId = paneId; + } + await awaitsForDone( + CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, options), + `Add ${filePath} to the working set` + ); + } + } + /** * Helper function to wait for tabs to appear * @param {string[]} filePaths - Array of file paths to wait for @@ -232,6 +251,91 @@ define(function (require, exports, module) { return $(tabBarId).find(`.tab[data-path="${filePath}"]`).length > 0; } + /** + * Helper function to get an item of a context menu by its label + * @param {string} menuId - id of the menu element, like "tabbar-context-menu" + * @param {string} text - the label of the item + * @returns {jQuery} - The li element of the item + */ + function getMenuItemByText(menuId, text) { + return $(`#${menuId} .menu-name`).filter(function () { + return $(this).text().trim() === text; + }).closest("li"); + } + + /** + * Helper function to open the context menu of a tab, in whichever pane it is + * @param {jQuery} $tab - the tab element + * @returns {Promise} + */ + async function openContextMenuOfTab($tab) { + expect($tab.length).toBe(1); + $tab.trigger($.Event("contextmenu", { pageX: 100, pageY: 100 })); + await awaitsFor( + function () { + return $("#tabbar-context-menu").hasClass("open"); + }, + "Tab context menu to appear" + ); + } + + /** + * Helper function to open the context menu of a working set item + * @param {jQuery} $row - the li element of the item + * @returns {Promise} + */ + async function openContextMenuOfWorkingSetItem($row) { + expect($row.length).toBe(1); + $row.trigger($.Event("contextmenu", { pageX: 100, pageY: 200, target: $row.find("a")[0] })); + await awaitsFor( + function () { + return $("#workingset-context-menu").hasClass("open"); + }, + "Working set context menu to appear" + ); + } + + /** + * Helper function to get the working set item of a file in a pane's list + * @param {string} filePath - The path of the file + * @param {string} paneId - The pane ID ("first-pane" or "second-pane") + * @returns {jQuery} - The li element of the item + */ + function getWorkingSetItemInPane(filePath, paneId) { + return $(`#working-set-list-${paneId} li`).filter(function () { + const file = $(this).data("file"); + return file && file.fullPath === filePath; + }); + } + + /** + * Helper function to get the tab of a file in a pane's tab bar + * @param {string} filePath - The path of the file + * @param {string} paneId - The pane ID ("first-pane" or "second-pane") + * @returns {jQuery} - The tab element + */ + function getTabInPane(filePath, paneId) { + const tabBarId = paneId === "first-pane" ? "#phoenix-tab-bar" : "#phoenix-tab-bar-2"; + return $(tabBarId).find(`.tab[data-path="${filePath}"]`); + } + + /** + * Helper function to read a file from disk + * @param {string} filePath - The path of the file + * @returns {Promise} - The text of the file + */ + function readFileFromDisk(filePath) { + return new Promise(function (resolve, reject) { + FileSystem.getFileForPath(filePath).read(function (err, text) { + if (err) { + reject(err); + } else { + resolve(text); + } + }); + }); + } + beforeAll(async function () { // Create the test window testWindow = await SpecRunnerUtils.createTestWindowAndRun(); @@ -1114,6 +1218,71 @@ define(function (require, exports, module) { }); }); + describe("Tab Reuse", function () { + beforeEach(async function () { + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + await testWindow.closeAllFiles(); + await awaitsFor( + function () { + return getTabCount() === 0; + }, + "Tab bar to update with no tabs" + ); + }); + + it("should keep the existing tab elements when files are opened, switched and closed", async function () { + await openTestFiles([testFilePath]); + await waitForTabs([testFilePath]); + const firstTab = getTab(testFilePath)[0]; + + // opening another file only adds a tab, the existing one is left alone + await openTestFiles([testFilePath2]); + await waitForTabs([testFilePath, testFilePath2]); + await awaitsFor( + function () { + return isTabActive(testFilePath2); + }, + "Second tab to become active" + ); + const secondTab = getTab(testFilePath2)[0]; + expect(getTab(testFilePath)[0]).toBe(firstTab); + + // switching files moves the active marker, the tabs stay the same elements + await openTestFiles([testFilePath]); + await awaitsFor( + function () { + return isTabActive(testFilePath); + }, + "First tab to become active" + ); + expect(getTab(testFilePath)[0]).toBe(firstTab); + expect(getTab(testFilePath2)[0]).toBe(secondTab); + + // a dirty flag change only toggles a class on the tab + const doc = DocumentManager.getOpenDocumentForPath(testFilePath); + doc.setText("// Modified content"); + await awaitsFor( + function () { + return isTabDirty(testFilePath); + }, + "Dirty indicator to appear" + ); + expect(getTab(testFilePath)[0]).toBe(firstTab); + doc.setText("// Test file 1 for TabBar"); + await awaitsForDone(CommandManager.execute(Commands.FILE_SAVE, { doc: doc }), "Save file"); + + // closing a file removes just its tab + await closeTestFiles([testFilePath2]); + await awaitsFor( + function () { + return !tabExists(testFilePath2); + }, + "Second tab to disappear" + ); + expect(getTab(testFilePath)[0]).toBe(firstTab); + }); + }); + describe("Active Tab", function () { beforeEach(async function () { // Enable the tab bar feature @@ -1432,6 +1601,33 @@ define(function (require, exports, module) { await closeTestFiles(testFiles); }); + it("should hide the overflow button once the tabs fit and never open an empty menu", async function () { + const testFiles = await createTestFiles(15, "overflow-test", "// Overflow test file"); + await openTestFiles(testFiles); + await waitForTabs(testFiles); + await awaitsFor( + function () { + return isOverflowButtonVisible(); + }, + "Overflow button to appear" + ); + + await closeTestFiles(testFiles.slice(3)); + await awaitsFor( + function () { + return getTabCount() === 3 && !isOverflowButtonVisible(); + }, + "Overflow button to hide once the tabs fit" + ); + + // the click handler of the button is still there, but with no hidden tab there is no menu to show + expect(getOverflowDropdown().length).toBe(0); + getOverflowButton().click(); + expect(getOverflowDropdown().length).toBe(0); + + await closeTestFiles(testFiles.slice(0, 3)); + }); + it("should scroll tab bar to make selected file visible when selecting from working set", async function () { // Create several test files to ensure overflow const testFiles = await createTestFiles(15, "overflow-test", "// Overflow test file"); @@ -1546,6 +1742,27 @@ define(function (require, exports, module) { expect(hasFileIcon(testFilePath)).toBe(true); }); + it("should draw the icons of a provider registered after the tabs were made", async function () { + await openTestFiles([testFilePath]); + await waitForTabs([testFilePath]); + expect(getTab(testFilePath).find(".late-test-icon").length).toBe(0); + + // a provider can't be unregistered, so it is switched off again once the spec is done + let lateIconActive = true; + testWindow.brackets.test.WorkingSetView.addIconProvider(function (data) { + if (lateIconActive && data.fullPath === testFilePath) { + return ""; + } + }, 100); + await awaitsFor( + function () { + return getTab(testFilePath).find(".late-test-icon").length === 1; + }, + "Late icon provider to draw on the existing tab" + ); + lateIconActive = false; + }); + it("should display a dirty indicator when the file is modified and remove it when saved", async function () { // Open the first test file await awaitsForDone( @@ -1846,6 +2063,8 @@ define(function (require, exports, module) { // Get the tab element const $tab = getTab(testFilePath); expect($tab.length).toBe(1); + const currentBefore = MainViewManager.getCurrentlyViewedPath("first-pane"); + expect(currentBefore).toBe(testFilePath3); // Simulate a right-click (contextmenu) event on the tab const event = $.Event("contextmenu", { @@ -1862,8 +2081,11 @@ define(function (require, exports, module) { "Context menu to appear" ); - // Verify the context menu is open + // Verify the context menu is open, for the right-clicked tab, while the current file stays put expect(getContextMenu().hasClass("open")).toBe(true); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(currentBefore); + expect(isTabActive(testFilePath3)).toBe(true); + expect($tab.hasClass("context")).toBe(true); // Clean up - close the context menu by clicking elsewhere $("body").click(); @@ -2089,7 +2311,7 @@ define(function (require, exports, module) { expect(getTabCount()).toBe(1); }); - it("should close saved tabs when selecting 'Close saved tabs' from context menu", async function () { + it("should close all tabs when selecting 'Close all tabs' from context menu", async function () { // Open all three test files await awaitsForDone( CommandManager.execute(Commands.FILE_OPEN, { fullPath: testFilePath }), @@ -2117,21 +2339,6 @@ define(function (require, exports, module) { expect(tabExists(testFilePath2)).toBe(true); expect(tabExists(testFilePath3)).toBe(true); - // Make the second file dirty - const doc2 = DocumentManager.getOpenDocumentForPath(testFilePath2); - doc2.setText("// Modified content"); - - // Wait for the dirty indicator to appear - await awaitsFor( - function () { - return isTabDirty(testFilePath2); - }, - "Dirty indicator to appear" - ); - - // Verify the second tab is dirty - expect(isTabDirty(testFilePath2)).toBe(true); - // Get any tab element (we'll use the first) const $tab = getTab(testFilePath); @@ -2157,144 +2364,1012 @@ define(function (require, exports, module) { "Context menu to appear" ); - // Find and click the "Close Saved Tabs" option - const $closeSavedTabsOption = getContextMenu() + // Find and click the "Close All Tabs" option + const $closeAllTabsOption = getContextMenu() .find(".menu-name") .filter(function () { - return $(this).text().trim() === Strings.CLOSE_SAVED_TABS; + return $(this).text().trim() === Strings.CLOSE_ALL_TABS; }) .closest("li"); - expect($closeSavedTabsOption.length).toBe(1); - $closeSavedTabsOption.click(); + expect($closeAllTabsOption.length).toBe(1); + $closeAllTabsOption.click(); // Cancel any save dialogs that might appear cancelSaveDialog(); - // Verify only the dirty tab remains + // Verify all tabs are closed await awaitsFor( function () { - return !tabExists(testFilePath) && tabExists(testFilePath2) && !tabExists(testFilePath3); + return !tabExists(testFilePath) && !tabExists(testFilePath2) && !tabExists(testFilePath3); }, - "Saved tabs to be closed" + "All tabs to be closed" ); expect(tabExists(testFilePath)).toBe(false); - expect(tabExists(testFilePath2)).toBe(true); + expect(tabExists(testFilePath2)).toBe(false); expect(tabExists(testFilePath3)).toBe(false); - expect(getTabCount()).toBe(1); - expect(isTabDirty(testFilePath2)).toBe(true); - - // Clean up - revert changes to the second file - doc2.setText("// Test file 2 for TabBar"); - await awaitsForDone( - CommandManager.execute(Commands.FILE_SAVE, { doc: doc2 }), - "Save file with original content" - ); + expect(getTabCount()).toBe(0); }); + }); - it("should close all tabs when selecting 'Close all tabs' from context menu", async function () { - // Open all three test files - await awaitsForDone( - CommandManager.execute(Commands.FILE_OPEN, { fullPath: testFilePath }), - "Open first test file" - ); - await awaitsForDone( - CommandManager.execute(Commands.FILE_OPEN, { fullPath: testFilePath2 }), - "Open second test file" - ); - await awaitsForDone( - CommandManager.execute(Commands.FILE_OPEN, { fullPath: testFilePath3 }), - "Open third test file" - ); - - // Wait for all tabs to appear - await awaitsFor( - function () { - return tabExists(testFilePath) && tabExists(testFilePath2) && tabExists(testFilePath3); - }, - "All tabs to appear" - ); - - // Verify all three tabs exist - expect(tabExists(testFilePath)).toBe(true); - expect(tabExists(testFilePath2)).toBe(true); - expect(tabExists(testFilePath3)).toBe(true); + describe("Pinned Tabs", function () { + let pinFiles; - // Get any tab element (we'll use the first) - const $tab = getTab(testFilePath); + function getContextMenuItem(text) { + return $("#tabbar-context-menu") + .find(".menu-name") + .filter(function () { + return $(this).text().trim() === text; + }) + .closest("li"); + } - // Right-click on the tab to open context menu - const mousedownEvent = $.Event("mousedown", { - button: 2, - pageX: 100, - pageY: 100 + function getWorkingSetItem(filePath) { + return $("#working-set-list-container li").filter(function () { + const file = $(this).data("file"); + return file && file.fullPath === filePath; }); - $tab.trigger(mousedownEvent); + } - const contextmenuEvent = $.Event("contextmenu", { - pageX: 100, - pageY: 100 - }); - $tab.trigger(contextmenuEvent); + function getWorkingSetOrder() { + return MainViewManager.getWorkingSet("first-pane").map((file) => file.fullPath); + } - // Wait for context menu to appear + async function openTabContextMenu(filePath) { + getTab(filePath).trigger($.Event("contextmenu", { pageX: 100, pageY: 100 })); await awaitsFor( function () { - return getContextMenu().hasClass("open"); + return $("#tabbar-context-menu").hasClass("open"); }, "Context menu to appear" ); + } - // Find and click the "Close All Tabs" option - const $closeAllTabsOption = getContextMenu() - .find(".menu-name") - .filter(function () { - return $(this).text().trim() === Strings.CLOSE_ALL_TABS; - }) - .closest("li"); - expect($closeAllTabsOption.length).toBe(1); - $closeAllTabsOption.click(); - - // Cancel any save dialogs that might appear - cancelSaveDialog(); - - // Verify all tabs are closed + async function closeContextMenu() { + $("body").click(); await awaitsFor( function () { - return !tabExists(testFilePath) && !tabExists(testFilePath2) && !tabExists(testFilePath3); + return !$("#tabbar-context-menu").hasClass("open"); }, - "All tabs to be closed" + "Context menu to disappear" ); + } - expect(tabExists(testFilePath)).toBe(false); - expect(tabExists(testFilePath2)).toBe(false); - expect(tabExists(testFilePath3)).toBe(false); - expect(getTabCount()).toBe(0); - }); - }); + async function pinFromContextMenu(filePath, paneId = "first-pane") { + await openTabContextMenu(filePath); + const $pinItem = getContextMenuItem(Strings.PIN_TAB); + expect($pinItem.length).toBe(1); + $pinItem.click(); + await awaitsFor( + function () { + return MainViewManager.isPathPinned(paneId, filePath) && getTab(filePath).hasClass("pinned"); + }, + "Tab to be pinned" + ); + } - describe("Number of Tabs Preference", function () { beforeEach(async function () { - // Enable the tab bar feature with default settings PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); - - // Close all files to start with a clean state await testWindow.closeAllFiles(); + MainViewManager.setLayoutScheme(1, 1); + pinFiles = await createTestFiles(3, "pin-test", "// Pin test file"); + await openTestFiles(pinFiles); + await waitForTabs(pinFiles); }); afterEach(async function () { - // Reset preferences to default - PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + await testWindow.closeAllFiles(); + MainViewManager.setLayoutScheme(1, 1); }); - it("should show all tabs when numberOfTabs is set to -1", async function () { - // Create several test files - const testFiles = []; - for (let i = 0; i < 10; i++) { - const filePath = testFolder + `/number-test-${i}.js`; - testFiles.push(filePath); - await jsPromise(SpecRunnerUtils.createTextFile(filePath, `// Number test file ${i}`, FileSystem)); - } + it("should pin a tab from the context menu and show a pin instead of the close button", async function () { + await pinFromContextMenu(pinFiles[1]); + + const $tab = getTab(pinFiles[1]); + expect($tab.find(".tab-pin").length).toBe(1); + expect($tab.find(".tab-close").length).toBe(0); + expect(getTab(pinFiles[0]).find(".tab-close").length).toBe(1); + + // pinned tabs move to the front, in the working set and in the tab bar + expect(getWorkingSetOrder()).toEqual([pinFiles[1], pinFiles[0], pinFiles[2]]); + expect($("#phoenix-tab-bar .tab").first().attr("data-path")).toBe(pinFiles[1]); + + // the menu offers to unpin a pinned tab + await openTabContextMenu(pinFiles[1]); + expect(getContextMenuItem(Strings.UNPIN_TAB).length).toBe(1); + expect(getContextMenuItem(Strings.PIN_TAB).length).toBe(0); + await closeContextMenu(); + }); + + it("should unpin a tab when its pin icon is clicked", async function () { + await pinFromContextMenu(pinFiles[0]); + + getTab(pinFiles[0]).find(".tab-pin").click(); + await awaitsFor( + function () { + return !MainViewManager.isPathPinned("first-pane", pinFiles[0]) && + getTab(pinFiles[0]).find(".tab-close").length === 1; + }, + "Tab to be unpinned" + ); + expect(getTab(pinFiles[0]).hasClass("pinned")).toBe(false); + }); + + it("should show the pin in the working set and unpin from there", async function () { + await pinFromContextMenu(pinFiles[2]); + + await awaitsFor( + function () { + return getWorkingSetItem(pinFiles[2]).find(".file-status-icon.pinned").length === 1; + }, + "Pin icon to appear in the working set" + ); + // the working set lists the pinned file first as well + expect($("#working-set-list-container li").first().data("file").fullPath).toBe(pinFiles[2]); + + getWorkingSetItem(pinFiles[2]).find(".file-status-icon.pinned").trigger("mousedown"); + await awaitsFor( + function () { + return !MainViewManager.isPathPinned("first-pane", pinFiles[2]) && + !getTab(pinFiles[2]).hasClass("pinned"); + }, + "File to be unpinned from the working set" + ); + expect(getWorkingSetItem(pinFiles[2]).find(".file-status-icon.pinned").length).toBe(0); + }); + + it("should keep pinned tabs open when closing all tabs from the context menu", async function () { + await pinFromContextMenu(pinFiles[0]); + + await openTabContextMenu(pinFiles[1]); + const $closeAll = getContextMenuItem(Strings.CLOSE_ALL_TABS); + expect($closeAll.length).toBe(1); + $closeAll.click(); + cancelSaveDialog(); + + await awaitsFor( + function () { + return getTabCount() === 1; + }, + "Unpinned tabs to close" + ); + expect(tabExists(pinFiles[0])).toBe(true); + expect(getWorkingSetOrder()).toEqual([pinFiles[0]]); + }); + + it("should skip pinned tabs for close tabs to the left and disable it when nothing is left to close", async function () { + await pinFromContextMenu(pinFiles[0]); + await pinFromContextMenu(pinFiles[1]); + expect(getWorkingSetOrder()).toEqual([pinFiles[0], pinFiles[1], pinFiles[2]]); + + // everything to the left of the last tab is pinned, so there is nothing to close + await openTabContextMenu(pinFiles[2]); + expect(getContextMenuItem(Strings.CLOSE_TABS_TO_THE_LEFT).find("a").hasClass("disabled")).toBe(true); + expect(getContextMenuItem(Strings.CLOSE_OTHER_TABS).find("a").hasClass("disabled")).toBe(true); + expect(getContextMenuItem(Strings.CLOSE_TABS_TO_THE_RIGHT).find("a").hasClass("disabled")).toBe(true); + expect(getContextMenuItem(Strings.CLOSE_ALL_TABS).find("a").hasClass("disabled")).toBe(false); + await closeContextMenu(); + + // from the first tab, close tabs to the right only closes the unpinned one + await openTabContextMenu(pinFiles[0]); + expect(getContextMenuItem(Strings.CLOSE_TABS_TO_THE_RIGHT).find("a").hasClass("disabled")).toBe(false); + getContextMenuItem(Strings.CLOSE_TABS_TO_THE_RIGHT).click(); + cancelSaveDialog(); + + await awaitsFor( + function () { + return !tabExists(pinFiles[2]); + }, + "Unpinned tab to the right to close" + ); + expect(getWorkingSetOrder()).toEqual([pinFiles[0], pinFiles[1]]); + }); + + it("should pin a tab dropped in front of a pinned tab and unpin one dropped behind an unpinned tab", async function () { + await pinFromContextMenu(pinFiles[0]); + + await simulateTabDragAndDrop(pinFiles[2], pinFiles[0], true); + await awaitsFor( + function () { + return MainViewManager.isPathPinned("first-pane", pinFiles[2]) && + getTab(pinFiles[2]).hasClass("pinned"); + }, + "Tab dropped in front of a pinned tab to be pinned" + ); + expect(getWorkingSetOrder()).toEqual([pinFiles[2], pinFiles[0], pinFiles[1]]); + + await simulateTabDragAndDrop(pinFiles[0], pinFiles[1], false); + await awaitsFor( + function () { + return !MainViewManager.isPathPinned("first-pane", pinFiles[0]) && + !getTab(pinFiles[0]).hasClass("pinned"); + }, + "Tab dropped behind an unpinned tab to be unpinned" + ); + expect(getWorkingSetOrder()).toEqual([pinFiles[2], pinFiles[1], pinFiles[0]]); + expect(MainViewManager.isPathPinned("first-pane", pinFiles[2])).toBe(true); + }); + + it("should show the dirty indicator on a pinned tab and on its working set item", async function () { + await pinFromContextMenu(pinFiles[1]); + const doc = DocumentManager.getOpenDocumentForPath(pinFiles[1]); + doc.setText("// Pin test file changed"); + await awaitsFor( + function () { + return isTabDirty(pinFiles[1]); + }, + "Pinned tab to show the dirty indicator" + ); + expect(getTab(pinFiles[1]).hasClass("pinned")).toBe(true); + expect(getTab(pinFiles[1]).find(".tab-pin").length).toBe(1); + expect(getTab(pinFiles[1]).find(".tab-close").length).toBe(0); + + // the working set item shows the dirty dot while the mouse is away and the pin while it hovers + const $row = getWorkingSetItem(pinFiles[1]); + await awaitsFor( + function () { + return $row.find(".file-status-icon.dirty").length === 1; + }, + "Pinned working set item to show the dirty dot" + ); + expect($row.find(".file-status-icon.pinned").length).toBe(0); + $row.trigger("mouseenter"); + expect($row.find(".file-status-icon.pinned").length).toBe(1); + expect($row.find(".file-status-icon.dirty").length).toBe(0); + $row.trigger("mouseleave"); + expect($row.find(".file-status-icon.dirty").length).toBe(1); + + // once the file is clean again the pin is back + await awaitsForDone(CommandManager.execute(Commands.FILE_SAVE, { doc: doc }), "Save the pinned file"); + await awaitsFor( + function () { + return !isTabDirty(pinFiles[1]) && $row.find(".file-status-icon.pinned").length === 1; + }, + "Pin to show again once the file is clean" + ); + expect($row.find(".file-status-icon.dirty").length).toBe(0); + }); + + it("should close an unpinned tab on middle click but not a pinned one", async function () { + await pinFromContextMenu(pinFiles[0]); + + getTab(pinFiles[0]).trigger($.Event("mousedown", { which: 2, button: 1 })); + getTab(pinFiles[1]).trigger($.Event("mousedown", { which: 2, button: 1 })); + cancelSaveDialog(); + await awaitsFor( + function () { + return !tabExists(pinFiles[1]); + }, + "Unpinned tab to close on middle click" + ); + expect(tabExists(pinFiles[0])).toBe(true); + expect(MainViewManager.isPathPinned("first-pane", pinFiles[0])).toBe(true); + expect(getWorkingSetOrder()).toEqual([pinFiles[0], pinFiles[2]]); + }); + + it("should only focus the editor when the active tab is clicked again", async function () { + expect(isTabActive(pinFiles[2])).toBe(true); + const executeSpy = spyOn(CommandManager, "execute").and.callThrough(); + const openCalls = () => executeSpy.calls.allArgs().filter((args) => args[0] === Commands.FILE_OPEN); + + // the active tab doesn't open the file again, that would rebuild the editor + getTab(pinFiles[2]).trigger($.Event("mousedown", { which: 1, button: 0 })); + expect(openCalls().length).toBe(0); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(pinFiles[2]); + + // another tab is opened through the command + getTab(pinFiles[1]).trigger($.Event("mousedown", { which: 1, button: 0 })); + expect(openCalls().length).toBe(1); + expect(openCalls()[0][1].fullPath).toBe(pinFiles[1]); + await awaitsFor( + function () { + return isTabActive(pinFiles[1]); + }, + "Other tab to become active" + ); + }); + + it("should take a ctrl click on mac as a right click and not open the tab", async function () { + expect(isTabActive(pinFiles[2])).toBe(true); + const executeSpy = spyOn(CommandManager, "execute").and.callThrough(); + const openCalls = () => executeSpy.calls.allArgs().filter((args) => args[0] === Commands.FILE_OPEN); + + getTab(pinFiles[0]).trigger($.Event("mousedown", { which: 1, button: 0, ctrlKey: true })); + if (testWindow.brackets.platform === "mac") { + expect(openCalls().length).toBe(0); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(pinFiles[2]); + } else { + expect(openCalls().length).toBe(1); + expect(openCalls()[0][1].fullPath).toBe(pinFiles[0]); + await awaitsFor( + function () { + return isTabActive(pinFiles[0]); + }, + "Ctrl clicked tab to become active" + ); + } + }); + + it("should keep a tab pinned when it is dragged to the other pane", async function () { + await pinFromContextMenu(pinFiles[0]); + + MainViewManager.setLayoutScheme(1, 2); + await awaitsFor( + function () { + return MainViewManager.getPaneCount() === 2; + }, + "Second pane to be created" + ); + + const sourceTab = getTab(pinFiles[0]); + const emptyPaneTarget = $("#second-pane .pane-content"); + sourceTab.trigger($.Event("dragstart", { + originalEvent: { dataTransfer: { setData: function () {}, effectAllowed: "move" } } + })); + emptyPaneTarget.trigger($.Event("dragover", { + originalEvent: { dataTransfer: { dropEffect: "move" } }, + preventDefault: function () {} + })); + emptyPaneTarget.trigger($.Event("drop", { + originalEvent: { dataTransfer: {} }, + preventDefault: function () {}, + stopPropagation: function () {} + })); + sourceTab.trigger($.Event("dragend")); + + await awaitsFor( + function () { + return tabExistsInPane(pinFiles[0], "second-pane") && !tabExistsInPane(pinFiles[0], "first-pane"); + }, + "Tab to move to the second pane" + ); + expect(MainViewManager.isPathPinned("second-pane", pinFiles[0])).toBe(true); + expect(MainViewManager.isPathPinned("first-pane", pinFiles[0])).toBe(false); + + // the working set lists follow the move as well + const workingSetPaneOf = (filePath) => $("#working-set-list-container li").filter(function () { + const file = $(this).data("file"); + return file && file.fullPath === filePath; + }).closest(".working-set-view").attr("id"); + await awaitsFor( + function () { + return workingSetPaneOf(pinFiles[0]) === "working-set-list-second-pane"; + }, + "Working set to list the file under the second pane" + ); + expect($("#working-set-list-second-pane .file-status-icon.pinned").length).toBe(1); + await awaitsFor( + function () { + return $("#phoenix-tab-bar-2").find(`.tab[data-path="${pinFiles[0]}"] .tab-pin`).length === 1; + }, + "Pin to show on the moved tab" + ); + }); + }); + + describe("Settings Menu", function () { + beforeEach(async function () { + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1, smoothScroll: false }); + await testWindow.closeAllFiles(); + await openTestFiles([testFilePath]); + await waitForTabs([testFilePath]); + }); + + afterEach(function () { + testWindow.brackets.test.Menus.closeAll(); + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1, smoothScroll: false }); + }); + + async function openSettingsMenu() { + const $bar = $("#phoenix-tab-bar"); + const barRect = $bar[0].getBoundingClientRect(); + $bar.trigger($.Event("mousedown", { which: 1, target: $bar[0] })); + $bar.trigger($.Event("click", { target: $bar[0], pageX: barRect.right - 5, pageY: barRect.top + 5 })); + await awaitsFor( + function () { + return $("#tabbar-settings-context-menu").hasClass("open"); + }, + "Settings menu to open" + ); + } + + it("should open the tab bar settings from the empty part of the bar and toggle smooth scrolling", async function () { + const $bar = $("#phoenix-tab-bar"); + const barRect = $bar[0].getBoundingClientRect(); + $bar.trigger($.Event("mousedown", { which: 1, target: $bar[0] })); + $bar.trigger($.Event("click", { target: $bar[0], pageX: barRect.right - 5, pageY: barRect.top + 5 })); + + await awaitsFor( + function () { + return $("#tabbar-settings-context-menu").hasClass("open"); + }, + "Settings menu to open" + ); + const itemNames = $("#tabbar-settings-context-menu .menu-name").map(function () { + return $(this).text().trim(); + }).get(); + expect(itemNames).toContain(Strings.CMD_TOGGLE_TABBAR_SMOOTH_SCROLL); + expect(itemNames).toContain(Strings.TABBAR_NUMBER_OF_TABS); + expect(itemNames).toContain(Strings.CMD_HIDE_TABBAR); + expect(itemNames).not.toContain(Strings.CMD_WORKINGSET_SORT_BY_NAME); + + const $smoothItem = $("#tabbar-settings-context-menu .menu-name").filter(function () { + return $(this).text().trim() === Strings.CMD_TOGGLE_TABBAR_SMOOTH_SCROLL; + }).closest("li"); + expect($smoothItem.find("a").hasClass("checked")).toBe(false); + $smoothItem.click(); + + await awaitsFor( + function () { + return PreferencesManager.get("tabBar.options").smoothScroll === true; + }, + "Smooth scrolling preference to turn on" + ); + expect(CommandManager.get(Commands.TOGGLE_TABBAR_SMOOTH_SCROLL).getChecked()).toBe(true); + + // a press that started on a tab and ended on the empty part of the bar is reported as a click + // on the bar as well, that one must not open the settings + getTab(testFilePath).trigger($.Event("mousedown", { which: 1 })); + $bar.trigger($.Event("click", { target: $bar[0], pageX: barRect.right - 5, pageY: barRect.top + 5 })); + expect($("#tabbar-settings-context-menu").hasClass("open")).toBe(false); + }); + + it("should limit the tabs when a count is picked from the number of tabs submenu", async function () { + const files = await createTestFiles(5, "count-test", "// count test file"); + await openTestFiles(files); + await waitForTabs(files); + expect(getTabCount()).toBe(6); + + await openSettingsMenu(); + const $countItem = $("#tabbar-number-of-tabs-submenu .menu-name").filter(function () { + return $(this).text().trim() === "3"; + }).closest("li"); + expect($countItem.length).toBe(1); + expect($countItem.find("a").hasClass("checked")).toBe(false); + $countItem.click(); + + await awaitsFor( + function () { + return PreferencesManager.get("tabBar.options").numberOfTabs === 3 && getTabCount() === 3; + }, + "Only three tabs to show" + ); + // the current file is past the limit and still gets a tab + expect(isTabActive(files[4])).toBe(true); + expect(CommandManager.get("tabbar.numberOfTabs.3").getChecked()).toBe(true); + expect(CommandManager.get("tabbar.numberOfTabs.all").getChecked()).toBe(false); + }); + }); + + describe("Placeholder Rows", function () { + let originalProjectPath; + + function getWorkingSetItem(filePath) { + return $("#working-set-list-container li").filter(function () { + const file = $(this).data("file"); + return file && file.fullPath === filePath; + }); + } + + // a file is only previewed when it belongs to the project, files from elsewhere always go + // straight into the working set. So the test folder becomes the project for these specs + beforeAll(async function () { + originalProjectPath = testWindow.brackets.test.ProjectManager.getProjectRoot().fullPath; + await testWindow.closeAllFiles(); + await SpecRunnerUtils.loadProjectInTestWindow(testFolder); + }, 30000); + + afterAll(async function () { + await testWindow.closeAllFiles(); + await SpecRunnerUtils.loadProjectInTestWindow(originalProjectPath); + }, 30000); + + beforeEach(async function () { + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + await testWindow.closeAllFiles(); + await addToWorkingSet([testFilePath]); + await waitForTabs([testFilePath]); + }); + + it("should show a previewed file as a placeholder in the working set and the tab bar alike", async function () { + const FileViewController = testWindow.brackets.test.FileViewController; + await awaitsForDone( + FileViewController.openAndSelectDocument(testFilePath2, FileViewController.PROJECT_MANAGER), + "Preview second file from the file tree" + ); + + await awaitsFor( + function () { + return getTab(testFilePath2).hasClass("placeholder") && + getWorkingSetItem(testFilePath2).hasClass("placeholder"); + }, + "Placeholder tab and working set row to appear" + ); + expect(MainViewManager.findInWorkingSet("first-pane", testFilePath2)).toBe(-1); + // the placeholder sits after the working set files in both + expect($("#phoenix-tab-bar .tab").last().attr("data-path")).toBe(testFilePath2); + expect($("#working-set-list-container li").last().data("file").fullPath).toBe(testFilePath2); + + // previewing another file replaces the placeholder in both + await awaitsForDone( + FileViewController.openAndSelectDocument(testFilePath3, FileViewController.PROJECT_MANAGER), + "Preview third file from the file tree" + ); + await awaitsFor( + function () { + return getTab(testFilePath3).hasClass("placeholder") && !tabExists(testFilePath2) && + getWorkingSetItem(testFilePath3).hasClass("placeholder") && + getWorkingSetItem(testFilePath2).length === 0; + }, + "Placeholder to move to the third file" + ); + + // clicking the placeholder row keeps the file, in the working set and the tab bar + getWorkingSetItem(testFilePath3).trigger($.Event("mousedown", { which: 1, pageY: 10 })); + await awaitsFor( + function () { + return MainViewManager.findInWorkingSet("first-pane", testFilePath3) !== -1 && + !getTab(testFilePath3).hasClass("placeholder") && + getWorkingSetItem(testFilePath3).length === 1 && + !getWorkingSetItem(testFilePath3).hasClass("placeholder"); + }, + "Placeholder to become a working set file" + ); + }); + + it("should keep the current file when a working set item is right-clicked", async function () { + await addToWorkingSet([testFilePath2]); + await waitForTabs([testFilePath, testFilePath2]); + await awaitsFor( + function () { + return getWorkingSetItem(testFilePath).length === 1 && isTabActive(testFilePath2); + }, + "Working set rows to appear" + ); + + const $row = getWorkingSetItem(testFilePath); + $row.trigger($.Event("mousedown", { which: 3, pageY: 10 })); + $row.trigger($.Event("contextmenu", { pageX: 100, pageY: 200, target: $row.find("a")[0] })); + await awaitsFor( + function () { + return $("#workingset-context-menu").hasClass("open"); + }, + "Working set context menu to appear" + ); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath2); + expect($row.hasClass("context")).toBe(true); + + // the menu acts on the right-clicked file: Close closes it, not the current file + const $closeItem = $("#workingset-context-menu .menu-name").filter(function () { + return $(this).text().trim() === Strings.CMD_FILE_CLOSE; + }).closest("li"); + $closeItem.click(); + cancelSaveDialog(); + await awaitsFor( + function () { + return !tabExists(testFilePath) && getWorkingSetItem(testFilePath).length === 0; + }, + "Right-clicked file to close" + ); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath2); + expect(testWindow.brackets.test.FileViewController.getWorkingSetContext()).toBe(null); + }); + + it("should forget the right-clicked file as soon as the menu is gone, however it went", async function () { + const FileViewController = testWindow.brackets.test.FileViewController; + const Menus = testWindow.brackets.test.Menus; + await addToWorkingSet([testFilePath2]); + await waitForTabs([testFilePath, testFilePath2]); + + // a left click on another row closes the menu through Menus.closeAll without telling it + const $row = getWorkingSetItem(testFilePath); + $row.trigger($.Event("contextmenu", { pageX: 100, pageY: 200, target: $row.find("a")[0] })); + await awaitsFor(() => $("#workingset-context-menu").hasClass("open"), "Working set menu to appear"); + expect(FileViewController.getWorkingSetContext().file.fullPath).toBe(testFilePath); + Menus.closeAll(); + await awaitsFor(() => FileViewController.getWorkingSetContext() === null, "Context forgotten after closeAll"); + expect($row.hasClass("context")).toBe(false); + + // a click anywhere else closes it through the dropdown plugin on the document + const $tab = getTab(testFilePath); + $tab.trigger($.Event("contextmenu", { pageX: 100, pageY: 100 })); + await awaitsFor(() => $("#tabbar-context-menu").hasClass("open"), "Tab context menu to appear"); + expect(FileViewController.getWorkingSetContext().file.fullPath).toBe(testFilePath); + $(testWindow.document).trigger("click"); + await awaitsFor(() => FileViewController.getWorkingSetContext() === null, "Context forgotten after a click"); + expect($tab.hasClass("context")).toBe(false); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath2); + }); + + it("should keep a previewed file when it is saved", async function () { + const FileViewController = testWindow.brackets.test.FileViewController; + await awaitsForDone( + FileViewController.openAndSelectDocument(testFilePath2, FileViewController.PROJECT_MANAGER), + "Preview second file from the file tree" + ); + await awaitsFor(() => getTab(testFilePath2).hasClass("placeholder"), "Placeholder tab to appear"); + + await awaitsForDone(CommandManager.execute(Commands.FILE_SAVE), "Save the previewed file"); + await awaitsFor( + function () { + return MainViewManager.findInWorkingSet("first-pane", testFilePath2) !== -1 && + !getTab(testFilePath2).hasClass("placeholder") && + getWorkingSetItem(testFilePath2).length === 1 && + !getWorkingSetItem(testFilePath2).hasClass("placeholder"); + }, + "Saved preview to become a working set file" + ); + }); + + it("should keep a previewed file when it is pinned from its tab", async function () { + const FileViewController = testWindow.brackets.test.FileViewController; + await awaitsForDone( + FileViewController.openAndSelectDocument(testFilePath2, FileViewController.PROJECT_MANAGER), + "Preview second file from the file tree" + ); + await awaitsFor(() => getTab(testFilePath2).hasClass("placeholder"), "Placeholder tab to appear"); + + await openContextMenuOfTab(getTab(testFilePath2)); + getMenuItemByText("tabbar-context-menu", Strings.PIN_TAB).click(); + await awaitsFor( + function () { + return MainViewManager.isPathPinned("first-pane", testFilePath2) && + getTab(testFilePath2).hasClass("pinned") && !getTab(testFilePath2).hasClass("placeholder"); + }, + "Previewed file to join the working set pinned" + ); + expect(MainViewManager.getWorkingSet("first-pane").map((file) => file.fullPath)) + .toEqual([testFilePath2, testFilePath]); + const $row = getWorkingSetItem(testFilePath2); + expect($row.length).toBe(1); + expect($row.hasClass("placeholder")).toBe(false); + expect($row.find(".file-status-icon.pinned").length).toBe(1); + }); + + it("should keep the previewed file when saving all files", async function () { + const FileViewController = testWindow.brackets.test.FileViewController; + await awaitsForDone( + FileViewController.openAndSelectDocument(testFilePath2, FileViewController.PROJECT_MANAGER), + "Preview second file from the file tree" + ); + await awaitsFor(() => getTab(testFilePath2).hasClass("placeholder"), "Placeholder tab to appear"); + + await awaitsForDone(CommandManager.execute(Commands.FILE_SAVE_ALL), "Save all files"); + await awaitsFor( + function () { + return MainViewManager.findInWorkingSet("first-pane", testFilePath2) !== -1 && + !getTab(testFilePath2).hasClass("placeholder") && + getWorkingSetItem(testFilePath2).length === 1 && + !getWorkingSetItem(testFilePath2).hasClass("placeholder"); + }, + "Saved preview to become a working set file" + ); + }); + + it("should not show a closed file as a placeholder while the next one opens", async function () { + await addToWorkingSet([testFilePath2, testFilePath3]); + await waitForTabs([testFilePath, testFilePath2, testFilePath3]); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath3); + + // the closed file stays in view until the next one is ready, during which neither list may + // treat it as a preview. The pane reports that state directly + const pane = MainViewManager._getPane("first-pane"); + const file = FileSystem.getFileForPath(testFilePath3); + const closePromise = CommandManager.execute(Commands.FILE_CLOSE, { file: file }); + expect(pane.getTemporaryViewFile()).toBe(null); + await awaitsForDone(closePromise, "Close the current file"); + await awaitsFor( + () => !tabExists(testFilePath3) && getWorkingSetItem(testFilePath3).length === 0, + "Closed file to leave both lists" + ); + expect($("#phoenix-tab-bar .tab.placeholder").length).toBe(0); + expect($("#working-set-list-container li.placeholder").length).toBe(0); + }); + }); + + describe("Context Menu Commands", function () { + let originalProjectPath, ProjectManager, SidebarView, Menus; + + // rename and show in file tree act on the file tree, so the test folder is the project here + beforeAll(async function () { + ProjectManager = testWindow.brackets.test.ProjectManager; + SidebarView = testWindow.brackets.test.SidebarView; + Menus = testWindow.brackets.test.Menus; + originalProjectPath = ProjectManager.getProjectRoot().fullPath; + await testWindow.closeAllFiles(); + await SpecRunnerUtils.loadProjectInTestWindow(testFolder); + }, 30000); + + afterAll(async function () { + await testWindow.closeAllFiles(); + await SpecRunnerUtils.loadProjectInTestWindow(originalProjectPath); + }, 30000); + + beforeEach(async function () { + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + await testWindow.closeAllFiles(); + await addToWorkingSet([testFilePath, testFilePath2]); + await waitForTabs([testFilePath, testFilePath2]); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath2); + }); + + afterEach(async function () { + Menus.closeAll(); + if ($("#project-files-container .jstree-rename-input").length) { + ProjectManager._actionCreator.cancelRename(); + } + SidebarView.show(); + await testWindow.closeAllFiles(); + }); + + it("should save the right-clicked file and not the current one", async function () { + const docA = DocumentManager.getOpenDocumentForPath(testFilePath), + docB = DocumentManager.getOpenDocumentForPath(testFilePath2); + docA.setText("// Test file 1 for TabBar, changed"); + docB.setText("// Test file 2 for TabBar, changed"); + await awaitsFor( + function () { + return isTabDirty(testFilePath) && isTabDirty(testFilePath2); + }, + "Both tabs to show the dirty indicator" + ); + + await openContextMenuOfTab(getTab(testFilePath)); + getMenuItemByText("tabbar-context-menu", Strings.CMD_FILE_SAVE).click(); + await awaitsFor( + function () { + return !docA.isDirty && !isTabDirty(testFilePath); + }, + "Right-clicked file to be saved" + ); + expect(await readFileFromDisk(testFilePath)).toBe("// Test file 1 for TabBar, changed"); + expect(docB.isDirty).toBe(true); + expect(isTabDirty(testFilePath2)).toBe(true); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath2); + + // put the file back the way the other specs expect it + docA.setText("// Test file 1 for TabBar"); + await awaitsForDone(CommandManager.execute(Commands.FILE_SAVE, { doc: docA }), "Restore the file"); + }); + + it("should copy the path of the right-clicked file", async function () { + const copySpy = spyOn(testWindow.Phoenix.app, "copyToClipboard"); + await openContextMenuOfTab(getTab(testFilePath)); + getMenuItemByText("tabbar-context-menu", Strings.CMD_FILE_COPY_PATH).click(); + await awaitsFor( + function () { + return copySpy.calls.count() > 0; + }, + "Path to be copied" + ); + expect(copySpy).toHaveBeenCalledWith(ProjectManager.makeProjectRelativeIfPossible(testFilePath)); + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath2); + }); + + it("should reveal the right-clicked file in the tree even when the sidebar is hidden", async function () { + const fileName = testFilePath.split("/").pop(); + SidebarView.hide(); + expect(SidebarView.isVisible()).toBe(false); + + await openContextMenuOfTab(getTab(testFilePath)); + getMenuItemByText("tabbar-context-menu", Strings.CMD_SHOW_IN_TREE).click(); + await awaitsFor( + function () { + const selectedName = $("#project-files-container .selected-node").text() + .replace(new RegExp("\\s", "g"), ""); + return SidebarView.isVisible() && selectedName === fileName; + }, + "Sidebar to show with the right-clicked file selected in the tree" + ); + expect(ProjectManager.getSelectedItem().fullPath).toBe(testFilePath); + // the file selected in the tree is always the one in the editor, so the revealed file is shown too + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath); + }); + + it("should rename the right-clicked file in the tree even when the sidebar is hidden", async function () { + SidebarView.hide(); + expect(SidebarView.isVisible()).toBe(false); + + await openContextMenuOfTab(getTab(testFilePath)); + getMenuItemByText("tabbar-context-menu", Strings.CMD_FILE_RENAME).click(); + await awaitsFor( + function () { + return SidebarView.isVisible() && + $("#project-files-container .jstree-rename-input").length === 1; + }, + "Sidebar to show with the rename input in the tree" + ); + expect($("#project-files-container .jstree-rename-input").val()).toBe(testFilePath.split("/").pop()); + + ProjectManager._actionCreator.cancelRename(); + await awaitsFor( + function () { + return $("#project-files-container .jstree-rename-input").length === 0; + }, + "Rename to be cancelled" + ); + }); + + it("should only offer rename and show in tree when the right-clicked file is on disk", async function () { + const untitledDoc = await jsPromise(CommandManager.execute(Commands.FILE_NEW_UNTITLED)); + const untitledPath = untitledDoc.file.fullPath; + await waitForTabs([untitledPath]); + await openTestFiles([testFilePath]); + await awaitsFor(() => isTabActive(testFilePath), "First file to be current again"); + + function itemDisabled(text) { + return getMenuItemByText("tabbar-context-menu", text).find("a").hasClass("disabled"); + } + + // the untitled file isn't on disk, while the current file is + await openContextMenuOfTab(getTab(untitledPath)); + await awaitsFor( + function () { + return itemDisabled(Strings.CMD_FILE_RENAME) && itemDisabled(Strings.CMD_SHOW_IN_TREE); + }, + "Rename and show in file tree to be disabled for the untitled file" + ); + Menus.closeAll(); + await awaitsFor(() => !$("#tabbar-context-menu").hasClass("open"), "Menu to close"); + + // the other way round, the untitled file is current and a file on disk is right-clicked + MainViewManager._edit("first-pane", untitledDoc); + await awaitsFor(() => isTabActive(untitledPath), "Untitled file to be current"); + await openContextMenuOfTab(getTab(testFilePath)); + await awaitsFor( + function () { + return !itemDisabled(Strings.CMD_FILE_RENAME) && !itemDisabled(Strings.CMD_SHOW_IN_TREE); + }, + "Rename and show in file tree to be enabled for the file on disk" + ); + }); + }); + + describe("Second Pane Menus", function () { + let paneFiles; + + beforeEach(async function () { + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + await testWindow.closeAllFiles(); + MainViewManager.setLayoutScheme(1, 2); + paneFiles = await createTestFiles(3, "second-pane-menu", "// second pane menu test file"); + await addToWorkingSet([paneFiles[0], paneFiles[1]], "second-pane"); + await addToWorkingSet([paneFiles[2]], "first-pane"); + await waitForTabs([paneFiles[0], paneFiles[1]], "second-pane"); + await waitForTabs([paneFiles[2]], "first-pane"); + expect(MainViewManager.getActivePaneId()).toBe("first-pane"); + }); + + afterEach(async function () { + testWindow.brackets.test.Menus.closeAll(); + await testWindow.closeAllFiles(); + MainViewManager.setLayoutScheme(1, 1); + }); + + it("should close and pin the right-clicked tab of the inactive pane", async function () { + await openContextMenuOfTab(getTabInPane(paneFiles[1], "second-pane")); + expect(MainViewManager.getActivePaneId()).toBe("first-pane"); + getMenuItemByText("tabbar-context-menu", Strings.CMD_FILE_CLOSE).click(); + cancelSaveDialog(); + await awaitsFor( + function () { + return !tabExistsInPane(paneFiles[1], "second-pane"); + }, + "Right-clicked tab of the second pane to close" + ); + expect(MainViewManager.findInWorkingSet("second-pane", paneFiles[1])).toBe(-1); + expect(MainViewManager.getWorkingSet("first-pane").map((file) => file.fullPath)) + .toEqual([paneFiles[2]]); + expect(tabExistsInPane(paneFiles[2], "first-pane")).toBe(true); + + await openContextMenuOfTab(getTabInPane(paneFiles[0], "second-pane")); + getMenuItemByText("tabbar-context-menu", Strings.PIN_TAB).click(); + await awaitsFor( + function () { + return MainViewManager.isPathPinned("second-pane", paneFiles[0]) && + getTabInPane(paneFiles[0], "second-pane").hasClass("pinned"); + }, + "Right-clicked tab of the second pane to be pinned" + ); + expect(MainViewManager.isPathPinned("first-pane", paneFiles[2])).toBe(false); + }); + + it("should close and pin the right-clicked working set item of the inactive pane", async function () { + await openContextMenuOfWorkingSetItem(getWorkingSetItemInPane(paneFiles[1], "second-pane")); + expect(MainViewManager.getActivePaneId()).toBe("first-pane"); + getMenuItemByText("workingset-context-menu", Strings.CMD_FILE_CLOSE).click(); + cancelSaveDialog(); + await awaitsFor( + function () { + return getWorkingSetItemInPane(paneFiles[1], "second-pane").length === 0; + }, + "Right-clicked item of the second pane to close" + ); + expect(MainViewManager.findInWorkingSet("second-pane", paneFiles[1])).toBe(-1); + expect(MainViewManager.getWorkingSet("first-pane").map((file) => file.fullPath)) + .toEqual([paneFiles[2]]); + expect(getWorkingSetItemInPane(paneFiles[2], "first-pane").length).toBe(1); + + await openContextMenuOfWorkingSetItem(getWorkingSetItemInPane(paneFiles[0], "second-pane")); + getMenuItemByText("workingset-context-menu", Strings.CMD_FILE_PIN).click(); + await awaitsFor( + function () { + const $row = getWorkingSetItemInPane(paneFiles[0], "second-pane"); + return MainViewManager.isPathPinned("second-pane", paneFiles[0]) && + $row.find(".file-status-icon.pinned").length === 1; + }, + "Right-clicked item of the second pane to be pinned" + ); + expect(MainViewManager.isPathPinned("first-pane", paneFiles[2])).toBe(false); + }); + }); + + describe("Cross Pane Drag", function () { + afterEach(async function () { + await testWindow.closeAllFiles(); + MainViewManager.setLayoutScheme(1, 1); + }); + + it("should keep the drop position when the target pane already has the file", async function () { + MainViewManager.setLayoutScheme(1, 2); + const files = await createTestFiles(3, "both-panes", "// file in both panes"); + await addToWorkingSet([files[1]], "first-pane"); + await addToWorkingSet([files[0], files[1], files[2]], "second-pane"); + await waitForTabs([files[1]], "first-pane"); + await waitForTabs([files[0], files[1], files[2]], "second-pane"); + + // dropping the file from the first pane in front of the last tab of the second pane leaves + // it where it already is, instead of pushing it past that tab + const sourceTab = $(`#phoenix-tab-bar .tab[data-path="${files[1]}"]`); + const targetTab = $(`#phoenix-tab-bar-2 .tab[data-path="${files[2]}"]`); + const dropX = targetTab[0].getBoundingClientRect().left + 5; + sourceTab.trigger($.Event("dragstart", { + originalEvent: { dataTransfer: { setData: function () {}, effectAllowed: "move" } } + })); + targetTab.trigger($.Event("dragenter")); + targetTab.trigger($.Event("dragover", { + originalEvent: { dataTransfer: { dropEffect: "move" }, clientX: dropX }, + preventDefault: function () {} + })); + targetTab.trigger($.Event("drop", { + originalEvent: { dataTransfer: {}, clientX: dropX }, + preventDefault: function () {}, + stopPropagation: function () {} + })); + sourceTab.trigger($.Event("dragend")); + + await awaitsFor( + () => !tabExistsInPane(files[1], "first-pane") && + MainViewManager.getCurrentlyViewedPath("second-pane") === files[1], + "File to leave the first pane and show in the second" + ); + expect(MainViewManager.getWorkingSet("second-pane").map((file) => file.fullPath)).toEqual(files); + expect(MainViewManager.getWorkingSet("first-pane").length).toBe(0); + }); + }); + + describe("Number of Tabs Preference", function () { + beforeEach(async function () { + // Enable the tab bar feature with default settings + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + + // Close all files to start with a clean state + await testWindow.closeAllFiles(); + }); + + afterEach(async function () { + // Reset preferences to default + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + }); + + it("should show all tabs when numberOfTabs is set to -1", async function () { + // Create several test files + const testFiles = []; + for (let i = 0; i < 10; i++) { + const filePath = testFolder + `/number-test-${i}.js`; + testFiles.push(filePath); + await jsPromise(SpecRunnerUtils.createTextFile(filePath, `// Number test file ${i}`, FileSystem)); + } // Open all the test files for (const filePath of testFiles) { @@ -2678,6 +3753,42 @@ define(function (require, exports, module) { expect(getPaneTabCount("second-pane")).toBe(0); }); + it("should keep the active tab of the inactive pane in step with its current file", async function () { + const files = await createTestFiles(2, "inactive-pane", "// inactive pane file"); + await addToWorkingSet([files[0], files[1]], "second-pane"); + await addToWorkingSet([testFilePath], "first-pane"); + await waitForTabs([files[0], files[1]], "second-pane"); + await waitForTabs([testFilePath], "first-pane"); + expect(MainViewManager.getActivePaneId()).toBe("first-pane"); + await awaitsFor( + function () { + return $("#phoenix-tab-bar-2 .tab.active").attr("data-path") === files[1]; + }, + "Second pane to mark its current file" + ); + + // showing the other file in the second pane without activating the pane is only announced by + // the pane itself, the tab bar has to pick that up + await awaitsForDone( + CommandManager.execute(Commands.FILE_OPEN, { + fullPath: files[0], + paneId: "second-pane", + options: { noPaneActivate: true } + }), + "Open the first file in the second pane without activating it" + ); + expect(MainViewManager.getActivePaneId()).toBe("first-pane"); + expect(MainViewManager.getCurrentlyViewedPath("second-pane")).toBe(files[0]); + await awaitsFor( + function () { + return $("#phoenix-tab-bar-2 .tab.active").attr("data-path") === files[0]; + }, + "Tab of the inactive pane to follow its current file" + ); + expect($("#phoenix-tab-bar-2 .tab.active").length).toBe(1); + expect($("#phoenix-tab-bar .tab.active").attr("data-path")).toBe(testFilePath); + }); + it("should work correctly with vertical split layout", async function () { // Change to vertical split layout (2 rows, 1 column) MainViewManager.setLayoutScheme(2, 1); @@ -2955,8 +4066,9 @@ define(function (require, exports, module) { wheelEventRight.originalEvent = { deltaY: 500 }; // Large scroll right $tabBar.trigger(wheelEventRight); - // Should not exceed maximum scroll (a small floating point tolerance) - expect($tabBar.scrollLeft()).toBeLessThanOrEqual(maxScrollLeft + 1); + // Should not exceed maximum scroll. scrollWidth and clientWidth are rounded to whole + // pixels while the tabs have fractional widths, so the real end can sit a pixel past them + expect($tabBar.scrollLeft()).toBeLessThanOrEqual(maxScrollLeft + 2); // Scroll far to the left $tabBar.scrollLeft(-1000); // Try to scroll beyond minimum diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js index 4465e1a2d4..4b48d4dbef 100644 --- a/test/spec/MainViewManager-integ-test.js +++ b/test/spec/MainViewManager-integ-test.js @@ -543,6 +543,42 @@ define(function (require, exports, module) { inlineEditor.focus(); expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); }); + it("should not bounce between panes when a document shown in one pane was last focused in the other", async function () { + const js = testPath + "/test.js", + css = testPath + "/test.css", + opens = [[css, "first-pane"], [js, "first-pane"], [js, "second-pane"], [css, "second-pane"]]; + + // the files go into both working sets so each pane keeps an editor for the file it is not showing + MainViewManager.setLayoutScheme(1, 2); + for (let i = 0; i < opens.length; i++) { + promise = CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, + { fullPath: opens[i][0], paneId: opens[i][1] }); + await awaitsForDone(promise, Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN); + } + expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toEqual(js); + expect(MainViewManager.getCurrentlyViewedPath("second-pane")).toEqual(css); + + // both documents have an editor in each pane. Focusing test.js in the second pane and + // test.css in the first pane and then switching files would leave each document's master + // editor in the pane that is not showing it, so set that state up directly + const docJs = DocumentManager.getOpenDocumentForPath(js), + docCss = DocumentManager.getOpenDocumentForPath(css); + docJs._toggleMasterEditor(docJs._associatedFullEditors.find(function (editor) { + return editor._paneId === "second-pane"; + })); + docCss._toggleMasterEditor(docCss._associatedFullEditors.find(function (editor) { + return editor._paneId === "first-pane"; + })); + + const errorSpy = spyOn(testWindow.console, "error").and.callThrough(); + MainViewManager.setActivePaneId("first-pane"); + expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); + MainViewManager.setActivePaneId("second-pane"); + expect(MainViewManager.getActivePaneId()).toEqual("second-pane"); + _$("#first-pane").click(); + expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); + expect(errorSpy).not.toHaveBeenCalled(); + }); it("should activate pane when pane is clicked", async function () { var activePaneChangeListener = jasmine.createSpy(); diff --git a/test/spec/Pane-test.js b/test/spec/Pane-test.js index c80a9d42ad..f22babdb2b 100644 --- a/test/spec/Pane-test.js +++ b/test/spec/Pane-test.js @@ -451,6 +451,199 @@ define(function (require, exports, module) { } }); }); + describe("Temporary views", function () { + it("should report the current view while its file is not in the view list", function () { + createTestPane(); + const view = createMockView("/temp/a.js"); + myPane.addView(view); + myPane.showView(view); + expect(myPane.getTemporaryViewFile()).toBe(view.getFile()); + + myPane.addToViewList(view.getFile()); + expect(myPane.getTemporaryViewFile()).toBe(null); + }); + + it("should not report a closed file whose view waits for the next one", function () { + createTestPane(); + const first = createMockView("/temp/first.js"); + const second = createMockView("/temp/second.js"); + myPane.addToViewList(first.getFile()); + myPane.addView(first); + myPane.addToViewList(second.getFile()); + myPane.addView(second); + myPane.showView(first); + + myPane._doRemove(first.getFile(), true); + expect(myPane.getCurrentlyViewedPath()).toBe("/temp/first.js"); + expect(myPane.getTemporaryViewFile()).toBe(null); + + myPane.showView(second); + expect(myPane.getTemporaryViewFile()).toBe(null); + + const preview = createMockView("/temp/preview.js"); + myPane.addView(preview); + myPane.showView(preview); + expect(myPane.getTemporaryViewFile()).toBe(preview.getFile()); + }); + + it("should announce the rename of a file that is only being looked at", function () { + createTestPane(); + const view = createMockView("/temp/old.js"); + myPane.addView(view); + myPane.showView(view); + const spy = jasmine.createSpy("viewListChange"); + myPane.on("viewListChange", spy); + + // the file system renames the File object before the event arrives + view.file.fullPath = "/temp/new.js"; + myPane._handleFileNameChange(null, "/temp/old.js", "/temp/new.js"); + expect(spy).toHaveBeenCalled(); + expect(myPane._views["/temp/new.js"]).toBe(view); + expect(myPane.getTemporaryViewFile().fullPath).toBe("/temp/new.js"); + }); + }); + + describe("Pinning", function () { + let files; + + function paths() { + return myPane._viewList.map(function (file) { + return file.fullPath; + }); + } + + beforeEach(function () { + files = []; + for (let i = 0; i < 4; i++) { + files[i] = createMockView("/pin/" + i.toString()).getFile(); + } + myPane.addListToViewList(files); + }); + + it("should move a pinned file to the front and an unpinned one to the start of the unpinned files", function () { + expect(myPane.setPathPinned("/pin/2", true)).toBe(true); + expect(paths()).toEqual(["/pin/2", "/pin/0", "/pin/1", "/pin/3"]); + expect(myPane.setPathPinned("/pin/3", true)).toBe(true); + expect(paths()).toEqual(["/pin/2", "/pin/3", "/pin/0", "/pin/1"]); + expect(myPane.getPinnedCount()).toEqual(2); + expect(myPane.isPathPinned("/pin/3")).toBe(true); + + expect(myPane.setPathPinned("/pin/2", false)).toBe(true); + expect(paths()).toEqual(["/pin/3", "/pin/2", "/pin/0", "/pin/1"]); + expect(myPane.isPathPinned("/pin/2")).toBe(false); + expect(myPane.getPinnedCount()).toEqual(1); + + expect(myPane.setPathPinned("/pin/2", false)).toBe(false); + expect(myPane.setPathPinned("/not/there", true)).toBe(false); + }); + + it("should pin a file moved in front of a pinned file and unpin one moved behind an unpinned file", function () { + myPane.setPathPinned("/pin/0", true); + + myPane.moveWorkingSetItem(3, 0); + expect(paths()).toEqual(["/pin/3", "/pin/0", "/pin/1", "/pin/2"]); + expect(myPane.isPathPinned("/pin/3")).toBe(true); + + myPane.moveWorkingSetItem(1, 3); + expect(paths()).toEqual(["/pin/3", "/pin/1", "/pin/2", "/pin/0"]); + expect(myPane.isPathPinned("/pin/0")).toBe(false); + + // dropping right behind the pinned files keeps the file as it was + myPane.moveWorkingSetItem(3, 1); + expect(paths()).toEqual(["/pin/3", "/pin/0", "/pin/1", "/pin/2"]); + expect(myPane.isPathPinned("/pin/0")).toBe(false); + expect(myPane.getPinnedCount()).toEqual(1); + }); + + it("should keep pinned files in front when sorting", function () { + myPane.setPathPinned("/pin/3", true); + myPane.sortViewList(function (paneId, file1, file2) { + return file2.fullPath.localeCompare(file1.fullPath); + }); + expect(paths()).toEqual(["/pin/3", "/pin/2", "/pin/1", "/pin/0"]); + + myPane.setPathPinned("/pin/0", true); + myPane.sortViewList(function (paneId, file1, file2) { + return file1.fullPath.localeCompare(file2.fullPath); + }); + expect(paths()).toEqual(["/pin/0", "/pin/3", "/pin/1", "/pin/2"]); + }); + + it("should pin a file added inside the pinned block and keep the others behind the pinned files", function () { + myPane.setPathPinned("/pin/0", true); + myPane.setPathPinned("/pin/1", true); + const inside = createMockView("/pin/inside").getFile(), + front = createMockView("/pin/front").getFile(); + + expect(myPane.addToViewList(inside, 1)).toEqual(1); + expect(myPane.isPathPinned("/pin/inside")).toBe(true); + expect(paths()).toEqual(["/pin/0", "/pin/inside", "/pin/1", "/pin/2", "/pin/3"]); + + // a move from another pane without an index lands right after the pinned files + expect(myPane._addToViewList(front, { indexRequested: true })).toEqual(3); + expect(myPane.isPathPinned("/pin/front")).toBe(false); + expect(paths()).toEqual(["/pin/0", "/pin/inside", "/pin/1", "/pin/front", "/pin/2", "/pin/3"]); + }); + + it("should keep the pinned files of a merged pane pinned and in front", function () { + myPane.setPathPinned("/pin/2", true); + const otherPane = createMockPane("other-pane"), + otherFiles = [createMockView("/other/a").getFile(), createMockView("/other/b").getFile()]; + otherPane.addListToViewList(otherFiles); + otherPane.setPathPinned("/other/b", true); + + myPane.mergeFrom(otherPane); + expect(paths()).toEqual(["/pin/2", "/other/b", "/pin/0", "/pin/1", "/pin/3", "/other/a"]); + expect(myPane.isPathPinned("/other/b")).toBe(true); + expect(myPane.isPathPinned("/other/a")).toBe(false); + expect(myPane.getPinnedCount()).toEqual(2); + otherPane.destroy(); + }); + + it("should move the pinned state along when a pinned file or its folder is renamed", function () { + myPane.setPathPinned("/pin/1", true); + // the file system renames the File objects before the event arrives + files[1].fullPath = "/pin/renamed"; + myPane._handleFileNameChange(null, "/pin/1", "/pin/renamed"); + expect(myPane.isPathPinned("/pin/renamed")).toBe(true); + expect(myPane.isPathPinned("/pin/1")).toBe(false); + + myPane.setPathPinned("/pin/3", true); + files.forEach(function (file) { + file.fullPath = file.fullPath.replace("/pin/", "/moved/"); + }); + myPane._handleFileNameChange(null, "/pin/", "/moved/"); + expect(myPane.isPathPinned("/moved/renamed")).toBe(true); + expect(myPane.isPathPinned("/moved/3")).toBe(true); + expect(myPane.isPathPinned("/pin/3")).toBe(false); + expect(myPane.getPinnedCount()).toEqual(2); + expect(paths().slice(0, 2)).toEqual(["/moved/renamed", "/moved/3"]); + }); + + it("should forget the pinned state when the file is removed", function () { + myPane.setPathPinned("/pin/1", true); + myPane.removeView(files[1]); + expect(myPane.isPathPinned("/pin/1")).toBe(false); + expect(myPane.getPinnedCount()).toEqual(0); + }); + + it("should save and restore the pinned state", function () { + myPane.setPathPinned("/pin/2", true); + const state = myPane.saveState(); + expect(state[0].file).toEqual("/pin/2"); + expect(state[0].pinned).toBe(true); + expect(state[1].pinned).toBeUndefined(); + + const otherPane = createMockPane("other-pane"); + otherPane.loadState(state); + expect(otherPane.getViewList().map(function (file) { + return file.fullPath; + })).toEqual(["/pin/2", "/pin/0", "/pin/1", "/pin/3"]); + expect(otherPane.isPathPinned("/pin/2")).toBe(true); + expect(otherPane.getPinnedCount()).toEqual(1); + otherPane.destroy(); + }); + }); describe("Dispatch events", function () { beforeEach(function () { myPane.addView(myView); diff --git a/test/spec/ViewUtils-test.js b/test/spec/ViewUtils-test.js index ab57c92052..76db09aba9 100644 --- a/test/spec/ViewUtils-test.js +++ b/test/spec/ViewUtils-test.js @@ -31,10 +31,9 @@ define(function (require, exports, module) { describe("ViewUtils", function () { /* - * Note: This suite uses ViewUtils to apply the .scroller-shadow class to the fixture. - * However, the brackets.less file is not part of the SpecRunner. Therefore, no background-image - * is displayed or animated. These tests simply validate that the correct - * background-position value is written to the scrolling DOMElement. + * Note: This suite uses ViewUtils to add the .scroller-shadow elements to the fixture. The + * SpecRunner loads brackets.less, so they get their sticky 8px boxes, but the shadows are only + * faded in and out. These tests validate the opacity written to the shadow elements. */ describe("Scroller Shadows", function () { @@ -59,41 +58,50 @@ define(function (require, exports, module) { $fixture.trigger("scroll"); } - function backgroundY(position) { - return parseInt($fixture.find(".scroller-shadow." + position).css("background-position").split(" ")[1], 10); + function shadowOpacity(position) { + return parseFloat($fixture.find(".scroller-shadow." + position).css("opacity")); } - it("should not show the top shadow when no scrolling is available", function () { + // the bottommost scroll position. The scrollbars of the test window take away from the + // client height, so this is not simply the content height minus the fixture height + function maxScrollTop() { + return fixture.scrollHeight - fixture.clientHeight; + } + + it("should not show either shadow when no scrolling is available", function () { $fixture.find("#content").height(50); // make height shorter than the viewport ViewUtils.addScrollerShadow(fixture, null, true); expect(fixture.scrollTop).toEqual(0); - expect(backgroundY("top")).toEqual(-ViewUtils.SCROLL_SHADOW_HEIGHT); - expect(backgroundY("bottom")).toEqual(ViewUtils.SCROLL_SHADOW_HEIGHT); + expect(shadowOpacity("top")).toEqual(0); + expect(shadowOpacity("bottom")).toEqual(0); }); it("should partially reveal the shadow", function () { ViewUtils.addScrollerShadow(fixture, null, true); scrollTop(3); - expect(backgroundY("top")).toEqual(3 - ViewUtils.SCROLL_SHADOW_HEIGHT); - expect(backgroundY("bottom")).toEqual(0); + expect(shadowOpacity("top")).toBeCloseTo(3 / ViewUtils.SCROLL_SHADOW_HEIGHT, 5); + expect(shadowOpacity("bottom")).toEqual(1); - scrollTop(899); - expect(backgroundY("top")).toEqual(0); + // 1px of content left below the viewport + scrollTop(maxScrollTop() - 1); + expect(shadowOpacity("top")).toEqual(1); + expect(shadowOpacity("bottom")).toBeCloseTo(1 / ViewUtils.SCROLL_SHADOW_HEIGHT, 5); }); - it("should update shadow position when installed", function () { + it("should update the shadows when installed", function () { scrollTop(100); ViewUtils.addScrollerShadow(fixture, null, true); - expect(backgroundY("top")).toEqual(0); + expect(shadowOpacity("top")).toEqual(1); }); - it("should fully reveal the shadow at the bottommost scroll position", function () { + it("should fully reveal the top shadow and hide the bottom one at the bottommost position", function () { ViewUtils.addScrollerShadow(fixture, null, true); - scrollTop(900); + scrollTop(maxScrollTop()); - expect(backgroundY("top")).toEqual(0); + expect(shadowOpacity("top")).toEqual(1); + expect(shadowOpacity("bottom")).toEqual(0); }); }); diff --git a/test/spec/WorkingSetView-integ-test.js b/test/spec/WorkingSetView-integ-test.js index f0e4c741f7..57501337e9 100644 --- a/test/spec/WorkingSetView-integ-test.js +++ b/test/spec/WorkingSetView-integ-test.js @@ -192,6 +192,55 @@ define(function (require, exports, module) { expect(listItems.find(".file-status-icon dirty").length).toBe(0); }); + it("should pin and unpin files moved across the pinned block and show the pin in the list", async function () { + const $ = testWindow.$; + await openAndMakeDirty(testPath + "/file_three.js"); + await awaitsFor(function () { + return workingSetListItemCount === 3; + }, "third file to be in the working set"); + + // a pinned item only shows its pin while the file is clean + const files = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + files.forEach(function (file) { + DocumentManager.getOpenDocumentForPath(file.fullPath)._markClean(); + }); + function rowPaths() { + return $(".open-files-container > ul").children().map(function (index, row) { + return $(row).data("file").fullPath; + }).get(); + } + function pinnedRows() { + return $(".open-files-container > ul").children().filter(function (index, row) { + return $(row).find(".file-status-icon.pinned").length === 1; + }).map(function (index, row) { + return $(row).data("file").fullPath; + }).get(); + } + + MainViewManager.setFilePinned(MainViewManager.ACTIVE_PANE, files[1], true); + await awaitsFor(function () { + return rowPaths()[0] === files[1].fullPath && pinnedRows().length === 1; + }, "pinned file to move to the top with its pin"); + expect(pinnedRows()).toEqual([files[1].fullPath]); + + // dropping the last file in front of the pinned one pins it as well + MainViewManager._moveWorkingSetItem(MainViewManager.ACTIVE_PANE, 2, 0); + await awaitsFor(function () { + return rowPaths()[0] === files[2].fullPath && pinnedRows().length === 2; + }, "file dropped in front of the pinned one to be pinned"); + expect(rowPaths()).toEqual([files[2].fullPath, files[1].fullPath, files[0].fullPath]); + expect(MainViewManager.isPathPinned(MainViewManager.ACTIVE_PANE, files[2].fullPath)).toBe(true); + + // dropping a pinned file behind the unpinned one unpins it + MainViewManager._moveWorkingSetItem(MainViewManager.ACTIVE_PANE, 1, 2); + await awaitsFor(function () { + return rowPaths()[2] === files[1].fullPath && pinnedRows().length === 1; + }, "file dropped behind the unpinned one to be unpinned"); + expect(rowPaths()).toEqual([files[2].fullPath, files[0].fullPath, files[1].fullPath]); + expect(pinnedRows()).toEqual([files[2].fullPath]); + expect(MainViewManager.isPathPinned(MainViewManager.ACTIVE_PANE, files[1].fullPath)).toBe(false); + }); + it("should show the file in project tree when a file is being renamed", async function () { var $ = testWindow.$; var secondItem = $(".open-files-container > ul").children().eq(1);