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
7 changes: 2 additions & 5 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,8 @@ 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`).
Gists are labeled as `owner/shortsha:file` (first seven characters of the gist id),
for example `kzu/0ac826d:run.cs`, instead of the full host URL.

```console
# Interactive: pick a previous run, then optional args
Expand Down
55 changes: 22 additions & 33 deletions src/Tests/RunHistoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ public void ParseArgsLine_roundtrips_via_ToRunInputs_as_shipped_path()
}

[Fact]
public void List_formats_gists_as_owner_filename_from_path_suffix()
public void List_formats_gists_as_owner_shortsha_file_from_path_suffix()
{
var path = NewSettingsPath();
try
Expand All @@ -342,7 +342,7 @@ public void List_formats_gists_as_owner_filename_from_path_suffix()

var listed = RunHistory.List(path);
var gist = listed.Single(e => e.Input.Contains("0ac826dc", StringComparison.Ordinal));
Assert.Equal("kzu/run.cs", gist.Display);
Assert.Equal("kzu/0ac826d:run.cs", gist.Display);

var repo = listed.Single(e => e.Input == "kzu/sandbox");
Assert.Equal("kzu/sandbox", repo.Display);
Expand All @@ -354,7 +354,7 @@ public void List_formats_gists_as_owner_filename_from_path_suffix()
}

[Fact]
public void List_formats_gists_as_owner_filename_from_cached_entry()
public void List_formats_gists_as_owner_shortsha_file_from_cached_entry()
{
var path = NewSettingsPath();
try
Expand All @@ -375,7 +375,7 @@ public void List_formats_gists_as_owner_filename_from_cached_entry()

var listed = RunHistory.List(path);
Assert.Single(listed);
Assert.Equal("kzu/hello.cs", listed[0].Display);
Assert.Equal("kzu/aaaaaaa:hello.cs", listed[0].Display);
}
finally
{
Expand All @@ -384,7 +384,7 @@ public void List_formats_gists_as_owner_filename_from_cached_entry()
}

[Fact]
public void List_disambiguates_same_owner_file_with_short_gist_id()
public void List_always_includes_short_gist_id_even_for_unique_owner_file()
{
var path = NewSettingsPath();
try
Expand Down Expand Up @@ -424,8 +424,7 @@ public void List_disambiguates_same_owner_file_with_short_gist_id()

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);
Assert.Equal("other/ccccccc:run.cs", other.Display);
}
finally
{
Expand Down Expand Up @@ -464,34 +463,24 @@ public void List_gist_without_known_file_keeps_raw_input()
}

[Fact]
public void FormatBaseDisplay_and_ShortId_helpers()
public void FormatDisplay_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);
Assert.Equal(
"kzu/0ac826d:app.cs",
RunHistory.FormatDisplay(
"gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381:app.cs",
cachedEntry: null));

Assert.Equal(
"kzu/0ac826d:from-cache.cs",
RunHistory.FormatDisplay(
"gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381",
cachedEntry: "from-cache.cs"));

Assert.Equal("kzu/sandbox", RunHistory.FormatDisplay("kzu/sandbox", null));
}

[Fact]
Expand All @@ -507,7 +496,7 @@ public void Record_captures_gist_entry_from_path_suffix()
Assert.Equal("run.cs", settings.History![0].Entry);

var listed = RunHistory.List(path);
Assert.Equal("kzu/run.cs", listed[0].Display);
Assert.Equal("kzu/0ac826d:run.cs", listed[0].Display);
}
finally
{
Expand Down Expand Up @@ -537,7 +526,7 @@ public void List_formats_https_gist_url_with_cached_entry()

var listed = RunHistory.List(path);
Assert.Single(listed);
Assert.Equal("kzu/run.cs", listed[0].Display);
Assert.Equal("kzu/0ac826d:run.cs", listed[0].Display);
}
finally
{
Expand Down
67 changes: 11 additions & 56 deletions src/go/RunHistory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Devlooped;
/// <summary>A previously run go entry from root <c>go.toml</c> history.</summary>
public record RunHistoryEntry(string Input, DateTimeOffset LastUsedUtc, int UseCount)
{
/// <summary>Display label for the picker (forward slashes for paths; gists as owner/file).</summary>
/// <summary>Display label for the picker (forward slashes for paths; gists as owner/shortsha:file).</summary>
public string Display { get; init; } = Input.Replace('\\', '/');
}

Expand All @@ -28,8 +28,7 @@ public static IReadOnlyList<RunHistoryEntry> List(string? settingsPath = null)
return [];

var pruned = false;
// 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);
var listed = new List<RunHistoryEntry>(settings.History.Count);
for (var i = settings.History.Count - 1; i >= 0; i--)
{
var h = settings.History[i];
Expand All @@ -40,48 +39,18 @@ public static IReadOnlyList<RunHistoryEntry> List(string? settingsPath = null)
continue;
}

var baseDisplay = FormatBaseDisplay(h.Input, h.Entry, out var gistOwner, out var gistId, out var gistFile);
pending.Add((
listed.Add(new RunHistoryEntry(
h.Input,
h.LastUsedUtc == default ? DateTimeOffset.MinValue : h.LastUsedUtc,
h.UseCount <= 0 ? 1 : h.UseCount,
baseDisplay,
gistOwner,
gistId,
gistFile));
h.UseCount <= 0 ? 1 : h.UseCount)
{
Display = FormatDisplay(h.Input, h.Entry),
});
}

if (pruned)
SettingsStore.Save(settings, settingsPath);

// Disambiguate gist labels that share the same owner/file with a short gist id.
var gistLabelCounts = new Dictionary<string, int>(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<RunHistoryEntry>(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);
Expand Down Expand Up @@ -297,31 +266,17 @@ public static string NormalizeInput(string input)
}

/// <summary>
/// Builds the base picker label. Gists become <c>owner/filename</c> when the file is known
/// Builds the picker label. Gists use <c>owner/shortsha:file</c> when the file is known
/// (explicit <c>:path</c>, 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 <c>owner/shortsha:file</c>.
/// normalized input.
/// </summary>
internal static string FormatBaseDisplay(
string input,
string? cachedEntry,
out string? gistOwner,
out string? gistId,
out string? gistFile)
internal static string FormatDisplay(string input, string? cachedEntry)
{
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 $"{owner}/{ShortId(id)}:{file}";
}

return input.Replace('\\', '/');
Expand Down
Loading