diff --git a/ModernTests/PTYTabTmuxTabGroupSettingsTests.swift b/ModernTests/PTYTabTmuxTabGroupSettingsTests.swift new file mode 100644 index 0000000000..10850b6e55 --- /dev/null +++ b/ModernTests/PTYTabTmuxTabGroupSettingsTests.swift @@ -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") + } +} diff --git a/sources/PTYSession/PTYSession.m b/sources/PTYSession/PTYSession.m index d86df41f20..3a9dad7934 100644 --- a/sources/PTYSession/PTYSession.m +++ b/sources/PTYSession/PTYSession.m @@ -11001,6 +11001,7 @@ - (void)tmuxWindowClosedWithId:(int)windowId if (tab) { [[tab realParentWindow] removeTab:tab]; } + [_tmuxController forgetPerTabSettingsForWindow:windowId]; [_tmuxController windowsChanged]; } diff --git a/sources/TerminalView/PTYTab.m b/sources/TerminalView/PTYTab.m index ab31e3bc41..6bf1d51105 100644 --- a/sources/TerminalView/PTYTab.m +++ b/sources/TerminalView/PTYTab.m @@ -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 *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 *parts = [setting componentsSeparatedByString:@"&"]; + NSString *groupID = nil; + NSString *groupName = nil; + NSColor *groupColor = nil; + BOOL groupCollapsed = NO; for (NSString *part in parts) { iTermTuple *kvp = [part it_stringBySplittingOnFirstSubstring:@"="]; if (!kvp) { @@ -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; } } diff --git a/sources/TerminalView/PseudoTerminal.h b/sources/TerminalView/PseudoTerminal.h index 4a7f9f0a98..f158e6e2a4 100644 --- a/sources/TerminalView/PseudoTerminal.h +++ b/sources/TerminalView/PseudoTerminal.h @@ -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 *)restoredTabs; + // Updates the window when screen parameters (number of screens, resolutions, // etc.) change. - (void)screenParametersDidChange; diff --git a/sources/TerminalView/PseudoTerminal.m b/sources/TerminalView/PseudoTerminal.m index 511bf41c88..bc551d749d 100644 --- a/sources/TerminalView/PseudoTerminal.m +++ b/sources/TerminalView/PseudoTerminal.m @@ -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 *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 *)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 diff --git a/sources/tmux/TmuxController.h b/sources/tmux/TmuxController.h index e93c33f976..3e9895baba 100644 --- a/sources/tmux/TmuxController.h +++ b/sources/tmux/TmuxController.h @@ -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. diff --git a/sources/tmux/TmuxController.m b/sources/tmux/TmuxController.m index e4eaa04a91..14fa4451b1 100644 --- a/sources/tmux/TmuxController.m +++ b/sources/tmux/TmuxController.m @@ -231,6 +231,14 @@ @implementation TmuxController { NSString *_lastSavePerTabSettingsCommand; NSDictionary *_perTabSettings; + // NO until @per_tab_settings has been read for the current session. Saving before then + // would overwrite it with whatever this client happens to have. + BOOL _perTabSettingsLoaded; + // Settings of the open tabs as of the last save, to skip saves when nothing changed. + NSString *_lastLivePerTabSettings; + BOOL _tabGroupSaveScheduled; + // Windows opened since restored tab groups were last applied. + NSMutableSet *_windowsOpenedSinceTabGroupsApplied; // When positive do not send select-pane or select-window commands when the selected pane // or window changes. This is to prevent getting into a loop like this: @@ -319,6 +327,7 @@ - (instancetype)initWithGateway:(TmuxGateway *)gateway origins_ = [[NSMutableDictionary alloc] init]; pendingWindowOpens_ = [[NSMutableSet alloc] init]; hiddenWindows_ = [[NSMutableSet alloc] init]; + _windowsOpenedSinceTabGroupsApplied = [[NSMutableSet alloc] init]; _hotkeys = [[NSMutableDictionary alloc] init]; _tabColors = [[NSMutableDictionary alloc] init]; self.clientName = [[TmuxControllerRegistry sharedInstance] uniqueClientNameBasedOn:clientName]; @@ -560,6 +569,8 @@ - (void)sessionChangedTo:(NSString *)newSessionName sessionId:(int)sessionid { self.sessionGuid = nil; self.sessionName = newSessionName; sessionId_ = sessionid; + _perTabSettingsLoaded = NO; + _lastLivePerTabSettings = nil; _paneToActivateWhenCreated = -1; _tmuxCurrentWindowID = -1; _detaching = YES; @@ -700,6 +711,7 @@ - (void)initialListWindowsResponse:(NSString *)response { return; } [self pinWindowSizesThenSetDeferredClientSizeForInitialListing:doc]; + [self forgetPerTabSettingsForWindowsNotIn:doc]; NSMutableArray *windowsToOpen = [NSMutableArray array]; BOOL haveHidden = NO; NSNumber *newWindowAffinity = nil; @@ -3312,22 +3324,55 @@ - (void)saveBuriedIndexes { [gateway_ sendCommand:command responseTarget:nil responseSelector:nil]; } -- (void)savePerTabSettings { - NSMutableArray *settings = [NSMutableArray array]; - iTermController *cont = [iTermController sharedInstance]; - NSArray *terminals = [cont terminals]; - for (PseudoTerminal *term in terminals) { +// Per-tab settings of this controller's open tabs, keyed by window ID. A tab with nothing to +// save maps to the empty string. +- (NSDictionary *)livePerTabSettings { + NSMutableDictionary *settings = [NSMutableDictionary dictionary]; + for (PseudoTerminal *term in [[iTermController sharedInstance] terminals]) { for (PTYTab *tab in term.tabs) { - if (!tab.isTmuxTab) { + if (!tab.isTmuxTab || tab.tmuxController != self) { continue; } - NSString *setting = [tab tmuxPerTabSetting]; - if (setting) { - [settings addObject:[NSString stringWithFormat:@"%d:%@", tab.tmuxWindow, setting]]; + // Tabs left open after a detach or session switch no longer belong to this session. + if (![tab.sessions anyWithBlock:^BOOL(PTYSession *session) { return !session.exited; }]) { + continue; } + settings[[@(tab.tmuxWindow) stringValue]] = [tab tmuxPerTabSetting] ?: @""; } } - NSString *arg = [settings componentsJoinedByString:@";"]; + return settings; +} + +- (NSString *)perTabSettingsStringFromDictionary:(NSDictionary *)settings { + NSArray *keys = [settings.allKeys sortedArrayUsingSelector:@selector(compare:)]; + return [[keys mapWithBlock:^id(NSString *key) { + if (settings[key].length == 0) { + return nil; + } + return [NSString stringWithFormat:@"%@:%@", key, settings[key]]; + }] componentsJoinedByString:@";"]; +} + +- (void)savePerTabSettings { + if (_detaching || detached_ || !_perTabSettingsLoaded) { + return; + } + NSDictionary *live = [self livePerTabSettings]; + _lastLivePerTabSettings = [self perTabSettingsStringFromDictionary:live]; + + // Windows without an open tab (hidden, still opening, or being reopened from the + // dashboard) keep their last known settings. They're forgotten when the window closes. + NSMutableDictionary *settings = [_perTabSettings ?: @{} mutableCopy]; + [live enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *setting, BOOL *stop) { + if (setting.length) { + settings[key] = setting; + } else { + [settings removeObjectForKey:key]; + } + }]; + NSString *arg = [self perTabSettingsStringFromDictionary:settings]; + _perTabSettings = [settings copy]; + DLog(@"Save per-tab settings: %@", arg); NSString *command = [NSString stringWithFormat:@"set -t $%d @per_tab_settings \"%@\"", sessionId_, @@ -3339,8 +3384,49 @@ - (void)savePerTabSettings { [gateway_ sendCommand:command responseTarget:nil responseSelector:nil]; } +- (void)tabGroupsDidChange { + // This is called on every tab bar update, so coalesce and save only on a real change. + if (_tabGroupSaveScheduled) { + return; + } + _tabGroupSaveScheduled = YES; + __weak __typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf saveTabGroupsIfChanged]; + }); +} + +- (void)saveTabGroupsIfChanged { + _tabGroupSaveScheduled = NO; + if (pendingWindowOpens_.count || _detaching || detached_ || !_perTabSettingsLoaded) { + return; + } + NSString *live = [self perTabSettingsStringFromDictionary:[self livePerTabSettings]]; + if ([live isEqualToString:_lastLivePerTabSettings]) { + return; + } + [self savePerTabSettings]; +} + +// Windows closed while no client was attached never send %window-close. +- (void)forgetPerTabSettingsForWindowsNotIn:(TSVDocument *)doc { + NSSet *windowIDs = [NSSet setWithArray:[doc.records mapWithBlock:^id(NSArray *record) { + return [@([self windowIdFromString:[doc valueInRecord:record forField:@"window_id"]]) stringValue]; + }]]; + _perTabSettings = [_perTabSettings filteredWithBlock:^BOOL(NSString *key, NSString *value) { + return [windowIDs containsObject:key]; + }]; +} + +- (void)forgetPerTabSettingsForWindow:(int)windowId { + NSMutableDictionary *settings = [_perTabSettings mutableCopy]; + [settings removeObjectForKey:[@(windowId) stringValue]]; + _perTabSettings = [settings copy]; +} + - (void)getPerTabSettingsResponse:(NSString *)result { [self setPerTabSettingsFromString:[self decodedString:result optionalPrefix:iTermTmuxControllerEncodingPrefixPerTabSettings]]; + _perTabSettingsLoaded = YES; } - (void)savePerWindowSettings { @@ -4262,8 +4348,19 @@ - (void)closeAllPanes { - (void)windowDidOpen:(TmuxWindowOpener *)windowOpener { NSNumber *windowIndex = @(windowOpener.windowIndex); RLog(@"TmuxController windowDidOpen for index %@ with error count %@", windowIndex, @(windowOpener.errorCount)); + // Layout updates also end here, but only real opens were pending. + const BOOL opened = [pendingWindowOpens_ containsObject:windowIndex]; [pendingWindowOpens_ removeObject:windowIndex]; + if (opened && windowOpener.errorCount == 0) { + [_windowsOpenedSinceTabGroupsApplied addObject:windowIndex]; + } if (windowOpener.errorCount != 0) { + if (pendingWindowOpens_.count == 0) { + [self applyRestoredTabGroups]; + // A group change made while windows were opening wasn't saved, and this path + // skips saveAffinities. + [self tabGroupsDidChange]; + } [affinities_ removeValue:[@(windowOpener.windowIndex) stringValue]]; [[iTermNotificationController sharedInstance] notify:NSLocalizedStringWithDefaultValue(@"TmuxController.ErrorOpeningTabTitle", nil, [NSBundle mainBundle], @"Error opening tmux tab", @"Notification title shown when a tmux tab fails to open") withDescription:NSLocalizedStringWithDefaultValue(@"TmuxController.ErrorOpeningTabDescription", nil, [NSBundle mainBundle], @"A tmux pane terminated immediately after creation", @"Notification body explaining that a tmux pane terminated immediately after creation")]; @@ -4284,10 +4381,29 @@ - (void)windowDidOpen:(TmuxWindowOpener *)windowOpener { _initialWindowHint = NSZeroRect; [self saveAffinities]; if (pendingWindowOpens_.count == 0) { + [self applyRestoredTabGroups]; [self sendInitialWindowsOpenedNotificationIfNeeded]; } } +// Tab group membership comes from @per_tab_settings, which is applied to each tab as its +// window opens. Once every window has opened, let each window show its groups. +- (void)applyRestoredTabGroups { + if (_windowsOpenedSinceTabGroupsApplied.count == 0) { + return; + } + NSSet *opened = [_windowsOpenedSinceTabGroupsApplied copy]; + [_windowsOpenedSinceTabGroupsApplied removeAllObjects]; + for (PseudoTerminal *term in [[iTermController sharedInstance] terminals]) { + NSArray *restoredTabs = [term.tabs filteredArrayUsingBlock:^BOOL(PTYTab *tab) { + return tab.tmuxController == self && [opened containsObject:@(tab.tmuxWindow)]; + }]; + if ([restoredTabs anyWithBlock:^BOOL(PTYTab *tab) { return tab.tabGroupID.length > 0; }]) { + [term tmuxTabGroupsDidLoadForTabs:restoredTabs]; + } + } +} + - (void)sendInitialWindowsOpenedNotificationIfNeeded { if (!_haveOpenedInitialWindows) { [gateway_.delegate tmuxDidOpenInitialWindows];