// // PBGitHistoryView.m // GitX // // Created by Pieter de Bie on 19-09-08. // Copyright 2008 __MyCompanyName__. All rights reserved. // #import "PBGitHistoryController.h" #import "CWQuickLook.h" #import "PBGitGrapher.h" #import "PBGitRevisionCell.h" #import "PBCommitList.h" #import "ApplicationController.h" #import "PBQLOutlineView.h" #import "PBCreateBranchSheet.h" #import "PBCreateTagSheet.h" #import "PBAddRemoteSheet.h" #import "PBGitSidebarController.h" #import "PBGitGradientBarView.h" #import "PBDiffWindowController.h" #import "PBGitDefaults.h" #import "PBGitRevList.h" #define QLPreviewPanel NSClassFromString(@"QLPreviewPanel") #define kHistorySelectedDetailIndexKey @"PBHistorySelectedDetailIndex" #define kHistoryDetailViewIndex 0 #define kHistoryTreeViewIndex 1 @interface PBGitHistoryController () - (void) updateBranchFilterMatrix; - (void) restoreFileBrowserSelection; - (void) saveFileBrowserSelection; @end @implementation PBGitHistoryController @synthesize selectedCommitDetailsIndex, webCommit, gitTree, commitController, refController; #pragma mark NSToolbarItemValidation Methods - (BOOL) validateToolbarItem:(NSToolbarItem *)theItem { NSString * curBranchDesc = [[repository currentBranch] description]; NSArray * candidates = [NSArray arrayWithObjects:@"Push", @"Pull", @"Rebase", nil]; BOOL res; if (([candidates containsObject:[theItem label]]) && (([curBranchDesc isEqualToString:@"All branches"]) || ([curBranchDesc isEqualToString:@"Local branches"]))) { res = NO; } else { res = YES; } return res; } #pragma mark PBGitHistoryController - (void)awakeFromNib { self.selectedCommitDetailsIndex = [[NSUserDefaults standardUserDefaults] integerForKey:kHistorySelectedDetailIndexKey]; [commitController addObserver:self forKeyPath:@"selection" options:0 context:@"commitChange"]; [commitController addObserver:self forKeyPath:@"arrangedObjects.@count" options:NSKeyValueObservingOptionInitial context:@"updateCommitCount"]; [treeController addObserver:self forKeyPath:@"selection" options:0 context:@"treeChange"]; [repository.revisionList addObserver:self forKeyPath:@"isUpdating" options:0 context:@"revisionListUpdating"]; [repository.revisionList addObserver:self forKeyPath:@"updatedGraph" options:0 context:@"revisionListUpdatedGraph"]; [repository addObserver:self forKeyPath:@"currentBranch" options:0 context:@"branchChange"]; [repository addObserver:self forKeyPath:@"refs" options:0 context:@"updateRefs"]; forceSelectionUpdate = YES; NSSize cellSpacing = [commitList intercellSpacing]; cellSpacing.height = 0; [commitList setIntercellSpacing:cellSpacing]; [fileBrowser setTarget:self]; [fileBrowser setDoubleAction:@selector(openSelectedFile:)]; if (!repository.currentBranch) { [repository reloadRefs]; [repository readCurrentBranch]; } else [repository lazyReload]; // Set a sort descriptor for the subject column in the history list, as // It can't be sorted by default (because it's bound to a PBGitCommit) [[commitList tableColumnWithIdentifier:@"subject"] setSortDescriptorPrototype:[[NSSortDescriptor alloc] initWithKey:@"subject" ascending:YES]]; // Add a menu that allows a user to select which columns to view [[commitList headerView] setMenu:[self tableColumnMenu]]; [historySplitView setTopMin:58.0 andBottomMin:100.0]; [historySplitView uncollapse]; [upperToolbarView setTopShade:237/255.0 bottomShade:216/255.0]; [scopeBarView setTopColor:[NSColor colorWithCalibratedHue:0.579 saturation:0.068 brightness:0.898 alpha:1.000] bottomColor:[NSColor colorWithCalibratedHue:0.579 saturation:0.119 brightness:0.765 alpha:1.000]]; //[scopeBarView setTopShade:207/255.0 bottomShade:180/255.0]; [self updateBranchFilterMatrix]; [super awakeFromNib]; } - (void) updateKeys { selectedCommit = [[commitController selectedObjects] lastObject]; if (self.selectedCommitDetailsIndex == kHistoryTreeViewIndex) { self.gitTree = selectedCommit.tree; [self restoreFileBrowserSelection]; } else // kHistoryDetailViewIndex self.webCommit = selectedCommit; BOOL isOnHeadBranch = [selectedCommit isOnHeadBranch]; [mergeButton setEnabled:!isOnHeadBranch]; [cherryPickButton setEnabled:!isOnHeadBranch]; [rebaseButton setEnabled:!isOnHeadBranch]; } - (void) updateBranchFilterMatrix { if ([repository.currentBranch isSimpleRef]) { [allBranchesFilterItem setEnabled:YES]; [localRemoteBranchesFilterItem setEnabled:YES]; NSInteger filter = repository.currentBranchFilter; [allBranchesFilterItem setState:(filter == kGitXAllBranchesFilter)]; [localRemoteBranchesFilterItem setState:(filter == kGitXLocalRemoteBranchesFilter)]; [selectedBranchFilterItem setState:(filter == kGitXSelectedBranchFilter)]; } else { [allBranchesFilterItem setState:NO]; [localRemoteBranchesFilterItem setState:NO]; [allBranchesFilterItem setEnabled:NO]; [localRemoteBranchesFilterItem setEnabled:NO]; [selectedBranchFilterItem setState:YES]; } [selectedBranchFilterItem setTitle:[repository.currentBranch title]]; [selectedBranchFilterItem sizeToFit]; [localRemoteBranchesFilterItem setTitle:[[repository.currentBranch ref] isRemote] ? @"Remote" : @"Local"]; } - (PBGitCommit *) firstCommit { NSArray *arrangedObjects = [commitController arrangedObjects]; if ([arrangedObjects count] > 0) return [arrangedObjects objectAtIndex:0]; return nil; } - (void) setSelectedCommitDetailsIndex:(int)detailsIndex { if (selectedCommitDetailsIndex == detailsIndex) return; selectedCommitDetailsIndex = detailsIndex; [[NSUserDefaults standardUserDefaults] setInteger:selectedCommitDetailsIndex forKey:kHistorySelectedDetailIndexKey]; forceSelectionUpdate = YES; [self updateKeys]; } - (void) updateStatus { self.isBusy = repository.revisionList.isUpdating; self.status = [NSString stringWithFormat:@"%d commits loaded", [[commitController arrangedObjects] count]]; } - (void) restoreFileBrowserSelection { if (self.selectedCommitDetailsIndex != kHistoryTreeViewIndex) return; NSArray *children = [treeController content]; if ([children count] == 0) return; NSIndexPath *path = [[NSIndexPath alloc] init]; if ([currentFileBrowserSelectionPath count] == 0) path = [path indexPathByAddingIndex:0]; else { for (NSString *pathComponent in currentFileBrowserSelectionPath) { PBGitTree *child = nil; NSUInteger childIndex = 0; for (child in children) { if ([child.path isEqualToString:pathComponent]) { path = [path indexPathByAddingIndex:childIndex]; children = child.children; break; } childIndex++; } if (!child) return; } } [treeController setSelectionIndexPath:path]; } - (void) saveFileBrowserSelection { NSArray *objects = [treeController selectedObjects]; NSArray *content = [treeController content]; if ([objects count] && [content count]) { PBGitTree *treeItem = [objects objectAtIndex:0]; currentFileBrowserSelectionPath = [treeItem.fullPath componentsSeparatedByString:@"/"]; } } - (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([(NSString *)context isEqualToString: @"commitChange"]) { [self updateKeys]; [self restoreFileBrowserSelection]; return; } if ([(NSString *)context isEqualToString: @"treeChange"]) { [self updateQuicklookForce: NO]; [self saveFileBrowserSelection]; return; } if([(NSString *)context isEqualToString:@"branchChange"]) { // Reset the sorting if ([[commitController sortDescriptors] count]) [commitController setSortDescriptors:[NSArray array]]; [self updateBranchFilterMatrix]; return; } if([(NSString *)context isEqualToString:@"updateRefs"]) { [commitController rearrangeObjects]; return; } if([(NSString *)context isEqualToString:@"updateCommitCount"] || [(NSString *)context isEqualToString:@"revisionListUpdating"]) { [self updateStatus]; return; } if([(NSString *)context isEqualToString:@"revisionListUpdatedGraph"]) { if ([repository.currentBranch isSimpleRef]) [self selectCommit:[repository shaForRef:[repository.currentBranch ref]]]; else [self selectCommit:[[self firstCommit] realSha]]; return; } [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; } - (IBAction) openSelectedFile: sender { NSArray* selectedFiles = [treeController selectedObjects]; if ([selectedFiles count] == 0) return; PBGitTree* tree = [selectedFiles objectAtIndex:0]; NSString* name = [tree tmpFileNameForContents]; [[NSWorkspace sharedWorkspace] openFile:name]; } - (IBAction) setDetailedView:(id)sender { self.selectedCommitDetailsIndex = kHistoryDetailViewIndex; forceSelectionUpdate = YES; } - (IBAction) setTreeView:(id)sender { self.selectedCommitDetailsIndex = kHistoryTreeViewIndex; forceSelectionUpdate = YES; } - (IBAction) setBranchFilter:(id)sender { repository.currentBranchFilter = [sender tag]; [PBGitDefaults setBranchFilter:repository.currentBranchFilter]; [self updateBranchFilterMatrix]; forceSelectionUpdate = YES; } - (void)keyDown:(NSEvent*)event { if ([[event charactersIgnoringModifiers] isEqualToString: @"f"] && [event modifierFlags] & NSAlternateKeyMask && [event modifierFlags] & NSCommandKeyMask) { // command+alt+f [[superController window] makeFirstResponder: searchField]; } else { [super keyDown: event]; } } - (void) copyCommitInfo { PBGitCommit *commit = [[commitController selectedObjects] objectAtIndex:0]; if (!commit) return; NSString *info = [NSString stringWithFormat:@"%@ (%@)", [[commit realSha] substringToIndex:10], [commit subject]]; NSPasteboard *a =[NSPasteboard generalPasteboard]; [a declareTypes:[NSArray arrayWithObject:NSStringPboardType] owner:self]; [a setString:info forType: NSStringPboardType]; } - (IBAction) toggleQLPreviewPanel:(id)sender { if ([[QLPreviewPanel sharedPreviewPanel] respondsToSelector:@selector(setDataSource:)]) { // Public QL API if ([QLPreviewPanel sharedPreviewPanelExists] && [[QLPreviewPanel sharedPreviewPanel] isVisible]) [[QLPreviewPanel sharedPreviewPanel] orderOut:nil]; else [[QLPreviewPanel sharedPreviewPanel] makeKeyAndOrderFront:nil]; } else { // Private QL API (10.5 only) if ([[QLPreviewPanel sharedPreviewPanel] isOpen]) [[QLPreviewPanel sharedPreviewPanel] closePanel]; else { [[QLPreviewPanel sharedPreviewPanel] makeKeyAndOrderFrontWithEffect:1]; [self updateQuicklookForce:YES]; } } } - (void) updateQuicklookForce:(BOOL)force { if (!force && ![[QLPreviewPanel sharedPreviewPanel] isOpen]) return; if ([[QLPreviewPanel sharedPreviewPanel] respondsToSelector:@selector(setDataSource:)]) { // Public QL API [previewPanel reloadData]; } else { // Private QL API (10.5 only) NSArray *selectedFiles = [treeController selectedObjects]; NSMutableArray *fileNames = [NSMutableArray array]; for (PBGitTree *tree in selectedFiles) { NSString *filePath = [tree tmpFileNameForContents]; if (filePath) [fileNames addObject:[NSURL fileURLWithPath:filePath]]; } if ([fileNames count]) [[QLPreviewPanel sharedPreviewPanel] setURLs:fileNames currentIndex:0 preservingDisplayState:YES]; } } - (IBAction) refresh: sender { [repository forceUpdateRevisions]; } - (void) updateView { [self updateKeys]; } - (NSResponder *)firstResponder; { return commitList; } - (void) scrollSelectionToTopOfViewFrom:(NSInteger)oldIndex { if (oldIndex == NSIntegerMax) oldIndex = 0; NSInteger newIndex = [[commitController selectionIndexes] firstIndex]; if (newIndex > oldIndex) { NSInteger visibleRows = floorf([[commitList superview] bounds].size.height / [commitList rowHeight]); newIndex += visibleRows - 1; if (newIndex >= [[commitController content] count]) newIndex = [[commitController content] count] - 1; } [commitList scrollRowToVisible:newIndex]; } - (NSArray *) selectedObjectsForSHA:(NSString *)commitSHA { NSPredicate *selection = [NSPredicate predicateWithFormat:@"realSha == %@", commitSHA]; NSArray *selectedCommits = [[commitController content] filteredArrayUsingPredicate:selection]; if (([selectedCommits count] == 0) && [self firstCommit]) selectedCommits = [NSArray arrayWithObject:[self firstCommit]]; return selectedCommits; } - (void) selectCommit:(NSString *)commitSHA { if (!forceSelectionUpdate && [[selectedCommit realSha] isEqualToString:commitSHA]) return; NSInteger oldIndex = [[commitController selectionIndexes] firstIndex]; NSArray *selectedCommits = [self selectedObjectsForSHA:commitSHA]; [commitController setSelectedObjects:selectedCommits]; if (repository.currentBranchFilter != kGitXSelectedBranchFilter) [self scrollSelectionToTopOfViewFrom:oldIndex]; } - (BOOL) hasNonlinearPath { return [commitController filterPredicate] || [[commitController sortDescriptors] count] > 0; } - (void) removeView { [webView close]; [commitController removeObserver:self forKeyPath:@"selection"]; [treeController removeObserver:self forKeyPath:@"selection"]; [repository removeObserver:self forKeyPath:@"currentBranch"]; [super removeView]; } #pragma mark Table Column Methods - (NSMenu *)tableColumnMenu { NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Table columns menu"]; for (NSTableColumn *column in [commitList tableColumns]) { NSMenuItem *item = [[NSMenuItem alloc] init]; [item setTitle:[[column headerCell] stringValue]]; [item bind:@"value" toObject:column withKeyPath:@"hidden" options:[NSDictionary dictionaryWithObject:@"NSNegateBoolean" forKey:NSValueTransformerNameBindingOption]]; [menu addItem:item]; } return menu; } #pragma mark Tree Context Menu Methods - (void)showCommitsFromTree:(id)sender { // TODO: Enable this from webview as well! NSMutableArray *filePaths = [NSMutableArray arrayWithObjects:@"HEAD", @"--", NULL]; [filePaths addObjectsFromArray:[sender representedObject]]; PBGitRevSpecifier *revSpec = [[PBGitRevSpecifier alloc] initWithParameters:filePaths]; repository.currentBranch = [repository addBranch:revSpec]; } - (void)showInFinderAction:(id)sender { NSString *workingDirectory = [[repository workingDirectory] stringByAppendingString:@"/"]; NSString *path; NSWorkspace *ws = [NSWorkspace sharedWorkspace]; for (NSString *filePath in [sender representedObject]) { path = [workingDirectory stringByAppendingPathComponent:filePath]; [ws selectFile: path inFileViewerRootedAtPath:path]; } } - (void)openFilesAction:(id)sender { NSString *workingDirectory = [[repository workingDirectory] stringByAppendingString:@"/"]; NSString *path; NSWorkspace *ws = [NSWorkspace sharedWorkspace]; for (NSString *filePath in [sender representedObject]) { path = [workingDirectory stringByAppendingPathComponent:filePath]; [ws openFile:path]; } } - (void) checkoutFiles:(id)sender { NSMutableArray *files = [NSMutableArray array]; for (NSString *filePath in [sender representedObject]) [files addObject:[filePath stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]]; [repository checkoutFiles:files fromRefish:selectedCommit]; } - (void) diffFilesAction:(id)sender { [PBDiffWindowController showDiffWindowWithFiles:[sender representedObject] fromCommit:selectedCommit diffCommit:nil]; } - (NSMenu *)contextMenuForTreeView { NSArray *filePaths = [[treeController selectedObjects] valueForKey:@"fullPath"]; NSMenu *menu = [[NSMenu alloc] init]; for (NSMenuItem *item in [self menuItemsForPaths:filePaths]) [menu addItem:item]; return menu; } - (NSArray *)menuItemsForPaths:(NSArray *)paths { NSMutableArray *filePaths = [NSMutableArray array]; for (NSString *filePath in paths) [filePaths addObject:[filePath stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]]; BOOL multiple = [filePaths count] != 1; NSMenuItem *historyItem = [[NSMenuItem alloc] initWithTitle:multiple? @"Show history of files" : @"Show history of file" action:@selector(showCommitsFromTree:) keyEquivalent:@""]; PBGitRef *headRef = [[repository headRef] ref]; NSString *headRefName = [headRef shortName]; NSString *diffTitle = [NSString stringWithFormat:@"Diff %@ with %@", multiple ? @"files" : @"file", headRefName]; BOOL isHead = [[selectedCommit realSha] isEqualToString:[repository headSHA]]; NSMenuItem *diffItem = [[NSMenuItem alloc] initWithTitle:diffTitle action:isHead ? nil : @selector(diffFilesAction:) keyEquivalent:@""]; NSMenuItem *checkoutItem = [[NSMenuItem alloc] initWithTitle:multiple ? @"Checkout files" : @"Checkout file" action:@selector(checkoutFiles:) keyEquivalent:@""]; NSMenuItem *finderItem = [[NSMenuItem alloc] initWithTitle:@"Show in Finder" action:@selector(showInFinderAction:) keyEquivalent:@""]; NSMenuItem *openFilesItem = [[NSMenuItem alloc] initWithTitle:multiple? @"Open Files" : @"Open File" action:@selector(openFilesAction:) keyEquivalent:@""]; NSArray *menuItems = [NSArray arrayWithObjects:historyItem, diffItem, checkoutItem, finderItem, openFilesItem, nil]; for (NSMenuItem *item in menuItems) { [item setTarget:self]; [item setRepresentedObject:filePaths]; } return menuItems; } - (BOOL)splitView:(NSSplitView *)sender canCollapseSubview:(NSView *)subview { return TRUE; } - (BOOL)splitView:(NSSplitView *)splitView shouldCollapseSubview:(NSView *)subview forDoubleClickOnDividerAtIndex:(NSInteger)dividerIndex { int index = [[splitView subviews] indexOfObject:subview]; // this method (and canCollapse) are called by the splitView to decide how to collapse on double-click // we compare our two subviews, so that always the smaller one is collapsed. if([[[splitView subviews] objectAtIndex:index] frame].size.height < [[[splitView subviews] objectAtIndex:((index+1)%2)] frame].size.height) { return TRUE; } return FALSE; } - (CGFloat)splitView:(NSSplitView *)sender constrainMinCoordinate:(CGFloat)proposedMin ofSubviewAt:(NSInteger)offset { return proposedMin + historySplitView.topViewMin; } - (CGFloat)splitView:(NSSplitView *)sender constrainMaxCoordinate:(CGFloat)proposedMax ofSubviewAt:(NSInteger)offset { if(offset == 1) return proposedMax - historySplitView.bottomViewMin; return [sender frame].size.height; } #pragma mark Repository Methods - (IBAction) createBranch:(id)sender { PBGitRef *currentRef = [repository.currentBranch ref]; if (!selectedCommit || [selectedCommit hasRef:currentRef]) [PBCreateBranchSheet beginCreateBranchSheetAtRefish:currentRef inRepository:self.repository]; else [PBCreateBranchSheet beginCreateBranchSheetAtRefish:selectedCommit inRepository:self.repository]; } - (IBAction) createTag:(id)sender { if (!selectedCommit) [PBCreateTagSheet beginCreateTagSheetAtRefish:[repository.currentBranch ref] inRepository:repository]; else [PBCreateTagSheet beginCreateTagSheetAtRefish:selectedCommit inRepository:repository]; } - (IBAction) showAddRemoteSheet:(id)sender { [PBAddRemoteSheet beginAddRemoteSheetForRepository:self.repository]; } - (IBAction) merge:(id)sender { if (selectedCommit) [repository mergeWithRefish:selectedCommit]; } - (IBAction) cherryPick:(id)sender { if (selectedCommit) [repository cherryPickRefish:selectedCommit]; } - (IBAction) rebase:(id)sender { if (selectedCommit) { PBGitRef *headRef = [[repository headRef] ref]; [repository rebaseBranch:headRef onRefish:selectedCommit]; } } #pragma mark - #pragma mark Quick Look Public API support @protocol QLPreviewItem; #pragma mark (QLPreviewPanelController) - (BOOL) acceptsPreviewPanelControl:(id)panel { return YES; } - (void)beginPreviewPanelControl:(id)panel { // This document is now responsible of the preview panel // It is allowed to set the delegate, data source and refresh panel. previewPanel = panel; [previewPanel setDelegate:self]; [previewPanel setDataSource:self]; } - (void)endPreviewPanelControl:(id)panel { // This document loses its responsisibility on the preview panel // Until the next call to -beginPreviewPanelControl: it must not // change the panel's delegate, data source or refresh it. previewPanel = nil; } #pragma mark - (NSInteger)numberOfPreviewItemsInPreviewPanel:(id)panel { return [[fileBrowser selectedRowIndexes] count]; } - (id )previewPanel:(id)panel previewItemAtIndex:(NSInteger)index { PBGitTree *treeItem = (PBGitTree *)[[treeController selectedObjects] objectAtIndex:index]; NSURL *previewURL = [NSURL fileURLWithPath:[treeItem tmpFileNameForContents]]; return (id )previewURL; } #pragma mark - (BOOL)previewPanel:(id)panel handleEvent:(NSEvent *)event { // redirect all key down events to the table view if ([event type] == NSKeyDown) { [fileBrowser keyDown:event]; return YES; } return NO; } // This delegate method provides the rect on screen from which the panel will zoom. - (NSRect)previewPanel:(id)panel sourceFrameOnScreenForPreviewItem:(id )item { NSInteger index = [fileBrowser rowForItem:[[treeController selectedNodes] objectAtIndex:0]]; if (index == NSNotFound) { return NSZeroRect; } NSRect iconRect = [fileBrowser frameOfCellAtColumn:0 row:index]; // check that the icon rect is visible on screen NSRect visibleRect = [fileBrowser visibleRect]; if (!NSIntersectsRect(visibleRect, iconRect)) { return NSZeroRect; } // convert icon rect to screen coordinates iconRect = [fileBrowser convertRectToBase:iconRect]; iconRect.origin = [[fileBrowser window] convertBaseToScreen:iconRect.origin]; return iconRect; } @end