tabby-tmux 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +511 -371
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -77,35 +77,42 @@ var ___CSS_LOADER_EXPORT___ = _node_modules_pnpm_css_loader_7_1_2_webpack_5_104_
|
|
|
77
77
|
// Module
|
|
78
78
|
___CSS_LOADER_EXPORT___.push([module.id, `@charset "UTF-8";
|
|
79
79
|
/*
|
|
80
|
-
* TmuxPaneTab —
|
|
80
|
+
* TmuxPaneTab — pure container for the xterm content area.
|
|
81
81
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* so the pane occupies 100 % of its allocated container — pixel-accurate
|
|
86
|
-
* and no rounding surprises.
|
|
82
|
+
* tmux owns the cell grid; the pane container is sized exactly to
|
|
83
|
+
* cols × cell pixels via pixel-absolute positioning. No padding,
|
|
84
|
+
* no border, no margin — the xterm canvas fills 100 % of the box.
|
|
87
85
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* Padding is subtracted in measureClientSize() so tmux grid calculations
|
|
92
|
-
* remain accurate.
|
|
86
|
+
* The scrollbar uses overlay mode so it floats above the content
|
|
87
|
+
* without consuming layout space, keeping the container width
|
|
88
|
+
* exactly cols × cellWidth.
|
|
93
89
|
*/
|
|
94
90
|
:host > .content {
|
|
95
91
|
margin: 0;
|
|
96
|
-
padding:
|
|
92
|
+
padding: 0;
|
|
97
93
|
border: 0;
|
|
98
94
|
box-sizing: border-box;
|
|
99
95
|
}
|
|
100
96
|
|
|
101
|
-
/*
|
|
97
|
+
/* xterm fills the pane completely; no overflow clipping needed */
|
|
102
98
|
:host ::ng-deep .xterm {
|
|
103
99
|
overflow: hidden !important;
|
|
104
100
|
}
|
|
105
101
|
|
|
102
|
+
/* Overlay scrollbar — floats above content, no layout space consumed */
|
|
106
103
|
:host ::ng-deep .xterm-viewport {
|
|
107
|
-
overflow-y:
|
|
108
|
-
|
|
104
|
+
overflow-y: overlay !important;
|
|
105
|
+
scrollbar-width: thin;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
:host ::ng-deep .xterm-viewport::-webkit-scrollbar {
|
|
109
|
+
width: 6px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
:host ::ng-deep .xterm-viewport::-webkit-scrollbar-thumb {
|
|
113
|
+
background: rgba(255, 255, 255, 0.2);
|
|
114
|
+
border-radius: 3px;
|
|
115
|
+
}`, "",{"version":3,"sources":["webpack://./src/components/tmuxPaneTab.component.scss"],"names":[],"mappings":"AAAA,gBAAgB;AAAhB;;;;;;;;;;EAAA;AAWA;EACI,SAAA;EACA,UAAA;EACA,SAAA;EACA,sBAAA;AAEJ;;AACA,iEAAA;AACA;EACI,2BAAA;AAEJ;;AAAA,uEAAA;AACA;EACI,8BAAA;EACA,qBAAA;AAGJ;;AADA;EACI,UAAA;AAIJ;;AAFA;EACI,oCAAA;EACA,kBAAA;AAKJ","sourcesContent":["/*\n * TmuxPaneTab — pure container for the xterm content area.\n *\n * tmux owns the cell grid; the pane container is sized exactly to\n * cols × cell pixels via pixel-absolute positioning. No padding,\n * no border, no margin — the xterm canvas fills 100 % of the box.\n *\n * The scrollbar uses overlay mode so it floats above the content\n * without consuming layout space, keeping the container width\n * exactly cols × cellWidth.\n */\n:host > .content {\n margin: 0;\n padding: 0;\n border: 0;\n box-sizing: border-box;\n}\n\n/* xterm fills the pane completely; no overflow clipping needed */\n:host ::ng-deep .xterm {\n overflow: hidden !important;\n}\n/* Overlay scrollbar — floats above content, no layout space consumed */\n:host ::ng-deep .xterm-viewport {\n overflow-y: overlay !important;\n scrollbar-width: thin;\n}\n:host ::ng-deep .xterm-viewport::-webkit-scrollbar {\n width: 6px;\n}\n:host ::ng-deep .xterm-viewport::-webkit-scrollbar-thumb {\n background: rgba(255,255,255,0.2);\n border-radius: 3px;\n}\n"],"sourceRoot":""}]);
|
|
109
116
|
// Exports
|
|
110
117
|
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___);
|
|
111
118
|
|
|
@@ -439,7 +446,7 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
|
|
|
439
446
|
// xterm's automatic fit-to-container so the pane never overrides the
|
|
440
447
|
// tmux-dictated grid with its own (pixel-rounded) size — that mismatch
|
|
441
448
|
// is what causes off-by-one wrapping / cursor errors. The grid is set
|
|
442
|
-
// explicitly via setTmuxGrid() from
|
|
449
|
+
// explicitly via setTmuxGrid() from applyPixelLayout() instead.
|
|
443
450
|
this.frontendReady$.pipe((0, rxjs_1.first)()).subscribe(() => {
|
|
444
451
|
this._frontendReady = true;
|
|
445
452
|
const frontend = this.frontend;
|
|
@@ -454,8 +461,16 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
|
|
|
454
461
|
}
|
|
455
462
|
}
|
|
456
463
|
// Apply any grid size that arrived before the frontend was ready.
|
|
464
|
+
//
|
|
465
|
+
// IMPORTANT: Defer with setTimeout(0) to avoid re-entrant xterm.resize().
|
|
466
|
+
// frontendReady$ fires inside the onResize callback of fitAddon.fit()'s
|
|
467
|
+
// xterm.resize(N, M). Calling xterm.resize(tmuxCols, tmuxRows) from
|
|
468
|
+
// within that callback is re-entrant — the outer resize continues its
|
|
469
|
+
// internal bookkeeping after onResize returns and overwrites our changes.
|
|
470
|
+
// Deferring ensures applyTmuxGrid() runs after fitAddon.fit() and the
|
|
471
|
+
// outer resize have fully completed.
|
|
457
472
|
if (this._tmuxCols > 0 && this._tmuxRows > 0) {
|
|
458
|
-
this.applyTmuxGrid();
|
|
473
|
+
setTimeout(() => this.applyTmuxGrid(), 0);
|
|
459
474
|
}
|
|
460
475
|
});
|
|
461
476
|
}
|
|
@@ -578,6 +593,12 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
|
|
|
578
593
|
{ label: this.translate.instant('Up'), click: () => this.splitPane('up') },
|
|
579
594
|
],
|
|
580
595
|
},
|
|
596
|
+
{
|
|
597
|
+
label: this.translate.instant('Zoom pane'),
|
|
598
|
+
type: 'checkbox',
|
|
599
|
+
checked: this._isZoomed,
|
|
600
|
+
click: () => this.toggleZoom(),
|
|
601
|
+
},
|
|
581
602
|
{
|
|
582
603
|
label: this.translate.instant('Focus all tmux panes'),
|
|
583
604
|
type: 'checkbox',
|
|
@@ -597,6 +618,24 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
|
|
|
597
618
|
event.stopPropagation();
|
|
598
619
|
this.platform.popupContextMenu(await this.buildContextMenu(), event);
|
|
599
620
|
}
|
|
621
|
+
/** Whether this pane is currently zoomed (fills the entire window). */
|
|
622
|
+
get _isZoomed() {
|
|
623
|
+
if (!this.controller || !this.paneId)
|
|
624
|
+
return false;
|
|
625
|
+
// Find which window owns this pane
|
|
626
|
+
for (const ws of this.controller.getAllWindowStates()) {
|
|
627
|
+
if (ws.panes.has(this.paneId)) {
|
|
628
|
+
return ws.zoomedPaneId === this.paneId;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
/** Toggle zoom via tmux resize-pane -Z (same as prefix+z). */
|
|
634
|
+
async toggleZoom() {
|
|
635
|
+
if (!this.controller)
|
|
636
|
+
return;
|
|
637
|
+
await this.controller.zoomPane(this.paneId);
|
|
638
|
+
}
|
|
600
639
|
async splitPane(direction) {
|
|
601
640
|
if (!this.controller)
|
|
602
641
|
return;
|
|
@@ -707,7 +746,6 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
707
746
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
708
747
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
709
748
|
};
|
|
710
|
-
var TmuxSessionTabComponent_1;
|
|
711
749
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
712
750
|
exports.TmuxSessionTabComponent = void 0;
|
|
713
751
|
const core_1 = __webpack_require__(/*! @angular/core */ "@angular/core");
|
|
@@ -715,6 +753,7 @@ const tabby_core_1 = __webpack_require__(/*! tabby-core */ "tabby-core");
|
|
|
715
753
|
const tabby_core_2 = __webpack_require__(/*! tabby-core */ "tabby-core");
|
|
716
754
|
const session_1 = __webpack_require__(/*! ../session */ "./src/session.ts");
|
|
717
755
|
const tmux_service_1 = __webpack_require__(/*! ../services/tmux.service */ "./src/services/tmux.service.ts");
|
|
756
|
+
const gateway_1 = __webpack_require__(/*! ../gateway */ "./src/gateway.ts");
|
|
718
757
|
const tmuxPaneTab_component_1 = __webpack_require__(/*! ./tmuxPaneTab.component */ "./src/components/tmuxPaneTab.component.ts");
|
|
719
758
|
const layoutParser_1 = __webpack_require__(/*! ../layoutParser */ "./src/layoutParser.ts");
|
|
720
759
|
/**
|
|
@@ -724,9 +763,14 @@ const layoutParser_1 = __webpack_require__(/*! ../layoutParser */ "./src/layoutP
|
|
|
724
763
|
* removeTab()/addTab() when switching windows. The bottom window bar provides
|
|
725
764
|
* window switching UI.
|
|
726
765
|
*
|
|
766
|
+
* Layout is pixel-absolute: pane positions are computed from tmux's character
|
|
767
|
+
* coordinates × cell pixel size, NOT from SplitTab's ratio-based percentage
|
|
768
|
+
* layout. The SplitContainer tree is only used by addTab()/removeTab() for
|
|
769
|
+
* ViewContainerRef management.
|
|
770
|
+
*
|
|
727
771
|
* Always created by TmuxService.attachToTerminal() with existingController set.
|
|
728
772
|
*/
|
|
729
|
-
let TmuxSessionTabComponent =
|
|
773
|
+
let TmuxSessionTabComponent = class TmuxSessionTabComponent extends tabby_core_1.SplitTabComponent {
|
|
730
774
|
constructor(injector, tmuxService, configService, tabsService, cdr, hostElement, log) {
|
|
731
775
|
super(injector.get(tabby_core_1.HotkeysService), tabsService, injector.get(tabby_core_2.TabRecoveryService), injector);
|
|
732
776
|
this.tmuxService = tmuxService;
|
|
@@ -750,12 +794,8 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
750
794
|
/** Last dimensions sent to tmux, for dedup */
|
|
751
795
|
this._lastSentCols = 0;
|
|
752
796
|
this._lastSentRows = 0;
|
|
753
|
-
/**
|
|
754
|
-
this.
|
|
755
|
-
/** mousedown handler attached to .pane-area for border drag detection */
|
|
756
|
-
this._paneAreaMouseDownHandler = null;
|
|
757
|
-
/** mousemove handler for border hover highlight */
|
|
758
|
-
this._paneAreaMouseMoveHandler = null;
|
|
797
|
+
/** Active divider DOM elements for the current window layout */
|
|
798
|
+
this._dividerElements = [];
|
|
759
799
|
this._tabsService = tabsService;
|
|
760
800
|
this.logger = log.create('tmux-session');
|
|
761
801
|
}
|
|
@@ -792,13 +832,19 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
792
832
|
// has finished inserting us into its ViewContainerRef
|
|
793
833
|
requestAnimationFrame(async () => {
|
|
794
834
|
this._initialized = true;
|
|
835
|
+
// ── Step A: Disable tmux pane borders + push client size FIRST ──
|
|
836
|
+
// tmux draws box-drawing characters in pane borders by default.
|
|
837
|
+
// We draw our own CSS dividers instead, so disable tmux's borders
|
|
838
|
+
// to avoid double-rendering (the border chars would show through
|
|
839
|
+
// our transparent divider elements).
|
|
840
|
+
// Then push client size so tmux relays out without border space.
|
|
841
|
+
this.controller.gateway.sendCommand('set-option -gw pane-border-lines off', gateway_1.TMUX_COMMAND_TOLERATE_ERRORS).catch(() => { });
|
|
842
|
+
this.refreshClientSize();
|
|
843
|
+
await this.eventQueue;
|
|
844
|
+
// ── Step B: Pane discovery (now based on correct size) ──
|
|
795
845
|
await this.controller.refreshPanes();
|
|
796
846
|
this.bootstrapFromControllerState();
|
|
797
|
-
// Wait for all queued events (window-add, pane-add) from refreshPanes
|
|
798
|
-
// to be processed before switching to the first window.
|
|
799
847
|
await this.eventQueue;
|
|
800
|
-
// Prefer the tmux-side active window (from list-windows #{window_active}
|
|
801
|
-
// or %session-window-changed). Fall back to the first window in the map.
|
|
802
848
|
const activeWindowId = this.controller.getActiveWindowId();
|
|
803
849
|
const targetWindowId = (activeWindowId !== null && this.windowPaneTabs.has(activeWindowId))
|
|
804
850
|
? activeWindowId
|
|
@@ -806,26 +852,15 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
806
852
|
if (targetWindowId !== undefined) {
|
|
807
853
|
await this.switchToWindow(targetWindowId);
|
|
808
854
|
}
|
|
809
|
-
//
|
|
810
|
-
// Only fires when the browser window changes size, not during
|
|
811
|
-
// internal SplitTab layout operations. Debounced to avoid flooding.
|
|
855
|
+
// ── Step C: ResizeObserver + window resize ──
|
|
812
856
|
this._resizeHandler = () => this.scheduleRefreshClientSize();
|
|
813
857
|
window.addEventListener('resize', this._resizeHandler);
|
|
814
|
-
// Observe the .pane-area container directly. This is the single
|
|
815
|
-
// source of truth for the client size: any time the container's
|
|
816
|
-
// pixel size changes (window resize, spanner drag, sidebar toggle,
|
|
817
|
-
// first mount), we recompute and push the whole-window grid to tmux.
|
|
818
|
-
// Per-pane xterm fit is disabled, so this never feeds back.
|
|
819
858
|
const host = this.hostElement.nativeElement;
|
|
820
859
|
const paneArea = host.querySelector('.pane-area');
|
|
821
860
|
if (paneArea && typeof ResizeObserver !== 'undefined') {
|
|
822
861
|
this._paneAreaObserver = new ResizeObserver(() => this.scheduleRefreshClientSize());
|
|
823
862
|
this._paneAreaObserver.observe(paneArea);
|
|
824
863
|
}
|
|
825
|
-
// Attach border hover + drag handlers to the pane-area
|
|
826
|
-
this.attachPaneAreaBorderHandlers();
|
|
827
|
-
// Initial size sync after pane mount
|
|
828
|
-
this.scheduleRefreshClientSize();
|
|
829
864
|
});
|
|
830
865
|
}
|
|
831
866
|
bootstrapFromControllerState() {
|
|
@@ -926,6 +961,11 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
926
961
|
this.handlePaneClose(event.paneId, event.windowId);
|
|
927
962
|
}
|
|
928
963
|
break;
|
|
964
|
+
case 'active-pane-changed':
|
|
965
|
+
if (event.paneId !== undefined && event.windowId !== undefined) {
|
|
966
|
+
this.handleActivePaneChanged(event.paneId, event.windowId);
|
|
967
|
+
}
|
|
968
|
+
break;
|
|
929
969
|
case 'layout-change':
|
|
930
970
|
// NOTE: We always call syncLayout for the active window.
|
|
931
971
|
// For non-active windows, we save the layout but don't rebuild
|
|
@@ -933,7 +973,7 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
933
973
|
if (event.windowId !== undefined && ((_b = event.data) === null || _b === void 0 ? void 0 : _b.layout)) {
|
|
934
974
|
if (event.windowId === this.activeWindowId) {
|
|
935
975
|
this.logger.info(`Syncing layout for active window @${event.windowId}`);
|
|
936
|
-
await this.syncLayout(event.data.layout);
|
|
976
|
+
await this.syncLayout(event.data.layout, event.data.zoomed, event.data.visibleLayout);
|
|
937
977
|
}
|
|
938
978
|
else {
|
|
939
979
|
this.logger.info(`Layout changed for inactive window @${event.windowId}, saved for next switch`);
|
|
@@ -951,15 +991,13 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
951
991
|
* Hides current window's panes and shows target window's panes.
|
|
952
992
|
*/
|
|
953
993
|
async switchToWindow(windowId) {
|
|
954
|
-
var _a, _b;
|
|
994
|
+
var _a, _b, _c;
|
|
955
995
|
if (windowId === this.activeWindowId)
|
|
956
996
|
return;
|
|
957
997
|
this.logger.info(`Switching to window @${windowId}`);
|
|
958
998
|
// Clear dividers while switching windows
|
|
959
|
-
this.
|
|
960
|
-
// 1. Detach current active window's pane views
|
|
961
|
-
// SplitTabComponent.removeTab destroys the tab when root.children
|
|
962
|
-
// becomes empty). Instead, clear the root directly.
|
|
999
|
+
this.clearDividers();
|
|
1000
|
+
// 1. Detach current active window's pane views
|
|
963
1001
|
if (this.activeWindowId !== null) {
|
|
964
1002
|
const paneMap = this.windowPaneTabs.get(this.activeWindowId);
|
|
965
1003
|
if (paneMap) {
|
|
@@ -978,20 +1016,9 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
978
1016
|
}
|
|
979
1017
|
const paneMap = this.windowPaneTabs.get(windowId);
|
|
980
1018
|
if (paneMap.size === 0) {
|
|
981
|
-
// First time visiting this window. Pane tabs will be created by
|
|
982
|
-
// handlePaneAdd (triggered by discoverPanesFromLayout on
|
|
983
|
-
// %layout-change). We don't call addPanesForWindow — panes are
|
|
984
|
-
// discovered asynchronously (iTerm2-style).
|
|
985
|
-
//
|
|
986
|
-
// HOWEVER: if the controller already knows the layout for this
|
|
987
|
-
// window (from batch discovery during attach), we can proactively
|
|
988
|
-
// discover panes from it instead of waiting for a layout-change
|
|
989
|
-
// event that may never come (tmux doesn't re-send layout-change
|
|
990
|
-
// for windows that haven't changed).
|
|
991
1019
|
const windowState = (_a = this.controller) === null || _a === void 0 ? void 0 : _a.getWindowState(windowId);
|
|
992
1020
|
if (windowState === null || windowState === void 0 ? void 0 : windowState.layout) {
|
|
993
1021
|
this.logger.info(`No pane tabs yet for window @${windowId}, but layout is known — discovering panes proactively`);
|
|
994
|
-
// Extract pane IDs from the known layout and create pane tabs
|
|
995
1022
|
const { parseTmuxLayout, flattenLayout } = await Promise.resolve().then(() => __importStar(__webpack_require__(/*! ../layoutParser */ "./src/layoutParser.ts")));
|
|
996
1023
|
const layoutTree = parseTmuxLayout(windowState.layout);
|
|
997
1024
|
if (layoutTree) {
|
|
@@ -1013,45 +1040,81 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1013
1040
|
else {
|
|
1014
1041
|
this.logger.info(`Mounting existing ${paneMap.size} pane(s) for window @${windowId}`);
|
|
1015
1042
|
}
|
|
1016
|
-
// 4.
|
|
1043
|
+
// 4. Determine zoom state and discover all pane tabs needed.
|
|
1044
|
+
// layout = real multi-pane layout (always has all pane IDs)
|
|
1045
|
+
// visibleLayout = zoomed display layout (single pane filling window)
|
|
1046
|
+
const windowState = (_b = this.controller) === null || _b === void 0 ? void 0 : _b.getWindowState(windowId);
|
|
1047
|
+
const isZoomed = !!(windowState === null || windowState === void 0 ? void 0 : windowState.zoomedPaneId);
|
|
1048
|
+
// Ensure pane tabs for ALL panes exist (discovered from layout, which is always real)
|
|
1049
|
+
if (windowState === null || windowState === void 0 ? void 0 : windowState.layout) {
|
|
1050
|
+
const fullTree = (0, layoutParser_1.parseTmuxLayout)(windowState.layout);
|
|
1051
|
+
if (fullTree) {
|
|
1052
|
+
for (const pane of (0, layoutParser_1.flattenLayout)(fullTree)) {
|
|
1053
|
+
if (!paneMap.has(pane.paneId)) {
|
|
1054
|
+
this.logger.info(`Creating pane tab for %${pane.paneId}` + (isZoomed ? ' (zoomed window)' : ''));
|
|
1055
|
+
const paneTab = this.createPaneTab(pane.paneId);
|
|
1056
|
+
paneTab.controller = this.controller;
|
|
1057
|
+
paneTab.paneId = pane.paneId;
|
|
1058
|
+
paneMap.set(pane.paneId, paneTab);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// 5. Attach views for display pane tabs only
|
|
1064
|
+
// Reset root tree so addTab() registers panes into a clean structure.
|
|
1017
1065
|
this.root = new tabby_core_1.SplitContainer();
|
|
1018
1066
|
this.root.orientation = 'h';
|
|
1067
|
+
// Display layout: visibleLayout when zoomed (what's on screen), layout otherwise
|
|
1068
|
+
const displayLayoutStr = isZoomed && (windowState === null || windowState === void 0 ? void 0 : windowState.visibleLayout)
|
|
1069
|
+
? windowState.visibleLayout
|
|
1070
|
+
: windowState === null || windowState === void 0 ? void 0 : windowState.layout;
|
|
1071
|
+
const displayTree = displayLayoutStr ? (0, layoutParser_1.parseTmuxLayout)(displayLayoutStr) : null;
|
|
1072
|
+
const displayPaneIds = displayTree
|
|
1073
|
+
? new Set((0, layoutParser_1.flattenLayout)(displayTree).map(p => p.paneId))
|
|
1074
|
+
: new Set(paneMap.keys()); // no layout → show all
|
|
1019
1075
|
const paneTabs = Array.from(paneMap.values());
|
|
1020
1076
|
if (paneTabs.length > 0) {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1077
|
+
for (const paneTab of paneTabs) {
|
|
1078
|
+
const isDisplay = displayPaneIds.has(paneTab.paneId);
|
|
1079
|
+
if (!((_c = this.viewRefs) === null || _c === void 0 ? void 0 : _c.has(paneTab))) {
|
|
1080
|
+
if (isDisplay) {
|
|
1081
|
+
await this.addTab(paneTab, null, 'r');
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (isDisplay) {
|
|
1085
|
+
;
|
|
1086
|
+
paneTab.emitVisibility(true);
|
|
1087
|
+
paneTab.emitFocused();
|
|
1026
1088
|
}
|
|
1027
1089
|
else {
|
|
1028
|
-
|
|
1090
|
+
;
|
|
1091
|
+
paneTab.emitVisibility(false);
|
|
1029
1092
|
}
|
|
1030
|
-
paneTab.emitVisibility(true);
|
|
1031
1093
|
}
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
this.
|
|
1036
|
-
await this.syncLayout(windowState.layout);
|
|
1094
|
+
// 6. Apply pixel layout from tmux
|
|
1095
|
+
if (displayTree) {
|
|
1096
|
+
this.applyPixelLayout(displayTree);
|
|
1097
|
+
this.updateDividers(displayTree);
|
|
1037
1098
|
}
|
|
1038
1099
|
}
|
|
1039
|
-
//
|
|
1040
|
-
// .pane-area ResizeObserver once xterm renders its cell grid.
|
|
1100
|
+
// 7. Detect changes and push size
|
|
1041
1101
|
this.cdr.detectChanges();
|
|
1042
|
-
// 7. Push the correct client size to tmux once panes are mounted.
|
|
1043
|
-
// We use requestAnimationFrame to wait for xterm to render its
|
|
1044
|
-
// character grid (getCellSize needs real cell dimensions), then
|
|
1045
|
-
// force a non-deduplicated refresh-client -C.
|
|
1046
1102
|
if (paneTabs.length > 0) {
|
|
1047
1103
|
requestAnimationFrame(() => {
|
|
1048
|
-
// Reset dedup so the next call always goes through
|
|
1049
1104
|
this._lastSentCols = 0;
|
|
1050
1105
|
this._lastSentRows = 0;
|
|
1051
1106
|
this.refreshClientSize();
|
|
1052
1107
|
});
|
|
1053
1108
|
}
|
|
1054
1109
|
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Override layout() to no-op. SplitTab's layoutInternal() uses percentage
|
|
1112
|
+
* positioning which conflicts with our pixel-absolute layout. Pane
|
|
1113
|
+
* positioning is handled exclusively by applyPixelLayout().
|
|
1114
|
+
*/
|
|
1115
|
+
layout() {
|
|
1116
|
+
// Intentionally empty — pixel-absolute layout replaces SplitTab layout.
|
|
1117
|
+
}
|
|
1055
1118
|
/**
|
|
1056
1119
|
* Override focus to manage which pane is the active (hotkey-target) pane.
|
|
1057
1120
|
*
|
|
@@ -1115,9 +1178,6 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1115
1178
|
// Do NOT destroy self when root is empty — this is normal during
|
|
1116
1179
|
// tmux window switches.
|
|
1117
1180
|
}
|
|
1118
|
-
/**
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
1181
|
/**
|
|
1122
1182
|
* Create a TmuxPaneTabComponent using TabsService (proper Angular DI).
|
|
1123
1183
|
* This ensures the component has a hostView and ViewContainerRef.
|
|
@@ -1213,8 +1273,15 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1213
1273
|
}
|
|
1214
1274
|
/**
|
|
1215
1275
|
* Handle a tmux window being closed.
|
|
1276
|
+
*
|
|
1277
|
+
* tmux automatically activates an adjacent window (next by index, or
|
|
1278
|
+
* previous if it was the last) and sends %session-window-changed.
|
|
1279
|
+
* The controller updates activeWindowId from that event, so we check
|
|
1280
|
+
* it to decide which window to switch to — matching tmux default
|
|
1281
|
+
* behavior (and browser tab close behavior).
|
|
1216
1282
|
*/
|
|
1217
1283
|
async handleWindowClose(windowId) {
|
|
1284
|
+
var _a;
|
|
1218
1285
|
const paneMap = this.windowPaneTabs.get(windowId);
|
|
1219
1286
|
if (paneMap) {
|
|
1220
1287
|
// Destroy all pane tabs for this window
|
|
@@ -1232,43 +1299,60 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1232
1299
|
this.activeWindowId = null;
|
|
1233
1300
|
const remainingWindows = Array.from(this.windowPaneTabs.keys());
|
|
1234
1301
|
if (remainingWindows.length > 0) {
|
|
1235
|
-
|
|
1302
|
+
// tmux sends %session-window-changed which updates
|
|
1303
|
+
// controller.activeWindowId — prefer that over arbitrary choice
|
|
1304
|
+
const tmuxActiveId = (_a = this.controller) === null || _a === void 0 ? void 0 : _a.getActiveWindowId();
|
|
1305
|
+
const target = (tmuxActiveId !== null && this.windowPaneTabs.has(tmuxActiveId))
|
|
1306
|
+
? tmuxActiveId
|
|
1307
|
+
: remainingWindows[0];
|
|
1308
|
+
await this.switchToWindow(target);
|
|
1236
1309
|
}
|
|
1237
1310
|
else {
|
|
1238
|
-
// No windows left —
|
|
1239
|
-
this.
|
|
1240
|
-
this.root.orientation = 'h';
|
|
1241
|
-
this.layout();
|
|
1311
|
+
// No windows left — clear dividers
|
|
1312
|
+
this.clearDividers();
|
|
1242
1313
|
this.cdr.detectChanges();
|
|
1243
1314
|
}
|
|
1244
1315
|
}
|
|
1245
1316
|
}
|
|
1246
1317
|
/**
|
|
1247
|
-
* Synchronize
|
|
1318
|
+
* Synchronize layout with tmux's layout string.
|
|
1248
1319
|
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
* panes, and rebuilds the entire tree from the tmux layout string.
|
|
1320
|
+
* Creates missing pane tabs, attaches their views, cleans up stale
|
|
1321
|
+
* panes, and positions everything via pixel-absolute layout.
|
|
1252
1322
|
*/
|
|
1253
|
-
async syncLayout(layoutStr) {
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1323
|
+
async syncLayout(layoutStr, zoomed, visibleLayout) {
|
|
1324
|
+
// tmux %layout-change semantics:
|
|
1325
|
+
// layout = real multi-pane layout (all panes, their actual sizes)
|
|
1326
|
+
// visibleLayout = layout that tmux actually displays on screen
|
|
1327
|
+
// When zoomed: visibleLayout is the single zoomed pane filling the window.
|
|
1328
|
+
//
|
|
1329
|
+
// For display, use visibleLayout when zoomed (what's on screen), layout otherwise.
|
|
1330
|
+
// For pane discovery, always use layout (has all pane IDs).
|
|
1331
|
+
var _a, _b;
|
|
1332
|
+
// Display layout: what's actually shown on screen
|
|
1333
|
+
const displayLayoutStr = zoomed && visibleLayout ? visibleLayout : layoutStr;
|
|
1334
|
+
const displayTree = (0, layoutParser_1.parseTmuxLayout)(displayLayoutStr);
|
|
1335
|
+
if (!displayTree) {
|
|
1336
|
+
this.logger.warn('Failed to parse display layout:', displayLayoutStr);
|
|
1258
1337
|
return;
|
|
1259
1338
|
}
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1264
|
-
|
|
1339
|
+
const displayPanes = (0, layoutParser_1.flattenLayout)(displayTree);
|
|
1340
|
+
const displayPaneIds = new Set(displayPanes.map(p => p.paneId));
|
|
1341
|
+
// Full pane list from layout (always the real multi-pane layout)
|
|
1342
|
+
const fullTree = (0, layoutParser_1.parseTmuxLayout)(layoutStr);
|
|
1343
|
+
const allPanes = fullTree ? (0, layoutParser_1.flattenLayout)(fullTree) : displayPanes;
|
|
1344
|
+
this.logger.info(`Syncing layout for window @${this.activeWindowId}: ` +
|
|
1345
|
+
`${displayPanes.length} display pane(s), ${allPanes.length} total` +
|
|
1346
|
+
(zoomed ? ' (zoomed)' : ''));
|
|
1347
|
+
// Ensure pane tabs exist and have attached views
|
|
1265
1348
|
if (this.activeWindowId !== null) {
|
|
1266
1349
|
let paneMap = this.windowPaneTabs.get(this.activeWindowId);
|
|
1267
1350
|
if (!paneMap) {
|
|
1268
1351
|
paneMap = new Map();
|
|
1269
1352
|
this.windowPaneTabs.set(this.activeWindowId, paneMap);
|
|
1270
1353
|
}
|
|
1271
|
-
for (
|
|
1354
|
+
// Create pane tabs for ALL panes (including hidden ones when zoomed)
|
|
1355
|
+
for (const pane of allPanes) {
|
|
1272
1356
|
if (!paneMap.has(pane.paneId)) {
|
|
1273
1357
|
this.logger.info(`Creating pane tab for %${pane.paneId} during layout sync`);
|
|
1274
1358
|
const paneTab = this.createPaneTab(pane.paneId);
|
|
@@ -1277,35 +1361,39 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1277
1361
|
paneMap.set(pane.paneId, paneTab);
|
|
1278
1362
|
}
|
|
1279
1363
|
}
|
|
1280
|
-
//
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1364
|
+
// Ensure root exists for addTab to register ViewContainerRefs.
|
|
1365
|
+
if (!(this.root instanceof tabby_core_1.SplitContainer)) {
|
|
1366
|
+
this.root = new tabby_core_1.SplitContainer();
|
|
1367
|
+
this.root.orientation = 'h';
|
|
1368
|
+
}
|
|
1369
|
+
// Attach views for panes that should be displayed
|
|
1370
|
+
for (const pane of displayPanes) {
|
|
1284
1371
|
const paneTab = paneMap.get(pane.paneId);
|
|
1285
1372
|
if (!((_a = this.viewRefs) === null || _a === void 0 ? void 0 : _a.has(paneTab))) {
|
|
1286
1373
|
this.logger.info(`Attaching view for pane %${pane.paneId}`);
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1374
|
+
await this.addTab(paneTab, null, 'r');
|
|
1375
|
+
}
|
|
1376
|
+
;
|
|
1377
|
+
paneTab.emitVisibility(true);
|
|
1378
|
+
paneTab.emitFocused();
|
|
1379
|
+
}
|
|
1380
|
+
// Hide panes not in the display set (e.g. non-zoomed panes)
|
|
1381
|
+
for (const [paneId, paneTab] of paneMap) {
|
|
1382
|
+
if (!displayPaneIds.has(paneId)) {
|
|
1294
1383
|
;
|
|
1295
|
-
paneTab.emitVisibility(
|
|
1296
|
-
|
|
1384
|
+
paneTab.emitVisibility(false);
|
|
1385
|
+
if ((_b = this.viewRefs) === null || _b === void 0 ? void 0 : _b.has(paneTab)) {
|
|
1386
|
+
this.detachPaneView(paneTab);
|
|
1387
|
+
}
|
|
1297
1388
|
}
|
|
1298
1389
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
const paneMap = this.windowPaneTabs.get(this.activeWindowId);
|
|
1305
|
-
if (paneMap) {
|
|
1306
|
-
const layoutPaneIds = new Set(panes.map(p => p.paneId));
|
|
1390
|
+
// Clean up stale pane tabs no longer in the full layout.
|
|
1391
|
+
// When zoomed, only clean up panes absent from visibleLayout;
|
|
1392
|
+
// panes hidden by zoom are still alive in tmux.
|
|
1393
|
+
if (!zoomed) {
|
|
1394
|
+
const fullPaneIds = new Set(allPanes.map(p => p.paneId));
|
|
1307
1395
|
for (const [paneId, paneTab] of paneMap) {
|
|
1308
|
-
if (!
|
|
1396
|
+
if (!fullPaneIds.has(paneId)) {
|
|
1309
1397
|
this.logger.info(`Pane %${paneId} no longer in layout, cleaning up`);
|
|
1310
1398
|
paneMap.delete(paneId);
|
|
1311
1399
|
paneTab.emitVisibility(false);
|
|
@@ -1315,30 +1403,25 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1315
1403
|
}
|
|
1316
1404
|
}
|
|
1317
1405
|
}
|
|
1318
|
-
//
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
this.layout();
|
|
1323
|
-
}
|
|
1324
|
-
else if (newRoot) {
|
|
1325
|
-
// Single pane — wrap in a container
|
|
1326
|
-
this.root = new tabby_core_1.SplitContainer();
|
|
1327
|
-
this.root.orientation = 'h';
|
|
1328
|
-
this.root.children.push(newRoot);
|
|
1329
|
-
this.root.ratios.push(1);
|
|
1330
|
-
this.layout();
|
|
1331
|
-
}
|
|
1406
|
+
// Position panes using pixel-absolute layout + set character grids
|
|
1407
|
+
this.applyPixelLayout(displayTree);
|
|
1408
|
+
// Update divider elements
|
|
1409
|
+
this.updateDividers(displayTree);
|
|
1332
1410
|
this.cdr.detectChanges();
|
|
1333
|
-
// tmux is authoritative over each pane's character grid: push the exact
|
|
1334
|
-
// cell dimensions from the layout string into each xterm. This keeps
|
|
1335
|
-
// wrapping aligned with tmux and avoids any pixel-derived resize.
|
|
1336
|
-
this.applyLayoutGrids(layoutTree);
|
|
1337
1411
|
}
|
|
1338
1412
|
/**
|
|
1339
1413
|
* Handle a pane being closed (from %pane-close event or manual cleanup).
|
|
1414
|
+
*
|
|
1415
|
+
* Note: we do NOT activate a neighboring pane here. tmux sends
|
|
1416
|
+
* %window-pane-changed after closing a pane, which triggers
|
|
1417
|
+
* handleActivePaneChanged() to focus the correct pane.
|
|
1418
|
+
*
|
|
1419
|
+
* When zoomed, closing a hidden pane just removes it from the map.
|
|
1420
|
+
* Closing the zoomed pane triggers tmux to auto-unzoom + kill,
|
|
1421
|
+
* which sends %layout-change to restore the real layout.
|
|
1340
1422
|
*/
|
|
1341
1423
|
handlePaneClose(paneId, windowId) {
|
|
1424
|
+
var _a;
|
|
1342
1425
|
const paneMap = this.windowPaneTabs.get(windowId);
|
|
1343
1426
|
if (!paneMap)
|
|
1344
1427
|
return;
|
|
@@ -1347,43 +1430,70 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1347
1430
|
return;
|
|
1348
1431
|
this.logger.info(`Cleaning up closed pane %${paneId} in window @${windowId}`);
|
|
1349
1432
|
paneMap.delete(paneId);
|
|
1350
|
-
if (
|
|
1433
|
+
// Only detach view if it's actually attached (visible panes).
|
|
1434
|
+
// Hidden panes (e.g. non-zoomed panes when zoomed) are already detached.
|
|
1435
|
+
if (windowId === this.activeWindowId && ((_a = this.viewRefs) === null || _a === void 0 ? void 0 : _a.has(paneTab))) {
|
|
1351
1436
|
;
|
|
1352
1437
|
paneTab.emitVisibility(false);
|
|
1353
1438
|
this.detachPaneView(paneTab);
|
|
1354
|
-
this.layout();
|
|
1355
1439
|
this.cdr.detectChanges();
|
|
1356
1440
|
}
|
|
1357
1441
|
;
|
|
1358
1442
|
paneTab.destroy();
|
|
1359
1443
|
}
|
|
1360
1444
|
/**
|
|
1361
|
-
*
|
|
1445
|
+
* Handle tmux telling us the active pane changed (e.g. after pane close).
|
|
1446
|
+
* Focuses the pane in the UI, matching tmux default behavior.
|
|
1362
1447
|
*/
|
|
1363
|
-
|
|
1364
|
-
if (
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1448
|
+
handleActivePaneChanged(paneId, windowId) {
|
|
1449
|
+
if (windowId !== this.activeWindowId)
|
|
1450
|
+
return;
|
|
1451
|
+
const paneMap = this.windowPaneTabs.get(windowId);
|
|
1452
|
+
if (!paneMap)
|
|
1453
|
+
return;
|
|
1454
|
+
const paneTab = paneMap.get(paneId);
|
|
1455
|
+
if (!paneTab)
|
|
1456
|
+
return;
|
|
1457
|
+
this.logger.info(`Activating pane %${paneId} in window @${windowId}`);
|
|
1458
|
+
this.focus(paneTab);
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Position each pane using tmux's absolute character coordinates × cell pixel size.
|
|
1462
|
+
* Also sets the xterm character grid for each pane. One pass, zero rounding.
|
|
1463
|
+
*/
|
|
1464
|
+
applyPixelLayout(layoutTree) {
|
|
1465
|
+
var _a;
|
|
1466
|
+
const cell = this.getCellSize();
|
|
1467
|
+
if (!cell)
|
|
1468
|
+
return;
|
|
1469
|
+
const paneMap = this.windowPaneTabs.get(this.activeWindowId);
|
|
1470
|
+
if (!paneMap)
|
|
1471
|
+
return;
|
|
1472
|
+
// Read pane-area padding so absolute-positioned panes respect it.
|
|
1473
|
+
// CSS absolute positioning ignores parent padding, so we offset manually.
|
|
1474
|
+
const host = this.hostElement.nativeElement;
|
|
1475
|
+
const paneArea = host.querySelector('.pane-area');
|
|
1476
|
+
const padL = paneArea ? parseFloat(getComputedStyle(paneArea).paddingLeft) || 0 : 0;
|
|
1477
|
+
const padT = paneArea ? parseFloat(getComputedStyle(paneArea).paddingTop) || 0 : 0;
|
|
1478
|
+
for (const pane of (0, layoutParser_1.flattenLayout)(layoutTree)) {
|
|
1479
|
+
const paneTab = paneMap.get(pane.paneId);
|
|
1480
|
+
if (!paneTab)
|
|
1481
|
+
continue;
|
|
1482
|
+
// Set pixel position from tmux char coords
|
|
1483
|
+
const viewRef = (_a = this.viewRefs) === null || _a === void 0 ? void 0 : _a.get(paneTab);
|
|
1484
|
+
if (viewRef) {
|
|
1485
|
+
const el = viewRef.rootNodes[0];
|
|
1486
|
+
el.classList.add('child');
|
|
1487
|
+
el.style.left = `${padL + pane.x * cell.width}px`;
|
|
1488
|
+
el.style.top = `${padT + pane.y * cell.height}px`;
|
|
1489
|
+
el.style.width = `${pane.width * cell.width}px`;
|
|
1490
|
+
el.style.height = `${pane.height * cell.height}px`;
|
|
1491
|
+
}
|
|
1492
|
+
// Set xterm character grid
|
|
1493
|
+
if (paneTab.setTmuxGrid) {
|
|
1494
|
+
paneTab.setTmuxGrid(pane.width, pane.height);
|
|
1384
1495
|
}
|
|
1385
1496
|
}
|
|
1386
|
-
return container.children.length > 0 ? container : null;
|
|
1387
1497
|
}
|
|
1388
1498
|
/**
|
|
1389
1499
|
* Refresh tmux client size based purely on the container (.pane-area) size.
|
|
@@ -1429,75 +1539,27 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1429
1539
|
this.controller.resizePane(0, cols, rows);
|
|
1430
1540
|
}
|
|
1431
1541
|
}
|
|
1432
|
-
/**
|
|
1433
|
-
* Apply the tmux-authoritative character grid to each mounted pane.
|
|
1434
|
-
*
|
|
1435
|
-
* tmux's layout string gives the exact width/height (in cells) of every
|
|
1436
|
-
* pane. We push those directly into the corresponding xterm so display
|
|
1437
|
-
* wrapping matches tmux exactly. xterm auto-fit is disabled for tmux panes,
|
|
1438
|
-
* so this is the only thing that sizes them.
|
|
1439
|
-
*/
|
|
1440
|
-
applyLayoutGrids(layoutTree) {
|
|
1441
|
-
if (this.activeWindowId === null)
|
|
1442
|
-
return;
|
|
1443
|
-
const paneMap = this.windowPaneTabs.get(this.activeWindowId);
|
|
1444
|
-
if (!paneMap)
|
|
1445
|
-
return;
|
|
1446
|
-
for (const pane of (0, layoutParser_1.flattenLayout)(layoutTree)) {
|
|
1447
|
-
const paneTab = paneMap.get(pane.paneId);
|
|
1448
|
-
if (paneTab === null || paneTab === void 0 ? void 0 : paneTab.setTmuxGrid) {
|
|
1449
|
-
paneTab.setTmuxGrid(pane.width, pane.height);
|
|
1450
|
-
}
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
1542
|
/**
|
|
1454
1543
|
* Measure the whole-window character grid from the .pane-area container.
|
|
1455
1544
|
*
|
|
1456
|
-
*
|
|
1457
|
-
* padding
|
|
1458
|
-
*
|
|
1459
|
-
* then add tmux's 1-char dividers between panes so tmux's own grid lines up.
|
|
1545
|
+
* Pure pixel-to-cell conversion. Uses clientWidth/clientHeight to
|
|
1546
|
+
* exclude padding from the measurement — pane-area padding is purely
|
|
1547
|
+
* cosmetic and must not affect the tmux grid calculation.
|
|
1460
1548
|
*/
|
|
1461
1549
|
measureClientSize() {
|
|
1462
|
-
var _a
|
|
1550
|
+
var _a;
|
|
1463
1551
|
const host = this.hostElement.nativeElement;
|
|
1464
1552
|
const paneArea = (_a = host.querySelector('.pane-area')) !== null && _a !== void 0 ? _a : host;
|
|
1465
|
-
const
|
|
1466
|
-
|
|
1553
|
+
const pw = paneArea.clientWidth;
|
|
1554
|
+
const ph = paneArea.clientHeight;
|
|
1555
|
+
if (pw < 10 || ph < 10)
|
|
1467
1556
|
return null;
|
|
1468
1557
|
const cell = this.getCellSize();
|
|
1469
1558
|
if (!cell)
|
|
1470
1559
|
return null;
|
|
1471
|
-
// Determine pane/split counts for the active window.
|
|
1472
|
-
const paneMap = this.activeWindowId !== null
|
|
1473
|
-
? this.windowPaneTabs.get(this.activeWindowId)
|
|
1474
|
-
: null;
|
|
1475
|
-
const paneCount = (_b = paneMap === null || paneMap === void 0 ? void 0 : paneMap.size) !== null && _b !== void 0 ? _b : 1;
|
|
1476
|
-
// UI spanner pixel widths: tabby's split-tab-spanner is 10px each.
|
|
1477
|
-
// Our custom tmux dividers are purely visual overlays with no pixel cost.
|
|
1478
|
-
// _spanners is still populated by layoutInternal() for tabby's internal
|
|
1479
|
-
// bookkeeping, but we no longer render split-tab-spanner elements, so
|
|
1480
|
-
// their pixel width should not be subtracted here.
|
|
1481
|
-
const spannerPx = 0;
|
|
1482
|
-
// Per-pane visual padding defined in tmuxPaneTab.component.scss.
|
|
1483
|
-
// Each pane has 4px on all sides (8px total per axis).
|
|
1484
|
-
// For N panes in a row: total horizontal padding = N * 8px.
|
|
1485
|
-
// For N panes in a column: total vertical padding = N * 8px.
|
|
1486
|
-
// Approximate as all panes contributing to both axes (safe overestimate).
|
|
1487
|
-
const panePadPerAxis = 8;
|
|
1488
|
-
const totalPadPx = paneCount * panePadPerAxis;
|
|
1489
|
-
const availableWidth = rect.width - spannerPx - totalPadPx;
|
|
1490
|
-
const availableHeight = rect.height - totalPadPx;
|
|
1491
|
-
const contentCols = Math.floor(availableWidth / cell.width);
|
|
1492
|
-
const contentRows = Math.floor(availableHeight / cell.height);
|
|
1493
|
-
// tmux inserts a 1-char divider between adjacent panes; add them back so
|
|
1494
|
-
// the size we report covers content + dividers (tmux subtracts them again
|
|
1495
|
-
// when splitting). Approximate as a horizontal split (most common case).
|
|
1496
|
-
const numDividers = Math.max(0, paneCount - 1);
|
|
1497
|
-
const cols = contentCols + numDividers;
|
|
1498
1560
|
return {
|
|
1499
|
-
cols: Math.max(2,
|
|
1500
|
-
rows: Math.max(1,
|
|
1561
|
+
cols: Math.max(2, Math.floor(pw / cell.width)),
|
|
1562
|
+
rows: Math.max(1, Math.floor(ph / cell.height)),
|
|
1501
1563
|
};
|
|
1502
1564
|
}
|
|
1503
1565
|
/**
|
|
@@ -1531,158 +1593,173 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1531
1593
|
this.refreshClientSize();
|
|
1532
1594
|
}, debounceMs);
|
|
1533
1595
|
}
|
|
1596
|
+
// ─── Divider management ──────────────────────────────────────────────────
|
|
1534
1597
|
/**
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
* a tmux resize-pane operation.
|
|
1598
|
+
* Generate independent divider <div> elements for adjacent pane boundaries.
|
|
1599
|
+
* Walks the layout tree to find sibling edges and creates draggable lines.
|
|
1538
1600
|
*/
|
|
1539
|
-
|
|
1601
|
+
updateDividers(layoutTree) {
|
|
1540
1602
|
const host = this.hostElement.nativeElement;
|
|
1541
1603
|
const paneArea = host.querySelector('.pane-area');
|
|
1542
1604
|
if (!paneArea)
|
|
1543
1605
|
return;
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1606
|
+
this.clearDividers();
|
|
1607
|
+
const cell = this.getCellSize();
|
|
1608
|
+
if (!cell)
|
|
1609
|
+
return;
|
|
1610
|
+
this.collectDividers(layoutTree, cell, paneArea);
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Recursively collect divider lines from the layout tree.
|
|
1614
|
+
* For each container node, consecutive children share a boundary → divider.
|
|
1615
|
+
*
|
|
1616
|
+
* tmux layout semantics:
|
|
1617
|
+
* - 'vertical' ([...]): children stacked top-to-bottom → horizontal divider line
|
|
1618
|
+
* - 'horizontal' ({...}): children side-by-side → vertical divider line
|
|
1619
|
+
*
|
|
1620
|
+
* Same-level siblings always share the same cross-axis extent (tmux guarantees
|
|
1621
|
+
* this for its binary splits), so divider size is simply derived from the parent.
|
|
1622
|
+
*
|
|
1623
|
+
* For container children (non-leaf), we find the actual pane IDs at the
|
|
1624
|
+
* boundary using helper methods so drag-resize works at every level.
|
|
1625
|
+
*/
|
|
1626
|
+
collectDividers(node, cell, paneArea) {
|
|
1627
|
+
if (!node.children || node.children.length < 2) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
// Read pane-area padding to offset divider positions (same as applyPixelLayout)
|
|
1631
|
+
const cs = getComputedStyle(paneArea);
|
|
1632
|
+
const padL = parseFloat(cs.paddingLeft) || 0;
|
|
1633
|
+
const padT = parseFloat(cs.paddingTop) || 0;
|
|
1634
|
+
for (let i = 0; i < node.children.length - 1; i++) {
|
|
1635
|
+
const left = node.children[i];
|
|
1636
|
+
const right = node.children[i + 1];
|
|
1637
|
+
if (node.type === 'horizontal') {
|
|
1638
|
+
// Children are side-by-side → vertical divider between left and right
|
|
1639
|
+
// Divider is 1 cell wide, centered at the shared boundary
|
|
1640
|
+
const x = padL + (left.x + left.width) * cell.width;
|
|
1641
|
+
const top = padT + node.y * cell.height;
|
|
1642
|
+
const height = node.height * cell.height;
|
|
1643
|
+
// Find the rightmost pane(s) in `left` and leftmost pane(s) in `right`
|
|
1644
|
+
const paneIdA = this.getRightmostLeafPaneId(left);
|
|
1645
|
+
const paneIdB = this.getLeftmostLeafPaneId(right);
|
|
1646
|
+
this.createDividerElement(paneArea, 'v', x, top, cell.width, height, paneIdA, paneIdB, cell);
|
|
1551
1647
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
const children = paneArea.querySelectorAll('.child');
|
|
1563
|
-
for (const child of Array.from(children)) {
|
|
1564
|
-
const el = child;
|
|
1565
|
-
const r = el.getBoundingClientRect();
|
|
1566
|
-
const right = r.right - areaRect.left;
|
|
1567
|
-
const bottom = r.bottom - areaRect.top;
|
|
1568
|
-
const left = r.left - areaRect.left;
|
|
1569
|
-
const top = r.top - areaRect.top;
|
|
1570
|
-
// Right border hit: within HIT of right edge, vertically inside
|
|
1571
|
-
if (Math.abs(mx - right) <= HIT && my >= top && my <= bottom) {
|
|
1572
|
-
el.classList.add('border-hover-right');
|
|
1573
|
-
paneArea.style.cursor = 'col-resize';
|
|
1574
|
-
hoveredChild = el;
|
|
1575
|
-
hoveredEdge = 'right';
|
|
1576
|
-
break;
|
|
1577
|
-
}
|
|
1578
|
-
// Bottom border hit: within HIT of bottom edge, horizontally inside
|
|
1579
|
-
if (Math.abs(my - bottom) <= HIT && mx >= left && mx <= right) {
|
|
1580
|
-
el.classList.add('border-hover-bottom');
|
|
1581
|
-
paneArea.style.cursor = 'row-resize';
|
|
1582
|
-
hoveredChild = el;
|
|
1583
|
-
hoveredEdge = 'bottom';
|
|
1584
|
-
break;
|
|
1585
|
-
}
|
|
1648
|
+
else {
|
|
1649
|
+
// Children are stacked top-to-bottom → horizontal divider between top and bottom
|
|
1650
|
+
// Divider is 1 cell tall, centered at the shared boundary
|
|
1651
|
+
const y = padT + (left.y + left.height) * cell.height;
|
|
1652
|
+
const leftPx = padL + node.x * cell.width;
|
|
1653
|
+
const width = node.width * cell.width;
|
|
1654
|
+
// Find the bottommost pane(s) in `left` and topmost pane(s) in `right`
|
|
1655
|
+
const paneIdA = this.getBottommostLeafPaneId(left);
|
|
1656
|
+
const paneIdB = this.getTopmostLeafPaneId(right);
|
|
1657
|
+
this.createDividerElement(paneArea, 'h', leftPx, y, width, cell.height, paneIdA, paneIdB, cell);
|
|
1586
1658
|
}
|
|
1587
|
-
};
|
|
1588
|
-
const onDown = (e) => {
|
|
1589
|
-
if (!hoveredChild || !hoveredEdge || !this.controller)
|
|
1590
|
-
return;
|
|
1591
|
-
e.preventDefault();
|
|
1592
|
-
e.stopPropagation();
|
|
1593
|
-
const edge = hoveredEdge;
|
|
1594
|
-
const cell = this.getCellSize();
|
|
1595
|
-
if (!cell)
|
|
1596
|
-
return;
|
|
1597
|
-
const startX = e.clientX;
|
|
1598
|
-
const startY = e.clientY;
|
|
1599
|
-
// Find the pane ID for this .child element
|
|
1600
|
-
const resizeTarget = hoveredChild;
|
|
1601
|
-
const paneId = this.findPaneIdForElement(resizeTarget);
|
|
1602
|
-
if (paneId === null)
|
|
1603
|
-
return;
|
|
1604
|
-
// Track last sent delta to send incremental resize commands
|
|
1605
|
-
let lastSentCols = 0;
|
|
1606
|
-
let lastSentRows = 0;
|
|
1607
|
-
const onDragMove = (de) => {
|
|
1608
|
-
document.body.style.cursor = edge === 'right' ? 'col-resize' : 'row-resize';
|
|
1609
|
-
if (edge === 'right') {
|
|
1610
|
-
const deltaCols = Math.round((de.clientX - startX) / cell.width);
|
|
1611
|
-
if (deltaCols !== lastSentCols) {
|
|
1612
|
-
const diff = deltaCols - lastSentCols;
|
|
1613
|
-
const flag = diff > 0 ? '-R' : '-L';
|
|
1614
|
-
this.controller.gateway.sendCommand(`resize-pane ${flag} -t %${paneId} ${Math.abs(diff)}`);
|
|
1615
|
-
lastSentCols = deltaCols;
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
else {
|
|
1619
|
-
const deltaRows = Math.round((de.clientY - startY) / cell.height);
|
|
1620
|
-
if (deltaRows !== lastSentRows) {
|
|
1621
|
-
const diff = deltaRows - lastSentRows;
|
|
1622
|
-
const flag = diff > 0 ? '-D' : '-U';
|
|
1623
|
-
this.controller.gateway.sendCommand(`resize-pane ${flag} -t %${paneId} ${Math.abs(diff)}`);
|
|
1624
|
-
lastSentRows = deltaRows;
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
};
|
|
1628
|
-
const onDragUp = () => {
|
|
1629
|
-
document.removeEventListener('mousemove', onDragMove);
|
|
1630
|
-
document.removeEventListener('mouseup', onDragUp);
|
|
1631
|
-
document.body.style.cursor = '';
|
|
1632
|
-
clearHover();
|
|
1633
|
-
};
|
|
1634
|
-
document.addEventListener('mousemove', onDragMove);
|
|
1635
|
-
document.addEventListener('mouseup', onDragUp);
|
|
1636
|
-
};
|
|
1637
|
-
const onLeave = () => clearHover();
|
|
1638
|
-
this._paneAreaMouseMoveHandler = onMove;
|
|
1639
|
-
this._paneAreaMouseDownHandler = onDown;
|
|
1640
|
-
paneArea.addEventListener('mousemove', onMove);
|
|
1641
|
-
paneArea.addEventListener('mousedown', onDown);
|
|
1642
|
-
paneArea.addEventListener('mouseleave', onLeave);
|
|
1643
|
-
}
|
|
1644
|
-
detachPaneAreaBorderHandlers() {
|
|
1645
|
-
const host = this.hostElement.nativeElement;
|
|
1646
|
-
const paneArea = host.querySelector('.pane-area');
|
|
1647
|
-
if (!paneArea)
|
|
1648
|
-
return;
|
|
1649
|
-
if (this._paneAreaMouseMoveHandler) {
|
|
1650
|
-
paneArea.removeEventListener('mousemove', this._paneAreaMouseMoveHandler);
|
|
1651
|
-
this._paneAreaMouseMoveHandler = null;
|
|
1652
1659
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
this.
|
|
1660
|
+
// Recurse into children
|
|
1661
|
+
for (const child of node.children) {
|
|
1662
|
+
this.collectDividers(child, cell, paneArea);
|
|
1656
1663
|
}
|
|
1657
1664
|
}
|
|
1665
|
+
/** Find the rightmost leaf pane in a layout subtree (for vertical divider) */
|
|
1666
|
+
getRightmostLeafPaneId(node) {
|
|
1667
|
+
var _a;
|
|
1668
|
+
if (node.type === 'pane')
|
|
1669
|
+
return node.paneId;
|
|
1670
|
+
if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
|
|
1671
|
+
return undefined;
|
|
1672
|
+
return this.getRightmostLeafPaneId(node.children[node.children.length - 1]);
|
|
1673
|
+
}
|
|
1674
|
+
/** Find the leftmost leaf pane in a layout subtree (for vertical divider) */
|
|
1675
|
+
getLeftmostLeafPaneId(node) {
|
|
1676
|
+
var _a;
|
|
1677
|
+
if (node.type === 'pane')
|
|
1678
|
+
return node.paneId;
|
|
1679
|
+
if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
|
|
1680
|
+
return undefined;
|
|
1681
|
+
return this.getLeftmostLeafPaneId(node.children[0]);
|
|
1682
|
+
}
|
|
1683
|
+
/** Find the bottommost leaf pane in a layout subtree (for horizontal divider) */
|
|
1684
|
+
getBottommostLeafPaneId(node) {
|
|
1685
|
+
var _a;
|
|
1686
|
+
if (node.type === 'pane')
|
|
1687
|
+
return node.paneId;
|
|
1688
|
+
if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
|
|
1689
|
+
return undefined;
|
|
1690
|
+
return this.getBottommostLeafPaneId(node.children[node.children.length - 1]);
|
|
1691
|
+
}
|
|
1692
|
+
/** Find the topmost leaf pane in a layout subtree (for horizontal divider) */
|
|
1693
|
+
getTopmostLeafPaneId(node) {
|
|
1694
|
+
var _a;
|
|
1695
|
+
if (node.type === 'pane')
|
|
1696
|
+
return node.paneId;
|
|
1697
|
+
if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
|
|
1698
|
+
return undefined;
|
|
1699
|
+
return this.getTopmostLeafPaneId(node.children[0]);
|
|
1700
|
+
}
|
|
1658
1701
|
/**
|
|
1659
|
-
*
|
|
1702
|
+
* Create a single divider DOM element with drag-to-resize behavior.
|
|
1660
1703
|
*/
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1704
|
+
createDividerElement(paneArea, orientation, x, y, w, h, paneIdA, paneIdB, cell) {
|
|
1705
|
+
const div = document.createElement('div');
|
|
1706
|
+
div.className = `tmux-divider ${orientation}`;
|
|
1707
|
+
div.style.left = `${x}px`;
|
|
1708
|
+
div.style.top = `${y}px`;
|
|
1709
|
+
div.style.width = `${w}px`;
|
|
1710
|
+
div.style.height = `${h}px`;
|
|
1711
|
+
// Divider is already 1 cell wide/tall — natural hit target matches tmux
|
|
1712
|
+
if (paneIdA !== undefined && paneIdB !== undefined) {
|
|
1713
|
+
const onDown = (e) => {
|
|
1714
|
+
e.preventDefault();
|
|
1715
|
+
e.stopPropagation();
|
|
1716
|
+
const startX = e.clientX;
|
|
1717
|
+
const startY = e.clientY;
|
|
1718
|
+
let lastSentCols = 0;
|
|
1719
|
+
let lastSentRows = 0;
|
|
1720
|
+
const onMove = (de) => {
|
|
1721
|
+
var _a, _b;
|
|
1722
|
+
document.body.style.cursor = orientation === 'v' ? 'col-resize' : 'row-resize';
|
|
1723
|
+
if (orientation === 'v') {
|
|
1724
|
+
const deltaCols = Math.round((de.clientX - startX) / cell.width);
|
|
1725
|
+
if (deltaCols !== lastSentCols) {
|
|
1726
|
+
const diff = deltaCols - lastSentCols;
|
|
1727
|
+
const flag = diff > 0 ? '-R' : '-L';
|
|
1728
|
+
(_a = this.controller) === null || _a === void 0 ? void 0 : _a.gateway.sendCommand(`resize-pane ${flag} -t %${paneIdA} ${Math.abs(diff)}`);
|
|
1729
|
+
lastSentCols = deltaCols;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
else {
|
|
1733
|
+
const deltaRows = Math.round((de.clientY - startY) / cell.height);
|
|
1734
|
+
if (deltaRows !== lastSentRows) {
|
|
1735
|
+
const diff = deltaRows - lastSentRows;
|
|
1736
|
+
const flag = diff > 0 ? '-D' : '-U';
|
|
1737
|
+
(_b = this.controller) === null || _b === void 0 ? void 0 : _b.gateway.sendCommand(`resize-pane ${flag} -t %${paneIdA} ${Math.abs(diff)}`);
|
|
1738
|
+
lastSentRows = deltaRows;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
const onUp = () => {
|
|
1743
|
+
document.removeEventListener('mousemove', onMove);
|
|
1744
|
+
document.removeEventListener('mouseup', onUp);
|
|
1745
|
+
document.body.style.cursor = '';
|
|
1746
|
+
};
|
|
1747
|
+
document.addEventListener('mousemove', onMove);
|
|
1748
|
+
document.addEventListener('mouseup', onUp);
|
|
1749
|
+
};
|
|
1750
|
+
div.addEventListener('mousedown', onDown);
|
|
1673
1751
|
}
|
|
1674
|
-
|
|
1752
|
+
paneArea.appendChild(div);
|
|
1753
|
+
this._dividerElements.push(div);
|
|
1675
1754
|
}
|
|
1676
|
-
// ─── (legacy divider stubs removed) ─────────────────────────────────────
|
|
1677
1755
|
/**
|
|
1678
|
-
*
|
|
1679
|
-
* When the user drags a spanner (split divider), the pane containers
|
|
1680
|
-
* resize and xterm.js auto-fits. We need to tell tmux the new client size
|
|
1681
|
-
* so it can recalculate its layout accordingly.
|
|
1756
|
+
* Remove all divider elements from the DOM.
|
|
1682
1757
|
*/
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1758
|
+
clearDividers() {
|
|
1759
|
+
for (const el of this._dividerElements) {
|
|
1760
|
+
el.remove();
|
|
1761
|
+
}
|
|
1762
|
+
this._dividerElements = [];
|
|
1686
1763
|
}
|
|
1687
1764
|
// --- UI Event Handlers ---
|
|
1688
1765
|
onDisconnect() {
|
|
@@ -1720,7 +1797,7 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1720
1797
|
clearTimeout(this._resizeTimer);
|
|
1721
1798
|
this._resizeTimer = null;
|
|
1722
1799
|
}
|
|
1723
|
-
this.
|
|
1800
|
+
this.clearDividers();
|
|
1724
1801
|
super.ngOnDestroy();
|
|
1725
1802
|
}
|
|
1726
1803
|
async canClose() {
|
|
@@ -1743,9 +1820,6 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
|
|
|
1743
1820
|
return null;
|
|
1744
1821
|
}
|
|
1745
1822
|
};
|
|
1746
|
-
// ─── Border-based pane separator ────────────────────────────────────────
|
|
1747
|
-
/** Pixels from the right/bottom edge of a .child that counts as "on border" */
|
|
1748
|
-
TmuxSessionTabComponent.BORDER_HIT = 10;
|
|
1749
1823
|
TmuxSessionTabComponent.ctorParameters = () => [
|
|
1750
1824
|
{ type: core_1.Injector },
|
|
1751
1825
|
{ type: tmux_service_1.TmuxService },
|
|
@@ -1759,7 +1833,7 @@ TmuxSessionTabComponent.propDecorators = {
|
|
|
1759
1833
|
profile: [{ type: core_1.Input }],
|
|
1760
1834
|
existingController: [{ type: core_1.Input }]
|
|
1761
1835
|
};
|
|
1762
|
-
TmuxSessionTabComponent =
|
|
1836
|
+
TmuxSessionTabComponent = __decorate([
|
|
1763
1837
|
(0, core_1.Component)({
|
|
1764
1838
|
selector: 'tmux-session-tab',
|
|
1765
1839
|
host: {
|
|
@@ -1778,7 +1852,7 @@ TmuxSessionTabComponent = TmuxSessionTabComponent_1 = __decorate([
|
|
|
1778
1852
|
(createWindow)="onCreateWindow()"
|
|
1779
1853
|
></tmux-window-bar>
|
|
1780
1854
|
`,
|
|
1781
|
-
styles: ["\n :host {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n }\n .pane-area {\n flex: 1 1 0;\n position: relative;\n min-height: 0;\n }\n /*
|
|
1855
|
+
styles: ["\n :host {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n }\n .pane-area {\n flex: 1 1 0;\n position: relative;\n min-height: 0;\n padding: 4px;\n box-sizing: border-box;\n }\n /* Pane containers: pixel-absolute positioned by applyPixelLayout().\n No border, no padding \u2014 the xterm canvas fills the entire box. */\n ::ng-deep .pane-area > .child {\n position: absolute;\n box-sizing: border-box;\n opacity: .75;\n transition: opacity 0.125s;\n }\n ::ng-deep .pane-area > .child.focused {\n opacity: 1;\n }\n /* Independent divider elements for pane boundaries + resize dragging.\n Width/height is set inline to 1 cell to match tmux's 1-char separator.\n The visible line is a 1px ::after pseudo-element centered in the hit area. */\n ::ng-deep .tmux-divider {\n position: absolute;\n z-index: 5;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n ::ng-deep .tmux-divider::after {\n content: '';\n background: rgba(128,128,128,0.3);\n transition: background 0.15s;\n }\n ::ng-deep .tmux-divider:hover::after {\n background: rgba(128,128,128,0.75);\n }\n ::ng-deep .tmux-divider.v { /* vertical divider: left-right split */\n cursor: col-resize;\n }\n ::ng-deep .tmux-divider.v::after {\n width: 1px;\n height: 100%;\n }\n ::ng-deep .tmux-divider.h { /* horizontal divider: top-bottom split */\n cursor: row-resize;\n }\n ::ng-deep .tmux-divider.h::after {\n height: 1px;\n width: 100%;\n }\n tmux-window-bar {\n flex: 0 0 auto;\n position: relative;\n z-index: 10;\n }\n "]
|
|
1782
1856
|
}),
|
|
1783
1857
|
__metadata("design:paramtypes", [core_1.Injector,
|
|
1784
1858
|
tmux_service_1.TmuxService,
|
|
@@ -1839,6 +1913,11 @@ let TmuxWindowBarComponent = class TmuxWindowBarComponent {
|
|
|
1839
1913
|
case 'initialized':
|
|
1840
1914
|
this.refreshWindows();
|
|
1841
1915
|
break;
|
|
1916
|
+
case 'layout-change':
|
|
1917
|
+
// Layout changes may affect pane count display
|
|
1918
|
+
// (e.g. zoom shows fewer panes in layout, but real count is unchanged)
|
|
1919
|
+
this.refreshWindows();
|
|
1920
|
+
break;
|
|
1842
1921
|
}
|
|
1843
1922
|
});
|
|
1844
1923
|
}
|
|
@@ -3280,7 +3359,20 @@ class TmuxController {
|
|
|
3280
3359
|
this.gateway.layoutChange$.subscribe(({ windowId, layout, visibleLayout, zoomed }) => {
|
|
3281
3360
|
const state = this.windowStates.get(windowId);
|
|
3282
3361
|
if (state) {
|
|
3362
|
+
// tmux %layout-change semantics:
|
|
3363
|
+
// layout = real multi-pane layout (all panes, actual sizes)
|
|
3364
|
+
// visibleLayout = what tmux displays (zoomed single pane when zoomed)
|
|
3283
3365
|
state.layout = layout;
|
|
3366
|
+
if (zoomed && visibleLayout) {
|
|
3367
|
+
// Extract zoomed pane ID from visibleLayout (the single pane filling window)
|
|
3368
|
+
const m = /\d+x\d+,\d+,\d+,(\d+)/.exec(visibleLayout);
|
|
3369
|
+
state.zoomedPaneId = m ? parseInt(m[1]) : undefined;
|
|
3370
|
+
state.visibleLayout = visibleLayout;
|
|
3371
|
+
}
|
|
3372
|
+
else {
|
|
3373
|
+
state.zoomedPaneId = undefined;
|
|
3374
|
+
state.visibleLayout = undefined;
|
|
3375
|
+
}
|
|
3284
3376
|
}
|
|
3285
3377
|
// Discover new panes from the layout string, then emit layout-change
|
|
3286
3378
|
this.discoverPanesFromLayout(windowId, layout, visibleLayout, zoomed);
|
|
@@ -3292,6 +3384,12 @@ class TmuxController {
|
|
|
3292
3384
|
this.activeWindowId = windowId;
|
|
3293
3385
|
this.events.next({ type: 'active-window-changed', windowId });
|
|
3294
3386
|
});
|
|
3387
|
+
// Handle pane focus changes (e.g. after pane close, tmux auto-focuses
|
|
3388
|
+
// the next pane and sends %window-pane-changed).
|
|
3389
|
+
this.gateway.paneChanged$.subscribe(({ windowId, paneId }) => {
|
|
3390
|
+
this.log.info(`Active pane changed to %${paneId} in window @${windowId}`);
|
|
3391
|
+
this.events.next({ type: 'active-pane-changed', paneId, windowId });
|
|
3392
|
+
});
|
|
3295
3393
|
this.gateway.exit$.subscribe(reason => {
|
|
3296
3394
|
this.attached = false;
|
|
3297
3395
|
this.events.next({ type: 'exit', data: { reason } });
|
|
@@ -3436,15 +3534,18 @@ class TmuxController {
|
|
|
3436
3534
|
* layout-change so syncLayout() runs after pane tabs exist.
|
|
3437
3535
|
*/
|
|
3438
3536
|
async discoverPanesFromLayout(windowId, layout, visibleLayout, zoomed) {
|
|
3439
|
-
// Extract pane IDs from
|
|
3440
|
-
//
|
|
3441
|
-
//
|
|
3537
|
+
// Extract pane IDs from layout strings.
|
|
3538
|
+
// When zoomed, the layout only contains the zoomed pane — also scan
|
|
3539
|
+
// visibleLayout (the real multi-pane layout) so all panes are discovered.
|
|
3442
3540
|
const paneIdSet = new Set();
|
|
3443
|
-
// Match all occurrences of ,\d+ at the end of leaf node specs
|
|
3444
3541
|
const leafPattern = /\d+x\d+,\d+,\d+,(\d+)/g;
|
|
3445
3542
|
let m;
|
|
3446
|
-
|
|
3447
|
-
|
|
3543
|
+
const layoutsToScan = zoomed && visibleLayout ? [layout, visibleLayout] : [layout];
|
|
3544
|
+
for (const ls of layoutsToScan) {
|
|
3545
|
+
leafPattern.lastIndex = 0;
|
|
3546
|
+
while ((m = leafPattern.exec(ls)) !== null) {
|
|
3547
|
+
paneIdSet.add(parseInt(m[1]));
|
|
3548
|
+
}
|
|
3448
3549
|
}
|
|
3449
3550
|
if (paneIdSet.size === 0)
|
|
3450
3551
|
return;
|
|
@@ -3459,6 +3560,30 @@ class TmuxController {
|
|
|
3459
3560
|
newPaneIds.push({ paneId, windowId });
|
|
3460
3561
|
}
|
|
3461
3562
|
}
|
|
3563
|
+
// Remove panes no longer in the layout (only when not zoomed).
|
|
3564
|
+
// When zoomed, the real layout is in visibleLayout, and pane-close
|
|
3565
|
+
// events should handle cleanup of actually closed panes.
|
|
3566
|
+
if (!zoomed && windowState) {
|
|
3567
|
+
const closedPaneIds = [];
|
|
3568
|
+
for (const paneId of windowState.panes) {
|
|
3569
|
+
if (!paneIdSet.has(paneId)) {
|
|
3570
|
+
closedPaneIds.push(paneId);
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
for (const paneId of closedPaneIds) {
|
|
3574
|
+
windowState.panes.delete(paneId);
|
|
3575
|
+
this.knownPanes.delete(paneId);
|
|
3576
|
+
// Clean up pane session
|
|
3577
|
+
const session = this.paneSessions.get(paneId);
|
|
3578
|
+
if (session) {
|
|
3579
|
+
session.destroy();
|
|
3580
|
+
this.paneSessions.delete(paneId);
|
|
3581
|
+
}
|
|
3582
|
+
this.pendingPaneOutput.delete(paneId);
|
|
3583
|
+
this.log.info(`Removed closed pane %${paneId} from window @${windowId} (not in layout)`);
|
|
3584
|
+
this.events.next({ type: 'pane-close', paneId, windowId });
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3462
3587
|
if (newPaneIds.length > 0) {
|
|
3463
3588
|
this.log.info(`Discovered ${newPaneIds.length} new pane(s) from layout-change for window @${windowId}`);
|
|
3464
3589
|
// Capture history + state for new panes (same as discoverWindowsAndPanes Step 3)
|
|
@@ -3519,7 +3644,15 @@ class TmuxController {
|
|
|
3519
3644
|
registerPane(paneId, session) {
|
|
3520
3645
|
this.paneSessions.set(paneId, session);
|
|
3521
3646
|
this.knownPanes.add(paneId);
|
|
3522
|
-
//
|
|
3647
|
+
// If a snapshot exists, the pending output is redundant — the snapshot
|
|
3648
|
+
// already contains the same content (and more). Discard it to avoid
|
|
3649
|
+
// writing the prompt/scrollback twice (once from buffered %output,
|
|
3650
|
+
// once from capture-pane history in restorePaneHistory).
|
|
3651
|
+
if (this.pendingSnapshots.has(paneId)) {
|
|
3652
|
+
this.pendingPaneOutput.delete(paneId);
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
// No snapshot (shouldn't happen normally) — flush buffered output
|
|
3523
3656
|
const buffered = this.pendingPaneOutput.get(paneId);
|
|
3524
3657
|
if (buffered) {
|
|
3525
3658
|
for (const data of buffered) {
|
|
@@ -3809,6 +3942,13 @@ class TmuxController {
|
|
|
3809
3942
|
async killPane(paneId) {
|
|
3810
3943
|
await this.gateway.sendCommand(`kill-pane -t %${paneId}`, gateway_1.TMUX_COMMAND_TOLERATE_ERRORS);
|
|
3811
3944
|
}
|
|
3945
|
+
/**
|
|
3946
|
+
* Toggle zoom on a pane (tmux prefix+z equivalent).
|
|
3947
|
+
* When zoomed, the pane fills the entire window; other panes are hidden.
|
|
3948
|
+
*/
|
|
3949
|
+
async zoomPane(paneId) {
|
|
3950
|
+
await this.gateway.sendCommand(`resize-pane -Z -t %${paneId}`, gateway_1.TMUX_COMMAND_TOLERATE_ERRORS);
|
|
3951
|
+
}
|
|
3812
3952
|
// --- Window Operations ---
|
|
3813
3953
|
async createWindow() {
|
|
3814
3954
|
try {
|