// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_group_mediator.h"
#import <algorithm>
#import "base/check.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/drag_and_drop/model/drag_item_util.h"
#import "ios/chrome/browser/policy/model/policy_util.h"
#import "ios/chrome/browser/saved_tab_groups/model/ios_tab_group_sync_util.h"
#import "ios/chrome/browser/saved_tab_groups/model/tab_group_sync_service_factory.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/shared/model/web_state_list/browser_util.h"
#import "ios/chrome/browser/shared/model/web_state_list/tab_group.h"
#import "ios/chrome/browser/shared/model/web_state_list/tab_utils.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/commands/tab_groups_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_collection_consumer.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_collection_drag_drop_metrics.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_item_identifier.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_utils.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_grid_idle_status_handler.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_group_consumer.h"
#import "ios/chrome/browser/ui/tab_switcher/web_state_tab_switcher_item.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/web/public/navigation/navigation_manager.h"
#import "ios/web/public/web_state.h"
#import "ios/web/public/web_state_id.h"
@implementation TabGroupMediator {
// Tab group consumer.
__weak id<TabGroupConsumer> _groupConsumer;
// Current group.
base::WeakPtr<const TabGroup> _tabGroup;
}
- (instancetype)initWithWebStateList:(WebStateList*)webStateList
tabGroup:(base::WeakPtr<const TabGroup>)tabGroup
consumer:(id<TabGroupConsumer>)groupConsumer
gridConsumer:(id<TabCollectionConsumer>)gridConsumer
modeHolder:(TabGridModeHolder*)modeHolder {
CHECK(IsTabGroupInGridEnabled())
<< "You should not be able to create a tab group mediator outside the "
"Tab Groups experiment.";
CHECK(webStateList);
CHECK(groupConsumer);
CHECK(tabGroup);
if ((self = [super initWithModeHolder:modeHolder])) {
self.webStateList = webStateList;
_groupConsumer = groupConsumer;
self.consumer = gridConsumer;
_tabGroup = tabGroup;
[_groupConsumer setGroupTitle:tabGroup->GetTitle()];
[_groupConsumer setGroupColor:tabGroup->GetColor()];
[self populateConsumerItems];
}
return self;
}
#pragma mark - TabGroupMutator
- (BOOL)addNewItemInGroup {
[self.tabGridIdleStatusHandler
tabGridDidPerformAction:TabGridActionType::kInPageAction];
return [self addTabToGroup:_tabGroup.get()];
}
- (void)ungroup {
[self.tabGridIdleStatusHandler
tabGridDidPerformAction:TabGridActionType::kInPageAction];
auto scoped_lock = self.webStateList->StartBatchOperation();
self.webStateList->DeleteGroup(_tabGroup.get());
_tabGroup.reset();
}
- (void)closeGroup {
[self.tabGridIdleStatusHandler
tabGridDidPerformAction:TabGridActionType::kInPageAction];
if (IsTabGroupSyncEnabled()) {
tab_groups::TabGroupSyncService* syncService =
tab_groups::TabGroupSyncServiceFactory::GetForBrowserState(
self.browser->GetBrowserState());
tab_groups::utils::CloseTabGroupLocally(_tabGroup.get(), self.webStateList,
syncService);
} else {
CloseAllWebStatesInGroup(*self.webStateList, _tabGroup.get(),
WebStateList::CLOSE_USER_ACTION);
}
_tabGroup.reset();
}
- (void)deleteGroup {
[self.tabGridIdleStatusHandler
tabGridDidPerformAction:TabGridActionType::kInPageAction];
CloseAllWebStatesInGroup(*self.webStateList, _tabGroup.get(),
WebStateList::CLOSE_USER_ACTION);
_tabGroup.reset();
}
#pragma mark - Parent's functions
- (void)configureToolbarsButtons {
// No-op
}
// Overrides the parent to only display tabs from the group.
- (void)populateConsumerItems {
if (!self.webStateList || !_tabGroup) {
return;
}
GridItemIdentifier* identifier = nil;
int webStateIndex = self.webStateList->active_index();
if (webStateIndex != WebStateList::kInvalidIndex &&
self.webStateList->GetGroupOfWebStateAt(webStateIndex) ==
_tabGroup.get()) {
web::WebState* webState = self.webStateList->GetWebStateAt(webStateIndex);
identifier = [GridItemIdentifier tabIdentifier:webState];
}
[self.consumer populateItems:CreateTabItems(self.webStateList,
_tabGroup->range())
selectedItemIdentifier:identifier];
}
// Override the parent to only show individual web state in the group.
- (GridItemIdentifier*)activeIdentifier {
WebStateList* webStateList = self.webStateList;
if (!webStateList || !_tabGroup) {
return nil;
}
int webStateIndex = webStateList->active_index();
if (webStateIndex == WebStateList::kInvalidIndex) {
return nil;
}
if (!_tabGroup->range().contains(webStateIndex)) {
return nil;
}
return [GridItemIdentifier
tabIdentifier:webStateList->GetWebStateAt(webStateIndex)];
}
// Overrides the parent observations: only observe the group `WebState`s.
- (void)addWebStateObservations {
if (!_tabGroup) {
return;
}
for (int index : _tabGroup->range()) {
web::WebState* webState = self.webStateList->GetWebStateAt(index);
[self addObservationForWebState:webState];
}
}
// Overrides the parent as there is only tab cells.
- (void)insertItem:(GridItemIdentifier*)item
beforeWebStateIndex:(int)nextWebStateIndex {
GridItemIdentifier* nextItemIdentifier;
if (nextWebStateIndex < _tabGroup->range().range_end()) {
nextItemIdentifier = [GridItemIdentifier
tabIdentifier:self.webStateList->GetWebStateAt(nextWebStateIndex)];
}
[self.consumer insertItem:item
beforeItemID:nextItemIdentifier
selectedItemIdentifier:[self activeIdentifier]];
}
// Overrides the parent as there is only tab cells.
- (void)moveItem:(GridItemIdentifier*)item
beforeWebStateIndex:(int)nextWebStateIndex {
GridItemIdentifier* nextItem;
if (nextWebStateIndex < _tabGroup->range().range_end()) {
nextItem = [GridItemIdentifier
tabIdentifier:self.webStateList->GetWebStateAt(nextWebStateIndex)];
}
[self.consumer moveItem:item beforeItem:nextItem];
}
// Overrides the parent as there is only tab cells.
- (void)updateConsumerItemForWebState:(web::WebState*)webState {
GridItemIdentifier* item = [GridItemIdentifier tabIdentifier:webState];
[self.consumer replaceItem:item withReplacementItem:item];
}
- (void)insertNewWebStateAtGridIndex:(int)index withURL:(const GURL&)newTabURL {
CHECK(self.browser->GetBrowserState());
web::WebState::CreateParams params(self.browser->GetBrowserState());
std::unique_ptr<web::WebState> webState = web::WebState::Create(params);
int webStateListIndex = _tabGroup->range().range_begin() + index;
webStateListIndex =
std::clamp(webStateListIndex, _tabGroup->range().range_begin(),
_tabGroup->range().range_end());
const auto insertionParams =
WebStateList::InsertionParams::AtIndex(webStateListIndex)
.InGroup(_tabGroup.get())
.Activate();
web::NavigationManager::WebLoadParams loadParams(newTabURL);
loadParams.transition_type = ui::PAGE_TRANSITION_TYPED;
webState->GetNavigationManager()->LoadURLWithParams(loadParams);
self.webStateList->InsertWebState(std::move(webState), insertionParams);
}
- (BOOL)canHandleTabGroupDrop:(TabGroupInfo*)tabGroupInfo {
return NO;
}
- (void)recordExternalURLDropped {
base::UmaHistogramEnumeration(kUmaGroupViewDragOrigin,
DragItemOrigin::kOther);
}
#pragma mark - TabCollectionDragDropHandler override
// Overrides the parent as the given destination index do not take into account
// elements outside the group.
- (void)dropItem:(UIDragItem*)dragItem
toIndex:(NSUInteger)destinationIndex
fromSameCollection:(BOOL)fromSameCollection {
WebStateList* webStateList = self.webStateList;
// Tab move operations only originate from Chrome so a local object is used.
// Local objects allow synchronous drops, whereas NSItemProvider only allows
// asynchronous drops.
if ([dragItem.localObject isKindOfClass:[TabInfo class]]) {
int destinationWebStateIndex =
_tabGroup->range().range_begin() + destinationIndex;
TabInfo* tabInfo = static_cast<TabInfo*>(dragItem.localObject);
int sourceWebStateIndex =
GetWebStateIndex(webStateList, WebStateSearchCriteria{
.identifier = tabInfo.tabID,
});
const auto insertionParams =
WebStateList::InsertionParams::AtIndex(destinationWebStateIndex)
.InGroup(_tabGroup.get());
if (sourceWebStateIndex == WebStateList::kInvalidIndex) {
base::UmaHistogramEnumeration(kUmaGroupViewDragOrigin,
DragItemOrigin::kOtherBrowser);
MoveTabToBrowser(tabInfo.tabID, self.browser, insertionParams);
return;
}
if (fromSameCollection) {
base::UmaHistogramEnumeration(kUmaGroupViewDragOrigin,
DragItemOrigin::kSameCollection);
} else {
base::UmaHistogramEnumeration(kUmaGroupViewDragOrigin,
DragItemOrigin::kSameBrowser);
}
// Reorder tabs.
MoveWebStateWithIdentifierToInsertionParams(
tabInfo.tabID, insertionParams, webStateList, fromSameCollection);
return;
}
base::UmaHistogramEnumeration(kUmaGroupViewDragOrigin,
DragItemOrigin::kOther);
// Handle URLs from within Chrome synchronously using a local object.
if ([dragItem.localObject isKindOfClass:[URLInfo class]]) {
URLInfo* droppedURL = static_cast<URLInfo*>(dragItem.localObject);
[self insertNewWebStateAtGridIndex:destinationIndex withURL:droppedURL.URL];
return;
}
}
#pragma mark - WebStateListObserving override
// Overrides the parent observations. The parent treats a group as one cell,
// whereas this TabGroupMediator only cares about one group, and shows grouped
// tabs as many cells.
- (void)willChangeWebStateList:(WebStateList*)webStateList
change:(const WebStateListChangeDetach&)detachChange
status:(const WebStateListStatus&)status {
DCHECK_EQ(self.webStateList, webStateList);
if (webStateList->IsBatchInProgress() || !_tabGroup) {
return;
}
CHECK(detachChange.group() == _tabGroup.get());
web::WebState* detachedWebState = detachChange.detached_web_state();
GridItemIdentifier* identifierToRemove =
[GridItemIdentifier tabIdentifier:detachedWebState];
[self.consumer removeItemWithIdentifier:identifierToRemove
selectedItemIdentifier:[self activeIdentifier]];
[self removeObservationForWebState:detachedWebState];
}
// Overrides the parent observations. The parent treats a group as one cell and
// just update it, whereas this TabGroupMediator treats them as multiples cell,
// so this overrides manages notifications accordingly.
- (void)didChangeWebStateList:(WebStateList*)webStateList
change:(const WebStateListChange&)change
status:(const WebStateListStatus&)status {
DCHECK_EQ(self.webStateList, webStateList);
if (webStateList->IsBatchInProgress() || !_tabGroup) {
return;
}
switch (change.type()) {
case WebStateListChange::Type::kStatusOnly: {
const WebStateListChangeStatusOnly& selectionOnlyChange =
change.As<WebStateListChangeStatusOnly>();
const TabGroup* oldGroup = selectionOnlyChange.old_group();
const TabGroup* newGroup = selectionOnlyChange.new_group();
if (oldGroup != newGroup) {
// There is a change of group.
if (oldGroup == _tabGroup.get()) {
web::WebState* currentWebState =
self.webStateList->GetWebStateAt(selectionOnlyChange.index());
GridItemIdentifier* tabIdentifierToAddToGroup =
[GridItemIdentifier tabIdentifier:currentWebState];
[self.consumer removeItemWithIdentifier:tabIdentifierToAddToGroup
selectedItemIdentifier:[self activeIdentifier]];
[self removeObservationForWebState:currentWebState];
}
if (newGroup == _tabGroup.get()) {
int webStateIndex = selectionOnlyChange.index();
web::WebState* currentWebState =
self.webStateList->GetWebStateAt(webStateIndex);
[self insertItem:[GridItemIdentifier tabIdentifier:currentWebState]
beforeWebStateIndex:webStateIndex + 1];
[self addObservationForWebState:currentWebState];
}
break;
}
break;
}
case WebStateListChange::Type::kGroupVisualDataUpdate: {
const WebStateListChangeGroupVisualDataUpdate& visualDataChange =
change.As<WebStateListChangeGroupVisualDataUpdate>();
const TabGroup* tabGroup = visualDataChange.updated_group();
if (_tabGroup.get() != tabGroup) {
break;
}
[_groupConsumer setGroupTitle:tabGroup->GetTitle()];
[_groupConsumer setGroupColor:tabGroup->GetColor()];
break;
}
case WebStateListChange::Type::kGroupDelete: {
const WebStateListChangeGroupDelete& groupDeleteChange =
change.As<WebStateListChangeGroupDelete>();
if (groupDeleteChange.deleted_group() == _tabGroup.get()) {
_tabGroup.reset();
[self.tabGroupsHandler hideTabGroup];
}
break;
}
case WebStateListChange::Type::kMove: {
const WebStateListChangeMove& moveChange =
change.As<WebStateListChangeMove>();
if (moveChange.old_group() != _tabGroup.get() &&
moveChange.new_group() != _tabGroup.get()) {
// Not related to this group.
break;
}
web::WebState* webState = moveChange.moved_web_state();
GridItemIdentifier* item = [GridItemIdentifier tabIdentifier:webState];
if (moveChange.old_group() == moveChange.new_group()) {
// Move in the same group
[self moveItem:item
beforeWebStateIndex:moveChange.moved_to_index() + 1];
} else {
if (moveChange.old_group() == _tabGroup.get()) {
// The tab left the group.
[self.consumer removeItemWithIdentifier:item
selectedItemIdentifier:[self activeIdentifier]];
[self removeObservationForWebState:webState];
} else if (moveChange.new_group() == _tabGroup.get()) {
// The tab joined the group.
[self insertInConsumerWebState:webState
atIndex:moveChange.moved_to_index()];
[self addObservationForWebState:webState];
}
}
break;
}
case WebStateListChange::Type::kInsert: {
const WebStateListChangeInsert& insertChange =
change.As<WebStateListChangeInsert>();
if (insertChange.group() != _tabGroup.get()) {
break;
}
[self insertInConsumerWebState:insertChange.inserted_web_state()
atIndex:insertChange.index()];
[self addObservationForWebState:insertChange.inserted_web_state()];
break;
}
default:
[super didChangeWebStateList:webStateList change:change status:status];
break;
}
if (_tabGroup) {
// Update the title in case the number of tabs changed.
[_groupConsumer setGroupTitle:_tabGroup->GetTitle()];
}
if (status.active_web_state_change()) {
[self.consumer selectItemWithIdentifier:[self activeIdentifier]];
}
}
- (void)webStateListBatchOperationEnded:(WebStateList*)webStateList {
DCHECK_EQ(self.webStateList, webStateList);
[self addWebStateObservations];
[self populateConsumerItems];
if (_tabGroup) {
[_groupConsumer setGroupTitle:_tabGroup->GetTitle()];
[_groupConsumer setGroupColor:_tabGroup->GetColor()];
} else {
[self.tabGroupsHandler hideTabGroup];
}
}
#pragma mark - Private
// Adds a tab to the `group`. Returns whether it succeed.
- (BOOL)addTabToGroup:(const TabGroup*)group {
if (!self.browser || !group) {
return NO;
}
ChromeBrowserState* browserState = self.browser->GetBrowserState();
if (!browserState ||
!IsAddNewTabAllowedByPolicy(browserState->GetPrefs(),
browserState->IsOffTheRecord())) {
return NO;
}
WebStateList* webStateList = self.webStateList;
if (!webStateList->ContainsGroup(group)) {
return NO;
}
web::WebState::CreateParams params(browserState);
std::unique_ptr<web::WebState> webState = web::WebState::Create(params);
web::NavigationManager::WebLoadParams loadParams((GURL(kChromeUINewTabURL)));
loadParams.transition_type = ui::PAGE_TRANSITION_TYPED;
webState->GetNavigationManager()->LoadURLWithParams(loadParams);
webStateList->InsertWebState(
std::move(webState),
WebStateList::InsertionParams::Automatic().InGroup(group).Activate());
return YES;
}
// Inserts an item representing `webState` in the consumer at `index`.
- (void)insertInConsumerWebState:(web::WebState*)webState atIndex:(int)index {
CHECK(_tabGroup);
GridItemIdentifier* newItem = [GridItemIdentifier tabIdentifier:webState];
GridItemIdentifier* nextItemIdentifier;
if (index + 1 < _tabGroup->range().range_end()) {
nextItemIdentifier = [GridItemIdentifier
tabIdentifier:self.webStateList->GetWebStateAt(index + 1)];
}
[self.consumer insertItem:newItem
beforeItemID:nextItemIdentifier
selectedItemIdentifier:[self activeIdentifier]];
}
@end