// // P4Workspace.m // Perforce // // Created by Adam Czubernat on 02/10/2013. // Copyright (c) 2013 Perforce Software, Inc. All rights reserved. // #import "P4Workspace.h" #import "PSFileEvents.h" NSString * const P4ChangelistUpdatedNotification = @"P4ChangelistUpdatedNotification"; NSString * const P4UnreadUpdatedNotification = @"P4UnreadUpdatedNotification"; NSString * const P4SyncStartedNotification = @"P4SyncStartedNotification"; NSString * const P4SyncFinishedNotification = @"P4SyncFinishedNotification"; NSString * const P4SubmitFinishedNotification = @"P4SubmitFinishedNotification"; NSString * const P4ShelvedChangelistDescription = @"P4 Shelved progress"; @interface P4Workspace () <PSFileEventsDelegate> { P4Connection *listConnection; // Listing actions UI P4Connection *editConnection; // For checkout add delete and move actions P4Connection *syncConnection; // Sync and submit PSFileEvents *fileEventsP4; PSFileEvents *fileEventsUI; NSMutableArray *observers; NSString *address; NSString *username; NSString *workspace; NSString *root; NSFileManager *filemanager; NSOperationQueue *queue; NSTimer *autosyncTimer; NSTimeInterval autosyncInterval; NSMutableDictionary *mapping; NSMutableArray *mappingIgnored; NSDictionary *changelist; NSMutableArray *unreadList; NSDictionary *usersList; NSNumber *shelvedChange; NSMutableArray *shelvedList; NSString *searchUrl; NSString *searchTicket; struct { unsigned int connected:1; unsigned int loggedIn:1; unsigned int loginInProgress:1; unsigned int synchronizing:1; } flags; } - (void)autosync; - (void)handleClientResponse:(NSArray *)response; - (void)handleChangelistResponse:(NSArray *)response; - (NSMutableArray *)observers; - (NSString *)mappingPath:(NSString *)path; - (NSArray *)mappingPaths:(NSArray *)paths; - (NSArray *)contentOfDirectory:(NSString *)path; - (NSArray *)contentOfDirectories:(NSArray *)paths; - (void)appendDirectorySuffixes:(NSArray **)paths; - (void)appendDirectorySuffixes:(NSArray **)paths movedPaths:(NSArray **)movedPaths; @end @implementation P4Credentials @synthesize username, password, address, resumeSession; @end @implementation P4Workspace @synthesize address, username, workspace, root, syncDescription; + (P4Workspace *)sharedInstance { static P4Workspace *sharedInstance; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sharedInstance = [[P4Workspace alloc] init]; }); return sharedInstance; } - (void)addObserver:(id <P4WorkspaceDelegate>)observer { if (!observers) { // Non-retaining array CFArrayCallBacks callbacks = { 0, NULL, NULL, CFCopyDescription, CFEqual }; observers = CFBridgingRelease(CFArrayCreateMutable(NULL, 0, &callbacks)); } if (![observers containsObject:observer]) [observers addObject:observer]; } - (void)removeObserver:(id <P4WorkspaceDelegate>)observer { if ([observers containsObject:observer]) [observers removeObject:observer]; } - (id)init { self = [super init]; if (self) { queue = [[NSOperationQueue alloc] init]; queue.name = @"com.perforce.P4Workspace"; queue.maxConcurrentOperationCount = 1; filemanager = [NSFileManager defaultManager]; } return self; } - (NSString *)ticket { return [editConnection ticket]; } - (NSInteger)openedFilesCount { return [[changelist objectForKey:@"edit"] count]; } - (NSInteger)addedFilesCount { return [[changelist objectForKey:@"add"] count] + [[changelist objectForKey:@"move/add"] count]; } - (NSInteger)deletedFilesCount { return [[changelist objectForKey:@"delete"] count] + [[changelist objectForKey:@"move/delete"] count]; } - (NSInteger)unreadCount { return unreadList.count; } #pragma mark Login - (BOOL)isConnected { return flags.connected; } - (void)connectWithCredentials:(P4Credentials *)credentials response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert([NSThread isMainThread], @"Not in the main thread"); // Reset connections [editConnection disconnect]; [listConnection disconnect]; [syncConnection disconnect]; editConnection = listConnection = syncConnection = nil; flags.connected = NO; address = credentials.address; username = credentials.username; [editConnection = [[P4Connection alloc] initWithName:@"P4.Edit"] connectWithHost:address username:username response:^(P4Operation *operation, NSArray *response) { flags.connected = !operation.errors; if (flags.connected) { PSLog(@"Connected %@@%@", username, address); // Create auxiliary connections listConnection = [[P4Connection alloc] initWithConnection:editConnection name:@"P4.List"]; syncConnection = [[P4Connection alloc] initWithConnection:editConnection name:@"P4.Sync"]; } responseBlock(operation, response); }]; } - (BOOL)isLoggedIn { return flags.loggedIn; } - (void)loginWithCredentials:(P4Credentials *)credentials response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert([NSThread isMainThread], @"Not in main thread"); NSAssert(editConnection, @"Login with null connection"); NSAssert([editConnection isConnected], @"Login with disconnected connection"); NSAssert(!flags.loginInProgress, @"Already logging in"); NSAssert(!flags.loggedIn, @"Already logged in"); NSAssert(!workspace, @"Login should have unset workspace"); NSAssert(!root, @"Login should have unset workspace root"); flags.loggedIn = NO; flags.loginInProgress = YES; [editConnection run:@"login" arguments:@[ credentials.resumeSession ? @"-s" : @"-a" ] prompt:credentials.password ?: @"" input:nil receive:nil response:^(P4Operation *operation, NSArray *response) { // Bail out if errors if (operation.errors) { flags.loginInProgress = NO; flags.loggedIn = NO; responseBlock(operation, response); return; } if (credentials.resumeSession) { // Retrieve previous search ticket searchTicket = [NSUserDefaults stringForKey:kDefaultSearchTicket]; } else { // Retrieve initial ticket as p4search searchTicket = [editConnection ticket]; [NSUserDefaults setString:searchTicket forKey:kDefaultSearchTicket]; } // Retrieve search service url [listConnection run:@"key" arguments:@[ @"p4search.url" ] response:^(P4Operation *operation, NSArray *response) { NSString *url = [[response lastObject] objectForKey:@"value"]; PSLog(@"p4search.url = \"%@\"", url); if (!url || ![url isKindOfClass:[NSString class]]) { url = [NSUserDefaults stringForKey:kDefaultSearchUrl]; PSLog(@"Defaults p4search.url = \"%@\"", url); } PSLogStore(@"p4search.url", @"%@", url); // Check for url scheme if (![url hasPrefix:@"http:/"] && ![url hasPrefix:@"https:/"]) url = [@"http://" stringByAppendingPathComponent:url]; // Append search API paths url = [url stringByAppendingPath:@"api/search"]; searchUrl = url; }]; flags.loginInProgress = NO; flags.loggedIn = YES; responseBlock(operation, response); }]; } - (void)loginWithSSO:(P4Credentials *)credentials response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; PSLog(@"SSO > Login..."); NSAssert([NSThread isMainThread], @"Not in main thread"); NSAssert(editConnection, @"Login with null connection"); NSAssert([editConnection isConnected], @"Login with disconnected connection"); NSAssert(!flags.loginInProgress, @"Already logging in"); NSAssert(!flags.loggedIn, @"Already logged in"); [editConnection runBlock:^(P4ThreadOperation *operation) { // Check if sso-client script exists NSString *script = @"/usr/local/sso/sso-client.sh"; NSURL *url = [NSURL fileURLWithPath:script]; BOOL exists = [url checkResourceIsReachableAndReturnError:NULL]; NSNumber *executable = nil; // Check if executable [url getResourceValue:&executable forKey:NSURLIsExecutableKey error:NULL]; // Notify if /usr/local script is not executable if (exists && !executable.boolValue) { [operation addError:[NSError errorWithFormat:@"SSO error script %@ is not executable", script]]; [[NSOperationQueue mainQueue] addOperationWithBlock:^{ responseBlock(operation, nil); }]; return; } // If not found get built-in script if (!exists) { script = [[NSBundle mainBundle] pathForResource:@"sso-client.sh" ofType:nil]; url = [NSURL fileURLWithPath:script]; exists = [url checkResourceIsReachableAndReturnError:NULL]; [url getResourceValue:&executable forKey:NSURLIsExecutableKey error:NULL]; } // Fail if cannot run the script if (!exists || !executable.boolValue) { NSString *reason = !exists ? @"doesn't exist" : @"is not executable"; [operation addError:[NSError errorWithFormat:@"SSO error script %@ %@", script, reason]]; [[NSOperationQueue mainQueue] addOperationWithBlock:^{ responseBlock(operation, nil); }]; return; } PSLog(@"SSO > Running script %@", script); // Set environment variables NSString *env; env = [[NSBundle mainBundle] resourcePath]; if (setenv("BASE", [env UTF8String], 1)) PSLog(@"Error > setenv %@ errno : %d", env, errno); env = [NSString stringWithFormat:@"\"%@\" %%serverAddress%%", script]; if (setenv("P4LOGINSSO", [env UTF8String], 1)) PSLog(@"Error > setenv %@ errno : %d", env, errno); // PSLog(@"Env variables:\n%@", [[NSProcessInfo processInfo] environment]); // Login without password using SSO [[NSOperationQueue mainQueue] addOperationWithBlock:^{ PSLog(@"SSO > Authenticating..."); [self loginWithCredentials:credentials response:^(P4Operation *operation, NSArray *response) { // Unset environment variable unsetenv("P4LOGINSSO"); responseBlock(operation, response); }]; }]; }]; } - (void)reloginWithCredentials:(P4Credentials *)credentials response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(editConnection, @"Relogin with null connection"); NSAssert([editConnection isConnected], @"Relogin with disconnected connection"); flags.loggedIn = NO; flags.loginInProgress = YES; [editConnection run:@"logout" arguments:nil response:^(P4Operation *operation, NSArray *response) { [editConnection run:@"login" arguments:@[ @"-a" ] prompt:credentials.password input:nil receive:nil response:^(P4Operation *operation, NSArray *response) { NSString *ticket = [editConnection ticket]; if (ticket) { // Retrieve initial ticket as p4search searchTicket = ticket; [NSUserDefaults setString:searchTicket forKey:kDefaultSearchTicket]; // Reset connections to new ticket [syncConnection setTicket:ticket]; [listConnection setTicket:ticket]; } flags.loginInProgress = NO; flags.loggedIn = !operation.errors; responseBlock(operation, response); }]; }]; } - (void)logout:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; // Invalidate sync timer [autosyncTimer invalidate]; autosyncTimer = nil; autosyncInterval = 0; [editConnection run:@"logout" arguments:nil response:^(P4Operation *operation, NSArray *response) { [editConnection disconnect]; [listConnection disconnect]; [syncConnection disconnect]; editConnection = listConnection = syncConnection = nil; flags.connected = NO; fileEventsP4 = nil; fileEventsUI = nil; address = nil; username = nil; workspace = nil; root = nil; flags.loggedIn = NO; mapping = nil; mappingIgnored = nil; changelist = nil; unreadList = nil; usersList = nil; shelvedChange = nil; shelvedList = nil; searchUrl = nil; searchTicket = nil; responseBlock(operation, response); }]; } - (void)listDepots:(P4ResponseBlock_t)responseBlock { [listConnection run:@"depots" arguments:nil response:responseBlock]; } - (void)listWorkspaces:(P4ResponseBlock_t)responseBlock { [listConnection run:@"clients" arguments:@[ @"-u", username ] response:responseBlock]; } #pragma mark Workspace - (void)setWorkspace:(NSString *)name response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; name = name ?: @""; [editConnection run:@"client" arguments:@[ @"-o", @"-t", name, name ] response:^(P4Operation *operation, NSArray *response) { if (operation.errors && (responseBlock(operation, response), 1)) return; [self handleClientResponse:response]; NSDictionary *client = [response lastObject]; root = [client objectForKey:@"Root"]; root = [root directoryPath]; workspace = [client objectForKey:@"Client"]; // Set connections' workspaces [editConnection setWorkspace:workspace root:root]; [listConnection runBlock:^(P4ThreadOperation *operation) { dispatch_sync(dispatch_get_main_queue(), ^{ // Sync to force execution on queue [listConnection setWorkspace:workspace root:root]; }); }]; [syncConnection runBlock:^(P4ThreadOperation *operation) { dispatch_sync(dispatch_get_main_queue(), ^{ // Sync to force execution on queue [syncConnection setWorkspace:workspace root:root]; }); }]; // Set file events fileEventsP4 = [[PSFileEvents alloc] initWithRoot:root ignoreSelf:YES]; fileEventsP4.delegate = self; fileEventsUI = [[PSFileEvents alloc] initWithRoot:root]; fileEventsUI.delegate = self; // Refresh data changelist = nil; usersList = nil; unreadList = [NSMutableArray array]; [self refreshPendingFiles]; [self refreshShelvedFiles]; [self refreshUsers]; responseBlock(operation, response); }]; } - (void)createWorkspace:(NSDictionary *)workspaceSpecs response:(P4ResponseBlock_t)responseBlock { [editConnection run:@"client" arguments:@[ @"-i" ] prompt:nil input:workspaceSpecs receive:nil response:responseBlock]; } #pragma mark Sync - (BOOL)isSynchronizing { return flags.synchronizing; } - (void)syncWorkspace:(P4ReceiveBlock_t)receiveBlock response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; if (flags.synchronizing) return; [autosyncTimer invalidate]; autosyncTimer = nil; flags.synchronizing = YES; [[NSNotificationCenter defaultCenter] postNotificationName:P4SyncStartedNotification object:self]; [syncConnection run:@"sync" arguments:@[ @"//..." ] prompt:nil input:nil receive:receiveBlock response:^(P4Operation *operation, NSArray *response) { flags.synchronizing = NO; syncDescription = nil; [self handleChangelistResponse:response]; [self setAutosyncInterval:autosyncInterval]; [[NSNotificationCenter defaultCenter] postNotificationName:P4SyncFinishedNotification object:operation]; responseBlock(operation, response); }]; // Refresh [self refreshUsers]; } - (void)setAutosyncInterval:(NSTimeInterval)interval { autosyncInterval = interval; [autosyncTimer invalidate]; autosyncTimer = nil; if (!autosyncInterval) return; autosyncTimer = [NSTimer scheduledTimerWithTimeInterval:autosyncInterval target:self selector:@selector(autosync) userInfo:nil repeats:NO]; } - (void)submitFiles:(NSArray *)paths message:(NSString *)message receive:(P4ReceiveBlock_t)receiveBlock response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSDictionary *changelistSpecs = @{ @"Change" : @"new", @"Client" : workspace, @"User" : username, @"Description" : message, @"Files" : paths, }; [editConnection run:@"reopen" arguments:@[ @"-c", @"default", @"//..." ] response:^(P4Operation *operation, NSArray *response) { [syncConnection run:@"submit" arguments:@[ @"-f", @"revertunchanged", @"-i" ] prompt:nil input:changelistSpecs receive:receiveBlock response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; // Check if there was no files to submit NSArray *noChanges = [operation errorsWithCode:P4ErrorNoFilesToSubmit]; [operation ignoreErrors:noChanges]; // Discard shelved versions of files that were submitted if (!operation.errors && shelvedChange) [self discardShelvedFiles:paths response:nil]; // Remove created changelist if no changes if (noChanges.count && response.count) { NSDictionary *info = [response objectAtIndex:0]; NSNumber *changelistId = [info objectForKey:@"change"]; [syncConnection run:@"change" arguments:@[ @"-d", changelistId ] response:nil]; } [[NSNotificationCenter defaultCenter] postNotificationName:P4SubmitFinishedNotification object:operation]; responseBlock(operation, response); }]; }]; } #pragma mark Mapping - (NSArray *)mappingForPaths:(NSArray *)paths { NSMutableArray *result = [NSMutableArray arrayWithCapacity:paths.count]; BOOL depot = [[paths lastObject] hasPrefix:@"//"]; for (NSString *path in paths) { __block NSString *localMapping, *depotMapping; __block BOOL have, tracked, mapped; have = tracked = mapped = NO; [mapping enumerateKeysAndObjectsUsingBlock:^(NSString *remote, NSString *local, BOOL *stop) { NSString *mappingPath = depot ? remote : local; if ([path hasPrefix:mappingPath]) { localMapping = local; depotMapping = remote; tracked = ![mappingIgnored containsObject:remote]; if ((mapped = (path.length == mappingPath.length))) *stop = YES; } else if ([path hasSuffix:@"/"] && [mappingPath hasPrefix:path]) have = YES; }]; NSString *action; if (mapped) action = tracked ? @"mapped" : @"ignored"; else if (tracked) { NSString *relative = [path substringFromIndex:depot ? depotMapping.length : localMapping.length]; localMapping = [localMapping stringByAppendingString:relative]; depotMapping = [depotMapping stringByAppendingString:relative]; action = @"tracked"; } else if (have) action = @"have"; else action = @"untracked"; [result addObject:[NSDictionary dictionaryWithObjectsAndKeys: path, @"path", action, @"action", localMapping, @"clientFile", depotMapping, @"depotFile", nil]]; } return result; } - (void)mappingSet:(BOOL)map path:(NSString *)path response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(workspace.length, @"P4 Mapping without workspace set"); NSAssert([path hasPrefix:@"//"], @"P4 Mapping path should start with //"); NSAssert([path hasSuffix:@"/"], @"P4 Mapping path should end with /"); // Mapping [editConnection run:@"client" arguments:@[ @"-o", workspace ] response:^(P4Operation *operation, NSArray *response) { if (operation.errors && (responseBlock(operation, response), 1)) return; NSMutableDictionary *specs = [response lastObject]; NSString *client = [specs objectForKey:@"Client"]; NSString *clientPrefix = [NSString stringWithFormat:@"//%@/", client]; NSString *rootPrefix = [specs objectForKey:@"Root"]; NSMutableDictionary *mapped = [NSMutableDictionary dictionaryWithCapacity:specs.count]; NSMutableArray *ignored = [NSMutableArray array]; NSString *parent = nil; BOOL ignoring = NO; NSString *view, *key; NSMutableArray *views = [NSMutableArray array]; for (NSInteger idx=0; (key = [NSString stringWithFormat:@"View%ld", idx], view = [specs objectForKey:key]); idx++, [specs removeObjectForKey:key]) { NSArray *args = [view arrayOfArguments]; NSAssert(args.count == 2, @"P4 Mapping has incorrect view value"); NSString *localPath = [args objectAtIndex:1]; NSString *depotPath = [args objectAtIndex:0]; BOOL ignorePath = [depotPath hasPrefix:@"-"]; depotPath = [depotPath stringByRemovingSuffix:@"..."]; depotPath = [depotPath stringByRemovingSuffix:@"*"]; if (ignorePath) [ignored addObject:depotPath = [depotPath substringFromIndex:1]]; localPath = [localPath stringByRemovingSuffix:@"..."]; localPath = [localPath stringByRemovingSuffix:@"*"]; localPath = [localPath stringByRemovingPrefix:clientPrefix]; localPath = [rootPrefix stringByAppendingPath:localPath]; [mapped setObject:localPath forKey:depotPath]; if ([path hasPrefix:depotPath] && ![parent hasPrefix:depotPath]) { ignoring = ignorePath; parent = depotPath; if ([depotPath isEqualToString:path]) { if (ignoring == map) // Re-add to remove ignore continue; break; // Already added } } [views addObject:view]; } NSString *relativePath = [path stringByRemovingPrefix:@"//"]; NSString *localPath = [rootPrefix stringByAppendingString:relativePath]; NSString *newView = [NSString stringWithFormat:@"\"//%1$@...\" \"%2$@%1$@...\"", relativePath, clientPrefix]; if (map) { // Map if (parent && !ignoring) { // Parent already added response = @[ [NSString stringWithFormat: @"Already mapped: %@ to %@", path, parent] ]; responseBlock(operation, response); return; } else if ([path isEqualToString:parent]) { response = @[ [NSString stringWithFormat:@"Remove ignoring view: %@", newView] ]; [mapped removeObjectForKey:path]; [ignored removeObject:path]; } else { response = @[ [NSString stringWithFormat:@"Mapping new view: %@", newView] ]; [mapped setObject:localPath forKey:path]; [views addObject:newView]; } } else { if (!parent || ignoring) { // Parent already ignored response = @[ ignoring ? [NSString stringWithFormat:@"Already ignored: %@ in %@", path, parent] : [NSString stringWithFormat:@"Already unmapped: %@", path] ]; responseBlock(operation, response); return; } else if ([path isEqualToString:parent]) { response = @[ [NSString stringWithFormat:@"Removing view: %@", newView] ]; [mapped removeObjectForKey:path]; } else { newView = [@"\"-" stringByAppendingString:[newView substringFromIndex:1]]; response = @[ [NSString stringWithFormat:@"Ignoring view: %@", newView] ]; [mapped setObject:localPath forKey:path]; [ignored addObject:path]; [views addObject:newView]; } } // Create updated specs [specs setObject:views forKey:@"View"]; // Update client [editConnection run:@"client" arguments:@[ @"-i" ] prompt:nil input:specs receive:nil response:^(P4Operation *operation, NSArray *updateResponse) { if (operation.errors && (responseBlock(operation, response), 1)) return; // Apply mapping changes mapping = mapped; mappingIgnored = ignored; NSDictionary *changeInfo = nil; if ([views containsObject:newView]) { NSString *action = [ignored containsObject:path] ? @"ignored" : @"mapped"; changeInfo = @{ @"action" : action, @"clientFile" : localPath, @"depotFile" : path, }; } // Notify observers for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(file:mappingChanged:)]) [target file:path mappingChanged:changeInfo]; } responseBlock(operation, response); // Refresh changelist [self refreshPendingFiles]; }]; }]; } - (void)listMappings:(NSString *)path response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(path.length, @"Listing map of empty folder"); NSAssert([path hasSuffix:@"/"], @"Listing mapping of non-directory"); [listConnection run:@"where" arguments:@[ [path stringByAppendingPath:@"..."] ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorNoSuchFile]; [operation ignoreErrorsWithCode:P4ErrorFileNotInView]; NSMutableDictionary *have = [NSMutableDictionary dictionary]; PSLog(@"MAPPING : %@", path); for (NSDictionary *responseMapping in response) { NSString *depotPath = [responseMapping objectForKey:@"depotFile"]; NSString *clientFile = [responseMapping objectForKey:@"path"]; NSInteger loc = [depotPath rangeOfString:@"/" options:0 range:(NSRange) { path.length, depotPath.length - path.length }].location; loc = loc == NSNotFound ? path.length : loc+1; NSString *pathExtension = [depotPath substringFromIndex:loc]; depotPath = [depotPath substringToIndex:loc]; BOOL wholeMap = [pathExtension isEqualToString:@"..."]; if (!wholeMap && [have objectForKey:depotPath]) continue; if (!wholeMap) { if ([have objectForKey:depotPath]) continue; clientFile = [clientFile substringToIndex:clientFile.length - pathExtension.length]; } if ([responseMapping objectForKey:@"unmap"]) { if (!wholeMap) continue; clientFile = [@"-" stringByAppendingString:clientFile]; } [have setObject:clientFile forKey:depotPath]; }; responseBlock(operation, @[ have ]); }]; } #pragma mark Users - (NSDictionary *)userInfo:(NSString *)user { if (!user.length) return nil; NSInteger loc = [user rangeOfString:@"@"].location; if (loc != NSNotFound) user = [user substringToIndex:loc]; return [usersList objectForKey:user]; } #pragma mark Commands - (void)listFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Listing empty paths"); NSMutableArray *array = [NSMutableArray arrayWithCapacity:paths.count]; for (NSString *path in paths) { BOOL dir = [path hasSuffix:@"/"]; [array addObject:dir ? [path stringByAppendingString:@"*"] : path]; } paths = array; [listConnection run:@"fstat" arguments:@[ @"-A", @"tags", // Filter attributes to tags-only @"-Oah", @"-Dah", paths ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorNoSuchFile]; [operation ignoreErrorsWithCode:P4ErrorFileNotInView]; responseBlock(operation, response); }]; } - (void)listDepotFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Listing empty depot paths"); NSMutableArray *array = [NSMutableArray arrayWithCapacity:paths.count]; for (NSString *path in paths) { BOOL dir = [path hasSuffix:@"/"]; [array addObject:dir ? [path stringByAppendingString:@"*"] : path]; } paths = array; [listConnection run:@"fstat" arguments:@[ @"-A", @"tags", // Filter attributes to tags-only @"-Oa", @"-Da", @"-F", @"^headAction=delete & ^headAction=move/delete & headRev | dir", paths ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorNoSuchFile]; [operation ignoreErrorsWithCode:P4ErrorFileNotInView]; responseBlock(operation, response); }]; } - (void)listPendingFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; [self appendDirectorySuffixes:&paths]; [listConnection run:@"fstat" arguments:@[ @"-A", @"tags", @"-Oa", @"-F", @"action", // Filter by files opened in the clients workspace paths.count ? paths : @"..." ] response:^(P4Operation *operation, NSArray *response) { if (!operation.errors) { changelist = @{ @"edit" : [NSMutableArray array], @"add" : [NSMutableArray array], @"delete" : [NSMutableArray array], @"move/add" : [NSMutableArray array], @"move/delete" : [NSMutableArray array], }; for (NSDictionary *dict in response) { NSString *action = [dict objectForKey:@"action"]; NSMutableArray *changelistFiles = [changelist objectForKey:action]; [changelistFiles addObject:[dict objectForKey:@"depotFile"]]; } [[NSNotificationCenter defaultCenter] postNotificationName:P4ChangelistUpdatedNotification object:self]; } responseBlock(operation, response); }]; } - (void)listVersions:(NSString *)path response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(path.length, @"Listing versions of empty path"); [listConnection run:@"filelog" arguments:@[ @"-i", @"-l", path ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorNoSuchFile]; [operation ignoreErrorsWithCode:P4ErrorFileNotInView]; NSMutableArray *iterations = [NSMutableArray arrayWithCapacity:response.count]; for (NSDictionary *version in response) { NSInteger numberOfVersions = [[version objectForKey:@"rev0"] integerValue]; NSMutableDictionary *iteration = [NSMutableDictionary dictionary]; NSMutableArray *versions = [NSMutableArray arrayWithCapacity:numberOfVersions]; [iteration setObject:versions forKey:@"versions"]; [iterations addObject:iteration]; for (NSInteger idx = 0; idx < numberOfVersions; idx++) [versions addObject:[NSMutableDictionary dictionary]]; for (NSString *key in version) { NSInteger location = [key rangeOfCharacterFromSet: [NSCharacterSet decimalDigitCharacterSet]].location; id object = [version objectForKey:key]; if (location != NSNotFound) { NSInteger versionNumber = [[key substringFromIndex:location] integerValue]; NSMutableDictionary *dict = [versions objectAtIndex:versionNumber]; NSString *dictKey = [key substringToIndex:location]; [dict setObject:object forKey:dictKey]; } else { [iteration setObject:object forKey:key]; } } } responseBlock(operation, iterations); }]; } - (void)listVersionsDetails:(NSArray *)versionPaths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(versionPaths.count, @"Listing version details of empty paths"); [listConnection run:@"fstat" arguments:@[ @"-Oaf", // @"-F", @"^headAction=delete & ^headAction=move/delete", versionPaths ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorNoSuchFile]; [operation ignoreErrorsWithCode:P4ErrorFileNotInView]; responseBlock(operation, response); }]; } #pragma mark File Management - (void)editFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Editing empty file list"); [self appendDirectorySuffixes:&paths]; [editConnection run:@"edit" arguments:paths response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; // // TODO: Add error checking and adding untracked files // responseBlock(operation, response); }]; } - (void)addFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Adding empty file list"); [queue addOperationWithBlock:^{ // Get recursively all paths contents NSArray *content = [self contentOfDirectories:paths]; /* Alternative way using reconcile */ /* [self appendDirectorySuffixes:&paths]; */ [editConnection run:@"add" /* @"reconcile" */ arguments:content /* @[ @"-a", paths ] */ response:^(P4Operation *addOperation, NSArray *response) { [self handleChangelistResponse:response]; // Revert already added files NSArray *alreadyAdded = [addOperation errorsWithCode:P4ErrorAddExistingFile]; NSArray *existing = [addOperation errorsWithCode:P4ErrorAddExisting]; if (existing) { alreadyAdded = [[NSArray arrayWithArray:alreadyAdded] arrayByAddingObjectsFromArray:existing]; } if (!alreadyAdded.count && (responseBlock(addOperation, response), 1)) return; NSMutableArray *revertPaths = [NSMutableArray array]; for (NSError *error in alreadyAdded) [revertPaths addObject:error.localizedFailureReason]; [addOperation ignoreErrors:alreadyAdded]; // Reconcile already added paths [self reconcileFiles:revertPaths response:responseBlock]; }]; }]; } - (void)removeFiles:(NSMutableArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Removing empty file list"); [self appendDirectorySuffixes:&paths]; // Discard shelved files if (shelvedList.count) [self discardShelvedFiles:paths response:nil]; [editConnection run:@"revert" arguments:@[ @"-k", paths ] response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; // Get moved files that were deleted for (NSDictionary *item in response) { if ([[item objectForKey:@"oldAction"] isEqualToString:@"move/delete"]) { NSString *path = [item objectForKey:@"clientFile"]; if (![paths containsObject:path]) [paths addObject:path]; } } [editConnection setNextOperation: [editConnection run:@"delete" arguments:@[ @"-k", paths ] response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; // Get deleted files from response for (NSDictionary *item in response) { // Files successfully deleted NSString *path = [item objectForKey:@"clientFile"]; [paths removeObject:path]; } // Remove untracked files directly NSArray *untracked = [operation errorsWithCode:P4ErrorFileNotOnClient]; for (NSError *error in untracked) { NSString *path = error.localizedFailureReason; [paths removeObject:path]; path = [path stringByRemovingSuffix:@"/..."]; if ([filemanager fileExistsAtPath:path]) { [filemanager removeItemAtPath:path error:NULL]; PSLog(@"Warning > Removing file manually %@", path); } } [operation ignoreErrors:untracked]; // NSAssert(!paths.count || operation.errors, @"Deleted count doesn't match"); responseBlock(operation, response); }] ]; }]; } - (void)moveFiles:(NSArray *)paths toPaths:(NSArray *)toPaths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Moving empty file list"); NSAssert(paths.count == toPaths.count, @"Move should have equal number of from-to paths"); [self appendDirectorySuffixes:&paths movedPaths:&toPaths]; // Open for edit [editConnection run:@"edit" arguments:paths response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; // // TODO: Add support for moving into locations marked for delete // // Move command performs only one move at once, enumerate [paths enumerateObjectsUsingBlock:^(NSString *path, NSUInteger idx, BOOL *stop) { NSString *fromFile = path; NSString *toFile = [toPaths objectAtIndex:idx]; [editConnection run:@"move" arguments:@[ @"-k", fromFile, toFile ] response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; NSArray *adds = ([operation errorsWithCode:P4ErrorFileNotInView] ?: [operation errorsWithCode:P4ErrorPathNotUnderRoot]); NSArray *deletes = [operation errorsWithCode:P4ErrorDestinationNotInView]; NSArray *overwrites = [operation errorsWithCode:P4ErrorMoveToExistingFile]; [operation ignoreErrors:adds]; [operation ignoreErrors:deletes]; [operation ignoreErrors:overwrites]; // Adds if (adds.count) [self addFiles:@[ [toFile stringByRemovingSuffix:@"..."] ] response:nil]; // Deletes if (deletes.count) [self removeFiles:@[ fromFile ] response:nil]; // Overwrites if (overwrites.count) { [self reconcileFiles:@[ toFile ] response:nil]; [self removeFiles:@[ fromFile ] response:nil]; } responseBlock(operation, response); }]; }]; }]; } - (void)revertFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; [self appendDirectorySuffixes:&paths]; [editConnection run:@"revert" arguments:paths response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; // // TODO: Add error checking // responseBlock(operation, response); }]; } - (void)revertUnchangedFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; [self appendDirectorySuffixes:&paths]; [editConnection run:@"revert" arguments:@[ @"-a", paths ] response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; // // TODO: Add error checking // responseBlock(operation, response); }]; } - (void)reconcileFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Reconciling empty paths"); [self appendDirectorySuffixes:&paths]; [editConnection run:@"revert" arguments:@[ @"-k", paths ] response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; [operation ignoreErrorsWithCode:P4ErrorFileNotOpened]; [editConnection setNextOperation: [editConnection run:@"reconcile" arguments:@[ @"-ead", paths ] response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; [operation ignoreErrorsWithCode:P4ErrorReconcile]; responseBlock(operation, response); }] ]; }]; } - (void)resolveFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; NSAssert(paths.count, @"Resolving empty paths"); [editConnection run:@"sync" arguments:@[ @"-f", paths ] response:^(P4Operation *operation, NSArray *response) { NSArray *resolveErrors = [operation errorsWithCode:P4ErrorMustResolve]; NSArray *resolvePaths = [resolveErrors valueForKeyPath:@"localizedFailureReason"]; [operation ignoreErrors:resolveErrors]; [operation ignoreErrorsWithCode:P4ErrorSyncFileOpened]; // Finish only if resolve path is not acquired if (!resolvePaths.count && (responseBlock(operation, response), 1)) return; [editConnection run:@"resolve" arguments:@[ @"-o", @"-ay", resolvePaths ] response:responseBlock]; }]; } - (void)setAttribute:(NSString *)name value:(NSString *)value paths:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; [editConnection run:@"edit" arguments:paths response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; NSArray *args = value ? @[ @"-n", name, @"-v", value, paths ] : @[ @"-n", name, paths ]; [editConnection run:@"attribute" arguments:args response:^(P4Operation *operation, NSArray *response) { responseBlock(operation, response); }]; }]; } #pragma mark - Refresh - (void)refreshMapping { [listConnection run:@"client" arguments:@[ @"-o", workspace ] response:^(P4Operation *operation, NSArray *response) { if (operation.errors) return; [self handleClientResponse:response]; }]; } - (void)refreshPendingFiles { [self listPendingFiles:nil response:nil]; } - (void)refreshShelvedFiles { [listConnection run:@"changes" arguments:@[ @"-u", username, @"-s", @"shelved" ] response:^(P4Operation *operation, NSArray *response) { if (operation.errors) return; NSNumber *change = nil; for (NSDictionary *dict in response) { NSString *client = [dict objectForKey:@"client"]; NSString *desc = [dict objectForKey:@"desc"]; if ([desc hasPrefix:P4ShelvedChangelistDescription]) { change = [dict objectForKey:@"change"]; if ([client isEqualToString:workspace]) break; } } if (![change isKindOfClass:[NSNumber class]]) { shelvedChange = nil; shelvedList = nil; return; } [listConnection run:@"fstat" arguments:@[ @"-Rs", @"-e", change, @"..." ] response:^(P4Operation *operation, NSArray *response) { if (operation.errors) return; shelvedList = [NSMutableArray arrayWithCapacity:response.count]; for (NSDictionary *dict in response) { NSString *depotPath = [dict objectForKey:@"depotFile"]; if (depotPath && ![shelvedList containsObject:depotPath]) [shelvedList addObject:depotPath]; } shelvedChange = change; }]; }]; } - (void)refreshUsers { [listConnection run:@"users" arguments:nil response:^(P4Operation *operation, NSArray *response) { if (operation.errors) return; NSMutableDictionary *users = [NSMutableDictionary dictionaryWithCapacity:response.count]; for (NSDictionary *user in response) { NSString *name = [user objectForKey:@"User"]; if (name.length) [users setObject:user forKey:name]; } usersList = users; }]; } #pragma mark Shelving - (NSArray *)shelvedForPaths:(NSArray *)paths { if (!shelvedList.count) return nil; NSMutableArray *result = [NSMutableArray array]; for (NSString *path in paths) { if ([path hasSuffix:@"/"]) { // Directory for (NSString *shelvedPath in shelvedList) { if ([shelvedPath hasPrefix:path]) { [result addObject:shelvedPath]; } } } else if ([shelvedList containsObject:path]) { [result addObject:path]; } } return result; } - (void)shelveFile:(NSString *)path response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; // Mapping local paths into depot if (![path hasPrefix:@"//"]) path = [self mappingPath:path]; NSMutableArray *list = [shelvedList mutableCopy] ?: [NSMutableArray array]; if (![list containsObject:path]) [list addObject:path]; P4ResponseBlock_t shelveBlock = ^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorShelveExclusive]; if (!operation.errors) { NSDictionary *dict = [response lastObject]; NSNumber *change = [dict objectForKey:@"change"]; if ([change isKindOfClass:[NSNumber class]]) shelvedChange = change; shelvedList = list; // Notify observers for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(file:shelved:)]) [target file:path shelved:dict]; } } responseBlock(operation, response); }; if (shelvedChange) { // Append shelved file to shelved changelist [editConnection run:@"reopen" arguments:@[ @"-c", shelvedChange, path ] response:^(P4Operation *operation, NSArray *response) { [editConnection run:@"shelve" arguments:@[ @"-f", @"-c", shelvedChange, path ] response:shelveBlock]; }]; } else { // Create new shelved changelist [editConnection run:@"shelve" arguments:@[ @"-i" ] prompt:nil input:@{ @"Change" : @"new", @"Client" : workspace, @"User" : username, @"Description" : P4ShelvedChangelistDescription, @"Files" : list, } receive:nil response:shelveBlock]; } } - (void)unshelveFile:(NSString *)path response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; // Mapping local paths into depot if (![path hasPrefix:@"//"]) path = [self mappingPath:path]; [editConnection run:@"unshelve" arguments:@[ @"-s", shelvedChange, path ] response:^(P4Operation *operation, NSArray *response) { [self handleChangelistResponse:response]; NSArray *resolveErrors = [operation errorsWithCode:P4ErrorMustSubmitResolve]; NSString *resolvePath = [[resolveErrors lastObject] localizedFailureReason]; [operation ignoreErrors:resolveErrors]; if (!operation.errors && resolvePath.length) { // Resolve [editConnection run:@"resolve" arguments:@[ @"-o", @"-at", resolvePath ] response:^(P4Operation *operation, NSArray *response) { responseBlock(operation, response); }]; } else { // Errors or resolving not needed responseBlock(operation, response); } }]; } - (void)discardShelvedFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; if (!shelvedChange && (responseBlock(nil, nil), 1)) return; if (paths.count && ![[paths lastObject] hasPrefix:@"//"]) paths = [self mappingPaths:paths]; // Mapping local paths into depot if (!paths.count && (responseBlock(nil, nil), 1)) return; [editConnection run:@"shelve" arguments:@[ @"-c", shelvedChange, @"-d", paths ] response:^(P4Operation *operation, NSArray *response) { if (!operation.errors) { [shelvedList removeObjectsInArray:paths]; // Notify observers for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(file:shelved:)]) for (NSString *path in paths) [target file:path shelved:nil]; } } if (shelvedList.count && (responseBlock(operation, response), 1)) return; // Move all files to default changelist [editConnection run:@"reopen" arguments:@[ @"-c", @"default", @"//..." ] response:^(P4Operation *operation, NSArray *response) { // Delete empty changelist [editConnection run:@"change" arguments:@[ @"-d", shelvedChange ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorChangeDeleted]; if (!operation.errors) { shelvedChange = nil; shelvedList = nil; } responseBlock(operation, response); }]; }]; }]; } - (void)openShelvedFile:(NSString *)path response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; if (!shelvedChange && (responseBlock(nil, nil), 1)) return; NSString *tmpPath = [NSString stringWithFormat:@"/tmp/p4/%@/%@", workspace, path.lastPathComponent]; [listConnection run:@"print" arguments:@[ @"-o", tmpPath, [path stringByAppendingFormat:@"@=%@", shelvedChange] ] response:^(P4Operation *operation, NSArray *response) { responseBlock(operation, response); if (!operation.errors) [[NSWorkspace sharedWorkspace] openFile:tmpPath]; }]; } - (void)listShelvedFiles:(NSArray *)paths response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; if (!shelvedChange && (responseBlock(nil, nil), 1)) return; [listConnection run:@"fstat" arguments:@[ @"-Oa", @"-Rs", @"-e", shelvedChange, paths ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorNoSuchFile]; [operation ignoreErrorsWithCode:P4ErrorFileNotInView]; responseBlock(operation, response); }]; } #pragma mark Search - (P4NetworkOperation *)searchFiles:(NSString *)search path:(NSString *)path response:(P4ResponseBlock_t)responseBlock { // Make query NSString *query = search; PSLog(@"Search query : %@", query); PSLog(@"Search ticket : %@\nConnection ticket : %@", searchTicket, [editConnection ticket]); // Create payload NSString *ticket = searchTicket ?: [editConnection ticket]; NSString *method = @"POST"; NSDictionary *headers = @{ @"Content-Type" : @"application/json" }; NSDictionary *JSON = @{ @"userId" : username, @"ticket" : ticket, @"query" : query, @"paths" : path ? @[ path ] : @[ ], @"rowCount" : @(500), @"searchFields" : @[ // @{ @"field" : @"p4attr_tags", @"value" : tags ?: @"" } ], }; // Make body JSON NSError *error; NSData *body = !JSON ? nil : [NSJSONSerialization dataWithJSONObject:JSON options:NSJSONWritingPrettyPrinted error:&error]; P4NetworkOperation *operation; operation = [P4NetworkOperation operationWithUrl:searchUrl HTTPMethod:method HTTPHeaders:headers HTTPBody:body receive:nil completion:^(P4NetworkOperation *operation) { NSArray *payload = nil; if (operation.success && operation.data) { NSString *MIMEType = [operation.MIMEType lowercaseString]; if ([MIMEType isEqualToString:@"application/json"]) { NSError *error; NSDictionary *JSON = [NSJSONSerialization JSONObjectWithData:operation.data options:NSJSONReadingMutableContainers error:&error]; if (JSON) { payload = [JSON objectForKey:@"payload"]; NSDictionary *status = [JSON objectForKey:@"status"]; NSInteger code = [[status objectForKey:@"code"] integerValue]; if (code != 200) operation.error = [NSError errorWithFormat:@"%@", [status objectForKey:@"message"]]; } else { operation.error = error; } } else if ([MIMEType isEqualToString:@"text/plain"]) { operation.error = [NSError errorWithFormat:@"%@", [[NSString alloc] initWithData:operation.data encoding:NSUTF8StringEncoding]]; } else { operation.error = [NSError errorWithFormat:@"Server " "returned unrecognized MIME %@", operation.MIMEType]; PSLog(@"Response: %@", [[NSString alloc] initWithData:operation.data encoding:NSUTF8StringEncoding]); } } responseBlock((id)operation, payload); }]; // Simulate connection error when something's wrong with JSON if (JSON && !body) { operation.error = error; [operation cancel]; } else { [listConnection runOperation:operation]; } return operation; } #pragma mark Unread - (void)markFilesRead:(NSArray *)paths { if (!unreadList.count || !paths.count) return; for (NSString *path in paths) { if ([path hasSuffix:@"/"]) { // Directory NSIndexSet *indexes; indexes = [unreadList indexesOfObjectsPassingTest: ^BOOL(NSString *unreadPath, NSUInteger idx, BOOL *stop) { return [unreadPath hasPrefix:path]; }]; [unreadList removeObjectsAtIndexes:indexes]; } else { [unreadList removeObject:path]; } } [[NSNotificationCenter defaultCenter] postNotificationName:P4UnreadUpdatedNotification object:self]; } - (NSArray *)unreadForPaths:(NSArray *)paths { if (!unreadList.count) return nil; NSMutableArray *result = [NSMutableArray array]; for (NSString *path in paths) { if ([path hasSuffix:@"/"]) { // Directory for (NSString *unreadPath in unreadList) { if ([unreadPath hasPrefix:path]) { [result addObject:path]; break; } } } else if ([unreadList containsObject:path]) { [result addObject:path]; } } return result; } - (void)listUnreadFiles:(NSString *)path response:(P4ResponseBlock_t)responseBlock { responseBlock = responseBlock ?: (P4ResponseBlock_t)^{ }; if (!path) path = root; [queue addOperationWithBlock:^{ // Check if unread files still exists NSMutableIndexSet *nonexistent = [NSMutableIndexSet indexSet]; NSMutableIndexSet *unreadPaths = [NSMutableIndexSet indexSet]; [unreadList enumerateObjectsUsingBlock:^(NSString *unreadPath, NSUInteger idx, BOOL *stop) { if ([filemanager fileExistsAtPath:unreadPath]) { if ([unreadPath hasPrefix:path]) [unreadPaths addIndex:idx]; } else { [nonexistent addIndex:idx]; } }]; // Remove non-existing records if (nonexistent.count) [unreadList removeObjectsAtIndexes:nonexistent]; // Post notification [[NSNotificationCenter defaultCenter] postNotificationName:P4UnreadUpdatedNotification object:self]; if (!unreadPaths.count) { [[NSOperationQueue mainQueue] addOperationWithBlock:^{ responseBlock(nil, nil); }]; return; } [listConnection run:@"fstat" arguments:@[ @"-A", @"tags", // Filter attributes to tags-only @"-Oah", [unreadList objectsAtIndexes:unreadPaths] ] response:^(P4Operation *operation, NSArray *response) { [operation ignoreErrorsWithCode:P4ErrorNoSuchFile]; [operation ignoreErrorsWithCode:P4ErrorFileNotInView]; responseBlock(operation, response); }]; }]; } #pragma mark Generic - (void)runCommand:(NSString *)command response:(P4ResponseBlock_t)responseBlock { [listConnection run:command response:responseBlock]; } #pragma mark - Private - (void)autosync { PSLog(@"Starting auto-sync..."); syncDescription = @"Auto-sync in progress..."; [self syncWorkspace:nil response:nil]; } - (void)handleClientResponse:(NSArray *)response { NSDictionary *specs = [response lastObject]; NSString *client = [specs objectForKey:@"Client"]; NSString *clientPrefix = [NSString stringWithFormat:@"//%@/", client]; NSString *rootPrefix = [specs objectForKey:@"Root"]; NSMutableDictionary *mapped = [NSMutableDictionary dictionaryWithCapacity:specs.count]; NSMutableArray *ignored = [NSMutableArray array]; NSString *view, *key; for (NSInteger idx=0; (key = [NSString stringWithFormat:@"View%ld", idx], view = [specs objectForKey:key]); idx++) { NSArray *args = [view arrayOfArguments]; NSAssert(args.count == 2, @"P4 Mapping has incorrect view value"); NSString *localPath = [args objectAtIndex:1]; NSString *depotPath = [args objectAtIndex:0]; BOOL ignorePath = [depotPath hasPrefix:@"-"]; depotPath = [depotPath stringByRemovingSuffix:@"..."]; depotPath = [depotPath stringByRemovingSuffix:@"*"]; if (ignorePath) [ignored addObject:depotPath = [depotPath substringFromIndex:1]]; localPath = [localPath stringByRemovingSuffix:@"..."]; localPath = [localPath stringByRemovingSuffix:@"*"]; localPath = [localPath stringByRemovingPrefix:clientPrefix]; localPath = [rootPrefix stringByAppendingPath:localPath]; [mapped setObject:localPath forKey:depotPath]; } mapping = mapped; mappingIgnored = ignored; } - (void)handleChangelistResponse:(NSArray *)response { if (!response) return; // Flush file events so they'll appear before p4 events [fileEventsUI flush]; BOOL submit = NO; NSDictionary *submitHeader = response.count ? [response objectAtIndex:0] : nil; if ([submitHeader isKindOfClass:[NSDictionary class]] && [submitHeader objectForKey:@"openFiles"]) submit = YES; for (NSDictionary *dict in response) { if (![dict isKindOfClass:[NSDictionary class]]) continue; NSString *action = [dict objectForKey:@"action"]; if (!action) continue; // Check if reverted NSString *oldAction = [dict objectForKey:@"oldAction"]; BOOL revert = oldAction || submit; // Update changelist counters NSString *pendingAction = oldAction ?: action; NSMutableArray *changelistFiles = [changelist objectForKey:pendingAction]; if (revert) [changelistFiles removeObject:[dict objectForKey:@"depotFile"]]; else [changelistFiles addObject:[dict objectForKey:@"depotFile"]]; // Update changelist after move NSString *fromFile = [dict objectForKey:@"fromFile"]; if (fromFile) { [[changelist objectForKey:@"edit"] removeObject:fromFile]; [[changelist objectForKey:@"add"] removeObject:fromFile]; [[changelist objectForKey:@"move/add"] removeObject:fromFile]; if ([action isEqualToString:@"move/add"]) [[changelist objectForKey:@"move/delete"] addObject:fromFile]; } NSString *path = ([dict objectForKey:@"path"] ?: [dict objectForKey:@"clientFile"] ?: [dict objectForKey:@"depotFile"]); // Update unread list BOOL updated = [@[ @"added", @"updated" ] containsObject:action]; if (updated) { if (![unreadList containsObject:path]) [unreadList addObject:path]; } // Notify observers if (updated) { for (id <P4WorkspaceDelegate> target in [self observers]) if ([target respondsToSelector:@selector(file:updated:)]) [target file:path updated:dict]; } else if (revert) { for (id <P4WorkspaceDelegate> target in [self observers]) if ([target respondsToSelector:@selector(file:revertedAction:)]) [target file:path revertedAction:dict]; } else { for (id <P4WorkspaceDelegate> target in [self observers]) if ([target respondsToSelector:@selector(file:actionChanged:)]) [target file:path actionChanged:dict]; } } [[NSNotificationCenter defaultCenter] postNotificationName:P4ChangelistUpdatedNotification object:self]; [[NSNotificationCenter defaultCenter] postNotificationName:P4UnreadUpdatedNotification object:self]; } - (NSArray *)observers { return [observers copy]; } - (NSString *)mappingPath:(NSString *)path { BOOL depot = [path hasPrefix:@"//"]; __block NSString *localMapping, *depotMapping; __block BOOL tracked, mapped; tracked = mapped = NO; [mapping enumerateKeysAndObjectsUsingBlock:^(NSString *remote, NSString *local, BOOL *stop) { NSString *mappingPath = depot ? remote : local; if ([path hasPrefix:mappingPath]) { localMapping = local; depotMapping = remote; tracked = ![mappingIgnored containsObject:remote]; if ((mapped = (path.length == mappingPath.length))) *stop = YES; } }]; if (!mapped && tracked) { NSString *relative = [path substringFromIndex:depot ? depotMapping.length : localMapping.length]; localMapping = [localMapping stringByAppendingString:relative]; depotMapping = [depotMapping stringByAppendingString:relative]; } return depot ? localMapping : depotMapping; } - (NSArray *)mappingPaths:(NSArray *)paths { NSMutableArray *result = [NSMutableArray arrayWithCapacity:paths.count]; for (NSString *path in paths) { NSString *mappedPath = [self mappingPath:path]; if (mappedPath) [result addObject:mappedPath]; } return result; } // Recursively list directory tree - (NSArray *)contentOfDirectory:(NSString *)path { NSDirectoryEnumerator *enumerator; enumerator = [[NSFileManager defaultManager] enumeratorAtURL:[NSURL fileURLWithPath:path] includingPropertiesForKeys:@[ NSURLIsDirectoryKey ] options:NSDirectoryEnumerationSkipsHiddenFiles errorHandler:NULL]; NSMutableArray *array = enumerator ? [NSMutableArray arrayWithCapacity:512] : nil; for (NSURL *url in enumerator) { NSNumber *dir = nil; [url getResourceValue:&dir forKey:NSURLIsDirectoryKey error:NULL]; if (dir.boolValue) continue; [array addObject:url.path]; } return array; } // Recursively list multiple directory trees watching cycles - (NSArray *)contentOfDirectories:(NSArray *)paths { NSMutableArray *array = [NSMutableArray arrayWithCapacity:paths.count]; NSMutableArray *dirs = [NSMutableArray arrayWithCapacity:paths.count]; SEL comparator = @selector(localizedStandardCompare:); for (NSString *path in [paths sortedArrayUsingSelector:comparator]) { if (dirs.count && [path hasPrefix:[dirs lastObject]]) continue; // Ignore paths beneath included directories /* BOOL dir = [filemanager fileExistsAtPath:path isDirectory:&dir] && dir; */ BOOL dir = [path hasSuffix:@"/"]; [dir ? dirs : array addObject:path]; } for (NSString *dir in dirs) { NSDirectoryEnumerator *enumerator; enumerator = [[NSFileManager defaultManager] enumeratorAtURL:[NSURL fileURLWithPath:dir] includingPropertiesForKeys:@[ NSURLIsDirectoryKey ] options:NSDirectoryEnumerationSkipsHiddenFiles errorHandler:NULL]; for (NSURL *url in enumerator) { NSNumber *dir = nil; [url getResourceValue:&dir forKey:NSURLIsDirectoryKey error:NULL]; if (dir.boolValue) continue; [array addObject:url.path]; } } return array; } // Appends '...' suffixes into directories watching cycles - (void)appendDirectorySuffixes:(NSArray *__autoreleasing *)paths { NSMutableArray *array = [NSMutableArray arrayWithCapacity:(*paths).count]; NSMutableArray *dirs = [NSMutableArray arrayWithCapacity:(*paths).count]; SEL comparator = @selector(localizedStandardCompare:); for (NSString *path in [(*paths) sortedArrayUsingSelector:comparator]) { if (dirs.count && [path hasPrefix:[dirs lastObject]]) continue; // Ignore paths beneath included directories /* BOOL dir = [filemanager fileExistsAtPath:path isDirectory:&dir] && dir; */ BOOL dir = [path hasSuffix:@"/"]; [array addObject:dir ? ([dirs addObject:path], [path stringByAppendingString:@"..."]) : path]; } *paths = array; } // Appends '...' suffixes to both source or destination if any of them is a directory - (void)appendDirectorySuffixes:(NSArray *__autoreleasing *)paths movedPaths:(NSArray *__autoreleasing *)movedPaths { NSMutableArray *fromPaths = [NSMutableArray arrayWithCapacity:(*paths).count]; NSMutableArray *toPaths = [NSMutableArray arrayWithCapacity:(*paths).count]; [*paths enumerateObjectsUsingBlock:^(NSString *path, NSUInteger idx, BOOL *stop) { NSString *from = path; NSString *to = [*movedPaths objectAtIndex:idx]; /* BOOL dir = ([filemanager fileExistsAtPath:to isDirectory:&dir] ?: [filemanager fileExistsAtPath:from isDirectory:&dir]) && dir; */ BOOL dir = [to hasSuffix:@"/"] || [from hasSuffix:@"/"]; [fromPaths addObject:dir ? [from stringByAppendingString:@"..."] : from]; [toPaths addObject:dir ? [to stringByAppendingString:@"..."] : to]; }]; *paths = fromPaths; *movedPaths = toPaths; } #pragma mark - FileEventDelegate - (void)fileEvents:(PSFileEvents *)events created:(NSArray *)paths { if (events == fileEventsUI) { for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(fileCreated:)]) for (NSString *path in paths) [target fileCreated:path]; } } else { PSLog(@"File > Created \n%@", [paths componentsJoinedByString:@"\n"]); [self addFiles:paths response:nil]; } } - (void)fileEvents:(PSFileEvents *)events removed:(NSArray *)paths { if (events == fileEventsUI) { for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(fileRemoved:)]) for (NSString *path in paths) [target fileRemoved:path]; } [self markFilesRead:paths]; } else { PSLog(@"File > Removed \n%@", [paths componentsJoinedByString:@"\n"]); [self removeFiles:paths response:nil]; } } - (void)fileEvents:(PSFileEvents *)events moved:(NSArray *)paths to:(NSArray *)newPaths { if (events == fileEventsUI) { for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(fileMoved:toPath:)]) [paths enumerateObjectsUsingBlock:^(NSString *path, NSUInteger idx, BOOL *stop) { [target fileMoved:path toPath:[newPaths objectAtIndex:idx]]; }]; } [self markFilesRead:paths]; } else { NSMutableString *string = [NSMutableString string]; [paths enumerateObjectsUsingBlock:^(NSString *path, NSUInteger idx, BOOL *stop) { [string appendFormat:@"\n%@\n-> %@", path, [newPaths objectAtIndex:idx]]; }]; PSLog(@"File > Moved %@", string); [self moveFiles:paths toPaths:newPaths response:nil]; } } - (void)fileEvents:(PSFileEvents *)events renamed:(NSArray *)paths to:(NSArray *)newPaths { if (events == fileEventsUI) { for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(fileRenamed:toPath:)]) [paths enumerateObjectsUsingBlock:^(NSString *path, NSUInteger idx, BOOL *stop) { [target fileRenamed:path toPath:[newPaths objectAtIndex:idx]]; }]; } [self markFilesRead:paths]; } else { NSMutableString *string = [NSMutableString string]; [paths enumerateObjectsUsingBlock:^(NSString *path, NSUInteger idx, BOOL *stop) { [string appendFormat:@"\n%@\n-> %@", path, [newPaths objectAtIndex:idx]]; }]; PSLog(@"File > Renamed %@", string); [self moveFiles:paths toPaths:newPaths response:nil]; } } - (void)fileEvents:(PSFileEvents *)events modified:(NSArray *)paths { if (events == fileEventsUI) { for (id <P4WorkspaceDelegate> target in [self observers]) { if ([target respondsToSelector:@selector(fileModified:)]) for (NSString *path in paths) [target fileModified:path]; } } else { PSLog(@"File > Modified %@", [paths componentsJoinedByString:@"\n"]); [self reconcileFiles:paths response:nil]; } } @end