From 0da1a0c27e34a919a4d60276636983b7b569a8b3 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 15 Feb 2026 13:58:52 +0530 Subject: [PATCH 1/3] feat: tabs support in main sidebar --- src/brackets.js | 2 + src/core-ai/main.js | 44 +++ src/styles/Extn-SidebarTabs.less | 110 +++++++ src/styles/brackets.less | 1 + src/view/SidebarTabs.js | 478 +++++++++++++++++++++++++++++++ 5 files changed, 635 insertions(+) create mode 100644 src/core-ai/main.js create mode 100644 src/styles/Extn-SidebarTabs.less create mode 100644 src/view/SidebarTabs.js diff --git a/src/brackets.js b/src/brackets.js index 75aabed6d2..9944d23c9f 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -126,6 +126,8 @@ define(function (require, exports, module) { require("search/QuickOpenHelper"); require("file/FileUtils"); require("project/SidebarView"); + require("view/SidebarTabs"); + require("core-ai/main"); require("utils/Resizer"); require("LiveDevelopment/main"); require("utils/NodeConnection"); diff --git a/src/core-ai/main.js b/src/core-ai/main.js new file mode 100644 index 0000000000..0e826ee181 --- /dev/null +++ b/src/core-ai/main.js @@ -0,0 +1,44 @@ +/* + * 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. + * + */ + +/** + * Registers a placeholder AI sidebar tab. This serves as a starting point for + * AI assistant integration. The tab displays a placeholder message until an AI + * provider extension is installed. + */ +define(function (require, exports, module) { + + var AppInit = require("utils/AppInit"), + SidebarTabs = require("view/SidebarTabs"); + + AppInit.appReady(function () { + SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); + + var $content = $( + '
' + + '
' + + '
AI Assistant
' + + '
Please add an AI provider to start using AI
' + + '
' + ); + + SidebarTabs.addToTab("ai", $content); + }); +}); diff --git a/src/styles/Extn-SidebarTabs.less b/src/styles/Extn-SidebarTabs.less new file mode 100644 index 0000000000..94cdb7caca --- /dev/null +++ b/src/styles/Extn-SidebarTabs.less @@ -0,0 +1,110 @@ +/* + * 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. + * + */ + +/* Sidebar tab bar — switches between tab panes within #sidebar */ + +#navTabBar { + display: none; + align-items: center; + background-color: @bc-sidebar-bg; + height: 2rem; + overflow: hidden; + user-select: none; + + &.has-tabs { + display: flex; + } +} + +.sidebar-tab { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0 0.75rem; + height: 100%; + cursor: pointer; + position: relative; + color: @project-panel-text-2; + font-size: 0.8rem; + letter-spacing: 0.3px; + transition: color 0.15s ease, background-color 0.15s ease; + + i { + font-size: 0.75rem; + } + + &:hover { + color: @project-panel-text-1; + background-color: rgba(255, 255, 255, 0.06); + } + + &.active { + color: @project-panel-text-1; + + &::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background-color: @project-panel-text-2; + } + } +} + +/* Hide sidebar children not belonging to the active tab */ +.sidebar-tab-hidden { + display: none !important; +} + +/* AI tab placeholder content */ +.ai-tab-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + .box-flex(1); + flex: 1; + color: @project-panel-text-2; + padding: 2rem; + text-align: center; + min-height: 0; + height: 100%; + + .ai-tab-icon { + font-size: 2.5rem; + margin-bottom: 1rem; + opacity: 0.6; + } + + .ai-tab-title { + font-size: 1rem; + font-weight: 600; + color: @project-panel-text-1; + margin-bottom: 0.5rem; + } + + .ai-tab-message { + font-size: 0.8rem; + line-height: 1.4; + opacity: 0.7; + } +} diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 7a44bcd568..0630566651 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -46,6 +46,7 @@ @import "Extn-CSSColorPreview.less"; @import "Extn-CustomSnippets.less"; @import "Extn-CollapseFolders.less"; +@import "Extn-SidebarTabs.less"; @import "UserProfile.less"; @import "phoenix-pro.less"; diff --git a/src/view/SidebarTabs.js b/src/view/SidebarTabs.js new file mode 100644 index 0000000000..a8c6f4e38e --- /dev/null +++ b/src/view/SidebarTabs.js @@ -0,0 +1,478 @@ +/* + * 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. + * + */ + +/** + * SidebarTabs manages multiple tab panes within the sidebar. It inserts a + * `#navTabBar` element after `#mainNavBar` and provides an API for registering + * tabs, associating DOM content with tabs, and switching between them. + * + * Existing sidebar children that are not explicitly associated with a tab via + * `addToTab` are treated as belonging to the default "Files" tab. This means + * extensions that add DOM nodes to the sidebar will continue to work without + * any code changes. + * + * Tab switching works purely by toggling the `.sidebar-tab-hidden` CSS class + * (`display: none !important`). No DOM reparenting or detaching occurs, so + * cached jQuery/DOM references held by extensions remain valid. + */ +define(function (require, exports, module) { + + var AppInit = require("utils/AppInit"), + EventDispatcher = require("utils/EventDispatcher"); + + // --- Constants ----------------------------------------------------------- + + /** + * The built-in Files tab id. + * @const {string} + */ + var SIDEBAR_TAB_FILES = "sidebar-tab-files"; + + // --- Events -------------------------------------------------------------- + + /** + * Fired when a new tab is registered via `addTab`. + * @const {string} + */ + var EVENT_TAB_ADDED = "tabAdded"; + + /** + * Fired when a tab is removed via `removeTab`. + * @const {string} + */ + var EVENT_TAB_REMOVED = "tabRemoved"; + + /** + * Fired when the active tab changes via `setActiveTab`. + * @const {string} + */ + var EVENT_TAB_CHANGED = "tabChanged"; + + // --- Private state ------------------------------------------------------- + + /** @type {jQuery} */ + var $navTabBar; + + /** @type {jQuery} */ + var $sidebar; + + /** + * Ordered array of registered tab descriptors. + * Each entry: { id, label, iconClass, priority, $tabItem } + * @type {Array} + */ + var _tabs = []; + + /** + * Map from tabId -> array of DOM elements (not jQuery) associated with + * that tab via `addToTab`. + * @type {Object.>} + */ + var _tabContent = {}; + + /** + * Set of DOM elements that were appended to #sidebar by `addToTab` (i.e. + * they were NOT already children of #sidebar). Used so `removeFromTab` can + * decide whether to also detach the node from the DOM. + * @type {Set.} + */ + var _appendedNodes = new Set(); + + /** + * Currently active tab id. + * @type {string} + */ + var _activeTabId = SIDEBAR_TAB_FILES; + + // --- IDs to always exclude from visibility toggling ---------------------- + + var _EXCLUDED_IDS = { "mainNavBar": true, "navTabBar": true }; + + /** + * CSS classes that mark structural/resizer elements which must never be + * hidden by tab switching. + */ + var _EXCLUDED_CLASSES = ["horz-resizer", "vert-resizer"]; + + // --- Private helpers ----------------------------------------------------- + + /** + * Returns true if a sidebar child node should never be touched by tab + * switching (e.g. nav bars, resizer handles). + */ + function _isExcludedNode(node) { + if (_EXCLUDED_IDS[node.id]) { + return true; + } + for (var i = 0; i < _EXCLUDED_CLASSES.length; i++) { + if (node.classList.contains(_EXCLUDED_CLASSES[i])) { + return true; + } + } + return false; + } + + /** + * Rebuild the tab bar DOM to reflect current _tabs (sorted by priority). + */ + function _rebuildTabBar() { + $navTabBar.empty(); + _tabs.sort(function (a, b) { return a.priority - b.priority; }); + _tabs.forEach(function (tab) { + var $item = $(''); + if (tab.id === _activeTabId) { + $item.addClass("active"); + } + tab.$tabItem = $item; + $navTabBar.append($item); + }); + + // Show/hide the tab bar based on tab count + if (_tabs.length >= 2) { + $navTabBar.addClass("has-tabs"); + } else { + $navTabBar.removeClass("has-tabs"); + } + } + + /** + * Returns true if the given node is explicitly associated with the + * specified tab. + */ + function _isNodeInTab(node, tabId) { + return _tabContent[tabId] && _tabContent[tabId].indexOf(node) !== -1; + } + + /** + * Returns true if the given node is explicitly associated with ANY + * registered tab. + */ + function _isNodeInAnyTab(node) { + var tabIds = Object.keys(_tabContent); + for (var i = 0; i < tabIds.length; i++) { + if (_tabContent[tabIds[i]].indexOf(node) !== -1) { + return true; + } + } + return false; + } + + /** + * Apply visibility for the currently active tab. Hides/shows sidebar + * children as appropriate. + * + * A node can be associated with multiple tabs. It is visible if any of + * its associated tabs is the active tab. Unassociated nodes default to + * the files tab. + */ + function _applyTabVisibility() { + if (!$sidebar || !$sidebar.length) { + return; + } + + var children = $sidebar.children().toArray(); + + if (_activeTabId === SIDEBAR_TAB_FILES) { + // Files tab: show nodes that are in the files tab content OR + // unassociated (not in any tab). Hide nodes that are exclusively + // in other tabs. + var filesNodes = new Set(_tabContent[SIDEBAR_TAB_FILES] || []); + + children.forEach(function (child) { + if (_isExcludedNode(child)) { + return; // never touch these + } + if (filesNodes.has(child) || !_isNodeInAnyTab(child)) { + child.classList.remove("sidebar-tab-hidden"); + } else { + child.classList.add("sidebar-tab-hidden"); + } + }); + } else { + // Non-files tab: show nodes associated with this tab, hide + // everything else (except excluded nodes). + var activeNodes = new Set(_tabContent[_activeTabId] || []); + + children.forEach(function (child) { + if (_isExcludedNode(child)) { + return; + } + if (activeNodes.has(child)) { + child.classList.remove("sidebar-tab-hidden"); + } else { + child.classList.add("sidebar-tab-hidden"); + } + }); + } + } + + // --- Public API ---------------------------------------------------------- + + /** + * Register a new sidebar tab. + * + * @param {string} id Unique tab identifier + * @param {string} label Display text shown in the tab bar + * @param {string} iconClass FontAwesome (or other) icon class string + * @param {Object} [options] + * @param {number} [options.priority=100] Lower values appear further left + */ + function addTab(id, label, iconClass, options) { + options = options || {}; + + // Prevent duplicate registrations + for (var i = 0; i < _tabs.length; i++) { + if (_tabs[i].id === id) { + return; + } + } + + var tab = { + id: id, + label: label, + iconClass: iconClass, + priority: options.priority !== undefined ? options.priority : 100, + $tabItem: null + }; + _tabs.push(tab); + _tabContent[id] = _tabContent[id] || []; + + _rebuildTabBar(); + exports.trigger(EVENT_TAB_ADDED, id); + } + + /** + * Associate a DOM node (or jQuery element) with a tab. If the node is not + * already a child of `#sidebar`, it is appended. If the tab is not the + * currently active tab, the node is immediately hidden. + * + * @param {string} tabId The tab to associate with + * @param {jQuery|Element} $content DOM node or jQuery wrapper + */ + function addToTab(tabId, $content) { + var node = $content instanceof $ ? $content[0] : $content; + if (!node) { + return; + } + + // Ensure content array exists + if (!_tabContent[tabId]) { + _tabContent[tabId] = []; + } + + // Avoid duplicate association + if (_tabContent[tabId].indexOf(node) !== -1) { + return; + } + + _tabContent[tabId].push(node); + + // If not already in sidebar, append it + if (!$sidebar[0].contains(node)) { + $sidebar.append(node); + _appendedNodes.add(node); + } + + // Show/hide based on whether the node belongs to the active tab. + // A node may be in multiple tabs, so only hide it if none of its + // associated tabs is the currently active one. + if (tabId === _activeTabId || _isNodeInTab(node, _activeTabId)) { + node.classList.remove("sidebar-tab-hidden"); + } else { + node.classList.add("sidebar-tab-hidden"); + } + } + + /** + * Remove a DOM node's association with a tab. If the node was appended by + * `addToTab` (was not originally in the sidebar) and is no longer + * associated with any tab, it is also removed from the DOM. + * + * @param {string} tabId The tab to disassociate from + * @param {jQuery|Element} $content DOM node or jQuery wrapper + */ + function removeFromTab(tabId, $content) { + var node = $content instanceof $ ? $content[0] : $content; + if (!node || !_tabContent[tabId]) { + return; + } + + var idx = _tabContent[tabId].indexOf(node); + if (idx === -1) { + return; + } + + _tabContent[tabId].splice(idx, 1); + + if (_isNodeInAnyTab(node)) { + // Node is still in other tab(s) — re-evaluate its visibility + _applyTabVisibility(); + } else if (_appendedNodes.has(node)) { + // Node was appended by addToTab and is no longer in any tab — + // remove it from the DOM + $(node).remove(); + _appendedNodes.delete(node); + } else { + // Originally in sidebar and no longer in any tab — make it + // visible again so it reverts to default (files tab) behavior + node.classList.remove("sidebar-tab-hidden"); + } + } + + /** + * Remove a tab entirely. Only succeeds if all content has been removed via + * `removeFromTab` first. Returns false if content still exists. + * + * @param {string} id The tab id to remove + * @return {boolean} true if removed, false if content still associated + */ + function removeTab(id) { + if (id === SIDEBAR_TAB_FILES) { + return false; // cannot remove the built-in files tab + } + + if (_tabContent[id] && _tabContent[id].length > 0) { + return false; + } + + var removed = false; + for (var i = _tabs.length - 1; i >= 0; i--) { + if (_tabs[i].id === id) { + _tabs.splice(i, 1); + removed = true; + break; + } + } + + if (removed) { + delete _tabContent[id]; + + // If the removed tab was active, switch back to files + if (_activeTabId === id) { + _activeTabId = SIDEBAR_TAB_FILES; + } + + _rebuildTabBar(); + _applyTabVisibility(); + exports.trigger(EVENT_TAB_REMOVED, id); + } + + return removed; + } + + /** + * Switch the active sidebar tab. Shows nodes associated with the target + * tab, hides all others. + * + * @param {string} id The tab id to activate + */ + function setActiveTab(id) { + // Verify the tab exists + var found = false; + for (var i = 0; i < _tabs.length; i++) { + if (_tabs[i].id === id) { + found = true; + break; + } + } + if (!found) { + return; + } + + var previousTabId = _activeTabId; + _activeTabId = id; + + // Update active class on tab items + $navTabBar.find(".sidebar-tab").removeClass("active"); + $navTabBar.find('.sidebar-tab[data-tab-id="' + id + '"]').addClass("active"); + + _applyTabVisibility(); + + if (previousTabId !== id) { + exports.trigger(EVENT_TAB_CHANGED, id, previousTabId); + } + } + + /** + * Get the currently active tab id. + * @return {string} + */ + function getActiveTab() { + return _activeTabId; + } + + /** + * Get an array of all registered tab descriptors. + * @return {Array.<{id: string, label: string, iconClass: string, priority: number}>} + */ + function getAllTabs() { + return _tabs.map(function (tab) { + return { + id: tab.id, + label: tab.label, + iconClass: tab.iconClass, + priority: tab.priority + }; + }); + } + + // --- Initialization ------------------------------------------------------ + + AppInit.htmlReady(function () { + $sidebar = $("#sidebar"); + + // Create the tab bar and insert after #mainNavBar + $navTabBar = $(''); + $sidebar.find("#mainNavBar").after($navTabBar); + + // Register the built-in Files tab + addTab(SIDEBAR_TAB_FILES, "Files", "fa-solid fa-folder", { priority: 0 }); + + // Set up click handler for tab switching + $navTabBar.on("click", ".sidebar-tab", function () { + var tabId = $(this).attr("data-tab-id"); + if (tabId) { + setActiveTab(tabId); + } + }); + }); + + // --- Make this module an EventDispatcher ---------------------------------- + + EventDispatcher.makeEventDispatcher(exports); + + // --- Exports ------------------------------------------------------------- + + exports.SIDEBAR_TAB_FILES = SIDEBAR_TAB_FILES; + exports.EVENT_TAB_ADDED = EVENT_TAB_ADDED; + exports.EVENT_TAB_REMOVED = EVENT_TAB_REMOVED; + exports.EVENT_TAB_CHANGED = EVENT_TAB_CHANGED; + + exports.addTab = addTab; + exports.addToTab = addToTab; + exports.removeFromTab = removeFromTab; + exports.removeTab = removeTab; + exports.setActiveTab = setActiveTab; + exports.getActiveTab = getActiveTab; + exports.getAllTabs = getAllTabs; +}); From d66755e3dc34ad0be354c2dfa35514d1f309ada5 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 15 Feb 2026 14:26:46 +0530 Subject: [PATCH 2/3] test: sidebar tabs integ tests --- src/brackets.js | 1 + src/view/SidebarTabs.js | 62 ++-- test/UnitTestSuite.js | 1 + test/spec/SidebarTabs-integ-test.js | 458 ++++++++++++++++++++++++++++ 4 files changed, 491 insertions(+), 31 deletions(-) create mode 100644 test/spec/SidebarTabs-integ-test.js diff --git a/src/brackets.js b/src/brackets.js index 9944d23c9f..79428dbc72 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -292,6 +292,7 @@ define(function (require, exports, module) { WorkspaceManager: require("view/WorkspaceManager"), SearchResultsView: require("search/SearchResultsView"), ScrollTrackMarkers: require("search/ScrollTrackMarkers"), + SidebarTabs: require("view/SidebarTabs"), WorkingSetView: require("project/WorkingSetView"), doneLoading: false }; diff --git a/src/view/SidebarTabs.js b/src/view/SidebarTabs.js index a8c6f4e38e..9a67ea455a 100644 --- a/src/view/SidebarTabs.js +++ b/src/view/SidebarTabs.js @@ -34,7 +34,7 @@ */ define(function (require, exports, module) { - var AppInit = require("utils/AppInit"), + const AppInit = require("utils/AppInit"), EventDispatcher = require("utils/EventDispatcher"); // --- Constants ----------------------------------------------------------- @@ -43,7 +43,7 @@ define(function (require, exports, module) { * The built-in Files tab id. * @const {string} */ - var SIDEBAR_TAB_FILES = "sidebar-tab-files"; + const SIDEBAR_TAB_FILES = "sidebar-tab-files"; // --- Events -------------------------------------------------------------- @@ -51,41 +51,41 @@ define(function (require, exports, module) { * Fired when a new tab is registered via `addTab`. * @const {string} */ - var EVENT_TAB_ADDED = "tabAdded"; + const EVENT_TAB_ADDED = "tabAdded"; /** * Fired when a tab is removed via `removeTab`. * @const {string} */ - var EVENT_TAB_REMOVED = "tabRemoved"; + const EVENT_TAB_REMOVED = "tabRemoved"; /** * Fired when the active tab changes via `setActiveTab`. * @const {string} */ - var EVENT_TAB_CHANGED = "tabChanged"; + const EVENT_TAB_CHANGED = "tabChanged"; // --- Private state ------------------------------------------------------- /** @type {jQuery} */ - var $navTabBar; + let $navTabBar; /** @type {jQuery} */ - var $sidebar; + let $sidebar; /** * Ordered array of registered tab descriptors. * Each entry: { id, label, iconClass, priority, $tabItem } * @type {Array} */ - var _tabs = []; + const _tabs = []; /** * Map from tabId -> array of DOM elements (not jQuery) associated with * that tab via `addToTab`. * @type {Object.>} */ - var _tabContent = {}; + const _tabContent = {}; /** * Set of DOM elements that were appended to #sidebar by `addToTab` (i.e. @@ -93,23 +93,23 @@ define(function (require, exports, module) { * decide whether to also detach the node from the DOM. * @type {Set.} */ - var _appendedNodes = new Set(); + const _appendedNodes = new Set(); /** * Currently active tab id. * @type {string} */ - var _activeTabId = SIDEBAR_TAB_FILES; + let _activeTabId = SIDEBAR_TAB_FILES; // --- IDs to always exclude from visibility toggling ---------------------- - var _EXCLUDED_IDS = { "mainNavBar": true, "navTabBar": true }; + const _EXCLUDED_IDS = { "mainNavBar": true, "navTabBar": true }; /** * CSS classes that mark structural/resizer elements which must never be * hidden by tab switching. */ - var _EXCLUDED_CLASSES = ["horz-resizer", "vert-resizer"]; + const _EXCLUDED_CLASSES = ["horz-resizer", "vert-resizer"]; // --- Private helpers ----------------------------------------------------- @@ -121,7 +121,7 @@ define(function (require, exports, module) { if (_EXCLUDED_IDS[node.id]) { return true; } - for (var i = 0; i < _EXCLUDED_CLASSES.length; i++) { + for (let i = 0; i < _EXCLUDED_CLASSES.length; i++) { if (node.classList.contains(_EXCLUDED_CLASSES[i])) { return true; } @@ -136,7 +136,7 @@ define(function (require, exports, module) { $navTabBar.empty(); _tabs.sort(function (a, b) { return a.priority - b.priority; }); _tabs.forEach(function (tab) { - var $item = $('