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