Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
28 changes: 28 additions & 0 deletions src/Tests/GoArgsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
210 changes: 210 additions & 0 deletions src/Tests/OpenTargetTests.cs
Original file line number Diff line number Diff line change
@@ -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 */ }
}
}
6 changes: 3 additions & 3 deletions src/go/GoArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Subcommands = new(StringComparer.OrdinalIgnoreCase) { "dev", "clean", "remove", "check", "skill" };
static readonly HashSet<string> Subcommands = new(StringComparer.OrdinalIgnoreCase) { "dev", "clean", "remove", "check", "skill", "open" };

static string[]? forwardArgs;

Expand Down Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions src/go/OpenTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Diagnostics.CodeAnalysis;

namespace Devlooped;

/// <summary>
/// Pure resolution of an open input to a local file path or remote web URL.
/// </summary>
public static class OpenTarget
{
/// <summary>
/// Maps <paramref name="input"/> to a shell-open target string.
/// Existing local files resolve to their full path; remote refs resolve via <see cref="RemoteRefExtensions.ToWebUrl"/>.
/// </summary>
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;
}
}
Loading
Loading