Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions ModernTests/PTYTabTmuxTabGroupSettingsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// PTYTabTmuxTabGroupSettingsTests.swift
// ModernTests
//
// Tab group membership of tmux tabs is saved in the @per_tab_settings session
// option so it survives detach and reattach. These tests cover the per-tab
// encoding: g= (group ID), gn= (name), gc= (color), gx= (collapsed).
//

import XCTest
@testable import iTerm2SharedARC

final class PTYTabTmuxTabGroupSettingsTests: XCTestCase {
private func makeTab() -> PTYTab {
let session = PTYSession(synthetic: false)!
session.view = SessionView(frame: NSRect(x: 0, y: 0, width: 400, height: 300))
return PTYTab(session: session, parentWindow: nil)!
}

private func base64(_ string: String) -> String {
return Data(string.utf8).base64EncodedString()
}

func testUngroupedTabWithoutTitleHasNoSetting() {
XCTAssertNil(makeTab().tmuxPerTabSetting())
}

func testGroupRoundTrips() throws {
let original = makeTab()
original.tabGroupID = "8C1F0E5A-2B7D-4E3A-9F11-0D6C2A7B5E44"
original.tabGroupName = "web & api; a=b: c"
original.tabGroupColor = NSColor(srgbRed: 0.2, green: 0.4, blue: 0.6, alpha: 1)
original.tabGroupCollapsed = true

let setting = try XCTUnwrap(original.tmuxPerTabSetting())
XCTAssertFalse(setting.contains(";"), "Semicolon separates tabs in @per_tab_settings")

let restored = makeTab()
restored.setPerTabSettings(setting)
XCTAssertEqual(restored.tabGroupID, original.tabGroupID)
XCTAssertEqual(restored.tabGroupName, original.tabGroupName)
XCTAssertTrue(restored.tabGroupCollapsed)
XCTAssertEqual(restored.tmuxPerTabSetting(), setting)
}

func testGroupColorKeepsItsColorSpaceAcrossSaves() throws {
let colors = [
NSColor(srgbRed: 0.2, green: 0.4, blue: 0.6, alpha: 1),
NSColor(srgbRed: 1, green: 0.5, blue: 0, alpha: 1),
NSColor(displayP3Red: 0.9, green: 0.1, blue: 0.3, alpha: 1),
// New groups get system colors, which have no color space of their own.
NSColor.systemBlue,
]
for color in colors {
let original = makeTab()
original.tabGroupID = "group-color"
original.tabGroupColor = color
var setting = try XCTUnwrap(original.tmuxPerTabSetting())
// Saving the restored tab again must give the same value, so repeated
// detach and reattach can't drift the color.
for _ in 0..<3 {
let restored = makeTab()
restored.setPerTabSettings(setting)
if color.type == .componentBased {
XCTAssertEqual(restored.tabGroupColor?.colorSpace, color.colorSpace)
}
let resaved = try XCTUnwrap(restored.tmuxPerTabSetting())
XCTAssertEqual(resaved, setting)
setting = resaved
}
}
}

func testTitleAndGroupRoundTripTogether() throws {
let original = makeTab()
original.setPerTabSettings("t=" + base64("my title"))
original.tabGroupID = "group-1"
original.tabGroupName = "Group"

let setting = try XCTUnwrap(original.tmuxPerTabSetting())
let restored = makeTab()
restored.setPerTabSettings(setting)
let parts = try XCTUnwrap(restored.tmuxPerTabSetting()).components(separatedBy: "&")
XCTAssertTrue(parts.contains("t=" + base64("my title")))
XCTAssertEqual(restored.tabGroupID, "group-1")
XCTAssertEqual(restored.tabGroupName, "Group")
XCTAssertFalse(restored.tabGroupCollapsed)
}

func testUnknownKeysAreIgnored() {
let tab = makeTab()
tab.setPerTabSettings("zz=whatever&g=\(base64("group-2"))&gn=\(base64("Two"))&qq")
XCTAssertEqual(tab.tabGroupID, "group-2")
XCTAssertEqual(tab.tabGroupName, "Two")
}

func testSettingWithoutGroupLeavesExistingGroupAlone() {
// An older version rewrites @per_tab_settings without group keys. Applying
// that must not ungroup a tab.
let tab = makeTab()
tab.tabGroupID = "group-3"
tab.tabGroupName = "Three"
tab.setPerTabSettings("t=" + base64("title"))
XCTAssertEqual(tab.tabGroupID, "group-3")
XCTAssertEqual(tab.tabGroupName, "Three")
}
}
1 change: 1 addition & 0 deletions sources/PTYSession/PTYSession.m
Original file line number Diff line number Diff line change
Expand Up @@ -11001,6 +11001,7 @@ - (void)tmuxWindowClosedWithId:(int)windowId
if (tab) {
[[tab realParentWindow] removeTab:tab];
}
[_tmuxController forgetPerTabSettingsForWindow:windowId];
[_tmuxController windowsChanged];
}

