diff --git a/InterlinedList/Models/GitHubBackedListCreated.cs b/InterlinedList/Models/GitHubBackedListCreated.cs
new file mode 100644
index 0000000..d9f2ae9
--- /dev/null
+++ b/InterlinedList/Models/GitHubBackedListCreated.cs
@@ -0,0 +1,84 @@
+using System.Text.Json;
+
+namespace InterlinedList.Models;
+
+///
+/// What POST /api/lists answers when source: "github" —
+/// { message, data, refreshStatus }, where refreshStatus is
+/// documented as "present only for GitHub-backed lists".
+///
+///
+/// The GitHub-backed create was deliberately never executed. Creating one
+/// wires a real GitHub repository to a real list on shared test
+/// infrastructure, so this app's create path is built and left unexercised. What
+/// was verified live on 2026-09-16, without creating anything:
+///
+///
+/// POST /api/lists { title, source: "github" } with no
+/// githubRepo → 400{ "error": "githubRepo is required for
+/// GitHub-backed lists (format: owner/repo)", "code": "bad_request" } — and
+/// nothing was created (a following GET /api/lists still showed exactly
+/// the one pre-existing list). So the server does recognise
+/// source: "github", and it wants owner/repo.
+/// The plain local create returns
+/// { "message": "List created successfully", "data": { …, "source": "local",
+/// "githubRepo": null, "githubRepoPrivate": null, "properties": [] } } with
+/// norefreshStatus key, matching the spec's "present only for
+/// GitHub-backed lists".
+///
+///
+/// Hence is nullable and is kept:
+/// per this repo's read-after-write rule the caller re-reads the list rather than
+/// trusting this envelope.
+///
+///
+public sealed class GitHubBackedListCreated
+{
+ /// The full response body.
+ public JsonElement Raw { get; init; }
+
+ /// Server confirmation, e.g. "List created successfully".
+ public string? Message { get; init; }
+
+ ///
+ /// The new list's GitHub backing, read straight out of the data object.
+ /// Null if the server answered without one.
+ ///
+ public GitHubListBacking? Backing { get; init; }
+
+ ///
+ /// refreshStatus — the initial issue sync's outcome. Unobserved (see
+ /// the class remarks), so treat a null as "no status reported", not as
+ /// failure.
+ ///
+ public string? RefreshStatus { get; init; }
+
+ /// The created list's id, when the server returned one.
+ public string? ListId => Backing?.ListId is { Length: > 0 } id ? id : null;
+
+ public static GitHubBackedListCreated FromJson(JsonElement body)
+ {
+ string? Str(JsonElement obj, string name) =>
+ obj.ValueKind == JsonValueKind.Object &&
+ obj.TryGetProperty(name, out var p) &&
+ p.ValueKind == JsonValueKind.String
+ ? p.GetString()
+ : null;
+
+ GitHubListBacking? backing = null;
+ if (body.ValueKind == JsonValueKind.Object &&
+ body.TryGetProperty("data", out var data) &&
+ data.ValueKind == JsonValueKind.Object)
+ {
+ backing = GitHubListBacking.FromListJson(data);
+ }
+
+ return new GitHubBackedListCreated
+ {
+ Raw = body,
+ Message = Str(body, "message"),
+ RefreshStatus = Str(body, "refreshStatus"),
+ Backing = backing
+ };
+ }
+}
diff --git a/InterlinedList/Models/GitHubListBacking.cs b/InterlinedList/Models/GitHubListBacking.cs
new file mode 100644
index 0000000..a5519e3
--- /dev/null
+++ b/InterlinedList/Models/GitHubListBacking.cs
@@ -0,0 +1,145 @@
+using System.Text.Json;
+
+namespace InterlinedList.Models;
+
+///
+/// The GitHub-backing facts about one list — source, githubRepo
+/// and githubRepoPrivate — projected out of the raw list JSON.
+///
+///
+/// Why this exists instead of properties on .
+/// The API does return those three fields on every list (verified live
+/// 2026-09-16: GET /api/lists gives
+/// "source": "local", "githubRepo": null, "githubRepoPrivate": null for
+/// the test account's one list), but is being extended
+/// to the full wire type in a separate open PR. Reading the three fields out of
+/// the raw JSON here keeps this feature independent of that change and merges
+/// cleanly either way; once carries them,
+/// InterlinedApiClient.GetListGitHubBackingAsync is the only thing that
+/// needs to change.
+///
+///
+///
+/// is deliberately bool?. /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." A non-nullable
+/// bool would default to false and silently assert *public repo*
+/// for a list whose visibility has never been recorded. Since the tag exists to
+/// warn that GitHub will show a collaborator a sign-in page or a 404, asserting
+/// the wrong direction is a real (if small) trust problem — so
+/// fires only on an explicit true, and
+/// names the third state rather than folding
+/// it into "public".
+///
+///
+///
+/// The private/public flag is about the repository on GitHub, not about
+/// who can see the InterlinedList list — is
+/// that, and the two are set separately.
+///
+///
+public sealed class GitHubListBacking
+{
+ /// The InterlinedList list id these facts belong to.
+ public required string ListId { get; init; }
+
+ /// The list's title, carried along so a badge or header can label itself.
+ public string? Title { get; init; }
+
+ ///
+ /// source — "local" or "github" (both observed live;
+ /// "github" is what POST /api/lists requires to build a
+ /// GitHub-backed list).
+ ///
+ public string Source { get; init; } = LocalSource;
+
+ /// githubRepo — owner/repo, or null on a local list.
+ public string? Repo { get; init; }
+
+ ///
+ /// githubRepoPrivate. true = private repository on GitHub,
+ /// false = public, null = never recorded (no sync yet) — see the
+ /// class remarks for why the null case must not render as "public".
+ ///
+ public bool? RepoPrivate { get; init; }
+
+ public const string LocalSource = "local";
+ public const string GitHubSource = "github";
+
+ ///
+ /// Whether this list syncs its rows from GitHub issues. Keyed off
+ /// source, with a populated githubRepo as a fallback so a list
+ /// whose source string ever changes spelling still reads correctly.
+ ///
+ public bool IsGitHubBacked =>
+ string.Equals(Source, GitHubSource, StringComparison.OrdinalIgnoreCase) ||
+ Repo is { Length: > 0 };
+
+ ///
+ /// Show the "Private repo" tag — only on an explicit true.
+ ///
+ public bool ShowPrivateRepoTag => IsGitHubBacked && RepoPrivate == true;
+
+ ///
+ /// GitHub-backed, but visibility has never been recorded: show no tag at all
+ /// and say so, rather than implying the repository is public.
+ ///
+ public bool VisibilityUnrecorded => IsGitHubBacked && RepoPrivate is null;
+
+ /// Owner login half of owner/repo.
+ public string Owner =>
+ Repo is { Length: > 0 } r && r.IndexOf('/') is var i && i > 0 ? r[..i] : string.Empty;
+
+ /// Repository-name half of owner/repo — the default list title.
+ public string RepoName =>
+ Repo is { Length: > 0 } r && r.IndexOf('/') is var i && i >= 0 && i < r.Length - 1
+ ? r[(i + 1)..]
+ : Repo ?? string.Empty;
+
+ /// The link label the web app shows under the list name: "owner/repo issues".
+ public string RepoLinkLabel => Repo is { Length: > 0 } r ? $"{r} issues" : string.Empty;
+
+ /// The repository's issues page, opened in the OS browser.
+ public string? IssuesUrl => Repo is { Length: > 0 } r ? $"https://github.com/{r}/issues" : null;
+
+ ///
+ /// Reads the three backing fields out of one list object — the element under
+ /// data for GET /api/lists/{id}, or one entry of the
+ /// lists array for GET /api/lists. Missing fields degrade to a
+ /// local list rather than throwing, because this has to survive both the
+ /// current and the extended wire shape.
+ ///
+ public static GitHubListBacking FromListJson(JsonElement list, string? fallbackId = null)
+ {
+ string? Str(string name) =>
+ list.ValueKind == JsonValueKind.Object &&
+ list.TryGetProperty(name, out var p) &&
+ p.ValueKind == JsonValueKind.String
+ ? p.GetString()
+ : null;
+
+ bool? Bool(string name)
+ {
+ if (list.ValueKind != JsonValueKind.Object || !list.TryGetProperty(name, out var p))
+ return null;
+ return p.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ _ => null
+ };
+ }
+
+ return new GitHubListBacking
+ {
+ ListId = Str("id") ?? fallbackId ?? string.Empty,
+ Title = Str("title"),
+ Source = Str("source") ?? LocalSource,
+ Repo = Str("githubRepo"),
+ RepoPrivate = Bool("githubRepoPrivate")
+ };
+ }
+
+ /// A list we know nothing GitHub-ish about — renders no badge, no tag.
+ public static GitHubListBacking Local(string listId) => new() { ListId = listId };
+}
diff --git a/InterlinedList/Services/GitHubListIndex.cs b/InterlinedList/Services/GitHubListIndex.cs
new file mode 100644
index 0000000..0dc9387
--- /dev/null
+++ b/InterlinedList/Services/GitHubListIndex.cs
@@ -0,0 +1,110 @@
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// One shared, lazily-loaded map of list id → ,
+/// so that marking GitHub-backed lists in a lists browser costs one
+/// request rather than one per row.
+///
+///
+/// A per-row GET /api/lists/{id} would be the obvious way to give each
+/// badge its own data, and it is exactly the wrong one: a user with fifty lists
+/// would fire fifty requests to draw fifty tiny labels. GET /api/lists?all=1
+/// already returns source/githubRepo/githubRepoPrivate for
+/// every list (verified live 2026-09-16), so one call answers every badge.
+///
+///
+///
+/// Deliberately not registered in : it is a cache
+/// in front of an existing singleton, has no other collaborators, and adding it
+/// there would touch a file three other open branches also touch. It resolves
+/// itself at call time.
+///
+///
+///
+/// UI-thread affine by design. Every caller is a WPF control, loads are awaited
+/// on the dispatcher, and is raised on whichever thread
+/// completed the load — which, with the default synchronization context, is the
+/// UI thread. Concurrent loads are collapsed onto one in-flight task rather than
+/// locked, so two badges appearing at once make one request.
+///
+///
+public static class GitHubListIndex
+{
+ private static readonly Dictionary Cache = new(StringComparer.Ordinal);
+ private static Task? _inFlight;
+ private static bool _loaded;
+
+ /// Raised whenever the cache changes, so live badges can re-read it.
+ public static event EventHandler? Changed;
+
+ ///
+ /// The backing for one list, or null if the index hasn't been loaded or
+ /// doesn't know that list. Null means "don't know yet" — never "local" — so
+ /// callers must not draw a conclusion from it.
+ ///
+ public static GitHubListBacking? Get(string? listId) =>
+ listId is { Length: > 0 } id && Cache.TryGetValue(id, out var backing) ? backing : null;
+
+ ///
+ /// Loads the index once. Repeat calls are free; concurrent calls share the
+ /// single in-flight request. Failures are swallowed — a missing badge is not
+ /// worth an error banner — but leave false so a later
+ /// call retries.
+ ///
+ public static Task EnsureLoadedAsync()
+ {
+ if (_loaded) return Task.CompletedTask;
+ return _inFlight ??= LoadAsync();
+ }
+
+ /// Forces a reload, e.g. after creating a list or refreshing one from GitHub.
+ public static Task RefreshAsync()
+ {
+ _loaded = false;
+ _inFlight = null;
+ return EnsureLoadedAsync();
+ }
+
+ ///
+ /// Writes one freshly-read list straight into the cache — used after a
+ /// "Refresh from GitHub", where the list's githubRepoPrivate may have
+ /// just changed and re-reading every list would be wasteful.
+ ///
+ public static void Put(GitHubListBacking backing)
+ {
+ if (backing.ListId is not { Length: > 0 }) return;
+ Cache[backing.ListId] = backing;
+ Changed?.Invoke(null, EventArgs.Empty);
+ }
+
+ private static async Task LoadAsync()
+ {
+ try
+ {
+ var backings = await AppServices.Session.Api.GetListGitHubBackingsAsync();
+
+ Cache.Clear();
+ foreach (var backing in backings)
+ {
+ if (backing.ListId is { Length: > 0 })
+ Cache[backing.ListId] = backing;
+ }
+
+ _loaded = true;
+ Changed?.Invoke(null, EventArgs.Empty);
+ }
+ catch (Exception ex) when (ex is InterlinedApiException or System.Net.Http.HttpRequestException
+ or System.Text.Json.JsonException)
+ {
+ // A badge that can't be drawn is not an error the user needs; the next
+ // EnsureLoadedAsync will try again. Logged so it isn't invisible.
+ AppLog.Warn($"GitHubListIndex load failed: {ex.Message}");
+ }
+ finally
+ {
+ _inFlight = null;
+ }
+ }
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs b/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs
new file mode 100644
index 0000000..167b8c2
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.GitHubLists.cs
@@ -0,0 +1,148 @@
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// Where lists meet GitHub: creating a GitHub-backed list and reading a list's
+/// GitHub backing.
+///
+///
+/// Kept in its own partial rather than folded into
+/// InterlinedApiClient.Lists.cs because that file's
+/// CreateListAsync(title, description) only sends two of the eleven fields
+/// POST /api/lists accepts, and the GitHub path needs four more —
+/// source, githubRepo, parentId, isPublic (the full
+/// documented set is title, description, messageId, metadata, schema,
+/// parentId, isPublic, source, githubRepo, githubSource, initialRows).
+///
+///
+///
+/// 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:
+///
+///
+/// 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".
+/// GET /api/lists really does carry
+/// source/githubRepo/githubRepoPrivate on every list
+/// ("local"/null/null for the account's one
+/// list).
+///
+///
+public sealed partial class InterlinedApiClient
+{
+ ///
+ /// Creates a GitHub-backed list: POST /api/lists with
+ /// source: "github" and githubRepo: "owner/repo". Its rows are
+ /// the repository's issues from then on (add a row → create an issue, edit →
+ /// update, delete → close).
+ ///
+ /// Never executed live — see the class remarks. Re-read the list with
+ /// afterwards rather than trusting
+ /// the returned envelope.
+ ///
+ ///
+ ///
+ /// owner/repo. Required, and required in that exact shape: omitting it
+ /// answers 400 "githubRepo is required for GitHub-backed lists (format:
+ /// owner/repo)" (verified live).
+ ///
+ ///
+ /// The list title. /help/lists says this "defaults to the repo name" in the
+ /// web UI; the default is applied in the view model, not here, so a caller
+ /// that wants something else isn't fighting the client.
+ ///
+ ///
+ /// Whether the InterlinedList list is public. Unrelated to whether the
+ /// GitHub repository is private — the two are set separately (see
+ /// ).
+ ///
+ public async Task CreateGitHubBackedListAsync(
+ string repoFullName,
+ string title,
+ string? description = null,
+ string? parentId = null,
+ bool isPublic = false,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(repoFullName) || !repoFullName.Contains('/'))
+ {
+ // Matches the server's own rule, caught here so the user gets the
+ // reason instead of a round trip and a raw 400.
+ throw new ArgumentException(
+ "A GitHub-backed list needs a repository as owner/repo.", nameof(repoFullName));
+ }
+
+ if (string.IsNullOrWhiteSpace(title))
+ throw new ArgumentException("A list needs a title.", nameof(title));
+
+ // Built key-by-key so unset fields are omitted rather than sent as
+ // explicit nulls — the local create path sends only what it has too.
+ var payload = new Dictionary
+ {
+ ["title"] = title.Trim(),
+ ["source"] = GitHubListBacking.GitHubSource,
+ ["githubRepo"] = repoFullName.Trim(),
+ ["isPublic"] = isPublic
+ };
+ if (!string.IsNullOrWhiteSpace(description)) payload["description"] = description.Trim();
+ if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId;
+
+ var json = await SendElementAsync(HttpMethod.Post, "api/lists", payload, ct);
+ return GitHubBackedListCreated.FromJson(json);
+ }
+
+ ///
+ /// One list's GitHub backing (source, githubRepo,
+ /// githubRepoPrivate) from GET /api/lists/{id}, whose body is
+ /// under a data envelope.
+ ///
+ /// Reads the raw JSON rather than on purpose — see
+ /// for why.
+ ///
+ ///
+ public async Task GetListGitHubBackingAsync(string listId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/lists/{listId}", ct);
+ return json.ValueKind == JsonValueKind.Object &&
+ json.TryGetProperty("data", out var data) &&
+ data.ValueKind == JsonValueKind.Object
+ ? GitHubListBacking.FromListJson(data, listId)
+ : GitHubListBacking.Local(listId);
+ }
+
+ ///
+ /// Every one of the caller's lists reduced to its GitHub backing, in one
+ /// request — so a lists browser can badge the GitHub-backed ones without a
+ /// fetch per row.
+ ///
+ /// Uses the documented ?all=1 (paging off) and falls back to the
+ /// ordinary offset page if that ever stops being honoured, so the badge
+ /// degrades to "first page only" rather than disappearing.
+ ///
+ ///
+ public async Task> GetListGitHubBackingsAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/lists?all=1", ct);
+
+ if (json.ValueKind != JsonValueKind.Object ||
+ !json.TryGetProperty("lists", out var lists) ||
+ lists.ValueKind != JsonValueKind.Array)
+ {
+ return [];
+ }
+
+ var backings = new List();
+ foreach (var list in lists.EnumerateArray())
+ backings.Add(GitHubListBacking.FromListJson(list));
+
+ return backings;
+ }
+}
diff --git a/InterlinedList/ViewModels/GitHubLinkViewModel.cs b/InterlinedList/ViewModels/GitHubLinkViewModel.cs
new file mode 100644
index 0000000..af26292
--- /dev/null
+++ b/InterlinedList/ViewModels/GitHubLinkViewModel.cs
@@ -0,0 +1,233 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The GitHub link state and the two browser handoffs that act on it —
+/// "Reconnect for GitHub Issues" and "Manage organization access".
+///
+///
+/// Shared on purpose: both the GitHub-backed-list flow (which must not let a user
+/// reach "Create" only to fail) and Connected Accounts (where the reconnect
+/// action lives per /help/lists) need exactly this, and duplicating it would let
+/// the two drift apart on the one point that is easy to get wrong — see
+/// .
+///
+///
+///
+/// Link state comes from GET /api/user/identities, never from an empty
+/// collection. On the test account GET /api/github/repos and
+/// GET /api/github/orgs both answer 200 [] while GitHub is
+/// linked (as InterlinedListMessenger, verified live 2026-09-16) — that
+/// account simply owns no repositories and joins no organizations. Reading "not
+/// connected" out of [] would show a Connect prompt to a connected user.
+/// InterlinedApiClient.GetGitHubLinkStateAsync is the one honest signal.
+///
+///
+public partial class GitHubLinkViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsLinked))]
+ [NotifyPropertyChangedFor(nameof(StatusLine))]
+ [NotifyPropertyChangedFor(nameof(CanManageOrgAccess))]
+ [NotifyPropertyChangedFor(nameof(DefaultRepo))]
+ [NotifyPropertyChangedFor(nameof(ReconnectLabel))]
+ private GitHubLinkState? linkState;
+
+ [ObservableProperty]
+ private bool isLoading;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ /// Set after a handoff so the UI can say "come back and press Check again".
+ [ObservableProperty]
+ private string? handoffNotice;
+
+ public GitHubLinkViewModel(SessionService session)
+ {
+ _session = session;
+ }
+
+ public bool IsLinked => LinkState?.IsLinked == true;
+
+ /// Null until the first load — distinct from "loaded and not linked".
+ public bool? LinkKnown => LinkState is null ? null : LinkState.IsLinked;
+
+ public string? DefaultRepo => LinkState?.DefaultRepo;
+
+ public bool CanManageOrgAccess => LinkState?.ManageOrgAccessUrl is { Length: > 0 };
+
+ public string StatusLine => LinkState switch
+ {
+ null when IsLoading => "Checking your GitHub connection…",
+ null => "GitHub connection not checked yet.",
+ { IsLinked: true } s => $"Connected as @{s.Username ?? "unknown"}",
+ { ProviderConfigured: false } => "GitHub sign-in isn't configured on the server.",
+ _ => "GitHub isn't connected to this account."
+ };
+
+ ///
+ /// The one-line label for the handoff button. "Reconnect" once linked, because
+ /// the point is to add the Issues scope to a link that already exists.
+ ///
+ public string ReconnectLabel => IsLinked ? "Reconnect for GitHub Issues" : "Connect GitHub";
+
+ ///
+ /// The honest answer to "does this link have the Issues scope?" — we cannot
+ /// tell, and saying so beats guessing.
+ ///
+ /// GET /api/user/identities reports the provider, username and
+ /// timestamps, with no scope field. GET /api/auth/github/status reports
+ /// { configured, clientId, manageOrgAccessUrl } — whether the
+ /// server has a GitHub OAuth app, not what this user
+ /// granted. So there is no scope introspection anywhere in the API, and the
+ /// first evidence of a sign-in-only link is a call that comes back empty or
+ /// 403/404. Hence the reconnect affordance stays visible even when linked.
+ ///
+ ///
+ public string ScopeNote =>
+ "The API doesn't report which scopes a link has: /api/user/identities lists the provider " +
+ "and username, and /api/auth/github/status only says whether the server has GitHub OAuth " +
+ "configured. So a sign-in-only link can't be told apart from an Issues-capable one until a " +
+ "call comes back empty or refused — reconnect if repositories don't appear.";
+
+ ///
+ /// What ?link=true buys, spelled out because it is the whole mechanism
+ /// behind the reconnect action.
+ ///
+ public string ReconnectExplanation =>
+ "Reconnecting opens GitHub in your browser asking for repo and read:org on top of sign-in — " +
+ "that's the Issues access a GitHub-backed list needs. Approve it there, then come back and " +
+ "press Check again.";
+
+ [RelayCommand]
+ public async Task LoadAsync(CancellationToken ct = default)
+ {
+ IsLoading = true;
+ try
+ {
+ LinkState = await _session.Api.GetGitHubLinkStateAsync(ct);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ // GetGitHubLinkStateAsync only propagates a failure of
+ // /api/user/identities itself — its two enrichment reads are swallowed
+ // inside the client — so this really is "we don't know the link state".
+ ErrorMessage = ex.Message;
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException)
+ {
+ // CLAUDE.md records a real "installs but won't run" crash from catching
+ // only InterlinedApiException on a path that can equally throw network
+ // or JSON errors. Cancellation is left to propagate.
+ ErrorMessage = "Couldn't reach InterlinedList to check your GitHub connection.";
+ }
+ finally
+ {
+ IsLoading = false;
+ OnPropertyChanged(nameof(StatusLine));
+ }
+ }
+
+ ///
+ /// Opens {base}api/auth/github/authorize?link=true in the OS browser.
+ ///
+ /// ?link=true is load-bearing (re-verified live 2026-09-16 by
+ /// reading the 307 Location both ways): without it the redirect asks
+ /// for scope=user:email read:user — sign-in only; with it,
+ /// scope=user:email read:user repo read:org — the Issues scope. A
+ /// ?scope= of our own is ignored and an arbitrary redirect_uri
+ /// is rejected, so the URL is handed over exactly as the server builds it.
+ ///
+ ///
+ /// No WebView2 in this app: OAuth goes to the OS browser and the user comes
+ /// back and refreshes, the same pattern the cross-post providers use.
+ ///
+ ///
+ [RelayCommand]
+ private void Reconnect()
+ {
+ try
+ {
+ InterlinedApiClient.OpenGitHubReconnect();
+ HandoffNotice = "GitHub is open in your browser. Approve the repo and read:org scopes, " +
+ "then come back and press Check again.";
+ }
+ catch (Exception ex)
+ {
+ // Process.Start can fail with no default browser registered — a broken
+ // handoff should say so, not disappear.
+ AppLog.Error("Opening the GitHub authorize URL failed.", ex);
+ ErrorMessage = $"Couldn't open your browser. Go to {InterlinedApiClient.GitHubReconnectUrl} manually.";
+ }
+ }
+
+ ///
+ /// Opens GitHub's "Authorized OAuth Apps → InterlinedList" page, the
+ /// separate remedy for repositories missing because an organization
+ /// hasn't approved the app.
+ ///
+ /// This is not a second Reconnect button. Re-running OAuth with unchanged
+ /// scopes returns silently without changing organization access, so a
+ /// reconnect genuinely cannot fix it — which is why
+ /// /api/auth/github/status publishes manageOrgAccessUrl
+ /// (https://github.com/settings/connections/applications/{clientId},
+ /// verified live 2026-09-16) and why this is its own action.
+ ///
+ ///
+ [RelayCommand]
+ private void ManageOrgAccess()
+ {
+ if (LinkState?.ManageOrgAccessUrl 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
+ });
+ HandoffNotice = "GitHub's authorized-apps page is open. Grant InterlinedList access to the " +
+ "organization, then come back and press Check again.";
+ }
+ catch (Exception ex)
+ {
+ AppLog.Error("Opening the GitHub org-access URL failed.", ex);
+ ErrorMessage = $"Couldn't open your browser. Go to {url} manually.";
+ }
+ }
+
+ ///
+ /// Turns a failed /api/github/* call into the remedy that actually
+ /// applies, rather than a generic error toast.
+ ///
+ public string ExplainFailure(InterlinedApiException ex) =>
+ InterlinedApiClient.ClassifyGitHubFailure(ex) switch
+ {
+ GitHubFailureReason.NotLinked =>
+ "GitHub isn't connected to this account. Connect it, then try again.",
+ GitHubFailureReason.NotAuthenticated =>
+ "Your InterlinedList session was rejected. Sign in again.",
+ GitHubFailureReason.RepositoryInaccessible =>
+ "GitHub wouldn't show that. Either the name isn't an organization (a personal " +
+ "account's repositories come from \"My repositories\", not an org filter), or the " +
+ "organization hasn't approved InterlinedList — which only \"Manage organization " +
+ "access\" can fix, not reconnecting.",
+ GitHubFailureReason.RepositoryRequired =>
+ "That call needs a repository. Pick one, or set a default repo in Settings.",
+ GitHubFailureReason.InvalidRequest =>
+ $"GitHub rejected the request: {ex.Message}",
+ GitHubFailureReason.RateLimited =>
+ "GitHub is rate-limiting this account. Wait a minute and try again.",
+ _ => ex.Message
+ };
+}
diff --git a/InterlinedList/ViewModels/GitHubRepoPickerViewModel.cs b/InterlinedList/ViewModels/GitHubRepoPickerViewModel.cs
new file mode 100644
index 0000000..6c088dc
--- /dev/null
+++ b/InterlinedList/ViewModels/GitHubRepoPickerViewModel.cs
@@ -0,0 +1,274 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The repository picker behind the GitHub-backed-list flow: "My repositories"
+/// or an organization's, filtered down by typing, one selection out.
+///
+///
+/// Paging: there is none to do, and the epic was wrong in both directions.
+/// #73 asked for a "paginated" picker because the docs call the endpoint "fully
+/// paginated across all affiliations". The server does that walking itself and
+/// returns the complete set in one bare array — 559 repositories for
+/// ?org=github, 278 for ?org=dotnet, well past GitHub's own
+/// 100-per-page ceiling. There is no envelope, no Link header, and
+/// ?page=/?per_page= change nothing (probed live 2026-09-15 and
+/// re-confirmed 2026-09-16). So this loads once and filters locally.
+///
+///
+///
+/// But an ?org= is required to see anything but your own. Bare
+/// GET /api/github/repos returns only the linked account's own
+/// repositories — [] on the test account, which owns none. That empty
+/// array is not evidence of a missing link (see
+/// ), so the empty state says so instead of
+/// offering a Connect button.
+///
+///
+///
+/// The 2000 cap is real. Six organizations have now each returned exactly
+/// 2000 rows — microsoft, google, apache (2026-09-15) plus
+/// Azure, mozilla, IBM (2026-09-16) — while
+/// aws (552), elastic (958), hashicorp (943),
+/// adobe (1123) and intel (1360) returned natural counts. Exactly
+/// 2000 is therefore a truncation, not a total, and
+/// says "first 2000" rather than implying the list is
+/// complete.
+///
+///
+///
+/// ?org= must name an organization: a user login answers
+/// 404 not_found (GitHub's /orgs/{org}/repos has no such org), which
+/// turns into that sentence
+/// rather than "not found".
+///
+///
+public partial class GitHubRepoPickerViewModel : ObservableObject
+{
+ ///
+ /// The server's apparent per-request cap. Six large organizations have each
+ /// returned exactly this many — see the class remarks.
+ ///
+ public const int ServerRepoCap = 2000;
+
+ ///
+ /// How many filtered rows to hand the UI at once. The ListBox hosting
+ /// them virtualizes, so this is about keeping the "showing N of M" line
+ /// honest rather than about performance.
+ ///
+ private const int MaxVisible = 400;
+
+ private readonly SessionService _session;
+ private readonly GitHubLinkViewModel _link;
+ private readonly List _all = [];
+
+ /// Organizations the linked account belongs to, for the drop-down.
+ public ObservableCollection Orgs { get; } = new();
+
+ /// The filtered slice the picker actually shows.
+ public ObservableCollection VisibleRepos { get; } = new();
+
+ [ObservableProperty]
+ private bool isLoading;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ /// An organization login typed by hand — needed because GET /api/github/orgs
+ /// is empty for an account that belongs to no organizations, yet any public
+ /// org's repositories are still readable (?org=github returned 559 from
+ /// exactly such an account).
+ [ObservableProperty]
+ private string orgLogin = "";
+
+ [ObservableProperty]
+ private string filterText = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasSelection))]
+ private GitHubRepo? selectedRepo;
+
+ /// Which scope the currently-loaded set came from, for the summary line.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ResultSummary))]
+ private string? loadedScope;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ResultSummary))]
+ [NotifyPropertyChangedFor(nameof(HasLoaded))]
+ private bool hasLoadedOnce;
+
+ public GitHubRepoPickerViewModel(SessionService session, GitHubLinkViewModel link)
+ {
+ _session = session;
+ _link = link;
+ }
+
+ public bool HasSelection => SelectedRepo is not null;
+
+ public bool HasLoaded => HasLoadedOnce;
+
+ /// True when the loaded set was truncated by the server's cap.
+ public bool IsCapped => _all.Count >= ServerRepoCap;
+
+ ///
+ /// The line under the picker. Never claims completeness at exactly the cap,
+ /// and never reads an empty result as a broken connection.
+ ///
+ public string ResultSummary
+ {
+ get
+ {
+ if (!HasLoadedOnce) return "Load your repositories, or an organization's, to pick one.";
+
+ var scope = LoadedScope is { Length: > 0 } s ? s : "your repositories";
+
+ if (_all.Count == 0)
+ {
+ return scope == MyReposScope
+ ? "That GitHub account owns no repositories. It is still connected — an empty list " +
+ "isn't a broken link. Try an organization, or reconnect if you expected repo access."
+ : $"No repositories came back for {scope}.";
+ }
+
+ var shown = VisibleRepos.Count;
+ var capNote = IsCapped
+ ? $" The server returns at most {ServerRepoCap:N0} per organization, so this is the " +
+ "first 2,000, not all of them — narrow it with the filter."
+ : string.Empty;
+
+ return shown == _all.Count
+ ? $"{_all.Count:N0} from {scope}.{capNote}"
+ : $"Showing {shown:N0} of {_all.Count:N0} from {scope}.{capNote}";
+ }
+ }
+
+ private const string MyReposScope = "your repositories";
+
+ ///
+ /// The organizations the account belongs to. Empty on the test account, and
+ /// the item shape has therefore never been observed — hence
+ /// 's all-nullable fields and the typed-login box
+ /// beside this drop-down.
+ ///
+ [RelayCommand]
+ private async Task LoadOrgsAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var orgs = await _session.Api.GetGitHubOrgsAsync(ct);
+
+ Orgs.Clear();
+ foreach (var org in orgs.Where(o => o.FilterValue.Length > 0))
+ Orgs.Add(org);
+ }
+ catch (InterlinedApiException ex)
+ {
+ // Non-fatal: the typed-login box still works without the drop-down.
+ AppLog.Warn($"GET /api/github/orgs failed: {ex.Message}");
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException)
+ {
+ AppLog.Warn($"GET /api/github/orgs failed: {ex.Message}");
+ }
+ }
+
+ /// The linked account's own repositories — bare GET /api/github/repos.
+ [RelayCommand]
+ private Task LoadMyReposAsync(CancellationToken ct = default) => LoadAsync(null, ct);
+
+ private bool CanLoadOrgRepos() => !string.IsNullOrWhiteSpace(OrgLogin);
+
+ /// One organization's repositories — GET /api/github/repos?org=.
+ [RelayCommand(CanExecute = nameof(CanLoadOrgRepos))]
+ private Task LoadOrgReposAsync(CancellationToken ct = default) => LoadAsync(OrgLogin.Trim(), ct);
+
+ /// Loads an org's repositories straight from the drop-down.
+ [RelayCommand]
+ private Task PickOrgAsync(GitHubOrg org)
+ {
+ OrgLogin = org.FilterValue;
+ return LoadAsync(org.FilterValue);
+ }
+
+ private async Task LoadAsync(string? org, CancellationToken ct = default)
+ {
+ IsLoading = true;
+ try
+ {
+ var repos = await _session.Api.GetGitHubReposAsync(org, ct);
+
+ _all.Clear();
+ // owner/name order so a filtered 2000-row org reads predictably.
+ _all.AddRange(repos.OrderBy(r => r.FullName, StringComparer.OrdinalIgnoreCase));
+
+ LoadedScope = org is { Length: > 0 } ? $"the {org} organization" : MyReposScope;
+ HasLoadedOnce = true;
+ SelectedRepo = null;
+ ErrorMessage = null;
+ ApplyFilter();
+ PreselectDefaultRepo();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = _link.ExplainFailure(ex);
+ _all.Clear();
+ HasLoadedOnce = true;
+ LoadedScope = org is { Length: > 0 } ? $"the {org} organization" : MyReposScope;
+ ApplyFilter();
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException)
+ {
+ ErrorMessage = "Couldn't reach InterlinedList to list repositories.";
+ }
+ finally
+ {
+ IsLoading = false;
+ OnPropertyChanged(nameof(ResultSummary));
+ OnPropertyChanged(nameof(IsCapped));
+ }
+ }
+
+ ///
+ /// Pre-selects the user's githubDefaultRepo when it happens to be in
+ /// the loaded set — the same value GET /api/github/issues falls back to
+ /// when called without a repo. Null on the test account, so this is
+ /// built from the documented field rather than from an observed value.
+ ///
+ private void PreselectDefaultRepo()
+ {
+ if (_link.DefaultRepo is not { Length: > 0 } preferred) return;
+
+ var match = _all.FirstOrDefault(r =>
+ string.Equals(r.FullName, preferred, StringComparison.OrdinalIgnoreCase));
+
+ if (match is not null)
+ SelectedRepo = match;
+ }
+
+ private void ApplyFilter()
+ {
+ var needle = FilterText.Trim();
+
+ IEnumerable matches = needle.Length == 0
+ ? _all
+ : _all.Where(r =>
+ r.FullName.Contains(needle, StringComparison.OrdinalIgnoreCase) ||
+ r.Name.Contains(needle, StringComparison.OrdinalIgnoreCase));
+
+ VisibleRepos.Clear();
+ foreach (var repo in matches.Take(MaxVisible))
+ VisibleRepos.Add(repo);
+
+ OnPropertyChanged(nameof(ResultSummary));
+ }
+
+ partial void OnFilterTextChanged(string value) => ApplyFilter();
+
+ partial void OnOrgLoginChanged(string value) => LoadOrgReposCommand.NotifyCanExecuteChanged();
+}
diff --git a/InterlinedList/ViewModels/NewListTabsViewModel.cs b/InterlinedList/ViewModels/NewListTabsViewModel.cs
new file mode 100644
index 0000000..829348d
--- /dev/null
+++ b/InterlinedList/ViewModels/NewListTabsViewModel.cs
@@ -0,0 +1,251 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The two-tab new-list flow from /help/lists — Local List and
+/// GitHub-backed List — of which this owns the tab choice and the whole
+/// GitHub tab. The Local tab is the host's existing title/description form, which
+/// this only shows and hides via ; rebuilding it
+/// would have meant a much larger edit to a heavily contended view for no gain.
+///
+///
+/// The GitHub tab follows the documented flow exactly: pick a repository, a title
+/// that defaults to the repository name, an optional parent list, and a
+/// Public list toggle.
+///
+///
+///
+/// Never executed live. Creating a GitHub-backed list would wire a real
+/// GitHub repository to a real list on shared test infrastructure, so
+/// is built and left unexercised. The
+/// contract it targets was verified without creating anything:
+/// POST /api/lists { title, source: "github" } with no githubRepo
+/// answers 400 "githubRepo is required for GitHub-backed lists (format:
+/// owner/repo)" and creates nothing (2026-09-16).
+///
+///
+///
+/// On "detect the missing Issues scope up front". That is #73's acceptance
+/// criterion and it cannot be met literally — the API exposes no scope
+/// introspection at all (see ). What
+/// it does instead, which is the useful half: gate the tab on real link state
+/// from /api/user/identities, keep the reconnect handoff visible the whole
+/// time rather than only after a failure, and when a repository load comes back
+/// empty or refused, name the specific remedy — reconnect for the Issues scope,
+/// or grant organization access, which a reconnect cannot fix.
+///
+///
+public partial class NewListTabsViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ /// The repo name the title was auto-filled from, so a user's own typing is never overwritten.
+ private string? _autoFilledFrom;
+
+ public GitHubLinkViewModel Link { get; }
+
+ public GitHubRepoPickerViewModel Picker { get; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsLocalTabSelected))]
+ private bool isGitHubTabSelected;
+
+ [ObservableProperty]
+ private string gitHubTitle = "";
+
+ [ObservableProperty]
+ private string gitHubDescription = "";
+
+ [ObservableProperty]
+ private ListSummary? selectedParentList;
+
+ [ObservableProperty]
+ private bool isPublic;
+
+ [ObservableProperty]
+ private bool isCreating;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ [ObservableProperty]
+ private string? successMessage;
+
+ /// Raised after a successful create so the host can reload its lists.
+ public event EventHandler? ListCreated;
+
+ public NewListTabsViewModel(SessionService session)
+ {
+ _session = session;
+ Link = new GitHubLinkViewModel(session);
+ Picker = new GitHubRepoPickerViewModel(session, Link);
+ Picker.PropertyChanged += OnPickerPropertyChanged;
+ Link.PropertyChanged += OnLinkPropertyChanged;
+ }
+
+ /// The host binds its own form's visibility to this.
+ public bool IsLocalTabSelected => !IsGitHubTabSelected;
+
+ public string SelectedRepoLabel => Picker.SelectedRepo?.FullName ?? "No repository picked yet.";
+
+ ///
+ /// The private/public state of the repository on GitHub, shown while
+ /// picking so the "Private repo" tag on the finished list isn't a surprise.
+ /// This is not the list's own visibility — is that, and
+ /// the two are set separately.
+ ///
+ public bool SelectedRepoIsPrivate => Picker.SelectedRepo?.IsPrivate == true;
+
+ public bool CanCreate =>
+ !IsCreating &&
+ Picker.SelectedRepo is not null &&
+ !string.IsNullOrWhiteSpace(GitHubTitle) &&
+ Link.IsLinked;
+
+ [RelayCommand]
+ private void SelectLocalTab() => IsGitHubTabSelected = false;
+
+ ///
+ /// Switching to the GitHub tab is what triggers the link check and the org
+ /// list — the point of checking before "Create" rather than after it.
+ ///
+ [RelayCommand]
+ private async Task SelectGitHubTabAsync()
+ {
+ IsGitHubTabSelected = true;
+
+ if (Link.LinkState is null)
+ await Link.LoadAsync();
+
+ if (Link.IsLinked && Picker.Orgs.Count == 0)
+ await Picker.LoadOrgsCommand.ExecuteAsync(null);
+ }
+
+ [RelayCommand]
+ private async Task CheckConnectionAsync()
+ {
+ await Link.LoadAsync();
+ if (Link.IsLinked && Picker.Orgs.Count == 0)
+ await Picker.LoadOrgsCommand.ExecuteAsync(null);
+ OnPropertyChanged(nameof(CanCreate));
+ }
+
+ [RelayCommand]
+ private void ClearParentList() => SelectedParentList = null;
+
+ [RelayCommand(CanExecute = nameof(CanCreate))]
+ private async Task CreateGitHubListAsync()
+ {
+ if (Picker.SelectedRepo is not { } repo)
+ {
+ ErrorMessage = "Pick a repository first.";
+ return;
+ }
+
+ if (!Link.IsLinked)
+ {
+ // Up front, not at the API: a create with no GitHub link cannot work,
+ // and the remedy is the handoff sitting right above this button.
+ ErrorMessage = "Connect GitHub before creating a GitHub-backed list.";
+ return;
+ }
+
+ IsCreating = true;
+ try
+ {
+ var created = await _session.Api.CreateGitHubBackedListAsync(
+ repo.FullName,
+ GitHubTitle,
+ string.IsNullOrWhiteSpace(GitHubDescription) ? null : GitHubDescription,
+ SelectedParentList?.Id,
+ IsPublic);
+
+ // Read-after-write, per this repo's rule for unverified envelopes: the
+ // returned refreshStatus has never been observed, so the list's real
+ // state comes from re-reading it.
+ var status = created.RefreshStatus is { Length: > 0 } s ? $" Initial sync: {s}." : string.Empty;
+ SuccessMessage = $"Created \"{GitHubTitle.Trim()}\" from {repo.FullName}.{status}";
+ ErrorMessage = null;
+
+ GitHubTitle = "";
+ GitHubDescription = "";
+ SelectedParentList = null;
+ IsPublic = false;
+ Picker.SelectedRepo = null;
+ _autoFilledFrom = null;
+
+ // The badge cache is now stale by exactly one list.
+ await GitHubListIndex.RefreshAsync();
+ ListCreated?.Invoke(this, EventArgs.Empty);
+ }
+ catch (ArgumentException ex)
+ {
+ // The client's own owner/repo guard, which mirrors the server's 400.
+ ErrorMessage = ex.Message;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = Link.ExplainFailure(ex);
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException)
+ {
+ ErrorMessage = "Couldn't reach InterlinedList to create the list.";
+ }
+ finally
+ {
+ IsCreating = false;
+ OnPropertyChanged(nameof(CanCreate));
+ }
+ }
+
+ private void OnLinkPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is not (nameof(GitHubLinkViewModel.LinkState) or nameof(GitHubLinkViewModel.IsLinked)))
+ return;
+
+ OnPropertyChanged(nameof(CanCreate));
+ CreateGitHubListCommand.NotifyCanExecuteChanged();
+ }
+
+ private void OnPickerPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(GitHubRepoPickerViewModel.SelectedRepo)) return;
+
+ ApplyTitleDefault();
+ OnPropertyChanged(nameof(SelectedRepoLabel));
+ OnPropertyChanged(nameof(SelectedRepoIsPrivate));
+ OnPropertyChanged(nameof(CanCreate));
+ CreateGitHubListCommand.NotifyCanExecuteChanged();
+ }
+
+ ///
+ /// "Title defaults to the repo name" (/help/lists) — without clobbering a
+ /// title the user typed. Only an empty box, or one still holding the previous
+ /// repository's auto-filled name, gets replaced.
+ ///
+ private void ApplyTitleDefault()
+ {
+ var name = Picker.SelectedRepo?.Name;
+ if (name is not { Length: > 0 }) return;
+
+ var untouched = string.IsNullOrWhiteSpace(GitHubTitle) ||
+ string.Equals(GitHubTitle, _autoFilledFrom, StringComparison.Ordinal);
+
+ if (!untouched) return;
+
+ GitHubTitle = name;
+ _autoFilledFrom = name;
+ }
+
+ partial void OnGitHubTitleChanged(string value)
+ {
+ OnPropertyChanged(nameof(CanCreate));
+ CreateGitHubListCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnIsCreatingChanged(bool value) => OnPropertyChanged(nameof(CanCreate));
+}
diff --git a/InterlinedList/Views/AiConverters.cs b/InterlinedList/Views/AiConverters.cs
index 64dc564..3c47860 100644
--- a/InterlinedList/Views/AiConverters.cs
+++ b/InterlinedList/Views/AiConverters.cs
@@ -6,20 +6,6 @@
namespace InterlinedList.Views;
-///
-/// Visible when the bound object is non-null. The AI panels use one nullable
-/// / artifact slot each, so "is something there" is the
-/// visibility question over and over.
-///
-public sealed class NotNullToVisibilityConverter : IValueConverter
-{
- public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value is null ? Visibility.Collapsed : Visibility.Visible;
-
- public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
- throw new NotSupportedException();
-}
-
// InverseBoolToVisibilityConverter is NOT declared here. It already exists in
// ListsViewConverters.cs, in this same InterlinedList.Views namespace, with a
// byte-identical implementation — declaring it twice is CS0101/CS0111.
diff --git a/InterlinedList/Views/CommonConverters.cs b/InterlinedList/Views/CommonConverters.cs
index 16e9bf8..b7650f8 100644
--- a/InterlinedList/Views/CommonConverters.cs
+++ b/InterlinedList/Views/CommonConverters.cs
@@ -1,8 +1,19 @@
using System.Globalization;
+using System.Windows;
using System.Windows.Data;
namespace InterlinedList.Views;
+// The home for converters more than one view needs.
+//
+// Read this before adding one elsewhere. During the parity merge pass the same
+// converter was independently added in two files FIVE times
+// (InverseBoolToVisibilityConverter twice, NotNullToVisibilityConverter twice,
+// plus others). Every time, git reported the PRs MERGEABLE — different files, no
+// textual overlap — and only the compiler objected, with CS0101/CS0111 on a
+// green-in-isolation branch. These are namespace-visible with no `using`, so a
+// per-view copy is never necessary.
+
///
/// Negates a bool — for binding IsEnabled to a busy flag.
///
@@ -14,3 +25,26 @@ public object Convert(object value, Type targetType, object parameter, CultureIn
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : false;
}
+
+///
+/// Collapsed when the bound bool is true — the inverse of the framework's
+/// BooleanToVisibilityConverter.
+///
+public sealed class InverseBoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is true ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+/// Visible when the bound value is non-null.
+public sealed class NotNullToVisibilityConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is null ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
diff --git a/InterlinedList/Views/GitHubListBadge.xaml b/InterlinedList/Views/GitHubListBadge.xaml
new file mode 100644
index 0000000..25ee33a
--- /dev/null
+++ b/InterlinedList/Views/GitHubListBadge.xaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/GitHubListBadge.xaml.cs b/InterlinedList/Views/GitHubListBadge.xaml.cs
new file mode 100644
index 0000000..3d79b2f
--- /dev/null
+++ b/InterlinedList/Views/GitHubListBadge.xaml.cs
@@ -0,0 +1,95 @@
+using System.Windows;
+using System.Windows.Controls;
+using InterlinedList.Services;
+
+namespace InterlinedList.Views;
+
+///
+/// The GitHub mark that marks a GitHub-backed list in a lists browser
+/// (/help/lists: "GitHub-backed lists display a GitHub icon").
+///
+///
+/// Give it a and it answers from
+/// — one request for the whole browser, not
+/// one per row. It stays collapsed until the index positively says the list is
+/// GitHub-backed, so an unloaded index shows no badge rather than a wrong one.
+///
+///
+///
+/// Deliberately view-model-less: it has no commands and no state of its own
+/// beyond what the index already holds, so a view model would be a layer with
+/// nothing in it. It is a projection of shared state onto three dependency
+/// properties.
+///
+///
+public partial class GitHubListBadge : UserControl
+{
+ public GitHubListBadge()
+ {
+ InitializeComponent();
+
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ /// The list this badge describes.
+ public static readonly DependencyProperty ListIdProperty =
+ DependencyProperty.Register(nameof(ListId), typeof(string), typeof(GitHubListBadge),
+ new PropertyMetadata(null, OnListIdChanged));
+
+ public string? ListId
+ {
+ get => (string?)GetValue(ListIdProperty);
+ set => SetValue(ListIdProperty, value);
+ }
+
+ /// Tooltip naming the backing repository, when one is known.
+ public static readonly DependencyProperty BadgeTooltipProperty =
+ DependencyProperty.Register(nameof(BadgeTooltip), typeof(string), typeof(GitHubListBadge),
+ new PropertyMetadata("Rows sync from GitHub issues."));
+
+ public string? BadgeTooltip
+ {
+ get => (string?)GetValue(BadgeTooltipProperty);
+ private set => SetValue(BadgeTooltipProperty, value);
+ }
+
+ private static void OnListIdChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((GitHubListBadge)d).Apply();
+
+ private async void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ GitHubListIndex.Changed += OnIndexChanged;
+ Apply();
+
+ // Safe to fire and forget: EnsureLoadedAsync swallows its own failures and
+ // raises Changed on success, which is what re-applies below.
+ await GitHubListIndex.EnsureLoadedAsync();
+ Apply();
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ // ItemsControl recycles rows, so an unsubscribed badge would leak a handler
+ // into a static event for the app's lifetime.
+ GitHubListIndex.Changed -= OnIndexChanged;
+ }
+
+ private void OnIndexChanged(object? sender, EventArgs e) => Apply();
+
+ private void Apply()
+ {
+ var backing = GitHubListIndex.Get(ListId);
+
+ if (backing is null || !backing.IsGitHubBacked)
+ {
+ Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ Visibility = Visibility.Visible;
+ BadgeTooltip = backing.Repo is { Length: > 0 } repo
+ ? $"Rows sync from {repo} issues."
+ : "Rows sync from GitHub issues.";
+ }
+}
diff --git a/InterlinedList/Views/GitHubMark.xaml b/InterlinedList/Views/GitHubMark.xaml
new file mode 100644
index 0000000..a05859c
--- /dev/null
+++ b/InterlinedList/Views/GitHubMark.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/GitHubMark.xaml.cs b/InterlinedList/Views/GitHubMark.xaml.cs
new file mode 100644
index 0000000..79a3bce
--- /dev/null
+++ b/InterlinedList/Views/GitHubMark.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace InterlinedList.Views;
+
+///
+/// The GitHub mark as a reusable vector, inheriting Foreground from its
+/// host so every use stays on the Strata palette. Set Width/Height
+/// to size it.
+///
+public partial class GitHubMark : UserControl
+{
+ public GitHubMark()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/InterlinedList/Views/GitHubViewConverters.cs b/InterlinedList/Views/GitHubViewConverters.cs
new file mode 100644
index 0000000..d0ea61c
--- /dev/null
+++ b/InterlinedList/Views/GitHubViewConverters.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace InterlinedList.Views;
+
+///
+/// Visible only on an explicit true — a null stays hidden.
+///
+///
+/// This exists because of githubRepoPrivate. Per /help/lists, "lists
+/// created before this tag existed show no tag until their first sync", so an
+/// unrecorded visibility must never be presented as a decision either way. WPF's
+/// built-in BooleanToVisibilityConverter is typed to non-nullable
+/// bool and turns a bool? of null into Collapsed only
+/// by accident of unboxing; being explicit about it is the difference between a
+/// deliberate third state and a lucky default.
+///
+///
+public sealed class TrueOnlyToVisibilityConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is true ? Visibility.Visible : Visibility.Collapsed;
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 805a8d6..37f3d16 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -460,15 +460,25 @@
+
-
+
+
+
throw new NotSupportedException();
}
-
-///
-/// Visible when the bound bool is FALSE. Used for the "—" placeholder a row grid
-/// shows where a column has no value, which is a different thing from false.
-///
-public sealed class InverseBoolToVisibilityConverter : IValueConverter
-{
- public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value is true ? Visibility.Collapsed : Visibility.Visible;
-
- public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
- throw new NotSupportedException();
-}
diff --git a/InterlinedList/Views/NewListTabs.xaml b/InterlinedList/Views/NewListTabs.xaml
new file mode 100644
index 0000000..7af7645
--- /dev/null
+++ b/InterlinedList/Views/NewListTabs.xaml
@@ -0,0 +1,432 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/NewListTabs.xaml.cs b/InterlinedList/Views/NewListTabs.xaml.cs
new file mode 100644
index 0000000..ca9dbdc
--- /dev/null
+++ b/InterlinedList/Views/NewListTabs.xaml.cs
@@ -0,0 +1,107 @@
+using System.Collections;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// The two-tab new-list flow — Local List / GitHub-backed List —
+/// as a self-contained control that owns its own view model.
+///
+///
+/// Why a control and not code in ListsView.ListsView.xaml is
+/// being edited by several open PRs at once, so this follows the pattern that
+/// worked on the list/document invite panels: everything lives here, context
+/// arrives through dependency properties, and hosting costs one element plus a
+/// visibility binding on the form this replaces on the GitHub tab. Nothing is
+/// added to ListsViewModel and no code-behind of the host changes.
+///
+///
+///
+/// Hosting contract, all optional:
+///
+///
+/// — the host's list collection,
+/// for the "Parent list" drop-down.
+/// — run after a successful
+/// create, so the host's browser picks the new list up.
+/// — read by the host to show
+/// or hide its own local create form, which is the Local tab.
+///
+///
+public partial class NewListTabs : UserControl
+{
+ private readonly NewListTabsViewModel _vm;
+
+ public NewListTabs()
+ {
+ InitializeComponent();
+
+ _vm = new NewListTabsViewModel(Services.AppServices.Session);
+ DataContext = _vm;
+
+ // Mirror the tab choice out as a dependency property so the host can bind
+ // its own form's Visibility to it with one attribute.
+ _vm.PropertyChanged += OnViewModelPropertyChanged;
+ _vm.ListCreated += OnListCreated;
+
+ IsLocalTabSelected = _vm.IsLocalTabSelected;
+ }
+
+ /// The host's lists, offered as parent candidates. Any enumerable of ListSummary.
+ public static readonly DependencyProperty ParentListSourceProperty =
+ DependencyProperty.Register(nameof(ParentListSource), typeof(IEnumerable), typeof(NewListTabs),
+ new PropertyMetadata(null));
+
+ public IEnumerable? ParentListSource
+ {
+ get => (IEnumerable?)GetValue(ParentListSourceProperty);
+ set => SetValue(ParentListSourceProperty, value);
+ }
+
+ ///
+ /// Executed after a successful create. The panel deliberately does not reach
+ /// into the host's view model itself — the host says how to refresh.
+ ///
+ public static readonly DependencyProperty ListsRefreshCommandProperty =
+ DependencyProperty.Register(nameof(ListsRefreshCommand), typeof(ICommand), typeof(NewListTabs),
+ new PropertyMetadata(null));
+
+ public ICommand? ListsRefreshCommand
+ {
+ get => (ICommand?)GetValue(ListsRefreshCommandProperty);
+ set => SetValue(ListsRefreshCommandProperty, value);
+ }
+
+ ///
+ /// True while the Local List tab is chosen. Read-only in practice — the
+ /// panel sets it; the host binds to it.
+ ///
+ public static readonly DependencyProperty IsLocalTabSelectedProperty =
+ DependencyProperty.Register(nameof(IsLocalTabSelected), typeof(bool), typeof(NewListTabs),
+ new PropertyMetadata(true));
+
+ public bool IsLocalTabSelected
+ {
+ get => (bool)GetValue(IsLocalTabSelectedProperty);
+ private set => SetValue(IsLocalTabSelectedProperty, value);
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(NewListTabsViewModel.IsLocalTabSelected)
+ or nameof(NewListTabsViewModel.IsGitHubTabSelected))
+ {
+ IsLocalTabSelected = _vm.IsLocalTabSelected;
+ }
+ }
+
+ private void OnListCreated(object? sender, EventArgs e)
+ {
+ if (ListsRefreshCommand?.CanExecute(null) == true)
+ ListsRefreshCommand.Execute(null);
+ }
+}