diff --git a/src/brackets.js b/src/brackets.js index b14f4e1b99..4071ad0d25 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -135,6 +135,7 @@ define(function (require, exports, module) { require("utils/NodeUtils"); require("utils/ColorUtils"); require("view/ThemeManager"); + require("view/DefaultPanelView"); require("thirdparty/lodash"); require("language/XMLUtils"); require("language/JSONUtils"); diff --git a/src/extensions/default/DebugCommands/testBuilder.js b/src/extensions/default/DebugCommands/testBuilder.js index 38e58ddf0e..0bf8508f57 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()); @@ -49,23 +49,19 @@ define(function (require, exports, module) { } const panelHTML = `
-
-
-
Test Builder
+
+
-
+
- - × -
+ +
-
`; @@ -161,7 +157,6 @@ define(function (require, exports, module) { builderEditor && builderEditor.updateLayout(); }).observe($panel[0]); - $panel.find(".close").click(toggleTestBuilder); $panel.find(".save-test-builder").click(saveFile); $panel.find(".run-test-builder").click(()=>{ runTests(); @@ -177,7 +172,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..3a90cac8e9 100644 --- a/src/extensions/default/Git/src/Panel.js +++ b/src/extensions/default/Git/src/Panel.js @@ -1212,7 +1212,7 @@ define(function (require, exports) { return; } const mainToolbarWidth = $mainToolbar.width(); - let overFlowWidth = 565; + let overFlowWidth = 540; const breakpoints = [ { width: overFlowWidth, className: "hide-when-small" }, { width: 400, className: "hide-when-x-small" } @@ -1240,13 +1240,12 @@ 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]); $mainToolbar = $gitPanel.find(".mainToolbar"); $gitPanel - .on("click", ".close", toggle) .on("click", ".check-all", function () { if ($(this).is(":checked")) { return Git.stageAll().then(function () { @@ -1502,6 +1501,17 @@ define(function (require, exports) { handleGitCommit(lastCommitMessage[ProjectManager.getProjectRoot().fullPath], false, COMMIT_MODE.DEFAULT); }); + // When the panel tab is closed externally (e.g. via the × button), + // update the toolbar icon and menu checked state to stay in sync. + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN, function (event, panelID) { + if (panelID === "main-git.panel" && gitPanel) { + Main.$icon.toggleClass("on", false); + Main.$icon.toggleClass("selected-button", false); + CommandManager.get(Constants.CMD_GIT_TOGGLE_PANEL).setChecked(false); + Preferences.set("panelEnabled", false); + } + }); + exports.init = init; exports.refresh = refresh; exports.toggle = toggle; diff --git a/src/extensions/default/Git/styles/git-styles.less b/src/extensions/default/Git/styles/git-styles.less index 8075032b8b..0cf885384b 100644 --- a/src/extensions/default/Git/styles/git-styles.less +++ b/src/extensions/default/Git/styles/git-styles.less @@ -1013,17 +1013,12 @@ .toolbar { overflow: visible; - .close { - position: absolute; - top: 22px; - margin-top: -10px; - } } .git-more-options-btn { position: absolute; - right: 25px; - top: 8px; - padding: 4px 8px 2px 8px; + right: 8px; + top: 5px; + padding: 4px 9px 2px 8px; opacity: .7; .dark & { opacity: .5; @@ -1070,7 +1065,11 @@ .btn-group { line-height: 1; button { - height: 26px; + height: 22px; + margin-top: 2px; + margin-bottom: 2px; + padding-top: 2px; + padding-bottom: 2px; } } } @@ -1086,7 +1085,7 @@ } .git-right-icons { position:absolute; - right: 55px; + right: 32px; top: 5px; } .octicon:not(:only-child) { diff --git a/src/extensions/default/Git/templates/git-panel.html b/src/extensions/default/Git/templates/git-panel.html index 94f2d4099a..b0133c2c87 100644 --- a/src/extensions/default/Git/templates/git-panel.html +++ b/src/extensions/default/Git/templates/git-panel.html @@ -69,8 +69,7 @@
- × -
+
diff --git a/src/extensionsIntegrated/CustomSnippets/UIHelper.js b/src/extensionsIntegrated/CustomSnippets/UIHelper.js index 545214da0b..8a4f59b6f6 100644 --- a/src/extensionsIntegrated/CustomSnippets/UIHelper.js +++ b/src/extensionsIntegrated/CustomSnippets/UIHelper.js @@ -21,9 +21,11 @@ /* eslint-disable no-invalid-this */ define(function (require, exports, module) { const StringUtils = require("utils/StringUtils"); - const Global = require("./global"); const Strings = require("strings"); + /** @type {Object} Reference to the panel instance, set via init() */ + let _panel; + /** * this is a generic function to show error messages for input fields * @@ -113,7 +115,6 @@ define(function (require, exports, module) { const $backToListMenuBtn = $("#back-to-list-menu-btn"); const $addNewSnippetBtn = $("#add-new-snippet-btn"); const $filterSnippetsPanel = $("#filter-snippets-panel"); - const $toolbarTitle = $(".toolbar-title"); $addSnippetMenu.removeClass("hidden"); $snippetListMenu.addClass("hidden"); @@ -122,7 +123,9 @@ define(function (require, exports, module) { $addNewSnippetBtn.addClass("hidden"); $filterSnippetsPanel.addClass("hidden"); - $toolbarTitle.html(`${Strings.CUSTOM_SNIPPETS_ADD_PANEL_TITLE} `); + if (_panel) { + _panel.setTitle(Strings.CUSTOM_SNIPPETS_ADD_PANEL_TITLE); + } } /** @@ -137,7 +140,6 @@ define(function (require, exports, module) { const $backToListMenuBtn = $("#back-to-list-menu-btn"); const $addNewSnippetBtn = $("#add-new-snippet-btn"); const $filterSnippetsPanel = $("#filter-snippets-panel"); - const $toolbarTitle = $(".toolbar-title"); $addSnippetMenu.addClass("hidden"); $editSnippetMenu.addClass("hidden"); @@ -147,12 +149,9 @@ define(function (require, exports, module) { $addNewSnippetBtn.removeClass("hidden"); $filterSnippetsPanel.removeClass("hidden"); - // add the snippet count in the toolbar (the no. of snippets added) - const snippetCount = Global.SnippetHintsList.length; - const countText = snippetCount > 0 ? `(${snippetCount})` : ""; - $toolbarTitle.html( - `${Strings.CUSTOM_SNIPPETS_PANEL_TITLE} ${countText}` - ); + if (_panel) { + _panel.setTitle(Strings.CUSTOM_SNIPPETS_PANEL_TITLE); + } $("#filter-snippets-input").val(""); } @@ -167,7 +166,6 @@ define(function (require, exports, module) { const $backToListMenuBtn = $("#back-to-list-menu-btn"); const $addNewSnippetBtn = $("#add-new-snippet-btn"); const $filterSnippetsPanel = $("#filter-snippets-panel"); - const $toolbarTitle = $(".toolbar-title"); $editSnippetMenu.removeClass("hidden"); $snippetListMenu.addClass("hidden"); @@ -176,8 +174,9 @@ define(function (require, exports, module) { $addNewSnippetBtn.addClass("hidden"); $filterSnippetsPanel.addClass("hidden"); - // Update toolbar title - $toolbarTitle.html(`${Strings.CUSTOM_SNIPPETS_EDIT_PANEL_TITLE} `); + if (_panel) { + _panel.setTitle(Strings.CUSTOM_SNIPPETS_EDIT_PANEL_TITLE); + } } /** @@ -213,18 +212,24 @@ define(function (require, exports, module) { } /** - * Initializes the toolbar title for the list view - * This is called when the panel is first opened to ensure the snippet count is displayed + * Resets the tab title back to the default list view title. + * Called when the panel is first opened or toggled visible. */ function initializeListViewToolbarTitle() { - const $toolbarTitle = $(".toolbar-title"); - const snippetCount = Global.SnippetHintsList.length; - const countText = snippetCount > 0 ? `(${snippetCount})` : ""; - $toolbarTitle.html( - `${Strings.CUSTOM_SNIPPETS_PANEL_TITLE} ${countText}` - ); + if (_panel) { + _panel.setTitle(Strings.CUSTOM_SNIPPETS_PANEL_TITLE); + } + } + + /** + * Sets the panel reference so UIHelper can update the tab title. + * @param {Object} panel The Panel instance returned by WorkspaceManager.createBottomPanel + */ + function init(panel) { + _panel = panel; } + exports.init = init; exports.showEmptySnippetMessage = showEmptySnippetMessage; exports.showSnippetsList = showSnippetsList; exports.clearSnippetsList = clearSnippetsList; diff --git a/src/extensionsIntegrated/CustomSnippets/helper.js b/src/extensionsIntegrated/CustomSnippets/helper.js index f68dfe6294..43713dcda5 100644 --- a/src/extensionsIntegrated/CustomSnippets/helper.js +++ b/src/extensionsIntegrated/CustomSnippets/helper.js @@ -569,20 +569,6 @@ define(function (require, exports, module) { $("#edit-file-extn-box").val(""); } - /** - * Updates the snippets count which is displayed in the toolbar at the left side - * @private - */ - function updateSnippetsCount() { - const count = Global.SnippetHintsList.length; - const $countSpan = $("#snippets-count"); - if (count > 0) { - $countSpan.text(`(${count})`); - } else { - $countSpan.text(""); - } - } - /** * validates and sanitizes file extension input * @@ -932,7 +918,6 @@ define(function (require, exports, module) { exports.isSnippetSupportedInFile = isSnippetSupportedInFile; exports.hasExactMatchingSnippet = hasExactMatchingSnippet; exports.getMatchingSnippets = getMatchingSnippets; - exports.updateSnippetsCount = updateSnippetsCount; exports.sanitizeFileExtensionInput = sanitizeFileExtensionInput; exports.handleFileExtensionInput = handleFileExtensionInput; exports.handleFileExtensionKeypress = handleFileExtensionKeypress; diff --git a/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html b/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html index f1b5e3c0e2..b6312b8c4d 100644 --- a/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html +++ b/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html @@ -1,10 +1,10 @@
- {{Strings.CUSTOM_SNIPPETS_PANEL_TITLE}}
diff --git a/src/extensionsIntegrated/CustomSnippets/main.js b/src/extensionsIntegrated/CustomSnippets/main.js index 5e2b1b3d0f..b66ed5df63 100644 --- a/src/extensionsIntegrated/CustomSnippets/main.js +++ b/src/extensionsIntegrated/CustomSnippets/main.js @@ -58,7 +58,9 @@ 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); + UIHelper.init(customSnippetsPanel); customSnippetsPanel.show(); // also register the handlers @@ -129,7 +131,6 @@ define(function (require, exports, module) { * @private */ function _registerHandlers() { - const $closePanelBtn = $("#close-custom-snippets-panel-btn"); const $saveCustomSnippetBtn = $("#save-custom-snippet-btn"); const $cancelCustomSnippetBtn = $("#cancel-custom-snippet-btn"); const $abbrInput = $("#abbr-box"); @@ -161,10 +162,6 @@ define(function (require, exports, module) { SnippetsList.showSnippetsList(); }); - $closePanelBtn.on("click", function () { - _hidePanel(); - }); - $saveCustomSnippetBtn.on("click", function () { Driver.handleSaveBtnClick(); }); @@ -258,6 +255,14 @@ define(function (require, exports, module) { }); } + // When the panel tab is closed externally (e.g. via the × button), + // update the menu checked state to stay in sync. + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN, function (event, panelID) { + if (panelID === PANEL_ID && customSnippetsPanel) { + CommandManager.get(MY_COMMAND_ID).setChecked(false); + } + }); + AppInit.appReady(function () { CommandManager.register(MENU_ITEM_NAME, MY_COMMAND_ID, showCustomSnippetsPanel); // Render template with localized strings diff --git a/src/extensionsIntegrated/CustomSnippets/snippetsList.js b/src/extensionsIntegrated/CustomSnippets/snippetsList.js index 6215842986..de45f3d95f 100644 --- a/src/extensionsIntegrated/CustomSnippets/snippetsList.js +++ b/src/extensionsIntegrated/CustomSnippets/snippetsList.js @@ -180,8 +180,6 @@ define(function (require, exports, module) { console.error("failed to delete custom snippet correctly:", error); }); - // update the snippets count in toolbar - Helper.updateSnippetsCount(); // Refresh the entire list to properly handle filtering showSnippetsList(); } diff --git a/src/extensionsIntegrated/DisplayShortcuts/main.js b/src/extensionsIntegrated/DisplayShortcuts/main.js index 857ddf79ce..0b8f49678c 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"); @@ -505,10 +506,6 @@ define(function (require, exports, module) { } }); - $shortcutsPanel.find(".close").click(function () { - CommandManager.execute(TOGGLE_SHORTCUTS_ID); - }); - $shortcutsPanel.find(".reset-to-default").click(function () { Dialogs.showConfirmDialog( Strings.KEYBOARD_SHORTCUT_RESET_DIALOG_TITLE, @@ -541,5 +538,15 @@ define(function (require, exports, module) { KeyBindingManager.on(KeyBindingManager.EVENT_KEY_BINDING_REMOVED, _updateKeyBindings); KeyBindingManager.on(KeyBindingManager.EVENT_NEW_PRESET, _updatePresets); KeyBindingManager.on(KeyBindingManager.EVENT_PRESET_CHANGED, _updatePresets); + + // When the panel tab is closed externally (e.g. via the × button), + // update the menu checked state and clean up resources. + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN, function (event, panelID) { + if (panelID === TOGGLE_SHORTCUTS_ID && panel) { + destroyKeyList(); + _clearSortingEventHandlers(); + CommandManager.get(TOGGLE_SHORTCUTS_ID).setChecked(false); + } + }); }); }); diff --git a/src/extensionsIntegrated/DisplayShortcuts/templates/bottom-panel.html b/src/extensionsIntegrated/DisplayShortcuts/templates/bottom-panel.html index 4762ac7851..a9f10e7fda 100644 --- a/src/extensionsIntegrated/DisplayShortcuts/templates/bottom-panel.html +++ b/src/extensionsIntegrated/DisplayShortcuts/templates/bottom-panel.html @@ -1,7 +1,5 @@
- {{KEYBOARD_SHORTCUT_PANEL_TITLE}} - × diff --git a/src/extensionsIntegrated/NoDistractions/main.js b/src/extensionsIntegrated/NoDistractions/main.js index e0da1645eb..55f8d27a94 100644 --- a/src/extensionsIntegrated/NoDistractions/main.js +++ b/src/extensionsIntegrated/NoDistractions/main.js @@ -87,15 +87,22 @@ define(function (require, exports, module) { * hide all open panels */ function _hidePanelsIfRequired() { - var panelIDs = WorkspaceManager.getAllPanelIDs(); _previouslyOpenPanelIDs = []; - panelIDs.forEach(function (panelID) { - var panel = WorkspaceManager.getPanelForID(panelID); + + // Batch-hide all open bottom panel tabs in one pass, avoiding O(n) + // intermediate tab activations and layout recalcs. + let hiddenBottomPanels = WorkspaceManager.hideAllOpenBottomPanels(); + _previouslyOpenPanelIDs = hiddenBottomPanels; + + // Hide any remaining visible panels (e.g. plugin side-panels) + let panelIDs = WorkspaceManager.getAllPanelIDs(); + for (let i = 0; i < panelIDs.length; i++) { + let panel = WorkspaceManager.getPanelForID(panelIDs[i]); if (panel && panel.isVisible()) { panel.hide(); - _previouslyOpenPanelIDs.push(panelID); + _previouslyOpenPanelIDs.push(panelIDs[i]); } - }); + } } /** 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/htmlContent/problems-panel.html b/src/htmlContent/problems-panel.html index 7ad8e75ddf..34ca3fa58e 100644 --- a/src/htmlContent/problems-panel.html +++ b/src/htmlContent/problems-panel.html @@ -2,7 +2,6 @@
- ×
-
+
diff --git a/src/htmlContent/search-panel.html b/src/htmlContent/search-panel.html index e297478baa..4f81e7af5b 100644 --- a/src/htmlContent/search-panel.html +++ b/src/htmlContent/search-panel.html @@ -1,10 +1,8 @@
- ×
-
- +
diff --git a/src/language/CodeInspection.js b/src/language/CodeInspection.js index 8cbb15d89f..6e5103bcdc 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(()=>{ @@ -1354,11 +1354,6 @@ define(function (require, exports, module) { } }); - $("#problems-panel .close").click(function () { - toggleCollapsed(true); - MainViewManager.focusActivePane(); - }); - // Status bar indicator - icon & tooltip updated by run() var statusIconHtml = Mustache.render("
 
", Strings); StatusBar.addIndicator(INDICATOR_ID, $(statusIconHtml), true, "", "", "status-indent"); @@ -1370,6 +1365,14 @@ define(function (require, exports, module) { } }); + // When the panel tab is closed externally (e.g. via the × button), + // update the collapsed state so the panel doesn't auto-reopen. + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN, function (event, panelID) { + if (panelID === "errors") { + _collapsed = true; + } + }); + // Set initial UI state toggleEnabled(prefs.get(PREF_ENABLED), true); toggleCollapsed(prefs.get(PREF_COLLAPSED), true); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index cde5aeabfb..71aaa7bf13 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1249,6 +1249,13 @@ 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", + "BOTTOM_PANEL_DEFAULT_TITLE": "Quick Access", + "BOTTOM_PANEL_DEFAULT_HEADING": "Open a Panel", "CMD_FIND_DOCUMENT_SYMBOLS": "Find Document Symbols", "CMD_FIND_PROJECT_SYMBOLS": "Find Project Symbols", @@ -1396,6 +1403,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", @@ -1696,6 +1704,7 @@ define({ "CUSTOM_SNIPPETS_ADD_PANEL_TITLE": "Add Snippet", "CUSTOM_SNIPPETS_EDIT_PANEL_TITLE": "Edit Snippet", "CUSTOM_SNIPPETS_ADD_NEW_TITLE": "Add new snippet", + "CUSTOM_SNIPPETS_ADD_BTN_LABEL": "Add", "CUSTOM_SNIPPETS_BACK_TO_LIST_TITLE": "Back to snippets list", "CUSTOM_SNIPPETS_BACK": "Back", "CUSTOM_SNIPPETS_FILTER_PLACEHOLDER": "Filter...", diff --git a/src/search/FindInFilesUI.js b/src/search/FindInFilesUI.js index 6a02cd7e19..8ce87f25a4 100644 --- a/src/search/FindInFilesUI.js +++ b/src/search/FindInFilesUI.js @@ -111,7 +111,7 @@ define(function (require, exports, module) { } } else { - _resultsView.close(); + _resultsView.showNoResults(); if (_findBar) { var showMessage = false; @@ -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..e79a86116b 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"); @@ -96,12 +97,10 @@ define(function (require, exports, module) { }).observe(this._panel.$panel[0]); function _showPanelIfResultsAvailable(_e, shownPanelID) { - if(self._model.numMatches === 0){ - self._panel.hide(); - } - if(shownPanelID === self._panel.panelID && !self._model.isReplace){ - // If it is replace, _handleModelChange will close the find bar as we dont - // do replace if there is a model change. So we wont enter this flow if it is a replace operation + if (shownPanelID === self._panel.panelID && self._model.numMatches > 0 && !self._model.isReplace) { + // Refresh results when the tab is re-activated (they may have changed + // while the panel was in a background tab). Skip when numMatches is 0 + // so the "no results" state isn't disturbed. self._handleModelChange(); } } @@ -328,12 +327,6 @@ define(function (require, exports, module) { var self = this; this._panel.$panel .off(".searchResults") // Remove the old events - .on("dblclick.searchResults", ".toolbar", function() { - self._panel.hide(); - }) - .on("click.searchResults", ".close", function () { - self._panel.hide(); - }) // The link to go the first page .on("click.searchResults", ".first-page:not(.disabled)", function () { self._currentStart = 0; @@ -530,18 +523,18 @@ define(function (require, exports, module) { let self = this; let count = self._model.countFilesMatches(), lastIndex = self._getLastIndex(count.matches), - typeStr = (count.matches > 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, + typeStr = (count.matches !== 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, filesStr, summary; if(this._searchResultsType === "reference") { - typeStr = (count.matches > 1) ? Strings.REFERENCES_IN_FILES : Strings.REFERENCE_IN_FILES; + typeStr = (count.matches !== 1) ? Strings.REFERENCES_IN_FILES : Strings.REFERENCE_IN_FILES; } filesStr = StringUtils.format( Strings.FIND_NUM_FILES, count.files, - (count.files > 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE) + (count.files !== 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE) ); // This text contains some formatting, so all the strings are assumed to be already escaped @@ -576,8 +569,17 @@ define(function (require, exports, module) { * Shows the current set of results. */ SearchResultsView.prototype._render = function () { + let count = this._model.countFilesMatches(); + if (count.matches === 0 && this._model.queryInfo) { + // Only redirect to showNoResults() when the model has a valid query + // (i.e. this is a real "no results" state, not a transient clear). + this.showNoResults(); + return; + } + + this._panel.$panel.removeClass("search-no-results"); + let searchItems, match, i, item, multiLine, - count = this._model.countFilesMatches(), searchFiles = this._model.prioritizeOpenFile(this._initialFilePath), lastIndex = this._getLastIndex(count.matches), matchesCounter = 0, @@ -823,12 +825,43 @@ define(function (require, exports, module) { this._model.on("change.SearchResultsView", this._handleModelChange.bind(this)); }; + /** + * Opens the panel and displays a "no results" message instead of closing it. + * Keeps the tab visible so the user gets clear feedback without jarring tab switches. + * @param {string=} message Optional message to display. Defaults to Strings.FIND_NO_RESULTS. + */ + SearchResultsView.prototype.showNoResults = function (message) { + this._currentStart = 0; + this._$selectedRow = null; + this._allChecked = false; + + if (this._timeoutID) { + window.clearTimeout(this._timeoutID); + this._timeoutID = null; + } + + this._$table.empty(); + this._closePreviewEditor(); + + this._panel.$panel.addClass("search-no-results"); + this._showSummary(); + this._$table.append( + $('
').text(message || Strings.FIND_NO_RESULTS) + ); + + this._panel.$panel.off(".searchResults"); + this._model.off("change.SearchResultsView"); + + this._panel.show(); + }; + /** * Hides the Search Results Panel and unregisters listeners. */ SearchResultsView.prototype.close = function () { if (this._panel && this._panel.isVisible()) { this._$table.empty(); + this._panel.$panel.removeClass("search-no-results"); this._panel.hide(); this._panel.$panel.off(".searchResults"); this._model.off("change.SearchResultsView"); diff --git a/src/styles/Extn-BottomPanelTabs.less b/src/styles/Extn-BottomPanelTabs.less new file mode 100644 index 0000000000..d2bb588270 --- /dev/null +++ b/src/styles/Extn-BottomPanelTabs.less @@ -0,0 +1,319 @@ +/* + * 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; + } + + .resizable-content { + flex: 1; + min-height: 0; + height: auto !important; + } + } +} + +#bottom-panel-tab-bar { + display: flex; + align-items: center; + height: 2rem; + min-height: 2rem; + background-color: #f5f5f5; + border-bottom: 1px solid #d6d6d6; + overflow: hidden; + user-select: none; + + .dark & { + background-color: #222222; + border-bottom: 1px solid #333; + } +} + +.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; + } + } +} + +.default-panel-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 16px; + gap: 12px; + user-select: none; +} + +.default-panel-heading { + font-size: 14px; + letter-spacing: 0.6px; + word-spacing: 1px; + font-weight: 500; + color: #555; + margin-bottom: 4px; + + .dark & { + color: #bbb; + } +} + +.default-panel-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.default-panel-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 120px; + height: 72px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + background: rgba(0, 0, 0, 0.03); + color: #444; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; + + i { + font-size: 20px; + } + + .default-panel-btn-label { + font-size: 11px; + text-align: center; + line-height: 1.2; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &:hover { + background: rgba(0, 0, 0, 0.07); + border-color: rgba(0, 0, 0, 0.25); + } + + &:active { + background: rgba(0, 0, 0, 0.12); + } + + .dark & { + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.05); + color: #ccc; + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); + } + + &:active { + background: rgba(255, 255, 255, 0.15); + } + } +} diff --git a/src/styles/Extn-CustomSnippets.less b/src/styles/Extn-CustomSnippets.less index 57e6b32966..9f14524cf2 100644 --- a/src/styles/Extn-CustomSnippets.less +++ b/src/styles/Extn-CustomSnippets.less @@ -27,42 +27,30 @@ align-items: center; gap: 10px; flex-wrap: wrap; + margin-left: 4px; } .buttons { gap: 4px; flex-wrap: wrap; align-items: center; + margin: 0; } } -.toolbar-title { - color: @bc-text; - font-size: 15px; - font-weight: 500; - - .dark & { - color: @dark-bc-text; - } -} - -.snippets-count { - color: @bc-text; - font-size: 15px; - font-weight: 500; - - .dark & { - color: @dark-bc-text; - } -} .custom-snippet-btn button { - height: 26px; + height: 21px; border-radius: 4px; background-color: @bc-btn-bg; color: @bc-text; border: 1px solid @bc-btn-border; box-shadow: inset 0 1px @bc-highlight; + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px; + font-size: 12px; .dark & { background-color: @dark-bc-btn-bg; @@ -72,11 +60,6 @@ } } -.custom-snippet-btn button { - display: flex; - align-items: center; -} - .custom-snippet-btn .back-btn-left-icon { position: relative; top: 0.6px; @@ -93,14 +76,6 @@ top: 0.6px; } -#custom-snippets-panel .close { - padding-top: 2.5px; -} - -#custom-snippets-panel .close:hover { - cursor: pointer; -} - .filter-snippets-panel { display: inline-block; } @@ -113,8 +88,7 @@ height: 14px; min-width: 120px; margin-bottom: 0; - margin-top: -4px; - margin-right: 30px; + margin-right: 4px; } #custom-snippets-list.hidden { diff --git a/src/styles/Extn-DisplayShortcuts.less b/src/styles/Extn-DisplayShortcuts.less index 77765ffbf0..a326162b06 100644 --- a/src/styles/Extn-DisplayShortcuts.less +++ b/src/styles/Extn-DisplayShortcuts.less @@ -106,15 +106,15 @@ } #shortcuts-panel .toolbar { - display: block; - padding-right: 28px; + display: flex; + justify-content: flex-end; + padding-right: 4px; } #shortcuts-panel .toolbar button.reset-to-default, #shortcuts-panel .toolbar button.presetPicker, #shortcuts-panel .toolbar .filter { - float: right; - margin: 4px 10px; + margin: 1px 8px 1px 6px; padding-top: 2px; padding-bottom: 2px; } diff --git a/src/styles/brackets.less b/src/styles/brackets.less index a0f0c46faa..72fbe70317 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; @@ -1899,11 +1908,14 @@ a, img { /* Find in Files results panel - temporary UI, to be replaced with a richer search feature later */ +.search-results .toolbar { + padding: 5px 8px; +} .search-results .title { .sane-box-model; - padding-right: 20px; + margin-left: 4px; width: 100%; - line-height: 25px; + line-height: 26px; .flex-box; .contracting-col { @@ -1969,6 +1981,31 @@ a, img { } } +.search-results.search-no-results { + .table-container { + width: 100% !important; + } + .search-editor-preview { + display: none !important; + } +} + +.search-no-results-message { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #888; + font-size: 15px; + letter-spacing: 0.5px; + word-spacing: 1px; + user-select: none; + + .dark & { + color: #999; + } +} + .search-results .disclosure-triangle, #problems-panel .disclosure-triangle { .expand-collapse-triangle(); @@ -2365,6 +2402,7 @@ a, img { .search-input-container { display: inline-flex; + vertical-align: top; } .filter-container{ margin-left: -11px; @@ -3032,6 +3070,12 @@ textarea.exclusions-editor { #problems-panel { .user-select(text); // allow selecting error messages for easy web searching + .toolbar { + padding: 9px 8px; + } + .title { + margin-left: 4px; + } .line { text-align: right; // make line number line up with editor line numbers } diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js new file mode 100644 index 0000000000..b342d24c9c --- /dev/null +++ b/src/view/DefaultPanelView.js @@ -0,0 +1,204 @@ +/* + * 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. + * + */ + +/** + * DefaultPanelView - A launcher panel shown in the bottom panel area when no + * other panels are open. Provides quick-access buttons for common panels + * (Problems, Find in Files, Git, Custom Snippets, Keyboard Shortcuts) and a + * link to the documentation. + * + * @module view/DefaultPanelView + */ +define(function (require, exports, module) { + + const AppInit = require("utils/AppInit"), + Commands = require("command/Commands"), + CommandManager = require("command/CommandManager"), + Strings = require("strings"), + WorkspaceManager = require("view/WorkspaceManager"), + PanelView = require("view/PanelView"); + + /** + * Descriptors for each launcher button. + */ + const _panelButtons = [ + { + id: "problems", + icon: "fa-solid fa-triangle-exclamation", + label: Strings.CMD_VIEW_TOGGLE_PROBLEMS || "Problems", + commandID: Commands.VIEW_TOGGLE_PROBLEMS + }, + { + id: "search", + icon: "fa-solid fa-magnifying-glass", + label: Strings.CMD_FIND_IN_FILES || "Find in Files", + commandID: Commands.CMD_FIND_IN_FILES + }, + { + id: "git", + icon: "fa-solid fa-code-branch", + label: Strings.GIT_PANEL_TITLE || "Git", + commandID: Commands.CMD_GIT_TOGGLE_PANEL + }, + { + id: "snippets", + icon: "fa-solid fa-code", + label: Strings.CUSTOM_SNIPPETS_PANEL_TITLE || "Custom Snippets", + commandID: "custom_snippets" + }, + { + id: "shortcuts", + icon: "fa-solid fa-keyboard", + label: Strings.KEYBOARD_SHORTCUT_PANEL_TITLE || "Keyboard Shortcuts", + commandID: Commands.HELP_TOGGLE_SHORTCUTS_PANEL + } + ]; + + /** @type {Panel} The default panel instance */ + let _panel; + + /** @type {jQueryObject} The panel DOM element */ + let _$panel; + + /** + * Build the panel DOM. + * @return {jQueryObject} + * @private + */ + function _buildPanelHTML() { + let $panel = $('
'); + let $content = $('
'); + let $heading = $('
') + .text(Strings.BOTTOM_PANEL_DEFAULT_HEADING); + $content.append($heading); + + let $buttonsRow = $('
'); + + _panelButtons.forEach(function (btn) { + let $button = $('') + .attr("data-command", btn.commandID) + .attr("data-btn-id", btn.id) + .attr("title", btn.label); + let $icon = $('').addClass(btn.icon); + let $label = $('').text(btn.label); + $button.append($icon).append($label); + $buttonsRow.append($button); + }); + + $content.append($buttonsRow); + + $panel.append($content); + return $panel; + } + + /** + * Check whether Git is available for the current project. + * The Git extension hides its toolbar icon with the "forced-hidden" class + * when Git is not available (no binary, not a repo, extension disabled, etc.). + * @return {boolean} + * @private + */ + function _isGitAvailable() { + const $gitIcon = $("#git-toolbar-icon"); + return $gitIcon.length > 0 && !$gitIcon.hasClass("forced-hidden"); + } + + /** + * Check whether there are currently lint errors/warnings. + * The status bar indicator gets the "inspection-errors" class when problems exist. + * @return {boolean} + * @private + */ + function _hasProblems() { + const $indicator = $("#status-inspection"); + return $indicator.length > 0 && $indicator.hasClass("inspection-errors"); + } + + /** + * Show or hide the Git and Problems buttons based on current state. + * @private + */ + function _updateButtonVisibility() { + if (!_$panel) { + return; + } + _$panel.find('.default-panel-btn[data-btn-id="git"]').toggle(_isGitAvailable()); + _$panel.find('.default-panel-btn[data-btn-id="problems"]').toggle(_hasProblems()); + } + + /** + * Set up MutationObservers on the Git toolbar icon and status-inspection + * indicator so that button visibility updates live. + * @private + */ + function _observeStateChanges() { + // Watch Git toolbar icon for class changes (forced-hidden added/removed) + const gitIcon = document.getElementById("git-toolbar-icon"); + if (gitIcon) { + const gitObserver = new MutationObserver(_updateButtonVisibility); + gitObserver.observe(gitIcon, {attributes: true, attributeFilter: ["class"]}); + } + + // Watch status-inspection indicator for class changes (inspection-errors) + const statusInspection = document.getElementById("status-inspection"); + if (statusInspection) { + const inspectionObserver = new MutationObserver(_updateButtonVisibility); + inspectionObserver.observe(statusInspection, {attributes: true, attributeFilter: ["class"]}); + } + } + + /** + * Initialise the default panel. Called once at appReady. + * @private + */ + function _init() { + _$panel = _buildPanelHTML(); + _panel = WorkspaceManager.createBottomPanel( + WorkspaceManager.DEFAULT_PANEL_ID, + _$panel, + undefined, + Strings.BOTTOM_PANEL_DEFAULT_TITLE + ); + + // Button click handler: execute the command to open the target panel. + // The auto-hide listener (EVENT_PANEL_SHOWN) will close the default panel. + _$panel.on("click", ".default-panel-btn", function () { + let commandID = $(this).attr("data-command"); + if (commandID) { + CommandManager.execute(commandID); + } + }); + + // Auto-hide when any other panel is shown. + // hide() is a no-op if the panel is already closed, so no guard needed. + PanelView.on(PanelView.EVENT_PANEL_SHOWN, function (event, panelID) { + if (panelID !== WorkspaceManager.DEFAULT_PANEL_ID) { + _panel.hide(); + } + if (panelID === WorkspaceManager.DEFAULT_PANEL_ID) { + _updateButtonVisibility(); + } + }); + + // Initial visibility update and set up live observers + _updateButtonVisibility(); + _observeStateChanges(); + } + + AppInit.appReady(_init); +}); diff --git a/src/view/PanelView.js b/src/view/PanelView.js index d3c6e266fa..a857972fc1 100644 --- a/src/view/PanelView.js +++ b/src/view/PanelView.js @@ -26,8 +26,9 @@ define(function (require, exports, module) { const EventDispatcher = require("utils/EventDispatcher"), - Resizer = require("utils/Resizer"); - + Resizer = require("utils/Resizer"), + Strings = require("strings"); + /** * Event when panel is hidden * @type {string} @@ -49,15 +50,133 @@ define(function (require, exports, module) { */ const PANEL_TYPE_BOTTOM_PANEL = 'bottomPanel'; + // --- Module-level tab state --- + + /** @type {Object.} Maps panel ID to Panel instance */ + let _panelMap = {}; + + /** @type {jQueryObject} The single container wrapping all bottom panels */ + let _$container; + + /** @type {jQueryObject} The tab bar inside the container */ + let _$tabBar; + + /** @type {jQueryObject} Scrollable area holding the tab elements */ + let _$tabsOverflow; + + /** @type {string[]} Ordered list of currently open (tabbed) panel IDs */ + let _openIds = []; + + /** @type {string|null} The panel ID of the currently visible (active) tab */ + let _activeId = null; + + // --- Tab helper functions --- + + /** + * 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 _openIds. + * Call this when tabs are added, removed, or renamed. + * @private + */ + function _updateBottomPanelTabBar() { + if (!_$tabsOverflow) { + return; + } + _$tabsOverflow.empty(); + + _openIds.forEach(function (panelId) { + let panel = _panelMap[panelId]; + if (!panel) { + return; + } + let title = panel._tabTitle || _getPanelTitle(panelId, panel.$panel); + let isActive = (panelId === _activeId); + let $tab = $('
') + .toggleClass('active', isActive) + .attr('data-panel-id', panelId); + $tab.append($('').text(title)); + $tab.append($('×').attr('title', Strings.CLOSE)); + _$tabsOverflow.append($tab); + }); + } + + /** + * Swap the .active class on the tab bar without rebuilding the DOM. + * @private + */ + function _updateActiveTabHighlight() { + if (!_$tabBar) { + return; + } + _$tabBar.find(".bottom-panel-tab").each(function () { + let $tab = $(this); + if ($tab.data("panel-id") === _activeId) { + $tab.addClass("active"); + } else { + $tab.removeClass("active"); + } + }); + } + + /** + * Switch the active tab to the given panel. Does not show/hide the container. + * @param {string} panelId + * @private + */ + function _switchToTab(panelId) { + if (_activeId === panelId) { + return; + } + // Remove active class from current + if (_activeId) { + let prevPanel = _panelMap[_activeId]; + if (prevPanel) { + prevPanel.$panel.removeClass("active-bottom-panel"); + } + } + // Set new active + _activeId = panelId; + let newPanel = _panelMap[panelId]; + if (newPanel) { + newPanel.$panel.addClass("active-bottom-panel"); + } + _updateActiveTabHighlight(); + } + /** * Represents a panel below the editor area (a child of ".content"). * @constructor * @param {!jQueryObject} $panel The entire panel, including any chrome, already in the DOM. + * @param {string} id Unique panel identifier. + * @param {string=} title Optional display title for the tab bar. */ - function Panel($panel, id) { + function Panel($panel, id, title) { this.$panel = $panel; this.panelID = id; + this._tabTitle = _getPanelTitle(id, $panel, title); + _panelMap[id] = this; } /** @@ -71,7 +190,7 @@ define(function (require, exports, module) { * @return {boolean} true if visible, false if not */ Panel.prototype.isVisible = function () { - return this.$panel.is(":visible"); + return (_activeId === this.panelID) && _$container && _$container.is(":visible"); }; /** @@ -104,20 +223,75 @@ define(function (require, exports, module) { * Shows the panel */ Panel.prototype.show = function () { - if(!this.isVisible() && this.canBeShown()){ - Resizer.show(this.$panel[0]); - exports.trigger(EVENT_PANEL_SHOWN, this.panelID); + if (!this.canBeShown()) { + return; } + let panelId = this.panelID; + let isOpen = _openIds.indexOf(panelId) !== -1; + let isActive = (_activeId === panelId); + + if (isOpen && isActive) { + // Already open and active — just ensure container is visible + if (!_$container.is(":visible")) { + Resizer.show(_$container[0]); + exports.trigger(EVENT_PANEL_SHOWN, panelId); + } + return; + } + if (isOpen && !isActive) { + // Open but not active - switch tab and ensure container is visible + _switchToTab(panelId); + if (!_$container.is(":visible")) { + Resizer.show(_$container[0]); + } + exports.trigger(EVENT_PANEL_SHOWN, panelId); + return; + } + // Not open: add to open set + _openIds.push(panelId); + + // Show container if it was hidden + if (!_$container.is(":visible")) { + Resizer.show(_$container[0]); + } + + _switchToTab(panelId); + _updateBottomPanelTabBar(); + exports.trigger(EVENT_PANEL_SHOWN, panelId); }; /** * Hides the panel */ Panel.prototype.hide = function () { - if(this.isVisible()){ - Resizer.hide(this.$panel[0]); - exports.trigger(EVENT_PANEL_HIDDEN, this.panelID); + let panelId = this.panelID; + let idx = _openIds.indexOf(panelId); + if (idx === -1) { + // Not open - no-op + return; + } + + // Remove from open set + _openIds.splice(idx, 1); + this.$panel.removeClass("active-bottom-panel"); + + let wasActive = (_activeId === panelId); + + // Tab was removed — rebuild tab bar, then activate next if needed + if (wasActive && _openIds.length > 0) { + let nextIdx = Math.min(idx, _openIds.length - 1); + let nextId = _openIds[nextIdx]; + _activeId = null; // clear so _switchToTab runs + _switchToTab(nextId); + exports.trigger(EVENT_PANEL_SHOWN, nextId); + } else if (wasActive) { + // No more tabs - hide the container + _activeId = null; + Resizer.hide(_$container[0]); } + _updateBottomPanelTabBar(); + + exports.trigger(EVENT_PANEL_HIDDEN, panelId); }; /** @@ -132,6 +306,18 @@ define(function (require, exports, module) { } }; + /** + * Updates the display title shown in the tab bar for this panel. + * @param {string} newTitle The new title to display. + */ + Panel.prototype.setTitle = function (newTitle) { + this._tabTitle = newTitle; + if (_$tabsOverflow) { + _$tabsOverflow.find('.bottom-panel-tab[data-panel-id="' + this.panelID + '"] .bottom-panel-tab-title') + .text(newTitle); + } + }; + /** * gets the Panel's type * @return {string} @@ -140,10 +326,103 @@ define(function (require, exports, module) { return PANEL_TYPE_BOTTOM_PANEL; }; + /** + * Initializes the PanelView module with references to the bottom panel container DOM elements. + * Called by WorkspaceManager during htmlReady. + * @param {jQueryObject} $container The bottom panel container element. + * @param {jQueryObject} $tabBar The tab bar element inside the container. + * @param {jQueryObject} $tabsOverflow The scrollable area holding tab elements. + */ + function init($container, $tabBar, $tabsOverflow) { + _$container = $container; + _$tabBar = $tabBar; + _$tabsOverflow = $tabsOverflow; + + // Tab bar click handlers + _$tabBar.on("click", ".bottom-panel-tab-close-btn", function (e) { + e.stopPropagation(); + let panelId = $(this).closest(".bottom-panel-tab").data("panel-id"); + if (panelId) { + let panel = _panelMap[panelId]; + if (panel) { + panel.hide(); + } + } + }); + + _$tabBar.on("click", ".bottom-panel-tab", function (e) { + let panelId = $(this).data("panel-id"); + if (panelId && panelId !== _activeId) { + let panel = _panelMap[panelId]; + if (panel) { + panel.show(); + } + } + }); + + // Hide-panel button collapses the container but keeps tabs intact + _$tabBar.on("click", ".bottom-panel-hide-btn", function (e) { + e.stopPropagation(); + if (_$container.is(":visible")) { + Resizer.hide(_$container[0]); + } + }); + } + + /** + * Returns a copy of the currently open bottom panel IDs in tab order. + * @return {string[]} + */ + function getOpenBottomPanelIDs() { + return _openIds.slice(); + } + + /** + * Hides every open bottom panel tab in a single batch + * @return {string[]} The IDs of panels that were open (useful for restoring later). + */ + function hideAllOpenPanels() { + if (_openIds.length === 0) { + return []; + } + let closedIds = _openIds.slice(); + + // Remove visual active state from every panel + for (let i = 0; i < closedIds.length; i++) { + let panel = _panelMap[closedIds[i]]; + if (panel) { + panel.$panel.removeClass("active-bottom-panel"); + } + } + + // Clear internal state BEFORE hiding the container so the + // panelCollapsed handler sees an empty _openIds and doesn't + // redundantly update the stacks. + _openIds = []; + _activeId = null; + + if (_$container && _$container.is(":visible")) { + Resizer.hide(_$container[0]); + } + + _updateBottomPanelTabBar(); + + // Fire one EVENT_PANEL_HIDDEN per panel for stack tracking. + // No intermediate EVENT_PANEL_SHOWN events are emitted. + for (let i = 0; i < closedIds.length; i++) { + exports.trigger(EVENT_PANEL_HIDDEN, closedIds[i]); + } + + return closedIds; + } + EventDispatcher.makeEventDispatcher(exports); // Public API exports.Panel = Panel; + exports.init = init; + exports.getOpenBottomPanelIDs = getOpenBottomPanelIDs; + exports.hideAllOpenPanels = hideAllOpenPanels; //events exports.EVENT_PANEL_HIDDEN = EVENT_PANEL_HIDDEN; exports.EVENT_PANEL_SHOWN = EVENT_PANEL_SHOWN; diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js index c56bbb4d95..45412239c0 100644 --- a/src/view/WorkspaceManager.js +++ b/src/view/WorkspaceManager.js @@ -40,11 +40,20 @@ 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"), KeyEvent = require("utils/KeyEvent"); + /** + * Panel ID for the default launcher panel shown when no other panels are open. + * @const + * @private + */ + const DEFAULT_PANEL_ID = "workspace.defaultPanel"; + /** * Event triggered when the workspace layout updates. @@ -120,9 +129,16 @@ define(function (require, exports, module) { */ var windowResizing = false; - let lastHiddenBottomPanelStack = [], - lastShownBottomPanelStack = []; + let lastShownBottomPanelStack = []; + /** @type {jQueryObject} The single container wrapping all bottom panels */ + let $bottomPanelContainer; + + /** @type {jQueryObject} Chevron toggle in the status bar */ + let $statusBarPanelToggle; + + /** @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,7 +242,6 @@ define(function (require, exports, module) { }); } - /** * Creates a new resizable panel beneath the editor area and above the status bar footer. Panel is initially invisible. * The panel's size & visibility are automatically saved & restored as a view-state preference. @@ -234,21 +249,16 @@ define(function (require, exports, module) { * @param {!string} id Unique id for this panel. Use package-style naming, e.g. "myextension.feature.panelname" * @param {!jQueryObject} $panel DOM content to use as the panel. Need not be in the document yet. Must have an id * attribute, for use as a preferences key. - * @param {number=} minSize Minimum height of panel in px. + * @param {number=} minSize @deprecated No longer used. Pass `undefined`. + * @param {string=} title Display title shown in the bottom panel tab bar. * @return {!Panel} */ - function createBottomPanel(id, $panel, minSize) { - $panel.insertBefore("#status-bar"); + function createBottomPanel(id, $panel, minSize, title) { + $bottomPanelContainer.append($panel); $panel.hide(); - updateResizeLimits(); // initialize panel's max size - - let bottomPanel = new PanelView.Panel($panel, id); + updateResizeLimits(); + let bottomPanel = new PanelView.Panel($panel, id, title); panelIDMap[id] = bottomPanel; - - Resizer.makeResizable($panel[0], Resizer.DIRECTION_VERTICAL, Resizer.POSITION_TOP, minSize, - false, undefined, true); - listenToResize($panel); - return bottomPanel; } @@ -324,6 +334,81 @@ define(function (require, exports, module) { $mainPluginPanel = $("#main-plugin-panel"); $pluginIconsBar = $("#plugin-icons-bar"); + // --- Create the bottom panel tabbed container --- + $bottomPanelContainer = $('
'); + let $bottomPanelTabBar = $('
'); + let $bottomPanelTabsOverflow = $('
'); + let $tabBarActions = $('
'); + $tabBarActions.append( + $('') + .attr('title', Strings.BOTTOM_PANEL_HIDE) + ); + $bottomPanelTabBar.append($bottomPanelTabsOverflow); + $bottomPanelTabBar.append($tabBarActions); + $bottomPanelContainer.append($bottomPanelTabBar); + $bottomPanelContainer.insertBefore("#status-bar"); + $bottomPanelContainer.hide(); + + // Initialize PanelView with container DOM references and tab bar click handlers + PanelView.init($bottomPanelContainer, $bottomPanelTabBar, $bottomPanelTabsOverflow); + + // Create status bar chevron toggle for bottom panel + $statusBarPanelToggle = $('
') + .attr('title', Strings.BOTTOM_PANEL_SHOW); + $("#status-indicators").prepend($statusBarPanelToggle); + + $statusBarPanelToggle.on("click", function () { + _statusBarToggleInProgress = true; + if ($bottomPanelContainer.is(":visible")) { + Resizer.hide($bottomPanelContainer[0]); + triggerUpdateLayout(); + } else if (PanelView.getOpenBottomPanelIDs().length > 0) { + Resizer.show($bottomPanelContainer[0]); + triggerUpdateLayout(); + } else { + _showDefaultPanel(); + } + _statusBarToggleInProgress = false; + }); + + // Make the container resizable (not individual panels) + Resizer.makeResizable($bottomPanelContainer[0], Resizer.DIRECTION_VERTICAL, Resizer.POSITION_TOP, + 200, false, undefined, true); + listenToResize($bottomPanelContainer); + + $bottomPanelContainer.on("panelCollapsed", function () { + $statusBarPanelToggle.find("i") + .removeClass("fa-chevron-down") + .addClass("fa-chevron-up"); + $statusBarPanelToggle.attr("title", Strings.BOTTOM_PANEL_SHOW); + if (!_statusBarToggleInProgress) { + AnimationUtils.animateUsingClass($statusBarPanelToggle[0], "flash", 800); + } + // When the container collapses while tabs are still open, clear the + // shown stack so the Escape-key handler doesn't silently close tabs + // that the user can't even see. + let openIds = PanelView.getOpenBottomPanelIDs(); + for (let i = 0; i < openIds.length; i++) { + lastShownBottomPanelStack = lastShownBottomPanelStack.filter(item => item !== openIds[i]); + } + }); + + $bottomPanelContainer.on("panelExpanded", function () { + $statusBarPanelToggle.find("i") + .removeClass("fa-chevron-up") + .addClass("fa-chevron-down"); + $statusBarPanelToggle.attr("title", Strings.BOTTOM_PANEL_HIDE_TOGGLE); + if (!_statusBarToggleInProgress) { + AnimationUtils.animateUsingClass($statusBarPanelToggle[0], "flash", 800); + } + // When the container re-expands, add the open panels to the shown stack. + let openIds = PanelView.getOpenBottomPanelIDs(); + for (let i = 0; i < openIds.length; i++) { + lastShownBottomPanelStack = lastShownBottomPanelStack.filter(item => item !== openIds[i]); + lastShownBottomPanelStack.push(openIds[i]); + } + }); + // Sidebar is a special case: it isn't a Panel, and is not created dynamically. Need to explicitly // listen for resize here. listenToResize($("#sidebar")); @@ -351,16 +436,15 @@ define(function (require, exports, module) { EventDispatcher.makeEventDispatcher(exports); PanelView.on(PanelView.EVENT_PANEL_SHOWN, (event, panelID)=>{ - lastHiddenBottomPanelStack = lastHiddenBottomPanelStack.filter(item => item !== panelID); lastShownBottomPanelStack = lastShownBottomPanelStack.filter(item => item !== panelID); lastShownBottomPanelStack.push(panelID); exports.trigger(EVENT_WORKSPACE_PANEL_SHOWN, panelID); + triggerUpdateLayout(); }); PanelView.on(PanelView.EVENT_PANEL_HIDDEN, (event, panelID)=>{ - lastHiddenBottomPanelStack = lastHiddenBottomPanelStack.filter(item => item !== panelID); - lastHiddenBottomPanelStack.push(panelID); lastShownBottomPanelStack = lastShownBottomPanelStack.filter(item => item !== panelID); exports.trigger(EVENT_WORKSPACE_PANEL_HIDDEN, panelID); + triggerUpdateLayout(); }); let currentlyShownPanel = null, @@ -492,44 +576,28 @@ define(function (require, exports, module) { return false; } - function _showLastHiddenPanelIfPossible() { - while(lastHiddenBottomPanelStack.length > 0){ - let panelToShow = getPanelForID(lastHiddenBottomPanelStack.pop()); - if(panelToShow.canBeShown()){ - panelToShow.show(); - return true; - } + /** + * Shows the default launcher panel when no other panels are open. + * @private + */ + function _showDefaultPanel() { + let defaultPanel = panelIDMap[DEFAULT_PANEL_ID]; + if (defaultPanel) { + defaultPanel.show(); } - return false; } function _handleEscapeKey() { - let allPanelsIDs = getAllPanelIDs(); - // first we see if there is any least recently shown panel - if(lastShownBottomPanelStack.length > 0){ - let panelToHide = getPanelForID(lastShownBottomPanelStack.pop()); - panelToHide.hide(); + // Collapse the entire bottom panel container, keeping all tabs intact + if ($bottomPanelContainer && $bottomPanelContainer.is(":visible")) { + Resizer.hide($bottomPanelContainer[0]); + triggerUpdateLayout(); return true; } - // if not, see if there is any open panels that are not yet tracked in the least recently used stacks. - for(let panelID of allPanelsIDs){ - let panel = getPanelForID(panelID); - if(panel.getPanelType() === PanelView.PANEL_TYPE_BOTTOM_PANEL && panel.isVisible()){ - panel.hide(); - lastHiddenBottomPanelStack.push(panelID); - return true; - } - } - // no panels hidden, we will toggle the last hidden panel with succeeding escape key presses - return _showLastHiddenPanelIfPossible(); - } - - function _handleShiftEscapeKey() { - // show hidden panels one by one - return _showLastHiddenPanelIfPossible(); + return false; } - // pressing escape when focused on editor will toggle the last opened bottom panel + // pressing escape when focused on editor will hide the bottom panel container function _handleKeydown(event) { if(event.keyCode !== KeyEvent.DOM_VK_ESCAPE || KeyBindingManager.isInOverlayMode()){ return; @@ -553,9 +621,7 @@ define(function (require, exports, module) { return; } - if (event.keyCode === KeyEvent.DOM_VK_ESCAPE && event.shiftKey) { - _handleShiftEscapeKey(); - } else if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { + if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { _handleEscapeKey(); } @@ -572,12 +638,15 @@ define(function (require, exports, module) { exports.recomputeLayout = recomputeLayout; exports.getAllPanelIDs = getAllPanelIDs; exports.getPanelForID = getPanelForID; + exports.getOpenBottomPanelIDs = PanelView.getOpenBottomPanelIDs; + exports.hideAllOpenBottomPanels = PanelView.hideAllOpenPanels; exports.addEscapeKeyEventHandler = addEscapeKeyEventHandler; exports.removeEscapeKeyEventHandler = removeEscapeKeyEventHandler; exports._setMockDOM = _setMockDOM; exports.EVENT_WORKSPACE_UPDATE_LAYOUT = EVENT_WORKSPACE_UPDATE_LAYOUT; exports.EVENT_WORKSPACE_PANEL_SHOWN = EVENT_WORKSPACE_PANEL_SHOWN; exports.EVENT_WORKSPACE_PANEL_HIDDEN = EVENT_WORKSPACE_PANEL_HIDDEN; + exports.DEFAULT_PANEL_ID = DEFAULT_PANEL_ID; /** * Constant representing the type of bottom panel