Expand Down
72 changes: 67 additions & 5 deletions sources/TerminalView/PTYTab.m
Original file line number Diff line number Diff line change
Expand Up @@ -7600,19 +7600,65 @@ - (id)valueForVariable:(NSString *)name {
return [self.variablesScope valueForVariableName:name];
}

// Like -hexStringPreservingColorSpace, but rounds sRGB components rather than truncating them so
// the color doesn't darken a little on each save. Colors that aren't sRGB or Display P3 (such as
// the system colors new groups get) are saved as sRGB.
- (NSString *)tmuxTabGroupColorString {
NSColor *color = self.tabGroupColor;
if (!color) {
return nil;
}
if (color.type == NSColorTypeComponentBased &&
[color.colorSpace isEqual:[NSColorSpace displayP3ColorSpace]]) {
return [color hexStringPreservingColorSpace];
}
color = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]];
if (!color) {
return nil;
}
return [NSString stringWithFormat:@"#%02x%02x%02x",
(int)round(MAX(0, MIN(1, color.redComponent)) * 255),
(int)round(MAX(0, MIN(1, color.greenComponent)) * 255),
(int)round(MAX(0, MIN(1, color.blueComponent)) * 255)];
}

- (NSString *)tmuxPerTabSetting {
// key=value&key=value&...
// Semicolon is reserved. Don't use it. Older versions ignore unknown keys.
NSMutableArray<NSString *> *parts = [NSMutableArray array];
NSString *format = self.variablesScope.tabTitleOverrideFormat;
if (format == nil) {
if (format != nil) {
[parts addObject:[NSString stringWithFormat:@"t=%@", [format base64EncodedWithEncoding:NSUTF8StringEncoding]]];
}
if (self.tabGroupID.length > 0) {
// g=[base64 group ID], gn=[base64 group name], gc=[group color as #rrggbb or p3#rrrrggggbbbb],
// gx=1 if collapsed.
// Every member carries the group's attributes, as in window arrangements.
[parts addObject:[NSString stringWithFormat:@"g=%@", [self.tabGroupID base64EncodedWithEncoding:NSUTF8StringEncoding]]];
if (self.tabGroupName.length > 0) {
[parts addObject:[NSString stringWithFormat:@"gn=%@", [self.tabGroupName base64EncodedWithEncoding:NSUTF8StringEncoding]]];
}
NSString *colorString = [self tmuxTabGroupColorString];
if (colorString.length > 0) {
[parts addObject:[NSString stringWithFormat:@"gc=%@", colorString]];
}
if (self.tabGroupCollapsed) {
[parts addObject:@"gx=1"];
}
}
if (parts.count == 0) {
return nil;
}
// key=value&key=value&...
// Semicolon is reserved. Don't use it.
return [NSString stringWithFormat:@"%@=%@", @"t", [format base64EncodedWithEncoding:NSUTF8StringEncoding]];
return [parts componentsJoinedByString:@"&"];
}

- (void)setPerTabSettings:(NSString *)setting {
DLog(@"SET per-tab settings %@ for %@", setting, self);
NSArray<NSString *> *parts = [setting componentsSeparatedByString:@"&"];
NSString *groupID = nil;
NSString *groupName = nil;
NSColor *groupColor = nil;
BOOL groupCollapsed = NO;
for (NSString *part in parts) {
iTermTuple<NSString *, NSString *> *kvp = [part it_stringBySplittingOnFirstSubstring:@"="];
if (!kvp) {
Expand All @@ -7626,7 +7672,23 @@ - (void)setPerTabSettings:(NSString *)setting {
self.variablesScope.tabTitleOverrideFormat = titleOverride;
}
}
}
} else if ([kvp.firstObject isEqualToString:@"g"]) {
groupID = [kvp.secondObject stringByBase64DecodingStringWithEncoding:NSUTF8StringEncoding];
} else if ([kvp.firstObject isEqualToString:@"gn"]) {
groupName = [kvp.secondObject stringByBase64DecodingStringWithEncoding:NSUTF8StringEncoding];
} else if ([kvp.firstObject isEqualToString:@"gc"]) {
groupColor = kvp.secondObject.length > 0 ? [NSColor colorPreservingColorspaceFromString:kvp.secondObject] : nil;
} else if ([kvp.firstObject isEqualToString:@"gx"]) {
groupCollapsed = [kvp.secondObject isEqualToString:@"1"];
}
}
// Only ever adds group membership. A setting without a group (e.g., written by an older
// version) must not ungroup a tab the user grouped since it was saved.
if (groupID.length > 0) {
self.tabGroupID = groupID;
self.tabGroupName = groupName;
self.tabGroupColor = groupColor;
self.tabGroupCollapsed = groupCollapsed;
}
}

