summaryrefslogtreecommitdiff
Commit message (Collapse)AuthorAgeFilesLines
* convert readme to nfo; convert docs to txt; relicense to 0bsdHEADmainChristian Cleberg35 hours9-783/+86
|
* Update test fixtures to [email protected] emailChristian Cleberg41 hours2-5/+5
|
* Update SonarCloud project id, contact emails, and bump to 3.8.5 (93)Christian Cleberg41 hours5-18/+18
| | | | | | - Point README badges and ROADMAP query at the krazywarez_hutch SonarCloud project - Change user-facing contact emails to [email protected] (README, SECURITY, About screen) - Bump MARKETING_VERSION to 3.8.5 and CURRENT_PROJECT_VERSION to 93
* Bump marketing version to 3.8.4Christian Cleberg41 hours1-6/+6
| | | | The 3.8.3 train is closed for new submissions on App Store Connect.
* update stats endpointChristian Cleberg41 hours2-13/+13
|
* update org nameChristian Cleberg41 hours4-7/+7
|
* Merge pull request #24 from zerolabsco/remove-splash-highlighterChristian Cleberg9 days3-71/+1
|\ | | | | Remove Splash syntax highlighter dependency
| * Remove Splash syntax highlighter dependencyremove-splash-highlighterChristian Cleberg9 days3-71/+1
|/ | | | | | | | | | | | | | Splash only highlights Swift, leaving every other language unhighlighted. Drop the dependency entirely and render file contents as plain text until a broader-coverage highlighter is adopted. - Remove `import Splash` and the Splash-backed theming in FileTreeView - Simplify CodeSyntaxHighlighter to emit plain attributed text - Remove the Splash SwiftPM package reference and product dependency Refs https://github.com/zerolabsco/hutch/issues/16 Co-Authored-By: Claude Opus 4.8 <[email protected]>
* add app store badge to readmeChristian Cleberg2026-07-161-0/+2
|
* Fix Safari extension manifest for App Store uploadv3.8.2Christian Cleberg2026-07-161-1/+2
| | | | | | The non-persistent background declares scripts without a service_worker, so App Store validation (code 90849) requires the persistent flag to be explicitly false on iOS/iPadOS. Add "persistent": false.
* Merge pull request #20 from zerolabsco/home-status-badgeChristian Cleberg2026-07-164-37/+72
|\ | | | | Move Home system status into a title-bar status badge
| * Move Home system status into a title-bar status badgeChristian Cleberg2026-07-164-37/+72
|/ | | | | | | | | | | | | | | | Replace the disruption-only banner on the Home tab with a persistent circular status indicator in the navigation bar, next to the "Home" title. The badge reflects the existing status logic — green/check when operational, orange/exclamation on disruption, gray otherwise — and taps through to System Status. SystemStatusSummaryRow is unchanged and still used by the More tab. Resolve the system-status result first in HomeViewModel.loadDashboard so the badge settles from cache immediately instead of waiting on the slower projects/jobs/tickets/inbox loads, which had left it spinning for several seconds. Bump MARKETING_VERSION to 3.8.2 and record it in ROADMAP.
* Merge pull request #19 from zerolabsco/docs-dual-forge-trackersChristian Cleberg2026-07-161-0/+2
|\ | | | | docs: note dual-forge issue/patch tracking in README
| * docs: note dual-forge issue/patch tracking in READMEChristian Cleberg2026-07-161-0/+2
|/ | | | | Hutch is hosted on both GitHub and sourcehut as synced peers; tell contributors they can file issues or send patches on either forge.
* Merge pull request #8 from zerolabsco/phase-3-sonarcloud-housekeepingv3.8.1Christian Cleberg2026-07-1614-52/+109
|\ | | | | v3.8.1: SonarCloud triage, forceRefresh fix, housekeeping
| * chore: track the two HutchIntents TODOs in the roadmap (S1135)Christian Cleberg2026-07-162-4/+26
| | | | | | | | | | | | | | | | SearchHutchIntent's route-to-Lookup stopgap and the absent OpenSavedSearchIntent are both gated on a global search/persistence layer Hutch lacks. Promote both to ROADMAP § "App Intent gaps" and replace the inline TODOs with plain references, clearing those two S1135 issues without losing the design intent.
| * fix: move the S1186 empty-block comments inside the bracesChristian Cleberg2026-07-163-3/+3
| | | | | | | | | | | | Trailing // comments after {} left the block lexically empty, so SonarCloud kept flagging stopLoading (and would have re-flagged the two Cancel buttons). S1186 wants a *nested* comment; use /* ... */ inside.
| * chore: record the SonarCloud + housekeeping pass, bump to 3.8.1Christian Cleberg2026-07-162-30/+64
| | | | | | | | | | | | Update the roadmap's SonarCloud section to the live 53-issue / 10-rule reality and mark what v3.8.1 fixed, silenced-as-bug, and left Won't Fix. Bump MARKETING_VERSION on the app, widget, and Safari extension.
| * chore: clear the actionable SonarCloud code smellsChristian Cleberg2026-07-167-11/+12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - S1871: merge the identical .home / .recentActivity deep-link cases in RootView — recent activity is a section of Home, not its own screen. - S1186: comment the two intentionally-empty Cancel buttons (PatchsetDetailView, TicketDetailView) and the empty URLProtocol stopLoading override in APICacheTests. - S108: comment the expected-miss catch block in APICacheTests. - S1172: rename the unused url parameter in mimeType(for:) to _. - S4624: extract the nested template literal in the deep-link builders (background.js, content.js) to a pathSegment variable. Left as Won't Fix, with reasons: the 35 hardcoded-URI warnings (a one-forge client and its literal-URL tests), executeCached's 8 params (38 call sites, no benefit), the forceRefresh S1172 pair (fixed as a real bug instead), S1481 on ArtifactsView (false positive — $vm.error is used), and S7785 (top-level await would break a classic content script).
| * chore: move AccountSession.swift out of the stray nested Hutch/Hutch dirChristian Cleberg2026-07-161-0/+0
| | | | | | | | | | | | | | | | It sat at Hutch/Hutch/App/AccountSession.swift, one level too deep. The target is a PBXFileSystemSynchronizedRootGroup rooted at Hutch/, so the file compiled by path with no pbxproj reference; moving it beside the rest of App/ needs no project-file change. Also removed the emptied Hutch/Hutch tree and the stray empty Hutch/HutchTests dir.
| * fix: honor forceRefresh for projects and system status on the dashboardChristian Cleberg2026-07-163-9/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | loadDashboard(forceRefresh:) fanned the flag out to five loaders, but loadProjects and loadSystemStatusSnapshot dropped it: they called fetchProjects() and snapshotResult() with no policy, so pull-to-refresh returned cached projects and status while the other three sections refreshed. SonarCloud flagged both params as unused (swift:S1172). Thread forceRefresh through ProjectService.fetchProjects into the page policy (refreshIgnoringCache when forced), and pass it to snapshotResult, which already accepted it. ProjectsListView carried the same latent bug via its own .refreshable — fixed there too now that fetchProjects can force.
* | Merge pull request #10 from zerolabsco/ci-no-post-merge-runChristian Cleberg2026-07-161-4/+11
|\ \ | |/ |/| ci: drop the redundant post-merge test run
| * ci: drop the redundant post-merge test runChristian Cleberg2026-07-161-4/+11
|/ | | | | | | | The suite ran on push to main and on pull_request. Since merges here are merge commits (not squash) and pull_request tests the merge result, a green PR already validates the exact tree that lands on main — the push run just re-confirmed it on a macOS runner. Trigger on pull_request (and workflow_dispatch) only.
* docs: bucket the Phase 3 backlog into semantic versionsChristian Cleberg2026-07-161-8/+67
|
* chore: ignore local agent notesChristian Cleberg2026-07-161-0/+5
| | | | | | CLAUDE.md holds working notes for this checkout — build invocation, remote topology, and which mistakes this codebase invites. Project facts stay in ROADMAP.md and SCOPE.md, which are committed.
* docs: record the SonarCloud backlog and the What's cooking taskChristian Cleberg2026-07-162-1/+63
| | | | | | | | | | | | | | | | | | | Two additions to Phase 3. SonarCloud, with the breakdown rather than the headline: 51 open issues are 0 bugs, 0 vulnerabilities and 51 code smells, and 35 of those are hardcoded-URI warnings against a deep-link mapper's test fixtures and a one-forge client's endpoint constants. Those want triaging as Won't Fix, not refactoring. The part worth real thought is the 3 hotspots — the sr.ht token is stored without a SecAccessControl, so an unlocked phone hands it over, which is a product decision rather than a lint nit. Ingesting sr.ht's quarterly "What's cooking" posts, because nothing here tracks the API's evolution and this repo's assumptions rot silently. Already proven worthwhile: SCOPE.md claimed pronouns were not in the schema while AppState queries them and UserProfileView displays them. That entry is struck through and kept as evidence. Q2 2026 also reports a writable hub.sr.ht API and finished deploy keys, both of which contradict what is written here.
* Merge pull request #6 from zerolabsco/phase-3-api-featuresChristian Cleberg2026-07-1619-74/+944
|\ | | | | Phase 3: API features
| * chore: remove the artifacts debug probev3.8.0Christian Cleberg2026-07-161-18/+0
| | | | | | | | | | It did its job: tags=1 confirmed the menu was disabled by its own tags check while the explanation sat unreachable inside it.
| * fix: deleted repositories linger until the cache expiresChristian Cleberg2026-07-162-1/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Deleting a repository left it on the list, and pulling to refresh did not shift it. Two independent reasons, both cache-related. deleteRepository never invalidated anything. Creation invalidates the repositories and home prefixes; deletion was written without it, so the list and Home kept serving a repository that no longer exists. And forceRefresh only ever reached the build statuses — its own doc comment says so — while the repository list itself was pinned to useCache: true. So a pull to refresh re-served the same cache it already had. fetchPage already takes useCache and falls through to an uncached fetch; it was simply never told.
| * fix: download artifacts through the API instead of handing them to SafariChristian Cleberg2026-07-163-2/+87
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Tapping download opened Artifact.url in the browser, which answered with "Authorization header is required". That URL is not a web page: git.sr.ht resolves it to <api origin>/query/artifact/<checksum>/<filename>, which demands a bearer token. Safari has none and no way to get one, so the download could never have worked — this predates the upload work. Fetch it with the client that already holds the token and hand the user the file through a share sheet. fetchData mirrors fetchText, including its host guard, so an authenticated request still cannot be aimed anywhere but *.sr.ht over https. Also guards zero-byte uploads. sr.ht streams into S3, which rejects a zero-part multipart completion with "MalformedXML: UnknownError" — an error that says nothing about the cause and cost a round of testing to identify. Empty files are now refused by name before the request is made.
| * fix: make artifact upload actually fire, and show it when it failsChristian Cleberg2026-07-161-10/+37
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Picking a file did nothing. The fileImporter's isPresented binding was derived from uploadTargetRef and nilled it on dismissal, but dismissal happens before the completion runs — so the completion read nil and returned without uploading. Presentation state and payload state cannot be the same state. A plain isImporting bool drives presentation now; the tag survives in uploadTargetRef until the completion consumes it. The upload menu was also disabled when the repository has no tags, while the explanation for that state lived inside the menu — unreachable exactly when it applies, so the tap died with no reason given. sr.ht requires revspec to match a tag, so having none is a real state worth explaining rather than hiding. Failures were invisible too. uploadArtifact and deleteArtifact set error, but the overlay only renders it when the list is empty, so a rejection on a repository that already has artifacts — a duplicate filename is the likely one, since sr.ht requires filenames to be unique per repository — set an error nobody saw. The tab carries an error banner now.
| * fix: push mailing lists locally, fix upload menu, drop the events feedChristian Cleberg2026-07-1610-346/+52
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a mailing list from More → Projects still blanked. The cause was not in handleTabNavigation: the row called openMailingList and then dismiss(), so a path rebuild and a pop of this very view raced each other. Projects already lives in the More tab, so there is nothing to navigate to — push MailingListDetailView directly, which also lands back on the project rather than on Mailing Lists. Sources and trackers keep routing, because they really do land in other tabs. The upload controls did nothing. Two .confirmationDialog modifiers on one view leave one silently dead, and this view already had one for delete, so the tag picker never presented. It is a Menu now, which also puts the tags one tap away instead of two. The ticket activity feed is removed. todo.sr.ht's root events resolver joins event.participant_id, which references participant(id), against participant.user_id — different id spaces — so it returns an empty list for every user. The rows exist; that join cannot find them. Ticket.events is unaffected because it filters on ticket_id, which is why ticket timelines work. No client can fix this, and a screen that is permanently empty while blaming the token's scopes is worse than no screen. Recorded in SCOPE.md with the query.
| * fix: blank mailing list from Projects, swipe flicker, hidden uploadChristian Cleberg2026-07-166-46/+69
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three problems from manual testing. Opening a mailing list from More → Projects showed a blank screen, while the same tap on a project pinned to Home worked. handleTabNavigation reset the target path and appended to it two Task.yields later. When the target tab is already on screen — Projects lives under More — the reset starts an animated pop of the view the user is standing on and the appends land mid-animation. From Home the tab actually changes, so the More stack is quiescent and the appends land cleanly. Each case now builds its path and assigns it once, so SwiftUI gets a single diff with nothing to race. Destructive swipe actions made the row vanish and spring back while the confirmation was still up. role: .destructive makes SwiftUI perform the row removal on activation, which allowsFullSwipe: false does not prevent — the report was a tap, not a full swipe. These buttons only record pending state and wait for an answer, so they are plain buttons tinted red instead. Six sites: the two added here, plus trackers, pastes, and tracker ACLs and labels, which had the same flicker already. The artifacts upload control was invisible. It was declared as a toolbar item from a view that is a segment inside RepositoryDetailView's tab switch rather than its own navigation destination, so it never reached the navigation bar. It is a row in the list now, and also an action on the empty state — the overlay covers the list, and a repository with no artifacts is precisely the one that needs uploading.
| * fix: stop destructive swipes animating rows out before confirmationChristian Cleberg2026-07-162-2/+8
| | | | | | | | | | | | | | | | | | | | | | Swiping to delete made the row vanish and then spring back while the confirmation was still on screen. A destructive swipe action left to full-swipe performs itself on the gesture and animates the row away, but these actions only set pending state and wait for an answer, so the row returned when the data had not changed. allowsFullSwipe: false, which PasteListView already uses for exactly this confirm-then-delete shape. Both new swipes had the same omission.
| * chore: bump to 3.8.0 and record Phase 3 API featuresChristian Cleberg2026-07-164-29/+71
| | | | | | | | | | | | | | | | | | | | | | | | MARKETING_VERSION 3.7.0 -> 3.8.0, build 89 -> 90. SCOPE.md gains the items that did not survive contact with the API: archiveMessage and mailingListSubscribe are blocked, while webhooks, shareSecret, and build groups are reachable but declined on judgement. The reasoning is recorded so they do not get re-proposed as gaps. ROADMAP.md notes that Phase 3 is several releases rather than one, with the measured size of each.
| * feat: add a ticket activity feedChristian Cleberg2026-07-165-0/+318
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | todo.sr.ht's root events query returns what the authenticated user is subscribed to or implicated in, newest first, across every tracker including ones they do not own. It was never called, so the only way to notice a reply was to open the ticket. Reuses EventChange, which already decodes todo's polymorphic EventDetail for ticket timelines, so the same inline fragments describe both surfaces. Rows push straight to the ticket. events is nullable and comes back null when the token lacks the EVENTS scope, so that case reports a scope problem rather than an empty feed, which would read as "nothing has happened". Paginates rather than fetching everything: an active account's history is unbounded and the top of it is the whole point. archiveMessage, the other half of this roadmap item, is not here. It is marked @internal in the schema and inaccessible, like revokePersonalAccessToken already recorded in SCOPE.md.
| * feat: create, edit, and delete mailing listsChristian Cleberg2026-07-151-5/+356
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | createMailingList, updateMailingList, and deleteMailingList existed in the API but were never called, so lists could only be read. Editing needed a read first. InboxMailingListReference carries only id/rid/name/owner, so a settings sheet seeded from it would have offered an empty description and Public visibility, and saving would have blanked the real description and quietly changed who can see the list. The sheet now reads the current values and refuses to save until it has them. Clearing a description sends an explicit null via updateValue rather than a nil subscript assignment, which would drop the key and leave the old text in place. permitMime and rejectMime are left untouched rather than sent empty, which would wipe the list's mime filters. Edit and delete are gated on ownership: the subscriptions query that builds this view returns lists the user follows, which is not the same as lists they own. Non-owners keep the unsubscribe action instead. Deleting destroys the archive for everyone, so the confirmation says exactly that.
| * feat: show the meta.sr.ht audit logChristian Cleberg2026-07-153-0/+108
| | | | | | | | | | | | | | | | | | | | | | | | | | auditLog existed in the API but was never called, so the record of what has happened to your account — logins, key changes, the addresses they came from — was web-only. Sits under the tokens in Profile and loads on demand for the same reason they do: an audit log is something you go looking for, not something worth a request on every profile view. One page, newest first; the archive stays on meta.sr.ht. Its errors are kept separate from the shared `error` so a failed audit fetch cannot bury a profile save failure, and vice versa.
| * feat: upload and delete repository artifactsChristian Cleberg2026-07-153-2/+216
|/ | | | | | | | | | | | | | | | | | | | | | | uploadArtifact and deleteArtifact existed in git.sr.ht's API but were never called, so the artifacts tab could only download. Upload is reachable two ways, and the second is the one that matters: the tab only lists tags that already carry an artifact, so a per-section button alone could never attach the first one to a tag — and the app cannot create that first artifact any other way. A toolbar action picks from all tags instead. The file variable is top-level here, unlike meta's avatar upload where it nests inside an input object. This is the second caller of executeMultipart, which until now only served avatars. Artifacts are tarballs and signatures, so the upload declares application/octet-stream rather than guessing a type from the extension. Security-scoped access is released after the read, since fileImporter hands back a URL the app does not otherwise own. Both actions are gated on repository ownership, reusing the check RepositoryDetailView already applies to its other management surfaces rather than recomputing it. Delete sits behind a confirmation naming the file.
* ci: skip the suite for Docs markdown tooChristian Cleberg2026-07-151-5/+6
| | | | | | | | | Docs/API_CACHE.md and Docs/StoreKit.md are prose with no runtime surface, same as the root docs. Both patterns stay single-star, so they match those two levels and nothing deeper. Hutch/API_REFERENCE.md still runs the suite: a .md living inside the source tree is close enough to code to be worth the five minutes.
* ci: skip the suite for root markdown changesChristian Cleberg2026-07-151-0/+9
| | | | | | | | | | | | | | A README or ROADMAP edit spent five minutes on a macOS runner building an iOS app to prove that prose had not broken it. Scoped to `*.md`, which in a GitHub path filter matches root level only, so the four root docs are skipped while Docs/**.md and everything under Hutch/ stay gated. A skip rule that is too broad is how a real change slips through ungated. Safe on pull_request because main has no required status checks. A run skipped by a path filter never reports, so a required check would leave a docs-only PR waiting on a status that never arrives.
* docs: record the API traps and Swift 6 blockersChristian Cleberg2026-07-151-8/+58
| | | | | | | | | | | | | | | | | | | | | | Adds a SourceHut API traps section for the two things that cost this work real time and that the schema will not tell you: - Thread.updated is the root email's insert time, not thread activity. It never advances on a reply, while the schema describes threads as ordered "most recently bumped". Every inbox surface trusted it. - The schema dumps in Docs/API omit inputFields and enumValues, so they answer "what shape is this mutation's input" and "what does this enum accept" with an empty array rather than an error. Both questions have to go to the real SDL. Phase 3 picks up the Swift 6 language mode blockers and the three view models still reading client.responseCache directly, which was previously buried as a footnote inside completed Phase 1. Also corrects the Phase 0 record. It credited eff81f3 with fixing an Identifiable collision between same-subject threads. There is no such collision to fix: deduplicateThreads merges those threads before anything renders, and the test that motivated the change built its summaries by hand and skipped that step. The change stands on clarity; the claimed bug was not real.
* Merge pull request #5 from zerolabsco/inbox-unread-baselineChristian Cleberg2026-07-159-40/+423
|\ | | | | Start a new account at zero unread
| * fix: derive inbox activity from mail, not thread.updatedChristian Cleberg2026-07-154-6/+240
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Thread.updated is the root email's insert time. It never advances when a reply arrives, despite the name and despite the schema describing threads as ordered "most recently bumped". sr.ht returns updated seven seconds after root.date on a thread carrying four replies: subject: "Re: test post" updated: 2026-07-16T03:44:55Z replies: 4 root: { date: 2026-07-15T22:44:48-05:00 } The app fed that field into lastActivityAt everywhere, so a reply to an old thread was invisible: it did not mark the thread unread, did not raise it in the list, and did not update the timestamp beside it. This was inert until the unread baseline landed, because an absent view record returned unread unconditionally and the date was never compared to anything. The baseline did not break unread; it was the first code to depend on that field meaning what it says. Use MailingList.emails instead, which is real reverse-chronological arrival data, keyed by thread root id. The scan stops once it is older than the cutoff, so a quiet list costs one page and a busy one costs only what is new. Threads with nothing in the window keep the root timestamp and stay read, which they already were. Failure returns empty activity rather than throwing: unread is a decoration and should not take the thread list down with it. Uses Email.received rather than Email.date — received is server-side and non-null, date comes from the sender's header and is neither.
| * fix: let pull to refresh actually refresh HomeChristian Cleberg2026-07-153-30/+35
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | New mail showed as unread inside a mailing list but not on Home. The list view reads threads with client.execute, so it always sees the server. Home reads them with executeCached under cacheFirstThenRefresh, which returns the cached value and only schedules a background fetch. loadDashboard had no way to bypass that, so pulling to refresh could not surface mail that arrived after the last fetch — it returned the same cache it already had and Home stayed a cycle behind. Thread forceRefresh through loadDashboard to the cached reads, and use it from the pull to refresh on Home and Work. Matches loadSystemStatus(forceRefresh:), which MoreView already refreshes this way. The staleness predates the unread baseline. It was invisible while every thread counted as unread, because Home always had a large number to show; now that Home correctly ignores mail older than sign-in, a stale cache reads as a truthful 0.
| * fix: start a new account at zero unreadChristian Cleberg2026-07-153-4/+148
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An absent view record read as unread, so on first sign-in every thread a list had ever carried was unread. On a busy list that is thousands of threads the user never intended to read, inflating the Home dashboard, the needs-attention snapshot, and the widget with a number that means nothing. Record a baseline when an account is activated: mail that predates it is read, mail after it is not. activate() is the single funnel for launch validation, account switching, and adding an account, so one call covers every sign-in path. Accounts that already carry read state have been in use, so they get a distantPast baseline and keep every unread thread they had — upgrading must not silently mark a real backlog as read. markUnread now records an explicit distantPast marker instead of deleting the entry. Deleting would drop the thread back to the baseline rule, so marking an old thread unread would appear to do nothing.
* | Merge pull request #4 from zerolabsco/phase-2-patchsetsChristian Cleberg2026-07-1511-117/+1158
|\ \ | |/ |/| Phase 2: patchset review
| * fix: render patchsets on a plain listv3.7.0Christian Cleberg2026-07-151-0/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Collapsing patches shrank the layout loop from 3674pt/1647pt to 718pt/600pt but did not end it. The oscillating item is section 1 item 0 — the cover letter, not a patch — so size alone was not the cause. The log shows the cell laid out at width 390.0 while the content reports its preferred size at 390.333. That is inset grouped's 20pt insets landing on a fractional width: the Text reflows to a different height than the cell was sized for, each size triggers the other, and it never settles. ThreadDetailView renders the same bodies through the same DiffView with the same modifiers and does not loop. The difference is .listStyle(.plain), which this view never set and so inherited inset grouped.
| * fix: collapse patches to stop a recursive layout loopChristian Cleberg2026-07-151-15/+89
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a patchset wedged the app. UICollectionView reported a row oscillating between 3674pt and 1647pt and trapped in a recursive layout loop, leaving the UI unresponsive. The detail view rendered every patch in the series expanded, so a List held one enormous self-sizing row per patch, each with a full diff. Self-sizing cells that large do not settle. Patches now start collapsed and expand on tap, so at most the ones a reviewer opens are measured. This is what ThreadDetailView already does — it collapses every message but the last, and renders the same diffs through the same DiffView without trouble. Reviewing a series one patch at a time is also closer to how the reading actually goes. The rendering of a block list is shared between the cover letter and patches rather than duplicated.
| * fix: push patchset views directly instead of by routeChristian Cleberg2026-07-154-8/+15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Tapping a patch failed with "no matching navigationDestination declaration visible from the location of the link". MailingListDetailView is presented from four places, but only the More tab and Lookup declare a MoreRoute destination. Reached from a project, via ProjectMailingListView, there is no such destination in the surrounding stack, so a NavigationLink carrying MoreRoute.patchset had nowhere to resolve. The thread rows beside it already use the closure form for exactly this reason. Push PatchsetDetailView directly, from the rows and from the version-chain links inside the detail view, which inherits whatever stack presented it. That leaves MoreRoute.patchset with no users, so it and its two destinations are removed rather than left as a route nothing links to. Neither the compiler nor the tests catch this: it is a runtime SwiftUI resolution failure.