From cca4a3f01299066ffad7739effea95b76f56585a Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 25 Jul 2026 16:15:53 +0530 Subject: [PATCH 1/3] fix(workingset): reposition selection marker when working set becomes visible The working set selection highlight is an absolutely positioned marker div that only moves when a selectionChanged event fires. On startup the working set container gets the working-set-hidden class (the showWorkingSet pref is read before it is defined), so the session-restore file open positions the marker while the container is display:none - every offset computes to 0 and the marker parks on the first row. The same stale marker happens when the current file changes while the working set is hidden via the showWorkingSet pref or while another sidebar tab (e.g. AI) is active. Add WorkingSetView.refreshSelection() which recomputes the selection marker and scrolls the selected item into view, and call it when the showWorkingSet pref un-hides the container and when switching back to the files sidebar tab. Note refresh() cannot be used for this as it restores the pre-redraw scrollTop, which reads 0 on hidden elements and would undo the scroll-into-view. Adds integration tests for both paths in mainview:WorkingSetView. Claude-Session: https://claude.ai/code/session_018BpQ3Z7w4H2U5qrPGWYRWB --- src/project/SidebarView.js | 3 + src/project/WorkingSetView.js | 12 ++++ src/view/SidebarTabs.js | 8 ++- test/spec/WorkingSetView-integ-test.js | 88 ++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/project/SidebarView.js b/src/project/SidebarView.js index ed17a37d88..27a94e7a7f 100644 --- a/src/project/SidebarView.js +++ b/src/project/SidebarView.js @@ -301,6 +301,9 @@ define(function (require, exports, module) { if(getPref) { // refer to brackets.less file for styles $workingSet.removeClass("working-set-hidden"); + // marker positions computed while the container was display:none are + // all 0, so recompute the selection now that it is visible again + WorkingSetView.refreshSelection(); } else { $workingSet.addClass("working-set-hidden"); } diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index bfa4cb7b95..bacb3bf1f3 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -121,6 +121,17 @@ define(function (require, exports, module) { */ var _DRAG_MOVE_DETECTION_START = 3; + /** + * Recomputes the selection marker of all Pane View List Views and scrolls + * the selected item into view. Needed after the working set becomes visible + * again, as marker positions computed while it was display:none are all 0. + */ + function refreshSelection() { + _.forEach(_views, function (view) { + view._updateListSelection(); + }); + } + /** * Refreshes all Pane View List Views */ @@ -1628,6 +1639,7 @@ define(function (require, exports, module) { // Public API exports.createWorkingSetViewForPane = createWorkingSetViewForPane; exports.refresh = refresh; + exports.refreshSelection = refreshSelection; exports.addIconProvider = addIconProvider; exports.addClassProvider = addClassProvider; exports.syncSelectionIndicator = syncSelectionIndicator; diff --git a/src/view/SidebarTabs.js b/src/view/SidebarTabs.js index 97736b9edc..4774f4fe72 100644 --- a/src/view/SidebarTabs.js +++ b/src/view/SidebarTabs.js @@ -40,7 +40,8 @@ define(function (require, exports, module) { const AppInit = require("utils/AppInit"), EventDispatcher = require("utils/EventDispatcher"), Metrics = require("utils/Metrics"), - PreferencesManager = require("preferences/PreferencesManager"); + PreferencesManager = require("preferences/PreferencesManager"), + WorkingSetView = require("project/WorkingSetView"); // --- Constants ----------------------------------------------------------- @@ -447,6 +448,11 @@ define(function (require, exports, module) { } if (previousTabId !== id) { + if (id === SIDEBAR_TAB_FILES) { + // working set marker positions computed while the files tab content was + // display:none are all 0, so recompute the selection now that it is visible + WorkingSetView.refreshSelection(); + } exports.trigger(EVENT_TAB_CHANGED, id, previousTabId); } } diff --git a/test/spec/WorkingSetView-integ-test.js b/test/spec/WorkingSetView-integ-test.js index de04c7f32b..f0e4c741f7 100644 --- a/test/spec/WorkingSetView-integ-test.js +++ b/test/spec/WorkingSetView-integ-test.js @@ -107,6 +107,11 @@ define(function (require, exports, module) { }); afterEach(async function () { + // restore state in case a selection marker test failed midway + testWindow.brackets.test.PreferencesManager.set("showWorkingSet", true); + const SidebarTabs = testWindow.brackets.test.SidebarTabs; + SidebarTabs.setActiveTab(SidebarTabs.SIDEBAR_TAB_FILES); + testWindow.$(".open-files-container").css("height", ""); await testWindow.closeAllFiles(); }); @@ -341,6 +346,89 @@ define(function (require, exports, module) { }); }); + function _isMarkerAlignedWithSelection() { + const $ = testWindow.$; + const $selected = $(".open-files-container li.selected"); + const $marker = $(".open-files-container .sidebar-selection"); + return $selected.length === 1 && $marker.length === 1 && + Math.abs($marker.offset().top - $selected.offset().top) < 2; + } + + it("should reposition selection marker and reveal selection when working set is shown again", async function () { + const $ = testWindow.$; + const PreferencesManager = testWindow.brackets.test.PreferencesManager; + const fileList = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + const $container = $(".open-files-container"); + + // view the first file + await awaitsForDone(CommandManager.execute(Commands.FILE_OPEN, { fullPath: fileList[0].fullPath }), + "FILE_OPEN first file"); + + // shrink the list so the second item overflows and must be scrolled to + $container.css("height", "30px"); + $container.scrollTop(0); + + // hide the working set + PreferencesManager.set("showWorkingSet", false); + await awaitsFor(function () { + return $("#working-set-list-container").hasClass("working-set-hidden"); + }, "working set to hide"); + + // switch to the second file while hidden - marker offsets all compute + // to 0 on display:none elements, so it cannot be positioned now + await awaitsForDone(CommandManager.execute(Commands.FILE_OPEN, { fullPath: fileList[1].fullPath }), + "FILE_OPEN second file"); + + // show the working set again + PreferencesManager.set("showWorkingSet", true); + await awaitsFor(function () { + return !$("#working-set-list-container").hasClass("working-set-hidden"); + }, "working set to show"); + + // the marker must align with the newly selected item and the item + // must be scrolled into view + await awaitsFor(_isMarkerAlignedWithSelection, "selection marker to align with selected item"); + await awaitsFor(function () { + const $selected = $(".open-files-container li.selected"); + const containerTop = $container.offset().top; + const containerBottom = containerTop + $container.height(); + return $selected.offset().top >= containerTop && + ($selected.offset().top + $selected.height()) <= containerBottom; + }, "selected item to be scrolled into view"); + }); + + it("should reposition selection marker when switching back to the files sidebar tab", async function () { + const $ = testWindow.$; + const SidebarTabs = testWindow.brackets.test.SidebarTabs; + const fileList = MainViewManager.getWorkingSet(MainViewManager.ACTIVE_PANE); + const TEST_TAB = "sidebar-tab-wsv-selection-test"; + + // view the first file + await awaitsForDone(CommandManager.execute(Commands.FILE_OPEN, { fullPath: fileList[0].fullPath }), + "FILE_OPEN first file"); + + // switch to another sidebar tab, which hides the files tab content + SidebarTabs.addTab(TEST_TAB, "wsv test", "fa-solid fa-flask"); + SidebarTabs.setActiveTab(TEST_TAB); + await awaitsFor(function () { + return !$("#working-set-list-container").is(":visible"); + }, "working set to be hidden by tab switch"); + + // switch to the second file while the files tab content is hidden + await awaitsForDone(CommandManager.execute(Commands.FILE_OPEN, { fullPath: fileList[1].fullPath }), + "FILE_OPEN second file"); + + // switch back to the files tab + SidebarTabs.setActiveTab(SidebarTabs.SIDEBAR_TAB_FILES); + await awaitsFor(function () { + return $("#working-set-list-container").is(":visible"); + }, "working set to be visible again"); + + await awaitsFor(_isMarkerAlignedWithSelection, "selection marker to align with selected item"); + + SidebarTabs.removeTab(TEST_TAB); + }); + it("should allow refresh to be used to update the class list", async function () { function classProvider(file) { return "one"; From a7f9677983968bd9ea0a074d0ef6ffa0a43fb639 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 25 Jul 2026 19:34:22 +0530 Subject: [PATCH 2/3] feat(workingset): switch files on mouse down for faster perceived switching The file tree and editor tab bar switch files on mouse down, but the working set only switched on mouse up, making it feel slow. Open the file immediately on left-button press (unless the press is on the close icon) and skip the duplicate open in the mouse up drop handler. Right/middle click paths already resolved at mouse down and are unchanged; close via the close icon still happens on mouse up without selecting the file. A drag now makes the pressed item the current file as soon as it is pressed, consistent with dragging tabs in other editors. Claude-Session: https://claude.ai/code/session_018BpQ3Z7w4H2U5qrPGWYRWB --- src/project/WorkingSetView.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index bacb3bf1f3..cb8da7106c 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -309,7 +309,8 @@ define(function (require, exports, module) { offset, $copy, $ghost, - draggingCurrentFile; + draggingCurrentFile, + openedOnMouseDown = false; function initDragging() { itemHeight = $el.height(); @@ -789,6 +790,10 @@ define(function (require, exports, module) { .always(function () { postDropCleanup(); }); + } else if (openedOnMouseDown) { + // file was already opened on mouse down for a fast switch feel, + // nothing left to do here + postDropCleanup(); } else { // Normal right and left click - select the item FileViewController.setFileViewFocus(FileViewController.WORKING_SET_VIEW); @@ -864,7 +869,16 @@ define(function (require, exports, module) { return; } - + // open the file right away on mouse down like the file tree and the + // 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) { + openedOnMouseDown = true; + FileViewController.setFileViewFocus(FileViewController.WORKING_SET_VIEW); + CommandManager.execute(Commands.FILE_OPEN, {fullPath: sourceFile.fullPath, + paneId: sourceView.paneId}); + } e.stopPropagation(); }); From d96c44170b957029cb8bce3cb483c0cadbd92a96 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 25 Jul 2026 19:51:00 +0530 Subject: [PATCH 3/3] fix(docs): use typedef for execFileWithInput return type to unbreak mdx build jsdoc-to-markdown copied the inline object return type {code, stdout, stderr} verbatim into the generated heading, and Docusaurus parses md as MDX where a bare {...} is a JSX expression - acorn fails and the docs site build errors out. A named ExecFileResult typedef generates a brace-free linked heading plus a property table. Also picks up the previously uncommitted regenerated docs for WorkingSetView.refreshSelection. --- docs/API-Reference/project/WorkingSetView.md | 8 ++++++++ docs/API-Reference/utils/NodeUtils.md | 14 +++++++++++++- src/utils/NodeUtils.js | 9 ++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/API-Reference/project/WorkingSetView.md b/docs/API-Reference/project/WorkingSetView.md index c202ff693c..9b05a76404 100644 --- a/docs/API-Reference/project/WorkingSetView.md +++ b/docs/API-Reference/project/WorkingSetView.md @@ -3,6 +3,14 @@ const WorkingSetView = brackets.getModule("project/WorkingSetView") ``` + + +## refreshSelection() +Recomputes the selection marker of all Pane View List Views and scrolls +the selected item into view. Needed after the working set becomes visible +again, as marker positions computed while it was display:none are all 0. + +**Kind**: global function ## refresh() diff --git a/docs/API-Reference/utils/NodeUtils.md b/docs/API-Reference/utils/NodeUtils.md index 5a1acd91b6..f8ef63a400 100644 --- a/docs/API-Reference/utils/NodeUtils.md +++ b/docs/API-Reference/utils/NodeUtils.md @@ -123,7 +123,7 @@ This is only available in the native app. -## execFileWithInput(command, [args], [options]) ⇒ Promise.<{code: number, stdout: string, stderr: string}> +## execFileWithInput(command, [args], [options]) ⇒ [Promise.<ExecFileResult>](#ExecFileResult) Runs an executable with the given args, feeding it text on stdin and capturing its output - a one-shot filter-style invocation (e.g. `ruff format -` for the Python beautifier). No shell is involved. Resolves with the exit code rather than rejecting on non-zero, so @@ -271,3 +271,15 @@ operation - the promise then rejects with a "cancelled" error. | --- | --- | --- | | cancel | function | aborts the in-flight operation | + + +## ExecFileResult : Object +**Kind**: global typedef +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| code | number | the process's exit code | +| stdout | string | | +| stderr | string | | + diff --git a/src/utils/NodeUtils.js b/src/utils/NodeUtils.js index bcca700659..a99eb6b8c7 100644 --- a/src/utils/NodeUtils.js +++ b/src/utils/NodeUtils.js @@ -233,6 +233,13 @@ define(function (require, exports, module) { return utilsConnector.execPeer("setExecutableBits", {filePath}); } + /** + * @typedef {Object} ExecFileResult + * @property {number} code - the process's exit code + * @property {string} stdout + * @property {string} stderr + */ + /** * Runs an executable with the given args, feeding it text on stdin and capturing its output - * a one-shot filter-style invocation (e.g. `ruff format -` for the Python beautifier). No @@ -246,7 +253,7 @@ define(function (require, exports, module) { * @param {string} [options.stdinText] - written to the process's stdin, then closed * @param {string} [options.cwd] - working directory * @param {number} [options.timeoutMs] - kill the process and reject after this long - * @return {Promise<{code: number, stdout: string, stderr: string}>} + * @return {Promise} */ async function execFileWithInput(command, args, options) { if(!Phoenix.isNativeApp) {