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 +/// no refreshStatus 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 @@ + - + + + + + + + + + + + + + + + + + + + +