Przeglądaj źródła

allow dragging other tabs into existing split tabs - fixes #1347

Eugene Pankov 4 lat temu
rodzic
commit
be4cc804a2

+ 1 - 0
app/package.json

@@ -14,6 +14,7 @@
     "watch": "webpack --progress --color --watch"
   },
   "dependencies": {
+    "@angular/cdk": "^12.1.2",
     "@electron/remote": "1.2.0",
     "any-promise": "^1.3.0",
     "electron-config": "2.0.0",

+ 19 - 0
app/yarn.lock

@@ -2,6 +2,15 @@
 # yarn lockfile v1
 
 
+"@angular/cdk@^12.1.2":
+  version "12.1.2"
+  resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-12.1.2.tgz#5c2407324d860737374d873bd4381bf7f90f8a61"
+  integrity sha512-ALupZejZDsVYcbNZcEH1cV8SDgVBL40FAwDnlSZxCgd0HOBHH0ZqQV+8z0uCQeMatoNM+SwmJ8Y1JXYh9Bqfiw==
+  dependencies:
+    tslib "^2.2.0"
+  optionalDependencies:
+    parse5 "^5.0.0"
+
 "@electron/[email protected]":
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@electron/remote/-/remote-1.2.0.tgz#772eb4c3ac17aaba5a9cf05a09092f6277f5671f"
@@ -2558,6 +2567,11 @@ parse-json@^2.2.0:
   dependencies:
     error-ex "^1.2.0"
 
+parse5@^5.0.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
+  integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
+
 path-exists@^3.0.0:
   version "3.0.0"
   resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz"
@@ -3399,6 +3413,11 @@ tslib@^2.0.0:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
   integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
 
+tslib@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
+  integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
+
 tslib@~2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"

+ 10 - 7
tabby-core/src/components/appRoot.component.pug

@@ -14,15 +14,17 @@ title-bar(
         && config.store.appearance.frame == "thin" \
         && (config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left")')
         .tabs(
-            dnd-sortable-container,
-            [sortableData]='app.tabs',
+            cdkDropList,
+            [cdkDropListOrientation]='(config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "bottom") ? "horizontal" : "vertical"',
+            (cdkDropListDropped)='onTabsReordered($event)',
+            cdkAutoDropGroup='app-tabs'
         )
             tab-header(
                 *ngFor='let tab of app.tabs; let idx = index',
-                dnd-sortable,
-                [sortableIndex]='idx',
-                (onDragStart)='onTabDragStart()',
-                (onDragEnd)='onTabDragEnd()',
+                cdkDrag,
+                [cdkDragData]='tab',
+                (cdkDragStarted)='onTabDragStart()',
+                (cdkDragEnded)='onTabDragEnd()',
                 [index]='idx',
                 [tab]='tab',
                 [active]='tab == app.activeTab',
@@ -30,7 +32,7 @@ title-bar(
                 [@.disabled]='hasVerticalTabs()',
                 (click)='app.selectTab(tab)',
                 [class.fully-draggable]='hostApp.platform != Platform.macOS',
-                [class.drag-region]='hostApp.platform == Platform.macOS && !tabsDragging',
+                [class.drag-region]='hostApp.platform == Platform.macOS && !(app.tabDragActive$|async)',
             )
 
         .btn-group.background
@@ -109,6 +111,7 @@ title-bar(
         start-page.content-tab.content-tab-active(*ngIf='ready && app.tabs.length == 0')
 
         tab-body.content-tab(
+            #tabBodies,
             *ngFor='let tab of unsortedTabs',
             [class.content-tab-active]='tab == app.activeTab',
             [active]='tab == app.activeTab',

+ 8 - 0
tabby-core/src/components/appRoot.component.scss

@@ -132,6 +132,14 @@ $side-tab-width: 200px;
     window-controls {
         padding-left: 10px;
     }
+
+    .cdk-drag-animating {
+        transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+    }
+
+    .cdk-drop-list-dragging tab-header:not(.cdk-drag-placeholder) {
+        transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+    }
 }
 
 .content {

+ 19 - 5
tabby-core/src/components/appRoot.component.ts

@@ -1,7 +1,8 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Component, Inject, Input, HostListener, HostBinding } from '@angular/core'
+import { Component, Inject, Input, HostListener, HostBinding, ViewChildren } from '@angular/core'
 import { trigger, style, animate, transition, state } from '@angular/animations'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
 
 import { HostAppService, Platform } from '../api/hostApp'
 import { HotkeysService } from '../services/hotkeys.service'
@@ -12,6 +13,7 @@ import { UpdaterService } from '../services/updater.service'
 
 import { BaseTabComponent } from './baseTab.component'
 import { SafeModeModalComponent } from './safeModeModal.component'
+import { TabBodyComponent } from './tabBody.component'
 import { AppService, FileTransfer, HostWindowService, PlatformService, ToolbarButton, ToolbarButtonProvider } from '../api'
 
 /** @hidden */
@@ -57,7 +59,7 @@ export class AppRootComponent {
     @HostBinding('class.platform-darwin') platformClassMacOS = process.platform === 'darwin'
     @HostBinding('class.platform-linux') platformClassLinux = process.platform === 'linux'
     @HostBinding('class.no-tabs') noTabs = true
-    tabsDragging = false
+    @ViewChildren(TabBodyComponent) tabBodies: TabBodyComponent[]
     unsortedTabs: BaseTabComponent[] = []
     updatesAvailable = false
     activeTransfers: FileTransfer[] = []
@@ -126,11 +128,18 @@ export class AppRootComponent {
         this.app.tabOpened$.subscribe(tab => {
             this.unsortedTabs.push(tab)
             this.noTabs = false
+            this.app.emitTabDragEnded()
         })
 
-        this.app.tabClosed$.subscribe(tab => {
+        this.app.tabRemoved$.subscribe(tab => {
+            for (const tabBody of this.tabBodies) {
+                if (tabBody.tab === tab) {
+                    tabBody.detach()
+                }
+            }
             this.unsortedTabs = this.unsortedTabs.filter(x => x !== tab)
             this.noTabs = app.tabs.length === 0
+            this.app.emitTabDragEnded()
         })
 
         platform.fileTransferStarted$.subscribe(transfer => {
@@ -174,12 +183,12 @@ export class AppRootComponent {
     }
 
     onTabDragStart () {
-        this.tabsDragging = true
+        this.app.emitTabDragStarted()
     }
 
     onTabDragEnd () {
         setTimeout(() => {
-            this.tabsDragging = false
+            this.app.emitTabDragEnded()
             this.app.emitTabsChanged()
         })
     }
@@ -194,6 +203,11 @@ export class AppRootComponent {
         return submenuItems.some(x => !!x.icon)
     }
 
+    onTabsReordered (event: CdkDragDrop<BaseTabComponent[]>) {
+        moveItemInArray(this.app.tabs, event.previousIndex, event.currentIndex)
+        this.app.emitTabsChanged()
+    }
+
     private getToolbarButtons (aboveZero: boolean): ToolbarButton[] {
         let buttons: ToolbarButton[] = []
         this.config.enabledServices(this.toolbarButtonProviders).forEach(provider => {

+ 27 - 1
tabby-core/src/components/baseTab.component.ts

@@ -1,5 +1,5 @@
 import { Observable, Subject } from 'rxjs'
-import { ViewRef } from '@angular/core'
+import { EmbeddedViewRef, ViewContainerRef, ViewRef } from '@angular/core'
 import { RecoveryToken } from '../api/tabRecovery'
 import { BaseComponent } from './base.component'
 
@@ -52,6 +52,10 @@ export abstract class BaseTabComponent extends BaseComponent {
      * your tab state to be saved sooner
      */
     protected recoveryStateChangedHint = new Subject<void>()
+    protected viewContainer?: ViewContainerRef
+
+    /* @hidden */
+    viewContainerEmbeddedRef?: EmbeddedViewRef<any>
 
     private progressClearTimeout: number
     private titleChange = new Subject<string>()
@@ -61,6 +65,8 @@ export abstract class BaseTabComponent extends BaseComponent {
     private activity = new Subject<boolean>()
     private destroyed = new Subject<void>()
 
+    private _destroyCalled = false
+
     get focused$ (): Observable<void> { return this.focused }
     get blurred$ (): Observable<void> { return this.blurred }
     get titleChange$ (): Observable<string> { return this.titleChange }
@@ -152,10 +158,29 @@ export abstract class BaseTabComponent extends BaseComponent {
         this.blurred.next()
     }
 
+    insertIntoContainer (container: ViewContainerRef): EmbeddedViewRef<any> {
+        this.viewContainerEmbeddedRef = container.insert(this.hostView) as EmbeddedViewRef<any>
+        this.viewContainer = container
+        return this.viewContainerEmbeddedRef
+    }
+
+    removeFromContainer (): void {
+        if (!this.viewContainer || !this.viewContainerEmbeddedRef) {
+            return
+        }
+        this.viewContainer.detach(this.viewContainer.indexOf(this.viewContainerEmbeddedRef))
+        this.viewContainerEmbeddedRef = undefined
+        this.viewContainer = undefined
+    }
+
     /**
      * Called before the tab is closed
      */
     destroy (skipDestroyedEvent = false): void {
+        if (this._destroyCalled) {
+            return
+        }
+        this._destroyCalled = true
         this.focused.complete()
         this.blurred.complete()
         this.titleChange.complete()
@@ -166,6 +191,7 @@ export abstract class BaseTabComponent extends BaseComponent {
             this.destroyed.next()
         }
         this.destroyed.complete()
+        this.hostView.destroy()
     }
 
     /** @hidden */

+ 18 - 0
tabby-core/src/components/selfPositioning.component.ts

@@ -0,0 +1,18 @@
+import { HostBinding, ElementRef } from '@angular/core'
+import { BaseComponent } from './base.component'
+
+export abstract class SelfPositioningComponent extends BaseComponent {
+    @HostBinding('style.left') cssLeft: string
+    @HostBinding('style.top') cssTop: string
+    @HostBinding('style.width') cssWidth: string | null
+    @HostBinding('style.height') cssHeight: string | null
+
+    constructor (protected element: ElementRef) { super() }
+
+    protected setDimensions (x: number, y: number, w: number, h: number, unit = '%'): void {
+        this.cssLeft = `${x}${unit}`
+        this.cssTop = `${y}${unit}`
+        this.cssWidth = w ? `${w}${unit}` : null
+        this.cssHeight = h ? `${h}${unit}` : null
+    }
+}

+ 72 - 17
tabby-core/src/components/splitTab.component.ts

@@ -123,6 +123,14 @@ export interface SplitSpannerInfo {
     index: number
 }
 
+/**
+ * Represents a tab drop zone
+ */
+export interface SplitDropZoneInfo {
+    relativeToTab: BaseTabComponent
+    side: SplitDirection
+}
+
 /**
  * Split tab is a tab that contains other tabs and allows further splitting them
  * You'll mainly encounter it inside [[AppService]].tabs
@@ -137,6 +145,12 @@ export interface SplitSpannerInfo {
             [index]='spanner.index'
             (change)='onSpannerAdjusted(spanner)'
         ></split-tab-spanner>
+        <split-tab-drop-zone
+            *ngFor='let dropZone of _dropZones'
+            [dropZone]='dropZone'
+            (tabDropped)='onTabDropped($event, dropZone)'
+        >
+        </split-tab-drop-zone>
     `,
     styles: [require('./splitTab.component.scss')],
 })
@@ -157,6 +171,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
     /** @hidden */
     _spanners: SplitSpannerInfo[] = []
 
+    /** @hidden */
+    _dropZones: SplitDropZoneInfo[] = []
+
     /** @hidden */
     _allFocusMode = false
 
@@ -166,12 +183,19 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
     private viewRefs: Map<BaseTabComponent, EmbeddedViewRef<any>> = new Map()
 
     private tabAdded = new Subject<BaseTabComponent>()
+    private tabAdopted = new Subject<BaseTabComponent>()
     private tabRemoved = new Subject<BaseTabComponent>()
     private splitAdjusted = new Subject<SplitSpannerInfo>()
     private focusChanged = new Subject<BaseTabComponent>()
     private initialized = new Subject<void>()
 
     get tabAdded$ (): Observable<BaseTabComponent> { return this.tabAdded }
+
+    /**
+     * Fired when an existing top-level tab is dragged into this tab
+     */
+    get tabAdopted$ (): Observable<BaseTabComponent> { return this.tabAdopted }
+
     get tabRemoved$ (): Observable<BaseTabComponent> { return this.tabRemoved }
 
     /**
@@ -330,11 +354,27 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
         }
     }
 
+    addTab (tab: BaseTabComponent, relative: BaseTabComponent|null, side: SplitDirection): Promise<void> {
+        return this.add(tab, relative, side)
+    }
+
     /**
      * Inserts a new `tab` to the `side` of the `relative` tab
      */
-    async addTab (tab: BaseTabComponent, relative: BaseTabComponent|null, side: SplitDirection): Promise<void> {
-        tab.parent = this
+    async add (thing: BaseTabComponent|SplitContainer, relative: BaseTabComponent|null, side: SplitDirection): Promise<void> {
+        if (thing instanceof SplitTabComponent) {
+            const tab = thing
+            thing = tab.root
+            tab.root = new SplitContainer()
+            for (const child of thing.getAllTabs()) {
+                child.removeFromContainer()
+            }
+            tab.destroy()
+        }
+
+        if (thing instanceof BaseTabComponent) {
+            thing.parent = this
+        }
 
         let target = (relative ? this.getParentOf(relative) : null) ?? this.root
         let insertIndex = relative ? target.children.indexOf(relative) : -1
@@ -362,14 +402,16 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
             target.ratios[i] *= target.children.length / (target.children.length + 1)
         }
         target.ratios.splice(insertIndex, 0, 1 / (target.children.length + 1))
-        target.children.splice(insertIndex, 0, tab)
+        target.children.splice(insertIndex, 0, thing)
 
         this.recoveryStateChangedHint.next()
 
         await this.initialized$.toPromise()
 
-        this.attachTabView(tab)
-        this.onAfterTabAdded(tab)
+        for (const tab of thing instanceof SplitContainer ? thing.getAllTabs() : [thing]) {
+            this.attachTabView(tab)
+            this.onAfterTabAdded(tab)
+        }
     }
 
     removeTab (tab: BaseTabComponent): void {
@@ -381,8 +423,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
         parent.ratios.splice(index, 1)
         parent.children.splice(index, 1)
 
-        this.detachTabView(tab)
+        tab.removeFromContainer()
         tab.parent = null
+        this.viewRefs.delete(tab)
 
         this.layout()
 
@@ -401,7 +444,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
         }
         const position = parent.children.indexOf(tab)
         parent.children[position] = newTab
-        this.detachTabView(tab)
+        tab.removeFromContainer()
         this.attachTabView(newTab)
         tab.parent = null
         newTab.parent = this
@@ -508,6 +551,16 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
         this.splitAdjusted.next(spanner)
     }
 
+    /** @hidden */
+    onTabDropped (tab: BaseTabComponent, zone: SplitDropZoneInfo) { // eslint-disable-line @typescript-eslint/explicit-module-boundary-types
+        if (tab === this) {
+            return
+        }
+
+        this.add(tab, zone.relativeToTab, zone.side)
+        this.tabAdopted.next(tab)
+    }
+
     destroy (): void {
         super.destroy()
         for (const x of this.getAllTabs()) {
@@ -518,13 +571,13 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
     layout (): void {
         this.root.normalize()
         this._spanners = []
+        this._dropZones = []
         this.layoutInternal(this.root, 0, 0, 100, 100)
     }
 
     private attachTabView (tab: BaseTabComponent) {
-        const ref = this.viewContainer.insert(tab.hostView) as EmbeddedViewRef<any> // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
+        const ref = tab.insertIntoContainer(this.viewContainer)
         this.viewRefs.set(tab, ref)
-
         tab.addEventListenerUntilDestroyed(ref.rootNodes[0], 'click', () => this.focus(tab))
 
         tab.subscribeUntilDestroyed(tab.titleChange$, t => this.setTitle(t))
@@ -541,14 +594,6 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
         })
     }
 
-    private detachTabView (tab: BaseTabComponent) {
-        const ref = this.viewRefs.get(tab)
-        if (ref) {
-            this.viewRefs.delete(tab)
-            this.viewContainer.remove(this.viewContainer.indexOf(ref))
-        }
-    }
-
     private onAfterTabAdded (tab: BaseTabComponent) {
         setImmediate(() => {
             this.layout()
@@ -593,6 +638,13 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
                         element.style.width = '90%'
                         element.style.height = '90%'
                     }
+
+                    for (const side of ['t', 'r', 'b', 'l']) {
+                        this._dropZones.push({
+                            relativeToTab: child,
+                            side: side as SplitDirection,
+                        })
+                    }
                 }
             }
             offset += sizes[i]
@@ -612,6 +664,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
         root.ratios = state.ratios
         root.children = children
         for (const childState of state.children) {
+            if (!childState) {
+                continue
+            }
             if (childState.type === 'app:split-tab') {
                 const child = new SplitContainer()
                 await this.recoverContainer(child, childState, duplicate)

+ 37 - 0
tabby-core/src/components/splitTabDropZone.component.scss

@@ -0,0 +1,37 @@
+:host {
+    position: absolute;
+    display: flex;
+    z-index: 5;
+    padding: 15px;
+    transition: all 125ms cubic-bezier(0, 0, 0.2, 1);
+
+    > div {
+        flex: 1 1 0;
+        width: 100%;
+        height: 100%;
+
+        background: rgba(255, 255, 255, .125);
+        border-radius: 5px;
+        border: 1px solid rgba(255, 255, 255, .25);
+        transition: all 125ms cubic-bezier(0, 0, 0.2, 1);
+    }
+
+    &.highlighted {
+        padding: 0px;
+        border-radius: 3px;
+
+        > div {
+            background: rgba(255, 255, 255, .5);
+        }
+    }
+
+    &:not(.active) {
+        pointer-events: none;
+        opacity: 0;
+    }
+
+    ::ng-deep tab-header {
+        // placeholders
+        opacity: 0;
+    }
+}

+ 63 - 0
tabby-core/src/components/splitTabDropZone.component.ts

@@ -0,0 +1,63 @@
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { Component, Input, HostBinding, ElementRef, Output, EventEmitter } from '@angular/core'
+import { AppService } from '../services/app.service'
+import { BaseTabComponent } from './baseTab.component'
+import { SelfPositioningComponent } from './selfPositioning.component'
+import { SplitDropZoneInfo } from './splitTab.component'
+
+/** @hidden */
+@Component({
+    selector: 'split-tab-drop-zone',
+    template: `
+    <div
+        cdkDropList
+        (cdkDropListDropped)="tabDropped.emit($event.item.data); isHighlighted = false"
+        (cdkDropListEntered)="isHighlighted = true"
+        (cdkDropListExited)="isHighlighted = false"
+        cdkAutoDropGroup='app-tabs'
+    >
+    </div>
+    `,
+    styles: [require('./splitTabDropZone.component.scss')],
+})
+export class SplitTabDropZoneComponent extends SelfPositioningComponent {
+    @Input() dropZone: SplitDropZoneInfo
+    @Output() tabDropped = new EventEmitter<BaseTabComponent>()
+    @HostBinding('class.active') isActive = false
+    @HostBinding('class.highlighted') isHighlighted = false
+
+    // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+    constructor (
+        element: ElementRef,
+        app: AppService,
+    ) {
+        super(element)
+        this.subscribeUntilDestroyed(app.tabDragActive$, active => {
+            this.isActive = active
+            this.layout()
+        })
+    }
+
+    ngOnChanges () {
+        this.layout()
+    }
+
+    layout () {
+        const tabElement: HTMLElement = this.dropZone.relativeToTab.viewContainerEmbeddedRef?.rootNodes[0]
+
+        const args = {
+            t: [0, 0, tabElement.clientWidth, tabElement.clientHeight / 5],
+            l: [0, tabElement.clientHeight / 5, tabElement.clientWidth / 3, tabElement.clientHeight * 3 / 5],
+            r: [tabElement.clientWidth * 2 / 3, tabElement.clientHeight / 5, tabElement.clientWidth / 3, tabElement.clientHeight * 3 / 5],
+            b: [0, tabElement.clientHeight * 4 / 5, tabElement.clientWidth, tabElement.clientHeight / 5],
+        }[this.dropZone.side]
+
+        this.setDimensions(
+            args[0] + tabElement.offsetLeft,
+            args[1] + tabElement.offsetTop,
+            args[2],
+            args[3],
+            'px'
+        )
+    }
+}

+ 6 - 13
tabby-core/src/components/splitTabSpanner.component.ts

@@ -1,5 +1,6 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 import { Component, Input, HostBinding, ElementRef, Output, EventEmitter } from '@angular/core'
+import { SelfPositioningComponent } from './selfPositioning.component'
 import { SplitContainer } from './splitTab.component'
 
 /** @hidden */
@@ -8,20 +9,19 @@ import { SplitContainer } from './splitTab.component'
     template: '',
     styles: [require('./splitTabSpanner.component.scss')],
 })
-export class SplitTabSpannerComponent {
+export class SplitTabSpannerComponent extends SelfPositioningComponent {
     @Input() container: SplitContainer
     @Input() index: number
     @Output() change = new EventEmitter<void>()
     @HostBinding('class.active') isActive = false
     @HostBinding('class.h') isHorizontal = false
     @HostBinding('class.v') isVertical = true
-    @HostBinding('style.left') cssLeft: string
-    @HostBinding('style.top') cssTop: string
-    @HostBinding('style.width') cssWidth: string | null
-    @HostBinding('style.height') cssHeight: string | null
     private marginOffset = -5
 
-    constructor (private element: ElementRef) { }
+    // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+    constructor (element: ElementRef) {
+        super(element)
+    }
 
     ngAfterViewInit () {
         this.element.nativeElement.addEventListener('dblclick', () => {
@@ -92,11 +92,4 @@ export class SplitTabSpannerComponent {
         this.container.ratios[this.index] = ratio
         this.change.emit()
     }
-
-    private setDimensions (x: number, y: number, w: number, h: number) {
-        this.cssLeft = `${x}%`
-        this.cssTop = `${y}%`
-        this.cssWidth = w ? `${w}%` : null
-        this.cssHeight = h ? `${h}%` : null
-    }
 }

+ 4 - 0
tabby-core/src/components/tabBody.component.ts

@@ -27,6 +27,10 @@ export class TabBodyComponent implements OnChanges {
         }
     }
 
+    detach () {
+        this.placeholder?.detach()
+    }
+
     ngOnDestroy () {
         this.placeholder?.detach()
     }

+ 6 - 1
tabby-core/src/components/tabHeader.component.pug

@@ -2,9 +2,14 @@
 .progressbar([style.width]='progress + "%"', *ngIf='progress != null')
 .activity-indicator(*ngIf='tab.activity$|async')
 
-.index(*ngIf='!config.store.terminal.hideTabIndex', #handle) {{index + 1}}
+ng-container(*ngIf='!config.store.terminal.hideTabIndex')
+    .index(*ngIf='hostApp.platform === Platform.macOS', cdkDragHandle) {{index + 1}}
+    .index(*ngIf='hostApp.platform !== Platform.macOS') {{index + 1}}
+
 .name(
     [title]='tab.customTitle || tab.title',
     [class.no-hover]='config.store.terminal.hideCloseButton'
 ) {{tab.customTitle || tab.title}}
 button(*ngIf='!config.store.terminal.hideCloseButton',(click)='app.closeTab(tab, true)') &times;
+
+ng-content

+ 3 - 16
tabby-core/src/components/tabHeader.component.ts

@@ -1,6 +1,5 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Component, Input, Optional, Inject, HostBinding, HostListener, ViewChild, ElementRef, NgZone } from '@angular/core'
-import { SortableComponent } from 'ng2-dnd'
+import { Component, Input, Optional, Inject, HostBinding, HostListener, NgZone } from '@angular/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { TabContextMenuItemProvider } from '../api/tabContextMenuProvider'
 import { BaseTabComponent } from './baseTab.component'
@@ -13,11 +12,6 @@ import { BaseComponent } from './base.component'
 import { MenuItemOptions } from '../api/menu'
 import { PlatformService } from '../api/platform'
 
-/** @hidden */
-export interface SortableComponentProxy {
-    setDragHandle: (_: HTMLElement) => void
-}
-
 /** @hidden */
 @Component({
     selector: 'tab-header',
@@ -29,17 +23,16 @@ export class TabHeaderComponent extends BaseComponent {
     @Input() @HostBinding('class.active') active: boolean
     @Input() tab: BaseTabComponent
     @Input() progress: number|null
-    @ViewChild('handle') handle?: ElementRef
+    Platform = Platform
 
     constructor (
         public app: AppService,
         public config: ConfigService,
-        private hostApp: HostAppService,
+        public hostApp: HostAppService,
         private ngbModal: NgbModal,
         private hotkeys: HotkeysService,
         private platform: PlatformService,
         private zone: NgZone,
-        @Inject(SortableComponent) private parentDraggable: SortableComponentProxy,
         @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[],
     ) {
         super()
@@ -61,12 +54,6 @@ export class TabHeaderComponent extends BaseComponent {
         })
     }
 
-    ngAfterViewInit () {
-        if (this.handle && this.hostApp.platform === Platform.macOS) {
-            this.parentDraggable.setDragHandle(this.handle.nativeElement)
-        }
-    }
-
     showRenameTabModal (): void {
         const modal = this.ngbModal.open(RenameTabModalComponent)
         modal.componentInstance.value = this.tab.customTitle || this.tab.title

+ 26 - 0
tabby-core/src/directives/cdkAutoDropGroup.directive.ts

@@ -0,0 +1,26 @@
+import { Directive, Input, OnInit } from '@angular/core'
+import { CdkDropList } from '@angular/cdk/drag-drop'
+
+class FakeDropGroup {
+    _items: Set<CdkDropList> = new Set()
+}
+
+/** @hidden */
+@Directive({
+    selector: '[cdkAutoDropGroup]',
+})
+export class CdkAutoDropGroup implements OnInit {
+    static groups: Record<string, FakeDropGroup> = {}
+
+    @Input('cdkAutoDropGroup') groupName: string
+
+    constructor (
+        private cdkDropList: CdkDropList,
+    ) { }
+
+    ngOnInit (): void {
+        CdkAutoDropGroup.groups[this.groupName] ??= new FakeDropGroup()
+        CdkAutoDropGroup.groups[this.groupName]._items.add(this.cdkDropList)
+        this.cdkDropList['_group'] = CdkAutoDropGroup.groups[this.groupName]
+    }
+}

+ 7 - 0
tabby-core/src/index.ts

@@ -7,6 +7,7 @@ import { PerfectScrollbarModule, PERFECT_SCROLLBAR_CONFIG } from 'ngx-perfect-sc
 import { NgxFilesizeModule } from 'ngx-filesize'
 import { DndModule } from 'ng2-dnd'
 import { SortablejsModule } from 'ngx-sortablejs'
+import { DragDropModule } from '@angular/cdk/drag-drop'
 
 import { AppRootComponent } from './components/appRoot.component'
 import { CheckboxComponent } from './components/checkbox.component'
@@ -22,6 +23,7 @@ import { RenameTabModalComponent } from './components/renameTabModal.component'
 import { SelectorModalComponent } from './components/selectorModal.component'
 import { SplitTabComponent, SplitTabRecoveryProvider } from './components/splitTab.component'
 import { SplitTabSpannerComponent } from './components/splitTabSpanner.component'
+import { SplitTabDropZoneComponent } from './components/splitTabDropZone.component'
 import { UnlockVaultModalComponent } from './components/unlockVaultModal.component'
 import { WelcomeTabComponent } from './components/welcomeTab.component'
 import { TransfersMenuComponent } from './components/transfersMenu.component'
@@ -30,6 +32,7 @@ import { AutofocusDirective } from './directives/autofocus.directive'
 import { AlwaysVisibleTypeaheadDirective } from './directives/alwaysVisibleTypeahead.directive'
 import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
 import { DropZoneDirective } from './directives/dropZone.directive'
+import { CdkAutoDropGroup } from './directives/cdkAutoDropGroup.directive'
 
 import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ToolbarButtonProvider, ProfilesService, ProfileProvider } from './api'
 
@@ -78,6 +81,7 @@ const PROVIDERS = [
         NgxFilesizeModule,
         PerfectScrollbarModule,
         DndModule.forRoot(),
+        DragDropModule,
         SortablejsModule.forRoot({ animation: 150 }),
     ],
     declarations: [
@@ -98,10 +102,12 @@ const PROVIDERS = [
         SelectorModalComponent,
         SplitTabComponent,
         SplitTabSpannerComponent,
+        SplitTabDropZoneComponent,
         UnlockVaultModalComponent,
         WelcomeTabComponent,
         TransfersMenuComponent,
         DropZoneDirective,
+        CdkAutoDropGroup,
     ],
     entryComponents: [
         PromptModalComponent,
@@ -121,6 +127,7 @@ const PROVIDERS = [
         FastHtmlBindDirective,
         AlwaysVisibleTypeaheadDirective,
         SortablejsModule,
+        DragDropModule,
     ],
 })
 export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class

+ 29 - 6
tabby-core/src/services/app.service.ts

@@ -54,7 +54,9 @@ export class AppService {
     private activeTabChange = new Subject<BaseTabComponent|null>()
     private tabsChanged = new Subject<void>()
     private tabOpened = new Subject<BaseTabComponent>()
+    private tabRemoved = new Subject<BaseTabComponent>()
     private tabClosed = new Subject<BaseTabComponent>()
+    private tabDragActive = new Subject<boolean>()
     private ready = new AsyncSubject<void>()
 
     private completionObservers = new Map<BaseTabComponent, CompletionObserver>()
@@ -62,7 +64,9 @@ export class AppService {
     get activeTabChange$ (): Observable<BaseTabComponent|null> { return this.activeTabChange }
     get tabOpened$ (): Observable<BaseTabComponent> { return this.tabOpened }
     get tabsChanged$ (): Observable<void> { return this.tabsChanged }
+    get tabRemoved$ (): Observable<BaseTabComponent> { return this.tabRemoved }
     get tabClosed$ (): Observable<BaseTabComponent> { return this.tabClosed }
+    get tabDragActive$ (): Observable<boolean> { return this.tabDragActive }
 
     /** Fires once when the app is ready */
     get ready$ (): Observable<void> { return this.ready }
@@ -131,21 +135,30 @@ export class AppService {
         })
 
         tab.destroyed$.subscribe(() => {
-            const newIndex = Math.max(0, this.tabs.indexOf(tab) - 1)
-            this.tabs = this.tabs.filter((x) => x !== tab)
-            if (tab === this._activeTab) {
-                this.selectTab(this.tabs[newIndex])
-            }
-            this.tabsChanged.next()
+            this.removeTab(tab)
+            this.tabRemoved.next(tab)
             this.tabClosed.next(tab)
         })
 
         if (tab instanceof SplitTabComponent) {
             tab.tabAdded$.subscribe(() => this.emitTabsChanged())
             tab.tabRemoved$.subscribe(() => this.emitTabsChanged())
+            tab.tabAdopted$.subscribe(t => {
+                this.removeTab(t)
+                this.tabRemoved.next(t)
+            })
         }
     }
 
+    removeTab (tab: BaseTabComponent): void {
+        const newIndex = Math.max(0, this.tabs.indexOf(tab) - 1)
+        this.tabs = this.tabs.filter((x) => x !== tab)
+        if (tab === this._activeTab) {
+            this.selectTab(this.tabs[newIndex])
+        }
+        this.tabsChanged.next()
+    }
+
     /**
      * Adds a new tab **without** wrapping it in a SplitTabComponent
      * @param inputs  Properties to be assigned on the new tab component instance
@@ -344,6 +357,16 @@ export class AppService {
         this.hostApp.emitReady()
     }
 
+    /** @hidden */
+    emitTabDragStarted (): void {
+        this.tabDragActive.next(true)
+    }
+
+    /** @hidden */
+    emitTabDragEnded (): void {
+        this.tabDragActive.next(false)
+    }
+
     /**
      * Returns an observable that fires once
      * the tab's internal "process" (see [[BaseTabProcess]]) completes

+ 2 - 0
web/polyfills.ts

@@ -2,6 +2,7 @@
 /* eslint-disable @typescript-eslint/no-empty-function */
 /* eslint-disable @typescript-eslint/no-extraneous-class */
 import * as angularCoreModule from '@angular/core'
+import * as angularCDKModule from '@angular/cdk'
 import * as angularCompilerModule from '@angular/compiler'
 import * as angularCommonModule from '@angular/common'
 import * as angularFormsModule from '@angular/forms'
@@ -147,6 +148,7 @@ Tabby.registerModule('readline', {
 })
 
 Tabby.registerModule('@angular/core', angularCoreModule)
+Tabby.registerModule('@angular/cdk', angularCDKModule)
 Tabby.registerModule('@angular/compiler', angularCompilerModule)
 Tabby.registerModule('@angular/common', angularCommonModule)
 Tabby.registerModule('@angular/forms', angularFormsModule)