diff --git a/InterlinedList/Models/ListRefreshResult.cs b/InterlinedList/Models/ListRefreshResult.cs
new file mode 100644
index 0000000..743da55
--- /dev/null
+++ b/InterlinedList/Models/ListRefreshResult.cs
@@ -0,0 +1,98 @@
+using System.Text.Json;
+
+namespace InterlinedList.Models;
+
+///
+/// The result of POST /api/lists/{id}/refresh — "Refresh from GitHub".
+///
+///
+/// Envelope deliberately loose. The success body is not live
+/// verified: the test account owns no GitHub-backed list, and creating one would
+/// wire a real repository to it, so only the rejection path was exercised.
+/// Probed live 2026-09-16 against a throwaway local list (created, probed,
+/// deleted, confirmed gone):
+///
+///
+/// POST /api/lists/{local-list-id}/refresh
+/// → 400 { "error": "Refresh is only available for GitHub-backed lists",
+/// "code": "bad_request" }
+///
+///
+/// No mutation: re-reading the list afterwards returned an identical body with
+/// an unchanged updatedAt. The OpenAPI spec documents 201 for
+/// success with the body "not individually modelled yet", so rather than guess a
+/// typed envelope this keeps and picks up the handful of field
+/// names the spec's neighbouring routes use, if any of them happen to be there.
+/// Callers must re-read the list and its rows afterwards regardless — that is the
+/// repo's read-after-write rule and here it is also the only way to know what
+/// actually changed.
+///
+///
+public sealed class ListRefreshResult
+{
+ /// The whole response body, so nothing is lost to a wrong guess.
+ public JsonElement Raw { get; init; }
+
+ /// message, if the server sends one.
+ public string? Message { get; init; }
+
+ ///
+ /// refreshStatus/status, if present. POST /api/lists
+ /// documents a refreshStatus string "present only for GitHub-backed
+ /// lists", so the refresh route plausibly echoes the same field.
+ ///
+ public string? Status { get; init; }
+
+ /// Best-effort issue/row count, if the server reports one.
+ public int? Imported { get; init; }
+
+ /// What to show the user after a refresh; never empty.
+ public string DisplayText =>
+ (Message, Status, Imported) switch
+ {
+ ({ Length: > 0 } m, _, { } n) => $"{m} ({n} rows)",
+ ({ Length: > 0 } m, _, _) => m,
+ (_, { Length: > 0 } s, { } n) => $"Refreshed — {s} ({n} rows)",
+ (_, { Length: > 0 } s, _) => $"Refreshed — {s}",
+ (_, _, { } n) => $"Refreshed — {n} rows",
+ _ => "Refreshed from GitHub."
+ };
+
+ /// Projects whatever the server sent, tolerating an empty body.
+ public static ListRefreshResult FromJson(JsonElement body)
+ {
+ string? Str(params string[] names)
+ {
+ if (body.ValueKind != JsonValueKind.Object) return null;
+ foreach (var name in names)
+ {
+ if (body.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String)
+ return p.GetString();
+ }
+ return null;
+ }
+
+ int? Int(params string[] names)
+ {
+ if (body.ValueKind != JsonValueKind.Object) return null;
+ foreach (var name in names)
+ {
+ if (body.TryGetProperty(name, out var p) &&
+ p.ValueKind == JsonValueKind.Number &&
+ p.TryGetInt32(out var value))
+ {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ return new ListRefreshResult
+ {
+ Raw = body,
+ Message = Str("message"),
+ Status = Str("refreshStatus", "status"),
+ Imported = Int("imported", "issuesImported", "rowCount", "count")
+ };
+ }
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs b/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs
index 167b8c2..f912aaf 100644
--- a/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs
+++ b/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs
@@ -5,8 +5,8 @@
namespace InterlinedList.Services;
///
-/// Where lists meet GitHub: creating a GitHub-backed list and reading a list's
-/// GitHub backing.
+/// Where lists meet GitHub: creating a GitHub-backed list, reading a list's
+/// GitHub backing, and "Refresh from GitHub".
///
///
/// Kept in its own partial rather than folded into
@@ -21,15 +21,19 @@ namespace InterlinedList.Services;
///
/// Live-probe record, 2026-09-16 (test account, bearer sync-token). No
/// GitHub-backed list was created — doing so would link a real GitHub repository
-/// on shared test infrastructure — so the create success path is built
-/// and unexercised. What was verified, with a throwaway list titled
-/// "ZZ claude-probe …" that was deleted and confirmed gone afterwards:
+/// on shared test infrastructure — so the create and refresh success
+/// paths are built and unexercised. What was verified, with a throwaway list
+/// titled "ZZ claude-probe …" that was deleted and confirmed gone afterwards:
///
///
/// POST /api/lists { title, source: "github" } and no
/// githubRepo → 400 "githubRepo is required for GitHub-backed
/// lists (format: owner/repo)", and nothing was created. The server does
/// understand source: "github".
+/// POST /api/lists/{id}/refresh on a local list
+/// → 400 "Refresh is only available for GitHub-backed lists". Re-reading
+/// the list gave an identical body with an unchanged updatedAt, so the
+/// rejection is inert.
/// GET /api/lists really does carry
/// source/githubRepo/githubRepoPrivate on every list
/// ("local"/null/null for the account's one
@@ -99,6 +103,39 @@ public async Task CreateGitHubBackedListAsync(
return GitHubBackedListCreated.FromJson(json);
}
+ ///
+ /// "Refresh from GitHub" — POST /api/lists/{id}/refresh, re-pulling the
+ /// repository's issues into the list's rows and re-reading the repository's
+ /// public/private visibility.
+ ///
+ /// A local list answers 400 "Refresh is only available for
+ /// GitHub-backed lists" (verified live, and inert — nothing changed), so gate
+ /// the action on and treat that
+ /// 400 as a state error rather than something to retry. The success body is
+ /// unverified; re-read the list and its rows afterwards.
+ ///
+ ///
+ public async Task RefreshListFromGitHubAsync(string listId, CancellationToken ct = default)
+ {
+ using var resp = await SendAsync(HttpMethod.Post, $"api/lists/{listId}/refresh", new { }, ct);
+ await EnsureSuccessAsync(resp, ct);
+
+ // A 2xx with an empty body is possible and is not an error here — the
+ // caller re-reads the list for truth either way.
+ var text = await resp.Content.ReadAsStringAsync(ct);
+ if (string.IsNullOrWhiteSpace(text))
+ return new ListRefreshResult();
+
+ try
+ {
+ return ListRefreshResult.FromJson(JsonSerializer.Deserialize(text, JsonOptions));
+ }
+ catch (JsonException)
+ {
+ return new ListRefreshResult { Message = "Refreshed from GitHub." };
+ }
+ }
+
///
/// One list's GitHub backing (source, githubRepo,
/// githubRepoPrivate) from GET /api/lists/{id}, whose body is
diff --git a/InterlinedList/ViewModels/GitHubListHeaderViewModel.cs b/InterlinedList/ViewModels/GitHubListHeaderViewModel.cs
new file mode 100644
index 0000000..dc5546c
--- /dev/null
+++ b/InterlinedList/ViewModels/GitHubListHeaderViewModel.cs
@@ -0,0 +1,227 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The strip that sits under a GitHub-backed list's name: the
+/// owner/repo issues link, the Private repo tag, and
+/// Refresh from GitHub.
+///
+///
+/// Collapses to nothing for a local list, so it can be hosted unconditionally.
+///
+///
+///
+/// The private/public flag has three states, not two. /help/lists:
+/// "Visibility is re-read from GitHub each time the list syncs… Lists created
+/// before this tag existed show no tag until their first sync." So
+/// githubRepoPrivate is true (private), false (public) or
+/// null (never recorded) and the third case must render no tag —
+/// never "public". fires only on an explicit
+/// true; is the null case, and it says
+/// so rather than staying silent about why there is no tag.
+///
+///
+///
+/// And the tag is about the repository, not the list. A list can be public
+/// while its repository is private, or the reverse — they are set separately. The
+/// tag exists to warn that somebody invited to the list may have no
+/// access to the repository, so GitHub will show them a sign-in page or
+/// a 404 when they follow the link. is that
+/// sentence, and it is the whole reason the tag is worth drawing.
+///
+///
+public partial class GitHubListHeaderViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsGitHubBacked))]
+ [NotifyPropertyChangedFor(nameof(RepoLinkLabel))]
+ [NotifyPropertyChangedFor(nameof(ShowPrivateRepoTag))]
+ [NotifyPropertyChangedFor(nameof(VisibilityUnrecorded))]
+ [NotifyPropertyChangedFor(nameof(RepoVisibilityLine))]
+ private GitHubListBacking? backing;
+
+ [ObservableProperty]
+ private bool isBusy;
+
+ [ObservableProperty]
+ private string? statusMessage;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ /// Raised after a successful refresh so the host can reload the list's rows.
+ public event EventHandler? Refreshed;
+
+ public GitHubListHeaderViewModel(SessionService session)
+ {
+ _session = session;
+ }
+
+ public bool IsGitHubBacked => Backing?.IsGitHubBacked == true;
+
+ /// "owner/repo issues" — the label the web app shows under the list name.
+ public string RepoLinkLabel => Backing?.RepoLinkLabel ?? string.Empty;
+
+ /// Only ever true for an explicit githubRepoPrivate: true.
+ public bool ShowPrivateRepoTag => Backing?.ShowPrivateRepoTag == true;
+
+ /// GitHub-backed but visibility never recorded — no tag, and an explanation.
+ public bool VisibilityUnrecorded => Backing?.VisibilityUnrecorded == true;
+
+ ///
+ /// The copy that keeps the two visibilities apart. This is the point of the
+ /// tag, so it is spelled out rather than left to a one-word chip.
+ ///
+ public string PrivateTagExplanation =>
+ "This repository is private on GitHub. That's about the repository, not about who can see " +
+ "this InterlinedList list — the two are set separately. Anyone you invite to the list who " +
+ "doesn't have repository access will get a GitHub sign-in page or \"not found\" when they " +
+ "follow the link.";
+
+ ///
+ /// The line under the link. Says nothing about public/private when the value
+ /// has never been recorded — the one thing the null case must not do is imply
+ /// the repository is public.
+ ///
+ public string RepoVisibilityLine => Backing switch
+ {
+ { ShowPrivateRepoTag: true } => "Private repo",
+ { VisibilityUnrecorded: true } =>
+ "Repository visibility hasn't been recorded yet — it's read from GitHub on the next sync. " +
+ "Refresh from GitHub to find out.",
+ { RepoPrivate: false } => "Public repository on GitHub.",
+ _ => string.Empty
+ };
+
+ ///
+ /// Loads one list's backing. A null or empty id clears the strip, which is
+ /// what "no list selected" looks like.
+ ///
+ public async Task LoadAsync(string? listId, CancellationToken ct = default)
+ {
+ StatusMessage = null;
+ ErrorMessage = null;
+
+ if (listId is not { Length: > 0 })
+ {
+ Backing = null;
+ return;
+ }
+
+ // Answer instantly from the shared index when it already knows, so
+ // selecting a list doesn't wait on a round trip to draw the strip.
+ var cached = GitHubListIndex.Get(listId);
+ if (cached is not null)
+ Backing = cached;
+
+ try
+ {
+ var fresh = await _session.Api.GetListGitHubBackingAsync(listId, ct);
+ Backing = fresh;
+ GitHubListIndex.Put(fresh);
+ }
+ catch (InterlinedApiException ex)
+ {
+ // Keep whatever the index gave us rather than blanking the strip; a
+ // stale repo link is more use than none.
+ if (Backing is null)
+ ErrorMessage = ex.Message;
+ else
+ AppLog.Warn($"Re-reading list {listId} for its GitHub backing failed: {ex.Message}");
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException)
+ {
+ if (Backing is null)
+ ErrorMessage = "Couldn't reach InterlinedList to read this list.";
+ }
+ }
+
+ ///
+ /// Opens the repository's issues page in the OS browser — the link the web app
+ /// puts under the list name.
+ ///
+ [RelayCommand]
+ private void OpenRepoIssues()
+ {
+ if (Backing?.IssuesUrl is not { Length: > 0 } url) return;
+
+ try
+ {
+ // UseShellExecute is required for .NET Core/5+ to hand a URL to the OS
+ // default browser.
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = url,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex)
+ {
+ AppLog.Error($"Opening {url} failed.", ex);
+ ErrorMessage = $"Couldn't open your browser. The page is {url}";
+ }
+ }
+
+ private bool CanRefresh() => !IsBusy && IsGitHubBacked && Backing?.ListId is { Length: > 0 };
+
+ ///
+ /// "Refresh from GitHub" — POST /api/lists/{id}/refresh, then a re-read.
+ ///
+ /// The re-read is not belt-and-braces: the refresh response envelope has never
+ /// been observed (no GitHub-backed list exists on the test account to observe
+ /// it with), and re-reading is also the only way to pick up a
+ /// githubRepoPrivate that just changed — which is exactly what a sync
+ /// is documented to do.
+ ///
+ ///
+ [RelayCommand(CanExecute = nameof(CanRefresh))]
+ private async Task RefreshAsync()
+ {
+ if (Backing?.ListId is not { Length: > 0 } listId) return;
+
+ IsBusy = true;
+ try
+ {
+ var result = await _session.Api.RefreshListFromGitHubAsync(listId);
+
+ var before = Backing;
+ var fresh = await _session.Api.GetListGitHubBackingAsync(listId);
+ Backing = fresh;
+ GitHubListIndex.Put(fresh);
+
+ StatusMessage = before?.RepoPrivate != fresh.RepoPrivate
+ ? $"{result.DisplayText} Repository visibility is now {(fresh.RepoPrivate == true ? "private" : "public")}."
+ : result.DisplayText;
+ ErrorMessage = null;
+
+ Refreshed?.Invoke(this, EventArgs.Empty);
+ }
+ catch (InterlinedApiException ex)
+ {
+ // The one failure worth naming: refresh on a list that isn't
+ // GitHub-backed. CanRefresh should prevent it, so if it happens the
+ // list's source changed underneath us.
+ ErrorMessage = ex.StatusCode == 400 && ex.Message.Contains("GitHub-backed", StringComparison.OrdinalIgnoreCase)
+ ? "This list isn't GitHub-backed, so there's nothing to refresh from."
+ : ex.Message;
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException)
+ {
+ ErrorMessage = "Couldn't reach InterlinedList to refresh this list.";
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ partial void OnIsBusyChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged();
+
+ partial void OnBackingChanged(GitHubListBacking? value) => RefreshCommand.NotifyCanExecuteChanged();
+}
diff --git a/InterlinedList/Views/GitHubListHeader.xaml b/InterlinedList/Views/GitHubListHeader.xaml
new file mode 100644
index 0000000..cbddcd8
--- /dev/null
+++ b/InterlinedList/Views/GitHubListHeader.xaml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/GitHubListHeader.xaml.cs b/InterlinedList/Views/GitHubListHeader.xaml.cs
new file mode 100644
index 0000000..8d18257
--- /dev/null
+++ b/InterlinedList/Views/GitHubListHeader.xaml.cs
@@ -0,0 +1,88 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// The owner/repo issues link, Private repo tag and Refresh from
+/// GitHub action for a GitHub-backed list — self-contained, with its own view
+/// model, so hosting it costs one element.
+///
+///
+/// Hosting contract:
+///
+///
+/// — bind to the selected list's id. It
+/// reloads on change and collapses itself for a local list or a null id, so it
+/// can be dropped in unconditionally.
+/// /
+/// — run after a successful refresh.
+/// A refresh rewrites the list's rows from the repository's issues, so the host
+/// needs to reload them; this control deliberately doesn't know how.
+///
+///
+public partial class GitHubListHeader : UserControl
+{
+ private readonly GitHubListHeaderViewModel _vm;
+
+ public GitHubListHeader()
+ {
+ InitializeComponent();
+
+ _vm = new GitHubListHeaderViewModel(Services.AppServices.Session);
+ DataContext = _vm;
+ _vm.Refreshed += OnRefreshed;
+ }
+
+ /// The list to describe. Changing it reloads the strip.
+ public static readonly DependencyProperty ListIdProperty =
+ DependencyProperty.Register(nameof(ListId), typeof(string), typeof(GitHubListHeader),
+ new PropertyMetadata(null, OnListIdChanged));
+
+ public string? ListId
+ {
+ get => (string?)GetValue(ListIdProperty);
+ set => SetValue(ListIdProperty, value);
+ }
+
+ /// Executed after a successful refresh, so the host can reload rows.
+ public static readonly DependencyProperty AfterRefreshCommandProperty =
+ DependencyProperty.Register(nameof(AfterRefreshCommand), typeof(ICommand), typeof(GitHubListHeader),
+ new PropertyMetadata(null));
+
+ public ICommand? AfterRefreshCommand
+ {
+ get => (ICommand?)GetValue(AfterRefreshCommandProperty);
+ set => SetValue(AfterRefreshCommandProperty, value);
+ }
+
+ /// Parameter for — typically the selected list.
+ public static readonly DependencyProperty AfterRefreshCommandParameterProperty =
+ DependencyProperty.Register(nameof(AfterRefreshCommandParameter), typeof(object), typeof(GitHubListHeader),
+ new PropertyMetadata(null));
+
+ public object? AfterRefreshCommandParameter
+ {
+ get => GetValue(AfterRefreshCommandParameterProperty);
+ set => SetValue(AfterRefreshCommandParameterProperty, value);
+ }
+
+ private static async void OnListIdChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var header = (GitHubListHeader)d;
+
+ // async void on a DP callback is the one place WPF leaves no alternative;
+ // LoadAsync catches everything it can throw and reports it on the view
+ // model, so nothing escapes to the dispatcher's unhandled handler.
+ await header._vm.LoadAsync(e.NewValue as string);
+ }
+
+ private void OnRefreshed(object? sender, EventArgs e)
+ {
+ var parameter = AfterRefreshCommandParameter;
+ if (AfterRefreshCommand?.CanExecute(parameter) == true)
+ AfterRefreshCommand.Execute(parameter);
+ }
+}
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 37f3d16..2201d43 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -691,6 +691,7 @@
HorizontalScrollBarVisibility="Disabled"
Padding="20,12">
+