diff --git a/readme.md b/readme.md
index 08d2f67..1e2e626 100644
--- a/readme.md
+++ b/readme.md
@@ -54,6 +54,12 @@ With an empty history, or when stdin is redirected / non-interactive (CI, pipes)
Local paths that no longer exist are dropped from the picker; remote refs stay listed
even when their download bundle is gone (the next run re-downloads as usual).
+Gists are labeled like GitHub’s UI as `owner/filename` (for example `kzu/run.cs`)
+instead of the full host URL. When two or more history entries share the same
+owner and file name, the picker switches to a short ref form with the first
+seven characters of the gist id: `owner/shortsha:file` (for example
+`kzu/0ac826d:run.cs`).
+
```console
# Interactive: pick a previous run, then optional args
dnx go
@@ -111,9 +117,15 @@ dnx go -- kzu/sandbox@v1.2.3:src/hello.cs
# Full host (GitHub, Gist, GitLab, Azure DevOps)
dnx go -- github.com/kzu/sandbox@main:hello.cs
dnx go -- gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381
+# Multi-file gist: pick a specific entry file with :path (same as repos)
+dnx go -- gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381:run.cs
dnx go -- gitlab.com/kzu/runcs/-/blob/main/program.cs
```
+Without `:path`, a gist (or repo) defaults to `program.cs` when present, otherwise
+the first top-level `.cs` file. Use `:filename.cs` when a multi-file gist should
+run a different entry point.
+
The first argument is resolved by first checking if it is a local file (`File.Exists`).
If not, it falls back to parsing it as a remote ref (`owner/repo[@ref][:path]`).
diff --git a/src/Tests/RunHistoryTests.cs b/src/Tests/RunHistoryTests.cs
index cb4d502..60e9131 100644
--- a/src/Tests/RunHistoryTests.cs
+++ b/src/Tests/RunHistoryTests.cs
@@ -315,6 +315,236 @@ public void ParseArgsLine_roundtrips_via_ToRunInputs_as_shipped_path()
Assert.Equal(["arg1", "arg2"], appArgs);
}
+ [Fact]
+ public void List_formats_gists_as_owner_filename_from_path_suffix()
+ {
+ var path = NewSettingsPath();
+ try
+ {
+ SettingsStore.Save(new Settings
+ {
+ History =
+ [
+ new HistoryEntry
+ {
+ Input = "gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381:run.cs",
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 1,
+ },
+ new HistoryEntry
+ {
+ Input = "kzu/sandbox",
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 1,
+ },
+ ],
+ }, path);
+
+ var listed = RunHistory.List(path);
+ var gist = listed.Single(e => e.Input.Contains("0ac826dc", StringComparison.Ordinal));
+ Assert.Equal("kzu/run.cs", gist.Display);
+
+ var repo = listed.Single(e => e.Input == "kzu/sandbox");
+ Assert.Equal("kzu/sandbox", repo.Display);
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
+ [Fact]
+ public void List_formats_gists_as_owner_filename_from_cached_entry()
+ {
+ var path = NewSettingsPath();
+ try
+ {
+ SettingsStore.Save(new Settings
+ {
+ History =
+ [
+ new HistoryEntry
+ {
+ Input = "gist.github.com/kzu/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 1,
+ Entry = "hello.cs",
+ },
+ ],
+ }, path);
+
+ var listed = RunHistory.List(path);
+ Assert.Single(listed);
+ Assert.Equal("kzu/hello.cs", listed[0].Display);
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
+ [Fact]
+ public void List_disambiguates_same_owner_file_with_short_gist_id()
+ {
+ var path = NewSettingsPath();
+ try
+ {
+ SettingsStore.Save(new Settings
+ {
+ History =
+ [
+ new HistoryEntry
+ {
+ Input = "gist.github.com/kzu/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 2,
+ Entry = "run.cs",
+ },
+ new HistoryEntry
+ {
+ Input = "gist.github.com/kzu/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 1,
+ Entry = "run.cs",
+ },
+ new HistoryEntry
+ {
+ Input = "gist.github.com/other/cccccccccccccccccccccccccccccccc",
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 1,
+ Entry = "run.cs",
+ },
+ ],
+ }, path);
+
+ var listed = RunHistory.List(path);
+ var kzuA = listed.Single(e => e.Input.Contains("aaaaaaaa", StringComparison.Ordinal));
+ var kzuB = listed.Single(e => e.Input.Contains("bbbbbbbb", StringComparison.Ordinal));
+ var other = listed.Single(e => e.Input.Contains("cccccccc", StringComparison.Ordinal));
+
+ Assert.Equal("kzu/aaaaaaa:run.cs", kzuA.Display);
+ Assert.Equal("kzu/bbbbbbb:run.cs", kzuB.Display);
+ // Different owner — no disambiguation needed even though file name matches.
+ Assert.Equal("other/run.cs", other.Display);
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
+ [Fact]
+ public void List_gist_without_known_file_keeps_raw_input()
+ {
+ var path = NewSettingsPath();
+ try
+ {
+ var input = "gist.github.com/kzu/dddddddddddddddddddddddddddddddd";
+ SettingsStore.Save(new Settings
+ {
+ History =
+ [
+ new HistoryEntry
+ {
+ Input = input,
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 1,
+ },
+ ],
+ }, path);
+
+ var listed = RunHistory.List(path);
+ Assert.Single(listed);
+ Assert.Equal(input, listed[0].Display);
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
+ [Fact]
+ public void FormatBaseDisplay_and_ShortId_helpers()
+ {
+ Assert.Equal("aaaaaaa", RunHistory.ShortId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
+ Assert.Equal("short", RunHistory.ShortId("short"));
+
+ var withPath = RunHistory.FormatBaseDisplay(
+ "gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381:app.cs",
+ cachedEntry: null,
+ out var owner, out var gistId, out var file);
+ Assert.Equal("kzu/app.cs", withPath);
+ Assert.Equal("kzu", owner);
+ Assert.Equal("0ac826dc7de666546aaedd38e5965381", gistId);
+ Assert.Equal("app.cs", file);
+
+ var withCache = RunHistory.FormatBaseDisplay(
+ "gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381",
+ cachedEntry: "from-cache.cs",
+ out owner, out gistId, out file);
+ Assert.Equal("kzu/from-cache.cs", withCache);
+ Assert.Equal("kzu", owner);
+ Assert.Equal("0ac826dc7de666546aaedd38e5965381", gistId);
+ Assert.Equal("from-cache.cs", file);
+
+ var repo = RunHistory.FormatBaseDisplay("kzu/sandbox", null, out owner, out gistId, out file);
+ Assert.Equal("kzu/sandbox", repo);
+ Assert.Null(owner);
+ Assert.Null(gistId);
+ Assert.Null(file);
+ }
+
+ [Fact]
+ public void Record_captures_gist_entry_from_path_suffix()
+ {
+ var path = NewSettingsPath();
+ try
+ {
+ RunHistory.Record("gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381:run.cs", path);
+
+ var settings = SettingsStore.Load(path);
+ Assert.Single(settings.History!);
+ Assert.Equal("run.cs", settings.History![0].Entry);
+
+ var listed = RunHistory.List(path);
+ Assert.Equal("kzu/run.cs", listed[0].Display);
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
+ [Fact]
+ public void List_formats_https_gist_url_with_cached_entry()
+ {
+ var path = NewSettingsPath();
+ try
+ {
+ SettingsStore.Save(new Settings
+ {
+ History =
+ [
+ new HistoryEntry
+ {
+ Input = "https://gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381",
+ LastUsedUtc = DateTimeOffset.UtcNow,
+ UseCount = 1,
+ Entry = "run.cs",
+ },
+ ],
+ }, path);
+
+ var listed = RunHistory.List(path);
+ Assert.Single(listed);
+ Assert.Equal("kzu/run.cs", listed[0].Display);
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
static string NewSettingsPath()
=> Path.Combine(Path.GetTempPath(), "go-hist-test-" + Guid.NewGuid().ToString("N") + ".toml");
diff --git a/src/go/RunHistory.cs b/src/go/RunHistory.cs
index 09f7b39..2274db3 100644
--- a/src/go/RunHistory.cs
+++ b/src/go/RunHistory.cs
@@ -1,10 +1,12 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Devlooped;
/// A previously run go entry from root go.toml history.
public record RunHistoryEntry(string Input, DateTimeOffset LastUsedUtc, int UseCount)
{
- /// Display label for the picker (forward slashes for paths).
- public string Display => Input.Replace('\\', '/');
+ /// Display label for the picker (forward slashes for paths; gists as owner/file).
+ public string Display { get; init; } = Input.Replace('\\', '/');
}
///
@@ -26,7 +28,8 @@ public static IReadOnlyList List(string? settingsPath = null)
return [];
var pruned = false;
- var listed = new List(settings.History.Count);
+ // BaseDisplay is owner/file for gists when known; Gist* fields support collision disambiguation.
+ var pending = new List<(string Input, DateTimeOffset LastUsedUtc, int UseCount, string BaseDisplay, string? GistOwner, string? GistId, string? GistFile)>(settings.History.Count);
for (var i = settings.History.Count - 1; i >= 0; i--)
{
var h = settings.History[i];
@@ -37,15 +40,48 @@ public static IReadOnlyList List(string? settingsPath = null)
continue;
}
- listed.Add(new RunHistoryEntry(
+ var baseDisplay = FormatBaseDisplay(h.Input, h.Entry, out var gistOwner, out var gistId, out var gistFile);
+ pending.Add((
h.Input,
h.LastUsedUtc == default ? DateTimeOffset.MinValue : h.LastUsedUtc,
- h.UseCount <= 0 ? 1 : h.UseCount));
+ h.UseCount <= 0 ? 1 : h.UseCount,
+ baseDisplay,
+ gistOwner,
+ gistId,
+ gistFile));
}
if (pruned)
SettingsStore.Save(settings, settingsPath);
+ // Disambiguate gist labels that share the same owner/file with a short gist id.
+ var gistLabelCounts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var item in pending)
+ {
+ if (item.GistId is null)
+ continue;
+ gistLabelCounts[item.BaseDisplay] = gistLabelCounts.GetValueOrDefault(item.BaseDisplay) + 1;
+ }
+
+ var listed = new List(pending.Count);
+ foreach (var item in pending)
+ {
+ var display = item.BaseDisplay;
+ // Colliding owner/file gists → owner/shortsha:file (ref-like, familiar).
+ if (item.GistId is { } id
+ && item.GistFile is { } file
+ && gistLabelCounts.TryGetValue(item.BaseDisplay, out var count)
+ && count > 1)
+ {
+ display = $"{item.GistOwner}/{ShortId(id)}:{file}";
+ }
+
+ listed.Add(new RunHistoryEntry(item.Input, item.LastUsedUtc, item.UseCount)
+ {
+ Display = display,
+ });
+ }
+
listed.Sort(static (a, b) =>
{
var byCount = b.UseCount.CompareTo(a.UseCount);
@@ -105,6 +141,7 @@ public static void Record(string input, string? settingsPath = null)
settings.History ??= [];
var existing = settings.History.FirstOrDefault(h => HistoryKeyEquals(h.Input, key));
+ var capturedEntry = TryCaptureGistEntry(key);
var now = DateTimeOffset.UtcNow;
if (existing is not null)
@@ -112,6 +149,9 @@ public static void Record(string input, string? settingsPath = null)
existing.LastUsedUtc = now;
existing.UseCount = existing.UseCount <= 0 ? 2 : existing.UseCount + 1;
// Keep stored form stable (prefer existing casing/path form).
+ // Refresh entry filename when we can resolve it (e.g. after first download).
+ if (capturedEntry is not null)
+ existing.Entry = capturedEntry;
}
else
{
@@ -120,6 +160,7 @@ public static void Record(string input, string? settingsPath = null)
Input = key,
LastUsedUtc = now,
UseCount = 1,
+ Entry = capturedEntry,
});
}
@@ -254,4 +295,108 @@ public static string NormalizeInput(string input)
return input;
}
+
+ ///
+ /// Builds the base picker label. Gists become owner/filename when the file is known
+ /// (explicit :path, cached history entry, or live download bundle); otherwise the
+ /// normalized input. When a gist label is produced, owner/id/file outs are set for
+ /// collision disambiguation as owner/shortsha:file.
+ ///
+ internal static string FormatBaseDisplay(
+ string input,
+ string? cachedEntry,
+ out string? gistOwner,
+ out string? gistId,
+ out string? gistFile)
+ {
+ gistOwner = null;
+ gistId = null;
+ gistFile = null;
+ if (TryGetGistParts(input, out var owner, out var id, out var remote))
+ {
+ var file = ResolveGistFileName(remote, cachedEntry);
+ if (file is not null)
+ {
+ gistOwner = owner;
+ gistId = id;
+ gistFile = file;
+ return $"{owner}/{file}";
+ }
+ }
+
+ return input.Replace('\\', '/');
+ }
+
+ /// First 7 characters of a gist id (or the full id when shorter).
+ internal static string ShortId(string gistId)
+ => gistId.Length <= 7 ? gistId : gistId[..7];
+
+ ///
+ /// Resolves the entry file name for a gist: explicit path, history cache, remote settings,
+ /// or first top-level .cs in the download bundle.
+ ///
+ internal static string? ResolveGistFileName(RemoteRef remote, string? cachedEntry)
+ {
+ if (!string.IsNullOrEmpty(remote.Path))
+ return FileNameOnly(remote.Path);
+
+ if (!string.IsNullOrEmpty(cachedEntry))
+ return FileNameOnly(cachedEntry);
+
+ try
+ {
+ if (!Directory.Exists(remote.TempPath))
+ return null;
+
+ var settings = RemoteSettingsStore.Load(remote.TempPath);
+ if (!string.IsNullOrEmpty(settings.Entry))
+ return FileNameOnly(settings.Entry);
+
+ var prog = Path.Combine(remote.TempPath, "program.cs");
+ if (File.Exists(prog))
+ return "program.cs";
+
+ var first = Directory.EnumerateFiles(remote.TempPath, "*.cs", SearchOption.TopDirectoryOnly).FirstOrDefault();
+ if (first is not null)
+ return Path.GetFileName(first);
+ }
+ catch
+ {
+ // Bundle missing or unreadable — caller falls back to raw input.
+ }
+
+ return null;
+ }
+
+ static string? TryCaptureGistEntry(string input)
+ {
+ if (!TryGetGistParts(input, out _, out _, out var remote))
+ return null;
+ return ResolveGistFileName(remote, cachedEntry: null);
+ }
+
+ static bool TryGetGistParts(
+ string input,
+ [NotNullWhen(true)] out string? owner,
+ [NotNullWhen(true)] out string? gistId,
+ [NotNullWhen(true)] out RemoteRef? remote)
+ {
+ owner = null;
+ gistId = null;
+ remote = null;
+
+ if (!RemoteRef.TryParse(input, out var parsed))
+ return false;
+
+ if (!string.Equals(parsed.Host, "gist.github.com", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ owner = parsed.Owner;
+ gistId = parsed.Repo;
+ remote = parsed;
+ return true;
+ }
+
+ static string FileNameOnly(string path)
+ => Path.GetFileName(path.Replace('\\', '/'));
}
diff --git a/src/go/Settings.cs b/src/go/Settings.cs
index e34c959..c916611 100644
--- a/src/go/Settings.cs
+++ b/src/go/Settings.cs
@@ -22,6 +22,12 @@ public class HistoryEntry
public DateTimeOffset LastUsedUtc { get; set; }
public int UseCount { get; set; }
+
+ ///
+ /// Optional entry-point file name for display (e.g. gist entry file).
+ /// Captured when recording so MRU labels stay useful after the download cache is cleaned.
+ ///
+ public string? Entry { get; set; }
}
public static class SettingsStore