/* * Copyright 2013 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.git; import static org.eclipse.jgit.transport.BasePackPushConnection.CAPABILITY_SIDE_BAND_64K; import java.io.IOException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.BatchRefUpdate; import org.eclipse.jgit.lib.NullProgressMonitor; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.ProgressMonitor; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevSort; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.transport.ReceiveCommand; import org.eclipse.jgit.transport.ReceiveCommand.Result; import org.eclipse.jgit.transport.ReceiveCommand.Type; import org.eclipse.jgit.transport.ReceivePack; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.gitblit.Constants; import com.gitblit.Keys; import com.gitblit.extensions.PatchsetHook; import com.gitblit.manager.IGitblit; import com.gitblit.models.RepositoryModel; import com.gitblit.models.TicketModel; import com.gitblit.models.TicketModel.Change; import com.gitblit.models.TicketModel.Field; import com.gitblit.models.TicketModel.Patchset; import com.gitblit.models.TicketModel.PatchsetType; import com.gitblit.models.TicketModel.Status; import com.gitblit.models.UserModel; import com.gitblit.tickets.BranchTicketService; import com.gitblit.tickets.ITicketService; import com.gitblit.tickets.TicketMilestone; import com.gitblit.tickets.TicketNotifier; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.DiffUtils; import com.gitblit.utils.DiffUtils.DiffStat; import com.gitblit.utils.JGitUtils; import com.gitblit.utils.JGitUtils.MergeResult; import com.gitblit.utils.JGitUtils.MergeStatus; import com.gitblit.utils.RefLogUtils; import com.gitblit.utils.StringUtils; /** * PatchsetReceivePack processes receive commands and allows for creating, updating, * and closing Gitblit tickets. It also executes Groovy pre- and post- receive * hooks. * * The patchset mechanism defined in this class is based on the ReceiveCommits class * from the Gerrit code review server. * * The general execution flow is: *
    *
  1. onPreReceive()
  2. *
  3. executeCommands()
  4. *
  5. onPostReceive()
  6. *
* * @author Android Open Source Project * @author James Moger * */ public class PatchsetReceivePack extends GitblitReceivePack { protected static final List MAGIC_REFS = Arrays.asList(Constants.R_FOR, Constants.R_TICKET); protected static final Pattern NEW_PATCHSET = Pattern.compile("^refs/tickets/(?:[0-9a-zA-Z][0-9a-zA-Z]/)?([1-9][0-9]*)(?:/new)?$"); private static final Logger LOGGER = LoggerFactory.getLogger(PatchsetReceivePack.class); protected final ITicketService ticketService; protected final TicketNotifier ticketNotifier; private boolean requireMergeablePatchset; public PatchsetReceivePack(IGitblit gitblit, Repository db, RepositoryModel repository, UserModel user) { super(gitblit, db, repository, user); this.ticketService = gitblit.getTicketService(); this.ticketNotifier = ticketService.createNotifier(); } /** Returns the patchset ref root from the ref */ private String getPatchsetRef(String refName) { for (String patchRef : MAGIC_REFS) { if (refName.startsWith(patchRef)) { return patchRef; } } return null; } /** Checks if the supplied ref name is a patchset ref */ private boolean isPatchsetRef(String refName) { return !StringUtils.isEmpty(getPatchsetRef(refName)); } /** Checks if the supplied ref name is a change ref */ private boolean isTicketRef(String refName) { return refName.startsWith(Constants.R_TICKETS_PATCHSETS); } /** Extracts the integration branch from the ref name */ private String getIntegrationBranch(String refName) { String patchsetRef = getPatchsetRef(refName); String branch = refName.substring(patchsetRef.length()); if (branch.indexOf('%') > -1) { branch = branch.substring(0, branch.indexOf('%')); } String defaultBranch = "master"; try { defaultBranch = getRepository().getBranch(); } catch (Exception e) { LOGGER.error("failed to determine default branch for " + repository.name, e); } long ticketId = 0L; try { ticketId = Long.parseLong(branch); } catch (Exception e) { // not a number } if (ticketId > 0 || branch.equalsIgnoreCase("default") || branch.equalsIgnoreCase("new")) { return defaultBranch; } return branch; } /** Extracts the ticket id from the ref name */ private long getTicketId(String refName) { if (refName.indexOf('%') > -1) { refName = refName.substring(0, refName.indexOf('%')); } if (refName.startsWith(Constants.R_FOR)) { String ref = refName.substring(Constants.R_FOR.length()); try { return Long.parseLong(ref); } catch (Exception e) { // not a number } } else if (refName.startsWith(Constants.R_TICKET) || refName.startsWith(Constants.R_TICKETS_PATCHSETS)) { return PatchsetCommand.getTicketNumber(refName); } return 0L; } /** Returns true if the ref namespace exists */ private boolean hasRefNamespace(String ref) { Map blockingFors; try { blockingFors = getRepository().getRefDatabase().getRefs(ref); } catch (IOException err) { sendError("Cannot scan refs in {0}", repository.name); LOGGER.error("Error!", err); return true; } if (!blockingFors.isEmpty()) { sendError("{0} needs the following refs removed to receive patchsets: {1}", repository.name, blockingFors.keySet()); return true; } return false; } /** Removes change ref receive commands */ private List excludeTicketCommands(Collection commands) { List filtered = new ArrayList(); for (ReceiveCommand cmd : commands) { if (!isTicketRef(cmd.getRefName())) { // this is not a ticket ref update filtered.add(cmd); } } return filtered; } /** Removes patchset receive commands for pre- and post- hook integrations */ private List excludePatchsetCommands(Collection commands) { List filtered = new ArrayList(); for (ReceiveCommand cmd : commands) { if (!isPatchsetRef(cmd.getRefName())) { // this is a non-patchset ref update filtered.add(cmd); } } return filtered; } /** Process receive commands EXCEPT for Patchset commands. */ @Override public void onPreReceive(ReceivePack rp, Collection commands) { Collection filtered = excludePatchsetCommands(commands); super.onPreReceive(rp, filtered); } /** Process receive commands EXCEPT for Patchset commands. */ @Override public void onPostReceive(ReceivePack rp, Collection commands) { Collection filtered = excludePatchsetCommands(commands); super.onPostReceive(rp, filtered); // send all queued ticket notifications after processing all patchsets ticketNotifier.sendAll(); } @Override protected void validateCommands() { // workaround for JGit's awful scoping choices // // set the patchset refs to OK to bypass checks in the super implementation for (final ReceiveCommand cmd : filterCommands(Result.NOT_ATTEMPTED)) { if (isPatchsetRef(cmd.getRefName())) { if (cmd.getType() == ReceiveCommand.Type.CREATE) { cmd.setResult(Result.OK); } } } super.validateCommands(); } /** Execute commands to update references. */ @Override protected void executeCommands() { // we process patchsets unless the user is pushing something special boolean processPatchsets = true; for (ReceiveCommand cmd : filterCommands(Result.NOT_ATTEMPTED)) { if (ticketService instanceof BranchTicketService && BranchTicketService.BRANCH.equals(cmd.getRefName())) { // the user is pushing an update to the BranchTicketService data processPatchsets = false; } } // workaround for JGit's awful scoping choices // // reset the patchset refs to NOT_ATTEMPTED (see validateCommands) for (ReceiveCommand cmd : filterCommands(Result.OK)) { if (isPatchsetRef(cmd.getRefName())) { cmd.setResult(Result.NOT_ATTEMPTED); } else if (ticketService instanceof BranchTicketService && BranchTicketService.BRANCH.equals(cmd.getRefName())) { // the user is pushing an update to the BranchTicketService data processPatchsets = false; } } List toApply = filterCommands(Result.NOT_ATTEMPTED); if (toApply.isEmpty()) { return; } ProgressMonitor updating = NullProgressMonitor.INSTANCE; boolean sideBand = isCapabilityEnabled(CAPABILITY_SIDE_BAND_64K); if (sideBand) { SideBandProgressMonitor pm = new SideBandProgressMonitor(msgOut); pm.setDelayStart(250, TimeUnit.MILLISECONDS); updating = pm; } BatchRefUpdate batch = getRepository().getRefDatabase().newBatchUpdate(); batch.setAllowNonFastForwards(isAllowNonFastForwards()); batch.setRefLogIdent(getRefLogIdent()); batch.setRefLogMessage("push", true); ReceiveCommand patchsetRefCmd = null; PatchsetCommand patchsetCmd = null; for (ReceiveCommand cmd : toApply) { if (Result.NOT_ATTEMPTED != cmd.getResult()) { // Already rejected by the core receive process. continue; } if (isPatchsetRef(cmd.getRefName()) && processPatchsets) { if (ticketService == null) { sendRejection(cmd, "Sorry, the ticket service is unavailable and can not accept patchsets at this time."); continue; } if (!ticketService.isReady()) { sendRejection(cmd, "Sorry, the ticket service can not accept patchsets at this time."); continue; } if (UserModel.ANONYMOUS.equals(user)) { // server allows anonymous pushes, but anonymous patchset // contributions are prohibited by design sendRejection(cmd, "Sorry, anonymous patchset contributions are prohibited."); continue; } final Matcher m = NEW_PATCHSET.matcher(cmd.getRefName()); if (m.matches()) { // prohibit pushing directly to a patchset ref long id = getTicketId(cmd.getRefName()); sendError("You may not directly push directly to a patchset ref!"); sendError("Instead, please push to one the following:"); sendError(" - {0}{1,number,0}", Constants.R_FOR, id); sendError(" - {0}{1,number,0}", Constants.R_TICKET, id); sendRejection(cmd, "protected ref"); continue; } if (hasRefNamespace(Constants.R_FOR)) { // the refs/for/ namespace exists and it must not LOGGER.error("{} already has refs in the {} namespace", repository.name, Constants.R_FOR); sendRejection(cmd, "Sorry, a repository administrator will have to remove the {} namespace", Constants.R_FOR); continue; } if (cmd.getNewId().equals(ObjectId.zeroId())) { // ref deletion request if (cmd.getRefName().startsWith(Constants.R_TICKET)) { if (user.canDeleteRef(repository)) { batch.addCommand(cmd); } else { sendRejection(cmd, "Sorry, you do not have permission to delete {}", cmd.getRefName()); } } else { sendRejection(cmd, "Sorry, you can not delete {}", cmd.getRefName()); } continue; } if (patchsetRefCmd != null) { sendRejection(cmd, "You may only push one patchset at a time."); continue; } LOGGER.info(MessageFormat.format("Verifying {0} push ref \"{1}\" received from {2}", repository.name, cmd.getRefName(), user.username)); // responsible verification String responsible = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.RESPONSIBLE); if (!StringUtils.isEmpty(responsible)) { UserModel assignee = gitblit.getUserModel(responsible); if (assignee == null) { // no account by this name sendRejection(cmd, "{0} can not be assigned any tickets because there is no user account by that name", responsible); continue; } else if (!assignee.canPush(repository)) { // account does not have RW permissions sendRejection(cmd, "{0} ({1}) can not be assigned any tickets because the user does not have RW permissions for {2}", assignee.getDisplayName(), assignee.username, repository.name); continue; } } // milestone verification String milestone = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.MILESTONE); if (!StringUtils.isEmpty(milestone)) { TicketMilestone milestoneModel = ticketService.getMilestone(repository, milestone); if (milestoneModel == null) { // milestone does not exist sendRejection(cmd, "Sorry, \"{0}\" is not a valid milestone!", milestone); continue; } } // watcher verification List watchers = PatchsetCommand.getOptions(cmd, PatchsetCommand.WATCH); if (!ArrayUtils.isEmpty(watchers)) { boolean verified = true; for (String watcher : watchers) { UserModel user = gitblit.getUserModel(watcher); if (user == null) { // watcher does not exist sendRejection(cmd, "Sorry, \"{0}\" is not a valid username for the watch list!", watcher); verified = false; break; } } if (!verified) { continue; } } patchsetRefCmd = cmd; patchsetCmd = preparePatchset(cmd); if (patchsetCmd != null) { batch.addCommand(patchsetCmd); } continue; } batch.addCommand(cmd); } if (!batch.getCommands().isEmpty()) { try { batch.execute(getRevWalk(), updating); } catch (IOException err) { for (ReceiveCommand cmd : toApply) { if (cmd.getResult() == Result.NOT_ATTEMPTED) { sendRejection(cmd, "lock error: {0}", err.getMessage()); LOGGER.error(MessageFormat.format("failed to lock {0}:{1}", repository.name, cmd.getRefName()), err); } } } } // // set the results into the patchset ref receive command // if (patchsetRefCmd != null && patchsetCmd != null) { if (!patchsetCmd.getResult().equals(Result.OK)) { // patchset command failed! LOGGER.error(patchsetCmd.getType() + " " + patchsetCmd.getRefName() + " " + patchsetCmd.getResult()); patchsetRefCmd.setResult(patchsetCmd.getResult(), patchsetCmd.getMessage()); } else { // all patchset commands were applied patchsetRefCmd.setResult(Result.OK); // update the ticket branch ref RefUpdate ru = updateRef( patchsetCmd.getTicketBranch(), patchsetCmd.getNewId(), patchsetCmd.getPatchsetType()); updateReflog(ru); TicketModel ticket = processPatchset(patchsetCmd); if (ticket != null) { ticketNotifier.queueMailing(ticket); } } } // // if there are standard ref update receive commands that were // successfully processed, process referenced tickets, if any // List allUpdates = ReceiveCommand.filter(batch.getCommands(), Result.OK); List refUpdates = excludePatchsetCommands(allUpdates); List stdUpdates = excludeTicketCommands(refUpdates); if (!stdUpdates.isEmpty()) { int ticketsProcessed = 0; for (ReceiveCommand cmd : stdUpdates) { switch (cmd.getType()) { case CREATE: case UPDATE: case UPDATE_NONFASTFORWARD: if (cmd.getRefName().startsWith(Constants.R_HEADS)) { Collection tickets = processMergedTickets(cmd); ticketsProcessed += tickets.size(); for (TicketModel ticket : tickets) { ticketNotifier.queueMailing(ticket); } } break; default: break; } } if (ticketsProcessed == 1) { sendInfo("1 ticket updated"); } else if (ticketsProcessed > 1) { sendInfo("{0} tickets updated", ticketsProcessed); } } // reset the ticket caches for the repository ticketService.resetCaches(repository); } /** * Prepares a patchset command. * * @param cmd * @return the patchset command */ private PatchsetCommand preparePatchset(ReceiveCommand cmd) { String branch = getIntegrationBranch(cmd.getRefName()); long number = getTicketId(cmd.getRefName()); TicketModel ticket = null; if (number > 0 && ticketService.hasTicket(repository, number)) { ticket = ticketService.getTicket(repository, number); } if (ticket == null) { if (number > 0) { // requested ticket does not exist sendError("Sorry, {0} does not have ticket {1,number,0}!", repository.name, number); sendRejection(cmd, "Invalid ticket number"); return null; } } else { if (ticket.isMerged()) { // ticket already merged & resolved Change mergeChange = null; for (Change change : ticket.changes) { if (change.isMerge()) { mergeChange = change; break; } } if (mergeChange != null) { sendError("Sorry, {0} already merged {1} from ticket {2,number,0} to {3}!", mergeChange.author, mergeChange.patchset, number, ticket.mergeTo); } sendRejection(cmd, "Ticket {0,number,0} already resolved", number); return null; } else if (!StringUtils.isEmpty(ticket.mergeTo)) { // ticket specifies integration branch branch = ticket.mergeTo; } } final int shortCommitIdLen = settings.getInteger(Keys.web.shortCommitIdLength, 6); final String shortTipId = cmd.getNewId().getName().substring(0, shortCommitIdLen); final RevCommit tipCommit = JGitUtils.getCommit(getRepository(), cmd.getNewId().getName()); final String forBranch = branch; RevCommit mergeBase = null; Ref forBranchRef = getAdvertisedRefs().get(Constants.R_HEADS + forBranch); if (forBranchRef == null || forBranchRef.getObjectId() == null) { // unknown integration branch sendError("Sorry, there is no integration branch named ''{0}''.", forBranch); sendRejection(cmd, "Invalid integration branch specified"); return null; } else { // determine the merge base for the patchset on the integration branch String base = JGitUtils.getMergeBase(getRepository(), forBranchRef.getObjectId(), tipCommit.getId()); if (StringUtils.isEmpty(base)) { sendError(""); sendError("There is no common ancestry between {0} and {1}.", forBranch, shortTipId); sendError("Please reconsider your proposed integration branch, {0}.", forBranch); sendError(""); sendRejection(cmd, "no merge base for patchset and {0}", forBranch); return null; } mergeBase = JGitUtils.getCommit(getRepository(), base); } // ensure that the patchset can be cleanly merged right now MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch, repository.mergeType); switch (status) { case ALREADY_MERGED: sendError(""); sendError("You have already merged this patchset.", forBranch); sendError(""); sendRejection(cmd, "everything up-to-date"); return null; case MERGEABLE: break; default: if (ticket == null || requireMergeablePatchset) { sendError(""); sendError("Your patchset can not be cleanly merged into {0}.", forBranch); sendError("Please rebase your patchset and push again."); sendError("NOTE:", number); sendError("You should push your rebase to refs/for/{0,number,0}", number); sendError(""); sendError(" git push origin HEAD:refs/for/{0,number,0}", number); sendError(""); sendRejection(cmd, "patchset not mergeable"); return null; } } // check to see if this commit is already linked to a ticket long id = identifyTicket(tipCommit, false); if (id > 0) { sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, id); sendRejection(cmd, "everything up-to-date"); return null; } PatchsetCommand psCmd; if (ticket == null) { /* * NEW TICKET */ Patchset patchset = newPatchset(null, mergeBase.getName(), tipCommit.getName()); int minLength = 10; int maxLength = 100; String minTitle = MessageFormat.format(" minimum length of a title is {0} characters.", minLength); String maxTitle = MessageFormat.format(" maximum length of a title is {0} characters.", maxLength); if (patchset.commits > 1) { sendError(""); sendError("To create a proposal ticket, please squash your commits and"); sendError("provide a meaningful commit message with a short title &"); sendError("an optional description/body."); sendError(""); sendError(minTitle); sendError(maxTitle); sendError(""); sendRejection(cmd, "please squash to one commit"); return null; } // require a reasonable title/subject String title = tipCommit.getFullMessage().trim().split("\n")[0]; if (title.length() < minLength) { // reject, title too short sendError(""); sendError("Please supply a longer title in your commit message!"); sendError(""); sendError(minTitle); sendError(maxTitle); sendError(""); sendRejection(cmd, "ticket title is too short [{0}/{1}]", title.length(), maxLength); return null; } if (title.length() > maxLength) { // reject, title too long sendError(""); sendError("Please supply a more concise title in your commit message!"); sendError(""); sendError(minTitle); sendError(maxTitle); sendError(""); sendRejection(cmd, "ticket title is too long [{0}/{1}]", title.length(), maxLength); return null; } // assign new id long ticketId = ticketService.assignNewId(repository); // create the patchset command psCmd = new PatchsetCommand(user.username, patchset); psCmd.newTicket(tipCommit, forBranch, ticketId, cmd.getRefName()); } else { /* * EXISTING TICKET */ Patchset patchset = newPatchset(ticket, mergeBase.getName(), tipCommit.getName()); psCmd = new PatchsetCommand(user.username, patchset); psCmd.updateTicket(tipCommit, forBranch, ticket, cmd.getRefName()); } // confirm user can push the patchset boolean pushPermitted = ticket == null || !ticket.hasPatchsets() || ticket.isAuthor(user.username) || ticket.isPatchsetAuthor(user.username) || ticket.isResponsible(user.username) || user.canPush(repository); switch (psCmd.getPatchsetType()) { case Proposal: // proposals (first patchset) are always acceptable break; case FastForward: // patchset updates must be permitted if (!pushPermitted) { // reject sendError(""); sendError("To push a patchset to this ticket one of the following must be true:"); sendError(" 1. you created the ticket"); sendError(" 2. you created the first patchset"); sendError(" 3. you are specified as responsible for the ticket"); sendError(" 4. you have push (RW) permissions to {0}", repository.name); sendError(""); sendRejection(cmd, "not permitted to push to ticket {0,number,0}", ticket.number); return null; } break; default: // non-fast-forward push if (!pushPermitted) { // reject sendRejection(cmd, "non-fast-forward ({0})", psCmd.getPatchsetType()); return null; } break; } return psCmd; } /** * Creates or updates an ticket with the specified patchset. * * @param cmd * @return a ticket if the creation or update was successful */ private TicketModel processPatchset(PatchsetCommand cmd) { Change change = cmd.getChange(); if (cmd.isNewTicket()) { // create the ticket object TicketModel ticket = ticketService.createTicket(repository, cmd.getTicketId(), change); if (ticket != null) { sendInfo(""); sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG)); sendInfo("created proposal ticket from patchset"); sendInfo(ticketService.getTicketUrl(ticket)); sendInfo(""); // log the new patch ref RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName()))); // call any patchset hooks for (PatchsetHook hook : gitblit.getExtensions(PatchsetHook.class)) { try { hook.onNewPatchset(ticket); } catch (Exception e) { LOGGER.error("Failed to execute extension", e); } } return ticket; } else { sendError("FAILED to create ticket"); } } else { // update an existing ticket TicketModel ticket = ticketService.updateTicket(repository, cmd.getTicketId(), change); if (ticket != null) { sendInfo(""); sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG)); if (change.patchset.rev == 1) { // new patchset sendInfo("uploaded patchset {0} ({1})", change.patchset.number, change.patchset.type.toString()); } else { // updated patchset sendInfo("added {0} {1} to patchset {2}", change.patchset.added, change.patchset.added == 1 ? "commit" : "commits", change.patchset.number); } sendInfo(ticketService.getTicketUrl(ticket)); sendInfo(""); // log the new patchset ref RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName()))); // call any patchset hooks final boolean isNewPatchset = change.patchset.rev == 1; for (PatchsetHook hook : gitblit.getExtensions(PatchsetHook.class)) { try { if (isNewPatchset) { hook.onNewPatchset(ticket); } else { hook.onUpdatePatchset(ticket); } } catch (Exception e) { LOGGER.error("Failed to execute extension", e); } } // return the updated ticket return ticket; } else { sendError("FAILED to upload {0} for ticket {1,number,0}", change.patchset, cmd.getTick
/*!
 * jQuery UI Menu @VERSION
 * http://jqueryui.com
 *
 * Copyright jQuery Foundation and other contributors
 * Released under the MIT license.
 * http://jquery.org/license
 */

//>>label: Menu
//>>group: Widgets
//>>description: Creates nestable menus.
//>>docs: http://api.jqueryui.com/menu/
//>>demos: http://jqueryui.com/menu/
//>>css.structure: ../themes/base/core.css
//>>css.structure: ../themes/base/menu.css
//>>css.theme: ../themes/base/theme.css

( function( factory ) {
	if ( typeof define === "function" && define.amd ) {

		// AMD. Register as an anonymous module.
		define( [
			"jquery",
			"./core",
			"./widget",
			"./position"
		], factory );
	} else {

		// Browser globals
		factory( jQuery );
	}
}( function( $ ) {

return $.widget( "ui.menu", {
	version: "@VERSION",
	defaultElement: "<ul>",
	delay: 300,
	options: {
		icons: {
			submenu: "ui-icon-caret-1-e"
		},
		items: "> *",
		menus: "ul",
		position: {
			my: "left top",
			at: "right top"
		},
		role: "menu",

		// callbacks
		blur: null,
		focus: null,
		select: null
	},

	_create: function() {
		this.activeMenu = this.element;

		// Flag used to prevent firing of the click handler
		// as the event bubbles up through nested menus
		this.mouseHandled = false;
		this.element
			.uniqueId()
			.attr( {
				role: this.options.role,
				tabIndex: 0
			} );

		if ( this.options.disabled ) {
			this._addClass( null, "ui-state-disabled" );
			this.element.attr( "aria-disabled", "true" );
		}

		this._addClass( "ui-menu", "ui-widget ui-widget-content" );
		this._on( {

			// Prevent focus from sticking to links inside menu after clicking
			// them (focus should always stay on UL during navigation).
			"mousedown .ui-menu-item": function( event ) {
				event.preventDefault();
			},
			"click .ui-menu-item": function( event ) {
				var target = $( event.target );
				if ( !this.mouseHandled && target.not( ".ui-state-disabled" ).length ) {
					this.select( event );

					// Only set the mouseHandled flag if the event will bubble, see #9469.
					if ( !event.isPropagationStopped() ) {
						this.mouseHandled = true;
					}

					// Open submenu on click
					if ( target.has( ".ui-menu" ).length ) {
						this.expand( event );
					} else if ( !this.element.is( ":focus" ) && $( $.ui.safeActiveElement( this.document[ 0 ] ) ).closest( ".ui-menu" ).length ) {

						// Redirect focus to the menu
						this.element.trigger( "focus", [ true ] );

						// If the active item is on the top level, let it stay active.
						// Otherwise, blur the active item since it is no longer visible.
						if ( this.active && this.active.parents( ".ui-menu" ).length === 1 ) {
							clearTimeout( this.timer );
						}
					}
				}
			},
			"mouseenter .ui-menu-item": function( event ) {

				// Ignore mouse events while typeahead is active, see #10458.
				// Prevents focusing the wrong item when typeahead causes a scroll while the mouse
				// is over an item in the menu
				if ( this.previousFilter ) {
					return;
				}

				var actualTarget = $( event.target ).closest( ".ui-menu-item" ),
					target = $( event.currentTarget );

				// Ignore bubbled events on parent items, see #11641
				if ( actualTarget[ 0 ] !== target[ 0 ] ) {
					return;
				}

				// Remove ui-state-active class from siblings of the newly focused menu item
				// to avoid a jump caused by adjacent elements both having a class with a border
				this._removeClass( target.siblings().children( ".ui-state-active" ),
					null, "ui-state-active" );
				this.focus( event, target );
			},
			mouseleave: "collapseAll",
			"mouseleave .ui-menu": "collapseAll",
			focus: function( event, keepActiveItem ) {
				// If there's already an active item, keep it active
				// If not, activate the first item
				var item = this.active || this.element.find( this.options.items ).eq( 0 );

				if ( !keepActiveItem ) {
					this.focus( event, item );
				}
			},
			blur: function( event ) {
				this._delay( function() {
					if ( !$.contains( this.element[ 0 ], $.ui.safeActiveElement( this.document[ 0 ] ) ) ) {
						this.collapseAll( event );
					}
				} );
			},
			keydown: "_keydown"
		} );

		this.refresh();

		// Clicks outside of a menu collapse any open menus
		this._on( this.document, {
			click: function( event ) {
				if ( this._closeOnDocumentClick( event ) ) {
					this.collapseAll( event );
				}

				// Reset the mouseHandled flag
				this.mouseHandled = false;
			}
		} );
	},

	_destroy: function() {
		var items = this.element.find( ".ui-menu-item" )
				.removeAttr( "role aria-disabled" ),
			submenus = items.children( ".ui-menu-item-wrapper" )
				.removeUniqueId()
				.removeAttr( "tabIndex role aria-haspopup" );

		// Destroy (sub)menus
		this.element
			.removeAttr( "aria-activedescendant" )
			.find( ".ui-menu" ).addBack()
				.removeAttr( "role aria-labelledby aria-expanded aria-hidden aria-disabled " +
					"tabIndex" )
				.removeUniqueId()
				.show();

		submenus.children().each( function() {
			var elem = $( this );
			if ( elem.data( "ui-menu-submenu-caret" ) ) {
				elem.remove();
			}
		} );
	},

	_keydown: function( event ) {
		var match, prev, character, skip,
			preventDefault = true;

		switch ( event.keyCode ) {
		case $.ui.keyCode.PAGE_UP:
			this.previousPage( event );
			break;
		case $.ui.keyCode.PAGE_DOWN:
			this.nextPage( event );
			break;
		case $.ui.keyCode.HOME:
			this._move( "first", "first", event );
			break;
		case $.ui.keyCode.END:
			this._move( "last", "last", event );
			break;
		case $.ui.keyCode.UP:
			this.previous( event );
			break;
		case $.ui.keyCode.DOWN:
			this.next( event );
			break;
		case $.ui.keyCode.LEFT:
			this.collapse( event );
			break;
		case $.ui.keyCode.RIGHT:
			if ( this.active && !this.active.is( ".ui-state-disabled" ) ) {
				this.expand( event );
			}
			break;
		case $.ui.keyCode.ENTER:
		case $.ui.keyCode.SPACE:
			this._activate( event );
			break;
		case $.ui.keyCode.ESCAPE:
			this.collapse( event );
			break;
		default:
			preventDefault = false;
			prev = this.previousFilter || "";
			character = String.fromCharCode( event.keyCode );
			skip = false;

			clearTimeout( this.filterTimer );

			if ( character === prev ) {
				skip = true;
			} else {
				character = prev + character;
			}

			match = this._filterMenuItems( character );
			match = skip && match.index( this.active.next() ) !== -1 ?
				this.active.nextAll( ".ui-menu-item" ) :
				match;

			// If no matches on the current filter, reset to the last character pressed
			// to move down the menu to the first item that starts with that character
			if ( !match.length ) {
				character = String.fromCharCode( event.keyCode );
				match = this._filterMenuItems( character );
			}

			if ( match.length ) {
				this.focus( event, match );
				this.previousFilter = character;
				this.filterTimer = this._delay( function() {
					delete this.previousFilter;
				}, 1000 );
			} else {
				delete this.previousFilter;
			}
		}

		if ( preventDefault ) {
			event.preventDefault();
		}
	},

	_activate: function( event ) {
		if ( !this.active.is( ".ui-state-disabled" ) ) {
			if ( this.active.children( "[aria-haspopup='true']" ).length ) {
				this.expand( event );
			} else {
				this.select( event );
			}
		}
	},

	refresh: function() {
		var menus, items, newSubmenus, newItems, newWrappers,
			that = this,
			icon = this.options.icons.submenu,
			submenus = this.element.find( this.options.menus );

		this._toggleClass( "ui-menu-icons", null, !!this.element.find( ".ui-icon" ).length );

		// Initialize nested menus
		newSubmenus = submenus.filter( ":not(.ui-menu)" )
			.hide()
			.attr( {
				role: this.options.role,
				"aria-hidden": "true",
				"aria-expanded": "false"
			} )
			.each( function() {
				var menu = $( this ),
					item = menu.prev(),
					submenuCaret = $( "<span>" ).data( "ui-menu-submenu-caret", true );

				that._addClass( submenuCaret, "ui-menu-icon", "ui-icon " + icon );
				item
					.attr( "aria-haspopup", "true" )
					.prepend( submenuCaret );
				menu.attr( "aria-labelledby", item.attr( "id" ) );
			} );

		this._addClass( newSubmenus, "ui-menu", "ui-widget ui-widget-content ui-front" );

		menus = submenus.add( this.element );
		items = menus.find( this.options.items );

		// Initialize menu-items containing spaces and/or dashes only as dividers
		items.not( ".ui-menu-item" ).each( function() {
			var item = $( this );
			if ( that._isDivider( item ) ) {
				that._addClass( item, "ui-menu-divider", "ui-widget-content" );
			}
		} );

		// Don't refresh list items that are already adapted
		newItems = items.not( ".ui-menu-item, .ui-menu-divider" );
		newWrappers = newItems.children()
			.not( ".ui-menu" )
				.uniqueId()
				.attr( {
					tabIndex: -1,
					role: this._itemRole()
				} );
		this._addClass( newItems, "ui-menu-item" )
			._addClass( newWrappers, "ui-menu-item-wrapper" );

		// Add aria-disabled attribute to any disabled menu item
		items.filter( ".ui-state-disabled" ).attr( "aria-disabled", "true" );

		// If the active item has been removed, blur the menu
		if ( this.active && !$.contains( this.element[ 0 ], this.active[ 0 ] ) ) {
			this.blur();
		}
	},

	_itemRole: function() {
		return {
			menu: "menuitem",
			listbox: "option"
		}[ this.options.role ];
	},

	_setOption: function( key, value ) {
		if ( key === "icons" ) {
			var icons = this.element.find( ".ui-menu-icon" );
			this._removeClass( icons, null, this.options.icons.submenu )
				._addClass( icons, null, value.submenu );
		}
		if ( key === "disabled" ) {
			this.element.attr( "aria-disabled", value );
			this._toggleClass( null, "ui-state-disabled", !!value );
		}
		this._super( key, value );
	},

	focus: function( event, item ) {
		var nested, focused, activeParent;
		this.blur( event, event && event.type === "focus" );

		this._scrollIntoView( item );

		this.active = item.first();

		focused = this.active.children( ".ui-menu-item-wrapper" );
		this._addClass( focused, null, "ui-state-active" );

		// Only update aria-activedescendant if there's a role
		// otherwise we assume focus is managed elsewhere
		if ( this.options.role ) {
			this.element.attr( "aria-activedescendant", focused.attr( "id" ) );
		}

		// Highlight active parent menu item, if any
		activeParent = this.active
			.parent()
				.closest( ".ui-menu-item" )
					.children( ".ui-menu-item-wrapper" );
		this._addClass( activeParent, null, "ui-state-active" );

		if ( event && event.type === "keydown" ) {
			this._close();
		} else {
			this.timer = this._delay( function() {
				this._close();
			}, this.delay );
		}

		nested = item.children( ".ui-menu" );
		if ( nested.length && event && ( /^mouse/.test( event.type ) ) ) {
			this._startOpening( nested );
		}
		this.activeMenu = item.parent();

		this._trigger( "focus", event, { item: item } );
	},

	_scrollIntoView: function( item ) {
		var borderTop, paddingTop, offset, scroll, elementHeight, itemHeight;
		if ( this._hasScroll() ) {
			borderTop = parseFloat( $.css( this.activeMenu[ 0 ], "borderTopWidth" ) ) || 0;
			paddingTop = parseFloat( $.css( this.activeMenu[ 0 ], "paddingTop" ) ) || 0;
			offset = item.offset().top - this.activeMenu.offset().top - borderTop - paddingTop;
			scroll = this.activeMenu.scrollTop();
			elementHeight = this.activeMenu.height();
			itemHeight = item.outerHeight();

			if ( offset < 0 ) {
				this.activeMenu.scrollTop( scroll + offset );
			} else if ( offset + itemHeight > elementHeight ) {
				this.activeMenu.scrollTop( scroll + offset - elementHeight + itemHeight );
			}
		}
	},

	blur: function( event, fromFocus ) {
		if ( !fromFocus ) {
			clearTimeout( this.timer );
		}

		if ( !this.active ) {
			return;
		}

		this._removeClass( this.active.children( ".ui-menu-item-wrapper" ),
			null, "ui-state-active" );
		this.active = null;

		this._trigger( "blur", event, { item: this.active } );
	},

	_startOpening: function( submenu ) {
		clearTimeout( this.timer );

		// Don't open if already open fixes a Firefox bug that caused a .5 pixel
		// shift in the submenu position when mousing over the caret icon
		if ( submenu.attr( "aria-hidden" ) !== "true" ) {
			return;
		}

		this.timer = this._delay( function() {
			this._close();
			this._open( submenu );
		}, this.delay );
	},

	_open: function( submenu ) {
		var position = $.extend( {
			of: this.active
		}, this.options.position );

		clearTimeout( this.timer );
		this.element.find( ".ui-menu" ).not( submenu.parents( ".ui-menu" ) )
			.hide()
			.attr( "aria-hidden", "true" );

		submenu
			.show()
			.removeAttr( "aria-hidden" )
			.attr( "aria-expanded", "true" )
			.position( position );
	},

	collapseAll: function( event, all ) {
		clearTimeout( this.timer );
		this.timer = this._delay( function() {
			// If we were passed an event, look for the submenu that contains the event
			var currentMenu = all ? this.element :
				$( event && event.target ).closest( this.element.find( ".ui-menu" ) );

			// If we found no valid submenu ancestor, use the main menu to close all sub menus anyway
			if ( !currentMenu.length ) {
				currentMenu = this.element;
			}

			this._close( currentMenu );

			this.blur( event );
			this.activeMenu = currentMenu;
		}, this.delay );
	},

	// With no arguments, closes the currently active menu - if nothing is active
	// it closes all menus.  If passed an argument, it will search for menus BELOW
	_close: function( startMenu ) {
		if ( !startMenu ) {
			startMenu = this.active ? this.active.parent() : this.element;
		}

		var active = startMenu
			.find( ".ui-menu" )
				.hide()
				.attr( "aria-hidden", "true" )
				.attr( "aria-expanded", "false" )
			.end()
			.find( ".ui-state-active" ).not( ".ui-menu-item-wrapper" );
		this._removeClass( active, null, "ui-state-active" );
	},

	_closeOnDocumentClick: function( event ) {
		return !$( event.target ).closest( ".ui-menu" ).length;
	},

	_isDivider: function( item ) {

		// Match hyphen, em dash, en dash
		return !/[^\-\u2014\u2013\s]/.test( item.text() );
	},

	collapse: function( event ) {
		var newItem = this.active &&
			this.active.parent().closest( ".ui-menu-item", this.element );
		if ( newItem && newItem.length ) {
			this._close();
			this.focus( event, newItem );
		}
	},

	expand: function( event ) {
		var newItem = this.active &&
			this.active
				.children( ".ui-menu " )
					.find( this.options.items )
						.first();

		if ( newItem && newItem.length ) {
			this._open( newItem.parent() );

			// Delay so Firefox will not hide activedescendant change in expanding submenu from AT
			this._delay( function() {
				this.focus( event, newItem );
			} );
		}
	},

	next: function( event ) {
		this._move( "next", "first", event );
	},

	previous: function( event ) {
		this._move( "prev", "last", event );
	},

	isFirstItem: function() {
		return this.active && !this.active.prevAll( ".ui-menu-item" ).length;
	},

	isLastItem: function() {
		return this.active && !this.active.nextAll( ".ui-menu-item" ).length;
	},

	_move: function( direction, filter, event ) {
		var next;
		if ( this.active ) {
			if ( direction === "first" || direction === "last" ) {
				next = this.active
					[ direction === "first" ? "prevAll" : "nextAll" ]( ".ui-menu-item" )
					.eq( -1 );
			} else {
				next = this.active
					[ direction + "All" ]( ".ui-menu-item" )
					.eq( 0 );
			}
		}
		if ( !next || !next.length || !this.active ) {
			next = this.activeMenu.find( this.options.items )[ filter ]();
		}

		this.focus( event, next );
	},

	nextPage: function( event ) {
		var item, base, height;

		if ( !this.active ) {
			this.next( event );
			return;
		}
		if ( this.isLastItem() ) {
			return;
		}
		if ( this._hasScroll() ) {
			base = this.active.offset().top;
			height = this.element.height();
			this.active.nextAll( ".ui-menu-item" ).each( function() {
				item = $( this );
				return item.offset().top - base - height < 0;
			} );

			this.focus( event, item );
		} else {
			this.focus( event, this.activeMenu.find( this.options.items )
				[ !this.active ? "first" : "last" ]() );
		}
	},

	previousPage: function( event ) {
		var item, base, height;
		if ( !this.active ) {
			this.next( event );
			return;
		}
		if ( this.isFirstItem() ) {
			return;
		}
		if ( this._hasScroll() ) {
			base = this.active.offset().top;
			height = this.element.height();
			this.active.prevAll( ".ui-menu-item" ).each( function() {
				item = $( this );
				return item.offset().top - base + height > 0;
			} );

			this.focus( event, item );
		} else {
			this.focus( event, this.activeMenu.find( this.options.items ).first() );
		}
	},

	_hasScroll: function() {
		return this.element.outerHeight() < this.element.prop( "scrollHeight" );
	},

	select: function( event ) {
		// TODO: It should never be possible to not have an active item at this
		// point, but the tests don't trigger mouseenter before click.
		this.active = this.active || $( event.target ).closest( ".ui-menu-item" );
		var ui = { item: this.active };
		if ( !this.active.has( ".ui-menu" ).length ) {
			this.collapseAll( event, true );
		}
		this._trigger( "select", event, ui );
	},

	_filterMenuItems: function( character ) {
		var escapedCharacter = character.replace( /[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&" ),
			regex = new RegExp( "^" + escapedCharacter, "i" );

		return this.activeMenu
			.find( this.options.items )

				// Only match on items, not dividers or other content (#10571)
				.filter( ".ui-menu-item" )
					.filter( function() {
						return regex.test(
							$.trim( $( this ).children( ".ui-menu-item-wrapper" ).text() ) );
					} );
	}
} );

} ) );