From 5d990f56d25038448248b36493be7448fe58e032 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:01:52 +0530 Subject: [PATCH 01/33] feat: add pinned files to the working set model --- src/command/Commands.js | 3 + src/document/DocumentCommandHandlers.js | 23 +++ src/nls/root/strings.js | 2 + src/view/MainViewManager.js | 45 +++++- src/view/Pane.js | 182 +++++++++++++++++++++--- test/spec/Pane-test.js | 106 ++++++++++++++ 6 files changed, 338 insertions(+), 23 deletions(-) diff --git a/src/command/Commands.js b/src/command/Commands.js index 6366ef6594..0f488ddc93 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() diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 0f303e98ef..967853f415 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -2114,6 +2114,28 @@ 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 || {}, + paneId = data.paneId || MainViewManager.ACTIVE_PANE, + file = data.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 +2532,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/nls/root/strings.js b/src/nls/root/strings.js index 455651f51e..99e2f22f28 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1208,6 +1208,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", diff --git a/src/view/MainViewManager.js b/src/view/MainViewManager.js index b6eca09f39..bcb568dbee 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 @@ -1002,6 +1003,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 +1849,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..ec71582120 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.} @@ -514,6 +522,7 @@ define(function (require, exports, module) { this._viewList = []; this._viewListMRUOrder = []; this._viewListAddedOrder = []; + this._pinnedPaths = new Set(); this._views = {}; this._currentView = null; this.showInterstitial(true); @@ -616,6 +625,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 +676,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 +821,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 +843,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 +884,8 @@ define(function (require, exports, module) { // Add first to Added order this._viewListAddedOrder.unshift(file); + + return index; }; @@ -864,13 +898,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)); }; @@ -949,6 +977,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 @@ -976,22 +1005,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 +1057,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 @@ -1098,6 +1211,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 +1654,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 +1685,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 +1702,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/Pane-test.js b/test/spec/Pane-test.js index c80a9d42ad..dd8dd06ff0 100644 --- a/test/spec/Pane-test.js +++ b/test/spec/Pane-test.js @@ -451,6 +451,112 @@ define(function (require, exports, module) { } }); }); + describe("Pinning", function () { + var files; + + function paths() { + return myPane._viewList.map(function (file) { + return file.fullPath; + }); + } + + beforeEach(function () { + files = []; + for (var 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); + var 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 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); + var state = myPane.saveState(); + expect(state[0].file).toEqual("/pin/2"); + expect(state[0].pinned).toBe(true); + expect(state[1].pinned).toBeUndefined(); + + var 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); From 4d33450c0b50aeed463f4422984348f11fa798e4 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:02:18 +0530 Subject: [PATCH 02/33] feat: show a pin on pinned files in the working set --- src/project/WorkingSetView.js | 70 ++++++++++++++++++++++++++--------- src/styles/brackets.less | 41 ++++++++++++++++++++ 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index cb8da7106c..c461cbca6b 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -299,6 +299,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], @@ -782,8 +783,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}) @@ -873,7 +884,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, @@ -1183,15 +1194,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 +1224,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(".fa-thumbtack").length === 0) { + $fileStatusIcon.html("").attr("title", Strings.CMD_FILE_UNPIN); + } + } else if ($fileStatusIcon.children().length !== 0) { + $fileStatusIcon.empty().removeAttr("title"); + } } }; @@ -1270,20 +1295,30 @@ 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); $newItem.hover( function () { - self._updateFileStatusIcon($(this), _isOpenAndDirty(file), true); + self._updateFileStatusIcon($(this), _isOpenAndDirty(file), true, self._isPinned(file)); }, function () { - self._updateFileStatusIcon($(this), _isOpenAndDirty(file), 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 @@ -1392,9 +1427,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); + var canClose = ($listItem.find(".can-close, .pinned").length === 1); + var nextFile = $nextListItem.data(_FILE_KEY); + var isDirty = _isOpenAndDirty(nextFile); + this._updateFileStatusIcon($nextListItem, isDirty, canClose, this._isPinned(nextFile)); } $listItem.remove(); } @@ -1455,8 +1491,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); + var canClose = $(listItem).find(".can-close, .pinned").length === 1; + this._updateFileStatusIcon(listItem, doc.isDirty, canClose, this._isPinned(doc.file)); } }; diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 9c2f444a00..52952ddf06 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1849,6 +1849,47 @@ a, img { &.can-close:active:before { color: rgba(255, 255, 255, 0.3); } + + &.pinned { + cursor: pointer; + + > i { + display: inline-block; + width: 16px; + height: 16px; + line-height: 16px; + text-align: center; + font-size: 10px; + color: rgba(255, 255, 255, 0.5); + } + + &:hover > i { + color: rgba(255, 255, 255, 0.8); + } + + &:active > i { + color: rgba(255, 255, 255, 0.3); + } + } + + &: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 */ From d2bd6db78c8a10e07809d6f6466b498dd4748a73 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:03:33 +0530 Subject: [PATCH 03/33] feat: tab bar context menu now uses the working set menu commands --- src/command/Commands.js | 3 + src/command/DefaultMenus.js | 92 +++++--- src/command/Menus.js | 1 + src/extensions/default/CloseOthers/main.js | 213 ++++++++++-------- .../default/CloseOthers/unittests.js | 40 ++++ src/nls/root/strings.js | 3 + 6 files changed, 232 insertions(+), 120 deletions(-) diff --git a/src/command/Commands.js b/src/command/Commands.js index 0f488ddc93..0eaf8d9053 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -285,6 +285,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..ece87ee68d 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -32,7 +32,8 @@ 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"); /** * Disables menu items present in items if enabled is true. @@ -70,6 +71,24 @@ 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 working set remembers the right-clicked item, the tab bar makes the clicked tab + * current before opening its menu. + * @param {boolean} forFileTabs - true for the tab bar context menu, false for the working set one + */ + function _updatePinMenuItemName(forFileTabs) { + const contextFile = !forFileTabs && WorkingSetView.getContext(), + file = contextFile || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE), + pinned = file && MainViewManager.isPathPinned(MainViewManager.ACTIVE_PANE, 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 +329,53 @@ 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); + var 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); } - 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 + 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); }); - 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); @@ -485,6 +523,8 @@ 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.FILE_TABS_CONTEXT_MENU) + .on("beforeContextMenuOpen", _setMenuItemsVisible); Menus.getContextMenu(Menus.ContextMenuIds.PROJECT_MENU).on("beforeContextMenuOpen", _setMenuItemsVisible); }); }); diff --git a/src/command/Menus.js b/src/command/Menus.js index 5c4ed88f0a..6db5047ef8 100644 --- a/src/command/Menus.js +++ b/src/command/Menus.js @@ -72,6 +72,7 @@ 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", SPLITVIEW_MENU: "splitview-menu" }; diff --git a/src/extensions/default/CloseOthers/main.js b/src/extensions/default/CloseOthers/main.js index f09490f4b8..0080d83bce 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,129 @@ define(function (require, exports, module) { /** - * Handle the different Close Other commands - * @param {string} mode + * The files of the active 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. + * @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(mode, targetPath) { + const paneId = MainViewManager.ACTIVE_PANE, + workingSetList = MainViewManager.getWorkingSet(paneId), + files = []; + + let targetIndex = MainViewManager.findInWorkingSet(paneId, targetPath); + if (targetIndex === -1) { + 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}); + return files; } /** - * Enable/Disable the menu items depending on which document is selected in the working set + * Handle the different Close commands, relative to the current file of the active pane + * @param {string} mode */ - function contextMenuOpenHandler() { - var file = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); - - if (file) { - var targetIndex = MainViewManager.findInWorkingSet(MainViewManager.ACTIVE_PANE, file.fullPath), - workingSetListSize = MainViewManager.getWorkingSetSize(MainViewManager.ACTIVE_PANE); - - 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); - } - - 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); - } + function handleClose(mode) { + const targetPath = MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE); + if (!targetPath) { + return; + } - 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); - } + const files = getFilesToClose(mode, targetPath); + if (files.length) { + return CommandManager.execute(Commands.FILE_CLOSE_LIST, {fileList: files}); } } + /** + * 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) { + // the working set remembers the item that was right-clicked, the tab bar makes the clicked tab + // the current file before it opens its menu + const contextFile = !forFileTabs && WorkingSetView.getContext(), + file = contextFile || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE), + names = forFileTabs ? fileTabNames : workingSetNames; + + MENU_ORDER.forEach(function (commandId) { + const command = CommandManager.get(commandId); + command.setName(names[commandId]); + command.setEnabled(Boolean(file) && getFilesToClose(commandId, file.fullPath).length > 0); + }); + } + /** - * 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 +202,12 @@ 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); + }); - 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..dea7b096ca 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 () { + var 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 () { + var ws = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + MainViewManager.setFilePinned(MainViewManager.ACTIVE_PANE, ws[0], true); + + docSelectIndex = 2; + cmdToRun = "file.close_others"; + + await runCloseOthers(); + + var 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 () { + var 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"); + + var remaining = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + expect(remaining.length).toEqual(1); + expect(remaining[0].fullPath).toEqual(ws[3].fullPath); + }); }); }); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 99e2f22f28..e0f9e76856 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1151,9 +1151,12 @@ 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", From b24100eecaad02578d4f39666566d40bbb2e7b90 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:03:59 +0530 Subject: [PATCH 04/33] feat: update tabs in place instead of rebuilding the whole tab bar --- src/extensionsIntegrated/TabBar/drag-drop.js | 647 ++++------ src/extensionsIntegrated/TabBar/global.js | 43 - src/extensionsIntegrated/TabBar/helper.js | 166 --- .../TabBar/html/tabbar-pane.html | 9 - .../TabBar/html/tabbar-second-pane.html | 9 - src/extensionsIntegrated/TabBar/main.js | 1133 ++++++++--------- .../TabBar/more-options.js | 165 --- src/extensionsIntegrated/TabBar/overflow.js | 392 ++---- src/nls/root/strings.js | 1 - src/styles/Extn-TabBar.less | 575 ++++----- test/spec/Extn-Tabbar-integ-test.js | 374 ++++-- 11 files changed, 1414 insertions(+), 2100 deletions(-) delete mode 100644 src/extensionsIntegrated/TabBar/global.js delete mode 100644 src/extensionsIntegrated/TabBar/helper.js delete mode 100644 src/extensionsIntegrated/TabBar/html/tabbar-pane.html delete mode 100644 src/extensionsIntegrated/TabBar/html/tabbar-second-pane.html delete mode 100644 src/extensionsIntegrated/TabBar/more-options.js diff --git a/src/extensionsIntegrated/TabBar/drag-drop.js b/src/extensionsIntegrated/TabBar/drag-drop.js index 53bcb50062..31a59fc9e8 100644 --- a/src/extensionsIntegrated/TabBar/drag-drop.js +++ b/src/extensionsIntegrated/TabBar/drag-drop.js @@ -24,523 +24,328 @@ 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; + return closest; } /** - * this function handles the drag start - * @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 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); + function _dropIndex(paneId, tab, before) { + const index = MainViewManager.findInWorkingSet(paneId, tab.getAttribute("data-path")); + if (index === -1) { + return MainViewManager.getWorkingSetSize(paneId); + } + return before ? index : index + 1; } /** - * this function handles the drag over - * @param {Event} event - the event instance + * 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 handleDragOver(event) { - if (!draggedTab) { - return; - } - - event.preventDefault(); - event.originalEvent.dataTransfer.dropEffect = "move"; + function _moveWithinPane(paneId, draggedPath, toIndex) { + const fromIndex = MainViewManager.findInWorkingSet(paneId, draggedPath); - const targetTab = event.currentTarget; - if (targetTab === draggedTab) { + // 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 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; + if (fromIndex < toIndex) { + toIndex--; } - - 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(); + if (toIndex !== fromIndex) { + MainViewManager._moveWorkingSetItem(paneId, fromIndex, toIndex); } } /** - * this handles the drop - * @param {Event} event + * this function is responsible to move the tab in between different panes + * 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 {number} toIndex - the working set index in the target pane */ - function handleDrop(event) { - event.preventDefault(); - event.stopPropagation(); - - if (!draggedTab || this === draggedTab) { - cleanupDragState(); + function _moveToPane(targetPaneId, draggedPath, toIndex) { + const fromPaneId = sourcePaneId; + const file = FileSystem.getFileForPath(draggedPath); + const open = () => CommandManager.execute(Commands.FILE_OPEN, { fullPath: draggedPath, paneId: targetPaneId }); + + // a placeholder tab has nothing to move, the file joins the target pane's working set and the + // preview in the source pane goes away + if (MainViewManager.findInWorkingSet(fromPaneId, draggedPath) === -1) { + MainViewManager.addToWorkingSet(targetPaneId, file, toIndex); + open().always(() => CommandManager.execute(Commands.FILE_CLOSE, { file: file, paneId: fromPaneId })); 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(); - } - - /** - * this function handles the drop on container (empty space) - * @param {Event} event - */ - function handleContainerDrop(event) { - event.preventDefault(); - - if (!draggedTab) { - cleanupDragState(); + // the target pane already has this file, nothing to move, just show it there + if (MainViewManager.findInWorkingSet(targetPaneId, draggedPath) !== -1 || + MainViewManager._getPane(targetPaneId).getViewForPath(draggedPath)) { + open(); 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(); + MainViewManager._moveView(fromPaneId, targetPaneId, file, toIndex).always(open); } /** - * 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 + * 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 performTabMove(targetTab, targetPaneId, insertBefore) { - const draggedPath = $(draggedTab).attr("data-path"); - const targetPath = $(targetTab).attr("data-path"); + function _dropOnTab(tab, before) { + const targetPaneId = _paneIdOf(tab); + const draggedPath = draggedTab.getAttribute("data-path"); + const toIndex = _dropIndex(targetPaneId, tab, before); - if (dragSourcePane === targetPaneId) { - // same pane move - moveWorkingSetItem(targetPaneId, draggedPath, targetPath, insertBefore); + if (targetPaneId === sourcePaneId) { + _moveWithinPane(targetPaneId, draggedPath, toIndex); } else { - // different pane move - moveTabBetweenPanes(dragSourcePane, targetPaneId, draggedPath, targetPath, insertBefore); + _moveToPane(targetPaneId, draggedPath, toIndex); } } /** - * this function is responsible to move the tab within the pane - * @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 + * 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 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 init() { + const TAB = ".phoenix-tab-bar .tab"; - if (draggedIndex !== -1 && targetIndex !== -1) { - let newPosition = beforeTarget ? targetIndex : targetIndex + 1; - if (draggedIndex < newPosition) { - newPosition--; - } - MainViewManager._moveWorkingSetItem(paneId, draggedIndex, newPosition); - } - } + $(document) + .on("dragstart", TAB, function (event) { + draggedTab = this; + sourcePaneId = _paneIdOf(this); - /** - * this function is responsible to move the tab in between different panes - * @param {string} sourcePaneId - The source pane ID - * @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 - */ - 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; - } - } + const dataTransfer = event.originalEvent.dataTransfer; + dataTransfer.effectAllowed = "move"; + dataTransfer.setData("application/x-phoenix-tab", this.getAttribute("data-path")); - // Find the target index in the target pane - for (let i = 0; i < targetWorkingSet.length; i++) { - if (targetWorkingSet[i].fullPath === targetPath) { - targetIndex = i; - break; + this.classList.add("dragging"); + }) + .on("dragover", TAB, function (event) { + if (!draggedTab) { + return; } - } + event.preventDefault(); + event.originalEvent.dataTransfer.dropEffect = "move"; - 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 }); - } - } catch (error) { - console.error("Error during cross-pane tab move:", error); - } - } - - /** - * 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); - } - - /** - * this function handles drag leave - * @param {Event} event - the event instance - */ - 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"); - } - } - - /** - * Setup drag handlers for a tab bar - * @param {string} tabBarSelector - The jQuery selector for the tab bar - */ - function setupTabBarDragHandlers(tabBarSelector) { - const $tabBar = $(tabBarSelector); - - // Remove existing handlers to prevent duplicates - $tabBar.off("dragstart dragover dragenter dragleave drop dragend"); - - // 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); - - // container level handlers (for empty space) - $tabBar.on("dragover", handleContainerDragOver); - $tabBar.on("drop", handleContainerDrop); + 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(); - // Make tabs draggable - $tabBar.find(".tab").attr("draggable", true); - } + 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(); - /** - * 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"); - } + 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("dragleave", function (event) { - $(this).removeClass("empty-pane-drop-target pane-drop-target"); - }); + 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(); - $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 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(); - /** - * clean up the handlers - */ - function setupGlobalCleanup() { - // Clean up on document level events - $(document).on("dragend mouseup", function () { - if (draggedTab) { - setTimeout(() => cleanupDragState(), 100); - } - }); + _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..ade6a1df56 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -25,34 +25,38 @@ 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 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,677 +67,639 @@ 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); - } - - // 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"); + // 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"); } - - // 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; - } - - // Get all files from the working set. refer to `global.js` - getAllFilesFromWorkingSet(); + 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 + this.resizeObserver = new ResizeObserver(() => this.updateOverflowButton()); + this.resizeObserver.observe(this.bar); + } - // 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.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 currentFile = MainViewManager.getCurrentlyViewedFile(paneId); + const currentPath = currentFile ? currentFile.fullPath : null; + 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. But when the current file was just closed it isn't in the working set + // either while the next file is still being opened, that one had a real tab a moment ago so it's not + // a placeholder, its tab simply goes away + const inWorkingSet = workingSet.some((file) => file.fullPath === currentPath); + const previousTab = currentPath && this.tabs.get(currentPath); + const wasWorkingSetTab = previousTab && !previousTab.classList.contains("placeholder"); + if (currentFile && !inWorkingSet && !wasWorkingSetTab) { + entries.push({ file: currentFile, 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 + */ + TabBarView.prototype.updateOverflowButton = function () { + const bar = this.bar; + this.$overflowButton.toggleClass("hidden", bar.scrollWidth <= bar.clientWidth); + }; - 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; - } - - 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); - } + const bar = this.bar; + const barRect = bar.getBoundingClientRect(); + const tabRect = tab.getBoundingClientRect(); + let scrollLeft = bar.scrollLeft; - // 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; - } - - 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); - } - - // Add working set tabs - displayedEntries2.forEach(function (entry) { - $secondTabBar.append(createTab(entry, "second-pane")); - }); + bar.scrollTo({ left: scrollLeft, behavior: "smooth" }); + }; - // Add placeholder tab if needed - if (secondPanePlaceholder) { - $secondTabBar.append(createTab(secondPanePlaceholder, "second-pane")); - } - } + /** + * 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); } + }; - // 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")); - } + /** + * re-reads the git status of every tab + */ + TabBarView.prototype.refreshGitStatus = function () { + this.tabs.forEach((tab, path) => _applyGitStatus(tab, path)); + }; - if (Global.secondPaneWorkingSet.length === 0 && !secondPanePlaceholder && $("#phoenix-tab-bar-2")) { - Helper._hideTabBar($("#phoenix-tab-bar-2"), $("#overflow-button-2")); - } + function _forEachView(callback) { + Object.keys(_views).forEach((paneId) => callback(_views[paneId])); + } - // 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); - } + /** + * marks the pane's tab bar for an update in the next tick + * @param {string} paneId + */ + function _scheduleUpdate(paneId) { + if (!paneId || !_views[paneId]) { + return; } - - if ($secondTabBar.length) { - Overflow.toggleOverflowVisibility("second-pane"); - if (activePane === "second-pane") { - setTimeout(function () { - Overflow.scrollToActiveTab($secondTabBar); - }, 0); - } + _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 hasn't finished yet, focusing right away would get undone + setTimeout(function () { + const editor = EditorManager.getActiveEditor(); + if (editor) { + editor.focus(); } - }); + }, 0); + } - // 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 + if (event.which === 3 || $(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"; + const path = this.getAttribute("data-path"); + const paneId = _paneIdOf(this); - // get the file object - const fileObj = FileSystem.getFileForPath(filePath); - const currentActivePane = MainViewManager.getActivePaneId(); - const isPaneActive = paneId === currentActivePane; - const currentFile = MainViewManager.getCurrentlyViewedFile(currentActivePane); + // 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; + } // if the clicked tab is a placeholder tab, we add it to the working set - if ($(this).hasClass("placeholder")) { - MainViewManager.addToWorkingSet(paneId, fileObj); + if (this.classList.contains("placeholder")) { + MainViewManager.addToWorkingSet(paneId, FileSystem.getFileForPath(path)); } + _openTab(path, paneId); + }); - // 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; - } - CommandManager.execute(Commands.FILE_OPEN, { fullPath: filePath, paneId: paneId }) - .always(function () { - setTimeout(function () { - const editor = EditorManager.getActiveEditor(); - if (editor) { - editor.focus(); - } - }, 0); - }); + $(document).on("click", ".phoenix-tab-bar .tab-close", function (event) { + event.preventDefault(); + event.stopPropagation(); + const tab = this.parentNode; + CommandManager.execute(Commands.FILE_CLOSE, { + file: FileSystem.getFileForPath(tab.getAttribute("data-path")), + paneId: _paneIdOf(tab) + }); + }); + + $(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 + }); + }); + + // 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); }); // Add the contextmenu (right-click) handler - $(document).on("contextmenu", ".phoenix-tab-bar .tab", function (event) { + $(document).on("contextmenu", TAB, function (event) { event.preventDefault(); event.stopPropagation(); - // get the file path from the data-path attribute - const filePath = $(this).attr("data-path"); + const path = this.getAttribute("data-path"); + const paneId = _paneIdOf(this); + const location = { pageX: event.pageX, pageY: event.pageY }; - // determine which pane the tab belongs to - const isSecondPane = $(this).closest("#phoenix-tab-bar-2").length > 0; - const paneId = isSecondPane ? "second-pane" : "first-pane"; + // the menu commands work on the current file of the active pane, so the right-clicked tab + // is made current first, exactly like right-clicking an item in the working set selects it + const isCurrent = MainViewManager.getActivePaneId() === paneId && + MainViewManager.getCurrentlyViewedPath(paneId) === path; + const ready = isCurrent + ? $.Deferred().resolve().promise() + : CommandManager.execute(Commands.FILE_OPEN, { fullPath: path, paneId: paneId }); - // show the context menu at mouse position - MoreOptions.showMoreOptionsContextMenu(paneId, filePath, event.pageX, event.pageY); + ready.always(function () { + Menus.getContextMenu(Menus.ContextMenuIds.FILE_TABS_CONTEXT_MENU).open(location); + }); }); - } - // 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); + // 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; + }); + } /** * 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()) { + function _onBeforeCommand(event, commandId) { + if (!isTabBarActive() || (commandId !== Commands.FILE_SAVE && commandId !== Commands.FILE_SAVE_ALL)) { 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; - - // 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); - - const isSecondPanePlaceholder = - MainViewManager.getCurrentlyViewedFile("second-pane") && - MainViewManager.getCurrentlyViewedFile("second-pane").fullPath === filePath && - !Global.secondPaneWorkingSet.some((entry) => entry.path === filePath); - - // 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 savedPath = commandId === Commands.FILE_SAVE + ? MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE) + : null; - if (isSecondPanePlaceholder) { - const fileObj = FileSystem.getFileForPath(filePath); - MainViewManager.addToWorkingSet("second-pane", fileObj); - } + MainViewManager.getPaneIdList().forEach((paneId) => { + const file = MainViewManager.getCurrentlyViewedFile(paneId); + const isPlaceholder = file && MainViewManager.findInWorkingSet(paneId, file.fullPath) === -1; + if (isPlaceholder && (!savedPath || file.fullPath === savedPath)) { + MainViewManager.addToWorkingSet(paneId, file); } - } + }); } /** * 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); + }); + + 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); + }); - // For editor changes, update only the tabs. - MainViewManager.on(MainViewManager.EVENT_CURRENT_FILE_CHANGE, debounceUpdateTabs); + 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; - } - } - } - - // 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; - } - } - } - }); + CommandManager.on("beforeExecuteCommand", _onBeforeCommand); } /** * 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; @@ -742,9 +708,10 @@ define(function (require, exports, module) { CommandManager.get(Commands.TOGGLE_TABBAR).setChecked(prefs.showTabBar); if (isTabBarActive()) { - createTabBar(); + MainViewManager.getPaneIdList().forEach(_createView); + Object.keys(_views).forEach(_scheduleUpdate); } else { - cleanupTabBar(); + Object.keys(_views).forEach(_destroyView); } } @@ -762,34 +729,6 @@ define(function (require, exports, module) { }); } - /** - * 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 - */ - 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); - } - AppInit.appReady(function () { _registerCommands(); @@ -797,22 +736,14 @@ define(function (require, exports, module) { 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..1955c28024 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/nls/root/strings.js b/src/nls/root/strings.js index e0f9e76856..500368ceac 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1153,7 +1153,6 @@ define({ "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", diff --git a/src/styles/Extn-TabBar.less b/src/styles/Extn-TabBar.less index 59655a39fd..2356206a04 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,239 @@ 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 { + 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 { + font-size: 0.75rem; + padding: 0.1rem 0.4rem; + 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 { + 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.625rem; + } -.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 +331,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 +352,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 +408,40 @@ 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.625rem; } diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index 64aaa72cee..08d3e3b6f2 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -1114,6 +1114,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 @@ -2089,7 +2154,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 +2182,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,119 +2207,273 @@ 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); + expect(getTabCount()).toBe(0); + }); + }); - // 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" + describe("Pinned Tabs", function () { + let pinFiles; + + function getContextMenuItem(text) { + return $("#tabbar-context-menu") + .find(".menu-name") + .filter(function () { + return $(this).text().trim() === text; + }) + .closest("li"); + } + + function getWorkingSetItem(filePath) { + return $("#working-set-list-container li").filter(function () { + const file = $(this).data("file"); + return file && file.fullPath === filePath; + }); + } + + function getWorkingSetOrder() { + return MainViewManager.getWorkingSet("first-pane").map((file) => file.fullPath); + } + + async function openTabContextMenu(filePath) { + getTab(filePath).trigger($.Event("contextmenu", { pageX: 100, pageY: 100 })); + await awaitsFor( + function () { + return $("#tabbar-context-menu").hasClass("open"); + }, + "Context menu to appear" + ); + } + + async function closeContextMenu() { + $("body").click(); + await awaitsFor( + function () { + return !$("#tabbar-context-menu").hasClass("open"); + }, + "Context menu to disappear" ); + } + + 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" + ); + } + + beforeEach(async function () { + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1 }); + await testWindow.closeAllFiles(); + MainViewManager.setLayoutScheme(1, 1); + pinFiles = await createTestFiles(3, "pin-test", "// Pin test file"); + await openTestFiles(pinFiles); + await waitForTabs(pinFiles); }); - 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" + afterEach(async function () { + await testWindow.closeAllFiles(); + MainViewManager.setLayoutScheme(1, 1); + }); + + 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" ); - await awaitsForDone( - CommandManager.execute(Commands.FILE_OPEN, { fullPath: testFilePath2 }), - "Open second test file" + 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" ); - await awaitsForDone( - CommandManager.execute(Commands.FILE_OPEN, { fullPath: testFilePath3 }), - "Open third test file" + // 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(); - // Wait for all tabs to appear await awaitsFor( function () { - return tabExists(testFilePath) && tabExists(testFilePath2) && tabExists(testFilePath3); + return getTabCount() === 1; }, - "All tabs to appear" + "Unpinned tabs to close" ); + expect(tabExists(pinFiles[0])).toBe(true); + expect(getWorkingSetOrder()).toEqual([pinFiles[0]]); + }); - // Verify all three tabs exist - expect(tabExists(testFilePath)).toBe(true); - expect(tabExists(testFilePath2)).toBe(true); - expect(tabExists(testFilePath3)).toBe(true); + 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]]); - // Get any tab element (we'll use the first) - const $tab = getTab(testFilePath); + // 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(); - // Right-click on the tab to open context menu - const mousedownEvent = $.Event("mousedown", { - button: 2, - pageX: 100, - pageY: 100 - }); - $tab.trigger(mousedownEvent); + // 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(); - const contextmenuEvent = $.Event("contextmenu", { - pageX: 100, - pageY: 100 - }); - $tab.trigger(contextmenuEvent); + await awaitsFor( + function () { + return !tabExists(pinFiles[2]); + }, + "Unpinned tab to the right to close" + ); + expect(getWorkingSetOrder()).toEqual([pinFiles[0], pinFiles[1]]); + }); - // Wait for context menu to appear + 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 getContextMenu().hasClass("open"); + return MainViewManager.isPathPinned("first-pane", pinFiles[2]) && + getTab(pinFiles[2]).hasClass("pinned"); }, - "Context menu to appear" + "Tab dropped in front of a pinned tab to be pinned" ); + expect(getWorkingSetOrder()).toEqual([pinFiles[2], pinFiles[0], pinFiles[1]]); - // 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(); + 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); + }); - // Cancel any save dialogs that might appear - cancelSaveDialog(); + it("should keep a tab pinned when it is dragged to the other pane", async function () { + await pinFromContextMenu(pinFiles[0]); - // Verify all tabs are closed + MainViewManager.setLayoutScheme(1, 2); await awaitsFor( function () { - return !tabExists(testFilePath) && !tabExists(testFilePath2) && !tabExists(testFilePath3); + return MainViewManager.getPaneCount() === 2; }, - "All tabs to be closed" + "Second pane to be created" ); - expect(tabExists(testFilePath)).toBe(false); - expect(tabExists(testFilePath2)).toBe(false); - expect(tabExists(testFilePath3)).toBe(false); - expect(getTabCount()).toBe(0); + 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); + 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" + ); }); }); From 38536ffad3aa0e693b8c4938541931325c91ccc8 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:04:24 +0530 Subject: [PATCH 05/33] fix: overflow button staying visible when the tabs fit without it --- src/extensionsIntegrated/TabBar/main.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index ade6a1df56..3baa5bdf31 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -361,10 +361,13 @@ define(function (require, exports, module) { /** * 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; - this.$overflowButton.toggleClass("hidden", bar.scrollWidth <= bar.clientWidth); + const buttonWidth = this.$overflowButton.hasClass("hidden") ? 0 : this.$overflowButton[0].offsetWidth; + this.$overflowButton.toggleClass("hidden", bar.scrollWidth <= bar.clientWidth + buttonWidth); }; /** From 8ec76b9ab46fd0ed16578cd1095138dd09e863c2 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:04:49 +0530 Subject: [PATCH 06/33] fix: tabs of the inactive pane not following its current file --- src/extensionsIntegrated/TabBar/main.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 3baa5bdf31..bf67bb353f 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -267,12 +267,19 @@ define(function (require, exports, module) { // a resize (sidebar, live preview panel, window...) only changes whether all the tabs still fit this.resizeObserver = new ResizeObserver(() => this.updateOverflowButton()); this.resizeObserver.observe(this.bar); + + // 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)); } /** * removes the tab bar from the pane */ TabBarView.prototype.destroy = function () { + this.pane.off(".tabbar"); this.resizeObserver.disconnect(); this.$container.remove(); this.tabs.clear(); From 60f38bf09a39541b41619a39fcf3c6fa2ebae37a Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:05:17 +0530 Subject: [PATCH 07/33] fix: pinned working set items not showing the dirty indicator --- src/project/WorkingSetView.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index c461cbca6b..bc1aea5820 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -1299,11 +1299,15 @@ define(function (require, exports, module) { _updateListItemSelection($newItem, selectedFile); _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 () { + $(this).data("hovered", true); self._updateFileStatusIcon($(this), _isOpenAndDirty(file), true, self._isPinned(file)); }, function () { + $(this).data("hovered", false); self._updateFileStatusIcon($(this), _isOpenAndDirty(file), false, self._isPinned(file)); } ); @@ -1427,7 +1431,7 @@ 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, .pinned").length === 1); + var canClose = ($listItem.data("hovered") === true); var nextFile = $nextListItem.data(_FILE_KEY); var isDirty = _isOpenAndDirty(nextFile); this._updateFileStatusIcon($nextListItem, isDirty, canClose, this._isPinned(nextFile)); @@ -1491,7 +1495,7 @@ 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, .pinned").length === 1; + var canClose = $(listItem).data("hovered") === true; this._updateFileStatusIcon(listItem, doc.isDirty, canClose, this._isPinned(doc.file)); } }; From 458c3e922525a9eb5da1d9d69717b5579da49f32 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:05:42 +0530 Subject: [PATCH 08/33] fix: context menu commands acting on the active pane for the other pane's working set --- src/command/DefaultMenus.js | 5 ++-- src/extensions/default/CloseOthers/main.js | 32 ++++++++++++++-------- src/project/WorkingSetView.js | 20 ++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index ece87ee68d..d29e9b8db8 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -79,8 +79,9 @@ define(function (require, exports, module) { */ function _updatePinMenuItemName(forFileTabs) { const contextFile = !forFileTabs && WorkingSetView.getContext(), - file = contextFile || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE), - pinned = file && MainViewManager.isPathPinned(MainViewManager.ACTIVE_PANE, file.fullPath); + paneId = (!forFileTabs && WorkingSetView.getContextPaneId()) || MainViewManager.ACTIVE_PANE, + file = contextFile || MainViewManager.getCurrentlyViewedFile(paneId), + pinned = file && MainViewManager.isPathPinned(paneId, file.fullPath); let name = pinned ? Strings.CMD_FILE_UNPIN : Strings.CMD_FILE_PIN; if (forFileTabs) { diff --git a/src/extensions/default/CloseOthers/main.js b/src/extensions/default/CloseOthers/main.js index 0080d83bce..535d3aeb8d 100644 --- a/src/extensions/default/CloseOthers/main.js +++ b/src/extensions/default/CloseOthers/main.js @@ -72,20 +72,22 @@ define(function (require, exports, module) { /** - * The files of the active pane's working set that the command would close for the given target file. + * 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. + * 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 getFilesToClose(mode, targetPath) { - const paneId = MainViewManager.ACTIVE_PANE, - workingSetList = MainViewManager.getWorkingSet(paneId), + function getFilesToClose(paneId, mode, targetPath) { + const workingSetList = MainViewManager.getWorkingSet(paneId), files = []; let targetIndex = MainViewManager.findInWorkingSet(paneId, targetPath); - if (targetIndex === -1) { + const isPreview = targetIndex === -1; + if (isPreview) { targetIndex = workingSetList.length; } @@ -101,6 +103,13 @@ define(function (require, exports, module) { } } + if (mode === closeAll && isPreview) { + const previewFile = MainViewManager.getCurrentlyViewedFile(paneId); + if (previewFile && previewFile.fullPath === targetPath) { + files.push(previewFile); + } + } + return files; } @@ -114,7 +123,7 @@ define(function (require, exports, module) { return; } - const files = getFilesToClose(mode, targetPath); + const files = getFilesToClose(MainViewManager.ACTIVE_PANE, mode, targetPath); if (files.length) { return CommandManager.execute(Commands.FILE_CLOSE_LIST, {fileList: files}); } @@ -125,16 +134,17 @@ define(function (require, exports, module) { * @param {boolean} forFileTabs - true when the tab bar menu is opening, false for the working set menu */ function contextMenuOpenHandler(forFileTabs) { - // the working set remembers the item that was right-clicked, the tab bar makes the clicked tab - // the current file before it opens its menu + // the working set remembers the item (and pane) that was right-clicked, the tab bar makes the + // clicked tab the current file before it opens its menu const contextFile = !forFileTabs && WorkingSetView.getContext(), - file = contextFile || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE), + paneId = (!forFileTabs && WorkingSetView.getContextPaneId()) || MainViewManager.ACTIVE_PANE, + file = contextFile || MainViewManager.getCurrentlyViewedFile(paneId), names = forFileTabs ? fileTabNames : workingSetNames; MENU_ORDER.forEach(function (commandId) { const command = CommandManager.get(commandId); command.setName(names[commandId]); - command.setEnabled(Boolean(file) && getFilesToClose(commandId, file.fullPath).length > 0); + command.setEnabled(Boolean(file) && getFilesToClose(paneId, commandId, file.fullPath).length > 0); }); } diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index bc1aea5820..dc8e3603ee 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -69,6 +69,13 @@ define(function (require, exports, module) { */ var _contextEntry; + /** + * The id of the pane whose working set the context menu was opened on + * @type {string} + * @private + */ + var _contextPaneId; + /** * Class Providers * @see {@link #addClassProvider} @@ -1546,8 +1553,10 @@ define(function (require, exports, module) { // Disable horizontal scrolling until WebKit bug #99379 is fixed this.$openFilesContainer.css("overflow-x", "hidden"); + var self = this; this.$openFilesContainer.on("contextmenu.workingSetView", function (e) { _contextEntry = $(e.target).closest("li").data(_FILE_KEY); + _contextPaneId = self.paneId; Menus.getContextMenu(Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU).open(e); }); @@ -1690,6 +1699,16 @@ define(function (require, exports, module) { return _contextEntry; } + /** + * Gets the id of the pane whose working set the context menu was last opened on. The item that + * was right-clicked is being made the current file at the same time, so when the menu opens the + * active pane may not have caught up yet, this one has. + * @return {string} + */ + function getContextPaneId() { + return _contextPaneId; + } + // Public API exports.createWorkingSetViewForPane = createWorkingSetViewForPane; exports.refresh = refresh; @@ -1698,6 +1717,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; From edc46cf5362364e232f41fb1cbbf2b7460a66670 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:06:07 +0530 Subject: [PATCH 09/33] fix: working set not updating when a tab is dragged to another pane --- src/extensionsIntegrated/TabBar/drag-drop.js | 19 ++++++++---------- src/project/WorkingSetView.js | 21 ++++++++++++++++++++ test/spec/Extn-Tabbar-integ-test.js | 13 ++++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/drag-drop.js b/src/extensionsIntegrated/TabBar/drag-drop.js index 31a59fc9e8..4abb3d27bc 100644 --- a/src/extensionsIntegrated/TabBar/drag-drop.js +++ b/src/extensionsIntegrated/TabBar/drag-drop.js @@ -193,22 +193,19 @@ define(function (require, exports, module) { const fromPaneId = sourcePaneId; const file = FileSystem.getFileForPath(draggedPath); const open = () => CommandManager.execute(Commands.FILE_OPEN, { fullPath: draggedPath, paneId: targetPaneId }); - - // a placeholder tab has nothing to move, the file joins the target pane's working set and the - // preview in the source pane goes away - if (MainViewManager.findInWorkingSet(fromPaneId, draggedPath) === -1) { + const inSourceWorkingSet = MainViewManager.findInWorkingSet(fromPaneId, draggedPath) !== -1; + const targetHasIt = MainViewManager.findInWorkingSet(targetPaneId, draggedPath) !== -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) { MainViewManager.addToWorkingSet(targetPaneId, file, toIndex); open().always(() => CommandManager.execute(Commands.FILE_CLOSE, { file: file, paneId: fromPaneId })); return; } - // the target pane already has this file, nothing to move, just show it there - if (MainViewManager.findInWorkingSet(targetPaneId, draggedPath) !== -1 || - MainViewManager._getPane(targetPaneId).getViewForPath(draggedPath)) { - open(); - return; - } - MainViewManager._moveView(fromPaneId, targetPaneId, file, toIndex).always(open); } diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index dc8e3603ee..2c6ee3b04a 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -1521,6 +1521,26 @@ define(function (require, exports, module) { } }; + /** + * 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 @@ -1541,6 +1561,7 @@ 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)); DocumentManager.on(this._makeEventName("dirtyFlagChange"), _.bind(this._handleDirtyFlagChanged, this)); diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index 08d3e3b6f2..4a26fb9124 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2468,6 +2468,19 @@ define(function (require, exports, module) { ); 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; From ae8a1733a1d0bb8f9aa7276e12025b21f45633c7 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:06:32 +0530 Subject: [PATCH 10/33] fix: maximum call stack error when switching panes with a file open in both --- src/editor/EditorManager.js | 19 +++++++++++-- src/view/MainViewManager.js | 6 ++-- test/spec/MainViewManager-integ-test.js | 37 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/editor/EditorManager.js b/src/editor/EditorManager.js index 36e613070b..787da29536 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. + var paneEditor = paneId && MainViewManager.getCurrentlyViewedEditor(paneId); + if (paneEditor && paneEditor.document === doc) { + doc._toggleMasterEditor(paneEditor); + _notifyActiveEditorChanged(paneEditor); + } else { + _notifyActiveEditorChanged(doc._masterEditor); + } } /** diff --git a/src/view/MainViewManager.js b/src/view/MainViewManager.js index bcb568dbee..f48888f212 100644 --- a/src/view/MainViewManager.js +++ b/src/view/MainViewManager.js @@ -492,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 diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js index 4465e1a2d4..69d94b99a5 100644 --- a/test/spec/MainViewManager-integ-test.js +++ b/test/spec/MainViewManager-integ-test.js @@ -543,6 +543,43 @@ 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 () { + var js = testPath + "/test.js", + css = testPath + "/test.css", + opens = [[css, "first-pane"], [js, "first-pane"], [js, "second-pane"], [css, "second-pane"]], + i; + + // 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 (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 + var 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"; + })); + + var 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(); From 9742d8f4a7740e74c644955618b9a2a756496de8 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:06:57 +0530 Subject: [PATCH 11/33] fix: rename and show in file tree not working when the sidebar is hidden --- src/document/DocumentCommandHandlers.js | 30 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 967853f415..20b81161b4 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -1924,13 +1924,26 @@ 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 use current file (not selected in ProjectManager if not visible in tree or workingset) + entry = 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 +2019,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 +2030,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 +2041,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(); } /** From 7f046fd88b353122af2b9eb68a4336e3f26c90ad Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:07:23 +0530 Subject: [PATCH 12/33] fix: resize observer loop error when the overflow button toggles --- src/extensionsIntegrated/TabBar/main.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index bf67bb353f..dd491c438c 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -264,8 +264,21 @@ define(function (require, exports, module) { // 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 - this.resizeObserver = new ResizeObserver(() => this.updateOverflowButton()); + // 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); // MainViewManager only announces current file changes for the active pane, so the pane is asked From f57c23a6d816fc52b37aff80366270c42bcf9c0b Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:07:52 +0530 Subject: [PATCH 13/33] feat: add tab bar settings menu with smooth scrolling option --- src/command/Commands.js | 4 ++ src/command/Menus.js | 1 + src/extensionsIntegrated/TabBar/main.js | 59 +++++++++++++++---- src/extensionsIntegrated/TabBar/preference.js | 11 +++- src/nls/root/strings.js | 2 + test/spec/Extn-Tabbar-integ-test.js | 50 ++++++++++++++++ 6 files changed, 115 insertions(+), 12 deletions(-) diff --git a/src/command/Commands.js b/src/command/Commands.js index 0eaf8d9053..314e565c2c 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -270,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() diff --git a/src/command/Menus.js b/src/command/Menus.js index 6db5047ef8..d7e5053259 100644 --- a/src/command/Menus.js +++ b/src/command/Menus.js @@ -73,6 +73,7 @@ define(function (require, exports, module) { 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" }; diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index dd491c438c..9696ca3ca0 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -412,7 +412,7 @@ define(function (require, exports, module) { return; } - bar.scrollTo({ left: scrollLeft, behavior: "smooth" }); + bar.scrollTo({ left: scrollLeft, behavior: Preference.tabBarSmoothScroll ? "smooth" : "auto" }); }; /** @@ -623,6 +623,17 @@ define(function (require, exports, module) { }); }); + // a click on the empty part of the bar, not on a tab, opens the tab bar settings + $(document).on("click contextmenu", ".phoenix-tab-bar", function (event) { + if (event.target !== this) { + 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) { @@ -726,9 +737,11 @@ define(function (require, exports, module) { 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); if (isTabBarActive()) { MainViewManager.getPaneIdList().forEach(_createView); @@ -738,22 +751,48 @@ define(function (require, exports, module) { } } + /** + * flips one of the boolean keys of the tab bar preference + * @param {string} key - the key to toggle + */ + function _togglePreference(key) { + const currentPref = PreferencesManager.get(Preference.PREFERENCES_TAB_BAR); + PreferencesManager.set(Preference.PREFERENCES_TAB_BAR, { + ...currentPref, + [key]: !currentPref[key] + }); + } + /** * 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, + () => _togglePreference("showTabBar")); + CommandManager.register(Strings.CMD_TOGGLE_TABBAR_SMOOTH_SCROLL, Commands.TOGGLE_TABBAR_SMOOTH_SCROLL, + () => _togglePreference("smoothScroll")); + } + + /** + * the settings menu that opens from the empty part of the tab bar. The sort commands order the + * working set, which is what the tabs show, so they belong here as much as in the working set + */ + function _registerSettingsMenu() { + const menu = Menus.registerContextMenu(Menus.ContextMenuIds.FILE_TABS_SETTINGS_MENU); + menu.addMenuItem(Commands.CMD_WORKINGSET_SORT_BY_ADDED); + menu.addMenuItem(Commands.CMD_WORKINGSET_SORT_BY_NAME); + menu.addMenuItem(Commands.CMD_WORKINGSET_SORT_BY_TYPE); + menu.addMenuDivider(); + menu.addMenuItem(Commands.CMD_WORKING_SORT_TOGGLE_AUTO); + menu.addMenuDivider(); + menu.addMenuItem(Commands.TOGGLE_TABBAR_SMOOTH_SCROLL); + menu.addMenuItem(Commands.TOGGLE_TABBAR); } AppInit.appReady(function () { _registerCommands(); + _registerSettingsMenu(); // add the toggle tab bar command to the view menu const viewMenu = Menus.getMenu(Menus.AppMenuBar.VIEW_MENU); 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/nls/root/strings.js b/src/nls/root/strings.js index 500368ceac..b280220b5e 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1293,6 +1293,7 @@ define({ "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_SMOOTH_SCROLL": "Smooth Tab Scrolling", "CMD_TOGGLE_PANELS": "Toggle Panels", "CMD_TOGGLE_PURE_CODE": "No Distractions", "CMD_TOGGLE_FULLSCREEN": "Fullscreen", @@ -2150,6 +2151,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/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index 4a26fb9124..3e600a4c61 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2490,6 +2490,56 @@ define(function (require, exports, module) { }); }); + 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 () { + PreferencesManager.set("tabBar.options", { showTabBar: true, numberOfTabs: -1, smoothScroll: false }); + }); + + 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("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.CMD_WORKINGSET_SORT_BY_NAME); + expect(itemNames).toContain(Strings.CMD_TOGGLE_TABBAR); + + 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); + + // clicking a tab must not open the settings + getTab(testFilePath).trigger($.Event("click", { pageX: 10, pageY: 10 })); + expect($("#tabbar-settings-context-menu").hasClass("open")).toBe(false); + }); + }); + describe("Number of Tabs Preference", function () { beforeEach(async function () { // Enable the tab bar feature with default settings From 7220c6e20c74add43cd220538e20ee82def06bbe Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:08:22 +0530 Subject: [PATCH 14/33] feat: use the live preview pin icon for pinned files --- src/extensionsIntegrated/TabBar/main.js | 2 +- src/extensionsIntegrated/TabBar/overflow.js | 2 +- src/project/WorkingSetView.js | 4 ++-- src/styles/Extn-TabBar.less | 8 +++++-- src/styles/brackets.less | 24 ++++++--------------- src/styles/images/pinned-icon.svg | 1 + 6 files changed, 18 insertions(+), 23 deletions(-) create mode 100644 src/styles/images/pinned-icon.svg diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 9696ca3ca0..610a08c07a 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -209,7 +209,7 @@ define(function (require, exports, module) { if (control.classList.contains("tab-pin") !== state.pinned) { control.className = state.pinned ? "tab-pin" : "tab-close"; control.innerHTML = state.pinned - ? '' + ? '' : ''; control.removeAttribute("title"); } diff --git a/src/extensionsIntegrated/TabBar/overflow.js b/src/extensionsIntegrated/TabBar/overflow.js index 1955c28024..d821c4680f 100644 --- a/src/extensionsIntegrated/TabBar/overflow.js +++ b/src/extensionsIntegrated/TabBar/overflow.js @@ -82,7 +82,7 @@ define(function (require, exports, module) { : ''; // adding an empty span for better alignment const controlHtml = item.isPinned ? ` - ` + ` : ` `; diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index 2c6ee3b04a..f249a9c2f9 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -1235,8 +1235,8 @@ define(function (require, exports, module) { ViewUtils.toggleClass($fileStatusIcon, "can-close", showClose); ViewUtils.toggleClass($fileStatusIcon, "pinned", showPin); if (showPin) { - if ($fileStatusIcon.children(".fa-thumbtack").length === 0) { - $fileStatusIcon.html("").attr("title", Strings.CMD_FILE_UNPIN); + 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"); diff --git a/src/styles/Extn-TabBar.less b/src/styles/Extn-TabBar.less index 2356206a04..036467355e 100644 --- a/src/styles/Extn-TabBar.less +++ b/src/styles/Extn-TabBar.less @@ -272,7 +272,9 @@ } .tab-pin { - font-size: 0.625rem; + font-size: 0.85rem; + display: inline-flex; + align-items: center; } .tab.git-modified > .tab-icon::before, @@ -443,5 +445,7 @@ } .tab-pin-icon-overflow { - font-size: 0.625rem; + font-size: 0.85rem; + display: inline-flex; + align-items: center; } diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 52952ddf06..c718f3b160 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1645,7 +1645,7 @@ a, img { display: block; height: 16px; line-height: 15px; - margin-left: 18px; + margin-left: 20px; padding: 4px 0 3px 0; cursor: default; @@ -1852,25 +1852,15 @@ a, img { &.pinned { cursor: pointer; + display: flex; + align-items: center; + height: 23px; + margin-left: 5px; > i { - display: inline-block; - width: 16px; - height: 16px; - line-height: 16px; - text-align: center; - font-size: 10px; - color: rgba(255, 255, 255, 0.5); - } - - &:hover > i { - color: rgba(255, 255, 255, 0.8); - } - - &:active > i { - color: rgba(255, 255, 255, 0.3); + font-size: 13px; + color: rgba(255, 255, 255, 0.75); } - } &:hover > i { color: rgba(255, 255, 255, 0.95); 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 @@ + From 0b284cad710976e5b70d53730040c5782d1e32fc Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:08:50 +0530 Subject: [PATCH 15/33] fix: right click selecting the item before opening its context menu --- src/command/DefaultMenus.js | 35 ++++++++--- src/document/DocumentCommandHandlers.js | 34 ++++++++--- src/extensions/default/CloseOthers/main.js | 27 ++++++--- src/extensionsIntegrated/TabBar/main.js | 40 ++++++++----- src/project/FileViewController.js | 34 +++++++++++ src/project/ProjectManager.js | 44 +++++++------- src/project/WorkingSetView.js | 68 +++++++++++++--------- src/styles/Extn-TabBar.less | 3 +- src/styles/brackets.less | 4 ++ test/spec/Extn-Tabbar-integ-test.js | 7 ++- 10 files changed, 209 insertions(+), 87 deletions(-) diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index d29e9b8db8..5eccbeb4d5 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -33,7 +33,8 @@ define(function (require, exports, module) { Strings = require("strings"), MainViewManager = require("view/MainViewManager"), CommandManager = require("command/CommandManager"), - WorkingSetView = require("project/WorkingSetView"); + WorkingSetView = require("project/WorkingSetView"), + FileViewController = require("project/FileViewController"); /** * Disables menu items present in items if enabled is true. @@ -57,7 +58,7 @@ define(function (require, exports, module) { * disables menu items accordingly */ function _setMenuItemsVisible() { - var file = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); + var file = WorkingSetView.getContext() || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); if (file) { file.exists(function (err, isPresent) { if (err) { @@ -73,14 +74,12 @@ 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 working set remembers the right-clicked item, the tab bar makes the clicked tab - * current before opening its menu. + * 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 contextFile = !forFileTabs && WorkingSetView.getContext(), - paneId = (!forFileTabs && WorkingSetView.getContextPaneId()) || MainViewManager.ACTIVE_PANE, - file = contextFile || MainViewManager.getCurrentlyViewedFile(paneId), + 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; @@ -378,6 +377,28 @@ define(function (require, exports, module) { _updatePinMenuItemName(true); }); + // Both menus act on the right-clicked file without selecting it. That file is remembered while + // a menu is up and forgotten once it is gone: after an item was clicked (the item's own handler + // has run the command by then), when the menu closes any other way, or on a click elsewhere. + // Right-clicking another item closes the menu and reopens it in one go, so the check waits a + // tick and only forgets the file when neither menu is up anymore. + function _forgetWorkingSetContextWhenClosed() { + window.setTimeout(function () { + if (!workingset_cmenu.isOpen() && !filetabs_cmenu.isOpen()) { + FileViewController.setWorkingSetContext(null); + } + }, 0); + } + [workingset_cmenu, filetabs_cmenu].forEach(function (cmenu) { + cmenu.on("beforeContextMenuClose", _forgetWorkingSetContextWhenClosed); + $("#" + cmenu.id).on("click.dropdown-menu", _forgetWorkingSetContextWhenClosed); + }); + $(window.document).on("mousedown", function (e) { + if (FileViewController.getWorkingSetContext() && !$(e.target).closest(".context-menu").length) { + _forgetWorkingSetContextWhenClosed(); + } + }); + var splitview_menu = Menus.registerContextMenu(Menus.ContextMenuIds.SPLITVIEW_MENU); splitview_menu.addMenuItem(Commands.CMD_SPLITVIEW_NONE); splitview_menu.addMenuItem(Commands.CMD_SPLITVIEW_VERTICAL); diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 20b81161b4..9905ad5ade 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -1299,8 +1299,15 @@ 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(); } @@ -1480,6 +1487,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 + var 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) { @@ -1929,8 +1943,10 @@ define(function (require, exports, module) { return; } - // Else use current file (not selected in ProjectManager if not visible in tree or workingset) - entry = MainViewManager.getCurrentlyViewedFile(); + // 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) + var context = FileViewController.getWorkingSetContext(); + entry = (context && context.file) || MainViewManager.getCurrentlyViewedFile(); if (!entry) { return; } @@ -2071,9 +2087,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); } } @@ -2145,8 +2162,9 @@ define(function (require, exports, module) { */ function handleFilePin(commandData) { const data = commandData || {}, - paneId = data.paneId || MainViewManager.ACTIVE_PANE, - file = data.file || MainViewManager.getCurrentlyViewedFile(paneId); + context = FileViewController.getWorkingSetContext(), + paneId = data.paneId || (context && context.paneId) || MainViewManager.ACTIVE_PANE, + file = data.file || (context && context.file) || MainViewManager.getCurrentlyViewedFile(paneId); if (!file) { return; diff --git a/src/extensions/default/CloseOthers/main.js b/src/extensions/default/CloseOthers/main.js index 535d3aeb8d..8a707ecf20 100644 --- a/src/extensions/default/CloseOthers/main.js +++ b/src/extensions/default/CloseOthers/main.js @@ -114,16 +114,27 @@ define(function (require, exports, module) { } /** - * Handle the different Close commands, relative to the current file of the active pane + * 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 getTarget() { + const paneId = WorkingSetView.getContextPaneId() || MainViewManager.ACTIVE_PANE, + file = WorkingSetView.getContext() || MainViewManager.getCurrentlyViewedFile(paneId); + return { file: file, paneId: paneId }; + } + + /** + * Handle the different Close commands * @param {string} mode */ function handleClose(mode) { - const targetPath = MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE); - if (!targetPath) { + const target = getTarget(); + if (!target.file) { return; } - const files = getFilesToClose(MainViewManager.ACTIVE_PANE, mode, targetPath); + const files = getFilesToClose(target.paneId, mode, target.file.fullPath); if (files.length) { return CommandManager.execute(Commands.FILE_CLOSE_LIST, {fileList: files}); } @@ -134,11 +145,9 @@ define(function (require, exports, module) { * @param {boolean} forFileTabs - true when the tab bar menu is opening, false for the working set menu */ function contextMenuOpenHandler(forFileTabs) { - // the working set remembers the item (and pane) that was right-clicked, the tab bar makes the - // clicked tab the current file before it opens its menu - const contextFile = !forFileTabs && WorkingSetView.getContext(), - paneId = (!forFileTabs && WorkingSetView.getContextPaneId()) || MainViewManager.ACTIVE_PANE, - file = contextFile || MainViewManager.getCurrentlyViewedFile(paneId), + const target = getTarget(), + file = target.file, + paneId = target.paneId, names = forFileTabs ? fileTabNames : workingSetNames; MENU_ORDER.forEach(function (commandId) { diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 610a08c07a..653e705620 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -32,6 +32,7 @@ define(function (require, exports, module) { 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"); @@ -435,6 +436,22 @@ define(function (require, exports, module) { this.tabs.forEach((tab, path) => _applyGitStatus(tab, path)); }; + /** + * 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"); + } + const tab = path && this.tabs.get(path); + if (tab) { + tab.classList.add("context"); + } + }; + function _forEachView(callback) { Object.keys(_views).forEach((paneId) => callback(_views[paneId])); } @@ -602,25 +619,15 @@ define(function (require, exports, module) { }); // 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(); const path = this.getAttribute("data-path"); - const paneId = _paneIdOf(this); - const location = { pageX: event.pageX, pageY: event.pageY }; - - // the menu commands work on the current file of the active pane, so the right-clicked tab - // is made current first, exactly like right-clicking an item in the working set selects it - const isCurrent = MainViewManager.getActivePaneId() === paneId && - MainViewManager.getCurrentlyViewedPath(paneId) === path; - const ready = isCurrent - ? $.Deferred().resolve().promise() - : CommandManager.execute(Commands.FILE_OPEN, { fullPath: path, paneId: paneId }); - - ready.always(function () { - Menus.getContextMenu(Menus.ContextMenuIds.FILE_TABS_CONTEXT_MENU).open(location); - }); + FileViewController.setWorkingSetContext(FileSystem.getFileForPath(path), _paneIdOf(this)); + Menus.getContextMenu(Menus.ContextMenuIds.FILE_TABS_CONTEXT_MENU) + .open({ pageX: event.pageX, pageY: event.pageY }); }); // a click on the empty part of the bar, not on a tab, opens the tab bar settings @@ -726,6 +733,11 @@ define(function (require, exports, module) { } CommandManager.on("beforeExecuteCommand", _onBeforeCommand); + + FileViewController.on("workingSetContextChange", function (event, context) { + _forEachView((view) => view.setContextPath( + context && context.paneId === view.paneId ? context.file.fullPath : null)); + }); } /** diff --git a/src/project/FileViewController.js b/src/project/FileViewController.js index 1e63bc64ec..f809f115b3 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 + */ + var _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..706d4e79f2 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) { + var 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 f249a9c2f9..57310806f6 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -62,20 +62,6 @@ define(function (require, exports, module) { */ var _iconProviders = []; - /** - * The file/folder object of the current context - * @type {FileSystemEntry} - * @private - */ - var _contextEntry; - - /** - * The id of the pane whose working set the context menu was opened on - * @type {string} - * @private - */ - var _contextPaneId; - /** * Class Providers * @see {@link #addClassProvider} @@ -97,7 +83,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. @@ -880,9 +867,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; } @@ -1521,6 +1513,23 @@ 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) { + var $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 @@ -1566,6 +1575,8 @@ define(function (require, exports, module) { 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); @@ -1576,8 +1587,9 @@ define(function (require, exports, module) { var self = this; this.$openFilesContainer.on("contextmenu.workingSetView", function (e) { - _contextEntry = $(e.target).closest("li").data(_FILE_KEY); - _contextPaneId = self.paneId; + // the menu acts on the right-clicked item, the selection stays where it is + var file = $(e.target).closest("li").data(_FILE_KEY); + FileViewController.setWorkingSetContext(file || null, self.paneId); Menus.getContextMenu(Menus.ContextMenuIds.WORKING_SET_CONTEXT_MENU).open(e); }); @@ -1714,20 +1726,22 @@ 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; + var context = FileViewController.getWorkingSetContext(); + return context ? context.file : undefined; } /** - * Gets the id of the pane whose working set the context menu was last opened on. The item that - * was right-clicked is being made the current file at the same time, so when the menu opens the - * active pane may not have caught up yet, this one has. - * @return {string} + * Gets the id of the pane the file of `getContext()` is shown or listed in + * @return {?string} */ function getContextPaneId() { - return _contextPaneId; + var context = FileViewController.getWorkingSetContext(); + return context ? context.paneId : undefined; } // Public API diff --git a/src/styles/Extn-TabBar.less b/src/styles/Extn-TabBar.less index 036467355e..9321ae0d72 100644 --- a/src/styles/Extn-TabBar.less +++ b/src/styles/Extn-TabBar.less @@ -175,7 +175,8 @@ } } - &.drag-target { + &.drag-target, + &.context { background-color: #e5e5e5; .dark & { diff --git a/src/styles/brackets.less b/src/styles/brackets.less index c718f3b160..356d0ded66 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1688,6 +1688,10 @@ a, img { z-index: @z-index-brackets-selection-extension; /* scroller-shadow appears above the extension */ } +.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; diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index 3e600a4c61..bb5503b6a5 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -1911,6 +1911,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", { @@ -1927,8 +1929,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(); From 96130d5cac834a741f3aea318cdc480b63075d95 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:09:15 +0530 Subject: [PATCH 16/33] feat: show placeholder item in the working set for single clicked files --- src/project/WorkingSetView.js | 84 ++++++++++++++++++- src/styles/brackets.less | 4 + test/spec/Extn-Tabbar-integ-test.js | 124 ++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 3 deletions(-) diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index 57310806f6..ad56bc3454 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -914,13 +914,52 @@ 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 () { + var file = MainViewManager.getCurrentlyViewedFile(this.paneId); + if (file && MainViewManager.findInWorkingSet(this.paneId, file.fullPath) === -1) { + return file; + } + return null; + }; + + /** + * Keeps the placeholder item in step with the file the pane shows + * @private + */ + WorkingSetView.prototype._syncPlaceholder = function () { + var $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 () { var fileList = MainViewManager.getWorkingSet(this.paneId); - if (MainViewManager.getPaneCount() === 1 && (!fileList || fileList.length === 0)) { + var noFiles = !fileList || fileList.length === 0; + if (MainViewManager.getPaneCount() === 1 && noFiles && !this._getPlaceholderFile()) { this.$openFilesContainer.hide(); this.$workingSetListViewHeader.hide(); } else { @@ -1144,6 +1183,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(); @@ -1259,9 +1307,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, @@ -1296,7 +1345,24 @@ define(function (require, exports, module) { // Update the listItem's apperance 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 @@ -1336,6 +1402,11 @@ define(function (require, exports, module) { self._createNewListItem(file); }); + var placeholderFile = this._getPlaceholderFile(); + if (placeholderFile) { + this._createNewListItem(placeholderFile, true); + } + if (forceRedraw) { self._redraw(); } @@ -1572,6 +1643,12 @@ define(function (require, exports, module) { 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 = MainViewManager._getPane(this.paneId); + 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)); @@ -1605,6 +1682,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("")); }; diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 356d0ded66..1d8e9b360c 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1688,6 +1688,10 @@ 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); } diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index bb5503b6a5..a137a3de32 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2545,6 +2545,130 @@ define(function (require, exports, module) { }); }); + 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; + }); + } + + async function addToWorkingSet(filePaths) { + for (const filePath of filePaths) { + await awaitsForDone( + CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, { fullPath: filePath }), + `Add ${filePath} to the working set` + ); + } + } + + // 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); + }); + }); + describe("Number of Tabs Preference", function () { beforeEach(async function () { // Enable the tab bar feature with default settings From b80b2d32d196c9ca4c5c85879b529b700f1b9821 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:09:41 +0530 Subject: [PATCH 17/33] feat: number of tabs submenu in the tab bar settings and remove the sort options --- src/extensionsIntegrated/TabBar/main.js | 43 +++++++++++++++++-------- src/nls/root/strings.js | 3 ++ test/spec/Extn-Tabbar-integ-test.js | 5 +-- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 653e705620..c8a939154b 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -754,6 +754,9 @@ define(function (require, exports, module) { // 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()) { MainViewManager.getPaneIdList().forEach(_createView); @@ -763,15 +766,24 @@ define(function (require, exports, module) { } } + // the choices offered in the settings menu for how many tabs to show, -1 means all of them + const NUMBER_OF_TABS_CHOICES = [-1, 5, 10, 15, 20]; + const HIDE_TABBAR_COMMAND = "tabbar.hide"; + + function _numberOfTabsCommandId(count) { + return count === -1 ? "tabbar.numberOfTabs.all" : "tabbar.numberOfTabs." + count; + } + /** - * flips one of the boolean keys of the tab bar preference - * @param {string} key - the key to toggle + * changes one key of the tab bar preference + * @param {string} key - the key to change + * @param {*} value - the new value */ - function _togglePreference(key) { + function _setPreference(key, value) { const currentPref = PreferencesManager.get(Preference.PREFERENCES_TAB_BAR); PreferencesManager.set(Preference.PREFERENCES_TAB_BAR, { ...currentPref, - [key]: !currentPref[key] + [key]: value }); } @@ -781,25 +793,28 @@ define(function (require, exports, module) { */ function _registerCommands() { CommandManager.register(Strings.CMD_TOGGLE_TABBAR, Commands.TOGGLE_TABBAR, - () => _togglePreference("showTabBar")); + () => _setPreference("showTabBar", !Preference.tabBarEnabled)); CommandManager.register(Strings.CMD_TOGGLE_TABBAR_SMOOTH_SCROLL, Commands.TOGGLE_TABBAR_SMOOTH_SCROLL, - () => _togglePreference("smoothScroll")); + () => _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)); + }); } /** - * the settings menu that opens from the empty part of the tab bar. The sort commands order the - * working set, which is what the tabs show, so they belong here as much as in the working set + * the settings menu that opens from the empty part of the tab bar */ function _registerSettingsMenu() { const menu = Menus.registerContextMenu(Menus.ContextMenuIds.FILE_TABS_SETTINGS_MENU); - menu.addMenuItem(Commands.CMD_WORKINGSET_SORT_BY_ADDED); - menu.addMenuItem(Commands.CMD_WORKINGSET_SORT_BY_NAME); - menu.addMenuItem(Commands.CMD_WORKINGSET_SORT_BY_TYPE); + menu.addMenuItem(Commands.TOGGLE_TABBAR_SMOOTH_SCROLL); menu.addMenuDivider(); - menu.addMenuItem(Commands.CMD_WORKING_SORT_TOGGLE_AUTO); + 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(Commands.TOGGLE_TABBAR_SMOOTH_SCROLL); - menu.addMenuItem(Commands.TOGGLE_TABBAR); + menu.addMenuItem(HIDE_TABBAR_COMMAND); } AppInit.appReady(function () { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index b280220b5e..b36dfb0368 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1294,6 +1294,9 @@ define({ "LIVE_PREVIEW_EXIT_FULL_SCREEN": "Exit Full Screen", "CMD_TOGGLE_TABBAR": "File 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", diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index a137a3de32..a4f3dcf92e 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2522,8 +2522,9 @@ define(function (require, exports, module) { return $(this).text().trim(); }).get(); expect(itemNames).toContain(Strings.CMD_TOGGLE_TABBAR_SMOOTH_SCROLL); - expect(itemNames).toContain(Strings.CMD_WORKINGSET_SORT_BY_NAME); - expect(itemNames).toContain(Strings.CMD_TOGGLE_TABBAR); + 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; From 9220550d73ddcb492b4adc21c9fa7a2b53621272 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:10:07 +0530 Subject: [PATCH 18/33] fix: editor flashing when clicking on the already active tab --- src/extensionsIntegrated/TabBar/main.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index c8a939154b..9c5b5f3017 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -510,13 +510,15 @@ define(function (require, exports, module) { } function _focusEditor() { - // the mousedown that got us here hasn't finished yet, focusing right away would get undone - setTimeout(function () { + // 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(); } - }, 0); + }); } /** From b80b91708213b14fe8bf4e8518e595c5ccc7d419 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:10:35 +0530 Subject: [PATCH 19/33] fix: tab width changing on pin and pin icon misaligned in the working set --- src/styles/Extn-TabBar.less | 12 +++++++----- src/styles/brackets.less | 7 ++++--- test/spec/Extn-Tabbar-integ-test.js | 5 +++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/styles/Extn-TabBar.less b/src/styles/Extn-TabBar.less index 9321ae0d72..6e751f0ca2 100644 --- a/src/styles/Extn-TabBar.less +++ b/src/styles/Extn-TabBar.less @@ -238,8 +238,11 @@ .tab-close, .tab-pin { - font-size: 0.75rem; - padding: 0.1rem 0.4rem; + 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; @@ -260,6 +263,7 @@ } .tab-close { + font-size: 0.75rem; visibility: hidden; opacity: 0; pointer-events: none; @@ -273,9 +277,7 @@ } .tab-pin { - font-size: 0.85rem; - display: inline-flex; - align-items: center; + font-size: 0.8rem; } .tab.git-modified > .tab-icon::before, diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 1d8e9b360c..865017ac87 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1645,7 +1645,7 @@ a, img { display: block; height: 16px; line-height: 15px; - margin-left: 20px; + margin-left: 22px; padding: 4px 0 3px 0; cursor: default; @@ -1862,11 +1862,12 @@ a, img { cursor: pointer; display: flex; align-items: center; + justify-content: center; + width: 16px; height: 23px; - margin-left: 5px; > i { - font-size: 13px; + font-size: 12px; color: rgba(255, 255, 255, 0.75); } diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index a4f3dcf92e..a36649edbb 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -3352,8 +3352,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 From a4fadb33bd7abaee5ee51326a0d9a1893615fc9b Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:11:02 +0530 Subject: [PATCH 20/33] fix: right clicked file still remembered after the context menu closes --- src/command/DefaultMenus.js | 51 ++++++++++++++------------- test/spec/Extn-Tabbar-integ-test.js | 54 ++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index 5eccbeb4d5..76a10bcae0 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -34,7 +34,8 @@ define(function (require, exports, module) { MainViewManager = require("view/MainViewManager"), CommandManager = require("command/CommandManager"), WorkingSetView = require("project/WorkingSetView"), - FileViewController = require("project/FileViewController"); + FileViewController = require("project/FileViewController"), + ProjectManager = require("project/ProjectManager"); /** * Disables menu items present in items if enabled is true. @@ -56,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 = WorkingSetView.getContext() || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); + function _setMenuItemsVisible(contextEntry) { + var file = contextEntry || MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); if (file) { file.exists(function (err, isPresent) { if (err) { @@ -377,26 +379,20 @@ define(function (require, exports, module) { _updatePinMenuItemName(true); }); - // Both menus act on the right-clicked file without selecting it. That file is remembered while - // a menu is up and forgotten once it is gone: after an item was clicked (the item's own handler - // has run the command by then), when the menu closes any other way, or on a click elsewhere. - // Right-clicking another item closes the menu and reopens it in one go, so the check waits a - // tick and only forgets the file when neither menu is up anymore. + // 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() { - window.setTimeout(function () { - if (!workingset_cmenu.isOpen() && !filetabs_cmenu.isOpen()) { - FileViewController.setWorkingSetContext(null); - } - }, 0); + if (!workingset_cmenu.isOpen() && !filetabs_cmenu.isOpen()) { + FileViewController.setWorkingSetContext(null); + } } + const menuStateObserver = new window.MutationObserver(_forgetWorkingSetContextWhenClosed); [workingset_cmenu, filetabs_cmenu].forEach(function (cmenu) { - cmenu.on("beforeContextMenuClose", _forgetWorkingSetContextWhenClosed); - $("#" + cmenu.id).on("click.dropdown-menu", _forgetWorkingSetContextWhenClosed); - }); - $(window.document).on("mousedown", function (e) { - if (FileViewController.getWorkingSetContext() && !$(e.target).closest(".context-menu").length) { - _forgetWorkingSetContextWhenClosed(); - } + menuStateObserver.observe($("#" + cmenu.id)[0], { attributes: true, attributeFilter: ["class"] }); }); var splitview_menu = Menus.registerContextMenu(Menus.ContextMenuIds.SPLITVIEW_MENU); @@ -544,9 +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.FILE_TABS_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/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index a36649edbb..3bdbcd7d44 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -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 @@ -2556,15 +2575,6 @@ define(function (require, exports, module) { }); } - async function addToWorkingSet(filePaths) { - for (const filePath of filePaths) { - await awaitsForDone( - CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, { fullPath: filePath }), - `Add ${filePath} to the working set` - ); - } - } - // 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 () { @@ -2668,6 +2678,32 @@ define(function (require, exports, module) { 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); + }); }); describe("Number of Tabs Preference", function () { From 2e7210c9e859a26b622c51674edbbb7d9b0c1256 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:11:29 +0530 Subject: [PATCH 21/33] fix: closed file briefly showing as a placeholder while the next file opens --- src/extensionsIntegrated/TabBar/main.js | 15 ++++------- src/project/WorkingSetView.js | 8 ++---- src/view/Pane.js | 26 ++++++++++++++++++ test/spec/Extn-Tabbar-integ-test.js | 20 ++++++++++++++ test/spec/Pane-test.js | 36 +++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 9c5b5f3017..c18a890c50 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -310,19 +310,14 @@ define(function (require, exports, module) { TabBarView.prototype.update = function () { const paneId = this.paneId; const workingSet = MainViewManager.getWorkingSet(paneId); - const currentFile = MainViewManager.getCurrentlyViewedFile(paneId); - const currentPath = currentFile ? currentFile.fullPath : null; + 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 - // and gets a tab at the end. But when the current file was just closed it isn't in the working set - // either while the next file is still being opened, that one had a real tab a moment ago so it's not - // a placeholder, its tab simply goes away - const inWorkingSet = workingSet.some((file) => file.fullPath === currentPath); - const previousTab = currentPath && this.tabs.get(currentPath); - const wasWorkingSetTab = previousTab && !previousTab.classList.contains("placeholder"); - if (currentFile && !inWorkingSet && !wasWorkingSetTab) { - entries.push({ file: currentFile, placeholder: true }); + // and gets a tab at the end + const placeholderFile = this.pane.getTemporaryViewFile(); + if (placeholderFile) { + entries.push({ file: placeholderFile, placeholder: true }); } // if no files are present in the pane and no placeholder, we hide the tab bar for that pane diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index ad56bc3454..b1375783a6 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -922,11 +922,7 @@ define(function (require, exports, module) { * @return {?File} */ WorkingSetView.prototype._getPlaceholderFile = function () { - var file = MainViewManager.getCurrentlyViewedFile(this.paneId); - if (file && MainViewManager.findInWorkingSet(this.paneId, file.fullPath) === -1) { - return file; - } - return null; + return this._pane.getTemporaryViewFile(); }; /** @@ -1627,6 +1623,7 @@ define(function (require, exports, module) { * @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"); @@ -1646,7 +1643,6 @@ define(function (require, exports, module) { // 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 = MainViewManager._getPane(this.paneId); this._pane.on(this._makeEventName("currentViewChange"), _.bind(this._syncPlaceholder, this)); DocumentManager.on(this._makeEventName("dirtyFlagChange"), _.bind(this._handleDirtyFlagChanged, this)); diff --git a/src/view/Pane.js b/src/view/Pane.js index ec71582120..a95f376757 100644 --- a/src/view/Pane.js +++ b/src/view/Pane.js @@ -507,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} @@ -567,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 @@ -929,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); @@ -986,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; } } diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index 3bdbcd7d44..fb76853df2 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2704,6 +2704,26 @@ define(function (require, exports, module) { expect($tab.hasClass("context")).toBe(false); expect(MainViewManager.getCurrentlyViewedPath("first-pane")).toBe(testFilePath2); }); + + 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("Number of Tabs Preference", function () { diff --git a/test/spec/Pane-test.js b/test/spec/Pane-test.js index dd8dd06ff0..9999a30909 100644 --- a/test/spec/Pane-test.js +++ b/test/spec/Pane-test.js @@ -451,6 +451,42 @@ 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()); + }); + }); + describe("Pinning", function () { var files; From ff60e720ca61574df12e12bed12f46d969e42966 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:11:56 +0530 Subject: [PATCH 22/33] fix: save keeping the wrong placeholder when triggered from the context menu --- src/command/Menus.js | 1 - src/document/DocumentCommandHandlers.js | 23 +++++++++++++++++++++++ src/extensionsIntegrated/TabBar/main.js | 25 ------------------------- test/spec/Extn-Tabbar-integ-test.js | 20 ++++++++++++++++++++ 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/command/Menus.js b/src/command/Menus.js index d7e5053259..da48439327 100644 --- a/src/command/Menus.js +++ b/src/command/Menus.js @@ -746,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 9905ad5ade..0e0a4341db 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 @@ -1311,6 +1323,11 @@ define(function (require, exports, module) { 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()) { @@ -1421,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(); } diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index c18a890c50..51aa2a5998 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -646,29 +646,6 @@ define(function (require, exports, module) { }); } - /** - * 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 _onBeforeCommand(event, commandId) { - if (!isTabBarActive() || (commandId !== Commands.FILE_SAVE && commandId !== Commands.FILE_SAVE_ALL)) { - return; - } - - const savedPath = commandId === Commands.FILE_SAVE - ? MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE) - : null; - - MainViewManager.getPaneIdList().forEach((paneId) => { - const file = MainViewManager.getCurrentlyViewedFile(paneId); - const isPlaceholder = file && MainViewManager.findInWorkingSet(paneId, file.fullPath) === -1; - if (isPlaceholder && (!savedPath || file.fullPath === savedPath)) { - MainViewManager.addToWorkingSet(paneId, file); - } - }); - } - /** * Registers the event handlers */ @@ -729,8 +706,6 @@ define(function (require, exports, module) { }); } - CommandManager.on("beforeExecuteCommand", _onBeforeCommand); - FileViewController.on("workingSetContextChange", function (event, context) { _forEachView((view) => view.setContextPath( context && context.paneId === view.paneId ? context.file.fullPath : null)); diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index fb76853df2..29c0ccc019 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2705,6 +2705,26 @@ define(function (require, exports, module) { 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 not show a closed file as a placeholder while the next one opens", async function () { await addToWorkingSet([testFilePath2, testFilePath3]); await waitForTabs([testFilePath, testFilePath2, testFilePath3]); From c706f565b18fdc6318ce787df6d7d91c7860e504 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:12:23 +0530 Subject: [PATCH 23/33] fix: renaming a single clicked file leaving a stale tab and working set item --- src/view/Pane.js | 4 +++- test/spec/Pane-test.js | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/view/Pane.js b/src/view/Pane.js index a95f376757..8629a426bc 100644 --- a/src/view/Pane.js +++ b/src/view/Pane.js @@ -1227,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)) { diff --git a/test/spec/Pane-test.js b/test/spec/Pane-test.js index 9999a30909..789e639bc2 100644 --- a/test/spec/Pane-test.js +++ b/test/spec/Pane-test.js @@ -485,6 +485,22 @@ define(function (require, exports, module) { 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 () { From b3517d8bf52df59888057f3db046ef9152def22e Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:12:48 +0530 Subject: [PATCH 24/33] fix: dropping a tab in a pane that already has the file lands at the wrong position --- src/extensionsIntegrated/TabBar/drag-drop.js | 21 ++++++++-- test/spec/Extn-Tabbar-integ-test.js | 44 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/drag-drop.js b/src/extensionsIntegrated/TabBar/drag-drop.js index 4abb3d27bc..632328483d 100644 --- a/src/extensionsIntegrated/TabBar/drag-drop.js +++ b/src/extensionsIntegrated/TabBar/drag-drop.js @@ -173,6 +173,17 @@ define(function (require, exports, module) { return; } + _reorder(paneId, fromIndex, toIndex); + } + + /** + * 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 {number} fromIndex - the working set index of the item + * @param {number} toIndex - the working set index to move to, before the move + */ + function _reorder(paneId, fromIndex, toIndex) { if (fromIndex < toIndex) { toIndex--; } @@ -194,14 +205,18 @@ define(function (require, exports, module) { const file = FileSystem.getFileForPath(draggedPath); const open = () => CommandManager.execute(Commands.FILE_OPEN, { fullPath: draggedPath, paneId: targetPaneId }); const inSourceWorkingSet = MainViewManager.findInWorkingSet(fromPaneId, draggedPath) !== -1; - const targetHasIt = MainViewManager.findInWorkingSet(targetPaneId, draggedPath) !== -1 || - MainViewManager._getPane(targetPaneId).getViewForPath(draggedPath); + 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) { - MainViewManager.addToWorkingSet(targetPaneId, file, toIndex); + if (targetIndex === -1) { + MainViewManager.addToWorkingSet(targetPaneId, file, toIndex); + } else { + _reorder(targetPaneId, targetIndex, toIndex); + } open().always(() => CommandManager.execute(Commands.FILE_CLOSE, { file: file, paneId: fromPaneId })); return; } diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index 29c0ccc019..fb99ffbde6 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2746,6 +2746,50 @@ define(function (require, exports, module) { }); }); + 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 From 083de8f39d482aecee422d71fc13292bbd1ceea1 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:13:14 +0530 Subject: [PATCH 25/33] fix: ctrl click on mac opening the tab and presses ending on the empty bar opening the settings --- src/extensionsIntegrated/TabBar/main.js | 13 ++++++++++--- test/spec/Extn-Tabbar-integ-test.js | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 51aa2a5998..2b584c51de 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -560,7 +560,9 @@ define(function (require, exports, module) { $(document).on("mousedown", TAB, function (event) { // right clicks are for the context menu and the close/pin icons have their own click handler - if (event.which === 3 || $(event.target).closest(".tab-close, .tab-pin").length) { + const rightClick = event.which === 3 || event.button === 2 || + (event.ctrlKey && brackets.platform === "mac"); + if (rightClick || $(event.target).closest(".tab-close, .tab-pin").length) { return; } @@ -627,9 +629,14 @@ define(function (require, exports, module) { .open({ pageX: event.pageX, pageY: event.pageY }); }); - // a click on the empty part of the bar, not on a tab, opens the tab bar settings + // 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) { + if (event.target !== this || (event.type === "click" && !pressedEmptyBar)) { return; } event.preventDefault(); diff --git a/test/spec/Extn-Tabbar-integ-test.js b/test/spec/Extn-Tabbar-integ-test.js index fb99ffbde6..8e5976a63f 100644 --- a/test/spec/Extn-Tabbar-integ-test.js +++ b/test/spec/Extn-Tabbar-integ-test.js @@ -2529,6 +2529,7 @@ define(function (require, exports, module) { 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( From cc4fbccbccb1c21726f65753e39f7039f225ecc3 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:13:41 +0530 Subject: [PATCH 26/33] fix: overflow button showing up with an empty menu --- src/extensionsIntegrated/TabBar/main.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 2b584c51de..75f88473ce 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -383,7 +383,9 @@ define(function (require, exports, module) { TabBarView.prototype.updateOverflowButton = function () { const bar = this.bar; const buttonWidth = this.$overflowButton.hasClass("hidden") ? 0 : this.$overflowButton[0].offsetWidth; - this.$overflowButton.toggleClass("hidden", bar.scrollWidth <= bar.clientWidth + buttonWidth); + // 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); }; /** From 8f6f3f9d12d6ec1e90519979fcd6964cc8504027 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:14:09 +0530 Subject: [PATCH 27/33] fix: icon providers registered late not reflected on existing tabs --- src/extensionsIntegrated/TabBar/main.js | 17 +++++++++++++++++ src/project/WorkingSetView.js | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 75f88473ce..2758cc25a5 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -449,6 +449,17 @@ define(function (require, exports, module) { } }; + /** + * 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); + }); + }; + function _forEachView(callback) { Object.keys(_views).forEach((paneId) => callback(_views[paneId])); } @@ -719,6 +730,12 @@ define(function (require, exports, module) { _forEachView((view) => view.setContextPath( context && context.paneId === view.paneId ? context.file.fullPath : null)); }); + + // 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()); + }); } /** diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index b1375783a6..cd5c143237 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 @@ -1719,6 +1720,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"); } /** @@ -1742,6 +1744,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 () { @@ -1819,6 +1822,9 @@ define(function (require, exports, module) { } // 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; From c5ba2d37753d0376886f7a89caed7c11123c0add Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 18 Oct 2026 21:17:31 +0530 Subject: [PATCH 28/33] chore: auto update API docs --- docs/API-Reference/command/Commands.md | 18 +++++++ docs/API-Reference/command/Menus.md | 2 + docs/API-Reference/features/TaskManager.md | 1 + .../project/FileViewController.md | 21 ++++++++ docs/API-Reference/project/WorkingSetView.md | 11 +++- docs/API-Reference/utils/DragAndDrop.md | 3 +- docs/API-Reference/view/MainViewManager.md | 35 ++++++++++++- docs/API-Reference/view/Pane.md | 50 ++++++++++++++++++- 8 files changed, 136 insertions(+), 5 deletions(-) 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 From a691676d4691062626c125c5b97e34a713283036 Mon Sep 17 00:00:00 2001 From: Pluto Date: Tue, 20 Oct 2026 16:34:51 +0530 Subject: [PATCH 29/33] fix: pane header growing on hover and pushing the tab bar and editor --- src/styles/brackets.less | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 865017ac87..52efb9682a 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; } } } From 155980f33797a96b1508bfc2c1486af82a9583dd Mon Sep 17 00:00:00 2001 From: Pluto Date: Tue, 20 Oct 2026 16:50:08 +0530 Subject: [PATCH 30/33] fix: keyboard shortcut icon touching the menu item label --- src/styles/brackets_patterns_override.less | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index f293063952..2919376323 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -610,6 +610,7 @@ a:focus { .keyboard-icon { visibility: hidden; + margin-left: 15px; } .menu-shortcut { From fb0d8453a9165433e655f7dd78a86ac3495f89d3 Mon Sep 17 00:00:00 2001 From: Pluto Date: Tue, 20 Oct 2026 16:50:19 +0530 Subject: [PATCH 31/33] fix: tab bar settings menu offering impractical tab counts and an extra divider --- src/extensionsIntegrated/TabBar/main.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/extensionsIntegrated/TabBar/main.js b/src/extensionsIntegrated/TabBar/main.js index 2758cc25a5..7407e09fb6 100644 --- a/src/extensionsIntegrated/TabBar/main.js +++ b/src/extensionsIntegrated/TabBar/main.js @@ -765,7 +765,7 @@ define(function (require, exports, module) { } // the choices offered in the settings menu for how many tabs to show, -1 means all of them - const NUMBER_OF_TABS_CHOICES = [-1, 5, 10, 15, 20]; + const NUMBER_OF_TABS_CHOICES = [-1, 1, 3, 5, 10]; const HIDE_TABBAR_COMMAND = "tabbar.hide"; function _numberOfTabsCommandId(count) { @@ -808,7 +808,6 @@ define(function (require, exports, module) { function _registerSettingsMenu() { const menu = Menus.registerContextMenu(Menus.ContextMenuIds.FILE_TABS_SETTINGS_MENU); menu.addMenuItem(Commands.TOGGLE_TABBAR_SMOOTH_SCROLL); - menu.addMenuDivider(); 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(); From df2fc66a7067895aaca0c37e8db9f2b2f7923d8b Mon Sep 17 00:00:00 2001 From: Pluto Date: Tue, 20 Oct 2026 17:00:05 +0530 Subject: [PATCH 32/33] fix: working set drag ghost turning white when the mouse rests on it --- src/styles/brackets_patterns_override.less | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index 2919376323..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.) From bb4ee9d790d411e526801a9deb6293e6e7fe3e30 Mon Sep 17 00:00:00 2001 From: Pluto Date: Tue, 20 Oct 2026 17:02:14 +0530 Subject: [PATCH 33/33] fix: tab bar named inconsistently across menus --- src/nls/root/strings.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index b36dfb0368..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.)", @@ -1292,7 +1292,7 @@ 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",