From c63627c0a3a16e8605c1ae970a7d2d13bb480d05 Mon Sep 17 00:00:00 2001 From: reduckted Date: Sun, 15 Sep 2024 20:40:44 +1000 Subject: [PATCH] Allowed the Gitiles URL for the HTTP remote to be different to the URL for the web interface. --- shared/handler-schema.json | 16 +- shared/handlers/azure-dev-ops-cloud.json | 7 +- shared/handlers/github.json | 7 +- shared/handlers/gitiles.json | 16 + .../handlers/visual-studio-team-services.json | 7 +- .../Gitiles/GitilesOptionsControl.xaml | 2 + .../GitWebLinks/Options/ServerListItem.cs | 3 + .../Options/ServerOptionsPageBase.cs | 14 +- .../Services/DefinitionProvider.Json.cs | 11 +- .../Services/DefinitionProvider.cs | 17 +- .../GitWebLinks/Services/ILinkHandler.cs | 2 +- .../GitWebLinks/Services/LinkHandler.cs | 34 +- .../Services/LinkHandlerProvider.cs | 14 +- .../source/GitWebLinks/Types/DynamicServer.cs | 14 +- .../source/GitWebLinks/Types/RemoteServer.cs | 126 +++-- .../Types/ReverseServerSettings.cs | 6 +- .../source/GitWebLinks/Types/StaticServer.cs | 6 +- .../GitWebLinks/UI/Controls/ServerDataGrid.cs | 106 ++++ .../UI/Controls/ServerDataGrid.xaml | 25 +- .../Handlers/HandlerTests.cs | 4 +- .../Services/LinkHandlerTests.cs | 112 +++- .../Types/RemoteServerTests.cs | 490 ++++++++++++------ vscode/package.json | 4 + vscode/src/commands/go-to-file-command.ts | 4 +- vscode/src/link-handler-provider.ts | 2 +- vscode/src/link-handler.ts | 39 +- vscode/src/remote-server.ts | 172 ++++-- vscode/src/schema.ts | 36 +- vscode/src/types.ts | 7 + vscode/test/link-handler.test.ts | 53 +- vscode/test/remote-server.test.ts | 312 +++++++---- 31 files changed, 1229 insertions(+), 439 deletions(-) diff --git a/shared/handler-schema.json b/shared/handler-schema.json index bc6438c..af2fb8d 100644 --- a/shared/handler-schema.json +++ b/shared/handler-schema.json @@ -28,20 +28,28 @@ "items": { "type": "object", "properties": { - "pattern": { + "remotePattern": { "type": "string", "description": "A regular expression to match on a remote URL. The captured groups are provided to the `http` and `ssh` templates." }, "http": { "$ref": "#/definitions/template", - "description": "The template to build the HTTP(S) URL of the remote server.\n\nCaptured groups from `pattern` are made available via the `match` variable." + "description": "The template to build the HTTP(S) remote URL.\n\nCaptured groups from `pattern` are made available via the `match` variable." }, "ssh": { "$ref": "#/definitions/template", - "description": "The template to build the SSH URL of the remote server.\n\nCaptured groups from `pattern` are made available via the `match` variable." + "description": "The template to build the SSH remote URL.\n\nCaptured groups from `pattern` are made available via the `match` variable." + }, + "webPattern": { + "type": "string", + "description": "A regular expression to match on a web interface URL. The captured groups are provided to the `http` and `ssh` templates." + }, + "web": { + "$ref": "#/definitions/template", + "description": "The template to build the web interface URL.\n\nCaptured groups from `pattern` are made available via the `match` variable." } }, - "required": ["pattern", "http", "ssh"], + "required": ["remotePattern", "http", "ssh"], "additionalProperties": false } }, diff --git a/shared/handlers/azure-dev-ops-cloud.json b/shared/handlers/azure-dev-ops-cloud.json index 202441f..744313b 100644 --- a/shared/handlers/azure-dev-ops-cloud.json +++ b/shared/handlers/azure-dev-ops-cloud.json @@ -3,14 +3,15 @@ "name": "Azure Dev Ops Cloud", "server": [ { - "pattern": "^https:\\/\\/(?:.+@)?dev\\.azure\\.com\\/([^\\/]+)\\/([^\\/]+)\\/_git\\/.+", + "remotePattern": "^https:\\/\\/(?:.+@)?dev\\.azure\\.com\\/([^\\/]+)\\/([^\\/]+)\\/_git\\/.+", "http": "https://dev.azure.com/{{ match[1] }}/{{ match[2] }}/_git", "ssh": "git@ssh.dev.azure.com:v3/{{ match[1] }}/{{ match[2] }}" }, { - "pattern": "^(?:git@)?ssh\\.dev\\.azure\\.com:v3\\/([^\\/]+)\\/([^\\/]+)\\/.+$", + "remotePattern": "^(?:git@)?ssh\\.dev\\.azure\\.com:v3\\/([^\\/]+)\\/([^\\/]+)\\/.+$", "http": "https://dev.azure.com/{{ match[1] }}/{{ match[2] }}/_git", - "ssh": "git@ssh.dev.azure.com:v3/{{ match[1] }}/{{ match[2] }}" + "ssh": "git@ssh.dev.azure.com:v3/{{ match[1] }}/{{ match[2] }}", + "webPattern": "^ONLY MATCH TO SSH REMOTE URLS$" } ], "branchRef": "abbreviated", diff --git a/shared/handlers/github.json b/shared/handlers/github.json index ec60ae3..21eacd7 100644 --- a/shared/handlers/github.json +++ b/shared/handlers/github.json @@ -3,14 +3,15 @@ "name": "GitHub", "server": [ { - "pattern": "^https:\\/\\/github.(?:com|dev)", + "remotePattern": "^https:\\/\\/github.(?:com|dev)", "http": "https://github.com", "ssh": "git@github.com" }, { - "pattern": "^(?:git@)?github\\.com", + "remotePattern": "^(?:git@)?github\\.com", "http": "https://github.com", - "ssh": "git@github.com" + "ssh": "git@github.com", + "webPattern": "^ONLY MATCH TO SSH REMOTE URLS$" } ], "branchRef": "abbreviated", diff --git a/shared/handlers/gitiles.json b/shared/handlers/gitiles.json index a98f097..b785ea6 100644 --- a/shared/handlers/gitiles.json +++ b/shared/handlers/gitiles.json @@ -54,6 +54,22 @@ "remote": "https://git.company.com:1368/plugins/gitiles/foo/bar.git", "result": "https://git.company.com:1368/plugins/gitiles/foo/bar/+/{{ commit }}/src/file.txt" }, + "misc": [ + { + "name": "Web URL is different to clone URL", + "settings": { + "gitiles": [ + { + "http": "https://git.company.com/a", + "ssh": "ssh://git.company.com:29418", + "web": "https://git.company.com/plugins/gitiles" + } + ] + }, + "remote": "https://git.company.com/a/foo/bar.git", + "result": "https://git.company.com/plugins/gitiles/foo/bar/+/master/src/file.txt" + } + ], "selection": { "remote": "https://git.company.com:1368/plugins/gitiles/foo/bar.git", "point": { diff --git a/shared/handlers/visual-studio-team-services.json b/shared/handlers/visual-studio-team-services.json index 6431d2d..3bf08c9 100644 --- a/shared/handlers/visual-studio-team-services.json +++ b/shared/handlers/visual-studio-team-services.json @@ -3,12 +3,13 @@ "name": "Visual Studio Team Services", "server": [ { - "pattern": "^([^.]+)@vs-ssh\\.visualstudio\\.com:22(?:/(.+))?/_ssh/.+$", + "remotePattern": "^([^.]+)@vs-ssh\\.visualstudio\\.com:22(?:/(.+))?/_ssh/.+$", "http": "https://{{ match[1] }}.visualstudio.com{% if match[2] %}/{{ match[2] }}{% endif %}/_git", - "ssh": "{{ match[1] }}@vs-ssh.visualstudio.com:22{% if match[2] %}/{{ match[2] }}{% endif %}/_ssh" + "ssh": "{{ match[1] }}@vs-ssh.visualstudio.com:22{% if match[2] %}/{{ match[2] }}{% endif %}/_ssh", + "webPattern": "^ONLY MATCH TO SSH REMOTE URLS$" }, { - "pattern": "^https://([^.]+).visualstudio.com(?:/(.+))?/_git/.+$", + "remotePattern": "^https://([^.]+).visualstudio.com(?:/(.+))?/_git/.+$", "http": "https://{{ match[1] }}.visualstudio.com{% if match[2] %}/{{ match[2] }}{% endif %}/_git", "ssh": "{{ match[1] }}@vs-ssh.visualstudio.com:22{% if match[2] %}/{{ match[2] }}{% endif %}/_ssh" } diff --git a/visual-studio/source/GitWebLinks/Options/Gitiles/GitilesOptionsControl.xaml b/visual-studio/source/GitWebLinks/Options/Gitiles/GitilesOptionsControl.xaml index 4479ed6..bd04977 100644 --- a/visual-studio/source/GitWebLinks/Options/Gitiles/GitilesOptionsControl.xaml +++ b/visual-studio/source/GitWebLinks/Options/Gitiles/GitilesOptionsControl.xaml @@ -23,8 +23,10 @@ diff --git a/visual-studio/source/GitWebLinks/Options/ServerListItem.cs b/visual-studio/source/GitWebLinks/Options/ServerListItem.cs index 0ccebf6..f0f829e 100644 --- a/visual-studio/source/GitWebLinks/Options/ServerListItem.cs +++ b/visual-studio/source/GitWebLinks/Options/ServerListItem.cs @@ -9,4 +9,7 @@ public class ServerListItem { public string? Ssh { get; set; } = ""; + + public string? Web { get; set; } = ""; + } diff --git a/visual-studio/source/GitWebLinks/Options/ServerOptionsPageBase.cs b/visual-studio/source/GitWebLinks/Options/ServerOptionsPageBase.cs index 8aec620..d696a14 100644 --- a/visual-studio/source/GitWebLinks/Options/ServerOptionsPageBase.cs +++ b/visual-studio/source/GitWebLinks/Options/ServerOptionsPageBase.cs @@ -15,7 +15,13 @@ public abstract class ServerOptionsPageBase : OptionsPageBase { internal IReadOnlyList GetServers() { - return Servers.Select((x) => new StaticServer(x.Http ?? "", x.Ssh)).ToList(); + return Servers.Select( + (x) => new StaticServer( + x.Http ?? "", + string.IsNullOrEmpty(x.Ssh) ? null : x.Ssh, + string.IsNullOrEmpty(x.Web) ? null : x.Web + ) + ).ToList(); } @@ -38,7 +44,11 @@ public string JsonServers { protected static string SerializeServers(IEnumerable servers) { return JsonConvert.SerializeObject( - servers.Select((x) => new ServerListItem { Http = x.Http ?? "", Ssh = x.Ssh ?? "" }) + servers.Select((x) => new ServerListItem { + Http = x.Http ?? "", + Ssh = x.Ssh, + Web = x.Web + }) ); } diff --git a/visual-studio/source/GitWebLinks/Services/DefinitionProvider.Json.cs b/visual-studio/source/GitWebLinks/Services/DefinitionProvider.Json.cs index 5abd1ec..cfba75f 100644 --- a/visual-studio/source/GitWebLinks/Services/DefinitionProvider.Json.cs +++ b/visual-studio/source/GitWebLinks/Services/DefinitionProvider.Json.cs @@ -39,7 +39,7 @@ private class JsonHandlerDefinition { private class JsonServer { - public string? Pattern { get; set; } + public string? RemotePattern { get; set; } public string Http { get; set; } = ""; @@ -47,6 +47,12 @@ private class JsonServer { public string Ssh { get; set; } = ""; + + public string? WebPattern { get; set; } + + + public string? Web { get; set; } + } @@ -89,6 +95,9 @@ private class JsonReverseServerSettings { public string Ssh { get; set; } = ""; + + public string? Web { get; set; } + } diff --git a/visual-studio/source/GitWebLinks/Services/DefinitionProvider.cs b/visual-studio/source/GitWebLinks/Services/DefinitionProvider.cs index 35a6d73..b67363f 100644 --- a/visual-studio/source/GitWebLinks/Services/DefinitionProvider.cs +++ b/visual-studio/source/GitWebLinks/Services/DefinitionProvider.cs @@ -94,10 +94,18 @@ private static IReadOnlyList ParseServers(IReadOnlyList jso servers = new List(); foreach (var server in json) { - if (server.Pattern is not null) { - servers.Add(new DynamicServer(new Regex(server.Pattern), Template.Parse(server.Http), Template.Parse(server.Ssh))); + if (server.RemotePattern is not null) { + servers.Add( + new DynamicServer( + new Regex(server.RemotePattern), + Template.Parse(server.Http), + Template.Parse(server.Ssh), + (server.WebPattern is not null) ? new Regex(server.WebPattern) : null, + (server.Web is not null) ? Template.Parse(server.Web) : null + ) + ); } else { - servers.Add(new StaticServer(server.Http, server.Ssh)); + servers.Add(new StaticServer(server.Http, server.Ssh, server.Web)); } } @@ -117,7 +125,8 @@ private static ReverseSettings ParseReverseSettings(JsonReverseSettings json) { json.FileMayStartWithBranch, new ReverseServerSettings( Template.Parse(json.Server.Http), - Template.Parse(json.Server.Ssh) + Template.Parse(json.Server.Ssh), + (json.Server.Web is not null) ? Template.Parse(json.Server.Web) : null ), new ReverseSelectionSettings( Template.Parse(json.Selection.StartLine), diff --git a/visual-studio/source/GitWebLinks/Services/ILinkHandler.cs b/visual-studio/source/GitWebLinks/Services/ILinkHandler.cs index 567a77c..ea9221b 100644 --- a/visual-studio/source/GitWebLinks/Services/ILinkHandler.cs +++ b/visual-studio/source/GitWebLinks/Services/ILinkHandler.cs @@ -18,6 +18,6 @@ public interface ILinkHandler { Task GetUrlInfoAsync(string url, bool strict); - Task IsMatchAsync(string remoteUrl); + Task HandlesRemoteUrlAsync(string remoteUrl); } diff --git a/visual-studio/source/GitWebLinks/Services/LinkHandler.cs b/visual-studio/source/GitWebLinks/Services/LinkHandler.cs index 87c13b4..937ef85 100644 --- a/visual-studio/source/GitWebLinks/Services/LinkHandler.cs +++ b/visual-studio/source/GitWebLinks/Services/LinkHandler.cs @@ -38,8 +38,8 @@ public LinkHandler(HandlerDefinition definition, ISettings settings, Git git) { public string Name => _definition.Name; - public async Task IsMatchAsync(string remoteUrl) { - return await _server.MatchAsync(UrlHelpers.Normalize(remoteUrl)) is not null; + public async Task HandlesRemoteUrlAsync(string remoteUrl) { + return await _server.MatchRemoteUrlAsync(UrlHelpers.Normalize(remoteUrl)) is not null; } @@ -48,7 +48,7 @@ public async Task CreateUrlAsync(Repository repository, FileInf throw new InvalidOperationException("The repository must have a remote."); } - string remote; + string remoteUrl; StaticServer address; string refValue; RefType refType; @@ -86,15 +86,15 @@ public async Task CreateUrlAsync(Repository repository, FileInf // Adjust the remote URL so that it's in a // standard format that we can manipulate. - remote = UrlHelpers.Normalize(repository.Remote.Url); + remoteUrl = UrlHelpers.Normalize(repository.Remote.Url); - address = await GetAddressAsync(remote); + address = await GetAddressAsync(remoteUrl); relativePath = GetRelativePath(repository.Root, file.FilePath); data = TemplateData .Create() - .Add("base", address.Http) - .Add("repository", GetRepositoryPath(remote, address)) + .Add("base", address.Web ?? address.Http) + .Add("repository", GetRepositoryPath(remoteUrl, address)) .Add("ref", refValue) .Add("commit", await GetRefAsync(LinkType.Commit, repository.Root, repository.Remote)) .Add("file", relativePath) @@ -151,11 +151,11 @@ private static string ApplyModifications(string url, IReadOnlyList GetAddressAsync(string remote) { + private async Task GetAddressAsync(string remoteUrl) { StaticServer? address; - address = await _server.MatchAsync(remote); + address = await _server.MatchRemoteUrlAsync(remoteUrl); if (address is null) { throw new InvalidOperationException("Could not find a matching address."); @@ -168,11 +168,13 @@ private async Task GetAddressAsync(string remote) { private static StaticServer NormalizeServerUrls(StaticServer address) { string http; string? ssh; + string? web; http = UrlHelpers.Normalize(address.Http); ssh = address.Ssh is not null ? UrlHelpers.Normalize(address.Ssh) : null; + web = address.Web is not null ? UrlHelpers.Normalize(address.Web) : null; - return new StaticServer(http, ssh); + return new StaticServer(http, ssh, web); } @@ -298,7 +300,7 @@ private string GetRelativePath(string from, string to) { // If the file is a symbolic link, or is under a directory that's a // symbolic link, then we want to resolve the path to the real file - // because the sybmolic link won't be in the Git repository. + // because the symbolic link won't be in the Git repository. if (IsSymbolicLink(to, from)) { try { to = GetRealPath(to); @@ -449,13 +451,13 @@ private static bool TryGetFinalPathNameByHandle(IntPtr handle, int bufferSize, o } - public async Task GetUrlInfoAsync(string url, bool strict) { + public async Task GetUrlInfoAsync(string webUrl, bool strict) { StaticServer? address; Match match; // See if the URL matches the server address for the handler. - address = await _server.MatchAsync(url); + address = await _server.MatchWebUrlAsync(webUrl); // If we are performing a strict match, then the // URL must match to this handler's server. @@ -467,7 +469,7 @@ private static bool TryGetFinalPathNameByHandle(IntPtr handle, int bufferSize, o address = NormalizeServerUrls(address); } - match = _definition.Reverse.Pattern.Match(url); + match = _definition.Reverse.Pattern.Match(webUrl); if (match.Success) { Hash hash; @@ -480,6 +482,7 @@ private static bool TryGetFinalPathNameByHandle(IntPtr handle, int bufferSize, o .Create() .Add("http", address?.Http) .Add("ssh", address?.Ssh) + .Add("web", address?.Web) .Add(match) .ToHash(); @@ -487,7 +490,8 @@ private static bool TryGetFinalPathNameByHandle(IntPtr handle, int bufferSize, o server = new StaticServer( _definition.Reverse.Server.Http.Render(hash), - _definition.Reverse.Server.Ssh.Render(hash) + _definition.Reverse.Server.Ssh.Render(hash), + _definition.Reverse.Server.Web?.Render(hash) ); selection = new PartialSelectedRange( diff --git a/visual-studio/source/GitWebLinks/Services/LinkHandlerProvider.cs b/visual-studio/source/GitWebLinks/Services/LinkHandlerProvider.cs index 9b7b91a..eb47cae 100644 --- a/visual-studio/source/GitWebLinks/Services/LinkHandlerProvider.cs +++ b/visual-studio/source/GitWebLinks/Services/LinkHandlerProvider.cs @@ -33,7 +33,7 @@ public LinkHandlerProvider(ISettings settings, Git git, ILogger logger) { foreach (ILinkHandler handler in _handlers) { await _logger.LogAsync($"Testing '{handler.Name}"); - if (await handler.IsMatchAsync(repository.Remote.Url)) { + if (await handler.HandlesRemoteUrlAsync(repository.Remote.Url)) { await _logger.LogAsync($"Handler '{handler.Name}' is a match."); return handler; } @@ -44,23 +44,23 @@ public LinkHandlerProvider(ISettings settings, Git git, ILogger logger) { } - public async Task> GetUrlInfoAsync(string url) { + public async Task> GetUrlInfoAsync(string webUrl) { IReadOnlyCollection output; - await _logger.LogAsync($"Finding file info for URL '{url}'."); - output = await InternalGetUrlInfoAsync(url, true); + await _logger.LogAsync($"Finding file info for URL '{webUrl}'."); + output = await InternalGetUrlInfoAsync(webUrl, true); if (output.Count == 0) { await _logger.LogAsync("No strict matches found. Trying again with loose matching."); - output = await InternalGetUrlInfoAsync(url, false); + output = await InternalGetUrlInfoAsync(webUrl, false); } return output; } - private async Task> InternalGetUrlInfoAsync(string url, bool strict) { + private async Task> InternalGetUrlInfoAsync(string webUrl, bool strict) { List output; @@ -70,7 +70,7 @@ private async Task> InternalGetUrlInfoAsync(string UrlInfo? info; - info = await handler.GetUrlInfoAsync(url, strict); + info = await handler.GetUrlInfoAsync(webUrl, strict); if (info is not null) { await _logger.LogAsync($"The handler '{handler.Name}' mapped the file to '{info}'."); diff --git a/visual-studio/source/GitWebLinks/Types/DynamicServer.cs b/visual-studio/source/GitWebLinks/Types/DynamicServer.cs index 30028cf..9c0056f 100644 --- a/visual-studio/source/GitWebLinks/Types/DynamicServer.cs +++ b/visual-studio/source/GitWebLinks/Types/DynamicServer.cs @@ -7,14 +7,16 @@ namespace GitWebLinks; public class DynamicServer : IServer { - public DynamicServer(Regex pattern, Template http, Template ssh) { - Pattern = pattern; + public DynamicServer(Regex remotePattern, Template http, Template ssh, Regex? webPattern, Template? web) { + RemotePattern = remotePattern; Http = http; Ssh = ssh; + WebPattern = webPattern; + Web = web; } - public Regex Pattern { get; } + public Regex RemotePattern { get; } public Template Http { get; } @@ -22,4 +24,10 @@ public DynamicServer(Regex pattern, Template http, Template ssh) { public Template Ssh { get; } + + public Regex? WebPattern { get; } + + + public Template? Web { get; } + } diff --git a/visual-studio/source/GitWebLinks/Types/RemoteServer.cs b/visual-studio/source/GitWebLinks/Types/RemoteServer.cs index 0660138..59d7557 100644 --- a/visual-studio/source/GitWebLinks/Types/RemoteServer.cs +++ b/visual-studio/source/GitWebLinks/Types/RemoteServer.cs @@ -11,11 +11,11 @@ namespace GitWebLinks; public class RemoteServer { - private readonly List _matchers; + private readonly List _matchers; public RemoteServer(IServer server) { - _matchers = new List { CreateMatcher(server) }; + _matchers = new List { CreateMatcher(server) }; } @@ -25,68 +25,85 @@ public RemoteServer(IEnumerable servers) { public RemoteServer(Func>> serverFactory) { - _matchers = new List { CreateLazyStaticServerMatcher(serverFactory) }; + _matchers = new List { CreateLazyStaticServerMatcher(serverFactory) }; } - private static AsyncMatcher CreateMatcher(IServer server) { - Matcher matcher; - - + private static Matcher CreateMatcher(IServer server) { if (server is DynamicServer dynamicServer) { - matcher = CreateDynamicServerMatcher(dynamicServer); + return CreateDynamicServerMatcher(dynamicServer); } else { - matcher = CreateStaticServerMatcher((StaticServer)server); + return CreateStaticServerMatcher((StaticServer)server); } - - return (x) => Task.FromResult(matcher(x)); } private static Matcher CreateDynamicServerMatcher(DynamicServer server) { - return (url) => { - Match match; + return new Matcher( + Create(server.RemotePattern), + Create(server.WebPattern ?? server.RemotePattern) + ); + UrlMatcher Create(Regex pattern) { + return (url) => { + Match match; + StaticServer? result; - match = server.Pattern.Match(url); - if (match.Success) { - Hash hash; + match = pattern.Match(url); + if (match.Success) { + Hash hash; - // The URL matched the pattern. Render the templates to get the HTTP - // and SSH URLs, making the match available for the templates to use. - hash = TemplateData.Create().Add(match).ToHash(); - return new StaticServer( - server.Http.Render(hash), - server.Ssh.Render(hash) - ); - } + // The URL matched the pattern. Render the templates to get the HTTP + // and SSH URLs, making the match available for the templates to use. + hash = TemplateData.Create().Add(match).ToHash(); - return null; - }; + result = new StaticServer( + server.Http.Render(hash), + server.Ssh.Render(hash), + server.Web?.Render(hash) + ); + + } else { + result = null; + } + + return Task.FromResult(result); + }; + } } private static Matcher CreateStaticServerMatcher(StaticServer server) { - return (url) => IsMatch(url, server) ? server : null; + return new Matcher( + (url) => Task.FromResult(IsRemoteMatch(url, server) ? server : null), + (url) => Task.FromResult(IsWebMatch(url, server) ? server : null) + ); } - private static AsyncMatcher CreateLazyStaticServerMatcher(Func>> factory) { - return async (url) => (await factory()).Where((x) => IsMatch(url, x)).FirstOrDefault(); + private static Matcher CreateLazyStaticServerMatcher(Func>> factory) { + return new Matcher( + Create(IsRemoteMatch), + Create(IsWebMatch) + ); + + UrlMatcher Create(Func test) { + return async (url) => (await factory()).Where((x) => test(url, x)).FirstOrDefault(); + } } - private static bool IsMatch(string url, StaticServer server) { - url = UrlHelpers.Normalize(url); + private static bool IsRemoteMatch(string remoteUrl, StaticServer server) { + remoteUrl = UrlHelpers.Normalize(remoteUrl); - if (url.StartsWith(UrlHelpers.Normalize(server.Http), StringComparison.Ordinal)) { + if (remoteUrl.StartsWith(UrlHelpers.Normalize(server.Http), StringComparison.Ordinal)) { return true; } - if ((server.Ssh is not null) && url.StartsWith(UrlHelpers.Normalize(server.Ssh), StringComparison.Ordinal)) { + if ((server.Ssh is not null) && remoteUrl.StartsWith(UrlHelpers.Normalize(server.Ssh), StringComparison.Ordinal)) { return true; } @@ -94,12 +111,32 @@ private static bool IsMatch(string url, StaticServer server) { } - public async Task MatchAsync(string url) { - foreach (AsyncMatcher matcher in _matchers) { + private static bool IsWebMatch(string webUrl, StaticServer server) { + return UrlHelpers + .Normalize(webUrl) + .StartsWith(UrlHelpers.Normalize(server.Web ?? server.Http), StringComparison.Ordinal); + } + + + public Task MatchRemoteUrlAsync(string remoteUrl) { + return MatchUrlAsync(remoteUrl, static (x) => x.Remote); + } + + + public Task MatchWebUrlAsync(string webUrl) { + return MatchUrlAsync(webUrl, static (x) => x.Web); + } + + + private async Task MatchUrlAsync( + string url, + Func selectUrlMatcher + ) { + foreach (Matcher matcher in _matchers) { StaticServer? server; - server = await matcher(url); + server = await selectUrlMatcher(matcher)(url); if (server is not null) { return server; @@ -110,9 +147,22 @@ private static bool IsMatch(string url, StaticServer server) { } - private delegate Task AsyncMatcher(string url); + private delegate Task UrlMatcher(string url); + + private class Matcher { - private delegate StaticServer? Matcher(string url); + public Matcher(UrlMatcher remote, UrlMatcher web) { + Remote = remote; + Web = web; + } + + + public UrlMatcher Remote { get; } + + + public UrlMatcher Web { get; } + + } } diff --git a/visual-studio/source/GitWebLinks/Types/ReverseServerSettings.cs b/visual-studio/source/GitWebLinks/Types/ReverseServerSettings.cs index e3e11fa..7739188 100644 --- a/visual-studio/source/GitWebLinks/Types/ReverseServerSettings.cs +++ b/visual-studio/source/GitWebLinks/Types/ReverseServerSettings.cs @@ -6,9 +6,10 @@ namespace GitWebLinks; public class ReverseServerSettings { - public ReverseServerSettings(Template http, Template ssh) { + public ReverseServerSettings(Template http, Template ssh, Template? web) { Http = http; Ssh = ssh; + Web = web; } @@ -17,4 +18,7 @@ public ReverseServerSettings(Template http, Template ssh) { public Template Ssh { get; } + + public Template? Web { get; } + } diff --git a/visual-studio/source/GitWebLinks/Types/StaticServer.cs b/visual-studio/source/GitWebLinks/Types/StaticServer.cs index 8669741..787f319 100644 --- a/visual-studio/source/GitWebLinks/Types/StaticServer.cs +++ b/visual-studio/source/GitWebLinks/Types/StaticServer.cs @@ -4,9 +4,10 @@ namespace GitWebLinks; public class StaticServer : IServer { - public StaticServer(string http, string? ssh) { + public StaticServer(string http, string? ssh, string? web) { Http = http; Ssh = ssh; + Web = web; } @@ -15,4 +16,7 @@ public StaticServer(string http, string? ssh) { public string? Ssh { get; } + + public string? Web { get; } + } diff --git a/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.cs b/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.cs index 150ff5e..7cd7735 100644 --- a/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.cs +++ b/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.cs @@ -1,10 +1,16 @@ +#nullable enable + using System.Windows; using System.Windows.Controls; +using System.Windows.Data; namespace GitWebLinks; public class ServerDataGrid : ItemsControl { + private DataGrid? _dataGrid; + + static ServerDataGrid() { DefaultStyleKeyProperty.OverrideMetadata( typeof(ServerDataGrid), @@ -29,6 +35,33 @@ static ServerDataGrid() { ); + public static readonly DependencyProperty WebExampleProperty = DependencyProperty.Register( + nameof(WebExample), + typeof(string), + typeof(ServerDataGrid), + new FrameworkPropertyMetadata("") + ); + + + public static readonly DependencyProperty HasWebAddressProperty = DependencyProperty.Register( + nameof(HasWebAddress), + typeof(bool), + typeof(ServerDataGrid), + new FrameworkPropertyMetadata(false, OnHasWebAddressChanged) + ); + + + private static readonly DependencyPropertyKey WebExampleVisibilityPropertyKey = DependencyProperty.RegisterReadOnly( + nameof(WebExampleVisibility), + typeof(Visibility), + typeof(ServerDataGrid), + new FrameworkPropertyMetadata(Visibility.Collapsed) + ); + + + public static readonly DependencyProperty WebExampleVisibilityProperty = WebExampleVisibilityPropertyKey.DependencyProperty; + + public string HttpExample { get { return (string)GetValue(HttpExampleProperty); } set { SetValue(HttpExampleProperty, value); } @@ -40,4 +73,77 @@ public string SshExample { set { SetValue(SshExampleProperty, value); } } + + public string WebExample { + get { return (string)GetValue(WebExampleProperty); } + set { SetValue(WebExampleProperty, value); } + } + + + public bool HasWebAddress { + get { return (bool)GetValue(HasWebAddressProperty); } + set { SetValue(HasWebAddressProperty, value); } + } + + + public bool WebExampleVisibility { + get { return (bool)GetValue(WebExampleVisibilityProperty); } + } + + + private static void OnHasWebAddressChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { + d.SetValue( + WebExampleVisibilityPropertyKey, + (bool)e.NewValue ? Visibility.Visible : Visibility.Collapsed + ); + + (d as ServerDataGrid)?.ApplyColumns(); + } + + + public override void OnApplyTemplate() { + base.OnApplyTemplate(); + + _dataGrid = GetTemplateChild("PART_DataGrid") as DataGrid; + ApplyColumns(); + } + + private void ApplyColumns() { + if (_dataGrid is not null) { + _dataGrid.Columns.Clear(); + + _dataGrid.Columns.Add( + new DataGridTextColumn { + Width = new DataGridLength(1, DataGridLengthUnitType.Star), + Header = "HTTP URL", + Binding = new Binding(nameof(ServerListItem.Http)) { + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + } + } + ); + + _dataGrid.Columns.Add( + new DataGridTextColumn { + Width = new DataGridLength(1, DataGridLengthUnitType.Star), + Header = "SSH URL", + Binding = new Binding(nameof(ServerListItem.Ssh)) { + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + } + } + ); + + if (HasWebAddress) { + _dataGrid.Columns.Add( + new DataGridTextColumn { + Width = new DataGridLength(1, DataGridLengthUnitType.Star), + Header = "Web URL", + Binding = new Binding(nameof(ServerListItem.Web)) { + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + } + } + ); + } + } + } + } diff --git a/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.xaml b/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.xaml index 53df5b2..94ab2fd 100644 --- a/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.xaml +++ b/visual-studio/source/GitWebLinks/UI/Controls/ServerDataGrid.xaml @@ -14,6 +14,7 @@ - - - - - - - + /> @@ -54,12 +40,15 @@ + + @@ -70,6 +59,10 @@ + + + + diff --git a/visual-studio/tests/GitWebLinks.UnitTests/Handlers/HandlerTests.cs b/visual-studio/tests/GitWebLinks.UnitTests/Handlers/HandlerTests.cs index 2d8ce92..ce4edee 100644 --- a/visual-studio/tests/GitWebLinks.UnitTests/Handlers/HandlerTests.cs +++ b/visual-studio/tests/GitWebLinks.UnitTests/Handlers/HandlerTests.cs @@ -560,8 +560,8 @@ private void ApplySettings(Dictionary settings) { private static List CreateStaticServers(JToken value) { - return Convert(value, new[] { new { Http = "", Ssh = "" } }) - .Select((x) => new StaticServer(x.Http, x.Ssh)) + return Convert(value, new[] { new { Http = "", Ssh = "", Web = "" } }) + .Select((x) => new StaticServer(x.Http, x.Ssh, x.Web)) .ToList(); static T Convert(JToken value, T witness) => value.ToObject()!; diff --git a/visual-studio/tests/GitWebLinks.UnitTests/Services/LinkHandlerTests.cs b/visual-studio/tests/GitWebLinks.UnitTests/Services/LinkHandlerTests.cs index c3a3a6f..a54c196 100644 --- a/visual-studio/tests/GitWebLinks.UnitTests/Services/LinkHandlerTests.cs +++ b/visual-studio/tests/GitWebLinks.UnitTests/Services/LinkHandlerTests.cs @@ -243,7 +243,7 @@ public async Task ShouldHandleTheMatchingServerHttpAddressEndingWithSlash() { "http://example.com | foo/bar", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com/", ""), + Server = new StaticServer("http://example.com/", "", null), Url = "{{ base }} | {{ repository }}" }, new LinkTargetPreset(null) @@ -262,7 +262,7 @@ public async Task ShouldHandleTheMatchingServerHttpAddressNotEndingWithSlash() { "http://example.com | foo/bar", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com", ""), + Server = new StaticServer("http://example.com", "", null), Url = "{{ base }} | {{ repository }}" }, new LinkTargetPreset(null) @@ -281,7 +281,7 @@ public async Task ShouldHandleTheMatchingServerSshAddressEndingWithSlash() { "http://example.com | foo/bar", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com", "ssh://git@example.com/"), + Server = new StaticServer("http://example.com", "ssh://git@example.com/", null), Url = "{{ base }} | {{ repository }}" }, new LinkTargetPreset(null) @@ -300,7 +300,7 @@ public async Task ShouldHandleTheMatchingServerSshAddressNotEndingWithSlash() { "http://example.com | foo/bar", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com", "ssh://git@example.com"), + Server = new StaticServer("http://example.com", "ssh://git@example.com", null), Url = "{{ base }} | {{ repository }}" }, new LinkTargetPreset(null) @@ -319,7 +319,7 @@ public async Task ShouldHandleTheMatchingServerSshAddressNotEndingWithColon() { "http://example.com | foo/bar", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com/", "ssh://git@example.com"), + Server = new StaticServer("http://example.com/", "ssh://git@example.com", null), Url = "{{ base }} | {{ repository }}" }, new LinkTargetPreset(null) @@ -338,7 +338,7 @@ public async Task ShouldHandleTheMatchingServerSshAddressEndingWithColon() { "http://example.com | foo/bar", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com/", "ssh://git@example.com:"), + Server = new StaticServer("http://example.com/", "ssh://git@example.com:", null), Url = "{{ base }} | {{ repository }}" }, new LinkTargetPreset(null) @@ -357,7 +357,7 @@ public async Task ShouldTrimDotGitFromTheEndOfTheRepositoryPath() { "http://example.com | foo/bar", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com", ""), + Server = new StaticServer("http://example.com", "", null), Url = "{{ base }} | {{ repository }}" }, new LinkTargetPreset(null) @@ -376,7 +376,7 @@ public async Task ShouldHandleSshUrlWithProtocol() { "http://example.com", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com/", "ssh://git@example.com"), + Server = new StaticServer("http://example.com/", "ssh://git@example.com", null), Url = "{{ base }}" }, new LinkTargetPreset(null) @@ -395,7 +395,7 @@ public async Task ShouldHandleSshUrlWithoutProtocol() { "http://example.com", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com/", "git@example.com"), + Server = new StaticServer("http://example.com/", "git@example.com", null), Url = "{{ base }}" }, new LinkTargetPreset(null) @@ -414,7 +414,7 @@ public async Task ShouldHandleSshWithGitAt() { "http://example.com", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com/", "git@example.com"), + Server = new StaticServer("http://example.com/", "git@example.com", null), Url = "{{ base }}" }, new LinkTargetPreset(null) @@ -433,7 +433,7 @@ public async Task ShouldHandleSshWithoutGitAt() { "http://example.com", await CreateUrlAsync( new PartialHandlerDefinition { - Server = new StaticServer("http://example.com/", "example.com"), + Server = new StaticServer("http://example.com/", "example.com", null), Url = "{{ base }}" }, new LinkTargetPreset(null) @@ -442,6 +442,26 @@ await CreateUrlAsync( } + [Fact] + public async Task ShouldUseTheWebAddressFromTheMatchingServer() { + SetRemoteUrl("http://example.com/foo/bar"); + + await SetupRepositoryAsync(RootDirectory); + + Assert.Equal( + "http://web.example.com | foo/bar", + await CreateUrlAsync( + new PartialHandlerDefinition { + Server = new StaticServer("http://example.com/", "", "http://web.example.com/"), + Url = "{{ base }} | {{ repository }}" + }, + new LinkTargetPreset(null) + ) + ); + } + + + [Fact] public async Task ShouldUseTheRealPathForFilesUnderDirectoryThatIsSymbolicLink() { string real; @@ -634,10 +654,10 @@ private LinkHandler CreateHandler(PartialHandlerDefinition definition) { new Regex(""), EmptyTemplate, false, - new ReverseServerSettings(EmptyTemplate, EmptyTemplate), + new ReverseServerSettings(EmptyTemplate, EmptyTemplate, null), new ReverseSelectionSettings(EmptyTemplate, null, null, null) ), - new[] { definition.Server ?? new StaticServer("http://example.com", "ssh://example.com") } + new[] { definition.Server ?? new StaticServer("http://example.com", "ssh://example.com", null) } ), _settings, Git @@ -665,6 +685,9 @@ private class PartialHandlerDefinition { public class GetUrlInfoAsyncMethod : RepositoryTestBase { + private StaticServer _server = new("http://example.com", "ssh://example.com", null); + + static GetUrlInfoAsyncMethod() { TemplateEngine.Initialize(); } @@ -711,7 +734,7 @@ public async Task ShouldReturnTheInfoWhenThePatternMatchesTheUrl() { Assert.Equal( new UrlInfo( "bar.txt", - new StaticServer("http", "ssh"), + new StaticServer("http", "ssh", null), new PartialSelectedRange(10, 20, 30, 40)), await GetUrlInfoAsync( new PartialReverseSettings { @@ -719,7 +742,8 @@ await GetUrlInfoAsync( File = "{{ match.groups.file }}", Server = new ReverseServerSettings( Template.Parse("http"), - Template.Parse("ssh") + Template.Parse("ssh"), + null ), Selection = new ReverseSelectionSettings( Template.Parse("10"), @@ -741,7 +765,7 @@ public async Task ShouldHandleInvalidSelectionProperties() { Assert.Equal( new UrlInfo( "bar.txt", - new StaticServer("http", "ssh"), + new StaticServer("http", "ssh", null), new PartialSelectedRange(10, null, null, null) ), await GetUrlInfoAsync( @@ -749,15 +773,16 @@ await GetUrlInfoAsync( Pattern = "http://example\\.com/[^/]+/(?.+)", File = "{{ match.groups.file }}", Server = new ReverseServerSettings( - Template.Parse("http"), - Template.Parse("ssh") - ), + Template.Parse("http"), + Template.Parse("ssh"), + null + ), Selection = new ReverseSelectionSettings( - Template.Parse("10"), - Template.Parse("x"), - Template.Parse(""), - null - ) + Template.Parse("10"), + Template.Parse("x"), + Template.Parse(""), + null + ) }, "http://example.com/foo/bar.txt", false @@ -772,7 +797,7 @@ public async Task ShouldProvideTheMatchingServerInfoToTheTemplates() { Assert.Equal( new UrlInfo( "", - new StaticServer("http://example.com", "example.com"), + new StaticServer("http://example.com", "example.com", null), new PartialSelectedRange(null, null, null, null) ), await GetUrlInfoAsync( @@ -780,7 +805,8 @@ await GetUrlInfoAsync( Pattern = "http://example\\.com/.+", Server = new ReverseServerSettings( Template.Parse("{{ http }}"), - Template.Parse("{{ ssh }}") + Template.Parse("{{ ssh }}"), + null ) }, "http://example.com/foo/bar.txt", @@ -791,6 +817,36 @@ await GetUrlInfoAsync( } + [Fact] + public async Task ShouldUseTheWebTemplateWhenThereIsOne() { + _server = new StaticServer("http://example.com", "ssh://example.com", "http://web.example.com"); + + Assert.Equal( + new UrlInfo( + "", + new StaticServer( + "http://example.com", + "example.com", + "http://web.example.com" + ), + new PartialSelectedRange(null, null, null, null) + ), + await GetUrlInfoAsync( + new PartialReverseSettings { + Pattern = "http://(web\\.)?example\\.com/.+", + Server = new ReverseServerSettings( + Template.Parse("{{ http }}"), + Template.Parse("{{ ssh }}"), + Template.Parse("{{ web }}") + ) + }, + "http://web.example.com/foo/bar.txt", + false + ), + UrlInfoComparer.Instance + ); + } + private async Task GetUrlInfoAsync(PartialReverseSettings settings, string url, bool strict) { return await CreateHandler(settings).GetUrlInfoAsync(url, strict); @@ -810,10 +866,10 @@ private LinkHandler CreateHandler(PartialReverseSettings reverse) { new Regex(reverse.Pattern ?? ""), Template.Parse(reverse.File ?? ""), false, - reverse.Server ?? new ReverseServerSettings(EmptyTemplate, EmptyTemplate), + reverse.Server ?? new ReverseServerSettings(EmptyTemplate, EmptyTemplate, null), reverse.Selection ?? new ReverseSelectionSettings(EmptyTemplate, null, null, null) ), - new[] { new StaticServer("http://example.com", "ssh://example.com") } + new[] { _server } ), Substitute.For(), Git diff --git a/visual-studio/tests/GitWebLinks.UnitTests/Types/RemoteServerTests.cs b/visual-studio/tests/GitWebLinks.UnitTests/Types/RemoteServerTests.cs index c099d61..eea8211 100644 --- a/visual-studio/tests/GitWebLinks.UnitTests/Types/RemoteServerTests.cs +++ b/visual-studio/tests/GitWebLinks.UnitTests/Types/RemoteServerTests.cs @@ -5,329 +5,477 @@ namespace GitWebLinks; public static class RemoteServerTests { - public class SingleStaticServer { + public class SingleStaticServer : TestBase { - private readonly RemoteServer _server = new( - new StaticServer( - "http://example.com:8000", - "ssh://git@example.com:9000" + public SingleStaticServer() : base( + new( + new StaticServer( + "http://example.com:8000", + "ssh://git@example.com:9000", + null + ) ) - ); + ) { } [Fact] public async Task ShouldReturnNullWhenThereIsNoMatch() { - Assert.Null(await _server.MatchAsync("http://example.com:10000/foo/bar")); + Url = "http://example.com:10000/foo/bar"; + await MatchAsync(null); } [Fact] public async Task ShouldReturnTheServerWhenMatchingToTheHttpAddress() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("http://example.com:8000/foo/bar"), - StaticServerComparer.Instance - ); + Url = "http://example.com:8000/foo/bar"; + await MatchAsync(new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null)); } [Fact] public async Task ShouldReturnTheServerwhenMatchingToTheSshAddressWithTheSshProtocol() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("ssh://git@example.com:9000/foo/bar"), - StaticServerComparer.Instance + Url = "ssh://git@example.com:9000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null), + null ); } [Fact] public async Task ShouldReturnTheServerWhenMatchingToTheSshAddressWithoutTheSshProtocol() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("git@example.com:9000/foo/bar"), - StaticServerComparer.Instance + Url = "git@example.com:9000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null), + null + ); + } + + + [Fact] + public async Task ShouldMatchTheWebAddressWhenThereIsAWebAddress() { + Server = new RemoteServer( + new StaticServer( + "http://example.com:8000", + "ssh://git@example.com:9000", + "http://other.com:8000" + ) + ); + + Url = "http://other.com:8000/foo/bar"; + + await MatchAsync( + null, + new StaticServer( + "http://example.com:8000", + "ssh://git@example.com:9000", + "http://other.com:8000" + ) ); } } - public class MultipleStaticServers { + public class MultipleStaticServers : TestBase { - private readonly RemoteServer _server = new( - new IServer[] { - new StaticServer("http://example.com:8000","ssh://git@example.com:9000"), - new StaticServer("http://test.com:6000","ssh://git@test.com:7000") - } - ); + public MultipleStaticServers() : base( + new( + new IServer[] { + new StaticServer("http://example.com:8000","ssh://git@example.com:9000", null), + new StaticServer("http://test.com:6000","ssh://git@test.com:7000", null) + } + ) + ) { } [Fact] public async Task ShouldReturnNullWhenThereIsNoMatch() { - Assert.Null(await _server.MatchAsync("http://example.com:10000/foo/bar")); + Url = "http://example.com:10000/foo/bar"; + await MatchAsync(null); } [Fact] public async Task ShouldReturnTheMatchingServerWhenMatchingToTheHttpAddress() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("http://example.com:8000/foo/bar"), - StaticServerComparer.Instance + Url = "http://example.com:8000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null) ); - Assert.Equal( - new StaticServer("http://test.com:6000", "ssh://git@test.com:7000"), - await _server.MatchAsync("http://test.com:6000/foo/bar"), - StaticServerComparer.Instance + Url = "http://test.com:6000/foo/bar"; + await MatchAsync( + new StaticServer("http://test.com:6000", "ssh://git@test.com:7000", null) ); } [Fact] public async Task ShouldReturnTheMatchingServerWhenMatchingToTheSshAddressWithTheSshProtocol() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("ssh://git@example.com:9000/foo/bar"), - StaticServerComparer.Instance + Url = "ssh://git@example.com:9000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null), + null ); - Assert.Equal( - new StaticServer("http://test.com:6000", "ssh://git@test.com:7000"), - await _server.MatchAsync("ssh://git@test.com:7000/foo/bar"), - StaticServerComparer.Instance + Url = "ssh://git@test.com:7000/foo/bar"; + await MatchAsync( + new StaticServer("http://test.com:6000", "ssh://git@test.com:7000", null), + null ); } [Fact] public async Task ShouldReturnTheMatchingServerWhenMatchingToTheSshAddressWithoutTheSshProtocol() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("git@example.com:9000/foo/bar"), - StaticServerComparer.Instance + Url = ("git@example.com:9000/foo/bar"); + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null), + null ); - Assert.Equal( - new StaticServer("http://test.com:6000", "ssh://git@test.com:7000"), - await _server.MatchAsync("git@test.com:7000/foo/bar"), - StaticServerComparer.Instance + Url = ("git@test.com:7000/foo/bar"); + await MatchAsync( + new StaticServer("http://test.com:6000", "ssh://git@test.com:7000", null), + null ); } + + [Fact] + public async Task ShouldMatchTheWebAddressWhenThereIsAWebAddress() { + Server = new RemoteServer( + new IServer[] { + new StaticServer( + "http://example.com:8000", + "ssh://git@example.com:9000", + "http://web.example.com" + ), + new StaticServer( + "http://test.com:6000", + "ssh://git@test.com:7000", + "http://web.test.com" + ) + } + ); + + Url = "http://web.example.com/foo/bar"; + await MatchAsync( + null, + new StaticServer( + "http://example.com:8000", + "ssh://git@example.com:9000", + "http://web.example.com" + ) + ); + + Url = "http://web.test.com/foo/bar"; + await MatchAsync( + null, + new StaticServer( + "http://test.com:6000", + "ssh://git@test.com:7000", + "http://web.test.com" + ) + ); + } } - public class SingleDynamicServer { + public class SingleDynamicServer : TestBase { - private readonly RemoteServer _server = new( - new DynamicServer( - new Regex("http://(.+)\\.example\\.com:8000"), - Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), - Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}") + public SingleDynamicServer() : base( + new RemoteServer( + new DynamicServer( + new Regex("http://(.+)\\.example\\.com:8000"), + Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), + Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}"), + null, + null + ) ) - ); + ) { } [Fact] public async Task ShouldReturnNullWhenThereIsNoMatch() { - Assert.Null(await _server.MatchAsync("http://example.com:8000/foo/bar")); + Url = "http://example.com:8000/foo/bar"; + await MatchAsync(null); } [Fact] public async Task ShouldCreateTheDetailsOfTheMatchingServer() { - Assert.Equal( - new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo"), - await _server.MatchAsync("http://foo.example.com:8000/bar/meep"), - StaticServerComparer.Instance + Url = "http://foo.example.com:8000/bar/meep"; + await MatchAsync( + new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo", null) ); } - } - - - public class MultipleDynamicServers { - - private readonly RemoteServer _server = new( - new IServer[] { + [Fact] + public async Task ShouldMatchTheWebAddressWhenThereIsAWebAddress() { + Server = new RemoteServer( new DynamicServer( new Regex("http://(.+)\\.example\\.com:8000"), Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), - Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}") - ), - new DynamicServer( - new Regex("ssh://git@example\\.com:9000/_([^/]+)"), - Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), - Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}") + Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}"), + new Regex("http://(.+)\\.test\\.com:8000"), + Template.Parse("http://test.com:8000/repos/{{ match[1] }}") ) - } - ); + ); + + Url = "http://foo.test.com:8000/bar/meep"; + await MatchAsync( + null, + new StaticServer( + "http://example.com:8000/repos/foo", + "ssh://git@example.com:9000/_foo", + "http://test.com:8000/repos/foo" + ) + ); + } + + } + + + public class MultipleDynamicServers : TestBase { + + public MultipleDynamicServers() : base( + new RemoteServer( + new IServer[] { + new DynamicServer( + new Regex("http://(.+)\\.example\\.com:8000"), + Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), + Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}"), + null, + null + ), + new DynamicServer( + new Regex("ssh://git@example\\.com:9000/_([^/]+)"), + Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), + Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}"), + new Regex("^$"), // This server should only match SSH remote URLs. + null + ) + } + ) + ) { } [Fact] public async Task ShouldReturnNullWhenThereIsNoMatch() { - Assert.Null(await _server.MatchAsync("http://example.com:8000/foo/bar")); + Url = "http://example.com:8000/foo/bar"; + await MatchAsync(null); } [Fact] public async Task ShouldCreateTheDetailsOfTheMatchingServer() { - Assert.Equal( - new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo"), - await _server.MatchAsync("http://foo.example.com:8000/bar/meep"), - StaticServerComparer.Instance + Url = "http://foo.example.com:8000/bar/meep"; + await MatchAsync( + new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo", null) ); - Assert.Equal( - new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo"), - await _server.MatchAsync("ssh://git@example.com:9000/_foo/bar"), - StaticServerComparer.Instance + Url = "ssh://git@example.com:9000/_foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo", null), + null ); } - } + [Fact] + public async Task ShouldMatchTheWebAddressWhenThereIsAWebAddress() { + Server = new RemoteServer( + new IServer[] { + new DynamicServer( + new Regex("http://(.+)\\.example\\.com:8000"), + Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), + Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}"), + new Regex("http://(.+)\\.test\\.com:8000"), + Template.Parse("http://test.com:8000/repos/{{ match[1] }}") + ), + new DynamicServer( + new Regex("ssh://git@example\\.com:9000/_([^/]+)"), + Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), + Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}"), + new Regex("http://(.+)\\.other\\.com:8000"), + Template.Parse("http://other.com:8000/repos/{{ match[1] }}") + ) + } + ); - public class MixedStaticAndDynamicServers { + Url = "http://foo.test.com:8000/bar/meep"; + await MatchAsync( + null, + new StaticServer( + "http://example.com:8000/repos/foo", + "ssh://git@example.com:9000/_foo", + "http://test.com:8000/repos/foo" + ) + ); - private readonly RemoteServer _server = new( - new IServer[] { - new DynamicServer( - new Regex("http://(.+)\\.example\\.com:8000"), - Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), - Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}") - ), + Url = "http://foo.other.com:8000/bar/meep"; + await MatchAsync( + null, new StaticServer( - "http://example.com:10000", - "ssh://git@example.com:11000" + "http://example.com:8000/repos/foo", + "ssh://git@example.com:9000/_foo", + "http://other.com:8000/repos/foo" ) - } - ); + ); + } + + } + + + public class MixedStaticAndDynamicServers : TestBase { + + public MixedStaticAndDynamicServers() : base( + new RemoteServer( + new IServer[] { + new DynamicServer( + new Regex("http://(.+)\\.example\\.com:8000"), + Template.Parse("http://example.com:8000/repos/{{ match[1] }}"), + Template.Parse("ssh://git@example.com:9000/_{{ match[1] }}"), + null, + null + ), + new StaticServer( + "http://example.com:10000", + "ssh://git@example.com:11000", + null + ) + } + ) + ) { } [Fact] public async Task ShouldReturnNullWhenThereIsNoMatch() { - Assert.Null(await _server.MatchAsync("http://example.com:7000/foo/bar")); + Url = "http://example.com:7000/foo/bar"; + await MatchAsync(null); } [Fact] public async Task ShouldReturnTheMatchingServerWhenMatchingToTheStaticServer() { - Assert.Equal( - new StaticServer("http://example.com:10000", "ssh://git@example.com:11000"), - await _server.MatchAsync("http://example.com:10000/foo/bar"), - StaticServerComparer.Instance + Url = "http://example.com:10000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:10000", "ssh://git@example.com:11000", null) ); } [Fact] public async Task ShouldCreateTheDetailsOfTheMatchingServerWhenMatchingToTheDynamicServer() { - Assert.Equal( - new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo"), - await _server.MatchAsync("http://foo.example.com:8000/bar/meep"), - StaticServerComparer.Instance + Url = "http://foo.example.com:8000/bar/meep"; + await MatchAsync( + new StaticServer("http://example.com:8000/repos/foo", "ssh://git@example.com:9000/_foo", null) ); } } - public class StaticServerFactory { + public class StaticServerFactory : TestBase { private IEnumerable _source; - private readonly RemoteServer _server; - public StaticServerFactory() { + public StaticServerFactory() : base(new RemoteServer(new StaticServer("", null, null))) { _source = new[] { - new StaticServer("http://example.com:8000","ssh://git@example.com:9000"), - new StaticServer("http://test.com:6000","ssh://git@test.com:7000") + new StaticServer("http://example.com:8000","ssh://git@example.com:9000", null), + new StaticServer("http://test.com:6000","ssh://git@test.com:7000", "http://web.test.com") }; - - _server = new RemoteServer(() => Task.FromResult(_source)); + Server = new RemoteServer(() => Task.FromResult(_source)); } [Fact] public async Task ShouldReturnNullWhenThereIsNoMatch() { - Assert.Null(await _server.MatchAsync("http://example.com:9000/foo/bar")); + Url = "http://example.com:9000/foo/bar"; + await MatchAsync(null); } [Fact] public async Task ShouldReturnTheMatchingServerWhenMatchingToTheHttpAddress() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("http://example.com:8000/foo/bar"), - StaticServerComparer.Instance + Url = "http://example.com:8000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null) ); - Assert.Equal( - new StaticServer("http://test.com:6000", "ssh://git@test.com:7000"), - await _server.MatchAsync("http://test.com:6000/foo/bar"), - StaticServerComparer.Instance + Url = "http://test.com:6000/foo/bar"; + await MatchAsync( + new StaticServer("http://test.com:6000", "ssh://git@test.com:7000", "http://web.test.com"), + null + ); + + Url = "http://web.test.com/foo/bar"; + await MatchAsync( + null, + new StaticServer( + "http://test.com:6000", + "ssh://git@test.com:7000", + "http://web.test.com" + ) ); } [Fact] public async Task ShouldReturnTheMatchingServerWhenMatchingToTheSshAddress() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("ssh://git@example.com:9000/foo/bar"), - StaticServerComparer.Instance + Url = "ssh://git@example.com:9000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null), + null ); - Assert.Equal( - new StaticServer("http://test.com:6000", "ssh://git@test.com:7000"), - await _server.MatchAsync("ssh://git@test.com:7000/foo/bar"), - StaticServerComparer.Instance + Url = "ssh://git@test.com:7000/foo/bar"; + await MatchAsync( + new StaticServer("http://test.com:6000", "ssh://git@test.com:7000", "http://web.test.com"), + null ); } [Fact] public async Task ShouldReturnTheMatchingServerWhenTheRemoteUrlIsAnHttpAddresAndTheServerHasNoSshUrl() { - _source = new[] { new StaticServer("http://example.com:8000", null) }; + _source = new[] { new StaticServer("http://example.com:8000", null, null) }; - Assert.Equal( - new StaticServer("http://example.com:8000", null), - await _server.MatchAsync("http://example.com:8000/foo/bar"), - StaticServerComparer.Instance + Url = "http://example.com:8000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", null, null) ); } [Fact] public async Task ShouldNotReturnMatchWhenTheRemoteUrlIsAnSshAddressAndTheServerHNoSshURL() { - _source = new[] { new StaticServer("http://example.com:8000", null) }; + _source = new[] { new StaticServer("http://example.com:8000", null, null) }; - Assert.Null(await _server.MatchAsync("ssh://git@test.com:7000/foo/bar")); + Url = "ssh://git@test.com:7000/foo/bar"; + await MatchAsync(null); } [Fact] public async Task ShouldNotCacheTheServersReturnedFromTheFactory() { - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("http://example.com:8000/foo/bar"), - StaticServerComparer.Instance + Url = "http://example.com:8000/foo/bar"; + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null) ); - _source = new[] { new StaticServer("http://test.com:6000", "ssh://git@test.com:7000") }; + _source = new[] { new StaticServer("http://test.com:6000", "ssh://git@test.com:7000", null) }; - Assert.Null(await _server.MatchAsync("http://example.com:8000/foo/bar")); + await MatchAsync(null); - _source = new[] { new StaticServer("http://example.com:8000", "ssh://git@example.com:9000") }; + _source = new[] { new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null) }; - Assert.Equal( - new StaticServer("http://example.com:8000", "ssh://git@example.com:9000"), - await _server.MatchAsync("http://example.com:8000/foo/bar"), - StaticServerComparer.Instance + await MatchAsync( + new StaticServer("http://example.com:8000", "ssh://git@example.com:9000", null) ); } @@ -359,4 +507,42 @@ public int GetHashCode(StaticServer? obj) { } + + public abstract class TestBase { + + protected TestBase(RemoteServer defaultServer) { + Server = defaultServer; + } + + + protected RemoteServer Server { get; set; } + + + protected string Url { get; set; } = ""; + + + protected async Task MatchAsync(StaticServer? expectedMatch) { + await MatchAsync(expectedMatch, expectedMatch); + } + + + protected async Task MatchAsync( + StaticServer? expectedRemoteMatch, + StaticServer? expectedWebMatch + ) { + Assert.Equal( + expectedRemoteMatch, + await Server.MatchRemoteUrlAsync(Url), + StaticServerComparer.Instance + ); + + Assert.Equal( + expectedWebMatch, + await Server.MatchWebUrlAsync(Url), + StaticServerComparer.Instance + ); + } + + } + } diff --git a/vscode/package.json b/vscode/package.json index 47e4f38..3183f0e 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -340,6 +340,10 @@ "ssh": { "type": "string", "description": "The SSH URL for remotes. For example:\nssh://git.mygitiles.com:29418" + }, + "web": { + "type": "string", + "description": "The URL for the web interface if it is different to the `http` URL used for cloning. For example:\nhttps://mygitiles.com/plugins/gitiles" } }, "required": [ diff --git a/vscode/src/commands/go-to-file-command.ts b/vscode/src/commands/go-to-file-command.ts index b6d5699..08531d9 100644 --- a/vscode/src/commands/go-to-file-command.ts +++ b/vscode/src/commands/go-to-file-command.ts @@ -241,7 +241,7 @@ export class GoToFileCommand { // be because the remote URLs that we determined aren't quite correct, // or perhaps the URL comes from a fork of the repository. // - // We'll use the existance of the URI in the repository to determine + // We'll use the existence of the URI in the repository to determine // whether this repository *could* be a match. If the URI does not exist // in the repository, this this repository is not a match for the URI. // @@ -274,7 +274,7 @@ export class GoToFileCommand { */ private isMatchingRepository(repository: Repository, server: StaticServer): boolean { if (repository.remote) { - if (new RemoteServer(server).match(repository.remote.url)) { + if (new RemoteServer(server).matchRemoteUrl(repository.remote.url)) { return true; } } diff --git a/vscode/src/link-handler-provider.ts b/vscode/src/link-handler-provider.ts index aa53431..3cc45bf 100644 --- a/vscode/src/link-handler-provider.ts +++ b/vscode/src/link-handler-provider.ts @@ -30,7 +30,7 @@ export class LinkHandlerProvider { for (let handler of this.handlers) { log("Testing '%s'.", handler.name); - if (handler.isMatch(repository.remote.url)) { + if (handler.handlesRemoteUrl(repository.remote.url)) { log("Handler '%s' is a match.", handler.name); return handler; } diff --git a/vscode/src/link-handler.ts b/vscode/src/link-handler.ts index 43a8197..f15afe5 100644 --- a/vscode/src/link-handler.ts +++ b/vscode/src/link-handler.ts @@ -17,6 +17,7 @@ import { FileInfo, LinkOptions, LinkType, + Mutable, RepositoryWithRemote, SelectedRange, UrlInfo @@ -72,7 +73,10 @@ export class LinkHandler { file: parseTemplate(definition.reverse.file), server: { http: parseTemplate(definition.reverse.server.http), - ssh: parseTemplate(definition.reverse.server.ssh) + ssh: parseTemplate(definition.reverse.server.ssh), + web: definition.reverse.server.web + ? parseTemplate(definition.reverse.server.web) + : undefined }, selection: { startLine: parseTemplate(definition.reverse.selection.startLine), @@ -96,8 +100,8 @@ export class LinkHandler { * @param remoteUrl The remote URL to check. * @returns True if this handler handles the given remote URL; otherwise, false. */ - public isMatch(remoteUrl: string): boolean { - return this.server.match(normalizeUrl(remoteUrl)) !== undefined; + public handlesRemoteUrl(remoteUrl: string): boolean { + return this.server.matchRemoteUrl(normalizeUrl(remoteUrl)) !== undefined; } /** @@ -150,7 +154,7 @@ export class LinkHandler { relativePath = await this.getRelativePath(repository.root, file.filePath); data = { - base: address.http, + base: address.web ?? address.http, repository: this.getRepositoryPath(remote, address), ref, commit: await this.getRef('commit', repository), @@ -214,7 +218,7 @@ export class LinkHandler { private getAddress(remote: string): StaticServer { let address: StaticServer | undefined; - address = this.server.match(remote); + address = this.server.matchRemoteUrl(remote); if (!address) { throw new Error('Could not find a matching address.'); @@ -232,11 +236,13 @@ export class LinkHandler { private normalizeServerUrls(address: StaticServer): StaticServer { let http: string; let ssh: string | undefined; + let web: string | undefined; http = normalizeUrl(address.http); ssh = address.ssh ? normalizeUrl(address.ssh) : undefined; + web = address.web ? normalizeUrl(address.web) : undefined; - return { http, ssh }; + return { http, ssh, web }; } /** @@ -385,7 +391,7 @@ export class LinkHandler { private async getRelativePath(from: string, to: string): Promise { // If the file is a symbolic link, or is under a directory that's a // symbolic link, then we want to resolve the path to the real file - // because the sybmolic link won't be in the Git repository. + // because the symbolic link won't be in the Git repository. if (await this.isSymbolicLink(to, from)) { try { to = await fs.realpath(to); @@ -457,16 +463,16 @@ export class LinkHandler { /** * Gets information about the given URL. * - * @param url The URL to get the information from. + * @param webUrl The web interface URL to get the information from. * @param strict Whether to require the URL to match the server address of the handler. * @returns The URL information, or `undefined` if the information could not be determined. */ - public getUrlInfo(url: string, strict: boolean): UrlInfo | undefined { + public getUrlInfo(webUrl: string, strict: boolean): UrlInfo | undefined { let address: StaticServer | undefined; let match: RegExpExecArray | null; // See if the URL matches the server address for the handler. - address = this.server.match(url); + address = this.server.matchWebUrl(webUrl); // If we are performing a strict match, then the // URL must match to this handler's server. @@ -478,18 +484,19 @@ export class LinkHandler { address = this.normalizeServerUrls(address); } - match = this.reverse.pattern.exec(url); + match = this.reverse.pattern.exec(webUrl); if (match) { let data: FileData; let file: string; - let server: StaticServer; + let server: Mutable; let selection: Partial; data = { match, http: address?.http, - ssh: address?.ssh + ssh: address?.ssh, + web: address?.web }; file = this.reverse.file.render(data); @@ -499,6 +506,10 @@ export class LinkHandler { ssh: this.reverse.server.ssh.render(data) }; + if (this.reverse.server.web) { + server.web = this.reverse.server.web.render(data); + } + selection = { startLine: this.tryParseNumber(this.reverse.selection.startLine.render(data)), endLine: this.tryParseNumber(this.reverse.selection.endLine?.render(data)), @@ -599,6 +610,8 @@ interface FileData { readonly http?: string; readonly ssh?: string; + + readonly web?: string; } /** diff --git a/vscode/src/remote-server.ts b/vscode/src/remote-server.ts index 0691cee..444e2bb 100644 --- a/vscode/src/remote-server.ts +++ b/vscode/src/remote-server.ts @@ -1,6 +1,7 @@ import { log } from './log'; import { DynamicServer, StaticServer } from './schema'; import { ParsedTemplate, parseTemplate } from './templates'; +import { Mutable } from './types'; import { normalizeUrl } from './utilities'; /** @@ -31,17 +32,41 @@ export class RemoteServer { } } + /** + * Tests if this server is a match for the given remote URL. + * + * @param remoteUrl The remote URL to test against. + * @returns The server address if the URL is a match; otherwise, `undefined`. + */ + public matchRemoteUrl(remoteUrl: string): StaticServer | undefined { + return this.matchUrl(remoteUrl, (x) => x.remote); + } + + /** + * Tests if this server is a match for the given web interface URL. + * + * @param webUrl The web interface URL to test against. + * @returns The server address if the URL is a match; otherwise, `undefined`. + */ + public matchWebUrl(webUrl: string): StaticServer | undefined { + return this.matchUrl(webUrl, (x) => x.web); + } + /** * Tests if this server is a match for the given URL. * * @param url The URL to test against. + * @param selectUrlMatcher A function to select the matcher to use from a `Matcher`. * @returns The server address if the URL is a match; otherwise, `undefined`. */ - public match(url: string): StaticServer | undefined { + private matchUrl( + url: string, + selectUrlMatcher: (matcher: Matcher) => UrlMatcher + ): StaticServer | undefined { for (let matcher of this.matchers) { let server: StaticServer | undefined; - server = matcher(url); + server = selectUrlMatcher(matcher)(url); if (server) { return server; @@ -59,7 +84,7 @@ export class RemoteServer { * @returns The matcher function. */ function createMatcher(server: StaticServer | DynamicServer): Matcher { - if ('pattern' in server) { + if ('remotePattern' in server) { return createDynamicServerMatcher(server); } else { return createStaticServerMatcher(server); @@ -73,40 +98,78 @@ function createMatcher(server: StaticServer | DynamicServer): Matcher { * @returns The matcher for the server. */ function createDynamicServerMatcher(server: DynamicServer): Matcher { - let pattern: RegExp; + let remotePattern: RegExp; + let webPattern: RegExp; let httpTemplate: ParsedTemplate; let sshTemplate: ParsedTemplate; + let webTemplate: ParsedTemplate | undefined; - // The pattern is a regular expression, so parse - // it once instead of each time we execute. + // The patterns are regular expressions, so parse + // them once instead of each time we execute. try { - pattern = new RegExp(server.pattern); + remotePattern = new RegExp(server.remotePattern); } catch (ex) { - log("Invalid dynamic server pattern '%s': %s", server.pattern, ex); - return () => undefined; + log("Invalid dynamic server remote pattern '%s': %s", server.remotePattern, ex); + return { remote: () => undefined, web: () => undefined }; + } + + if (server.webPattern) { + try { + webPattern = new RegExp(server.webPattern); + } catch (ex) { + log("Invalid dynamic server web pattern '%s': %s", server.remotePattern, ex); + return { remote: () => undefined, web: () => undefined }; + } + } else { + webPattern = remotePattern; } // Parse the templates now so we don't // have to do it each time we execute. httpTemplate = parseTemplate(server.http); sshTemplate = parseTemplate(server.ssh); + webTemplate = server.web ? parseTemplate(server.web) : undefined; - return (url) => { - let match: RegExpMatchArray | null; + return { + remote: create(remotePattern), + web: create(webPattern) + }; - match = pattern.exec(url); + /** + * Creates a matcher function. + * + * @param pattern The pattern to test with. + * @returns The matcher function. + */ + function create(pattern: RegExp): Matcher['remote'] { + return (url) => { + let match: RegExpMatchArray | null; - if (match) { - // The URL matched the pattern. Render the templates to get the HTTP - // and SSH URLs, making the match available for the templates to use. - return { - http: httpTemplate.render({ match }), - ssh: sshTemplate.render({ match }) - }; - } + match = pattern.exec(url); - return undefined; - }; + if (match) { + let http: string; + let server: Mutable; + + http = httpTemplate.render({ match }); + + // The URL matched the pattern. Render the templates to get the + // URLs, making the match available for the templates to use. + server = { + http, + ssh: sshTemplate.render({ match }) + }; + + if (webTemplate) { + server.web = webTemplate?.render({ match }); + } + + return server; + } + + return undefined; + }; + } } /** @@ -116,30 +179,44 @@ function createDynamicServerMatcher(server: DynamicServer): Matcher { * @returns The matcher function. */ function createStaticServerMatcher(server: StaticServer): Matcher { - return (url) => (isMatch(url, server) ? server : undefined); + return { + remote: (url) => (isRemoteMatch(url, server) ? server : undefined), + web: (url) => (isWebMatch(url, server) ? server : undefined) + }; } /** - * Determines whether the given URL matches the given server definition. + * Determines whether the given remote URL matches the given server definition. * - * @param url The URL. + * @param remoteUrl The remote URL. * @param server The server definition. * @returns True if the URL matches the server; otherwise, false. */ -function isMatch(url: string, server: StaticServer): boolean { - url = normalizeUrl(url); +function isRemoteMatch(remoteUrl: string, server: StaticServer): boolean { + remoteUrl = normalizeUrl(remoteUrl); - if (url.startsWith(normalizeUrl(server.http))) { + if (remoteUrl.startsWith(normalizeUrl(server.http))) { return true; } - if (server.ssh && url.startsWith(normalizeUrl(server.ssh))) { + if (server.ssh && remoteUrl.startsWith(normalizeUrl(server.ssh))) { return true; } return false; } +/** + * Determines whether the given web interface URL matches the given server definition. + * + * @param webUrl The web interface URL. + * @param server The server definition. + * @returns True if the URL matches the server; otherwise, false. + */ +function isWebMatch(webUrl: string, server: StaticServer): boolean { + return normalizeUrl(webUrl).startsWith(normalizeUrl(server.web ?? server.http)); +} + /** * Creates a matcher function that fetches the static server definitions when invoked. * @@ -147,21 +224,36 @@ function isMatch(url: string, server: StaticServer): boolean { * @returns The matcher function. */ function createLazyStaticServerMatcher(factory: StaticServerFactory): Matcher { - return (url) => { - let servers: StaticServer[]; - - servers = factory(); + return { + remote: create(isRemoteMatch), + web: create(isWebMatch) + }; - for (let server of servers) { - if (isMatch(url, server)) { - return server; + /** + * Creates a matcher function. + * + * @param test The function to test a match. + * @returns The matcher function. + */ + function create(test: typeof isWebMatch | typeof isRemoteMatch): Matcher['remote'] { + return (url) => { + for (let server of factory()) { + if (test(url, server)) { + return server; + } } - } - return undefined; - }; + return undefined; + }; + } } type StaticServerFactory = () => StaticServer[]; -type Matcher = (url: string) => StaticServer | undefined; +type UrlMatcher = (url: string) => StaticServer | undefined; + +interface Matcher { + readonly remote: UrlMatcher; + + readonly web: UrlMatcher; +} diff --git a/vscode/src/schema.ts b/vscode/src/schema.ts index 5815265..3294f5e 100644 --- a/vscode/src/schema.ts +++ b/vscode/src/schema.ts @@ -125,14 +125,20 @@ export interface ReverseSettings { */ export interface ReverseServerSettings { /** - * The template to produce the HTTP server URL. + * The template to produce the HTTP remote URL. */ readonly http: Template; /** - * The template to produce the SSH server URL. + * The template to produce the SSH remote URL. */ readonly ssh: Template; + + /** + * The template to produce the URL for the web interface. + * When this is not specified, the `http` template is used. + */ + readonly web?: Template | undefined; } /** @@ -175,14 +181,20 @@ export type Server = StaticServer | DynamicServer[]; */ export interface StaticServer { /** - * The HTTP(S) URL of the remote server. + * The HTTP(S) remote URL. */ readonly http: string; /** - * The SSH URL of the remote server. + * The SSH remote URL. */ readonly ssh: string | undefined; + + /** + * The HTTP(S) URL of the web interface. When not specified, + * the `http` property defines the URL of the web interface. + */ + readonly web?: string | undefined; } /** @@ -192,17 +204,27 @@ export interface DynamicServer { /** * A regular expression to match on a remote URL. */ - readonly pattern: string; + readonly remotePattern: string; /** - * The template to build the HTTP(S) URL of the remote server. + * The template to build the HTTP(S) remote URL. */ readonly http: Template; /** - * The template to build the SSH URL of the remote server. + * The template to build the SSH remote URL. */ readonly ssh: Template; + + /** + * A regular expression to match on a web interface URL. + */ + readonly webPattern?: string | undefined; + + /** + * The template to build the URL of the web interface. + */ + readonly web?: Template | undefined; } /** diff --git a/vscode/src/types.ts b/vscode/src/types.ts index 9cc236d..0c75344 100644 --- a/vscode/src/types.ts +++ b/vscode/src/types.ts @@ -147,3 +147,10 @@ export interface UrlInfo { */ selection: Partial; } + +/** + * Makes all properties on an object read/write. + */ +export type Mutable = { + -readonly [P in keyof T]: T[P]; +}; diff --git a/vscode/test/link-handler.test.ts b/vscode/test/link-handler.test.ts index 15acf61..36442ce 100644 --- a/vscode/test/link-handler.test.ts +++ b/vscode/test/link-handler.test.ts @@ -6,7 +6,7 @@ import * as sinon from 'sinon'; import { git } from '../src/git'; import { LinkHandler } from '../src/link-handler'; import { NoRemoteHeadError } from '../src/no-remote-head-error'; -import { HandlerDefinition, ReverseSettings } from '../src/schema'; +import { HandlerDefinition, ReverseSettings, StaticServer } from '../src/schema'; import { Settings } from '../src/settings'; import { LinkOptions, LinkType, RepositoryWithRemote, UrlInfo } from '../src/types'; import { isErrorCode } from '../src/utilities'; @@ -368,6 +368,26 @@ describe('LinkHandler', function () { ).to.equal('http://example.com'); }); + it('should use the web address from the matching server.', async () => { + repository = { + ...repository, + remote: { url: 'http://example.com/foo/bar', name: 'origin' } + }; + + await setupRepository(root.path); + + expect( + await createUrl({ + server: { + http: 'http://example.com/', + ssh: '', + web: 'http://web.example.com/' + }, + url: '{{ base }} | {{ repository }}' + }) + ).to.equal('http://web.example.com | foo/bar'); + }); + it(`should use the real path for files under a directory that is a symbolic link.`, async function () { let real: string; let link: string; @@ -563,6 +583,12 @@ describe('LinkHandler', function () { }); describe('getUrlInfo', () => { + let server: StaticServer; + + beforeEach(() => { + server = { http: 'http://example.com', ssh: 'ssh://example.com' }; + }); + it('should return undefined in strict mode when the URL does not match the server.', () => { expect(getUrlInfo({ pattern: '.+' }, 'http://different.com/foo/bar.txt', true)).to.be .undefined; @@ -647,6 +673,29 @@ describe('LinkHandler', function () { }); }); + it('should use the web template when there is one.', () => { + server = { + http: 'http://example.com', + ssh: 'ssh://example.com', + web: 'http://web.example.com' + }; + + expect( + getUrlInfo( + { + pattern: 'http://(web\\.)?example\\.com/.+', + server: { http: '{{ http }}', ssh: '{{ ssh }}', web: '{{ web }}' } + }, + 'http://web.example.com/foo/bar.txt', + false + )?.server + ).to.deep.equal({ + http: 'http://example.com', + ssh: 'example.com', + web: 'http://web.example.com' + }); + }); + function getUrlInfo( settings: Partial, url: string, @@ -658,7 +707,7 @@ describe('LinkHandler', function () { function createHandler(reverse: Partial): LinkHandler { return new LinkHandler({ name: 'Test', - server: { http: 'http://example.com', ssh: 'ssh://example.com' }, + server, branchRef: 'abbreviated', url: '', selection: '', diff --git a/vscode/test/remote-server.test.ts b/vscode/test/remote-server.test.ts index a90d07a..4679340 100644 --- a/vscode/test/remote-server.test.ts +++ b/vscode/test/remote-server.test.ts @@ -5,6 +5,7 @@ import { StaticServer } from '../src/schema'; describe('RemoteServer', () => { let server: RemoteServer; + let url: string; describe('single static server', () => { beforeEach(() => { @@ -15,27 +16,43 @@ describe('RemoteServer', () => { }); it('should return undefined when there is no match.', () => { - expect(server.match('http://example.com:10000/foo/bar')).to.be.undefined; + url = 'http://example.com:10000/foo/bar'; + match(undefined, undefined); }); it('should return the server when matching to the HTTP address.', () => { - expect(server.match('http://example.com:8000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); + url = 'http://example.com:8000/foo/bar'; + match({ http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, 'same'); }); it('should return the server when matching to the SSH address with the SSH protocol.', () => { - expect(server.match('ssh://git@example.com:9000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); + url = 'ssh://git@example.com:9000/foo/bar'; + match( + { http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, + undefined + ); }); it('should return the server when matching to the SSH address without the SSH protocol.', () => { - expect(server.match('git@example.com:9000/foo/bar')).to.deep.equal({ + url = 'git@example.com:9000/foo/bar'; + match( + { http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, + undefined + ); + }); + + it('should match the web address when there is a web address.', () => { + server = new RemoteServer({ http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' + ssh: 'ssh://git@example.com:9000', + web: 'http://other.com:8000' + }); + + url = 'http://other.com:8000/foo/bar'; + match(undefined, { + http: 'http://example.com:8000', + ssh: 'ssh://git@example.com:9000', + web: 'http://other.com:8000' }); }); }); @@ -55,42 +72,66 @@ describe('RemoteServer', () => { }); it('should return undefined when there is no match.', () => { - expect(server.match('http://test.com:8000/foo/bar')).to.be.undefined; + url = 'http://test.com:8000/foo/bar'; + match(undefined, undefined); }); it('should return the matching server when matching to the HTTP address.', () => { - expect(server.match('http://example.com:8000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); + url = 'http://example.com:8000/foo/bar'; + match({ http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, 'same'); - expect(server.match('http://test.com:6000/foo/bar')).to.deep.equal({ - http: 'http://test.com:6000', - ssh: 'ssh://git@test.com:7000' - }); + url = 'http://test.com:6000/foo/bar'; + match({ http: 'http://test.com:6000', ssh: 'ssh://git@test.com:7000' }, 'same'); }); it('should return the matching server when matching to the SSH address with the SSH protocol.', () => { - expect(server.match('ssh://git@example.com:9000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); - - expect(server.match('ssh://git@test.com:7000/foo/bar')).to.deep.equal({ - http: 'http://test.com:6000', - ssh: 'ssh://git@test.com:7000' - }); + url = 'ssh://git@example.com:9000/foo/bar'; + match( + { http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, + undefined + ); + + url = 'ssh://git@test.com:7000/foo/bar'; + match({ http: 'http://test.com:6000', ssh: 'ssh://git@test.com:7000' }, undefined); }); it('should return the matching server when matching to the SSH address without the SSH protocol.', () => { - expect(server.match('git@example.com:9000/foo/bar')).to.deep.equal({ + url = 'git@example.com:9000/foo/bar'; + match( + { http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, + undefined + ); + + url = 'git@test.com:7000/foo/bar'; + match({ http: 'http://test.com:6000', ssh: 'ssh://git@test.com:7000' }, undefined); + }); + + it('should match the web address when there is a web address.', () => { + server = new RemoteServer([ + { + http: 'http://example.com:8000', + ssh: 'ssh://git@example.com:9000', + web: 'http://web.example.com' + }, + { + http: 'http://test.com:6000', + ssh: 'ssh://git@test.com:7000', + web: 'http://web.test.com' + } + ]); + + url = 'http://web.example.com/foo/bar'; + match(undefined, { http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' + ssh: 'ssh://git@example.com:9000', + web: 'http://web.example.com' }); - expect(server.match('git@test.com:7000/foo/bar')).to.deep.equal({ + url = 'http://web.test.com/foo/bar'; + match(undefined, { http: 'http://test.com:6000', - ssh: 'ssh://git@test.com:7000' + ssh: 'ssh://git@test.com:7000', + web: 'http://web.test.com' }); }); }); @@ -98,31 +139,54 @@ describe('RemoteServer', () => { describe('single dynamic server', () => { beforeEach(() => { server = new RemoteServer({ - pattern: 'http://(.+)\\.example\\.com:8000', + remotePattern: 'http://(.+)\\.example\\.com:8000', http: 'http://example.com:8000/repos/{{ match[1] }}', ssh: 'ssh://git@example.com:9000/_{{ match[1] }}' }); }); it('should return undefined when there is no match.', () => { - expect(server.match('http://example.com:8000/foo/bar')).to.be.undefined; + url = 'http://example.com:8000/foo/bar'; + match(undefined, undefined); }); it('should create the details of the matching server.', () => { - expect(server.match('http://foo.example.com:8000/bar/meep')).to.deep.equal({ - http: 'http://example.com:8000/repos/foo', - ssh: 'ssh://git@example.com:9000/_foo' - }); + url = 'http://foo.example.com:8000/bar/meep'; + match( + { + http: 'http://example.com:8000/repos/foo', + ssh: 'ssh://git@example.com:9000/_foo' + }, + 'same' + ); }); it('should not crash if pattern is invalid.', () => { server = new RemoteServer({ - pattern: 'foo[bar', + remotePattern: 'foo[bar', http: 'http://example.com', ssh: 'ssh://git@example.com' }); - expect(server.match('foo')).to.be.undefined; + url = 'foo'; + match(undefined, undefined); + }); + + it('should match the web address when there is a web address.', () => { + server = new RemoteServer({ + remotePattern: 'http://(.+)\\.example\\.com:8000', + http: 'http://example.com:8000/repos/{{ match[1] }}', + ssh: 'ssh://git@example.com:9000/_{{ match[1] }}', + webPattern: 'http://(.+)\\.test\\.com:8000', + web: 'http://test.com:8000/repos/{{ match[1] }}' + }); + + url = 'http://foo.test.com:8000/bar/meep'; + match(undefined, { + http: 'http://example.com:8000/repos/foo', + ssh: 'ssh://git@example.com:9000/_foo', + web: 'http://test.com:8000/repos/foo' + }); }); }); @@ -130,31 +194,74 @@ describe('RemoteServer', () => { beforeEach(() => { server = new RemoteServer([ { - pattern: 'http://(.+)\\.example\\.com:8000', + remotePattern: 'http://(.+)\\.example\\.com:8000', http: 'http://example.com:8000/repos/{{ match[1] }}', ssh: 'ssh://git@example.com:9000/_{{ match[1] }}' }, { - pattern: 'ssh://git@example\\.com:9000/_([^/]+)', + remotePattern: 'ssh://git@example\\.com:9000/_([^/]+)', http: 'http://example.com:8000/repos/{{ match[1] }}', - ssh: 'ssh://git@example.com:9000/_{{ match[1] }}' + ssh: 'ssh://git@example.com:9000/_{{ match[1] }}', + webPattern: '^$' // This server should only match SSH remote URLs. } ]); }); it('should return undefined when there is no match.', () => { - expect(server.match('http://example.com:8000/foo/bar')).to.be.undefined; + url = 'http://example.com:8000/foo/bar'; + match(undefined, undefined); }); it('should create the details of the matching server.', () => { - expect(server.match('http://foo.example.com:8000/bar/meep')).to.deep.equal({ + url = 'http://foo.example.com:8000/bar/meep'; + match( + { + http: 'http://example.com:8000/repos/foo', + ssh: 'ssh://git@example.com:9000/_foo' + }, + 'same' + ); + + url = 'ssh://git@example.com:9000/_foo/bar'; + match( + { + http: 'http://example.com:8000/repos/foo', + ssh: 'ssh://git@example.com:9000/_foo' + }, + undefined + ); + }); + + it('should match the web address when there is a web address.', () => { + server = new RemoteServer([ + { + remotePattern: 'http://(.+)\\.example\\.com:8000', + http: 'http://example.com:8000/repos/{{ match[1] }}', + ssh: 'ssh://git@example.com:9000/_{{ match[1] }}', + webPattern: 'http://(.+)\\.test\\.com:8000', + web: 'http://test.com:8000/repos/{{ match[1] }}' + }, + { + remotePattern: 'ssh://git@example\\.com:9000/_([^/]+)', + http: 'http://example.com:8000/repos/{{ match[1] }}', + ssh: 'ssh://git@example.com:9000/_{{ match[1] }}', + webPattern: 'http://(.+)\\.other\\.com:8000', + web: 'http://other.com:8000/repos/{{ match[1] }}' + } + ]); + + url = 'http://foo.test.com:8000/bar/meep'; + match(undefined, { http: 'http://example.com:8000/repos/foo', - ssh: 'ssh://git@example.com:9000/_foo' + ssh: 'ssh://git@example.com:9000/_foo', + web: 'http://test.com:8000/repos/foo' }); - expect(server.match('ssh://git@example.com:9000/_foo/bar')).to.deep.equal({ + url = 'http://foo.other.com:8000/bar/meep'; + match(undefined, { http: 'http://example.com:8000/repos/foo', - ssh: 'ssh://git@example.com:9000/_foo' + ssh: 'ssh://git@example.com:9000/_foo', + web: 'http://other.com:8000/repos/foo' }); }); }); @@ -163,7 +270,7 @@ describe('RemoteServer', () => { beforeEach(() => { server = new RemoteServer([ { - pattern: 'http://(.+)\\.example\\.com:8000', + remotePattern: 'http://(.+)\\.example\\.com:8000', http: 'http://example.com:8000/repos/{{ match[1] }}', ssh: 'ssh://git@example.com:9000/_{{ match[1] }}' }, @@ -175,21 +282,24 @@ describe('RemoteServer', () => { }); it('should return undefined when there is no match.', () => { - expect(server.match('http://example.com:7000/foo/bar')).to.be.undefined; + url = 'http://example.com:7000/foo/bar'; + match(undefined, undefined); }); it('should return the matching server when matching to the static server.', () => { - expect(server.match('http://example.com:10000/foo/bar')).to.deep.equal({ - http: 'http://example.com:10000', - ssh: 'ssh://git@example.com:11000' - }); + url = 'http://example.com:10000/foo/bar'; + match({ http: 'http://example.com:10000', ssh: 'ssh://git@example.com:11000' }, 'same'); }); it('should create the details of the matching server when matching to the dynamic server.', () => { - expect(server.match('http://foo.example.com:8000/bar/meep')).to.deep.equal({ - http: 'http://example.com:8000/repos/foo', - ssh: 'ssh://git@example.com:9000/_foo' - }); + url = 'http://foo.example.com:8000/bar/meep'; + match( + { + http: 'http://example.com:8000/repos/foo', + ssh: 'ssh://git@example.com:9000/_foo' + }, + 'same' + ); }); }); @@ -204,7 +314,8 @@ describe('RemoteServer', () => { }, { http: 'http://test.com:6000', - ssh: 'ssh://git@test.com:7000' + ssh: 'ssh://git@test.com:7000', + web: 'http://web.test.com' } ]; @@ -212,53 +323,67 @@ describe('RemoteServer', () => { }); it('should return undefined when there is no match.', () => { - expect(server.match('http://example.com:9000/foo/bar')).to.be.undefined; + url = 'http://example.com:9000/foo/bar'; + match(undefined, undefined); }); it('should return the matching server when matching to the HTTP address.', () => { - expect(server.match('http://example.com:8000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); + url = 'http://example.com:8000/foo/bar'; + match({ http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, 'same'); + + url = 'http://test.com:6000/foo/bar'; + match( + { + http: 'http://test.com:6000', + ssh: 'ssh://git@test.com:7000', + web: 'http://web.test.com' + }, + undefined + ); - expect(server.match('http://test.com:6000/foo/bar')).to.deep.equal({ + url = 'http://web.test.com/foo/bar'; + match(undefined, { http: 'http://test.com:6000', - ssh: 'ssh://git@test.com:7000' + ssh: 'ssh://git@test.com:7000', + web: 'http://web.test.com' }); }); it('should return the matching server when matching to the SSH address.', () => { - expect(server.match('ssh://git@example.com:9000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); - - expect(server.match('ssh://git@test.com:7000/foo/bar')).to.deep.equal({ - http: 'http://test.com:6000', - ssh: 'ssh://git@test.com:7000' - }); + url = 'ssh://git@example.com:9000/foo/bar'; + match( + { http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, + undefined + ); + + url = 'ssh://git@test.com:7000/foo/bar'; + match( + { + http: 'http://test.com:6000', + ssh: 'ssh://git@test.com:7000', + web: 'http://web.test.com' + }, + undefined + ); }); it('should return the matching server when the remote URL is an HTTP address and the server has no SSH URL.', () => { source = [{ http: 'http://example.com:8000', ssh: undefined }]; - expect(server.match('http://example.com:8000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: undefined - }); + url = 'http://example.com:8000/foo/bar'; + match({ http: 'http://example.com:8000', ssh: undefined }, 'same'); }); it('should not return a match when the remote URL is an SSH address and the server has no SSH URL.', () => { source = [{ http: 'http://example.com:8000', ssh: undefined }]; - expect(server.match('ssh://git@test.com:7000/foo/bar')).to.be.undefined; + url = 'ssh://git@test.com:7000/foo/bar'; + match(undefined, undefined); }); it('should not cache the servers returned from the factory.', () => { - expect(server.match('http://example.com:8000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); + url = 'http://example.com:8000/foo/bar'; + match({ http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, 'same'); source = [ { @@ -267,7 +392,7 @@ describe('RemoteServer', () => { } ]; - expect(server.match('http://example.com:8000/foo/bar')).to.be.undefined; + match(undefined, undefined); source = [ { @@ -276,10 +401,17 @@ describe('RemoteServer', () => { } ]; - expect(server.match('http://example.com:8000/foo/bar')).to.deep.equal({ - http: 'http://example.com:8000', - ssh: 'ssh://git@example.com:9000' - }); + match({ http: 'http://example.com:8000', ssh: 'ssh://git@example.com:9000' }, 'same'); }); }); + + function match( + expectedRemoteMatch: StaticServer | undefined, + expectedWebMatch: StaticServer | 'same' | undefined + ): void { + expect(server.matchRemoteUrl(url), 'remote').to.deep.equal(expectedRemoteMatch); + expect(server.matchWebUrl(url), 'web').to.deep.equal( + expectedWebMatch === 'same' ? expectedRemoteMatch : expectedWebMatch + ); + } });