diff --git a/src/extensions/default/Git/styles/git-styles.less b/src/extensions/default/Git/styles/git-styles.less index 534fbc1d69..4b98e72795 100644 --- a/src/extensions/default/Git/styles/git-styles.less +++ b/src/extensions/default/Git/styles/git-styles.less @@ -907,6 +907,7 @@ white-space: nowrap; padding: 2px 5px; margin-left: -5px; + margin-right: 2em; .dropdown-arrow { display: inline-block; width: 7px; diff --git a/src/extensionsIntegrated/CollapseFolders/main.js b/src/extensionsIntegrated/CollapseFolders/main.js new file mode 100644 index 0000000000..98dd4f3bbb --- /dev/null +++ b/src/extensionsIntegrated/CollapseFolders/main.js @@ -0,0 +1,80 @@ +/* + * 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. + * + */ + +/* Displays a Collapse button in the sidebar area */ +/* when the button gets clicked, it closes all the directories recursively that are opened */ +/* Styling for the button is done in `../../styles/Extn-CollapseFolders.less` */ +define(function (require, exports, module) { + const AppInit = require("utils/AppInit"); + const ProjectManager = require("project/ProjectManager"); + + /** + * This is the main function that handles the closing of all the directories + */ + function handleCollapseBtnClick() { + // this will give us an array of array's + // the root level directories will be at index 0, its next level will be at index 1 and so on + const openNodes = ProjectManager._actionCreator.model.getOpenNodes(); + if (!openNodes || openNodes.length === 0) { + return; + } + + // traversing from the back because the deepest nested directories should be closed first + // Note: this is an array of all the directories at the deepest level + for (let i = openNodes.length - 1; i >= 0; i--) { + // close all the directories + openNodes[i].forEach(function (folderPath) { + try { + // to close each dir + ProjectManager._actionCreator.setDirectoryOpen(folderPath, false); + } catch (error) { + console.error("Failed to close folder:", folderPath, error); + } + }); + } + } + + /** + * This function is responsible to create the 'Collapse All' button + * and append it to the sidebar area on the project-files-header + */ + function createCollapseButton() { + const $projectFilesHeader = $("#project-files-header"); + // make sure that we were able to get the project-files-header DOM element + if ($projectFilesHeader.length === 0) { + return; + } + + // create the collapse btn + const $collapseBtn = $(` +
+ + +
+ `); + + $collapseBtn.on("click", handleCollapseBtnClick); + $projectFilesHeader.append($collapseBtn); // append the btn to the project-files-header + } + + AppInit.appReady(function () { + createCollapseButton(); + }); +}); diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index 776a81dd0f..523db814bb 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -45,4 +45,5 @@ define(function (require, exports, module) { require("./CSSColorPreview/main"); require("./TabBar/main"); require("./CustomSnippets/main"); + require("./CollapseFolders/main"); }); diff --git a/src/styles/Extn-CollapseFolders.less b/src/styles/Extn-CollapseFolders.less new file mode 100644 index 0000000000..ecdfaab1ca --- /dev/null +++ b/src/styles/Extn-CollapseFolders.less @@ -0,0 +1,25 @@ +#collapse-folders { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0.2em 0.65em; + margin-top: 0.1em; + position: absolute !important; + right: 0; + opacity: 0; + visibility: hidden; + transition: + opacity 0.2s ease-in-out, + visibility 0.2s ease-in-out; + + .collapse-icon { + font-size: 0.5em; + line-height: 1; + } +} + +#sidebar:hover #collapse-folders { + opacity: 1; + visibility: visible; +} diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 5150753c88..f7100dee51 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -45,6 +45,7 @@ @import "Extn-DisplayShortcuts.less"; @import "Extn-CSSColorPreview.less"; @import "Extn-CustomSnippets.less"; +@import "Extn-CollapseFolders.less"; @import "UserProfile.less"; /* Overall layout */ diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 4fd8b5221d..0afbbd1b16 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -128,6 +128,7 @@ define(function (require, exports, module) { require("spec/Extn-JSHint-integ-test"); require("spec/Extn-ESLint-integ-test"); require("spec/Extn-CSSColorPreview-integ-test"); + require("spec/Extn-CollapseFolders-integ-test"); // extension integration tests require("spec/Extn-CSSCodeHints-integ-test"); require("spec/Extn-HTMLCodeHints-Lint-integ-test"); diff --git a/test/spec/Extn-CollapseFolders-integ-test.js b/test/spec/Extn-CollapseFolders-integ-test.js new file mode 100644 index 0000000000..b4bec85493 --- /dev/null +++ b/test/spec/Extn-CollapseFolders-integ-test.js @@ -0,0 +1,241 @@ +/* + * 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. + * + */ + +/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, awaits, jsPromise */ + +define(function (require, exports, module) { + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + describe("integration:Collapse Folders", function () { + let testWindow, ProjectManager, FileSystem, $, testProjectPath, testProjectFolder; + + beforeAll(async function () { + // Create the test window + testWindow = await SpecRunnerUtils.createTestWindowAndRun(); + // Get reference to useful modules + $ = testWindow.$; + ProjectManager = testWindow.brackets.test.ProjectManager; + FileSystem = testWindow.brackets.test.FileSystem; + + // Setup a test project folder with nested directories + testProjectPath = SpecRunnerUtils.getTempDirectory() + "/collapse-folders-test"; + testProjectFolder = FileSystem.getDirectoryForPath(testProjectPath); + + // Ensure the test directory exists + await SpecRunnerUtils.createTempDirectory(); + + // Create test project structure + await SpecRunnerUtils.ensureExistsDirAsync(testProjectPath); + await SpecRunnerUtils.ensureExistsDirAsync(testProjectPath + "/folder1"); + await SpecRunnerUtils.ensureExistsDirAsync(testProjectPath + "/folder1/subfolder1"); + await SpecRunnerUtils.ensureExistsDirAsync(testProjectPath + "/folder2"); + await SpecRunnerUtils.ensureExistsDirAsync(testProjectPath + "/folder2/subfolder2"); + + // Create some test files + await jsPromise(SpecRunnerUtils.createTextFile(testProjectPath + "/file.js", "// Test file", FileSystem)); + await jsPromise( + SpecRunnerUtils.createTextFile(testProjectPath + "/folder1/file1.js", "// Test file 1", FileSystem) + ); + await jsPromise( + SpecRunnerUtils.createTextFile( + testProjectPath + "/folder1/subfolder1/subfile1.js", + "// Test subfile 1", + FileSystem + ) + ); + await jsPromise( + SpecRunnerUtils.createTextFile(testProjectPath + "/folder2/file2.js", "// Test file 2", FileSystem) + ); + await jsPromise( + SpecRunnerUtils.createTextFile( + testProjectPath + "/folder2/subfolder2/subfile2.js", + "// Test subfile 2", + FileSystem + ) + ); + + // Load the test project + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + }, 30000); + + afterAll(async function () { + testWindow = null; + await SpecRunnerUtils.closeTestWindow(); + await SpecRunnerUtils.removeTempDirectory(); + }, 30000); + + /** + * Helper function to open a folder in the project tree + * @param {string} folderPath - The path of the folder to open + */ + async function openFolder(folderPath) { + const folderEntry = FileSystem.getDirectoryForPath(folderPath); + // Call setDirectoryOpen without awaitsForDone since it doesn't return a promise + ProjectManager._actionCreator.setDirectoryOpen(folderEntry.fullPath, true); + + // Wait for the folder to be opened in the UI + await awaitsFor( + function () { + const $folderNode = findDirectoryNode(folderPath); + return $folderNode && $folderNode.hasClass("jstree-open"); + }, + "Folder to be opened: " + folderPath, + 1000 + ); + } + + /** + * Helper function to find a directory node in the project tree + * @param {string} path - The path of the directory to find + * @returns {jQuery|null} - The jQuery object for the directory node, or null if not found + */ + function findDirectoryNode(path) { + const dirName = path.split("/").pop(); + const $treeItems = $("#project-files-container li"); + let $result = null; + + $treeItems.each(function () { + const $treeNode = $(this); + if ($treeNode.children("a").text().trim() === dirName) { + $result = $treeNode; + return false; // Break the loop + } + }); + + return $result; + } + + /** + * Helper function to check if a folder is open in the project tree + * @param {string} folderPath - The path of the folder to check + * @returns {boolean} - True if the folder is open, false otherwise + */ + function isFolderOpen(folderPath) { + const $folderNode = findDirectoryNode(folderPath); + // If the folder node can't be found, it's definitely not open + if (!$folderNode) { + return false; + } + return $folderNode.hasClass("jstree-open"); + } + + describe("UI", function () { + it("should have a collapse button in the project files header", async function () { + // Check if the collapse button exists + const $collapseBtn = $("#collapse-folders"); + expect($collapseBtn.length).toBe(1); + + // Check if the button has the collapse icons + expect($collapseBtn.find(".collapse-icon").length).toBe(2); + }); + + it("should collapse all open folders when the collapse button is clicked", async function () { + // Open some folders first + await openFolder(testProjectPath + "/folder1"); + await openFolder(testProjectPath + "/folder2"); + + // Verify folders are open + expect(isFolderOpen(testProjectPath + "/folder1")).toBe(true); + expect(isFolderOpen(testProjectPath + "/folder2")).toBe(true); + + // Click the collapse button + $("#collapse-folders").click(); + + // Wait for folders to be collapsed + await awaitsFor( + function () { + return ( + !isFolderOpen(testProjectPath + "/folder1") && !isFolderOpen(testProjectPath + "/folder2") + ); + }, + "Folders to be collapsed", + 1000 + ); + + // Verify folders are closed + expect(isFolderOpen(testProjectPath + "/folder1")).toBe(false); + expect(isFolderOpen(testProjectPath + "/folder2")).toBe(false); + }); + + it("should collapse nested folders when the collapse button is clicked", async function () { + // Open folders with nested structure + await openFolder(testProjectPath + "/folder1"); + await openFolder(testProjectPath + "/folder1/subfolder1"); + await openFolder(testProjectPath + "/folder2"); + await openFolder(testProjectPath + "/folder2/subfolder2"); + + // Verify folders are open + expect(isFolderOpen(testProjectPath + "/folder1")).toBe(true); + expect(isFolderOpen(testProjectPath + "/folder1/subfolder1")).toBe(true); + expect(isFolderOpen(testProjectPath + "/folder2")).toBe(true); + expect(isFolderOpen(testProjectPath + "/folder2/subfolder2")).toBe(true); + + // Click the collapse button + $("#collapse-folders").click(); + + // Wait for all folders to be collapsed + await awaitsFor( + function () { + return ( + !isFolderOpen(testProjectPath + "/folder1") && + !isFolderOpen(testProjectPath + "/folder1/subfolder1") && + !isFolderOpen(testProjectPath + "/folder2") && + !isFolderOpen(testProjectPath + "/folder2/subfolder2") + ); + }, + "All folders to be collapsed", + 1000 + ); + + // Verify all folders are closed + expect(isFolderOpen(testProjectPath + "/folder1")).toBe(false); + expect(isFolderOpen(testProjectPath + "/folder1/subfolder1")).toBe(false); + expect(isFolderOpen(testProjectPath + "/folder2")).toBe(false); + expect(isFolderOpen(testProjectPath + "/folder2/subfolder2")).toBe(false); + }); + + it("should do nothing when no folders are open", async function () { + // Make sure all folders are closed + $("#collapse-folders").click(); + await awaitsFor( + function () { + return ( + !isFolderOpen(testProjectPath + "/folder1") && !isFolderOpen(testProjectPath + "/folder2") + ); + }, + "Folders to be collapsed", + 1000 + ); + + // Get the current state of the project tree + const initialState = $("#project-files-container").html(); + + // Click the collapse button again + $("#collapse-folders").click(); + + // Wait a bit to ensure any potential changes would have happened + await awaits(300); + + // Verify the project tree hasn't changed + expect($("#project-files-container").html()).toBe(initialState); + }); + }); + }); +});