Expand Down
3 changes: 3 additions & 0 deletions sources/TerminalView/PseudoTerminal.h
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ extern NSString *const iTermDidCreateTerminalWindowNotification;
// tab-group contiguity invariant and re-push membership to the tab bar.
- (void)tabsDidReorder;

// Call after saved tab groups are applied to tmux tabs as their windows open.
- (void)tmuxTabGroupsDidLoadForTabs:(NSArray<PTYTab *> *)restoredTabs;

// Updates the window when screen parameters (number of screens, resolutions,
// etc.) change.
- (void)screenParametersDidChange;
Expand Down
36 changes: 36 additions & 0 deletions sources/TerminalView/PseudoTerminal.m
Original file line number Diff line number Diff line change
Expand Up @@ -9016,6 +9016,42 @@ - (void)updateTabGroups {
}
[control setTabGroupIdentifiers:identifiers forTabViewItems:items];
[control setTabGroupCollapsedFlags:collapsedFlags forTabViewItems:items];

// Groups of tmux tabs are saved in tmux so they survive detach and reattach.
NSMutableSet<TmuxController *> *controllers = [NSMutableSet set];
for (PTYTab *aTab in tabs) {
if (aTab.tmuxController) {
[controllers addObject:aTab.tmuxController];
}
}
for (TmuxController *controller in controllers) {
[controller tabGroupsDidChange];
}
}

- (void)tmuxTabGroupsDidLoadForTabs:(NSArray<PTYTab *> *)restoredTabs {
// A restored tab joining a group that's already open here (e.g., a window reopened
// from the dashboard) may have been saved before the group was renamed or recolored.
// The open members are current, so take their attributes.
for (PTYTab *restoredTab in restoredTabs) {
if (restoredTab.tabGroupID.length == 0) {
continue;
}
PTYTab *current = [self.tabs objectPassingTest:^BOOL(PTYTab *tab, NSUInteger index, BOOL *stop) {
return ![restoredTabs containsObject:tab] && [tab.tabGroupID isEqualToString:restoredTab.tabGroupID];
}];
if (current) {
restoredTab.tabGroupName = current.tabGroupName;
restoredTab.tabGroupColor = current.tabGroupColor;
restoredTab.tabGroupCollapsed = current.tabGroupCollapsed;
}
}
// Same steps as restoring an arrangement: keep members adjacent, make each group's
// collapsed state uniform, then push it to the tab bar without animating.
[self enforceTabGroupContiguityInvariant];
[self normalizeTabGroupCollapsedState];
[self updateTabGroups];
[self relayoutTabGroupChipsSynchronously];
}

// Re-derive the group chips and lay the tab bar out synchronously. The drag
Expand Down
5 changes: 5 additions & 0 deletions sources/tmux/TmuxController.h
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,11 @@ extern NSString *const kTmuxControllerDidChangeHiddenWindows;
- (void)saveHiddenWindows;
- (void)savePerWindowSettings;
- (void)savePerTabSettings;
// Call when tab groups may have changed. Saves per-tab settings soon if they did,
// unless windows are still opening.
- (void)tabGroupsDidChange;
// Call when a tmux window closes so its saved per-tab settings aren't carried forward.
- (void)forgetPerTabSettingsForWindow:(int)windowId;

- (void)swapPane:(int)pane1 withPane:(int)pane2;
// Floating panes (tmux 3.8 and later). Each change comes back as a layout change.
Expand Down
Loading