diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..48bb353
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.idea
+*.jar
+*.class
+out
+.DS_Store
+build/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..131bfe7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All Notable changes to command-bus will be documented in this file
+
+## NEXT - YYYY-MM-DD
+
+### Added
+- Nothing
+
+### Deprecated
+- Nothing
+
+### Fixed
+- Nothing
+
+### Removed
+- Nothing
+
+### Security
+- Nothing
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..3e1c7e6
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,22 @@
+# Contributing
+
+Contributions are **welcome** and will be fully **credited**.
+
+We accept contributions via Pull Requests on [Github](https://github.com/ben-gibson/remote-repository-mapper).
+
+## Pull Requests
+
+- **Add tests!** - Your patch won't be accepted if it doesn't have tests.
+
+- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date.
+
+- **Create feature branches** - Don't ask us to pull from your master branch.
+
+- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
+
+- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.
+
+
+## Running Tests
+
+**Happy coding**!
\ No newline at end of file
diff --git a/META-INF/plugin.xml b/META-INF/plugin.xml
new file mode 100644
index 0000000..ae08d3a
--- /dev/null
+++ b/META-INF/plugin.xml
@@ -0,0 +1,53 @@
+
+ uk.co.ben-gibson.remote.repository.mapper
+ Remote Repository Mapper
+ 1.0
+ https://github.com/ben-gibson/remote-repository-mapper
+
+ Other Settings -> Remote Repository Mapper (Defaults to GitHub).
+ The current checked out branch is used unless it does not track a remote branch, in which case it defaults to using master.
+
+ To use, open a file that is under git version control in the editor and select File->Open in remote repository.
+
+ The resulting link can be copied to the clipboard depending on your preference in the settings.
+ ]]>
+
+
+ Fixed clipboard preference not persisting
+ Updated default shortcut
+ Moved action to file menu
+ Action appears as disabled when unusable in the current context instead of being hidden
+
+ ]]>
+
+
+
+
+
+
+
+ com.intellij.modules.vcs
+ com.intellij.modules.lang
+ com.intellij.modules.platform
+ Git4Idea
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bc0a6fb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+# Remote Repository Mapper
+
+A Jetbrains plugin that opens a local file in a remote repository.
+
+## Install
+
+## Usage
+
+After installing select your remote repository provider in Settings -> Other Settings -> Remote Repository Mapper (Defaults to GitHub).
+Open a file that is under git version control in the editor and select File->Open in remote repository.
+The current checked out branch is used unless it does not track a remote branch, in which case it defaults to using master.
+The resulting link can be copied to the clipboard depending on your preference in the settings.
+
+## Change log
+
+Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently.
+
+## Contributing
+
+Please see [CONTRIBUTING](CONTRIBUTING.md) for details.
+
+## Credits
+
+## License
+
+Please see [CONTRIBUTING](LICENSE) for details.
\ No newline at end of file
diff --git a/remote-repository-mapper.iml b/remote-repository-mapper.iml
new file mode 100644
index 0000000..2d5c330
--- /dev/null
+++ b/remote-repository-mapper.iml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/resources/META-INF/plugin.xml b/resources/META-INF/plugin.xml
new file mode 100644
index 0000000..bdfef94
--- /dev/null
+++ b/resources/META-INF/plugin.xml
@@ -0,0 +1,42 @@
+
+ uk.co.ben-gibson.remote.repository.mapper
+ Remote Repository Mapper
+ 1.0
+ https://github.com/ben-gibson/remote-repository-mapper
+
+
+
+
+
+
+
+
+
+
+
+ com.intellij.modules.vcs
+ com.intellij.modules.lang
+ com.intellij.modules.platform
+ Git4Idea
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/uk/co/ben_gibson/repositorymapper/Context/Context.java b/src/uk/co/ben_gibson/repositorymapper/Context/Context.java
new file mode 100644
index 0000000..56dddb2
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/Context/Context.java
@@ -0,0 +1,90 @@
+package uk.co.ben_gibson.repositorymapper.Context;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import java.net.URL;
+
+/**
+ * Represents some context that can be opened in a remote repository.
+ */
+public class Context
+{
+ private static final String DEFAULT_BRANCH = "master";
+
+ @NotNull
+ private URL remoteHost;
+ @NotNull
+ private String path;
+ @NotNull
+ private String branch;
+ @Nullable
+ private Integer caretLinePosition;
+
+
+ /**
+ * Constructor.
+ *
+ * @param remoteHost The remote host.
+ * @param path The path of the file we want to view.
+ * @param branch The branch if we have one.
+ * @param caretLinePosition The line position of the caret.
+ */
+ public Context(
+ @NotNull URL remoteHost,
+ @NotNull String path,
+ @Nullable String branch,
+ @Nullable Integer caretLinePosition
+ )
+ {
+ this.remoteHost = remoteHost;
+ this.path = path;
+ this.branch = (branch != null) ? branch : DEFAULT_BRANCH;
+ this.caretLinePosition = caretLinePosition;
+ }
+
+
+ /**
+ * Get the path.
+ *
+ * @return String
+ */
+ @NotNull
+ public String getPath()
+ {
+ return this.path;
+ }
+
+
+ /**
+ * Get the caret line position.
+ *
+ * @return Integer
+ */
+ @Nullable
+ public Integer getCaretLinePosition()
+ {
+ return this.caretLinePosition;
+ }
+
+
+ /**
+ * Get the remote host.
+ *
+ * @return URL
+ */
+ @NotNull
+ public URL getRemoteHost() {
+ return remoteHost;
+ }
+
+
+ /**
+ * Get the branch.
+ *
+ * @return String
+ */
+ @NotNull
+ public String getBranch() {
+ return this.branch;
+ }
+}
\ No newline at end of file
diff --git a/src/uk/co/ben_gibson/repositorymapper/Context/ContextProvider.java b/src/uk/co/ben_gibson/repositorymapper/Context/ContextProvider.java
new file mode 100644
index 0000000..05479eb
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/Context/ContextProvider.java
@@ -0,0 +1,108 @@
+package uk.co.ben_gibson.repositorymapper.Context;
+
+import com.intellij.openapi.editor.Editor;
+import com.intellij.openapi.fileEditor.FileDocumentManager;
+import com.intellij.openapi.fileEditor.FileEditorManager;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.text.StringUtil;
+import com.intellij.openapi.vfs.VirtualFile;
+import git4idea.GitLocalBranch;
+import git4idea.GitUtil;
+import git4idea.repo.GitRemote;
+import git4idea.repo.GitRepository;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+/**
+ * Context Provider.
+ */
+public class ContextProvider
+{
+
+ /**
+ * Provides a context based on the current environment.
+ *
+ * @param project The active project.
+ *
+ * @return Context
+ */
+ @Nullable
+ public Context getContext(@NotNull Project project) throws MalformedURLException, ContextProviderException {
+
+ Editor editor = FileEditorManager.getInstance(project).getSelectedTextEditor();
+
+ if (editor == null) {
+ return null;
+ }
+
+ VirtualFile file = FileDocumentManager.getInstance().getFile(editor.getDocument());
+
+ if (file == null) {
+ return null;
+ }
+
+ GitRepository repository = GitUtil.getRepositoryManager(project).getRepositoryForFile(file);
+
+ if (repository == null) {
+ return null;
+ }
+
+ URL remoteHost = this.getRemoteHostFromRepository(repository);
+
+ GitLocalBranch branch = null;
+
+ if (repository.getCurrentBranch() != null && repository.getCurrentBranch().findTrackedBranch(repository) != null) {
+ branch = repository.getCurrentBranch();
+ }
+
+ String path = file.getPath().substring(repository.getRoot().getPath().length());
+ String branchName = branch != null ? branch.getName() : null;
+
+ Integer caretPosition = editor.getCaretModel().getLogicalPosition().line + 1;
+
+ return new Context(remoteHost, path, branchName, caretPosition);
+ }
+
+
+ /**
+ * Get a clean url from the repositories remote origin.
+ *
+ * @return URL
+ */
+ @NotNull
+ private URL getRemoteHostFromRepository(@NotNull GitRepository repository) throws MalformedURLException, ContextProviderException
+ {
+ GitRemote origin = null;
+
+ for (GitRemote remote : repository.getRemotes()) {
+ if (remote.getName().equals("origin")) {
+ origin = remote;
+ }
+ }
+
+ if (origin == null) {
+ throw ContextProviderException.originRemoteNotFound(repository);
+ }
+
+ if (origin.getFirstUrl() == null) {
+ throw ContextProviderException.urlNotFoundForRemote(origin);
+ }
+
+ String url = StringUtil.trimEnd(origin.getFirstUrl(), ".git");
+
+ url = url.replaceAll(":\\d{1,4}", ""); // remove port
+
+ if (url.startsWith("http")) {
+ return new URL(url);
+ }
+
+ url = StringUtil.replace(url, "git@", "");
+ url = StringUtil.replace(url, "ssh://", "");
+
+ url = "https://" + StringUtil.replace(url, ":", "/");
+
+ return new URL(url);
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/Context/ContextProviderException.java b/src/uk/co/ben_gibson/repositorymapper/Context/ContextProviderException.java
new file mode 100644
index 0000000..70c7d82
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/Context/ContextProviderException.java
@@ -0,0 +1,47 @@
+package uk.co.ben_gibson.repositorymapper.Context;
+
+import git4idea.repo.GitRemote;
+import git4idea.repo.GitRepository;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Thrown when when a context cannot be provided.
+ */
+public class ContextProviderException extends Exception
+{
+
+ /**
+ * Constructor.
+ *
+ * @param message The exception message.
+ */
+ public ContextProviderException(String message) {
+ super(message);
+ }
+
+
+ /**
+ * Origin remote not found for repository.
+ *
+ * @param repository The repository that has no origin remote.
+ *
+ * @return ContextProviderException
+ */
+ public static ContextProviderException originRemoteNotFound(@NotNull GitRepository repository)
+ {
+ return new ContextProviderException("The origin remote was not found for repository at path " + repository.getRoot().getPath());
+ }
+
+
+ /**
+ * No url found on remote.
+ *
+ * @param remote The remote with no URL.
+ *
+ * @return ContextProviderException
+ */
+ public static ContextProviderException urlNotFoundForRemote(@NotNull GitRemote remote)
+ {
+ return new ContextProviderException("URL not found on remote " + remote.getName());
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/OpenContextAction.java b/src/uk/co/ben_gibson/repositorymapper/OpenContextAction.java
new file mode 100644
index 0000000..aca8aaf
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/OpenContextAction.java
@@ -0,0 +1,100 @@
+package uk.co.ben_gibson.repositorymapper;
+
+import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.openapi.ui.Messages;
+import org.jetbrains.annotations.NotNull;
+import uk.co.ben_gibson.repositorymapper.Context.Context;
+import uk.co.ben_gibson.repositorymapper.Context.ContextProvider;
+import uk.co.ben_gibson.repositorymapper.Context.ContextProviderException;
+import uk.co.ben_gibson.repositorymapper.Settings.Settings;
+import com.intellij.ide.browsers.BrowserLauncher;
+import com.intellij.openapi.actionSystem.AnAction;
+import com.intellij.openapi.actionSystem.AnActionEvent;
+import com.intellij.openapi.components.ServiceManager;
+import com.intellij.openapi.project.Project;
+import uk.co.ben_gibson.repositorymapper.UrlFactory.UrlFactoryException;
+import uk.co.ben_gibson.repositorymapper.UrlFactory.UrlFactoryProvider;
+import java.awt.*;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.StringSelection;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+
+/**
+ * Opens the current context in a remote repository.
+ */
+public class OpenContextAction extends AnAction
+{
+
+ /**
+ * Open the current context.
+ *
+ * @param event The event.
+ */
+ public void actionPerformed(AnActionEvent event)
+ {
+ Project project = event.getProject();
+
+ if (project == null) {
+ return;
+ }
+
+ Settings settings = ServiceManager.getService(project, Settings.class);
+
+ try {
+
+ Context context = this.getContextProvider().getContext(project);
+
+ if (context == null) {
+ return;
+ }
+
+ UrlFactoryProvider urlFactoryProvider = ServiceManager.getService(UrlFactoryProvider.class);
+
+ URL url = urlFactoryProvider.getUrlFactoryForProvider(settings.getRepositoryProvider()).getUrlFromContext(context);
+
+ if (settings.getCopyToClipboard()) {
+ Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+ clipboard.setContents(new StringSelection(url.toString()), null);
+ }
+
+ BrowserLauncher.getInstance().browse(url.toURI());
+
+ } catch (MalformedURLException | URISyntaxException | ContextProviderException | UrlFactoryException e) {
+ Messages.showErrorDialog(event.getProject(), e.getMessage(), "Error");
+ }
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void update(AnActionEvent event)
+ {
+ Context context = null;
+
+ if (event.getProject() != null) {
+ try {
+ context = this.getContextProvider().getContext(event.getProject());
+ } catch (MalformedURLException | ContextProviderException e) {
+ Logger.getInstance(OpenContextAction.class).info(e);
+ }
+ }
+
+ event.getPresentation().setEnabled((context != null));
+ }
+
+
+ /**
+ * Get the context factory.
+ *
+ * @return ContextProvider
+ */
+ @NotNull
+ private ContextProvider getContextProvider()
+ {
+ return ServiceManager.getService(ContextProvider.class);
+ }
+}
\ No newline at end of file
diff --git a/src/uk/co/ben_gibson/repositorymapper/RepositoryProvider/RepositoryProvider.java b/src/uk/co/ben_gibson/repositorymapper/RepositoryProvider/RepositoryProvider.java
new file mode 100644
index 0000000..99b1f3b
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/RepositoryProvider/RepositoryProvider.java
@@ -0,0 +1,32 @@
+package uk.co.ben_gibson.repositorymapper.RepositoryProvider;
+
+/**
+ * Represents different remote repository providers that we support.
+ */
+public enum RepositoryProvider
+{
+ STASH("Stash"),
+ GIT_HUB("GitHub");
+
+ private final String name;
+
+
+ /**
+ * Constructor.
+ *
+ * @param name The name.
+ */
+ RepositoryProvider(String name)
+ {
+ this.name = name;
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ public String toString()
+ {
+ return this.name;
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/Settings/Configuration.java b/src/uk/co/ben_gibson/repositorymapper/Settings/Configuration.java
new file mode 100644
index 0000000..0ba9f23
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/Settings/Configuration.java
@@ -0,0 +1,145 @@
+package uk.co.ben_gibson.repositorymapper.Settings;
+
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.ui.ComboBox;
+import com.intellij.openapi.util.Comparing;
+import com.intellij.ui.EnumComboBoxModel;
+import com.intellij.ui.IdeBorderFactory;
+import com.intellij.ui.components.JBCheckBox;
+import com.intellij.ui.components.JBLabel;
+import com.intellij.openapi.components.ServiceManager;
+import com.intellij.openapi.options.Configurable;
+import com.intellij.openapi.options.ConfigurationException;
+import uk.co.ben_gibson.repositorymapper.RepositoryProvider.RepositoryProvider;
+import javax.swing.*;
+
+/**
+ * Configuration used in the settings panel.
+ */
+public class Configuration implements Configurable
+{
+
+ private static final String LABEL_COPY_TO_CLIPBOARD = "Copy to clipboard";
+ private static final String LABEL_PROVIDERS = "Providers";
+
+ private JBCheckBox copyToClipboardCheckBox;
+ private ComboBox providerComboBox;
+
+ private Settings settings;
+
+
+ /**
+ * Constructor.
+ *
+ * @param project The project.
+ */
+ public Configuration(Project project)
+ {
+ this.settings = ServiceManager.getService(project, Settings.class);
+
+ this.copyToClipboardCheckBox = new JBCheckBox(LABEL_COPY_TO_CLIPBOARD);
+ this.providerComboBox = new ComboBox(new EnumComboBoxModel<>(RepositoryProvider.class), 200);
+ }
+
+
+ /**
+ * Creates the panel component that is rendered in the setting dialog.
+ *
+ * @return JPanel
+ */
+ public JComponent createComponent()
+ {
+ this.reset();
+
+ JPanel panel = new JPanel();
+
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+
+ JBLabel label = new JBLabel(LABEL_PROVIDERS);
+ label.setMaximumSize(label.getPreferredSize());
+
+ this.providerComboBox.setMaximumSize(this.providerComboBox.getPreferredSize());
+ this.providerComboBox.setAlignmentX(0.0f);
+
+ this.copyToClipboardCheckBox.setMaximumSize(this.copyToClipboardCheckBox.getPreferredSize());
+
+ panel.add(label);
+ panel.add(this.providerComboBox);
+
+ JPanel spacing = new JPanel();
+ spacing.setBorder(BorderFactory.createEmptyBorder(10, 0, 10, 0));
+ spacing.setMaximumSize(spacing.getPreferredSize());
+
+ panel.add(spacing);
+
+ panel.add(this.copyToClipboardCheckBox);
+
+ return panel;
+ }
+
+
+ /**
+ * {@inheritDoc}
+ *
+ * This determines if the 'apply' button should be disabled.
+ */
+ public boolean isModified()
+ {
+ return !Comparing.equal(this.copyToClipboardCheckBox.isSelected(), this.settings.getCopyToClipboard()) ||
+ this.providerComboBox.getSelectedItem() != this.settings.getRepositoryProvider();
+ }
+
+
+ /**
+ * {@inheritDoc}
+ *
+ * Saves the changes.
+ */
+ public void apply() throws ConfigurationException
+ {
+ this.settings.setCopyToClipboard(this.copyToClipboardCheckBox.isSelected());
+ this.settings.setRepositoryProvider((RepositoryProvider) this.providerComboBox.getSelectedItem());
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void reset()
+ {
+ this.copyToClipboardCheckBox.setSelected(this.settings.getCopyToClipboard());
+ this.providerComboBox.setSelectedItem(this.settings.getRepositoryProvider());
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void disposeUIResources()
+ {
+ this.copyToClipboardCheckBox = null;
+ this.providerComboBox = null;
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getHelpTopic()
+ {
+ return null;
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getDisplayName()
+ {
+ return "Remote Repository Mapper";
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/Settings/Settings.java b/src/uk/co/ben_gibson/repositorymapper/Settings/Settings.java
new file mode 100644
index 0000000..7ddc5bc
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/Settings/Settings.java
@@ -0,0 +1,91 @@
+package uk.co.ben_gibson.repositorymapper.Settings;
+
+import com.intellij.openapi.components.PersistentStateComponent;
+import com.intellij.openapi.components.State;
+import com.intellij.openapi.components.Storage;
+import com.intellij.openapi.components.StoragePathMacros;
+import com.intellij.util.xmlb.XmlSerializerUtil;
+import org.jetbrains.annotations.NotNull;
+import uk.co.ben_gibson.repositorymapper.RepositoryProvider.RepositoryProvider;
+
+@State(name = "SaveActionSettings",
+ storages = {@Storage(id = "default", file = StoragePathMacros.PROJECT_CONFIG_DIR + "/settings.xml")}
+)
+
+/**
+ * Persistent settings.
+ */
+public class Settings implements PersistentStateComponent
+{
+ @NotNull
+ private Boolean copyToClipboard = false;
+
+ @NotNull
+ private RepositoryProvider repositoryProvider = RepositoryProvider.GIT_HUB;
+
+
+ /**
+ * {@inheritDoc}
+ *
+ * Self maintaining state.
+ */
+ public Settings getState()
+ {
+ return this;
+ }
+
+
+ /**
+ * Should the results be copied to the clipboard.
+ *
+ * @return Boolean
+ */
+ @NotNull
+ public Boolean getCopyToClipboard()
+ {
+ return this.copyToClipboard;
+ }
+
+
+ /**
+ * Set copy to clip board preference.
+ *
+ * getCopyToClipboard Should the results be copied to the clipboard?
+ */
+ public void setCopyToClipboard(@NotNull Boolean copyToClipboard)
+ {
+ this.copyToClipboard = copyToClipboard;
+ }
+
+
+ /**
+ * Get the repository provider.
+ *
+ * @return RepositoryProvider
+ */
+ @NotNull
+ public RepositoryProvider getRepositoryProvider()
+ {
+ return repositoryProvider;
+ }
+
+
+ /**
+ * Set the repository provider.
+ *
+ * @param repositoryProvider The repository provider.
+ */
+ public void setRepositoryProvider(@NotNull RepositoryProvider repositoryProvider)
+ {
+ this.repositoryProvider = repositoryProvider;
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ public void loadState(Settings state)
+ {
+ XmlSerializerUtil.copyBean(state, this);
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/UrlFactory/GitHubUrlFactory.java b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/GitHubUrlFactory.java
new file mode 100644
index 0000000..d0f8916
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/GitHubUrlFactory.java
@@ -0,0 +1,40 @@
+package uk.co.ben_gibson.repositorymapper.UrlFactory;
+
+import org.jetbrains.annotations.NotNull;
+import uk.co.ben_gibson.repositorymapper.Context.Context;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLEncoder;
+
+/**
+ * Creates a URL in the format expected by the remote repository provider GitHub.
+ */
+public class GitHubUrlFactory implements UrlFactory
+{
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @NotNull
+ public URL getUrlFromContext(@NotNull Context context) throws MalformedURLException, UrlFactoryException
+ {
+
+ String branch;
+
+ try {
+ branch = URLEncoder.encode(context.getBranch(), "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ throw new UrlFactoryException("Failed to encode path, unsupported encoding");
+ }
+
+ String path = context.getRemoteHost().toString() + "/blob/" + branch + context.getPath();
+
+ if (context.getCaretLinePosition() != null) {
+ path += "#L" + context.getCaretLinePosition().toString();
+ }
+
+ return new URL(path);
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/UrlFactory/StashUrlFactory.java b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/StashUrlFactory.java
new file mode 100644
index 0000000..14c8814
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/StashUrlFactory.java
@@ -0,0 +1,60 @@
+package uk.co.ben_gibson.repositorymapper.UrlFactory;
+
+import org.jetbrains.annotations.NotNull;
+import uk.co.ben_gibson.repositorymapper.Context.Context;
+
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLEncoder;
+
+/**
+ * Creates a URL in the format expected by the remote repository provider Stash.
+ */
+public class StashUrlFactory implements UrlFactory
+{
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @NotNull
+ public URL getUrlFromContext(@NotNull Context context) throws MalformedURLException, UrlFactoryException {
+
+ URL remoteHost = context.getRemoteHost();
+
+ String[] parts = remoteHost.getPath().split("/", 3);
+
+ if (parts.length < 3) {
+ throw new MalformedURLException("Could not find project and repo from path " + context.getRemoteHost().getPath());
+ }
+
+ /**
+ * If we find more providers need this level of flexibility we could split host, project and repo
+ * within Context but for now this will do.
+ */
+ String project = parts[1];
+ String repository = parts[2];
+
+ String fullPath = String.format(
+ "/projects/%s/repos/%s/browse%s",
+ project,
+ repository,
+ context.getPath()
+ );
+
+ try {
+ fullPath += "?at=" + URLEncoder.encode("refs/heads/" + context.getBranch(), "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ throw new UrlFactoryException("Failed to encode path, unsupported encoding");
+ }
+
+ if (context.getCaretLinePosition() != null) {
+ fullPath += "#" + context.getCaretLinePosition().toString();
+ }
+
+ fullPath = remoteHost.getProtocol() + "://" + remoteHost.getHost() + fullPath;
+
+ return new URL(fullPath);
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactory.java b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactory.java
new file mode 100644
index 0000000..7042fd8
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactory.java
@@ -0,0 +1,23 @@
+package uk.co.ben_gibson.repositorymapper.UrlFactory;
+
+import org.jetbrains.annotations.NotNull;
+import uk.co.ben_gibson.repositorymapper.Context.Context;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+/**
+ * An interface for remote repository Url factories.
+ */
+public interface UrlFactory
+{
+
+ /**
+ * Get a remote repository Url from a context.
+ *
+ * @param context The context to create a URL from.
+ *
+ * @return Url
+ */
+ @NotNull
+ URL getUrlFromContext(@NotNull Context context) throws MalformedURLException, UrlFactoryException;
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactoryException.java b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactoryException.java
new file mode 100644
index 0000000..dbf9369
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactoryException.java
@@ -0,0 +1,33 @@
+package uk.co.ben_gibson.repositorymapper.UrlFactory;
+
+import org.jetbrains.annotations.NotNull;
+import uk.co.ben_gibson.repositorymapper.RepositoryProvider.RepositoryProvider;
+
+/**
+ * URL factory exception.
+ */
+public class UrlFactoryException extends Exception
+{
+
+ /**
+ * Constructor.
+ *
+ * @param message The exception message.
+ */
+ public UrlFactoryException(String message) {
+ super(message);
+ }
+
+
+ /**
+ * Unsupported remote repository provider.
+ *
+ * @param provider The unsupported provider.
+ *
+ * @return UrlFactoryException
+ */
+ public static UrlFactoryException unsupportedProvider(@NotNull RepositoryProvider provider)
+ {
+ return new UrlFactoryException("Unsupported remote repository provider " + provider.toString());
+ }
+}
diff --git a/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactoryProvider.java b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactoryProvider.java
new file mode 100644
index 0000000..4db2cd6
--- /dev/null
+++ b/src/uk/co/ben_gibson/repositorymapper/UrlFactory/UrlFactoryProvider.java
@@ -0,0 +1,30 @@
+package uk.co.ben_gibson.repositorymapper.UrlFactory;
+
+import org.jetbrains.annotations.NotNull;
+import uk.co.ben_gibson.repositorymapper.RepositoryProvider.RepositoryProvider;
+
+/**
+ * Provides URL factories for remote repository providers.
+ */
+public class UrlFactoryProvider
+{
+
+ /**
+ * Get a url factory for a remote repository provider.
+ *
+ * @param provider The provider we want a Url factory for.
+ *
+ * @return UrlFactory
+ */
+ @NotNull
+ public UrlFactory getUrlFactoryForProvider(RepositoryProvider provider) throws UrlFactoryException
+ {
+ if (provider == RepositoryProvider.GIT_HUB) {
+ return new GitHubUrlFactory();
+ } else if (provider == RepositoryProvider.STASH) {
+ return new StashUrlFactory();
+ }
+
+ throw UrlFactoryException.unsupportedProvider(provider);
+ }
+}