diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index ec0a3d21c3712..5fa81f3fb3564 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -27,11 +27,11 @@ import { SelectionService } from '../common/selection-service'; import { MessageService } from '../common/message-service'; import { OpenerService, open } from '../browser/opener-service'; import { ApplicationShell } from './shell/application-shell'; -import { SHELL_TABBAR_CONTEXT_CLOSE, SHELL_TABBAR_CONTEXT_COPY, SHELL_TABBAR_CONTEXT_SPLIT } from './shell/tab-bars'; +import { SHELL_TABBAR_CONTEXT_CLOSE, SHELL_TABBAR_CONTEXT_COPY, SHELL_TABBAR_CONTEXT_PIN, SHELL_TABBAR_CONTEXT_SPLIT } from './shell/tab-bars'; import { AboutDialog } from './about-dialog'; import * as browser from './browser'; import URI from '../common/uri'; -import { ContextKeyService } from './context-key-service'; +import { ContextKey, ContextKeyService } from './context-key-service'; import { OS, isOSX, isWindows } from '../common/os'; import { ResourceContextKey } from './resource-context-key'; import { UriSelection } from '../common/selection'; @@ -59,6 +59,7 @@ import { ConfirmDialog, confirmExit, Dialog } from './dialogs'; import { WindowService } from './window/window-service'; import { FrontendApplicationConfigProvider } from './frontend-application-config-provider'; import { DecorationStyle } from './decoration-style'; +import { isPinned, Title, togglePinned, Widget } from './widgets'; export namespace CommonMenus { @@ -241,6 +242,16 @@ export namespace CommonCommands { category: VIEW_CATEGORY, label: 'Toggle Status Bar Visibility' }); + export const PIN_TAB = Command.toDefaultLocalizedCommand({ + id: 'workbench.action.pinEditor', + category: VIEW_CATEGORY, + label: 'Pin Editor' + }); + export const UNPIN_TAB = Command.toDefaultLocalizedCommand({ + id: 'workbench.action.unpinEditor', + category: VIEW_CATEGORY, + label: 'Unpin Editor' + }); export const TOGGLE_MAXIMIZED = Command.toLocalizedCommand({ id: 'core.toggleMaximized', category: VIEW_CATEGORY, @@ -372,6 +383,8 @@ export class CommonFrontendContribution implements FrontendApplicationContributi @inject(WindowService) protected readonly windowService: WindowService; + protected pinnedKey: ContextKey; + async configure(app: FrontendApplication): Promise { const configDirUri = await this.environments.getConfigDirUri(); // Global settings @@ -385,6 +398,10 @@ export class CommonFrontendContribution implements FrontendApplicationContributi this.contextKeyService.createKey('isWindows', OS.type() === OS.Type.Windows); this.contextKeyService.createKey('isWeb', !this.isElectron()); + this.pinnedKey = this.contextKeyService.createKey('activeEditorIsPinned', false); + this.updatePinnedKey(); + this.shell.onDidChangeActiveWidget(() => this.updatePinnedKey()); + this.initResourceContextKeys(); this.registerCtrlWHandling(); @@ -427,6 +444,13 @@ export class CommonFrontendContribution implements FrontendApplicationContributi } } + protected updatePinnedKey(): void { + const activeTab = this.shell.findTabBar(); + const pinningTarget = activeTab && this.shell.findTitle(activeTab); + const value = pinningTarget && isPinned(pinningTarget); + this.pinnedKey.set(value); + } + protected updateThemePreference(preferenceName: 'workbench.colorTheme' | 'workbench.iconTheme'): void { const inspect = this.preferenceService.inspect(preferenceName); const workspaceValue = inspect && inspect.workspaceValue; @@ -636,6 +660,16 @@ export class CommonFrontendContribution implements FrontendApplicationContributi label: nls.localizeByDefault('Toggle Menu Bar'), order: '0' }); + registry.registerMenuAction(SHELL_TABBAR_CONTEXT_PIN, { + commandId: CommonCommands.PIN_TAB.id, + label: nls.localizeByDefault('Pin'), + order: '7' + }); + registry.registerMenuAction(SHELL_TABBAR_CONTEXT_PIN, { + commandId: CommonCommands.UNPIN_TAB.id, + label: nls.localizeByDefault('Unpin'), + order: '8' + }); registry.registerMenuAction(CommonMenus.HELP, { commandId: CommonCommands.ABOUT_COMMAND.id, label: CommonCommands.ABOUT_COMMAND.label, @@ -877,16 +911,30 @@ export class CommonFrontendContribution implements FrontendApplicationContributi commandRegistry.registerCommand(CommonCommands.SELECT_ICON_THEME, { execute: () => this.selectIconTheme() }); - + commandRegistry.registerCommand(CommonCommands.PIN_TAB, new CurrentWidgetCommandAdapter(this.shell, { + isEnabled: title => Boolean(title && !isPinned(title)), + execute: title => this.togglePinned(title), + })); + commandRegistry.registerCommand(CommonCommands.UNPIN_TAB, new CurrentWidgetCommandAdapter(this.shell, { + isEnabled: title => Boolean(title && isPinned(title)), + execute: title => this.togglePinned(title), + })); commandRegistry.registerCommand(CommonCommands.CONFIGURE_DISPLAY_LANGUAGE, { execute: () => this.configureDisplayLanguage() }); } - private isElectron(): boolean { + protected isElectron(): boolean { return environment.electron.is(); } + protected togglePinned(title?: Title): void { + if (title) { + togglePinned(title); + this.updatePinnedKey(); + } + } + registerKeybindings(registry: KeybindingRegistry): void { if (supportCut) { registry.registerKeybinding({ @@ -996,6 +1044,16 @@ export class CommonFrontendContribution implements FrontendApplicationContributi { command: CommonCommands.SELECT_COLOR_THEME.id, keybinding: 'ctrlcmd+k ctrlcmd+t' + }, + { + command: CommonCommands.PIN_TAB.id, + keybinding: 'ctrlcmd+k shift+enter', + when: '!activeEditorIsPinned' + }, + { + command: CommonCommands.UNPIN_TAB.id, + keybinding: 'ctrlcmd+k shift+enter', + when: 'activeEditorIsPinned' } ); } diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 7ed7f96311dcf..f85995c0bf542 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -171,7 +171,8 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo const tabBarDecoratorService = container.get(TabBarDecoratorService); const iconThemeService = container.get(IconThemeService); const selectionService = container.get(SelectionService); - return new TabBarRenderer(contextMenuRenderer, tabBarDecoratorService, iconThemeService, selectionService); + const commandService = container.get(CommandService); + return new TabBarRenderer(contextMenuRenderer, tabBarDecoratorService, iconThemeService, selectionService, commandService); }); bindContributionProvider(bind, TabBarDecorator); diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 1837026d63b6f..92df24bf73bf7 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -596,8 +596,10 @@ export class ApplicationShell extends Widget { return { version: applicationShellLayoutVersion, mainPanel: this.mainPanel.saveLayout(), + mainPanelPinned: this.getPinnedMainWidgets(), bottomPanel: { config: this.bottomPanel.saveLayout(), + pinned: this.getPinnedBottomWidgets(), size: this.bottomPanel.isVisible ? this.getBottomPanelSize() : this.bottomPanelState.lastPanelSize, expanded: this.isExpanded('bottom') }, @@ -607,6 +609,28 @@ export class ApplicationShell extends Widget { }; } + // Get an array corresponding to main panel widgets' pinned state. + getPinnedMainWidgets(): boolean[] { + const pinned: boolean[] = []; + + toArray(this.mainPanel.widgets()).forEach((a, i) => { + pinned[i] = a.title.className.indexOf('theia-mod-pinned') >= 0; + }); + + return pinned; + } + + // Get an array corresponding to bottom panel widgets' pinned state. + getPinnedBottomWidgets(): boolean[] { + const pinned: boolean[] = []; + + toArray(this.bottomPanel.widgets()).forEach((a, i) => { + pinned[i] = a.title.className.indexOf('theia-mod-pinned') >= 0; + }); + + return pinned; + } + /** * Compute the current height of the bottom panel. This implementation assumes that the container * of the bottom panel is a `SplitPanel`. @@ -639,7 +663,7 @@ export class ApplicationShell extends Widget { * Apply a shell layout that has been previously created with `getLayoutData`. */ async setLayoutData(layoutData: ApplicationShell.LayoutData): Promise { - const { mainPanel, bottomPanel, leftPanel, rightPanel, activeWidgetId } = layoutData; + const { mainPanel, mainPanelPinned, bottomPanel, leftPanel, rightPanel, activeWidgetId } = layoutData; if (leftPanel) { this.leftPanelHandler.setLayoutData(leftPanel); this.registerWithFocusTracker(leftPanel); @@ -663,6 +687,15 @@ export class ApplicationShell extends Widget { } else { this.collapseBottomPanel(); } + const widgets = toArray(this.bottomPanel.widgets()); + if (bottomPanel.pinned && bottomPanel.pinned.length === widgets.length) { + widgets.forEach((a, i) => { + if (bottomPanel.pinned![i]) { + a.title.className += ' theia-mod-pinned'; + a.title.closable = false; + } + }); + } this.refreshBottomPanelToggleButton(); } // Proceed with the main panel once all others are set up @@ -670,6 +703,15 @@ export class ApplicationShell extends Widget { if (mainPanel) { this.mainPanel.restoreLayout(mainPanel); this.registerWithFocusTracker(mainPanel.main); + const widgets = toArray(this.mainPanel.widgets()); + if (mainPanelPinned && mainPanelPinned.length === widgets.length) { + widgets.forEach((a, i) => { + if (mainPanelPinned[i]) { + a.title.className += ' theia-mod-pinned'; + a.title.closable = false; + } + }); + } } if (activeWidgetId) { this.activateWidget(activeWidgetId); @@ -1954,6 +1996,7 @@ export namespace ApplicationShell { export interface LayoutData { version?: string | ApplicationShellLayoutVersion, mainPanel?: DockPanel.ILayoutConfig; + mainPanelPinned?: boolean[]; bottomPanel?: BottomPanelLayoutData; leftPanel?: SidePanel.LayoutData; rightPanel?: SidePanel.LayoutData; @@ -1967,6 +2010,7 @@ export namespace ApplicationShell { config?: DockPanel.ILayoutConfig; size?: number; expanded?: boolean; + pinned?: boolean[]; } /** diff --git a/packages/core/src/browser/shell/side-panel-handler.ts b/packages/core/src/browser/shell/side-panel-handler.ts index 00b9658b23d0c..828dc805d723b 100644 --- a/packages/core/src/browser/shell/side-panel-handler.ts +++ b/packages/core/src/browser/shell/side-panel-handler.ts @@ -272,7 +272,8 @@ export class SidePanelHandler { const items = toArray(map(this.tabBar.titles, title => { widget: title.owner, rank: SidePanelHandler.rankProperty.get(title.owner), - expanded: title === currentTitle + expanded: title === currentTitle, + pinned: title.className.indexOf('theia-mod-pinned') >= 0 })); // eslint-disable-next-line no-null/no-null const size = currentTitle !== null ? this.getPanelSize() : this.state.lastPanelSize; @@ -288,7 +289,7 @@ export class SidePanelHandler { let currentTitle: Title | undefined; if (layoutData.items) { - for (const { widget, rank, expanded } of layoutData.items) { + for (const { widget, rank, expanded, pinned } of layoutData.items) { if (widget) { if (rank) { SidePanelHandler.rankProperty.set(widget, rank); @@ -296,6 +297,10 @@ export class SidePanelHandler { if (expanded) { currentTitle = widget.title; } + if (pinned) { + widget.title.className += ' theia-mod-pinned'; + widget.title.closable = false; + } // Add the widgets directly to the tab bar in the same order as they are stored this.tabBar.addTab(widget.title); } @@ -725,6 +730,7 @@ export namespace SidePanel { /** Can be undefined in case the widget could not be restored. */ widget?: Widget; expanded?: boolean; + pinned?: boolean; } export interface State { diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index fa52d55b413ed..bd071de4b3a90 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -17,7 +17,7 @@ import PerfectScrollbar from 'perfect-scrollbar'; import { TabBar, Title, Widget } from '@phosphor/widgets'; import { VirtualElement, h, VirtualDOM, ElementInlineStyle } from '@phosphor/virtualdom'; -import { Disposable, DisposableCollection, MenuPath, notEmpty, SelectionService } from '../../common'; +import { Disposable, DisposableCollection, MenuPath, notEmpty, SelectionService, CommandService } from '../../common'; import { ContextMenuRenderer } from '../context-menu-renderer'; import { Signal, Slot } from '@phosphor/signaling'; import { Message, MessageLoop } from '@phosphor/messaging'; @@ -31,6 +31,7 @@ import { IconThemeService } from '../icon-theme-service'; import { BreadcrumbsRenderer, BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; import { NavigatableWidget } from '../navigatable-types'; import { IDragEvent } from '@phosphor/dragdrop'; +import { PINNED_CLASS } from '../widgets/widget'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -87,6 +88,7 @@ export class TabBarRenderer extends TabBar.Renderer { protected readonly decoratorService?: TabBarDecoratorService, protected readonly iconThemeService?: IconThemeService, protected readonly selectionService?: SelectionService, + protected readonly commandService?: CommandService ) { super(); if (this.decoratorService) { @@ -162,7 +164,10 @@ export class TabBarRenderer extends TabBar.Renderer { this.renderLabel(data, isInSidePanel), this.renderBadge(data, isInSidePanel) ), - this.renderCloseIcon(data) + h.div({ + className: 'p-TabBar-tabCloseIcon action-item', + onclick: this.handleCloseClickEvent + }) ); } @@ -467,6 +472,16 @@ export class TabBarRenderer extends TabBar.Renderer { } }; + protected handleCloseClickEvent = (event: MouseEvent) => { + if (this.tabBar && event.currentTarget instanceof HTMLElement) { + const id = event.currentTarget.parentElement!.id; + const title = this.tabBar.titles.find(t => this.createTabId(t) === id); + if (title?.closable === false && title?.className.includes(PINNED_CLASS) && this.commandService) { + this.commandService.executeCommand('workbench.action.unpinEditor', event); + } + } + }; + protected handleDblClickEvent = (event: MouseEvent) => { if (this.tabBar && event.currentTarget instanceof HTMLElement) { const id = event.currentTarget.id; diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 82c04b843eae5..90a0864716f2b 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -235,7 +235,8 @@ body.theia-editor-highlightModifiedTabs height: inherit; } -.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable > .p-TabBar-tabCloseIcon { +.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable > .p-TabBar-tabCloseIcon, +.p-TabBar.theia-app-centers .p-TabBar-tab.theia-mod-pinned > .p-TabBar-tabCloseIcon { padding: 2px; margin-top: 2px; margin-left: 4px; @@ -258,7 +259,8 @@ body.theia-editor-highlightModifiedTabs background-color: rgba(50%, 50%, 50%, 0.2); } -.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable { +.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable, +.p-TabBar.theia-app-centers .p-TabBar-tab.theia-mod-pinned { padding-right: 4px; } @@ -272,6 +274,14 @@ body.theia-editor-highlightModifiedTabs content: "\ea71"; } +.p-TabBar.theia-app-centers .p-TabBar-tab.theia-mod-pinned > .p-TabBar-tabCloseIcon:before { + content: "\eba0"; +} + +.p-TabBar.theia-app-centers .p-TabBar-tab.theia-mod-pinned.theia-mod-dirty > .p-TabBar-tabCloseIcon:before { + content: "\ebb2"; +} + .p-TabBar-tabIcon.no-icon { display: none !important; } diff --git a/packages/core/src/browser/view-container.ts b/packages/core/src/browser/view-container.ts index 573df31413340..fdd3a7b95b522 100644 --- a/packages/core/src/browser/view-container.ts +++ b/packages/core/src/browser/view-container.ts @@ -18,7 +18,7 @@ import { interfaces, injectable, inject, postConstruct } from 'inversify'; import { IIterator, toArray, find, some, every, map, ArrayExt } from '@phosphor/algorithm'; import { Widget, EXPANSION_TOGGLE_CLASS, COLLAPSED_CLASS, CODICON_TREE_ITEM_CLASSES, MessageLoop, Message, SplitPanel, - BaseWidget, addEventListener, SplitLayout, LayoutItem, PanelLayout, addKeyListener, waitForRevealed, UnsafeWidgetUtilities, DockPanel + BaseWidget, addEventListener, SplitLayout, LayoutItem, PanelLayout, addKeyListener, waitForRevealed, UnsafeWidgetUtilities, DockPanel, PINNED_CLASS } from './widgets'; import { Event as CommonEvent, Emitter } from '../common/event'; import { Disposable, DisposableCollection } from '../common/disposable'; @@ -276,7 +276,9 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica if (title.iconClass) { this.title.iconClass = title.iconClass; } - if (title.closeable !== undefined) { + if (this.title.className.includes(PINNED_CLASS)) { + this.title.closable &&= false; + } else if (title.closeable !== undefined) { this.title.closable = title.closeable; } } diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts index d15a52b538ac1..cf32fd7089b7b 100644 --- a/packages/core/src/browser/widgets/widget.ts +++ b/packages/core/src/browser/widgets/widget.ts @@ -17,7 +17,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { injectable, decorate, unmanaged } from 'inversify'; -import { Widget } from '@phosphor/widgets'; +import { Title, Widget } from '@phosphor/widgets'; import { Message, MessageLoop } from '@phosphor/messaging'; import { Emitter, Event, Disposable, DisposableCollection, MaybePromise } from '../../common'; import { KeyCode, KeysOrKeyCodes } from '../keyboard/keys'; @@ -52,6 +52,7 @@ export const BUSY_CLASS = 'theia-mod-busy'; export const CODICON_LOADING_CLASSES = codiconArray('loading'); export const SELECTED_CLASS = 'theia-mod-selected'; export const FOCUS_CLASS = 'theia-mod-focus'; +export const PINNED_CLASS = 'theia-mod-pinned'; export const DEFAULT_SCROLL_OPTIONS: PerfectScrollbar.Options = { suppressScrollX: true, minScrollbarLength: 35, @@ -353,3 +354,30 @@ function waitForVisible(widget: Widget, visible: boolean, attached?: boolean): P waitFor(); }); } + +export function isPinned(title: Title): boolean { + const pinnedState = !title.closable && title.className.includes(PINNED_CLASS); + return pinnedState; +} + +export function unpin(title: Title): void { + title.closable = true; + title.className = title.className.replace(PINNED_CLASS, '').trim(); +} + +export function pin(title: Title): void { + title.closable = false; + if (!title.className.includes(PINNED_CLASS)) { + title.className += ` ${PINNED_CLASS}`; + } +} + +export function togglePinned(title?: Title): void { + if (title) { + if (isPinned(title)) { + unpin(title); + } else { + pin(title); + } + } +} diff --git a/packages/editor-preview/src/browser/editor-preview-widget.ts b/packages/editor-preview/src/browser/editor-preview-widget.ts index 74e111b0d8c93..b35f7643a4d9e 100644 --- a/packages/editor-preview/src/browser/editor-preview-widget.ts +++ b/packages/editor-preview/src/browser/editor-preview-widget.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { Message } from '@theia/core/shared/@phosphor/messaging'; -import { DockPanel, TabBar, Widget } from '@theia/core/lib/browser'; +import { DockPanel, TabBar, Widget, PINNED_CLASS } from '@theia/core/lib/browser'; import { EditorWidget, TextEditor } from '@theia/editor/lib/browser'; import { Disposable, DisposableCollection, Emitter, SelectionService } from '@theia/core/lib/common'; import { find } from '@theia/core/shared/@phosphor/algorithm'; @@ -44,13 +44,23 @@ export class EditorPreviewWidget extends EditorWidget { } initializePreview(): void { + const oneTimeListeners = new DisposableCollection(); this._isPreview = true; this.title.className += ` ${PREVIEW_TITLE_CLASS}`; const oneTimeDirtyChangeListener = this.saveable.onDirtyChanged(() => { this.convertToNonPreview(); - oneTimeDirtyChangeListener.dispose(); + oneTimeListeners.dispose(); }); - this.toDispose.push(oneTimeDirtyChangeListener); + oneTimeListeners.push(oneTimeDirtyChangeListener); + const oneTimeTitleChangeHandler = () => { + if (this.title.className.indexOf(PINNED_CLASS) >= 0) { + this.convertToNonPreview(); + oneTimeListeners.dispose(); + } + }; + this.title.changed.connect(oneTimeTitleChangeHandler); + oneTimeListeners.push(Disposable.create(() => this.title.changed.disconnect(oneTimeTitleChangeHandler))); + this.toDispose.push(oneTimeListeners); } convertToNonPreview(): void {