diff --git a/src/extensions/default/DebugCommands/testBuilder.js b/src/extensions/default/DebugCommands/testBuilder.js
index 38e58ddf0e..bfc9b434af 100644
--- a/src/extensions/default/DebugCommands/testBuilder.js
+++ b/src/extensions/default/DebugCommands/testBuilder.js
@@ -38,7 +38,7 @@ define(function (require, exports, module) {
function toggleTestBuilder() {
if(!$panel){
$panel = $(panelHTML);
- builderPanel = WorkspaceManager.createBottomPanel("phcode-test-builder-panel", $panel, 100);
+ builderPanel = WorkspaceManager.createBottomPanel("phcode-test-builder-panel", $panel, 100, "Test Builder");
builderPanel.hide();
_setupPanel().then(()=>{
builderPanel.setVisible(!builderPanel.isVisible());
@@ -177,7 +177,7 @@ define(function (require, exports, module) {
return;
}
$panel = $(panelHTML);
- builderPanel = WorkspaceManager.createBottomPanel("phcode-test-builder-panel", $panel, 100);
+ builderPanel = WorkspaceManager.createBottomPanel("phcode-test-builder-panel", $panel, 100, "Test Builder");
builderPanel.hide();
_setupPanel();
});
diff --git a/src/extensions/default/Git/src/Panel.js b/src/extensions/default/Git/src/Panel.js
index 02a34cfcdc..cc4aac6dcb 100644
--- a/src/extensions/default/Git/src/Panel.js
+++ b/src/extensions/default/Git/src/Panel.js
@@ -1240,7 +1240,7 @@ define(function (require, exports) {
var $panelHtml = $(panelHtml);
$panelHtml.find(".git-available, .git-not-available").hide();
- gitPanel = WorkspaceManager.createBottomPanel("main-git.panel", $panelHtml, 100);
+ gitPanel = WorkspaceManager.createBottomPanel("main-git.panel", $panelHtml, 100, Strings.GIT_PANEL_TITLE);
$gitPanel = gitPanel.$panel;
const resizeObserver = new ResizeObserver(_panelResized);
resizeObserver.observe($gitPanel[0]);
diff --git a/src/extensionsIntegrated/CustomSnippets/main.js b/src/extensionsIntegrated/CustomSnippets/main.js
index 5e2b1b3d0f..6ca00fbfa2 100644
--- a/src/extensionsIntegrated/CustomSnippets/main.js
+++ b/src/extensionsIntegrated/CustomSnippets/main.js
@@ -58,7 +58,8 @@ define(function (require, exports, module) {
* @private
*/
function _createPanel() {
- customSnippetsPanel = WorkspaceManager.createBottomPanel(PANEL_ID, $snippetsPanel, PANEL_MIN_SIZE);
+ customSnippetsPanel = WorkspaceManager.createBottomPanel(PANEL_ID, $snippetsPanel, PANEL_MIN_SIZE,
+ Strings.CUSTOM_SNIPPETS_PANEL_TITLE);
customSnippetsPanel.show();
// also register the handlers
diff --git a/src/extensionsIntegrated/DisplayShortcuts/main.js b/src/extensionsIntegrated/DisplayShortcuts/main.js
index 857ddf79ce..96b41cd769 100644
--- a/src/extensionsIntegrated/DisplayShortcuts/main.js
+++ b/src/extensionsIntegrated/DisplayShortcuts/main.js
@@ -478,7 +478,8 @@ define(function (require, exports, module) {
// AppInit.htmlReady() has already executed before extensions are loaded
// so, for now, we need to call this ourself
- panel = WorkspaceManager.createBottomPanel(TOGGLE_SHORTCUTS_ID, $(s), 300);
+ panel = WorkspaceManager.createBottomPanel(TOGGLE_SHORTCUTS_ID, $(s), 300,
+ Strings.KEYBOARD_SHORTCUT_PANEL_TITLE);
panel.hide();
$shortcutsPanel = $("#shortcuts-panel");
diff --git a/src/extensionsIntegrated/NoDistractions/main.js b/src/extensionsIntegrated/NoDistractions/main.js
index e0da1645eb..17894b8c54 100644
--- a/src/extensionsIntegrated/NoDistractions/main.js
+++ b/src/extensionsIntegrated/NoDistractions/main.js
@@ -89,13 +89,20 @@ define(function (require, exports, module) {
function _hidePanelsIfRequired() {
var panelIDs = WorkspaceManager.getAllPanelIDs();
_previouslyOpenPanelIDs = [];
- panelIDs.forEach(function (panelID) {
- var panel = WorkspaceManager.getPanelForID(panelID);
- if (panel && panel.isVisible()) {
- panel.hide();
- _previouslyOpenPanelIDs.push(panelID);
+ // Loop until no visible panels remain. In a tabbed system, hiding the
+ // active tab may reveal the next tab, so we must iterate.
+ let hiddenSomething = true;
+ while (hiddenSomething) {
+ hiddenSomething = false;
+ for (let i = 0; i < panelIDs.length; i++) {
+ let panel = WorkspaceManager.getPanelForID(panelIDs[i]);
+ if (panel && panel.isVisible()) {
+ panel.hide();
+ _previouslyOpenPanelIDs.push(panelIDs[i]);
+ hiddenSomething = true;
+ }
}
- });
+ }
}
/**
diff --git a/src/features/FindReferencesManager.js b/src/features/FindReferencesManager.js
index 0c71269278..2cb213c871 100644
--- a/src/features/FindReferencesManager.js
+++ b/src/features/FindReferencesManager.js
@@ -194,7 +194,8 @@ define(function (require, exports, module) {
searchModel,
"reference-in-files-results",
"reference-in-files.results",
- "reference"
+ "reference",
+ Strings.REFERENCES_PANEL_TITLE
);
if(_resultsView) {
_resultsView
diff --git a/src/language/CodeInspection.js b/src/language/CodeInspection.js
index 8cbb15d89f..01a8352bda 100644
--- a/src/language/CodeInspection.js
+++ b/src/language/CodeInspection.js
@@ -1263,7 +1263,7 @@ define(function (require, exports, module) {
Editor.registerGutter(CODE_INSPECTION_GUTTER, CODE_INSPECTION_GUTTER_PRIORITY);
// Create bottom panel to list error details
var panelHtml = Mustache.render(PanelTemplate, Strings);
- problemsPanel = WorkspaceManager.createBottomPanel("errors", $(panelHtml), 100);
+ problemsPanel = WorkspaceManager.createBottomPanel("errors", $(panelHtml), 100, Strings.CMD_VIEW_TOGGLE_PROBLEMS);
$problemsPanel = $("#problems-panel");
$fixAllBtn = $problemsPanel.find(".problems-fix-all-btn");
$fixAllBtn.click(()=>{
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index cde5aeabfb..d1fd4bd702 100644
--- a/src/nls/root/strings.js
+++ b/src/nls/root/strings.js
@@ -1249,6 +1249,11 @@ define({
"REFERENCES_IN_FILES": "references",
"REFERENCE_IN_FILES": "reference",
"REFERENCES_NO_RESULTS": "No References available for current cursor position",
+ "REFERENCES_PANEL_TITLE": "References",
+ "SEARCH_RESULTS_PANEL_TITLE": "Search Results",
+ "BOTTOM_PANEL_HIDE": "Hide Panel",
+ "BOTTOM_PANEL_SHOW": "Show Bottom Panel",
+ "BOTTOM_PANEL_HIDE_TOGGLE": "Hide Bottom Panel",
"CMD_FIND_DOCUMENT_SYMBOLS": "Find Document Symbols",
"CMD_FIND_PROJECT_SYMBOLS": "Find Project Symbols",
@@ -1396,6 +1401,7 @@ define({
"BUTTON_CANCEL": "Cancel",
"CHECKOUT_COMMIT": "Checkout",
"CHECKOUT_COMMIT_DETAIL": "Commit Message: {0}
Commit hash: {1}",
+ "GIT_PANEL_TITLE": "Git",
"GIT_CLONE": "Clone",
"BUTTON_CLOSE": "Close",
"BUTTON_COMMIT": "Commit",
diff --git a/src/search/FindInFilesUI.js b/src/search/FindInFilesUI.js
index 6a02cd7e19..0aea171f61 100644
--- a/src/search/FindInFilesUI.js
+++ b/src/search/FindInFilesUI.js
@@ -536,7 +536,8 @@ define(function (require, exports, module) {
// Initialize items dependent on HTML DOM
AppInit.htmlReady(function () {
var model = FindInFiles.searchModel;
- _resultsView = new SearchResultsView(model, "find-in-files-results", "find-in-files.results");
+ _resultsView = new SearchResultsView(model, "find-in-files-results", "find-in-files.results",
+ undefined, Strings.SEARCH_RESULTS_PANEL_TITLE);
_resultsView
.on("replaceBatch", function () {
_finishReplaceBatch(model);
diff --git a/src/search/SearchResultsView.js b/src/search/SearchResultsView.js
index 54751a67e3..5557cf96d9 100644
--- a/src/search/SearchResultsView.js
+++ b/src/search/SearchResultsView.js
@@ -76,12 +76,13 @@ define(function (require, exports, module) {
* @param {string} panelID The CSS ID to use for the panel.
* @param {string} panelName The name to use for the panel, as passed to WorkspaceManager.createBottomPanel().
* @param {string} type type to identify if it is reference search or string match serach
+ * @param {string=} title Display title for the panel tab.
*/
- function SearchResultsView(model, panelID, panelName, type) {
+ function SearchResultsView(model, panelID, panelName, type, title) {
const self = this;
let panelHtml = Mustache.render(searchPanelTemplate, {panelID: panelID});
- this._panel = WorkspaceManager.createBottomPanel(panelName, $(panelHtml), 100);
+ this._panel = WorkspaceManager.createBottomPanel(panelName, $(panelHtml), 100, title);
this._$summary = this._panel.$panel.find(".title");
this._$table = this._panel.$panel.find(".table-container");
this._$previewEditor = this._panel.$panel.find(".search-editor-preview");
diff --git a/src/styles/Extn-BottomPanelTabs.less b/src/styles/Extn-BottomPanelTabs.less
new file mode 100644
index 0000000000..327f2d62e3
--- /dev/null
+++ b/src/styles/Extn-BottomPanelTabs.less
@@ -0,0 +1,227 @@
+/*
+ * 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.
+ *
+ */
+
+/* Bottom panel tab bar — switches between tabbed bottom panels.
+ * Visual style mirrors the file tab bar (Extn-TabBar.less) for consistency. */
+
+#bottom-panel-container {
+ background-color: @bc-panel-bg;
+ border-top: 1px solid @bc-panel-border;
+ display: flex;
+ flex-direction: column;
+
+ .dark & {
+ background-color: @dark-bc-panel-bg;
+ border-top: 1px solid @dark-bc-panel-border;
+ }
+
+ .bottom-panel {
+ display: none !important;
+ flex: 1;
+ min-height: 0;
+ border-top: none;
+ height: auto !important;
+
+ &.active-bottom-panel {
+ display: flex !important;
+ flex-direction: column;
+ }
+
+ .toolbar {
+ box-shadow: none;
+ }
+ }
+}
+
+#bottom-panel-tab-bar {
+ display: flex;
+ align-items: center;
+ height: 2rem;
+ min-height: 2rem;
+ background-color: #f5f5f5;
+ border-bottom: none;
+ overflow: hidden;
+ user-select: none;
+
+ .dark & {
+ background-color: #222222;
+ }
+}
+
+.bottom-panel-tabs-overflow {
+ flex: 1;
+ display: flex;
+ overflow-x: auto;
+ overflow-y: hidden;
+ height: 100%;
+
+ /* Hide scrollbar but allow scrolling */
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.bottom-panel-tab {
+ display: inline-flex;
+ align-items: center;
+ padding: 0 0.4rem 0 0.8rem;
+ height: 100%;
+ cursor: pointer;
+ position: relative;
+ flex: 0 0 auto;
+ min-width: fit-content;
+ color: #555;
+ background-color: #f1f1f1;
+ border-right: 1px solid rgba(0, 0, 0, 0.05);
+ font-size: 1rem;
+ font-weight: 500;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+ transition: color 0.12s ease-out, background-color 0.12s ease-out;
+
+ .dark & {
+ color: #aaa;
+ background-color: #292929;
+ border-right: 1px solid rgba(255, 255, 255, 0.05);
+ }
+
+ &:hover {
+ background-color: #e0e0e0;
+
+ .dark & {
+ background-color: #3b3a3a;
+ }
+ }
+
+ &.active {
+ color: #333;
+ background-color: #fff;
+
+ .dark & {
+ color: #dedede;
+ background-color: #1D1F21;
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 0.1rem;
+ background-color: #0078D7;
+
+ .dark & {
+ background-color: #75BEFF;
+ }
+ }
+ }
+}
+
+.bottom-panel-tab-title {
+ pointer-events: none;
+}
+
+.bottom-panel-tab-close-btn {
+ margin-left: 0.55rem;
+ border-radius: 3px;
+ cursor: pointer;
+ color: #999;
+ font-size: 1.25rem;
+ font-weight: 500;
+ padding: 0 4px;
+ line-height: 1;
+ opacity: 0;
+ transition: opacity 0.12s ease, color 0.12s ease, background-color 0.12s ease;
+
+ .dark & {
+ color: #666;
+ }
+
+ .bottom-panel-tab:hover & {
+ opacity: 1;
+ color: #666;
+
+ .dark & {
+ color: #888;
+ }
+ }
+
+ .bottom-panel-tab.active & {
+ opacity: 1;
+ color: #666;
+
+ .dark & {
+ color: #888;
+ }
+ }
+
+ &:hover {
+ opacity: 1;
+ color: #333;
+ background-color: rgba(0, 0, 0, 0.1);
+
+ .dark & {
+ color: #fff;
+ background-color: rgba(255, 255, 255, 0.12);
+ }
+ }
+}
+
+.bottom-panel-tab-bar-actions {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ margin-left: auto;
+ padding: 0 0.25rem;
+ flex: 0 0 auto;
+}
+
+.bottom-panel-hide-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.6rem;
+ height: 1.4rem;
+ margin-right: 0.4rem;
+ border-radius: 3px;
+ cursor: pointer;
+ color: #666;
+ font-size: 0.7rem;
+ -webkit-text-stroke: 0.4px;
+ line-height: 1;
+ transition: color 0.12s ease, background-color 0.12s ease;
+
+ .dark & {
+ color: #aaa;
+ }
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.1);
+ color: #333;
+
+ .dark & {
+ background-color: rgba(255, 255, 255, 0.12);
+ color: #eee;
+ }
+ }
+}
diff --git a/src/styles/brackets.less b/src/styles/brackets.less
index a0f0c46faa..500a374a68 100644
--- a/src/styles/brackets.less
+++ b/src/styles/brackets.less
@@ -47,6 +47,7 @@
@import "Extn-CustomSnippets.less";
@import "Extn-CollapseFolders.less";
@import "Extn-SidebarTabs.less";
+@import "Extn-BottomPanelTabs.less";
@import "Extn-AIChatPanel.less";
@import "UserProfile.less";
@import "phoenix-pro.less";
@@ -647,6 +648,14 @@ a, img {
animation: brightenFade 2s ease-in-out;
}
+#status-panel-toggle {
+ cursor: pointer;
+}
+
+#status-panel-toggle.flash {
+ animation: brightenFade 800ms ease-in-out;
+}
+
@keyframes brightenFade {
0% {
background-color: transparent;
diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js
index c56bbb4d95..8f2c594dcf 100644
--- a/src/view/WorkspaceManager.js
+++ b/src/view/WorkspaceManager.js
@@ -40,6 +40,8 @@ define(function (require, exports, module) {
EventDispatcher = require("utils/EventDispatcher"),
KeyBindingManager = require("command/KeyBindingManager"),
Resizer = require("utils/Resizer"),
+ AnimationUtils = require("utils/AnimationUtils"),
+ Strings = require("strings"),
PluginPanelView = require("view/PluginPanelView"),
PanelView = require("view/PanelView"),
EditorManager = require("editor/EditorManager"),
@@ -123,6 +125,28 @@ define(function (require, exports, module) {
let lastHiddenBottomPanelStack = [],
lastShownBottomPanelStack = [];
+ // --- Bottom panel tabbed container state ---
+
+ /** @type {jQueryObject} The single container wrapping all bottom panels */
+ let $bottomPanelContainer;
+
+ /** @type {jQueryObject} The tab bar inside the container */
+ let $bottomPanelTabBar;
+
+ /** @type {jQueryObject} Scrollable area holding the tab elements */
+ let $bottomPanelTabsOverflow;
+
+ /** @type {jQueryObject} Chevron toggle in the status bar */
+ let $statusBarPanelToggle;
+
+ /** @type {string[]} Ordered list of currently open (tabbed) panel IDs */
+ let _openBottomPanelIds = [];
+
+ /** @type {string|null} The panel ID of the currently visible (active) tab */
+ let _activeBottomPanelId = null;
+
+ /** @type {boolean} True while the status bar toggle button is handling a click */
+ let _statusBarToggleInProgress = false;
/**
* Calculates the available height for the full-size Editor (or the no-editor placeholder),
@@ -226,6 +250,106 @@ define(function (require, exports, module) {
});
}
+ // --- Bottom panel tab helpers ---
+
+ /**
+ * Resolve the display title for a bottom panel tab.
+ * Uses the explicit title if provided, then checks for a .toolbar .title
+ * DOM element in the panel, and finally derives a name from the panel id.
+ * @param {string} id The panel registration ID
+ * @param {jQueryObject} $panel The panel's jQuery element
+ * @param {string=} title Explicit title passed to createBottomPanel
+ * @return {string}
+ * @private
+ */
+ function _getPanelTitle(id, $panel, title) {
+ if (title) {
+ return title;
+ }
+ let $titleEl = $panel.find(".toolbar .title");
+ if ($titleEl.length && $.trim($titleEl.text())) {
+ return $.trim($titleEl.text());
+ }
+ let label = id.replace(new RegExp("[-_.]", "g"), " ").split(" ")[0];
+ return label.charAt(0).toUpperCase() + label.slice(1);
+ }
+
+ /**
+ * Full rebuild of the tab bar DOM from _openBottomPanelIds.
+ * Call this when tabs are added, removed, or renamed.
+ * @private
+ */
+ function _updateBottomPanelTabBar() {
+ if (!$bottomPanelTabsOverflow) {
+ return;
+ }
+ $bottomPanelTabsOverflow.empty();
+
+ _openBottomPanelIds.forEach(function (panelId) {
+ let panel = panelIDMap[panelId];
+ if (!panel) {
+ return;
+ }
+ let title = panel._tabTitle || _getPanelTitle(panelId, panel.$panel);
+ let isActive = (panelId === _activeBottomPanelId);
+ let $tab = $('