diff --git a/readme.md b/readme.md index 8a129e5..7b7ad18 100644 --- a/readme.md +++ b/readme.md @@ -65,6 +65,29 @@ dnx go dnx go -- app.cs dnx go -- kzu/sandbox ``` + +### Open local files and remote refs + +Use `open` to shell-open a local file in the default app, or open the web URL for a +remote ref in the browser — without downloading or running the app: + +```console +# Open a local file (editor / OS association) +dnx go -- open app.cs + +# Open the GitHub/GitLab/gist web page for a remote ref +dnx go -- open kzu/sandbox +dnx go -- open kzu/sandbox@main:src/hello.cs +dnx go -- open gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381 + +# Interactive: pick from MRU history, then open the selection +dnx go -- open +``` + +With no input, `open` uses the same history list as the default command (searchable +picker). Empty history or a non-interactive terminal fails with a clear error instead +of hanging. Opening does not record a new history entry. + Native AOT needs a platform C/C++ linker (VC++ build tools on Windows, `build-essential` on Ubuntu, Xcode Command Line Tools on macOS). Verify with: diff --git a/src/Tests/GoArgsTests.cs b/src/Tests/GoArgsTests.cs index 91a0bc7..436e90e 100644 --- a/src/Tests/GoArgsTests.cs +++ b/src/Tests/GoArgsTests.cs @@ -113,6 +113,34 @@ public void PrepareCafArgs_passes_remove_args_through_unchanged() Assert.Empty(GoArgs.ForwardArgs); } + [Fact] + public void PrepareCafArgs_passes_open_args_through_unchanged() + { + var caf = GoArgs.PrepareArgs(["open", "app.cs"]); + + // open owns its args (same non-forwarding path as clean/remove); do not treat path as app-forward. + Assert.Equal(["open", "app.cs"], caf); + Assert.Empty(GoArgs.ForwardArgs); + } + + [Fact] + public void PrepareCafArgs_passes_open_with_remote_ref_through_unchanged() + { + var caf = GoArgs.PrepareArgs(["open", "kzu/sandbox@main:program.cs"]); + + Assert.Equal(["open", "kzu/sandbox@main:program.cs"], caf); + Assert.Empty(GoArgs.ForwardArgs); + } + + [Fact] + public void PrepareCafArgs_passes_open_zero_arg_through_unchanged() + { + var caf = GoArgs.PrepareArgs(["open"]); + + Assert.Equal(["open"], caf); + Assert.Empty(GoArgs.ForwardArgs); + } + [Fact] public void PrepareCafArgs_passes_help_through_unchanged() { diff --git a/src/Tests/OpenTargetTests.cs b/src/Tests/OpenTargetTests.cs new file mode 100644 index 0000000..27b6ac6 --- /dev/null +++ b/src/Tests/OpenTargetTests.cs @@ -0,0 +1,210 @@ +using Devlooped; + +namespace Tests; + +public class OpenTargetTests +{ + [Fact] + public void TryResolve_local_existing_file_returns_full_path() + { + var temp = Path.Combine(Path.GetTempPath(), $"go-open-{Guid.NewGuid():N}.cs"); + try + { + File.WriteAllText(temp, "// open target fixture"); + + Assert.True(OpenTarget.TryResolve(temp, out var target, out var error)); + Assert.Null(error); + Assert.NotNull(target); + Assert.True(File.Exists(target)); + Assert.Equal( + Path.GetFullPath(temp), + Path.GetFullPath(target!), + ignoreCase: OperatingSystem.IsWindows()); + } + finally + { + TryDelete(temp); + } + } + + [Fact] + public void TryResolve_local_relative_existing_file_returns_full_path() + { + var dir = Path.Combine(Path.GetTempPath(), $"go-open-dir-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var name = "fixture.cs"; + var full = Path.Combine(dir, name); + var prev = Directory.GetCurrentDirectory(); + try + { + File.WriteAllText(full, "// relative open fixture"); + Directory.SetCurrentDirectory(dir); + + Assert.True(OpenTarget.TryResolve(name, out var target, out var error)); + Assert.Null(error); + Assert.Equal(Path.GetFullPath(full), Path.GetFullPath(target!), ignoreCase: OperatingSystem.IsWindows()); + } + finally + { + Directory.SetCurrentDirectory(prev); + TryDelete(full); + try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } + } + } + + [Fact] + public void TryResolve_remote_owner_repo_maps_to_github_web_url() + { + Assert.True(OpenTarget.TryResolve("kzu/sandbox", out var target, out var error)); + Assert.Null(error); + Assert.Equal("https://github.com/kzu/sandbox/tree/main", target); + } + + [Fact] + public void TryResolve_remote_with_ref_and_path_maps_to_blob_url() + { + Assert.True(OpenTarget.TryResolve("kzu/sandbox@v1.2.3:src/hello.cs", out var target, out var error)); + Assert.Null(error); + Assert.Equal("https://github.com/kzu/sandbox/blob/v1.2.3/src/hello.cs", target); + } + + [Fact] + public void TryResolve_gist_ref_maps_to_gist_url() + { + Assert.True(OpenTarget.TryResolve("gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381", out var target, out var error)); + Assert.Null(error); + Assert.Equal("https://gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381", target); + } + + [Fact] + public void TryResolve_gitlab_ref_maps_to_gitlab_url() + { + Assert.True(OpenTarget.TryResolve("gitlab.com/kzu/runcs@main:program.cs", out var target, out var error)); + Assert.Null(error); + Assert.Equal("https://gitlab.com/kzu/runcs/-/blob/main/program.cs", target); + } + + [Fact] + public void TryResolve_uses_shipped_ToWebUrl_for_remote() + { + // Drive the same mapping the product uses: parse + ToWebUrl must match TryResolve. + const string input = "github.com/owner/repo@main:app.cs"; + Assert.True(RemoteRef.TryParse(input, out var remote)); + var expected = remote.ToWebUrl(); + + Assert.True(OpenTarget.TryResolve(input, out var target, out _)); + Assert.Equal(expected, target); + } + + [Fact] + public void TryResolve_invalid_input_fails_without_target() + { + // Missing local file and not a remote ref (no owner/repo shape). + var missing = Path.Combine(Path.GetTempPath(), $"go-open-missing-{Guid.NewGuid():N}.cs"); + Assert.False(File.Exists(missing)); + + Assert.False(OpenTarget.TryResolve(missing, out var target, out var error)); + Assert.Null(target); + Assert.False(string.IsNullOrWhiteSpace(error)); + } + + [Fact] + public void TryResolve_empty_input_fails() + { + Assert.False(OpenTarget.TryResolve("", out var target, out var error)); + Assert.Null(target); + Assert.Contains("Specify", error, StringComparison.OrdinalIgnoreCase); + + Assert.False(OpenTarget.TryResolve(" ", out target, out error)); + Assert.Null(target); + Assert.NotNull(error); + } + + [Fact] + public void ShellOpen_spy_receives_resolved_local_target() + { + var temp = Path.Combine(Path.GetTempPath(), $"go-open-spy-{Guid.NewGuid():N}.cs"); + string? opened = null; + try + { + File.WriteAllText(temp, "// spy"); + ShellOpen.OpenImpl = path => + { + opened = path; + return true; + }; + + Assert.True(OpenTarget.TryResolve(temp, out var target, out _)); + Assert.True(ShellOpen.TryOpen(target!)); + Assert.Equal(target, opened); + } + finally + { + ShellOpen.OpenImpl = null; + TryDelete(temp); + } + } + + [Fact] + public void ShellOpen_spy_receives_resolved_remote_url() + { + string? opened = null; + try + { + ShellOpen.OpenImpl = path => + { + opened = path; + return true; + }; + + Assert.True(OpenTarget.TryResolve("kzu/sandbox@main:program.cs", out var target, out _)); + Assert.Equal("https://github.com/kzu/sandbox/blob/main/program.cs", target); + Assert.True(ShellOpen.TryOpen(target!)); + Assert.Equal(target, opened); + } + finally + { + ShellOpen.OpenImpl = null; + } + } + + [Fact] + public void ShellOpen_spy_false_means_open_failed() + { + try + { + ShellOpen.OpenImpl = _ => false; + Assert.False(ShellOpen.TryOpen("https://example.com")); + } + finally + { + ShellOpen.OpenImpl = null; + } + } + + [Fact] + public void ShellOpen_rejects_empty_without_calling_impl() + { + var called = false; + try + { + ShellOpen.OpenImpl = _ => + { + called = true; + return true; + }; + Assert.False(ShellOpen.TryOpen("")); + Assert.False(ShellOpen.TryOpen(" ")); + Assert.False(called); + } + finally + { + ShellOpen.OpenImpl = null; + } + } + + static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort */ } + } +} diff --git a/src/go/GoArgs.cs b/src/go/GoArgs.cs index 7f11828..a03c8dc 100644 --- a/src/go/GoArgs.cs +++ b/src/go/GoArgs.cs @@ -5,7 +5,7 @@ public static class GoArgs public static readonly string[] ReadyToRunPublishArgs = ["/p:PublishAot=false", "/p:PublishReadyToRun=true"]; static readonly string[] GoSwitchNames = ["debug", "r2r", "gdbg"]; - static readonly HashSet Subcommands = new(StringComparer.OrdinalIgnoreCase) { "dev", "clean", "remove", "check", "skill" }; + static readonly HashSet Subcommands = new(StringComparer.OrdinalIgnoreCase) { "dev", "clean", "remove", "check", "skill", "open" }; static string[]? forwardArgs; @@ -97,8 +97,8 @@ internal static string[] PrepareArgs(string[] args) index = 1; } - // clean / remove / check / skill (and nested skill remove) own their args; do not split for app forwarding. - if (subcommand is "clean" or "remove" or "check" or "skill") + // clean / remove / check / skill / open (and nested skill remove) own their args; do not split for app forwarding. + if (subcommand is "clean" or "remove" or "check" or "skill" or "open") { forwardArgs = []; return args; diff --git a/src/go/OpenTarget.cs b/src/go/OpenTarget.cs new file mode 100644 index 0000000..d1cf2f8 --- /dev/null +++ b/src/go/OpenTarget.cs @@ -0,0 +1,51 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Devlooped; + +/// +/// Pure resolution of an open input to a local file path or remote web URL. +/// +public static class OpenTarget +{ + /// + /// Maps to a shell-open target string. + /// Existing local files resolve to their full path; remote refs resolve via . + /// + public static bool TryResolve(string input, [NotNullWhen(true)] out string? target, [NotNullWhen(false)] out string? error) + { + target = null; + error = null; + + if (string.IsNullOrWhiteSpace(input)) + { + error = "Specify a .cs file or remote ref to open."; + return false; + } + + input = input.Trim(); + + // Prefer local existence (same order as clean/run artifact paths). + try + { + var full = Path.GetFullPath(input); + if (File.Exists(full)) + { + target = full; + return true; + } + } + catch + { + // Not a usable filesystem path — fall through to remote-ref parsing. + } + + if (RemoteRef.TryParse(input, out var remote)) + { + target = remote.ToWebUrl(); + return true; + } + + error = $"Not a local file or remote ref: {input}"; + return false; + } +} diff --git a/src/go/Program.cs b/src/go/Program.cs index ea2a555..6c8323b 100644 --- a/src/go/Program.cs +++ b/src/go/Program.cs @@ -11,6 +11,7 @@ app.Add("dev", DevAsync); app.Add("clean", CleanAsync); app.Add("remove", RemoveAsync); +app.Add("open", OpenAsync); app.Add(); app.Add(); app.Add(); @@ -117,6 +118,37 @@ static int CleanAsync([Argument] string? input = null, bool all = false, [Hidden return CleanArtifacts(input, all, missingInputMessage: "Specify a .cs file or remote ref to clean, or --all to clean all cached apps."); } +/// Opens a local file or the web URL for a remote ref in the default app/browser. When omitted, selects from previous runs (MRU). +/// Path to an existing local file or remote ref (owner/repo[@ref][:path]). When omitted, selects from previous runs (MRU). +/// Launch debugger before executing. +static int OpenAsync([Argument] string? input = null, [Hidden] bool gdbg = false) +{ + if (gdbg) + System.Diagnostics.Debugger.Launch(); + + if (string.IsNullOrWhiteSpace(input)) + { + var picked = TryPickOpenFromHistory(); + if (picked is null) + return 1; + input = picked; + } + + if (!OpenTarget.TryResolve(input, out var target, out var error)) + { + ConsoleApp.LogError(error!); + return 1; + } + + if (!ShellOpen.TryOpen(target)) + { + ConsoleApp.LogError($"Failed to open: {target}"); + return 1; + } + + return 0; +} + /// Cleans cached artifacts (same as clean) and removes the entry from MRU history. /// Path to an existing .cs file or remote ref (owner/repo[@ref][:path]). /// Clean all cached apps and clear the entire MRU history. @@ -292,6 +324,45 @@ static bool CanPromptInteractively() } } +/// +/// Interactive MRU picker for open (no app-args prompt). +/// Empty history / non-interactive: logs a clear error and returns null (no hang). +/// +static string? TryPickOpenFromHistory() +{ + var history = RunHistory.List(); + if (history.Count == 0) + { + ConsoleApp.LogError("No previous runs to open. Specify a file or remote ref."); + return null; + } + + if (!CanPromptInteractively()) + { + ConsoleApp.LogError("No interactive terminal; specify a file or remote ref to open."); + return null; + } + + try + { + var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select an entry to open:") + .PageSize(15) + .EnableSearch() + .MoreChoicesText("[grey](Move up/down to reveal more; type to search)[/]") + .UseConverter(static e => e.Display) + .AddChoices(history)); + + return selected.Input; + } + catch (Exception) + { + // Cancelled prompt or unexpected non-interactive environment. + return null; + } +} + static async Task ExecuteAppAsync(string publishDir, string historyInput, Func> execute) { Directory.Touch(publishDir); diff --git a/src/go/ShellOpen.cs b/src/go/ShellOpen.cs new file mode 100644 index 0000000..5a37d7e --- /dev/null +++ b/src/go/ShellOpen.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; + +namespace Devlooped; + +/// +/// Opens a local path or URL with the OS default application (shell execute). +/// +public static class ShellOpen +{ + /// + /// Optional override for tests: when set, delegates to this + /// and does not start a process. Reset to null after each test that assigns it. + /// + internal static Func? OpenImpl { get; set; } + + /// + /// Shell-opens . Returns true when the open was accepted + /// (or the test spy succeeded). Exceptions from the OS open path yield false. + /// + public static bool TryOpen(string pathOrUrl) + { + if (string.IsNullOrWhiteSpace(pathOrUrl)) + return false; + + if (OpenImpl is not null) + return OpenImpl(pathOrUrl); + + try + { + // UseShellExecute opens files/URLs via the OS association (browser, editor, etc.). + // On Windows a null Process is normal for document/URL opens; no exception means success. + Process.Start(new ProcessStartInfo + { + FileName = pathOrUrl, + UseShellExecute = true, + }); + return true; + } + catch + { + return false; + } + } +} diff --git a/src/go/help.md b/src/go/help.md index 2b6ed7d..2a42cec 100644 --- a/src/go/help.md +++ b/src/go/help.md @@ -14,6 +14,7 @@ Commands: check Verifies the native toolchain required for native AOT publishes. clean Deletes cached publish artifacts for a file-based .NET app, or for a remote ref. dev Runs a file-based .NET app from a .cs entrypoint using dotnet run for fast iteration. + open Opens a local file or the web URL for a remote ref in the default app/browser. When omitted, selects from previous runs (MRU). remove Cleans cached artifacts (same as clean) and removes the entry from MRU history. skill Installs the bundled go-sharp agent skill (SKILL.md) for agent tooling. skill remove Removes a previously installed go-sharp agent skill.